@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llman-sdd/core",
3
- "version": "0.3.1",
3
+ "version": "0.5.1",
4
4
  "description": "Pure domain logic for llman-sdd (config, gherkin specs, validation, templates)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -27,6 +27,7 @@
27
27
  "7z-wasm": "^1.2.0",
28
28
  "@cucumber/gherkin": "^42.0.1",
29
29
  "@cucumber/messages": "^34.2.1",
30
+ "@toon-format/toon": "^4.1.1",
30
31
  "nunjucks": "^3.2.4",
31
32
  "yaml": "^2.9.1",
32
33
  "zod": "^4.6.5"
@@ -1,12 +1,22 @@
1
1
  /**
2
2
  * Archive freeze/thaw orchestration (review-freeze capability, r24/r25).
3
- * Port of v1 change/freeze.rs: candidates = dated archive dirs; freeze adds
4
- * them into `freezed_changes.7z.archived` (7z a updates existing archives)
5
- * then removes the originals; thaw extracts selected dirs back into place.
6
- * All filesystem effects flow through the injected FreezeIo.
3
+ * Candidates = dated archive dirs; freeze writes a flat relationship-index
4
+ * card `<YYYY-MM-DD>-<id>.yaml` (only `title` + `depends_on`), adds the body
5
+ * files into `freezed_changes.7z.archived` (7z a updates existing archives),
6
+ * then removes the original dirs; thaw extracts selected dirs back and removes
7
+ * the card. All filesystem effects flow through the injected FreezeIo.
7
8
  */
8
9
  import { join } from 'node:path';
9
10
 
11
+ import { firstH1 } from '../change/collect.ts';
12
+ import { extractFrontmatter } from '../change/frontmatter.ts';
13
+ import {
14
+ FROZEN_CARD_EXT,
15
+ composeFrozenCard,
16
+ frozenCardIdOf,
17
+ frozenCardName,
18
+ isFrozenCard,
19
+ } from './frozenCard.ts';
10
20
  import type { SevenZipPort } from './sevenzip.ts';
11
21
 
12
22
  export const FREEZE_ARCHIVE_NAME = 'freezed_changes.7z.archived';
