@praxisflux/gates 0.53.0 → 0.55.0

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.
@@ -58,7 +58,12 @@ export function coarseStatus(stage) {
58
58
  }
59
59
 
60
60
  const PHASE_HEADING = /^##\s+(.+?)\s*$/;
61
- const TASK_LINE = /^\s*[-*]\s+\[([ xX])\]\s+\S/;
61
+ // Captures the checkbox char AND the box's trailing descriptive text. The matched-line SET is
62
+ // identical to the old `/^\s*[-*]\s+\[([ xX])\]\s+\S/` (both require a non-space after the box;
63
+ // per-line matching, so `.*?\s*$` always closes over the remainder) — this only adds capture
64
+ // group 2, so parseTasks() output is byte-identical. The text feeds spec 050's blocking message
65
+ // (AC #1: name the phase, the box, and the failing gate).
66
+ const TASK_LINE = /^\s*[-*]\s+\[([ xX])\]\s+(\S.*?)\s*$/;
62
67
 
63
68
  /** Strip Spec Kit's "Phase 3.1:" style prefix so phase names read as AC labels ("Setup"). */
64
69
  function phaseName(heading) {
@@ -66,34 +71,59 @@ function phaseName(heading) {
66
71
  }
67
72
 
68
73
  /**
69
- * Pure parser: tasks.md markdown -> [{ name, done, total }] in document order.
70
- * A phase is a `##` heading; a task is any checkbox list line under it. Checkbox lines before
71
- * the first heading are collected under a synthetic "Tasks" phase. Phases with no checkbox
72
- * lines (e.g. a "Dependencies" notes section) are dropped.
74
+ * Rich internal parse: tasks.md markdown -> [{ name, done, total, boxes }] in document order,
75
+ * where boxes is [{ checked, text }] for every checkbox line under the phase. A phase is a `##`
76
+ * heading; a task is any checkbox list line under it. Checkbox lines before the first heading
77
+ * are collected under a synthetic "Tasks" phase. Phases with no checkbox lines (e.g. a
78
+ * "Dependencies" notes section) are dropped. The two exported views below are pure projections
79
+ * of this one pass, so they can never disagree.
73
80
  */
74
- export function parseTasks(markdown) {
81
+ function parsePhaseList(markdown) {
75
82
  const phases = [];
76
83
  let current = null;
77
84
  for (const line of String(markdown ?? "").split("\n")) {
78
85
  const heading = line.match(PHASE_HEADING);
79
86
  if (heading) {
80
- current = { name: phaseName(heading[1]), done: 0, total: 0 };
87
+ current = { name: phaseName(heading[1]), done: 0, total: 0, boxes: [] };
81
88
  phases.push(current);
82
89
  continue;
83
90
  }
84
91
  const task = line.match(TASK_LINE);
85
92
  if (task) {
86
93
  if (!current) {
87
- current = { name: "Tasks", done: 0, total: 0 };
94
+ current = { name: "Tasks", done: 0, total: 0, boxes: [] };
88
95
  phases.push(current);
89
96
  }
90
97
  current.total += 1;
91
- if (task[1] !== " ") current.done += 1;
98
+ const checked = task[1] !== " ";
99
+ if (checked) current.done += 1;
100
+ current.boxes.push({ checked, text: task[2] });
92
101
  }
93
102
  }
94
103
  return phases.filter((p) => p.total > 0);
95
104
  }
96
105
 
106
+ /**
107
+ * Pure parser: tasks.md markdown -> [{ name, done, total }] in document order. The stable
108
+ * shape both the gate and the sync planner consume; deliberately carries no box text (see
109
+ * parseTaskBoxes for that). Byte-identical to its long-standing output.
110
+ */
111
+ export function parseTasks(markdown) {
112
+ return parsePhaseList(markdown).map(({ name, done, total }) => ({ name, done, total }));
113
+ }
114
+
115
+ /**
116
+ * The additive companion view (spec 050): tasks.md markdown -> [{ name, boxes }] where boxes is
117
+ * [{ checked, text }] per checkbox line, in document order. Kept SEPARATE from parseTasks rather
118
+ * than widening its objects, because existing tests pin parseTasks/deriveSpecState().phases to
119
+ * the exact { name, done, total } shape — a `boxes` key on those objects would break them. The
120
+ * spec-bridge project-gate check reads this (via deriveSpecState().phaseBoxes) to name the
121
+ * ticked box a red gate stands over.
122
+ */
123
+ export function parseTaskBoxes(markdown) {
124
+ return parsePhaseList(markdown).map(({ name, boxes }) => ({ name, boxes }));
125
+ }
126
+
97
127
  /** "Setup: 2/2 · Core: 4/7" — one segment per phase, empty string when there are no tasks. */
98
128
  export function progressNote(phases) {
99
129
  return phases.map((p) => `${p.name}: ${p.done}/${p.total}`).join(" · ");
@@ -121,7 +151,9 @@ export function deriveSpecState(specDir, { requireAnalysis = false } = {}) {
121
151
  try { return has(name) ? readFileSync(join(specDir, name), "utf8") : ""; } catch { return ""; }
122
152
  };
123
153
 
124
- const phases = parseTasks(read("tasks.md"));
154
+ const tasksMd = read("tasks.md");
155
+ const phases = parseTasks(tasksMd);
156
+ const phaseBoxes = parseTaskBoxes(tasksMd); // additive: per-box { checked, text }, same pass
125
157
  const tasksTotal = phases.reduce((n, p) => n + p.total, 0);
126
158
  const tasksDone = phases.reduce((n, p) => n + p.done, 0);
127
159
 
@@ -149,6 +181,9 @@ export function deriveSpecState(specDir, { requireAnalysis = false } = {}) {
149
181
 
150
182
  return {
151
183
  status: coarseStatus(stage), stage, phases, tasksDone, tasksTotal,
184
+ // phaseBoxes is strictly additive — .phases keeps its { name, done, total } shape (pinned by
185
+ // existing tests); phaseBoxes carries the box text spec 050's message needs, nothing more.
186
+ phaseBoxes,
152
187
  progressNote: progressNote(phases),
153
188
  analysis: { required: requireAnalysis, present: analysisPresent, criticals },
154
189
  };
@@ -58,7 +58,12 @@ export function coarseStatus(stage) {
58
58
  }
59
59
 
60
60
  const PHASE_HEADING = /^##\s+(.+?)\s*$/;
61
- const TASK_LINE = /^\s*[-*]\s+\[([ xX])\]\s+\S/;
61
+ // Captures the checkbox char AND the box's trailing descriptive text. The matched-line SET is
62
+ // identical to the old `/^\s*[-*]\s+\[([ xX])\]\s+\S/` (both require a non-space after the box;
63
+ // per-line matching, so `.*?\s*$` always closes over the remainder) — this only adds capture
64
+ // group 2, so parseTasks() output is byte-identical. The text feeds spec 050's blocking message
65
+ // (AC #1: name the phase, the box, and the failing gate).
66
+ const TASK_LINE = /^\s*[-*]\s+\[([ xX])\]\s+(\S.*?)\s*$/;
62
67
 
63
68
  /** Strip Spec Kit's "Phase 3.1:" style prefix so phase names read as AC labels ("Setup"). */
64
69
  function phaseName(heading) {
@@ -66,34 +71,59 @@ function phaseName(heading) {
66
71
  }
67
72
 
68
73
  /**
69
- * Pure parser: tasks.md markdown -> [{ name, done, total }] in document order.
70
- * A phase is a `##` heading; a task is any checkbox list line under it. Checkbox lines before
71
- * the first heading are collected under a synthetic "Tasks" phase. Phases with no checkbox
72
- * lines (e.g. a "Dependencies" notes section) are dropped.
74
+ * Rich internal parse: tasks.md markdown -> [{ name, done, total, boxes }] in document order,
75
+ * where boxes is [{ checked, text }] for every checkbox line under the phase. A phase is a `##`
76
+ * heading; a task is any checkbox list line under it. Checkbox lines before the first heading
77
+ * are collected under a synthetic "Tasks" phase. Phases with no checkbox lines (e.g. a
78
+ * "Dependencies" notes section) are dropped. The two exported views below are pure projections
79
+ * of this one pass, so they can never disagree.
73
80
  */
74
- export function parseTasks(markdown) {
81
+ function parsePhaseList(markdown) {
75
82
  const phases = [];
76
83
  let current = null;
77
84
  for (const line of String(markdown ?? "").split("\n")) {
78
85
  const heading = line.match(PHASE_HEADING);
79
86
  if (heading) {
80
- current = { name: phaseName(heading[1]), done: 0, total: 0 };
87
+ current = { name: phaseName(heading[1]), done: 0, total: 0, boxes: [] };
81
88
  phases.push(current);
82
89
  continue;
83
90
  }
84
91
  const task = line.match(TASK_LINE);
85
92
  if (task) {
86
93
  if (!current) {
87
- current = { name: "Tasks", done: 0, total: 0 };
94
+ current = { name: "Tasks", done: 0, total: 0, boxes: [] };
88
95
  phases.push(current);
89
96
  }
90
97
  current.total += 1;
91
- if (task[1] !== " ") current.done += 1;
98
+ const checked = task[1] !== " ";
99
+ if (checked) current.done += 1;
100
+ current.boxes.push({ checked, text: task[2] });
92
101
  }
93
102
  }
94
103
  return phases.filter((p) => p.total > 0);
95
104
  }
96
105
 
106
+ /**
107
+ * Pure parser: tasks.md markdown -> [{ name, done, total }] in document order. The stable
108
+ * shape both the gate and the sync planner consume; deliberately carries no box text (see
109
+ * parseTaskBoxes for that). Byte-identical to its long-standing output.
110
+ */
111
+ export function parseTasks(markdown) {
112
+ return parsePhaseList(markdown).map(({ name, done, total }) => ({ name, done, total }));
113
+ }
114
+
115
+ /**
116
+ * The additive companion view (spec 050): tasks.md markdown -> [{ name, boxes }] where boxes is
117
+ * [{ checked, text }] per checkbox line, in document order. Kept SEPARATE from parseTasks rather
118
+ * than widening its objects, because existing tests pin parseTasks/deriveSpecState().phases to
119
+ * the exact { name, done, total } shape — a `boxes` key on those objects would break them. The
120
+ * spec-bridge project-gate check reads this (via deriveSpecState().phaseBoxes) to name the
121
+ * ticked box a red gate stands over.
122
+ */
123
+ export function parseTaskBoxes(markdown) {
124
+ return parsePhaseList(markdown).map(({ name, boxes }) => ({ name, boxes }));
125
+ }
126
+
97
127
  /** "Setup: 2/2 · Core: 4/7" — one segment per phase, empty string when there are no tasks. */
98
128
  export function progressNote(phases) {
99
129
  return phases.map((p) => `${p.name}: ${p.done}/${p.total}`).join(" · ");
@@ -121,7 +151,9 @@ export function deriveSpecState(specDir, { requireAnalysis = false } = {}) {
121
151
  try { return has(name) ? readFileSync(join(specDir, name), "utf8") : ""; } catch { return ""; }
122
152
  };
123
153
 
124
- const phases = parseTasks(read("tasks.md"));
154
+ const tasksMd = read("tasks.md");
155
+ const phases = parseTasks(tasksMd);
156
+ const phaseBoxes = parseTaskBoxes(tasksMd); // additive: per-box { checked, text }, same pass
125
157
  const tasksTotal = phases.reduce((n, p) => n + p.total, 0);
126
158
  const tasksDone = phases.reduce((n, p) => n + p.done, 0);
127
159
 
@@ -149,6 +181,9 @@ export function deriveSpecState(specDir, { requireAnalysis = false } = {}) {
149
181
 
150
182
  return {
151
183
  status: coarseStatus(stage), stage, phases, tasksDone, tasksTotal,
184
+ // phaseBoxes is strictly additive — .phases keeps its { name, done, total } shape (pinned by
185
+ // existing tests); phaseBoxes carries the box text spec 050's message needs, nothing more.
186
+ phaseBoxes,
152
187
  progressNote: progressNote(phases),
153
188
  analysis: { required: requireAnalysis, present: analysisPresent, criticals },
154
189
  };
@@ -58,7 +58,12 @@ export function coarseStatus(stage) {
58
58
  }
59
59
 
60
60
  const PHASE_HEADING = /^##\s+(.+?)\s*$/;
61
- const TASK_LINE = /^\s*[-*]\s+\[([ xX])\]\s+\S/;
61
+ // Captures the checkbox char AND the box's trailing descriptive text. The matched-line SET is
62
+ // identical to the old `/^\s*[-*]\s+\[([ xX])\]\s+\S/` (both require a non-space after the box;
63
+ // per-line matching, so `.*?\s*$` always closes over the remainder) — this only adds capture
64
+ // group 2, so parseTasks() output is byte-identical. The text feeds spec 050's blocking message
65
+ // (AC #1: name the phase, the box, and the failing gate).
66
+ const TASK_LINE = /^\s*[-*]\s+\[([ xX])\]\s+(\S.*?)\s*$/;
62
67
 
63
68
  /** Strip Spec Kit's "Phase 3.1:" style prefix so phase names read as AC labels ("Setup"). */
64
69
  function phaseName(heading) {
@@ -66,34 +71,59 @@ function phaseName(heading) {
66
71
  }
67
72
 
68
73
  /**
69
- * Pure parser: tasks.md markdown -> [{ name, done, total }] in document order.
70
- * A phase is a `##` heading; a task is any checkbox list line under it. Checkbox lines before
71
- * the first heading are collected under a synthetic "Tasks" phase. Phases with no checkbox
72
- * lines (e.g. a "Dependencies" notes section) are dropped.
74
+ * Rich internal parse: tasks.md markdown -> [{ name, done, total, boxes }] in document order,
75
+ * where boxes is [{ checked, text }] for every checkbox line under the phase. A phase is a `##`
76
+ * heading; a task is any checkbox list line under it. Checkbox lines before the first heading
77
+ * are collected under a synthetic "Tasks" phase. Phases with no checkbox lines (e.g. a
78
+ * "Dependencies" notes section) are dropped. The two exported views below are pure projections
79
+ * of this one pass, so they can never disagree.
73
80
  */
74
- export function parseTasks(markdown) {
81
+ function parsePhaseList(markdown) {
75
82
  const phases = [];
76
83
  let current = null;
77
84
  for (const line of String(markdown ?? "").split("\n")) {
78
85
  const heading = line.match(PHASE_HEADING);
79
86
  if (heading) {
80
- current = { name: phaseName(heading[1]), done: 0, total: 0 };
87
+ current = { name: phaseName(heading[1]), done: 0, total: 0, boxes: [] };
81
88
  phases.push(current);
82
89
  continue;
83
90
  }
84
91
  const task = line.match(TASK_LINE);
85
92
  if (task) {
86
93
  if (!current) {
87
- current = { name: "Tasks", done: 0, total: 0 };
94
+ current = { name: "Tasks", done: 0, total: 0, boxes: [] };
88
95
  phases.push(current);
89
96
  }
90
97
  current.total += 1;
91
- if (task[1] !== " ") current.done += 1;
98
+ const checked = task[1] !== " ";
99
+ if (checked) current.done += 1;
100
+ current.boxes.push({ checked, text: task[2] });
92
101
  }
93
102
  }
94
103
  return phases.filter((p) => p.total > 0);
95
104
  }
96
105
 
106
+ /**
107
+ * Pure parser: tasks.md markdown -> [{ name, done, total }] in document order. The stable
108
+ * shape both the gate and the sync planner consume; deliberately carries no box text (see
109
+ * parseTaskBoxes for that). Byte-identical to its long-standing output.
110
+ */
111
+ export function parseTasks(markdown) {
112
+ return parsePhaseList(markdown).map(({ name, done, total }) => ({ name, done, total }));
113
+ }
114
+
115
+ /**
116
+ * The additive companion view (spec 050): tasks.md markdown -> [{ name, boxes }] where boxes is
117
+ * [{ checked, text }] per checkbox line, in document order. Kept SEPARATE from parseTasks rather
118
+ * than widening its objects, because existing tests pin parseTasks/deriveSpecState().phases to
119
+ * the exact { name, done, total } shape — a `boxes` key on those objects would break them. The
120
+ * spec-bridge project-gate check reads this (via deriveSpecState().phaseBoxes) to name the
121
+ * ticked box a red gate stands over.
122
+ */
123
+ export function parseTaskBoxes(markdown) {
124
+ return parsePhaseList(markdown).map(({ name, boxes }) => ({ name, boxes }));
125
+ }
126
+
97
127
  /** "Setup: 2/2 · Core: 4/7" — one segment per phase, empty string when there are no tasks. */
98
128
  export function progressNote(phases) {
99
129
  return phases.map((p) => `${p.name}: ${p.done}/${p.total}`).join(" · ");
@@ -121,7 +151,9 @@ export function deriveSpecState(specDir, { requireAnalysis = false } = {}) {
121
151
  try { return has(name) ? readFileSync(join(specDir, name), "utf8") : ""; } catch { return ""; }
122
152
  };
123
153
 
124
- const phases = parseTasks(read("tasks.md"));
154
+ const tasksMd = read("tasks.md");
155
+ const phases = parseTasks(tasksMd);
156
+ const phaseBoxes = parseTaskBoxes(tasksMd); // additive: per-box { checked, text }, same pass
125
157
  const tasksTotal = phases.reduce((n, p) => n + p.total, 0);
126
158
  const tasksDone = phases.reduce((n, p) => n + p.done, 0);
127
159
 
@@ -149,6 +181,9 @@ export function deriveSpecState(specDir, { requireAnalysis = false } = {}) {
149
181
 
150
182
  return {
151
183
  status: coarseStatus(stage), stage, phases, tasksDone, tasksTotal,
184
+ // phaseBoxes is strictly additive — .phases keeps its { name, done, total } shape (pinned by
185
+ // existing tests); phaseBoxes carries the box text spec 050's message needs, nothing more.
186
+ phaseBoxes,
152
187
  progressNote: progressNote(phases),
153
188
  analysis: { required: requireAnalysis, present: analysisPresent, criticals },
154
189
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@praxisflux/gates",
3
- "version": "0.53.0",
3
+ "version": "0.55.0",
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": {
@@ -20,6 +20,7 @@
20
20
 
21
21
  import { existsSync, readdirSync, readFileSync } from "node:fs";
22
22
  import { join } from "node:path";
23
+ import { spawnSync } from "node:child_process";
23
24
  import { deriveSpecState, STATUS, STAGE, STAGES } from "../lib/spec-derive.mjs";
24
25
  import { hasChild, findRootsDownwards } from "../lib/project-root.mjs";
25
26
 
@@ -83,6 +84,177 @@ export function vocabularyProfile(config) {
83
84
  return { names, cover };
84
85
  }
85
86
 
87
+ /**
88
+ * The opt-in project-gate declaration, normalized. `.spec-bridge.json` may carry
89
+ * { "projectGates": {
90
+ * "required": [ { "name": "tests", "command": ["node", "--test"] } ],
91
+ * "redByConstruction": [ { "name": "freshness", "command": ["node", "grounding-wiki/gates/cli.mjs", "freshness", ".", "docs/wiki"] } ]
92
+ * } }
93
+ * declaring which host gates must be green before a linked spec may be Done-eligible
94
+ * (`required`) and which a mid-PR phase MAY leave red — the freshness gate between a
95
+ * source edit and its re-pin commit (`redByConstruction`). "Red until the re-pin commit"
96
+ * is not a property this checker can see, so the host STATES it; it is data the check
97
+ * reads, never prose (spec 050 R1).
98
+ *
99
+ * This mirrors vocabularyProfile exactly: returns null — behavior bit-for-bit unchanged,
100
+ * every existing message and plan byte-identical — unless at least one validly-shaped gate
101
+ * entry exists. A valid entry is `{ name: <non-empty string>, command: <non-empty array of
102
+ * non-empty strings> }`; malformed entries are dropped silently (unknown keys / bad values
103
+ * ignored, never guessed), and if nothing valid survives in either bucket the whole opt-in
104
+ * is null. `command` is an argv array by design (never a shell string): declared commands
105
+ * run via spawnSync with shell:false, so there is no interpolation and no injection surface
106
+ * (spec 050 R4; the exec itself lands in phase 2). The field case this guards: 2026-08-01,
107
+ * spec 048 phases 1-2, "254 pass, 0 fail" reported and a tasks.md box ticked while four wiki
108
+ * notes were staled and the freshness gate was red.
109
+ *
110
+ * Returns { required, redByConstruction } — each an array of { name, command } with command
111
+ * a string[] argv; an absent-but-other-present bucket is []. Null iff no valid entry at all.
112
+ */
113
+ export function projectGatesProfile(config) {
114
+ const raw = config?.projectGates;
115
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
116
+ const parseBucket = (value) => {
117
+ if (!Array.isArray(value)) return [];
118
+ const out = [];
119
+ for (const entry of value) {
120
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
121
+ const name = typeof entry.name === "string" ? entry.name.trim() : "";
122
+ // A validly-shaped entry needs a name AND an argv that is a non-empty array of
123
+ // non-empty strings. A string `command` is NOT accepted — no safe split exists, and
124
+ // the whole point is to never touch a shell. A malformed element (empty/non-string)
125
+ // fails the whole entry rather than being silently repaired; drop it, never guess.
126
+ const cmd = entry.command;
127
+ const argvOk = Array.isArray(cmd) && cmd.length > 0 && cmd.every((a) => typeof a === "string" && a.trim());
128
+ if (!name || !argvOk) continue;
129
+ out.push({ name, command: cmd });
130
+ }
131
+ return out;
132
+ };
133
+ const required = parseBucket(raw.required);
134
+ const redByConstruction = parseBucket(raw.redByConstruction);
135
+ if (required.length === 0 && redByConstruction.length === 0) return null;
136
+ return { required, redByConstruction };
137
+ }
138
+
139
+ /* ── project gates: running the declared commands and turning red into findings ────────────
140
+ *
141
+ * spec 050. The field case this exists to stop: 2026-08-01, spec 048 phases 1-2, a dispatched
142
+ * implementer reported "node --test — 254 pass, 0 fail" and ticked its tasks.md box while four
143
+ * wiki notes were staled and the freshness gate was red; the next phase re-ran the suite and
144
+ * found 258/259. A ticked tasks.md checkbox IS status, and a status can never exceed the
145
+ * artifacts that prove it — so a ticked box standing over a red project gate blocks.
146
+ *
147
+ * One pure evaluator (evaluateProjectGates), two entry points that differ only in WHEN and
148
+ * WHICH buckets they run: the Stop hook (checkBridge) fires only at Done-eligible; the CLI
149
+ * `verify` verb (verifyBridge) covers the mid-PR case. Command execution is injected so tests
150
+ * drive every branch — green, red, spawn-failure, timeout — without a subprocess. */
151
+
152
+ /** Per-command wall-clock ceiling. `node --test` here is ~5.7s (spec 050 R4, measured); 2 min is
153
+ * ample headroom while still boxing a hung gate so it cannot wedge every Stop (fail-closed). */
154
+ export const GATE_TIMEOUT_MS = 120000;
155
+
156
+ /**
157
+ * Run ONE declared gate command as a subprocess and classify the outcome. argv only, shell:false
158
+ * (no interpolation, no injection surface — spec 050 R4); cwd is the project root. Returns
159
+ * { ok: true } — exit 0, green
160
+ * { ok: false, kind: "red", reason } — nonzero exit / killed by signal
161
+ * { ok: false, kind: "error", reason } — could not execute at all (ENOENT, spawn error)
162
+ * { ok: false, kind: "timeout", timeoutMs } — exceeded the time box
163
+ * "error" and "timeout" are the fail-closed cases: a command that cannot run is NEVER green (it
164
+ * is the gate-runner contract applied one level down — a crash is a blocking problem, not a
165
+ * silent pass). Sets SPEC_BRIDGE_GATE_ACTIVE on the child env so a declared command that itself
166
+ * invokes the bridge short-circuits instead of recursing (see checkBridge/verifyBridge).
167
+ */
168
+ export function runGateCommand(command, { cwd, timeoutMs = GATE_TIMEOUT_MS, spawn = spawnSync } = {}) {
169
+ let res;
170
+ try {
171
+ res = spawn(command[0], command.slice(1), {
172
+ cwd, timeout: timeoutMs, shell: false, encoding: "utf8",
173
+ env: { ...process.env, SPEC_BRIDGE_GATE_ACTIVE: "1" },
174
+ });
175
+ } catch (e) {
176
+ return { ok: false, kind: "error", reason: e.code || e.message };
177
+ }
178
+ if (res.error) {
179
+ if (res.error.code === "ETIMEDOUT") return { ok: false, kind: "timeout", timeoutMs };
180
+ return { ok: false, kind: "error", reason: res.error.code || res.error.message };
181
+ }
182
+ if (res.status === 0) return { ok: true };
183
+ if (res.status == null) return { ok: false, kind: "red", reason: res.signal ? `killed by ${res.signal}` : "no exit status" };
184
+ return { ok: false, kind: "red", reason: `exited ${res.status}` };
185
+ }
186
+
187
+ /** The last ticked box in document order — the tick that (in sequence) claimed the most, and so
188
+ * the box a red gate most directly stands over. Null when nothing is ticked. */
189
+ function lastTickedBox(phaseBoxes) {
190
+ let witness = null;
191
+ for (const p of phaseBoxes || [])
192
+ for (const b of p.boxes || [])
193
+ if (b.checked) witness = { phase: p.name, box: b.text };
194
+ return witness;
195
+ }
196
+
197
+ /** Flatten a profile into the ordered { name, command, bucket } gates for the named buckets. */
198
+ function gatesFor(profile, buckets) {
199
+ const out = [];
200
+ for (const bucket of buckets)
201
+ for (const g of profile[bucket] || []) out.push({ ...g, bucket });
202
+ return out;
203
+ }
204
+
205
+ /**
206
+ * Memoize a gate runner by its argv so each DISTINCT declared command runs at most once per
207
+ * bridge invocation. Declared gates are project-wide, not per-spec — the same `node --test` is
208
+ * the `tests` gate for every linked spec — so its RESULT is computed once and shared across all
209
+ * of them (spec 050 defect 2, Phase 5). Findings are still built per spec (each names its own
210
+ * phase/box/gate); only the gate result is shared, never the finding. Before this, checkBridge
211
+ * ran the full gate set once per Done-eligible spec — 49× on this repo's own board, ~358s — which
212
+ * defeated R4's cost argument the moment more than one spec was Done-eligible.
213
+ */
214
+ function memoizeRun(rawRun) {
215
+ const cache = new Map();
216
+ return (command) => {
217
+ const key = command.join("");
218
+ if (!cache.has(key)) cache.set(key, rawRun(command));
219
+ return cache.get(key);
220
+ };
221
+ }
222
+
223
+ /** The human clause for why a gate isn't green — honest about red vs. couldn't-run vs. timed-out. */
224
+ function gateReason(result) {
225
+ if (result.kind === "timeout") return `timed out after ${result.timeoutMs}ms and is treated as failed, never green`;
226
+ if (result.kind === "error") return `could not be executed (${result.reason}) and is treated as failed, never green`;
227
+ return `is red (${result.reason})`;
228
+ }
229
+
230
+ /** The blocking finding: names the phase, the box, and the failing gate (spec 050 AC #1). */
231
+ function projectGateProblem({ id, specDir, witness, gate, result }) {
232
+ const where = witness
233
+ ? `phase "${witness.phase}", box "${witness.box}" is ticked, but `
234
+ : "a ticked box stands over a gate that ";
235
+ const label = gate.bucket === "redByConstruction" ? "red-by-construction gate" : "required gate";
236
+ return `[spec-bridge] ${id} · ${specDir}: ${where}the ${label} "${gate.name}" ${gateReason(result)}. ` +
237
+ `A ticked tasks.md checkbox cannot outrun a red project gate — make the gate pass or set the box back.`;
238
+ }
239
+
240
+ /**
241
+ * Pure evaluator (spec 050): run the given gates for ONE linked spec, return one blocking finding
242
+ * per gate that is not green. `link` is { id, specDir, phaseBoxes }; `gates` the ordered flattened
243
+ * gate list (the caller picks the buckets — the two entry points differ there); `run` executes one
244
+ * command and returns runGateCommand's shape (injected so tests need no subprocess). The witness box
245
+ * is computed once from phaseBoxes so every finding names it.
246
+ */
247
+ export function evaluateProjectGates({ id, specDir, phaseBoxes }, gates, run) {
248
+ const witness = lastTickedBox(phaseBoxes);
249
+ const problems = [];
250
+ for (const gate of gates) {
251
+ const result = run(gate.command);
252
+ if (result.ok) continue;
253
+ problems.push(projectGateProblem({ id, specDir, witness, gate, result }));
254
+ }
255
+ return problems;
256
+ }
257
+
86
258
  const MARKER = /^Spec:\s*(\S+?)\/?\s*$/m;
87
259
  const RANK = { "to do": 0, "in progress": 1, done: 2 };
88
260
  const DERIVED_RANK = { [STATUS.TODO]: 0, [STATUS.IN_PROGRESS]: 1, [STATUS.DONE_ELIGIBLE]: 2 };
@@ -170,13 +342,25 @@ function shortfall(root, specDir, derived) {
170
342
  * problems — blocking messages, one per "exceeds"
171
343
  * warnings — non-blocking messages, one per "lags"
172
344
  */
173
- export function checkBridge(root) {
345
+ export function checkBridge(root, { runGates = true, run } = {}) {
174
346
  const links = [];
175
347
  const problems = [];
176
348
  const warnings = [];
177
349
  const config = loadBridgeConfig(root);
178
350
  const requireAnalysis = config.strictDone === true;
179
351
  const profile = vocabularyProfile(config);
352
+ const gatesProfile = projectGatesProfile(config);
353
+ // Run declared project gates only when they're opted into and the caller asked for it (the Stop
354
+ // hook runs them in `check` but not the duplicate `warn` pass). SPEC_BRIDGE_GATE_ACTIVE, set on
355
+ // every child runGateCommand spawns, is the reentrancy guard: a host gate command that itself
356
+ // re-invokes the bridge short-circuits instead of forking forever (spec 050 R4). But that guard
357
+ // exists to stop the DEFAULT runner from spawning real subprocesses — an injected `run` is a
358
+ // test double that spawns nothing, so it MUST bypass the guard (spec 050 defect 1, Phase 5).
359
+ // Without this bypass the bridge's own dogfood reddens its `tests` gate: `node --test` runs the
360
+ // Phase-3 suite with the flag set, and every injected-run test there fail-closes to [].
361
+ const injected = run !== undefined;
362
+ const execGates = runGates && !!gatesProfile && (injected || process.env.SPEC_BRIDGE_GATE_ACTIVE !== "1");
363
+ const runOne = memoizeRun(run || ((command) => runGateCommand(command, { cwd: root })));
180
364
  for (const task of findLinkedTasks(root)) {
181
365
  const derived = deriveSpecState(join(root, task.specDir), { requireAnalysis });
182
366
  // Opted-in boards are judged on the stage ladder against their own status names;
@@ -213,10 +397,62 @@ export function checkBridge(root) {
213
397
  : `unresolved CRITICAL finding(s) in analysis.md: ${a.criticals.join(" | ")}`)
214
398
  );
215
399
  }
400
+
401
+ // Project-gate check (spec 050 R4): execute declared gates ONLY when the spec is
402
+ // Done-eligible — the one bounded moment a red gate under a ticked box changes an outcome,
403
+ // so ordinary turns pay zero subprocess cost. At Done-eligible the mid-PR window has closed
404
+ // (every box, including the re-pin box, is ticked), so BOTH buckets must be green: required,
405
+ // AND redByConstruction — its "allowed red mid-PR" license has expired now that its re-pin
406
+ // was claimed done. Any red / unrunnable / timed-out gate is a blocking finding.
407
+ if (execGates && derived.status === STATUS.DONE_ELIGIBLE) {
408
+ const gates = gatesFor(gatesProfile, ["required", "redByConstruction"]);
409
+ problems.push(
410
+ ...evaluateProjectGates(
411
+ { id: task.id, specDir: task.specDir, phaseBoxes: derived.phaseBoxes }, gates, runOne)
412
+ );
413
+ }
216
414
  }
217
415
  return { links, problems, warnings };
218
416
  }
219
417
 
418
+ /**
419
+ * The `verify` entry point (spec 050 R4): the mid-PR counterpart to the Stop hook. For every
420
+ * linked spec that has at least one ticked box — a box claiming greenness — run the declared
421
+ * gates and return the blocking findings. A Done-eligible spec is held to BOTH buckets (as the
422
+ * Stop hook does); a mid-PR spec to `required` only, because redByConstruction gates are
423
+ * legitimately red between a source edit and its re-pin commit. Shares evaluateProjectGates, so
424
+ * it and the Stop hook agree by construction. Read-only like the rest of gates/: it runs the
425
+ * host's declared subprocesses but writes nothing itself. Injectable `run` for tests.
426
+ */
427
+ export function verifyBridge(root, { run } = {}) {
428
+ const problems = [];
429
+ const config = loadBridgeConfig(root);
430
+ const gatesProfile = projectGatesProfile(config);
431
+ if (!gatesProfile) return problems; // no opt-in → nothing to do
432
+ // Reentrancy guard (spec 050 defect 1, Phase 5): a spawned gate command that re-invokes the
433
+ // bridge with the DEFAULT runner short-circuits so it can't fork forever; an injected `run` is
434
+ // a test double that spawns nothing, so it bypasses the guard.
435
+ const injected = run !== undefined;
436
+ if (!injected && process.env.SPEC_BRIDGE_GATE_ACTIVE === "1") return problems;
437
+ const requireAnalysis = config.strictDone === true;
438
+ // Share each distinct gate result across every spec this invocation checks (spec 050 defect 2).
439
+ const runOne = memoizeRun(run || ((command) => runGateCommand(command, { cwd: root })));
440
+ for (const task of findLinkedTasks(root)) {
441
+ const derived = deriveSpecState(join(root, task.specDir), { requireAnalysis });
442
+ const anyTicked = (derived.phaseBoxes || []).some((p) => (p.boxes || []).some((b) => b.checked));
443
+ if (!anyTicked) continue; // nothing claims greenness yet — no tick to outrun a gate
444
+ const buckets = derived.status === STATUS.DONE_ELIGIBLE
445
+ ? ["required", "redByConstruction"]
446
+ : ["required"];
447
+ const gates = gatesFor(gatesProfile, buckets);
448
+ problems.push(
449
+ ...evaluateProjectGates(
450
+ { id: task.id, specDir: task.specDir, phaseBoxes: derived.phaseBoxes }, gates, runOne)
451
+ );
452
+ }
453
+ return problems;
454
+ }
455
+
220
456
  /* ── plan: the exact backlog edits that reconcile the board ────────────── */
221
457
 
222
458
  const PHASE_PREFIX = "Spec phase: ";
@@ -321,6 +557,9 @@ export function planBridge(root) {
321
557
  export const bridgeGate = {
322
558
  name: "spec-bridge",
323
559
  resolveRoots: (startDir) => findRootsDownwards(startDir, hasChild("backlog")),
324
- check: (root) => checkBridge(root).problems,
325
- warn: (root) => checkBridge(root).warnings,
560
+ // The runner calls check() then warn() per root; run the (possibly costly) project-gate
561
+ // commands only in check so a Stop pays for them once, not twice. Warnings never depend on
562
+ // gate execution, so runGates:false loses nothing.
563
+ check: (root) => checkBridge(root, { runGates: true }).problems,
564
+ warn: (root) => checkBridge(root, { runGates: false }).warnings,
326
565
  };
@@ -4,17 +4,21 @@
4
4
  // node cli.mjs state <specDir> derived state for one spec dir, as JSON
5
5
  // node cli.mjs links <root> every linked task under <root> with derived state + verdict, as JSON
6
6
  // node cli.mjs check <root> human report; exit 1 if any task's status exceeds its artifacts
7
+ // node cli.mjs verify <root> run declared project gates against ticked boxes (spec 050);
8
+ // exit 1 if any ticked box stands over a red/unrunnable gate.
9
+ // The mid-PR counterpart to the Done-eligible Stop-hook check —
10
+ // the sweep's per-phase loop and CI call it.
7
11
  // node cli.mjs plan <root> the ordered `backlog task edit` commands that reconcile the
8
12
  // board to the derived state (stdout; nothing on a reconciled
9
13
  // board). Prints, NEVER executes — the sync skill runs them.
10
14
  import { resolve } from "node:path";
11
15
  import { deriveSpecState } from "../lib/spec-derive.mjs";
12
16
  import { findRootUpwards, hasChild } from "../lib/project-root.mjs";
13
- import { checkBridge, loadBridgeConfig, planBridge, vocabularyProfile } from "./bridge.mjs";
17
+ import { checkBridge, loadBridgeConfig, planBridge, verifyBridge, vocabularyProfile } from "./bridge.mjs";
14
18
 
15
19
  const [cmd, target] = process.argv.slice(2);
16
20
  if (!cmd || !target) {
17
- console.error("usage: cli.mjs state <specDir> | links <root> | check <root> | plan <root>");
21
+ console.error("usage: cli.mjs state <specDir> | links <root> | check <root> | verify <root> | plan <root>");
18
22
  process.exit(2);
19
23
  }
20
24
 
@@ -34,6 +38,14 @@ if (cmd === "state") {
34
38
  process.exit(1);
35
39
  }
36
40
  console.log(`spec-bridge ok: ${links.length} linked task(s), none exceed their artifacts`);
41
+ } else if (cmd === "verify") {
42
+ const problems = verifyBridge(target);
43
+ if (problems.length) {
44
+ console.log(`\nGATE FAILED (${problems.length} issue(s)):`);
45
+ for (const p of problems) console.log(` - ${p}`);
46
+ process.exit(1);
47
+ }
48
+ console.log("spec-bridge verify ok: every ticked box's declared project gates are green");
37
49
  } else if (cmd === "plan") {
38
50
  const { commands, skipped } = planBridge(target);
39
51
  // Name the vocabulary the board actually speaks: the opted-in phase-level names when
@@ -58,7 +58,12 @@ export function coarseStatus(stage) {
58
58
  }
59
59
 
60
60
  const PHASE_HEADING = /^##\s+(.+?)\s*$/;
61
- const TASK_LINE = /^\s*[-*]\s+\[([ xX])\]\s+\S/;
61
+ // Captures the checkbox char AND the box's trailing descriptive text. The matched-line SET is
62
+ // identical to the old `/^\s*[-*]\s+\[([ xX])\]\s+\S/` (both require a non-space after the box;
63
+ // per-line matching, so `.*?\s*$` always closes over the remainder) — this only adds capture
64
+ // group 2, so parseTasks() output is byte-identical. The text feeds spec 050's blocking message
65
+ // (AC #1: name the phase, the box, and the failing gate).
66
+ const TASK_LINE = /^\s*[-*]\s+\[([ xX])\]\s+(\S.*?)\s*$/;
62
67
 
63
68
  /** Strip Spec Kit's "Phase 3.1:" style prefix so phase names read as AC labels ("Setup"). */
64
69
  function phaseName(heading) {
@@ -66,34 +71,59 @@ function phaseName(heading) {
66
71
  }
67
72
 
68
73
  /**
69
- * Pure parser: tasks.md markdown -> [{ name, done, total }] in document order.
70
- * A phase is a `##` heading; a task is any checkbox list line under it. Checkbox lines before
71
- * the first heading are collected under a synthetic "Tasks" phase. Phases with no checkbox
72
- * lines (e.g. a "Dependencies" notes section) are dropped.
74
+ * Rich internal parse: tasks.md markdown -> [{ name, done, total, boxes }] in document order,
75
+ * where boxes is [{ checked, text }] for every checkbox line under the phase. A phase is a `##`
76
+ * heading; a task is any checkbox list line under it. Checkbox lines before the first heading
77
+ * are collected under a synthetic "Tasks" phase. Phases with no checkbox lines (e.g. a
78
+ * "Dependencies" notes section) are dropped. The two exported views below are pure projections
79
+ * of this one pass, so they can never disagree.
73
80
  */
74
- export function parseTasks(markdown) {
81
+ function parsePhaseList(markdown) {
75
82
  const phases = [];
76
83
  let current = null;
77
84
  for (const line of String(markdown ?? "").split("\n")) {
78
85
  const heading = line.match(PHASE_HEADING);
79
86
  if (heading) {
80
- current = { name: phaseName(heading[1]), done: 0, total: 0 };
87
+ current = { name: phaseName(heading[1]), done: 0, total: 0, boxes: [] };
81
88
  phases.push(current);
82
89
  continue;
83
90
  }
84
91
  const task = line.match(TASK_LINE);
85
92
  if (task) {
86
93
  if (!current) {
87
- current = { name: "Tasks", done: 0, total: 0 };
94
+ current = { name: "Tasks", done: 0, total: 0, boxes: [] };
88
95
  phases.push(current);
89
96
  }
90
97
  current.total += 1;
91
- if (task[1] !== " ") current.done += 1;
98
+ const checked = task[1] !== " ";
99
+ if (checked) current.done += 1;
100
+ current.boxes.push({ checked, text: task[2] });
92
101
  }
93
102
  }
94
103
  return phases.filter((p) => p.total > 0);
95
104
  }
96
105
 
106
+ /**
107
+ * Pure parser: tasks.md markdown -> [{ name, done, total }] in document order. The stable
108
+ * shape both the gate and the sync planner consume; deliberately carries no box text (see
109
+ * parseTaskBoxes for that). Byte-identical to its long-standing output.
110
+ */
111
+ export function parseTasks(markdown) {
112
+ return parsePhaseList(markdown).map(({ name, done, total }) => ({ name, done, total }));
113
+ }
114
+
115
+ /**
116
+ * The additive companion view (spec 050): tasks.md markdown -> [{ name, boxes }] where boxes is
117
+ * [{ checked, text }] per checkbox line, in document order. Kept SEPARATE from parseTasks rather
118
+ * than widening its objects, because existing tests pin parseTasks/deriveSpecState().phases to
119
+ * the exact { name, done, total } shape — a `boxes` key on those objects would break them. The
120
+ * spec-bridge project-gate check reads this (via deriveSpecState().phaseBoxes) to name the
121
+ * ticked box a red gate stands over.
122
+ */
123
+ export function parseTaskBoxes(markdown) {
124
+ return parsePhaseList(markdown).map(({ name, boxes }) => ({ name, boxes }));
125
+ }
126
+
97
127
  /** "Setup: 2/2 · Core: 4/7" — one segment per phase, empty string when there are no tasks. */
98
128
  export function progressNote(phases) {
99
129
  return phases.map((p) => `${p.name}: ${p.done}/${p.total}`).join(" · ");
@@ -121,7 +151,9 @@ export function deriveSpecState(specDir, { requireAnalysis = false } = {}) {
121
151
  try { return has(name) ? readFileSync(join(specDir, name), "utf8") : ""; } catch { return ""; }
122
152
  };
123
153
 
124
- const phases = parseTasks(read("tasks.md"));
154
+ const tasksMd = read("tasks.md");
155
+ const phases = parseTasks(tasksMd);
156
+ const phaseBoxes = parseTaskBoxes(tasksMd); // additive: per-box { checked, text }, same pass
125
157
  const tasksTotal = phases.reduce((n, p) => n + p.total, 0);
126
158
  const tasksDone = phases.reduce((n, p) => n + p.done, 0);
127
159
 
@@ -149,6 +181,9 @@ export function deriveSpecState(specDir, { requireAnalysis = false } = {}) {
149
181
 
150
182
  return {
151
183
  status: coarseStatus(stage), stage, phases, tasksDone, tasksTotal,
184
+ // phaseBoxes is strictly additive — .phases keeps its { name, done, total } shape (pinned by
185
+ // existing tests); phaseBoxes carries the box text spec 050's message needs, nothing more.
186
+ phaseBoxes,
152
187
  progressNote: progressNote(phases),
153
188
  analysis: { required: requireAnalysis, present: analysisPresent, criticals },
154
189
  };