@esneiderbravo/speclaw 0.3.12 → 0.4.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 (49) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/commands/lawbook.js +42 -1
  3. package/dist/cli/commands/query.js +20 -0
  4. package/dist/cli/commands/update.js +10 -0
  5. package/dist/cli/index.js +8 -0
  6. package/dist/modules/compass/diff-context.js +134 -0
  7. package/dist/modules/compass/explore-rich.js +129 -0
  8. package/dist/modules/compass/impact-summary.js +33 -0
  9. package/dist/modules/compass/register.js +164 -74
  10. package/dist/modules/foundation/context-budget.js +1 -14
  11. package/dist/modules/foundation/doctor.js +77 -0
  12. package/dist/modules/foundation/register-core.js +57 -88
  13. package/dist/modules/foundation/register.js +1 -21
  14. package/dist/modules/foundation/setup-tool.js +96 -0
  15. package/dist/modules/lawbook/assets/commands/archive.md +1 -1
  16. package/dist/modules/lawbook/assets/commands/draft.md +1 -1
  17. package/dist/modules/lawbook/assets/commands/explore.md +1 -1
  18. package/dist/modules/lawbook/assets/commands/investigate.md +7 -0
  19. package/dist/modules/lawbook/assets/commands/sync.md +2 -2
  20. package/dist/modules/lawbook/assets/rules/spec-reports-disciplines.md +8 -0
  21. package/dist/modules/lawbook/assets/skills/archive/SKILL.md +1 -1
  22. package/dist/modules/lawbook/assets/skills/archive/steps/03-validate-and-sync.md +3 -3
  23. package/dist/modules/lawbook/assets/skills/archive/steps/04-archive.md +1 -1
  24. package/dist/modules/lawbook/assets/skills/draft/steps/02-understand.md +1 -1
  25. package/dist/modules/lawbook/assets/skills/draft/steps/04-write-artifacts.md +1 -0
  26. package/dist/modules/lawbook/assets/skills/draft/steps/05-validate.md +1 -1
  27. package/dist/modules/lawbook/assets/skills/explore/steps/01-investigate.md +1 -1
  28. package/dist/modules/lawbook/assets/skills/investigate/SKILL.md +10 -0
  29. package/dist/modules/lawbook/assets/skills/investigate/steps/01-investigate.md +7 -0
  30. package/dist/modules/lawbook/assets/skills/investigate/steps/02-hand-off.md +6 -0
  31. package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +1 -1
  32. package/dist/modules/lawbook/assets/skills/sync/SKILL.md +1 -1
  33. package/dist/modules/lawbook/assets/skills/sync/steps/03-validate.md +1 -1
  34. package/dist/modules/lawbook/assets/skills/sync/steps/04-promote.md +1 -1
  35. package/dist/modules/lawbook/bugfix.js +195 -0
  36. package/dist/modules/lawbook/change-tool.js +90 -0
  37. package/dist/modules/lawbook/engine.js +70 -7
  38. package/dist/modules/lawbook/investigate.js +358 -0
  39. package/dist/modules/lawbook/levels.js +49 -2
  40. package/dist/modules/lawbook/register.js +102 -52
  41. package/dist/modules/lawbook/stack-parse.js +135 -0
  42. package/dist/modules/tools/register.js +4 -26
  43. package/dist/shared/deprecation.js +99 -0
  44. package/dist/shared/exposure.js +5 -19
  45. package/dist/shared/git.js +25 -0
  46. package/dist/shared/mcp.js +29 -3
  47. package/dist/shared/output-budget.js +68 -0
  48. package/dist/shared/tool-catalog.js +49 -0
  49. package/package.json +1 -1
@@ -2,7 +2,8 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { coverageArchiveBlockers } from "./coverage.js";
4
4
  import { sealCapability } from "./anchors.js";
5
- import { artifactNeeds, confirmedLevel, countUncheckedTasks, gatherSignals, hasDisciplineReport, loadCeremonyConfig, proposeLevel, } from "./levels.js";
5
+ import { artifactNeeds, confirmedLevel, countUncheckedTasks, gatherSignals, hasDisciplineReport, loadCeremonyConfig, proposeLevel, readChangeType, readCeremonyRecord, } from "./levels.js";
6
+ import { inferBugResolution, preventionRequiresDelta, validateBugfixContent } from "./bugfix.js";
6
7
  // speclaw's own spec-driven workflow engine. Inspired by OpenSpec's model