@@ -15,8 +25,13 @@ export const ARCHIVE_DIR_REL = 'llmanspec/changes/archive';
15
25
  export interface FreezeIo {
16
26
  exists(path: string): boolean;
17
27
  listDir(path: string): string[];
28
+ readText(path: string): string;
29
+ writeText(path: string, content: string): void;
30
+ isDirectory(path: string): boolean;
18
31
  /** rm -rf */
19
32
  removeDir(path: string): void;
33
+ /** Remove a single file. */
34
+ remove(path: string): void;
20
35
  mkdirp(path: string): void;
21
36
  /** Move a directory within the repo (same-device; adapter may copy). */
22
37
  moveDir(from: string, to: string): void;
@@ -24,6 +39,11 @@ export interface FreezeIo {
24
39
 
25
40
  const DATED_RE = /^\d{4}-\d{2}-\d{2}-/u;
26
41
 
42
+ /**
43
+ * r24: freeze candidates = date-prefixed entries that are directories with a
44
+ * proposal.md body (i.e. not yet frozen — frozen dirs have been replaced by
45
+ * the flat `<date>-<id>.yaml` card, which is a file, not a dir).
46
+ */
27
47
  export function freezeCandidates(
28
48
  io: FreezeIo,
29
49
  archiveDir: string,
@@ -32,7 +52,7 @@ export function freezeCandidates(
32
52
  const all = io.exists(archiveDir)
33
53
  ? io
34
54
  .listDir(archiveDir)
35
- .filter((n) => DATED_RE.test(n))
55
+ .filter((n) => DATED_RE.test(n) && !isFrozenCard(n) && io.isDirectory(`${archiveDir}/${n}`))
36
56
  .toSorted()
37
57
  : [];
38
58
  let candidates = opts.before ? all.filter((n) => n.slice(0, 10) < (opts.before ?? '')) : [...all];
@@ -72,8 +92,31 @@ export async function runFreeze(
72
92
  ],
73
93
  };
74
94
  }
95
+
96
+ // Card = authoritative record that bodies are in the cold backup. Write
97
+ // cards first; on `7z add` failure roll them back and keep the original dirs.
75
98
  const archiveAbs = join(rootAbs, ARCHIVE_DIR_REL, FREEZE_ARCHIVE_NAME);
76
- await sz.add(archiveAbs, join(rootAbs, ARCHIVE_DIR_REL), candidates);
99
+
100
+ const cards: Array<{ datedId: string; source: string }> = [];
101
+ for (const c of candidates) {
102
+ const proposalRel = `${ARCHIVE_DIR_REL}/${c}/proposal.md`;
103
+ const proposal = io.exists(proposalRel) ? io.readText(proposalRel) : '';
104
+ const frontmatter = extractFrontmatter(proposal) ?? '';
105
+ cards.push({
106
+ datedId: c,
107
+ source: composeFrozenCard(firstH1(proposal), frontmatter),
108
+ });
109
+ }
110
+
111
+ for (const card of cards) {
112
+ io.writeText(`${ARCHIVE_DIR_REL}/${frozenCardName(card.datedId)}`, card.source);
113
+ }
114
+ try {
115
+ await sz.add(archiveAbs, join(rootAbs, ARCHIVE_DIR_REL), candidates);
116
+ } catch (error) {
117
+ for (const card of cards) io.remove(`${ARCHIVE_DIR_REL}/${frozenCardName(card.datedId)}`);
118
+ throw error;
119
+ }
77
120
  for (const c of candidates) {
78
121
  io.removeDir(`${ARCHIVE_DIR_REL}/${c}`);
79
122
  }
@@ -85,25 +128,39 @@ export async function runFreeze(
85
128
  };
86
129
  }
87
130
 
131
+ /**
132
+ * r24: `--list` enumerates frozen changes from the flat cards on disk (no 7z
133
+ * parse needed). Legacy entries frozen without cards (predecessor format) are
134
+ * derived from the 7z listing so old archives stay listable (r25 legacy compat).
135
+ */
88
136
  export async function runList(io: FreezeIo, sz: SevenZipPort, rootAbs: string): Promise<string[]> {
137
+ let names = new Set<string>();
138
+ const archiveDirRel = ARCHIVE_DIR_REL;
139
+ if (io.exists(archiveDirRel)) {
140
+ for (const name of io.listDir(archiveDirRel)) {
141
+ if (isFrozenCard(name) && frozenCardIdOf(name) !== null) {
142
+ names.add(name.slice(0, -FROZEN_CARD_EXT.length));
143
+ }
144
+ }
145
+ }
89
146
  const archiveAbs = join(rootAbs, ARCHIVE_DIR_REL, FREEZE_ARCHIVE_NAME);
90
- if (!io.exists(archiveAbs)) {
91
- return [`freeze archive not found: ./${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME}`];
147
+ if (io.exists(archiveAbs)) {
148
+ // Legacy/unexpected entries present only inside the 7z (no card on disk).
149
+ const entries = (await sz.listEntries(archiveAbs)).map((n) => n.replace(/\/$/u, ''));
150
+ const top = (n: string): string => n.split('/')[0] ?? n;
151
+ for (const e of entries.map(top).filter((n) => DATED_RE.test(n))) {
152
+ if (!names.has(e)) names.add(e);
153
+ }
92
154
  }
93
- // Real 7z lists file paths under their directory (`<dir>/proposal.md`);
94
- // directory entries themselves are skipped by parseListNames. Derive the
95
- // archived change names from the leading path segment.
96
- const entries = (await sz.listEntries(archiveAbs)).map((n) => n.replace(/\/$/u, ''));
97
- const top = (n: string): string => n.split('/')[0] ?? n;
98
- const unique = [...new Set(entries.map(top).filter((n) => DATED_RE.test(n)))].toSorted();
99
- if (unique.length === 0) {
155
+ const sorted = [...names].toSorted();
156
+ if (sorted.length === 0) {
100
157
  return [
101
158
  `Freeze archive ${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME} contains no archived changes`,
102
159
  ];
103
160
  }
104
161
  return [
105
- `Frozen archived changes in ${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME} (${unique.length}):`,
106
- ...unique.map((c) => ` - ${c}`),
162
+ `Frozen archived changes in ${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME} (${sorted.length}):`,
163
+ ...sorted.map((c) => ` - ${c}`),
107
164
  ];
108
165
  }
109
166
 
@@ -130,7 +187,16 @@ export async function runThaw(
130
187
  io.removeDir(tmpRel);
131
188
  try {
132
189
  await sz.extractAll(archiveAbs, tmpAbs);
133
- const available = new Set(io.listDir(tmpRel).filter((n) => DATED_RE.test(n)));
190
+ const available = new Set(
191
+ io.exists(tmpRel) ? io.listDir(tmpRel).filter((n) => DATED_RE.test(n)) : [],
192
+ );
193
+ // Cards on disk also count as available (they are the authoritative record
194
+ // that bodies are in the cold backup, even before any extraction listing).
195
+ for (const name of io.exists(ARCHIVE_DIR_REL) ? io.listDir(ARCHIVE_DIR_REL) : []) {
196
+ if (isFrozenCard(name) && frozenCardIdOf(name) !== null) {
197
+ available.add(name.slice(0, -FROZEN_CARD_EXT.length));
198
+ }
199
+ }
134
200
  const missing = names.filter((n) => !available.has(n));
135
201
  if (missing.length > 0) {
136
202
  throw new Error(
@@ -143,6 +209,8 @@ export async function runThaw(
143
209
  throw new Error(`target already exists: ${name}`);
144
210
  }
145
211
  io.moveDir(`${tmpRel}/${name}`, `${destRel}/${name}`);
212
+ const cardRel = `${ARCHIVE_DIR_REL}/${frozenCardName(name)}`;
213
+ if (io.exists(cardRel)) io.remove(cardRel);
146
214
  restored.push(name);
147
215
  }
148
216
  return {
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Frozen-change relationship-index card contract (review-freeze capability,
3
+ * r24/r25): `freeze` replaces a dated archive dir with a flat card
4
+ * `<YYYY-MM-DD>-<id>.yaml` whose content carries only the change's `title`
5
+ * (proposal H1) and `depends_on` (proposal frontmatter) — a human/agent
6
+ * readable navigation index. id and date are implied by the file name; bodies
7
+ * live in the 7z cold backup. Cards are the authoritative record that a change
8
+ * is in the cold backup.
9
+ *
10
+ * The card is a fenced YAML document (same `---` convention as proposal.md) so
11
+ * existing frontmatter readers (`extractFrontmatter` / `parseDeps`) work
12
+ * unchanged — parseDeps on a card text reads the preserved `depends_on`.
13
+ *
14
+ * Depends_on extraction is deliberately dependency-free (no `yaml` package):
15
+ * it recognizes single-line flow `[a, b]`, block `- item` lists and multi-line
16
+ * flow blocks, then normalizes to a single-line flow array so `parseDeps`
17
+ * reads it unchanged. Anything unparseable degrades to `depends_on: []` (never
18
+ * blocks the freeze).
19
+ *
20
+ * Pure string logic — all filesystem effects live in freeze.ts (FreezeIo).
21
+ */
22
+
23
+ export const FROZEN_CARD_EXT = '.yaml';
24
+
25
+ /** `2026-01-01-old` → `2026-01-01-old.yaml` */
26
+ export function frozenCardName(datedId: string): string {
27
+ return `${datedId}${FROZEN_CARD_EXT}`;
28
+ }
29
+
30
+ /** id for a card filename (`2026-01-01-old.yaml` → `old`); null when not a card. */
31
+ export function frozenCardIdOf(name: string): string | null {
32
+ const m = name.match(/^\d{4}-\d{2}-\d{2}-(.+)\.yaml$/u);
33
+ return m?.[1] ?? null;
34
+ }
35
+
36
+ export function isFrozenCard(name: string): boolean {
37
+ return frozenCardIdOf(name) !== null;
38
+ }
39
+
40
+ function escapeYamlScalar(value: string): string {
41
+ return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
42
+ }
43
+
44
+ /** Normalize a flow-list inner string (comma-separated, quotes stripped). */
45
+ function splitFlowItems(inner: string): string[] {
46
+ const out: string[] = [];
47
+ for (const raw of inner.split(',')) {
48
+ const item = raw.trim().replaceAll(/^['"]|['"]$/gu, '');
49
+ if (item !== '') out.push(item);
50
+ }
51
+ return out;
52
+ }
53
+
54
+ function toFlow(items: string[]): string {
55
+ return items.length === 0 ? 'depends_on: []' : `depends_on: [${items.join(', ')}]`;
56
+ }
57
+
58
+ /**
59
+ * Extract `depends_on` from a proposal frontmatter block. Falls back to
60
+ * `depends_on: []` when absent/empty/unparseable — a malformed dependency list
61
+ * never breaks the freeze.
62
+ */
63
+ export function extractDependsOn(frontmatter: string): string {
64
+ const lines = frontmatter.split('\n');
65
+ const flowIdx = lines.findIndex((l) => /^depends_on\s*:\s*\[/u.test(l));
66
+ if (flowIdx !== -1) {
67
+ // Single-line flow `depends_on: [a, b]` (may span lines).
68
+ let acc = (lines[flowIdx] ?? '').replace(/^depends_on\s*:/u, '').trim();
69
+ let j = flowIdx;
70
+ while (!/\]\s*$/u.test(acc) && j < lines.length - 1) {
71
+ j++;
72
+ acc += ` ${(lines[j] ?? '').trim()}`;
73
+ }
74
+ const inner = acc.replace(/^\[/u, '').replace(/\]\s*/u, '');
75
+ return toFlow(splitFlowItems(inner));
76
+ }
77
+ const blockIdx = lines.findIndex((l) => /^depends_on\s*:\s*$/u.test(l));
78
+ if (blockIdx !== -1) {
79
+ // Block list `- a` lines.
80
+ const items: string[] = [];
81
+ for (const l of lines.slice(blockIdx + 1)) {
82
+ const m = l.match(/^\s*-\s+(\S+)/u);
83
+ if (m?.[1]) items.push(m[1].replaceAll(/['"]/gu, ''));
84
+ // multi-line flow handled below
85
+ else if (/^\s*\[/u.test(l)) break;
86
+ else if (l.trim() !== '') break;
87
+ }
88
+ if (items.length > 0) return toFlow(items);
89
+ // Multi-line flow `[\n a,\n b\n ]`.
90
+ let acc = lines
91
+ .slice(blockIdx + 1)
92
+ .join(' ')
93
+ .replace(/^[^[]*\[/u, '');
94
+ acc = (acc.split(']')[0] ?? '').trim();
95
+ if (acc !== '') return toFlow(splitFlowItems(acc));
96
+ return 'depends_on: []';
97
+ }
98
+ return 'depends_on: []';
99
+ }
100
+
101
+ /** Compose a relationship-index card: `title` + `depends_on` inside fences. */
102
+ export function composeFrozenCard(title: string, frontmatter: string): string {
103
+ const dependsOn = extractDependsOn(frontmatter);
104
+ return ['---', `title: "${escapeYamlScalar(title)}"`, dependsOn, '---'].join('\n') + '\n';
105
+ }
@@ -1,5 +1,4 @@
1
1
  import { Buffer } from 'node:buffer';
2
- import { mkdirSync } from 'node:fs';
3
2
  import { basename, join } from 'node:path';
4
3
 
5
4
  /**
@@ -9,12 +8,12 @@ import { basename, join } from 'node:path';
9
8
  * (WASM 7z refuses existing -o dirs). Spike-proven on Bun: compress / list /
10
9
  * extract keep directory structure.
11
10
  *
12
- * Compiled single-file binaries have no on-disk 7zz.wasm (Emscripten would
13
- * probe $bunfs and abort), so build-binary.ts additionally injects the wasm
14
- * base64 via the literal define `process.env.LLMAN_SDD_EMBEDDED_7ZZ_WASM_B64`
15
- * (define only rewrites literal member access — do not route through a
16
- * variable). Unset in source/npm/Node runs → glue loads the .wasm from disk
17
- * as before.
11
+ * Purity: the wasm payload and the extraction-dir creation are caller-injected
12
+ * (`wasmB64` / `mkdirp`). Compiled binaries carry no on-disk 7zz.wasm
13
+ * (Emscripten would probe $bunfs and abort), so the CLI seam passes the
14
+ * build-time base64 define through `wasmB64`
15
+ * (see apps/cli/src/commands/archive.ts); unset in source/npm/Node runs →
16
+ * glue loads the .wasm from disk as before.
18
17
  */
19
18
  import SevenZip from '7z-wasm';
20
19
 
@@ -57,9 +56,12 @@ export function resolveEmbeddedWasmB64(value: unknown): Uint8Array | undefined {
57
56
  return new Uint8Array(bytes);
58
57
  }
59
58
 
60
- /** Read the build-injected wasm; undefined when absent or malformed. */
61
- export function embeddedWasmBinary(): Uint8Array | undefined {
62
- return resolveEmbeddedWasmB64(process.env.LLMAN_SDD_EMBEDDED_7ZZ_WASM_B64);
59
+ /** Caller-injected side effects for the 7z adapter (see module doc). */
60
+ export interface WasmSevenZipDeps {
61
+ /** Base64 wasm blob (build-time define at the CLI seam); undefined → disk. */
62
+ wasmB64?: string;
63
+ /** Create the extraction target directory recursively. */
64
+ mkdirp: (dir: string) => void;
63
65
  }
64
66
 
65
67
  async function initModule(
@@ -105,8 +107,8 @@ function parseListNames(lines: string[]): string[] {
105
107
  return names;
106
108
  }
107
109
 
108
- export async function makeWasmSevenZip(): Promise<SevenZipPort> {
109
- const wasmBinary = embeddedWasmBinary();
110
+ export async function makeWasmSevenZip(deps: WasmSevenZipDeps): Promise<SevenZipPort> {
111
+ const wasmBinary = resolveEmbeddedWasmB64(deps.wasmB64);
110
112
  return {
111
113
  async add(archivePath: string, baseDir: string, entries: string[]): Promise<void> {
112
114
  const mod = await initModule(undefined, wasmBinary);
@@ -129,7 +131,7 @@ export async function makeWasmSevenZip(): Promise<SevenZipPort> {
129
131
  async extractAll(archivePath: string, destDir: string): Promise<void> {
130
132
  const mod = await initModule(undefined, wasmBinary);
131
133
  const archMount = mount(mod, 1, join(archivePath, '..'));
132
- mkdirSync(destDir, { recursive: true });
134
+ deps.mkdirp(destDir);
133
135
  const destMount = mount(mod, 2, destDir);
134
136
  mod.FS.chdir(destMount);
135
137
  const rc = mod.callMain(['x', `${archMount}/${basename(archivePath)}`, '-y']);
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Whether a close-out (finalize / archive) must run bdd.run_command before
3
+ * any merge or rename. Pure: the CLI supplies the flags, the spec scan, and
4
+ * the nested-invocation bit.
5
+ */
6
+
7
+ export type CloseOutHarnessDecision =
8
+ | { kind: 'skip'; announce: boolean }
9
+ | { kind: 'abort'; message: string }
10
+ | { kind: 'run'; command: string };
11
+
12
+ export function decideCloseOutHarness(input: {
13
+ noCheck: boolean;
14
+ needsSpecsChange: boolean;
15
+ hasExecutable: boolean;
16
+ runCommand: string | null;
17
+ nested: boolean;
18
+ }): CloseOutHarnessDecision {
19
+ if (input.noCheck) return { kind: 'skip', announce: true };
20
+ if (!input.needsSpecsChange || !input.hasExecutable) return { kind: 'skip', announce: false };
21
+ const command = input.runCommand?.trim() ?? '';
22
+ if (command === '') {
23
+ return { kind: 'abort', message: 'executable scenarios have no bdd.run_command' };
24
+ }
25
+ if (input.nested) {
26
+ return { kind: 'abort', message: 'bdd harness skipped: nested invocation' };
27
+ }
28
+ return { kind: 'run', command };
29
+ }
@@ -0,0 +1,140 @@
1
+ import { readBinding } from './frontmatter.ts';
2
+ /**
3
+ * Change collection + stage derivation (change domain, peripheral-commands
4
+ * capability r20/r34 shapes; predecessor commands/list.rs port). Pure — IO injected.
5
+ * Lives in change/ (not report/) so the domain never imports its renderers:
6
+ * report/{show,specs}.ts and validation/changeCheck.ts consume this one-way.
7
+ */
8
+ import { CHANGES_DIR } from './lifecycle.ts';
9
+ import { parseTaskCheckboxes } from './tasks.ts';
10
+
11
+ export interface ChangeFsIo {
12
+ exists(path: string): boolean;
13
+ readText(path: string): string;
14
+ listDir(path: string): string[];
15
+ isDirectory(path: string): boolean;
16
+ mtimeMs(path: string): number;
17
+ }
18
+
19
+ /** Minimal structural io for the active-change walk (r58/r73 shared caliber). */
20
+ export interface ChangeScanIo {
21
+ exists(path: string): boolean;
22
+ listDir(path: string): string[];
23
+ isDirectory(path: string): boolean;
24
+ }
25
+
26
+ /**
27
+ * r58/r73 shared walk: visits every active change dir (a dir directly holding
28
+ * proposal.md) under changesRoot — nested groups allowed, leaf name = change
29
+ * id, `archive/` and dot-dirs skipped, depth-limited. The CLI change scan and
30
+ * the dependency-reference resolver share this one traversal.
31
+ */
32
+ export function walkActiveChangeDirs(
33
+ io: ChangeScanIo,
34
+ changesRoot: string,
35
+ visit: (dir: string, name: string) => void,
36
+ opts: { maxScanDepth?: number } = {},
37
+ ): void {
38
+ if (!io.exists(changesRoot) || !io.isDirectory(changesRoot)) return;
39
+ const maxDepth = opts.maxScanDepth ?? 8;
40
+ const walk = (dir: string, depth: number): void => {
41
+ if (depth > maxDepth) return;
42
+ for (const name of io.listDir(dir).toSorted()) {
43
+ if (name === 'archive' || name.startsWith('.')) continue;
44
+ const child = `${dir}/${name}`;
45
+ if (!io.isDirectory(child)) continue;
46
+ if (io.exists(`${child}/proposal.md`)) visit(child, name);
47
+ else walk(child, depth + 1);
48
+ }
49
+ };
50
+ walk(changesRoot, 1);
51
+ }
52
+
53
+ export interface ChangeSummary {
54
+ name: string;
55
+ path: string;
56
+ title: string;
57
+ stage: 'draft' | 'designed' | 'planned' | 'full';
58
+ hasBinding: boolean;
59
+ completedTasks: number;
60
+ totalTasks: number;
61
+ lastModified: Date;
62
+ idleDays: number;
63
+ }
64
+
65
+ export type ChangeStatus = 'no-tasks' | 'complete' | 'in-progress';
66
+
67
+ export function statusFor(total: number, completed: number): ChangeStatus {
68
+ if (total === 0) return 'no-tasks';
69
+ return completed >= total ? 'complete' : 'in-progress';
70
+ }
71
+
72
+ export function stageFor(
73
+ hasDesign: boolean,
74
+ hasTasks: boolean,
75
+ hasBinding: boolean,
76
+ ): ChangeSummary['stage'] {
77
+ // Monotonic predecessor parity (r34): design.md gates designed, tasks.md only
78
+ // upgrades on top of design, binding only upgrades the complete set.
79
+ if (hasDesign && hasTasks && hasBinding) return 'full';
80
+ if (hasDesign && hasTasks) return 'planned';
81
+ if (hasDesign) return 'designed';
82
+ return 'draft';
83
+ }
84
+
85
+ export function countTasks(tasksMd: string): { completed: number; total: number } {
86
+ const { completed, total } = parseTaskCheckboxes(tasksMd);
87
+ return { completed, total };
88
+ }
89
+
90
+ export function firstH1(md: string): string {
91
+ for (const line of md.split('\n')) {
92
+ const m = line.match(/^#\s+(.*)$/u);
93
+ if (m?.[1]) return m[1].trim();
94
+ }
95
+ return '';
96
+ }
97
+
98
+ export function collectChanges(
99
+ io: ChangeFsIo,
100
+ root: string,
101
+ now: Date,
102
+ opts: { maxScanDepth?: number } = {},
103
+ ): ChangeSummary[] {
104
+ const changesDir = `${root}/${CHANGES_DIR}`;
105
+ if (!io.exists(changesDir) || !io.isDirectory(changesDir)) return [];
106
+ const out: ChangeSummary[] = [];
107
+ const readChangeDir = (dir: string, name: string): void => {
108
+ const proposal = `${dir}/proposal.md`;
109
+ const hasDesign = io.exists(`${dir}/design.md`);
110
+ const hasTasks = io.exists(`${dir}/tasks.md`);
111
+ const hasBinding = readBinding(io.readText(proposal)) !== null;
112
+ const { completed, total } = hasTasks
113
+ ? countTasks(io.readText(`${dir}/tasks.md`))
114
+ : { completed: 0, total: 0 };
115
+
116
+ // lastModified = newest mtime across the change dir
117
+ let latest = io.mtimeMs(proposal);
118
+ for (const f of [hasDesign ? `${dir}/design.md` : null, hasTasks ? `${dir}/tasks.md` : null]) {
119
+ if (f) latest = Math.max(latest, io.mtimeMs(f));
120
+ }
121
+ const lastModified = new Date(latest);
122
+ const idleDays = Math.floor((now.getTime() - lastModified.getTime()) / 86_400_000);
123
+
124
+ out.push({
125
+ name,
126
+ path: name,
127
+ title: firstH1(io.readText(proposal)),
128
+ stage: stageFor(hasDesign, hasTasks, hasBinding),
129
+ hasBinding,
130
+ completedTasks: completed,
131
+ totalTasks: total,
132
+ lastModified,
133
+ idleDays,
134
+ });
135
+ };
136
+ // r58: recursive, depth-limited proposal discovery (predecessor --max-scan-depth parity)
137
+ walkActiveChangeDirs(io, changesDir, readChangeDir, { maxScanDepth: opts.maxScanDepth });
138
+ // predecessor lists newest-first
139
+ return out.toSorted((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
140
+ }
@@ -5,20 +5,62 @@
5
5
  */
6
6
  import { parseDocument } from 'yaml';
7
7
 
8
+ import type { GitLike } from '../git/spawnGit.ts';
9
+
8
10
  export interface ChangeBinding {
9
11
  branch: string;
10
12
  baseBranch: string;
11
13
  baseSha: string;
12
14
  }
13
15
 
16
+ /**
17
+ * Single frontmatter splitter (r73): the block starts with `---\n` at offset 0
18
+ * and closes at the first standalone `---` line (followed by `\n` or EOF).
19
+ * Every read path (extractFrontmatter / readBinding / parseDeps /
20
+ * needs_specs_change) and the write path share this one contract.
21
+ */
14
22
  function splitFrontmatter(proposal: string): { body: string; frontmatter: string | null } {
15
23
  if (!proposal.startsWith('---\n')) return { body: proposal, frontmatter: null };
16
- const end = proposal.indexOf('\n---\n', 4);
17
- if (end === -1) return { body: proposal, frontmatter: null };
18
- return {
19
- frontmatter: proposal.slice(4, end + 1),
20
- body: proposal.slice(end + 5),
21
- };
24
+ const rest = proposal.slice(4);
25
+ let pos = 0;
26
+ while (pos <= rest.length) {
27
+ const nl = rest.indexOf('\n', pos);
28
+ const lineEnd = nl === -1 ? rest.length : nl;
29
+ if (rest.slice(pos, lineEnd) === '---') {
30
+ return {
31
+ frontmatter: pos === 0 ? '' : rest.slice(0, pos - 1),
32
+ body: nl === -1 ? '' : rest.slice(nl + 1),
33
+ };
34
+ }
35
+ if (nl === -1) return { body: proposal, frontmatter: null };
36
+ pos = nl + 1;
37
+ }
38
+ return { body: proposal, frontmatter: null };
39
+ }
40
+
41
+ /** Frontmatter block content (between the delimiters), or null without a block. */
42
+ export function extractFrontmatter(proposal: string): string | null {
43
+ return splitFrontmatter(proposal).frontmatter;
44
+ }
45
+
46
+ /**
47
+ * r73: `needs_specs_change` is read from the frontmatter block only — a
48
+ * body line with the same text must not flip the verdict. Default true.
49
+ */
50
+ export function readNeedsSpecsChange(frontmatter: string | null): boolean {
51
+ const m = frontmatter?.match(/^needs_specs_change:\s*(true|false)\s*$/mu)?.[1];
52
+ return m === undefined ? true : m === 'true';
53
+ }
54
+
55
+ /**
56
+ * r73: live specs landed on the bound branch — per-line prefix match on
57
+ * `diff --name-only <base_branch>...<branch>` (a path like
58
+ * `docs/llmanspec/specs/x` must not count as landed).
59
+ */
60
+ export function specsLanded(git: GitLike, binding: ChangeBinding): boolean {
61
+ const touched =
62
+ git.runOpt(['diff', '--name-only', `${binding.baseBranch}...${binding.branch}`]) ?? '';
63
+ return touched.split('\n').some((line) => line.startsWith('llmanspec/specs/'));
22
64
  }
23
65
 
24
66
  export function readBinding(proposal: string): ChangeBinding | null {
package/src/change/id.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Change id derivation (change-lifecycle capability): v1 `change/new.rs`
2
+ * Change id derivation (change-lifecycle capability): predecessor `change/new.rs`
3
3
  * `derive_change_id` parity — pure kebab sanitization (lowercase alnum,
4
4
  * separators), no verb requirement, empty/oversized handling.
5
5
  */
@@ -8,11 +8,7 @@ const ID_CAP = 60;
8
8
 
9
9
  export class ChangeIdError extends Error {}
10
10
 
11
- export function isLegalChangeId(id: string): boolean {
12
- return /^[a-z0-9-]+$/u.test(id) && id.length > 0 && id.length <= ID_CAP;
13
- }
14
-
15
- /** v1 parity: sanitize to lowercase kebab (ASCII alnum only; CJK/punct dropped). */
11
+ /** predecessor parity: sanitize to lowercase kebab (ASCII alnum only; CJK/punct dropped). */
16
12
  export function deriveChangeId(description: string): string {
17
13
  const trimmed = description.trim();
18
14
  if (trimmed === '') {