@llman-sdd/core 0.3.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) 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 +81 -54
  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 +45 -22
  37. package/src/report/specs.ts +23 -25
  38. package/src/review/review.ts +45 -30
  39. package/src/spec/authoring.ts +147 -71
  40. package/src/spec/ir.ts +43 -15
  41. package/src/spec/keywords.ts +147 -0
  42. package/src/spec/migrateNative.ts +201 -0
  43. package/src/spec/parser.ts +95 -83
  44. package/src/spec/reqRegistry.ts +31 -15
  45. package/src/templates/embedded.ts +10 -16
  46. package/src/templates/engine.ts +10 -5
  47. package/src/templates/locale.ts +1 -1
  48. package/src/templates/skills.ts +4 -5
  49. package/src/validation/changeCheck.ts +128 -105
  50. package/src/validation/harness.ts +161 -0
  51. package/src/validation/staleness.ts +9 -5
  52. package/src/validation/validate.ts +60 -88
  53. package/templates/en/skills/llman-sdd-apply-cycle.md +20 -28
  54. package/templates/en/skills/llman-sdd-apply.md +58 -76
  55. package/templates/en/skills/llman-sdd-arch-review.md +12 -19
  56. package/templates/en/skills/llman-sdd-archive.md +27 -42
  57. package/templates/en/skills/llman-sdd-continue.md +17 -24
  58. package/templates/en/skills/llman-sdd-draft.md +17 -28
  59. package/templates/en/skills/llman-sdd-explore.md +29 -43
  60. package/templates/en/skills/llman-sdd-ff.md +12 -17
  61. package/templates/en/skills/llman-sdd-graph.md +14 -32
  62. package/templates/en/skills/llman-sdd-propose.md +48 -63
  63. package/templates/en/skills/llman-sdd-quick.md +12 -27
  64. package/templates/en/skills/llman-sdd-research.md +13 -24
  65. package/templates/en/skills/llman-sdd-specs-compact.md +14 -39
  66. package/templates/en/skills/llman-sdd-validate.md +11 -15
  67. package/templates/en/skills/llman-sdd-verify.md +23 -44
  68. package/templates/en/skills/llman-sdd-wayfinder.md +18 -22
  69. package/templates/en/units/skills/cli-footer.md +2 -0
  70. package/templates/en/units/skills/git-native-flow-brief.md +7 -6
  71. package/templates/en/units/skills/git-native-flow.md +21 -11
  72. package/templates/en/units/skills/human-readable-summary.md +2 -3
  73. package/templates/en/units/skills/stage-guard.md +7 -7
  74. package/templates/en/units/skills/structured-protocol.md +5 -8
  75. package/templates/en/units/skills/validation-hints.md +10 -14
  76. package/templates/en/units/spec/feature-contract.md +27 -16
  77. package/templates/en/units/workflow/archive-freeze-guidance.md +6 -3
  78. package/templates/zh-Hans/skills/llman-sdd-apply-cycle.md +23 -31
  79. package/templates/zh-Hans/skills/llman-sdd-apply.md +63 -81
  80. package/templates/zh-Hans/skills/llman-sdd-arch-review.md +21 -28
  81. package/templates/zh-Hans/skills/llman-sdd-archive.md +29 -44
  82. package/templates/zh-Hans/skills/llman-sdd-continue.md +17 -24
  83. package/templates/zh-Hans/skills/llman-sdd-draft.md +18 -29
  84. package/templates/zh-Hans/skills/llman-sdd-explore.md +34 -48
  85. package/templates/zh-Hans/skills/llman-sdd-ff.md +13 -18
  86. package/templates/zh-Hans/skills/llman-sdd-graph.md +16 -34
  87. package/templates/zh-Hans/skills/llman-sdd-propose.md +51 -65
  88. package/templates/zh-Hans/skills/llman-sdd-quick.md +15 -30
  89. package/templates/zh-Hans/skills/llman-sdd-research.md +17 -28
  90. package/templates/zh-Hans/skills/llman-sdd-specs-compact.md +15 -40
  91. package/templates/zh-Hans/skills/llman-sdd-validate.md +11 -15
  92. package/templates/zh-Hans/skills/llman-sdd-verify.md +26 -47
  93. package/templates/zh-Hans/skills/llman-sdd-wayfinder.md +25 -29
  94. package/templates/zh-Hans/units/skills/cli-footer.md +2 -0
  95. package/templates/zh-Hans/units/skills/git-native-flow-brief.md +7 -6
  96. package/templates/zh-Hans/units/skills/git-native-flow.md +22 -12
  97. package/templates/zh-Hans/units/skills/human-readable-summary.md +4 -5
  98. package/templates/zh-Hans/units/skills/stage-guard.md +9 -9
  99. package/templates/zh-Hans/units/skills/structured-protocol.md +5 -8
  100. package/templates/zh-Hans/units/skills/validation-hints.md +10 -14
  101. package/templates/zh-Hans/units/spec/feature-contract.md +25 -16
  102. package/templates/zh-Hans/units/workflow/archive-freeze-guidance.md +6 -2
  103. package/templates/en/skills/llman-sdd-onboard.md +0 -34
  104. package/templates/en/skills/llman-sdd-show.md +0 -24
  105. package/templates/en/units/migrate-prompt.md +0 -28
  106. package/templates/zh-Hans/skills/llman-sdd-onboard.md +0 -34
  107. package/templates/zh-Hans/skills/llman-sdd-show.md +0 -24
  108. package/templates/zh-Hans/units/migrate-prompt.md +0 -28
