@llman-sdd/core 0.3.1 → 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,387 +1,15 @@
1
1
  /**
2
2
  * Change dependency graph (peripheral-commands capability, r21/r30/r54):
3
- * v1 `commands/graph.rs` observable contract — scope parsing, connected
3
+ * predecessor `commands/graph.rs` observable contract — scope parsing, connected
4
4
  * component subgraphs, conditional classDef, empty placeholder, bidirectional
5
5
  * seed BFS, error paths. Pure: IO injected.
6
+ *
7
+ * Implementation split under `graph/` (pure mechanical move, no behavior
8
+ * change); this file stays the public barrel.
6
9
  */
7
10
 
8
- import { CHANGES_DIR } from '../change/lifecycle.ts';
9
-
10
- export interface GraphFsIo {
11
- exists(path: string): boolean;
12
- readText(path: string): string;
13
- listDir(path: string): string[];
14
- isDirectory(path: string): boolean;
15
- }
16
-
17
- interface GraphNode {
18
- id: string;
19
- archived: boolean;
20
- /** true = directory on disk; false = phantom (frozen/removed dependency). */
21
- present: boolean;
22
- }
23
-
24
- interface DepEdge {
25
- from: string;
26
- to: string;
27
- }
28
-
29
- export class GraphError extends Error {}
30
-
31
- function extractArchivedId(name: string): string | null {
32
- const m = name.match(/^\d{4}-\d{2}-\d{2}-(.+)$/u);
33
- return m ? (m[1] ?? null) : null;
34
- }
35
-
36
- /** Extract depends_on entries (flow + block styles, r30). */
37
- export function parseDeps(proposal: string): string[] {
38
- const fm = proposal.match(/^---\n([\s\S]*?)\n---/u);
39
- const deps: string[] = [];
40
- if (!fm?.[1]) return deps;
41
- let inDeps = false;
42
- for (const line of fm[1].split('\n')) {
43
- const flow = line.match(/^depends_on:\s*\[([^\]]*)\]\s*$/u);
44
- if (flow) {
45
- for (const item of flow[1]?.split(',') ?? []) {
46
- const dep = item.trim().replaceAll(/^['"]|['"]$/gu, '');
47
- if (dep !== '' && !/^[0-9]+$/u.test(dep)) deps.push(dep);
48
- }
49
- inDeps = false;
50
- continue;
51
- }
52
- if (/^depends_on:\s*$/u.test(line)) {
53
- inDeps = true;
54
- continue;
55
- }
56
- if (inDeps) {
57
- const m = line.match(/^\s*-\s+(\S+)/u);
58
- if (m?.[1] && !/^[0-9]+$/u.test(m[1])) deps.push(m[1]);
59
- else if (line.trim() !== '') inDeps = false;
60
- }
61
- }
62
- return deps;
63
- }
64
-
65
- type ScopeKind = 'active' | 'archived';
66
-
67
- function parseScope(scope: string): ScopeKind[] {
68
- if (scope === 'all') return ['active', 'archived'];
69
- const kinds: ScopeKind[] = [];
70
- for (const part of scope.split(',')) {
71
- const t = part.trim();
72
- if (t === 'active') kinds.push('active');
73
- else if (t === 'archived') kinds.push('archived');
74
- else {
75
- throw new GraphError(
76
- `Unknown scope: '${flagClean(t)}'. Supported: active, archived, all (or comma-separated like active,archived)`,
77
- );
78
- }
79
- }
80
- if (kinds.length === 0) throw new GraphError('Scope cannot be empty');
81
- return kinds;
82
- }
83
-
84
- function flagClean(v: string): string {
85
- return v;
86
- }
87
-
88
- /** Active nodes: dirs (recursively) with a proposal.md (v1 discovery). */
89
- function collectActiveNodes(io: GraphFsIo, root: string): GraphNode[] {
90
- const out: GraphNode[] = [];
91
- const changesDir = `${root}/${CHANGES_DIR}`;
92
- if (!io.exists(changesDir) || !io.isDirectory(changesDir)) return out;
93
- const visit = (dir: string): void => {
94
- for (const name of io.listDir(dir).toSorted()) {
95
- if (name === 'archive' || name.startsWith('.')) continue;
96
- const child = `${dir}/${name}`;
97
- if (!io.isDirectory(child)) continue;
98
- if (io.exists(`${child}/proposal.md`)) {
99
- out.push({ id: name, archived: false, present: true });
100
- } else {
101
- visit(child);
102
- }
103
- }
104
- };
105
- visit(changesDir);
106
- return out;
107
- }
108
-
109
- function collectArchivedNodes(io: GraphFsIo, root: string): GraphNode[] {
110
- const archiveDir = `${root}/${CHANGES_DIR}/archive`;
111
- if (!io.exists(archiveDir) || !io.isDirectory(archiveDir)) return [];
112
- const out: GraphNode[] = [];
113
- const seen = new Set<string>();
114
- for (const name of io.listDir(archiveDir)) {
115
- const child = `${archiveDir}/${name}`;
116
- if (!io.isDirectory(child)) continue;
117
- const id = extractArchivedId(name);
118
- if (id !== null && !seen.has(id)) {
119
- seen.add(id);
120
- out.push({ id, archived: true, present: true });
121
- }
122
- }
123
- return out;
124
- }
125
-
126
- function collectNodes(io: GraphFsIo, root: string, kinds: ScopeKind[]): GraphNode[] {
127
- const combined: GraphNode[] = [];
128
- const seen = new Set<string>();
129
- for (const kind of kinds) {
130
- const nodes = kind === 'active' ? collectActiveNodes(io, root) : collectArchivedNodes(io, root);
131
- for (const node of nodes) {
132
- if (!seen.has(node.id)) {
133
- seen.add(node.id);
134
- combined.push(node);
135
- }
136
- }
137
- }
138
- return combined;
139
- }
140
-
141
- function proposalFor(io: GraphFsIo, root: string, node: GraphNode): string {
142
- if (node.archived) {
143
- const archiveDir = `${root}/${CHANGES_DIR}/archive`;
144
- // latest matching dirtategy: pick the newest date-prefixed dir
145
- let best = '';
146
- let bestDate = '';
147
- for (const name of io.listDir(archiveDir)) {
148
- const id = extractArchivedId(name);
149
- if (id !== node.id) continue;
150
- const date = name.slice(0, 10);
151
- if (best === '' || date > bestDate) {
152
- best = name;
153
- bestDate = date;
154
- }
155
- }
156
- const p = `${archiveDir}/${best}/proposal.md`;
157
- return io.exists(p) ? io.readText(p) : '';
158
- }
159
- const changesDir = `${root}/${CHANGES_DIR}`;
160
- const found: string[] = [];
161
- const visit = (dir: string): void => {
162
- for (const name of io.listDir(dir).toSorted()) {
163
- if (name === 'archive' || name.startsWith('.')) continue;
164
- const child = `${dir}/${name}`;
165
- if (!io.isDirectory(child)) continue;
166
- if (io.exists(`${child}/proposal.md`)) {
167
- if (name === node.id) found.push(`${child}/proposal.md`);
168
- continue;
169
- // ^ leaf change dir — do not descend further
170
- }
171
- visit(child);
172
- }
173
- };
174
- visit(changesDir);
175
- if (found.length === 0) return '';
176
- return io.readText(found[0] ?? '');
177
- }
178
-
179
- interface RelationMaps {
180
- depends: Map<string, string[]>;
181
- reverse: Map<string, string[]>;
182
- nodeSet: Set<string>;
183
- }
184
-
185
- function buildMaps(io: GraphFsIo, root: string, nodes: GraphNode[]): RelationMaps {
186
- const depends = new Map<string, string[]>();
187
- const reverse = new Map<string, string[]>();
188
- const nodeSet = new Set<string>();
189
- for (const node of nodes) {
190
- nodeSet.add(node.id);
191
- if (!node.present) continue;
192
- for (const dep of parseDeps(proposalFor(io, root, node))) {
193
- depends.set(node.id, [...(depends.get(node.id) ?? []), dep]);
194
- reverse.set(dep, [...(reverse.get(dep) ?? []), node.id]);
195
- }
196
- }
197
- return { depends, reverse, nodeSet };
198
- }
199
-
200
- function buildDefaultNodes(io: GraphFsIo, root: string, kinds: ScopeKind[]): GraphNode[] {
201
- const nodes = collectNodes(io, root, kinds);
202
- const nodeIds = new Set(nodes.map((n) => n.id));
203
- const all = collectNodes(io, root, ['active', 'archived']);
204
- const allMap = new Map(all.map((n) => [n.id, n]));
205
-
206
- const missing: string[] = [];
207
- for (const node of all) {
208
- if (!nodeIds.has(node.id) || !node.present) continue;
209
- for (const dep of parseDeps(proposalFor(io, root, node))) {
210
- if (!nodeIds.has(dep)) missing.push(dep);
211
- }
212
- }
213
- const seenMissing = new Set<string>();
214
- for (const dep of missing) {
215
- if (seenMissing.has(dep)) continue;
216
- seenMissing.add(dep);
217
- const existing = allMap.get(dep);
218
- if (existing !== undefined) nodes.push(existing);
219
- else nodes.push({ id: dep, archived: false, present: false });
220
- }
221
- return nodes;
222
- }
223
-
224
- function collectEdges(io: GraphFsIo, root: string, nodes: GraphNode[]): DepEdge[] {
225
- const nodeIds = new Set(nodes.map((n) => n.id));
226
- const edges: DepEdge[] = [];
227
- for (const node of nodes) {
228
- if (!node.present) continue;
229
- for (const dep of parseDeps(proposalFor(io, root, node))) {
230
- if (nodeIds.has(dep)) edges.push({ from: node.id, to: dep });
231
- }
232
- }
233
- return edges;
234
- }
235
-
236
- function connectedComponents(nodeIds: string[], edges: DepEdge[]): string[][] {
237
- if (nodeIds.length === 0) return [];
238
- const parent = new Map<string, string>(nodeIds.map((id) => [id, id]));
239
- const find = (x: string): string => {
240
- let root = parent.get(x) ?? x;
241
- while (parent.get(root) !== root) root = parent.get(root) ?? root;
242
- return root;
243
- };
244
- const union = (a: string, b: string): void => {
245
- const ra = find(a);
246
- const rb = find(b);
247
- if (ra !== rb) parent.set(ra, rb);
248
- };
249
- for (const edge of edges) union(edge.from, edge.to);
250
- const groups = new Map<string, string[]>();
251
- for (const id of nodeIds) {
252
- const root = find(id);
253
- groups.set(root, [...(groups.get(root) ?? []), id]);
254
- }
255
- return [...groups.values()].toSorted((a, b) => b.length - a.length);
256
- }
257
-
258
- function componentLabel(nodes: GraphNode[], ids: Set<string>): string {
259
- const comp = nodes.filter((n) => ids.has(n.id));
260
- const allActive = comp.every((n) => !n.archived && n.present);
261
- const allArchived = comp.every((n) => n.archived);
262
- if (allActive) return 'Active';
263
- if (allArchived) return 'Done';
264
- return 'Mixed';
265
- }
266
-
267
- const sanitize = (id: string): string => id.replaceAll('-', '_');
268
-
269
- export interface GraphOptions {
270
- scope?: string;
271
- depth?: number;
272
- seed?: string;
273
- }
274
-
275
- export function graphMermaid(io: GraphFsIo, root: string, opts: GraphOptions = {}): string[] {
276
- const scope = opts.scope ?? 'active';
277
- const kinds = parseScope(scope);
278
-
279
- let nodes: GraphNode[];
280
- if (opts.seed !== undefined) {
281
- nodes = buildSeedNeighborhood(io, root, opts.seed, opts.depth ?? 1, kinds);
282
- } else {
283
- nodes = buildDefaultNodes(io, root, kinds);
284
- }
285
-
286
- if (nodes.length === 0) {
287
- return [
288
- 'flowchart TD',
289
- opts.seed !== undefined
290
- ? ` empty["Change '${opts.seed}' not found or has no relationships"]`
291
- : ` empty["No changes in scope '${scope}'"]`,
292
- ];
293
- }
294
-
295
- const edges = collectEdges(io, root, nodes);
296
- const nodeIds = nodes.map((n) => n.id);
297
- const components = connectedComponents(nodeIds, edges);
298
- const hasArchived = nodes.some((n) => n.archived);
299
- const hasMissing = nodes.some((n) => !n.present);
300
-
301
- const lines = ['flowchart TD'];
302
- if (components.length > 1) {
303
- components.forEach((comp, idx) => {
304
- const ids = new Set(comp);
305
- const label = componentLabel(nodes, ids);
306
- lines.push(` subgraph sg${idx + 1}["${label}"]`);
307
- renderNodesAndEdges(nodes, edges, ids, lines, true);
308
- lines.push(' end');
309
- });
310
- } else {
311
- renderNodesAndEdges(nodes, edges, new Set(nodeIds), lines, false);
312
- }
313
- if (hasArchived) lines.push(' classDef archived fill:#d4edda,stroke:#28a745,color:#333');
314
- if (hasMissing) lines.push(' classDef missing fill:#f8d7da,stroke:#dc3545,color:#333');
315
- return lines;
316
- }
317
-
318
- function renderNodesAndEdges(
319
- nodes: GraphNode[],
320
- edges: DepEdge[],
321
- ids: Set<string>,
322
- lines: string[],
323
- indented: boolean,
324
- ): void {
325
- const pad = indented ? ' ' : ' ';
326
- for (const node of nodes) {
327
- if (!ids.has(node.id)) continue;
328
- if (!node.present) lines.push(`${pad}${sanitize(node.id)}["${node.id} ⚠ missing"]:::missing`);
329
- else if (node.archived)
330
- lines.push(`${pad}${sanitize(node.id)}["${node.id} ✓ done"]:::archived`);
331
- else lines.push(`${pad}${sanitize(node.id)}["${node.id}"]`);
332
- }
333
- for (const edge of edges) {
334
- if (!ids.has(edge.from)) continue;
335
- lines.push(`${pad}${sanitize(edge.from)} -->|depends on| ${sanitize(edge.to)}`);
336
- }
337
- }
338
-
339
- /** Seed neighborhood: bidirectional BFS (depends_on + reverse) over both sets. */
340
- function buildSeedNeighborhood(
341
- io: GraphFsIo,
342
- root: string,
343
- seedId: string,
344
- maxDepth: number,
345
- _kinds: ScopeKind[],
346
- ): GraphNode[] {
347
- const resolved = resolveSeedId(io, root, seedId);
348
- const allNodes = collectNodes(io, root, ['active', 'archived']);
349
- const nodeMap = new Map(allNodes.map((n) => [n.id, n]));
350
- if (!nodeMap.has(resolved)) {
351
- const prefix = resolved.split('-')[0] ?? '';
352
- const suggestions = [...nodeMap.keys()].filter((k) => k.startsWith(prefix));
353
- const hint = suggestions.length > 0 ? ` Did you mean: ${suggestions.join(', ')}?` : '';
354
- throw new GraphError(`change '${seedId}' not found.${hint}`);
355
- }
356
- const maps = buildMaps(io, root, allNodes);
357
- const visited = new Set<string>([resolved]);
358
- const queue: [string, number][] = [[resolved, 0]];
359
- while (queue.length > 0) {
360
- const [current, depth] = queue.shift() as [string, number];
361
- if (depth >= maxDepth) continue;
362
- const neighbors = [...(maps.depends.get(current) ?? []), ...(maps.reverse.get(current) ?? [])];
363
- for (const neighbor of neighbors) {
364
- if (maps.nodeSet.has(neighbor) && !visited.has(neighbor)) {
365
- visited.add(neighbor);
366
- queue.push([neighbor, depth + 1]);
367
- }
368
- }
369
- }
370
- const result = [...visited]
371
- .filter((id) => nodeMap.has(id))
372
- .map((id) => nodeMap.get(id) as GraphNode);
373
- result.sort((a, b) => a.id.localeCompare(b.id));
374
- return result;
375
- }
376
-
377
- function resolveSeedId(io: GraphFsIo, root: string, seedId: string): string {
378
- const active = collectActiveNodes(io, root);
379
- const archived = collectArchivedNodes(io, root);
380
- const all = [...active.map((n) => n.id), ...archived.map((n) => n.id)];
381
- if (all.includes(seedId)) return seedId;
382
- const viaPrefix = all.filter((id) => id.startsWith(seedId));
383
- if (viaPrefix.length === 1) return viaPrefix[0] ?? seedId;
384
- const viaArchivedPrefix = archived.map((n) => n.id).filter((id) => id.startsWith(seedId));
385
- if (viaArchivedPrefix.length === 1) return viaArchivedPrefix[0] ?? seedId;
386
- return seedId;
387
- }
11
+ export { GraphError, type GraphFsIo } from './graph/types.ts';
12
+ export type { GraphOptions, GraphNodeIr, GraphEdgeIr, GraphDataIr } from './graph/types.ts';
13
+ export { parseDeps } from './graph/deps.ts';
14
+ export { graphData } from './graph/graphData.ts';
15
+ export { graphMermaid } from './graph/render.ts';
@@ -1,16 +1,21 @@
1
- import { readBinding } from '../change/frontmatter.ts';
1
+ import { countTasks, firstH1, stageFor, type ChangeFsIo } from '../change/collect.ts';
2
+ import {
3
+ extractFrontmatter,
4
+ readBinding,
5
+ readNeedsSpecsChange,
6
+ specsLanded as specsLandedOf,
7
+ } from '../change/frontmatter.ts';
2
8
  import { CHANGES_DIR } from '../change/lifecycle.ts';
3
9
  import { currentBranch, isCleanTree, type GitLike } from '../git/spawnGit.ts';
4
10
  import { discoverSpecs } from '../validation/discover.ts';
5
11
  /**
6
- * show change JSON (peripheral-commands capability, r21): v1 field set and
12
+ * show change JSON (peripheral-commands capability, r21): predecessor field set and
7
13
  * gate structure (clean-tree / on-bound-branch / stage-complete /
8
- * specs-landed / tasks-done / validate). Object keys are inserted in v1's
14
+ * specs-landed / tasks-done / validate). Object keys are inserted in predecessor's
9
15
  * order; consumers compare structurally. Effects flow through the injected
10
16
  * FsIo and GitLike ports — this module stays pure.
11
17
  */
12
18
  import { validateAllSpecs } from '../validation/validate.ts';
13
- import { countTasks, firstH1, stageFor, type ChangeFsIo } from './collect.ts';
14
19
 
15
20
  export type ShowFsIo = ChangeFsIo;
16
21
 
@@ -45,7 +50,7 @@ export function showChangeJson(
45
50
  ? countTasks(io.readText(`${dir}/tasks.md`))
46
51
  : { completed: 0, total: 0 };
47
52
 
48
- // v1 parity: show works (gracefully degraded gates) outside a git repo.
53
+ // predecessor parity: show works (gracefully degraded gates) outside a git repo.
49
54
  let cleanTree = false;
50
55
  let current = null;
51
56
  try {
@@ -57,16 +62,11 @@ export function showChangeJson(
57
62
  const onBoundBranch = binding !== null && current === binding.branch;
58
63
 
59
64
  // specs landing: bound changes must have touched llmanspec/specs/ since base
60
- let specsLanded = false;
65
+ let landedOnBranch = false;
61
66
  if (binding !== null) {
62
- const touched =
63
- git.runOpt(['diff', '--name-only', `${binding.baseBranch}...${binding.branch}`]) ?? '';
64
- specsLanded = touched.includes('llmanspec/specs/');
67
+ landedOnBranch = specsLandedOf(git, binding);
65
68
  }
66
- // needs_specs_change is an explicit frontmatter declaration (default true)
67
- const fmMatch = proposal.match(/^---\n([\s\S]*?)\n---/u);
68
- const declaredNeeds = fmMatch?.[1]?.match(/^needs_specs_change:\s*(true|false)\s*$/mu)?.[1];
69
- const needsSpecsChange = declaredNeeds !== undefined ? declaredNeeds === 'true' : true;
69
+ const needsSpecsChange = readNeedsSpecsChange(extractFrontmatter(proposal));
70
70
 
71
71
  const tasksDone = total > 0 && completed >= total;
72
72
  const specReport = validateAllSpecs(discoverSpecs(deps.specsDir, io), io);
@@ -77,7 +77,7 @@ export function showChangeJson(
77
77
  : !hasTasks
78
78
  ? 'add tasks.md (current: designed → planned)'
79
79
  : stage === 'planned'
80
- ? 'bind via `llman sdd change start <id>` (planned → full)'
80
+ ? 'bind via `llman-sdd change start <id>` (planned → full)'
81
81
  : '';
82
82
  const tasksHint =
83
83
  total > 0 && completed < total
@@ -93,7 +93,7 @@ export function showChangeJson(
93
93
  {
94
94
  name: 'on-bound-branch',
95
95
  pass: onBoundBranch,
96
- hint: onBoundBranch ? '' : 'change is not attached; run `llman sdd change start <id>`',
96
+ hint: onBoundBranch ? '' : 'change is not attached; run `llman-sdd change start <id>`',
97
97
  },
98
98
  {
99
99
  name: 'stage-complete',
@@ -102,11 +102,11 @@ export function showChangeJson(
102
102
  },
103
103
  {
104
104
  name: 'specs-landed',
105
- pass: specsLanded || !needsSpecsChange,
105
+ pass: landedOnBranch || !needsSpecsChange,
106
106
  hint:
107
- specsLanded || !needsSpecsChange
107
+ landedOnBranch || !needsSpecsChange
108
108
  ? ''
109
- : 'edit live specs on the bound branch and commit (or needs_specs_change: false)',
109
+ : 'edit live specs on the bound branch and commit (or set needs_specs_change to false)',
110
110
  },
111
111
  {
112
112
  name: 'tasks-done',
@@ -116,7 +116,7 @@ export function showChangeJson(
116
116
  {
117
117
  name: 'validate',
118
118
  pass: validateOk,
119
- hint: validateOk ? '' : 'fix issues reported by `llman sdd validate <id> --strict`',
119
+ hint: validateOk ? '' : 'fix issues reported by `llman-sdd validate <id> --strict`',
120
120
  },
121
121
  ];
122
122
 
@@ -127,11 +127,9 @@ export function showChangeJson(
127
127
  stage,
128
128
  artifacts,
129
129
  readyToImplement: gateChecks.every((g) => g.pass),
130
- specsLanded,
130
+ specsLanded: landedOnBranch,
131
131
  needsSpecsChange,
132
132
  attached: binding !== null,
133
- deltaCount: 0,
134
- deltas: [] as string[],
135
133
  gateChecks,
136
134
  matchedViaPrefix: opts.matchedViaPrefix === true,
137
135
  };
@@ -1,4 +1,5 @@
1
- import { parseCapability } from '../spec/parser.ts';
1
+ import { localeToGherkinLang, parseCapability } from '../spec/parser.ts';
2
+ import { buildReqRegistry } from '../spec/reqRegistry.ts';
2
3
  /**
3
4
  * Spec authoring helpers (peripheral-commands capability, r22):
4
5
  * skeleton generation + global next req id. Pure, IO injected.
@@ -14,31 +15,40 @@ export interface SpecHelperIo {
14
15
  listDir(path: string): string[];
15
16
  }
16
17
 
17
- /**
18
- * v1 parity (`req_registry.rs::next_req_id_from_index`): smallest free rN over
19
- * the RULE (@human) req ids only; acceptance-only req tags do not occupy ids.
20
- */
21
- export function nextReqId(io: SpecHelperIo, specsDir: string): string {
22
- const used = new Set<number>();
18
+ interface ParsedEntry {
19
+ fileName: string;
20
+ doc: ReturnType<typeof parseCapability>;
21
+ }
22
+
23
+ function collectSpecEntries(io: SpecHelperIo, specsDir: string): ParsedEntry[] {
24
+ const entries: ParsedEntry[] = [];
23
25
  const walk = (dir: string): void => {
24
26
  if (!io.exists(dir)) return;
25
27
  for (const name of io.listDir(dir)) {
26
28
  const full = `${dir}/${name}`;
27
29
  if (name.endsWith('.feature')) {
28
- const doc = parseCapability(io.readText(full), full);
29
- for (const scenario of doc.scenarios) {
30
- if (scenario.classification !== 'human') continue;
31
- for (const reqId of scenario.reqIds) {
32
- const n = Math.trunc(Number(reqId.replace(/^r/u, '')));
33
- if (Number.isFinite(n)) used.add(n);
34
- }
35
- }
30
+ entries.push({ fileName: full, doc: parseCapability(io.readText(full), full) });
36
31
  } else if (io.isDirectory(full)) {
37
32
  walk(full);
38
33
  }
39
34
  }
40
35
  };
41
36
  walk(specsDir);
37
+ return entries;
38
+ }
39
+
40
+ /**
41
+ * predecessor parity (`req_registry.rs::next_req_id_from_index`): smallest free
42
+ * rN over the requirement handles (`@req` on `规则:` headers) only. The id set
43
+ * comes from the global req registry fed with the parsed specs.
44
+ */
45
+ export function nextReqId(io: SpecHelperIo, specsDir: string): string {
46
+ const entries = collectSpecEntries(io, specsDir);
47
+ const used = new Set(
48
+ [...buildReqRegistry(entries).byId.keys()].map((reqId) =>
49
+ Math.trunc(Number(reqId.replace(/^r/u, ''))),
50
+ ),
51
+ );
42
52
  let n = 1;
43
53
  while (used.has(n)) n += 1;
44
54
  return `r${n}`;
@@ -46,15 +56,27 @@ export function nextReqId(io: SpecHelperIo, specsDir: string): string {
46
56
 
47
57
  export function skeletonContent(capability: string, reqId: string, locale: string): string {
48
58
  const zh = localeFallbacks(locale)[0] === 'zh-Hans';
59
+ // r7: the `# language:` header derives from the locale mapping — never a
60
+ // second hardcoded caliber.
61
+ const language = localeToGherkinLang(zh ? 'zh-Hans' : 'en');
49
62
  const header = zh
50
- ? `# language: zh-CN\n# capability: ${capability}\n# purpose: TODO: 一句话描述该能力与其目的。\n# scope: src/`
51
- : `# language: en\n# capability: ${capability}\n# purpose: TODO: Describe this capability and its purpose.\n# scope: src/`;
63
+ ? `# language: ${language}\n# capability: ${capability}\n# purpose: TODO: 一句话描述该能力与其目的。\n# scope: llmanspec/`
64
+ : `# language: ${language}\n# capability: ${capability}\n# purpose: TODO: Describe this capability and its purpose.\n# scope: llmanspec/`;
52
65
  const feature = zh ? `功能: ${capability}` : `Feature: ${capability}`;
53
- const scenario = zh ? '场景: TODO-rule' : 'Scenario: TODO-rule';
54
- return `${header}\n\n${feature}\n\n @req:${reqId} @human\n ${scenario}\n System MUST ...\n`;
66
+ const ruleKw = zh ? '规则' : 'Rule';
67
+ const scenarioKw = zh ? '场景' : 'Scenario';
68
+ const ruleTitle = 'TODO-rule';
69
+ const ruleDesc = zh ? 'TODO: 需求描述(自由文本)。' : 'TODO: requirement statement (free text).';
70
+ const scTitle = 'TODO-acceptance';
71
+ // native v2 skeleton: a `规则:` block (with @req handle) + one nested example
72
+ return (
73
+ `${header}\n\n${feature}\n\n @req:${reqId}\n ${ruleKw}: ${ruleTitle}\n` +
74
+ ` ${ruleDesc}\n\n ${scenarioKw}: ${scTitle}\n` +
75
+ ` 假如 TODO 前置\n 当 TODO 动作\n 那么 TODO 断言\n`
76
+ );
55
77
  }
56
78
 
57
- /** `spec skeleton <cap>`: write llmanspec/specs/<cap>.feature + scaffold scope dir. */
79
+ /** `spec skeleton <cap>`: write llmanspec/specs/<cap>.feature (no repo-root src/). */
58
80
  export function scaffoldSpec(
59
81
  io: SpecHelperIo,
60
82
  specsDir: string,
@@ -65,7 +87,6 @@ export function scaffoldSpec(
65
87
  const reqId = nextReqId(io, specsDir);
66
88
  const path = `${specsDir}/${capability}.feature`;
67
89
  if (!opts.force && io.exists(path)) throw new Error(`spec already exists: ${path}`);
68
- io.mkdirp('src/');
69
90
  io.writeText(path, skeletonContent(capability, reqId, locale));
70
91
  return path;
71
92
  }