@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.
- package/codebase-to-course/lib/spec-derive.mjs +45 -10
- package/grounding-wiki/lib/spec-derive.mjs +45 -10
- package/lib/spec-derive.mjs +45 -10
- package/package.json +1 -1
- package/spec-bridge/gates/bridge.mjs +242 -3
- package/spec-bridge/gates/cli.mjs +14 -2
- package/spec-bridge/lib/spec-derive.mjs +45 -10
|
@@ -58,7 +58,12 @@ export function coarseStatus(stage) {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
const PHASE_HEADING = /^##\s+(.+?)\s*$/;
|
|
61
|
-
|
|
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
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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/lib/spec-derive.mjs
CHANGED
|
@@ -58,7 +58,12 @@ export function coarseStatus(stage) {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
const PHASE_HEADING = /^##\s+(.+?)\s*$/;
|
|
61
|
-
|
|
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
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
@@ -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
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
};
|