@@ -1,37 +1,53 @@
1
1
  /**
2
- * Global rN registry (spec-parsing capability): @req:rN ids form a single
3
- * global namespace across all capability specs; duplicates are reported with
4
- * the conflicting file pairs.
2
+ * Global rN registry (spec-parsing capability): @req:rN ids on `规则:` blocks
3
+ * form a single global namespace across all capability specs. A duplicate is
4
+ * any id carried by more than one rule — within one file or across files
5
+ * (r10: 按携带该 id 的规则条数 > 1 判定).
5
6
  */
6
7
  import type { CapabilityDoc } from './ir.ts';
7
8
 
9
+ export interface RegistryOccurrence {
10
+ fileName: string;
11
+ /** 0-based index of the rule within its doc (in-file locating). */
12
+ ruleIndex: number;
13
+ title: string;
14
+ }
15
+
8
16
  export interface RegistryDuplicate {
9
17
  reqId: string;
18
+ /** Unique files referencing the id (sorted, display surface). */
10
19
  files: string[];
20
+ /** Every rule occurrence referencing the id, in scan order. */
21
+ occurrences: RegistryOccurrence[];
11
22
  }
12
23
 
13
24
  export interface ReqRegistry {
14
- /** reqId → files referencing it (sorted). */
15
- byId: Map<string, string[]>;
25
+ /** reqId → every rule occurrence referencing it (scan order). */
26
+ byId: Map<string, RegistryOccurrence[]>;
16
27
  duplicates: RegistryDuplicate[];
17
28
  }
18
29
 