7
8
  // (proposals, delta specs, changes, archive) but implemented from scratch and
8
9
  // deliberately simpler: a change's specs/ holds the full intended spec for each
@@ -176,7 +177,20 @@ export function specValidate(projectPath, change, remeasure) {
176
177
  };
177
178
  }
178
179
  const level = confirmedLevel(projectPath, change);
179
- const needs = artifactNeeds(level);
180
+ const changeType = readChangeType(projectPath, change);
181
+ const needs = artifactNeeds(level, changeType);
182
+ if (needs.bugfix) {
183
+ const bugPath = path.join(changeDir, "bugfix.md");
184
+ if (!fs.existsSync(bugPath)) {
185
+ issues.push(`missing bugfix.md (required for bug changes at level ${level})`);
186
+ }
187
+ else {
188
+ issues.push(...validateBugfixContent(level, fs.readFileSync(bugPath, "utf8")));
189
+ }
190
+ if (fs.existsSync(path.join(changeDir, "proposal.md"))) {
191
+ issues.push("bug changes must not include proposal.md — use bugfix.md");
192
+ }
193
+ }
180
194
  if (needs.record && !fs.existsSync(path.join(changeDir, "record.md"))) {
181
195
  issues.push(`missing record.md (required at ceremony level ${level})`);
182
196
  }
