From 297f2e56c7885294eeced7f33753516e5d78e98b Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Tue, 1 Sep 2026 18:10:40 +0200 Subject: [PATCH 01/14] [US-219] fix: front-load review contract coverage Bound re-review to immutable deltas and require a complete finite-state inventory before review or remediation. --- .claude/workflows/pair-implement-batch.js | 74 +++++++-- .../workflows/pair-implement-batch.test.mjs | 157 +++++++++++++++++- ...eline-and-provisioned-artifact-contract.md | 58 +++++++ ...ract-inventory-prevents-serial-findings.md | 58 +++++++ .pair/adoption/tech/way-of-working.md | 17 ++ .pair/llms.txt | 2 + .../.workflows/pair-implement-batch.js | 74 +++++++-- .../.workflows/pair-implement-batch.test.mjs | 157 +++++++++++++++++- 8 files changed, 555 insertions(+), 42 deletions(-) create mode 100644 .pair/adoption/decision-log/2026-08-31-review-baseline-and-provisioned-artifact-contract.md create mode 100644 .pair/adoption/decision-log/2026-09-01-review-contract-inventory-prevents-serial-findings.md diff --git a/.claude/workflows/pair-implement-batch.js b/.claude/workflows/pair-implement-batch.js index f95817702..2942ef0b9 100644 --- a/.claude/workflows/pair-implement-batch.js +++ b/.claude/workflows/pair-implement-batch.js @@ -795,7 +795,7 @@ const MAX_FIX_ROUNDS = PIPELINE.maxFixRounds // truncated structured output) — and the contentless shape is the one this repo // actually measured on #432 (the machine slept mid-response), i.e. the retry // missed the exact incident it was written for while covering its rarer sibling. -// The review step therefore passes `hasVerdict`, the SAME predicate its +// The review step therefore passes `hasReviewEvidence`, the SAME predicate its // convergence guard uses, so "did not review" means one thing at both sites: the // transient gets its second chance, and a step that comes back contentless twice // still fails closed. @@ -812,6 +812,11 @@ async function agentRetry(prompt, opts, isUsable = r => !!r) { // ONE predicate, asked by the retry and by the convergence guard, so the two // cannot drift into disagreeing about what a dead reviewer is. const hasVerdict = r => !!r && !!String(r.verdict ?? '').trim() +const REVIEWED_HEAD_PATTERN = /^[0-9a-f]{40}$/ +// A review also has to identify the immutable PR revision it actually inspected. +// Without that baseline a later reviewer cannot distinguish the fix delta from the +// already-audited PR surface, which turns each re-review into another full scan. +const hasReviewEvidence = r => hasVerdict(r) && REVIEWED_HEAD_PATTERN.test(String(r.reviewedHead ?? '')) // ── Schemas (orchestration return-value contracts) ───────────────────────── // These are the compact values agents RETURN for control-flow — NOT the artifact @@ -854,6 +859,9 @@ const LOOSE_REVIEW_SCHEMA = { // Control flow keys on `nonActionable` + actionable count, never on specific // verdict strings. verdict: { type: 'string' }, + // Immutable full SHA of the PR head reviewed. This is workflow evidence, not + // part of the human-facing review template vocabulary. + reviewedHead: { type: 'string', pattern: '^[0-9a-f]{40}$' }, needsHumanDecision: { type: 'boolean' }, findings: { type: 'array', @@ -879,7 +887,7 @@ const LOOSE_REVIEW_SCHEMA = { }, }, }, - required: ['verdict'], + required: ['verdict', 'reviewedHead'], } const FIX_SCHEMA = { type: 'object', @@ -977,7 +985,17 @@ const contracts = STORIES.length ? await parallel(CONTRACT_SPECS.map((s) => () = const crContract = contracts.find((c) => c.name === 'code-review') // Schema the reviewer returns: template-derived when the contract is usable, // the loose skeleton otherwise. Control flow stays value-agnostic either way. -const REVIEW_SCHEMA = crContract?.schema ?? LOOSE_REVIEW_SCHEMA +const REVIEW_SCHEMA_BASE = crContract?.schema ?? LOOSE_REVIEW_SCHEMA +// Template contracts own human verdict/finding vocabulary. The orchestration-only +// baseline is layered on top so a template refresh cannot accidentally remove it. +const REVIEW_SCHEMA = { + ...REVIEW_SCHEMA_BASE, + properties: { + ...REVIEW_SCHEMA_BASE.properties, + reviewedHead: { type: 'string', pattern: '^[0-9a-f]{40}$' }, + }, + required: [...new Set([...(REVIEW_SCHEMA_BASE.required ?? []), 'verdict', 'reviewedHead'])], +} // Reviewer prompt vocabulary: `verdictOptions` and `severities` are CANONICAL, // required contract keys (ensure-contract.mjs's validateContract rejects any // contract missing either) — so whenever a contract IS present, both are @@ -1006,6 +1024,11 @@ const TEXT_SHAPE = '(specific inputs/state -> the wrong output or the loss that follows) and the EVIDENCE it is real ' + '(what you ran, what it printed). Cut narration, never evidence.' +const CONTRACT_INVENTORY = + 'CONTRACT INVENTORY (mandatory): before reporting findings, map each changed observable contract to its authoritative producer, inputs, consumers and representations. A FIRST review inventories every changed contract; a re-review inventories only its fix delta and directly changed boundary. For a finite protocol, parser, configuration, state transition or command-output domain, build a finite decision table of every supported state plus its invalid/boundary pair, and probe the real behavior. Report every defect that table exposes now; do not leave ordinary rows for a later review.' + +const FINITE_STATE_COMPLETENESS = + 'FINITE-STATE COMPLETENESS (mandatory when a change parses, selects, snapshots, or branches on a finite protocol/state domain): identify the authoritative grammar or producer, make the complete decision table of supported states and invalid/boundary cases, then write and run a real test for every row before editing the canonical source. Do not implement one newly discovered row at a time and wait for re-review to name the next ordinary variant.' const SEVERITIES = (REVIEW_VOCAB?.severities ?? DEFAULT_SEVERITIES).join(', ') const VERDICTS = (REVIEW_VOCAB?.verdictOptions ?? DEFAULT_VERDICTS).join(', ') @@ -1048,20 +1071,28 @@ function baseOf(story) { return String(story.base ?? '').trim() || PIPELINE.baseBranch } -function wtClause(story) { +function wtClauseBase(story) { const base = baseOf(story) return `ISOLATION (mandatory): do ALL git/file work inside a dedicated worktree at \`${PIPELINE.worktreeRoot}/${story.id}\` — create-or-reuse it: \`git worktree add ${PIPELINE.worktreeRoot}/${story.id} -B ${story.branch} ${base}\` on first setup, or \`git worktree add ${PIPELINE.worktreeRoot}/${story.id} ${story.branch}\` if the branch already has commits; if the path already exists, just \`cd\` into it. NEVER modify the repo's main working tree and NEVER switch its branch.${base === PIPELINE.baseBranch ? '' : ` This story is STACKED on \`${base}\`: that branch is its base, so its commits are already in your history and must NOT be reverted, duplicated or re-implemented — only ADD your own work on top. When you open the PR, target \`${base}\` as the PR base branch, not \`main\`, so the diff shows only this story's change.`}` } +function wtClause(story) { + return `${wtClauseBase(story)} ${FINITE_STATE_COMPLETENESS}` +} + // Reviewer isolation: read-only inspection in a DETACHED throwaway worktree pinned // to the PR's pushed head. Detached HEAD never occupies the branch, so it can't // collide with the authoring worktree (which holds it) or with other stories' // reviewers in a parallel batch — and it never touches the main checkout's branch. -function revWtClause(story) { +function revWtClauseBase(story) { const p = `${PIPELINE.worktreeRoot}/${story.id}-review` return `ISOLATION (mandatory, read-only): NEVER switch the main checkout's branch. Inspect the code in a DETACHED throwaway worktree pinned to the PR's current pushed head: \`git worktree remove --force ${p} 2>/dev/null; git fetch origin -q; git worktree add --detach ${p} origin/${story.branch}\`, then \`cd ${p}\`. Read the code there (the untracked checkpoint is absent here — good, stay blind to it). When finished, remove it: \`git worktree remove --force ${p}\`.` } +function revWtClause(story) { + return `${revWtClauseBase(story)} ${CONTRACT_INVENTORY}` +} + // #373 finding 3: the escalate-flush shared block — supersede-the-prior-flush + the manual // out-of-band CONVENTION + the untracked-worktree-persistence note — is identical across BOTH // escalation prompts (MAX_FIX_ROUNDS + needsHumanDecision). Authored ONCE here so a future @@ -1216,6 +1247,7 @@ async function driveStory(story) { // before honouring it, so the escalation is deferred by a round rather than dropped. let humanDecisionPending = false let prevFindings = [] + let prevReviewedHead = null // ACCUMULATES across rounds — never reassigned. A finding accepted in round 0 (by-design, or // below the floor) is not re-raised by the round-1 reviewer, because round 1 only sees the // fixed code and has no memory of what the human was already told would be carried. So a @@ -1230,7 +1262,10 @@ async function driveStory(story) { const acceptedKeys = new Set() const accept = (findings) => { for (const f of findings) { - const key = `${f.location ?? ''}${f.description ?? ''}` + // Keep a collision-free delimiter without embedding an invisible raw NUL in the shipped + // JavaScript source. A readable space collapses `(location, description)` pairs such as + // (`"a b"`, `"c"`) and (`"a"`, `"b c"`), silently dropping one accepted finding. + const key = `${f.location ?? ''}\u0000${f.description ?? ''}` if (acceptedKeys.has(key)) continue acceptedKeys.add(key) accepted.push(f) @@ -1249,10 +1284,14 @@ async function driveStory(story) { // in-flight log AND no first-review comment already on the PR. Either signal makes // round-0 a SILENT re-review, so a PR never accrues a second first-review. const first = round === 0 && !isContinuation && !firstReviewPosted + // An initial/resumed-without-history review establishes the whole-PR baseline. + // Once a fix is in flight, even the file inventory must start at that baseline; + // otherwise the pacing loop invites a second full audit before its delta rule. + const reviewBase = prevFindings.length ? prevReviewedHead : baseOf(story) const review = await agentRetry( - `Independently review PR #${pr.prNumber} for story ${tag}, following ${SK.review}. ${revWtClause(story)} PACING (mandatory — this is what killed the previous four attempts at this review, measured): a supervisor kills any agent that goes 180 seconds without emitting a TEXT MESSAGE. Tool calls do NOT count as progress: the last stalled reviewer was calling \`sed\`/\`cat\` every ~5 seconds and was still killed, because it had not written a sentence in 200 seconds. So: after EVERY file you inspect, write ONE SHORT LINE of prose saying what you found or that it is clean — before moving to the next file. Never read two files in a row without speaking in between, and never go into a long silent analysis pass. Start by listing the changed files (\`git diff ${baseOf(story)}...origin/${story.branch} --name-only\`), say aloud the order you will take them, then go file by file, narrating as you go. Brevity is fine — one line is enough — but silence is fatal. Review ONLY from the story's acceptance criteria, the PR diff+description, and the code. Do NOT read ${BLIND_PATHS}, nor any checkpoint, handoff or working log under them — they are the author's private context and this review is independent and blind to it. Report EVERY finding regardless of severity (including minor/nit), using the ${REVIEW_TEMPLATE_LABEL} vocabulary: each finding = \`location\` (File:Line), \`severity\` ∈ {${SEVERITIES}}, \`description\` (the CONCRETE FAILURE CASE — inputs/state -> wrong output — not a retelling of the diff), \`recommendation\` (the change, in one or two lines); verdict ∈ {${VERDICTS}}. ${TEXT_SHAPE} DO NOT FILE NEW ISSUES. This is a hard rule, and it overrides any habit of deferring work to a follow-up card: a debt you find in this diff is resolved IN PLACE, in this same PR, within this story's scope. Never invoke ${SK.writeIssue}, never write \`Deferred to #\`, and never recommend "track this separately" — a finding parked in a fresh card is a finding nobody fixes, and it converts a reviewed PR into an unreviewed backlog. Set \`nonActionable: true\` ONLY if fixing it would be genuinely WRONG — byte-consistent with a source of truth, matching an existing convention, an ALREADY-EXISTING tracked story (cite its number; do not create one), or something that can only resolve after merge. Being outside this story's originally stated scope is NOT a reason: fix it here. Whenever you set \`nonActionable: true\`, ALSO set \`disposition\` with a concrete reason replacing the bare label (\`By convention …\` / \`Historical record\` / \`Already tracked in #\` / \`Resolves after merge\`); never leave "non-actionable" as the only explanation. If a finding is SO large that fixing it here would genuinely swamp the story, say so explicitly in \`description\` and leave it ACTIONABLE — the human decides at the merge gate whether to accept the bigger PR or carve it out; that decision is not yours to pre-empt by filing a card. ${first ? `This is the FIRST review: POST your full review report as a PR comment on #${pr.prNumber} (${REVIEW_TEMPLATE_LABEL} structure), and include the marker line \`${firstReviewMarker}\` VERBATIM as the first line of the comment body — it is an HTML comment (invisible in the rendered markdown, so no visible noise) that lets a later resume detect this first review by an EXACT substring match rather than a semantic reading (finding 1). Then return findings + verdict.` : prevFindings.length - ? `This is a RE-REVIEW: do NOT post any PR comment (the orchestrator synthesizes the cycle at the end). Return findings + verdict only. Verify these prior findings were genuinely resolved: ${JSON.stringify(prevFindings)}.` - : `This is a RE-REVIEW on a resumed in-flight cycle (round-0 of this run carries no prior findings): do a FRESH, independent full review pass. do NOT post any PR comment (the orchestrator synthesizes the cycle at the end). Return findings + verdict only.`} Return findings and a verdict.`, + `Independently review PR #${pr.prNumber} for story ${tag}, following ${SK.review}. ${revWtClause(story)} PACING (mandatory — this is what killed the previous four attempts at this review, measured): a supervisor kills any agent that goes 180 seconds without emitting a TEXT MESSAGE. Tool calls do NOT count as progress: the last stalled reviewer was calling \`sed\`/\`cat\` every ~5 seconds and was still killed, because it had not written a sentence in 200 seconds. So: after EVERY file you inspect, write ONE SHORT LINE of prose saying what you found or that it is clean — before moving to the next file. Never read two files in a row without speaking in between, and never go into a long silent analysis pass. Start by listing the changed files (\`git diff ${reviewBase}...origin/${story.branch} --name-only\`), say aloud the order you will take them, then go file by file, narrating as you go. Brevity is fine — one line is enough — but silence is fatal. Review ONLY from the story's acceptance criteria, the PR diff+description, and the code. Do NOT read ${BLIND_PATHS}, nor any checkpoint, handoff or working log under them — they are the author's private context and this review is independent and blind to it. Report EVERY finding regardless of severity (including minor/nit), using the ${REVIEW_TEMPLATE_LABEL} vocabulary: each finding = \`location\` (File:Line), \`severity\` ∈ {${SEVERITIES}}, \`description\` (the CONCRETE FAILURE CASE — inputs/state -> wrong output — not a retelling of the diff), \`recommendation\` (the change, in one or two lines); verdict ∈ {${VERDICTS}}. ${TEXT_SHAPE} DO NOT FILE NEW ISSUES. This is a hard rule, and it overrides any habit of deferring work to a follow-up card: a debt you find in this diff is resolved IN PLACE, in this same PR, within this story's scope. Never invoke ${SK.writeIssue}, never write \`Deferred to #\`, and never recommend "track this separately" — a finding parked in a fresh card is a finding nobody fixes, and it converts a reviewed PR into an unreviewed backlog. Set \`nonActionable: true\` ONLY if fixing it would be genuinely WRONG — byte-consistent with a source of truth, matching an existing convention, an ALREADY-EXISTING tracked story (cite its number; do not create one), or something that can only resolve after merge. Being outside this story's originally stated scope is NOT a reason: fix it here. Whenever you set \`nonActionable: true\`, ALSO set \`disposition\` with a concrete reason replacing the bare label (\`By convention …\` / \`Historical record\` / \`Already tracked in #\` / \`Resolves after merge\`); never leave "non-actionable" as the only explanation. If a finding is SO large that fixing it here would genuinely swamp the story, say so explicitly in \`description\` and leave it ACTIONABLE — the human decides at the merge gate whether to accept the bigger PR or carve it out; that decision is not yours to pre-empt by filing a card. ${first ? `This is the FIRST review: POST your full review report as a PR comment on #${pr.prNumber} (${REVIEW_TEMPLATE_LABEL} structure), and include the marker line \`${firstReviewMarker}\` VERBATIM as the first line of the comment body — it is an HTML comment (invisible in the rendered markdown, so no visible noise) that lets a later resume detect this first review by an EXACT substring match rather than a semantic reading (finding 1). Then return findings + verdict.` : prevFindings.length + ? `This is a RE-REVIEW: do NOT post any PR comment (the orchestrator synthesizes the cycle at the end). Verify these prior findings were genuinely resolved: ${JSON.stringify(prevFindings)}. The last complete review covered immutable head ${prevReviewedHead}. First inspect ONLY the fix delta with \`git diff ${prevReviewedHead}...origin/${story.branch} --name-status\`, then its directly changed producer/consumer contract boundaries. Do NOT re-audit the unchanged PR surface. A new finding is actionable only if it is in this delta or a contract boundary changed by this delta; otherwise report it as a Question for the human, not a new fix round.` + : `This is a RE-REVIEW on a resumed in-flight cycle (round-0 of this run carries no prior findings): do a FRESH, independent full review pass. do NOT post any PR comment (the orchestrator synthesizes the cycle at the end).`} Return findings, verdict, and \`reviewedHead\`: the lower-case 40-character SHA printed by \`git rev-parse origin/${story.branch}\` after your inspection.`, // effort was 'xhigh'. The measured cause of the repeated kills was NOT effort and NOT a // stuck command: transcript timing showed the reviewer issuing a tool call every ~5s // (97 events, mean gap 4.9s, max 49s — zero gaps over 180s) yet still killed, because @@ -1263,10 +1302,9 @@ async function driveStory(story) { // narration reliable, restoring 'xhigh' is legitimate: it costs review depth, which is // the whole point of this gate. Do not read this line as "xhigh causes stalls". withModel({ agentType: 'pair-reviewer', phase: 'Review', label: `rev:${tag} r${round}`, effort: 'high', schema: REVIEW_SCHEMA }), - // A review is USABLE only if it carries a verdict — the same predicate the guard below - // converges on. Without it the retry covered the dead reviewer (`null`) and skipped the - // contentless one (`{}`), which is the shape actually measured on #432. - hasVerdict, + // A review is USABLE only with a verdict and its immutable reviewed head. Without the + // latter, the next pass cannot be an evidence-bounded re-review. + hasReviewEvidence, ) // A DEAD reviewer is not a clean review. `agent()` returns null when the subagent // dies, and `review?.findings ?? []` then yields zero findings — which the @@ -1286,15 +1324,16 @@ async function driveStory(story) { // So the test is inverted: a VERDICT must be present. Absence of findings is not evidence // that a review happened; presence of a verdict is. Every real review emits one — it is a // required field of the contract schema — so this costs a genuine clean review nothing. - // `hasVerdict` is the SAME function `agentRetry` was given above: the contentless return is - // retried once like any other dead step, and only then does it land here. - if (!hasVerdict(review)) + // `hasReviewEvidence` is the SAME function `agentRetry` was given above: a contentless or + // unanchored return is retried once like any other dead step, then lands here. + if (!hasReviewEvidence(review)) // `acceptedFindings` travels on EVERY terminal arm, this one included. A card whose // reviewer dies mid-cycle otherwise reports the by-design and below-floor findings of // every earlier round as if none had been raised — and those are precisely the findings // the fixer never receives, so they are recoverable from nowhere else. AC4 says an // accepted finding always reaches the human; a failure is not an exception to that. return { story, prNumber: pr.prNumber, status: 'failed-review', round, acceptedFindings: accepted, reviewLog: cycleHasRemediation ? reviewLog : undefined } + const reviewedHead = String(review.reviewedHead).toLowerCase() const findings = review.findings ?? [] const allActionable = findings.filter((f) => !f.nonActionable) // Below the floor: still reported, still shown to the human, just not blocking. Marked @@ -1357,11 +1396,12 @@ async function driveStory(story) { round++ prevFindings = actionable + prevReviewedHead = reviewedHead cycleHasRemediation = true // FIX — implementer resumes checkpoint (if present) + resolves actionable findings. // Logs the round to the working review log INSTEAD of posting a per-round PR comment. const fix = await agentRetry( - `Resume story ${tag}. ${wtClause(story)} Read the checkpoint if present (${SK.checkpoint} $mode=resume); otherwise work from the PR diff + code. Resolve EVERY one of these actionable review findings on PR #${pr.prNumber} — including minor/nit, do not defer any: ${JSON.stringify(prevFindings)}. Fix them IN PLACE, in this PR: do NOT file a follow-up issue for any of them, do NOT invoke ${SK.writeIssue}, and do NOT leave a "tracked separately" note in lieu of the fix. If a finding turns out to be genuinely larger than this story, still fix what belongs here and say plainly in the working log what remains — the human decides at the merge gate, not a new card. Follow ${SK.implement} for the change itself (test-first where a finding describes a defect), verify with ${SK.verifyQuality} (tier-resolved — do not improvise a gate command), and record any decision a finding forces with ${SK.recordDecision}. Commit and push. Then re-invoke **${SK.publishPr}**: it is create-or-update and idempotent, and re-running it is what keeps the PR body, the classification tags and the \`pr-state:*\` label in sync with the NEW head commit instead of describing the pre-fix state. As in the open-PR step it will emit \`Review: review-dispatch-required\` rather than nesting — expected: this orchestrator drives the re-review. ${TEXT_SHAPE} Re-running it REWRITES the PR body, and this is the only step that does so once a cycle is under way: rewrite it to describe the CURRENT head, do not append a round-by-round history — a body that grows by one section per fix round is re-read in full by every later reviewer of this same cycle. Do NOT post a remediation PR comment; INSTEAD append this round to the working log \`${reviewLog}\` (create it if absent) as a COMPACT TABLE under a \`## Round N\` heading — one row per finding, columns \`severity | location | what changed | commit\`. One row, one line: no paragraph per finding, and do not restate the finding's description (its location identifies it). Add prose ONLY where a fix diverged from the recommendation, and then only the reason. Only for a genuine design disagreement set needsHumanDecision instead of forcing a fix. Do NOT merge.`, + `Resume story ${tag}. ${wtClause(story)} Read the checkpoint if present (${SK.checkpoint} $mode=resume); otherwise work from the PR diff + code. Resolve EVERY one of these actionable review findings on PR #${pr.prNumber} — including minor/nit, do not defer any: ${JSON.stringify(prevFindings)}. Fix them IN PLACE, in this PR: do NOT file a follow-up issue for any of them, do NOT invoke ${SK.writeIssue}, and do NOT leave a "tracked separately" note in lieu of the fix. If a finding turns out to be genuinely larger than this story, still fix what belongs here and say plainly in the working log what remains — the human decides at the merge gate, not a new card. CONVERGENCE SWEEP (mandatory): the finding location is the starting point, not the contract boundary. Before changing code, make a finite map of the same observable contract: the reported case and its paired success/failure path; any state transition or resume path the contract owns; and the canonical source plus every distributed representation of that behavior (generated asset, dataset, installed copy, or documented command). Change every map cell required for that one contract, then stop — do not use the sweep for unrelated cleanup, new behavior, or speculative hardening. For a generated/distributed artifact, resolve the canonical source from the asset registry, edit only that source, then run the declared generator/installer and inspect its output; never hand-edit a derived copy. PROVISIONED ARTIFACT CONTRACT (mandatory when a change installs, builds, publishes, names, or invokes an executable/package): map \`producer -> published identity -> consumer\` — for example installer/release step -> package manifest/bin/file/export -> workflow or user command. Prove the exact path in a clean temporary environment using the real built or installed artifact. Never stub, alias, or fake the exact producer, published identity, or consumer boundary; external effects may be isolated only after that boundary is crossed. For each logic defect, write a test that executes the real function/script against a real or realistic fixture and asserts output/side effects, never a source-string regex. Re-run the finding's evidence command and the mapped boundary cases before commit. Follow ${SK.implement} for the change itself: its TDD discipline and adoption-compliance phase are mandatory. Verify with ${SK.verifyQuality} (tier-resolved — do not improvise a gate command), and record any decision a finding forces with ${SK.recordDecision}. Commit and push. Then re-invoke **${SK.publishPr}**: it is create-or-update and idempotent, and re-running it is what keeps the PR body, the classification tags and the \`pr-state:*\` label in sync with the NEW head commit instead of describing the pre-fix state. As in the open-PR step it will emit \`Review: review-dispatch-required\` rather than nesting — expected: this orchestrator drives the re-review. ${TEXT_SHAPE} Re-running it REWRITES the PR body, and this is the only step that does so once a cycle is under way: rewrite it to describe the CURRENT head, do not append a round-by-round history — a body that grows by one section per fix round is re-read in full by every later reviewer of this same cycle. Do NOT post a remediation PR comment; INSTEAD append this round to the working log \`${reviewLog}\` (create it if absent) as a COMPACT TABLE under a \`## Round N\` heading — one row per finding, columns \`severity | location | what changed | commit\`. One row, one line: no paragraph per finding, and do not restate the finding's description (its location identifies it). Add prose ONLY where a fix diverged from the recommendation, and then only the reason. Only for a genuine design disagreement set needsHumanDecision instead of forcing a fix. Do NOT merge.`, withModel({ agentType: 'pair-implementer', phase: 'Review', label: `fix:${tag} r${round}`, effort: 'high', schema: FIX_SCHEMA }), ) // failed-fix: the fixer died mid-round; a partial working log may exist. Surface diff --git a/.claude/workflows/pair-implement-batch.test.mjs b/.claude/workflows/pair-implement-batch.test.mjs index b3360499b..e07ed47ff 100644 --- a/.claude/workflows/pair-implement-batch.test.mjs +++ b/.claude/workflows/pair-implement-batch.test.mjs @@ -25,12 +25,25 @@ const SRC = readFileSync(new URL('./pair-implement-batch.js', import.meta.url), '', ) const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor +const REVIEWED_HEAD = 'a'.repeat(40) async function runWorkflow({ args, dispatch }) { const calls = [] const agent = async (prompt, opts) => { calls.push({ prompt, opts }) - return dispatch(prompt, opts) + const result = await dispatch(prompt, opts) + // A real reviewer now returns the immutable revision it reviewed. Keep legacy + // fixtures concise while allowing focused tests to provide an invalid/missing + // value explicitly. + if ( + opts.agentType === 'pair-reviewer' && + result && + typeof result === 'object' && + String(result.verdict ?? '').trim() && + result.reviewedHead === undefined + ) + return { ...result, reviewedHead: REVIEWED_HEAD } + return result } // Mirrors the real primitive's contract: "a thunk that throws (or whose agent errors) // resolves to null in the result array — the call itself never rejects". The earlier @@ -105,7 +118,14 @@ test('valid contract: reviewer schema derives from contract.json (AC1) and cache dispatch: stdDispatch({ contractResult: { status: 'cache-hit', contract } }), }) const rev = calls.find(c => c.opts.agentType === 'pair-reviewer') - assert.deepEqual(rev.opts.schema, contract.schema) + assert.deepEqual(rev.opts.schema, { + ...contract.schema, + properties: { + ...contract.schema.properties, + reviewedHead: { type: 'string', pattern: '^[0-9a-f]{40}$' }, + }, + required: ['verdict', 'reviewedHead'], + }) assert.ok(rev.prompt.includes('Blocker'), 'severity vocabulary threaded from the contract') assert.ok(rev.prompt.includes('Rework'), 'verdict vocabulary threaded from the contract') assert.deepEqual(result.contracts, [{ name: 'code-review', status: 'cache-hit' }]) @@ -212,8 +232,16 @@ test('contract with usable schema but missing canonical vocabulary keys: prompt dispatch: stdDispatch({ contractResult: { status: 'cache-hit', contract } }), }) const rev = calls.find(c => c.opts.agentType === 'pair-reviewer') - // Schema is still enum-locked from the (structurally usable) contract... - assert.deepEqual(rev.opts.schema, contract.schema) + // Schema is still enum-locked from the (structurally usable) contract, with + // the orchestration-owned reviewed revision layered on top. + assert.deepEqual(rev.opts.schema, { + ...contract.schema, + properties: { + ...contract.schema.properties, + reviewedHead: { type: 'string', pattern: '^[0-9a-f]{40}$' }, + }, + required: ['verdict', 'reviewedHead'], + }) // ...but the prompt vocabulary text falls back to the documented defaults, // since verdictOptions/severities (the canonical keys it's threaded from) // are absent. In practice ensure-contract.mjs's validateContract now rejects @@ -1002,6 +1030,127 @@ test('the fix step is likewise barred from deferring a finding into a new issue' ) }) +test('the fix step sweeps the bounded contract surface before re-review', async () => { + const finding = { location: 'x.ts:1', severity: 'Major', description: 'd', recommendation: 'r' } + let round = 0 + const { calls } = await runWorkflow({ + args: { stories: [STORY] }, + dispatch: (prompt, opts) => { + if (opts.agentType === 'pair-contract-generator') return { status: 'cache-hit', contract: validContract() } + if (opts.agentType === 'pair-reviewer') return round++ === 0 ? { verdict: 'Rework', findings: [finding] } : { verdict: 'Approved', findings: [] } + if (opts.phase === 'Implement') return { gatesPassed: true, branch: 'b' } + if (opts.phase === 'PR') return { prNumber: 7 } + return { fixed: true } + }, + }) + + const fix = calls.find(c => c.opts.label?.startsWith('fix:')).prompt + assert.match(fix, /CONVERGENCE SWEEP/, 'the fixer must make the bounded contract explicit') + assert.match(fix, /location is the starting point/i, 'a finding location is not the contract boundary') + assert.match(fix, /success\/failure/i, 'paired execution paths are checked together') + assert.match(fix, /every distributed representation/i, 'source and shipped representations are checked together') + assert.match(fix, /PROVISIONED ARTIFACT CONTRACT/, 'a provisioned command has an explicit end-to-end check') + assert.match(fix, /producer.*published identity.*consumer/i, 'the provisioner, artifact metadata and invocation are mapped together') + assert.match(fix, /clean temporary environment/i, 'the actual installed or built artifact is exercised') + assert.match(fix, /never stub.*boundary/i, 'a stub cannot stand in for the published command boundary') + assert.match(fix, /unrelated cleanup/i, 'the sweep stays bounded and is not scope creep') + assert.doesNotMatch(fix, /touch ONLY what each finding's location names/, 'line-only scope discipline would recreate the gap') +}) + +test('review and fix exhaust finite protocol states before another round', async () => { + const finding = { location: 'state.ts:1', severity: 'Major', description: 'd', recommendation: 'r' } + let round = 0 + const { calls } = await runWorkflow({ + args: { stories: [STORY] }, + dispatch: (prompt, opts) => { + if (opts.agentType === 'pair-contract-generator') return { status: 'cache-hit', contract: validContract() } + if (opts.agentType === 'pair-reviewer') return round++ === 0 ? { verdict: 'Rework', findings: [finding] } : { verdict: 'Approved', findings: [] } + if (opts.phase === 'Implement') return { gatesPassed: true, branch: 'b' } + if (opts.phase === 'PR') return { prNumber: 7 } + return { fixed: true } + }, + }) + const review = calls.find(c => c.opts.agentType === 'pair-reviewer').prompt + const fix = calls.find(c => c.opts.label?.startsWith('fix:')).prompt + assert.ok(review.includes('CONTRACT INVENTORY (mandatory)'), 'the reviewer inventories a contract before reporting its first hole') + assert.ok(review.includes('finite decision table of every supported state'), 'a finite protocol/state space is exhausted in the same review') + assert.ok(fix.includes('FINITE-STATE COMPLETENESS (mandatory when'), 'the fixer must preserve that complete state model') + assert.ok(fix.includes('Do not implement one newly discovered row at a time'), 'the next re-review is not used to discover ordinary variants serially') +}) + +test('re-review is anchored to the reviewed revision and checks only the fix delta plus prior findings', async () => { + const finding = { location: 'workflow.yml:4', severity: 'Major', description: 'd', recommendation: 'r' } + let round = 0 + const { calls } = await runWorkflow({ + args: { stories: [STORY] }, + dispatch: (prompt, opts) => { + if (opts.agentType === 'pair-contract-generator') return { status: 'cache-hit', contract: validContract() } + if (opts.agentType === 'pair-reviewer') + return round++ === 0 + ? { verdict: 'Rework', findings: [finding] } + : { verdict: 'Approved', findings: [] } + if (opts.phase === 'Implement') return { gatesPassed: true, branch: 'b' } + if (opts.phase === 'PR') return { prNumber: 7 } + return { fixed: true } + }, + }) + + const reviews = calls.filter(c => c.opts.agentType === 'pair-reviewer') + assert.match(reviews[0].prompt, /reviewedHead/i, 'every review returns the immutable head it covered') + assert.match(reviews[1].prompt, new RegExp(`git diff ${REVIEWED_HEAD}\\.\\.\\.origin/feat/#292-x --name-only`), 're-review inventories the fix delta, not the entire PR') + assert.match(reviews[1].prompt, new RegExp(`git diff ${REVIEWED_HEAD}\\.\\.\\.origin/feat/#292-x`), 're-review starts from the previous review baseline') + assert.match(reviews[1].prompt, /only if it is in this delta or a contract boundary changed by this delta/i, 'unchanged PR surface is not repeatedly re-audited') +}) + +test('a review without an immutable baseline cannot converge', async () => { + const { result, calls } = await runWorkflow({ + args: { stories: [STORY] }, + dispatch: (prompt, opts) => { + if (opts.agentType === 'pair-contract-generator') return { status: 'cache-hit', contract: validContract() } + if (opts.agentType === 'pair-reviewer') return { verdict: 'Approved', findings: [], reviewedHead: 'not-a-sha' } + if (opts.phase === 'Implement') return { gatesPassed: true, branch: 'b' } + if (opts.phase === 'PR') return { prNumber: 7 } + return { fixed: true } + }, + }) + + assert.equal(result.batch[0].status, 'failed-review') + assert.equal(calls.filter(c => c.opts.agentType === 'pair-reviewer').length, 2, 'missing review evidence is retried once') +}) + +test('a review baseline must be lower-case like the review contract declares', async () => { + const { result } = await runWorkflow({ + args: { stories: [STORY] }, + dispatch: (prompt, opts) => { + if (opts.agentType === 'pair-contract-generator') return { status: 'cache-hit', contract: validContract() } + if (opts.agentType === 'pair-reviewer') return { verdict: 'Approved', findings: [], reviewedHead: 'A'.repeat(40) } + if (opts.phase === 'Implement') return { gatesPassed: true, branch: 'b' } + if (opts.phase === 'PR') return { prNumber: 7 } + return { fixed: true } + }, + }) + + assert.equal(result.batch[0].status, 'failed-review') +}) + +test('accepted-findings key is collision-free for location and description pairs', async () => { + const { result } = await runWorkflow({ + args: { stories: [STORY] }, + dispatch: stdDispatch({ + contractResult: { status: 'cache-hit', contract: validContract() }, + review: { + verdict: 'Approved', + findings: [ + { location: 'a b', severity: 'Minor', description: 'c', nonActionable: true }, + { location: 'a', severity: 'Minor', description: 'b c', nonActionable: true }, + ], + }, + }), + }) + + assert.equal(result.batch[0].acceptedFindings.length, 2) +}) + // ── A run that drove nothing must not report success ─────────────────────── // Observed: two workflows were launched concurrently on a saturated machine, every // implementer stalled past the supervisor's window, `parallel` returned six nulls, diff --git a/.pair/adoption/decision-log/2026-08-31-review-baseline-and-provisioned-artifact-contract.md b/.pair/adoption/decision-log/2026-08-31-review-baseline-and-provisioned-artifact-contract.md new file mode 100644 index 000000000..79afd4533 --- /dev/null +++ b/.pair/adoption/decision-log/2026-08-31-review-baseline-and-provisioned-artifact-contract.md @@ -0,0 +1,58 @@ +# Decision: Review re-checks use an immutable baseline and prove provisioned artifacts + +## Date + +2026-08-31 + +## Status + +Active + +## Category + +Process Decision + +## Context + +PR #474 / story #217 ran on `89793d27`, which already required a convergence sweep. In round 4, +the fixer added a CLI installation step and retained a runner invocation, but tested a stub named +`pair` rather than the installed package's declared `pair-cli` bin. Round 5 therefore found the +new functional defect. The generic sweep named distributed representations but did not require an +end-to-end proof across installation, published identity, and invocation. Re-review also rescanned +the whole accumulated PR, so each fix expanded the next review surface. + +## Decision + +1. Every reviewer return includes the lower-case 40-character SHA it inspected (`reviewedHead`). + Missing or invalid evidence is retried once then fails closed; it never converges a PR. +2. The initial review remains complete. Each later re-review verifies prior findings plus + `git diff ...origin/` and directly changed producer/consumer boundaries. + A new blocking finding must come from that delta or boundary; an unchanged surface is not + re-audited as a new fix round. +3. A fix touching an installed, built, published, named, or invoked artifact maps + `producer -> published identity -> consumer` and proves it in a clean temporary environment + using the real artifact. The exact boundary cannot be stubbed, aliased, or faked. + +## Alternatives Considered + +- **Keep full-PR scans on each round**: rejected — a growing diff makes a re-review another + independent first review and creates unbounded new scope. +- **Accept findings after a fixed round cap**: rejected — it hides genuine defects rather than + bounding their cause. +- **Only strengthen source-string tests**: rejected — #217 passed such a test while the published + CLI contract was broken. + +## Consequences + +- Re-review is bounded without downgrading Major or Minor findings. +- A caller resuming an older cycle has one fresh full review to establish a new baseline. +- Workflow authors must keep dataset source and root mirror byte-identical; tests cover both. + +## Adoption Impact + +- `.pair/adoption/tech/way-of-working.md`: records the review baseline and provisioned-artifact + proof convention. +- `packages/knowledge-hub/dataset/.workflows/pair-implement-batch.js` and its installed mirror: + enforce the convention. +- Both dry-run copies of `pair-implement-batch.test.mjs`: pin the schema, bounded re-review, and + real-artifact prompt requirements. diff --git a/.pair/adoption/decision-log/2026-09-01-review-contract-inventory-prevents-serial-findings.md b/.pair/adoption/decision-log/2026-09-01-review-contract-inventory-prevents-serial-findings.md new file mode 100644 index 000000000..ee73fa744 --- /dev/null +++ b/.pair/adoption/decision-log/2026-09-01-review-contract-inventory-prevents-serial-findings.md @@ -0,0 +1,58 @@ +# Decision: Review contract inventory prevents serial findings + +## Date + +2026-09-01 + +## Status + +Active + +## Category + +Process Decision + +## Context + +The bounded delta re-review correctly exposed defects introduced by fixes, but it found them one +ordinary state at a time. On #419, fixes moved from an already-dirty path, to an unsupported +`git status --porcelain` shape, to a silent skip of another normal shape. The reviewer had one +concrete failure case per round and the fixer repaired that example, not the finite input domain. +Stopping on a count increase is therefore useful evidence for human investigation, not a reason +to hide the increase. + +## Decision + +Before reporting or fixing a changed observable contract, the reviewer/fixer maps its authoritative +producer, inputs, consumers and distributed representations. For a finite protocol, parser, +configuration, state transition or command-output domain, it builds a decision table containing +every supported state and invalid/boundary pair, and probes/tests each row against real behavior. +The first review inventories the full PR surface; a re-review inventories only its fix delta and +directly changed boundary. A fixer may not implement one newly found normal row and wait for a +later re-review to reveal the next one. + +## Alternatives Considered + +- **Keep only the generic convergence sweep**: rejected — it names paired paths but did not make + a finite protocol domain explicit, so a scalar repro still drove a scalar fix. +- **Rescan the full PR on every re-review**: rejected — it reopens unchanged scope; the immutable + baseline/delta rule remains in force. +- **Ignore a rise in findings**: rejected — the monitor's stop is the deliberate signal that a + fix may have introduced a defect and requires investigation. + +## Consequences + +- The initial review may front-load more findings, but ordinary variants are no longer deferred to + later rounds. +- Fixes touching finite state domains carry a complete test matrix before code changes. +- A count increase remains a valid human stop condition; it now points to a specific missing + inventory rather than being treated as the convergence mechanism itself. + +## Adoption Impact + +- `.pair/adoption/tech/way-of-working.md`: records the inventory requirement under Review + Convergence. +- `packages/knowledge-hub/dataset/.workflows/pair-implement-batch.js` and its installed mirror: + require the inventory in reviewer/fixer prompts. +- Both dry-run copies of `pair-implement-batch.test.mjs`: pin the prompt path and finite-state + rule. diff --git a/.pair/adoption/tech/way-of-working.md b/.pair/adoption/tech/way-of-working.md index 3bbc3b8ed..c609040c1 100644 --- a/.pair/adoption/tech/way-of-working.md +++ b/.pair/adoption/tech/way-of-working.md @@ -54,6 +54,23 @@ Resolution order, the split-tool routing and why the fallback is never the authe - When a bug fix or feature changes behavior covered by an existing CP, the corresponding test case MUST be updated. - **CP5's docs page list is machine-asserted against the filesystem** — `packages/knowledge-hub/src/conformance/docs-page-coverage.test.ts` compares it to `apps/website/content/docs/**/*.mdx`, so adding a docs page without listing it in CP5 fails CI ([ADL](../decision-log/2026-08-20-cp5-page-list-is-asserted-against-the-filesystem.md)). +## Review Convergence + +- **Baseline then delta:** the first review is complete and returns the immutable 40-character + head it inspected. A re-review verifies prior findings plus only the diff from that head and + directly changed producer/consumer boundaries; an unchanged PR surface does not create another + fix round. Missing or invalid review-head evidence fails closed, never converges a PR. +- **Provisioned artifact proof:** a fix that installs, builds, publishes, names, or invokes an + artifact maps `producer -> published identity -> consumer` and proves the real path in a clean + temporary environment. The exact boundary is never stubbed, aliased, or faked. See ADL + [2026-08-31-review-baseline-and-provisioned-artifact-contract.md](../decision-log/2026-08-31-review-baseline-and-provisioned-artifact-contract.md). +- **Contract inventory before a loop:** before reporting or fixing a changed contract, inventory + its authoritative producer, inputs, consumers and representations. A finite protocol, parser, + configuration or state transition gets a complete decision table of supported and + invalid/boundary states, with a real probe/test per row; re-review applies the same rule only to + its delta and changed boundary. See ADL + [2026-09-01-review-contract-inventory-prevents-serial-findings.md](../decision-log/2026-09-01-review-contract-inventory-prevents-serial-findings.md). + ## Quality Gates - `pnpm quality-gate` is the adopted project-level quality gate command. diff --git a/.pair/llms.txt b/.pair/llms.txt index 9ca1efe76..323c5f248 100644 --- a/.pair/llms.txt +++ b/.pair/llms.txt @@ -125,6 +125,8 @@ - [Decision: the CLI invocation name is `pair-cli`, not `pair`](.pair/adoption/decision-log/2026-08-25-cli-invocation-canonical-name-is-pair-cli.md) - [Decision: Post-merge cleanup covers local branches and worktrees; PR analyses retire at merge](.pair/adoption/decision-log/2026-08-25-post-merge-cleanup-covers-local-branches-pr-analyses-retire-at-merge.md) - [Decision: tier 1's `$approval` posture is unconditional, and tier 1 has no declaring composition site yet](.pair/adoption/decision-log/2026-08-28-tier1-approval-posture-is-unconditional-and-has-no-declaring-composition-site-yet.md) +- [Decision: Review re-checks use an immutable baseline and prove provisioned artifacts](.pair/adoption/decision-log/2026-08-31-review-baseline-and-provisioned-artifact-contract.md) +- [Decision: Review contract inventory prevents serial findings](.pair/adoption/decision-log/2026-09-01-review-contract-inventory-prevents-serial-findings.md) ## How-To Guides diff --git a/packages/knowledge-hub/dataset/.workflows/pair-implement-batch.js b/packages/knowledge-hub/dataset/.workflows/pair-implement-batch.js index f95817702..2942ef0b9 100644 --- a/packages/knowledge-hub/dataset/.workflows/pair-implement-batch.js +++ b/packages/knowledge-hub/dataset/.workflows/pair-implement-batch.js @@ -795,7 +795,7 @@ const MAX_FIX_ROUNDS = PIPELINE.maxFixRounds // truncated structured output) — and the contentless shape is the one this repo // actually measured on #432 (the machine slept mid-response), i.e. the retry // missed the exact incident it was written for while covering its rarer sibling. -// The review step therefore passes `hasVerdict`, the SAME predicate its +// The review step therefore passes `hasReviewEvidence`, the SAME predicate its // convergence guard uses, so "did not review" means one thing at both sites: the // transient gets its second chance, and a step that comes back contentless twice // still fails closed. @@ -812,6 +812,11 @@ async function agentRetry(prompt, opts, isUsable = r => !!r) { // ONE predicate, asked by the retry and by the convergence guard, so the two // cannot drift into disagreeing about what a dead reviewer is. const hasVerdict = r => !!r && !!String(r.verdict ?? '').trim() +const REVIEWED_HEAD_PATTERN = /^[0-9a-f]{40}$/ +// A review also has to identify the immutable PR revision it actually inspected. +// Without that baseline a later reviewer cannot distinguish the fix delta from the +// already-audited PR surface, which turns each re-review into another full scan. +const hasReviewEvidence = r => hasVerdict(r) && REVIEWED_HEAD_PATTERN.test(String(r.reviewedHead ?? '')) // ── Schemas (orchestration return-value contracts) ───────────────────────── // These are the compact values agents RETURN for control-flow — NOT the artifact @@ -854,6 +859,9 @@ const LOOSE_REVIEW_SCHEMA = { // Control flow keys on `nonActionable` + actionable count, never on specific // verdict strings. verdict: { type: 'string' }, + // Immutable full SHA of the PR head reviewed. This is workflow evidence, not + // part of the human-facing review template vocabulary. + reviewedHead: { type: 'string', pattern: '^[0-9a-f]{40}$' }, needsHumanDecision: { type: 'boolean' }, findings: { type: 'array', @@ -879,7 +887,7 @@ const LOOSE_REVIEW_SCHEMA = { }, }, }, - required: ['verdict'], + required: ['verdict', 'reviewedHead'], } const FIX_SCHEMA = { type: 'object', @@ -977,7 +985,17 @@ const contracts = STORIES.length ? await parallel(CONTRACT_SPECS.map((s) => () = const crContract = contracts.find((c) => c.name === 'code-review') // Schema the reviewer returns: template-derived when the contract is usable, // the loose skeleton otherwise. Control flow stays value-agnostic either way. -const REVIEW_SCHEMA = crContract?.schema ?? LOOSE_REVIEW_SCHEMA +const REVIEW_SCHEMA_BASE = crContract?.schema ?? LOOSE_REVIEW_SCHEMA +// Template contracts own human verdict/finding vocabulary. The orchestration-only +// baseline is layered on top so a template refresh cannot accidentally remove it. +const REVIEW_SCHEMA = { + ...REVIEW_SCHEMA_BASE, + properties: { + ...REVIEW_SCHEMA_BASE.properties, + reviewedHead: { type: 'string', pattern: '^[0-9a-f]{40}$' }, + }, + required: [...new Set([...(REVIEW_SCHEMA_BASE.required ?? []), 'verdict', 'reviewedHead'])], +} // Reviewer prompt vocabulary: `verdictOptions` and `severities` are CANONICAL, // required contract keys (ensure-contract.mjs's validateContract rejects any // contract missing either) — so whenever a contract IS present, both are @@ -1006,6 +1024,11 @@ const TEXT_SHAPE = '(specific inputs/state -> the wrong output or the loss that follows) and the EVIDENCE it is real ' + '(what you ran, what it printed). Cut narration, never evidence.' +const CONTRACT_INVENTORY = + 'CONTRACT INVENTORY (mandatory): before reporting findings, map each changed observable contract to its authoritative producer, inputs, consumers and representations. A FIRST review inventories every changed contract; a re-review inventories only its fix delta and directly changed boundary. For a finite protocol, parser, configuration, state transition or command-output domain, build a finite decision table of every supported state plus its invalid/boundary pair, and probe the real behavior. Report every defect that table exposes now; do not leave ordinary rows for a later review.' + +const FINITE_STATE_COMPLETENESS = + 'FINITE-STATE COMPLETENESS (mandatory when a change parses, selects, snapshots, or branches on a finite protocol/state domain): identify the authoritative grammar or producer, make the complete decision table of supported states and invalid/boundary cases, then write and run a real test for every row before editing the canonical source. Do not implement one newly discovered row at a time and wait for re-review to name the next ordinary variant.' const SEVERITIES = (REVIEW_VOCAB?.severities ?? DEFAULT_SEVERITIES).join(', ') const VERDICTS = (REVIEW_VOCAB?.verdictOptions ?? DEFAULT_VERDICTS).join(', ') @@ -1048,20 +1071,28 @@ function baseOf(story) { return String(story.base ?? '').trim() || PIPELINE.baseBranch } -function wtClause(story) { +function wtClauseBase(story) { const base = baseOf(story) return `ISOLATION (mandatory): do ALL git/file work inside a dedicated worktree at \`${PIPELINE.worktreeRoot}/${story.id}\` — create-or-reuse it: \`git worktree add ${PIPELINE.worktreeRoot}/${story.id} -B ${story.branch} ${base}\` on first setup, or \`git worktree add ${PIPELINE.worktreeRoot}/${story.id} ${story.branch}\` if the branch already has commits; if the path already exists, just \`cd\` into it. NEVER modify the repo's main working tree and NEVER switch its branch.${base === PIPELINE.baseBranch ? '' : ` This story is STACKED on \`${base}\`: that branch is its base, so its commits are already in your history and must NOT be reverted, duplicated or re-implemented — only ADD your own work on top. When you open the PR, target \`${base}\` as the PR base branch, not \`main\`, so the diff shows only this story's change.`}` } +function wtClause(story) { + return `${wtClauseBase(story)} ${FINITE_STATE_COMPLETENESS}` +} + // Reviewer isolation: read-only inspection in a DETACHED throwaway worktree pinned // to the PR's pushed head. Detached HEAD never occupies the branch, so it can't // collide with the authoring worktree (which holds it) or with other stories' // reviewers in a parallel batch — and it never touches the main checkout's branch. -function revWtClause(story) { +function revWtClauseBase(story) { const p = `${PIPELINE.worktreeRoot}/${story.id}-review` return `ISOLATION (mandatory, read-only): NEVER switch the main checkout's branch. Inspect the code in a DETACHED throwaway worktree pinned to the PR's current pushed head: \`git worktree remove --force ${p} 2>/dev/null; git fetch origin -q; git worktree add --detach ${p} origin/${story.branch}\`, then \`cd ${p}\`. Read the code there (the untracked checkpoint is absent here — good, stay blind to it). When finished, remove it: \`git worktree remove --force ${p}\`.` } +function revWtClause(story) { + return `${revWtClauseBase(story)} ${CONTRACT_INVENTORY}` +} + // #373 finding 3: the escalate-flush shared block — supersede-the-prior-flush + the manual // out-of-band CONVENTION + the untracked-worktree-persistence note — is identical across BOTH // escalation prompts (MAX_FIX_ROUNDS + needsHumanDecision). Authored ONCE here so a future @@ -1216,6 +1247,7 @@ async function driveStory(story) { // before honouring it, so the escalation is deferred by a round rather than dropped. let humanDecisionPending = false let prevFindings = [] + let prevReviewedHead = null // ACCUMULATES across rounds — never reassigned. A finding accepted in round 0 (by-design, or // below the floor) is not re-raised by the round-1 reviewer, because round 1 only sees the // fixed code and has no memory of what the human was already told would be carried. So a @@ -1230,7 +1262,10 @@ async function driveStory(story) { const acceptedKeys = new Set() const accept = (findings) => { for (const f of findings) { - const key = `${f.location ?? ''}${f.description ?? ''}` + // Keep a collision-free delimiter without embedding an invisible raw NUL in the shipped + // JavaScript source. A readable space collapses `(location, description)` pairs such as + // (`"a b"`, `"c"`) and (`"a"`, `"b c"`), silently dropping one accepted finding. + const key = `${f.location ?? ''}\u0000${f.description ?? ''}` if (acceptedKeys.has(key)) continue acceptedKeys.add(key) accepted.push(f) @@ -1249,10 +1284,14 @@ async function driveStory(story) { // in-flight log AND no first-review comment already on the PR. Either signal makes // round-0 a SILENT re-review, so a PR never accrues a second first-review. const first = round === 0 && !isContinuation && !firstReviewPosted + // An initial/resumed-without-history review establishes the whole-PR baseline. + // Once a fix is in flight, even the file inventory must start at that baseline; + // otherwise the pacing loop invites a second full audit before its delta rule. + const reviewBase = prevFindings.length ? prevReviewedHead : baseOf(story) const review = await agentRetry( - `Independently review PR #${pr.prNumber} for story ${tag}, following ${SK.review}. ${revWtClause(story)} PACING (mandatory — this is what killed the previous four attempts at this review, measured): a supervisor kills any agent that goes 180 seconds without emitting a TEXT MESSAGE. Tool calls do NOT count as progress: the last stalled reviewer was calling \`sed\`/\`cat\` every ~5 seconds and was still killed, because it had not written a sentence in 200 seconds. So: after EVERY file you inspect, write ONE SHORT LINE of prose saying what you found or that it is clean — before moving to the next file. Never read two files in a row without speaking in between, and never go into a long silent analysis pass. Start by listing the changed files (\`git diff ${baseOf(story)}...origin/${story.branch} --name-only\`), say aloud the order you will take them, then go file by file, narrating as you go. Brevity is fine — one line is enough — but silence is fatal. Review ONLY from the story's acceptance criteria, the PR diff+description, and the code. Do NOT read ${BLIND_PATHS}, nor any checkpoint, handoff or working log under them — they are the author's private context and this review is independent and blind to it. Report EVERY finding regardless of severity (including minor/nit), using the ${REVIEW_TEMPLATE_LABEL} vocabulary: each finding = \`location\` (File:Line), \`severity\` ∈ {${SEVERITIES}}, \`description\` (the CONCRETE FAILURE CASE — inputs/state -> wrong output — not a retelling of the diff), \`recommendation\` (the change, in one or two lines); verdict ∈ {${VERDICTS}}. ${TEXT_SHAPE} DO NOT FILE NEW ISSUES. This is a hard rule, and it overrides any habit of deferring work to a follow-up card: a debt you find in this diff is resolved IN PLACE, in this same PR, within this story's scope. Never invoke ${SK.writeIssue}, never write \`Deferred to #\`, and never recommend "track this separately" — a finding parked in a fresh card is a finding nobody fixes, and it converts a reviewed PR into an unreviewed backlog. Set \`nonActionable: true\` ONLY if fixing it would be genuinely WRONG — byte-consistent with a source of truth, matching an existing convention, an ALREADY-EXISTING tracked story (cite its number; do not create one), or something that can only resolve after merge. Being outside this story's originally stated scope is NOT a reason: fix it here. Whenever you set \`nonActionable: true\`, ALSO set \`disposition\` with a concrete reason replacing the bare label (\`By convention …\` / \`Historical record\` / \`Already tracked in #\` / \`Resolves after merge\`); never leave "non-actionable" as the only explanation. If a finding is SO large that fixing it here would genuinely swamp the story, say so explicitly in \`description\` and leave it ACTIONABLE — the human decides at the merge gate whether to accept the bigger PR or carve it out; that decision is not yours to pre-empt by filing a card. ${first ? `This is the FIRST review: POST your full review report as a PR comment on #${pr.prNumber} (${REVIEW_TEMPLATE_LABEL} structure), and include the marker line \`${firstReviewMarker}\` VERBATIM as the first line of the comment body — it is an HTML comment (invisible in the rendered markdown, so no visible noise) that lets a later resume detect this first review by an EXACT substring match rather than a semantic reading (finding 1). Then return findings + verdict.` : prevFindings.length - ? `This is a RE-REVIEW: do NOT post any PR comment (the orchestrator synthesizes the cycle at the end). Return findings + verdict only. Verify these prior findings were genuinely resolved: ${JSON.stringify(prevFindings)}.` - : `This is a RE-REVIEW on a resumed in-flight cycle (round-0 of this run carries no prior findings): do a FRESH, independent full review pass. do NOT post any PR comment (the orchestrator synthesizes the cycle at the end). Return findings + verdict only.`} Return findings and a verdict.`, + `Independently review PR #${pr.prNumber} for story ${tag}, following ${SK.review}. ${revWtClause(story)} PACING (mandatory — this is what killed the previous four attempts at this review, measured): a supervisor kills any agent that goes 180 seconds without emitting a TEXT MESSAGE. Tool calls do NOT count as progress: the last stalled reviewer was calling \`sed\`/\`cat\` every ~5 seconds and was still killed, because it had not written a sentence in 200 seconds. So: after EVERY file you inspect, write ONE SHORT LINE of prose saying what you found or that it is clean — before moving to the next file. Never read two files in a row without speaking in between, and never go into a long silent analysis pass. Start by listing the changed files (\`git diff ${reviewBase}...origin/${story.branch} --name-only\`), say aloud the order you will take them, then go file by file, narrating as you go. Brevity is fine — one line is enough — but silence is fatal. Review ONLY from the story's acceptance criteria, the PR diff+description, and the code. Do NOT read ${BLIND_PATHS}, nor any checkpoint, handoff or working log under them — they are the author's private context and this review is independent and blind to it. Report EVERY finding regardless of severity (including minor/nit), using the ${REVIEW_TEMPLATE_LABEL} vocabulary: each finding = \`location\` (File:Line), \`severity\` ∈ {${SEVERITIES}}, \`description\` (the CONCRETE FAILURE CASE — inputs/state -> wrong output — not a retelling of the diff), \`recommendation\` (the change, in one or two lines); verdict ∈ {${VERDICTS}}. ${TEXT_SHAPE} DO NOT FILE NEW ISSUES. This is a hard rule, and it overrides any habit of deferring work to a follow-up card: a debt you find in this diff is resolved IN PLACE, in this same PR, within this story's scope. Never invoke ${SK.writeIssue}, never write \`Deferred to #\`, and never recommend "track this separately" — a finding parked in a fresh card is a finding nobody fixes, and it converts a reviewed PR into an unreviewed backlog. Set \`nonActionable: true\` ONLY if fixing it would be genuinely WRONG — byte-consistent with a source of truth, matching an existing convention, an ALREADY-EXISTING tracked story (cite its number; do not create one), or something that can only resolve after merge. Being outside this story's originally stated scope is NOT a reason: fix it here. Whenever you set \`nonActionable: true\`, ALSO set \`disposition\` with a concrete reason replacing the bare label (\`By convention …\` / \`Historical record\` / \`Already tracked in #\` / \`Resolves after merge\`); never leave "non-actionable" as the only explanation. If a finding is SO large that fixing it here would genuinely swamp the story, say so explicitly in \`description\` and leave it ACTIONABLE — the human decides at the merge gate whether to accept the bigger PR or carve it out; that decision is not yours to pre-empt by filing a card. ${first ? `This is the FIRST review: POST your full review report as a PR comment on #${pr.prNumber} (${REVIEW_TEMPLATE_LABEL} structure), and include the marker line \`${firstReviewMarker}\` VERBATIM as the first line of the comment body — it is an HTML comment (invisible in the rendered markdown, so no visible noise) that lets a later resume detect this first review by an EXACT substring match rather than a semantic reading (finding 1). Then return findings + verdict.` : prevFindings.length + ? `This is a RE-REVIEW: do NOT post any PR comment (the orchestrator synthesizes the cycle at the end). Verify these prior findings were genuinely resolved: ${JSON.stringify(prevFindings)}. The last complete review covered immutable head ${prevReviewedHead}. First inspect ONLY the fix delta with \`git diff ${prevReviewedHead}...origin/${story.branch} --name-status\`, then its directly changed producer/consumer contract boundaries. Do NOT re-audit the unchanged PR surface. A new finding is actionable only if it is in this delta or a contract boundary changed by this delta; otherwise report it as a Question for the human, not a new fix round.` + : `This is a RE-REVIEW on a resumed in-flight cycle (round-0 of this run carries no prior findings): do a FRESH, independent full review pass. do NOT post any PR comment (the orchestrator synthesizes the cycle at the end).`} Return findings, verdict, and \`reviewedHead\`: the lower-case 40-character SHA printed by \`git rev-parse origin/${story.branch}\` after your inspection.`, // effort was 'xhigh'. The measured cause of the repeated kills was NOT effort and NOT a // stuck command: transcript timing showed the reviewer issuing a tool call every ~5s // (97 events, mean gap 4.9s, max 49s — zero gaps over 180s) yet still killed, because @@ -1263,10 +1302,9 @@ async function driveStory(story) { // narration reliable, restoring 'xhigh' is legitimate: it costs review depth, which is // the whole point of this gate. Do not read this line as "xhigh causes stalls". withModel({ agentType: 'pair-reviewer', phase: 'Review', label: `rev:${tag} r${round}`, effort: 'high', schema: REVIEW_SCHEMA }), - // A review is USABLE only if it carries a verdict — the same predicate the guard below - // converges on. Without it the retry covered the dead reviewer (`null`) and skipped the - // contentless one (`{}`), which is the shape actually measured on #432. - hasVerdict, + // A review is USABLE only with a verdict and its immutable reviewed head. Without the + // latter, the next pass cannot be an evidence-bounded re-review. + hasReviewEvidence, ) // A DEAD reviewer is not a clean review. `agent()` returns null when the subagent // dies, and `review?.findings ?? []` then yields zero findings — which the @@ -1286,15 +1324,16 @@ async function driveStory(story) { // So the test is inverted: a VERDICT must be present. Absence of findings is not evidence // that a review happened; presence of a verdict is. Every real review emits one — it is a // required field of the contract schema — so this costs a genuine clean review nothing. - // `hasVerdict` is the SAME function `agentRetry` was given above: the contentless return is - // retried once like any other dead step, and only then does it land here. - if (!hasVerdict(review)) + // `hasReviewEvidence` is the SAME function `agentRetry` was given above: a contentless or + // unanchored return is retried once like any other dead step, then lands here. + if (!hasReviewEvidence(review)) // `acceptedFindings` travels on EVERY terminal arm, this one included. A card whose // reviewer dies mid-cycle otherwise reports the by-design and below-floor findings of // every earlier round as if none had been raised — and those are precisely the findings // the fixer never receives, so they are recoverable from nowhere else. AC4 says an // accepted finding always reaches the human; a failure is not an exception to that. return { story, prNumber: pr.prNumber, status: 'failed-review', round, acceptedFindings: accepted, reviewLog: cycleHasRemediation ? reviewLog : undefined } + const reviewedHead = String(review.reviewedHead).toLowerCase() const findings = review.findings ?? [] const allActionable = findings.filter((f) => !f.nonActionable) // Below the floor: still reported, still shown to the human, just not blocking. Marked @@ -1357,11 +1396,12 @@ async function driveStory(story) { round++ prevFindings = actionable + prevReviewedHead = reviewedHead cycleHasRemediation = true // FIX — implementer resumes checkpoint (if present) + resolves actionable findings. // Logs the round to the working review log INSTEAD of posting a per-round PR comment. const fix = await agentRetry( - `Resume story ${tag}. ${wtClause(story)} Read the checkpoint if present (${SK.checkpoint} $mode=resume); otherwise work from the PR diff + code. Resolve EVERY one of these actionable review findings on PR #${pr.prNumber} — including minor/nit, do not defer any: ${JSON.stringify(prevFindings)}. Fix them IN PLACE, in this PR: do NOT file a follow-up issue for any of them, do NOT invoke ${SK.writeIssue}, and do NOT leave a "tracked separately" note in lieu of the fix. If a finding turns out to be genuinely larger than this story, still fix what belongs here and say plainly in the working log what remains — the human decides at the merge gate, not a new card. Follow ${SK.implement} for the change itself (test-first where a finding describes a defect), verify with ${SK.verifyQuality} (tier-resolved — do not improvise a gate command), and record any decision a finding forces with ${SK.recordDecision}. Commit and push. Then re-invoke **${SK.publishPr}**: it is create-or-update and idempotent, and re-running it is what keeps the PR body, the classification tags and the \`pr-state:*\` label in sync with the NEW head commit instead of describing the pre-fix state. As in the open-PR step it will emit \`Review: review-dispatch-required\` rather than nesting — expected: this orchestrator drives the re-review. ${TEXT_SHAPE} Re-running it REWRITES the PR body, and this is the only step that does so once a cycle is under way: rewrite it to describe the CURRENT head, do not append a round-by-round history — a body that grows by one section per fix round is re-read in full by every later reviewer of this same cycle. Do NOT post a remediation PR comment; INSTEAD append this round to the working log \`${reviewLog}\` (create it if absent) as a COMPACT TABLE under a \`## Round N\` heading — one row per finding, columns \`severity | location | what changed | commit\`. One row, one line: no paragraph per finding, and do not restate the finding's description (its location identifies it). Add prose ONLY where a fix diverged from the recommendation, and then only the reason. Only for a genuine design disagreement set needsHumanDecision instead of forcing a fix. Do NOT merge.`, + `Resume story ${tag}. ${wtClause(story)} Read the checkpoint if present (${SK.checkpoint} $mode=resume); otherwise work from the PR diff + code. Resolve EVERY one of these actionable review findings on PR #${pr.prNumber} — including minor/nit, do not defer any: ${JSON.stringify(prevFindings)}. Fix them IN PLACE, in this PR: do NOT file a follow-up issue for any of them, do NOT invoke ${SK.writeIssue}, and do NOT leave a "tracked separately" note in lieu of the fix. If a finding turns out to be genuinely larger than this story, still fix what belongs here and say plainly in the working log what remains — the human decides at the merge gate, not a new card. CONVERGENCE SWEEP (mandatory): the finding location is the starting point, not the contract boundary. Before changing code, make a finite map of the same observable contract: the reported case and its paired success/failure path; any state transition or resume path the contract owns; and the canonical source plus every distributed representation of that behavior (generated asset, dataset, installed copy, or documented command). Change every map cell required for that one contract, then stop — do not use the sweep for unrelated cleanup, new behavior, or speculative hardening. For a generated/distributed artifact, resolve the canonical source from the asset registry, edit only that source, then run the declared generator/installer and inspect its output; never hand-edit a derived copy. PROVISIONED ARTIFACT CONTRACT (mandatory when a change installs, builds, publishes, names, or invokes an executable/package): map \`producer -> published identity -> consumer\` — for example installer/release step -> package manifest/bin/file/export -> workflow or user command. Prove the exact path in a clean temporary environment using the real built or installed artifact. Never stub, alias, or fake the exact producer, published identity, or consumer boundary; external effects may be isolated only after that boundary is crossed. For each logic defect, write a test that executes the real function/script against a real or realistic fixture and asserts output/side effects, never a source-string regex. Re-run the finding's evidence command and the mapped boundary cases before commit. Follow ${SK.implement} for the change itself: its TDD discipline and adoption-compliance phase are mandatory. Verify with ${SK.verifyQuality} (tier-resolved — do not improvise a gate command), and record any decision a finding forces with ${SK.recordDecision}. Commit and push. Then re-invoke **${SK.publishPr}**: it is create-or-update and idempotent, and re-running it is what keeps the PR body, the classification tags and the \`pr-state:*\` label in sync with the NEW head commit instead of describing the pre-fix state. As in the open-PR step it will emit \`Review: review-dispatch-required\` rather than nesting — expected: this orchestrator drives the re-review. ${TEXT_SHAPE} Re-running it REWRITES the PR body, and this is the only step that does so once a cycle is under way: rewrite it to describe the CURRENT head, do not append a round-by-round history — a body that grows by one section per fix round is re-read in full by every later reviewer of this same cycle. Do NOT post a remediation PR comment; INSTEAD append this round to the working log \`${reviewLog}\` (create it if absent) as a COMPACT TABLE under a \`## Round N\` heading — one row per finding, columns \`severity | location | what changed | commit\`. One row, one line: no paragraph per finding, and do not restate the finding's description (its location identifies it). Add prose ONLY where a fix diverged from the recommendation, and then only the reason. Only for a genuine design disagreement set needsHumanDecision instead of forcing a fix. Do NOT merge.`, withModel({ agentType: 'pair-implementer', phase: 'Review', label: `fix:${tag} r${round}`, effort: 'high', schema: FIX_SCHEMA }), ) // failed-fix: the fixer died mid-round; a partial working log may exist. Surface diff --git a/packages/knowledge-hub/dataset/.workflows/pair-implement-batch.test.mjs b/packages/knowledge-hub/dataset/.workflows/pair-implement-batch.test.mjs index b3360499b..e07ed47ff 100644 --- a/packages/knowledge-hub/dataset/.workflows/pair-implement-batch.test.mjs +++ b/packages/knowledge-hub/dataset/.workflows/pair-implement-batch.test.mjs @@ -25,12 +25,25 @@ const SRC = readFileSync(new URL('./pair-implement-batch.js', import.meta.url), '', ) const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor +const REVIEWED_HEAD = 'a'.repeat(40) async function runWorkflow({ args, dispatch }) { const calls = [] const agent = async (prompt, opts) => { calls.push({ prompt, opts }) - return dispatch(prompt, opts) + const result = await dispatch(prompt, opts) + // A real reviewer now returns the immutable revision it reviewed. Keep legacy + // fixtures concise while allowing focused tests to provide an invalid/missing + // value explicitly. + if ( + opts.agentType === 'pair-reviewer' && + result && + typeof result === 'object' && + String(result.verdict ?? '').trim() && + result.reviewedHead === undefined + ) + return { ...result, reviewedHead: REVIEWED_HEAD } + return result } // Mirrors the real primitive's contract: "a thunk that throws (or whose agent errors) // resolves to null in the result array — the call itself never rejects". The earlier @@ -105,7 +118,14 @@ test('valid contract: reviewer schema derives from contract.json (AC1) and cache dispatch: stdDispatch({ contractResult: { status: 'cache-hit', contract } }), }) const rev = calls.find(c => c.opts.agentType === 'pair-reviewer') - assert.deepEqual(rev.opts.schema, contract.schema) + assert.deepEqual(rev.opts.schema, { + ...contract.schema, + properties: { + ...contract.schema.properties, + reviewedHead: { type: 'string', pattern: '^[0-9a-f]{40}$' }, + }, + required: ['verdict', 'reviewedHead'], + }) assert.ok(rev.prompt.includes('Blocker'), 'severity vocabulary threaded from the contract') assert.ok(rev.prompt.includes('Rework'), 'verdict vocabulary threaded from the contract') assert.deepEqual(result.contracts, [{ name: 'code-review', status: 'cache-hit' }]) @@ -212,8 +232,16 @@ test('contract with usable schema but missing canonical vocabulary keys: prompt dispatch: stdDispatch({ contractResult: { status: 'cache-hit', contract } }), }) const rev = calls.find(c => c.opts.agentType === 'pair-reviewer') - // Schema is still enum-locked from the (structurally usable) contract... - assert.deepEqual(rev.opts.schema, contract.schema) + // Schema is still enum-locked from the (structurally usable) contract, with + // the orchestration-owned reviewed revision layered on top. + assert.deepEqual(rev.opts.schema, { + ...contract.schema, + properties: { + ...contract.schema.properties, + reviewedHead: { type: 'string', pattern: '^[0-9a-f]{40}$' }, + }, + required: ['verdict', 'reviewedHead'], + }) // ...but the prompt vocabulary text falls back to the documented defaults, // since verdictOptions/severities (the canonical keys it's threaded from) // are absent. In practice ensure-contract.mjs's validateContract now rejects @@ -1002,6 +1030,127 @@ test('the fix step is likewise barred from deferring a finding into a new issue' ) }) +test('the fix step sweeps the bounded contract surface before re-review', async () => { + const finding = { location: 'x.ts:1', severity: 'Major', description: 'd', recommendation: 'r' } + let round = 0 + const { calls } = await runWorkflow({ + args: { stories: [STORY] }, + dispatch: (prompt, opts) => { + if (opts.agentType === 'pair-contract-generator') return { status: 'cache-hit', contract: validContract() } + if (opts.agentType === 'pair-reviewer') return round++ === 0 ? { verdict: 'Rework', findings: [finding] } : { verdict: 'Approved', findings: [] } + if (opts.phase === 'Implement') return { gatesPassed: true, branch: 'b' } + if (opts.phase === 'PR') return { prNumber: 7 } + return { fixed: true } + }, + }) + + const fix = calls.find(c => c.opts.label?.startsWith('fix:')).prompt + assert.match(fix, /CONVERGENCE SWEEP/, 'the fixer must make the bounded contract explicit') + assert.match(fix, /location is the starting point/i, 'a finding location is not the contract boundary') + assert.match(fix, /success\/failure/i, 'paired execution paths are checked together') + assert.match(fix, /every distributed representation/i, 'source and shipped representations are checked together') + assert.match(fix, /PROVISIONED ARTIFACT CONTRACT/, 'a provisioned command has an explicit end-to-end check') + assert.match(fix, /producer.*published identity.*consumer/i, 'the provisioner, artifact metadata and invocation are mapped together') + assert.match(fix, /clean temporary environment/i, 'the actual installed or built artifact is exercised') + assert.match(fix, /never stub.*boundary/i, 'a stub cannot stand in for the published command boundary') + assert.match(fix, /unrelated cleanup/i, 'the sweep stays bounded and is not scope creep') + assert.doesNotMatch(fix, /touch ONLY what each finding's location names/, 'line-only scope discipline would recreate the gap') +}) + +test('review and fix exhaust finite protocol states before another round', async () => { + const finding = { location: 'state.ts:1', severity: 'Major', description: 'd', recommendation: 'r' } + let round = 0 + const { calls } = await runWorkflow({ + args: { stories: [STORY] }, + dispatch: (prompt, opts) => { + if (opts.agentType === 'pair-contract-generator') return { status: 'cache-hit', contract: validContract() } + if (opts.agentType === 'pair-reviewer') return round++ === 0 ? { verdict: 'Rework', findings: [finding] } : { verdict: 'Approved', findings: [] } + if (opts.phase === 'Implement') return { gatesPassed: true, branch: 'b' } + if (opts.phase === 'PR') return { prNumber: 7 } + return { fixed: true } + }, + }) + const review = calls.find(c => c.opts.agentType === 'pair-reviewer').prompt + const fix = calls.find(c => c.opts.label?.startsWith('fix:')).prompt + assert.ok(review.includes('CONTRACT INVENTORY (mandatory)'), 'the reviewer inventories a contract before reporting its first hole') + assert.ok(review.includes('finite decision table of every supported state'), 'a finite protocol/state space is exhausted in the same review') + assert.ok(fix.includes('FINITE-STATE COMPLETENESS (mandatory when'), 'the fixer must preserve that complete state model') + assert.ok(fix.includes('Do not implement one newly discovered row at a time'), 'the next re-review is not used to discover ordinary variants serially') +}) + +test('re-review is anchored to the reviewed revision and checks only the fix delta plus prior findings', async () => { + const finding = { location: 'workflow.yml:4', severity: 'Major', description: 'd', recommendation: 'r' } + let round = 0 + const { calls } = await runWorkflow({ + args: { stories: [STORY] }, + dispatch: (prompt, opts) => { + if (opts.agentType === 'pair-contract-generator') return { status: 'cache-hit', contract: validContract() } + if (opts.agentType === 'pair-reviewer') + return round++ === 0 + ? { verdict: 'Rework', findings: [finding] } + : { verdict: 'Approved', findings: [] } + if (opts.phase === 'Implement') return { gatesPassed: true, branch: 'b' } + if (opts.phase === 'PR') return { prNumber: 7 } + return { fixed: true } + }, + }) + + const reviews = calls.filter(c => c.opts.agentType === 'pair-reviewer') + assert.match(reviews[0].prompt, /reviewedHead/i, 'every review returns the immutable head it covered') + assert.match(reviews[1].prompt, new RegExp(`git diff ${REVIEWED_HEAD}\\.\\.\\.origin/feat/#292-x --name-only`), 're-review inventories the fix delta, not the entire PR') + assert.match(reviews[1].prompt, new RegExp(`git diff ${REVIEWED_HEAD}\\.\\.\\.origin/feat/#292-x`), 're-review starts from the previous review baseline') + assert.match(reviews[1].prompt, /only if it is in this delta or a contract boundary changed by this delta/i, 'unchanged PR surface is not repeatedly re-audited') +}) + +test('a review without an immutable baseline cannot converge', async () => { + const { result, calls } = await runWorkflow({ + args: { stories: [STORY] }, + dispatch: (prompt, opts) => { + if (opts.agentType === 'pair-contract-generator') return { status: 'cache-hit', contract: validContract() } + if (opts.agentType === 'pair-reviewer') return { verdict: 'Approved', findings: [], reviewedHead: 'not-a-sha' } + if (opts.phase === 'Implement') return { gatesPassed: true, branch: 'b' } + if (opts.phase === 'PR') return { prNumber: 7 } + return { fixed: true } + }, + }) + + assert.equal(result.batch[0].status, 'failed-review') + assert.equal(calls.filter(c => c.opts.agentType === 'pair-reviewer').length, 2, 'missing review evidence is retried once') +}) + +test('a review baseline must be lower-case like the review contract declares', async () => { + const { result } = await runWorkflow({ + args: { stories: [STORY] }, + dispatch: (prompt, opts) => { + if (opts.agentType === 'pair-contract-generator') return { status: 'cache-hit', contract: validContract() } + if (opts.agentType === 'pair-reviewer') return { verdict: 'Approved', findings: [], reviewedHead: 'A'.repeat(40) } + if (opts.phase === 'Implement') return { gatesPassed: true, branch: 'b' } + if (opts.phase === 'PR') return { prNumber: 7 } + return { fixed: true } + }, + }) + + assert.equal(result.batch[0].status, 'failed-review') +}) + +test('accepted-findings key is collision-free for location and description pairs', async () => { + const { result } = await runWorkflow({ + args: { stories: [STORY] }, + dispatch: stdDispatch({ + contractResult: { status: 'cache-hit', contract: validContract() }, + review: { + verdict: 'Approved', + findings: [ + { location: 'a b', severity: 'Minor', description: 'c', nonActionable: true }, + { location: 'a', severity: 'Minor', description: 'b c', nonActionable: true }, + ], + }, + }), + }) + + assert.equal(result.batch[0].acceptedFindings.length, 2) +}) + // ── A run that drove nothing must not report success ─────────────────────── // Observed: two workflows were launched concurrently on a saturated machine, every // implementer stalled past the supervisor's window, `parallel` returned six nulls, From fa4fc10f3b7be6a3056616acfb8cc74e64966388 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Tue, 1 Sep 2026 21:25:33 +0200 Subject: [PATCH 02/14] [US-219] fix: prove external boundaries Require real boundary evidence for externally-defined state and repair claims.\n\nRefs: #416 --- .claude/workflows/pair-implement-batch.js | 9 +++- .../workflows/pair-implement-batch.test.mjs | 2 + ...undary-proof-prevents-false-equivalence.md | 54 +++++++++++++++++++ .pair/adoption/tech/way-of-working.md | 9 ++-- .pair/llms.txt | 1 + .../.workflows/pair-implement-batch.js | 9 +++- .../.workflows/pair-implement-batch.test.mjs | 2 + 7 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 .pair/adoption/decision-log/2026-09-01-external-boundary-proof-prevents-false-equivalence.md diff --git a/.claude/workflows/pair-implement-batch.js b/.claude/workflows/pair-implement-batch.js index 2942ef0b9..50b7f7002 100644 --- a/.claude/workflows/pair-implement-batch.js +++ b/.claude/workflows/pair-implement-batch.js @@ -1024,11 +1024,16 @@ const TEXT_SHAPE = '(specific inputs/state -> the wrong output or the loss that follows) and the EVIDENCE it is real ' + '(what you ran, what it printed). Cut narration, never evidence.' +const AUTHORITATIVE_BOUNDARY_PROOF = + 'AUTHORITATIVE BOUNDARY PROOF (mandatory): when a table row, equivalence, normalization or remediation depends on an external command, service, file format or runtime, name the exact real producer/consumer that defines it and run a minimal isolated end-to-end probe for every such claim. Keep rows distinct until that boundary proves them equivalent. A unit test of the function being changed cannot establish external semantics or prove that user-facing repair advice works: apply the advice in a clean temporary environment and verify the promised postcondition.' + const CONTRACT_INVENTORY = - 'CONTRACT INVENTORY (mandatory): before reporting findings, map each changed observable contract to its authoritative producer, inputs, consumers and representations. A FIRST review inventories every changed contract; a re-review inventories only its fix delta and directly changed boundary. For a finite protocol, parser, configuration, state transition or command-output domain, build a finite decision table of every supported state plus its invalid/boundary pair, and probe the real behavior. Report every defect that table exposes now; do not leave ordinary rows for a later review.' + 'CONTRACT INVENTORY (mandatory): before reporting findings, map each changed observable contract to its authoritative producer, inputs, consumers and representations. A FIRST review inventories every changed contract; a re-review inventories only its fix delta and directly changed boundary. For a finite protocol, parser, configuration, state transition or command-output domain, build a finite decision table of every supported state plus its invalid/boundary pair, and probe the real behavior. Report every defect that table exposes now; do not leave ordinary rows for a later review. ' + + AUTHORITATIVE_BOUNDARY_PROOF const FINITE_STATE_COMPLETENESS = - 'FINITE-STATE COMPLETENESS (mandatory when a change parses, selects, snapshots, or branches on a finite protocol/state domain): identify the authoritative grammar or producer, make the complete decision table of supported states and invalid/boundary cases, then write and run a real test for every row before editing the canonical source. Do not implement one newly discovered row at a time and wait for re-review to name the next ordinary variant.' + 'FINITE-STATE COMPLETENESS (mandatory when a change parses, selects, snapshots, or branches on a finite protocol/state domain): identify the authoritative grammar or producer, make the complete decision table of supported states and invalid/boundary cases, then write and run a real test for every row before editing the canonical source. Do not implement one newly discovered row at a time and wait for re-review to name the next ordinary variant. ' + + AUTHORITATIVE_BOUNDARY_PROOF const SEVERITIES = (REVIEW_VOCAB?.severities ?? DEFAULT_SEVERITIES).join(', ') const VERDICTS = (REVIEW_VOCAB?.verdictOptions ?? DEFAULT_VERDICTS).join(', ') diff --git a/.claude/workflows/pair-implement-batch.test.mjs b/.claude/workflows/pair-implement-batch.test.mjs index e07ed47ff..cbaa5dbb4 100644 --- a/.claude/workflows/pair-implement-batch.test.mjs +++ b/.claude/workflows/pair-implement-batch.test.mjs @@ -1074,8 +1074,10 @@ test('review and fix exhaust finite protocol states before another round', async const fix = calls.find(c => c.opts.label?.startsWith('fix:')).prompt assert.ok(review.includes('CONTRACT INVENTORY (mandatory)'), 'the reviewer inventories a contract before reporting its first hole') assert.ok(review.includes('finite decision table of every supported state'), 'a finite protocol/state space is exhausted in the same review') + assert.ok(review.includes('AUTHORITATIVE BOUNDARY PROOF (mandatory)'), 'the reviewer must prove externally-defined state semantics at the real boundary') assert.ok(fix.includes('FINITE-STATE COMPLETENESS (mandatory when'), 'the fixer must preserve that complete state model') assert.ok(fix.includes('Do not implement one newly discovered row at a time'), 'the next re-review is not used to discover ordinary variants serially') + assert.ok(fix.includes('A unit test of the function being changed cannot establish external semantics'), 'the fixer cannot infer external-tool behavior from its own unit tests') }) test('re-review is anchored to the reviewed revision and checks only the fix delta plus prior findings', async () => { diff --git a/.pair/adoption/decision-log/2026-09-01-external-boundary-proof-prevents-false-equivalence.md b/.pair/adoption/decision-log/2026-09-01-external-boundary-proof-prevents-false-equivalence.md new file mode 100644 index 000000000..1fa09a35a --- /dev/null +++ b/.pair/adoption/decision-log/2026-09-01-external-boundary-proof-prevents-false-equivalence.md @@ -0,0 +1,54 @@ +# Decision: External boundary proof prevents false equivalence + +## Date + +2026-09-01 + +## Status + +Active + +## Category + +Process Decision + +## Context + +The finite-state inventory rule exposed #416's CRLF problem, but the fixer extended the repair to +bare CR by treating all carriage-return forms as equivalent. Its unit tests proved only the drift +checker’s classification. A minimal real Git probe showed the promised remedy was false: Git +normalizes CRLF to LF, but preserves a lone CR blob through checkout. The next review therefore +found a major regression: its advice forbade the only repair that actually worked. + +## Decision + +Whenever a decision-table row, equivalence, normalization or user-facing repair depends on an +external command, service, file format or runtime, identify its authoritative producer/consumer +and prove the claim with a minimal isolated end-to-end probe. Keep variants distinct until that +boundary demonstrates equivalence. Apply any proposed repair in the probe and verify its stated +postcondition. Unit tests of the changed function remain required but cannot substitute for this +boundary evidence. + +## Alternatives Considered + +- **Trust the implementation unit tests**: rejected — they cannot establish Git’s checkout + semantics or prove user guidance outside the function’s process. +- **Treat all syntactically similar inputs as one state**: rejected — the external producer can + distinguish them, as CRLF and lone CR demonstrate. +- **Defer external proof to re-review**: rejected — that makes the reviewer discover a repair + regression after the fixer has already committed it. + +## Consequences + +- A finite-state table may contain an external-boundary probe per row or equivalence class. +- Fix reports include command, observed result and postcondition for external repair claims. +- Re-review may still stop on increased findings; it remains the signal that the required proof + was absent or incorrect. + +## Adoption Impact + +- `.pair/adoption/tech/way-of-working.md`: extends Review Convergence with the boundary-proof + requirement. +- `packages/knowledge-hub/dataset/.workflows/pair-implement-batch.js` and installed mirror: + require reviewers and fixers to prove externally-defined semantics and repair advice. +- Both workflow test copies: pin the prompt requirement so it cannot silently disappear. diff --git a/.pair/adoption/tech/way-of-working.md b/.pair/adoption/tech/way-of-working.md index c609040c1..b74941683 100644 --- a/.pair/adoption/tech/way-of-working.md +++ b/.pair/adoption/tech/way-of-working.md @@ -67,9 +67,12 @@ Resolution order, the split-tool routing and why the fallback is never the authe - **Contract inventory before a loop:** before reporting or fixing a changed contract, inventory its authoritative producer, inputs, consumers and representations. A finite protocol, parser, configuration or state transition gets a complete decision table of supported and - invalid/boundary states, with a real probe/test per row; re-review applies the same rule only to - its delta and changed boundary. See ADL - [2026-09-01-review-contract-inventory-prevents-serial-findings.md](../decision-log/2026-09-01-review-contract-inventory-prevents-serial-findings.md). + invalid/boundary states, with a real probe/test per row. When a row, equivalence, normalization + or repair depends on an external tool/service/format, prove it at that authoritative boundary; + an internal unit test cannot prove external semantics or that repair advice works. Re-review + applies the same rule only to its delta and changed boundary. See ADLs + [2026-09-01-review-contract-inventory-prevents-serial-findings.md](../decision-log/2026-09-01-review-contract-inventory-prevents-serial-findings.md) + and [2026-09-01-external-boundary-proof-prevents-false-equivalence.md](../decision-log/2026-09-01-external-boundary-proof-prevents-false-equivalence.md). ## Quality Gates diff --git a/.pair/llms.txt b/.pair/llms.txt index 323c5f248..fc706dd27 100644 --- a/.pair/llms.txt +++ b/.pair/llms.txt @@ -126,6 +126,7 @@ - [Decision: Post-merge cleanup covers local branches and worktrees; PR analyses retire at merge](.pair/adoption/decision-log/2026-08-25-post-merge-cleanup-covers-local-branches-pr-analyses-retire-at-merge.md) - [Decision: tier 1's `$approval` posture is unconditional, and tier 1 has no declaring composition site yet](.pair/adoption/decision-log/2026-08-28-tier1-approval-posture-is-unconditional-and-has-no-declaring-composition-site-yet.md) - [Decision: Review re-checks use an immutable baseline and prove provisioned artifacts](.pair/adoption/decision-log/2026-08-31-review-baseline-and-provisioned-artifact-contract.md) +- [Decision: External boundary proof prevents false equivalence](.pair/adoption/decision-log/2026-09-01-external-boundary-proof-prevents-false-equivalence.md) - [Decision: Review contract inventory prevents serial findings](.pair/adoption/decision-log/2026-09-01-review-contract-inventory-prevents-serial-findings.md) ## How-To Guides diff --git a/packages/knowledge-hub/dataset/.workflows/pair-implement-batch.js b/packages/knowledge-hub/dataset/.workflows/pair-implement-batch.js index 2942ef0b9..50b7f7002 100644 --- a/packages/knowledge-hub/dataset/.workflows/pair-implement-batch.js +++ b/packages/knowledge-hub/dataset/.workflows/pair-implement-batch.js @@ -1024,11 +1024,16 @@ const TEXT_SHAPE = '(specific inputs/state -> the wrong output or the loss that follows) and the EVIDENCE it is real ' + '(what you ran, what it printed). Cut narration, never evidence.' +const AUTHORITATIVE_BOUNDARY_PROOF = + 'AUTHORITATIVE BOUNDARY PROOF (mandatory): when a table row, equivalence, normalization or remediation depends on an external command, service, file format or runtime, name the exact real producer/consumer that defines it and run a minimal isolated end-to-end probe for every such claim. Keep rows distinct until that boundary proves them equivalent. A unit test of the function being changed cannot establish external semantics or prove that user-facing repair advice works: apply the advice in a clean temporary environment and verify the promised postcondition.' + const CONTRACT_INVENTORY = - 'CONTRACT INVENTORY (mandatory): before reporting findings, map each changed observable contract to its authoritative producer, inputs, consumers and representations. A FIRST review inventories every changed contract; a re-review inventories only its fix delta and directly changed boundary. For a finite protocol, parser, configuration, state transition or command-output domain, build a finite decision table of every supported state plus its invalid/boundary pair, and probe the real behavior. Report every defect that table exposes now; do not leave ordinary rows for a later review.' + 'CONTRACT INVENTORY (mandatory): before reporting findings, map each changed observable contract to its authoritative producer, inputs, consumers and representations. A FIRST review inventories every changed contract; a re-review inventories only its fix delta and directly changed boundary. For a finite protocol, parser, configuration, state transition or command-output domain, build a finite decision table of every supported state plus its invalid/boundary pair, and probe the real behavior. Report every defect that table exposes now; do not leave ordinary rows for a later review. ' + + AUTHORITATIVE_BOUNDARY_PROOF const FINITE_STATE_COMPLETENESS = - 'FINITE-STATE COMPLETENESS (mandatory when a change parses, selects, snapshots, or branches on a finite protocol/state domain): identify the authoritative grammar or producer, make the complete decision table of supported states and invalid/boundary cases, then write and run a real test for every row before editing the canonical source. Do not implement one newly discovered row at a time and wait for re-review to name the next ordinary variant.' + 'FINITE-STATE COMPLETENESS (mandatory when a change parses, selects, snapshots, or branches on a finite protocol/state domain): identify the authoritative grammar or producer, make the complete decision table of supported states and invalid/boundary cases, then write and run a real test for every row before editing the canonical source. Do not implement one newly discovered row at a time and wait for re-review to name the next ordinary variant. ' + + AUTHORITATIVE_BOUNDARY_PROOF const SEVERITIES = (REVIEW_VOCAB?.severities ?? DEFAULT_SEVERITIES).join(', ') const VERDICTS = (REVIEW_VOCAB?.verdictOptions ?? DEFAULT_VERDICTS).join(', ') diff --git a/packages/knowledge-hub/dataset/.workflows/pair-implement-batch.test.mjs b/packages/knowledge-hub/dataset/.workflows/pair-implement-batch.test.mjs index e07ed47ff..cbaa5dbb4 100644 --- a/packages/knowledge-hub/dataset/.workflows/pair-implement-batch.test.mjs +++ b/packages/knowledge-hub/dataset/.workflows/pair-implement-batch.test.mjs @@ -1074,8 +1074,10 @@ test('review and fix exhaust finite protocol states before another round', async const fix = calls.find(c => c.opts.label?.startsWith('fix:')).prompt assert.ok(review.includes('CONTRACT INVENTORY (mandatory)'), 'the reviewer inventories a contract before reporting its first hole') assert.ok(review.includes('finite decision table of every supported state'), 'a finite protocol/state space is exhausted in the same review') + assert.ok(review.includes('AUTHORITATIVE BOUNDARY PROOF (mandatory)'), 'the reviewer must prove externally-defined state semantics at the real boundary') assert.ok(fix.includes('FINITE-STATE COMPLETENESS (mandatory when'), 'the fixer must preserve that complete state model') assert.ok(fix.includes('Do not implement one newly discovered row at a time'), 'the next re-review is not used to discover ordinary variants serially') + assert.ok(fix.includes('A unit test of the function being changed cannot establish external semantics'), 'the fixer cannot infer external-tool behavior from its own unit tests') }) test('re-review is anchored to the reviewed revision and checks only the fix delta plus prior findings', async () => { From a6792f4fb7e34e1e7d1d0043c51ab45c9e5c781c Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Tue, 1 Sep 2026 15:36:28 +0200 Subject: [PATCH 03/14] =?UTF-8?q?[#419]=20feat:=20mirrors:regenerate=20?= =?UTF-8?q?=E2=80=94=20local,=20deterministic=20mirror=20realignment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/regenerate-mirrors.sh: thin wrapper over the CLI's existing `pair update --source --offline` path; no generation logic - root script `mirrors:regenerate`; no check mode (the guards are the checker) - fail-loud: non-zero + reason on no git tree, no dataset, no toolchain - tests first: drift regenerated, idempotent, no published KB fetched, authored changes untouched, both failure paths - Task: T-1 — Root script for local-source mirror regeneration Refs: #419 --- package.json | 1 + .../quality-gates/regenerate-mirrors.test.ts | 186 ++++++++++++++++++ scripts/regenerate-mirrors.sh | 83 ++++++++ 3 files changed, 270 insertions(+) create mode 100644 packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts create mode 100755 scripts/regenerate-mirrors.sh diff --git a/package.json b/package.json index ddf80d3f8..ce3bc89f2 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "sync-version": "pnpm --filter @pair/dev-tools sync-version", "docs:staleness": "pnpm --filter @pair/website docs:staleness", "skills:conformance": "pnpm --filter @pair/knowledge-hub skills:conformance", + "mirrors:regenerate": "./scripts/regenerate-mirrors.sh", "dup:check": "jscpd apps packages", "quality-gate": "turbo ts:check test lint && pnpm workflows:test && pnpm format:check && pnpm gate:composition && pnpm hygiene:check && pnpm smoke-modes:check && pnpm docs:staleness && pnpm skills:conformance && pnpm dup:check", "e2e": "pnpm --filter @pair/website e2e", diff --git a/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts b/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts new file mode 100644 index 000000000..38dc06bbf --- /dev/null +++ b/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { execFileSync } from 'child_process' +import { + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + existsSync, + rmSync, + realpathSync, +} from 'fs' +import { tmpdir } from 'os' +import { join, resolve, dirname } from 'path' + +import { REPO_ROOT } from './repo-root' + +// #419: the mirror-equality guard's remedy is THIS script — a local, deterministic +// realignment of the generated mirrors from the working tree's dataset. `pair update` +// (the old remedy) resolves and installs a PUBLISHED knowledge base, which is a +// different operation and cannot be the fix for "your working tree drifted". +// +// Shape borrowed from run-format.test.ts: the real script is executed against a +// throwaway git fixture, because the thing under test IS the script's behaviour +// (a wrapper over the CLI's existing `--source` path — see the story's "no new +// generation logic" constraint), not a function it could delegate to. +const REGENERATE = resolve(REPO_ROOT, 'scripts/regenerate-mirrors.sh') + +interface RunResult { + status: number + stdout: string + stderr: string +} + +function run(cwd: string, env?: Record): RunResult { + try { + const stdout = execFileSync(REGENERATE, [], { + cwd, + env: env ? { ...process.env, ...env } : process.env, + }) + return { status: 0, stdout: stdout.toString('utf-8'), stderr: '' } + } catch (error) { + const e = error as { status: number; stdout?: Buffer; stderr?: Buffer } + return { + status: e.status, + stdout: e.stdout?.toString('utf-8') ?? '', + stderr: e.stderr?.toString('utf-8') ?? '', + } + } +} + +function git(dir: string, args: string[]): string { + return execFileSync('git', args, { cwd: dir }).toString('utf-8') +} + +function initRepo(dir: string): void { + git(dir, ['init', '-q']) + git(dir, ['config', 'user.email', 'test@example.com']) + git(dir, ['config', 'user.name', 'Test']) +} + +function write(path: string, content: string): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content) +} + +const STUB_SKILL = '# /stub\n\nA stub skill.\n' +const KB_INDEX = '# Mock Knowledge\n' + +/** + * The smallest tree `pair update --source ` accepts: a KB-shaped dataset + * (`validateKBStructure`) plus at least one ALREADY-INSTALLED target, since update + * refuses to run on a project that was never installed. `.pair/knowledge/index.md` + * plays both roles here — installed target and the file we deliberately drift. + */ +function makeFixture(): string { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'regen-mirrors-'))) + const dataset = join(dir, 'packages/knowledge-hub/dataset') + + write(join(dataset, 'manifest.json'), '{"version":"0.0.0"}\n') + write(join(dataset, 'AGENTS.md'), '# AGENTS\n') + write(join(dataset, '.pair/knowledge/index.md'), KB_INDEX) + write(join(dataset, '.pair/adoption/index.md'), '# Mock Adoption\n') + write(join(dataset, '.github/README.md'), '# Mock GitHub\n') + write(join(dataset, '.skills/capability/stub/SKILL.md'), STUB_SKILL) + // Same line the real repo carries: `.pair/.kb-version.json` is a local install stamp + // (it records a wall-clock `recordedAt`), so it is untracked by design. Without it the + // fixture would report a diff on every run for a file no repo commits. + write(join(dir, '.gitignore'), '.pair/.kb-version.json\n') + + initRepo(dir) + return dir +} + +/** A HOME nobody shares, so a KB cache slot written by a download is visible. */ +function isolatedHome(dir: string): Record { + const home = join(dir, '.home') + mkdirSync(home, { recursive: true }) + return { HOME: home } +} + +describe('regenerate-mirrors.sh — the local, deterministic mirror remedy (#419)', () => { + let tmp = '' + + afterEach(() => { + if (tmp) rmSync(tmp, { recursive: true, force: true }) + tmp = '' + }) + + it('regenerates a drifted mirror from the LOCAL dataset (AC1)', () => { + tmp = makeFixture() + const mirror = join(tmp, '.pair/knowledge/index.md') + write(mirror, '# hand-edited drift\n') + + const result = run(tmp, isolatedHome(tmp)) + + expect(result.status).toBe(0) + expect(readFileSync(mirror, 'utf-8')).toBe(KB_INDEX) + }) + + it('never fetches or installs a published KB version (AC1)', () => { + tmp = makeFixture() + write(join(tmp, '.pair/knowledge/index.md'), '# hand-edited drift\n') + const env = isolatedHome(tmp) + + const result = run(tmp, env) + + expect(result.status).toBe(0) + // A published-version resolution caches the downloaded KB under `~/.pair/kb/`. + // Its absence is the observable difference between "regenerated from the working tree" + // and "updated to whatever is published", which is the whole point of the story. + expect(existsSync(join(env['HOME'] as string, '.pair/kb'))).toBe(false) + }) + + it('is idempotent — a second run produces no further diff (AC2)', () => { + tmp = makeFixture() + write(join(tmp, '.pair/knowledge/index.md'), '# hand-edited drift\n') + run(tmp, isolatedHome(tmp)) + git(tmp, ['add', '-A']) + git(tmp, ['commit', '-q', '-m', 'regenerated']) + + const second = run(tmp, isolatedHome(tmp)) + + expect(second.status).toBe(0) + expect(git(tmp, ['status', '--porcelain'])).toBe('') + }) + + it('leaves unstaged authored changes untouched (dirty-tree edge case)', () => { + tmp = makeFixture() + const authored = join(tmp, 'src/authored.ts') + write(authored, 'export const authored = 1\n') + git(tmp, ['add', '-A']) + git(tmp, ['commit', '-q', '-m', 'fixture']) + writeFileSync(authored, 'export const authored = 2\n') + write(join(tmp, '.pair/knowledge/index.md'), '# hand-edited drift\n') + + const result = run(tmp, isolatedHome(tmp)) + + expect(result.status).toBe(0) + expect(readFileSync(authored, 'utf-8')).toBe('export const authored = 2\n') + }) + + it('exits non-zero and names the reason when the dataset is missing (AC7)', () => { + tmp = realpathSync(mkdtempSync(join(tmpdir(), 'regen-mirrors-'))) + initRepo(tmp) + + const result = run(tmp, isolatedHome(tmp)) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('packages/knowledge-hub/dataset') + }) + + it('exits non-zero and names the reason outside a git working tree (AC7)', () => { + tmp = realpathSync(mkdtempSync(join(tmpdir(), 'regen-mirrors-'))) + + const result = run(tmp, { ...isolatedHome(tmp), GIT_CEILING_DIRECTORIES: tmp }) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('git') + }) + + it('has no check mode — one writer, one checker (AC8)', () => { + const source = readFileSync(REGENERATE, 'utf-8') + expect(source).not.toMatch(/--check\b/) + expect(source).not.toMatch(/--dry-run\b/) + }) +}) diff --git a/scripts/regenerate-mirrors.sh b/scripts/regenerate-mirrors.sh new file mode 100755 index 000000000..5343b681b --- /dev/null +++ b/scripts/regenerate-mirrors.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env sh +# shellcheck shell=sh +# +# scripts/regenerate-mirrors.sh — realign the generated mirrors with the LOCAL dataset. +# +# This is the remedy the mirror-equality guards name (story #419). It is NOT +# `pair update`: that command resolves and installs the PUBLISHED knowledge base, +# so the fix would depend on what has been released rather than on what is in the +# working tree. Here the source is always `packages/knowledge-hub/dataset` of the +# repo you are standing in, and `--offline` makes the "no published version" part +# structural rather than a promise. +# +# A thin wrapper, deliberately: the regeneration itself is the CLI's existing +# local-source path (`pair update --source `), the same one the +# `source-resolution` smoke scenario exercises. No generation logic lives here. +# +# There is no check mode. The mirror-equality guards (`pnpm skills:conformance`) +# are the checker; this is the only writer — one writer, one checker. +# +# Two roots, and they are not the same thing: +# TOOLCHAIN_ROOT — where this script and the CLI that does the work live. +# TARGET_ROOT — the git working tree being realigned (derived from the cwd). +# They coincide in normal use. They differ under test, which is what makes the +# happy path exercisable against a throwaway fixture instead of the real repo — +# the same split `scripts/format-lib/run-format.sh` already uses. +# +# Exit codes: +# 0 — the mirrors match the local dataset (regenerated, or already in sync) +# 1 — broken: no git working tree, no dataset, no toolchain, or the CLI failed. +# Never a silent success over a no-op: if nothing could be written, this says so. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TOOLCHAIN_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +if ! TARGET_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"; then + echo "regenerate-mirrors: not inside a git working tree — \`git rev-parse --show-toplevel\`" >&2 + echo " failed from $(pwd), so there is no repo whose mirrors could be realigned." >&2 + exit 1 +fi + +DATASET="$TARGET_ROOT/packages/knowledge-hub/dataset" +if [ ! -d "$DATASET" ]; then + echo "regenerate-mirrors: no dataset at packages/knowledge-hub/dataset (looked in $DATASET)." >&2 + echo " That directory IS the regeneration source; nothing was written." >&2 + exit 1 +fi + +TURBO="$TOOLCHAIN_ROOT/node_modules/.bin/turbo" +if [ ! -x "$TURBO" ]; then + echo "regenerate-mirrors: $TURBO is missing — run \`pnpm install\` first." >&2 + exit 1 +fi + +# The CLI is TypeScript and its workspace dependency (`@pair/content-ops`) resolves to +# built output, so a compile is not optional — running a stale `dist/` would regenerate +# with yesterday's transform and produce a mirror the guards still reject. turbo caches +# it, so the cost is a cache hit on every run after the first. +BUILD_LOG="$(mktemp "${TMPDIR:-/tmp}/regenerate-mirrors.XXXXXX")" || { + echo "regenerate-mirrors: cannot create a temporary file (checked TMPDIR=${TMPDIR:-/tmp})." >&2 + exit 1 +} +if ! (cd "$TOOLCHAIN_ROOT" && "$TURBO" run build --filter=@pair/pair-cli...) >"$BUILD_LOG" 2>&1; then + cat "$BUILD_LOG" >&2 + rm -f "$BUILD_LOG" + echo "regenerate-mirrors: could not build the pair CLI — nothing was regenerated." >&2 + exit 1 +fi +rm -f "$BUILD_LOG" + +CLI="$TOOLCHAIN_ROOT/apps/pair-cli/dist/cli.js" +if [ ! -f "$CLI" ]; then + echo "regenerate-mirrors: the build reported success but $CLI does not exist." >&2 + exit 1 +fi + +# `INIT_CWD` is what the CLI reads as its install target, and it OUTRANKS both the +# positional target and the cwd. pnpm sets it to wherever the developer typed the +# command, which for `pnpm -w mirrors:regenerate` from a subdirectory is the +# subdirectory — so it is pinned here instead of inherited. +cd "$TARGET_ROOT" +export INIT_CWD="$TARGET_ROOT" +exec node "$CLI" update --source "$DATASET" --offline From f90b993a65e89a5ff0b83e2223257279e5e61545 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Tue, 1 Sep 2026 15:41:36 +0200 Subject: [PATCH 04/14] [#419] fix: the remedy every mirror guard names is mirrors:regenerate, not pair update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PRE_PUSH_REMEDY names `pnpm mirrors:regenerate`; new MIRROR_REMEDY_SCRIPT is dead-advice-checked like REMEDY_SCRIPT (both remedy steps must exist) - mirror-guard + skill-md-mirror print the same command, stated once as MIRROR_REGENERATE_COMMAND — the guard whose failure the contributor reads is the guard whose remedy AC-3 renames - DEVELOPMENT.md + development-setup.mdx: paragraph stays byte-identical modulo the ADL link form; both command lists gain the script - descriptive references to the `pair update` TRANSFORM are a different claim, untouched - Task: T-2, T-3 — rename the remedy in the gate message and both docs Refs: #419 --- DEVELOPMENT.md | 15 +++++--- .../docs/contributing/development-setup.mdx | 16 +++++--- .../pre-push-gate-composition.test.ts | 24 ++++++++++++ .../pre-push-gate-composition.ts | 38 ++++++++++++++----- .../src/tools/mirror-guard.test.ts | 17 ++++++++- .../knowledge-hub/src/tools/mirror-guard.ts | 4 +- .../src/tools/skill-md-mirror.test.ts | 7 ++-- .../src/tools/skill-md-mirror.ts | 20 +++++++++- 8 files changed, 112 insertions(+), 29 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 205eb2784..9b9fd0493 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -65,6 +65,7 @@ pnpm install # Install all dependencies pnpm quality-gate # Full quality check (ts:check + test + lint + format check + hygiene) pnpm format # Apply formatting (prettier + markdownlint, write mode) pnpm format:check # Check formatting only — what the gate runs; never writes +pnpm mirrors:regenerate # Realign the generated mirrors with the LOCAL dataset (offline) pnpm test # Run all tests (Turbo) pnpm build # Build all packages (Turbo) pnpm lint # Lint all packages (Turbo) @@ -129,12 +130,14 @@ the next diff. On a `format:check` failure, run `pnpm format` and commit the res instead of 1 means a formatter wrapper itself failed (a broken install, not drift) — read its output rather than running `pnpm format`. **Two-step remedy:** if `pnpm format` touched `packages/knowledge-hub/dataset/**`, re-sync the generated `.claude/skills/**` and -`.pair/knowledge/**` copies (`pair update`) in the same commit, or a mirror guard fails later in the -same gate — the dataset copy is inside format scope, its generated twin is not (`.claude/` and root -`.pair/` are not workspace members), and the mirror guards assert each twin equals the OUTPUT of the -real `pair update` transform — never the dataset source itself, which the corpus is transformed away -from. `gate:composition` guards the gate against a write-mode step (formatter or eslint autofix) -creeping back in. See ADL +`.pair/knowledge/**` copies (`pnpm mirrors:regenerate`) in the same commit, or a mirror guard fails +later in the same gate — the dataset copy is inside format scope, its generated twin is not +(`.claude/` and root `.pair/` are not workspace members), and the mirror guards assert each twin +equals the OUTPUT of the real `pair update` transform — never the dataset source itself, which the +corpus is transformed away from. `gate:composition` guards the gate against a write-mode step +(formatter or eslint autofix) creeping back in. `pnpm mirrors:regenerate` regenerates from the +working tree's own dataset, offline; `pair update` installs the latest PUBLISHED knowledge base and +is not the remedy for local drift. See ADL [2026-07-31-pre-push-gate-is-check-only.md](.pair/adoption/decision-log/2026-07-31-pre-push-gate-is-check-only.md). ### Custom Gate Registry diff --git a/apps/website/content/docs/contributing/development-setup.mdx b/apps/website/content/docs/contributing/development-setup.mdx index df8bb5ea3..b3bb09bbe 100644 --- a/apps/website/content/docs/contributing/development-setup.mdx +++ b/apps/website/content/docs/contributing/development-setup.mdx @@ -82,12 +82,14 @@ the next diff. On a `format:check` failure, run `pnpm format` and commit the res instead of 1 means a formatter wrapper itself failed (a broken install, not drift) — read its output rather than running `pnpm format`. **Two-step remedy:** if `pnpm format` touched `packages/knowledge-hub/dataset/**`, re-sync the generated `.claude/skills/**` and -`.pair/knowledge/**` copies (`pair update`) in the same commit, or a mirror guard fails later in the -same gate — the dataset copy is inside format scope, its generated twin is not (`.claude/` and root -`.pair/` are not workspace members), and the mirror guards assert each twin equals the OUTPUT of the -real `pair update` transform — never the dataset source itself, which the corpus is transformed away -from. `gate:composition` guards the gate against a write-mode step (formatter or eslint autofix) -creeping back in. See ADL +`.pair/knowledge/**` copies (`pnpm mirrors:regenerate`) in the same commit, or a mirror guard fails +later in the same gate — the dataset copy is inside format scope, its generated twin is not +(`.claude/` and root `.pair/` are not workspace members), and the mirror guards assert each twin +equals the OUTPUT of the real `pair update` transform — never the dataset source itself, which the +corpus is transformed away from. `gate:composition` guards the gate against a write-mode step +(formatter or eslint autofix) creeping back in. `pnpm mirrors:regenerate` regenerates from the +working tree's own dataset, offline; `pair update` installs the latest PUBLISHED knowledge base and +is not the remedy for local drift. See ADL [the pre-push gate is check-only](https://github.com/foomakers/pair/blob/main/.pair/adoption/decision-log/2026-07-31-pre-push-gate-is-check-only.md). ### Gate registry @@ -113,6 +115,7 @@ Husky is configured to run checks automatically: | Permission errors | `chmod +x .husky/*` | | Pre-commit fails | Run the hook command manually (`pnpm ts:check`) to debug. | | Pre-push fails on formatting | Run `pnpm format`, commit the result, push again — the gate never formats for you. | +| Pre-push fails on a mirror guard | Run `pnpm mirrors:regenerate` and commit the regenerated files — never hand-edit a mirror. | ## Common commands @@ -121,6 +124,7 @@ pnpm install # Install all dependencies pnpm quality-gate # Full quality check (never writes — formatting is checked only) pnpm format # Apply formatting (prettier + markdownlint, write mode) pnpm format:check # Check formatting only — what the gate runs +pnpm mirrors:regenerate # Realign the generated mirrors with the LOCAL dataset (offline) pnpm build # Build all packages pnpm test # Run all tests pnpm lint # Lint all packages diff --git a/packages/dev-tools/src/quality-gates/pre-push-gate-composition.test.ts b/packages/dev-tools/src/quality-gates/pre-push-gate-composition.test.ts index f30b298d3..00530b9b9 100644 --- a/packages/dev-tools/src/quality-gates/pre-push-gate-composition.test.ts +++ b/packages/dev-tools/src/quality-gates/pre-push-gate-composition.test.ts @@ -9,6 +9,7 @@ import { ROOT_PACKAGE_JSON, GUARD_SCRIPT, REMEDY_SCRIPT, + MIRROR_REMEDY_SCRIPT, PRE_PUSH_REMEDY, } from './pre-push-gate-composition' @@ -151,6 +152,17 @@ describe('the pre-push gate never runs a write-mode step (#394)', () => { expect(PRE_PUSH_REMEDY).toContain('.claude/skills/**') expect(PRE_PUSH_REMEDY).toContain('skills:conformance') }) + + // #419. The re-sync step used to name `pair update`, which is an INSTALL command: + // it resolves and installs the latest PUBLISHED knowledge base. A contributor whose + // working tree drifted needs regeneration from the working tree's own dataset, so + // the advertised remedy depended on what had been released rather than on what was + // in front of them — the most plausible reason three recorded drifts were hand-ported + // instead of regenerated. + it('names the LOCAL regeneration command, not the published-KB install (#419)', () => { + expect(PRE_PUSH_REMEDY).toContain(`pnpm ${MIRROR_REMEDY_SCRIPT}`) + expect(PRE_PUSH_REMEDY).not.toContain('pair update') + }) }) // The gate no longer NAMES a formatter — it delegates to `pnpm format:check`. @@ -253,6 +265,7 @@ describe('checkRootGate reads the repo gate rather than trusting a copy (#394)', 'mdlint:fix': "turbo mdlint:fix && ./tools/markdownlint-config/bin/markdownlint-fix.sh '*.md'", [GUARD_SCRIPT]: 'pnpm --filter @pair/dev-tools pre-push-gate:check', + [MIRROR_REMEDY_SCRIPT]: './scripts/regenerate-mirrors.sh', ...scripts, }, }) @@ -393,6 +406,17 @@ describe('checkRootGate reads the repo gate rather than trusting a copy (#394)', expect(r.message).toContain(REMEDY_SCRIPT) }) + // Same dead-advice check, for the second command the remedy now names (#419). Both + // steps of a two-step remedy have to exist, or the half that does not is a loop back + // to `--no-verify` — which is the failure this guard was written to prevent for the first. + it('fails when the mirror-regeneration remedy does not exist', () => { + const scripts = JSON.parse(pkg({})) as { scripts: Record } + delete scripts.scripts[MIRROR_REMEDY_SCRIPT] + const r = checkRootGate(JSON.stringify(scripts)) + expect(r.ok).toBe(false) + expect(r.message).toContain(MIRROR_REMEDY_SCRIPT) + }) + it('fails loudly when there is no gate at all, rather than passing vacuously', () => { expect(checkRootGate(JSON.stringify({ scripts: {} })).ok).toBe(false) }) diff --git a/packages/dev-tools/src/quality-gates/pre-push-gate-composition.ts b/packages/dev-tools/src/quality-gates/pre-push-gate-composition.ts index d25e669b4..f7be1fd07 100644 --- a/packages/dev-tools/src/quality-gates/pre-push-gate-composition.ts +++ b/packages/dev-tools/src/quality-gates/pre-push-gate-composition.ts @@ -141,6 +141,18 @@ export const GUARD_SCRIPT = 'gate:composition' /** The root script the failure message points developers at. Must exist. */ export const REMEDY_SCRIPT = 'format' +/** + * The root script the remedy's SECOND step names. Must exist, for the same reason + * `REMEDY_SCRIPT` must: advice pointing at a script the repo does not have is dead. + * + * It is deliberately not `pair update` (#419). That command resolves and installs the + * latest PUBLISHED knowledge base — an install, not a realignment — so it makes the fix + * for a drifted working tree depend on what has been released. `mirrors:regenerate` + * regenerates from the working tree's own dataset, offline, and is therefore the only + * form of the remedy that is deterministic. + */ +export const MIRROR_REMEDY_SCRIPT = 'mirrors:regenerate' + /** * What a developer should run instead. Named in the failure so it is actionable. * @@ -152,13 +164,18 @@ export const REMEDY_SCRIPT = 'format' * into a red `skills:conformance` LATER IN THE SAME GATE. Advertising `pnpm format` * alone would hand the developer a loop back to `--no-verify`. Structural fix (one * format scope for both copies) is #414. + * + * Kept byte-identical (modulo the ADL link form) to the same paragraph in + * `DEVELOPMENT.md` and `apps/website/content/docs/contributing/development-setup.mdx`, + * per ADL 2026-07-31 — the three copies are hand-kept, so a `diff` of the paragraph is + * the only signal that they have diverged. */ export const PRE_PUSH_REMEDY = 'Formatting is checked, not applied, before a push: run `pnpm format` and commit the result. ' + 'Applying it here could not fix the commits being pushed anyway. ' + 'If `pnpm format` touched `packages/knowledge-hub/dataset/.skills/**`, re-sync the generated ' + - '`.claude/skills/**` copies (`pair update`) in the same commit, or `skills:conformance` fails ' + - 'later in this same gate on the mirror-equality guard.' + `\`.claude/skills/**\` copies (\`pnpm ${MIRROR_REMEDY_SCRIPT}\`) in the same commit, or ` + + '`skills:conformance` fails later in this same gate on the mirror-equality guard.' /** Bounds the transitive expansion, so a cyclic or deep script graph terminates. */ const MAX_EXPANSION_DEPTH = 10 @@ -222,7 +239,8 @@ function writeModeFailure(offenders: string[], expanded: string): GateCheckResul * 1. any write-mode step reachable from it (directly or via delegation), * 2. the gate having stopped RUNNING the guard itself (`pnpm gate:composition`) — * `referencesScript`, not a substring, so `echo gate:composition` does not count, - * 3. the remedy script named in the failure message having disappeared. + * 3. either remedy script named in the failure message having disappeared — + * `format` (step one) and `mirrors:regenerate` (step two, #419). * * Takes the file TEXT (not a path) so it is testable without a fixture on disk * and without a process exit. @@ -256,12 +274,14 @@ export function checkRootGate(packageJsonText: string): GateCheckResult { } } - if (typeof scripts[REMEDY_SCRIPT] !== 'string') { - return { - ok: false, - message: - `The gate tells developers to run \`pnpm ${REMEDY_SCRIPT}\`, but the root package.json has\n` + - `no \`${REMEDY_SCRIPT}\` script — the advice is dead. Restore it or update PRE_PUSH_REMEDY.`, + for (const remedy of [REMEDY_SCRIPT, MIRROR_REMEDY_SCRIPT]) { + if (typeof scripts[remedy] !== 'string') { + return { + ok: false, + message: + `The gate tells developers to run \`pnpm ${remedy}\`, but the root package.json has\n` + + `no \`${remedy}\` script — the advice is dead. Restore it or update PRE_PUSH_REMEDY.`, + } } } diff --git a/packages/knowledge-hub/src/tools/mirror-guard.test.ts b/packages/knowledge-hub/src/tools/mirror-guard.test.ts index 822f9798e..ccc1325ac 100644 --- a/packages/knowledge-hub/src/tools/mirror-guard.test.ts +++ b/packages/knowledge-hub/src/tools/mirror-guard.test.ts @@ -21,6 +21,7 @@ import { CLAUDE_MD_MIRROR, type GuardedMirror, } from './mirror-guard' +import { MIRROR_REGENERATE_COMMAND } from './skill-md-mirror' // packages/knowledge-hub/src/tools -> repo root const REPO_ROOT = join(__dirname, '..', '..', '..', '..') @@ -531,10 +532,24 @@ describe('assertMirrorMatches — failure paths and message (#393)', () => { const message = captureThrownMessage(() => assertKb(REL, expected, 'drifted\n')) expect(message).toContain(join(KB_MIRROR.mirrorRel, REL)) expect(message).toContain(join(KB_MIRROR.datasetRel, REL)) - expect(message).toContain("Regenerate with 'pair update'") + expect(message).toContain(`Regenerate with '${MIRROR_REGENERATE_COMMAND}'`) expect(message).toContain('never hand-edit the mirror') }) + // #419. The remedy used to be `pair update`, which INSTALLS the latest published + // knowledge base — so the fix for "your working tree drifted" depended on what had + // been released. Three of the seven recorded drifts were hand-ported instead, which + // is what a disproportionate remedy buys. The command named here regenerates from + // the working tree's own dataset and nothing else. + it('names the LOCAL regeneration command, never the published-KB install (#419)', () => { + const message = captureThrownMessage(() => assertKb(REL, expected, 'drifted\n')) + const remedyLine = message + .split('\n') + .find(line => line.startsWith('Regenerate with')) as string + expect(remedyLine).toContain(MIRROR_REGENERATE_COMMAND) + expect(remedyLine).not.toContain('pair update') + }) + it('names the paths of the registry it was given, not the KB by default', () => { const rel = 'agents/product-manager.agent.md' const message = captureThrownMessage(() => diff --git a/packages/knowledge-hub/src/tools/mirror-guard.ts b/packages/knowledge-hub/src/tools/mirror-guard.ts index 2dc9f113f..3ad0999fb 100644 --- a/packages/knowledge-hub/src/tools/mirror-guard.ts +++ b/packages/knowledge-hub/src/tools/mirror-guard.ts @@ -78,7 +78,7 @@ import { // `SkillMd` in its name (the context it was introduced in), `diffSkillMd` is a // generic compact line-diff. Reusing it keeps ONE drift-report format across // both mirror guards. -import { diffSkillMd } from './skill-md-mirror' +import { diffSkillMd, MIRROR_REGENERATE_COMMAND } from './skill-md-mirror' /** * One (dataset source → installed copy) pair this module guards: where the @@ -461,7 +461,7 @@ export function assertMirrorMatches( throw new Error( `Mirror ${mirrorPath} has drifted.\n` + `${compared}\n` + - `Regenerate with 'pair update' — never hand-edit the mirror.\n` + + `Regenerate with '${MIRROR_REGENERATE_COMMAND}' — never hand-edit the mirror.\n` + `--- expected (dataset -> real 'pair update' transform)\n` + `+++ actual (installed mirror on disk)\n` + `${diffSkillMd(expected, actual)}`, diff --git a/packages/knowledge-hub/src/tools/skill-md-mirror.test.ts b/packages/knowledge-hub/src/tools/skill-md-mirror.test.ts index b78b714fd..28f4b3128 100644 --- a/packages/knowledge-hub/src/tools/skill-md-mirror.test.ts +++ b/packages/knowledge-hub/src/tools/skill-md-mirror.test.ts @@ -12,6 +12,7 @@ import { diffSkillMd, SKILL_COPY_OPTS, skillCopySyncOptions, + MIRROR_REGENERATE_COMMAND, type DatasetTree, } from './skill-md-mirror' @@ -455,7 +456,7 @@ describe('drift-injection: guard fails on each drift class, passes when reconcil it('FAILS loudly when the root mirror is missing (AC4)', () => { expect(() => assertRootArtifactMatches(SKILL, expected, undefined)).toThrow( - /missing[\s\S]*pair update/, + new RegExp(`missing[\\s\\S]*${MIRROR_REGENERATE_COMMAND}`), ) }) }) @@ -572,11 +573,11 @@ describe('drift-injection on sub-docs and nested references (non-SKILL.md artifa expect(() => assertRootArtifactMatches(SUB, sub, drifted)).toThrow(/drifted/) }) - it('FAILS loudly when the sub-doc root copy is missing, pointing at pair update (AC4)', () => { + it('FAILS loudly when the sub-doc root copy is missing, pointing at the remedy (AC4)', () => { const message = captureThrownMessage(() => assertRootArtifactMatches(SUB, mirror.byDatasetPath.get(SUB)!, undefined), ) - expect(message).toMatch(/missing[\s\S]*pair update/) + expect(message).toMatch(new RegExp(`missing[\\s\\S]*${MIRROR_REGENERATE_COMMAND}`)) expect(message).toContain(SUB) expect(message).toContain(SUB_ROOT) }) diff --git a/packages/knowledge-hub/src/tools/skill-md-mirror.ts b/packages/knowledge-hub/src/tools/skill-md-mirror.ts index 50a29f675..c0c776c41 100644 --- a/packages/knowledge-hub/src/tools/skill-md-mirror.ts +++ b/packages/knowledge-hub/src/tools/skill-md-mirror.ts @@ -64,6 +64,22 @@ import { walkMarkdownFiles, } from '@pair/content-ops' +/** + * The command every mirror guard in this package names when it fails — stated ONCE, + * so the two guards cannot advertise different remedies for the same drift. + * + * It is deliberately NOT `pair update` (#419). That command resolves and INSTALLS the + * latest published knowledge base, so a contributor whose working tree drifted was being + * told to update the KB to whatever had been released — a different operation, and a + * non-deterministic one. `pnpm mirrors:regenerate` regenerates the mirrors from the + * working tree's own dataset, offline. Three of the seven drift incidents on record were + * hand-ported mirrors, which is what a remedy nobody believes in produces. + * + * References to the `pair update` TRANSFORM elsewhere in this module are a different + * claim (what the mirror is compared against) and stay as they are. + */ +export const MIRROR_REGENERATE_COMMAND = 'pnpm mirrors:regenerate' + /** The exact naming-transform options the `skills` registry uses in config.json. */ export const SKILL_COPY_OPTS = { flatten: true, flattenDepth: 2, prefix: 'pair' } as const @@ -420,13 +436,13 @@ export function assertRootArtifactMatches( if (actual === undefined) { throw new Error( `Root mirror missing for dataset artifact '${datasetArtifact}': ` + - `${rootPath} does not exist. Run 'pair update' to regenerate it.`, + `${rootPath} does not exist. Run '${MIRROR_REGENERATE_COMMAND}' to regenerate it.`, ) } if (actual !== expected) { throw new Error( `Root mirror for dataset artifact '${datasetArtifact}' has drifted from its dataset ` + - `source transform. Run 'pair update' to regenerate ${rootPath}.\n` + + `source transform. Run '${MIRROR_REGENERATE_COMMAND}' to regenerate ${rootPath}.\n` + `--- expected (dataset → real transform)\n` + `+++ actual (root mirror on disk)\n` + `${diffSkillMd(expected, actual)}`, From f0a184ee0f02fb1671562242780f9637685c6d83 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Tue, 1 Sep 2026 15:48:01 +0200 Subject: [PATCH 05/14] [#419] feat: publish-pr realigns the mirrors and commits them separately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Phase 1 becomes "Realign Generated Mirrors, then Quality Gate": the step runs BEFORE the gate, because drift is what turns the gate red and a red gate HALTs — after it the remedy would be unreachable in its only case - the command is read from adoption (`mirror-realign-command`), never named in the skill: publish-pr ships to every adopter, `mirrors:regenerate` is ours - commits only the generated paths, alone, named a regeneration; no-op is silent (no commit, no output row); non-zero exit HALTs before any PR side effect - way-of-working declares the key; ADL records the ordering + indirection - conformance guard over the prose + whole-file mirror reproducibility - Task: T-4, T-5 — wire the step into publish-pr and guard it Refs: #419 --- .../pair-capability-publish-pr/SKILL.md | 29 ++-- ...ish-pr-realigns-mirrors-before-the-gate.md | 83 +++++++++++ .pair/adoption/tech/way-of-working.md | 1 + .pair/llms.txt | 1 + .../content/docs/reference/skills-catalog.mdx | 2 +- .../.skills/capability/publish-pr/SKILL.md | 29 ++-- .../conformance/mirror-realignment.test.ts | 136 ++++++++++++++++++ 7 files changed, 264 insertions(+), 17 deletions(-) create mode 100644 .pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md create mode 100644 packages/knowledge-hub/src/conformance/mirror-realignment.test.ts diff --git a/.claude/skills/pair-capability-publish-pr/SKILL.md b/.claude/skills/pair-capability-publish-pr/SKILL.md index 62a60c7f1..f5748779c 100644 --- a/.claude/skills/pair-capability-publish-pr/SKILL.md +++ b/.claude/skills/pair-capability-publish-pr/SKILL.md @@ -1,13 +1,13 @@ --- name: pair-capability-publish-pr -description: "Publishes a completed story branch as a pull request: runs the quality gate, creates or updates ONE PR from the pr-template (conditional sections filled only when pertinent), copies the story's classification tags, marks it ready-for-review, updates the board state, then enters the PR state flow — registers the required `pair-review` check as pending (merge blocked from t0) and dispatches the review to a clean-context subagent. Standalone — driven by a handoff/checkpoint, not by /pair-process-implement having run in the same session. Composed by /implement's closing phase (Step 3.3); reused by hotfix and automation loops. Composes /pair-capability-verify-quality, /pair-capability-checkpoint, /pair-capability-write-issue." -version: 0.7.1 +description: "Publishes a completed story branch as a pull request: realigns the generated mirrors from the local dataset (committing them separately when they drifted), runs the quality gate, creates or updates ONE PR from the pr-template (conditional sections filled only when pertinent), copies the story's classification tags, marks it ready-for-review, updates the board state, then enters the PR state flow — registers the required `pair-review` check as pending (merge blocked from t0) and dispatches the review to a clean-context subagent. Standalone — driven by a handoff/checkpoint, not by /pair-process-implement having run in the same session. Composed by /implement's closing phase (Step 3.3); reused by hotfix and automation loops. Composes /pair-capability-verify-quality, /pair-capability-checkpoint, /pair-capability-write-issue." +version: 0.8.0 author: Foomakers --- # /pair-capability-publish-pr — Publish a Story Branch as a PR -Take a completed story branch to a review-ready pull request in one standalone step: **gate → compose PR → propagate tags → ready-for-review → board state → review dispatch**. Reliable on a clean context (input is a handoff document, not session memory) and reusable outside `/pair-process-implement` — hotfix branches and automation loops (#212, G10) invoke it directly. +Take a completed story branch to a review-ready pull request in one standalone step: **realign mirrors → gate → compose PR → propagate tags → ready-for-review → board state → review dispatch**. Reliable on a clean context (input is a handoff document, not session memory) and reusable outside `/pair-process-implement` — hotfix branches and automation loops (#212, G10) invoke it directly. **One PR per story:** the story lands on ONE branch with ONE PR. If a PR already exists for the branch, this skill UPDATES it — it never opens a second PR for the same story. @@ -38,6 +38,7 @@ Two sibling sections cover git concerns and the split is deliberate: **`## Merge - **[way-of-working.md](../../../.pair/adoption/tech/way-of-working.md) → `## Merge Strategy`** — the same section the merge consumers read (`/pair-process-review` Phase 6): `Method` (`squash` | `merge` | `rebase`, **default `squash`**) and the `Commit format` ([commit template](../../../.pair/knowledge/guidelines/collaboration/templates/commit-template.md)). Recorded on the PR as the intended merge strategy; **squash happens at merge, never here**. `branch-format` (to parse the branch id) comes from the [branch template](../../../.pair/knowledge/guidelines/collaboration/templates/branch-template.md). - **way-of-working.md → `## Git Workflow`** — `code-host` (the tool owning branches/PRs) and `base-branch` (default `main`; **a `base-branch` declared under `## Merge Strategy`, where this skill's ≤ 0.4.1 versions documented it, is still honored** — the resolution order is single-sourced in the convention's **`base-branch` resolution** — the same order `/pair-process-implement` applies, so the two readers cannot disagree on the target branch). **`code-host` absent ⇒ code host = PM tool** (single-tool; the zero-configuration default, not a degradation), and the same tool named in both places is treated exactly as omitted. Resolution, the PM↔code-host routing table, and the cross-linking convention live in one place: [way-of-working / PM-tool + code-host resolution](../../../.pair/knowledge/guidelines/technical-standards/ai-development/skill-conventions/way-of-working-pm-resolution.md) — this skill states only which side each operation is on. +- **way-of-working.md → `## Quality Gates` → `mirror-realign-command`** — the project's single writer for its generated mirrors, run in Phase 1 before the gate. Declared as a command the project owns (e.g. a root script), because which artifacts a repo generates, and from what, is the repo's business and not this skill's — a hardcoded command would emit a step most projects cannot run. **Absent ⇒ the realignment step is skipped entirely** (zero-configuration default, not a degradation). The command must be a *writer*, local and idempotent: the guards that detect drift are the checkers, this is the one thing that fixes it. - **way-of-working.md → `## State Mapping`** — board-column ↔ canonical-macrostate mapping (see [canonical-states.md](../../../.pair/knowledge/guidelines/collaboration/project-management-tool/canonical-states.md)). Omitted ⇒ canonical names assumed. - **way-of-working.md → `## Assignment`** — the fallback when no `$assignee` is passed. This skill writes the **code-host** side, so it reads **`code-host-assignee` first and `default-assignee` second** — the split-configuration key exists because the same human often carries two identifiers, and resolving the PM-tool login against the code host is how a PR ends up rejected and published unassigned. **One rule, two callers**: the schema and the cascade live once, in the [resolution convention](../../../.pair/knowledge/guidelines/technical-standards/ai-development/skill-conventions/way-of-working-pm-resolution.md), and both this skill (the PR, a **code-host** write) and `/pair-capability-write-issue` (the item, a **PM-tool** write) read them from there rather than each defining their own. Both omitted ⇒ no default; the PR is published unassigned with a warning. @@ -54,12 +55,21 @@ Each phase follows the **check → skip → act → verify** pattern. Phases run 3. **Verify**: Story ID resolved AND the branch is known. If the story id cannot be resolved from handoff or branch → **HALT**: "Cannot resolve story id — pass `$story` explicitly." (edge case). 4. **Act**: If no handoff document exists, gather minimal state directly: branch (`git branch --show-current`), commits since base, and the story's ACs/tags from the PM tool. Note in the output that no handoff was found. -### Phase 1: Quality Gate (BLOCKING) +### Phase 1: Realign Generated Mirrors, then Quality Gate (BLOCKING) -1. **Act**: Compose `/pair-capability-verify-quality` with `$scope` (default `all`). This is a local pre-flight, not a replacement for CI (CI stays authoritative, #210). -2. **Check**: Did every required gate pass? -3. **Skip**: If all gates pass, proceed to Phase 2. -4. **Act**: If any required gate fails → **HALT** before creating or updating the PR. Report each failing check (gate name + first failing detail). No PR side effects occur on a red gate. +The realignment runs **before** the gate, and the order is load-bearing in both directions: mirror drift is precisely what turns the gate red, so a step placed after it would be unreachable in the only case it exists for — and a gate that ran first would have judged a tree the PR no longer contains. It is also the **only** write this skill makes to the branch. + +1. **Check**: Does the adoption declare a `mirror-realign-command`? +2. **Skip**: If it does not, go to step 5. A project with no generated mirrors has nothing to realign — the zero-configuration default, not a degradation, and nothing is reported. +3. **Act**: Run the declared command. It regenerates the mirrors from the working tree's **local** dataset — never a published release — and is idempotent. A **non-zero exit → HALT** before any PR side effect, reporting the command's own reason verbatim: it never reports success over a no-op, so a failure here means nothing was written and the drift is still there. +4. **Check → Act**: Read `git status --porcelain` for the generated paths the command owns. + - **No change** → a no-op stays **silent**: no commit, and no output row (the `Mirrors:` row is emitted only when a commit was made). Continue to step 5. + - **Changed** → stage **only** those paths — never `git add -A`: unstaged authored changes belong to the contributor and must survive the run untouched, and this skill must not commit them — and commit them **alone**, as their own commit, never mixed into a feature commit. Name it as a *regeneration* (e.g. `chore: regenerate mirrors from local dataset`), never a "fix": an overwritten hand-edit was restored to what the dataset generates, not repaired. Drift in a file this branch never touched is committed here too, and **said so in the output** — surprising, but better than pushing knowingly stale generated output, and the separate commit keeps even a dataset-wide regeneration readable next to the authored work. + - **Verify**: `git log` shows exactly one new commit, and `git status` still shows every pre-existing unstaged authored change, untouched. +5. **Act**: Compose `/pair-capability-verify-quality` with `$scope` (default `all`). This is a local pre-flight, not a replacement for CI (CI stays authoritative, #210). +6. **Check**: Did every required gate pass? +7. **Skip**: If all gates pass, proceed to Phase 2. +8. **Act**: If any required gate fails → **HALT** before creating or updating the PR. Report each failing check (gate name + first failing detail). No PR side effects occur on a red gate. ### Phase 2: Resolve Merge Strategy & Prepare Base @@ -154,6 +164,7 @@ The PR is ready; it must now be **under review and mechanically blocked** — se PUBLISH-PR REPORT: ├── Story: [#ID: Title] ├── Handoff: [.pair/working/checkpoints/.md | none — state gathered from branch+story] +├── Mirrors: [regenerated — commit , N file(s) — omit this row entirely when nothing was committed] ├── Gate: [PASS | HALTED — N gates failing] ├── Base: [base-branch — squash on merge: yes|no] ├── PR: [#PR-number — URL — Created | Updated — ready-for-review confirmed by read | ready-for-review not confirmed — finding] @@ -184,6 +195,7 @@ When invoked **independently** (hotfix, automation loop #212): ## HALT Conditions - **Story id unresolvable** from handoff or branch (Phase 0). +- **`mirror-realign-command` exits non-zero** (Phase 1) — report its own reason verbatim; nothing was regenerated and no PR side effects occur. Same shape as the gate-red HALT it precedes. - **Quality gate red** (Phase 1) — report failing checks; no PR side effects. - **pr-template not found** (Phase 3) — cannot compose a PR without it. - **Code host unreachable or unauthenticated** for create/update (Phase 4) — report with a setup pointer and stop; nothing partial is left ready. **PM-side work already done is not rolled back** (the board write is the PM tool's own state); re-invocation is idempotent and resumes at the code-host step. @@ -202,6 +214,7 @@ See [graceful degradation](../../../.pair/knowledge/guidelines/technical-standar - **A write the host reports as applied but a read does not show** (a tag, the assignee, ready-for-review, the `pair-review` status, the `pr-state:*` label — each read back where it is written: tags and assignee in Phase 4 step 3, ready-for-review in step 6, the check status and the state label in Phase 5 steps 3 and 4): report it as a finding on the corresponding output row and continue. The PR exists and is what matters; what must never happen is reporting the unapplied write as done. - **No board state maps to `Review`** (a minimal board, D4 — a project that reviews on the PR and merges straight to `Done`): **write no state field** in step 7 — membership is still established and confirmed — and report `Board: n-a — no Review state on this board`. The zero-configuration documented skip, **not** an error and not a degraded publish — the readiness signal is the PR itself. - **The direct board write cannot complete** (membership unconfirmable after the add and its one retry — the item writer's Step 7b; or a macrostate no board state can express — its Step 6): report the blocker verbatim on the `Board:` row as `not updated — ` and continue. The reasons are the item writer's, the write is **this skill's own** — it applies those beats by reference, it does not compose them. The PR is published and ready-for-review; a board write that did not happen is **reported, never absorbed into a green publish**, and this skill never HALTs on it (the code-host artifact is the work). +- **No `mirror-realign-command` declared**: skip the realignment step and report nothing (Phase 1) — the zero-configuration default for a project with no generated mirrors, **not** a degradation. Never substitute a guessed command, and never a knowledge-base *install* command: installing a published release is a different operation from realigning a working tree, and using one for the other makes the fix depend on what has been published. - **`/pair-capability-checkpoint` not installed**: gather state from branch + story directly (Phase 0). - **`/pair-capability-write-issue` not installed**: only the **comment-mode back-link** (Phase 4 step 5) is affected — write it directly per the PM tool's implementation guide **and read the item's comments back to confirm it**, or warn with the manual-link instruction. A direct post the read does not show is reported `back-link failed — manual link needed`, **never as posted**: losing the composition must not lose the confirming read with it, or the degraded path becomes the one path that claims a write it never made. **The board write in step 7 is unaffected and still runs in full** (membership → confirming read → state field): it is direct, never a composition, so a missing item writer can never leave the story off the board. Skipping the board write here would re-create #384/#372 — green, ready-for-review, and invisible. - **Nested subagent dispatch unavailable** (Phase 5 — the common case: this skill is itself running in `/pair-process-implement`'s handoff subagent and the harness forbids a second level): return `Review: review-dispatch-required — /pair-process-review $pr=` and let the **caller** dispatch (`/pair-process-implement` Step 3.3). This is the primary path when nested, not a degradation — the review still runs, one frame up, on a clean context. diff --git a/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md b/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md new file mode 100644 index 000000000..edb961fc0 --- /dev/null +++ b/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md @@ -0,0 +1,83 @@ +# Decision: `/pair-capability-publish-pr` realigns the mirrors BEFORE its gate, through an adoption-declared command + +## Date + +2026-09-01 + +## Status + +Active + +## Category + +Process Decision + +## Context + +Story #419 replaces `pair update` with a dedicated, local, deterministic realignment command +(`pnpm mirrors:regenerate`) as the remedy every mirror-equality guard names, and puts the write at +the point where a commit is still possible: pull-request creation. Two things had to be decided +before the step could be written into `/pair-capability-publish-pr`. + +**1. Where in the skill's phase order it runs.** The story's card proposed Phase 2 ("Resolve Merge +Strategy & Prepare Base"), *after* Phase 1's quality gate. That ordering does not survive contact +with the failure it exists for: mirror drift is exactly what turns Phase 1 red, and a red Phase 1 +**HALTs** the skill. The remedy would therefore be unreachable in the only case it was added for. +The reverse ordering has a second, independent justification: a gate that ran before the +regeneration judged a tree the PR no longer contains. + +**2. Whether the command is named in the skill.** `/pair-capability-publish-pr` ships to every project that installs +the pair corpus. `pnpm mirrors:regenerate` is a script of *this* repository — a skill that hardcoded +it would emit a step no adopter can run, on a repo with no mirrors to realign. + +## Decision + +**The realignment runs first inside Phase 1, ahead of `/pair-capability-verify-quality`, and the command it runs is +read from the adoption, never named in the skill.** + +- Phase 1 is renamed **"Realign Generated Mirrors, then Quality Gate (BLOCKING)"**. The realignment + is steps 1–4; the gate composition is step 5 onward, unchanged. +- The command comes from `way-of-working.md` → `## Quality Gates` → **`mirror-realign-command`**. + **Absent ⇒ the whole step is skipped** — the zero-configuration default, not a degradation: a + project with no generated mirrors has nothing to realign and must not be told to run a script it + does not have. +- The step commits **only** the generated paths, as **its own commit**, and only when the command + produced a diff. A no-op is **silent**: no commit, and no output row (the `Mirrors:` row is + emitted only when a commit was made). +- A non-zero exit from the command **HALTs** before any PR side effect — the same shape as the + gate-red HALT it now precedes. +- This project declares `mirror-realign-command: pnpm mirrors:regenerate`. + +## Alternatives Considered + +- **Step in Phase 2, after the push (the card's proposal)**: unreachable on drift, because Phase 1 + HALTs first; and it would leave the gate's verdict describing a tree the PR does not contain. +- **Hardcode `pnpm mirrors:regenerate` in the skill**: makes a repo-specific script part of a + distributed corpus. Every adopter would get a step that fails or does nothing. +- **Let the gate apply the fix itself**: declined already, by ADL + [2026-07-31-pre-push-gate-is-check-only.md](./2026-07-31-pre-push-gate-is-check-only.md) — the + gate reports, it never writes. This decision keeps that rule intact: the writer is an explicit, + separately-committed step, not a hook side effect. +- **Fold the realignment into `pnpm format`**: the other option that ADL left open, and declined + with it — formatting must stay formatting, and must not reach outside format scope. + +## Consequences + +- `/pair-capability-publish-pr` commits on the contributor's behalf. That is acceptable **only** under the + constraints above: generated content, its own commit, named as a *regeneration* (never a "fix" — + an overwritten hand-edit was restored, not repaired), and never `git add -A`, so unstaged authored + changes in the working tree survive untouched. +- Drift in a file the branch never touched is committed here too, and reported. Surprising, but + pushing knowingly stale generated output is worse. +- Running `/pair-capability-publish-pr` twice commits nothing the second time — the command is idempotent. +- A project that adopts the key inherits the behaviour; one that does not sees no change at all. + +## Adoption Impact + +- `adoption/tech/way-of-working.md` → `## Quality Gates`: declare `mirror-realign-command` + (`pnpm mirrors:regenerate`) and state the absent-⇒-skipped default. +- `packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md`: Phase 1 renamed and + extended, `Adoption Inputs` gains the key, `Output Format` gains the conditional `Mirrors:` row, + `HALT Conditions` gains the command-failed HALT, `Graceful Degradation` gains the absent-key skip. + The generated `.claude/skills/pair-capability-publish-pr/**` mirror is regenerated, never + hand-ported. diff --git a/.pair/adoption/tech/way-of-working.md b/.pair/adoption/tech/way-of-working.md index b74941683..9520263ae 100644 --- a/.pair/adoption/tech/way-of-working.md +++ b/.pair/adoption/tech/way-of-working.md @@ -85,6 +85,7 @@ Resolution order, the split-tool routing and why the fallback is never the authe - **Review enforcement**: `disabled` (default) — the pair review **runs and publishes its verdict**, but nothing it says blocks a merge: `pair-review` and `pair-explicit-approval` are not required status checks, and the 🔴 explicit-approval rule is advisory. Set to `enabled` to make them required and the rule binding, per [pr-states.md](../../knowledge/guidelines/collaboration/project-management-tool/pr-states.md); `/pair-capability-setup-gates` reads this flag before touching branch protection, and `/pair-process-bootstrap` asks for it when no decision exists. Disabled is the default deliberately: a review that blocks by default turns a first install into a repository nobody can merge into — on a single-maintainer repo the 🔴 non-author approval is unobtainable outright. The tier requirements themselves (reviewer count, SLA, checklist depth, whether 🔴 needs explicit approval) are redefinable in this file; that the review **runs** is not. - **Coverage guardrail**: `enabled` — pair dogfoods its own capability: the [`Coverage guardrail` step](../../../.github/workflows/ci.yml) in CI sources [`coverage-gate.sh`](../../knowledge/assets/coverage-gate.sh), extracts the line-coverage % from each package's istanbul `coverage-summary.json`, and blocks a PR whose coverage drops below the human-committed baseline in [`tech/coverage-baseline.md`](./coverage-baseline.md) (maintaining/improving passes — not an absolute wall). The framework **default remains `disabled`** (the dataset template ships off); this line is pair's project-level opt-in only. See [coverage guardrail](../../knowledge/guidelines/infrastructure/cicd-strategy/tier-aware-pipeline.md#coverage-guardrail-opt-in-regression-gate-consumed-by-this-pipeline) + [config format](../../knowledge/assets/coverage-config-example.md); `/pair-capability-setup-gates` reads this flag before generating the pipeline. **Coverage baseline commit-back**: `disabled` — the separate, nested opt-in ratchet (#372, framework default also `disabled`): when `enabled`, a **push to the base branch** (never a PR run, never a fork) proposes a raised `baseline.` as a **bot pull request** from `chore/coverage-baseline-ratchet`, never a push to `main`, and requires a repo-scoped `COVERAGE_RATCHET_TOKEN` (`contents: write` + `pull requests: write`, no protection bypass) — without it the step warns and the gate's verdict is unchanged. It stays `disabled` here until story #234's branch protection is applied and that secret is provisioned (ADR-018 lands with that story, so it is not linked from here yet); see ADL [2026-07-30-coverage-ratchet-pr-not-push.md](../decision-log/2026-07-30-coverage-ratchet-pr-not-push.md). The step that runs it is the **shipped** KB asset `node .pair/knowledge/assets/coverage-ratchet.cjs` (ADR-023) — the same one an adopter's generated pipeline invokes, so this flag being `enabled` means the same thing here as anywhere else. - **Pair review required checks**: `pair-review` + `pair-explicit-approval` are the required status checks that make the judgment review unskippable (R5.7) and enforce the 🔴 explicit-human-approval rule (D10) — see [pr-states.md](../../knowledge/guidelines/collaboration/project-management-tool/pr-states.md) and [ADR-018](adr/adr-018-pr-state-flow-required-checks.md). Status on this repo: **not yet applied** — writing branch protection needs admin scope, so it is a deliberate human step; until applied, enforcement here is advisory (the documented degraded mode). **Ordering constraint** (applies in this order, or every merge stops): 1. provision the `pr-state:*` labels + add the `pair-explicit-approval` workflow (neither needs admin scope — this repo has not added the workflow yet, so the context does not report today); 2. confirm on a real PR that `pair-review` and `pair-explicit-approval` both report on the head commit, **and** that the approval context re-reports on that same head SHA after a review submission; 3. only then `PUT` the branch protection, keeping `enforce_admins` off until one PR has merged through it. The whole sequence (including the merge-block outcomes per tier) was executed on a throwaway repository — see `github-implementation.md` § "Verified on a throwaway repository" — so what remains here is applying it, not discovering whether it works. **This repo is single-maintainer**, so a 🔴 PR cannot satisfy `pair-explicit-approval` (GitHub rejects a self-approval): a second human reviewer account is a prerequisite for making that context required here — otherwise leave it out of the required list and keep the 🔴 rule advisory. The solo-maintainer alternative (a verified human approval token instead of a second account) is tracked as [#398](https://github.com/foomakers/pair/issues/398). **When the protection is written here, use the `checks` form with `app_id` pinned** for `pair-explicit-approval` (an unpinned status context is satisfiable by any push-access token, including the agent's); `pair-review` stays unpinned and is an anti-accident control, not an authorization control — see `github-implementation.md` § "What each context proves". +- **`mirror-realign-command`**: `pnpm mirrors:regenerate` — the single, local, deterministic writer that realigns the generated mirrors (`.claude/**`, root `.pair/**`, `AGENTS.md`/`CLAUDE.md`, `.github/**`) with `packages/knowledge-hub/dataset`. It wraps the CLI's existing local-source path (`pair update --source --offline`) and adds no generation logic; it has **no check mode** — the mirror guards (`skills:conformance`) are the checker, this is the only writer. It is what `PRE_PUSH_REMEDY`, both mirror guards, `DEVELOPMENT.md` and its docs-site twin name, and what `/pair-capability-publish-pr` runs (Phase 1, before the gate) and commits separately when it produces a diff. Deliberately **not** `pair update`, which installs the latest PUBLISHED knowledge base and would make a local fix depend on what has been released. Absent this key, `/pair-capability-publish-pr` skips its realignment step entirely — the zero-configuration default, not a degradation. See ADL [2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md](../decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md) and story [#419](https://github.com/foomakers/pair/issues/419). - **Gate & tooling code:** a gate's logic lives in a tested module in its owning package (white-box unit tests); scripts/CLIs are thin entrypoints and a root gate delegates (`pnpm --filter `). Scripts are never unit-tested — CLI-level checks go to smoke tests. See ADL [2026-07-13-gate-tooling-code-in-tested-modules.md](../decision-log/2026-07-13-gate-tooling-code-in-tested-modules.md). Gate/tooling packages are organized by bounded context, not one package per tool family — a new tool family sharing an existing package's bounded context is a new folder there, not a new package. See [ADR-014](adr/adr-014-tool-package-boundary-by-bounded-context.md). - **Conformance tests** (`packages/knowledge-hub/src/conformance/`): one test file per target KB artifact (a `SKILL.md`, guideline, or template), not per introducing story — a new story extends the matching file's `describe` block instead of adding a new story-named file. See ADL [2026-07-18-conformance-test-per-file-not-per-story.md](../decision-log/2026-07-18-conformance-test-per-file-not-per-story.md). - **Monorepo tooling gotchas** (e.g. `pnpm --filter` bypassing turbo's `dependsOn` graph on a fresh checkout): documented once, centrally, in `DEVELOPMENT.md`'s `Turbo Caching` section — affected packages' READMEs carry only a short pointer, not a full copy. See ADL [2026-07-18-workspace-gotcha-doc-placement.md](../decision-log/2026-07-18-workspace-gotcha-doc-placement.md). diff --git a/.pair/llms.txt b/.pair/llms.txt index fc706dd27..0bab46c61 100644 --- a/.pair/llms.txt +++ b/.pair/llms.txt @@ -127,6 +127,7 @@ - [Decision: tier 1's `$approval` posture is unconditional, and tier 1 has no declaring composition site yet](.pair/adoption/decision-log/2026-08-28-tier1-approval-posture-is-unconditional-and-has-no-declaring-composition-site-yet.md) - [Decision: Review re-checks use an immutable baseline and prove provisioned artifacts](.pair/adoption/decision-log/2026-08-31-review-baseline-and-provisioned-artifact-contract.md) - [Decision: External boundary proof prevents false equivalence](.pair/adoption/decision-log/2026-09-01-external-boundary-proof-prevents-false-equivalence.md) +- [Decision: `/pair-capability-publish-pr` realigns the mirrors BEFORE its gate, through an adoption-declared command](.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md) - [Decision: Review contract inventory prevents serial findings](.pair/adoption/decision-log/2026-09-01-review-contract-inventory-prevents-serial-findings.md) ## How-To Guides diff --git a/apps/website/content/docs/reference/skills-catalog.mdx b/apps/website/content/docs/reference/skills-catalog.mdx index e86b8ea16..fe68eea15 100644 --- a/apps/website/content/docs/reference/skills-catalog.mdx +++ b/apps/website/content/docs/reference/skills-catalog.mdx @@ -125,7 +125,7 @@ All `analyze-*` skills **analyze and report**: they propose no adoption decision | Skill | Command | Description | | ----- | ------- | ----------- | | **checkpoint** | `/pair-capability-checkpoint` | Writes and resumes a self-contained progress checkpoint (story, branch, tasks done, decisions, remaining todos) so work survives a context reset. | -| **publish-pr** | `/pair-capability-publish-pr` | Publishes a completed story branch as a pull request: runs the quality gate, creates or updates ONE PR from the pr-template (conditional sections filled only when pertinent), copies the story's classification tags, marks it ready-for-review, updates the board state, then enters the PR state flow — registers the required `pair-review` check as pending (merge blocked from t0) and dispatches the review to a clean-context subagent. | +| **publish-pr** | `/pair-capability-publish-pr` | Publishes a completed story branch as a pull request: realigns the generated mirrors from the local dataset (committing them separately when they drifted), runs the quality gate, creates or updates ONE PR from the pr-template (conditional sections filled only when pertinent), copies the story's classification tags, marks it ready-for-review, updates the board state, then enters the PR state flow — registers the required `pair-review` check as pending (merge blocked from t0) and dispatches the review to a clean-context subagent. | ## Skill Properties diff --git a/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md b/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md index ffa03194f..11be438fd 100644 --- a/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md +++ b/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md @@ -1,13 +1,13 @@ --- name: publish-pr -description: "Publishes a completed story branch as a pull request: runs the quality gate, creates or updates ONE PR from the pr-template (conditional sections filled only when pertinent), copies the story's classification tags, marks it ready-for-review, updates the board state, then enters the PR state flow — registers the required `pair-review` check as pending (merge blocked from t0) and dispatches the review to a clean-context subagent. Standalone — driven by a handoff/checkpoint, not by /implement having run in the same session. Composed by /implement's closing phase (Step 3.3); reused by hotfix and automation loops. Composes /verify-quality, /checkpoint, /write-issue." -version: 0.7.1 +description: "Publishes a completed story branch as a pull request: realigns the generated mirrors from the local dataset (committing them separately when they drifted), runs the quality gate, creates or updates ONE PR from the pr-template (conditional sections filled only when pertinent), copies the story's classification tags, marks it ready-for-review, updates the board state, then enters the PR state flow — registers the required `pair-review` check as pending (merge blocked from t0) and dispatches the review to a clean-context subagent. Standalone — driven by a handoff/checkpoint, not by /implement having run in the same session. Composed by /implement's closing phase (Step 3.3); reused by hotfix and automation loops. Composes /verify-quality, /checkpoint, /write-issue." +version: 0.8.0 author: Foomakers --- # /publish-pr — Publish a Story Branch as a PR -Take a completed story branch to a review-ready pull request in one standalone step: **gate → compose PR → propagate tags → ready-for-review → board state → review dispatch**. Reliable on a clean context (input is a handoff document, not session memory) and reusable outside `/implement` — hotfix branches and automation loops (#212, G10) invoke it directly. +Take a completed story branch to a review-ready pull request in one standalone step: **realign mirrors → gate → compose PR → propagate tags → ready-for-review → board state → review dispatch**. Reliable on a clean context (input is a handoff document, not session memory) and reusable outside `/implement` — hotfix branches and automation loops (#212, G10) invoke it directly. **One PR per story:** the story lands on ONE branch with ONE PR. If a PR already exists for the branch, this skill UPDATES it — it never opens a second PR for the same story. @@ -38,6 +38,7 @@ Two sibling sections cover git concerns and the split is deliberate: **`## Merge - **[way-of-working.md](../../../.pair/adoption/tech/way-of-working.md) → `## Merge Strategy`** — the same section the merge consumers read (`/review` Phase 6): `Method` (`squash` | `merge` | `rebase`, **default `squash`**) and the `Commit format` ([commit template](../../../.pair/knowledge/guidelines/collaboration/templates/commit-template.md)). Recorded on the PR as the intended merge strategy; **squash happens at merge, never here**. `branch-format` (to parse the branch id) comes from the [branch template](../../../.pair/knowledge/guidelines/collaboration/templates/branch-template.md). - **way-of-working.md → `## Git Workflow`** — `code-host` (the tool owning branches/PRs) and `base-branch` (default `main`; **a `base-branch` declared under `## Merge Strategy`, where this skill's ≤ 0.4.1 versions documented it, is still honored** — the resolution order is single-sourced in the convention's **`base-branch` resolution** — the same order `/implement` applies, so the two readers cannot disagree on the target branch). **`code-host` absent ⇒ code host = PM tool** (single-tool; the zero-configuration default, not a degradation), and the same tool named in both places is treated exactly as omitted. Resolution, the PM↔code-host routing table, and the cross-linking convention live in one place: [way-of-working / PM-tool + code-host resolution](../../../.pair/knowledge/guidelines/technical-standards/ai-development/skill-conventions/way-of-working-pm-resolution.md) — this skill states only which side each operation is on. +- **way-of-working.md → `## Quality Gates` → `mirror-realign-command`** — the project's single writer for its generated mirrors, run in Phase 1 before the gate. Declared as a command the project owns (e.g. a root script), because which artifacts a repo generates, and from what, is the repo's business and not this skill's — a hardcoded command would emit a step most projects cannot run. **Absent ⇒ the realignment step is skipped entirely** (zero-configuration default, not a degradation). The command must be a *writer*, local and idempotent: the guards that detect drift are the checkers, this is the one thing that fixes it. - **way-of-working.md → `## State Mapping`** — board-column ↔ canonical-macrostate mapping (see [canonical-states.md](../../../.pair/knowledge/guidelines/collaboration/project-management-tool/canonical-states.md)). Omitted ⇒ canonical names assumed. - **way-of-working.md → `## Assignment`** — the fallback when no `$assignee` is passed. This skill writes the **code-host** side, so it reads **`code-host-assignee` first and `default-assignee` second** — the split-configuration key exists because the same human often carries two identifiers, and resolving the PM-tool login against the code host is how a PR ends up rejected and published unassigned. **One rule, two callers**: the schema and the cascade live once, in the [resolution convention](../../../.pair/knowledge/guidelines/technical-standards/ai-development/skill-conventions/way-of-working-pm-resolution.md), and both this skill (the PR, a **code-host** write) and `/write-issue` (the item, a **PM-tool** write) read them from there rather than each defining their own. Both omitted ⇒ no default; the PR is published unassigned with a warning. @@ -54,12 +55,21 @@ Each phase follows the **check → skip → act → verify** pattern. Phases run 3. **Verify**: Story ID resolved AND the branch is known. If the story id cannot be resolved from handoff or branch → **HALT**: "Cannot resolve story id — pass `$story` explicitly." (edge case). 4. **Act**: If no handoff document exists, gather minimal state directly: branch (`git branch --show-current`), commits since base, and the story's ACs/tags from the PM tool. Note in the output that no handoff was found. -### Phase 1: Quality Gate (BLOCKING) +### Phase 1: Realign Generated Mirrors, then Quality Gate (BLOCKING) -1. **Act**: Compose `/verify-quality` with `$scope` (default `all`). This is a local pre-flight, not a replacement for CI (CI stays authoritative, #210). -2. **Check**: Did every required gate pass? -3. **Skip**: If all gates pass, proceed to Phase 2. -4. **Act**: If any required gate fails → **HALT** before creating or updating the PR. Report each failing check (gate name + first failing detail). No PR side effects occur on a red gate. +The realignment runs **before** the gate, and the order is load-bearing in both directions: mirror drift is precisely what turns the gate red, so a step placed after it would be unreachable in the only case it exists for — and a gate that ran first would have judged a tree the PR no longer contains. It is also the **only** write this skill makes to the branch. + +1. **Check**: Does the adoption declare a `mirror-realign-command`? +2. **Skip**: If it does not, go to step 5. A project with no generated mirrors has nothing to realign — the zero-configuration default, not a degradation, and nothing is reported. +3. **Act**: Run the declared command. It regenerates the mirrors from the working tree's **local** dataset — never a published release — and is idempotent. A **non-zero exit → HALT** before any PR side effect, reporting the command's own reason verbatim: it never reports success over a no-op, so a failure here means nothing was written and the drift is still there. +4. **Check → Act**: Read `git status --porcelain` for the generated paths the command owns. + - **No change** → a no-op stays **silent**: no commit, and no output row (the `Mirrors:` row is emitted only when a commit was made). Continue to step 5. + - **Changed** → stage **only** those paths — never `git add -A`: unstaged authored changes belong to the contributor and must survive the run untouched, and this skill must not commit them — and commit them **alone**, as their own commit, never mixed into a feature commit. Name it as a *regeneration* (e.g. `chore: regenerate mirrors from local dataset`), never a "fix": an overwritten hand-edit was restored to what the dataset generates, not repaired. Drift in a file this branch never touched is committed here too, and **said so in the output** — surprising, but better than pushing knowingly stale generated output, and the separate commit keeps even a dataset-wide regeneration readable next to the authored work. + - **Verify**: `git log` shows exactly one new commit, and `git status` still shows every pre-existing unstaged authored change, untouched. +5. **Act**: Compose `/verify-quality` with `$scope` (default `all`). This is a local pre-flight, not a replacement for CI (CI stays authoritative, #210). +6. **Check**: Did every required gate pass? +7. **Skip**: If all gates pass, proceed to Phase 2. +8. **Act**: If any required gate fails → **HALT** before creating or updating the PR. Report each failing check (gate name + first failing detail). No PR side effects occur on a red gate. ### Phase 2: Resolve Merge Strategy & Prepare Base @@ -154,6 +164,7 @@ The PR is ready; it must now be **under review and mechanically blocked** — se PUBLISH-PR REPORT: ├── Story: [#ID: Title] ├── Handoff: [.pair/working/checkpoints/.md | none — state gathered from branch+story] +├── Mirrors: [regenerated — commit , N file(s) — omit this row entirely when nothing was committed] ├── Gate: [PASS | HALTED — N gates failing] ├── Base: [base-branch — squash on merge: yes|no] ├── PR: [#PR-number — URL — Created | Updated — ready-for-review confirmed by read | ready-for-review not confirmed — finding] @@ -184,6 +195,7 @@ When invoked **independently** (hotfix, automation loop #212): ## HALT Conditions - **Story id unresolvable** from handoff or branch (Phase 0). +- **`mirror-realign-command` exits non-zero** (Phase 1) — report its own reason verbatim; nothing was regenerated and no PR side effects occur. Same shape as the gate-red HALT it precedes. - **Quality gate red** (Phase 1) — report failing checks; no PR side effects. - **pr-template not found** (Phase 3) — cannot compose a PR without it. - **Code host unreachable or unauthenticated** for create/update (Phase 4) — report with a setup pointer and stop; nothing partial is left ready. **PM-side work already done is not rolled back** (the board write is the PM tool's own state); re-invocation is idempotent and resumes at the code-host step. @@ -202,6 +214,7 @@ See [graceful degradation](../../../.pair/knowledge/guidelines/technical-standar - **A write the host reports as applied but a read does not show** (a tag, the assignee, ready-for-review, the `pair-review` status, the `pr-state:*` label — each read back where it is written: tags and assignee in Phase 4 step 3, ready-for-review in step 6, the check status and the state label in Phase 5 steps 3 and 4): report it as a finding on the corresponding output row and continue. The PR exists and is what matters; what must never happen is reporting the unapplied write as done. - **No board state maps to `Review`** (a minimal board, D4 — a project that reviews on the PR and merges straight to `Done`): **write no state field** in step 7 — membership is still established and confirmed — and report `Board: n-a — no Review state on this board`. The zero-configuration documented skip, **not** an error and not a degraded publish — the readiness signal is the PR itself. - **The direct board write cannot complete** (membership unconfirmable after the add and its one retry — the item writer's Step 7b; or a macrostate no board state can express — its Step 6): report the blocker verbatim on the `Board:` row as `not updated — ` and continue. The reasons are the item writer's, the write is **this skill's own** — it applies those beats by reference, it does not compose them. The PR is published and ready-for-review; a board write that did not happen is **reported, never absorbed into a green publish**, and this skill never HALTs on it (the code-host artifact is the work). +- **No `mirror-realign-command` declared**: skip the realignment step and report nothing (Phase 1) — the zero-configuration default for a project with no generated mirrors, **not** a degradation. Never substitute a guessed command, and never a knowledge-base *install* command: installing a published release is a different operation from realigning a working tree, and using one for the other makes the fix depend on what has been published. - **`/checkpoint` not installed**: gather state from branch + story directly (Phase 0). - **`/write-issue` not installed**: only the **comment-mode back-link** (Phase 4 step 5) is affected — write it directly per the PM tool's implementation guide **and read the item's comments back to confirm it**, or warn with the manual-link instruction. A direct post the read does not show is reported `back-link failed — manual link needed`, **never as posted**: losing the composition must not lose the confirming read with it, or the degraded path becomes the one path that claims a write it never made. **The board write in step 7 is unaffected and still runs in full** (membership → confirming read → state field): it is direct, never a composition, so a missing item writer can never leave the story off the board. Skipping the board write here would re-create #384/#372 — green, ready-for-review, and invisible. - **Nested subagent dispatch unavailable** (Phase 5 — the common case: this skill is itself running in `/implement`'s handoff subagent and the harness forbids a second level): return `Review: review-dispatch-required — /review $pr=` and let the **caller** dispatch (`/implement` Step 3.3). This is the primary path when nested, not a degradation — the review still runs, one frame up, on a clean context. diff --git a/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts b/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts new file mode 100644 index 000000000..a19c9f9ec --- /dev/null +++ b/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'fs' +import { join } from 'path' +import { syncFrontmatter } from '@pair/content-ops' +import { + buildDatasetSkillNameMap, + buildSkillLinkPathMap, + applyKnownMirrorTransforms, +} from '../tools/skills-guide-mirror' +import { sectionBetween } from './test-utils' + +// Conformance guard for story #419: /publish-pr realigns the generated mirrors from +// the LOCAL dataset, at the last point where the regenerated output can still enter +// the branch — and commits it as its OWN commit, or says nothing at all. +// +// The behaviour of the command itself (regenerates, idempotent, fails loud, leaves +// authored changes alone) is exercised against a real fixture repo by +// `packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts`. What is guarded +// HERE is the part that lives in prose and can only regress in prose: which phase the +// step runs in, that the command is read from the adoption rather than named in the +// skill, that a no-op stays silent, and that the commit is separate and stages only +// generated paths. +// +// See ADL 2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md. + +const DATASET = join(__dirname, '../../dataset/.skills/capability/publish-pr/SKILL.md') +const MIRROR = join(__dirname, '../../../../.claude/skills/pair-capability-publish-pr/SKILL.md') +const SKILLS_DIR = join(__dirname, '../../dataset/.skills') +const WAY_OF_WORKING = join(__dirname, '../../../../.pair/adoption/tech/way-of-working.md') + +const dataset = (): string => readFileSync(DATASET, 'utf-8') +const mirror = (): string => readFileSync(MIRROR, 'utf-8') + +/** Phase 1's body, bounded by the next phase heading — fails closed on a rename. */ +const phase1 = (): string => + sectionBetween(dataset(), '### Phase 1:', '### Phase 2: Resolve Merge Strategy') + +describe('publish-pr realigns mirrors before its gate (#419)', () => { + it('runs the realignment inside Phase 1, ahead of the /verify-quality composition', () => { + const p1 = phase1() + // Ordering is the whole point: mirror drift is what turns the gate red, and a red + // gate HALTs — so a realignment placed after it is unreachable in the only case it + // exists for. Anchored to the Phase 1 SPAN, not to the file: `/verify-quality` + // appears in the frontmatter description and the composed-skills table long before + // any phase, so a global indexOf comparison would pass on any arrangement. + const realignIdx = p1.search(/mirror-realign-command/) + const gateIdx = p1.search(/Compose `\/verify-quality`/) + expect(realignIdx).toBeGreaterThanOrEqual(0) + expect(gateIdx).toBeGreaterThan(realignIdx) + }) + + it('reads the command from the adoption instead of naming one (portability)', () => { + const c = dataset() + expect(c).toContain('`mirror-realign-command`') + expect(c).toContain('## Quality Gates') + // A skill shipped to every adopter must not hardcode this repository's own script. + expect(c).not.toContain('pnpm mirrors:regenerate') + }) + + it('skips the step entirely when no command is declared, reporting nothing', () => { + const c = dataset() + expect(c).toMatch(/Absent ⇒ the realignment step is skipped entirely/) + expect(c).toMatch(/No `mirror-realign-command` declared[\s\S]{0,200}skip the realignment step/) + }) + + it('regenerates from the LOCAL dataset, never from a published release', () => { + const p1 = phase1() + expect(p1).toMatch(/\*\*local\*\* dataset/) + expect(p1).toContain('never a published release') + }) + + it('commits the generated paths ALONE, and never stages the whole tree', () => { + const p1 = phase1() + expect(p1).toContain('git add -A') + expect(p1).toMatch(/never `git add -A`/) + expect(p1).toMatch(/stage \*\*only\*\* those paths/) + expect(p1).toMatch(/never mixed into a feature commit/) + }) + + it('names the commit a regeneration, never a fix (an overwritten hand-edit was restored)', () => { + const p1 = phase1() + expect(p1).toMatch(/regenerate mirrors from local dataset/) + expect(p1).toMatch(/never a "fix"/) + }) + + it('leaves unstaged authored changes untouched and verifies they survived', () => { + const p1 = phase1() + expect(p1).toMatch(/unstaged authored changes[\s\S]{0,200}must survive the run untouched/) + expect(p1).toMatch(/`git status` still shows every pre-existing unstaged authored change/) + }) + + it('stays SILENT on a no-op — no commit and no output row', () => { + const p1 = phase1() + expect(p1).toMatch(/a no-op stays \*\*silent\*\*/) + expect(p1).toMatch(/no commit, and no output row/) + // The Mirrors row is conditional, which is what "reports nothing" means in a + // fixed-shape report: the row is absent, not filled with "nothing to do". + expect(dataset()).toMatch(/omit this row entirely when nothing was committed/) + }) + + it('commits drift in a file the branch never touched, and says so', () => { + expect(phase1()).toMatch(/Drift in a file this branch never touched is committed here too/) + }) + + it('HALTs before any PR side effect when the command exits non-zero', () => { + const c = dataset() + expect(phase1()).toMatch(/non-zero exit → HALT\*\* before any PR side effect/) + expect(c).toMatch(/exits non-zero\*\* \(Phase 1\)[\s\S]{0,200}no PR side effects/) + }) + + it('installed mirror is reproducible from the dataset via the real transform', () => { + // Same whole-file guarantee implement-compose-close.test.ts asserts: the mirror must + // equal the dataset run through the `pair update` copy pipeline (frontmatter `name` + // rename + the `/command` and `.skills/**` link rewrites). A hand-ported mirror — + // the exact anomaly #419's command exists to make unnecessary — fails here. + const reconstructed = applyKnownMirrorTransforms( + syncFrontmatter(dataset(), { from: 'publish-pr', to: 'pair-capability-publish-pr' }), + buildDatasetSkillNameMap(SKILLS_DIR), + buildSkillLinkPathMap(SKILLS_DIR), + ) + expect(mirror()).toBe(reconstructed) + }) +}) + +describe("this repository's own wiring for the realignment (#419)", () => { + it('declares mirror-realign-command, so the step actually runs here', () => { + const wow = readFileSync(WAY_OF_WORKING, 'utf-8') + expect(wow).toMatch(/\*\*`mirror-realign-command`\*\*: `pnpm mirrors:regenerate`/) + }) + + it('declares it under Quality Gates — the section publish-pr reads', () => { + const wow = readFileSync(WAY_OF_WORKING, 'utf-8') + const gates = sectionBetween(wow, '## Quality Gates', '### Review Tier Matrix') + expect(gates).toContain('`mirror-realign-command`') + }) +}) From af8f4765be47119ff30e4744953a7753f071600a Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Tue, 1 Sep 2026 15:48:16 +0200 Subject: [PATCH 06/14] chore: regenerate mirrors from local dataset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-existing drift this branch never touched: four adoption files still carry bare `/skill-name` references the install-time rewriter resolves to their prefixed form. Regenerated by `pnpm mirrors:regenerate`, committed separately — exactly the shape /pair-capability-publish-pr now produces. Refs: #419 --- .../collaborative-workflow.context.md | 2 +- ...1-approval-signal-on-the-composed-skill.md | 20 +++++++++---------- ...overage-ratchet-exposed-through-the-cli.md | 6 +++--- ...e-ratchet-ships-as-a-generated-kb-asset.md | 2 +- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.pair/adoption/product/subdomain/collaborative-workflow.context.md b/.pair/adoption/product/subdomain/collaborative-workflow.context.md index ecd9fbe2c..242f612e6 100644 --- a/.pair/adoption/product/subdomain/collaborative-workflow.context.md +++ b/.pair/adoption/product/subdomain/collaborative-workflow.context.md @@ -32,7 +32,7 @@ Ubiquitous language scoped to this subdomain. | Mutex resource | A shared skill, file, or module a card's declared touched surface names; two cards sharing one are never placed in the same parallel batch. Ref: [#250](https://github.com/foomakers/pair/issues/250). | | `max_parallelism` | The user-set ceiling on `pair-loop`'s parallel batch size (`min(dependency-allowed, max_parallelism)`) — a single global integer with an optional per-tier override; a ceiling, never a target. Ref: ADR-017 §6, [#250](https://github.com/foomakers/pair/issues/250). | | Stop predicate | A `` expression (over canonical macrostates/tags, never issue-body content) plus a mandatory max-iterations backstop, ending an unattended `pair-loop` run at whichever bound is reached first. Ref: [#250](https://github.com/foomakers/pair/issues/250), D18. | -| Continue-token | The re-invocation line (`pair-loop --root … --iteration n+1`) the portable `/loop` skill prints after its degraded one-card path, letting the caller resume with no new persistence format. Ref: ADR-017 §4-5, [#250](https://github.com/foomakers/pair/issues/250). | +| Continue-token | The re-invocation line (`pair-loop --root … --iteration n+1`) the portable `/pair-loop` skill prints after its degraded one-card path, letting the caller resume with no new persistence format. Ref: ADR-017 §4-5, [#250](https://github.com/foomakers/pair/issues/250). | | Audit trail | The append-only, per-iteration record an unattended `pair-loop` run writes under the working area (`## Audit Location`) — every selection, exclusion, and stop reconstructable by a human; an unwritable destination fails the run rather than proceeding unaudited. Ref: [#250](https://github.com/foomakers/pair/issues/250), D14. | | Execution adapter | A component that runs a pair skill inside a chosen agent process and decides only *how* to invoke it — never *what* to work on. `pair run` is the one pair ships; it holds no eligibility, ordering or merge logic (D18), borrows every policy parameter from `tech/automation.md`, and is therefore not a second process engine. Ref: [ADR-021](../../tech/adr/adr-021-fan-out-three-realizations.md), [#451](https://github.com/foomakers/pair/issues/451). | | Engine | An agent process an execution adapter can drive — today `pi`, `opencode` or `claude -p`. Distinct from a **harness** (an environment configured to execute pair's process, #450) and from a **model provider**: one harness may be driven as an engine, and the same engine may run several providers. Ref: [agent-harness framework](../../../knowledge/guidelines/technical-standards/ai-development/agent-harness/README.md), [#451](https://github.com/foomakers/pair/issues/451). | diff --git a/.pair/adoption/tech/adr/adr-021-approval-signal-on-the-composed-skill.md b/.pair/adoption/tech/adr/adr-021-approval-signal-on-the-composed-skill.md index e6e33e6be..236e1e3e8 100644 --- a/.pair/adoption/tech/adr/adr-021-approval-signal-on-the-composed-skill.md +++ b/.pair/adoption/tech/adr/adr-021-approval-signal-on-the-composed-skill.md @@ -10,11 +10,11 @@ Accepted — **extends** the [resolution cascade](../../../knowledge/guidelines/ ## Context -- Two composable families end in an **unconditional developer-approval round** and had **no non-interactive signal of their own**: the `assess-*` family (the cascade's Path A steps 3-4 "Confirm the override with the developer", each skill declaring its own prompt, plus its Path C "Developer approves" and Path B keep-or-redo), and the `map-*` family (`/map-subdomains` Step 3, `/map-contexts` Step 4 — "Approve or adjust?"). -- A caller that must not ask therefore had exactly one option: **declare, per composed skill, that it suppresses that skill's round**. `/bootstrap`'s quick depth did precisely that, twice, as disclosed deviations 2 and 3 of its `quick-mode-defaults.md`, mirrored by caller-side notes in its Steps 2.2 and 3.5. +- Two composable families end in an **unconditional developer-approval round** and had **no non-interactive signal of their own**: the `assess-*` family (the cascade's Path A steps 3-4 "Confirm the override with the developer", each skill declaring its own prompt, plus its Path C "Developer approves" and Path B keep-or-redo), and the `map-*` family (`/pair-capability-map-subdomains` Step 3, `/pair-capability-map-contexts` Step 4 — "Approve or adjust?"). +- A caller that must not ask therefore had exactly one option: **declare, per composed skill, that it suppresses that skill's round**. `/pair-process-bootstrap`'s quick depth did precisely that, twice, as disclosed deviations 2 and 3 of its `quick-mode-defaults.md`, mirrored by caller-side notes in its Steps 2.2 and 3.5. - That shape is not merely verbose, it is **structurally blind**: a caller-side note cannot see the *next* composed skill that asks. The same defect was found **twice in two consecutive review rounds** on the same PR — round 2 on `assess-*`, round 3 on `map-*` — and nothing prevented an eleventh surface. Every miss is a run that hangs on a question no one can answer, while the caller's disclosure reads as complete. - Constraint: **guided behaviour is untouchable.** A caller that passes nothing must get today's behaviour word for word; any shift in the default depth would make this a different, larger change. -- Constraint: **one gate must survive.** `/map-contexts` HALTs on an unbalanced + volatile relationship offered with neither mitigation nor acceptance. Writing a domain model that records a coupling risk nobody judged is worse than asking one question, so a generic signal that swallowed it would be a regression dressed as a feature. +- Constraint: **one gate must survive.** `/pair-capability-map-contexts` HALTs on an unbalanced + volatile relationship offered with neither mitigation nor acceptance. Writing a domain model that records a coupling risk nobody judged is worse than asking one question, so a generic signal that swallowed it would be a regression dressed as a feature. ## Options Considered @@ -32,9 +32,9 @@ Accepted — **extends** the [resolution cascade](../../../knowledge/guidelines/ ### Option 3: Reuse `$mode: quick` as the signal -- **Description**: give every family member the `$mode: quick` selector `/bootstrap` already declares. +- **Description**: give every family member the `$mode: quick` selector `/pair-process-bootstrap` already declares. - **Pros**: one vocabulary for "the quick depth"; no new argument name. -- **Cons**: `$mode` is already **taken and means something else** in this corpus — `/assess-cost` and `/assess-security` use it for `classify`/`report` and `review`/`audit`, which are different algorithms, not depths. It also conflates a *setup depth* (a whole run's shape) with *whether one round is asked*, so a skill with no depth to speak of would have to declare one, and the two meanings would collide in exactly the family the change targets. +- **Cons**: `$mode` is already **taken and means something else** in this corpus — `/pair-capability-assess-cost` and `/pair-capability-assess-security` use it for `classify`/`report` and `review`/`audit`, which are different algorithms, not depths. It also conflates a *setup depth* (a whole run's shape) with *whether one round is asked*, so a skill with no depth to speak of would have to declare one, and the two meanings would collide in exactly the family the change targets. ### Option 4 (chosen): `$approval` — a shared argument, defaulting to today's behaviour @@ -46,7 +46,7 @@ Accepted — **extends** the [resolution cascade](../../../knowledge/guidelines/ **Adopt `$approval` (Option 4): one shared argument on the composed skill, `interactive` by default, honoured by every skill that declares an approval round — with `auto`'s resolution fixed per round kind, and a judgement gate exempt by construction.** -The mechanism **belongs to the convention, not to a caller**. The convention is stated once in `approval-rounds.md`; the cascade doc qualifies the two rounds it owns; each skill declares the argument and qualifies only its own local rounds. `/bootstrap`'s quick depth passes `$approval: auto` and its disclosed deviations 2 and 3 are retired — the caller-side notes in Steps 2.2 and 3.5 with them, since they existed only to describe those deviations. +The mechanism **belongs to the convention, not to a caller**. The convention is stated once in `approval-rounds.md`; the cascade doc qualifies the two rounds it owns; each skill declares the argument and qualifies only its own local rounds. `/pair-process-bootstrap`'s quick depth passes `$approval: auto` and its disclosed deviations 2 and 3 are retired — the caller-side notes in Steps 2.2 and 3.5 with them, since they existed only to describe those deviations. Option 2 was rejected on **correctness**, not cost: an environment probe answers a question adjacent to the one that matters and cannot be exempted for the surviving gate. Options 1 and 3 were rejected on **enforceability** and **vocabulary collision** respectively. @@ -61,7 +61,7 @@ Two corollaries, recorded because neither is obvious: - A caller passes **one** signal and every composed skill in both families honours it; the class of defect that recurred twice in two review rounds cannot recur in an eleventh surface, because the obligation now lives where the round is. - **Guided is untouched by construction**: the default resolves to the pre-existing text, so a caller that passes nothing is unaffected — not by inspection, but because the qualified step *is* the old step when `$approval` is absent. -- The surviving `/map-contexts` HALT is derived, not excepted: a round with no proposal to accept is a gate, and `auto` suppresses asking, never judging. +- The surviving `/pair-capability-map-contexts` HALT is derived, not excepted: a round with no proposal to accept is a gate, and `auto` suppresses asking, never judging. - The obligation is **enforced over the corpus, per skill present**, so a future family member either honours the signal or fails the gate — no count to maintain, no list to remember. - The two families' approval semantics are now stated **once** rather than restated in each caller that composes them, which is what made the previous shape unauditable. @@ -70,8 +70,8 @@ Two corollaries, recorded because neither is obvious: - **Enforcement is a declared marker, not prose interpretation — decided after six review rounds, and worth recording as its own finding.** The first six rounds of this story enforced the convention by reading keywords out of a span computed from markdown layout: the whole file, then the step block, then a ±400-character window, then a sentence. Each narrowing closed the instance in front of it and left the class alive, because a layout-derived span does not fail when the prose changes shape — **it widens**, and something unrelated satisfies it. Rounds 5, 6 and 7 each found the same defect in the guard written to close the previous one. The fix is a marker on the round's own line (``) with both values drawn from **closed enums**: attachment becomes line identity rather than a window, and a bad resolution becomes *unrepresentable* rather than *unmatched* — "resolve the tie by whichever is listed first" has no spelling. The prose checks remain, but keyed on the marker, so what they verify is that the sentence agrees with the declared contract. Corollary the convention now states: **no check may degrade to a wider scope or an empty input when parsing fails.** - **Residual textual enforcement.** The gate still recognises an *unmarked* round by phrase patterns — confirmations ("Developer approves", "Approve or adjust?", "ask for confirmation") **and choices** ("ask developer to choose", "present top 2 with trade-off analysis", "Developer chooses"). A round phrased outside that set is invisible to it, and the missed tie-breaks above are the proof that this limitation bites in practice rather than in theory. The pattern set is one tested module with injection tests, matched on verbs rather than nouns (so a sentence *reporting* a decision is not flagged), and its file scope is every markdown a family skill's directory contributes — `SKILL.md` **and** its disclosed sub-docs, since progressive disclosure would otherwise be a legal way around the gate. It remains a heuristic over prose, not a type system — which is why it is now only the SAFETY NET for a round nobody marked, never the thing a marked round is judged by. - **The AC2 guard is a closed phrase list too.** `findGuidedDrift` catches `auto`-only vocabulary that leaked into a round's guided half — the failure mode that actually occurred, where qualifying a round quietly changed the question a guided caller is asked. It matches **five** phrases; a paraphrase outside that list is not detected, exactly as with the round-detection patterns. Two heuristics over prose, not a type system: what makes them worth having is that each one closed a defect that had already shipped, and each is a tested module a reviewer can extend in one place. -- **A caller must now pass the signal.** Previously a caller documented a suppression; now it threads an argument. A caller that does neither is interactive again, and the symptom is a hang rather than a red gate. Only `/bootstrap` (quick depth) is converted here. -- **UNTRACKED RESIDUAL — the other automated callers.** `/refine-story` (and through it `refine-batch`), and any loop composing the `map-*` skills, still pass nothing and therefore still ask. The mechanism they need now exists; threading it is caller-side work, deliberately outside this change's scope. **No existing card covers it** — this is stated plainly because an earlier draft of this ADR deferred it to #237, which is "Package-local adoption (override): co-located rules, diff-scoped skills" and has nothing to do with it: a residual pointed at the wrong owner is worse than one with no owner, because it reads as handled. Whoever picks this up files the card; nothing here claims it exists. +- **A caller must now pass the signal.** Previously a caller documented a suppression; now it threads an argument. A caller that does neither is interactive again, and the symptom is a hang rather than a red gate. Only `/pair-process-bootstrap` (quick depth) is converted here. +- **UNTRACKED RESIDUAL — the other automated callers.** `/pair-process-refine-story` (and through it `refine-batch`), and any loop composing the `map-*` skills, still pass nothing and therefore still ask. The mechanism they need now exists; threading it is caller-side work, deliberately outside this change's scope. **No existing card covers it** — this is stated plainly because an earlier draft of this ADR deferred it to #237, which is "Package-local adoption (override): co-located rules, diff-scoped skills" and has nothing to do with it: a residual pointed at the wrong owner is worse than one with no owner, because it reads as handled. Whoever picks this up files the card; nothing here claims it exists. - **One more argument in a corpus that prizes small argument tables.** Accepted: it is one row, identical in every skill, pointing at one convention. - **Two of the eleven `assess-*` skills do not declare it** (`assess-cost`, `assess-coupling`) — deliberately, because neither has an approval round. The gate is defect-driven (every round found must be qualified) rather than name-driven (every `assess-*` must declare the argument), so the corpus carries no argument that nothing honours; the day either grows a round, the gate requires the row. @@ -81,7 +81,7 @@ Two corollaries, recorded because neither is obvious: - **`resolution-cascade.md`** — Paths A and B qualified with `$approval`, once, for every skill that follows the cascade; the per-skill delta list now states that these two rounds are never restated per skill. - **`guided-quick-setup.md`** — a quick depth that composes other skills forwards the depth as one signal instead of disclosing per-composed-skill suppressions. - **Eleven skills** — the nine `assess-*` members with an approval round (`assess-security` included, whose only rounds are its audit-mode Path A/B) plus both `map-*` skills: an `$approval` argument row and each local round qualified, confirmation and tie-break alike. -- **`/bootstrap`** — quick mode composes `assess-*` and `map-*` with `$approval: auto`; `quick-mode-defaults.md` deviations 2 and 3 and the Step 2.2 / 3.5 caller-side notes are retired. +- **`/pair-process-bootstrap`** — quick mode composes `assess-*` and `map-*` with `$approval: auto`; `quick-mode-defaults.md` deviations 2 and 3 and the Step 2.2 / 3.5 caller-side notes are retired. - No change to `architecture.md`, `tech-stack.md` or `infrastructure.md` — this is a skill-corpus convention, not a stack or boundary decision. ## References diff --git a/.pair/adoption/tech/adr/adr-022-coverage-ratchet-exposed-through-the-cli.md b/.pair/adoption/tech/adr/adr-022-coverage-ratchet-exposed-through-the-cli.md index e77bb0fe4..155e78ce2 100644 --- a/.pair/adoption/tech/adr/adr-022-coverage-ratchet-exposed-through-the-cli.md +++ b/.pair/adoption/tech/adr/adr-022-coverage-ratchet-exposed-through-the-cli.md @@ -11,7 +11,7 @@ Accepted — **amended by [ADR-023](adr-023-coverage-ratchet-ships-as-a-generate ## Context - Story #372 (PR #405) shipped the coverage-baseline **ratchet** — the opt-in commit-back half of the coverage guardrail — and documented it in the adopter-facing KB: the nested `Coverage baseline commit-back` flag, the push-not-PR trigger, the bot-PR landing, the `COVERAGE_RATCHET_TOKEN` credential. -- The capability, however, was **pair-internal**. The logic lived in `packages/knowledge-hub/src/tools/coverage-baseline-ratchet.ts` and the only way to run it was `pnpm --filter @pair/knowledge-hub coverage:ratchet` — a workspace filter inside pair's own monorepo. `/setup-gates` never asked about the flag and never emitted a step. An adopter who wrote `Coverage baseline commit-back: enabled` therefore got a **silent no-op**: config on, docs describing behaviour, nothing running, nothing complaining. #405 closed the honesty gap by stating the pair-internal scope; story #409 closes the capability gap, and this decision is its gate (#409/T-1). +- The capability, however, was **pair-internal**. The logic lived in `packages/knowledge-hub/src/tools/coverage-baseline-ratchet.ts` and the only way to run it was `pnpm --filter @pair/knowledge-hub coverage:ratchet` — a workspace filter inside pair's own monorepo. `/pair-capability-setup-gates` never asked about the flag and never emitted a step. An adopter who wrote `Coverage baseline commit-back: enabled` therefore got a **silent no-op**: config on, docs describing behaviour, nothing running, nothing complaining. #405 closed the honesty gap by stating the pair-internal scope; story #409 closes the capability gap, and this decision is its gate (#409/T-1). - The other half of the guardrail, [`coverage-gate.sh`](../../../knowledge/assets/coverage-gate.sh), ships as a **provider-agnostic shell asset** in the KB, alongside `tier-resolve.sh`, `pr-state.sh` and `pr-tree-resolve.sh`. The ratchet was the only member of that family that was not reachable by an adopter, which is what makes "port it into the family" the obvious-looking answer. - Two adoption records constrain the answer in opposite directions: - ADL [2026-07-13-gate-tooling-code-in-tested-modules.md](../../decision-log/2026-07-13-gate-tooling-code-in-tested-modules.md): gate/tooling logic worth testing lives in an importable module, white-box unit-tested; scripts are thin entrypoints and are **never** unit-tested. ADL [2026-07-30-coverage-ratchet-pr-not-push.md](../../decision-log/2026-07-30-coverage-ratchet-pr-not-push.md) applied it to this very capability, rejecting persistence inside the shell gate on the grounds that "its logic belongs in a tested module, not in the shell asset". @@ -35,13 +35,13 @@ Accepted — **amended by [ADR-023](adr-023-coverage-ratchet-ships-as-a-generate ### Option 3: Declare an extension point and let the adopter supply the implementation -- **Description**: `/setup-gates` asks the nested question and emits a step that invokes a project-declared command (`PAIR_RATCHET_CMD`), documenting the contract pair's own step satisfies. +- **Description**: `/pair-capability-setup-gates` asks the nested question and emits a step that invokes a project-declared command (`PAIR_RATCHET_CMD`), documenting the contract pair's own step satisfies. - **Pros**: Trivial to ship; no new distribution surface. - **Cons**: Fails the story's own acceptance: the emitted step would not be "the same shape pair runs", and an adopter who enabled the flag would get a *loud* no-op instead of a silent one. Louder, still not working. ### Option 4: Expose the existing module through the published CLI (chosen) -- **Description**: The implementation stays one unit-tested TypeScript module and moves to `apps/pair-cli/src/commands/coverage-ratchet/ratchet.ts`, behind a new `pair coverage-ratchet` command (metadata + parser + thin handler). `/setup-gates` emits a step that invokes it with a pinned `npx --yes @foomakers/pair-cli@`; pair's own CI step invokes the same command from the dist it built earlier in the job. +- **Description**: The implementation stays one unit-tested TypeScript module and moves to `apps/pair-cli/src/commands/coverage-ratchet/ratchet.ts`, behind a new `pair coverage-ratchet` command (metadata + parser + thin handler). `/pair-capability-setup-gates` emits a step that invokes it with a pinned `npx --yes @foomakers/pair-cli@`; pair's own CI step invokes the same command from the dist it built earlier in the job. - **Pros**: No logic is rewritten and no assertion is lost — the drift the business rule forbids is structurally impossible, because there is only ever one implementation. The CLI is pair's **existing** distribution channel to adopters, and generated adopter-facing shell already shells out to it with a pinned `npx` (`scaffold-kb`'s `release.sh`, `PAIR_CLI` override). Argument validation gains a real parser: a malformed invocation exits non-zero where the hand-rolled argv loop was untested. - **Cons**: Widens the published CLI surface with a command a human will rarely type (it is CI machinery), and makes the adopter path depend on Node + npm availability in their pipeline — honest, but a dependency the shell assets do not have. The module also crosses a package boundary: it leaves the KB-tools package for the CLI app. diff --git a/.pair/adoption/tech/adr/adr-023-coverage-ratchet-ships-as-a-generated-kb-asset.md b/.pair/adoption/tech/adr/adr-023-coverage-ratchet-ships-as-a-generated-kb-asset.md index 07c1f93fa..77d50db41 100644 --- a/.pair/adoption/tech/adr/adr-023-coverage-ratchet-ships-as-a-generated-kb-asset.md +++ b/.pair/adoption/tech/adr/adr-023-coverage-ratchet-ships-as-a-generated-kb-asset.md @@ -23,7 +23,7 @@ The ratchet ships as a **generated KB asset**, closing the loop differently: - `packages/knowledge-hub/dataset/.pair/knowledge/assets/coverage-ratchet.cjs` — the shipped corpus - `.pair/knowledge/assets/coverage-ratchet.cjs` — pair's own installed copy The module imports only node builtins, so a single-file transpile is a complete program; CommonJS output keeps its `require.main === module` entrypoint working under plain `node`. -- `/setup-gates` emits `node .pair/knowledge/assets/coverage-ratchet.cjs …` in the adopter's push-triggered workflow — the file `pair install` put there. No npm registry round-trip, no version pin to maintain, no new command. +- `/pair-capability-setup-gates` emits `node .pair/knowledge/assets/coverage-ratchet.cjs …` in the adopter's push-triggered workflow — the file `pair install` put there. No npm registry round-trip, no version pin to maintain, no new command. - Pair's own CI step invokes the same relative path from its installed copy. - A conformance gate (`conformance/coverage-ratchet-asset.test.ts`) compiles the source fresh and asserts both committed copies match byte-for-byte: editing an asset by hand, or editing the source without regenerating, turns red. The smoke scenario executes the shipped `.cjs` end-to-end. From f0161b9fa787e3ee6bb832540f3bf9ac195ea3cb Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Tue, 1 Sep 2026 15:53:23 +0200 Subject: [PATCH 07/14] [#419] docs: close the check-only ADL's Open Decision; pin the script tests' timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 2026-07-31-pre-push-gate-is-check-only.md: the Open Decision is closed by this story, with what actually shipped (incl. the two guard messages the card did not list) and a pointer to the phase-order ADL - regenerate-mirrors.test.ts: explicit 120s per-test timeout — each case builds the CLI and runs a 7-registry regeneration, and vitest's 5s default is measured while turbo runs every other package in parallel (it flaked in the full gate) - verified AC-6: `format`/`format:check`/`prettier:*`/`mdlint:*`/`quality-gate` are byte-identical to main; only `mirrors:regenerate` is added - Task: T-6 — verify format unchanged and close the ADL Refs: #419 --- .../2026-07-31-pre-push-gate-is-check-only.md | 4 +- .../quality-gates/regenerate-mirrors.test.ts | 128 +++++++++++------- 2 files changed, 79 insertions(+), 53 deletions(-) diff --git a/.pair/adoption/decision-log/2026-07-31-pre-push-gate-is-check-only.md b/.pair/adoption/decision-log/2026-07-31-pre-push-gate-is-check-only.md index 323458acc..242328ab1 100644 --- a/.pair/adoption/decision-log/2026-07-31-pre-push-gate-is-check-only.md +++ b/.pair/adoption/decision-log/2026-07-31-pre-push-gate-is-check-only.md @@ -50,6 +50,8 @@ A second failure mode surfaced while implementing this: generated artifacts. In ## Resolved Decision (2026-08-05) — neither A nor B; a dedicated command instead +**Closed 2026-09-01 by story #419** — the Open Decision below is no longer open, and nothing here is pending. All three parts shipped: the command is the root script `pnpm mirrors:regenerate` (`scripts/regenerate-mirrors.sh`, wrapping `pair update --source --offline`, no check mode); `PRE_PUSH_REMEDY`, `DEVELOPMENT.md`, its docs-site twin **and both mirror guards' own failure messages** name it instead of `pair update`; and `/pair-capability-publish-pr` runs it in Phase 1, before its gate, committing the output separately when it drifted — see ADL [2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md](./2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md) for why that phase and why the command is read from the adoption. `pnpm format` and the check-only gate are byte-for-byte unchanged. + **Decided by the maintainer on 2026-08-05. Tracked as story #419.** Both shapes below were declined as written, for reasons that only became visible when the actual remedy command was inspected. **The remedy was naming the wrong command.** `PRE_PUSH_REMEDY`, `DEVELOPMENT.md` and its docs-site twin all say `pair update` — which `DEVELOPMENT.md` itself documents as *"Update knowledge base to latest version"*. That resolves and installs the **published** KB; what a mirror divergence needs is regeneration **from the local dataset** (`pair update --source `, the form `CP3` and the `source-resolution` smoke scenario already exercise). So the documented fix for a reformatted table was a knowledge-base update — disproportionate and non-deterministic, and the most plausible explanation for why three of the seven incidents were hand-ports: a contributor faced with that command reasonably chose to edit the mirror instead. @@ -68,7 +70,7 @@ A second failure mode surfaced while implementing this: generated artifacts. In This also becomes load-bearing once **#414** lands: with the mirrors inside `format:check` scope, a contributor without this command would be pushed toward hand-formatting a generated file — which the mirror guards forbid. -### Original framing (kept for the record) +### Original framing (kept for the record — closed, see above) **Should the gate apply the fix as well as failing, and should `pnpm format` realign the generated mirrors?** Raised by the maintainer 2026-08-04 while reviewing this story; deliberately not implemented at the time, and not to be implemented without their call. diff --git a/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts b/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts index 38dc06bbf..d9736d88f 100644 --- a/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts +++ b/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts @@ -98,6 +98,14 @@ function isolatedHome(dir: string): Record { return { HOME: home } } +// Every test here shells out to the REAL script, which builds the CLI (turbo, cached +// after the first) and then runs a full 7-registry regeneration over a fixture tree. +// That is seconds, not milliseconds, and vitest's 5s default is measured while turbo +// runs every other package's suite in parallel — so the default is a flake, not a +// budget. `SCRIPT_RUN_TIMEOUT_MS` is per test, and the same explicit-timeout treatment +// run-format.test.ts already gives its multi-subprocess cases. +const SCRIPT_RUN_TIMEOUT_MS = 120_000 + describe('regenerate-mirrors.sh — the local, deterministic mirror remedy (#419)', () => { let tmp = '' @@ -106,58 +114,74 @@ describe('regenerate-mirrors.sh — the local, deterministic mirror remedy (#419 tmp = '' }) - it('regenerates a drifted mirror from the LOCAL dataset (AC1)', () => { - tmp = makeFixture() - const mirror = join(tmp, '.pair/knowledge/index.md') - write(mirror, '# hand-edited drift\n') - - const result = run(tmp, isolatedHome(tmp)) - - expect(result.status).toBe(0) - expect(readFileSync(mirror, 'utf-8')).toBe(KB_INDEX) - }) - - it('never fetches or installs a published KB version (AC1)', () => { - tmp = makeFixture() - write(join(tmp, '.pair/knowledge/index.md'), '# hand-edited drift\n') - const env = isolatedHome(tmp) - - const result = run(tmp, env) - - expect(result.status).toBe(0) - // A published-version resolution caches the downloaded KB under `~/.pair/kb/`. - // Its absence is the observable difference between "regenerated from the working tree" - // and "updated to whatever is published", which is the whole point of the story. - expect(existsSync(join(env['HOME'] as string, '.pair/kb'))).toBe(false) - }) - - it('is idempotent — a second run produces no further diff (AC2)', () => { - tmp = makeFixture() - write(join(tmp, '.pair/knowledge/index.md'), '# hand-edited drift\n') - run(tmp, isolatedHome(tmp)) - git(tmp, ['add', '-A']) - git(tmp, ['commit', '-q', '-m', 'regenerated']) - - const second = run(tmp, isolatedHome(tmp)) - - expect(second.status).toBe(0) - expect(git(tmp, ['status', '--porcelain'])).toBe('') - }) - - it('leaves unstaged authored changes untouched (dirty-tree edge case)', () => { - tmp = makeFixture() - const authored = join(tmp, 'src/authored.ts') - write(authored, 'export const authored = 1\n') - git(tmp, ['add', '-A']) - git(tmp, ['commit', '-q', '-m', 'fixture']) - writeFileSync(authored, 'export const authored = 2\n') - write(join(tmp, '.pair/knowledge/index.md'), '# hand-edited drift\n') - - const result = run(tmp, isolatedHome(tmp)) - - expect(result.status).toBe(0) - expect(readFileSync(authored, 'utf-8')).toBe('export const authored = 2\n') - }) + it( + 'regenerates a drifted mirror from the LOCAL dataset (AC1)', + () => { + tmp = makeFixture() + const mirror = join(tmp, '.pair/knowledge/index.md') + write(mirror, '# hand-edited drift\n') + + const result = run(tmp, isolatedHome(tmp)) + + expect(result.status).toBe(0) + expect(readFileSync(mirror, 'utf-8')).toBe(KB_INDEX) + }, + SCRIPT_RUN_TIMEOUT_MS, + ) + + it( + 'never fetches or installs a published KB version (AC1)', + () => { + tmp = makeFixture() + write(join(tmp, '.pair/knowledge/index.md'), '# hand-edited drift\n') + const env = isolatedHome(tmp) + + const result = run(tmp, env) + + expect(result.status).toBe(0) + // A published-version resolution caches the downloaded KB under `~/.pair/kb/`. + // Its absence is the observable difference between "regenerated from the working tree" + // and "updated to whatever is published", which is the whole point of the story. + expect(existsSync(join(env['HOME'] as string, '.pair/kb'))).toBe(false) + }, + SCRIPT_RUN_TIMEOUT_MS, + ) + + it( + 'is idempotent — a second run produces no further diff (AC2)', + () => { + tmp = makeFixture() + write(join(tmp, '.pair/knowledge/index.md'), '# hand-edited drift\n') + run(tmp, isolatedHome(tmp)) + git(tmp, ['add', '-A']) + git(tmp, ['commit', '-q', '-m', 'regenerated']) + + const second = run(tmp, isolatedHome(tmp)) + + expect(second.status).toBe(0) + expect(git(tmp, ['status', '--porcelain'])).toBe('') + }, + SCRIPT_RUN_TIMEOUT_MS, + ) + + it( + 'leaves unstaged authored changes untouched (dirty-tree edge case)', + () => { + tmp = makeFixture() + const authored = join(tmp, 'src/authored.ts') + write(authored, 'export const authored = 1\n') + git(tmp, ['add', '-A']) + git(tmp, ['commit', '-q', '-m', 'fixture']) + writeFileSync(authored, 'export const authored = 2\n') + write(join(tmp, '.pair/knowledge/index.md'), '# hand-edited drift\n') + + const result = run(tmp, isolatedHome(tmp)) + + expect(result.status).toBe(0) + expect(readFileSync(authored, 'utf-8')).toBe('export const authored = 2\n') + }, + SCRIPT_RUN_TIMEOUT_MS, + ) it('exits non-zero and names the reason when the dataset is missing (AC7)', () => { tmp = realpathSync(mkdtempSync(join(tmpdir(), 'regen-mirrors-'))) From de9824c3e4d97f00567a94130d8ee1657946c24f Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Tue, 1 Sep 2026 16:24:46 +0200 Subject: [PATCH 08/14] [#419] fix: stage what the realign command wrote, not a path glob (review round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major: publish-pr Phase 1 staged "the paths the command owns", resolved from the adoption's owned-path globs. Root `.pair/**` is on that list and holds 117 tracked authored files under `.pair/adoption/**` — a contributor's unstaged prose landed in `chore: regenerate mirrors from local dataset`. Now: snapshot porcelain before and after, stage exactly what changed. No adopter enumerates globs. Also: Notes carve out the Phase-1 write instead of denying it; way-of-working states the writer/checker scope asymmetry (guards check dataset-sourced mirrors only) and marks its tree list descriptive; MIRROR_REGENERATE_COMMAND now tied to root package.json; AC-7's missing-turbo / build-failure / missing-dist branches tested, plus a TMPDIR-leak trap; refusal assertions no longer pass vacuously on a signal kill; PRE_PUSH_REMEDY names both mirror trees and its docblock scopes the byte-identity claim to the two documents; ADL records the thin-script-in-vitest deviation; turbo inputs widened so none of these guards can replay a stale PASS. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UQJzGMhRqBRRboxMrRqFPP --- .../pair-capability-publish-pr/SKILL.md | 12 +- ...-13-gate-tooling-code-in-tested-modules.md | 2 + ...ish-pr-realigns-mirrors-before-the-gate.md | 55 ++++++- .pair/adoption/tech/way-of-working.md | 4 +- .../pre-push-gate-composition.test.ts | 9 +- .../pre-push-gate-composition.ts | 30 ++-- .../quality-gates/regenerate-mirrors.test.ts | 150 ++++++++++++++++-- .../.skills/capability/publish-pr/SKILL.md | 12 +- .../conformance/mirror-realignment.test.ts | 66 +++++++- .../conformance/web-cloud-environment.test.ts | 11 +- scripts/regenerate-mirrors.sh | 17 +- turbo.json | 13 +- 12 files changed, 338 insertions(+), 43 deletions(-) diff --git a/.claude/skills/pair-capability-publish-pr/SKILL.md b/.claude/skills/pair-capability-publish-pr/SKILL.md index f5748779c..37a9752c1 100644 --- a/.claude/skills/pair-capability-publish-pr/SKILL.md +++ b/.claude/skills/pair-capability-publish-pr/SKILL.md @@ -61,11 +61,11 @@ The realignment runs **before** the gate, and the order is load-bearing in both 1. **Check**: Does the adoption declare a `mirror-realign-command`? 2. **Skip**: If it does not, go to step 5. A project with no generated mirrors has nothing to realign — the zero-configuration default, not a degradation, and nothing is reported. -3. **Act**: Run the declared command. It regenerates the mirrors from the working tree's **local** dataset — never a published release — and is idempotent. A **non-zero exit → HALT** before any PR side effect, reporting the command's own reason verbatim: it never reports success over a no-op, so a failure here means nothing was written and the drift is still there. -4. **Check → Act**: Read `git status --porcelain` for the generated paths the command owns. - - **No change** → a no-op stays **silent**: no commit, and no output row (the `Mirrors:` row is emitted only when a commit was made). Continue to step 5. - - **Changed** → stage **only** those paths — never `git add -A`: unstaged authored changes belong to the contributor and must survive the run untouched, and this skill must not commit them — and commit them **alone**, as their own commit, never mixed into a feature commit. Name it as a *regeneration* (e.g. `chore: regenerate mirrors from local dataset`), never a "fix": an overwritten hand-edit was restored to what the dataset generates, not repaired. Drift in a file this branch never touched is committed here too, and **said so in the output** — surprising, but better than pushing knowingly stale generated output, and the separate commit keeps even a dataset-wide regeneration readable next to the authored work. - - **Verify**: `git log` shows exactly one new commit, and `git status` still shows every pre-existing unstaged authored change, untouched. +3. **Act**: Take the **before** snapshot — `git status --porcelain`, whole tree — and only then run the declared command. It regenerates the mirrors from the working tree's **local** dataset — never a published release — and is idempotent. A **non-zero exit → HALT** before any PR side effect, reporting the command's own reason verbatim: it never reports success over a no-op, so a failure here means nothing was written and the drift is still there. +4. **Check → Act**: Take the **after** snapshot (`git status --porcelain` again) and compare it with the before snapshot. **The set to stage is what this run actually wrote** — every path whose porcelain entry appeared, disappeared or changed between the two reads — and it is derived that way rather than from a **path glob** of "the paths the command owns" deliberately. A glob is a *guess about the command*, and it is wrong wherever generated output and authored files share a prefix — the ordinary case, since the directory holding a project's generated mirrors is usually the same one holding its hand-written adoption/knowledge files. Under a glob, a contributor who left an authored file dirty beneath that prefix gets it committed under `chore: regenerate mirrors from local dataset` — their prose, under a regeneration message, in a commit they never wrote. The before/after comparison cannot do that: a file this run did not touch has an identical entry in both snapshots. It also means **no adopter has to enumerate owned globs anywhere** — the command's own effect is the declaration. + - **No change** → the two snapshots are equal; a no-op stays **silent**: no commit, and no output row (the `Mirrors:` row is emitted only when a commit was made). Continue to step 5. + - **Changed** → stage **only** the paths that comparison produced — never `git add -A`, and never a glob: unstaged authored changes belong to the contributor and must survive the run untouched, and this skill must not commit them — and commit them **alone**, as their own commit, never mixed into a feature commit. Name it as a *regeneration* (e.g. `chore: regenerate mirrors from local dataset`), never a "fix": an overwritten hand-edit was restored to what the dataset generates, not repaired. Drift in a file this branch never touched is committed here too, and **said so in the output** — surprising, but better than pushing knowingly stale generated output, and the separate commit keeps even a dataset-wide regeneration readable next to the authored work. + - **Verify**: `git log` shows exactly one new commit, its file list equals the before/after difference exactly, and `git status` still shows every pre-existing unstaged authored change, untouched. 5. **Act**: Compose `/pair-capability-verify-quality` with `$scope` (default `all`). This is a local pre-flight, not a replacement for CI (CI stays authoritative, #210). 6. **Check**: Did every required gate pass? 7. **Skip**: If all gates pass, proceed to Phase 2. @@ -226,7 +226,7 @@ See [graceful degradation](../../../.pair/knowledge/guidelines/technical-standar ## Notes -- This skill **creates git-host artifacts** (a pushed branch, one PR, a pending `pair-review` check, a `pr-state:*` label) and updates board state — it does not modify source files, never renders a review verdict, and never merges. +- This skill **creates git-host artifacts** (a pushed branch, one PR, a pending `pair-review` check, a `pr-state:*` label) and updates board state. It modifies files **only** through the adoption-declared `mirror-realign-command` (Phase 1) — generated content, staged as the before/after comparison computed it, in its own commit — and writes nothing else in the working tree; it never renders a review verdict, and never merges. Read the two together: Phase 1 is the single, bounded exception, not a contradiction of this bullet, and it is skipped entirely when no command is declared. - **Gate ≠ review** ([pr-states.md](../../../.pair/knowledge/guidelines/collaboration/project-management-tool/pr-states.md)): the Phase 1 gate is mechanical; the judgment verdict belongs to `/pair-process-review`, dispatched here on a clean context and enforced by the required `pair-review` check (R5.7). - **Idempotent** — see [idempotency convention](../../../.pair/knowledge/guidelines/technical-standards/ai-development/skill-conventions/idempotency.md). Re-invocation detects the existing PR and updates it in place; re-runs the gate (fast if already green); re-parses the handoff. Never a duplicate PR. - Tag propagation is a **copy**; the authoritative classification is (re)done in `/pair-process-review` (G6). diff --git a/.pair/adoption/decision-log/2026-07-13-gate-tooling-code-in-tested-modules.md b/.pair/adoption/decision-log/2026-07-13-gate-tooling-code-in-tested-modules.md index 8a5800815..0b28bd539 100644 --- a/.pair/adoption/decision-log/2026-07-13-gate-tooling-code-in-tested-modules.md +++ b/.pair/adoption/decision-log/2026-07-13-gate-tooling-code-in-tested-modules.md @@ -37,6 +37,8 @@ and the package script runs the module through a TS runner (`ts-node`/`tsx`) beh **Scripts are never unit-tested.** No importing a script's functions into a test, and no black-box `spawnSync`/`exec` of a script inside a vitest unit test. Unit tests target the module's exported logic. When script/CLI-level (end-to-end) verification is wanted, it uses the **smoke-test suite** (`scripts/smoke-tests/`, `pnpm smoke-tests`), not vitest. +> **Bounded exception, added 2026-09-01 (#419)** — a *thin script whose behaviour IS the deliverable* (no logic to extract; `run-format.sh`, `regenerate-mirrors.sh`) may be black-box executed from vitest against a **throwaway fixture**, asserting observable behaviour only. Conditions, rationale and why the smoke suite is not the right home for those cases: [2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md](./2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md), Decision. Everything above stands for every script that does hold logic — the fix there is still "extract to a module + white-box test". + Rationale: a gate is testable logic, not an opaque script; keeping the logic in an importable module removes duplication and orphan tests that reach into root `scripts/`; unit tests then cover module logic while smoke tests cover CLI wiring end-to-end. The module's public functions are the single tested surface; the CLI wrapper is a trivial, unit-test-exempt shell. ## Alternatives Considered diff --git a/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md b/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md index edb961fc0..2d9dd3dbb 100644 --- a/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md +++ b/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md @@ -30,6 +30,23 @@ regeneration judged a tree the PR no longer contains. the pair corpus. `pnpm mirrors:regenerate` is a script of *this* repository — a skill that hardcoded it would emit a step no adopter can run, on a repo with no mirrors to realign. +**3. How the skill decides what to stage.** The first draft staged "the generated paths the command +owns", resolved through the owned-path globs this file declares. Review found that unsound: root +`.pair/**` is on that list and holds 117 tracked *authored* files under `.pair/adoption/**` +(`git ls-files .pair/adoption | wc -l` → 117). A contributor who edits +`.pair/adoption/tech/way-of-working.md`, leaves it unstaged and runs the skill — the state this very +PR was in — would have their prose committed under `chore: regenerate mirrors from local dataset`, +contradicting the same phase's "unstaged authored changes must survive the run untouched". + +**4. Where a script's own behaviour is tested.** ADL +[2026-07-13-gate-tooling-code-in-tested-modules.md](./2026-07-13-gate-tooling-code-in-tested-modules.md) +forbids black-box `spawnSync`/`exec` of a script inside a vitest unit test and routes CLI-level +verification to `scripts/smoke-tests/`. `scripts/regenerate-mirrors.sh` is a *thin wrapper whose +behaviour is the entire deliverable* — there is no module to extract, because the story's own +constraint is "no new generation logic". `run-format.test.ts` already deviates the same way and the +deviation was nowhere recorded, so the repo's adoption said one thing and two of its test files did +another with nothing telling the next author which wins. + ## Decision **The realignment runs first inside Phase 1, ahead of `/pair-capability-verify-quality`, and the command it runs is @@ -44,6 +61,24 @@ read from the adoption, never named in the skill.** - The step commits **only** the generated paths, as **its own commit**, and only when the command produced a diff. A no-op is **silent**: no commit, and no output row (the `Mirrors:` row is emitted only when a commit was made). +- **The staged set is the command's own effect, not a path glob.** The skill snapshots + `git status --porcelain` before running the command and again after, and stages exactly the paths + whose entry appeared, disappeared or changed. A glob is a guess about the command and is wrong + wherever generated output and authored files share a prefix; the comparison cannot be, because a + file the run did not touch has an identical entry in both snapshots. Corollary: **no adopter has + to enumerate owned globs anywhere**, and this file's own list of written trees is descriptive + only. +- **A thin script whose behaviour IS the deliverable may be exercised from vitest**, black-box, + against a throwaway fixture — a bounded, documented exception to ADL 2026-07-13, which otherwise + stands unchanged. Conditions, all of them: the script holds no logic that could be extracted to a + module (extract it instead, per that ADL); the fixture is disposable and never the real repo; and + the test asserts observable behaviour (exit status, stderr reason, files on disk), never the + script's source text. The smoke suite is not the right home for these: `scripts/smoke-tests/` + exercises the *published* CLI end to end, and these cases deliberately point `TOOLCHAIN_ROOT` at a + broken tree (no turbo, a failing build, a build that writes no `dist/cli.js`) — situations a smoke + scenario over a working installation cannot produce. Applies to + `packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts` and, retroactively, to + `run-format.test.ts`, which already had this shape unrecorded. - A non-zero exit from the command **HALTs** before any PR side effect — the same shape as the gate-red HALT it now precedes. - This project declares `mirror-realign-command: pnpm mirrors:regenerate`. @@ -60,6 +95,16 @@ read from the adoption, never named in the skill.** separately-committed step, not a hook side effect. - **Fold the realignment into `pnpm format`**: the other option that ADL left open, and declined with it — formatting must stay formatting, and must not reach outside format scope. +- **Stage by owned-path glob (the first draft)**: rejected — see Context 3. The glob covers authored + files in this repo, and any adopter would have to enumerate its own, correctly, for a rule whose + failure mode is committing someone else's work. +- **Move the script tests to `scripts/smoke-tests/`**: rejected — see Context 4. The suite would lose + the broken-toolchain cases outright (a smoke scenario runs against a working install), and vitest + is where the assertions and the fixture helpers already live. +- **Extract `regenerate-mirrors.sh`'s logic into a module and unit-test that**: rejected — there is + no logic to extract. The script's content is argument resolution and five fail-loud guards over + the filesystem and a subprocess; a module wrapping them would be tested through the same + filesystem fixtures, one indirection further from what actually runs. ## Consequences @@ -75,7 +120,15 @@ read from the adoption, never named in the skill.** ## Adoption Impact - `adoption/tech/way-of-working.md` → `## Quality Gates`: declare `mirror-realign-command` - (`pnpm mirrors:regenerate`) and state the absent-⇒-skipped default. + (`pnpm mirrors:regenerate`), state the absent-⇒-skipped default, mark the written-tree list as + descriptive rather than a staging rule, and state the writer/checker scope asymmetry (the guards + check the dataset-sourced mirrors; the command additionally rewrites skill references across the + whole installed tree, which nothing verifies). +- `adoption/tech/way-of-working.md` → `## Quality Gates` → "Gate & tooling code": record the bounded + vitest exception above next to the rule it qualifies, so the two are read together. +- [2026-07-13-gate-tooling-code-in-tested-modules.md](./2026-07-13-gate-tooling-code-in-tested-modules.md): + unchanged in force; its "scripts are never unit-tested" clause gains a pointer to this record's + bounded exception. - `packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md`: Phase 1 renamed and extended, `Adoption Inputs` gains the key, `Output Format` gains the conditional `Mirrors:` row, `HALT Conditions` gains the command-failed HALT, `Graceful Degradation` gains the absent-key skip. diff --git a/.pair/adoption/tech/way-of-working.md b/.pair/adoption/tech/way-of-working.md index 9520263ae..381e54afb 100644 --- a/.pair/adoption/tech/way-of-working.md +++ b/.pair/adoption/tech/way-of-working.md @@ -85,8 +85,8 @@ Resolution order, the split-tool routing and why the fallback is never the authe - **Review enforcement**: `disabled` (default) — the pair review **runs and publishes its verdict**, but nothing it says blocks a merge: `pair-review` and `pair-explicit-approval` are not required status checks, and the 🔴 explicit-approval rule is advisory. Set to `enabled` to make them required and the rule binding, per [pr-states.md](../../knowledge/guidelines/collaboration/project-management-tool/pr-states.md); `/pair-capability-setup-gates` reads this flag before touching branch protection, and `/pair-process-bootstrap` asks for it when no decision exists. Disabled is the default deliberately: a review that blocks by default turns a first install into a repository nobody can merge into — on a single-maintainer repo the 🔴 non-author approval is unobtainable outright. The tier requirements themselves (reviewer count, SLA, checklist depth, whether 🔴 needs explicit approval) are redefinable in this file; that the review **runs** is not. - **Coverage guardrail**: `enabled` — pair dogfoods its own capability: the [`Coverage guardrail` step](../../../.github/workflows/ci.yml) in CI sources [`coverage-gate.sh`](../../knowledge/assets/coverage-gate.sh), extracts the line-coverage % from each package's istanbul `coverage-summary.json`, and blocks a PR whose coverage drops below the human-committed baseline in [`tech/coverage-baseline.md`](./coverage-baseline.md) (maintaining/improving passes — not an absolute wall). The framework **default remains `disabled`** (the dataset template ships off); this line is pair's project-level opt-in only. See [coverage guardrail](../../knowledge/guidelines/infrastructure/cicd-strategy/tier-aware-pipeline.md#coverage-guardrail-opt-in-regression-gate-consumed-by-this-pipeline) + [config format](../../knowledge/assets/coverage-config-example.md); `/pair-capability-setup-gates` reads this flag before generating the pipeline. **Coverage baseline commit-back**: `disabled` — the separate, nested opt-in ratchet (#372, framework default also `disabled`): when `enabled`, a **push to the base branch** (never a PR run, never a fork) proposes a raised `baseline.` as a **bot pull request** from `chore/coverage-baseline-ratchet`, never a push to `main`, and requires a repo-scoped `COVERAGE_RATCHET_TOKEN` (`contents: write` + `pull requests: write`, no protection bypass) — without it the step warns and the gate's verdict is unchanged. It stays `disabled` here until story #234's branch protection is applied and that secret is provisioned (ADR-018 lands with that story, so it is not linked from here yet); see ADL [2026-07-30-coverage-ratchet-pr-not-push.md](../decision-log/2026-07-30-coverage-ratchet-pr-not-push.md). The step that runs it is the **shipped** KB asset `node .pair/knowledge/assets/coverage-ratchet.cjs` (ADR-023) — the same one an adopter's generated pipeline invokes, so this flag being `enabled` means the same thing here as anywhere else. - **Pair review required checks**: `pair-review` + `pair-explicit-approval` are the required status checks that make the judgment review unskippable (R5.7) and enforce the 🔴 explicit-human-approval rule (D10) — see [pr-states.md](../../knowledge/guidelines/collaboration/project-management-tool/pr-states.md) and [ADR-018](adr/adr-018-pr-state-flow-required-checks.md). Status on this repo: **not yet applied** — writing branch protection needs admin scope, so it is a deliberate human step; until applied, enforcement here is advisory (the documented degraded mode). **Ordering constraint** (applies in this order, or every merge stops): 1. provision the `pr-state:*` labels + add the `pair-explicit-approval` workflow (neither needs admin scope — this repo has not added the workflow yet, so the context does not report today); 2. confirm on a real PR that `pair-review` and `pair-explicit-approval` both report on the head commit, **and** that the approval context re-reports on that same head SHA after a review submission; 3. only then `PUT` the branch protection, keeping `enforce_admins` off until one PR has merged through it. The whole sequence (including the merge-block outcomes per tier) was executed on a throwaway repository — see `github-implementation.md` § "Verified on a throwaway repository" — so what remains here is applying it, not discovering whether it works. **This repo is single-maintainer**, so a 🔴 PR cannot satisfy `pair-explicit-approval` (GitHub rejects a self-approval): a second human reviewer account is a prerequisite for making that context required here — otherwise leave it out of the required list and keep the 🔴 rule advisory. The solo-maintainer alternative (a verified human approval token instead of a second account) is tracked as [#398](https://github.com/foomakers/pair/issues/398). **When the protection is written here, use the `checks` form with `app_id` pinned** for `pair-explicit-approval` (an unpinned status context is satisfiable by any push-access token, including the agent's); `pair-review` stays unpinned and is an anti-accident control, not an authorization control — see `github-implementation.md` § "What each context proves". -- **`mirror-realign-command`**: `pnpm mirrors:regenerate` — the single, local, deterministic writer that realigns the generated mirrors (`.claude/**`, root `.pair/**`, `AGENTS.md`/`CLAUDE.md`, `.github/**`) with `packages/knowledge-hub/dataset`. It wraps the CLI's existing local-source path (`pair update --source --offline`) and adds no generation logic; it has **no check mode** — the mirror guards (`skills:conformance`) are the checker, this is the only writer. It is what `PRE_PUSH_REMEDY`, both mirror guards, `DEVELOPMENT.md` and its docs-site twin name, and what `/pair-capability-publish-pr` runs (Phase 1, before the gate) and commits separately when it produces a diff. Deliberately **not** `pair update`, which installs the latest PUBLISHED knowledge base and would make a local fix depend on what has been released. Absent this key, `/pair-capability-publish-pr` skips its realignment step entirely — the zero-configuration default, not a degradation. See ADL [2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md](../decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md) and story [#419](https://github.com/foomakers/pair/issues/419). -- **Gate & tooling code:** a gate's logic lives in a tested module in its owning package (white-box unit tests); scripts/CLIs are thin entrypoints and a root gate delegates (`pnpm --filter `). Scripts are never unit-tested — CLI-level checks go to smoke tests. See ADL [2026-07-13-gate-tooling-code-in-tested-modules.md](../decision-log/2026-07-13-gate-tooling-code-in-tested-modules.md). Gate/tooling packages are organized by bounded context, not one package per tool family — a new tool family sharing an existing package's bounded context is a new folder there, not a new package. See [ADR-014](adr/adr-014-tool-package-boundary-by-bounded-context.md). +- **`mirror-realign-command`**: `pnpm mirrors:regenerate` — the single, local, deterministic writer that realigns the generated mirrors with `packages/knowledge-hub/dataset`. It writes into `.claude/**`, root `.pair/**`, `AGENTS.md`/`CLAUDE.md` and `.github/**` — **a description of where its output lands, never a staging rule**: those same trees hold authored files (117 tracked files under `.pair/adoption/**` alone), so anything that committed the glob rather than the command's actual effect would sweep a contributor's unstaged prose into a regeneration commit. `/pair-capability-publish-pr` therefore stages a **before/after `git status --porcelain` comparison**, and no adopter enumerates owned globs anywhere. It wraps the CLI's existing local-source path (`pair update --source --offline`) and adds no generation logic; it has **no check mode** — the mirror guards (`skills:conformance`) are the checker, this is the only writer. **Writer and checker are not the same scope, and the asymmetry is the writer's**: the guards check the **dataset-sourced** mirrors (a target-tree file with no counterpart in the dataset is compared to nothing), while this command additionally rewrites skill references across the whole installed tree, which nothing verifies. Evidence: commit `6655439d` regenerated `adr-021`, `adr-022`, `adr-023` and `collaborative-workflow.context.md` — four files with no dataset counterpart — after they had sat drifted on a green `main`. Drift in that region accumulates undetected and then lands, unrelated, in whichever PR next runs the writer. It is what `PRE_PUSH_REMEDY`, both mirror guards, `DEVELOPMENT.md` and its docs-site twin name, and what `/pair-capability-publish-pr` runs (Phase 1, before the gate) and commits separately when it produces a diff. Deliberately **not** `pair update`, which installs the latest PUBLISHED knowledge base and would make a local fix depend on what has been released. Absent this key, `/pair-capability-publish-pr` skips its realignment step entirely — the zero-configuration default, not a degradation. See ADL [2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md](../decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md) and story [#419](https://github.com/foomakers/pair/issues/419). +- **Gate & tooling code:** a gate's logic lives in a tested module in its owning package (white-box unit tests); scripts/CLIs are thin entrypoints and a root gate delegates (`pnpm --filter `). Scripts are never unit-tested — CLI-level checks go to smoke tests. See ADL [2026-07-13-gate-tooling-code-in-tested-modules.md](../decision-log/2026-07-13-gate-tooling-code-in-tested-modules.md). **One bounded exception** (#419): a thin script whose behaviour IS the deliverable, with no logic to extract, is black-box executed from vitest against a throwaway fixture, asserting observable behaviour only — `scripts/format-lib/run-format.sh` and `scripts/regenerate-mirrors.sh`. Conditions and why the smoke suite is not their home: ADL [2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md](../decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md). Gate/tooling packages are organized by bounded context, not one package per tool family — a new tool family sharing an existing package's bounded context is a new folder there, not a new package. See [ADR-014](adr/adr-014-tool-package-boundary-by-bounded-context.md). - **Conformance tests** (`packages/knowledge-hub/src/conformance/`): one test file per target KB artifact (a `SKILL.md`, guideline, or template), not per introducing story — a new story extends the matching file's `describe` block instead of adding a new story-named file. See ADL [2026-07-18-conformance-test-per-file-not-per-story.md](../decision-log/2026-07-18-conformance-test-per-file-not-per-story.md). - **Monorepo tooling gotchas** (e.g. `pnpm --filter` bypassing turbo's `dependsOn` graph on a fresh checkout): documented once, centrally, in `DEVELOPMENT.md`'s `Turbo Caching` section — affected packages' READMEs carry only a short pointer, not a full copy. See ADL [2026-07-18-workspace-gotcha-doc-placement.md](../decision-log/2026-07-18-workspace-gotcha-doc-placement.md). diff --git a/packages/dev-tools/src/quality-gates/pre-push-gate-composition.test.ts b/packages/dev-tools/src/quality-gates/pre-push-gate-composition.test.ts index 00530b9b9..9671259cd 100644 --- a/packages/dev-tools/src/quality-gates/pre-push-gate-composition.test.ts +++ b/packages/dev-tools/src/quality-gates/pre-push-gate-composition.test.ts @@ -147,9 +147,14 @@ describe('the pre-push gate never runs a write-mode step (#394)', () => { // cannot reach its generated .claude twin (not a workspace member), while skill-md-mirror // asserts byte equality — so format:check-green becomes skills:conformance-red later in // the SAME gate. Reproduced on the real MD049 drift this branch cleared. - it('the remedy warns that a dataset .skills edit needs the .claude mirror re-synced', () => { - expect(PRE_PUSH_REMEDY).toContain('packages/knowledge-hub/dataset/.skills/**') + it('the remedy warns that a dataset edit needs BOTH generated mirror trees re-synced', () => { + expect(PRE_PUSH_REMEDY).toContain('packages/knowledge-hub/dataset/**') expect(PRE_PUSH_REMEDY).toContain('.claude/skills/**') + // Naming only `.claude/skills/**` (the pre-#419-review shape) sends a contributor who + // reformatted a dataset GUIDELINE looking at the skills tree, where nothing changed — + // its twin is `.pair/knowledge/**`. Both documents already named both trees; this + // constant did not, which is the divergence the docblock above now scopes explicitly. + expect(PRE_PUSH_REMEDY).toContain('.pair/knowledge/**') expect(PRE_PUSH_REMEDY).toContain('skills:conformance') }) diff --git a/packages/dev-tools/src/quality-gates/pre-push-gate-composition.ts b/packages/dev-tools/src/quality-gates/pre-push-gate-composition.ts index f7be1fd07..e77dc0ac2 100644 --- a/packages/dev-tools/src/quality-gates/pre-push-gate-composition.ts +++ b/packages/dev-tools/src/quality-gates/pre-push-gate-composition.ts @@ -156,26 +156,34 @@ export const MIRROR_REMEDY_SCRIPT = 'mirrors:regenerate' /** * What a developer should run instead. Named in the failure so it is actionable. * - * The second step is not optional advice: `packages/knowledge-hub/dataset/.skills/**` - * IS in format scope (workspace package), while its generated twin - * `.claude/skills/pair-/**` is NOT (`.claude/` is not a workspace member), - * and `skill-md-mirror` asserts the two are byte-equal through the real `pair update` + * The second step is not optional advice: `packages/knowledge-hub/dataset/**` IS in + * format scope (workspace package), while its generated twins `.claude/skills/**` and + * root `.pair/knowledge/**` are NOT (neither is a workspace member), and the mirror + * guards assert each twin is byte-equal to the output of the real `pair update` * transform. So a format-only edit to the dataset copy turns a green `format:check` * into a red `skills:conformance` LATER IN THE SAME GATE. Advertising `pnpm format` * alone would hand the developer a loop back to `--no-verify`. Structural fix (one * format scope for both copies) is #414. * - * Kept byte-identical (modulo the ADL link form) to the same paragraph in - * `DEVELOPMENT.md` and `apps/website/content/docs/contributing/development-setup.mdx`, - * per ADL 2026-07-31 — the three copies are hand-kept, so a `diff` of the paragraph is - * the only signal that they have diverged. + * SCOPE OF THE BYTE-IDENTITY RULE (ADL 2026-07-31): the two hand-kept **documents** — + * `DEVELOPMENT.md` and `apps/website/content/docs/contributing/development-setup.mdx` — + * are byte-identical to each other modulo the ADL link form, and a `diff` of those two + * paragraph blocks is the only signal that they have diverged. This constant is NOT a + * third copy of that paragraph and is not diffed against them: it is the same remedy in + * the failure-message register, deliberately shorter. It carries the two-step remedy and + * both mirror trees (the substance); it drops the docs' exit-2 sentence, their + * `gate:composition` aside and the ADL link (none of which help at the failure), and it + * never spells `pair update` — the docs contrast the two commands for a reader, whereas a + * failure message that names an install command is exactly the dead advice #419 removed, + * which is why the unit test forbids the string here and not there. */ export const PRE_PUSH_REMEDY = 'Formatting is checked, not applied, before a push: run `pnpm format` and commit the result. ' + 'Applying it here could not fix the commits being pushed anyway. ' + - 'If `pnpm format` touched `packages/knowledge-hub/dataset/.skills/**`, re-sync the generated ' + - `\`.claude/skills/**\` copies (\`pnpm ${MIRROR_REMEDY_SCRIPT}\`) in the same commit, or ` + - '`skills:conformance` fails later in this same gate on the mirror-equality guard.' + 'If `pnpm format` touched `packages/knowledge-hub/dataset/**`, re-sync the generated ' + + `\`.claude/skills/**\` and \`.pair/knowledge/**\` copies (\`pnpm ${MIRROR_REMEDY_SCRIPT}\`) ` + + 'in the same commit, or `skills:conformance` fails later in this same gate on the ' + + 'mirror-equality guard.' /** Bounds the transitive expansion, so a cyclic or deep script graph terminates. */ const MAX_EXPANSION_DEPTH = 10 diff --git a/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts b/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts index d9736d88f..0f1996f6b 100644 --- a/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts +++ b/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts @@ -1,10 +1,13 @@ import { describe, it, expect, afterEach } from 'vitest' -import { execFileSync } from 'child_process' +import { execFileSync, spawn } from 'child_process' import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, + readdirSync, + copyFileSync, + chmodSync, existsSync, rmSync, realpathSync, @@ -26,28 +29,44 @@ import { REPO_ROOT } from './repo-root' const REGENERATE = resolve(REPO_ROOT, 'scripts/regenerate-mirrors.sh') interface RunResult { - status: number + /** `null` when the child was killed by a SIGNAL — never conflated with an exit code. */ + status: number | null stdout: string stderr: string } -function run(cwd: string, env?: Record): RunResult { +function run(cwd: string, env?: Record, script: string = REGENERATE): RunResult { try { - const stdout = execFileSync(REGENERATE, [], { + const stdout = execFileSync(script, [], { cwd, env: env ? { ...process.env, ...env } : process.env, }) return { status: 0, stdout: stdout.toString('utf-8'), stderr: '' } } catch (error) { - const e = error as { status: number; stdout?: Buffer; stderr?: Buffer } + const e = error as { status: number | null; stdout?: Buffer; stderr?: Buffer } return { - status: e.status, + status: e.status ?? null, stdout: e.stdout?.toString('utf-8') ?? '', stderr: e.stderr?.toString('utf-8') ?? '', } } } +/** + * "The script refused and said why", not merely "the script did not exit 0". + * + * `status !== 0` alone passes VACUOUSLY on a timeout kill: `execFileSync` reports a + * signalled child with `status === null`, and `null !== 0`. These cases were already + * observed flaking under parallel turbo load, which is exactly when a signal kill + * happens — so a suite asserting only `not.toBe(0)` would go green on the flake it was + * written to survive. + */ +function expectRefusal(result: RunResult, reason: string): void { + expect(result.status).not.toBe(0) + expect(result.status).not.toBeNull() + expect(result.stderr).toContain(reason) +} + function git(dir: string, args: string[]): string { return execFileSync('git', args, { cwd: dir }).toString('utf-8') } @@ -91,6 +110,40 @@ function makeFixture(): string { return dir } +/** + * A fixture where TOOLCHAIN_ROOT is the fixture too, not this repo. + * + * The script derives `TOOLCHAIN_ROOT` from its OWN location (`$(dirname $0)/..`), so the + * only way to exercise the toolchain branches — no turbo, build failure, build green but + * no `dist/cli.js` — is to run the REAL script from a copy inside the fixture's + * `scripts/`. It is copied byte-for-byte, never re-implemented: a divergence between the + * copy and `scripts/regenerate-mirrors.sh` would test a script nobody runs. + */ +function makeToolchainFixture(): string { + const dir = makeFixture() + const copy = join(dir, 'scripts/regenerate-mirrors.sh') + mkdirSync(dirname(copy), { recursive: true }) + copyFileSync(REGENERATE, copy) + chmodSync(copy, 0o755) + return dir +} + +/** A turbo the fixture owns, so the build step's outcome is the case under test. */ +function writeTurboStub(dir: string, body: string): void { + const stub = join(dir, 'node_modules/.bin/turbo') + write(stub, `#!/bin/sh\n${body}`) + chmodSync(stub, 0o755) +} + +/** Polls `condition` until it holds, so the interrupt lands mid-build, not before it. */ +async function waitUntil(condition: () => boolean, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs + while (!condition()) { + if (Date.now() > deadline) throw new Error('waitUntil: condition never became true') + await new Promise(resolve => setTimeout(resolve, 25)) + } +} + /** A HOME nobody shares, so a KB cache slot written by a download is visible. */ function isolatedHome(dir: string): Record { const home = join(dir, '.home') @@ -189,8 +242,7 @@ describe('regenerate-mirrors.sh — the local, deterministic mirror remedy (#419 const result = run(tmp, isolatedHome(tmp)) - expect(result.status).not.toBe(0) - expect(result.stderr).toContain('packages/knowledge-hub/dataset') + expectRefusal(result, 'packages/knowledge-hub/dataset') }) it('exits non-zero and names the reason outside a git working tree (AC7)', () => { @@ -198,10 +250,88 @@ describe('regenerate-mirrors.sh — the local, deterministic mirror remedy (#419 const result = run(tmp, { ...isolatedHome(tmp), GIT_CEILING_DIRECTORIES: tmp }) - expect(result.status).not.toBe(0) - expect(result.stderr).toContain('git') + // The full sentence, not just 'git': almost any failure mentions git, so the loose + // form would not distinguish this branch from an unrelated crash. + expectRefusal(result, 'not inside a git working tree') }) + it( + 'exits non-zero and names the reason when the toolchain has no turbo (AC7)', + () => { + tmp = makeToolchainFixture() + + const result = run(tmp, isolatedHome(tmp), join(tmp, 'scripts/regenerate-mirrors.sh')) + + // Softening `[ ! -x "$TURBO" ]` to a warning would let the script fall through to + // `exec node "$CLI"` against whatever stale dist/ is on disk — a regeneration with + // yesterday's transform, reported as success. That is the silent success AC-7 forbids. + expectRefusal(result, 'run `pnpm install` first') + }, + SCRIPT_RUN_TIMEOUT_MS, + ) + + it( + 'exits non-zero and names the reason when the CLI build fails (AC7)', + () => { + tmp = makeToolchainFixture() + writeTurboStub(tmp, 'echo "TS2304: build exploded" >&2\nexit 1\n') + + const result = run(tmp, isolatedHome(tmp), join(tmp, 'scripts/regenerate-mirrors.sh')) + + expectRefusal(result, 'could not build the pair CLI — nothing was regenerated') + // The build's own output is forwarded, or the developer gets a verdict with no cause. + expect(result.stderr).toContain('TS2304: build exploded') + }, + SCRIPT_RUN_TIMEOUT_MS, + ) + + it( + 'exits non-zero when the build claims success but produced no CLI (AC7)', + () => { + tmp = makeToolchainFixture() + writeTurboStub(tmp, 'exit 0\n') // green build, no dist/cli.js written + + const result = run(tmp, isolatedHome(tmp), join(tmp, 'scripts/regenerate-mirrors.sh')) + + // Dropping this post-build check is the worst of the four: `exec node "$CLI"` on a + // stale dist/ regenerates with yesterday's transform and EXITS 0, over output the + // guards still reject. + expectRefusal(result, 'the build reported success but') + expect(result.stderr).toContain('apps/pair-cli/dist/cli.js') + }, + SCRIPT_RUN_TIMEOUT_MS, + ) + + it( + 'removes its temporary build log when INTERRUPTED mid-build (no TMPDIR leak)', + async () => { + // The failure and success paths already `rm` the log explicitly. The gap is the + // window between `mktemp` and those `rm`s: a Ctrl-C there (or a CI job cancelled + // during the turbo build, which is where the seconds are spent) leaks one file into + // TMPDIR per interrupted run. Only the EXIT/HUP/INT/TERM trap closes it, so this + // case interrupts a real, slow build rather than an already-terminated one. + tmp = makeToolchainFixture() + writeTurboStub(tmp, 'sleep 30\n') + const tmpEnvDir = join(tmp, '.tmpdir') + mkdirSync(tmpEnvDir, { recursive: true }) + + // `detached` so the signal reaches the whole group: sh defers a TERM trap until the + // foreground command returns, and the foreground command here is the sleeping build. + const child = spawn(join(tmp, 'scripts/regenerate-mirrors.sh'), [], { + cwd: tmp, + detached: true, + env: { ...process.env, ...isolatedHome(tmp), TMPDIR: tmpEnvDir }, + }) + const exited = new Promise(resolve => child.on('close', () => resolve())) + await waitUntil(() => readdirSync(tmpEnvDir).length === 1) + process.kill(-(child.pid as number), 'SIGTERM') + await exited + + expect(readdirSync(tmpEnvDir)).toEqual([]) + }, + SCRIPT_RUN_TIMEOUT_MS, + ) + it('has no check mode — one writer, one checker (AC8)', () => { const source = readFileSync(REGENERATE, 'utf-8') expect(source).not.toMatch(/--check\b/) diff --git a/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md b/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md index 11be438fd..b64958647 100644 --- a/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md +++ b/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md @@ -61,11 +61,11 @@ The realignment runs **before** the gate, and the order is load-bearing in both 1. **Check**: Does the adoption declare a `mirror-realign-command`? 2. **Skip**: If it does not, go to step 5. A project with no generated mirrors has nothing to realign — the zero-configuration default, not a degradation, and nothing is reported. -3. **Act**: Run the declared command. It regenerates the mirrors from the working tree's **local** dataset — never a published release — and is idempotent. A **non-zero exit → HALT** before any PR side effect, reporting the command's own reason verbatim: it never reports success over a no-op, so a failure here means nothing was written and the drift is still there. -4. **Check → Act**: Read `git status --porcelain` for the generated paths the command owns. - - **No change** → a no-op stays **silent**: no commit, and no output row (the `Mirrors:` row is emitted only when a commit was made). Continue to step 5. - - **Changed** → stage **only** those paths — never `git add -A`: unstaged authored changes belong to the contributor and must survive the run untouched, and this skill must not commit them — and commit them **alone**, as their own commit, never mixed into a feature commit. Name it as a *regeneration* (e.g. `chore: regenerate mirrors from local dataset`), never a "fix": an overwritten hand-edit was restored to what the dataset generates, not repaired. Drift in a file this branch never touched is committed here too, and **said so in the output** — surprising, but better than pushing knowingly stale generated output, and the separate commit keeps even a dataset-wide regeneration readable next to the authored work. - - **Verify**: `git log` shows exactly one new commit, and `git status` still shows every pre-existing unstaged authored change, untouched. +3. **Act**: Take the **before** snapshot — `git status --porcelain`, whole tree — and only then run the declared command. It regenerates the mirrors from the working tree's **local** dataset — never a published release — and is idempotent. A **non-zero exit → HALT** before any PR side effect, reporting the command's own reason verbatim: it never reports success over a no-op, so a failure here means nothing was written and the drift is still there. +4. **Check → Act**: Take the **after** snapshot (`git status --porcelain` again) and compare it with the before snapshot. **The set to stage is what this run actually wrote** — every path whose porcelain entry appeared, disappeared or changed between the two reads — and it is derived that way rather than from a **path glob** of "the paths the command owns" deliberately. A glob is a *guess about the command*, and it is wrong wherever generated output and authored files share a prefix — the ordinary case, since the directory holding a project's generated mirrors is usually the same one holding its hand-written adoption/knowledge files. Under a glob, a contributor who left an authored file dirty beneath that prefix gets it committed under `chore: regenerate mirrors from local dataset` — their prose, under a regeneration message, in a commit they never wrote. The before/after comparison cannot do that: a file this run did not touch has an identical entry in both snapshots. It also means **no adopter has to enumerate owned globs anywhere** — the command's own effect is the declaration. + - **No change** → the two snapshots are equal; a no-op stays **silent**: no commit, and no output row (the `Mirrors:` row is emitted only when a commit was made). Continue to step 5. + - **Changed** → stage **only** the paths that comparison produced — never `git add -A`, and never a glob: unstaged authored changes belong to the contributor and must survive the run untouched, and this skill must not commit them — and commit them **alone**, as their own commit, never mixed into a feature commit. Name it as a *regeneration* (e.g. `chore: regenerate mirrors from local dataset`), never a "fix": an overwritten hand-edit was restored to what the dataset generates, not repaired. Drift in a file this branch never touched is committed here too, and **said so in the output** — surprising, but better than pushing knowingly stale generated output, and the separate commit keeps even a dataset-wide regeneration readable next to the authored work. + - **Verify**: `git log` shows exactly one new commit, its file list equals the before/after difference exactly, and `git status` still shows every pre-existing unstaged authored change, untouched. 5. **Act**: Compose `/verify-quality` with `$scope` (default `all`). This is a local pre-flight, not a replacement for CI (CI stays authoritative, #210). 6. **Check**: Did every required gate pass? 7. **Skip**: If all gates pass, proceed to Phase 2. @@ -226,7 +226,7 @@ See [graceful degradation](../../../.pair/knowledge/guidelines/technical-standar ## Notes -- This skill **creates git-host artifacts** (a pushed branch, one PR, a pending `pair-review` check, a `pr-state:*` label) and updates board state — it does not modify source files, never renders a review verdict, and never merges. +- This skill **creates git-host artifacts** (a pushed branch, one PR, a pending `pair-review` check, a `pr-state:*` label) and updates board state. It modifies files **only** through the adoption-declared `mirror-realign-command` (Phase 1) — generated content, staged as the before/after comparison computed it, in its own commit — and writes nothing else in the working tree; it never renders a review verdict, and never merges. Read the two together: Phase 1 is the single, bounded exception, not a contradiction of this bullet, and it is skipped entirely when no command is declared. - **Gate ≠ review** ([pr-states.md](../../../.pair/knowledge/guidelines/collaboration/project-management-tool/pr-states.md)): the Phase 1 gate is mechanical; the judgment verdict belongs to `/review`, dispatched here on a clean context and enforced by the required `pair-review` check (R5.7). - **Idempotent** — see [idempotency convention](../../../.pair/knowledge/guidelines/technical-standards/ai-development/skill-conventions/idempotency.md). Re-invocation detects the existing PR and updates it in place; re-runs the gate (fast if already green); re-parses the handoff. Never a duplicate PR. - Tag propagation is a **copy**; the authoritative classification is (re)done in `/review` (G6). diff --git a/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts b/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts index a19c9f9ec..955c7753f 100644 --- a/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts +++ b/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts @@ -7,6 +7,7 @@ import { buildSkillLinkPathMap, applyKnownMirrorTransforms, } from '../tools/skills-guide-mirror' +import { MIRROR_REGENERATE_COMMAND } from '../tools/skill-md-mirror' import { sectionBetween } from './test-utils' // Conformance guard for story #419: /publish-pr realigns the generated mirrors from @@ -27,6 +28,7 @@ const DATASET = join(__dirname, '../../dataset/.skills/capability/publish-pr/SKI const MIRROR = join(__dirname, '../../../../.claude/skills/pair-capability-publish-pr/SKILL.md') const SKILLS_DIR = join(__dirname, '../../dataset/.skills') const WAY_OF_WORKING = join(__dirname, '../../../../.pair/adoption/tech/way-of-working.md') +const ROOT_PACKAGE_JSON = join(__dirname, '../../../../package.json') const dataset = (): string => readFileSync(DATASET, 'utf-8') const mirror = (): string => readFileSync(MIRROR, 'utf-8') @@ -35,6 +37,18 @@ const mirror = (): string => readFileSync(MIRROR, 'utf-8') const phase1 = (): string => sectionBetween(dataset(), '### Phase 1:', '### Phase 2: Resolve Merge Strategy') +/** + * The `## Notes` section. It is the file's LAST section, so it runs to EOF and + * `sectionBetween` (which needs an end marker) does not apply — this fails closed the + * same way, by throwing when the heading is gone. + */ +const notes = (): string => { + const content = dataset() + const start = content.indexOf('## Notes') + if (start === -1) throw new Error('publish-pr SKILL.md: `## Notes` heading not found') + return content.slice(start) +} + describe('publish-pr realigns mirrors before its gate (#419)', () => { it('runs the realignment inside Phase 1, ahead of the /verify-quality composition', () => { const p1 = phase1() @@ -73,10 +87,29 @@ describe('publish-pr realigns mirrors before its gate (#419)', () => { const p1 = phase1() expect(p1).toContain('git add -A') expect(p1).toMatch(/never `git add -A`/) - expect(p1).toMatch(/stage \*\*only\*\* those paths/) + expect(p1).toMatch(/stage \*\*only\*\* the paths that comparison produced/) expect(p1).toMatch(/never mixed into a feature commit/) }) + it('derives the staged set from a BEFORE/AFTER porcelain comparison, never from a path glob', () => { + // The rule this replaces staged "the generated paths the command owns", resolved + // through the adoption's owned-path globs. In this repository those globs include + // root `.pair/**`, which holds 117 tracked AUTHORED files under `.pair/adoption/**` + // (`git ls-files .pair/adoption | wc -l` -> 117). A contributor who edits + // `.pair/adoption/tech/way-of-working.md`, leaves it unstaged and runs the skill + // would have their prose committed under `chore: regenerate mirrors from local + // dataset` — a commit they never wrote — contradicting this same phase's + // "unstaged authored changes ... must survive the run untouched". + const p1 = phase1() + expect(p1).toMatch(/\*\*before\*\* snapshot — `git status --porcelain`/) + expect(p1).toMatch(/\*\*after\*\* snapshot \(`git status --porcelain` again\)/) + expect(p1).toMatch(/appeared, disappeared or changed between the two reads/) + expect(p1).toMatch(/rather than from a \*\*path glob\*\*/) + expect(p1).toMatch(/and never a glob/) + // The portability payoff, stated where the rule is: no adopter enumerates globs. + expect(p1).toMatch(/no adopter has to enumerate owned globs anywhere/) + }) + it('names the commit a regeneration, never a fix (an overwritten hand-edit was restored)', () => { const p1 = phase1() expect(p1).toMatch(/regenerate mirrors from local dataset/) @@ -108,6 +141,19 @@ describe('publish-pr realigns mirrors before its gate (#419)', () => { expect(c).toMatch(/exits non-zero\*\* \(Phase 1\)[\s\S]{0,200}no PR side effects/) }) + it('Notes carve the Phase-1 write out instead of denying it', () => { + // A skill whose behaviour IS its prose cannot carry a normative "does not modify + // source files" in Notes while Phase 1 writes and commits files: an agent or + // maintainer reconciling the two can conclude the realignment is out of contract + // and skip or delete it. The Notes bullet must name the exception. + const n = notes() + expect(n).not.toMatch(/it does not modify source files/) + expect(n).toMatch( + /modifies files \*\*only\*\* through the adoption-declared `mirror-realign-command`/, + ) + expect(n).toMatch(/never renders a review verdict, and never merges/) + }) + it('installed mirror is reproducible from the dataset via the real transform', () => { // Same whole-file guarantee implement-compose-close.test.ts asserts: the mirror must // equal the dataset run through the `pair update` copy pipeline (frontmatter `name` @@ -133,4 +179,22 @@ describe("this repository's own wiring for the realignment (#419)", () => { const gates = sectionBetween(wow, '## Quality Gates', '### Review Tier Matrix') expect(gates).toContain('`mirror-realign-command`') }) + + it('every mirror guard prints a command the root package.json actually defines', () => { + // MIRROR_REGENERATE_COMMAND is the copy of the script name that had NO guard tying + // it to package.json. `gate:composition` covers dev-tools' MIRROR_REMEDY_SCRIPT and + // the test above covers the way-of-working literal, so renaming the script to + // `mirrors:sync` in package.json + MIRROR_REMEDY_SCRIPT + way-of-working.md left + // both green while every mirror-guard failure still printed + // "Regenerate with 'pnpm mirrors:regenerate'" — a dead command, the exact class + // gate:composition exists to prevent for the other remedy step. This closes it from + // this side: the two packages now both fail against the same package.json. + const rootPkg = JSON.parse(readFileSync(ROOT_PACKAGE_JSON, 'utf-8')) as { + scripts?: Record + } + const runner = 'pnpm ' + expect(MIRROR_REGENERATE_COMMAND.startsWith(runner)).toBe(true) + const script = MIRROR_REGENERATE_COMMAND.slice(runner.length) + expect(Object.keys(rootPkg.scripts ?? {})).toContain(script) + }) }) diff --git a/packages/knowledge-hub/src/conformance/web-cloud-environment.test.ts b/packages/knowledge-hub/src/conformance/web-cloud-environment.test.ts index 6dec181d9..f493cce39 100644 --- a/packages/knowledge-hub/src/conformance/web-cloud-environment.test.ts +++ b/packages/knowledge-hub/src/conformance/web-cloud-environment.test.ts @@ -567,6 +567,10 @@ describe("turbo.json keeps each package's #test / #test:coverage inputs in sync" '$TURBO_ROOT$/.claude-plugin/marketplace.json', '$TURBO_ROOT$/.pair/**', '$TURBO_ROOT$/apps/pair-cli/config.json', + // #419: mirror-realignment.test.ts asserts MIRROR_REGENERATE_COMMAND names a script + // the ROOT package.json actually defines. Renaming that script touches no file in + // this package, so without this entry the guard replays a cached PASS over dead advice. + '$TURBO_ROOT$/package.json', '$TURBO_ROOT$/apps/website/content/docs/**', '$TURBO_ROOT$/apps/website/e2e/docs.e2e.test.ts', '$TURBO_ROOT$/qa/**', @@ -577,7 +581,12 @@ describe("turbo.json keeps each package's #test / #test:coverage inputs in sync" }, { pkg: '@pair/dev-tools', - requiredInputs: ['$TURBO_DEFAULT$', '$TURBO_ROOT$/scripts/format-lib/**'], + // #419 widened `scripts/format-lib/**` to `scripts/**`: regenerate-mirrors.test.ts + // execFileSyncs scripts/regenerate-mirrors.sh the same way run-format.test.ts does its + // script, and a per-script list degrades SILENTLY (a stale PASS) the next time one is + // added without it. `package.json` is required because pre-push-gate-composition.test.ts + // runs checkRootGate against the REAL root manifest. + requiredInputs: ['$TURBO_DEFAULT$', '$TURBO_ROOT$/scripts/**', '$TURBO_ROOT$/package.json'], }, ] diff --git a/scripts/regenerate-mirrors.sh b/scripts/regenerate-mirrors.sh index 5343b681b..5927df595 100755 --- a/scripts/regenerate-mirrors.sh +++ b/scripts/regenerate-mirrors.sh @@ -15,7 +15,16 @@ # `source-resolution` smoke scenario exercises. No generation logic lives here. # # There is no check mode. The mirror-equality guards (`pnpm skills:conformance`) -# are the checker; this is the only writer — one writer, one checker. +# are the checker; this is the only writer. +# +# The two scopes are NOT symmetric, and pretending otherwise hides real drift: the +# guards check the DATASET-SOURCED mirrors (a file with no counterpart in +# `packages/knowledge-hub/dataset` is compared to nothing), while this command +# additionally rewrites skill references across the whole installed tree. Observed: +# commit 6655439d regenerated adr-021, adr-022, adr-023 and +# collaborative-workflow.context.md — four files that exist only in the target tree — +# after they had sat drifted on a green `main`. So in that region drift accumulates +# undetected until whichever run of this writer comes next, and lands there. # # Two roots, and they are not the same thing: # TOOLCHAIN_ROOT — where this script and the CLI that does the work live. @@ -60,6 +69,12 @@ BUILD_LOG="$(mktemp "${TMPDIR:-/tmp}/regenerate-mirrors.XXXXXX")" || { echo "regenerate-mirrors: cannot create a temporary file (checked TMPDIR=${TMPDIR:-/tmp})." >&2 exit 1 } +# The explicit `rm`s below cover the paths this script controls; the trap covers the one +# it does not — Ctrl-C or a SIGTERM between `mktemp` and the `rm`, which would otherwise +# leak a file into TMPDIR on every interrupted run. It cannot cover the final `exec` +# (which replaces this process), which is why the success path still removes the log +# itself before reaching it. +trap 'rm -f "$BUILD_LOG"' EXIT HUP INT TERM if ! (cd "$TOOLCHAIN_ROOT" && "$TURBO" run build --filter=@pair/pair-cli...) >"$BUILD_LOG" 2>&1; then cat "$BUILD_LOG" >&2 rm -f "$BUILD_LOG" diff --git a/turbo.json b/turbo.json index c736c849c..f3aead2ff 100644 --- a/turbo.json +++ b/turbo.json @@ -45,6 +45,7 @@ "$TURBO_ROOT$/.claude-plugin/marketplace.json", "$TURBO_ROOT$/.pair/**", "$TURBO_ROOT$/apps/pair-cli/config.json", + "$TURBO_ROOT$/package.json", "$TURBO_ROOT$/apps/website/content/docs/**", "$TURBO_ROOT$/apps/website/e2e/docs.e2e.test.ts", "$TURBO_ROOT$/qa/**", @@ -71,6 +72,7 @@ "$TURBO_ROOT$/.claude-plugin/marketplace.json", "$TURBO_ROOT$/.pair/**", "$TURBO_ROOT$/apps/pair-cli/config.json", + "$TURBO_ROOT$/package.json", "$TURBO_ROOT$/apps/website/content/docs/**", "$TURBO_ROOT$/apps/website/e2e/docs.e2e.test.ts", "$TURBO_ROOT$/qa/**", @@ -87,15 +89,22 @@ // logic would therefore replay a cached PASS on this test locally — the identical // local-false-green class this same PR's knowledge-hub#test entry above exists to close, // for the one test file in this PR that most needs to actually run. + // WIDENED to scripts/** (#419): regenerate-mirrors.test.ts exercises + // scripts/regenerate-mirrors.sh the same way, so a per-script list would have to grow with + // every new one and degrades SILENTLY (stale PASS) when it does not — the same reason the + // note at the top of this file gives for preferring over-approximation. $TURBO_ROOT$/package.json + // is here because pre-push-gate-composition.test.ts runs checkRootGate against the REAL root + // package.json: deleting the `mirrors:regenerate` script is exactly the dead-advice regression + // that guard exists to catch, and it touches no file under packages/dev-tools. "@pair/dev-tools#test": { "dependsOn": ["build"], "outputs": [], - "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/scripts/format-lib/**"] + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/scripts/**", "$TURBO_ROOT$/package.json"] }, "@pair/dev-tools#test:coverage": { "dependsOn": ["build"], "outputs": ["coverage/**"], - "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/scripts/format-lib/**"] + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/scripts/**", "$TURBO_ROOT$/package.json"] }, "ts:check": { "dependsOn": ["^build"], From d750ba3360a3bbdbc4577c2be181ebfdd3555a14 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Tue, 1 Sep 2026 16:38:02 +0200 Subject: [PATCH 09/14] [#419] fix: drop two vacuous assertions; name the toolchain-in-worktree rule (review round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minor findings 86783a82 did not reach. mirror-guard.test.ts cast `find(...)` to string: reword the guard's remedy line and the test dies with `TypeError: Cannot read properties of undefined` instead of naming the broken contract. Asserted, not cast. mirror-realignment.test.ts asserted `toContain('git add -A')` next to the negated form — satisfied by the exact prose it exists to forbid ("stage everything with `git add -A`"). Deleted; the regex proves presence and polarity. ADL's rejected alternative said "Phase 2, after the push" — nothing is pushed at Phase 2 (the PR is created in Phase 4), so a later reader is given a reason that is not the one the same paragraph gives. Now "after the gate". regenerate-mirrors.sh: TOOLCHAIN_ROOT is the script's own tree, so a fresh `git worktree add` (no node_modules) exits 1 and publish-pr HALTs. Verified, and intended — that tree fails the gate one step later anyway. Header says so. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UQJzGMhRqBRRboxMrRqFPP --- ...6-09-01-publish-pr-realigns-mirrors-before-the-gate.md | 2 +- .../src/conformance/mirror-realignment.test.ts | 4 +++- packages/knowledge-hub/src/tools/mirror-guard.test.ts | 8 +++++--- scripts/regenerate-mirrors.sh | 7 +++++++ 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md b/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md index 2d9dd3dbb..cfae980bf 100644 --- a/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md +++ b/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md @@ -85,7 +85,7 @@ read from the adoption, never named in the skill.** ## Alternatives Considered -- **Step in Phase 2, after the push (the card's proposal)**: unreachable on drift, because Phase 1 +- **Step in Phase 2, after the gate (the card's proposal)**: unreachable on drift, because Phase 1 HALTs first; and it would leave the gate's verdict describing a tree the PR does not contain. - **Hardcode `pnpm mirrors:regenerate` in the skill**: makes a repo-specific script part of a distributed corpus. Every adopter would get a step that fails or does nothing. diff --git a/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts b/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts index 955c7753f..718205799 100644 --- a/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts +++ b/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts @@ -85,7 +85,9 @@ describe('publish-pr realigns mirrors before its gate (#419)', () => { it('commits the generated paths ALONE, and never stages the whole tree', () => { const p1 = phase1() - expect(p1).toContain('git add -A') + // Only the negated form is asserted: a bare `toContain('git add -A')` is satisfied by + // the exact prose this test exists to forbid ("stage everything with `git add -A`"), + // so it cannot distinguish the rule from its inverse. The regex below proves both. expect(p1).toMatch(/never `git add -A`/) expect(p1).toMatch(/stage \*\*only\*\* the paths that comparison produced/) expect(p1).toMatch(/never mixed into a feature commit/) diff --git a/packages/knowledge-hub/src/tools/mirror-guard.test.ts b/packages/knowledge-hub/src/tools/mirror-guard.test.ts index ccc1325ac..6de4367ab 100644 --- a/packages/knowledge-hub/src/tools/mirror-guard.test.ts +++ b/packages/knowledge-hub/src/tools/mirror-guard.test.ts @@ -543,9 +543,11 @@ describe('assertMirrorMatches — failure paths and message (#393)', () => { // the working tree's own dataset and nothing else. it('names the LOCAL regeneration command, never the published-KB install (#419)', () => { const message = captureThrownMessage(() => assertKb(REL, expected, 'drifted\n')) - const remedyLine = message - .split('\n') - .find(line => line.startsWith('Regenerate with')) as string + const remedyLine = message.split('\n').find(line => line.startsWith('Regenerate with')) + // Asserted, not cast: reword the guard's remedy line and `find` returns undefined, so + // a cast would surface the break as `TypeError: Cannot read properties of undefined` + // instead of naming the contract that broke — the remedy line must still be there. + expect(remedyLine).toBeDefined() expect(remedyLine).toContain(MIRROR_REGENERATE_COMMAND) expect(remedyLine).not.toContain('pair update') }) diff --git a/scripts/regenerate-mirrors.sh b/scripts/regenerate-mirrors.sh index 5927df595..004df6a00 100755 --- a/scripts/regenerate-mirrors.sh +++ b/scripts/regenerate-mirrors.sh @@ -33,6 +33,13 @@ # happy path exercisable against a throwaway fixture instead of the real repo — # the same split `scripts/format-lib/run-format.sh` already uses. # +# Consequence, and it is intended: the toolchain must be installed in the tree being +# realigned. A freshly created linked worktree (`git worktree add`, the shape pair's own +# automation uses) has no `node_modules/`, so this exits 1 with "run `pnpm install` +# first" — and `/pair-capability-publish-pr` HALTs on that before creating the PR. That +# tree cannot pass the quality gate one step later either, so the outcome is the same +# either way; refusing here just names the cause with the shorter message. +# # Exit codes: # 0 — the mirrors match the local dataset (regenerated, or already in sync) # 1 — broken: no git working tree, no dataset, no toolchain, or the CLI failed. From 92fd56d55c51248ca17929a82af2ad681eb708c2 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Tue, 1 Sep 2026 17:00:37 +0200 Subject: [PATCH 10/14] [#419] fix: digest the pre-dirty paths; make dev-tools#test depend on the CLI it runs (review round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minor 1 — publish-pr Phase 1 compared two `git status --porcelain` snapshots to decide what the realign command wrote. A porcelain entry encodes STATUS, not content: HEAD carries a drifted mirror, the contributor holds an uncommitted edit to that same file, the command regenerates it — same unstaged-modified entry on both reads. The agent read NO CHANGE: hand-edit destroyed with nothing reported, stale mirror still pushed, `skills:conformance` red on the PR this step exists to keep green. And the step-4 Verify ("git status still shows every pre-existing unstaged authored change") PASSED on exactly that state. Now the before snapshot carries a `git hash-object` digest of every dirty path; the staged set adds the pre-dirty paths whose digest moved; those are named on the `Mirrors:` row (`overwrote uncommitted changes in: `); the Verify reads the digest, not the listing. Measured against the real script: regenerate-mirrors.test.ts, 'overwrites a pre-dirty mirror while `git status --porcelain` stays byte-identical'. Minor 2 — regenerate-mirrors.test.ts AC1/AC2 build and run apps/pair-cli and assert the real `pair update --source` transform, but @pair/dev-tools declares no dependency on @pair/pair-cli, so no input or task edge covered that tree. Measured at 0a6712e3: a comment appended to apps/pair-cli/src/registry/skill-refs.ts replayed `1 cached, 125ms >>> FULL TURBO`. `dependsOn: ["build", "@pair/pair-cli#build"]` on #test and #test:coverage — a task edge follows the whole closure and cannot go stale like a hand-listed set of trees. After: 2 cached / 4; content-ops probe 0 cached. web-cloud-environment.test.ts gains a requiredDependsOn guard, mutation-proven. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UQJzGMhRqBRRboxMrRqFPP --- .../pair-capability-publish-pr/SKILL.md | 11 +- ...ish-pr-realigns-mirrors-before-the-gate.md | 22 ++++ .../quality-gates/regenerate-mirrors.test.ts | 60 ++++++++++ .../.skills/capability/publish-pr/SKILL.md | 11 +- .../conformance/mirror-realignment.test.ts | 39 +++++- .../conformance/web-cloud-environment.test.ts | 113 ++++++++++++------ turbo.json | 16 ++- 7 files changed, 218 insertions(+), 54 deletions(-) diff --git a/.claude/skills/pair-capability-publish-pr/SKILL.md b/.claude/skills/pair-capability-publish-pr/SKILL.md index 37a9752c1..f7a8ae5d1 100644 --- a/.claude/skills/pair-capability-publish-pr/SKILL.md +++ b/.claude/skills/pair-capability-publish-pr/SKILL.md @@ -61,11 +61,12 @@ The realignment runs **before** the gate, and the order is load-bearing in both 1. **Check**: Does the adoption declare a `mirror-realign-command`? 2. **Skip**: If it does not, go to step 5. A project with no generated mirrors has nothing to realign — the zero-configuration default, not a degradation, and nothing is reported. -3. **Act**: Take the **before** snapshot — `git status --porcelain`, whole tree — and only then run the declared command. It regenerates the mirrors from the working tree's **local** dataset — never a published release — and is idempotent. A **non-zero exit → HALT** before any PR side effect, reporting the command's own reason verbatim: it never reports success over a no-op, so a failure here means nothing was written and the drift is still there. -4. **Check → Act**: Take the **after** snapshot (`git status --porcelain` again) and compare it with the before snapshot. **The set to stage is what this run actually wrote** — every path whose porcelain entry appeared, disappeared or changed between the two reads — and it is derived that way rather than from a **path glob** of "the paths the command owns" deliberately. A glob is a *guess about the command*, and it is wrong wherever generated output and authored files share a prefix — the ordinary case, since the directory holding a project's generated mirrors is usually the same one holding its hand-written adoption/knowledge files. Under a glob, a contributor who left an authored file dirty beneath that prefix gets it committed under `chore: regenerate mirrors from local dataset` — their prose, under a regeneration message, in a commit they never wrote. The before/after comparison cannot do that: a file this run did not touch has an identical entry in both snapshots. It also means **no adopter has to enumerate owned globs anywhere** — the command's own effect is the declaration. - - **No change** → the two snapshots are equal; a no-op stays **silent**: no commit, and no output row (the `Mirrors:` row is emitted only when a commit was made). Continue to step 5. +3. **Act**: Take the **before** snapshot — `git status --porcelain`, whole tree — **paired with a content digest of every path that snapshot reports as dirty** (`git hash-object ` over that set, or an equivalent `git diff` capture). The digest is not belt-and-braces: a porcelain entry encodes **status, not content**, so a path that is *already* dirty reports the same unstaged-modified `M ` entry before and after whether the run rewrote the file or never opened it — status alone cannot tell those two apart, and one of them is a destroyed hand-edit. Only then run the declared command. It regenerates the mirrors from the working tree's **local** dataset — never a published release — and is idempotent. A **non-zero exit → HALT** before any PR side effect, reporting the command's own reason verbatim: it never reports success over a no-op, so a failure here means nothing was written and the drift is still there. +4. **Check → Act**: Take the **after** snapshot (`git status --porcelain` again, plus the digest of the same paths) and compare it with the before snapshot. **The set to stage is what this run actually wrote** — every path whose porcelain entry appeared, disappeared or changed between the two reads, **plus every path already dirty in the before snapshot whose digest changed** — and it is derived that way rather than from a **path glob** of "the paths the command owns" deliberately. A glob is a *guess about the command*, and it is wrong wherever generated output and authored files share a prefix — the ordinary case, since the directory holding a project's generated mirrors is usually the same one holding its hand-written adoption/knowledge files. Under a glob, a contributor who left an authored file dirty beneath that prefix gets it committed under `chore: regenerate mirrors from local dataset` — their prose, under a regeneration message, in a commit they never wrote. The before/after comparison cannot do that: a file this run did not touch has an identical entry **and an identical digest** in both snapshots. It also means **no adopter has to enumerate owned globs anywhere** — the command's own effect is the declaration. + - **Overwritten uncommitted work** (the digest half, and the only case `git status` cannot show): HEAD carries a drifted mirror, the contributor is holding an *uncommitted* edit to that same file, and the regeneration replaces it — the entry stays the same unstaged-modified `M ` on both reads while the bytes changed. Those paths join the staged set like any other write (the regenerated content is what must ship; leaving it out pushes the stale mirror the guards reject and turns the branch's own conformance job red), **and every one of them is named in the output** — `overwrote uncommitted changes in: ` on the `Mirrors:` row. Never silent here: the contributor's work is gone from disk, and a loss nobody is told about is worse than the drift this step exists to fix. + - **No change** → the two snapshots are equal **and no dirty path's digest moved**; a no-op stays **silent**: no commit, and no output row (the `Mirrors:` row is emitted only when a commit was made). Continue to step 5. - **Changed** → stage **only** the paths that comparison produced — never `git add -A`, and never a glob: unstaged authored changes belong to the contributor and must survive the run untouched, and this skill must not commit them — and commit them **alone**, as their own commit, never mixed into a feature commit. Name it as a *regeneration* (e.g. `chore: regenerate mirrors from local dataset`), never a "fix": an overwritten hand-edit was restored to what the dataset generates, not repaired. Drift in a file this branch never touched is committed here too, and **said so in the output** — surprising, but better than pushing knowingly stale generated output, and the separate commit keeps even a dataset-wide regeneration readable next to the authored work. - - **Verify**: `git log` shows exactly one new commit, its file list equals the before/after difference exactly, and `git status` still shows every pre-existing unstaged authored change, untouched. + - **Verify**: `git log` shows exactly one new commit and its file list equals that set exactly; **every pre-existing dirty path that is NOT in the set still carries its before digest** — the survival check is on **content**, because a path still being listed by `git status` is exactly what an overwrite also produces, so a check phrased on the listing would certify the loss it is meant to catch. 5. **Act**: Compose `/pair-capability-verify-quality` with `$scope` (default `all`). This is a local pre-flight, not a replacement for CI (CI stays authoritative, #210). 6. **Check**: Did every required gate pass? 7. **Skip**: If all gates pass, proceed to Phase 2. @@ -164,7 +165,7 @@ The PR is ready; it must now be **under review and mechanically blocked** — se PUBLISH-PR REPORT: ├── Story: [#ID: Title] ├── Handoff: [.pair/working/checkpoints/.md | none — state gathered from branch+story] -├── Mirrors: [regenerated — commit , N file(s) — omit this row entirely when nothing was committed] +├── Mirrors: [regenerated — commit , N file(s)[; overwrote uncommitted changes in: ] — omit this row entirely when nothing was committed] ├── Gate: [PASS | HALTED — N gates failing] ├── Base: [base-branch — squash on merge: yes|no] ├── PR: [#PR-number — URL — Created | Updated — ready-for-review confirmed by read | ready-for-review not confirmed — finding] diff --git a/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md b/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md index cfae980bf..4a00c0021 100644 --- a/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md +++ b/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md @@ -68,6 +68,23 @@ read from the adoption, never named in the skill.** file the run did not touch has an identical entry in both snapshots. Corollary: **no adopter has to enumerate owned globs anywhere**, and this file's own list of written trees is descriptive only. +- **The comparison is content-aware on paths that were ALREADY dirty**, because a porcelain entry + encodes status, not content. The before snapshot therefore carries a digest + (`git hash-object`) of every path it reports dirty, and the staged set is the entries that + appeared/disappeared/changed **plus the pre-dirty paths whose digest moved**. Without it: HEAD + carries a drifted mirror, the contributor holds an uncommitted edit to that same file, the + command regenerates it — the same unstaged-modified `M ` entry on both reads — and a + status-only comparison reads NO CHANGE, so the hand-edit is destroyed with nothing reported + *and* the stale mirror is + pushed, turning the branch's own conformance job red. Measured against the real script in + `regenerate-mirrors.test.ts` ("overwrites a pre-dirty mirror while `git status --porcelain` stays + byte-identical"). +- **The overwrite is reported, never silent.** Those paths are committed like any other write (the + regenerated content is what must ship), and each is named on the `Mirrors:` row — + `overwrote uncommitted changes in: `. The commit is not the remedy for the loss; naming it + is. And the step-4 Verify reads the **digest** of every pre-existing dirty path not in the staged + set, not `git status`'s listing: the listing is exactly what an overwrite also leaves behind, so a + survival check phrased on it certifies the loss it exists to catch. - **A thin script whose behaviour IS the deliverable may be exercised from vitest**, black-box, against a throwaway fixture — a bounded, documented exception to ADL 2026-07-13, which otherwise stands unchanged. Conditions, all of them: the script holds no logic that could be extracted to a @@ -98,6 +115,11 @@ read from the adoption, never named in the skill.** - **Stage by owned-path glob (the first draft)**: rejected — see Context 3. The glob covers authored files in this repo, and any adopter would have to enumerate its own, correctly, for a rule whose failure mode is committing someone else's work. +- **HALT when the run overwrote a pre-dirty path**: rejected. The overwrite has already happened by + the time it is detectable — the command ran — so a HALT recovers nothing the contributor lost, and + it additionally blocks the PR on a condition this step itself caused, leaving the drift in place. + Committing the regenerated content and *naming the loss* keeps both the mirror and the contributor + informed; only the silence was the defect. - **Move the script tests to `scripts/smoke-tests/`**: rejected — see Context 4. The suite would lose the broken-toolchain cases outright (a smoke scenario runs against a working install), and vitest is where the assertions and the fixture helpers already live. diff --git a/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts b/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts index 0f1996f6b..af0c0a118 100644 --- a/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts +++ b/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts @@ -236,6 +236,66 @@ describe('regenerate-mirrors.sh — the local, deterministic mirror remedy (#419 SCRIPT_RUN_TIMEOUT_MS, ) + it( + 'overwrites a pre-dirty mirror while `git status --porcelain` stays byte-identical', + () => { + // The measurement behind /publish-pr's Phase-1 staging rule (#419 round 2). That rule + // derives "what this run wrote" from a before/after `git status --porcelain` diff. A + // porcelain entry encodes STATUS, not content — so on a path that was ALREADY dirty + // before the run, the entry is ` M ` before and ` M ` after whether the run + // rewrote the file or never opened it. Both cases are in this fixture at once: + // - the mirror, whose committed content is drifted and whose working copy carries an + // uncommitted hand-edit the run destroys; + // - an authored file the run does not touch. + // The two are INDISTINGUISHABLE in the snapshots, which is why the rule pairs each dirty + // path with a content digest: without it the agent reads "no change", commits nothing, + // reports nothing, and pushes the stale mirror the guards reject. + tmp = makeFixture() + const mirror = join(tmp, '.pair/knowledge/index.md') + const authored = join(tmp, 'src/authored.ts') + write(authored, 'export const authored = 1\n') + // The installed target has to exist before the first run: `pair update` refuses a + // project it was never installed into (same reason the AC1 case writes it). + write(mirror, '# pre-existing install\n') + + // Converge first, so the second run's ONLY write is the mirror — otherwise the fixture's + // first-ever regeneration touches other installed files and the snapshots differ for a + // reason that has nothing to do with the case under test. + expect(run(tmp, isolatedHome(tmp)).status).toBe(0) + git(tmp, ['add', '-A']) + git(tmp, ['commit', '-q', '-m', 'converged']) + + // HEAD now carries a DRIFTED mirror: this is what makes the run write something. + writeFileSync(mirror, '# committed drift\n') + git(tmp, ['add', '-A']) + git(tmp, ['commit', '-q', '-m', 'drifted mirror on HEAD']) + + // Two uncommitted changes: one on the mirror (about to be destroyed), one authored. + writeFileSync(mirror, '# uncommitted hand-edit\n') + writeFileSync(authored, 'export const authored = 2\n') + + const before = git(tmp, ['status', '--porcelain']) + const mirrorBefore = git(tmp, ['hash-object', mirror]).trim() + const authoredBefore = git(tmp, ['hash-object', authored]).trim() + + const result = run(tmp, isolatedHome(tmp)) + + expect(result.status).toBe(0) + const after = git(tmp, ['status', '--porcelain']) + expect(before).toMatch(/^ M \.pair\/knowledge\/index\.md$/m) + expect(before).toMatch(/^ M src\/authored\.ts$/m) + // THE DEFECT, executed: the snapshots the staging rule compares are equal... + expect(after).toBe(before) + // ...yet the hand-edit is gone, replaced by what the dataset generates. + expect(git(tmp, ['hash-object', mirror]).trim()).not.toBe(mirrorBefore) + expect(readFileSync(mirror, 'utf-8')).toBe(KB_INDEX) + // ...and the authored file, whose entry is identically unchanged, really is untouched. + expect(git(tmp, ['hash-object', authored]).trim()).toBe(authoredBefore) + expect(readFileSync(authored, 'utf-8')).toBe('export const authored = 2\n') + }, + SCRIPT_RUN_TIMEOUT_MS, + ) + it('exits non-zero and names the reason when the dataset is missing (AC7)', () => { tmp = realpathSync(mkdtempSync(join(tmpdir(), 'regen-mirrors-'))) initRepo(tmp) diff --git a/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md b/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md index b64958647..099a3b3ca 100644 --- a/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md +++ b/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md @@ -61,11 +61,12 @@ The realignment runs **before** the gate, and the order is load-bearing in both 1. **Check**: Does the adoption declare a `mirror-realign-command`? 2. **Skip**: If it does not, go to step 5. A project with no generated mirrors has nothing to realign — the zero-configuration default, not a degradation, and nothing is reported. -3. **Act**: Take the **before** snapshot — `git status --porcelain`, whole tree — and only then run the declared command. It regenerates the mirrors from the working tree's **local** dataset — never a published release — and is idempotent. A **non-zero exit → HALT** before any PR side effect, reporting the command's own reason verbatim: it never reports success over a no-op, so a failure here means nothing was written and the drift is still there. -4. **Check → Act**: Take the **after** snapshot (`git status --porcelain` again) and compare it with the before snapshot. **The set to stage is what this run actually wrote** — every path whose porcelain entry appeared, disappeared or changed between the two reads — and it is derived that way rather than from a **path glob** of "the paths the command owns" deliberately. A glob is a *guess about the command*, and it is wrong wherever generated output and authored files share a prefix — the ordinary case, since the directory holding a project's generated mirrors is usually the same one holding its hand-written adoption/knowledge files. Under a glob, a contributor who left an authored file dirty beneath that prefix gets it committed under `chore: regenerate mirrors from local dataset` — their prose, under a regeneration message, in a commit they never wrote. The before/after comparison cannot do that: a file this run did not touch has an identical entry in both snapshots. It also means **no adopter has to enumerate owned globs anywhere** — the command's own effect is the declaration. - - **No change** → the two snapshots are equal; a no-op stays **silent**: no commit, and no output row (the `Mirrors:` row is emitted only when a commit was made). Continue to step 5. +3. **Act**: Take the **before** snapshot — `git status --porcelain`, whole tree — **paired with a content digest of every path that snapshot reports as dirty** (`git hash-object ` over that set, or an equivalent `git diff` capture). The digest is not belt-and-braces: a porcelain entry encodes **status, not content**, so a path that is *already* dirty reports the same unstaged-modified `M ` entry before and after whether the run rewrote the file or never opened it — status alone cannot tell those two apart, and one of them is a destroyed hand-edit. Only then run the declared command. It regenerates the mirrors from the working tree's **local** dataset — never a published release — and is idempotent. A **non-zero exit → HALT** before any PR side effect, reporting the command's own reason verbatim: it never reports success over a no-op, so a failure here means nothing was written and the drift is still there. +4. **Check → Act**: Take the **after** snapshot (`git status --porcelain` again, plus the digest of the same paths) and compare it with the before snapshot. **The set to stage is what this run actually wrote** — every path whose porcelain entry appeared, disappeared or changed between the two reads, **plus every path already dirty in the before snapshot whose digest changed** — and it is derived that way rather than from a **path glob** of "the paths the command owns" deliberately. A glob is a *guess about the command*, and it is wrong wherever generated output and authored files share a prefix — the ordinary case, since the directory holding a project's generated mirrors is usually the same one holding its hand-written adoption/knowledge files. Under a glob, a contributor who left an authored file dirty beneath that prefix gets it committed under `chore: regenerate mirrors from local dataset` — their prose, under a regeneration message, in a commit they never wrote. The before/after comparison cannot do that: a file this run did not touch has an identical entry **and an identical digest** in both snapshots. It also means **no adopter has to enumerate owned globs anywhere** — the command's own effect is the declaration. + - **Overwritten uncommitted work** (the digest half, and the only case `git status` cannot show): HEAD carries a drifted mirror, the contributor is holding an *uncommitted* edit to that same file, and the regeneration replaces it — the entry stays the same unstaged-modified `M ` on both reads while the bytes changed. Those paths join the staged set like any other write (the regenerated content is what must ship; leaving it out pushes the stale mirror the guards reject and turns the branch's own conformance job red), **and every one of them is named in the output** — `overwrote uncommitted changes in: ` on the `Mirrors:` row. Never silent here: the contributor's work is gone from disk, and a loss nobody is told about is worse than the drift this step exists to fix. + - **No change** → the two snapshots are equal **and no dirty path's digest moved**; a no-op stays **silent**: no commit, and no output row (the `Mirrors:` row is emitted only when a commit was made). Continue to step 5. - **Changed** → stage **only** the paths that comparison produced — never `git add -A`, and never a glob: unstaged authored changes belong to the contributor and must survive the run untouched, and this skill must not commit them — and commit them **alone**, as their own commit, never mixed into a feature commit. Name it as a *regeneration* (e.g. `chore: regenerate mirrors from local dataset`), never a "fix": an overwritten hand-edit was restored to what the dataset generates, not repaired. Drift in a file this branch never touched is committed here too, and **said so in the output** — surprising, but better than pushing knowingly stale generated output, and the separate commit keeps even a dataset-wide regeneration readable next to the authored work. - - **Verify**: `git log` shows exactly one new commit, its file list equals the before/after difference exactly, and `git status` still shows every pre-existing unstaged authored change, untouched. + - **Verify**: `git log` shows exactly one new commit and its file list equals that set exactly; **every pre-existing dirty path that is NOT in the set still carries its before digest** — the survival check is on **content**, because a path still being listed by `git status` is exactly what an overwrite also produces, so a check phrased on the listing would certify the loss it is meant to catch. 5. **Act**: Compose `/verify-quality` with `$scope` (default `all`). This is a local pre-flight, not a replacement for CI (CI stays authoritative, #210). 6. **Check**: Did every required gate pass? 7. **Skip**: If all gates pass, proceed to Phase 2. @@ -164,7 +165,7 @@ The PR is ready; it must now be **under review and mechanically blocked** — se PUBLISH-PR REPORT: ├── Story: [#ID: Title] ├── Handoff: [.pair/working/checkpoints/.md | none — state gathered from branch+story] -├── Mirrors: [regenerated — commit , N file(s) — omit this row entirely when nothing was committed] +├── Mirrors: [regenerated — commit , N file(s)[; overwrote uncommitted changes in: ] — omit this row entirely when nothing was committed] ├── Gate: [PASS | HALTED — N gates failing] ├── Base: [base-branch — squash on merge: yes|no] ├── PR: [#PR-number — URL — Created | Updated — ready-for-review confirmed by read | ready-for-review not confirmed — finding] diff --git a/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts b/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts index 718205799..71c05ab87 100644 --- a/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts +++ b/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts @@ -104,7 +104,7 @@ describe('publish-pr realigns mirrors before its gate (#419)', () => { // "unstaged authored changes ... must survive the run untouched". const p1 = phase1() expect(p1).toMatch(/\*\*before\*\* snapshot — `git status --porcelain`/) - expect(p1).toMatch(/\*\*after\*\* snapshot \(`git status --porcelain` again\)/) + expect(p1).toMatch(/\*\*after\*\* snapshot \(`git status --porcelain` again/) expect(p1).toMatch(/appeared, disappeared or changed between the two reads/) expect(p1).toMatch(/rather than from a \*\*path glob\*\*/) expect(p1).toMatch(/and never a glob/) @@ -112,16 +112,49 @@ describe('publish-pr realigns mirrors before its gate (#419)', () => { expect(p1).toMatch(/no adopter has to enumerate owned globs anywhere/) }) + it('pairs the porcelain snapshots with a content DIGEST of the already-dirty paths', () => { + // Round-2 finding. A porcelain entry encodes STATUS, not content, so on a path that was + // already dirty before the run the entry is identical either way. Concrete loss: HEAD + // carries a drifted mirror, the contributor holds an uncommitted hand-edit to that same + // file, the command regenerates it -> ` M ` before, ` M ` after. Under a + // status-only comparison the agent reads NO CHANGE: no commit, no `Mirrors:` row, silence + // — while the hand-edit is gone from disk and the branch still pushes the stale mirror, + // turning `skills:conformance` red on the very PR this step exists to keep green. + // MEASURED against the real script: + // packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts, 'overwrites a pre-dirty + // mirror while `git status --porcelain` stays byte-identical'. + const p1 = phase1() + expect(p1).toMatch(/content digest of every path that snapshot reports as dirty/) + expect(p1).toMatch(/`git hash-object `/) + expect(p1).toMatch(/encodes \*\*status, not content\*\*/) + // The digest half must be IN the staged set, not merely detected. + expect(p1).toMatch(/plus every path already dirty in the before snapshot whose digest changed/) + // ...and the loss must be reported: silence is the failure mode, not the commit. + expect(p1).toMatch(/overwrote uncommitted changes in: /) + expect(p1).toMatch(/Never silent here/) + expect(dataset()).toMatch(/overwrote uncommitted changes in: \]/) + // The no-op branch must require BOTH halves to be quiet, or it re-opens the same hole. + expect(p1).toMatch(/equal \*\*and no dirty path's digest moved\*\*/) + }) + it('names the commit a regeneration, never a fix (an overwritten hand-edit was restored)', () => { const p1 = phase1() expect(p1).toMatch(/regenerate mirrors from local dataset/) expect(p1).toMatch(/never a "fix"/) }) - it('leaves unstaged authored changes untouched and verifies they survived', () => { + it('verifies survival by CONTENT, so the check cannot certify an unseen overwrite', () => { const p1 = phase1() expect(p1).toMatch(/unstaged authored changes[\s\S]{0,200}must survive the run untouched/) - expect(p1).toMatch(/`git status` still shows every pre-existing unstaged authored change/) + // The previous wording — "`git status` still shows every pre-existing unstaged authored + // change, untouched" — PASSES on an overwritten file: the path is still listed, because + // that is what an overwrite of a dirty path leaves behind. The Verify must read the + // digest, and must say why the listing is not evidence. + expect(p1).not.toMatch(/`git status` still shows every pre-existing unstaged authored change/) + expect(p1).toMatch( + /every pre-existing dirty path that is NOT in the set still carries its before digest/, + ) + expect(p1).toMatch(/certify the loss it is meant to catch/) }) it('stays SILENT on a no-op — no commit and no output row', () => { diff --git a/packages/knowledge-hub/src/conformance/web-cloud-environment.test.ts b/packages/knowledge-hub/src/conformance/web-cloud-environment.test.ts index f493cce39..3fe5076ac 100644 --- a/packages/knowledge-hub/src/conformance/web-cloud-environment.test.ts +++ b/packages/knowledge-hub/src/conformance/web-cloud-environment.test.ts @@ -531,12 +531,12 @@ describe("turbo.json keeps each package's #test / #test:coverage inputs in sync" // name. const TURBO = join(ROOT, 'turbo.json') - const readTurboTasks = (): Record => { + const readTurboTasks = (): Record => { const stripped = read(TURBO).replace(/^[ \t]*\/\/.*$/gm, '') const parsed: unknown = JSON.parse(stripped) const tasks = (parsed as { tasks?: unknown }).tasks expect(tasks, 'turbo.json has no top-level "tasks" object').toBeTypeOf('object') - return tasks as Record + return tasks as Record } // The actual repo-wide reads each package's tests depend on turbo invalidating on — not just @@ -552,43 +552,60 @@ describe("turbo.json keeps each package's #test / #test:coverage inputs in sync" // scripts/format-lib/run-format.sh). Asserting only the first pair left the second an // unguarded hand-maintained duplicate — the exact class this describe block exists to close, // reintroduced by its own follow-up fix one round later. - const TASK_PAIRS: Array<{ pkg: string; requiredInputs: string[] }> = [ - { - pkg: '@pair/knowledge-hub', - // The FULL 11-entry list this PR ships, not a subset — a round-9 review found the guard - // only checking 6 of them (missing `.claude/**`, `.claude-plugin/marketplace.json`, - // `apps/pair-cli/config.json`, `.github/workflows/**`, `scripts/**`), and mutation-proved - // that deleting `.claude/**` from BOTH arrays — read by 20+ conformance files in this - // package — stayed green. A partial floor is exactly the "we asserted the ONE entry that - // matters least" mistake this describe block's own history keeps making one level down. - requiredInputs: [ - '$TURBO_DEFAULT$', - '$TURBO_ROOT$/.claude/**', - '$TURBO_ROOT$/.claude-plugin/marketplace.json', - '$TURBO_ROOT$/.pair/**', - '$TURBO_ROOT$/apps/pair-cli/config.json', - // #419: mirror-realignment.test.ts asserts MIRROR_REGENERATE_COMMAND names a script - // the ROOT package.json actually defines. Renaming that script touches no file in - // this package, so without this entry the guard replays a cached PASS over dead advice. - '$TURBO_ROOT$/package.json', - '$TURBO_ROOT$/apps/website/content/docs/**', - '$TURBO_ROOT$/apps/website/e2e/docs.e2e.test.ts', - '$TURBO_ROOT$/qa/**', - '$TURBO_ROOT$/.github/workflows/**', - '$TURBO_ROOT$/scripts/**', - '$TURBO_ROOT$/turbo.json', - ], - }, - { - pkg: '@pair/dev-tools', - // #419 widened `scripts/format-lib/**` to `scripts/**`: regenerate-mirrors.test.ts - // execFileSyncs scripts/regenerate-mirrors.sh the same way run-format.test.ts does its - // script, and a per-script list degrades SILENTLY (a stale PASS) the next time one is - // added without it. `package.json` is required because pre-push-gate-composition.test.ts - // runs checkRootGate against the REAL root manifest. - requiredInputs: ['$TURBO_DEFAULT$', '$TURBO_ROOT$/scripts/**', '$TURBO_ROOT$/package.json'], - }, - ] + // + // `requiredDependsOn` covers the reads a path list CANNOT: a test that EXECUTES another + // package's built output depends on that package's whole source closure, and a task + // dependency is the only entry that follows the closure when it grows. + const TASK_PAIRS: Array<{ pkg: string; requiredInputs: string[]; requiredDependsOn: string[] }> = + [ + { + pkg: '@pair/knowledge-hub', + requiredDependsOn: ['build'], + // The FULL 11-entry list this PR ships, not a subset — a round-9 review found the guard + // only checking 6 of them (missing `.claude/**`, `.claude-plugin/marketplace.json`, + // `apps/pair-cli/config.json`, `.github/workflows/**`, `scripts/**`), and mutation-proved + // that deleting `.claude/**` from BOTH arrays — read by 20+ conformance files in this + // package — stayed green. A partial floor is exactly the "we asserted the ONE entry that + // matters least" mistake this describe block's own history keeps making one level down. + requiredInputs: [ + '$TURBO_DEFAULT$', + '$TURBO_ROOT$/.claude/**', + '$TURBO_ROOT$/.claude-plugin/marketplace.json', + '$TURBO_ROOT$/.pair/**', + '$TURBO_ROOT$/apps/pair-cli/config.json', + // #419: mirror-realignment.test.ts asserts MIRROR_REGENERATE_COMMAND names a script + // the ROOT package.json actually defines. Renaming that script touches no file in + // this package, so without this entry the guard replays a cached PASS over dead advice. + '$TURBO_ROOT$/package.json', + '$TURBO_ROOT$/apps/website/content/docs/**', + '$TURBO_ROOT$/apps/website/e2e/docs.e2e.test.ts', + '$TURBO_ROOT$/qa/**', + '$TURBO_ROOT$/.github/workflows/**', + '$TURBO_ROOT$/scripts/**', + '$TURBO_ROOT$/turbo.json', + ], + }, + { + pkg: '@pair/dev-tools', + // #419 widened `scripts/format-lib/**` to `scripts/**`: regenerate-mirrors.test.ts + // execFileSyncs scripts/regenerate-mirrors.sh the same way run-format.test.ts does its + // script, and a per-script list degrades SILENTLY (a stale PASS) the next time one is + // added without it. `package.json` is required because pre-push-gate-composition.test.ts + // runs checkRootGate against the REAL root manifest. + requiredInputs: ['$TURBO_DEFAULT$', '$TURBO_ROOT$/scripts/**', '$TURBO_ROOT$/package.json'], + // #419 round 2: regenerate-mirrors.test.ts AC1/AC2 run the real + // scripts/regenerate-mirrors.sh, whose TOOLCHAIN_ROOT is this repo — so they BUILD and + // RUN apps/pair-cli and assert the output of the real `pair update --source` transform. + // @pair/dev-tools declares no dependency on @pair/pair-cli, so neither `inputs` above + // nor `^build` reached apps/pair-cli/**, packages/content-ops/** or + // packages/knowledge-hub/**. MEASURED at 0a6712e3, clean worktree: appending a comment + // to apps/pair-cli/src/registry/skill-refs.ts (the skill-reference rewriter those tests + // exercise) and re-running `turbo run test --filter @pair/dev-tools` replayed + // `1 cached, 125ms >>> FULL TURBO`. With this dependency: `2 cached, 12.7s` — and the + // same probe on packages/content-ops/src/index.ts gives `0 cached`. + requiredDependsOn: ['build', '@pair/pair-cli#build'], + }, + ] it.each(TASK_PAIRS)( '$pkg has identical, non-empty inputs for #test and #test:coverage, covering the real repo-wide reads', @@ -604,4 +621,22 @@ describe("turbo.json keeps each package's #test / #test:coverage inputs in sync" expect(coverageInputs).toEqual(testInputs) }, ) + + it.each(TASK_PAIRS)( + '$pkg declares the same dependsOn for #test and #test:coverage, covering the packages its tests EXECUTE', + ({ pkg, requiredDependsOn }) => { + const tasks = readTurboTasks() + const testDeps = tasks[`${pkg}#test`]?.dependsOn + const coverageDeps = tasks[`${pkg}#test:coverage`]?.dependsOn + expect(Array.isArray(testDeps), `${pkg}#test has no dependsOn array`).toBe(true) + expect(Array.isArray(coverageDeps), `${pkg}#test:coverage has no dependsOn array`).toBe(true) + for (const dep of requiredDependsOn) { + expect(testDeps, `${pkg}#test is missing dependsOn ${dep}`).toContain(dep) + } + // The coverage variant is a hand-maintained duplicate with no anchor mechanism in + // turbo.json: drifting only one of the two is how a stale cache comes back on the half + // nobody re-ran. + expect(coverageDeps).toEqual(testDeps) + }, + ) }) diff --git a/turbo.json b/turbo.json index f3aead2ff..f4b429c5c 100644 --- a/turbo.json +++ b/turbo.json @@ -96,13 +96,25 @@ // is here because pre-push-gate-composition.test.ts runs checkRootGate against the REAL root // package.json: deleting the `mirrors:regenerate` script is exactly the dead-advice regression // that guard exists to catch, and it touches no file under packages/dev-tools. + // `@pair/pair-cli#build` in dependsOn (#419, round 2) covers the one tree these tests + // EXECUTE that neither list reaches: regenerate-mirrors.test.ts AC1/AC2 run the real + // scripts/regenerate-mirrors.sh with TOOLCHAIN_ROOT = this repo, so they build and run + // apps/pair-cli and assert the output of the real `pair update --source` transform. + // @pair/dev-tools declares no dependency on @pair/pair-cli (only the four config + // packages), so nothing in `inputs` or in `^build` covered apps/pair-cli/**, + // packages/content-ops/** or packages/knowledge-hub/**. MEASURED at 0a6712e3: + // `printf '\n// cache probe\n' >> apps/pair-cli/src/registry/skill-refs.ts` — the + // skill-reference rewriter these tests exercise — then + // `turbo run test --filter @pair/dev-tools` replayed `1 cached, 125ms >>> FULL TURBO`. + // A task dependency enters the hash, so this invalidates on the WHOLE CLI closure and, + // unlike a hand-listed set of trees, cannot go stale when that closure grows. "@pair/dev-tools#test": { - "dependsOn": ["build"], + "dependsOn": ["build", "@pair/pair-cli#build"], "outputs": [], "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/scripts/**", "$TURBO_ROOT$/package.json"] }, "@pair/dev-tools#test:coverage": { - "dependsOn": ["build"], + "dependsOn": ["build", "@pair/pair-cli#build"], "outputs": ["coverage/**"], "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/scripts/**", "$TURBO_ROOT$/package.json"] }, From fe39709e1cdcf7e78ef8cd39980cb05fb85b4cd2 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Tue, 1 Sep 2026 17:19:36 +0200 Subject: [PATCH 11/14] [#419] fix: digest only what git can hash, and with -w (review round 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1's before-snapshot ran `git hash-object ` over every dirty porcelain entry. Two of three ordinary shapes are fatal: ` D gone.md` -> "could not open … for reading" (exit 128, which the step's own non-zero -> HALT turns into a PR blocked by its own snapshot pass), `?? newdir/` -> "Unable to hash newdir/" — and that entry is identical before and after however many files under it the run rewrote, so the untracked subtree kept the status-vs-content blindness the digest was added to close. Now: `--untracked-files=all` (per-file `?? dir/a.md` entries, hashable), digest only entries whose worktree file exists (a recreated deletion moves its porcelain entry anyway), and `-w` so the overwritten bytes land in the ODB — the Mirrors: row now carries `(recover: git cat-file -p > )` instead of naming a loss nobody can undo. Executed against the real script (regenerate-mirrors.test.ts): all three shapes in one fixture, snapshots byte-identical before/after while both digests move, and `git cat-file -p` prints the hand-edits back. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UQJzGMhRqBRRboxMrRqFPP --- .../pair-capability-publish-pr/SKILL.md | 13 +- ...ish-pr-realigns-mirrors-before-the-gate.md | 27 +++- .../quality-gates/regenerate-mirrors.test.ts | 150 ++++++++++++++++++ .../.skills/capability/publish-pr/SKILL.md | 13 +- .../conformance/mirror-realignment.test.ts | 62 +++++++- 5 files changed, 241 insertions(+), 24 deletions(-) diff --git a/.claude/skills/pair-capability-publish-pr/SKILL.md b/.claude/skills/pair-capability-publish-pr/SKILL.md index f7a8ae5d1..c6f7f9045 100644 --- a/.claude/skills/pair-capability-publish-pr/SKILL.md +++ b/.claude/skills/pair-capability-publish-pr/SKILL.md @@ -61,12 +61,15 @@ The realignment runs **before** the gate, and the order is load-bearing in both 1. **Check**: Does the adoption declare a `mirror-realign-command`? 2. **Skip**: If it does not, go to step 5. A project with no generated mirrors has nothing to realign — the zero-configuration default, not a degradation, and nothing is reported. -3. **Act**: Take the **before** snapshot — `git status --porcelain`, whole tree — **paired with a content digest of every path that snapshot reports as dirty** (`git hash-object ` over that set, or an equivalent `git diff` capture). The digest is not belt-and-braces: a porcelain entry encodes **status, not content**, so a path that is *already* dirty reports the same unstaged-modified `M ` entry before and after whether the run rewrote the file or never opened it — status alone cannot tell those two apart, and one of them is a destroyed hand-edit. Only then run the declared command. It regenerates the mirrors from the working tree's **local** dataset — never a published release — and is idempotent. A **non-zero exit → HALT** before any PR side effect, reporting the command's own reason verbatim: it never reports success over a no-op, so a failure here means nothing was written and the drift is still there. -4. **Check → Act**: Take the **after** snapshot (`git status --porcelain` again, plus the digest of the same paths) and compare it with the before snapshot. **The set to stage is what this run actually wrote** — every path whose porcelain entry appeared, disappeared or changed between the two reads, **plus every path already dirty in the before snapshot whose digest changed** — and it is derived that way rather than from a **path glob** of "the paths the command owns" deliberately. A glob is a *guess about the command*, and it is wrong wherever generated output and authored files share a prefix — the ordinary case, since the directory holding a project's generated mirrors is usually the same one holding its hand-written adoption/knowledge files. Under a glob, a contributor who left an authored file dirty beneath that prefix gets it committed under `chore: regenerate mirrors from local dataset` — their prose, under a regeneration message, in a commit they never wrote. The before/after comparison cannot do that: a file this run did not touch has an identical entry **and an identical digest** in both snapshots. It also means **no adopter has to enumerate owned globs anywhere** — the command's own effect is the declaration. - - **Overwritten uncommitted work** (the digest half, and the only case `git status` cannot show): HEAD carries a drifted mirror, the contributor is holding an *uncommitted* edit to that same file, and the regeneration replaces it — the entry stays the same unstaged-modified `M ` on both reads while the bytes changed. Those paths join the staged set like any other write (the regenerated content is what must ship; leaving it out pushes the stale mirror the guards reject and turns the branch's own conformance job red), **and every one of them is named in the output** — `overwrote uncommitted changes in: ` on the `Mirrors:` row. Never silent here: the contributor's work is gone from disk, and a loss nobody is told about is worse than the drift this step exists to fix. +3. **Act**: Take the **before** snapshot — `git status --porcelain --untracked-files=all`, whole tree — **paired with a content digest of every entry whose worktree file still exists** (`git hash-object -w ` over that set). The digest is not belt-and-braces: a porcelain entry encodes **status, not content**, so a path that is *already* dirty reports the same unstaged-modified `M ` entry before and after whether the run rewrote the file or never opened it — status alone cannot tell those two apart, and one of them is a destroyed hand-edit. Only then run the declared command. It regenerates the mirrors from the working tree's **local** dataset — never a published release — and is idempotent. A **non-zero exit → HALT** before any PR side effect, reporting the command's own reason verbatim: it never reports success over a no-op, so a failure here means nothing was written and the drift is still there. Each of the three flags in that sentence is doing work, and skipping one puts the step back where the digest found it: + - **`--untracked-files=all`**, because the default collapses a not-yet-committed directory into one `?? dir/` entry — one entry however many files under it the run rewrote, identical on both reads — and `git hash-object dir/` answers `fatal: Unable to hash dir/`, so that whole subtree would be undetectable *and* unhashable: exactly the status-vs-content blindness the digest exists to close, surviving where the digest cannot reach. Expanded per file, `?? dir/a.md` hashes like any other path. (An `equivalent git diff capture` is not equivalent here: **it never reports untracked paths at all**.) + - **only entries whose file still exists**, because a deletion has none to read: `git hash-object gone.md` on the ` D ` entry that path left behind is `fatal: could not open 'gone.md' for reading`, exit 128 — and this step's own **non-zero exit → HALT** would turn that into a PR blocked by the snapshot pass that was meant to protect it. **Skip those entries** (` D `, `AD`, `DD`), and nothing is lost by skipping: a deleted path the command recreates **moves its porcelain entry** (` D ` → ` M `, or gone), so the status comparison already catches it. The digest is only needed for the shapes where status *cannot* move. + - **`-w`**, because plain `git hash-object` prints a hash and throws the bytes away, while `-w` also **writes the blob into the object database** — same output, and the difference is whether the loss reported two steps later is recoverable. Once the command overwrites a pre-dirty path, the contributor's uncommitted content is in no HEAD (never committed), no index, no disk (overwritten); with `-w` it is in the ODB, and `git cat-file -p ` prints it back. +4. **Check → Act**: Take the **after** snapshot (`git status --porcelain --untracked-files=all` again, plus the digest of the same paths — **re-hashing needs no `-w`**: only the pre-overwrite content was at risk) and compare it with the before snapshot. **The set to stage is what this run actually wrote** — every path whose porcelain entry appeared, disappeared or changed between the two reads, **plus every path already dirty in the before snapshot whose digest changed** — and it is derived that way rather than from a **path glob** of "the paths the command owns" deliberately. A glob is a *guess about the command*, and it is wrong wherever generated output and authored files share a prefix — the ordinary case, since the directory holding a project's generated mirrors is usually the same one holding its hand-written adoption/knowledge files. Under a glob, a contributor who left an authored file dirty beneath that prefix gets it committed under `chore: regenerate mirrors from local dataset` — their prose, under a regeneration message, in a commit they never wrote. The before/after comparison cannot do that: a file this run did not touch has an identical entry **and an identical digest** in both snapshots. It also means **no adopter has to enumerate owned globs anywhere** — the command's own effect is the declaration. + - **Overwritten uncommitted work** (the digest half, and the only case `git status` cannot show): HEAD carries a drifted mirror, the contributor is holding an *uncommitted* edit to that same file, and the regeneration replaces it — the entry stays the same unstaged-modified `M ` on both reads while the bytes changed. Those paths join the staged set like any other write (the regenerated content is what must ship; leaving it out pushes the stale mirror the guards reject and turns the branch's own conformance job red), **and every one of them is named in the output** — `overwrote uncommitted changes in: (recover: git cat-file -p > )` on the `Mirrors:` row, `` being the before snapshot's `-w` digest. Never silent here: the contributor's work is gone from disk, and a loss nobody is told about is worse than the drift this step exists to fix. The `-w` is what makes that row a remedy instead of an obituary — a named path the contributor cannot restore is only a better-documented loss. - **No change** → the two snapshots are equal **and no dirty path's digest moved**; a no-op stays **silent**: no commit, and no output row (the `Mirrors:` row is emitted only when a commit was made). Continue to step 5. - **Changed** → stage **only** the paths that comparison produced — never `git add -A`, and never a glob: unstaged authored changes belong to the contributor and must survive the run untouched, and this skill must not commit them — and commit them **alone**, as their own commit, never mixed into a feature commit. Name it as a *regeneration* (e.g. `chore: regenerate mirrors from local dataset`), never a "fix": an overwritten hand-edit was restored to what the dataset generates, not repaired. Drift in a file this branch never touched is committed here too, and **said so in the output** — surprising, but better than pushing knowingly stale generated output, and the separate commit keeps even a dataset-wide regeneration readable next to the authored work. - - **Verify**: `git log` shows exactly one new commit and its file list equals that set exactly; **every pre-existing dirty path that is NOT in the set still carries its before digest** — the survival check is on **content**, because a path still being listed by `git status` is exactly what an overwrite also produces, so a check phrased on the listing would certify the loss it is meant to catch. + - **Verify**: `git log` shows exactly one new commit and its file list equals that set exactly; **every pre-existing dirty path that is NOT in the set and still has a file on disk still carries its before digest** — the survival check is on **content**, because a path still being listed by `git status` is exactly what an overwrite also produces, so a check phrased on the listing would certify the loss it is meant to catch. The on-disk qualifier is not a loophole: a deleted path has no digest by construction (step 3), and its survival is carried by the porcelain entry, which any rewrite would have moved. 5. **Act**: Compose `/pair-capability-verify-quality` with `$scope` (default `all`). This is a local pre-flight, not a replacement for CI (CI stays authoritative, #210). 6. **Check**: Did every required gate pass? 7. **Skip**: If all gates pass, proceed to Phase 2. @@ -165,7 +168,7 @@ The PR is ready; it must now be **under review and mechanically blocked** — se PUBLISH-PR REPORT: ├── Story: [#ID: Title] ├── Handoff: [.pair/working/checkpoints/.md | none — state gathered from branch+story] -├── Mirrors: [regenerated — commit , N file(s)[; overwrote uncommitted changes in: ] — omit this row entirely when nothing was committed] +├── Mirrors: [regenerated — commit , N file(s)[; overwrote uncommitted changes in: (recover: git cat-file -p > )] — omit this row entirely when nothing was committed] ├── Gate: [PASS | HALTED — N gates failing] ├── Base: [base-branch — squash on merge: yes|no] ├── PR: [#PR-number — URL — Created | Updated — ready-for-review confirmed by read | ready-for-review not confirmed — finding] diff --git a/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md b/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md index 4a00c0021..bb180ed58 100644 --- a/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md +++ b/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md @@ -70,7 +70,8 @@ read from the adoption, never named in the skill.** only. - **The comparison is content-aware on paths that were ALREADY dirty**, because a porcelain entry encodes status, not content. The before snapshot therefore carries a digest - (`git hash-object`) of every path it reports dirty, and the staged set is the entries that + (`git hash-object -w`) of every dirty path **whose worktree file exists**, read from + `git status --porcelain --untracked-files=all`, and the staged set is the entries that appeared/disappeared/changed **plus the pre-dirty paths whose digest moved**. Without it: HEAD carries a drifted mirror, the contributor holds an uncommitted edit to that same file, the command regenerates it — the same unstaged-modified `M ` entry on both reads — and a @@ -79,12 +80,24 @@ read from the adoption, never named in the skill.** pushed, turning the branch's own conformance job red. Measured against the real script in `regenerate-mirrors.test.ts` ("overwrites a pre-dirty mirror while `git status --porcelain` stays byte-identical"). -- **The overwrite is reported, never silent.** Those paths are committed like any other write (the - regenerated content is what must ship), and each is named on the `Mirrors:` row — - `overwrote uncommitted changes in: `. The commit is not the remedy for the loss; naming it - is. And the step-4 Verify reads the **digest** of every pre-existing dirty path not in the staged - set, not `git status`'s listing: the listing is exactly what an overwrite also leaves behind, so a - survival check phrased on it certifies the loss it exists to catch. +- **Each of the three flags in the snapshot recipe is load-bearing** (round-3 review, measured in a + scratch repo): `--untracked-files=all`, because the default reports a not-yet-committed directory + as one `?? dir/` entry — identical on both reads whatever the run wrote inside it — and + `git hash-object dir/` is `fatal: Unable to hash dir/`, i.e. the same blindness the digest closes, + surviving where the digest cannot reach; **file-exists scoping**, because `git hash-object` on a + ` D ` entry is `fatal: could not open … for reading` (exit 128) and this step's own + non-zero → HALT would block the PR on a condition the snapshot pass created — safe to skip, + since a recreated deletion *moves* its porcelain entry; and **`-w`**, because plain + `git hash-object` discards the bytes it hashes. +- **The overwrite is reported *and* recoverable, never silent.** Those paths are committed like any + other write (the regenerated content is what must ship), and each is named on the `Mirrors:` row — + `overwrote uncommitted changes in: (recover: git cat-file -p > )`. The commit + is not the remedy for the loss; the `-w` blob plus that line is. Naming a path the contributor + cannot restore — the content is in no HEAD, no index, no disk — is only a better-documented loss. + And the step-4 Verify reads the **digest** of every pre-existing dirty path not in the staged + set **that still has a file on disk**, not `git status`'s listing: the listing is exactly what an + overwrite also leaves behind, so a survival check phrased on it certifies the loss it exists to + catch. - **A thin script whose behaviour IS the deliverable may be exercised from vitest**, black-box, against a throwaway fixture — a bounded, documented exception to ADL 2026-07-13, which otherwise stands unchanged. Conditions, all of them: the script holds no logic that could be extracted to a diff --git a/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts b/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts index af0c0a118..8ff738be4 100644 --- a/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts +++ b/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts @@ -71,6 +71,51 @@ function git(dir: string, args: string[]): string { return execFileSync('git', args, { cwd: dir }).toString('utf-8') } +/** `git`, but a refusal is data — used to MEASURE the shapes `git hash-object` cannot read. */ +function tryGit(dir: string, args: string[]): RunResult { + try { + return { status: 0, stdout: git(dir, args), stderr: '' } + } catch (error) { + const e = error as { status: number | null; stdout?: Buffer; stderr?: Buffer } + return { + status: e.status ?? null, + stdout: e.stdout?.toString('utf-8') ?? '', + stderr: e.stderr?.toString('utf-8') ?? '', + } + } +} + +interface Snapshot { + entries: string + digests: Map +} + +/** + * /publish-pr Phase 1's before/after snapshot, executed exactly as the skill words it: + * `git status --porcelain --untracked-files=all`, plus `git hash-object [-w] ` over + * every entry whose worktree file still exists. `untrackedFilesAll` and `writeBlobs` are + * knobs ONLY so the test can run the pre-fix recipe next to the fixed one and show the + * difference; the skill documents one setting for each. + */ +function snapshotTree( + dir: string, + opts: { untrackedFilesAll: boolean; writeBlobs: boolean }, +): Snapshot { + const args = ['status', '--porcelain'] + if (opts.untrackedFilesAll) args.push('--untracked-files=all') + const entries = git(dir, args) + const digests = new Map() + for (const line of entries.split('\n').filter(Boolean)) { + const path = line.slice(3) + // "digest only entries whose worktree file exists": a deletion has nothing to read. + if (!existsSync(join(dir, path))) continue + const hash = ['hash-object'] + if (opts.writeBlobs) hash.push('-w') + digests.set(path, git(dir, [...hash, path]).trim()) + } + return { entries, digests } +} + function initRepo(dir: string): void { git(dir, ['init', '-q']) git(dir, ['config', 'user.email', 'test@example.com']) @@ -296,6 +341,111 @@ describe('regenerate-mirrors.sh — the local, deterministic mirror remedy (#419 SCRIPT_RUN_TIMEOUT_MS, ) + it( + 'the documented before/after recipe survives every ordinary porcelain shape', + () => { + // Round-3 finding, executed. The Phase-1 snapshot pass has to hold for the tree a real + // contributor is standing in, which is not "one modified tracked file": it also has + // uncommitted DELETIONS and NOT-YET-COMMITTED DIRECTORIES, and `git hash-object` refuses + // both. This fixture puts all three shapes in one tree and runs the real script over it: + // ` D doomed.md` — deletion: unhashable, and a fatal here meets the + // step's own non-zero → HALT (PR blocked by the + // snapshot pass that exists to protect it); + // `?? .pair/knowledge/sub/note.md` — a REGENERATED file inside an untracked directory: + // under the default -u mode it is one `?? sub/` + // entry, identical before and after, and unhashable + // — the run rewrites it and the comparison reads + // NO CHANGE, so it never gets staged; + // ` M .pair/knowledge/index.md` — the overwritten hand-edit, recoverable only if the + // before digest was taken with `-w`. + tmp = makeFixture() + const dataset = join(tmp, 'packages/knowledge-hub/dataset') + const mirror = join(tmp, '.pair/knowledge/index.md') + const NESTED = '# nested note\n' + write(join(dataset, '.pair/knowledge/sub/note.md'), NESTED) + write(mirror, '# pre-existing install\n') + + // Converge, then commit everything EXCEPT the nested install: that directory is the + // untracked-directory case, and it has to stay uncommitted to be one. + expect(run(tmp, isolatedHome(tmp)).status).toBe(0) + const nested = join(tmp, '.pair/knowledge/sub/note.md') + expect(readFileSync(nested, 'utf-8')).toBe(NESTED) + write(join(tmp, '.gitignore'), '.pair/.kb-version.json\n') + git(tmp, ['add', '-A', '--', ':!.pair/knowledge/sub']) + git(tmp, ['commit', '-q', '-m', 'converged, nested install left uncommitted']) + + // A tracked file the contributor deleted without committing the deletion. + write(join(tmp, 'doomed.md'), '# doomed\n') + git(tmp, ['add', 'doomed.md']) + git(tmp, ['commit', '-q', '-m', 'doomed']) + rmSync(join(tmp, 'doomed.md')) + + // HEAD carries a drifted mirror (so the run writes), the worktree an uncommitted hand-edit. + writeFileSync(mirror, '# committed drift\n') + git(tmp, ['add', '.pair/knowledge/index.md']) + git(tmp, ['commit', '-q', '-m', 'drifted mirror on HEAD']) + const HAND_EDIT = '# uncommitted hand-edit\n' + writeFileSync(mirror, HAND_EDIT) + const NESTED_EDIT = '# nested hand-edit\n' + writeFileSync(nested, NESTED_EDIT) + + // THE PRE-FIX RECIPE, measured: two of the three shapes are fatal, not hashable. + const defaultPorcelain = git(tmp, ['status', '--porcelain']) + expect(defaultPorcelain).toMatch(/^\?\? \.pair\/knowledge\/sub\/$/m) + expect(tryGit(tmp, ['hash-object', '.pair/knowledge/sub/']).stderr).toContain( + 'fatal: Unable to hash', + ) + expect(tryGit(tmp, ['hash-object', 'doomed.md']).stderr).toContain( + "fatal: could not open 'doomed.md' for reading", + ) + expect(tryGit(tmp, ['hash-object', 'doomed.md']).status).toBe(128) + + const before = snapshotTree(tmp, { untrackedFilesAll: true, writeBlobs: true }) + expect(before.entries).toMatch(/^ D doomed\.md$/m) + expect(before.entries).toMatch(/^ M \.pair\/knowledge\/index\.md$/m) + // -uall is what turns the collapsed `?? sub/` into a hashable per-file entry. + expect(before.entries).toMatch(/^\?\? \.pair\/knowledge\/sub\/note\.md$/m) + expect(before.digests.has('doomed.md')).toBe(false) + expect(before.digests.has('.pair/knowledge/sub/note.md')).toBe(true) + + const result = run(tmp, isolatedHome(tmp)) + expect(result.status).toBe(0) + + const after = snapshotTree(tmp, { untrackedFilesAll: true, writeBlobs: false }) + // Status is blind to BOTH overwrites — that is why the digest half exists... + expect(after.entries).toBe(before.entries) + // ...and with the recipe as documented, both are detected. + expect(after.digests.get('.pair/knowledge/index.md')).not.toBe( + before.digests.get('.pair/knowledge/index.md'), + ) + expect(after.digests.get('.pair/knowledge/sub/note.md')).not.toBe( + before.digests.get('.pair/knowledge/sub/note.md'), + ) + expect(readFileSync(mirror, 'utf-8')).toBe(KB_INDEX) + expect(readFileSync(nested, 'utf-8')).toBe(NESTED) + + // The deletion is untouched by the run, and its survival is carried by the entry — + // the Verify's on-disk qualifier is sound because status DOES move on a recreated path. + expect(existsSync(join(tmp, 'doomed.md'))).toBe(false) + + // `-w` is the difference between naming the loss and undoing it. + const mirrorSha = before.digests.get('.pair/knowledge/index.md') + const nestedSha = before.digests.get('.pair/knowledge/sub/note.md') + expect(mirrorSha).toBeDefined() + expect(nestedSha).toBeDefined() + expect(git(tmp, ['cat-file', '-p', mirrorSha ?? ''])).toBe(HAND_EDIT) + expect(git(tmp, ['cat-file', '-p', nestedSha ?? ''])).toBe(NESTED_EDIT) + // Without `-w` the same content hashes to the same sha and lands nowhere: the report row + // would name a path whose bytes are in no HEAD, no index, no disk and no ODB. + const control = join(tmp, 'control.md') + writeFileSync(control, '# not written to the ODB\n') + const unwritten = git(tmp, ['hash-object', control]).trim() + writeFileSync(control, '# overwritten\n') + expect(tryGit(tmp, ['cat-file', '-p', unwritten]).stderr).toContain('Not a valid object name') + }, + SCRIPT_RUN_TIMEOUT_MS, + ) + it('exits non-zero and names the reason when the dataset is missing (AC7)', () => { tmp = realpathSync(mkdtempSync(join(tmpdir(), 'regen-mirrors-'))) initRepo(tmp) diff --git a/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md b/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md index 099a3b3ca..93932175f 100644 --- a/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md +++ b/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md @@ -61,12 +61,15 @@ The realignment runs **before** the gate, and the order is load-bearing in both 1. **Check**: Does the adoption declare a `mirror-realign-command`? 2. **Skip**: If it does not, go to step 5. A project with no generated mirrors has nothing to realign — the zero-configuration default, not a degradation, and nothing is reported. -3. **Act**: Take the **before** snapshot — `git status --porcelain`, whole tree — **paired with a content digest of every path that snapshot reports as dirty** (`git hash-object ` over that set, or an equivalent `git diff` capture). The digest is not belt-and-braces: a porcelain entry encodes **status, not content**, so a path that is *already* dirty reports the same unstaged-modified `M ` entry before and after whether the run rewrote the file or never opened it — status alone cannot tell those two apart, and one of them is a destroyed hand-edit. Only then run the declared command. It regenerates the mirrors from the working tree's **local** dataset — never a published release — and is idempotent. A **non-zero exit → HALT** before any PR side effect, reporting the command's own reason verbatim: it never reports success over a no-op, so a failure here means nothing was written and the drift is still there. -4. **Check → Act**: Take the **after** snapshot (`git status --porcelain` again, plus the digest of the same paths) and compare it with the before snapshot. **The set to stage is what this run actually wrote** — every path whose porcelain entry appeared, disappeared or changed between the two reads, **plus every path already dirty in the before snapshot whose digest changed** — and it is derived that way rather than from a **path glob** of "the paths the command owns" deliberately. A glob is a *guess about the command*, and it is wrong wherever generated output and authored files share a prefix — the ordinary case, since the directory holding a project's generated mirrors is usually the same one holding its hand-written adoption/knowledge files. Under a glob, a contributor who left an authored file dirty beneath that prefix gets it committed under `chore: regenerate mirrors from local dataset` — their prose, under a regeneration message, in a commit they never wrote. The before/after comparison cannot do that: a file this run did not touch has an identical entry **and an identical digest** in both snapshots. It also means **no adopter has to enumerate owned globs anywhere** — the command's own effect is the declaration. - - **Overwritten uncommitted work** (the digest half, and the only case `git status` cannot show): HEAD carries a drifted mirror, the contributor is holding an *uncommitted* edit to that same file, and the regeneration replaces it — the entry stays the same unstaged-modified `M ` on both reads while the bytes changed. Those paths join the staged set like any other write (the regenerated content is what must ship; leaving it out pushes the stale mirror the guards reject and turns the branch's own conformance job red), **and every one of them is named in the output** — `overwrote uncommitted changes in: ` on the `Mirrors:` row. Never silent here: the contributor's work is gone from disk, and a loss nobody is told about is worse than the drift this step exists to fix. +3. **Act**: Take the **before** snapshot — `git status --porcelain --untracked-files=all`, whole tree — **paired with a content digest of every entry whose worktree file still exists** (`git hash-object -w ` over that set). The digest is not belt-and-braces: a porcelain entry encodes **status, not content**, so a path that is *already* dirty reports the same unstaged-modified `M ` entry before and after whether the run rewrote the file or never opened it — status alone cannot tell those two apart, and one of them is a destroyed hand-edit. Only then run the declared command. It regenerates the mirrors from the working tree's **local** dataset — never a published release — and is idempotent. A **non-zero exit → HALT** before any PR side effect, reporting the command's own reason verbatim: it never reports success over a no-op, so a failure here means nothing was written and the drift is still there. Each of the three flags in that sentence is doing work, and skipping one puts the step back where the digest found it: + - **`--untracked-files=all`**, because the default collapses a not-yet-committed directory into one `?? dir/` entry — one entry however many files under it the run rewrote, identical on both reads — and `git hash-object dir/` answers `fatal: Unable to hash dir/`, so that whole subtree would be undetectable *and* unhashable: exactly the status-vs-content blindness the digest exists to close, surviving where the digest cannot reach. Expanded per file, `?? dir/a.md` hashes like any other path. (An `equivalent git diff capture` is not equivalent here: **it never reports untracked paths at all**.) + - **only entries whose file still exists**, because a deletion has none to read: `git hash-object gone.md` on the ` D ` entry that path left behind is `fatal: could not open 'gone.md' for reading`, exit 128 — and this step's own **non-zero exit → HALT** would turn that into a PR blocked by the snapshot pass that was meant to protect it. **Skip those entries** (` D `, `AD`, `DD`), and nothing is lost by skipping: a deleted path the command recreates **moves its porcelain entry** (` D ` → ` M `, or gone), so the status comparison already catches it. The digest is only needed for the shapes where status *cannot* move. + - **`-w`**, because plain `git hash-object` prints a hash and throws the bytes away, while `-w` also **writes the blob into the object database** — same output, and the difference is whether the loss reported two steps later is recoverable. Once the command overwrites a pre-dirty path, the contributor's uncommitted content is in no HEAD (never committed), no index, no disk (overwritten); with `-w` it is in the ODB, and `git cat-file -p ` prints it back. +4. **Check → Act**: Take the **after** snapshot (`git status --porcelain --untracked-files=all` again, plus the digest of the same paths — **re-hashing needs no `-w`**: only the pre-overwrite content was at risk) and compare it with the before snapshot. **The set to stage is what this run actually wrote** — every path whose porcelain entry appeared, disappeared or changed between the two reads, **plus every path already dirty in the before snapshot whose digest changed** — and it is derived that way rather than from a **path glob** of "the paths the command owns" deliberately. A glob is a *guess about the command*, and it is wrong wherever generated output and authored files share a prefix — the ordinary case, since the directory holding a project's generated mirrors is usually the same one holding its hand-written adoption/knowledge files. Under a glob, a contributor who left an authored file dirty beneath that prefix gets it committed under `chore: regenerate mirrors from local dataset` — their prose, under a regeneration message, in a commit they never wrote. The before/after comparison cannot do that: a file this run did not touch has an identical entry **and an identical digest** in both snapshots. It also means **no adopter has to enumerate owned globs anywhere** — the command's own effect is the declaration. + - **Overwritten uncommitted work** (the digest half, and the only case `git status` cannot show): HEAD carries a drifted mirror, the contributor is holding an *uncommitted* edit to that same file, and the regeneration replaces it — the entry stays the same unstaged-modified `M ` on both reads while the bytes changed. Those paths join the staged set like any other write (the regenerated content is what must ship; leaving it out pushes the stale mirror the guards reject and turns the branch's own conformance job red), **and every one of them is named in the output** — `overwrote uncommitted changes in: (recover: git cat-file -p > )` on the `Mirrors:` row, `` being the before snapshot's `-w` digest. Never silent here: the contributor's work is gone from disk, and a loss nobody is told about is worse than the drift this step exists to fix. The `-w` is what makes that row a remedy instead of an obituary — a named path the contributor cannot restore is only a better-documented loss. - **No change** → the two snapshots are equal **and no dirty path's digest moved**; a no-op stays **silent**: no commit, and no output row (the `Mirrors:` row is emitted only when a commit was made). Continue to step 5. - **Changed** → stage **only** the paths that comparison produced — never `git add -A`, and never a glob: unstaged authored changes belong to the contributor and must survive the run untouched, and this skill must not commit them — and commit them **alone**, as their own commit, never mixed into a feature commit. Name it as a *regeneration* (e.g. `chore: regenerate mirrors from local dataset`), never a "fix": an overwritten hand-edit was restored to what the dataset generates, not repaired. Drift in a file this branch never touched is committed here too, and **said so in the output** — surprising, but better than pushing knowingly stale generated output, and the separate commit keeps even a dataset-wide regeneration readable next to the authored work. - - **Verify**: `git log` shows exactly one new commit and its file list equals that set exactly; **every pre-existing dirty path that is NOT in the set still carries its before digest** — the survival check is on **content**, because a path still being listed by `git status` is exactly what an overwrite also produces, so a check phrased on the listing would certify the loss it is meant to catch. + - **Verify**: `git log` shows exactly one new commit and its file list equals that set exactly; **every pre-existing dirty path that is NOT in the set and still has a file on disk still carries its before digest** — the survival check is on **content**, because a path still being listed by `git status` is exactly what an overwrite also produces, so a check phrased on the listing would certify the loss it is meant to catch. The on-disk qualifier is not a loophole: a deleted path has no digest by construction (step 3), and its survival is carried by the porcelain entry, which any rewrite would have moved. 5. **Act**: Compose `/verify-quality` with `$scope` (default `all`). This is a local pre-flight, not a replacement for CI (CI stays authoritative, #210). 6. **Check**: Did every required gate pass? 7. **Skip**: If all gates pass, proceed to Phase 2. @@ -165,7 +168,7 @@ The PR is ready; it must now be **under review and mechanically blocked** — se PUBLISH-PR REPORT: ├── Story: [#ID: Title] ├── Handoff: [.pair/working/checkpoints/.md | none — state gathered from branch+story] -├── Mirrors: [regenerated — commit , N file(s)[; overwrote uncommitted changes in: ] — omit this row entirely when nothing was committed] +├── Mirrors: [regenerated — commit , N file(s)[; overwrote uncommitted changes in: (recover: git cat-file -p > )] — omit this row entirely when nothing was committed] ├── Gate: [PASS | HALTED — N gates failing] ├── Base: [base-branch — squash on merge: yes|no] ├── PR: [#PR-number — URL — Created | Updated — ready-for-review confirmed by read | ready-for-review not confirmed — finding] diff --git a/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts b/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts index 71c05ab87..3b957f9c8 100644 --- a/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts +++ b/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts @@ -103,8 +103,10 @@ describe('publish-pr realigns mirrors before its gate (#419)', () => { // dataset` — a commit they never wrote — contradicting this same phase's // "unstaged authored changes ... must survive the run untouched". const p1 = phase1() - expect(p1).toMatch(/\*\*before\*\* snapshot — `git status --porcelain`/) - expect(p1).toMatch(/\*\*after\*\* snapshot \(`git status --porcelain` again/) + expect(p1).toMatch(/\*\*before\*\* snapshot — `git status --porcelain --untracked-files=all`/) + expect(p1).toMatch( + /\*\*after\*\* snapshot \(`git status --porcelain --untracked-files=all` again/, + ) expect(p1).toMatch(/appeared, disappeared or changed between the two reads/) expect(p1).toMatch(/rather than from a \*\*path glob\*\*/) expect(p1).toMatch(/and never a glob/) @@ -124,19 +126,65 @@ describe('publish-pr realigns mirrors before its gate (#419)', () => { // packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts, 'overwrites a pre-dirty // mirror while `git status --porcelain` stays byte-identical'. const p1 = phase1() - expect(p1).toMatch(/content digest of every path that snapshot reports as dirty/) - expect(p1).toMatch(/`git hash-object `/) + expect(p1).toMatch(/content digest of every entry whose worktree file still exists/) + expect(p1).toMatch(/`git hash-object -w `/) expect(p1).toMatch(/encodes \*\*status, not content\*\*/) // The digest half must be IN the staged set, not merely detected. expect(p1).toMatch(/plus every path already dirty in the before snapshot whose digest changed/) // ...and the loss must be reported: silence is the failure mode, not the commit. - expect(p1).toMatch(/overwrote uncommitted changes in: /) + expect(p1).toMatch(/overwrote uncommitted changes in: /) expect(p1).toMatch(/Never silent here/) - expect(dataset()).toMatch(/overwrote uncommitted changes in: \]/) + expect(dataset()).toMatch(/overwrote uncommitted changes in: \(recover: /) // The no-op branch must require BOTH halves to be quiet, or it re-opens the same hole. expect(p1).toMatch(/equal \*\*and no dirty path's digest moved\*\*/) }) + it('scopes the digest to the porcelain shapes `git hash-object` can actually read', () => { + // Round-3 finding (a). The digest pass, as first written, ran `git hash-object ` over + // EVERY dirty entry. Two of the three ordinary shapes are not hashable, MEASURED in a scratch + // repo (`rm gone.md`; `mkdir newdir && echo a > newdir/a.md`; hand-edit `tracked.md`): + // ` D gone.md` -> fatal: could not open 'gone.md' for reading (exit 128) + // `?? newdir/` -> fatal: Unable to hash newdir/ (exit 128) + // ` M tracked.md` -> 6d9435b… + // A fatal inside step 3 meets the step's own "non-zero exit → HALT" and blocks the PR on a + // condition the snapshot pass itself created; and `?? dir/` is ONE entry however many files + // under it the run rewrote — identical before and after, unhashable, so the untracked subtree + // keeps exactly the status-vs-content blindness the digest was added to close. + // Executed end to end against the real script: + // packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts, 'the documented before/after + // recipe survives every ordinary porcelain shape'. + const p1 = phase1() + expect(p1).toMatch(/`git status --porcelain --untracked-files=all`/) + expect(p1).toMatch(/collapses a not-yet-committed directory into one `\?\? dir\/` entry/) + expect(p1).toMatch(/fatal: Unable to hash dir\//) + expect(p1).toMatch(/fatal: could not open 'gone\.md' for reading/) + expect(p1).toMatch(/Skip those entries/) + // Skipping deletions must be justified, not merely permitted: status DOES move on a recreated + // path, so the digest is only needed where it cannot. + expect(p1).toMatch(/moves its porcelain entry/) + // The `git diff` escape hatch the earlier wording offered cannot cover the untracked half. + expect(p1).toMatch(/it never reports untracked paths at all/) + // Verify must not demand a digest from a path that has none. + expect(p1).toMatch( + /every pre-existing dirty path that is NOT in the set and still has a file on disk/, + ) + }) + + it('persists the before digest with `-w`, so the overwritten bytes are recoverable', () => { + // Round-3 finding (b). Plain `git hash-object` hashes and throws the content away. After the + // command overwrites a pre-dirty path the contributor's uncommitted bytes are in no HEAD (never + // committed), no index, no disk (overwritten) and no ODB — so `overwrote uncommitted changes + // in: ` named a loss with no remedy. MEASURED: `SHA=$(git hash-object -w tracked.md)`, + // overwrite, `git cat-file -p $SHA` -> `v2-handedit`; the same sha taken WITHOUT `-w` -> + // `fatal: Not a valid object name`. + const p1 = phase1() + expect(p1).toMatch(/writes the blob into the object database/) + expect(p1).toMatch(/`git cat-file -p `/) + expect(p1).toMatch(/overwrote uncommitted changes in: \(recover: git cat-file -p /) + // The after pass must NOT be told to write blobs: only the pre-overwrite content is at risk. + expect(p1).toMatch(/re-hashing needs no `-w`/) + }) + it('names the commit a regeneration, never a fix (an overwritten hand-edit was restored)', () => { const p1 = phase1() expect(p1).toMatch(/regenerate mirrors from local dataset/) @@ -152,7 +200,7 @@ describe('publish-pr realigns mirrors before its gate (#419)', () => { // digest, and must say why the listing is not evidence. expect(p1).not.toMatch(/`git status` still shows every pre-existing unstaged authored change/) expect(p1).toMatch( - /every pre-existing dirty path that is NOT in the set still carries its before digest/, + /every pre-existing dirty path that is NOT in the set and still has a file on disk still carries its before digest/, ) expect(p1).toMatch(/certify the loss it is meant to catch/) }) From c939f1a990b176b45be2d7d88f93423c891246bb Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Tue, 1 Sep 2026 19:40:07 +0200 Subject: [PATCH 12/14] [#419] fix: one remedy in every guard branch; -z snapshot + pathspec commit (review round 4) - mirror-guard: the MISSING branch and the orphan-sweep remedy now name MIRROR_REGENERATE_COMMAND, like the drifted branch already did. Both are reached by ADDING a dataset file: `pair update` installs the published KB, so the new file is in no release and every other mirror is overwritten with released content. - publish-pr Phase 1 snapshot is `git status --porcelain -z -uall`: the default format quotes/octal-escapes a path with a space or a non-ASCII byte, so the entry was silently dropped from the digest (and `git add` refused the quoted string as a pathspec). - the regeneration commit is made BY PATHSPEC: a plain `git commit` after `git add ` commits the whole index, sweeping in prose the contributor staged before the run. - skill-md-mirror docblock + unreadable-path message name the constant. --- .../pair-capability-publish-pr/SKILL.md | 7 +- ...ish-pr-realigns-mirrors-before-the-gate.md | 38 ++- .pair/adoption/tech/way-of-working.md | 2 +- .../quality-gates/regenerate-mirrors.test.ts | 217 +++++++++++++++++- .../.skills/capability/publish-pr/SKILL.md | 7 +- .../conformance/mirror-realignment.test.ts | 51 +++- .../src/tools/mirror-guard.test.ts | 25 +- .../knowledge-hub/src/tools/mirror-guard.ts | 5 +- .../src/tools/skill-md-mirror.test.ts | 18 +- .../src/tools/skill-md-mirror.ts | 7 +- 10 files changed, 334 insertions(+), 43 deletions(-) diff --git a/.claude/skills/pair-capability-publish-pr/SKILL.md b/.claude/skills/pair-capability-publish-pr/SKILL.md index c6f7f9045..e506cde8b 100644 --- a/.claude/skills/pair-capability-publish-pr/SKILL.md +++ b/.claude/skills/pair-capability-publish-pr/SKILL.md @@ -61,14 +61,15 @@ The realignment runs **before** the gate, and the order is load-bearing in both 1. **Check**: Does the adoption declare a `mirror-realign-command`? 2. **Skip**: If it does not, go to step 5. A project with no generated mirrors has nothing to realign — the zero-configuration default, not a degradation, and nothing is reported. -3. **Act**: Take the **before** snapshot — `git status --porcelain --untracked-files=all`, whole tree — **paired with a content digest of every entry whose worktree file still exists** (`git hash-object -w ` over that set). The digest is not belt-and-braces: a porcelain entry encodes **status, not content**, so a path that is *already* dirty reports the same unstaged-modified `M ` entry before and after whether the run rewrote the file or never opened it — status alone cannot tell those two apart, and one of them is a destroyed hand-edit. Only then run the declared command. It regenerates the mirrors from the working tree's **local** dataset — never a published release — and is idempotent. A **non-zero exit → HALT** before any PR side effect, reporting the command's own reason verbatim: it never reports success over a no-op, so a failure here means nothing was written and the drift is still there. Each of the three flags in that sentence is doing work, and skipping one puts the step back where the digest found it: +3. **Act**: Take the **before** snapshot — `git status --porcelain -z --untracked-files=all`, whole tree — **paired with a content digest of every entry whose worktree file still exists** (`git hash-object -w ` over that set). The digest is not belt-and-braces: a porcelain entry encodes **status, not content**, so a path that is *already* dirty reports the same unstaged-modified `M ` entry before and after whether the run rewrote the file or never opened it — status alone cannot tell those two apart, and one of them is a destroyed hand-edit. Only then run the declared command. It regenerates the mirrors from the working tree's **local** dataset — never a published release — and is idempotent. A **non-zero exit → HALT** before any PR side effect, reporting the command's own reason verbatim: it never reports success over a no-op, so a failure here means nothing was written and the drift is still there. Each of the four rules in that sentence is doing work, and skipping one puts the step back where the digest found it: + - **`-z`**, because the default porcelain format **quotes and octal-escapes** any path holding a space or a non-ASCII byte: `with space.md` prints as ` M "with space.md" ` and `caffè.md` as ` M "caff\303\250.md" `, so the path field read off the entry is *not a filename* — it fails the file-exists test below and the entry is dropped from the digest silently, which is the same status-vs-content blindness the digest exists to close, reached through the parser instead of through `git`. It also breaks the other direction: a NEW generated file with a space is caught by the status comparison, and then `git add '"with space.md"'` fails as a pathspec mid-step. `-z` prints the raw bytes, **NUL-separated**, never quoted or escaped — so **split on NUL**, not on newline (a filename may contain one). Its one parsing rule: a rename/copy entry is `R ` + a second field holding `` — consume that field, never read it as an entry of its own. (This is also what removes the `old -> new` ambiguity the default format's rename line has.) - **`--untracked-files=all`**, because the default collapses a not-yet-committed directory into one `?? dir/` entry — one entry however many files under it the run rewrote, identical on both reads — and `git hash-object dir/` answers `fatal: Unable to hash dir/`, so that whole subtree would be undetectable *and* unhashable: exactly the status-vs-content blindness the digest exists to close, surviving where the digest cannot reach. Expanded per file, `?? dir/a.md` hashes like any other path. (An `equivalent git diff capture` is not equivalent here: **it never reports untracked paths at all**.) - **only entries whose file still exists**, because a deletion has none to read: `git hash-object gone.md` on the ` D ` entry that path left behind is `fatal: could not open 'gone.md' for reading`, exit 128 — and this step's own **non-zero exit → HALT** would turn that into a PR blocked by the snapshot pass that was meant to protect it. **Skip those entries** (` D `, `AD`, `DD`), and nothing is lost by skipping: a deleted path the command recreates **moves its porcelain entry** (` D ` → ` M `, or gone), so the status comparison already catches it. The digest is only needed for the shapes where status *cannot* move. - **`-w`**, because plain `git hash-object` prints a hash and throws the bytes away, while `-w` also **writes the blob into the object database** — same output, and the difference is whether the loss reported two steps later is recoverable. Once the command overwrites a pre-dirty path, the contributor's uncommitted content is in no HEAD (never committed), no index, no disk (overwritten); with `-w` it is in the ODB, and `git cat-file -p ` prints it back. -4. **Check → Act**: Take the **after** snapshot (`git status --porcelain --untracked-files=all` again, plus the digest of the same paths — **re-hashing needs no `-w`**: only the pre-overwrite content was at risk) and compare it with the before snapshot. **The set to stage is what this run actually wrote** — every path whose porcelain entry appeared, disappeared or changed between the two reads, **plus every path already dirty in the before snapshot whose digest changed** — and it is derived that way rather than from a **path glob** of "the paths the command owns" deliberately. A glob is a *guess about the command*, and it is wrong wherever generated output and authored files share a prefix — the ordinary case, since the directory holding a project's generated mirrors is usually the same one holding its hand-written adoption/knowledge files. Under a glob, a contributor who left an authored file dirty beneath that prefix gets it committed under `chore: regenerate mirrors from local dataset` — their prose, under a regeneration message, in a commit they never wrote. The before/after comparison cannot do that: a file this run did not touch has an identical entry **and an identical digest** in both snapshots. It also means **no adopter has to enumerate owned globs anywhere** — the command's own effect is the declaration. +4. **Check → Act**: Take the **after** snapshot (`git status --porcelain -z --untracked-files=all` again, plus the digest of the same paths — **re-hashing needs no `-w`**: only the pre-overwrite content was at risk) and compare it with the before snapshot. **The set to stage is what this run actually wrote** — every path whose porcelain entry appeared, disappeared or changed between the two reads, **plus every path already dirty in the before snapshot whose digest changed** — and it is derived that way rather than from a **path glob** of "the paths the command owns" deliberately. A glob is a *guess about the command*, and it is wrong wherever generated output and authored files share a prefix — the ordinary case, since the directory holding a project's generated mirrors is usually the same one holding its hand-written adoption/knowledge files. Under a glob, a contributor who left an authored file dirty beneath that prefix gets it committed under `chore: regenerate mirrors from local dataset` — their prose, under a regeneration message, in a commit they never wrote. The before/after comparison cannot do that: a file this run did not touch has an identical entry **and an identical digest** in both snapshots. It also means **no adopter has to enumerate owned globs anywhere** — the command's own effect is the declaration. - **Overwritten uncommitted work** (the digest half, and the only case `git status` cannot show): HEAD carries a drifted mirror, the contributor is holding an *uncommitted* edit to that same file, and the regeneration replaces it — the entry stays the same unstaged-modified `M ` on both reads while the bytes changed. Those paths join the staged set like any other write (the regenerated content is what must ship; leaving it out pushes the stale mirror the guards reject and turns the branch's own conformance job red), **and every one of them is named in the output** — `overwrote uncommitted changes in: (recover: git cat-file -p > )` on the `Mirrors:` row, `` being the before snapshot's `-w` digest. Never silent here: the contributor's work is gone from disk, and a loss nobody is told about is worse than the drift this step exists to fix. The `-w` is what makes that row a remedy instead of an obituary — a named path the contributor cannot restore is only a better-documented loss. - **No change** → the two snapshots are equal **and no dirty path's digest moved**; a no-op stays **silent**: no commit, and no output row (the `Mirrors:` row is emitted only when a commit was made). Continue to step 5. - - **Changed** → stage **only** the paths that comparison produced — never `git add -A`, and never a glob: unstaged authored changes belong to the contributor and must survive the run untouched, and this skill must not commit them — and commit them **alone**, as their own commit, never mixed into a feature commit. Name it as a *regeneration* (e.g. `chore: regenerate mirrors from local dataset`), never a "fix": an overwritten hand-edit was restored to what the dataset generates, not repaired. Drift in a file this branch never touched is committed here too, and **said so in the output** — surprising, but better than pushing knowingly stale generated output, and the separate commit keeps even a dataset-wide regeneration readable next to the authored work. + - **Changed** → stage **only** the paths that comparison produced — never `git add -A`, and never a glob: unstaged authored changes belong to the contributor and must survive the run untouched, and this skill must not commit them — and commit them **alone**, as their own commit, never mixed into a feature commit. Commit them **by pathspec**: `git commit -m "chore: regenerate mirrors from local dataset" -- `. The pathspec is not a stylistic preference — a plain `git commit` after `git add ` commits **the whole index**, and content the contributor had **already staged before the run** is never part of this commit. That case is ordinary, not exotic: this skill is standalone, explicitly runs on a dirty tree, and a resumed or interrupted `/pair-process-implement` leaves a populated index — so the staged prose would land inside the regeneration commit, which is the same harm the rule above prevents for *unstaged* work, reached through the index instead of through a glob. The pathspec form leaves those entries staged and untouched. Name it as a *regeneration* (e.g. `chore: regenerate mirrors from local dataset`), never a "fix": an overwritten hand-edit was restored to what the dataset generates, not repaired. Drift in a file this branch never touched is committed here too, and **said so in the output** — surprising, but better than pushing knowingly stale generated output, and the separate commit keeps even a dataset-wide regeneration readable next to the authored work. - **Verify**: `git log` shows exactly one new commit and its file list equals that set exactly; **every pre-existing dirty path that is NOT in the set and still has a file on disk still carries its before digest** — the survival check is on **content**, because a path still being listed by `git status` is exactly what an overwrite also produces, so a check phrased on the listing would certify the loss it is meant to catch. The on-disk qualifier is not a loophole: a deleted path has no digest by construction (step 3), and its survival is carried by the porcelain entry, which any rewrite would have moved. 5. **Act**: Compose `/pair-capability-verify-quality` with `$scope` (default `all`). This is a local pre-flight, not a replacement for CI (CI stays authoritative, #210). 6. **Check**: Did every required gate pass? diff --git a/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md b/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md index bb180ed58..013981a90 100644 --- a/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md +++ b/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md @@ -62,7 +62,7 @@ read from the adoption, never named in the skill.** produced a diff. A no-op is **silent**: no commit, and no output row (the `Mirrors:` row is emitted only when a commit was made). - **The staged set is the command's own effect, not a path glob.** The skill snapshots - `git status --porcelain` before running the command and again after, and stages exactly the paths + `git status --porcelain -z` before running the command and again after, and stages exactly the paths whose entry appeared, disappeared or changed. A glob is a guess about the command and is wrong wherever generated output and authored files share a prefix; the comparison cannot be, because a file the run did not touch has an identical entry in both snapshots. Corollary: **no adopter has @@ -71,7 +71,7 @@ read from the adoption, never named in the skill.** - **The comparison is content-aware on paths that were ALREADY dirty**, because a porcelain entry encodes status, not content. The before snapshot therefore carries a digest (`git hash-object -w`) of every dirty path **whose worktree file exists**, read from - `git status --porcelain --untracked-files=all`, and the staged set is the entries that + `git status --porcelain -z --untracked-files=all`, and the staged set is the entries that appeared/disappeared/changed **plus the pre-dirty paths whose digest moved**. Without it: HEAD carries a drifted mirror, the contributor holds an uncommitted edit to that same file, the command regenerates it — the same unstaged-modified `M ` entry on both reads — and a @@ -80,7 +80,34 @@ read from the adoption, never named in the skill.** pushed, turning the branch's own conformance job red. Measured against the real script in `regenerate-mirrors.test.ts` ("overwrites a pre-dirty mirror while `git status --porcelain` stays byte-identical"). -- **Each of the three flags in the snapshot recipe is load-bearing** (round-3 review, measured in a +- **The snapshot is read NUL-separated (`-z`), because the default format is not a list of paths** + (round-4 review, measured in a scratch repo). Porcelain v1 quotes and octal-escapes any path with + a space or a non-ASCII byte: ` M "with space.md" `, ` M "caff\303\250.md" ` — the path field read + off such an entry is not a filename, fails the file-exists test, and is dropped from the digest + in silence. That is the same status-vs-content blindness the digest closes, re-entering through + the parser: a generated `docs/My Guide.md` already carrying a hand-edit is overwritten with an + unchanged entry on both reads and no digest, so the run reads NO CHANGE — hand-edit gone with no + `recover:` row, regenerated bytes never staged, stale mirror pushed. The reverse shape costs the + step outright: a new generated file with a space is caught by status and then + `git add '"with space.md"'` fails as a pathspec. `-z` prints raw bytes, never quoted, so the + snapshot is **split on NUL** (a filename may contain a newline) and a rename entry's `` + arrives as a second field to be consumed, not read as an entry — which is also what removes the + `old -> new` ambiguity of the default rename line. Measured in + `regenerate-mirrors.test.ts` ("the snapshot recipe sees a path with a space and a non-ASCII byte + — the default parse does not"). +- **The regeneration commit is made by PATHSPEC, never by a bare `git commit`** (round-4 review, + measured). The staging rule protected *unstaged* authored work, but `git add ` followed by + a plain `git commit` commits the whole INDEX: with `M authored.md` staged and ` M mirror.md ` + regenerated, the resulting commit lists both — the contributor's prose under + `chore: regenerate mirrors from local dataset`, a commit they never wrote. It is the harm the + whole staging-rule section exists to prevent, reached through the index instead of through a + glob, and it is ordinary: this skill is standalone, explicitly runs on a dirty tree, and a + resumed/interrupted `/pair-process-implement` leaves a populated index. The step-4 Verify catches + it only *after* the commit exists, and a Verify failure is not a HALT condition — so the + mislabelled commit would be pushed. `git commit -m "…" -- ` commits only the pathspec and + leaves the staged entries staged and untouched. +- **Each of the three remaining rules in the snapshot recipe is load-bearing** (round-3 review, + measured in a scratch repo): `--untracked-files=all`, because the default reports a not-yet-committed directory as one `?? dir/` entry — identical on both reads whatever the run wrote inside it — and `git hash-object dir/` is `fatal: Unable to hash dir/`, i.e. the same blindness the digest closes, @@ -145,8 +172,9 @@ read from the adoption, never named in the skill.** - `/pair-capability-publish-pr` commits on the contributor's behalf. That is acceptable **only** under the constraints above: generated content, its own commit, named as a *regeneration* (never a "fix" — - an overwritten hand-edit was restored, not repaired), and never `git add -A`, so unstaged authored - changes in the working tree survive untouched. + an overwritten hand-edit was restored, not repaired), never `git add -A`, and never a bare + `git commit` — so both the unstaged authored changes in the working tree and anything the + contributor had already staged survive untouched. - Drift in a file the branch never touched is committed here too, and reported. Surprising, but pushing knowingly stale generated output is worse. - Running `/pair-capability-publish-pr` twice commits nothing the second time — the command is idempotent. diff --git a/.pair/adoption/tech/way-of-working.md b/.pair/adoption/tech/way-of-working.md index 381e54afb..dbed5e940 100644 --- a/.pair/adoption/tech/way-of-working.md +++ b/.pair/adoption/tech/way-of-working.md @@ -85,7 +85,7 @@ Resolution order, the split-tool routing and why the fallback is never the authe - **Review enforcement**: `disabled` (default) — the pair review **runs and publishes its verdict**, but nothing it says blocks a merge: `pair-review` and `pair-explicit-approval` are not required status checks, and the 🔴 explicit-approval rule is advisory. Set to `enabled` to make them required and the rule binding, per [pr-states.md](../../knowledge/guidelines/collaboration/project-management-tool/pr-states.md); `/pair-capability-setup-gates` reads this flag before touching branch protection, and `/pair-process-bootstrap` asks for it when no decision exists. Disabled is the default deliberately: a review that blocks by default turns a first install into a repository nobody can merge into — on a single-maintainer repo the 🔴 non-author approval is unobtainable outright. The tier requirements themselves (reviewer count, SLA, checklist depth, whether 🔴 needs explicit approval) are redefinable in this file; that the review **runs** is not. - **Coverage guardrail**: `enabled` — pair dogfoods its own capability: the [`Coverage guardrail` step](../../../.github/workflows/ci.yml) in CI sources [`coverage-gate.sh`](../../knowledge/assets/coverage-gate.sh), extracts the line-coverage % from each package's istanbul `coverage-summary.json`, and blocks a PR whose coverage drops below the human-committed baseline in [`tech/coverage-baseline.md`](./coverage-baseline.md) (maintaining/improving passes — not an absolute wall). The framework **default remains `disabled`** (the dataset template ships off); this line is pair's project-level opt-in only. See [coverage guardrail](../../knowledge/guidelines/infrastructure/cicd-strategy/tier-aware-pipeline.md#coverage-guardrail-opt-in-regression-gate-consumed-by-this-pipeline) + [config format](../../knowledge/assets/coverage-config-example.md); `/pair-capability-setup-gates` reads this flag before generating the pipeline. **Coverage baseline commit-back**: `disabled` — the separate, nested opt-in ratchet (#372, framework default also `disabled`): when `enabled`, a **push to the base branch** (never a PR run, never a fork) proposes a raised `baseline.` as a **bot pull request** from `chore/coverage-baseline-ratchet`, never a push to `main`, and requires a repo-scoped `COVERAGE_RATCHET_TOKEN` (`contents: write` + `pull requests: write`, no protection bypass) — without it the step warns and the gate's verdict is unchanged. It stays `disabled` here until story #234's branch protection is applied and that secret is provisioned (ADR-018 lands with that story, so it is not linked from here yet); see ADL [2026-07-30-coverage-ratchet-pr-not-push.md](../decision-log/2026-07-30-coverage-ratchet-pr-not-push.md). The step that runs it is the **shipped** KB asset `node .pair/knowledge/assets/coverage-ratchet.cjs` (ADR-023) — the same one an adopter's generated pipeline invokes, so this flag being `enabled` means the same thing here as anywhere else. - **Pair review required checks**: `pair-review` + `pair-explicit-approval` are the required status checks that make the judgment review unskippable (R5.7) and enforce the 🔴 explicit-human-approval rule (D10) — see [pr-states.md](../../knowledge/guidelines/collaboration/project-management-tool/pr-states.md) and [ADR-018](adr/adr-018-pr-state-flow-required-checks.md). Status on this repo: **not yet applied** — writing branch protection needs admin scope, so it is a deliberate human step; until applied, enforcement here is advisory (the documented degraded mode). **Ordering constraint** (applies in this order, or every merge stops): 1. provision the `pr-state:*` labels + add the `pair-explicit-approval` workflow (neither needs admin scope — this repo has not added the workflow yet, so the context does not report today); 2. confirm on a real PR that `pair-review` and `pair-explicit-approval` both report on the head commit, **and** that the approval context re-reports on that same head SHA after a review submission; 3. only then `PUT` the branch protection, keeping `enforce_admins` off until one PR has merged through it. The whole sequence (including the merge-block outcomes per tier) was executed on a throwaway repository — see `github-implementation.md` § "Verified on a throwaway repository" — so what remains here is applying it, not discovering whether it works. **This repo is single-maintainer**, so a 🔴 PR cannot satisfy `pair-explicit-approval` (GitHub rejects a self-approval): a second human reviewer account is a prerequisite for making that context required here — otherwise leave it out of the required list and keep the 🔴 rule advisory. The solo-maintainer alternative (a verified human approval token instead of a second account) is tracked as [#398](https://github.com/foomakers/pair/issues/398). **When the protection is written here, use the `checks` form with `app_id` pinned** for `pair-explicit-approval` (an unpinned status context is satisfiable by any push-access token, including the agent's); `pair-review` stays unpinned and is an anti-accident control, not an authorization control — see `github-implementation.md` § "What each context proves". -- **`mirror-realign-command`**: `pnpm mirrors:regenerate` — the single, local, deterministic writer that realigns the generated mirrors with `packages/knowledge-hub/dataset`. It writes into `.claude/**`, root `.pair/**`, `AGENTS.md`/`CLAUDE.md` and `.github/**` — **a description of where its output lands, never a staging rule**: those same trees hold authored files (117 tracked files under `.pair/adoption/**` alone), so anything that committed the glob rather than the command's actual effect would sweep a contributor's unstaged prose into a regeneration commit. `/pair-capability-publish-pr` therefore stages a **before/after `git status --porcelain` comparison**, and no adopter enumerates owned globs anywhere. It wraps the CLI's existing local-source path (`pair update --source --offline`) and adds no generation logic; it has **no check mode** — the mirror guards (`skills:conformance`) are the checker, this is the only writer. **Writer and checker are not the same scope, and the asymmetry is the writer's**: the guards check the **dataset-sourced** mirrors (a target-tree file with no counterpart in the dataset is compared to nothing), while this command additionally rewrites skill references across the whole installed tree, which nothing verifies. Evidence: commit `6655439d` regenerated `adr-021`, `adr-022`, `adr-023` and `collaborative-workflow.context.md` — four files with no dataset counterpart — after they had sat drifted on a green `main`. Drift in that region accumulates undetected and then lands, unrelated, in whichever PR next runs the writer. It is what `PRE_PUSH_REMEDY`, both mirror guards, `DEVELOPMENT.md` and its docs-site twin name, and what `/pair-capability-publish-pr` runs (Phase 1, before the gate) and commits separately when it produces a diff. Deliberately **not** `pair update`, which installs the latest PUBLISHED knowledge base and would make a local fix depend on what has been released. Absent this key, `/pair-capability-publish-pr` skips its realignment step entirely — the zero-configuration default, not a degradation. See ADL [2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md](../decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md) and story [#419](https://github.com/foomakers/pair/issues/419). +- **`mirror-realign-command`**: `pnpm mirrors:regenerate` — the single, local, deterministic writer that realigns the generated mirrors with `packages/knowledge-hub/dataset`. It writes into `.claude/**`, root `.pair/**`, `AGENTS.md`/`CLAUDE.md` and `.github/**` — **a description of where its output lands, never a staging rule**: those same trees hold authored files (117 tracked files under `.pair/adoption/**` alone), so anything that committed the glob rather than the command's actual effect would sweep a contributor's unstaged prose into a regeneration commit. `/pair-capability-publish-pr` therefore stages a **before/after `git status --porcelain -z` comparison**, and no adopter enumerates owned globs anywhere. It wraps the CLI's existing local-source path (`pair update --source --offline`) and adds no generation logic; it has **no check mode** — the mirror guards (`skills:conformance`) are the checker, this is the only writer. **Writer and checker are not the same scope, and the asymmetry is the writer's**: the guards check the **dataset-sourced** mirrors (a target-tree file with no counterpart in the dataset is compared to nothing), while this command additionally rewrites skill references across the whole installed tree, which nothing verifies. Evidence: commit `6655439d` regenerated `adr-021`, `adr-022`, `adr-023` and `collaborative-workflow.context.md` — four files with no dataset counterpart — after they had sat drifted on a green `main`. Drift in that region accumulates undetected and then lands, unrelated, in whichever PR next runs the writer. It is what `PRE_PUSH_REMEDY`, both mirror guards, `DEVELOPMENT.md` and its docs-site twin name, and what `/pair-capability-publish-pr` runs (Phase 1, before the gate) and commits separately when it produces a diff. Deliberately **not** `pair update`, which installs the latest PUBLISHED knowledge base and would make a local fix depend on what has been released. Absent this key, `/pair-capability-publish-pr` skips its realignment step entirely — the zero-configuration default, not a degradation. See ADL [2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md](../decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md) and story [#419](https://github.com/foomakers/pair/issues/419). - **Gate & tooling code:** a gate's logic lives in a tested module in its owning package (white-box unit tests); scripts/CLIs are thin entrypoints and a root gate delegates (`pnpm --filter `). Scripts are never unit-tested — CLI-level checks go to smoke tests. See ADL [2026-07-13-gate-tooling-code-in-tested-modules.md](../decision-log/2026-07-13-gate-tooling-code-in-tested-modules.md). **One bounded exception** (#419): a thin script whose behaviour IS the deliverable, with no logic to extract, is black-box executed from vitest against a throwaway fixture, asserting observable behaviour only — `scripts/format-lib/run-format.sh` and `scripts/regenerate-mirrors.sh`. Conditions and why the smoke suite is not their home: ADL [2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md](../decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md). Gate/tooling packages are organized by bounded context, not one package per tool family — a new tool family sharing an existing package's bounded context is a new folder there, not a new package. See [ADR-014](adr/adr-014-tool-package-boundary-by-bounded-context.md). - **Conformance tests** (`packages/knowledge-hub/src/conformance/`): one test file per target KB artifact (a `SKILL.md`, guideline, or template), not per introducing story — a new story extends the matching file's `describe` block instead of adding a new story-named file. See ADL [2026-07-18-conformance-test-per-file-not-per-story.md](../decision-log/2026-07-18-conformance-test-per-file-not-per-story.md). - **Monorepo tooling gotchas** (e.g. `pnpm --filter` bypassing turbo's `dependsOn` graph on a fresh checkout): documented once, centrally, in `DEVELOPMENT.md`'s `Turbo Caching` section — affected packages' READMEs carry only a short pointer, not a full copy. See ADL [2026-07-18-workspace-gotcha-doc-placement.md](../decision-log/2026-07-18-workspace-gotcha-doc-placement.md). diff --git a/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts b/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts index 8ff738be4..099c83666 100644 --- a/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts +++ b/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts @@ -90,23 +90,57 @@ interface Snapshot { digests: Map } +/** + * One porcelain entry: its two-letter status code and the worktree path it names. + * + * `-z` is NUL-SEPARATED and, unlike the default, never quotes or octal-escapes a path — + * which is the whole reason the recipe uses it (round-4 finding). It costs one parsing + * rule in exchange: a rename/copy entry spends a SECOND field on its OLD path + * (`R new\0old\0`), so that field must be CONSUMED, never read as an entry of its own — + * it has no status code, and `slice(3)` over it would yield a truncated path. + */ +interface PorcelainEntry { + xy: string + path: string +} + +function parsePorcelainZ(out: string): PorcelainEntry[] { + const fields = out.split('\0').filter(field => field !== '') + const entries: PorcelainEntry[] = [] + for (let i = 0; i < fields.length; i += 1) { + const field = fields[i] as string + const xy = field.slice(0, 2) + entries.push({ xy, path: field.slice(3) }) + if (xy.includes('R') || xy.includes('C')) i += 1 + } + return entries +} + /** * /publish-pr Phase 1's before/after snapshot, executed exactly as the skill words it: - * `git status --porcelain --untracked-files=all`, plus `git hash-object [-w] ` over - * every entry whose worktree file still exists. `untrackedFilesAll` and `writeBlobs` are - * knobs ONLY so the test can run the pre-fix recipe next to the fixed one and show the - * difference; the skill documents one setting for each. + * `git status --porcelain -z --untracked-files=all`, plus `git hash-object [-w] ` + * over every entry whose worktree file still exists. `untrackedFilesAll`, `writeBlobs` + * and `nulSeparated` are knobs ONLY so the test can run a pre-fix recipe next to the + * fixed one and show the difference; the skill documents one setting for each. */ function snapshotTree( dir: string, - opts: { untrackedFilesAll: boolean; writeBlobs: boolean }, + opts: { untrackedFilesAll: boolean; writeBlobs: boolean; nulSeparated: boolean }, ): Snapshot { const args = ['status', '--porcelain'] + if (opts.nulSeparated) args.push('-z') if (opts.untrackedFilesAll) args.push('--untracked-files=all') const entries = git(dir, args) + const paths = opts.nulSeparated + ? parsePorcelainZ(entries).map(entry => entry.path) + : // The pre-fix parse, kept verbatim so the failure it produces is MEASURED, not argued: + // a quoted/escaped path fails the exists test below and is dropped from the digest. + entries + .split('\n') + .filter(Boolean) + .map(line => line.slice(3)) const digests = new Map() - for (const line of entries.split('\n').filter(Boolean)) { - const path = line.slice(3) + for (const path of paths) { // "digest only entries whose worktree file exists": a deletion has nothing to read. if (!existsSync(join(dir, path))) continue const hash = ['hash-object'] @@ -400,18 +434,27 @@ describe('regenerate-mirrors.sh — the local, deterministic mirror remedy (#419 ) expect(tryGit(tmp, ['hash-object', 'doomed.md']).status).toBe(128) - const before = snapshotTree(tmp, { untrackedFilesAll: true, writeBlobs: true }) - expect(before.entries).toMatch(/^ D doomed\.md$/m) - expect(before.entries).toMatch(/^ M \.pair\/knowledge\/index\.md$/m) + const before = snapshotTree(tmp, { + untrackedFilesAll: true, + writeBlobs: true, + nulSeparated: true, + }) + const beforeEntries = parsePorcelainZ(before.entries) + expect(beforeEntries).toContainEqual({ xy: ' D', path: 'doomed.md' }) + expect(beforeEntries).toContainEqual({ xy: ' M', path: '.pair/knowledge/index.md' }) // -uall is what turns the collapsed `?? sub/` into a hashable per-file entry. - expect(before.entries).toMatch(/^\?\? \.pair\/knowledge\/sub\/note\.md$/m) + expect(beforeEntries).toContainEqual({ xy: '??', path: '.pair/knowledge/sub/note.md' }) expect(before.digests.has('doomed.md')).toBe(false) expect(before.digests.has('.pair/knowledge/sub/note.md')).toBe(true) const result = run(tmp, isolatedHome(tmp)) expect(result.status).toBe(0) - const after = snapshotTree(tmp, { untrackedFilesAll: true, writeBlobs: false }) + const after = snapshotTree(tmp, { + untrackedFilesAll: true, + writeBlobs: false, + nulSeparated: true, + }) // Status is blind to BOTH overwrites — that is why the digest half exists... expect(after.entries).toBe(before.entries) // ...and with the recipe as documented, both are detected. @@ -446,6 +489,156 @@ describe('regenerate-mirrors.sh — the local, deterministic mirror remedy (#419 SCRIPT_RUN_TIMEOUT_MS, ) + it( + 'the snapshot recipe sees a path with a space and a non-ASCII byte — the default parse does not', + () => { + // Round-4 finding, executed. Porcelain v1 QUOTES and octal-escapes any path holding a + // space or a non-ASCII byte, so `line.slice(3)` yields `"caff\303\250.md"` — a string + // that is not a filename. The entry then fails every "does the worktree file exist" + // test and is DROPPED from the digest, which is the same status-vs-content blindness + // the digest exists to close, reached through the parser instead of through `git`. + // + // CONCRETE LOSS this fixture reproduces: a generated mirror at `.pair/knowledge/con + // spazio.md`, already dirty with an uncommitted hand-edit, is overwritten by the run. + // Its porcelain entry is ` M "con spazio.md"` before AND after (status unchanged) and + // its digest was never taken — so the comparison reads NO CHANGE: the hand-edit is + // destroyed with no `recover:` row, and the regenerated bytes are never staged, so the + // branch pushes the stale mirror and its own conformance job goes red. + tmp = makeFixture() + const dataset = join(tmp, 'packages/knowledge-hub/dataset') + const SPACED = '# spaced note\n' + const ACCENTED = '# accented note\n' + write(join(dataset, '.pair/knowledge/con spazio.md'), SPACED) + write(join(dataset, '.pair/knowledge/caffè.md'), ACCENTED) + write(join(tmp, '.pair/knowledge/index.md'), '# pre-existing install\n') + + // Converge and commit, so the only writes the case measures are the two overwrites. + expect(run(tmp, isolatedHome(tmp)).status).toBe(0) + const spaced = join(tmp, '.pair/knowledge/con spazio.md') + const accented = join(tmp, '.pair/knowledge/caffè.md') + expect(readFileSync(spaced, 'utf-8')).toBe(SPACED) + expect(readFileSync(accented, 'utf-8')).toBe(ACCENTED) + git(tmp, ['add', '-A']) + git(tmp, ['commit', '-q', '-m', 'converged']) + + // HEAD carries drift on both, so the run genuinely writes and the entries stay ` M `. + writeFileSync(spaced, '# committed drift\n') + writeFileSync(accented, '# committed drift\n') + git(tmp, ['add', '-A']) + git(tmp, ['commit', '-q', '-m', 'drifted mirrors on HEAD']) + + // Uncommitted hand-edits on both: pre-dirty, so ONLY the digest can see the overwrite. + const SPACED_EDIT = '# spaced hand-edit\n' + const ACCENTED_EDIT = '# accented hand-edit\n' + writeFileSync(spaced, SPACED_EDIT) + writeFileSync(accented, ACCENTED_EDIT) + + // THE PRE-FIX PARSE, measured: quoted and escaped, so neither path is digested. + const quoted = git(tmp, ['status', '--porcelain', '--untracked-files=all']) + expect(quoted).toMatch(/^ M "\.pair\/knowledge\/con spazio\.md"$/m) + expect(quoted).toMatch(/^ M "\.pair\/knowledge\/caff\\303\\250\.md"$/m) + const preFix = snapshotTree(tmp, { + untrackedFilesAll: true, + writeBlobs: true, + nulSeparated: false, + }) + expect(preFix.digests.has('.pair/knowledge/con spazio.md')).toBe(false) + expect(preFix.digests.has('.pair/knowledge/caffè.md')).toBe(false) + + const before = snapshotTree(tmp, { + untrackedFilesAll: true, + writeBlobs: true, + nulSeparated: true, + }) + const beforeEntries = parsePorcelainZ(before.entries) + // -z prints the real bytes: no quotes, no octal escapes. + expect(beforeEntries).toContainEqual({ xy: ' M', path: '.pair/knowledge/con spazio.md' }) + expect(beforeEntries).toContainEqual({ xy: ' M', path: '.pair/knowledge/caffè.md' }) + expect(before.digests.has('.pair/knowledge/con spazio.md')).toBe(true) + expect(before.digests.has('.pair/knowledge/caffè.md')).toBe(true) + + expect(run(tmp, isolatedHome(tmp)).status).toBe(0) + + const after = snapshotTree(tmp, { + untrackedFilesAll: true, + writeBlobs: false, + nulSeparated: true, + }) + // Status is identical across the run for both paths — the overwrite is invisible there. + expect(after.entries).toBe(before.entries) + // ...and the documented recipe detects it, on both, and can hand the bytes back. + for (const [rel, edit] of [ + ['.pair/knowledge/con spazio.md', SPACED_EDIT], + ['.pair/knowledge/caffè.md', ACCENTED_EDIT], + ] as const) { + expect(after.digests.get(rel)).not.toBe(before.digests.get(rel)) + expect(git(tmp, ['cat-file', '-p', before.digests.get(rel) ?? ''])).toBe(edit) + } + expect(readFileSync(spaced, 'utf-8')).toBe(SPACED) + expect(readFileSync(accented, 'utf-8')).toBe(ACCENTED) + + // Second shape from the same finding: a NEW generated file with a space appears only in + // the after snapshot, so status DOES catch it — but under the default parse the agent + // stages the literal quoted string, and `git add` refuses it as a pathspec. + expect(tryGit(tmp, ['add', '"con spazio.md"']).stderr).toContain('did not match any files') + }, + SCRIPT_RUN_TIMEOUT_MS, + ) + + it( + 'the regeneration commit carries only the regenerated paths, never a pre-STAGED authored file', + () => { + // Round-4 finding, executed. The staging rule protects UNSTAGED authored work, but a + // plain `git commit` after `git add ` commits THE WHOLE INDEX — and publish-pr + // is standalone, explicitly runs on a dirty tree, and a resumed/interrupted implement + // leaves a populated index. CONCRETE LOSS: the contributor's staged prose lands inside + // `chore: regenerate mirrors from local dataset`, a commit they never wrote — verbatim + // the harm the whole staging-rule section exists to prevent, reached through the index + // instead of through a glob. The documented form commits by PATHSPEC, which cannot. + tmp = makeFixture() + const mirror = join(tmp, '.pair/knowledge/index.md') + const authored = join(tmp, 'src/authored.ts') + write(authored, 'export const authored = 1\n') + write(mirror, '# pre-existing install\n') + + expect(run(tmp, isolatedHome(tmp)).status).toBe(0) + git(tmp, ['add', '-A']) + git(tmp, ['commit', '-q', '-m', 'converged']) + + // HEAD carries a drifted mirror, so the run writes something... + writeFileSync(mirror, '# committed drift\n') + git(tmp, ['add', '-A']) + git(tmp, ['commit', '-q', '-m', 'drifted mirror on HEAD']) + // ...and the contributor has ALREADY STAGED an authored change before the run. + writeFileSync(authored, 'export const authored = 2\n') + git(tmp, ['add', 'src/authored.ts']) + + expect(run(tmp, isolatedHome(tmp)).status).toBe(0) + + // The control: the index-based form, measured. It sweeps the staged prose in. + const REL = '.pair/knowledge/index.md' + const MSG = 'chore: regenerate mirrors from local dataset' + git(tmp, ['add', REL]) + git(tmp, ['commit', '-q', '-m', MSG]) + expect( + git(tmp, ['show', '--name-only', '--format=', 'HEAD']).split('\n').filter(Boolean).sort(), + ).toEqual([REL, 'src/authored.ts']) + + // The documented form, on the same state: pathspec, so the index is not consulted. + git(tmp, ['reset', '-q', '--soft', 'HEAD~1']) + git(tmp, ['commit', '-q', '-m', MSG, '--', REL]) + expect( + git(tmp, ['show', '--name-only', '--format=', 'HEAD']).split('\n').filter(Boolean), + ).toEqual([REL]) + // The contributor's prose is still THEIRS: staged, uncommitted, unmodified. + expect(parsePorcelainZ(git(tmp, ['status', '--porcelain', '-z']))).toEqual([ + { xy: 'M ', path: 'src/authored.ts' }, + ]) + expect(readFileSync(authored, 'utf-8')).toBe('export const authored = 2\n') + }, + SCRIPT_RUN_TIMEOUT_MS, + ) + it('exits non-zero and names the reason when the dataset is missing (AC7)', () => { tmp = realpathSync(mkdtempSync(join(tmpdir(), 'regen-mirrors-'))) initRepo(tmp) diff --git a/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md b/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md index 93932175f..a895d0b65 100644 --- a/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md +++ b/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md @@ -61,14 +61,15 @@ The realignment runs **before** the gate, and the order is load-bearing in both 1. **Check**: Does the adoption declare a `mirror-realign-command`? 2. **Skip**: If it does not, go to step 5. A project with no generated mirrors has nothing to realign — the zero-configuration default, not a degradation, and nothing is reported. -3. **Act**: Take the **before** snapshot — `git status --porcelain --untracked-files=all`, whole tree — **paired with a content digest of every entry whose worktree file still exists** (`git hash-object -w ` over that set). The digest is not belt-and-braces: a porcelain entry encodes **status, not content**, so a path that is *already* dirty reports the same unstaged-modified `M ` entry before and after whether the run rewrote the file or never opened it — status alone cannot tell those two apart, and one of them is a destroyed hand-edit. Only then run the declared command. It regenerates the mirrors from the working tree's **local** dataset — never a published release — and is idempotent. A **non-zero exit → HALT** before any PR side effect, reporting the command's own reason verbatim: it never reports success over a no-op, so a failure here means nothing was written and the drift is still there. Each of the three flags in that sentence is doing work, and skipping one puts the step back where the digest found it: +3. **Act**: Take the **before** snapshot — `git status --porcelain -z --untracked-files=all`, whole tree — **paired with a content digest of every entry whose worktree file still exists** (`git hash-object -w ` over that set). The digest is not belt-and-braces: a porcelain entry encodes **status, not content**, so a path that is *already* dirty reports the same unstaged-modified `M ` entry before and after whether the run rewrote the file or never opened it — status alone cannot tell those two apart, and one of them is a destroyed hand-edit. Only then run the declared command. It regenerates the mirrors from the working tree's **local** dataset — never a published release — and is idempotent. A **non-zero exit → HALT** before any PR side effect, reporting the command's own reason verbatim: it never reports success over a no-op, so a failure here means nothing was written and the drift is still there. Each of the four rules in that sentence is doing work, and skipping one puts the step back where the digest found it: + - **`-z`**, because the default porcelain format **quotes and octal-escapes** any path holding a space or a non-ASCII byte: `with space.md` prints as ` M "with space.md" ` and `caffè.md` as ` M "caff\303\250.md" `, so the path field read off the entry is *not a filename* — it fails the file-exists test below and the entry is dropped from the digest silently, which is the same status-vs-content blindness the digest exists to close, reached through the parser instead of through `git`. It also breaks the other direction: a NEW generated file with a space is caught by the status comparison, and then `git add '"with space.md"'` fails as a pathspec mid-step. `-z` prints the raw bytes, **NUL-separated**, never quoted or escaped — so **split on NUL**, not on newline (a filename may contain one). Its one parsing rule: a rename/copy entry is `R ` + a second field holding `` — consume that field, never read it as an entry of its own. (This is also what removes the `old -> new` ambiguity the default format's rename line has.) - **`--untracked-files=all`**, because the default collapses a not-yet-committed directory into one `?? dir/` entry — one entry however many files under it the run rewrote, identical on both reads — and `git hash-object dir/` answers `fatal: Unable to hash dir/`, so that whole subtree would be undetectable *and* unhashable: exactly the status-vs-content blindness the digest exists to close, surviving where the digest cannot reach. Expanded per file, `?? dir/a.md` hashes like any other path. (An `equivalent git diff capture` is not equivalent here: **it never reports untracked paths at all**.) - **only entries whose file still exists**, because a deletion has none to read: `git hash-object gone.md` on the ` D ` entry that path left behind is `fatal: could not open 'gone.md' for reading`, exit 128 — and this step's own **non-zero exit → HALT** would turn that into a PR blocked by the snapshot pass that was meant to protect it. **Skip those entries** (` D `, `AD`, `DD`), and nothing is lost by skipping: a deleted path the command recreates **moves its porcelain entry** (` D ` → ` M `, or gone), so the status comparison already catches it. The digest is only needed for the shapes where status *cannot* move. - **`-w`**, because plain `git hash-object` prints a hash and throws the bytes away, while `-w` also **writes the blob into the object database** — same output, and the difference is whether the loss reported two steps later is recoverable. Once the command overwrites a pre-dirty path, the contributor's uncommitted content is in no HEAD (never committed), no index, no disk (overwritten); with `-w` it is in the ODB, and `git cat-file -p ` prints it back. -4. **Check → Act**: Take the **after** snapshot (`git status --porcelain --untracked-files=all` again, plus the digest of the same paths — **re-hashing needs no `-w`**: only the pre-overwrite content was at risk) and compare it with the before snapshot. **The set to stage is what this run actually wrote** — every path whose porcelain entry appeared, disappeared or changed between the two reads, **plus every path already dirty in the before snapshot whose digest changed** — and it is derived that way rather than from a **path glob** of "the paths the command owns" deliberately. A glob is a *guess about the command*, and it is wrong wherever generated output and authored files share a prefix — the ordinary case, since the directory holding a project's generated mirrors is usually the same one holding its hand-written adoption/knowledge files. Under a glob, a contributor who left an authored file dirty beneath that prefix gets it committed under `chore: regenerate mirrors from local dataset` — their prose, under a regeneration message, in a commit they never wrote. The before/after comparison cannot do that: a file this run did not touch has an identical entry **and an identical digest** in both snapshots. It also means **no adopter has to enumerate owned globs anywhere** — the command's own effect is the declaration. +4. **Check → Act**: Take the **after** snapshot (`git status --porcelain -z --untracked-files=all` again, plus the digest of the same paths — **re-hashing needs no `-w`**: only the pre-overwrite content was at risk) and compare it with the before snapshot. **The set to stage is what this run actually wrote** — every path whose porcelain entry appeared, disappeared or changed between the two reads, **plus every path already dirty in the before snapshot whose digest changed** — and it is derived that way rather than from a **path glob** of "the paths the command owns" deliberately. A glob is a *guess about the command*, and it is wrong wherever generated output and authored files share a prefix — the ordinary case, since the directory holding a project's generated mirrors is usually the same one holding its hand-written adoption/knowledge files. Under a glob, a contributor who left an authored file dirty beneath that prefix gets it committed under `chore: regenerate mirrors from local dataset` — their prose, under a regeneration message, in a commit they never wrote. The before/after comparison cannot do that: a file this run did not touch has an identical entry **and an identical digest** in both snapshots. It also means **no adopter has to enumerate owned globs anywhere** — the command's own effect is the declaration. - **Overwritten uncommitted work** (the digest half, and the only case `git status` cannot show): HEAD carries a drifted mirror, the contributor is holding an *uncommitted* edit to that same file, and the regeneration replaces it — the entry stays the same unstaged-modified `M ` on both reads while the bytes changed. Those paths join the staged set like any other write (the regenerated content is what must ship; leaving it out pushes the stale mirror the guards reject and turns the branch's own conformance job red), **and every one of them is named in the output** — `overwrote uncommitted changes in: (recover: git cat-file -p > )` on the `Mirrors:` row, `` being the before snapshot's `-w` digest. Never silent here: the contributor's work is gone from disk, and a loss nobody is told about is worse than the drift this step exists to fix. The `-w` is what makes that row a remedy instead of an obituary — a named path the contributor cannot restore is only a better-documented loss. - **No change** → the two snapshots are equal **and no dirty path's digest moved**; a no-op stays **silent**: no commit, and no output row (the `Mirrors:` row is emitted only when a commit was made). Continue to step 5. - - **Changed** → stage **only** the paths that comparison produced — never `git add -A`, and never a glob: unstaged authored changes belong to the contributor and must survive the run untouched, and this skill must not commit them — and commit them **alone**, as their own commit, never mixed into a feature commit. Name it as a *regeneration* (e.g. `chore: regenerate mirrors from local dataset`), never a "fix": an overwritten hand-edit was restored to what the dataset generates, not repaired. Drift in a file this branch never touched is committed here too, and **said so in the output** — surprising, but better than pushing knowingly stale generated output, and the separate commit keeps even a dataset-wide regeneration readable next to the authored work. + - **Changed** → stage **only** the paths that comparison produced — never `git add -A`, and never a glob: unstaged authored changes belong to the contributor and must survive the run untouched, and this skill must not commit them — and commit them **alone**, as their own commit, never mixed into a feature commit. Commit them **by pathspec**: `git commit -m "chore: regenerate mirrors from local dataset" -- `. The pathspec is not a stylistic preference — a plain `git commit` after `git add ` commits **the whole index**, and content the contributor had **already staged before the run** is never part of this commit. That case is ordinary, not exotic: this skill is standalone, explicitly runs on a dirty tree, and a resumed or interrupted `/implement` leaves a populated index — so the staged prose would land inside the regeneration commit, which is the same harm the rule above prevents for *unstaged* work, reached through the index instead of through a glob. The pathspec form leaves those entries staged and untouched. Name it as a *regeneration* (e.g. `chore: regenerate mirrors from local dataset`), never a "fix": an overwritten hand-edit was restored to what the dataset generates, not repaired. Drift in a file this branch never touched is committed here too, and **said so in the output** — surprising, but better than pushing knowingly stale generated output, and the separate commit keeps even a dataset-wide regeneration readable next to the authored work. - **Verify**: `git log` shows exactly one new commit and its file list equals that set exactly; **every pre-existing dirty path that is NOT in the set and still has a file on disk still carries its before digest** — the survival check is on **content**, because a path still being listed by `git status` is exactly what an overwrite also produces, so a check phrased on the listing would certify the loss it is meant to catch. The on-disk qualifier is not a loophole: a deleted path has no digest by construction (step 3), and its survival is carried by the porcelain entry, which any rewrite would have moved. 5. **Act**: Compose `/verify-quality` with `$scope` (default `all`). This is a local pre-flight, not a replacement for CI (CI stays authoritative, #210). 6. **Check**: Did every required gate pass? diff --git a/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts b/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts index 3b957f9c8..d98222f77 100644 --- a/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts +++ b/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts @@ -103,9 +103,11 @@ describe('publish-pr realigns mirrors before its gate (#419)', () => { // dataset` — a commit they never wrote — contradicting this same phase's // "unstaged authored changes ... must survive the run untouched". const p1 = phase1() - expect(p1).toMatch(/\*\*before\*\* snapshot — `git status --porcelain --untracked-files=all`/) expect(p1).toMatch( - /\*\*after\*\* snapshot \(`git status --porcelain --untracked-files=all` again/, + /\*\*before\*\* snapshot — `git status --porcelain -z --untracked-files=all`/, + ) + expect(p1).toMatch( + /\*\*after\*\* snapshot \(`git status --porcelain -z --untracked-files=all` again/, ) expect(p1).toMatch(/appeared, disappeared or changed between the two reads/) expect(p1).toMatch(/rather than from a \*\*path glob\*\*/) @@ -154,7 +156,7 @@ describe('publish-pr realigns mirrors before its gate (#419)', () => { // packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts, 'the documented before/after // recipe survives every ordinary porcelain shape'. const p1 = phase1() - expect(p1).toMatch(/`git status --porcelain --untracked-files=all`/) + expect(p1).toMatch(/`git status --porcelain -z --untracked-files=all`/) expect(p1).toMatch(/collapses a not-yet-committed directory into one `\?\? dir\/` entry/) expect(p1).toMatch(/fatal: Unable to hash dir\//) expect(p1).toMatch(/fatal: could not open 'gone\.md' for reading/) @@ -185,6 +187,49 @@ describe('publish-pr realigns mirrors before its gate (#419)', () => { expect(p1).toMatch(/re-hashing needs no `-w`/) }) + it('reads the porcelain NUL-separated, so a path git would quote is still a path', () => { + // Round-4 finding (a). Porcelain v1 QUOTES and octal-escapes any path holding a space or a + // non-ASCII byte, so the path field taken from the entry is not a filename and the + // "worktree file still exists" test drops it from the digest. MEASURED in a scratch repo: + // `git status --porcelain -uall` over `with space.md` / `caffè.md` prints + // ` M "with space.md"` and ` M "caff\303\250.md"`, and both resolve to no such file. + // CONCRETE LOSS: a generated `docs/My Guide.md`, already dirty with a hand-edit, is + // overwritten by the run — entry unchanged before and after, digest never taken, so the + // comparison reads NO CHANGE: the hand-edit dies with no `recover:` row AND the + // regenerated bytes are never staged, so the branch pushes the stale mirror. Second shape: + // a NEW generated file with a space appears only in the after snapshot and the agent + // stages the literal quoted string — `git add '"con spazio.md"'` -> `fatal: pathspec ... + // did not match any files`, mid-step. Executed against the real script: + // packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts, 'the snapshot recipe + // sees a path with a space and a non-ASCII byte — the default parse does not'. + const p1 = phase1() + expect(p1).toMatch(/\*\*`-z`\*\*/) + expect(p1).toMatch(/quotes and octal-escapes/) + expect(p1).toMatch(/NUL-separated/) + // -z also fixes the rename shape, and the price of that is one parsing rule that must + // be stated: the old path is a SECOND field, not an entry. + expect(p1).toMatch(/`R {2}` \+ a second field holding ``/) + expect(p1).toMatch(/split on NUL/) + }) + + it('commits by PATHSPEC, so content staged before the run is never swept in', () => { + // Round-4 finding (b). The staging rule protects UNSTAGED authored work, but a plain + // `git commit` after `git add ` commits the WHOLE INDEX. MEASURED: with + // `M authored.md` (staged prose) and ` M mirror.md` (regenerated), + // `git add mirror.md && git commit -m 'chore: regenerate mirrors from local dataset'` + // produces a commit listing BOTH — the contributor's prose under a regeneration message, + // in a commit they never wrote. Reachable on the ordinary path: publish-pr is standalone, + // explicitly runs on a dirty tree, and a resumed/interrupted implement leaves a populated + // index. `git commit -m … -- ` commits only the pathspec and leaves `M authored.md` + // staged and untouched. Executed against the real script (both forms, same fixture): + // regenerate-mirrors.test.ts, 'the regeneration commit carries only the regenerated paths, + // never a pre-STAGED authored file'. + const p1 = phase1() + expect(p1).toMatch(/git commit -m "chore: regenerate mirrors from local dataset" -- /) + expect(p1).toMatch(/already staged before the run/) + expect(p1).toMatch(/never part of this commit/) + }) + it('names the commit a regeneration, never a fix (an overwritten hand-edit was restored)', () => { const p1 = phase1() expect(p1).toMatch(/regenerate mirrors from local dataset/) diff --git a/packages/knowledge-hub/src/tools/mirror-guard.test.ts b/packages/knowledge-hub/src/tools/mirror-guard.test.ts index 6de4367ab..6c488c098 100644 --- a/packages/knowledge-hub/src/tools/mirror-guard.test.ts +++ b/packages/knowledge-hub/src/tools/mirror-guard.test.ts @@ -602,10 +602,19 @@ describe('assertMirrorMatches — failure paths and message (#393)', () => { expect(message).not.toContain('naming transform') }) - it('reports a missing mirror as missing, with the regenerate hint (not as drift)', () => { - expect(() => assertKb(REL, expected, undefined)).toThrow( - /Mirror missing.*does not exist.*pair update/s, - ) + // #419 round 4: the MISSING branch, not just the drifted one. It is the branch a + // contributor reaches by adding `packages/knowledge-hub/dataset/.pair/knowledge/new-guide.md` + // and committing before regenerating — the most common way here, since a brand-new dataset + // file has no mirror yet. Told to run `pair update`, they install the PUBLISHED KB: their + // new file is in no release so the guard stays red, AND every other local mirror is + // overwritten with released content — manufacturing the drift this guard exists to stop. + it('reports a missing mirror as missing (not as drift) and names the LOCAL regeneration command', () => { + const message = captureThrownMessage(() => assertKb(REL, expected, undefined)) + expect(message).toContain('Mirror missing') + expect(message).toContain('does not exist') + expect(message).not.toContain('has drifted') + expect(message).toContain(MIRROR_REGENERATE_COMMAND) + expect(message).not.toContain('pair update') }) }) @@ -646,7 +655,13 @@ describe('assertNoOrphanedMirrorEntries — the reverse sweep (#393)', () => { ) expect(message).toContain('DELETE it') expect(message).toContain(`ADD it to the dataset under ${KB_MIRROR.datasetRel}`) - expect(message).toContain("'pair update'") + // #419 round 4, same contract as the forward guard's two branches: the SECOND half of + // this remedy ("add it to the dataset AND regenerate") is exactly the case `pair update` + // cannot serve — a file just added to the local dataset is in no published release, so + // the reader who follows the instruction literally comes back to a still-red guard. + // The `pair update` sentence one line above is a different claim (what the install + // does to an installed-only file) and stays. + expect(message).toContain(`regenerate with '${MIRROR_REGENERATE_COMMAND}'`) // states what it compared, like its forward sibling, so the reader cannot // mistake it for the transform assertion expect(message).toContain('COMPARED') diff --git a/packages/knowledge-hub/src/tools/mirror-guard.ts b/packages/knowledge-hub/src/tools/mirror-guard.ts index 3ad0999fb..2bd35a6c3 100644 --- a/packages/knowledge-hub/src/tools/mirror-guard.ts +++ b/packages/knowledge-hub/src/tools/mirror-guard.ts @@ -377,7 +377,8 @@ export function assertNoOrphanedMirrorEntries( `IMAGE, so a file only the target has is drift: 'pair update' neither writes nor removes it, ` + `and it goes on being read as if it were shipped content${alsoIndexed}.\n` + `Remedy: DELETE it, or ADD it to the dataset under ${mirror.datasetRel} and regenerate with ` + - `'pair update'.`, + `'${MIRROR_REGENERATE_COMMAND}' (#419: a file just added to the LOCAL dataset is in no ` + + `published release, so 'pair update' cannot install it).`, ) } @@ -433,7 +434,7 @@ export function assertMirrorMatches( if (actual === undefined) { throw new Error( `Mirror missing for dataset file '${datasetRelPath}': ${mirrorPath} does not exist. ` + - `Run 'pair update' to regenerate it.`, + `Run '${MIRROR_REGENERATE_COMMAND}' to regenerate it.`, ) } if (actual !== expected) { diff --git a/packages/knowledge-hub/src/tools/skill-md-mirror.test.ts b/packages/knowledge-hub/src/tools/skill-md-mirror.test.ts index 28f4b3128..1cbb9aac8 100644 --- a/packages/knowledge-hub/src/tools/skill-md-mirror.test.ts +++ b/packages/knowledge-hub/src/tools/skill-md-mirror.test.ts @@ -23,12 +23,13 @@ const ROOT_CLAUDE_SKILLS = join(REPO_ROOT, '.claude/skills') /** * On-disk root mirror of a dataset artifact, or `undefined` ONLY when it is - * genuinely absent (which the guard reports as "missing → run `pair update`"). + * genuinely absent (which the guard reports as "missing → run + * `MIRROR_REGENERATE_COMMAND`"). * * A copy that EXISTS but cannot be read (EACCES, a directory in its place) is a * different failure with a different fix: it is rethrown naming the path and the * underlying cause, never collapsed into `undefined` — which would mislabel it - * as missing and hand the developer a hint (`pair update`) that cannot fix an + * as missing and hand the developer a regeneration hint that cannot fix an * EACCES. `read` is injectable so that branch is actually covered by a test. * * PLACEMENT (deliberate, and the reason it differs from its sibling): the @@ -54,7 +55,7 @@ const rootMirrorContent = ( throw new Error( `Root mirror for dataset artifact '${datasetArtifact}' EXISTS at ${p} but is ` + `unreadable: ${(err as Error).message}. Fix the file/permissions — ` + - `'pair update' cannot regenerate over an unreadable path.`, + `'${MIRROR_REGENERATE_COMMAND}' cannot regenerate over an unreadable path.`, ) } } @@ -181,15 +182,15 @@ describe('directional guard ignores root-only artifacts with no dataset source', }) /** - * Missing vs unreadable are DISTINCT failures with distinct fixes: `pair update` - * regenerates a missing copy, but cannot fix an EACCES. The root-copy read must + * Missing vs unreadable are DISTINCT failures with distinct fixes: the regeneration + * command regenerates a missing copy, but cannot fix an EACCES. The root-copy read must * therefore never collapse "unreadable" into "missing" (nor, worse, into a pass). */ describe('root-copy read distinguishes a missing copy from an unreadable one', () => { it('reports an EXISTING but unreadable root copy as unreadable, never as missing', () => { // The catch branch of the root-copy read: an EACCES (or a dir in its place) // must fail with its path and cause, not be swallowed into `undefined` and - // mislabelled "does not exist. Run 'pair update'". + // mislabelled "does not exist. Run ''". const artifact = 'next/SKILL.md' const rootPath = join(ROOT_CLAUDE_SKILLS, installedArtifactPath(artifact)) expect(existsSync(rootPath)).toBe(true) // precondition: it DOES exist @@ -204,6 +205,11 @@ describe('root-copy read distinguishes a missing copy from an unreadable one', ( expect(message).toContain('unreadable') expect(message).toContain('EACCES: permission denied') expect(message).not.toContain('does not exist') + // #419 round 4: a developer reads this message on a real failure, so it is one of the + // places the remedy is named. It must name the SAME command the two guard branches do — + // two remedies for one guard is the condition MIRROR_REGENERATE_COMMAND removed. + expect(message).toContain(MIRROR_REGENERATE_COMMAND) + expect(message).not.toContain('pair update') }) }) diff --git a/packages/knowledge-hub/src/tools/skill-md-mirror.ts b/packages/knowledge-hub/src/tools/skill-md-mirror.ts index c0c776c41..adde82886 100644 --- a/packages/knowledge-hub/src/tools/skill-md-mirror.ts +++ b/packages/knowledge-hub/src/tools/skill-md-mirror.ts @@ -414,9 +414,10 @@ export function diffSkillMd(expected: string, actual: string): string { * Asserts one root mirror artifact — a `SKILL.md` or any sub-doc the same * `pair update` transform generates — equals the real pipeline output. * Throws LOUDLY, naming the offending artifact by its DATASET-relative path - * (its canonical identity), pointing at the generated root path, and giving the - * `pair update` regenerate hint, when the mirror is missing (AC4) or has - * drifted (AC2). This is the guard's assertion helper, kept in a tested + * (its canonical identity), pointing at the generated root path, and giving + * `MIRROR_REGENERATE_COMMAND` as the regenerate hint — the same one in BOTH + * branches, missing (AC4) and drifted (AC2), which is the whole reason that + * constant exists (#419). This is the guard's assertion helper, kept in a tested * production module (per the "gate & tooling code in tested modules" ADL) so * both the real on-disk guard and the drift-injection tests drive the same * code path. From d24fec8cdc9229d08b3fc94187f428a35c0f6b97 Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Tue, 1 Sep 2026 21:25:20 +0200 Subject: [PATCH 13/14] [#419] fix: stage before the pathspec commit; name the orphan remedy's reason (review round 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pathspec resolves against paths git already knows, so the newly CREATED mirror — the shape a contributor gets by adding a dataset file — aborts the commit with 'did not match any file(s) known to git'. Stage first, always. Co-Authored-By: Claude Sonnet 5 --- .../pair-capability-publish-pr/SKILL.md | 2 +- ...ish-pr-realigns-mirrors-before-the-gate.md | 11 ++- .../quality-gates/regenerate-mirrors.test.ts | 81 +++++++++++++++++++ .../.skills/capability/publish-pr/SKILL.md | 2 +- .../conformance/mirror-realignment.test.ts | 28 +++++++ .../knowledge-hub/src/tools/mirror-guard.ts | 7 +- 6 files changed, 127 insertions(+), 4 deletions(-) diff --git a/.claude/skills/pair-capability-publish-pr/SKILL.md b/.claude/skills/pair-capability-publish-pr/SKILL.md index e506cde8b..9283cca1b 100644 --- a/.claude/skills/pair-capability-publish-pr/SKILL.md +++ b/.claude/skills/pair-capability-publish-pr/SKILL.md @@ -69,7 +69,7 @@ The realignment runs **before** the gate, and the order is load-bearing in both 4. **Check → Act**: Take the **after** snapshot (`git status --porcelain -z --untracked-files=all` again, plus the digest of the same paths — **re-hashing needs no `-w`**: only the pre-overwrite content was at risk) and compare it with the before snapshot. **The set to stage is what this run actually wrote** — every path whose porcelain entry appeared, disappeared or changed between the two reads, **plus every path already dirty in the before snapshot whose digest changed** — and it is derived that way rather than from a **path glob** of "the paths the command owns" deliberately. A glob is a *guess about the command*, and it is wrong wherever generated output and authored files share a prefix — the ordinary case, since the directory holding a project's generated mirrors is usually the same one holding its hand-written adoption/knowledge files. Under a glob, a contributor who left an authored file dirty beneath that prefix gets it committed under `chore: regenerate mirrors from local dataset` — their prose, under a regeneration message, in a commit they never wrote. The before/after comparison cannot do that: a file this run did not touch has an identical entry **and an identical digest** in both snapshots. It also means **no adopter has to enumerate owned globs anywhere** — the command's own effect is the declaration. - **Overwritten uncommitted work** (the digest half, and the only case `git status` cannot show): HEAD carries a drifted mirror, the contributor is holding an *uncommitted* edit to that same file, and the regeneration replaces it — the entry stays the same unstaged-modified `M ` on both reads while the bytes changed. Those paths join the staged set like any other write (the regenerated content is what must ship; leaving it out pushes the stale mirror the guards reject and turns the branch's own conformance job red), **and every one of them is named in the output** — `overwrote uncommitted changes in: (recover: git cat-file -p > )` on the `Mirrors:` row, `` being the before snapshot's `-w` digest. Never silent here: the contributor's work is gone from disk, and a loss nobody is told about is worse than the drift this step exists to fix. The `-w` is what makes that row a remedy instead of an obituary — a named path the contributor cannot restore is only a better-documented loss. - **No change** → the two snapshots are equal **and no dirty path's digest moved**; a no-op stays **silent**: no commit, and no output row (the `Mirrors:` row is emitted only when a commit was made). Continue to step 5. - - **Changed** → stage **only** the paths that comparison produced — never `git add -A`, and never a glob: unstaged authored changes belong to the contributor and must survive the run untouched, and this skill must not commit them — and commit them **alone**, as their own commit, never mixed into a feature commit. Commit them **by pathspec**: `git commit -m "chore: regenerate mirrors from local dataset" -- `. The pathspec is not a stylistic preference — a plain `git commit` after `git add ` commits **the whole index**, and content the contributor had **already staged before the run** is never part of this commit. That case is ordinary, not exotic: this skill is standalone, explicitly runs on a dirty tree, and a resumed or interrupted `/pair-process-implement` leaves a populated index — so the staged prose would land inside the regeneration commit, which is the same harm the rule above prevents for *unstaged* work, reached through the index instead of through a glob. The pathspec form leaves those entries staged and untouched. Name it as a *regeneration* (e.g. `chore: regenerate mirrors from local dataset`), never a "fix": an overwritten hand-edit was restored to what the dataset generates, not repaired. Drift in a file this branch never touched is committed here too, and **said so in the output** — surprising, but better than pushing knowingly stale generated output, and the separate commit keeps even a dataset-wide regeneration readable next to the authored work. + - **Changed** → stage **only** the paths that comparison produced — never `git add -A`, and never a glob: unstaged authored changes belong to the contributor and must survive the run untouched, and this skill must not commit them — and commit them **alone**, as their own commit, never mixed into a feature commit. Commit them **by pathspec**: `git add `, then `git commit -m "chore: regenerate mirrors from local dataset" -- `. The pathspec is not a stylistic preference — a plain `git commit` after `git add ` commits **the whole index**, and content the contributor had **already staged before the run** is never part of this commit. The pathspec replaces the index as the commit's **scope**, not the `git add` as its **step** — stage first, always, and exactly the same set: a pathspec resolves against paths git already knows (index or HEAD), so a mirror this run **created** (a `??` entry — what a contributor gets by adding a file to the dataset, the one case a published-KB install cannot serve) is not committable by pathspec alone. `error: pathspec '' did not match any file(s) known to git`, exit 1, and the whole commit aborts mid-step, so the regenerated mirror never lands and the branch pushes without it. A tracked path that was modified or deleted **does** commit by pathspec while unstaged, which is exactly what makes a dropped `git add` look harmless until the first new mirror. The index case is ordinary, not exotic: this skill is standalone, explicitly runs on a dirty tree, and a resumed or interrupted `/pair-process-implement` leaves a populated index — so the staged prose would land inside the regeneration commit, which is the same harm the rule above prevents for *unstaged* work, reached through the index instead of through a glob. The pathspec form leaves those entries staged and untouched. Name it as a *regeneration* (e.g. `chore: regenerate mirrors from local dataset`), never a "fix": an overwritten hand-edit was restored to what the dataset generates, not repaired. Drift in a file this branch never touched is committed here too, and **said so in the output** — surprising, but better than pushing knowingly stale generated output, and the separate commit keeps even a dataset-wide regeneration readable next to the authored work. - **Verify**: `git log` shows exactly one new commit and its file list equals that set exactly; **every pre-existing dirty path that is NOT in the set and still has a file on disk still carries its before digest** — the survival check is on **content**, because a path still being listed by `git status` is exactly what an overwrite also produces, so a check phrased on the listing would certify the loss it is meant to catch. The on-disk qualifier is not a loophole: a deleted path has no digest by construction (step 3), and its survival is carried by the porcelain entry, which any rewrite would have moved. 5. **Act**: Compose `/pair-capability-verify-quality` with `$scope` (default `all`). This is a local pre-flight, not a replacement for CI (CI stays authoritative, #210). 6. **Check**: Did every required gate pass? diff --git a/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md b/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md index 013981a90..20414b88f 100644 --- a/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md +++ b/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md @@ -105,7 +105,16 @@ read from the adoption, never named in the skill.** resumed/interrupted `/pair-process-implement` leaves a populated index. The step-4 Verify catches it only *after* the commit exists, and a Verify failure is not a HALT condition — so the mislabelled commit would be pushed. `git commit -m "…" -- ` commits only the pathspec and - leaves the staged entries staged and untouched. + leaves the staged entries staged and untouched. The pathspec replaces the index as the commit's + **scope**, not the `git add` as its **step**: a pathspec resolves against paths git already knows + (index or HEAD), so a mirror the run CREATED — `?? `, the shape a contributor produces by + adding a file to the dataset, the one case a published-KB install cannot serve — is + `error: pathspec '' did not match any file(s) known to git`, exit 1, and the commit aborts + whole, leaving the branch to push without the mirror it just regenerated. A modified or deleted + tracked path DOES commit by pathspec while unstaged, which is why a dropped `git add` looks + harmless until the first new mirror — so the recipe states both the step and the asymmetry. + Measured end to end against the real script in `regenerate-mirrors.test.ts` ("stages a newly + created mirror before committing it — a pathspec alone cannot name it"). - **Each of the three remaining rules in the snapshot recipe is load-bearing** (round-3 review, measured in a scratch repo): `--untracked-files=all`, because the default reports a not-yet-committed directory diff --git a/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts b/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts index 099c83666..ed5d3203f 100644 --- a/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts +++ b/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts @@ -163,6 +163,8 @@ function write(path: string, content: string): void { const STUB_SKILL = '# /stub\n\nA stub skill.\n' const KB_INDEX = '# Mock Knowledge\n' +/** A dataset file added AFTER convergence: its mirror does not exist yet, so the run creates it. */ +const NEW_GUIDE = '# Mock New Guide\n' /** * The smallest tree `pair update --source ` accepts: a KB-shaped dataset @@ -639,6 +641,85 @@ describe('regenerate-mirrors.sh — the local, deterministic mirror remedy (#419 SCRIPT_RUN_TIMEOUT_MS, ) + it( + 'stages a newly created mirror before committing it — a pathspec alone cannot name it', + () => { + // The residual of the round-4 pathspec fix, and its paired failure path. `git commit -- + // ` resolves the pathspec against paths git ALREADY KNOWS (index or HEAD). The + // single most common way this step produces a path at all is a contributor ADDING a file + // to the dataset — the one case a published-KB install provably cannot serve — and the + // run then CREATES its mirror: a `??` entry, which git does not know. CONCRETE FAILURE: + // the pathspec commit exits 1 with `error: pathspec ... did not match any file(s) known + // to git` and aborts WHOLE, so the regenerated mirror is never committed; the branch is + // pushed without it and its own `skills:conformance` job goes red — the exact drift the + // realignment step exists to remove, now caused by the step. It is silent until then + // because a MODIFIED tracked mirror commits by pathspec while unstaged (asserted below), + // so a recipe without `git add` works on every drifted mirror and fails on the first new + // one. + tmp = makeFixture() + const authored = join(tmp, 'src/authored.ts') + write(authored, 'export const authored = 1\n') + write(join(tmp, '.pair/knowledge/index.md'), '# pre-existing install\n') + + expect(run(tmp, isolatedHome(tmp)).status).toBe(0) + git(tmp, ['add', '-A']) + git(tmp, ['commit', '-q', '-m', 'converged']) + + // A drifted tracked mirror (the ` M ` row) AND a brand-new dataset file whose mirror does + // not exist yet (the `??` row) — the two shapes one realignment run routinely produces. + writeFileSync(join(tmp, '.pair/knowledge/index.md'), '# committed drift\n') + write(join(tmp, 'packages/knowledge-hub/dataset/.pair/knowledge/new-guide.md'), NEW_GUIDE) + git(tmp, ['add', '-A']) + git(tmp, ['commit', '-q', '-m', 'drifted mirror + a new dataset file']) + // ...and the contributor has already staged authored work, so the round-4 property + // (a pre-STAGED path is never swept in) has to survive the added `git add` too. + writeFileSync(authored, 'export const authored = 2\n') + git(tmp, ['add', 'src/authored.ts']) + + expect(run(tmp, isolatedHome(tmp)).status).toBe(0) + + const DRIFTED = '.pair/knowledge/index.md' + const CREATED = '.pair/knowledge/new-guide.md' + const MSG = 'chore: regenerate mirrors from local dataset' + // The run created the new mirror from the LOCAL dataset (no release carries it)... + expect(readFileSync(join(tmp, CREATED), 'utf-8')).toBe(NEW_GUIDE) + // ...and it is untracked, which is the whole defect. + expect(parsePorcelainZ(git(tmp, ['status', '--porcelain', '-z', '-uall']))).toContainEqual({ + xy: '??', + path: CREATED, + }) + + // Pathspec WITHOUT the `git add`: refused, and it takes the drifted mirror down with it. + const head = git(tmp, ['rev-parse', 'HEAD']).trim() + const refused = tryGit(tmp, ['commit', '-m', MSG, '--', CREATED, DRIFTED]) + expect(refused.status).not.toBe(0) + expect(refused.stderr).toContain(`did not match any file(s) known to git`) + expect(git(tmp, ['rev-parse', 'HEAD']).trim()).toBe(head) + + // Why the omission stays invisible: the tracked, unstaged, MODIFIED mirror commits by + // pathspec on its own. Every drifted-mirror run works; only a new mirror breaks. + git(tmp, ['commit', '-q', '-m', MSG, '--', DRIFTED]) + expect( + git(tmp, ['show', '--name-only', '--format=', 'HEAD']).split('\n').filter(Boolean), + ).toEqual([DRIFTED]) + git(tmp, ['reset', '-q', '--soft', 'HEAD~1']) + git(tmp, ['reset', '-q', 'HEAD', '--', DRIFTED]) + + // The documented form: stage the same set first, then scope the commit by pathspec. + git(tmp, ['add', CREATED, DRIFTED]) + git(tmp, ['commit', '-q', '-m', MSG, '--', CREATED, DRIFTED]) + expect( + git(tmp, ['show', '--name-only', '--format=', 'HEAD']).split('\n').filter(Boolean).sort(), + ).toEqual([CREATED, DRIFTED].sort()) + // The `git add` did not cost the round-4 property: the prose is still staged, uncommitted. + expect(parsePorcelainZ(git(tmp, ['status', '--porcelain', '-z', '-uall']))).toEqual([ + { xy: 'M ', path: 'src/authored.ts' }, + ]) + expect(readFileSync(authored, 'utf-8')).toBe('export const authored = 2\n') + }, + SCRIPT_RUN_TIMEOUT_MS, + ) + it('exits non-zero and names the reason when the dataset is missing (AC7)', () => { tmp = realpathSync(mkdtempSync(join(tmpdir(), 'regen-mirrors-'))) initRepo(tmp) diff --git a/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md b/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md index a895d0b65..d6f057bb4 100644 --- a/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md +++ b/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md @@ -69,7 +69,7 @@ The realignment runs **before** the gate, and the order is load-bearing in both 4. **Check → Act**: Take the **after** snapshot (`git status --porcelain -z --untracked-files=all` again, plus the digest of the same paths — **re-hashing needs no `-w`**: only the pre-overwrite content was at risk) and compare it with the before snapshot. **The set to stage is what this run actually wrote** — every path whose porcelain entry appeared, disappeared or changed between the two reads, **plus every path already dirty in the before snapshot whose digest changed** — and it is derived that way rather than from a **path glob** of "the paths the command owns" deliberately. A glob is a *guess about the command*, and it is wrong wherever generated output and authored files share a prefix — the ordinary case, since the directory holding a project's generated mirrors is usually the same one holding its hand-written adoption/knowledge files. Under a glob, a contributor who left an authored file dirty beneath that prefix gets it committed under `chore: regenerate mirrors from local dataset` — their prose, under a regeneration message, in a commit they never wrote. The before/after comparison cannot do that: a file this run did not touch has an identical entry **and an identical digest** in both snapshots. It also means **no adopter has to enumerate owned globs anywhere** — the command's own effect is the declaration. - **Overwritten uncommitted work** (the digest half, and the only case `git status` cannot show): HEAD carries a drifted mirror, the contributor is holding an *uncommitted* edit to that same file, and the regeneration replaces it — the entry stays the same unstaged-modified `M ` on both reads while the bytes changed. Those paths join the staged set like any other write (the regenerated content is what must ship; leaving it out pushes the stale mirror the guards reject and turns the branch's own conformance job red), **and every one of them is named in the output** — `overwrote uncommitted changes in: (recover: git cat-file -p > )` on the `Mirrors:` row, `` being the before snapshot's `-w` digest. Never silent here: the contributor's work is gone from disk, and a loss nobody is told about is worse than the drift this step exists to fix. The `-w` is what makes that row a remedy instead of an obituary — a named path the contributor cannot restore is only a better-documented loss. - **No change** → the two snapshots are equal **and no dirty path's digest moved**; a no-op stays **silent**: no commit, and no output row (the `Mirrors:` row is emitted only when a commit was made). Continue to step 5. - - **Changed** → stage **only** the paths that comparison produced — never `git add -A`, and never a glob: unstaged authored changes belong to the contributor and must survive the run untouched, and this skill must not commit them — and commit them **alone**, as their own commit, never mixed into a feature commit. Commit them **by pathspec**: `git commit -m "chore: regenerate mirrors from local dataset" -- `. The pathspec is not a stylistic preference — a plain `git commit` after `git add ` commits **the whole index**, and content the contributor had **already staged before the run** is never part of this commit. That case is ordinary, not exotic: this skill is standalone, explicitly runs on a dirty tree, and a resumed or interrupted `/implement` leaves a populated index — so the staged prose would land inside the regeneration commit, which is the same harm the rule above prevents for *unstaged* work, reached through the index instead of through a glob. The pathspec form leaves those entries staged and untouched. Name it as a *regeneration* (e.g. `chore: regenerate mirrors from local dataset`), never a "fix": an overwritten hand-edit was restored to what the dataset generates, not repaired. Drift in a file this branch never touched is committed here too, and **said so in the output** — surprising, but better than pushing knowingly stale generated output, and the separate commit keeps even a dataset-wide regeneration readable next to the authored work. + - **Changed** → stage **only** the paths that comparison produced — never `git add -A`, and never a glob: unstaged authored changes belong to the contributor and must survive the run untouched, and this skill must not commit them — and commit them **alone**, as their own commit, never mixed into a feature commit. Commit them **by pathspec**: `git add `, then `git commit -m "chore: regenerate mirrors from local dataset" -- `. The pathspec is not a stylistic preference — a plain `git commit` after `git add ` commits **the whole index**, and content the contributor had **already staged before the run** is never part of this commit. The pathspec replaces the index as the commit's **scope**, not the `git add` as its **step** — stage first, always, and exactly the same set: a pathspec resolves against paths git already knows (index or HEAD), so a mirror this run **created** (a `??` entry — what a contributor gets by adding a file to the dataset, the one case a published-KB install cannot serve) is not committable by pathspec alone. `error: pathspec '' did not match any file(s) known to git`, exit 1, and the whole commit aborts mid-step, so the regenerated mirror never lands and the branch pushes without it. A tracked path that was modified or deleted **does** commit by pathspec while unstaged, which is exactly what makes a dropped `git add` look harmless until the first new mirror. The index case is ordinary, not exotic: this skill is standalone, explicitly runs on a dirty tree, and a resumed or interrupted `/implement` leaves a populated index — so the staged prose would land inside the regeneration commit, which is the same harm the rule above prevents for *unstaged* work, reached through the index instead of through a glob. The pathspec form leaves those entries staged and untouched. Name it as a *regeneration* (e.g. `chore: regenerate mirrors from local dataset`), never a "fix": an overwritten hand-edit was restored to what the dataset generates, not repaired. Drift in a file this branch never touched is committed here too, and **said so in the output** — surprising, but better than pushing knowingly stale generated output, and the separate commit keeps even a dataset-wide regeneration readable next to the authored work. - **Verify**: `git log` shows exactly one new commit and its file list equals that set exactly; **every pre-existing dirty path that is NOT in the set and still has a file on disk still carries its before digest** — the survival check is on **content**, because a path still being listed by `git status` is exactly what an overwrite also produces, so a check phrased on the listing would certify the loss it is meant to catch. The on-disk qualifier is not a loophole: a deleted path has no digest by construction (step 3), and its survival is carried by the porcelain entry, which any rewrite would have moved. 5. **Act**: Compose `/verify-quality` with `$scope` (default `all`). This is a local pre-flight, not a replacement for CI (CI stays authoritative, #210). 6. **Check**: Did every required gate pass? diff --git a/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts b/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts index d98222f77..807a5dfa7 100644 --- a/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts +++ b/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts @@ -230,6 +230,34 @@ describe('publish-pr realigns mirrors before its gate (#419)', () => { expect(p1).toMatch(/never part of this commit/) }) + it('stages first, because a pathspec cannot name a mirror the run just CREATED', () => { + // The residual of the round-4 pathspec fix, and its paired failure path. `git commit -- + // ` resolves the pathspec against paths git ALREADY KNOWS (index or HEAD), so the + // single most common shape this step produces — a contributor adds a file to the dataset, + // the run CREATES its mirror, `?? ` — is not committable by pathspec alone. + // MEASURED in a scratch repo (untracked `brandnew.md`): + // git commit -m 'chore: regenerate mirrors from local dataset' -- brandnew.md + // -> error: pathspec 'brandnew.md' did not match any file(s) known to git (exit 1) + // and the whole commit aborts, so the regenerated mirror never lands: the branch pushes + // without it and its own `skills:conformance` job goes red — the exact failure the + // realignment step exists to prevent, now caused by the step. The reason the omission is + // SILENT is the other half of the table, measured on the same tree: ` M tracked.md` and + // ` D gone.md` DO commit by pathspec while unstaged (`git show --name-status` -> `M + // tracked.md`, `D gone.md`), so a recipe without `git add` works on every drifted or + // removed mirror and fails only on the first NEW one. Executed against the real script: + // packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts, 'stages a newly created + // mirror before committing it — a pathspec alone cannot name it'. + const p1 = phase1() + expect(p1).toMatch(/`git add `, then `git commit -m/) + expect(p1).toMatch( + /replaces the index as the commit's \*\*scope\*\*, not the `git add` as its \*\*step\*\*/, + ) + expect(p1).toMatch(/did not match any file\(s\) known to git/) + // The asymmetry must be stated, or the next editor drops the `git add` again for the same + // reason it was dropped once: on every case they are likely to try, it is redundant. + expect(p1).toMatch(/modified or deleted \*\*does\*\* commit by pathspec while unstaged/) + }) + it('names the commit a regeneration, never a fix (an overwritten hand-edit was restored)', () => { const p1 = phase1() expect(p1).toMatch(/regenerate mirrors from local dataset/) diff --git a/packages/knowledge-hub/src/tools/mirror-guard.ts b/packages/knowledge-hub/src/tools/mirror-guard.ts index 2bd35a6c3..a0219a90b 100644 --- a/packages/knowledge-hub/src/tools/mirror-guard.ts +++ b/packages/knowledge-hub/src/tools/mirror-guard.ts @@ -347,7 +347,12 @@ export function orphanedMirrorEntries( * (`.pair/adoption/decision-log/2026-08-13-pair-update-deletes-what-the-mirror-no-longer-ships.md`); * it cannot fix a red here, because this repo's installed trees ARE the source * of truth being guarded. The remedy printed is therefore the human one — remove - * the file, or give it a dataset source and regenerate. + * the file, or give it a dataset source and regenerate with + * `MIRROR_REGENERATE_COMMAND` and never `pair update` (#419): the ADD half of + * that remedy puts the file in the LOCAL dataset only, so no published release + * carries it and an install cannot serve it. The message itself carries the + * reason and no issue number — it is read by a contributor watching a gate fail, + * not by a maintainer reading history. * * Valid for a `behavior: "mirror"` registry, whose target is meant to be the * dataset's IMAGE. It would be wrong for `behavior: "add"` (`adoption`), where a From 95a117369fb308548234fa218778bbff6ab0d98a Mon Sep 17 00:00:00 2001 From: Gianluca Carucci Date: Wed, 2 Sep 2026 22:13:54 +0200 Subject: [PATCH 14/14] [#419] fix: report what the run removed; no-op on an empty cached diff; HALT on untracked files under written trees (review round 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three round-6 findings on PR #476, all measured against the real script in a fixture repo: - Major: a mirror registry deletes a contributor's untracked/staged-new draft under it; its vanished `??` (or `A.`→`AD`) entry put it in the step-4 set and `git add`/the pathspec commit aborted Phase 1 after the destructive run, with no report row. Such paths now leave `git add` and the pathspec and are named `removed untracked: (recover: git cat-file -p > )`. - Minor: a path whose render equals HEAD moves its entry when rewritten (`M.`→`MM`, `D.`→`D.`+`??`, `.M`→gone) but equals HEAD after `git add`; `git commit -- ` over only such paths exits 1 (`nothing to commit`). Recipe now runs `git diff --cached --quiet -- ` first; recover rows are driven by the digest comparison whether or not a commit was made; Verify compares against the cached name list. - Minor: the writer indexes untracked `.pair/adoption/**` files into `.pair/llms.txt` (dangling link + WIP filename in history). Step 3 now HALTs on `??`/`A.` entries under the trees the adoption names as written, remedy `git stash push -u -- ` / `git stash pop`; stated in way-of-working and the script header. Tests: 3 real-script cases (regenerate-mirrors.test.ts), 4 conformance pins (mirror-realignment.test.ts). .claude mirror regenerated via `pnpm mirrors:regenerate`, never hand-edited. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01FfhvsS5rippi6aUbbGrf6F --- .../pair-capability-publish-pr/SKILL.md | 16 +- ...ish-pr-realigns-mirrors-before-the-gate.md | 37 ++- .pair/adoption/tech/way-of-working.md | 2 +- .../quality-gates/regenerate-mirrors.test.ts | 279 ++++++++++++++++++ .../.skills/capability/publish-pr/SKILL.md | 16 +- .../conformance/mirror-realignment.test.ts | 83 ++++++ scripts/regenerate-mirrors.sh | 6 + 7 files changed, 424 insertions(+), 15 deletions(-) diff --git a/.claude/skills/pair-capability-publish-pr/SKILL.md b/.claude/skills/pair-capability-publish-pr/SKILL.md index 9283cca1b..6bba9a994 100644 --- a/.claude/skills/pair-capability-publish-pr/SKILL.md +++ b/.claude/skills/pair-capability-publish-pr/SKILL.md @@ -38,7 +38,7 @@ Two sibling sections cover git concerns and the split is deliberate: **`## Merge - **[way-of-working.md](../../../.pair/adoption/tech/way-of-working.md) → `## Merge Strategy`** — the same section the merge consumers read (`/pair-process-review` Phase 6): `Method` (`squash` | `merge` | `rebase`, **default `squash`**) and the `Commit format` ([commit template](../../../.pair/knowledge/guidelines/collaboration/templates/commit-template.md)). Recorded on the PR as the intended merge strategy; **squash happens at merge, never here**. `branch-format` (to parse the branch id) comes from the [branch template](../../../.pair/knowledge/guidelines/collaboration/templates/branch-template.md). - **way-of-working.md → `## Git Workflow`** — `code-host` (the tool owning branches/PRs) and `base-branch` (default `main`; **a `base-branch` declared under `## Merge Strategy`, where this skill's ≤ 0.4.1 versions documented it, is still honored** — the resolution order is single-sourced in the convention's **`base-branch` resolution** — the same order `/pair-process-implement` applies, so the two readers cannot disagree on the target branch). **`code-host` absent ⇒ code host = PM tool** (single-tool; the zero-configuration default, not a degradation), and the same tool named in both places is treated exactly as omitted. Resolution, the PM↔code-host routing table, and the cross-linking convention live in one place: [way-of-working / PM-tool + code-host resolution](../../../.pair/knowledge/guidelines/technical-standards/ai-development/skill-conventions/way-of-working-pm-resolution.md) — this skill states only which side each operation is on. -- **way-of-working.md → `## Quality Gates` → `mirror-realign-command`** — the project's single writer for its generated mirrors, run in Phase 1 before the gate. Declared as a command the project owns (e.g. a root script), because which artifacts a repo generates, and from what, is the repo's business and not this skill's — a hardcoded command would emit a step most projects cannot run. **Absent ⇒ the realignment step is skipped entirely** (zero-configuration default, not a degradation). The command must be a *writer*, local and idempotent: the guards that detect drift are the checkers, this is the one thing that fixes it. +- **way-of-working.md → `## Quality Gates` → `mirror-realign-command`** — the project's single writer for its generated mirrors, run in Phase 1 before the gate. Declared as a command the project owns (e.g. a root script), because which artifacts a repo generates, and from what, is the repo's business and not this skill's — a hardcoded command would emit a step most projects cannot run. **Absent ⇒ the realignment step is skipped entirely** (zero-configuration default, not a degradation). The command must be a *writer*, local and idempotent: the guards that detect drift are the checkers, this is the one thing that fixes it. The same entry **should also name the trees the command writes into** (descriptive — e.g. `.claude/**`, `.pair/**` — never a staging rule): Phase 1 step 3 reads that list to find the contributor's untracked files the run would delete or index. **Names no written trees ⇒ that one check is skipped** and step 4's `removed untracked:` row is the only net. - **way-of-working.md → `## State Mapping`** — board-column ↔ canonical-macrostate mapping (see [canonical-states.md](../../../.pair/knowledge/guidelines/collaboration/project-management-tool/canonical-states.md)). Omitted ⇒ canonical names assumed. - **way-of-working.md → `## Assignment`** — the fallback when no `$assignee` is passed. This skill writes the **code-host** side, so it reads **`code-host-assignee` first and `default-assignee` second** — the split-configuration key exists because the same human often carries two identifiers, and resolving the PM-tool login against the code host is how a PR ends up rejected and published unassigned. **One rule, two callers**: the schema and the cascade live once, in the [resolution convention](../../../.pair/knowledge/guidelines/technical-standards/ai-development/skill-conventions/way-of-working-pm-resolution.md), and both this skill (the PR, a **code-host** write) and `/pair-capability-write-issue` (the item, a **PM-tool** write) read them from there rather than each defining their own. Both omitted ⇒ no default; the PR is published unassigned with a warning. @@ -61,16 +61,18 @@ The realignment runs **before** the gate, and the order is load-bearing in both 1. **Check**: Does the adoption declare a `mirror-realign-command`? 2. **Skip**: If it does not, go to step 5. A project with no generated mirrors has nothing to realign — the zero-configuration default, not a degradation, and nothing is reported. -3. **Act**: Take the **before** snapshot — `git status --porcelain -z --untracked-files=all`, whole tree — **paired with a content digest of every entry whose worktree file still exists** (`git hash-object -w ` over that set). The digest is not belt-and-braces: a porcelain entry encodes **status, not content**, so a path that is *already* dirty reports the same unstaged-modified `M ` entry before and after whether the run rewrote the file or never opened it — status alone cannot tell those two apart, and one of them is a destroyed hand-edit. Only then run the declared command. It regenerates the mirrors from the working tree's **local** dataset — never a published release — and is idempotent. A **non-zero exit → HALT** before any PR side effect, reporting the command's own reason verbatim: it never reports success over a no-op, so a failure here means nothing was written and the drift is still there. Each of the four rules in that sentence is doing work, and skipping one puts the step back where the digest found it: +3. **Act**: Take the **before** snapshot — `git status --porcelain -z --untracked-files=all`, whole tree — and **first read its `??` and `A.` entries (porcelain `XY`, `.` marking the blank column) against the trees the command writes into** (Adoption Inputs). A file HEAD does not have, lying under one of those trees, is not safe across the run, in one of two ways the command cannot tell apart from dataset content: a **mirror** tree is made *equal* to the dataset, so the file is **deleted** (the contributor's `.pair/knowledge/wip-draft.md`, gone); an **add** tree keeps it, but a **generated index such as `llms.txt`** is built from the whole tree on disk, so the file is **indexed** — the index then commits with a link to a path this branch does not carry, and the contributor's private WIP filename lands in history while the file itself, its entry unchanged, is left out of the commit. Bytes untouched, derived output leaked. Any such entry → **HALT** before running the command — nothing has been written yet, so this is the one point where the HALT costs nothing — naming each path and the remedy: `git stash push -u -- ` (then `git stash pop` after Phase 1), or commit or move them first. If the adoption names no written trees, this check is skipped (the step-4 `removed untracked:` row still catches the deletion after the fact; nothing catches the indexing). Then pair the snapshot **with a content digest of every entry whose worktree file still exists** (`git hash-object -w ` over that set). The digest is not belt-and-braces: a porcelain entry encodes **status, not content**, so a path that is *already* dirty reports the same unstaged-modified `M ` entry before and after whether the run rewrote the file or never opened it — status alone cannot tell those two apart, and one of them is a destroyed hand-edit. Only then run the declared command. It regenerates the mirrors from the working tree's **local** dataset — never a published release — and is idempotent. A **non-zero exit → HALT** before any PR side effect, reporting the command's own reason verbatim: it never reports success over a no-op, so a failure here means nothing was written and the drift is still there. Each of the four rules in that sentence is doing work, and skipping one puts the step back where the digest found it: - **`-z`**, because the default porcelain format **quotes and octal-escapes** any path holding a space or a non-ASCII byte: `with space.md` prints as ` M "with space.md" ` and `caffè.md` as ` M "caff\303\250.md" `, so the path field read off the entry is *not a filename* — it fails the file-exists test below and the entry is dropped from the digest silently, which is the same status-vs-content blindness the digest exists to close, reached through the parser instead of through `git`. It also breaks the other direction: a NEW generated file with a space is caught by the status comparison, and then `git add '"with space.md"'` fails as a pathspec mid-step. `-z` prints the raw bytes, **NUL-separated**, never quoted or escaped — so **split on NUL**, not on newline (a filename may contain one). Its one parsing rule: a rename/copy entry is `R ` + a second field holding `` — consume that field, never read it as an entry of its own. (This is also what removes the `old -> new` ambiguity the default format's rename line has.) - **`--untracked-files=all`**, because the default collapses a not-yet-committed directory into one `?? dir/` entry — one entry however many files under it the run rewrote, identical on both reads — and `git hash-object dir/` answers `fatal: Unable to hash dir/`, so that whole subtree would be undetectable *and* unhashable: exactly the status-vs-content blindness the digest exists to close, surviving where the digest cannot reach. Expanded per file, `?? dir/a.md` hashes like any other path. (An `equivalent git diff capture` is not equivalent here: **it never reports untracked paths at all**.) - **only entries whose file still exists**, because a deletion has none to read: `git hash-object gone.md` on the ` D ` entry that path left behind is `fatal: could not open 'gone.md' for reading`, exit 128 — and this step's own **non-zero exit → HALT** would turn that into a PR blocked by the snapshot pass that was meant to protect it. **Skip those entries** (` D `, `AD`, `DD`), and nothing is lost by skipping: a deleted path the command recreates **moves its porcelain entry** (` D ` → ` M `, or gone), so the status comparison already catches it. The digest is only needed for the shapes where status *cannot* move. - **`-w`**, because plain `git hash-object` prints a hash and throws the bytes away, while `-w` also **writes the blob into the object database** — same output, and the difference is whether the loss reported two steps later is recoverable. Once the command overwrites a pre-dirty path, the contributor's uncommitted content is in no HEAD (never committed), no index, no disk (overwritten); with `-w` it is in the ODB, and `git cat-file -p ` prints it back. 4. **Check → Act**: Take the **after** snapshot (`git status --porcelain -z --untracked-files=all` again, plus the digest of the same paths — **re-hashing needs no `-w`**: only the pre-overwrite content was at risk) and compare it with the before snapshot. **The set to stage is what this run actually wrote** — every path whose porcelain entry appeared, disappeared or changed between the two reads, **plus every path already dirty in the before snapshot whose digest changed** — and it is derived that way rather than from a **path glob** of "the paths the command owns" deliberately. A glob is a *guess about the command*, and it is wrong wherever generated output and authored files share a prefix — the ordinary case, since the directory holding a project's generated mirrors is usually the same one holding its hand-written adoption/knowledge files. Under a glob, a contributor who left an authored file dirty beneath that prefix gets it committed under `chore: regenerate mirrors from local dataset` — their prose, under a regeneration message, in a commit they never wrote. The before/after comparison cannot do that: a file this run did not touch has an identical entry **and an identical digest** in both snapshots. It also means **no adopter has to enumerate owned globs anywhere** — the command's own effect is the declaration. - **Overwritten uncommitted work** (the digest half, and the only case `git status` cannot show): HEAD carries a drifted mirror, the contributor is holding an *uncommitted* edit to that same file, and the regeneration replaces it — the entry stays the same unstaged-modified `M ` on both reads while the bytes changed. Those paths join the staged set like any other write (the regenerated content is what must ship; leaving it out pushes the stale mirror the guards reject and turns the branch's own conformance job red), **and every one of them is named in the output** — `overwrote uncommitted changes in: (recover: git cat-file -p > )` on the `Mirrors:` row, `` being the before snapshot's `-w` digest. Never silent here: the contributor's work is gone from disk, and a loss nobody is told about is worse than the drift this step exists to fix. The `-w` is what makes that row a remedy instead of an obituary — a named path the contributor cannot restore is only a better-documented loss. - - **No change** → the two snapshots are equal **and no dirty path's digest moved**; a no-op stays **silent**: no commit, and no output row (the `Mirrors:` row is emitted only when a commit was made). Continue to step 5. - - **Changed** → stage **only** the paths that comparison produced — never `git add -A`, and never a glob: unstaged authored changes belong to the contributor and must survive the run untouched, and this skill must not commit them — and commit them **alone**, as their own commit, never mixed into a feature commit. Commit them **by pathspec**: `git add `, then `git commit -m "chore: regenerate mirrors from local dataset" -- `. The pathspec is not a stylistic preference — a plain `git commit` after `git add ` commits **the whole index**, and content the contributor had **already staged before the run** is never part of this commit. The pathspec replaces the index as the commit's **scope**, not the `git add` as its **step** — stage first, always, and exactly the same set: a pathspec resolves against paths git already knows (index or HEAD), so a mirror this run **created** (a `??` entry — what a contributor gets by adding a file to the dataset, the one case a published-KB install cannot serve) is not committable by pathspec alone. `error: pathspec '' did not match any file(s) known to git`, exit 1, and the whole commit aborts mid-step, so the regenerated mirror never lands and the branch pushes without it. A tracked path that was modified or deleted **does** commit by pathspec while unstaged, which is exactly what makes a dropped `git add` look harmless until the first new mirror. The index case is ordinary, not exotic: this skill is standalone, explicitly runs on a dirty tree, and a resumed or interrupted `/pair-process-implement` leaves a populated index — so the staged prose would land inside the regeneration commit, which is the same harm the rule above prevents for *unstaged* work, reached through the index instead of through a glob. The pathspec form leaves those entries staged and untouched. Name it as a *regeneration* (e.g. `chore: regenerate mirrors from local dataset`), never a "fix": an overwritten hand-edit was restored to what the dataset generates, not repaired. Drift in a file this branch never touched is committed here too, and **said so in the output** — surprising, but better than pushing knowingly stale generated output, and the separate commit keeps even a dataset-wide regeneration readable next to the authored work. - - **Verify**: `git log` shows exactly one new commit and its file list equals that set exactly; **every pre-existing dirty path that is NOT in the set and still has a file on disk still carries its before digest** — the survival check is on **content**, because a path still being listed by `git status` is exactly what an overwrite also produces, so a check phrased on the listing would certify the loss it is meant to catch. The on-disk qualifier is not a loophole: a deleted path has no digest by construction (step 3), and its survival is carried by the porcelain entry, which any rewrite would have moved. + - **Removed uncommitted work** (the other loss `git status` shows only as an absence): a before entry HEAD does not know — `??` or `A.` — whose file is **gone** after the run. A mirror tree ships exactly the dataset's file set, so a contributor's draft under it is deleted, and its entry *disappears* (`??`) or turns `AD` (`A.`). Those paths are in the comparison's set, and they are **not stageable**: `git add ` on the vanished `??` is `fatal: pathspec '' did not match any files`, exit 128, and on the `AD` shape it *succeeds* — it stages the removal, dropping the index's only copy — so the failure moves to the commit, `error: pathspec '' did not match any file(s) known to git`, exit 1, aborting every genuine regeneration in the same set with it. Both leave Phase 1 dead *after* the destructive run: regenerated mirrors uncommitted, the branch pushed stale, its own conformance job red. So these paths go **neither in `git add ` nor in the pathspec**; each is named on the `Mirrors:` row as `removed untracked: (recover: git cat-file -p > )`, `` being its before `-w` digest — the row exists because the overwrite row cannot carry it: that one fires on a digest that moved, and here the entry vanished. (A path the run deleted that HEAD *does* have — ` D ` appeared — is ordinary: `git add` stages the deletion and the pathspec commits it.) + - **The recover rows are driven by the digest comparison alone**, whether or not a commit was made and however the path entered the set: every before-digested path whose file is gone or whose after content differs from its `-w` digest is named — `overwrote …` if the file exists, `removed untracked: …` if it does not. An entry that *moved* is not a substitute (see the cached-empty case below: `M.` → `MM` moves the entry and destroys the staged content in the same stroke). + - **No change** → the two snapshots are equal **and no dirty path's digest moved**; a no-op stays **silent**: no commit, and no output row (the `Mirrors:` row is emitted only when a commit was made or uncommitted work was overwritten or removed). Continue to step 5. + - **Changed** → stage **only** the paths that comparison produced — never `git add -A`, and never a glob: unstaged authored changes belong to the contributor and must survive the run untouched, and this skill must not commit them — and commit them **alone**, as their own commit, never mixed into a feature commit. Commit them **by pathspec**: `git add `, then `git commit -m "chore: regenerate mirrors from local dataset" -- ` — with **one check between the two**: `git diff --cached --quiet -- `, exit 0 meaning the index does *not* differ from HEAD on those paths. The cached check is a real branch, not defensiveness: a path whose dataset render already **equals HEAD** moves its entry when the run rewrites it (a staged hand-edit `M.` → `MM`; a staged deletion `D.` → `D.` + `??`; an unstaged hand-edit `.M` → gone), so it is in the set, yet after `git add` its index entry equals HEAD. A set made only of such paths commits nothing: `git commit … -- ` is `nothing to commit, working tree clean`, exit 1, and a recipe with no branch for that aborts Phase 1 mid-step — while the hand-edits it just staged over are gone from disk *and* index, recoverable only through their `-w` digests and reported only by the recover rows above (which is why those rows do not wait for a commit). **Empty ⇒ no commit** — treat it as the no-op branch (no `regenerated —` on the `Mirrors:` row), the recover rows still emitted. **Non-empty ⇒ commit**, and note that the commit's file list is then `git diff --cached --name-only -- `, a *subset* of the set whenever such a path is mixed in with a genuine regeneration — the Verify below compares against that list, not the set. The pathspec is not a stylistic preference — a plain `git commit` after `git add ` commits **the whole index**, and content the contributor had **already staged before the run** is never part of this commit. The pathspec replaces the index as the commit's **scope**, not the `git add` as its **step** — stage first, always, and exactly the same set: a pathspec resolves against paths git already knows (index or HEAD), so a mirror this run **created** (a `??` entry — what a contributor gets by adding a file to the dataset, the one case a published-KB install cannot serve) is not committable by pathspec alone. `error: pathspec '' did not match any file(s) known to git`, exit 1, and the whole commit aborts mid-step, so the regenerated mirror never lands and the branch pushes without it. A tracked path that was modified or deleted **does** commit by pathspec while unstaged, which is exactly what makes a dropped `git add` look harmless until the first new mirror. The index case is ordinary, not exotic: this skill is standalone, explicitly runs on a dirty tree, and a resumed or interrupted `/pair-process-implement` leaves a populated index — so the staged prose would land inside the regeneration commit, which is the same harm the rule above prevents for *unstaged* work, reached through the index instead of through a glob. The pathspec form leaves those entries staged and untouched. Name it as a *regeneration* (e.g. `chore: regenerate mirrors from local dataset`), never a "fix": an overwritten hand-edit was restored to what the dataset generates, not repaired. Drift in a file this branch never touched is committed here too, and **said so in the output** — surprising, but better than pushing knowingly stale generated output, and the separate commit keeps even a dataset-wide regeneration readable next to the authored work. + - **Verify**: `git log` shows exactly one new commit (none, when the cached diff was empty) and its file list equals `git diff --cached --name-only -- ` as read just before it — the set minus the removed paths and minus every path whose staged content equals HEAD; **every pre-existing dirty path that is NOT in the set and still has a file on disk still carries its before digest** — the survival check is on **content**, because a path still being listed by `git status` is exactly what an overwrite also produces, so a check phrased on the listing would certify the loss it is meant to catch. The on-disk qualifier is not a loophole: a deleted path has no digest by construction (step 3), and its survival is carried by the porcelain entry, which any rewrite would have moved. 5. **Act**: Compose `/pair-capability-verify-quality` with `$scope` (default `all`). This is a local pre-flight, not a replacement for CI (CI stays authoritative, #210). 6. **Check**: Did every required gate pass? 7. **Skip**: If all gates pass, proceed to Phase 2. @@ -169,7 +171,7 @@ The PR is ready; it must now be **under review and mechanically blocked** — se PUBLISH-PR REPORT: ├── Story: [#ID: Title] ├── Handoff: [.pair/working/checkpoints/.md | none — state gathered from branch+story] -├── Mirrors: [regenerated — commit , N file(s)[; overwrote uncommitted changes in: (recover: git cat-file -p > )] — omit this row entirely when nothing was committed] +├── Mirrors: [regenerated — commit , N file(s) | no commit — every regenerated path already equals HEAD][; overwrote uncommitted changes in: (recover: git cat-file -p > )][; removed untracked: (recover: git cat-file -p > )] — omit this row entirely when nothing was committed and no uncommitted work was overwritten or removed] ├── Gate: [PASS | HALTED — N gates failing] ├── Base: [base-branch — squash on merge: yes|no] ├── PR: [#PR-number — URL — Created | Updated — ready-for-review confirmed by read | ready-for-review not confirmed — finding] @@ -200,6 +202,7 @@ When invoked **independently** (hotfix, automation loop #212): ## HALT Conditions - **Story id unresolvable** from handoff or branch (Phase 0). +- **Untracked files under the written trees** (Phase 1) — a `??` or `A.` entry under a tree the adoption names as written by `mirror-realign-command`; the run would delete it (mirror tree) or index it into a generated file (add tree). Named per path with the stash remedy; the command has not run, so nothing was written. - **`mirror-realign-command` exits non-zero** (Phase 1) — report its own reason verbatim; nothing was regenerated and no PR side effects occur. Same shape as the gate-red HALT it precedes. - **Quality gate red** (Phase 1) — report failing checks; no PR side effects. - **pr-template not found** (Phase 3) — cannot compose a PR without it. @@ -220,6 +223,7 @@ See [graceful degradation](../../../.pair/knowledge/guidelines/technical-standar - **No board state maps to `Review`** (a minimal board, D4 — a project that reviews on the PR and merges straight to `Done`): **write no state field** in step 7 — membership is still established and confirmed — and report `Board: n-a — no Review state on this board`. The zero-configuration documented skip, **not** an error and not a degraded publish — the readiness signal is the PR itself. - **The direct board write cannot complete** (membership unconfirmable after the add and its one retry — the item writer's Step 7b; or a macrostate no board state can express — its Step 6): report the blocker verbatim on the `Board:` row as `not updated — ` and continue. The reasons are the item writer's, the write is **this skill's own** — it applies those beats by reference, it does not compose them. The PR is published and ready-for-review; a board write that did not happen is **reported, never absorbed into a green publish**, and this skill never HALTs on it (the code-host artifact is the work). - **No `mirror-realign-command` declared**: skip the realignment step and report nothing (Phase 1) — the zero-configuration default for a project with no generated mirrors, **not** a degradation. Never substitute a guessed command, and never a knowledge-base *install* command: installing a published release is a different operation from realigning a working tree, and using one for the other makes the fix depend on what has been published. +- **`mirror-realign-command` names no written trees**: the Phase 1 untracked-files check has nothing to scope itself to and is skipped — the step-4 `removed untracked:` row still names a deleted draft after the fact; an indexed one goes unnoticed. Declaring the trees is one descriptive clause on the same adoption line. - **`/pair-capability-checkpoint` not installed**: gather state from branch + story directly (Phase 0). - **`/pair-capability-write-issue` not installed**: only the **comment-mode back-link** (Phase 4 step 5) is affected — write it directly per the PM tool's implementation guide **and read the item's comments back to confirm it**, or warn with the manual-link instruction. A direct post the read does not show is reported `back-link failed — manual link needed`, **never as posted**: losing the composition must not lose the confirming read with it, or the degraded path becomes the one path that claims a write it never made. **The board write in step 7 is unaffected and still runs in full** (membership → confirming read → state field): it is direct, never a composition, so a missing item writer can never leave the story off the board. Skipping the board write here would re-create #384/#372 — green, ready-for-review, and invisible. - **Nested subagent dispatch unavailable** (Phase 5 — the common case: this skill is itself running in `/pair-process-implement`'s handoff subagent and the harness forbids a second level): return `Review: review-dispatch-required — /pair-process-review $pr=` and let the **caller** dispatch (`/pair-process-implement` Step 3.3). This is the primary path when nested, not a degradation — the review still runs, one frame up, on a clean context. diff --git a/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md b/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md index 20414b88f..a4569dc99 100644 --- a/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md +++ b/.pair/adoption/decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md @@ -145,6 +145,37 @@ read from the adoption, never named in the skill.** scenario over a working installation cannot produce. Applies to `packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts` and, retroactively, to `run-format.test.ts`, which already had this shape unrecorded. +- **What the run REMOVED leaves the stageable set and is named** (round-6 review, measured against + the real script). A `behavior: "mirror"` registry makes the target equal to the dataset, so a + contributor's untracked `.pair/knowledge/wip-draft.md` is deleted and its `??` entry disappears — + which puts it in the set — and `git add ` on it is `fatal: pathspec … did not match any + files`, exit 128; the staged-new shape (porcelain `A.` → `AD`, `.` marking the blank column) passes `git add` (staging the removal) and + fails the pathspec commit instead, exit 1, aborting every genuine regeneration in the same set. + Phase 1 died *after* the destructive run, and the draft was destroyed with no report row (the + overwrite row fires on a moved digest, not a vanished entry). Such paths go neither in `git add` + nor in the pathspec and are named `removed untracked: (recover: git cat-file -p > + )` from the before `-w` digest. +- **A staged set whose cached diff is empty is a no-op, not a failed commit** (round-6 review, + measured). A path whose render already equals HEAD moves its entry when rewritten (`M.` → `MM`, + `D.` → `D.` + `??`, `.M` → gone) yet equals HEAD in the index after `git add`; `git commit … -- + ` over only such paths is `nothing to commit, working tree clean`, exit 1. The recipe now + runs `git diff --cached --quiet -- ` after staging: empty ⇒ no commit; and the recover rows + are driven by the digest comparison alone, whether or not a commit was made — the two hand-edits + in that case are gone from disk *and* index, so a row that waited for the commit would never + name them. The Verify compares the commit's file list to `git diff --cached --name-only`, since a + mixed set commits a subset. +- **Untracked files under the written trees HALT the step before the command runs** (round-6 + review, measured). The writer reads the whole target tree: under a mirror registry an untracked + file is deleted; under the `add` registry (`.pair/adoption`) it survives but the CLI's + `generateLlmsTxt` indexes it, so the committed `.pair/llms.txt` carries a dangling link and the + contributor's private WIP filename. Bytes untouched, derived output leaked — the story's edge + case held on bytes only. Since the harm is decided by *which* tree the file is under and the skill + owns no globs, the check is scoped by the trees the adoption's `mirror-realign-command` entry + names (descriptive, the same clause this file already carried), HALTs on any `??`/`A.` entry + under them with the remedy `git stash push -u -- ` / `git stash pop`, and is skipped when + the adoption names none — a HALT here costs nothing, since nothing has been written yet, unlike + the post-run HALT rejected below. Measured to its postcondition: stashed, the run leaves + `llms.txt` untouched; popped, the note is back. - A non-zero exit from the command **HALTs** before any PR side effect — the same shape as the gate-red HALT it now precedes. - This project declares `mirror-realign-command: pnpm mirrors:regenerate`. @@ -193,9 +224,11 @@ read from the adoption, never named in the skill.** - `adoption/tech/way-of-working.md` → `## Quality Gates`: declare `mirror-realign-command` (`pnpm mirrors:regenerate`), state the absent-⇒-skipped default, mark the written-tree list as - descriptive rather than a staging rule, and state the writer/checker scope asymmetry (the guards + descriptive rather than a staging rule, state the writer/checker scope asymmetry (the guards check the dataset-sourced mirrors; the command additionally rewrites skill references across the - whole installed tree, which nothing verifies). + whole installed tree, which nothing verifies), and state that the writer reads the whole target + tree — untracked files are deleted under mirror registries and indexed into `.pair/llms.txt` + under the `add` one — so the run starts with none under the written trees. - `adoption/tech/way-of-working.md` → `## Quality Gates` → "Gate & tooling code": record the bounded vitest exception above next to the rule it qualifies, so the two are read together. - [2026-07-13-gate-tooling-code-in-tested-modules.md](./2026-07-13-gate-tooling-code-in-tested-modules.md): diff --git a/.pair/adoption/tech/way-of-working.md b/.pair/adoption/tech/way-of-working.md index dbed5e940..3413d4f96 100644 --- a/.pair/adoption/tech/way-of-working.md +++ b/.pair/adoption/tech/way-of-working.md @@ -85,7 +85,7 @@ Resolution order, the split-tool routing and why the fallback is never the authe - **Review enforcement**: `disabled` (default) — the pair review **runs and publishes its verdict**, but nothing it says blocks a merge: `pair-review` and `pair-explicit-approval` are not required status checks, and the 🔴 explicit-approval rule is advisory. Set to `enabled` to make them required and the rule binding, per [pr-states.md](../../knowledge/guidelines/collaboration/project-management-tool/pr-states.md); `/pair-capability-setup-gates` reads this flag before touching branch protection, and `/pair-process-bootstrap` asks for it when no decision exists. Disabled is the default deliberately: a review that blocks by default turns a first install into a repository nobody can merge into — on a single-maintainer repo the 🔴 non-author approval is unobtainable outright. The tier requirements themselves (reviewer count, SLA, checklist depth, whether 🔴 needs explicit approval) are redefinable in this file; that the review **runs** is not. - **Coverage guardrail**: `enabled` — pair dogfoods its own capability: the [`Coverage guardrail` step](../../../.github/workflows/ci.yml) in CI sources [`coverage-gate.sh`](../../knowledge/assets/coverage-gate.sh), extracts the line-coverage % from each package's istanbul `coverage-summary.json`, and blocks a PR whose coverage drops below the human-committed baseline in [`tech/coverage-baseline.md`](./coverage-baseline.md) (maintaining/improving passes — not an absolute wall). The framework **default remains `disabled`** (the dataset template ships off); this line is pair's project-level opt-in only. See [coverage guardrail](../../knowledge/guidelines/infrastructure/cicd-strategy/tier-aware-pipeline.md#coverage-guardrail-opt-in-regression-gate-consumed-by-this-pipeline) + [config format](../../knowledge/assets/coverage-config-example.md); `/pair-capability-setup-gates` reads this flag before generating the pipeline. **Coverage baseline commit-back**: `disabled` — the separate, nested opt-in ratchet (#372, framework default also `disabled`): when `enabled`, a **push to the base branch** (never a PR run, never a fork) proposes a raised `baseline.` as a **bot pull request** from `chore/coverage-baseline-ratchet`, never a push to `main`, and requires a repo-scoped `COVERAGE_RATCHET_TOKEN` (`contents: write` + `pull requests: write`, no protection bypass) — without it the step warns and the gate's verdict is unchanged. It stays `disabled` here until story #234's branch protection is applied and that secret is provisioned (ADR-018 lands with that story, so it is not linked from here yet); see ADL [2026-07-30-coverage-ratchet-pr-not-push.md](../decision-log/2026-07-30-coverage-ratchet-pr-not-push.md). The step that runs it is the **shipped** KB asset `node .pair/knowledge/assets/coverage-ratchet.cjs` (ADR-023) — the same one an adopter's generated pipeline invokes, so this flag being `enabled` means the same thing here as anywhere else. - **Pair review required checks**: `pair-review` + `pair-explicit-approval` are the required status checks that make the judgment review unskippable (R5.7) and enforce the 🔴 explicit-human-approval rule (D10) — see [pr-states.md](../../knowledge/guidelines/collaboration/project-management-tool/pr-states.md) and [ADR-018](adr/adr-018-pr-state-flow-required-checks.md). Status on this repo: **not yet applied** — writing branch protection needs admin scope, so it is a deliberate human step; until applied, enforcement here is advisory (the documented degraded mode). **Ordering constraint** (applies in this order, or every merge stops): 1. provision the `pr-state:*` labels + add the `pair-explicit-approval` workflow (neither needs admin scope — this repo has not added the workflow yet, so the context does not report today); 2. confirm on a real PR that `pair-review` and `pair-explicit-approval` both report on the head commit, **and** that the approval context re-reports on that same head SHA after a review submission; 3. only then `PUT` the branch protection, keeping `enforce_admins` off until one PR has merged through it. The whole sequence (including the merge-block outcomes per tier) was executed on a throwaway repository — see `github-implementation.md` § "Verified on a throwaway repository" — so what remains here is applying it, not discovering whether it works. **This repo is single-maintainer**, so a 🔴 PR cannot satisfy `pair-explicit-approval` (GitHub rejects a self-approval): a second human reviewer account is a prerequisite for making that context required here — otherwise leave it out of the required list and keep the 🔴 rule advisory. The solo-maintainer alternative (a verified human approval token instead of a second account) is tracked as [#398](https://github.com/foomakers/pair/issues/398). **When the protection is written here, use the `checks` form with `app_id` pinned** for `pair-explicit-approval` (an unpinned status context is satisfiable by any push-access token, including the agent's); `pair-review` stays unpinned and is an anti-accident control, not an authorization control — see `github-implementation.md` § "What each context proves". -- **`mirror-realign-command`**: `pnpm mirrors:regenerate` — the single, local, deterministic writer that realigns the generated mirrors with `packages/knowledge-hub/dataset`. It writes into `.claude/**`, root `.pair/**`, `AGENTS.md`/`CLAUDE.md` and `.github/**` — **a description of where its output lands, never a staging rule**: those same trees hold authored files (117 tracked files under `.pair/adoption/**` alone), so anything that committed the glob rather than the command's actual effect would sweep a contributor's unstaged prose into a regeneration commit. `/pair-capability-publish-pr` therefore stages a **before/after `git status --porcelain -z` comparison**, and no adopter enumerates owned globs anywhere. It wraps the CLI's existing local-source path (`pair update --source --offline`) and adds no generation logic; it has **no check mode** — the mirror guards (`skills:conformance`) are the checker, this is the only writer. **Writer and checker are not the same scope, and the asymmetry is the writer's**: the guards check the **dataset-sourced** mirrors (a target-tree file with no counterpart in the dataset is compared to nothing), while this command additionally rewrites skill references across the whole installed tree, which nothing verifies. Evidence: commit `6655439d` regenerated `adr-021`, `adr-022`, `adr-023` and `collaborative-workflow.context.md` — four files with no dataset counterpart — after they had sat drifted on a green `main`. Drift in that region accumulates undetected and then lands, unrelated, in whichever PR next runs the writer. It is what `PRE_PUSH_REMEDY`, both mirror guards, `DEVELOPMENT.md` and its docs-site twin name, and what `/pair-capability-publish-pr` runs (Phase 1, before the gate) and commits separately when it produces a diff. Deliberately **not** `pair update`, which installs the latest PUBLISHED knowledge base and would make a local fix depend on what has been released. Absent this key, `/pair-capability-publish-pr` skips its realignment step entirely — the zero-configuration default, not a degradation. See ADL [2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md](../decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md) and story [#419](https://github.com/foomakers/pair/issues/419). +- **`mirror-realign-command`**: `pnpm mirrors:regenerate` — the single, local, deterministic writer that realigns the generated mirrors with `packages/knowledge-hub/dataset`. It writes into `.claude/**`, root `.pair/**`, `AGENTS.md`/`CLAUDE.md` and `.github/**` — **a description of where its output lands, never a staging rule**: those same trees hold authored files (117 tracked files under `.pair/adoption/**` alone), so anything that committed the glob rather than the command's actual effect would sweep a contributor's unstaged prose into a regeneration commit. `/pair-capability-publish-pr` therefore stages a **before/after `git status --porcelain -z` comparison**, and no adopter enumerates owned globs anywhere. It wraps the CLI's existing local-source path (`pair update --source --offline`) and adds no generation logic; it has **no check mode** — the mirror guards (`skills:conformance`) are the checker, this is the only writer. **Writer and checker are not the same scope, and the asymmetry is the writer's**: the guards check the **dataset-sourced** mirrors (a target-tree file with no counterpart in the dataset is compared to nothing), while this command additionally rewrites skill references across the whole installed tree, which nothing verifies. Evidence: commit `6655439d` regenerated `adr-021`, `adr-022`, `adr-023` and `collaborative-workflow.context.md` — four files with no dataset counterpart — after they had sat drifted on a green `main`. Drift in that region accumulates undetected and then lands, unrelated, in whichever PR next runs the writer. **The writer reads the whole target tree, untracked files included** (measured in `regenerate-mirrors.test.ts`): under a `behavior: "mirror"` registry (`.pair/knowledge`, `.github`, `AGENTS.md`) a file only the target has is **deleted** — a contributor's untracked `.pair/knowledge/wip-draft.md` is gone after the run, recoverable only from the `-w` blob `/pair-capability-publish-pr` took before it; under the `behavior: "add"` registry (`.pair/adoption`) it survives, but the CLI's `generateLlmsTxt` indexes everything on disk, so an untracked `.pair/adoption/tech/wip-note.md` lands as a dangling `- [adoption note](.pair/adoption/tech/wip-note.md)` line in the committed `.pair/llms.txt`. So the run must start with **no untracked (`??`/`A.` in porcelain, `.` marking the blank column) files under the written trees above** — `git stash push -u -- ` them and `git stash pop` afterwards, or commit them first; `/pair-capability-publish-pr` HALTs on them before running the command. It is what `PRE_PUSH_REMEDY`, both mirror guards, `DEVELOPMENT.md` and its docs-site twin name, and what `/pair-capability-publish-pr` runs (Phase 1, before the gate) and commits separately when it produces a diff. Deliberately **not** `pair update`, which installs the latest PUBLISHED knowledge base and would make a local fix depend on what has been released. Absent this key, `/pair-capability-publish-pr` skips its realignment step entirely — the zero-configuration default, not a degradation. See ADL [2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md](../decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md) and story [#419](https://github.com/foomakers/pair/issues/419). - **Gate & tooling code:** a gate's logic lives in a tested module in its owning package (white-box unit tests); scripts/CLIs are thin entrypoints and a root gate delegates (`pnpm --filter `). Scripts are never unit-tested — CLI-level checks go to smoke tests. See ADL [2026-07-13-gate-tooling-code-in-tested-modules.md](../decision-log/2026-07-13-gate-tooling-code-in-tested-modules.md). **One bounded exception** (#419): a thin script whose behaviour IS the deliverable, with no logic to extract, is black-box executed from vitest against a throwaway fixture, asserting observable behaviour only — `scripts/format-lib/run-format.sh` and `scripts/regenerate-mirrors.sh`. Conditions and why the smoke suite is not their home: ADL [2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md](../decision-log/2026-09-01-publish-pr-realigns-mirrors-before-the-gate.md). Gate/tooling packages are organized by bounded context, not one package per tool family — a new tool family sharing an existing package's bounded context is a new folder there, not a new package. See [ADR-014](adr/adr-014-tool-package-boundary-by-bounded-context.md). - **Conformance tests** (`packages/knowledge-hub/src/conformance/`): one test file per target KB artifact (a `SKILL.md`, guideline, or template), not per introducing story — a new story extends the matching file's `describe` block instead of adding a new story-named file. See ADL [2026-07-18-conformance-test-per-file-not-per-story.md](../decision-log/2026-07-18-conformance-test-per-file-not-per-story.md). - **Monorepo tooling gotchas** (e.g. `pnpm --filter` bypassing turbo's `dependsOn` graph on a fresh checkout): documented once, centrally, in `DEVELOPMENT.md`'s `Turbo Caching` section — affected packages' READMEs carry only a short pointer, not a full copy. See ADL [2026-07-18-workspace-gotcha-doc-placement.md](../decision-log/2026-07-18-workspace-gotcha-doc-placement.md). diff --git a/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts b/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts index ed5d3203f..83723e249 100644 --- a/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts +++ b/packages/dev-tools/src/quality-gates/regenerate-mirrors.test.ts @@ -720,6 +720,285 @@ describe('regenerate-mirrors.sh — the local, deterministic mirror remedy (#419 SCRIPT_RUN_TIMEOUT_MS, ) + it( + 'deletes an uncommitted file under a mirror registry — nothing to stage, only the `-w` blob survives', + () => { + // Round-6 finding (Major), executed against the real script. The `knowledge` registry is + // `behavior: "mirror"` (apps/pair-cli/config.json): the target is made EQUAL to the dataset, + // so a file only the target has is REMOVED — including a contributor's draft that was never + // in the dataset. Two shapes HEAD does not know, both destroyed by the run: + // `?? .pair/knowledge/wip-draft.md` — untracked: entry DISAPPEARS after the run; + // `A .pair/knowledge/staged-draft.md` — staged-new: entry becomes `AD`. + // Under step 4 as first written both are "entry changed ⇒ in the set", and the documented + // next step is fatal: `git add wip-draft.md` -> `fatal: pathspec ... did not match any + // files`, exit 128; `git add staged-draft.md` exits 0 (it stages the REMOVAL) and then the + // pathspec commit is `error: pathspec ... did not match any file(s) known to git`, exit 1, + // taking every genuine regeneration in the same set down with it. CONCRETE LOSS: Phase 1 + // aborts AFTER the destructive run — the regenerated mirrors sit uncommitted, the branch + // pushes stale and its own conformance job goes red — and the draft is gone with NO report + // row, because `overwrote uncommitted changes in:` fires only on a digest that MOVED, never + // on an entry that vanished. The fix: such paths leave the stageable set and are named as + // `removed untracked: (recover: git cat-file -p > )`. + tmp = makeFixture() + write(join(tmp, '.pair/knowledge/index.md'), '# pre-existing install\n') + expect(run(tmp, isolatedHome(tmp)).status).toBe(0) + git(tmp, ['add', '-A']) + git(tmp, ['commit', '-q', '-m', 'converged']) + + const WIP = '.pair/knowledge/wip-draft.md' + const STAGED = '.pair/knowledge/staged-draft.md' + const CREATED = '.pair/knowledge/new-guide.md' + const WIP_CONTENT = '# my wip draft\n' + const STAGED_CONTENT = '# my staged draft\n' + // One genuine regeneration in the same run, so the set is MIXED: the recipe has to land + // this one while leaving the two removed paths out of `git add` and the pathspec. + write(join(tmp, 'packages/knowledge-hub/dataset/.pair/knowledge/new-guide.md'), NEW_GUIDE) + git(tmp, ['add', 'packages/knowledge-hub/dataset']) + git(tmp, ['commit', '-q', '-m', 'a new dataset file']) + write(join(tmp, WIP), WIP_CONTENT) + write(join(tmp, STAGED), STAGED_CONTENT) + git(tmp, ['add', STAGED]) + + const before = snapshotTree(tmp, { + untrackedFilesAll: true, + writeBlobs: true, + nulSeparated: true, + }) + expect(parsePorcelainZ(before.entries)).toEqual( + expect.arrayContaining([ + { xy: '??', path: WIP }, + { xy: 'A ', path: STAGED }, + ]), + ) + // Both have a file on disk before the run, so both are digested — with `-w`. + expect(before.digests.has(WIP)).toBe(true) + expect(before.digests.has(STAGED)).toBe(true) + + expect(run(tmp, isolatedHome(tmp)).status).toBe(0) + + // THE EFFECT, measured: the mirror registry removed what the dataset does not ship. + expect(existsSync(join(tmp, WIP))).toBe(false) + expect(existsSync(join(tmp, STAGED))).toBe(false) + const afterEntries = parsePorcelainZ( + git(tmp, ['status', '--porcelain', '-z', '--untracked-files=all']), + ) + expect(afterEntries.find(entry => entry.path === WIP)).toBeUndefined() + expect(afterEntries).toContainEqual({ xy: 'AD', path: STAGED }) + expect(afterEntries).toContainEqual({ xy: '??', path: CREATED }) + + // THE PRE-FIX RECIPE, measured: both removed paths are in the set, and staging them is fatal. + const MSG = 'chore: regenerate mirrors from local dataset' + const head = git(tmp, ['rev-parse', 'HEAD']).trim() + const addWip = tryGit(tmp, ['add', WIP]) + expect(addWip.status).toBe(128) + expect(addWip.stderr).toContain(`fatal: pathspec '${WIP}' did not match any files`) + // The staged-new shape is worse: `git add` SUCCEEDS (it stages the removal, dropping the + // index's only copy), and the failure moves to the commit — which aborts whole. + expect(tryGit(tmp, ['add', STAGED, CREATED]).status).toBe(0) + const refused = tryGit(tmp, ['commit', '-m', MSG, '--', STAGED, CREATED]) + expect(refused.status).toBe(1) + expect(refused.stderr).toContain( + `pathspec '${STAGED}' did not match any file(s) known to git`, + ) + expect(git(tmp, ['rev-parse', 'HEAD']).trim()).toBe(head) + + // THE DOCUMENTED RECIPE: the removed paths are not in `git add` and not in the pathspec. + // They are reported instead, and the genuine regeneration lands. + git(tmp, ['add', CREATED]) + expect(tryGit(tmp, ['diff', '--cached', '--quiet', '--', CREATED]).status).toBe(1) + git(tmp, ['commit', '-q', '-m', MSG, '--', CREATED]) + expect( + git(tmp, ['show', '--name-only', '--format=', 'HEAD']).split('\n').filter(Boolean), + ).toEqual([CREATED]) + + // The `recover:` recipe, applied as documented: the blob is the ONLY copy, and it is enough. + for (const [rel, content] of [ + [WIP, WIP_CONTENT], + [STAGED, STAGED_CONTENT], + ] as const) { + const sha = before.digests.get(rel) + expect(sha).toBeDefined() + expect(git(tmp, ['cat-file', '-p', sha ?? ''])).toBe(content) + writeFileSync(join(tmp, rel), git(tmp, ['cat-file', '-p', sha ?? ''])) + expect(readFileSync(join(tmp, rel), 'utf-8')).toBe(content) + } + }, + SCRIPT_RUN_TIMEOUT_MS, + ) + + it( + 'a non-empty set whose cached diff is empty is a no-op, never a failed commit', + () => { + // Round-6 finding (Minor), executed. Three shapes where the run rewrites a path whose + // dataset render EQUALS HEAD — so after `git add ` the index equals HEAD and there is + // nothing to commit, while the porcelain entry DID move (so the path is in the set): + // `M a.md` staged hand-edit -> run rewrites worktree -> `MM a.md` + // `D b.md` staged deletion -> run recreates -> `D b.md` + `?? b.md` + // ` M c.md` unstaged hand-edit -> run rewrites worktree -> entry GONE + // MEASURED: `git add a b c` exits 0, `git diff --cached --quiet -- a b c` exits 0 (empty), + // and `git commit -m … -- a b c` is `nothing to commit, working tree clean`, exit 1 — a + // step with no branch for it aborts Phase 1 mid-way, and (a)/(c)'s hand-edits are gone from + // index and disk with no report row, because they entered the set via ENTRY change, not via + // a digest that moved on an unchanged entry. The documented recipe checks the cached diff + // after staging: empty ⇒ no commit, no `Mirrors:` row — but every path whose before digest + // differs from its after content is STILL named on the recover row. + tmp = makeFixture() + const dataset = join(tmp, 'packages/knowledge-hub/dataset') + const A = '.pair/knowledge/a.md' + const B = '.pair/knowledge/b.md' + const C = '.pair/knowledge/c.md' + const D = '.pair/knowledge/d.md' + for (const rel of [A, B, C]) write(join(dataset, rel), `# ${rel}\n`) + write(join(tmp, '.pair/knowledge/index.md'), '# pre-existing install\n') + expect(run(tmp, isolatedHome(tmp)).status).toBe(0) + git(tmp, ['add', '-A']) + git(tmp, ['commit', '-q', '-m', 'converged — every render equals HEAD']) + + const A_EDIT = '# staged hand-edit\n' + const C_EDIT = '# unstaged hand-edit\n' + writeFileSync(join(tmp, A), A_EDIT) + git(tmp, ['add', A]) + git(tmp, ['rm', '-q', B]) + writeFileSync(join(tmp, C), C_EDIT) + + const before = snapshotTree(tmp, { + untrackedFilesAll: true, + writeBlobs: true, + nulSeparated: true, + }) + expect(parsePorcelainZ(before.entries)).toEqual( + expect.arrayContaining([ + { xy: 'M ', path: A }, + { xy: 'D ', path: B }, + { xy: ' M', path: C }, + ]), + ) + + expect(run(tmp, isolatedHome(tmp)).status).toBe(0) + + const afterEntries = parsePorcelainZ( + git(tmp, ['status', '--porcelain', '-z', '--untracked-files=all']), + ) + expect(afterEntries).toContainEqual({ xy: 'MM', path: A }) + expect(afterEntries).toContainEqual({ xy: 'D ', path: B }) + expect(afterEntries).toContainEqual({ xy: '??', path: B }) + expect(afterEntries.find(entry => entry.path === C)).toBeUndefined() + + // THE PRE-FIX RECIPE, measured: stage the set, commit by pathspec -> exit 1, HEAD unmoved. + const MSG = 'chore: regenerate mirrors from local dataset' + const head = git(tmp, ['rev-parse', 'HEAD']).trim() + expect(tryGit(tmp, ['add', A, B, C]).status).toBe(0) + expect(tryGit(tmp, ['diff', '--cached', '--quiet', '--', A, B, C]).status).toBe(0) + const refused = tryGit(tmp, ['commit', '-m', MSG, '--', A, B, C]) + expect(refused.status).toBe(1) + expect(refused.stdout).toContain('nothing to commit') + expect(git(tmp, ['rev-parse', 'HEAD']).trim()).toBe(head) + + // The hand-edits are gone from disk AND index — the `-w` blob is the only copy left, and the + // recover row has to be emitted from the digest comparison, not from the entry comparison. + const after = snapshotTree(tmp, { + untrackedFilesAll: true, + writeBlobs: false, + nulSeparated: true, + }) + expect(git(tmp, ['hash-object', A]).trim()).not.toBe(before.digests.get(A)) + expect(git(tmp, ['hash-object', C]).trim()).not.toBe(before.digests.get(C)) + expect(after.digests.size).toBe(0) // the tree is clean: nothing dirty is left to digest + expect(git(tmp, ['cat-file', '-p', before.digests.get(A) ?? ''])).toBe(A_EDIT) + expect(git(tmp, ['cat-file', '-p', before.digests.get(C) ?? ''])).toBe(C_EDIT) + expect(before.digests.has(B)).toBe(false) // a deletion has no digest by construction + + // The MIXED set: the same three no-op paths plus one genuine regeneration. The cached diff + // over the whole set is non-empty, the pathspec commit succeeds, and its file list is the + // CACHED list — not the set — which is what the Verify has to compare against. + write(join(dataset, D), `# ${D}\n`) + git(tmp, ['add', 'packages/knowledge-hub/dataset']) + git(tmp, ['commit', '-q', '-m', 'a new dataset file']) + writeFileSync(join(tmp, A), A_EDIT) + git(tmp, ['add', A]) + expect(run(tmp, isolatedHome(tmp)).status).toBe(0) + git(tmp, ['add', A, D]) + expect(tryGit(tmp, ['diff', '--cached', '--quiet', '--', A, D]).status).toBe(1) + expect( + git(tmp, ['diff', '--cached', '--name-only', '--', A, D]).split('\n').filter(Boolean), + ).toEqual([D]) + git(tmp, ['commit', '-q', '-m', MSG, '--', A, D]) + expect( + git(tmp, ['show', '--name-only', '--format=', 'HEAD']).split('\n').filter(Boolean), + ).toEqual([D]) + }, + SCRIPT_RUN_TIMEOUT_MS, + ) + + it( + 'indexes an untracked adoption file into the generated llms.txt — stash it before the run', + () => { + // Round-6 finding (Minor), executed. The `adoption` registry is `behavior: "add"` (a file + // only the target has SURVIVES), and `generateLlmsTxt` (apps/pair-cli/src/registry/ + // llms-generation.ts) indexes the WHOLE `.pair/adoption/**` tree it finds on disk — + // untracked files included. CONCRETE FAILURE: untracked `.pair/adoption/tech/wip-note.md` + // -> the run rewrites `.pair/llms.txt` with `- [adoption note](.pair/adoption/tech/ + // wip-note.md)`. Under the staging rule `.pair/llms.txt` (entry appeared) is committed and + // `wip-note.md` (entry unchanged, `??`) is not: the committed index carries a dangling link + // and the contributor's private WIP filename lands in history. Bytes are untouched, so the + // story's "unstaged authored changes must be left untouched" holds — and the derived output + // still leaks. The remedy the skill names is measured here to its postcondition. + tmp = makeFixture() + write(join(tmp, '.pair/knowledge/index.md'), '# pre-existing install\n') + expect(run(tmp, isolatedHome(tmp)).status).toBe(0) + git(tmp, ['add', '-A']) + git(tmp, ['commit', '-q', '-m', 'converged']) + const LLMS = '.pair/llms.txt' + const NOTE = '.pair/adoption/tech/wip-note.md' + const LINK = '- [adoption note](.pair/adoption/tech/wip-note.md)' + expect(readFileSync(join(tmp, LLMS), 'utf-8')).not.toContain('wip-note') + + write(join(tmp, NOTE), '# adoption note\n') + const before = snapshotTree(tmp, { + untrackedFilesAll: true, + writeBlobs: true, + nulSeparated: true, + }) + expect(parsePorcelainZ(before.entries)).toEqual([{ xy: '??', path: NOTE }]) + + expect(run(tmp, isolatedHome(tmp)).status).toBe(0) + + // THE EFFECT, measured: the note survives (add behaviour) and the index now links it. + expect(readFileSync(join(tmp, NOTE), 'utf-8')).toBe('# adoption note\n') + expect(readFileSync(join(tmp, LLMS), 'utf-8')).toContain(LINK) + const afterEntries = parsePorcelainZ( + git(tmp, ['status', '--porcelain', '-z', '--untracked-files=all']), + ) + expect(afterEntries).toEqual( + expect.arrayContaining([ + { xy: ' M', path: LLMS }, + { xy: '??', path: NOTE }, + ]), + ) + // ...so the staging rule commits the index WITHOUT its target: a dangling link in history. + const MSG = 'chore: regenerate mirrors from local dataset' + git(tmp, ['add', LLMS]) + git(tmp, ['commit', '-q', '-m', MSG, '--', LLMS]) + expect(git(tmp, ['show', `HEAD:${LLMS}`])).toContain(LINK) + expect(tryGit(tmp, ['cat-file', '-e', `HEAD:${NOTE}`]).status).not.toBe(0) + + // THE DOCUMENTED REMEDY, applied to its postcondition: stash the untracked path, run, pop. + git(tmp, ['reset', '-q', '--hard', 'HEAD~1']) + expect(readFileSync(join(tmp, LLMS), 'utf-8')).not.toContain('wip-note') + git(tmp, ['stash', 'push', '-u', '-q', '--', NOTE]) + expect(existsSync(join(tmp, NOTE))).toBe(false) + expect(run(tmp, isolatedHome(tmp)).status).toBe(0) + expect(git(tmp, ['status', '--porcelain', '-z', '--untracked-files=all'])).toBe('') + expect(readFileSync(join(tmp, LLMS), 'utf-8')).not.toContain('wip-note') + git(tmp, ['stash', 'pop', '-q']) + expect(readFileSync(join(tmp, NOTE), 'utf-8')).toBe('# adoption note\n') + expect(parsePorcelainZ(git(tmp, ['status', '--porcelain', '-z', '-uall']))).toEqual([ + { xy: '??', path: NOTE }, + ]) + }, + SCRIPT_RUN_TIMEOUT_MS, + ) + it('exits non-zero and names the reason when the dataset is missing (AC7)', () => { tmp = realpathSync(mkdtempSync(join(tmpdir(), 'regen-mirrors-'))) initRepo(tmp) diff --git a/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md b/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md index d6f057bb4..f6388e12a 100644 --- a/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md +++ b/packages/knowledge-hub/dataset/.skills/capability/publish-pr/SKILL.md @@ -38,7 +38,7 @@ Two sibling sections cover git concerns and the split is deliberate: **`## Merge - **[way-of-working.md](../../../.pair/adoption/tech/way-of-working.md) → `## Merge Strategy`** — the same section the merge consumers read (`/review` Phase 6): `Method` (`squash` | `merge` | `rebase`, **default `squash`**) and the `Commit format` ([commit template](../../../.pair/knowledge/guidelines/collaboration/templates/commit-template.md)). Recorded on the PR as the intended merge strategy; **squash happens at merge, never here**. `branch-format` (to parse the branch id) comes from the [branch template](../../../.pair/knowledge/guidelines/collaboration/templates/branch-template.md). - **way-of-working.md → `## Git Workflow`** — `code-host` (the tool owning branches/PRs) and `base-branch` (default `main`; **a `base-branch` declared under `## Merge Strategy`, where this skill's ≤ 0.4.1 versions documented it, is still honored** — the resolution order is single-sourced in the convention's **`base-branch` resolution** — the same order `/implement` applies, so the two readers cannot disagree on the target branch). **`code-host` absent ⇒ code host = PM tool** (single-tool; the zero-configuration default, not a degradation), and the same tool named in both places is treated exactly as omitted. Resolution, the PM↔code-host routing table, and the cross-linking convention live in one place: [way-of-working / PM-tool + code-host resolution](../../../.pair/knowledge/guidelines/technical-standards/ai-development/skill-conventions/way-of-working-pm-resolution.md) — this skill states only which side each operation is on. -- **way-of-working.md → `## Quality Gates` → `mirror-realign-command`** — the project's single writer for its generated mirrors, run in Phase 1 before the gate. Declared as a command the project owns (e.g. a root script), because which artifacts a repo generates, and from what, is the repo's business and not this skill's — a hardcoded command would emit a step most projects cannot run. **Absent ⇒ the realignment step is skipped entirely** (zero-configuration default, not a degradation). The command must be a *writer*, local and idempotent: the guards that detect drift are the checkers, this is the one thing that fixes it. +- **way-of-working.md → `## Quality Gates` → `mirror-realign-command`** — the project's single writer for its generated mirrors, run in Phase 1 before the gate. Declared as a command the project owns (e.g. a root script), because which artifacts a repo generates, and from what, is the repo's business and not this skill's — a hardcoded command would emit a step most projects cannot run. **Absent ⇒ the realignment step is skipped entirely** (zero-configuration default, not a degradation). The command must be a *writer*, local and idempotent: the guards that detect drift are the checkers, this is the one thing that fixes it. The same entry **should also name the trees the command writes into** (descriptive — e.g. `.claude/**`, `.pair/**` — never a staging rule): Phase 1 step 3 reads that list to find the contributor's untracked files the run would delete or index. **Names no written trees ⇒ that one check is skipped** and step 4's `removed untracked:` row is the only net. - **way-of-working.md → `## State Mapping`** — board-column ↔ canonical-macrostate mapping (see [canonical-states.md](../../../.pair/knowledge/guidelines/collaboration/project-management-tool/canonical-states.md)). Omitted ⇒ canonical names assumed. - **way-of-working.md → `## Assignment`** — the fallback when no `$assignee` is passed. This skill writes the **code-host** side, so it reads **`code-host-assignee` first and `default-assignee` second** — the split-configuration key exists because the same human often carries two identifiers, and resolving the PM-tool login against the code host is how a PR ends up rejected and published unassigned. **One rule, two callers**: the schema and the cascade live once, in the [resolution convention](../../../.pair/knowledge/guidelines/technical-standards/ai-development/skill-conventions/way-of-working-pm-resolution.md), and both this skill (the PR, a **code-host** write) and `/write-issue` (the item, a **PM-tool** write) read them from there rather than each defining their own. Both omitted ⇒ no default; the PR is published unassigned with a warning. @@ -61,16 +61,18 @@ The realignment runs **before** the gate, and the order is load-bearing in both 1. **Check**: Does the adoption declare a `mirror-realign-command`? 2. **Skip**: If it does not, go to step 5. A project with no generated mirrors has nothing to realign — the zero-configuration default, not a degradation, and nothing is reported. -3. **Act**: Take the **before** snapshot — `git status --porcelain -z --untracked-files=all`, whole tree — **paired with a content digest of every entry whose worktree file still exists** (`git hash-object -w ` over that set). The digest is not belt-and-braces: a porcelain entry encodes **status, not content**, so a path that is *already* dirty reports the same unstaged-modified `M ` entry before and after whether the run rewrote the file or never opened it — status alone cannot tell those two apart, and one of them is a destroyed hand-edit. Only then run the declared command. It regenerates the mirrors from the working tree's **local** dataset — never a published release — and is idempotent. A **non-zero exit → HALT** before any PR side effect, reporting the command's own reason verbatim: it never reports success over a no-op, so a failure here means nothing was written and the drift is still there. Each of the four rules in that sentence is doing work, and skipping one puts the step back where the digest found it: +3. **Act**: Take the **before** snapshot — `git status --porcelain -z --untracked-files=all`, whole tree — and **first read its `??` and `A.` entries (porcelain `XY`, `.` marking the blank column) against the trees the command writes into** (Adoption Inputs). A file HEAD does not have, lying under one of those trees, is not safe across the run, in one of two ways the command cannot tell apart from dataset content: a **mirror** tree is made *equal* to the dataset, so the file is **deleted** (the contributor's `.pair/knowledge/wip-draft.md`, gone); an **add** tree keeps it, but a **generated index such as `llms.txt`** is built from the whole tree on disk, so the file is **indexed** — the index then commits with a link to a path this branch does not carry, and the contributor's private WIP filename lands in history while the file itself, its entry unchanged, is left out of the commit. Bytes untouched, derived output leaked. Any such entry → **HALT** before running the command — nothing has been written yet, so this is the one point where the HALT costs nothing — naming each path and the remedy: `git stash push -u -- ` (then `git stash pop` after Phase 1), or commit or move them first. If the adoption names no written trees, this check is skipped (the step-4 `removed untracked:` row still catches the deletion after the fact; nothing catches the indexing). Then pair the snapshot **with a content digest of every entry whose worktree file still exists** (`git hash-object -w ` over that set). The digest is not belt-and-braces: a porcelain entry encodes **status, not content**, so a path that is *already* dirty reports the same unstaged-modified `M ` entry before and after whether the run rewrote the file or never opened it — status alone cannot tell those two apart, and one of them is a destroyed hand-edit. Only then run the declared command. It regenerates the mirrors from the working tree's **local** dataset — never a published release — and is idempotent. A **non-zero exit → HALT** before any PR side effect, reporting the command's own reason verbatim: it never reports success over a no-op, so a failure here means nothing was written and the drift is still there. Each of the four rules in that sentence is doing work, and skipping one puts the step back where the digest found it: - **`-z`**, because the default porcelain format **quotes and octal-escapes** any path holding a space or a non-ASCII byte: `with space.md` prints as ` M "with space.md" ` and `caffè.md` as ` M "caff\303\250.md" `, so the path field read off the entry is *not a filename* — it fails the file-exists test below and the entry is dropped from the digest silently, which is the same status-vs-content blindness the digest exists to close, reached through the parser instead of through `git`. It also breaks the other direction: a NEW generated file with a space is caught by the status comparison, and then `git add '"with space.md"'` fails as a pathspec mid-step. `-z` prints the raw bytes, **NUL-separated**, never quoted or escaped — so **split on NUL**, not on newline (a filename may contain one). Its one parsing rule: a rename/copy entry is `R ` + a second field holding `` — consume that field, never read it as an entry of its own. (This is also what removes the `old -> new` ambiguity the default format's rename line has.) - **`--untracked-files=all`**, because the default collapses a not-yet-committed directory into one `?? dir/` entry — one entry however many files under it the run rewrote, identical on both reads — and `git hash-object dir/` answers `fatal: Unable to hash dir/`, so that whole subtree would be undetectable *and* unhashable: exactly the status-vs-content blindness the digest exists to close, surviving where the digest cannot reach. Expanded per file, `?? dir/a.md` hashes like any other path. (An `equivalent git diff capture` is not equivalent here: **it never reports untracked paths at all**.) - **only entries whose file still exists**, because a deletion has none to read: `git hash-object gone.md` on the ` D ` entry that path left behind is `fatal: could not open 'gone.md' for reading`, exit 128 — and this step's own **non-zero exit → HALT** would turn that into a PR blocked by the snapshot pass that was meant to protect it. **Skip those entries** (` D `, `AD`, `DD`), and nothing is lost by skipping: a deleted path the command recreates **moves its porcelain entry** (` D ` → ` M `, or gone), so the status comparison already catches it. The digest is only needed for the shapes where status *cannot* move. - **`-w`**, because plain `git hash-object` prints a hash and throws the bytes away, while `-w` also **writes the blob into the object database** — same output, and the difference is whether the loss reported two steps later is recoverable. Once the command overwrites a pre-dirty path, the contributor's uncommitted content is in no HEAD (never committed), no index, no disk (overwritten); with `-w` it is in the ODB, and `git cat-file -p ` prints it back. 4. **Check → Act**: Take the **after** snapshot (`git status --porcelain -z --untracked-files=all` again, plus the digest of the same paths — **re-hashing needs no `-w`**: only the pre-overwrite content was at risk) and compare it with the before snapshot. **The set to stage is what this run actually wrote** — every path whose porcelain entry appeared, disappeared or changed between the two reads, **plus every path already dirty in the before snapshot whose digest changed** — and it is derived that way rather than from a **path glob** of "the paths the command owns" deliberately. A glob is a *guess about the command*, and it is wrong wherever generated output and authored files share a prefix — the ordinary case, since the directory holding a project's generated mirrors is usually the same one holding its hand-written adoption/knowledge files. Under a glob, a contributor who left an authored file dirty beneath that prefix gets it committed under `chore: regenerate mirrors from local dataset` — their prose, under a regeneration message, in a commit they never wrote. The before/after comparison cannot do that: a file this run did not touch has an identical entry **and an identical digest** in both snapshots. It also means **no adopter has to enumerate owned globs anywhere** — the command's own effect is the declaration. - **Overwritten uncommitted work** (the digest half, and the only case `git status` cannot show): HEAD carries a drifted mirror, the contributor is holding an *uncommitted* edit to that same file, and the regeneration replaces it — the entry stays the same unstaged-modified `M ` on both reads while the bytes changed. Those paths join the staged set like any other write (the regenerated content is what must ship; leaving it out pushes the stale mirror the guards reject and turns the branch's own conformance job red), **and every one of them is named in the output** — `overwrote uncommitted changes in: (recover: git cat-file -p > )` on the `Mirrors:` row, `` being the before snapshot's `-w` digest. Never silent here: the contributor's work is gone from disk, and a loss nobody is told about is worse than the drift this step exists to fix. The `-w` is what makes that row a remedy instead of an obituary — a named path the contributor cannot restore is only a better-documented loss. - - **No change** → the two snapshots are equal **and no dirty path's digest moved**; a no-op stays **silent**: no commit, and no output row (the `Mirrors:` row is emitted only when a commit was made). Continue to step 5. - - **Changed** → stage **only** the paths that comparison produced — never `git add -A`, and never a glob: unstaged authored changes belong to the contributor and must survive the run untouched, and this skill must not commit them — and commit them **alone**, as their own commit, never mixed into a feature commit. Commit them **by pathspec**: `git add `, then `git commit -m "chore: regenerate mirrors from local dataset" -- `. The pathspec is not a stylistic preference — a plain `git commit` after `git add ` commits **the whole index**, and content the contributor had **already staged before the run** is never part of this commit. The pathspec replaces the index as the commit's **scope**, not the `git add` as its **step** — stage first, always, and exactly the same set: a pathspec resolves against paths git already knows (index or HEAD), so a mirror this run **created** (a `??` entry — what a contributor gets by adding a file to the dataset, the one case a published-KB install cannot serve) is not committable by pathspec alone. `error: pathspec '' did not match any file(s) known to git`, exit 1, and the whole commit aborts mid-step, so the regenerated mirror never lands and the branch pushes without it. A tracked path that was modified or deleted **does** commit by pathspec while unstaged, which is exactly what makes a dropped `git add` look harmless until the first new mirror. The index case is ordinary, not exotic: this skill is standalone, explicitly runs on a dirty tree, and a resumed or interrupted `/implement` leaves a populated index — so the staged prose would land inside the regeneration commit, which is the same harm the rule above prevents for *unstaged* work, reached through the index instead of through a glob. The pathspec form leaves those entries staged and untouched. Name it as a *regeneration* (e.g. `chore: regenerate mirrors from local dataset`), never a "fix": an overwritten hand-edit was restored to what the dataset generates, not repaired. Drift in a file this branch never touched is committed here too, and **said so in the output** — surprising, but better than pushing knowingly stale generated output, and the separate commit keeps even a dataset-wide regeneration readable next to the authored work. - - **Verify**: `git log` shows exactly one new commit and its file list equals that set exactly; **every pre-existing dirty path that is NOT in the set and still has a file on disk still carries its before digest** — the survival check is on **content**, because a path still being listed by `git status` is exactly what an overwrite also produces, so a check phrased on the listing would certify the loss it is meant to catch. The on-disk qualifier is not a loophole: a deleted path has no digest by construction (step 3), and its survival is carried by the porcelain entry, which any rewrite would have moved. + - **Removed uncommitted work** (the other loss `git status` shows only as an absence): a before entry HEAD does not know — `??` or `A.` — whose file is **gone** after the run. A mirror tree ships exactly the dataset's file set, so a contributor's draft under it is deleted, and its entry *disappears* (`??`) or turns `AD` (`A.`). Those paths are in the comparison's set, and they are **not stageable**: `git add ` on the vanished `??` is `fatal: pathspec '' did not match any files`, exit 128, and on the `AD` shape it *succeeds* — it stages the removal, dropping the index's only copy — so the failure moves to the commit, `error: pathspec '' did not match any file(s) known to git`, exit 1, aborting every genuine regeneration in the same set with it. Both leave Phase 1 dead *after* the destructive run: regenerated mirrors uncommitted, the branch pushed stale, its own conformance job red. So these paths go **neither in `git add ` nor in the pathspec**; each is named on the `Mirrors:` row as `removed untracked: (recover: git cat-file -p > )`, `` being its before `-w` digest — the row exists because the overwrite row cannot carry it: that one fires on a digest that moved, and here the entry vanished. (A path the run deleted that HEAD *does* have — ` D ` appeared — is ordinary: `git add` stages the deletion and the pathspec commits it.) + - **The recover rows are driven by the digest comparison alone**, whether or not a commit was made and however the path entered the set: every before-digested path whose file is gone or whose after content differs from its `-w` digest is named — `overwrote …` if the file exists, `removed untracked: …` if it does not. An entry that *moved* is not a substitute (see the cached-empty case below: `M.` → `MM` moves the entry and destroys the staged content in the same stroke). + - **No change** → the two snapshots are equal **and no dirty path's digest moved**; a no-op stays **silent**: no commit, and no output row (the `Mirrors:` row is emitted only when a commit was made or uncommitted work was overwritten or removed). Continue to step 5. + - **Changed** → stage **only** the paths that comparison produced — never `git add -A`, and never a glob: unstaged authored changes belong to the contributor and must survive the run untouched, and this skill must not commit them — and commit them **alone**, as their own commit, never mixed into a feature commit. Commit them **by pathspec**: `git add `, then `git commit -m "chore: regenerate mirrors from local dataset" -- ` — with **one check between the two**: `git diff --cached --quiet -- `, exit 0 meaning the index does *not* differ from HEAD on those paths. The cached check is a real branch, not defensiveness: a path whose dataset render already **equals HEAD** moves its entry when the run rewrites it (a staged hand-edit `M.` → `MM`; a staged deletion `D.` → `D.` + `??`; an unstaged hand-edit `.M` → gone), so it is in the set, yet after `git add` its index entry equals HEAD. A set made only of such paths commits nothing: `git commit … -- ` is `nothing to commit, working tree clean`, exit 1, and a recipe with no branch for that aborts Phase 1 mid-step — while the hand-edits it just staged over are gone from disk *and* index, recoverable only through their `-w` digests and reported only by the recover rows above (which is why those rows do not wait for a commit). **Empty ⇒ no commit** — treat it as the no-op branch (no `regenerated —` on the `Mirrors:` row), the recover rows still emitted. **Non-empty ⇒ commit**, and note that the commit's file list is then `git diff --cached --name-only -- `, a *subset* of the set whenever such a path is mixed in with a genuine regeneration — the Verify below compares against that list, not the set. The pathspec is not a stylistic preference — a plain `git commit` after `git add ` commits **the whole index**, and content the contributor had **already staged before the run** is never part of this commit. The pathspec replaces the index as the commit's **scope**, not the `git add` as its **step** — stage first, always, and exactly the same set: a pathspec resolves against paths git already knows (index or HEAD), so a mirror this run **created** (a `??` entry — what a contributor gets by adding a file to the dataset, the one case a published-KB install cannot serve) is not committable by pathspec alone. `error: pathspec '' did not match any file(s) known to git`, exit 1, and the whole commit aborts mid-step, so the regenerated mirror never lands and the branch pushes without it. A tracked path that was modified or deleted **does** commit by pathspec while unstaged, which is exactly what makes a dropped `git add` look harmless until the first new mirror. The index case is ordinary, not exotic: this skill is standalone, explicitly runs on a dirty tree, and a resumed or interrupted `/implement` leaves a populated index — so the staged prose would land inside the regeneration commit, which is the same harm the rule above prevents for *unstaged* work, reached through the index instead of through a glob. The pathspec form leaves those entries staged and untouched. Name it as a *regeneration* (e.g. `chore: regenerate mirrors from local dataset`), never a "fix": an overwritten hand-edit was restored to what the dataset generates, not repaired. Drift in a file this branch never touched is committed here too, and **said so in the output** — surprising, but better than pushing knowingly stale generated output, and the separate commit keeps even a dataset-wide regeneration readable next to the authored work. + - **Verify**: `git log` shows exactly one new commit (none, when the cached diff was empty) and its file list equals `git diff --cached --name-only -- ` as read just before it — the set minus the removed paths and minus every path whose staged content equals HEAD; **every pre-existing dirty path that is NOT in the set and still has a file on disk still carries its before digest** — the survival check is on **content**, because a path still being listed by `git status` is exactly what an overwrite also produces, so a check phrased on the listing would certify the loss it is meant to catch. The on-disk qualifier is not a loophole: a deleted path has no digest by construction (step 3), and its survival is carried by the porcelain entry, which any rewrite would have moved. 5. **Act**: Compose `/verify-quality` with `$scope` (default `all`). This is a local pre-flight, not a replacement for CI (CI stays authoritative, #210). 6. **Check**: Did every required gate pass? 7. **Skip**: If all gates pass, proceed to Phase 2. @@ -169,7 +171,7 @@ The PR is ready; it must now be **under review and mechanically blocked** — se PUBLISH-PR REPORT: ├── Story: [#ID: Title] ├── Handoff: [.pair/working/checkpoints/.md | none — state gathered from branch+story] -├── Mirrors: [regenerated — commit , N file(s)[; overwrote uncommitted changes in: (recover: git cat-file -p > )] — omit this row entirely when nothing was committed] +├── Mirrors: [regenerated — commit , N file(s) | no commit — every regenerated path already equals HEAD][; overwrote uncommitted changes in: (recover: git cat-file -p > )][; removed untracked: (recover: git cat-file -p > )] — omit this row entirely when nothing was committed and no uncommitted work was overwritten or removed] ├── Gate: [PASS | HALTED — N gates failing] ├── Base: [base-branch — squash on merge: yes|no] ├── PR: [#PR-number — URL — Created | Updated — ready-for-review confirmed by read | ready-for-review not confirmed — finding] @@ -200,6 +202,7 @@ When invoked **independently** (hotfix, automation loop #212): ## HALT Conditions - **Story id unresolvable** from handoff or branch (Phase 0). +- **Untracked files under the written trees** (Phase 1) — a `??` or `A.` entry under a tree the adoption names as written by `mirror-realign-command`; the run would delete it (mirror tree) or index it into a generated file (add tree). Named per path with the stash remedy; the command has not run, so nothing was written. - **`mirror-realign-command` exits non-zero** (Phase 1) — report its own reason verbatim; nothing was regenerated and no PR side effects occur. Same shape as the gate-red HALT it precedes. - **Quality gate red** (Phase 1) — report failing checks; no PR side effects. - **pr-template not found** (Phase 3) — cannot compose a PR without it. @@ -220,6 +223,7 @@ See [graceful degradation](../../../.pair/knowledge/guidelines/technical-standar - **No board state maps to `Review`** (a minimal board, D4 — a project that reviews on the PR and merges straight to `Done`): **write no state field** in step 7 — membership is still established and confirmed — and report `Board: n-a — no Review state on this board`. The zero-configuration documented skip, **not** an error and not a degraded publish — the readiness signal is the PR itself. - **The direct board write cannot complete** (membership unconfirmable after the add and its one retry — the item writer's Step 7b; or a macrostate no board state can express — its Step 6): report the blocker verbatim on the `Board:` row as `not updated — ` and continue. The reasons are the item writer's, the write is **this skill's own** — it applies those beats by reference, it does not compose them. The PR is published and ready-for-review; a board write that did not happen is **reported, never absorbed into a green publish**, and this skill never HALTs on it (the code-host artifact is the work). - **No `mirror-realign-command` declared**: skip the realignment step and report nothing (Phase 1) — the zero-configuration default for a project with no generated mirrors, **not** a degradation. Never substitute a guessed command, and never a knowledge-base *install* command: installing a published release is a different operation from realigning a working tree, and using one for the other makes the fix depend on what has been published. +- **`mirror-realign-command` names no written trees**: the Phase 1 untracked-files check has nothing to scope itself to and is skipped — the step-4 `removed untracked:` row still names a deleted draft after the fact; an indexed one goes unnoticed. Declaring the trees is one descriptive clause on the same adoption line. - **`/checkpoint` not installed**: gather state from branch + story directly (Phase 0). - **`/write-issue` not installed**: only the **comment-mode back-link** (Phase 4 step 5) is affected — write it directly per the PM tool's implementation guide **and read the item's comments back to confirm it**, or warn with the manual-link instruction. A direct post the read does not show is reported `back-link failed — manual link needed`, **never as posted**: losing the composition must not lose the confirming read with it, or the degraded path becomes the one path that claims a write it never made. **The board write in step 7 is unaffected and still runs in full** (membership → confirming read → state field): it is direct, never a composition, so a missing item writer can never leave the story off the board. Skipping the board write here would re-create #384/#372 — green, ready-for-review, and invisible. - **Nested subagent dispatch unavailable** (Phase 5 — the common case: this skill is itself running in `/implement`'s handoff subagent and the harness forbids a second level): return `Review: review-dispatch-required — /review $pr=` and let the **caller** dispatch (`/implement` Step 3.3). This is the primary path when nested, not a degradation — the review still runs, one frame up, on a clean context. diff --git a/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts b/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts index 807a5dfa7..8cbcd74ca 100644 --- a/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts +++ b/packages/knowledge-hub/src/conformance/mirror-realignment.test.ts @@ -258,6 +258,77 @@ describe('publish-pr realigns mirrors before its gate (#419)', () => { expect(p1).toMatch(/modified or deleted \*\*does\*\* commit by pathspec while unstaged/) }) + it('leaves what the run REMOVED out of the stageable set, and names it with its recover sha', () => { + // Round-6 finding (Major). A `behavior: "mirror"` registry (apps/pair-cli/config.json — + // `knowledge`, `github`, `agents`) makes the target EQUAL to the dataset, so a file only the + // target has is deleted: a contributor's untracked `.pair/knowledge/wip-draft.md` is gone after + // the run, and its `??` entry has DISAPPEARED — which puts it in the step-4 set. MEASURED with + // the real script: `git add .pair/knowledge/wip-draft.md` -> `fatal: pathspec ... did not match + // any files`, exit 128; the staged-new shape (`A ` -> `AD`) passes `git add` (staging the + // removal) and then fails the pathspec commit, exit 1, aborting every genuine regeneration in + // the same set. Phase 1 dies AFTER the destructive run, and the draft is destroyed with no + // report row: `overwrote uncommitted changes in:` fires only on a digest that MOVED, never on an + // entry that vanished — though the `-w` blob exists and `git cat-file -p ` prints it back. + // Executed end to end: regenerate-mirrors.test.ts, 'deletes an uncommitted file under a mirror + // registry — nothing to stage, only the `-w` blob survives'. + const p1 = phase1() + expect(p1).toMatch(/removed untracked: \(recover: git cat-file -p > \)/) + expect(p1).toMatch(/fatal: pathspec '' did not match any files/) + // Both shapes HEAD does not know must be named, or the staged-new one re-opens the hole. + expect(p1).toMatch(/`\?\?` or `A\.`/) + // The exclusion has to reach the pathspec too, not only the `git add`. + expect(p1).toMatch(/neither in `git add ` nor in the pathspec/) + // ...and the row is on the report, next to the overwrite row. + expect(dataset()).toMatch( + /removed untracked: \(recover: git cat-file -p > \)/, + ) + }) + + it('treats a staged set whose cached diff is empty as a no-op, never as a failed commit', () => { + // Round-6 finding (Minor). A path whose dataset render EQUALS HEAD moves its entry when the run + // rewrites it (`M ` -> `MM`; `D ` -> `D ` + `??`; ` M` -> gone), so it is in the set — but after + // `git add` the index equals HEAD for it. MEASURED with the real script over all three shapes + // at once: `git diff --cached --quiet -- a b c` exits 0 and `git commit -m … -- a b c` is + // `nothing to commit, working tree clean`, exit 1 — a recipe with no branch for that aborts + // Phase 1, and the two hand-edits are gone from disk AND index with no report row, because + // they entered the set through the ENTRY comparison, not through a digest moving on an + // unchanged entry. The recover row therefore has to be driven by the digest comparison alone, + // independent of how the path entered the set. Executed end to end: regenerate-mirrors.test.ts, + // 'a non-empty set whose cached diff is empty is a no-op, never a failed commit'. + const p1 = phase1() + expect(p1).toMatch(/`git diff --cached --quiet -- `/) + expect(p1).toMatch(/nothing to commit, working tree clean/) + // Empty ⇒ the no-op branch (silent), but the recover row is emitted regardless of the commit. + expect(p1).toMatch(/whether or not a commit was made/) + // A mixed set commits a SUBSET, so the Verify compares against the cached list, not the set. + expect(p1).toMatch(/`git diff --cached --name-only -- `/) + expect(p1).not.toMatch(/its file list equals that set exactly/) + }) + + it('HALTs before the run over untracked files under the trees the command writes into', () => { + // Round-6 finding (Minor). The `adoption` registry is `behavior: "add"` — a file only the + // target has survives — and the CLI's `generateLlmsTxt` indexes the WHOLE `.pair/adoption/**` + // tree it finds on disk, untracked files included. MEASURED with the real script: untracked + // `.pair/adoption/tech/wip-note.md` -> `.pair/llms.txt` gains + // `- [adoption note](.pair/adoption/tech/wip-note.md)`; under the staging rule the index is + // committed (entry appeared) and the note is not (entry unchanged) — a dangling link, and the + // contributor's private WIP filename in history. Bytes untouched, derived output leaked. The + // same untracked file under a MIRROR registry is deleted instead. Both are avoided by the same + // precondition, checked BEFORE the command runs, when a HALT still costs nothing. Executed end + // to end, remedy included: regenerate-mirrors.test.ts, 'indexes an untracked adoption file into + // the generated llms.txt — stash it before the run'. + const c = dataset() + const p1 = phase1() + expect(p1).toMatch(/generated index such as `llms\.txt`/) + expect(p1).toMatch(/`git stash push -u -- `/) + expect(p1).toMatch(/`git stash pop`/) + // The check is scoped by the trees the adoption names, and skipped when it names none. + expect(c).toMatch(/the trees the command writes into/) + expect(c).toMatch(/names no written trees[\s\S]{0,300}skipped/) + // A HALT condition, listed with the others. + expect(c).toMatch(/\*\*Untracked files under the written trees\*\* \(Phase 1\)/) + }) + it('names the commit a regeneration, never a fix (an overwritten hand-edit was restored)', () => { const p1 = phase1() expect(p1).toMatch(/regenerate mirrors from local dataset/) @@ -330,6 +401,18 @@ describe("this repository's own wiring for the realignment (#419)", () => { expect(wow).toMatch(/\*\*`mirror-realign-command`\*\*: `pnpm mirrors:regenerate`/) }) + it('states that the writer deletes untracked files under mirror registries and indexes them under add ones', () => { + // Round-6 finding (Minor): the bullet lists where the output lands but not that the writer + // reads the WHOLE target tree — so an untracked adoption file is indexed into `.pair/llms.txt`, + // and an untracked knowledge file is deleted. Both measured in regenerate-mirrors.test.ts. + const wow = readFileSync(WAY_OF_WORKING, 'utf-8') + const gates = sectionBetween(wow, '## Quality Gates', '### Review Tier Matrix') + expect(gates).toMatch(/untracked[\s\S]{0,200}`\.pair\/llms\.txt`/) + expect(gates).toMatch(/behavior: "mirror"/) + expect(gates).toMatch(/behavior: "add"/) + expect(gates).toMatch(/git stash push -u/) + }) + it('declares it under Quality Gates — the section publish-pr reads', () => { const wow = readFileSync(WAY_OF_WORKING, 'utf-8') const gates = sectionBetween(wow, '## Quality Gates', '### Review Tier Matrix') diff --git a/scripts/regenerate-mirrors.sh b/scripts/regenerate-mirrors.sh index 004df6a00..8f02098e8 100755 --- a/scripts/regenerate-mirrors.sh +++ b/scripts/regenerate-mirrors.sh @@ -26,6 +26,12 @@ # after they had sat drifted on a green `main`. So in that region drift accumulates # undetected until whichever run of this writer comes next, and lands there. # +# The writer also reads the WHOLE target tree, untracked files included: under a +# `behavior: "mirror"` registry a file only the target has is DELETED (an untracked +# `.pair/knowledge/wip-draft.md` does not survive the run), and under the `add` +# registry it is kept but INDEXED into `.pair/llms.txt`. Stash such files before +# running (`git stash push -u -- `); `/pair-capability-publish-pr` HALTs on them. +# # Two roots, and they are not the same thing: # TOOLCHAIN_ROOT — where this script and the CLI that does the work live. # TARGET_ROOT — the git working tree being realigned (derived from the cwd).