19
30
  export function buildReqRegistry(
20
31
  docs: readonly { fileName: string; doc: CapabilityDoc }[],
21
32
  ): ReqRegistry {
22
- const byId = new Map<string, string[]>();
33
+ const byId = new Map<string, RegistryOccurrence[]>();
23
34
  for (const { fileName, doc } of docs) {
24
- for (const scenario of doc.scenarios) {
25
- for (const reqId of scenario.reqIds) {
26
- const files = byId.get(reqId) ?? [];
27
- if (!files.includes(fileName)) files.push(fileName);
28
- byId.set(reqId, files);
29
- }
30
- }
35
+ doc.rules.forEach((rule, ruleIndex) => {
36
+ if (rule.reqId === '') return;
37
+ const occurrences = byId.get(rule.reqId) ?? [];
38
+ occurrences.push({ fileName, ruleIndex, title: rule.title });
39
+ byId.set(rule.reqId, occurrences);
40
+ });
31
41
  }
32
42
  const duplicates: RegistryDuplicate[] = [];
33
- for (const [reqId, files] of byId) {
34
- if (files.length > 1) duplicates.push({ reqId, files: files.toSorted() });
43
+ for (const [reqId, occurrences] of byId) {
44
+ if (occurrences.length > 1) {
45
+ duplicates.push({
46
+ reqId,
47
+ files: [...new Set(occurrences.map((o) => o.fileName))].toSorted(),
48
+ occurrences,
49
+ });
50
+ }
35
51
  }
36
52
  duplicates.sort((a, b) => a.reqId.localeCompare(b.reqId));
37
53
  return { byId, duplicates };
@@ -1,19 +1,13 @@
1
1
  /**
2
2
  * Embedded template table for single-file binaries.
3
3
  *
4
- * Bun 1.4.x offers no embedding that survives our Node >= 24 dual-runtime
5
- * constraint: `with { type: "text" }` works in compiled output (probed on
6
- * 1.4.2) but Node cannot load that attribute, and `assets`/`?raw`/`?asset`
7
- * never land in the bundle — so templates are injected through the same
8
- * build-time `define` mechanism as LLMAN_SDD_VERSION: build-binary.ts
9
- * collects packages/core/templates into a root-relative path→content map and
10
- * sets process.env.LLMAN_SDD_EMBEDDED_TEMPLATES to that JSON. Bun inlines
11
- * define values as raw expressions — the JSON is a valid object literal, so
12
- * in the compiled binary the expression IS the table object itself, while a
13
- * JSON string reaches these callers through the same expression in other
14
- * engines or when force-set via env. Both forms are accepted below; every
15
- * non-compiled run (source, npm package, Node) leaves the define unset and
16
- * falls back to the real filesystem via TEMPLATES_ROOT.
4
+ * Compiled binaries receive the table through the same build-time define
5
+ * mechanism as the version (see apps/cli/src/cli-shared.ts — the define read
6
+ * lives at the CLI seam, core only receives already-resolved values). Bun
7
+ * inlines define values as raw expressions; both the JSON-string form and a
8
+ * pre-parsed object literal are accepted below. Every non-compiled run
9
+ * (source, npm package, Node) leaves the value unset and falls back to the
10
+ * real filesystem via TEMPLATES_ROOT.
17
11
  */
18
12
  import type { TemplateIo } from './skills.ts';
19
13
 
@@ -37,9 +31,9 @@ export function resolveEmbeddedTable(value: unknown): Record<string, string> | u
37
31
  return undefined;
38
32
  }
39
33
 
40
- /** Read the build-injected table; undefined when absent or malformed. */
41
- export function embeddedTemplates(): Record<string, string> | undefined {
42
- return resolveEmbeddedTable(process.env.LLMAN_SDD_EMBEDDED_TEMPLATES);
34
+ /** Resolve the caller-passed define value; undefined when absent or malformed. */
35
+ export function embeddedTemplates(raw?: unknown): Record<string, string> | undefined {
36
+ return resolveEmbeddedTable(raw);
43
37
  }
44
38
 
45
39
  /**
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Template engine (init-generators capability, r17): nunjucks adapter with
3
- * minijinja-compatible semantics used by v1 — string-only globals, undefined
3
+ * minijinja-compatible semantics used by predecessor — string-only globals, undefined
4
4
  * renders empty (Lenient), nested `unit(id)` expansion capped at 32, and
5
5
  * trailing whitespace trimmed from the final product.
6
6
  */
@@ -22,22 +22,26 @@ export function renderWithUnits(
22
22
  units: UnitRegistry,
23
23
  vars: Record<string, string>,
24
24
  depth = 0,
25
+ opts: { throwOnUndefined?: boolean } = {},
25
26
  ): string {
26
27
  if (depth > MAX_UNIT_NESTING_DEPTH) {
27
28
  throw new Error(`unit nesting exceeded ${MAX_UNIT_NESTING_DEPTH}`);
28
29
  }
29
30
  // minijinja keep_trailing_newline=false: a single trailing newline of the
30
- // template SOURCE is stripped before rendering (v1 parity). \r? first —
31
+ // template SOURCE is stripped before rendering (predecessor parity). \r? first —
31
32
  // stripping \n alone would strand a trailing \r for CRLF sources.
32
33
  const source = raw.replace(/\r?\n$/u, '');
33
- const env = new nunjucks.Environment(null, { autoescape: false });
34
+ const env = new nunjucks.Environment(null, {
35
+ autoescape: false,
36
+ throwOnUndefined: opts.throwOnUndefined === true,
37
+ });
34
38
  for (const [key, value] of Object.entries(vars)) {
35
39
  env.addGlobal(key, value);
36
40
  }
37
41
  env.addGlobal('unit', (id: string): string => {
38
42
  const content = units.get(id);
39
43
  if (content === undefined) throw new MissingUnitError(id);
40
- return renderWithUnits(content, units, vars, depth + 1);
44
+ return renderWithUnits(content, units, vars, depth + 1, opts);
41
45
  });
42
46
  return env.renderString(source, {});
43
47
  }
@@ -47,6 +51,7 @@ export function renderTemplate(
47
51
  raw: string,
48
52
  units: UnitRegistry,
49
53
  vars: Record<string, string>,
54
+ opts: { throwOnUndefined?: boolean } = {},
50
55
  ): string {
51
- return renderWithUnits(raw, units, vars, 0).trimEnd();
56
+ return renderWithUnits(raw, units, vars, 0, opts).trimEnd();
52
57
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Locale normalization + fallback chain (init-generators capability, r18).
3
- * Port of v1 config.rs normalize_locale / locale_fallbacks.
3
+ * Port of predecessor config.rs normalize_locale / locale_fallbacks.
4
4
  */
5
5
  export function normalizeLocale(value: string): string {
6
6
  const trimmed = value.trim();
@@ -2,7 +2,7 @@ import type { SddConfig } from '../config/schema.ts';
2
2
  import { renderTemplate, type UnitRegistry } from './engine.ts';
3
3
  /**
4
4
  * Skills rendering + init orchestration (init-generators capability, r19).
5
- * Port of v1 templates.rs / update_skills.rs / init.rs semantics.
5
+ * Port of predecessor templates.rs / update_skills.rs / init.rs semantics.
6
6
  */
7
7
  import { localeFallbacks } from './locale.ts';
8
8
 
@@ -37,6 +37,7 @@ export const UNIT_FILES: readonly string[] = [
37
37
  'skills/ethics-governance.md',
38
38
  'spec/feature-contract.md',
39
39
  'skills/structured-protocol.md',
40
+ 'skills/cli-footer.md',
40
41
  'workflow/archive-freeze-guidance.md',
41
42
  ];
42
43
 
@@ -48,7 +49,7 @@ export const ETHICS_KEYS: readonly string[] = [
48
49
  'ethics.escalation_policy',
49
50
  ];
50
51
 
51
- /** Framework-derived run_command (v1 config.rs effective_run_command). */
52
+ /** Framework-derived run_command (predecessor config.rs effective_run_command). */
52
53
  export function effectiveRunCommand(bdd: NonNullable<SddConfig['bdd']>): string {
53
54
  if (bdd.run_command) return bdd.run_command;
54
55
  switch (bdd.framework ?? '') {
@@ -65,15 +66,13 @@ export function effectiveRunCommand(bdd: NonNullable<SddConfig['bdd']>): string
65
66
  }
66
67
  }
67
68
 
68
- /** All-string globals (v1 BTreeMap<String, String> semantics). */
69
+ /** All-string globals (predecessor BTreeMap<String, String> semantics). */
69
70
  export function buildTemplateVars(config: SddConfig, version: string): Record<string, string> {
70
71
  const vars: Record<string, string> = { llman_version: version };
71
72
  if (config.bdd) {
72
73
  vars['bdd_enabled'] = 'true';
73
74
  vars['bdd_framework'] = config.bdd.framework ?? '';
74
- if (config.bdd.feature_dir) vars['bdd_feature_dir'] = config.bdd.feature_dir;
75
75
  vars['bdd_run_command'] = effectiveRunCommand(config.bdd);
76
- if (config.bdd.default_language) vars['bdd_default_language'] = config.bdd.default_language;
77
76
  if (config.bdd.verify_prompt) vars['bdd_verify_prompt'] = config.bdd.verify_prompt;
78
77
  }
79
78
  const extras = new Set<string>(config.extra_skills ?? []);
@@ -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
- }