@llman-sdd/core 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -33,6 +33,18 @@ export {
33
33
  type SddConfigInput,
34
34
  } from './config/schema.ts';
35
35
  export { ConfigValidationError, MAX_REPORTED_ISSUES, loadConfig } from './config/load.ts';
36
+ export {
37
+ ChangeIdError,
38
+ compileChangeIdPattern,
39
+ nextUniqueNumber,
40
+ renderChangeIdTemplate,
41
+ } from './config/changeId.ts';
42
+ export {
43
+ ExtraSkillsError,
44
+ renderConfigOverview,
45
+ setExtraSkills,
46
+ skillsJson,
47
+ } from './config/surface.ts';
36
48
 
37
49
  export type {
38
50
  CapabilityDoc,
@@ -48,6 +60,16 @@ export {
48
60
  parseFeatureSource,
49
61
  } from './spec/parser.ts';
50
62
  export { buildReqRegistry, type ReqRegistry, type RegistryDuplicate } from './spec/reqRegistry.ts';
63
+ export {
64
+ addReq,
65
+ addScenario,
66
+ allReqIds,
67
+ AuthoringError,
68
+ planDedupe,
69
+ resolveReq,
70
+ type DedupePlanItem,
71
+ type ResolvedReq,
72
+ } from './spec/authoring.ts';
51
73
  export type {
52
74
  SpecEntry,
53
75
  SpecIo,
@@ -56,7 +78,26 @@ export type {
56
78
  ValidationLevel,
57
79
  ValidationReport,
58
80
  } from './validation/validate.ts';
59
- export { validateAllSpecs, validateCapability } from './validation/validate.ts';
81
+ export { validateAllSpecs, validateCapability, applyStrict } from './validation/validate.ts';
82
+ export { splitVerb } from './config/changeId.ts';
83
+ export {
84
+ evaluateStaleness,
85
+ notApplicableStaleness,
86
+ type StalenessInfo,
87
+ } from './validation/staleness.ts';
88
+ export {
89
+ checkChangeDoc,
90
+ validateChange,
91
+ expandRunCommand,
92
+ hasPlaceholders,
93
+ STAGE_ORDER,
94
+ type ChangeCheckConfig,
95
+ type ChangeCheckInput,
96
+ type ChangeCheckResult,
97
+ type ChangeIssue,
98
+ type ChangeFsIoLite,
99
+ type StageGate,
100
+ } from './validation/changeCheck.ts';
60
101
  export { discoverSpecs, type DiscoveryIo } from './validation/discover.ts';
61
102
  export type { ChangeBinding } from './change/frontmatter.ts';
62
103
  export { readBinding, writeBinding } from './change/frontmatter.ts';
@@ -70,6 +111,7 @@ export {
70
111
  type GitLike,
71
112
  } from './git/spawnGit.ts';
72
113
  export { DRAFT_PROPOSAL_TEMPLATE, deriveChangeId, isLegalChangeId } from './change/id.ts';
114
+ export { extractUniqueNumber, harvestUniqueNumbers } from './change/nextId.ts';
73
115
  export {
74
116
  CHANGES_DIR,
75
117
  LifecycleError,
@@ -80,6 +122,9 @@ export {
80
122
  startChange,
81
123
  type FinalizeResult,
82
124
  type FsIo,
125
+ archiveChange,
126
+ archiveTaskGate,
127
+ changeDiffInfo,
83
128
  } from './change/lifecycle.ts';
84
129
  export { normalizeLocale, localeFallbacks } from './templates/locale.ts';
85
130
  export {
@@ -37,8 +37,10 @@ export function stageFor(
37
37
  hasTasks: boolean,
38
38
  hasBinding: boolean,
39
39
  ): ChangeSummary['stage'] {
40
- if (hasBinding) return 'full';
41
- if (hasTasks) return 'planned';
40
+ // Monotonic v1 parity (r34): design.md gates designed, tasks.md only
41
+ // upgrades on top of design, binding only upgrades the complete set.
42
+ if (hasDesign && hasTasks && hasBinding) return 'full';
43
+ if (hasDesign && hasTasks) return 'planned';
42
44
  if (hasDesign) return 'designed';
43
45
  return 'draft';
44
46
  }
@@ -64,17 +66,32 @@ export function firstH1(md: string): string {
64
66
  return '';
65
67
  }
66
68
 
67
- export function collectChanges(io: ChangeFsIo, root: string, now: Date): ChangeSummary[] {
69
+ export function collectChanges(
70
+ io: ChangeFsIo,
71
+ root: string,
72
+ now: Date,
73
+ opts: { maxScanDepth?: number } = {},
74
+ ): ChangeSummary[] {
68
75
  const changesDir = `${root}/${CHANGES_DIR}`;
69
76
  if (!io.exists(changesDir) || !io.isDirectory(changesDir)) return [];
77
+ const maxDepth = opts.maxScanDepth ?? 8;
70
78
  const out: ChangeSummary[] = [];
71
- for (const name of io.listDir(changesDir).toSorted()) {
72
- if (name === 'archive') continue;
73
- const dir = `${changesDir}/${name}`;
74
- if (!io.isDirectory(dir)) continue;
79
+ // r58: recursive, depth-limited proposal discovery (v1 --max-scan-depth parity)
80
+ const visit = (dir: string, depth: number): void => {
81
+ if (depth > maxDepth) return;
82
+ for (const name of io.listDir(dir).toSorted()) {
83
+ if (name === 'archive' || name.startsWith('.')) continue;
84
+ const child = `${dir}/${name}`;
85
+ if (!io.isDirectory(child)) continue;
86
+ if (io.exists(`${child}/proposal.md`)) {
87
+ readChangeDir(child, name);
88
+ } else {
89
+ visit(child, depth + 1);
90
+ }
91
+ }
92
+ };
93
+ const readChangeDir = (dir: string, name: string): void => {
75
94
  const proposal = `${dir}/proposal.md`;
76
- if (!io.exists(proposal)) continue;
77
-
78
95
  const hasDesign = io.exists(`${dir}/design.md`);
79
96
  const hasTasks = io.exists(`${dir}/tasks.md`);
80
97
  const hasBinding = readBinding(io.readText(proposal)) !== null;
@@ -101,7 +118,8 @@ export function collectChanges(io: ChangeFsIo, root: string, now: Date): ChangeS
101
118
  lastModified,
102
119
  idleDays,
103
120
  });
104
- }
121
+ };
122
+ visit(changesDir, 1);
105
123
  // v1 lists newest-first
106
124
  return out.toSorted((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
107
125
  }
@@ -130,12 +148,10 @@ export function pad(s: string, width: number): string {
130
148
  export function renderChangesList(changes: readonly ChangeSummary[], now: Date): string[] {
131
149
  const lines = ['Active changes:'];
132
150
  if (changes.length === 0) return lines;
133
- const nameWidth = Math.max(...changes.map((c) => c.name.length)) + 2;
134
- const stageWidth = Math.max(...changes.map((c) => c.stage.length)) + 7;
135
- const statusWidth = Math.max(...changes.map((c) => statusHuman(c).length)) + 5;
151
+ const nameWidth = Math.max(...changes.map((c) => c.name.length));
136
152
  for (const c of changes) {
137
153
  lines.push(
138
- ` ${pad(c.name, nameWidth)}${pad(c.stage, stageWidth)}${pad(statusHuman(c), statusWidth)}${relativeTime(
154
+ ` ${pad(c.name, nameWidth)} ${pad(c.stage, 10)} ${pad(statusHuman(c), 12)} ${relativeTime(
139
155
  c.lastModified,
140
156
  now,
141
157
  )}${statusFor(c.totalTasks, c.completedTasks) === 'no-tasks' ? ` (idle ${c.idleDays}d)` : ''}`,
@@ -1,8 +1,10 @@
1
1
  /**
2
- * Change dependency graph (peripheral-commands capability, r21):
3
- * mermaid output with v1 conventions — node ids dash→underscore, archived
4
- * changes annotated "✓ done" with the archived class, depends_on edges.
2
+ * Change dependency graph (peripheral-commands capability, r21/r30/r54):
3
+ * v1 `commands/graph.rs` observable contract — scope parsing, connected
4
+ * component subgraphs, conditional classDef, empty placeholder, bidirectional
5
+ * seed BFS, error paths. Pure: IO injected.
5
6
  */
7
+
6
8
  import { CHANGES_DIR } from '../change/lifecycle.ts';
7
9
 
8
10
  export interface GraphFsIo {
@@ -15,77 +17,371 @@ export interface GraphFsIo {
15
17
  interface GraphNode {
16
18
  id: string;
17
19
  archived: boolean;
18
- dependsOn: string[];
20
+ /** true = directory on disk; false = phantom (frozen/removed dependency). */
21
+ present: boolean;
22
+ }
23
+
24
+ interface DepEdge {
25
+ from: string;
26
+ to: string;
19
27
  }
20
28
 
21
- /** Extract depends_on entries from a proposal's YAML frontmatter. */
22
- function parseDeps(proposal: string): string[] {
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[] {
23
38
  const fm = proposal.match(/^---\n([\s\S]*?)\n---/u);
24
39
  const deps: string[] = [];
25
40
  if (!fm?.[1]) return deps;
26
41
  let inDeps = false;
27
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
+ }
28
52
  if (/^depends_on:\s*$/u.test(line)) {
29
53
  inDeps = true;
30
54
  continue;
31
55
  }
32
56
  if (inDeps) {
33
57
  const m = line.match(/^\s*-\s+(\S+)/u);
34
- if (m?.[1]) deps.push(m[1]);
58
+ if (m?.[1] && !/^[0-9]+$/u.test(m[1])) deps.push(m[1]);
35
59
  else if (line.trim() !== '') inDeps = false;
36
60
  }
37
61
  }
38
62
  return deps;
39
63
  }
40
64
 
41
- function collectNodes(io: GraphFsIo, root: string): GraphNode[] {
42
- const nodes: GraphNode[] = [];
43
- const scan = (dir: string, archived: boolean, stripDatePrefix: boolean): void => {
44
- if (!io.exists(dir) || !io.isDirectory(dir)) return;
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 => {
45
94
  for (const name of io.listDir(dir).toSorted()) {
46
- if (name === 'archive') continue;
47
- const full = `${dir}/${name}`;
48
- if (!io.isDirectory(full)) continue;
49
- const proposal = `${full}/proposal.md`;
50
- if (!io.exists(proposal)) continue;
51
- const id = archived && stripDatePrefix ? name.replace(/^\d{4}-\d{2}-\d{2}-/u, '') : name;
52
- nodes.push({ id, archived, dependsOn: parseDeps(io.readText(proposal)) });
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
+ }
53
103
  }
54
104
  };
55
- scan(`${root}/${CHANGES_DIR}`, false, false);
56
- // archived nodes appear only when referenced by an active depends_on
57
- const referenced = new Set(nodes.flatMap((n) => n.dependsOn));
105
+ visit(changesDir);
106
+ return out;
107
+ }
108
+
109
+ function collectArchivedNodes(io: GraphFsIo, root: string): GraphNode[] {
58
110
  const archiveDir = `${root}/${CHANGES_DIR}/archive`;
59
- if (io.exists(archiveDir) && io.isDirectory(archiveDir)) {
60
- for (const name of io.listDir(archiveDir).toSorted()) {
61
- if (!/\d{4}-\d{2}-\d{2}-/u.test(name)) continue;
62
- const id = name.replace(/^\d{4}-\d{2}-\d{2}-/u, '');
63
- if (!referenced.has(id)) continue;
64
- // archived nodes keep their real depends_on — v1 emits their edges too
65
- const proposal = `${archiveDir}/${name}/proposal.md`;
66
- const deps = io.exists(proposal) ? parseDeps(io.readText(proposal)) : [];
67
- nodes.push({ id, archived: true, dependsOn: deps });
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 });
68
121
  }
69
122
  }
70
- return nodes;
123
+ return out;
71
124
  }
72
125
 
73
- export function graphMermaid(io: GraphFsIo, root: string): string[] {
74
- const nodes = collectNodes(io, root);
75
- const lines: string[] = ['flowchart TD'];
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>();
76
189
  for (const node of nodes) {
77
- const nid = node.id.replaceAll('-', '_');
78
- const label = node.archived ? `${node.id} ✓ done` : node.id;
79
- lines.push(` ${nid}["${label}"]${node.archived ? ':::archived' : ''}`);
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 });
80
220
  }
81
- const byId = new Set(nodes.map((n) => n.id));
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[] = [];
82
227
  for (const node of nodes) {
83
- for (const dep of node.dependsOn) {
84
- // edges render only when both endpoints are shown nodes
85
- if (!byId.has(dep)) continue;
86
- lines.push(` ${node.id.replaceAll('-', '_')} -->|depends on| ${dep.replaceAll('-', '_')}`);
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 });
87
231
  }
88
232
  }
89
- lines.push(' classDef archived fill:#d4edda,stroke:#28a745,color:#333');
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');
90
315
  return lines;
91
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
+ }
@@ -41,8 +41,16 @@ export function showChangeJson(deps: ShowDeps, id: string): Record<string, unkno
41
41
  ? countTasks(io.readText(`${dir}/tasks.md`))
42
42
  : { completed: 0, total: 0 };
43
43
 
44
- const cleanTree = isCleanTree(git);
45
- const onBoundBranch = binding !== null && currentBranch(git) === binding.branch;
44
+ // v1 parity: show works (gracefully degraded gates) outside a git repo.
45
+ let cleanTree = false;
46
+ let current = null;
47
+ try {
48
+ cleanTree = isCleanTree(git);
49
+ current = currentBranch(git);
50
+ } catch {
51
+ current = null;
52
+ }
53
+ const onBoundBranch = binding !== null && current === binding.branch;
46
54
 
47
55
  // specs landing: bound changes must have touched llmanspec/specs/ since base
48
56
  let specsLanded = false;
@@ -60,7 +68,17 @@ export function showChangeJson(deps: ShowDeps, id: string): Record<string, unkno
60
68
  const specReport = validateAllSpecs(discoverSpecs(deps.specsDir, io), io);
61
69
  const validateOk = !specReport.failed && (total === 0 || tasksDone);
62
70
 
63
- const stageComplete = stage === 'full';
71
+ const stageHint = !hasDesign
72
+ ? 'add design.md (current: draft → designed)'
73
+ : !hasTasks
74
+ ? 'add tasks.md (current: designed → planned)'
75
+ : stage === 'planned'
76
+ ? 'bind via `llman sdd change start <id>` (planned → full)'
77
+ : '';
78
+ const tasksHint =
79
+ total > 0 && completed < total
80
+ ? `${total - completed} unchecked tasks`
81
+ : 'complete or check off remaining tasks';
64
82
 
65
83
  const gateChecks = [
66
84
  {
@@ -71,29 +89,30 @@ export function showChangeJson(deps: ShowDeps, id: string): Record<string, unkno
71
89
  {
72
90
  name: 'on-bound-branch',
73
91
  pass: onBoundBranch,
74
- hint: onBoundBranch ? '' : 'change is not attached; run `llman-sdd change start <id>`',
92
+ hint: onBoundBranch ? '' : 'change is not attached; run `llman sdd change start <id>`',
75
93
  },
76
94
  {
77
95
  name: 'stage-complete',
78
- pass: stageComplete,
79
- hint: stageComplete ? '' : `add ${!hasDesign ? 'design.md' : 'tasks.md'} (current: ${stage})`,
96
+ pass: hasDesign && hasTasks,
97
+ hint: hasDesign && hasTasks ? '' : (stageHint as string),
80
98
  },
81
99
  {
82
100
  name: 'specs-landed',
83
- pass: specsLanded,
84
- hint: specsLanded
85
- ? ''
86
- : 'edit live specs on the bound branch and commit (or needs_specs_change: false)',
101
+ pass: specsLanded || !needsSpecsChange,
102
+ hint:
103
+ specsLanded || !needsSpecsChange
104
+ ? ''
105
+ : 'edit live specs on the bound branch and commit (or needs_specs_change: false)',
87
106
  },
88
107
  {
89
108
  name: 'tasks-done',
90
- pass: tasksDone,
91
- hint: tasksDone ? '' : `${total - completed} unchecked tasks`,
109
+ pass: tasksDone && total > 0,
110
+ hint: tasksDone && total > 0 ? '' : tasksHint,
92
111
  },
93
112
  {
94
113
  name: 'validate',
95
114
  pass: validateOk,
96
- hint: validateOk ? '' : 'fix issues reported by `llman-sdd validate <id> --strict`',
115
+ hint: validateOk ? '' : 'fix issues reported by `llman sdd validate <id> --strict`',
97
116
  },
98
117
  ];
99
118