@llman-sdd/core 0.3.0 → 0.5.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.
Files changed (107) hide show
  1. package/package.json +2 -1
  2. package/src/archive/freeze.ts +86 -18
  3. package/src/archive/frozenCard.ts +105 -0
  4. package/src/archive/sevenzip.ts +15 -13
  5. package/src/change/closeOutHarness.ts +29 -0
  6. package/src/change/collect.ts +140 -0
  7. package/src/change/frontmatter.ts +48 -6
  8. package/src/change/id.ts +2 -6
  9. package/src/change/lifecycle.ts +285 -86
  10. package/src/change/nextId.ts +63 -2
  11. package/src/change/resolve.ts +2 -2
  12. package/src/change/tasks.ts +59 -0
  13. package/src/config/changeId.ts +14 -12
  14. package/src/config/load.ts +14 -0
  15. package/src/config/schema.ts +4 -41
  16. package/src/config/surface.ts +6 -36
  17. package/src/context/indexStore.ts +7 -3
  18. package/src/context/retrieve.ts +8 -10
  19. package/src/context/tree.ts +28 -24
  20. package/src/git/spawnGit.ts +90 -2
  21. package/src/index.ts +67 -59
  22. package/src/init/defaultConfig.ts +1 -5
  23. package/src/init/init.ts +19 -4
  24. package/src/ports.ts +1 -7
  25. package/src/project/migrateNotes.ts +104 -0
  26. package/src/render/machine.ts +30 -0
  27. package/src/report/collect.ts +11 -127
  28. package/src/report/graph/analysis.ts +152 -0
  29. package/src/report/graph/deps.ts +30 -0
  30. package/src/report/graph/graphData.ts +53 -0
  31. package/src/report/graph/nodes.ts +130 -0
  32. package/src/report/graph/render.ts +83 -0
  33. package/src/report/graph/types.ts +47 -0
  34. package/src/report/graph.ts +9 -381
  35. package/src/report/show.ts +20 -22
  36. package/src/report/specHelpers.ts +42 -21
  37. package/src/report/specs.ts +23 -25
  38. package/src/review/review.ts +45 -30
  39. package/src/spec/authoring.ts +91 -63
  40. package/src/spec/ir.ts +43 -15
  41. package/src/spec/migrateNative.ts +167 -0
  42. package/src/spec/parser.ts +73 -77
  43. package/src/spec/reqRegistry.ts +8 -9
  44. package/src/templates/embedded.ts +10 -16
  45. package/src/templates/engine.ts +10 -5
  46. package/src/templates/locale.ts +1 -1
  47. package/src/templates/skills.ts +4 -5
  48. package/src/validation/changeCheck.ts +128 -105
  49. package/src/validation/harness.ts +161 -0
  50. package/src/validation/staleness.ts +9 -5
  51. package/src/validation/validate.ts +60 -88
  52. package/templates/en/skills/llman-sdd-apply-cycle.md +20 -28
  53. package/templates/en/skills/llman-sdd-apply.md +58 -76
  54. package/templates/en/skills/llman-sdd-arch-review.md +12 -19
  55. package/templates/en/skills/llman-sdd-archive.md +27 -42
  56. package/templates/en/skills/llman-sdd-continue.md +17 -24
  57. package/templates/en/skills/llman-sdd-draft.md +17 -28
  58. package/templates/en/skills/llman-sdd-explore.md +29 -43
  59. package/templates/en/skills/llman-sdd-ff.md +12 -17
  60. package/templates/en/skills/llman-sdd-graph.md +14 -32
  61. package/templates/en/skills/llman-sdd-propose.md +48 -63
  62. package/templates/en/skills/llman-sdd-quick.md +12 -27
  63. package/templates/en/skills/llman-sdd-research.md +13 -24
  64. package/templates/en/skills/llman-sdd-specs-compact.md +14 -39
  65. package/templates/en/skills/llman-sdd-validate.md +11 -15
  66. package/templates/en/skills/llman-sdd-verify.md +23 -44
  67. package/templates/en/skills/llman-sdd-wayfinder.md +18 -22
  68. package/templates/en/units/skills/cli-footer.md +2 -0
  69. package/templates/en/units/skills/git-native-flow-brief.md +7 -6
  70. package/templates/en/units/skills/git-native-flow.md +21 -11
  71. package/templates/en/units/skills/human-readable-summary.md +2 -3
  72. package/templates/en/units/skills/stage-guard.md +7 -7
  73. package/templates/en/units/skills/structured-protocol.md +5 -8
  74. package/templates/en/units/skills/validation-hints.md +10 -14
  75. package/templates/en/units/spec/feature-contract.md +27 -16
  76. package/templates/en/units/workflow/archive-freeze-guidance.md +6 -3
  77. package/templates/zh-Hans/skills/llman-sdd-apply-cycle.md +23 -31
  78. package/templates/zh-Hans/skills/llman-sdd-apply.md +63 -81
  79. package/templates/zh-Hans/skills/llman-sdd-arch-review.md +21 -28
  80. package/templates/zh-Hans/skills/llman-sdd-archive.md +29 -44
  81. package/templates/zh-Hans/skills/llman-sdd-continue.md +17 -24
  82. package/templates/zh-Hans/skills/llman-sdd-draft.md +18 -29
  83. package/templates/zh-Hans/skills/llman-sdd-explore.md +34 -48
  84. package/templates/zh-Hans/skills/llman-sdd-ff.md +13 -18
  85. package/templates/zh-Hans/skills/llman-sdd-graph.md +16 -34
  86. package/templates/zh-Hans/skills/llman-sdd-propose.md +51 -65
  87. package/templates/zh-Hans/skills/llman-sdd-quick.md +15 -30
  88. package/templates/zh-Hans/skills/llman-sdd-research.md +17 -28
  89. package/templates/zh-Hans/skills/llman-sdd-specs-compact.md +15 -40
  90. package/templates/zh-Hans/skills/llman-sdd-validate.md +11 -15
  91. package/templates/zh-Hans/skills/llman-sdd-verify.md +26 -47
  92. package/templates/zh-Hans/skills/llman-sdd-wayfinder.md +25 -29
  93. package/templates/zh-Hans/units/skills/cli-footer.md +2 -0
  94. package/templates/zh-Hans/units/skills/git-native-flow-brief.md +7 -6
  95. package/templates/zh-Hans/units/skills/git-native-flow.md +22 -12
  96. package/templates/zh-Hans/units/skills/human-readable-summary.md +4 -5
  97. package/templates/zh-Hans/units/skills/stage-guard.md +9 -9
  98. package/templates/zh-Hans/units/skills/structured-protocol.md +5 -8
  99. package/templates/zh-Hans/units/skills/validation-hints.md +10 -14
  100. package/templates/zh-Hans/units/spec/feature-contract.md +25 -16
  101. package/templates/zh-Hans/units/workflow/archive-freeze-guidance.md +6 -2
  102. package/templates/en/skills/llman-sdd-onboard.md +0 -34
  103. package/templates/en/skills/llman-sdd-show.md +0 -24
  104. package/templates/en/units/migrate-prompt.md +0 -28
  105. package/templates/zh-Hans/skills/llman-sdd-onboard.md +0 -34
  106. package/templates/zh-Hans/skills/llman-sdd-show.md +0 -24
  107. package/templates/zh-Hans/units/migrate-prompt.md +0 -28
