@praxisflux/gates 0.60.0 → 0.61.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,6 +18,14 @@
18
18
  //
19
19
  // Contract (Claude Code Stop hook): stdin is JSON with { stop_hook_active, session_id, cwd, … };
20
20
  // exit 0 = allow the model to stop; exit 2 = block, and stderr becomes the message it sees.
21
+ //
22
+ // Root resolution in evaluate() (spec 062), most specific first: explicit { cwd } option ->
23
+ // $CLAUDE_PROJECT_DIR -> input.cwd -> process.cwd(). An explicit argument is a caller naming a
24
+ // directory on purpose and must never be silently overridden by an ambient variable. The env
25
+ // var still outranks input.cwd: it's the harness's authoritative statement of the project root,
26
+ // while input.cwd is merely where the hook happened to fire (a worktree, a subdir, wherever the
27
+ // session sat). The only production caller, runStopHook below, passes no cwd, so this ordering
28
+ // is byte-identical to before for the real Stop-hook path.
21
29
 
22
30
  /** Read all of stdin as a string (empty string on a TTY, so it's safe to run by hand). */
23
31
  export function readStdin() {
@@ -34,9 +42,9 @@ export function readStdin() {
34
42
  * Pure core: given the parsed hook input and the gates, return { block, message }.
35
43
  * Separated from process I/O so it is unit-testable.
36
44
  */
37
- export function evaluate(input, gates, { cwd = process.cwd() } = {}) {
45
+ export function evaluate(input, gates, { cwd = undefined } = {}) {
38
46
  if (input && input.stop_hook_active === true) return { block: false, message: "", warnings: "" };
39
- const start = process.env.CLAUDE_PROJECT_DIR || (input && input.cwd) || cwd;
47
+ const start = cwd || process.env.CLAUDE_PROJECT_DIR || (input && input.cwd) || process.cwd();
40
48
  const ctx = {
41
49
  sessionId: (input && input.session_id) || process.env.CLAUDE_CODE_SESSION_ID || null,
42
50
  input: input || {},
@@ -18,6 +18,14 @@
18
18
  //
19
19
  // Contract (Claude Code Stop hook): stdin is JSON with { stop_hook_active, session_id, cwd, … };
20
20
  // exit 0 = allow the model to stop; exit 2 = block, and stderr becomes the message it sees.
21
+ //
22
+ // Root resolution in evaluate() (spec 062), most specific first: explicit { cwd } option ->
23
+ // $CLAUDE_PROJECT_DIR -> input.cwd -> process.cwd(). An explicit argument is a caller naming a
24
+ // directory on purpose and must never be silently overridden by an ambient variable. The env
25
+ // var still outranks input.cwd: it's the harness's authoritative statement of the project root,
26
+ // while input.cwd is merely where the hook happened to fire (a worktree, a subdir, wherever the
27
+ // session sat). The only production caller, runStopHook below, passes no cwd, so this ordering
28
+ // is byte-identical to before for the real Stop-hook path.
21
29
 
22
30
  /** Read all of stdin as a string (empty string on a TTY, so it's safe to run by hand). */
23
31
  export function readStdin() {
@@ -34,9 +42,9 @@ export function readStdin() {
34
42
  * Pure core: given the parsed hook input and the gates, return { block, message }.
35
43
  * Separated from process I/O so it is unit-testable.
36
44
  */
37
- export function evaluate(input, gates, { cwd = process.cwd() } = {}) {
45
+ export function evaluate(input, gates, { cwd = undefined } = {}) {
38
46
  if (input && input.stop_hook_active === true) return { block: false, message: "", warnings: "" };
39
- const start = process.env.CLAUDE_PROJECT_DIR || (input && input.cwd) || cwd;
47
+ const start = cwd || process.env.CLAUDE_PROJECT_DIR || (input && input.cwd) || process.cwd();
40
48
  const ctx = {
41
49
  sessionId: (input && input.session_id) || process.env.CLAUDE_CODE_SESSION_ID || null,
42
50
  input: input || {},
@@ -18,6 +18,14 @@
18
18
  //
19
19
  // Contract (Claude Code Stop hook): stdin is JSON with { stop_hook_active, session_id, cwd, … };
20
20
  // exit 0 = allow the model to stop; exit 2 = block, and stderr becomes the message it sees.
21
+ //
22
+ // Root resolution in evaluate() (spec 062), most specific first: explicit { cwd } option ->
23
+ // $CLAUDE_PROJECT_DIR -> input.cwd -> process.cwd(). An explicit argument is a caller naming a
24
+ // directory on purpose and must never be silently overridden by an ambient variable. The env
25
+ // var still outranks input.cwd: it's the harness's authoritative statement of the project root,
26
+ // while input.cwd is merely where the hook happened to fire (a worktree, a subdir, wherever the
27
+ // session sat). The only production caller, runStopHook below, passes no cwd, so this ordering
28
+ // is byte-identical to before for the real Stop-hook path.
21
29
 
22
30
  /** Read all of stdin as a string (empty string on a TTY, so it's safe to run by hand). */
23
31
  export function readStdin() {
@@ -34,9 +42,9 @@ export function readStdin() {
34
42
  * Pure core: given the parsed hook input and the gates, return { block, message }.
35
43
  * Separated from process I/O so it is unit-testable.
36
44
  */
37
- export function evaluate(input, gates, { cwd = process.cwd() } = {}) {
45
+ export function evaluate(input, gates, { cwd = undefined } = {}) {
38
46
  if (input && input.stop_hook_active === true) return { block: false, message: "", warnings: "" };
39
- const start = process.env.CLAUDE_PROJECT_DIR || (input && input.cwd) || cwd;
47
+ const start = cwd || process.env.CLAUDE_PROJECT_DIR || (input && input.cwd) || process.cwd();
40
48
  const ctx = {
41
49
  sessionId: (input && input.session_id) || process.env.CLAUDE_CODE_SESSION_ID || null,
42
50
  input: input || {},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@praxisflux/gates",
3
- "version": "0.60.0",
3
+ "version": "0.61.1",
4
4
  "description": "praxisflux gate checks as a zero-dependency CLI (spec-bridge, wiki-freshness, course) — status can't exceed proven artifacts",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -18,9 +18,10 @@
18
18
  // derivation-stage ladder against the board's own status names. Absent that config, every
19
19
  // path below behaves exactly as described above.
20
20
 
21
- import { existsSync, readFileSync } from "node:fs";
21
+ import { existsSync, readFileSync, appendFileSync } from "node:fs";
22
22
  import { join } from "node:path";
23
23
  import { spawnSync } from "node:child_process";
24
+ import { tmpdir } from "node:os";
24
25
  import { deriveSpecState, STATUS, STAGE, STAGES } from "../lib/spec-derive.mjs";
25
26
  import { hasAnyChild, findRootsDownwards } from "../lib/project-root.mjs";
26
27
  import { parseLinkedTask, findLinkedTasks, readMirror, providers, mirrorStaleness } from "../lib/board-mirror.mjs";
@@ -165,8 +166,14 @@ export const GATE_TIMEOUT_MS = 120000;
165
166
  * is the gate-runner contract applied one level down — a crash is a blocking problem, not a
166
167
  * silent pass). Sets SPEC_BRIDGE_GATE_ACTIVE on the child env so a declared command that itself
167
168
  * invokes the bridge short-circuits instead of recursing (see checkBridge/verifyBridge).
169
+ *
170
+ * `trace` (spec 061 R4) is an optional callback invoked with the RAW outcome — { command, cwd,
171
+ * status, signal, stdout, stderr, error } — before it's classified into the shape above. It
172
+ * exists solely for opt-in instrumentation (see `bridgeGate.check`); omitted (the default for
173
+ * every existing caller and every test), it costs one falsy check, never a syscall. A throwing
174
+ * `trace` is swallowed here too — instrumentation must never affect this function's verdict.
168
175
  */
169
- export function runGateCommand(command, { cwd, timeoutMs = GATE_TIMEOUT_MS, spawn = spawnSync } = {}) {
176
+ export function runGateCommand(command, { cwd, timeoutMs = GATE_TIMEOUT_MS, spawn = spawnSync, trace } = {}) {
170
177
  let res;
171
178
  try {
172
179
  res = spawn(command[0], command.slice(1), {
@@ -174,8 +181,19 @@ export function runGateCommand(command, { cwd, timeoutMs = GATE_TIMEOUT_MS, spaw
174
181
  env: { ...process.env, SPEC_BRIDGE_GATE_ACTIVE: "1" },
175
182
  });
176
183
  } catch (e) {
184
+ if (trace) try { trace({ command, cwd, status: null, signal: null, stdout: "", stderr: "", error: e.code || e.message }); } catch { /* swallowed */ }
177
185
  return { ok: false, kind: "error", reason: e.code || e.message };
178
186
  }
187
+ if (trace) {
188
+ try {
189
+ trace({
190
+ command, cwd,
191
+ status: res.status ?? null, signal: res.signal ?? null,
192
+ stdout: res.stdout || "", stderr: res.stderr || "",
193
+ error: res.error ? (res.error.code || res.error.message) : null,
194
+ });
195
+ } catch { /* swallowed: instrumentation must never affect the verdict */ }
196
+ }
179
197
  if (res.error) {
180
198
  if (res.error.code === "ETIMEDOUT") return { ok: false, kind: "timeout", timeoutMs };
181
199
  return { ok: false, kind: "error", reason: res.error.code || res.error.message };
@@ -185,6 +203,92 @@ export function runGateCommand(command, { cwd, timeoutMs = GATE_TIMEOUT_MS, spaw
185
203
  return { ok: false, kind: "red", reason: `exited ${res.status}` };
186
204
  }
187
205
 
206
+ /**
207
+ * Whether <root>'s working tree has uncommitted changes (spec 061 R2), via `git status
208
+ * --porcelain` — non-empty stdout = dirty. **Fail closed**: any failure to determine (nonzero
209
+ * exit, spawn error, git absent) => `false` (treated clean, gate keeps blocking) — an
210
+ * undeterminable condition must never silently disarm the gate, the same posture
211
+ * `runGateCommand` takes for a command that cannot run. Argv only, shell:false, cwd = root —
212
+ * this reads the same tree a gate command would run against, never the CWD it happened to be
213
+ * invoked from.
214
+ */
215
+ export function isTreeDirty(root, { spawn = spawnSync } = {}) {
216
+ let res;
217
+ try {
218
+ res = spawn("git", ["status", "--porcelain"], { cwd: root, encoding: "utf8", shell: false });
219
+ } catch {
220
+ return false;
221
+ }
222
+ if (res.error || res.status !== 0) return false;
223
+ return typeof res.stdout === "string" && res.stdout.trim().length > 0;
224
+ }
225
+
226
+ /**
227
+ * Clause appended to a collapsed gate finding sampled against a dirty tree (spec 061 R2): the
228
+ * mirror image of this repo's own F6 finding ("a gate run against a dirty working tree proves
229
+ * nothing about the commit"), recorded there for false greens and here producing false reds.
230
+ * Rounds 1/2/4 of this repo's own fan-out chase were exactly this — an implementer's
231
+ * uncommitted mid-dispatch edits, sampled and misread as a broken commit. The verdict is not
232
+ * dropped (silence would hide a genuine red) but it must not block.
233
+ */
234
+ const DIRTY_TREE_CLAUSE =
235
+ " This verdict was sampled against a DIRTY working tree, not a commit — it proves nothing " +
236
+ "about the commit and must not block. Commit or stash the tree, then re-run for a real verdict.";
237
+
238
+ /* ── R4 instrumentation: opt-in record of what a Stop invocation actually saw ────────────────
239
+ *
240
+ * Rounds 5-7 of this repo's own fan-out chase were unreproduced (see the card's elimination
241
+ * list). Round 7's candidate mechanism — an orphaned worktree tree resolving as a SECOND root —
242
+ * can only be settled by seeing, from a real Stop invocation, exactly which roots resolveRoots
243
+ * returned and what each gate command actually did. This is that instrumentation:
244
+ *
245
+ * - OFF by default: `SPEC_BRIDGE_GATE_TRACE` unset ⇒ `tracePath()` returns null ⇒ every call
246
+ * site below is a single falsy check, never a write, never a spawn.
247
+ * - Append-only JSONL, OUTSIDE the tracked tree: `$CLAUDE_JOB_DIR` if set (this repo's own
248
+ * scratch convention), else the OS temp dir — never inside the repo, or a Stop hook writing
249
+ * it would dirty the very tree Phase 2's dirty-tree check reads.
250
+ * - Verdict-neutral: every write is wrapped in try/catch. A trace failure is swallowed and
251
+ * can never turn a green gate red or vice versa.
252
+ *
253
+ * Wired ONLY into `bridgeGate.check` (the real Stop-hook path) — not into `checkBridge` callers
254
+ * generally and not into `verifyBridge` — because R4 is about diagnosing Stop-time firings, and
255
+ * an injected `run` (every test) never touches `runGateCommand`, so tests are unaffected whether
256
+ * or not the env var happens to be set.
257
+ */
258
+
259
+ /** Truthy env var → the JSONL path to append to. `"1"`/`"true"` mean "use the default scratch
260
+ * location"; any other value is used as an explicit path override. Unset ⇒ null (off). */
261
+ function tracePath() {
262
+ const v = process.env.SPEC_BRIDGE_GATE_TRACE;
263
+ if (!v) return null;
264
+ if (v === "1" || v === "true") return join(process.env.CLAUDE_JOB_DIR || tmpdir(), "spec-bridge-gate-trace.jsonl");
265
+ return v;
266
+ }
267
+
268
+ const TRACE_CAP = 4000; // bound stdout/stderr — the `tests` gate's own output is large
269
+
270
+ /** Bound a captured stream to TRACE_CAP chars, noting how much was cut. */
271
+ function capTrace(s) {
272
+ if (typeof s !== "string" || s.length <= TRACE_CAP) return s;
273
+ return s.slice(0, TRACE_CAP) + `…[${s.length - TRACE_CAP} more bytes truncated]`;
274
+ }
275
+
276
+ /** Append one JSONL record for this bridgeGate.check() invocation. Never throws — a write
277
+ * failure (unwritable path, ENOENT, disk full) is swallowed, never affecting the gate verdict. */
278
+ function traceGateRun(root, roots, commandRecords) {
279
+ const path = tracePath();
280
+ if (!path) return;
281
+ try {
282
+ const record = {
283
+ ts: new Date().toISOString(),
284
+ root,
285
+ roots,
286
+ commands: commandRecords.map((r) => ({ ...r, stdout: capTrace(r.stdout), stderr: capTrace(r.stderr) })),
287
+ };
288
+ appendFileSync(path, JSON.stringify(record) + "\n");
289
+ } catch { /* verdict-neutral: instrumentation failure is swallowed */ }
290
+ }
291
+
188
292
  /** The last ticked box in document order — the tick that (in sequence) claimed the most, and so
189
293
  * the box a red gate most directly stands over. Null when nothing is ticked. */
190
294
  function lastTickedBox(phaseBoxes) {
@@ -228,14 +332,16 @@ function gateReason(result) {
228
332
  return `is red (${result.reason})`;
229
333
  }
230
334
 
335
+ /** Shared tail clause for every project-gate finding, per-spec or collapsed alike. */
336
+ const CANT_OUTRUN = "A ticked tasks.md checkbox cannot outrun a red project gate — make the gate pass or set the box back.";
337
+
231
338
  /** The blocking finding: names the phase, the box, and the failing gate (spec 050 AC #1). */
232
339
  function projectGateProblem({ id, specDir, witness, gate, result }) {
233
340
  const where = witness
234
341
  ? `phase "${witness.phase}", box "${witness.box}" is ticked, but `
235
342
  : "a ticked box stands over a gate that ";
236
343
  const label = gate.bucket === "redByConstruction" ? "red-by-construction gate" : "required gate";
237
- return `[spec-bridge] ${id} · ${specDir}: ${where}the ${label} "${gate.name}" ${gateReason(result)}. ` +
238
- `A ticked tasks.md checkbox cannot outrun a red project gate — make the gate pass or set the box back.`;
344
+ return `[spec-bridge] ${id} · ${specDir}: ${where}the ${label} "${gate.name}" ${gateReason(result)}. ${CANT_OUTRUN}`;
239
345
  }
240
346
 
241
347
  /**
@@ -244,6 +350,12 @@ function projectGateProblem({ id, specDir, witness, gate, result }) {
244
350
  * gate list (the caller picks the buckets — the two entry points differ there); `run` executes one
245
351
  * command and returns runGateCommand's shape (injected so tests need no subprocess). The witness box
246
352
  * is computed once from phaseBoxes so every finding names it.
353
+ *
354
+ * NOTE (spec 061 R1): `checkBridge`/`verifyBridge` no longer call this directly — one red project
355
+ * gate now yields ONE collapsed finding per invocation naming the gate and the affected count
356
+ * (see `collapsedGateProblems` below), not one finding per linked spec. This function remains the
357
+ * pure per-spec evaluator: exported for direct testing of the per-gate/per-spec decision logic,
358
+ * and available to any caller that wants the per-spec witness view (e.g. `cli.mjs state <specDir>`).
247
359
  */
248
360
  export function evaluateProjectGates({ id, specDir, phaseBoxes }, gates, run) {
249
361
  const witness = lastTickedBox(phaseBoxes);
@@ -256,6 +368,31 @@ export function evaluateProjectGates({ id, specDir, phaseBoxes }, gates, run) {
256
368
  return problems;
257
369
  }
258
370
 
371
+ /**
372
+ * Collapsed evaluator (spec 061 R1): run each distinct gate ONCE (via the memoized `runOne`) and
373
+ * return at most one finding per non-green gate, naming the gate + bucket + `gateReason` + how
374
+ * many qualifying specs are affected — never enumerating them. `counts` is `{ required,
375
+ * redByConstruction }`, the number of specs the caller has already determined are held to each
376
+ * bucket (checkBridge: every Done-eligible spec, held to both; verifyBridge: every ticked spec
377
+ * for `required`, only the Done-eligible ones for `redByConstruction` — the bucket asymmetry).
378
+ * A gate whose bucket count is 0 is never even run — no spec is holding it, so nothing changes
379
+ * about WHICH gates run for WHICH specs, only how a non-green one is reported.
380
+ */
381
+ function collapsedGateProblems(gates, counts, runOne) {
382
+ const problems = [];
383
+ for (const gate of gates) {
384
+ const count = counts[gate.bucket] ?? 0;
385
+ if (count === 0) continue;
386
+ const result = runOne(gate.command);
387
+ if (result.ok) continue;
388
+ const label = gate.bucket === "redByConstruction" ? "red-by-construction gate" : "required gate";
389
+ problems.push(
390
+ `[spec-bridge] the ${label} "${gate.name}" ${gateReason(result)} — ${count} linked spec${count === 1 ? "" : "s"} affected. ${CANT_OUTRUN}`
391
+ );
392
+ }
393
+ return problems;
394
+ }
395
+
259
396
  const RANK = { "to do": 0, "in progress": 1, done: 2 };
260
397
  const DERIVED_RANK = { [STATUS.TODO]: 0, [STATUS.IN_PROGRESS]: 1, [STATUS.DONE_ELIGIBLE]: 2 };
261
398
 
@@ -343,10 +480,30 @@ function shortfall(root, specDir, derived) {
343
480
  /**
344
481
  * Judge every linked task under <root>. Returns:
345
482
  * links — [{ id, status, specDir, derived, verdict }]
346
- * problems — blocking messages, one per "exceeds"
347
- * warningsnon-blocking messages, one per "lags"
483
+ * problems — blocking messages, one per "exceeds", plus one per non-green declared project
484
+ * gate (spec 061 R1) UNLESS the tree is dirty, in which case that gate finding
485
+ * moves to `warnings`, labeled (spec 061 R2): a verdict sampled against an
486
+ * uncommitted tree proves nothing about the commit and must not block.
487
+ * warnings — non-blocking messages: one per "lags", plus any dirty-tree-labeled gate finding.
488
+ * `isDirty(root)` (default `isTreeDirty`) is injectable for tests, same pattern as `run`.
489
+ * `trace` (spec 061 R4, opt-in instrumentation): a callback forwarded to the DEFAULT gate
490
+ * runner's `runGateCommand` calls only — an injected `run` (every test) never reaches it, so
491
+ * tests are unaffected whether or not it's provided.
492
+ *
493
+ * `gateActive` (spec 061 Phase 3b, T027): the reentrancy signal itself, injectable with a real
494
+ * default — `process.env.SPEC_BRIDGE_GATE_ACTIVE === "1"` — same shape as `run`/`isDirty`. Every
495
+ * real caller (cli.mjs, bridgeGate's production wiring) leaves it at the default, so the actual
496
+ * env is still what decides for them; only a caller that explicitly passes `gateActive: false`
497
+ * (a test-owned invocation, e.g. `bridgeGate.check(root, { gateActive: false })`) can make this
498
+ * DEFAULT runner execute gates while an ambient SPEC_BRIDGE_GATE_ACTIVE=1 is set. This is what
499
+ * lets `bridgeGate` tests (below) stay honest when this repo's own dogfood `tests` gate — bare
500
+ * `node --test` — runs the whole suite as a child of a real gate spawn: those tests opt out of
501
+ * the flag explicitly; nothing else does, so real recursive spawning is stopped exactly as before.
348
502
  */
349
- export function checkBridge(root, { runGates = true, run } = {}) {
503
+ export function checkBridge(root, {
504
+ runGates = true, run, isDirty = isTreeDirty, trace,
505
+ gateActive = process.env.SPEC_BRIDGE_GATE_ACTIVE === "1",
506
+ } = {}) {
350
507
  const links = [];
351
508
  const problems = [];
352
509
  const warnings = [];
@@ -403,8 +560,9 @@ export function checkBridge(root, { runGates = true, run } = {}) {
403
560
  // Without this bypass the bridge's own dogfood reddens its `tests` gate: `node --test` runs the
404
561
  // Phase-3 suite with the flag set, and every injected-run test there fail-closes to [].
405
562
  const injected = run !== undefined;
406
- const execGates = runGates && !!gatesProfile && (injected || process.env.SPEC_BRIDGE_GATE_ACTIVE !== "1");
407
- const runOne = memoizeRun(run || ((command) => runGateCommand(command, { cwd: root })));
563
+ const execGates = runGates && !!gatesProfile && (injected || !gateActive);
564
+ const runOne = memoizeRun(run || ((command) => runGateCommand(command, { cwd: root, trace })));
565
+ let doneEligibleCount = 0; // spec 061 R1: gate findings collapse to one-per-gate after the loop
408
566
  for (const task of boardLinks(root)) {
409
567
  const derived = deriveSpecState(join(root, task.specDir), { requireAnalysis });
410
568
  // Opted-in boards are judged on the stage ladder against their own status names;
@@ -442,18 +600,26 @@ export function checkBridge(root, { runGates = true, run } = {}) {
442
600
  );
443
601
  }
444
602
 
445
- // Project-gate check (spec 050 R4): execute declared gates ONLY when the spec is
603
+ // Project-gate check (spec 050 R4): a spec is held to its declared gates ONLY when
446
604
  // Done-eligible — the one bounded moment a red gate under a ticked box changes an outcome,
447
605
  // so ordinary turns pay zero subprocess cost. At Done-eligible the mid-PR window has closed
448
606
  // (every box, including the re-pin box, is ticked), so BOTH buckets must be green: required,
449
607
  // AND redByConstruction — its "allowed red mid-PR" license has expired now that its re-pin
450
- // was claimed done. Any red / unrunnable / timed-out gate is a blocking finding.
451
- if (execGates && derived.status === STATUS.DONE_ELIGIBLE) {
452
- const gates = gatesFor(gatesProfile, ["required", "redByConstruction"]);
453
- problems.push(
454
- ...evaluateProjectGates(
455
- { id: task.id, specDir: task.specDir, phaseBoxes: derived.phaseBoxes }, gates, runOne)
456
- );
608
+ // was claimed done. Tally the count here; the gates themselves run once, after the loop
609
+ // (spec 061 R1: one finding per red gate, not one per linked spec).
610
+ if (execGates && derived.status === STATUS.DONE_ELIGIBLE) doneEligibleCount++;
611
+ }
612
+ if (doneEligibleCount > 0) {
613
+ const gates = gatesFor(gatesProfile, ["required", "redByConstruction"]);
614
+ const counts = { required: doneEligibleCount, redByConstruction: doneEligibleCount };
615
+ const found = collapsedGateProblems(gates, counts, runOne);
616
+ if (found.length > 0) {
617
+ // spec 061 R2: dirtiness is computed ONCE per invocation (T009) — only reached at all
618
+ // when there is at least one non-green gate to label. A dirty tree routes the finding
619
+ // into `warnings` (non-blocking, labeled); clean or undeterminable (fail-closed) keeps
620
+ // it in `problems`, unchanged from today.
621
+ if (isDirty(root)) warnings.push(...found.map((p) => p + DIRTY_TREE_CLAUSE));
622
+ else problems.push(...found);
457
623
  }
458
624
  }
459
625
  return { links, problems, warnings };
@@ -464,37 +630,48 @@ export function checkBridge(root, { runGates = true, run } = {}) {
464
630
  * linked spec that has at least one ticked box — a box claiming greenness — run the declared
465
631
  * gates and return the blocking findings. A Done-eligible spec is held to BOTH buckets (as the
466
632
  * Stop hook does); a mid-PR spec to `required` only, because redByConstruction gates are
467
- * legitimately red between a source edit and its re-pin commit. Shares evaluateProjectGates, so
468
- * it and the Stop hook agree by construction. Read-only like the rest of gates/: it runs the
469
- * host's declared subprocesses but writes nothing itself. Injectable `run` for tests.
633
+ * legitimately red between a source edit and its re-pin commit. Shares collapsedGateProblems
634
+ * (spec 061 R1), so it and the Stop hook agree by construction. Read-only like the rest of
635
+ * gates/: it runs the host's declared subprocesses but writes nothing itself. Injectable `run`
636
+ * for tests.
637
+ *
638
+ * Returns `{ problems, warnings }` (spec 061 T018a — this used to be a flat `problems` array;
639
+ * `verify` is the mid-PR entry point, exactly the window where a working tree is dirtiest, so
640
+ * R2's "a non-green project gate must not block on a dirty-tree sample" applies here too, not
641
+ * only to `checkBridge`. A dirty-tree gate finding is never dropped — it moves to `warnings`,
642
+ * still labeled, never silently disappearing. `isDirty(root)` (default `isTreeDirty`) is
643
+ * injectable, same pattern as `checkBridge`.
470
644
  */
471
- export function verifyBridge(root, { run } = {}) {
472
- const problems = [];
645
+ export function verifyBridge(root, { run, isDirty = isTreeDirty } = {}) {
473
646
  const config = loadBridgeConfig(root);
474
647
  const gatesProfile = projectGatesProfile(config);
475
- if (!gatesProfile) return problems; // no opt-in → nothing to do
648
+ if (!gatesProfile) return { problems: [], warnings: [] }; // no opt-in → nothing to do
476
649
  // Reentrancy guard (spec 050 defect 1, Phase 5): a spawned gate command that re-invokes the
477
650
  // bridge with the DEFAULT runner short-circuits so it can't fork forever; an injected `run` is
478
651
  // a test double that spawns nothing, so it bypasses the guard.
479
652
  const injected = run !== undefined;
480
- if (!injected && process.env.SPEC_BRIDGE_GATE_ACTIVE === "1") return problems;
653
+ if (!injected && process.env.SPEC_BRIDGE_GATE_ACTIVE === "1") return { problems: [], warnings: [] };
481
654
  const requireAnalysis = config.strictDone === true;
482
655
  // Share each distinct gate result across every spec this invocation checks (spec 050 defect 2).
483
656
  const runOne = memoizeRun(run || ((command) => runGateCommand(command, { cwd: root })));
657
+ // Tally counts per bucket (spec 061 R1/R4): `required` covers every ticked spec; `redByConstruction`
658
+ // only the ticked specs that are ALSO Done-eligible — the bucket asymmetry, preserved exactly as
659
+ // before (which gates run for which specs is unchanged; only the reporting collapses).
660
+ const counts = { required: 0, redByConstruction: 0 };
484
661
  for (const task of boardLinks(root)) {
485
662
  const derived = deriveSpecState(join(root, task.specDir), { requireAnalysis });
486
663
  const anyTicked = (derived.phaseBoxes || []).some((p) => (p.boxes || []).some((b) => b.checked));
487
664
  if (!anyTicked) continue; // nothing claims greenness yet — no tick to outrun a gate
488
- const buckets = derived.status === STATUS.DONE_ELIGIBLE
489
- ? ["required", "redByConstruction"]
490
- : ["required"];
491
- const gates = gatesFor(gatesProfile, buckets);
492
- problems.push(
493
- ...evaluateProjectGates(
494
- { id: task.id, specDir: task.specDir, phaseBoxes: derived.phaseBoxes }, gates, runOne)
495
- );
665
+ counts.required += 1;
666
+ if (derived.status === STATUS.DONE_ELIGIBLE) counts.redByConstruction += 1;
496
667
  }
497
- return problems;
668
+ const gates = gatesFor(gatesProfile, ["required", "redByConstruction"]);
669
+ const found = collapsedGateProblems(gates, counts, runOne);
670
+ if (found.length === 0) return { problems: [], warnings: [] };
671
+ // spec 061 T018a: same dirty-tree routing as checkBridge — labeled and non-blocking, never
672
+ // silently dropped. Computed only once found.length > 0, matching checkBridge's shape.
673
+ if (isDirty(root)) return { problems: [], warnings: found.map((p) => p + DIRTY_TREE_CLAUSE) };
674
+ return { problems: found, warnings: [] };
498
675
  }
499
676
 
500
677
  /* ── plan: reconciliation intents, and the Backlog renderer for them ───── */
@@ -656,17 +833,59 @@ export function planBridge(root) {
656
833
  };
657
834
  }
658
835
 
836
+ // spec 061 R2/T011: `bridgeGate.warn` deliberately calls `checkBridge(root, { runGates: false })`
837
+ // so a Stop pays for the gate subprocesses once, not twice (the cost regression spec 050
838
+ // fixed). A dirty-tree gate warning is only knowable from the runGates:true pass, so `check`
839
+ // stashes it here, keyed by root, for `warn` to read-and-clear on the very next call for that
840
+ // same root (lib/gate-runner.mjs calls check(root) then warn(root) in that order, per root, in
841
+ // one evaluate() pass — see gate-runner.mjs's evaluate loop). No second gate run, ever.
842
+ const dirtyTreeGateWarningsByRoot = new Map();
843
+
844
+ // spec 061 R4: the full roots list from the MOST RECENT resolveRoots() call, read (not
845
+ // consumed) by check() so its trace record can show what resolveRoots returned this
846
+ // invocation — round 7's candidate mechanism (an orphaned worktree resolving as a second
847
+ // root) is only visible in that full list, not in the single root check() is handed.
848
+ let lastResolvedRootsForTrace = null;
849
+
659
850
  /**
660
851
  * The Stop-hook gate, in gate-runner shape. Roots are directories holding a backlog/ dir;
661
852
  * a root with no linked tasks yields no problems, so the gate is a natural no-op outside
662
853
  * bridged projects. "exceeds" blocks; "lags" only warns.
854
+ *
855
+ * `check`/`warn` take an optional second arg beyond gate-runner's own `(root, ctx)` call —
856
+ * gate-runner's `ctx` is always `{ sessionId, input }` (spec 061 Phase 3b: neither key is
857
+ * named `gateActive`), so a real invocation's `opts.gateActive` is always `undefined` and
858
+ * `checkBridge`'s own default (the real env read) decides, exactly as before. Only a test that
859
+ * explicitly calls e.g. `bridgeGate.check(root, { gateActive: false })` can force the DEFAULT
860
+ * runner to execute under an ambient SPEC_BRIDGE_GATE_ACTIVE=1 — see `checkBridge`'s `gateActive`
861
+ * doc above for why that can't be reached by anything spawned as a real gate command.
663
862
  */
664
863
  export const bridgeGate = {
665
864
  name: "spec-bridge",
666
- resolveRoots: (startDir) => findRootsDownwards(startDir, hasAnyChild(".board", "backlog")),
865
+ resolveRoots: (startDir) => {
866
+ const roots = findRootsDownwards(startDir, hasAnyChild(".board", "backlog"));
867
+ lastResolvedRootsForTrace = roots;
868
+ return roots;
869
+ },
667
870
  // The runner calls check() then warn() per root; run the (possibly costly) project-gate
668
871
  // commands only in check so a Stop pays for them once, not twice. Warnings never depend on
669
- // gate execution, so runGates:false loses nothing.
670
- check: (root) => checkBridge(root, { runGates: true }).problems,
671
- warn: (root) => checkBridge(root, { runGates: false }).warnings,
872
+ // gate execution, so runGates:false loses nothing — except the dirty-tree gate label (spec
873
+ // 061 R2), which check() computed for free as part of running the gates and stashes below.
874
+ check: (root, opts = {}) => {
875
+ const path = tracePath(); // spec 061 R4: null unless SPEC_BRIDGE_GATE_TRACE is set
876
+ const commandRecords = path ? [] : null;
877
+ const { problems, warnings } = checkBridge(root, {
878
+ runGates: true,
879
+ trace: commandRecords ? (rec) => commandRecords.push(rec) : undefined,
880
+ gateActive: opts.gateActive,
881
+ });
882
+ dirtyTreeGateWarningsByRoot.set(root, warnings.filter((w) => w.includes(DIRTY_TREE_CLAUSE)));
883
+ if (commandRecords) traceGateRun(root, lastResolvedRootsForTrace, commandRecords);
884
+ return problems;
885
+ },
886
+ warn: (root, opts = {}) => {
887
+ const stashed = dirtyTreeGateWarningsByRoot.get(root) || [];
888
+ dirtyTreeGateWarningsByRoot.delete(root);
889
+ return [...checkBridge(root, { runGates: false, gateActive: opts.gateActive }).warnings, ...stashed];
890
+ },
672
891
  };
@@ -41,7 +41,10 @@ if (cmd === "state") {
41
41
  }
42
42
  console.log(`spec-bridge ok: ${links.length} linked task(s), none exceed their artifacts`);
43
43
  } else if (cmd === "verify") {
44
- const problems = verifyBridge(target);
44
+ // spec 061 T018a: verifyBridge now returns { problems, warnings } — a dirty-tree gate finding
45
+ // is never dropped, only routed to warnings (printed, non-blocking) instead of problems.
46
+ const { problems, warnings } = verifyBridge(target);
47
+ for (const w of warnings) console.log(`warn: ${w}`);
45
48
  if (problems.length) {
46
49
  console.log(`\nGATE FAILED (${problems.length} issue(s)):`);
47
50
  for (const p of problems) console.log(` - ${p}`);
@@ -18,6 +18,14 @@
18
18
  //
19
19
  // Contract (Claude Code Stop hook): stdin is JSON with { stop_hook_active, session_id, cwd, … };
20
20
  // exit 0 = allow the model to stop; exit 2 = block, and stderr becomes the message it sees.
21
+ //
22
+ // Root resolution in evaluate() (spec 062), most specific first: explicit { cwd } option ->
23
+ // $CLAUDE_PROJECT_DIR -> input.cwd -> process.cwd(). An explicit argument is a caller naming a
24
+ // directory on purpose and must never be silently overridden by an ambient variable. The env
25
+ // var still outranks input.cwd: it's the harness's authoritative statement of the project root,
26
+ // while input.cwd is merely where the hook happened to fire (a worktree, a subdir, wherever the
27
+ // session sat). The only production caller, runStopHook below, passes no cwd, so this ordering
28
+ // is byte-identical to before for the real Stop-hook path.
21
29
 
22
30
  /** Read all of stdin as a string (empty string on a TTY, so it's safe to run by hand). */
23
31
  export function readStdin() {
@@ -34,9 +42,9 @@ export function readStdin() {
34
42
  * Pure core: given the parsed hook input and the gates, return { block, message }.
35
43
  * Separated from process I/O so it is unit-testable.
36
44
  */
37
- export function evaluate(input, gates, { cwd = process.cwd() } = {}) {
45
+ export function evaluate(input, gates, { cwd = undefined } = {}) {
38
46
  if (input && input.stop_hook_active === true) return { block: false, message: "", warnings: "" };
39
- const start = process.env.CLAUDE_PROJECT_DIR || (input && input.cwd) || cwd;
47
+ const start = cwd || process.env.CLAUDE_PROJECT_DIR || (input && input.cwd) || process.cwd();
40
48
  const ctx = {
41
49
  sessionId: (input && input.session_id) || process.env.CLAUDE_CODE_SESSION_ID || null,
42
50
  input: input || {},