Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 42 additions & 15 deletions .claude/workflows/pair-implement-batch.js

Large diffs are not rendered by default.

136 changes: 132 additions & 4 deletions .claude/workflows/pair-implement-batch.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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' }])
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1002,6 +1030,106 @@ 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('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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Decision: the two atomicity primitives (exclusive create, append) use `node:fs` directly, in leaf modules tested against a real temporary directory

## Date

2026-08-30

## Status

Active

## Category

Convention Adoption

## Context

Story #217's dispatch needs two filesystem operations whose whole value is **atomicity**:

- an **exclusive create** — the per-card lock that guarantees a trigger burst never starts two runs on one card;
- an **append** — the audit trail, whose lines must survive two dispatches writing the same file concurrently.

The project's convention is dependency injection through `FileSystemService`, with an `InMemoryFileSystemService` double instead of mocks. That service exposes neither primitive: `mkdirSync` is modelled in the double as "add the path to a set" (it cannot fail a second create at all), and the only write is `writeFile`, a full overwrite — so an append would have to be read-concat-write, which reintroduces exactly the lost update `O_APPEND` exists to prevent.

Widening `FileSystemService` was the obvious alternative, and it is the one worth stating why we did not take.

## Decision

`card-lock.ts` and `dispatch-audit.ts` call `node:fs` **directly** (`mkdirSync` without `recursive`, `appendFileSync`), and are:

- **leaf modules** — nothing else in the dispatch path touches the filesystem, so the untestable surface is two small files rather than a layer;
- **injected at the call site** — the handler takes a `LockAcquirer` and an `AuditAppender`, so every other test in the run pipeline stays hermetic and none of them touches a real working area;
- **tested against a real temporary directory** (`mkdtempSync`), because the properties under test — a second create fails, two appends both survive — are properties of the real filesystem and of nothing else. There is precedent in this repo: `path-containment.test.ts` tests symlink containment the same way, for the same reason.

The rule generalises: **when the behaviour under test IS an atomicity or containment guarantee of the operating system, test it against the operating system.** A double that cannot fail the way production fails proves nothing, and asserting against it is worse than not asserting — it reads like coverage.

## Alternatives Considered

- **Add `mkdirExclusive`/`appendFile` to `FileSystemService`**: correct in principle, but it widens a package shared by every other story in flight for two callers, and the in-memory double would still have to *simulate* the failure mode — so the double's fidelity, not the filesystem's behaviour, is what the tests would end up asserting. Reconsider when a third caller appears.
- **Read-concat-write the audit through `writeFile`**: loses records when two dispatches on different cards write the same audit file; the per-card lock does not protect a shared file.
- **Lock with `existsSync` + `mkdirSync`**: a check-then-act window, which is the exact race the lock exists to close.

## Consequences

- Two modules in `apps/pair-cli/src/commands/run/` bypass `FileSystemService`, each carrying a comment saying why and pointing here.
- Their tests are slower than the rest of the suite (real I/O in `os.tmpdir()`), and clean up after themselves.
- Handler-level tests inject fakes for both, so the dispatch pipeline remains testable in memory.
- A future third caller for either primitive is the trigger to revisit and put it on `FileSystemService` properly.

## Adoption Impact

- `adoption/tech/way-of-working.md` — Quality Gates section: records the exception to the "avoid mocks, use the in-memory double" convention for OS atomicity/containment guarantees.
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Decision: an empty `--card-tags` means "this card carries no labels", not a malformed flag

## Date

2026-08-30

## Status

Active

## Category

Convention Adoption

## Context

`pair run` refuses every flag passed with an empty value — `--root ""`, `--filter ""`, `--skill ""` all fail at parse time, deliberately: a flag named with nothing behind it is a caller bug, and accepting it silently is how an unattended run ends up doing something nobody asked for.

Story #217's `--card-tags` inherited that rule, and the end-to-end test on a populated board (T5) showed it was the wrong rule for this one flag. The dispatch entry point is called by a **host trigger**, and the reference GitHub adapter renders the labels it observed as `join(github.event.issue.labels.*.name, ',')`. On an issue with **no labels** that expression renders `""`. So the very state AC2 is about — "an issue with no mapped tag runs nothing" — arrived at the parser as an empty value and was rejected with `--card-tags was passed with an empty value`, exit 1.

Two consequences, both bad, and neither visible from inside the module suites (they pass tag lists, not the empty string a host renders):

- the opt-in boundary of the whole feature — untagged ⇒ skipped, reported, exit 0 — became **unreachable through the entry point**;
- the commonest card on any board turned every trigger firing on it into a **failed CI job**, which is the noise that gets a trigger disabled.

## Decision

For `--card-tags`, and only for it, an **empty or whitespace-only value is data**: it is read as the observation "the trigger saw no labels on this card", producing an empty tag list. The dispatcher then does what it does for any card with no mapped tag — skips it, reports the reason, appends the skip to the audit trail, exits `0`.

A **hole inside a list** stays an error: `auto-dev,,risk:green` still HALTs. The two cases are genuinely different. An empty value is a complete observation of an empty set; a hole is an incomplete rendering of a non-empty one — the caller built a list and lost an item, which is exactly the string-interpolation bug worth failing on.

The general rule this instantiates: **a flag that carries an observation from an external system is empty-valid when the empty case is a real state of that system; a flag that carries an operator's intent is not.** `--root`, `--filter` and `--skill` are intent — nobody means "" by them. `--card-tags` is an observation, and "no labels" is a state of every board.

## Alternatives Considered

- **Keep the refusal, make the adapter skip the call when the label list is empty**: pushes an authorization-relevant decision — "should this card run?" — into every per-host adapter, where it is untested, duplicated per host, and free to drift. ADR-024 puts that decision in the routing core precisely so no adapter can widen or narrow it.
- **Keep the refusal, have the adapter pass a sentinel** (`--card-tags "(none)"`): invents a label that could collide with a real one and makes the trail lie about what the trigger saw.
- **Accept empty values on every flag**: loses the guard where it earns its keep — an empty `--root` or `--skill` is a caller bug with no legitimate reading.

## Consequences

- `apps/pair-cli/src/commands/run/parser.ts` reads an empty/whitespace `--card-tags` as an empty tag list; the empty-entry HALT for a hole inside a list is unchanged.
- An unlabelled card now produces the documented skip and exit `0` end-to-end, so a host adapter needs no pre-filter and no conditional call.
- The asymmetry between this flag and its neighbours is deliberate and must stay documented where a reader meets it: the parser module, the CLI reference, and the reference adapter in the KB.

## Adoption Impact

- `adoption/tech/way-of-working.md` — CLI conventions: records that flags carrying an external observation are empty-valid when the empty case is a real state of the observed system, while flags carrying operator intent are not.
Original file line number Diff line number Diff line change
@@ -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 <reviewedHead>...origin/<branch>` 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.
Loading
Loading