@praxisflux/gates 0.17.0 → 0.19.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/README.md CHANGED
@@ -43,6 +43,9 @@ Dependabot's `github-actions` ecosystem automates that.
43
43
 
44
44
  - **`spec-bridge`** — every Backlog task linked to a Spec Kit spec dir carries a status its
45
45
  spec artifacts prove (needs a `backlog/` dir; passes trivially with zero linked tasks).
46
+ The gate honors the checked repo's own `.spec-bridge.json`: `strictDone` (analyze-gated
47
+ Done) and `statusVocabulary` (opt-in phase-level status names, enforced at that finer
48
+ granularity). The contract for both lives in `spec-bridge/README.md`.
46
49
  - **`wiki-freshness`** — every `docs/wiki` note is fresh against its `verified_against` pin
47
50
  (needs full git history: `fetch-depth: 0`; a shallow clone fails with exactly that fix).
48
51
  - **`course`** — a built codebase-to-course course passes its output gate (self-contained,
@@ -13,6 +13,11 @@
13
13
  // "Done-eligible" deliberately isn't "Done": the sync skill may move the Backlog task to Done,
14
14
  // and the bridge gate treats a Done status without this derivation as a blocking problem.
15
15
  //
16
+ // Every derivation also names a finer STAGE (specifying → planning → implementing →
17
+ // validating → reviewing); the status above is a fixed collapse of it (coarseStatus). The
18
+ // stage feeds the opt-in phase-level board vocabulary in gates/bridge.mjs and is purely
19
+ // additive — nothing here behaves differently because of it.
20
+ //
16
21
  // Strict mode ({ requireAnalysis: true }): checked boxes are necessary but weak proof, so
17
22
  // Done-eligible additionally requires an analysis report saved as analysis.md in the spec dir
18
23
  // (the durable artifact of /speckit.analyze — chat output doesn't count) with no unresolved
@@ -28,6 +33,30 @@ export const STATUS = {
28
33
  DONE_ELIGIBLE: "Done-eligible",
29
34
  };
30
35
 
36
+ // The finer-grained lifecycle ladder underneath the 3-status vocabulary. Every derivation
37
+ // also names the stage it is in; the 3 statuses are a fixed collapse of the 5 stages
38
+ // (specifying → To Do; planning/implementing/validating → In Progress; reviewing →
39
+ // Done-eligible), so the stage is strictly additive information — consumers that never look
40
+ // at it see exactly the behavior above.
41
+ export const STAGE = {
42
+ SPECIFYING: "specifying", // no spec.md yet
43
+ PLANNING: "planning", // spec.md present, no plan.md
44
+ IMPLEMENTING: "implementing", // plan.md present, work remaining outside the final phase
45
+ VALIDATING: "validating", // only the final tasks.md phase still has unchecked work,
46
+ // or all boxes checked but strict-mode analysis pending
47
+ REVIEWING: "reviewing", // everything proven — same state as Done-eligible
48
+ };
49
+
50
+ /** The stages in lifecycle order — the ranking the bridge gate compares against. */
51
+ export const STAGES = [STAGE.SPECIFYING, STAGE.PLANNING, STAGE.IMPLEMENTING, STAGE.VALIDATING, STAGE.REVIEWING];
52
+
53
+ /** The fixed collapse: stage → 3-status. */
54
+ export function coarseStatus(stage) {
55
+ if (stage === STAGE.SPECIFYING) return STATUS.TODO;
56
+ if (stage === STAGE.REVIEWING) return STATUS.DONE_ELIGIBLE;
57
+ return STATUS.IN_PROGRESS;
58
+ }
59
+
31
60
  const PHASE_HEADING = /^##\s+(.+?)\s*$/;
32
61
  const TASK_LINE = /^\s*[-*]\s+\[([ xX])\]\s+\S/;
33
62
 
@@ -100,15 +129,27 @@ export function deriveSpecState(specDir, { requireAnalysis = false } = {}) {
100
129
  const criticals = requireAnalysis && analysisPresent ? findCriticalFindings(read("analysis.md")) : [];
101
130
  const analysisOk = !requireAnalysis || (analysisPresent && criticals.length === 0);
102
131
 
103
- let status = STATUS.TODO;
132
+ // Stage first, status as its fixed collapse — one ladder, two vocabularies. The stage
133
+ // rules refine "In Progress" without moving any 3-status boundary: Done-eligible is still
134
+ // exactly all-boxes-checked (+ clean analysis in strict mode), To Do is still no spec.md.
135
+ const allChecked = tasksTotal > 0 && tasksDone === tasksTotal;
136
+ let stage = STAGE.SPECIFYING;
104
137
  if (has("spec.md")) {
105
- status = has("plan.md") && tasksTotal > 0 && tasksDone === tasksTotal && analysisOk
106
- ? STATUS.DONE_ELIGIBLE
107
- : STATUS.IN_PROGRESS;
138
+ if (!has("plan.md")) stage = STAGE.PLANNING;
139
+ else if (allChecked && analysisOk) stage = STAGE.REVIEWING;
140
+ else {
141
+ // Validating: the only unchecked work left sits in tasks.md's final phase (needs ≥2
142
+ // phases to mean anything), or every box is checked and only the strict-mode analysis
143
+ // artifact is outstanding. Anything earlier is implementing.
144
+ const earlierPhasesDone =
145
+ phases.length >= 2 && phases.slice(0, -1).every((p) => p.done === p.total);
146
+ stage = allChecked || earlierPhasesDone ? STAGE.VALIDATING : STAGE.IMPLEMENTING;
147
+ }
108
148
  }
109
149
 
110
150
  return {
111
- status, phases, tasksDone, tasksTotal, progressNote: progressNote(phases),
151
+ status: coarseStatus(stage), stage, phases, tasksDone, tasksTotal,
152
+ progressNote: progressNote(phases),
112
153
  analysis: { required: requireAnalysis, present: analysisPresent, criticals },
113
154
  };
114
155
  }
@@ -13,6 +13,11 @@
13
13
  // "Done-eligible" deliberately isn't "Done": the sync skill may move the Backlog task to Done,
14
14
  // and the bridge gate treats a Done status without this derivation as a blocking problem.
15
15
  //
16
+ // Every derivation also names a finer STAGE (specifying → planning → implementing →
17
+ // validating → reviewing); the status above is a fixed collapse of it (coarseStatus). The
18
+ // stage feeds the opt-in phase-level board vocabulary in gates/bridge.mjs and is purely
19
+ // additive — nothing here behaves differently because of it.
20
+ //
16
21
  // Strict mode ({ requireAnalysis: true }): checked boxes are necessary but weak proof, so
17
22
  // Done-eligible additionally requires an analysis report saved as analysis.md in the spec dir
18
23
  // (the durable artifact of /speckit.analyze — chat output doesn't count) with no unresolved
@@ -28,6 +33,30 @@ export const STATUS = {
28
33
  DONE_ELIGIBLE: "Done-eligible",
29
34
  };
30
35
 
36
+ // The finer-grained lifecycle ladder underneath the 3-status vocabulary. Every derivation
37
+ // also names the stage it is in; the 3 statuses are a fixed collapse of the 5 stages
38
+ // (specifying → To Do; planning/implementing/validating → In Progress; reviewing →
39
+ // Done-eligible), so the stage is strictly additive information — consumers that never look
40
+ // at it see exactly the behavior above.
41
+ export const STAGE = {
42
+ SPECIFYING: "specifying", // no spec.md yet
43
+ PLANNING: "planning", // spec.md present, no plan.md
44
+ IMPLEMENTING: "implementing", // plan.md present, work remaining outside the final phase
45
+ VALIDATING: "validating", // only the final tasks.md phase still has unchecked work,
46
+ // or all boxes checked but strict-mode analysis pending
47
+ REVIEWING: "reviewing", // everything proven — same state as Done-eligible
48
+ };
49
+
50
+ /** The stages in lifecycle order — the ranking the bridge gate compares against. */
51
+ export const STAGES = [STAGE.SPECIFYING, STAGE.PLANNING, STAGE.IMPLEMENTING, STAGE.VALIDATING, STAGE.REVIEWING];
52
+
53
+ /** The fixed collapse: stage → 3-status. */
54
+ export function coarseStatus(stage) {
55
+ if (stage === STAGE.SPECIFYING) return STATUS.TODO;
56
+ if (stage === STAGE.REVIEWING) return STATUS.DONE_ELIGIBLE;
57
+ return STATUS.IN_PROGRESS;
58
+ }
59
+
31
60
  const PHASE_HEADING = /^##\s+(.+?)\s*$/;
32
61
  const TASK_LINE = /^\s*[-*]\s+\[([ xX])\]\s+\S/;
33
62
 
@@ -100,15 +129,27 @@ export function deriveSpecState(specDir, { requireAnalysis = false } = {}) {
100
129
  const criticals = requireAnalysis && analysisPresent ? findCriticalFindings(read("analysis.md")) : [];
101
130
  const analysisOk = !requireAnalysis || (analysisPresent && criticals.length === 0);
102
131
 
103
- let status = STATUS.TODO;
132
+ // Stage first, status as its fixed collapse — one ladder, two vocabularies. The stage
133
+ // rules refine "In Progress" without moving any 3-status boundary: Done-eligible is still
134
+ // exactly all-boxes-checked (+ clean analysis in strict mode), To Do is still no spec.md.
135
+ const allChecked = tasksTotal > 0 && tasksDone === tasksTotal;
136
+ let stage = STAGE.SPECIFYING;
104
137
  if (has("spec.md")) {
105
- status = has("plan.md") && tasksTotal > 0 && tasksDone === tasksTotal && analysisOk
106
- ? STATUS.DONE_ELIGIBLE
107
- : STATUS.IN_PROGRESS;
138
+ if (!has("plan.md")) stage = STAGE.PLANNING;
139
+ else if (allChecked && analysisOk) stage = STAGE.REVIEWING;
140
+ else {
141
+ // Validating: the only unchecked work left sits in tasks.md's final phase (needs ≥2
142
+ // phases to mean anything), or every box is checked and only the strict-mode analysis
143
+ // artifact is outstanding. Anything earlier is implementing.
144
+ const earlierPhasesDone =
145
+ phases.length >= 2 && phases.slice(0, -1).every((p) => p.done === p.total);
146
+ stage = allChecked || earlierPhasesDone ? STAGE.VALIDATING : STAGE.IMPLEMENTING;
147
+ }
108
148
  }
109
149
 
110
150
  return {
111
- status, phases, tasksDone, tasksTotal, progressNote: progressNote(phases),
151
+ status: coarseStatus(stage), stage, phases, tasksDone, tasksTotal,
152
+ progressNote: progressNote(phases),
112
153
  analysis: { required: requireAnalysis, present: analysisPresent, criticals },
113
154
  };
114
155
  }
@@ -13,6 +13,11 @@
13
13
  // "Done-eligible" deliberately isn't "Done": the sync skill may move the Backlog task to Done,
14
14
  // and the bridge gate treats a Done status without this derivation as a blocking problem.
15
15
  //
16
+ // Every derivation also names a finer STAGE (specifying → planning → implementing →
17
+ // validating → reviewing); the status above is a fixed collapse of it (coarseStatus). The
18
+ // stage feeds the opt-in phase-level board vocabulary in gates/bridge.mjs and is purely
19
+ // additive — nothing here behaves differently because of it.
20
+ //
16
21
  // Strict mode ({ requireAnalysis: true }): checked boxes are necessary but weak proof, so
17
22
  // Done-eligible additionally requires an analysis report saved as analysis.md in the spec dir
18
23
  // (the durable artifact of /speckit.analyze — chat output doesn't count) with no unresolved
@@ -28,6 +33,30 @@ export const STATUS = {
28
33
  DONE_ELIGIBLE: "Done-eligible",
29
34
  };
30
35
 
36
+ // The finer-grained lifecycle ladder underneath the 3-status vocabulary. Every derivation
37
+ // also names the stage it is in; the 3 statuses are a fixed collapse of the 5 stages
38
+ // (specifying → To Do; planning/implementing/validating → In Progress; reviewing →
39
+ // Done-eligible), so the stage is strictly additive information — consumers that never look
40
+ // at it see exactly the behavior above.
41
+ export const STAGE = {
42
+ SPECIFYING: "specifying", // no spec.md yet
43
+ PLANNING: "planning", // spec.md present, no plan.md
44
+ IMPLEMENTING: "implementing", // plan.md present, work remaining outside the final phase
45
+ VALIDATING: "validating", // only the final tasks.md phase still has unchecked work,
46
+ // or all boxes checked but strict-mode analysis pending
47
+ REVIEWING: "reviewing", // everything proven — same state as Done-eligible
48
+ };
49
+
50
+ /** The stages in lifecycle order — the ranking the bridge gate compares against. */
51
+ export const STAGES = [STAGE.SPECIFYING, STAGE.PLANNING, STAGE.IMPLEMENTING, STAGE.VALIDATING, STAGE.REVIEWING];
52
+
53
+ /** The fixed collapse: stage → 3-status. */
54
+ export function coarseStatus(stage) {
55
+ if (stage === STAGE.SPECIFYING) return STATUS.TODO;
56
+ if (stage === STAGE.REVIEWING) return STATUS.DONE_ELIGIBLE;
57
+ return STATUS.IN_PROGRESS;
58
+ }
59
+
31
60
  const PHASE_HEADING = /^##\s+(.+?)\s*$/;
32
61
  const TASK_LINE = /^\s*[-*]\s+\[([ xX])\]\s+\S/;
33
62
 
@@ -100,15 +129,27 @@ export function deriveSpecState(specDir, { requireAnalysis = false } = {}) {
100
129
  const criticals = requireAnalysis && analysisPresent ? findCriticalFindings(read("analysis.md")) : [];
101
130
  const analysisOk = !requireAnalysis || (analysisPresent && criticals.length === 0);
102
131
 
103
- let status = STATUS.TODO;
132
+ // Stage first, status as its fixed collapse — one ladder, two vocabularies. The stage
133
+ // rules refine "In Progress" without moving any 3-status boundary: Done-eligible is still
134
+ // exactly all-boxes-checked (+ clean analysis in strict mode), To Do is still no spec.md.
135
+ const allChecked = tasksTotal > 0 && tasksDone === tasksTotal;
136
+ let stage = STAGE.SPECIFYING;
104
137
  if (has("spec.md")) {
105
- status = has("plan.md") && tasksTotal > 0 && tasksDone === tasksTotal && analysisOk
106
- ? STATUS.DONE_ELIGIBLE
107
- : STATUS.IN_PROGRESS;
138
+ if (!has("plan.md")) stage = STAGE.PLANNING;
139
+ else if (allChecked && analysisOk) stage = STAGE.REVIEWING;
140
+ else {
141
+ // Validating: the only unchecked work left sits in tasks.md's final phase (needs ≥2
142
+ // phases to mean anything), or every box is checked and only the strict-mode analysis
143
+ // artifact is outstanding. Anything earlier is implementing.
144
+ const earlierPhasesDone =
145
+ phases.length >= 2 && phases.slice(0, -1).every((p) => p.done === p.total);
146
+ stage = allChecked || earlierPhasesDone ? STAGE.VALIDATING : STAGE.IMPLEMENTING;
147
+ }
108
148
  }
109
149
 
110
150
  return {
111
- status, phases, tasksDone, tasksTotal, progressNote: progressNote(phases),
151
+ status: coarseStatus(stage), stage, phases, tasksDone, tasksTotal,
152
+ progressNote: progressNote(phases),
112
153
  analysis: { required: requireAnalysis, present: analysisPresent, criticals },
113
154
  };
114
155
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@praxisflux/gates",
3
- "version": "0.17.0",
3
+ "version": "0.19.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": {
@@ -12,22 +12,77 @@
12
12
  // ok — they agree.
13
13
  // unknown — the task uses a status outside To Do / In Progress / Done (custom workflow);
14
14
  // the bridge doesn't guess, so it neither blocks nor warns.
15
+ //
16
+ // A project MAY opt into a finer, phase-level status vocabulary via `statusVocabulary` in
17
+ // `.spec-bridge.json` (see vocabularyProfile): the same four verdicts, ranked on the
18
+ // derivation-stage ladder against the board's own status names. Absent that config, every
19
+ // path below behaves exactly as described above.
15
20
 
16
21
  import { existsSync, readdirSync, readFileSync } from "node:fs";
17
22
  import { join } from "node:path";
18
- import { deriveSpecState, STATUS } from "../lib/spec-derive.mjs";
23
+ import { deriveSpecState, STATUS, STAGE, STAGES } from "../lib/spec-derive.mjs";
19
24
  import { hasChild, findRootsDownwards } from "../lib/project-root.mjs";
20
25
 
21
26
  /**
22
27
  * Per-project bridge config: `.spec-bridge.json` at the project root (beside backlog/).
23
- * `{ "strictDone": true }` turns on analyze-gated Done (see lib/spec-derive.mjs). Missing or
24
- * malformed config means checkbox-only mode strictness is opt-in.
28
+ * `{ "strictDone": true }` turns on analyze-gated Done (see lib/spec-derive.mjs);
29
+ * `"statusVocabulary"` opts the board into phase-level status names (see
30
+ * vocabularyProfile below). Missing or malformed config means checkbox-only mode with the
31
+ * 3-status vocabulary — everything finer is opt-in.
25
32
  */
26
33
  export function loadBridgeConfig(root) {
27
34
  try { return JSON.parse(readFileSync(join(root, ".spec-bridge.json"), "utf8")) ?? {}; }
28
35
  catch { return {}; }
29
36
  }
30
37
 
38
+ /**
39
+ * What each derivation stage is called on a board that has NOT renamed it. This is exactly
40
+ * the 3-status collapse ("reviewing" is named Done because the sync skill's only move from
41
+ * there is `-s Done`), which is why a statusVocabulary that renames nothing behaves
42
+ * bit-for-bit like no statusVocabulary at all.
43
+ */
44
+ export const DEFAULT_STAGE_NAMES = {
45
+ [STAGE.SPECIFYING]: "To Do",
46
+ [STAGE.PLANNING]: "In Progress",
47
+ [STAGE.IMPLEMENTING]: "In Progress",
48
+ [STAGE.VALIDATING]: "In Progress",
49
+ [STAGE.REVIEWING]: "Done",
50
+ };
51
+
52
+ /**
53
+ * The opt-in phase-level vocabulary, normalized. `.spec-bridge.json` may carry
54
+ * { "statusVocabulary": { "<stage>": "<board status name>", ... } }
55
+ * mapping any of the derivation stages (specifying / planning / implementing / validating /
56
+ * reviewing) to the consumer board's own status names; unmapped stages keep their
57
+ * DEFAULT_STAGE_NAMES. Returns null — 3-status behavior, unchanged — unless at least one
58
+ * stage is validly renamed (string values only; unknown keys ignored).
59
+ *
60
+ * The profile carries:
61
+ * names — stage → board status name (defaults overlaid with the config)
62
+ * cover — lowercase board status name → { min, max } span of stage ranks it stands for
63
+ * (a name used for several stages honestly covers all of them; "Done" always
64
+ * covers at least the top stage, so Done-eligibility is unchanged by opting in)
65
+ */
66
+ export function vocabularyProfile(config) {
67
+ const raw = config?.statusVocabulary;
68
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
69
+ const names = { ...DEFAULT_STAGE_NAMES };
70
+ let renamed = false;
71
+ for (const key of STAGES) {
72
+ if (typeof raw[key] === "string" && raw[key].trim()) { names[key] = raw[key].trim(); renamed = true; }
73
+ }
74
+ if (!renamed) return null;
75
+ const top = STAGES.length - 1;
76
+ const cover = new Map([["done", { min: top, max: top }]]);
77
+ STAGES.forEach((stageKey, rank) => {
78
+ const name = names[stageKey].toLowerCase();
79
+ const c = cover.get(name);
80
+ if (!c) cover.set(name, { min: rank, max: rank });
81
+ else { c.min = Math.min(c.min, rank); c.max = Math.max(c.max, rank); }
82
+ });
83
+ return { names, cover };
84
+ }
85
+
31
86
  const MARKER = /^Spec:\s*(\S+?)\/?\s*$/m;
32
87
  const RANK = { "to do": 0, "in progress": 1, done: 2 };
33
88
  const DERIVED_RANK = { [STATUS.TODO]: 0, [STATUS.IN_PROGRESS]: 1, [STATUS.DONE_ELIGIBLE]: 2 };
@@ -79,6 +134,20 @@ export function verdict(taskStatus, derivedStatus) {
79
134
  return t > d ? "exceeds" : t < d ? "lags" : "ok";
80
135
  }
81
136
 
137
+ /**
138
+ * The same comparison at phase grain, against an opted-in vocabulary profile: a board status
139
+ * exceeds when even the EARLIEST stage it stands for is later than the derived stage, lags
140
+ * when even the LATEST is earlier, is ok anywhere inside its span, and is unknown when the
141
+ * status isn't in the vocabulary (custom workflow: don't guess) — verdict()'s semantics,
142
+ * finer ruler.
143
+ */
144
+ export function stageVerdict(taskStatus, derivedStage, profile) {
145
+ const c = profile.cover.get(String(taskStatus).toLowerCase());
146
+ const d = STAGES.indexOf(derivedStage);
147
+ if (!c || d < 0) return "unknown";
148
+ return c.min > d ? "exceeds" : c.max < d ? "lags" : "ok";
149
+ }
150
+
82
151
  /** One human sentence on why a spec dir doesn't prove more than its derived status. */
83
152
  function shortfall(root, specDir, derived) {
84
153
  const missing = ["spec.md", "plan.md"].filter((f) => !existsSync(join(root, specDir, f)));
@@ -105,19 +174,24 @@ export function checkBridge(root) {
105
174
  const links = [];
106
175
  const problems = [];
107
176
  const warnings = [];
108
- const requireAnalysis = loadBridgeConfig(root).strictDone === true;
177
+ const config = loadBridgeConfig(root);
178
+ const requireAnalysis = config.strictDone === true;
179
+ const profile = vocabularyProfile(config);
109
180
  for (const task of findLinkedTasks(root)) {
110
181
  const derived = deriveSpecState(join(root, task.specDir), { requireAnalysis });
111
- const v = verdict(task.status, derived.status);
182
+ // Opted-in boards are judged on the stage ladder against their own status names;
183
+ // everyone else gets the 3-status comparison, untouched.
184
+ const v = profile ? stageVerdict(task.status, derived.stage, profile) : verdict(task.status, derived.status);
185
+ const proven = profile ? profile.names[derived.stage] : derived.status;
112
186
  links.push({ ...task, derived, verdict: v });
113
187
  if (v === "exceeds") {
114
188
  problems.push(
115
- `[spec-bridge] ${task.id} is "${task.status}" but ${task.specDir} only proves "${derived.status}": ` +
189
+ `[spec-bridge] ${task.id} is "${task.status}" but ${task.specDir} only proves "${proven}": ` +
116
190
  `${shortfall(root, task.specDir, derived)}. Finish the spec work or set the task back (backlog task edit ${task.id} -s "...").`
117
191
  );
118
192
  } else if (v === "lags") {
119
193
  warnings.push(
120
- `[spec-bridge] ${task.id} is "${task.status}" but ${task.specDir} already derives "${derived.status}" — run the spec-bridge sync skill to catch the board up.`
194
+ `[spec-bridge] ${task.id} is "${task.status}" but ${task.specDir} already derives "${proven}" — run the spec-bridge sync skill to catch the board up.`
121
195
  );
122
196
  } else if (
123
197
  // Strict-mode near-miss: the status is honest ("ok"), every checkbox is checked, and
@@ -156,13 +230,22 @@ const sq = (s) => `'${String(s).replace(/'/g, "'\\''")}'`;
156
230
  * removals (highest index first, so earlier indexes stay valid) → phase-AC additions →
157
231
  * check/uncheck at post-edit indexes → one progress note (only when something changed).
158
232
  * ACs that don't start with "Spec phase: " are human-authored and are never touched.
233
+ *
234
+ * With an opted-in vocabulary profile (third argument), status targets are the profile's
235
+ * stage names instead of the 3-status collapse. Done keeps its meaning: a board that leaves
236
+ * "reviewing" at its default still plans `-s Done` with the derived final summary; a board
237
+ * that names it (say "In Review") is planned to that name, and moving to Done stays a
238
+ * human/consumer act the gate already accepts (Done never exceeds a fully-proven spec).
159
239
  */
160
- export function planLinkedTask(task, derived) {
240
+ export function planLinkedTask(task, derived, profile = null) {
161
241
  const cmds = [];
162
242
  const edit = (args) => cmds.push(`backlog task edit ${task.id} ${args}`);
163
243
 
164
244
  // Status — Done-eligible is the only path to Done and carries the derived final summary.
165
- const target = derived.status === STATUS.DONE_ELIGIBLE ? "Done" : derived.status;
245
+ const mapped = profile ? profile.names[derived.stage] : null;
246
+ const target = derived.status === STATUS.DONE_ELIGIBLE
247
+ ? (mapped && mapped.toLowerCase() !== "done" ? mapped : "Done")
248
+ : (mapped ?? derived.status);
166
249
  const statusChanged = String(task.status).toLowerCase() !== target.toLowerCase();
167
250
  if (statusChanged) {
168
251
  if (target === "Done")
@@ -218,11 +301,14 @@ export function planLinkedTask(task, derived) {
218
301
  export function planBridge(root) {
219
302
  const commands = [];
220
303
  const skipped = [];
221
- const requireAnalysis = loadBridgeConfig(root).strictDone === true;
304
+ const config = loadBridgeConfig(root);
305
+ const requireAnalysis = config.strictDone === true;
306
+ const profile = vocabularyProfile(config);
222
307
  for (const task of findLinkedTasks(root)) {
223
308
  const derived = deriveSpecState(join(root, task.specDir), { requireAnalysis });
224
- if (verdict(task.status, derived.status) === "unknown") { skipped.push({ id: task.id, status: task.status }); continue; }
225
- commands.push(...planLinkedTask(task, derived));
309
+ const v = profile ? stageVerdict(task.status, derived.stage, profile) : verdict(task.status, derived.status);
310
+ if (v === "unknown") { skipped.push({ id: task.id, status: task.status }); continue; }
311
+ commands.push(...planLinkedTask(task, derived, profile));
226
312
  }
227
313
  return { commands, skipped };
228
314
  }
@@ -10,7 +10,7 @@
10
10
  import { resolve } from "node:path";
11
11
  import { deriveSpecState } from "../lib/spec-derive.mjs";
12
12
  import { findRootUpwards, hasChild } from "../lib/project-root.mjs";
13
- import { checkBridge, loadBridgeConfig, planBridge } from "./bridge.mjs";
13
+ import { checkBridge, loadBridgeConfig, planBridge, vocabularyProfile } from "./bridge.mjs";
14
14
 
15
15
  const [cmd, target] = process.argv.slice(2);
16
16
  if (!cmd || !target) {
@@ -36,8 +36,14 @@ if (cmd === "state") {
36
36
  console.log(`spec-bridge ok: ${links.length} linked task(s), none exceed their artifacts`);
37
37
  } else if (cmd === "plan") {
38
38
  const { commands, skipped } = planBridge(target);
39
+ // Name the vocabulary the board actually speaks: the opted-in phase-level names when
40
+ // .spec-bridge.json carries a statusVocabulary, the 3-status default otherwise.
41
+ const profile = vocabularyProfile(loadBridgeConfig(target));
42
+ const vocab = profile
43
+ ? [...new Set([...Object.values(profile.names), "Done"])].join("/")
44
+ : "To Do/In Progress/Done";
39
45
  for (const s of skipped)
40
- console.error(`# ${s.id}: status "${s.status}" is outside To Do/In Progress/Done — not planned; resolve by hand`);
46
+ console.error(`# ${s.id}: status "${s.status}" is outside ${vocab} — not planned; resolve by hand`);
41
47
  for (const c of commands) console.log(c);
42
48
  } else {
43
49
  console.error(`unknown command: ${cmd}`);
@@ -13,6 +13,11 @@
13
13
  // "Done-eligible" deliberately isn't "Done": the sync skill may move the Backlog task to Done,
14
14
  // and the bridge gate treats a Done status without this derivation as a blocking problem.
15
15
  //
16
+ // Every derivation also names a finer STAGE (specifying → planning → implementing →
17
+ // validating → reviewing); the status above is a fixed collapse of it (coarseStatus). The
18
+ // stage feeds the opt-in phase-level board vocabulary in gates/bridge.mjs and is purely
19
+ // additive — nothing here behaves differently because of it.
20
+ //
16
21
  // Strict mode ({ requireAnalysis: true }): checked boxes are necessary but weak proof, so
17
22
  // Done-eligible additionally requires an analysis report saved as analysis.md in the spec dir
18
23
  // (the durable artifact of /speckit.analyze — chat output doesn't count) with no unresolved
@@ -28,6 +33,30 @@ export const STATUS = {
28
33
  DONE_ELIGIBLE: "Done-eligible",
29
34
  };
30
35
 
36
+ // The finer-grained lifecycle ladder underneath the 3-status vocabulary. Every derivation
37
+ // also names the stage it is in; the 3 statuses are a fixed collapse of the 5 stages
38
+ // (specifying → To Do; planning/implementing/validating → In Progress; reviewing →
39
+ // Done-eligible), so the stage is strictly additive information — consumers that never look
40
+ // at it see exactly the behavior above.
41
+ export const STAGE = {
42
+ SPECIFYING: "specifying", // no spec.md yet
43
+ PLANNING: "planning", // spec.md present, no plan.md
44
+ IMPLEMENTING: "implementing", // plan.md present, work remaining outside the final phase
45
+ VALIDATING: "validating", // only the final tasks.md phase still has unchecked work,
46
+ // or all boxes checked but strict-mode analysis pending
47
+ REVIEWING: "reviewing", // everything proven — same state as Done-eligible
48
+ };
49
+
50
+ /** The stages in lifecycle order — the ranking the bridge gate compares against. */
51
+ export const STAGES = [STAGE.SPECIFYING, STAGE.PLANNING, STAGE.IMPLEMENTING, STAGE.VALIDATING, STAGE.REVIEWING];
52
+
53
+ /** The fixed collapse: stage → 3-status. */
54
+ export function coarseStatus(stage) {
55
+ if (stage === STAGE.SPECIFYING) return STATUS.TODO;
56
+ if (stage === STAGE.REVIEWING) return STATUS.DONE_ELIGIBLE;
57
+ return STATUS.IN_PROGRESS;
58
+ }
59
+
31
60
  const PHASE_HEADING = /^##\s+(.+?)\s*$/;
32
61
  const TASK_LINE = /^\s*[-*]\s+\[([ xX])\]\s+\S/;
33
62
 
@@ -100,15 +129,27 @@ export function deriveSpecState(specDir, { requireAnalysis = false } = {}) {
100
129
  const criticals = requireAnalysis && analysisPresent ? findCriticalFindings(read("analysis.md")) : [];
101
130
  const analysisOk = !requireAnalysis || (analysisPresent && criticals.length === 0);
102
131
 
103
- let status = STATUS.TODO;
132
+ // Stage first, status as its fixed collapse — one ladder, two vocabularies. The stage
133
+ // rules refine "In Progress" without moving any 3-status boundary: Done-eligible is still
134
+ // exactly all-boxes-checked (+ clean analysis in strict mode), To Do is still no spec.md.
135
+ const allChecked = tasksTotal > 0 && tasksDone === tasksTotal;
136
+ let stage = STAGE.SPECIFYING;
104
137
  if (has("spec.md")) {
105
- status = has("plan.md") && tasksTotal > 0 && tasksDone === tasksTotal && analysisOk
106
- ? STATUS.DONE_ELIGIBLE
107
- : STATUS.IN_PROGRESS;
138
+ if (!has("plan.md")) stage = STAGE.PLANNING;
139
+ else if (allChecked && analysisOk) stage = STAGE.REVIEWING;
140
+ else {
141
+ // Validating: the only unchecked work left sits in tasks.md's final phase (needs ≥2
142
+ // phases to mean anything), or every box is checked and only the strict-mode analysis
143
+ // artifact is outstanding. Anything earlier is implementing.
144
+ const earlierPhasesDone =
145
+ phases.length >= 2 && phases.slice(0, -1).every((p) => p.done === p.total);
146
+ stage = allChecked || earlierPhasesDone ? STAGE.VALIDATING : STAGE.IMPLEMENTING;
147
+ }
108
148
  }
109
149
 
110
150
  return {
111
- status, phases, tasksDone, tasksTotal, progressNote: progressNote(phases),
151
+ status: coarseStatus(stage), stage, phases, tasksDone, tasksTotal,
152
+ progressNote: progressNote(phases),
112
153
  analysis: { required: requireAnalysis, present: analysisPresent, criticals },
113
154
  };
114
155
  }