@@ -1,6 +1,13 @@
1
- import { readBinding } from '../change/frontmatter.ts';
1
+ import { stageFor, walkActiveChangeDirs } from '../change/collect.ts';
2
+ import {
3
+ extractFrontmatter,
4
+ readBinding,
5
+ readNeedsSpecsChange,
6
+ specsLanded,
7
+ } from '../change/frontmatter.ts';
8
+ import { CLOSE_OUT_TASK_HINT, closeOutTaskLines, parseTaskCheckboxes } from '../change/tasks.ts';
2
9
  /**
3
- * Change-domain validation (v1 `commands/validate.rs` change path parity):
10
+ * Change-domain validation (predecessor `commands/validate.rs` change path parity):
4
11
  * frontmatter/depends_on gates, design/tasks constraints, completeness stage
5
12
  * INFO, pattern gate and task gates. IO + git injected (pure).
6
13
  */
@@ -10,7 +17,7 @@ export type ChangeIssueLevel = 'ERROR' | 'WARNING' | 'INFO';
10
17
 
11
18
  export interface ChangeIssue {
12
19
  level: ChangeIssueLevel;
13
- /** v1-style anchor (issue path), e.g. `proposal.md/frontmatter.depends_on`. */
20
+ /** predecessor-style anchor (issue path), e.g. `proposal.md/frontmatter.depends_on`. */
14
21
  path: string;
15
22
  message: string;
16
23
  }