@@ -200,6 +214,14 @@ export function specValidate(projectPath, change, remeasure) {
200
214
  if (needs.deltaSpecs && deltas.length === 0) {
201
215
  issues.push(`no delta specs under specs/ (required at ceremony level ${level})`);
202
216
  }
217
+ if (needs.bugfix) {
218
+ const bugPath = path.join(changeDir, "bugfix.md");
219
+ if (fs.existsSync(bugPath) && preventionRequiresDelta(fs.readFileSync(bugPath, "utf8"))) {
220
+ if (deltas.length === 0) {
221
+ issues.push("prevention §7 indicates a missing canonical requirement — add a delta spec under specs/");
222
+ }
223
+ }
224
+ }
203
225
  // Scope-growth: when remeasure targets provided (or change.json has prior signals
204
226
  // with paths we cannot recover), only check if caller passes targets.
205
227
  if (remeasure && (remeasure.paths.length > 0 || remeasure.symbols.length > 0)) {
@@ -315,8 +337,9 @@ export function specArchivePreconditions(projectPath, change) {
315
337
  return [`change "${change}" not found under lawbook/changes/`];
316
338
  const blockers = [];
317
339
  const level = confirmedLevel(projectPath, change);
318
- const needs = artifactNeeds(level);
319
- // 1. Every task must be checked (tasks.md, or checklist in record.md at level 0).
340
+ const changeType = readChangeType(projectPath, change);
341
+ const needs = artifactNeeds(level, changeType);
342
+ // 1. Every task must be checked (tasks.md, record.md, or bugfix checklist at level 0).
320
343
  if (needs.tasksFile) {
321
344
  const tasksPath = path.join(changeDir, "tasks.md");
322
345
  if (!fs.existsSync(tasksPath)) {
@@ -339,12 +362,32 @@ export function specArchivePreconditions(projectPath, change) {
339
362
  blockers.push(`${unchecked} unchecked task(s) in record.md`);
340
363
  }
341
364
  }
365
+ else if (needs.bugfix && level === 0) {
366
+ const bugPath = path.join(changeDir, "bugfix.md");
367
+ if (fs.existsSync(bugPath)) {
368
+ const unchecked = countUncheckedTasks(fs.readFileSync(bugPath, "utf8"));
369
+ if (unchecked > 0)
370
+ blockers.push(`${unchecked} unchecked item(s) in bugfix.md checklist`);
371
+ }
372
+ }
373
+ if (needs.bugfix) {
374
+ const bugPath = path.join(changeDir, "bugfix.md");
375
+ if (fs.existsSync(bugPath)) {
376
+ const content = fs.readFileSync(bugPath, "utf8");
377
+ const bugIssues = validateBugfixContent(level, content);
378
+ blockers.push(...bugIssues.filter((i) => i.includes("Regression test") || i.includes("§6")));
379
+ }
380
+ }
342
381
  // 2. At least one discipline report must exist (README.md scaffold aside).
343
382
  if (!hasDisciplineReport(changeDir)) {
344
383
  blockers.push("no discipline report under reports/ (build must record what was tested)");
345
384
  }
346
- // 3. Delta specs must already be synced — only when the level requires them.
347
- if (needs.deltaSpecs) {
385
+ // 3. Delta specs must already be synced — when required or when bug prevention demands it.
386
+ const bugPath = path.join(changeDir, "bugfix.md");
387
+ const bugNeedsDelta = needs.bugfix &&
388
+ fs.existsSync(bugPath) &&
389
+ preventionRequiresDelta(fs.readFileSync(bugPath, "utf8"));
390
+ if (needs.deltaSpecs || bugNeedsDelta) {
348
391
  for (const file of deltaSpecFiles(changeDir)) {
349
392
  const rel = path.relative(path.join(changeDir, "specs"), file);
350
393
  const canonical = path.join(root, "specs", rel);
@@ -381,7 +424,27 @@ export function specArchive(projectPath, change, date) {
381
424
  throw new Error(`cannot archive "${change}" — resolve first:\n${blockers.map((b) => ` - ${b}`).join("\n")}`);
382
425
  }
383
426
  const level = confirmedLevel(projectPath, change);
384
- const sync = artifactNeeds(level).deltaSpecs
427
+ const changeType = readChangeType(projectPath, change);
428
+ const needs = artifactNeeds(level, changeType);
429
+ // Record bug resolution before move.
430
+ if (changeType === "bug") {
431
+ const bugPath = path.join(changeDir, "bugfix.md");
432
+ if (fs.existsSync(bugPath)) {
433
+ const rec = readCeremonyRecord(projectPath, change);
434
+ if (rec) {
435
+ rec.changeType = "bug";
436
+ rec.resolution = inferBugResolution(fs.readFileSync(bugPath, "utf8"));
437
+ const cj = path.join(changeDir, "change.json");
438
+ fs.writeFileSync(cj, JSON.stringify(rec, null, 2) + "\n");
439
+ }
440
+ }
441
+ }
442
+ const bugPath = path.join(changeDir, "bugfix.md");
443
+ const shouldSync = needs.deltaSpecs ||
444
+ (changeType === "bug" &&
445
+ fs.existsSync(bugPath) &&
446
+ preventionRequiresDelta(fs.readFileSync(bugPath, "utf8")));
447
+ const sync = shouldSync
385
448
  ? specSync(projectPath, change)
386
449
  : { change, promoted: [], created: [], updated: [] };
387
450
  const { promoted, created, updated } = sync;
@@ -0,0 +1,358 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { indexExists, openDb } from "../compass/db.js";
4
+ import { explore, impact, recall } from "../compass/query.js";
5
+ import { affectedTests } from "../compass/affected.js";
6
+ import { hotspots, coupling } from "../compass/hotspots.js";
7
+ import { isGitRepo } from "../../shared/git.js";
8
+ import { lastTouch } from "../../shared/git-history.js";
9
+ import { frameSymbolName, parseStackTrace, } from "./stack-parse.js";
10
+ const WEIGHTS = {
11
+ "stack-frame": 40,
12
+ "frame-caller": 25,
13
+ "frame-callee": 15,
14
+ hotspot: 20,
15
+ "temporal-coupling": 15,
16
+ "semantic-match": 10,
17
+ "hint-path": 8,
18
+ "recently-changed": 10,
19
+ };
20
+ function resolveAtLine(projectPath, file, line) {
21
+ if (!indexExists(projectPath))
22
+ return null;
23
+ const db = openDb(projectPath);
24
+ try {
25
+ const row = db
26
+ .prepare(`SELECT s.name, s.kind, s.start_line AS startLine, s.signature
27
+ FROM nodes s JOIN files f ON f.id = s.file_id
28
+ WHERE f.path = ? AND s.start_line <= ? AND s.end_line >= ?
29
+ ORDER BY (s.end_line - s.start_line) ASC
30
+ LIMIT 1`)
31
+ .get(file, line, line);
32
+ if (!row)
33
+ return null;
34
+ return {
35
+ name: row.name,
36
+ kind: row.kind,
37
+ startLine: row.startLine,
38
+ signature: row.signature ?? undefined,
39
+ };
40
+ }
41
+ finally {
42
+ db.close();
43
+ }
44
+ }
45
+ function callerCount(projectPath, name) {
46
+ if (!indexExists(projectPath))
47
+ return 0;
48
+ const ex = explore(projectPath, name);
49
+ return ex.callers?.length ?? 0;
50
+ }
51
+ function addCandidate(map, key, c) {
52
+ const prev = map.get(key);
53
+ const weight = c.weight ?? WEIGHTS[c.reason];
54
+ const entry = prev ?? {
55
+ name: c.name,
56
+ kind: c.kind,
57
+ file: c.file,
58
+ startLine: c.startLine,
59
+ signature: c.signature,
60
+ reasons: [],
61
+ distanceFromFrame: c.distanceFromFrame,
62
+ callerCount: c.callerCount,
63
+ hotspotScore: c.hotspotScore,
64
+ };
65
+ entry.reasons.push({ reason: c.reason, weight, detail: c.detail });
66
+ entry.callerCount = Math.max(entry.callerCount, c.callerCount);
67
+ if (c.hotspotScore !== undefined) {
68
+ entry.hotspotScore = Math.max(entry.hotspotScore ?? 0, c.hotspotScore);
69
+ }
70
+ map.set(key, entry);
71
+ }
72
+ function scoreCandidate(c) {
73
+ let raw = c.reasons.reduce((s, r) => s + r.weight, 0);
74
+ raw /= Math.log2(c.callerCount + 2);
75
+ return Math.round(Math.min(100, Math.max(0, raw)));
76
+ }
77
+ function scanArchivedRootCauses(projectPath, symbol) {
78
+ const archiveRoot = path.join(projectPath, "lawbook", "changes", "archive");
79
+ if (!fs.existsSync(archiveRoot))
80
+ return [];
81
+ const hits = [];
82
+ for (const dir of fs.readdirSync(archiveRoot)) {
83
+ const bugfix = path.join(archiveRoot, dir, "bugfix.md");
84
+ if (!fs.existsSync(bugfix))
85
+ continue;
86
+ const text = fs.readFileSync(bugfix, "utf8");
87
+ if (text.includes(symbol))
88
+ hits.push(dir);
89
+ }
90
+ return hits;
91
+ }
92
+ /**
93
+ * Rank likely bug origins from the code graph and git history.
94
+ */
95
+ export async function investigate(args) {
96
+ const maxSuspects = args.maxSuspects ?? 8;
97
+ const degraded = [];
98
+ const hintPaths = (args.hintPaths ?? []).map((p) => p.replace(/^\.\//, ""));
99
+ if (!args.stackTrace?.trim() && !args.symptom?.trim()) {
100
+ throw new Error("provide stackTrace or symptom");
101
+ }
102
+ if (args.stackTrace?.trim()) {
103
+ const parsed = parseStackTrace(args.projectPath, args.stackTrace);
104
+ if (parsed.format === "unknown" && parsed.frames.length === 0 && parsed.unresolved.length > 0) {
105
+ return {
106
+ suspects: [],
107
+ unresolvedFrames: parsed.unresolved,
108
+ degraded: [],
109
+ guidance: "Stack trace could not be parsed. speclaw indexes TS/JS/Python only — use `symptom` for prose triage.",
110
+ inputSymptom: args.stackTrace.split("\n")[0],
111
+ };
112
+ }
113
+ }
114
+ if (!indexExists(args.projectPath)) {
115
+ return {
116
+ suspects: [],
117
+ unresolvedFrames: [],
118
+ degraded: ["no-index"],
119
+ guidance: "No Compass index — run `speclaw index` first. Without the graph, suspects cannot be verified.",
120
+ inputSymptom: args.symptom ?? args.stackTrace?.split("\n")[0],
121
+ };
122
+ }
123
+ const candidates = new Map();
124
+ let unresolvedFrames = [];
125
+ let frames = [];
126
+ if (args.stackTrace?.trim()) {
127
+ const parsed = parseStackTrace(args.projectPath, args.stackTrace);
128
+ unresolvedFrames = parsed.unresolved;
129
+ frames = parsed.frames;
130
+ if (parsed.format === "unknown" && parsed.frames.length === 0) {
131
+ return {
132
+ suspects: [],
133
+ unresolvedFrames,
134
+ degraded: [],
135
+ guidance: "Stack trace could not be parsed. speclaw indexes TS/JS/Python only — use `symptom` for prose triage.",
136
+ inputSymptom: args.stackTrace.split("\n")[0],
137
+ };
138
+ }
139
+ frames.forEach((frame, idx) => {
140
+ const dist = idx;
141
+ const atLine = resolveAtLine(args.projectPath, frame.file, frame.line);
142
+ const symName = atLine?.name ?? frameSymbolName(frame) ?? frame.fn;
143
+ if (atLine || symName) {
144
+ const name = atLine?.name ?? symName;
145
+ const cc = callerCount(args.projectPath, name);
146
+ addCandidate(candidates, `${frame.file}:${name}`, {
147
+ name,
148
+ kind: atLine?.kind ?? "function",
149
+ file: frame.file,
150
+ startLine: atLine?.startLine ?? frame.line,
151
+ signature: atLine?.signature,
152
+ reason: "stack-frame",
153
+ detail: `frame at ${frame.file}:${frame.line}`,
154
+ distanceFromFrame: dist,
155
+ callerCount: cc,
156
+ });
157
+ if (symName) {
158
+ const ex = explore(args.projectPath, name);
159
+ if (ex.found) {
160
+ for (const caller of ex.callers ?? []) {
161
+ addCandidate(candidates, `${caller.file}:${caller.name}`, {
162
+ name: caller.name,
163
+ kind: caller.kind,
164
+ file: caller.file,
165
+ startLine: caller.line,
166
+ reason: "frame-caller",
167
+ detail: `calls ${name} from the trace`,
168
+ distanceFromFrame: dist + 1,
169
+ callerCount: callerCount(args.projectPath, caller.name),
170
+ });
171
+ }
172
+ for (const callee of ex.callees ?? []) {
173
+ if (!callee.file)
174
+ continue;
175
+ addCandidate(candidates, `${callee.file}:${callee.name}`, {
176
+ name: callee.name,
177
+ kind: "function",
178
+ file: callee.file,
179
+ startLine: callee.line,
180
+ reason: "frame-callee",
181
+ detail: `called by ${name} in the trace`,
182
+ distanceFromFrame: dist + 1,
183
+ callerCount: callerCount(args.projectPath, callee.name),
184
+ });
185
+ }
186
+ }
187
+ }
188
+ }
189
+ });
190
+ }
191
+ if (args.symptom?.trim() && candidates.size === 0) {
192
+ try {
193
+ const hits = await recall(args.projectPath, args.symptom, 15);
194
+ for (const h of hits) {
195
+ addCandidate(candidates, `${h.file}:${h.name}`, {
196
+ name: h.name,
197
+ kind: h.kind,
198
+ file: h.file,
199
+ startLine: h.line,
200
+ signature: h.signature ?? undefined,
201
+ reason: "semantic-match",
202
+ detail: `semantic match for symptom`,
203
+ distanceFromFrame: null,
204
+ callerCount: callerCount(args.projectPath, h.name),
205
+ });
206
+ }
207
+ }
208
+ catch {
209
+ degraded.push("no-embeddings");
210
+ }
211
+ }
212
+ // Hotspots + coupling
213
+ const files = new Set([...candidates.values()].map((c) => c.file));
214
+ for (const f of frames)
215
+ files.add(f.file);
216
+ for (const h of hintPaths)
217
+ files.add(h);
218
+ try {
219
+ const hs = hotspots(args.projectPath, { days: 90, sortBy: "combined", limit: 200 });
220
+ let max = 0;
221
+ for (const h of hs.hotspots)
222
+ max = Math.max(max, h.combinedScore);
223
+ if (max <= 0)
224
+ degraded.push("no-hotspots");
225
+ else {
226
+ const hotspotByFile = new Map(hs.hotspots.map((h) => [h.file, h.combinedScore / max]));
227
+ for (const [file, score] of hotspotByFile) {
228
+ if (score < 0.3)
229
+ continue;
230
+ for (const [key, c] of candidates) {
231
+ if (c.file !== file)
232
+ continue;
233
+ addCandidate(candidates, key, {
234
+ ...c,
235
+ reason: "hotspot",
236
+ detail: `hotspot score ${score.toFixed(2)} on ${file}`,
237
+ hotspotScore: score,
238
+ });
239
+ }
240
+ }
241
+ }
242
+ }
243
+ catch {
244
+ degraded.push("no-hotspots");
245
+ }
246
+ try {
247
+ for (const file of files) {
248
+ const co = coupling(args.projectPath, file, { limit: 5 });
249
+ for (const p of co.partners) {
250
+ if (p.strength < 0.2)
251
+ continue;
252
+ for (const frameFile of frames.map((f) => f.file)) {
253
+ if (p.file === frameFile) {
254
+ for (const [key, c] of candidates) {
255
+ if (c.file === file) {
256
+ addCandidate(candidates, key, {
257
+ ...c,
258
+ reason: "temporal-coupling",
259
+ detail: `temporally coupled to ${frameFile} (strength ${p.strength.toFixed(2)})`,
260
+ });
261
+ }
262
+ }
263
+ }
264
+ }
265
+ }
266
+ }
267
+ }
268
+ catch {
269
+ degraded.push("no-coupling");
270
+ }
271
+ if (isGitRepo(args.projectPath)) {
272
+ for (const [key, c] of candidates) {
273
+ const touch = lastTouch(args.projectPath, c.file);
274
+ if (touch) {
275
+ addCandidate(candidates, key, {
276
+ ...c,
277
+ reason: "recently-changed",
278
+ detail: `last touched ${touch}`,
279
+ });
280
+ }
281
+ }
282
+ }
283
+ else {
284
+ degraded.push("no-git");
285
+ }
286
+ for (const h of hintPaths) {
287
+ for (const [key, c] of candidates) {
288
+ if (c.file === h || c.file.endsWith(h)) {
289
+ addCandidate(candidates, key, {
290
+ ...c,
291
+ reason: "hint-path",
292
+ detail: `matches hint path ${h}`,
293
+ });
294
+ }
295
+ }
296
+ }
297
+ let suspects = [...candidates.values()]
298
+ .map((c) => ({
299
+ name: c.name,
300
+ kind: c.kind,
301
+ file: c.file,
302
+ startLine: c.startLine,
303
+ signature: c.signature,
304
+ score: scoreCandidate(c),
305
+ reasons: c.reasons,
306
+ distanceFromFrame: c.distanceFromFrame,
307
+ hotspotScore: c.hotspotScore,
308
+ coveringTests: [],
309
+ }))
310
+ .sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
311
+ // Covering tests for top candidates
312
+ for (const s of suspects.slice(0, 5)) {
313
+ try {
314
+ const at = affectedTests(args.projectPath, { files: [s.file] });
315
+ s.coveringTests = at.tests.map((t) => t.file).slice(0, 5);
316
+ }
317
+ catch {
318
+ /* soft */
319
+ }
320
+ }
321
+ if (suspects.length > 0 && suspects.length < 3) {
322
+ // keep all when fewer than 3
323
+ }
324
+ else if (suspects.length > maxSuspects) {
325
+ suspects = suspects.slice(0, maxSuspects);
326
+ }
327
+ const top = suspects[0];
328
+ let blastRadiusSummary;
329
+ let priorFixes;
330
+ if (top) {
331
+ priorFixes = scanArchivedRootCauses(args.projectPath, top.name);
332
+ try {
333
+ const imp = impact(args.projectPath, { symbol: top.name, format: "grouped", maxDepth: 3 });
334
+ blastRadiusSummary = `${imp.totals.nodes} node(s) in ${imp.totals.modules} module(s) reachable from ${top.name}`;
335
+ }
336
+ catch {
337
+ blastRadiusSummary = `(run compass_impact on ${top.name})`;
338
+ }
339
+ }
340
+ const guidance = "Treat this ranking as evidence, not a verdict — read the top suspects yourself. " +
341
+ "Stack frames outrank graph neighbours; external/node_modules frames are excluded. " +
342
+ (priorFixes?.length
343
+ ? `Prior archive(s) mention this symbol: ${priorFixes.join(", ")}.`
344
+ : "No matching archived bugfix root cause found.");
345
+ return {
346
+ suspects,
347
+ unresolvedFrames,
348
+ degraded: [...new Set(degraded)],
349
+ guidance,
350
+ inputSymptom: args.symptom ?? args.stackTrace?.split("\n")[0]?.trim(),
351
+ blastRadiusSummary,
352
+ priorFixes,
353
+ };
354
+ }
355
+ /** Format investigate result as stable JSON text (deterministic key order). */
356
+ export function formatInvestigateResult(result) {
357
+ return JSON.stringify(result, null, 2);
358
+ }
@@ -34,7 +34,45 @@ const BUCKETS = {
34
34
  affectedTests: [0, 3, 15, Infinity],
35
35
  blastRadiusNodes: [2, 10, 50, Infinity],
36
36
  };
37
- export function artifactNeeds(level) {
37
+ export function artifactNeeds(level, changeType = "feature") {
38
+ if (changeType === "bug") {
39
+ switch (level) {
40
+ case 0:
41
+ return {
42
+ record: false,
43
+ proposal: false,
44
+ design: false,
45
+ tasksFile: false,
46
+ deltaSpecs: false,
47
+ reports: true,
48
+ designOptionalWithJustification: false,
49
+ bugfix: true,
50
+ };
51
+ case 1:
52
+ return {
53
+ record: false,
54
+ proposal: false,
55
+ design: false,
56
+ tasksFile: true,
57
+ deltaSpecs: false,
58
+ reports: true,
59
+ designOptionalWithJustification: false,
60
+ bugfix: true,
61
+ };
62
+ case 2:
63
+ case 3:
64
+ return {
65
+ record: false,
66
+ proposal: false,
67
+ design: true,
68
+ tasksFile: true,
69
+ deltaSpecs: false,
70
+ reports: true,
71
+ designOptionalWithJustification: false,
72
+ bugfix: true,
73
+ };
74
+ }
75
+ }
38
76
  switch (level) {
39
77
  case 0:
40
78
  return {
@@ -45,6 +83,7 @@ export function artifactNeeds(level) {
45
83
  deltaSpecs: false,
46
84
  reports: true,
47
85
  designOptionalWithJustification: false,
86
+ bugfix: false,
48
87
  };
49
88
  case 1:
50
89
  return {
@@ -55,6 +94,7 @@ export function artifactNeeds(level) {
55
94
  deltaSpecs: true,
56
95
  reports: true,
57
96
  designOptionalWithJustification: false,
97
+ bugfix: false,
58
98
  };
59
99
  case 2:
60
100
  return {
@@ -65,6 +105,7 @@ export function artifactNeeds(level) {
65
105
  deltaSpecs: true,
66
106
  reports: true,
67
107
  designOptionalWithJustification: true,
108
+ bugfix: false,
68
109
  };
69
110
  case 3:
70
111
  return {
@@ -75,9 +116,15 @@ export function artifactNeeds(level) {
75
116
  deltaSpecs: true,
76
117
  reports: true,
77
118
  designOptionalWithJustification: false,
119
+ bugfix: false,
78
120
  };
79
121
  }
80
122
  }
123
+ /** Read change type from change.json; missing ⇒ feature. */
124
+ export function readChangeType(projectPath, change) {
125
+ const rec = readCeremonyRecord(projectPath, change);
126
+ return rec?.changeType === "bug" ? "bug" : "feature";
127
+ }
81
128
  function bucketPoints(value, edges, points) {
82
129
  const i = edges.findIndex((max) => value <= max);
83
130
  return points[i < 0 ? 3 : i];
@@ -370,7 +417,7 @@ export function scaffoldArtifactsForLevel(projectPath, change, level) {
370
417
  const changeDir = path.join(projectPath, "lawbook", "changes", change);
371
418
  if (!fs.existsSync(changeDir))
372
419
  return;
373
- const needs = artifactNeeds(level);
420
+ const needs = artifactNeeds(level, readChangeType(projectPath, change));
374
421
  const recordPath = path.join(changeDir, "record.md");
375
422
  const recordText = fs.existsSync(recordPath) ? fs.readFileSync(recordPath, "utf8") : "";
376
423
  const ensure = (rel, content) => {