@@ -25,7 +32,6 @@ export interface ChangeCheckInput {
25
32
 
26
33
  export interface ChangeCheckConfig {
27
34
  strict_defer?: boolean | null;
28
- min_completion_ratio?: number | null;
29
35
  change_id_pattern?: string | null;
30
36
  }
31
37
 
@@ -38,6 +44,62 @@ export interface ChangeFsIoLite {
38
44
 
39
45
  export const STAGE_ORDER = ['draft', 'designed', 'planned', 'full'] as const;
40
46
 
47
+ /**
48
+ * r73 dependency-reference resolution: `active` matches a leaf change dir by
49
+ * name (nested groups included — same caliber as the CLI change scan),
50
+ * `archived` matches an `archive/<YYYY-MM-DD>-<id>` dir OR a flat frozen card
51
+ * `archive/<YYYY-MM-DD>-<id>.yaml` exactly (a suffix like
52
+ * `2026-01-01-the-other` must NOT satisfy id `other`). A missing
53
+ * `archive/` dir reads as empty and never throws.
54
+ */
55
+ function resolveChangeRef(
56
+ io: ChangeFsIoLite,
57
+ changesRoot: string,
58
+ id: string,
59
+ ): 'active' | 'archived' | 'unknown' {
60
+ let active = false;
61
+ walkActiveChangeDirs(io, changesRoot, (_dir, name) => {
62
+ if (name === id) active = true;
63
+ });
64
+ if (active) return 'active';
65
+ const archive = `${changesRoot}/archive`;
66
+ const entries = io.exists(archive) && io.isDirectory(archive) ? io.listDir(archive) : [];
67
+ // `YYYY-MM-DD-` is 11 chars; the remainder must equal the id exactly.
68
+ const archived = entries.some((name) => {
69
+ if (!/^\d{4}-\d{2}-\d{2}-/u.test(name)) return false;
70
+ const base = name.endsWith('.yaml') ? name.slice(0, -'.yaml'.length) : name;
71
+ return base.slice(11) === id;
72
+ });
73
+ return archived ? 'archived' : 'unknown';
74
+ }
75
+
76
+ /**
77
+ * r87: global change-id uniqueness gate. Enumerates active change ids (leaf
78
+ * dir names), archived dir ids and frozen-card ids, and returns every id that
79
+ * appears more than once across the whole set. This is the cheap, enumerable
80
+ * form of the uniqueness contract: frozen ids are no longer hidden inside the
81
+ * cold backup, so collisions are checkable without thawing.
82
+ */
83
+ export function checkGlobalChangeIdUniqueness(io: ChangeFsIoLite, changesRoot: string): string[] {
84
+ const archive = `${changesRoot}/archive`;
85
+ const entries = io.exists(archive) && io.isDirectory(archive) ? io.listDir(archive) : [];
86
+ const counts = new Map<string, number>();
87
+ walkActiveChangeDirs(io, changesRoot, (_dir, name) => {
88
+ counts.set(name, (counts.get(name) ?? 0) + 1);
89
+ });
90
+ for (const name of entries) {
91
+ if (!/^\d{4}-\d{2}-\d{2}-/u.test(name)) continue;
92
+ const base = name.endsWith('.yaml') ? name.slice(0, -'.yaml'.length) : name;
93
+ const id = base.slice(11);
94
+ if (id === '') continue;
95
+ counts.set(id, (counts.get(id) ?? 0) + 1);
96
+ }
97
+ return [...counts]
98
+ .filter(([, n]) => n > 1)
99
+ .map(([id]) => id)
100
+ .toSorted((a, b) => a.localeCompare(b));
101
+ }
102
+
41
103
  export type StageGate = (typeof STAGE_ORDER)[number];
42
104
 
43
105
  export interface ChangeCheckResult {
@@ -46,11 +108,14 @@ export interface ChangeCheckResult {
46
108
  issues: ChangeIssue[];
47
109
  }
48
110
 
49
- /** Legacy pure gate (r47 surface) — kept for existing callers/tests. */
111
+ /**
112
+ * Legacy pure gate (r47 surface) — structural checks only (tasks, binding,
113
+ * pattern); the stage-ordinal branch was removed with the dual-caliber gate
114
+ * (the artifact-presence `--stage` gate lives in validateChange).
115
+ */
50
116
  export function checkChangeDoc(
51
117
  input: ChangeCheckInput,
52
118
  config: ChangeCheckConfig,
53
- opts: { stage?: StageGate } = {},
54
119
  ): ChangeCheckResult {
55
120
  const issues: ChangeIssue[] = [];
56
121
  if (input.totalTasks > 0 && input.completedTasks < input.totalTasks) {
@@ -61,18 +126,6 @@ export function checkChangeDoc(
61
126
  message: `${pending} unchecked task(s) in tasks.md`,
62
127
  });
63
128
  }
64
- if (
65
- config.min_completion_ratio !== undefined &&
66
- config.min_completion_ratio !== null &&
67
- input.totalTasks > 0 &&
68
- input.completedTasks / input.totalTasks < config.min_completion_ratio
69
- ) {
70
- issues.push({
71
- level: 'ERROR',
72
- path: 'tasks.md',
73
- message: `completion ratio below archive.min_completion_ratio (${config.min_completion_ratio})`,
74
- });
75
- }
76
129
  if (!input.hasBinding) {
77
130
  issues.push({
78
131
  level: 'WARNING',
@@ -81,23 +134,20 @@ export function checkChangeDoc(
81
134
  });
82
135
  }
83
136
  if (config.change_id_pattern) {
84
- const re = new RegExp(config.change_id_pattern, 'u');
85
- if (!re.test(input.name)) {
137
+ try {
138
+ const re = new RegExp(config.change_id_pattern, 'u');
139
+ if (!re.test(input.name)) {
140
+ issues.push({
141
+ level: 'ERROR',
142
+ path: 'change-id',
143
+ message: `Change id '${input.name}' does not match change_id.pattern '${config.change_id_pattern}' (active changes only; archived changes are not re-checked).`,
144
+ });
145
+ }
146
+ } catch (error) {
86
147
  issues.push({
87
148
  level: 'ERROR',
88
149
  path: 'change-id',
89
- message: `Change id '${input.name}' does not match change_id.pattern '${config.change_id_pattern}' (sdd-workflow r29; scope: active changes only, archive/legacy shapes are not back-checked).`,
90
- });
91
- }
92
- }
93
- if (opts.stage !== undefined) {
94
- const currentIdx = STAGE_ORDER.indexOf(input.stage);
95
- const requiredIdx = STAGE_ORDER.indexOf(opts.stage);
96
- if (currentIdx < requiredIdx) {
97
- issues.push({
98
- level: 'ERROR',
99
- path: 'stage',
100
- message: `stage \`${input.stage}\` is below the required \`${opts.stage}\``,
150
+ message: `change_id.pattern is not a valid regular expression: ${(error as Error).message}`,
101
151
  });
102
152
  }
103
153
  }
@@ -108,17 +158,17 @@ export type ChangeValidationConfig = ChangeCheckConfig;
108
158
 
109
159
  const COMPLETENESS: Record<string, string> = {
110
160
  draft:
111
- "Change is in 'draft' stage (next: add design.md + tasks.md, then `llman sdd change start <id>` to enter a feature branch)",
161
+ "Change is in 'draft' stage (next: add design.md + tasks.md, then `llman-sdd change start <id>` to enter a feature branch)",
112
162
  designed:
113
- "Change is in 'designed' stage (next: add tasks.md to reach 'planned', then `llman sdd change start` to enter feature branch and reach 'full')",
163
+ "Change is in 'designed' stage (next: add tasks.md to reach 'planned', then `llman-sdd change start` to enter feature branch and reach 'full')",
114
164
  planned:
115
- "Change is in 'planned' stage (next: `llman sdd change start` to enter feature branch and reach 'full')",
116
- full: "Change is bound and stage is 'full' (verify readiness via `llman sdd show <id> --json` readyToImplement)",
165
+ "Change is in 'planned' stage (next: `llman-sdd change start` to enter feature branch and reach 'full')",
166
+ full: "Change is bound and stage is 'full' (verify readiness via `llman-sdd show <id> --json` readyToImplement)",
117
167
  };
118
168
 
119
169
  /**
120
- * File-aware change validation with v1 messages/paths (used by the validate
121
- * command). `strict` escalates WARNING issues to ERROR (v1 build_report).
170
+ * File-aware change validation with predecessor messages/paths (used by the validate
171
+ * command). `strict` escalates WARNING issues to ERROR (predecessor build_report).
122
172
  * `git` (optional) enables the r63 completeness WARNINGs — Full-not-ready
123
173
  * with skill guidance, per change.
124
174
  */
@@ -137,12 +187,15 @@ export function validateChange(
137
187
  const dir = `${root}/llmanspec/changes/${id}/`;
138
188
  const baseDir = `${root}/llmanspec/changes`;
139
189
  const proposal = `${dir}proposal.md`;
190
+ // Probed once here: shared by the stage inference below and the --stage
191
+ // artifact gate at the end (predecessor issue order preserved).
192
+ const hasDesign = io.exists(`${dir}design.md`);
193
+ const hasTasks = io.exists(`${dir}tasks.md`);
140
194
  if (!io.exists(proposal)) {
141
195
  push('ERROR', 'proposal.md', 'Change is missing proposal.md.');
142
196
  } else {
143
197
  const text = io.readText(proposal);
144
- const fmMatch = text.match(/^---\n([\s\S]*?)\n---/u);
145
- const fm = fmMatch?.[1] ?? '';
198
+ const fm = extractFrontmatter(text) ?? '';
146
199
  const fmLines = fm.split('\n').map((l) => l.trim());
147
200
 
148
201
  for (const key of ['depends_on', 'blocks'] as const) {
@@ -180,13 +233,7 @@ export function validateChange(
180
233
  );
181
234
  continue;
182
235
  }
183
- const depDir = `${baseDir}/${item}`;
184
- const archived = `${baseDir}/archive`;
185
- if (
186
- !io.exists(`${depDir}/proposal.md`) &&
187
- !io.exists(dir.replace(/\/[^/]+\/$/u, '/archive/')) &&
188
- !io.listDir(archived).some((n) => n.endsWith(`-${item}`))
189
- ) {
236
+ if (resolveChangeRef(io, baseDir, item) === 'unknown') {
190
237
  push(
191
238
  'ERROR',
192
239
  `proposal.md/frontmatter.${key}`,
@@ -196,7 +243,7 @@ export function validateChange(
196
243
  }
197
244
  }
198
245
 
199
- // Unknown frontmatter field gate (v1).
246
+ // Unknown frontmatter field gate (predecessor).
200
247
  for (const l of fmLines) {
201
248
  if (l === '' || l.startsWith('-') || l.startsWith('#')) continue;
202
249
  const m = l.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:/u);
@@ -204,22 +251,14 @@ export function validateChange(
204
251
  push(
205
252
  'ERROR',
206
253
  'proposal.md/frontmatter',
207
- `proposal.md frontmatter has unknown field '${m[1]}'; allowed fields are: ${ALLOWED_FIELDS.join(', ')}. Stage is inferred from on-disk artifacts (run \`llman sdd show\` / \`llman sdd list\`); do not store lifecycle state in frontmatter.`,
254
+ `proposal.md frontmatter has unknown field '${m[1]}'; allowed fields are: ${ALLOWED_FIELDS.join(', ')}. Stage is inferred from on-disk artifacts (run \`llman-sdd show\` / \`llman-sdd list\`); do not store lifecycle state in frontmatter.`,
208
255
  );
209
256
  }
210
257
  }
211
258
 
212
- const hasDesign = io.exists(`${dir}design.md`);
213
- const hasTasks = io.exists(`${dir}tasks.md`);
214
259
  const binding = readBinding(text);
215
- const stage =
216
- hasDesign && hasTasks
217
- ? binding !== null
218
- ? 'full'
219
- : 'planned'
220
- : hasDesign
221
- ? 'designed'
222
- : 'draft';
260
+ // stageFor (change/collect) is the monotonic stage SSOT (r34).
261
+ const stage = stageFor(hasDesign, hasTasks, binding !== null);
223
262
 
224
263
  if (hasTasks && !hasDesign) {
225
264
  push(
@@ -230,16 +269,7 @@ export function validateChange(
230
269
  }
231
270
 
232
271
  if (hasTasks) {
233
- const tasks = io.readText(`${dir}tasks.md`);
234
- let total = 0;
235
- let completed = 0;
236
- for (const line of tasks.split('\n')) {
237
- const m = line.match(/^\s*-\s+\[( |x|X)\]/u);
238
- if (m) {
239
- total += 1;
240
- if (m[1] !== ' ') completed += 1;
241
- }
242
- }
272
+ const { completed, total, pendingLines } = parseTaskCheckboxes(io.readText(`${dir}tasks.md`));
243
273
  if (total > 0 && completed < total) {
244
274
  const n = total - completed;
245
275
  push(
@@ -248,31 +278,41 @@ export function validateChange(
248
278
  `${n} unchecked task(s) in tasks.md`,
249
279
  );
250
280
  }
281
+ // D9/B23 (active changes only — validate domain): an unchecked task whose
282
+ // title starts with a close-out verb is a pipeline-step pseudo-task; the
283
+ // task gate requires every task checked, so it can never legitimately
284
+ // stay as a task. Match on the leading verb only, so "fix finalize task
285
+ // gate" (a task ABOUT close-out) is not flagged.
286
+ for (const line of closeOutTaskLines(pendingLines)) {
287
+ const title = line.replace(/^-\s+\[ \]\s*/u, '').trim();
288
+ push(
289
+ 'WARNING',
290
+ 'tasks',
291
+ `task "${title}" looks like a close-out step; ${CLOSE_OUT_TASK_HINT}`,
292
+ );
293
+ }
251
294
  }
252
295
 
253
- // completeness INFO (v1 surface).
296
+ // completeness INFO (predecessor surface).
254
297
  push('INFO', 'completeness', COMPLETENESS[stage] ?? '');
255
298
 
256
- // r63: Full-but-not-ready WARNING with skill guidance (v1 r1 surface).
299
+ // r63: Full-but-not-ready WARNING with skill guidance (predecessor r1 surface).
257
300
  if (opts.git !== undefined && binding !== null && stage === 'full') {
258
- const needs =
259
- text.match(/^needs_specs_change:\s*(true|false)\s*$/mu)?.[1] !== undefined
260
- ? text.match(/^needs_specs_change:\s*(true|false)\s*$/mu)?.[1] === 'true'
261
- : true;
262
- const touched =
263
- opts.git.runOpt(['diff', '--name-only', `${binding.baseBranch}...${binding.branch}`]) ?? '';
264
- const landed = touched.includes('llmanspec/specs/');
301
+ const needs = readNeedsSpecsChange(fm);
302
+ const landed = specsLanded(opts.git, binding);
265
303
  if (!landed && needs) {
266
304
  push(
267
305
  'WARNING',
268
306
  'proposal.md',
269
- `specs not landed: change bound to \`${binding.branch}\` but no changes under \`llmanspec/specs/\` on its bound branch. Edit live specs there and commit (or set \`needs_specs_change: false\` if this change has no live contract edits). Skill: llman-sdd-propose — do NOT re-run change start when already attached; apply only when \`llman-sdd show <id> --json\` reports readyToImplement=true (llman-sdd-apply).`,
307
+ `specs not landed: change bound to \`${binding.branch}\` but no changes under \`llmanspec/specs/\` on its bound branch. Edit live specs there and commit (or set \`needs_specs_change\` to false in the frontmatter if this change has no live contract edits). Skill: llman-sdd-propose — do NOT re-run change start when already attached; start llman-sdd-apply once the specs-landed gate passes (see \`llman-sdd show <id>\` gateChecks).`,
270
308
  );
271
309
  }
272
310
  }
273
311
  }
274
312
 
275
- // pattern gate (v1 change-id path).
313
+ // pattern gate (predecessor change-id path). loadConfig compiles the pattern first
314
+ // on the CLI path; this branch defends direct core callers that build a
315
+ // ChangeCheckConfig without going through loadConfig.
276
316
  if (config.change_id_pattern) {
277
317
  try {
278
318
  const re = new RegExp(config.change_id_pattern, 'u');
@@ -280,18 +320,20 @@ export function validateChange(
280
320
  push(
281
321
  'ERROR',
282
322
  'change-id',
283
- `Change id '${id}' does not match change_id.pattern '${config.change_id_pattern}' (sdd-workflow r29; scope: active changes only, archive/legacy shapes are not back-checked).`,
323
+ `Change id '${id}' does not match change_id.pattern '${config.change_id_pattern}' (active changes only; archived changes are not re-checked).`,
284
324
  );
285
325
  }
286
- } catch {
287
- /* compile already validated at load; ignore */
326
+ } catch (error) {
327
+ push(
328
+ 'ERROR',
329
+ 'change-id',
330
+ `change_id.pattern is not a valid regular expression: ${(error as Error).message}`,
331
+ );
288
332
  }
289
333
  }
290
334
 
291
- // stage gate (v1 --stage: artifact presence per gate).
335
+ // stage gate (predecessor --stage: artifact presence per gate).
292
336
  if (opts.stage !== undefined) {
293
- const hasDesign = io.exists(`${dir}design.md`);
294
- const hasTasks = io.exists(`${dir}tasks.md`);
295
337
  if (opts.stage === 'designed' && !hasDesign) {
296
338
  push('ERROR', 'design.md', `Stage forced to 'designed' but design.md is missing`);
297
339
  }
@@ -320,22 +362,3 @@ const ALLOWED_FIELDS = [
320
362
  'base_sha',
321
363
  'needs_specs_change',
322
364
  ];
323
-
324
- export interface PlaceholderTarget {
325
- featureDir: string;
326
- featureName: string;
327
- featurePath: string;
328
- }
329
-
330
- /** True when run_command carries any r48 placeholder. */
331
- export function hasPlaceholders(runCommand: string): boolean {
332
- return /\{feature_dir\}|\{feature_name\}|\{feature_path\}/u.test(runCommand);
333
- }
334
-
335
- /** Expand {feature_dir}/{feature_name}/{feature_path} for one target (r48). */
336
- export function expandRunCommand(runCommand: string, target: PlaceholderTarget): string {
337
- return runCommand
338
- .replaceAll('{feature_dir}', target.featureDir)
339
- .replaceAll('{feature_name}', target.featureName)
340
- .replaceAll('{feature_path}', target.featurePath);
341
- }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * BDD harness execution for validate (validation capability, r13/r48):
3
+ * expands bdd.run_command per capability, executes each expanded command
4
+ * string at most once per validate invocation (batch-once), and maps results
5
+ * to per-capability issues. Pure — the subprocess runs through the injected
6
+ * HarnessRunner; this module never reads the environment or the wall clock
7
+ * (the nested-invocation guard is read by the CLI and passed in as a
8
+ * parameter).
9
+ */
10
+ import type { ValidationItem } from './validate.ts';
11
+
12
+ /**
13
+ * Harness runner port (r13/r48): executes one expanded bdd.run_command
14
+ * string; the CLI adapter owns the subprocess (and the nested-invocation
15
+ * guard env it exports). exitCode is null only when the command could not
16
+ * start (spawnError then carries the reason). Lives here rather than in
17
+ * ports.ts so the validation module gains no new cross-module edge (r72).
18
+ */
19
+ export interface HarnessRunner {
20
+ run(
21
+ command: string,
22
+ cwd: string,
23
+ ): {
24
+ exitCode: number | null;
25
+ output: string;
26
+ spawnError?: string;
27
+ };
28
+ }
29
+
30
+ export interface HarnessTarget {
31
+ /** Capability id ({feature_name}). */
32
+ capability: string;
33
+ /** Repo-root-relative path of the capability's main .feature file. */
34
+ featurePath: string;
35
+ }
36
+
37
+ /** Trigger state assembled by the CLI (env guard + flags + config + runner). */
38
+ export interface HarnessGate {
39
+ /** LLMAN_SDD_HARNESS_ACTIVE=1 was set by an enclosing validate invocation. */
40
+ nested: boolean;
41
+ check: 'default' | 'on' | 'off';
42
+ /** Undefined when bdd.run_command is not configured (non-empty string). */
43
+ runner: HarnessRunner | undefined;
44
+ runCommand: string | null;
45
+ /** Project root — the cwd every harness subprocess runs in. */
46
+ cwd: string;
47
+ /** CLI emits its one-line stderr banner through this hook before the first execution. */
48
+ onBeforeFirstRun?: (expanded: string) => void;
49
+ }
50
+
51
+ export interface HarnessRunOutcome {
52
+ /** Harness issues per capability, in target order. */
53
+ issuesByCapability: Map<string, ValidationItem[]>;
54
+ /** True when at least one command actually executed in this call. */
55
+ executed: boolean;
56
+ }
57
+
58
+ /** Plain-text placeholder expansion — capability ids are kebab-constrained. */
59
+ export function expandRunCommand(command: string, target: HarnessTarget): string {
60
+ const dirEnd = target.featurePath.lastIndexOf('/');
61
+ const featureDir = dirEnd === -1 ? target.featurePath : target.featurePath.slice(0, dirEnd);
62
+ return command
63
+ .replaceAll('{feature_path}', target.featurePath)
64
+ .replaceAll('{feature_dir}', featureDir)
65
+ .replaceAll('{feature_name}', target.capability);
66
+ }
67
+
68
+ interface CacheEntry {
69
+ success: boolean;
70
+ /** First ERROR message (≤200 chars) so a cached failure points at the cause. */
71
+ failureSummary: string | null;
72
+ }
73
+
74
+ const OUTPUT_TAIL = 200;
75
+
76
+ /**
77
+ * Trigger matrix (r13): --no-check skips silently; a nested invocation skips
78
+ * with a per-spec INFO; an explicit --check without a configured run_command
79
+ * yields a single INFO on the first spec; otherwise every expanded command
80
+ * executes at most once (cache keyed by the expanded string).
81
+ */
82
+ export function runHarnessForSpecs(
83
+ targets: readonly HarnessTarget[],
84
+ gate: HarnessGate,
85
+ ): HarnessRunOutcome {
86
+ const issuesByCapability = new Map<string, ValidationItem[]>();
87
+ if (gate.check === 'off') return { issuesByCapability, executed: false };
88
+ if (gate.nested) {
89
+ for (const target of targets) {
90
+ issuesByCapability.set(target.capability, [
91
+ {
92
+ level: 'INFO',
93
+ id: target.featurePath,
94
+ message: 'bdd harness skipped: nested invocation',
95
+ },
96
+ ]);
97
+ }
98
+ return { issuesByCapability, executed: false };
99
+ }
100
+ if (gate.runner === undefined || gate.runCommand === null || gate.runCommand === '') {
101
+ if (gate.check === 'on' && targets.length > 0) {
102
+ const first = targets[0] as HarnessTarget;
103
+ issuesByCapability.set(first.capability, [
104
+ {
105
+ level: 'INFO',
106
+ id: first.featurePath,
107
+ message: '--check has no effect: bdd.run_command is not configured',
108
+ },
109
+ ]);
110
+ }
111
+ return { issuesByCapability, executed: false };
112
+ }
113
+ const cache = new Map<string, CacheEntry>();
114
+ let executed = false;
115
+ let announced = false;
116
+ for (const target of targets) {
117
+ const expanded = expandRunCommand(gate.runCommand, target);
118
+ const cached = cache.get(expanded);
119
+ let issues: ValidationItem[];
120
+ if (cached !== undefined) {
121
+ issues = cached.success
122
+ ? [
123
+ {
124
+ level: 'INFO',
125
+ id: target.featurePath,
126
+ message: `bdd harness passed (cached): ${expanded}`,
127
+ },
128
+ ]
129
+ : [
130
+ {
131
+ level: 'ERROR',
132
+ id: target.featurePath,
133
+ message: `bdd harness failed (cached result of ${expanded}): ${cached.failureSummary}`,
134
+ },
135
+ ];
136
+ } else {
137
+ if (!announced) {
138
+ gate.onBeforeFirstRun?.(expanded);
139
+ announced = true;
140
+ }
141
+ executed = true;
142
+ const result = gate.runner.run(expanded, gate.cwd);
143
+ const firstError =
144
+ result.spawnError !== undefined
145
+ ? `bdd harness could not start: ${expanded}: ${result.spawnError}`
146
+ : result.exitCode === 0
147
+ ? null
148
+ : `bdd harness failed (exit ${result.exitCode}): ${expanded}: ${result.output.slice(-OUTPUT_TAIL)}`;
149
+ issues =
150
+ firstError === null
151
+ ? [{ level: 'INFO', id: target.featurePath, message: `bdd harness passed: ${expanded}` }]
152
+ : [{ level: 'ERROR', id: target.featurePath, message: firstError }];
153
+ cache.set(expanded, {
154
+ success: firstError === null,
155
+ failureSummary: firstError === null ? null : firstError.slice(0, OUTPUT_TAIL),
156
+ });
157
+ }
158
+ issuesByCapability.set(target.capability, issues);
159
+ }
160
+ return { issuesByCapability, executed };
161
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Staleness evaluator (validation/staleness parity): v1 `sdd/spec/staleness.rs`
2
+ * Staleness evaluator (validation/staleness parity): predecessor `sdd/spec/staleness.rs`
3
3
  * observable contract — status/baseRef/scope/touchedPaths/specUpdated/dirty/
4
4
  * notes plus per-capability staleness issues. Pure: git + env are injected.
5
5
  */
@@ -62,9 +62,9 @@ const BASE_MISSING_MSG =
62
62
  "Note: Unable to resolve base ref for staleness check. Set LLMANSPEC_BASE_REF (e.g. 'main' or 'origin/main'), or upgrade llman if below 0.0.60.";
63
63
 
64
64
  /**
65
- * Evaluate staleness for one capability spec — v1 status semantics.
65
+ * Evaluate staleness for one capability spec — predecessor status semantics.
66
66
  * Returns the info object plus warning/info issues (strict escalation is the
67
- * caller's job, matching v1 `apply_strict`).
67
+ * caller's job, matching predecessor `apply_strict`).
68
68
  */
69
69
  export function evaluateStaleness(deps: StalenessDeps): {
70
70
  info: StalenessInfo;
@@ -125,7 +125,7 @@ export function evaluateStaleness(deps: StalenessDeps): {
125
125
  }
126
126
  }
127
127
 
128
- // Tolerant: outside a git repo (or on git failure) treat as dirty (v1 unwrap_or(true)).
128
+ // Tolerant: outside a git repo (or on git failure) treat as dirty (predecessor unwrap_or(true)).
129
129
  const dirty = (git.runOpt(['status', '--porcelain']) ?? 'dirty') !== '';
130
130
  if (dirty) {
131
131
  if (status === 'OK' || status === 'STALE') status = 'INFO';
@@ -143,13 +143,17 @@ export function evaluateStaleness(deps: StalenessDeps): {
143
143
  }
144
144
  }
145
145
 
146
- const STALE_MSG = 'Note: Spec files changed on the base branch; re-review the spec.';
146
+ const STALE_MSG =
147
+ "Note: Code in this spec's scope changed on this branch but the spec was not updated; re-review the spec.";
147
148
  const SPEC_UPDATED_MSG = 'Note: Spec updated on this branch.';
148
149
 
149
150
  function revParse(git: GitLike, ref: string): string | null {
150
151
  return git.runOpt(['rev-parse', '--verify', '--quiet', ref]) ?? null;
151
152
  }
152
153
 
154
+ // Deliberately NOT shared with defaultBranch() in git/spawnGit.ts — this
155
+ // predecessor staleness probe is local-only (main → master, 'main' fallback, no origin
156
+ // consultation). Do not merge the two.
153
157
  function defaultBranchNameFn(git: GitLike): string {
154
158
  if (git.runOpt(['show-ref', '--verify', '--quiet', 'refs/heads/main']) !== null) return 'main';
155
159
  if (git.runOpt(['show-ref', '--verify', '--quiet', 'refs/heads/master']) !== null)