@esneiderbravo/speclaw 0.3.12 → 0.3.13

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.
@@ -1,5 +1,7 @@
1
1
  import { specInit, specValidate, specSync, specArchive, specList, } from "../../modules/lawbook/engine.js";
2
2
  import { handleLevel } from "../../modules/lawbook/quick.js";
3
+ import { scaffoldBugfix } from "../../modules/lawbook/bugfix.js";
4
+ import { investigate, formatInvestigateResult } from "../../modules/lawbook/investigate.js";
3
5
  import { list } from "../lib/args.js";
4
6
  import { ui } from "../lib/ui.js";
5
7
  function today() {
@@ -89,6 +91,45 @@ export async function runSpec(flags) {
89
91
  r.promoted.forEach((p) => ui.info(`${r.created.includes(p) ? "created" : "updated"}: ${p}`));
90
92
  return;
91
93
  }
94
+ case "investigate": {
95
+ const stackTrace = typeof flags["stack-trace"] === "string"
96
+ ? flags["stack-trace"]
97
+ : typeof flags.stackTrace === "string"
98
+ ? flags.stackTrace
99
+ : undefined;
100
+ const symptom = typeof flags.symptom === "string" ? flags.symptom : undefined;
101
+ const result = await investigate({
102
+ projectPath: cwd,
103
+ stackTrace,
104
+ symptom,
105
+ hintPaths: list(flags.path),
106
+ maxSuspects: flags.max !== undefined && flags.max !== true ? Number(flags.max) : undefined,
107
+ });
108
+ if (flags.json) {
109
+ console.log(formatInvestigateResult(result));
110
+ return;
111
+ }
112
+ console.log(formatInvestigateResult(result));
113
+ return;
114
+ }
115
+ case "draft": {
116
+ if (!flags.bug) {
117
+ ui.err("Usage: speclaw lawbook draft --bug <name> [--level N] [--json]");
118
+ process.exit(1);
119
+ }
120
+ const name = typeof flags.bug === "string" ? flags.bug : req(change, "lawbook draft --bug <name>");
121
+ const levelFlag = flags.level;
122
+ const level = levelFlag === undefined || levelFlag === true
123
+ ? undefined
124
+ : Number(levelFlag);
125
+ const result = scaffoldBugfix(cwd, name, { level });
126
+ if (flags.json) {
127
+ console.log(JSON.stringify(result, null, 2));
128
+ return;
129
+ }
130
+ ui.ok(`bug change "${name}" scaffolded at ${result.dir}`);
131
+ return;
132
+ }
92
133
  case "archive": {
93
134
  const r = specArchive(cwd, req(change, "lawbook archive <change>"), today());
94
135
  ui.ok(`archived to ${r.archivedTo} (${r.promoted.length} spec(s) promoted)`);
@@ -103,7 +144,7 @@ export async function runSpec(flags) {
103
144
  return;
104
145
  }
105
146
  default:
106
- ui.err("Usage: speclaw lawbook <init|list|validate|sync|archive|level> [change]");
147
+ ui.err("Usage: speclaw lawbook <init|list|validate|sync|archive|level|draft|investigate> [change]");
107
148
  process.exit(1);
108
149
  }
109
150
  }
@@ -135,6 +135,14 @@ const MIGRATIONS = [
135
135
  "docs/standards/lawbook.md wording if the project still says every change needs all four artifacts.\n" +
136
136
  "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
137
137
  },
138
+ {
139
+ version: "0.3.13",
140
+ describe: "Bugfix specs — draft --bug, bugfix.md, lawbook_investigate",
141
+ agentPrompt: "- Mention bug changes: `speclaw lawbook draft --bug`, `bugfix.md` (repro + regression + prevention), " +
142
+ "and `lawbook_investigate` / the investigate skill for graph-backed RCA. Feature ceremony unchanged; " +
143
+ "`changeType: bug` in `change.json`. Security-withheld mode is not in this release.\n" +
144
+ "- Preserve all project-specific wording; only apply these speclaw-authored changes.",
145
+ },
138
146
  ];
139
147
  /**
140
148
  * Update speclaw and bring the current project up to date without a full re-init:
package/dist/cli/index.js CHANGED
@@ -35,6 +35,8 @@ Lawbook (spec-driven workflow)
35
35
  lawbook init Create the lawbook/ workspace
36
36
  lawbook list Active/archived changes and capabilities
37
37
  lawbook level <mode> Propose/set/promote/explain ceremony level (--json)
38
+ lawbook draft --bug <c> Scaffold a bug change (bugfix.md + reports)
39
+ lawbook investigate Rank bug suspects from graph (--symptom / --stack-trace, --json)
38
40
  lawbook validate <c> Validate a change's artifacts
39
41
  lawbook sync <c> Promote delta specs to canonical
40
42
  lawbook archive <c> Finalize and archive a change
@@ -102,6 +104,10 @@ function maybeHeader(cmd, flags) {
102
104
  return;
103
105
  if (cmd === "lawbook" && flags.json && flags._[0] === "level")
104
106
  return;
107
+ if (cmd === "lawbook" && flags.json && flags._[0] === "investigate")
108
+ return;
109
+ if (cmd === "lawbook" && flags.json && flags._[0] === "draft")
110
+ return;
105
111
  header();
106
112
  }
107
113
  /** Run the handler for a single command. Returns when the command completes. */
@@ -527,6 +527,37 @@ function ceremonyChecks(projectPath) {
527
527
  ? "no archived changes"
528
528
  : `archived levels: 0=${counts["0"]}, 1=${counts["1"]}, 2=${counts["2"]}, 3=${counts["3"]} (missing change.json=${counts.missing})`,
529
529
  });
530
+ const typeCounts = { feature: 0, bug: 0, unknown: 0 };
531
+ if (fs.existsSync(archiveRoot)) {
532
+ for (const name of fs.readdirSync(archiveRoot)) {
533
+ const dir = path.join(archiveRoot, name);
534
+ if (!fs.statSync(dir).isDirectory())
535
+ continue;
536
+ try {
537
+ const p = path.join(dir, "change.json");
538
+ if (!fs.existsSync(p)) {
539
+ typeCounts.unknown += 1;
540
+ typeCounts.feature += 1;
541
+ continue;
542
+ }
543
+ const raw = JSON.parse(fs.readFileSync(p, "utf8"));
544
+ if (raw.changeType === "bug")
545
+ typeCounts.bug += 1;
546
+ else
547
+ typeCounts.feature += 1;
548
+ }
549
+ catch {
550
+ typeCounts.unknown += 1;
551
+ }
552
+ }
553
+ }
554
+ out.push({
555
+ id: "cfg.ceremony.changeTypes",
556
+ title: "change type distribution",
557
+ status: "ok",
558
+ value: JSON.stringify(typeCounts),
559
+ detail: `archived types: feature=${typeCounts.feature}, bug=${typeCounts.bug}`,
560
+ });
530
561
  return out;
531
562
  }
532
563
  function configurationChecks(projectPath, initialised) {
@@ -0,0 +1,7 @@
1
+ ---
2
+ description: Investigate a bug — graph-backed suspect ranking before draft --bug.
3
+ ---
4
+
5
+ Investigate: $ARGUMENTS
6
+
7
+ Follow the `investigate` skill: refresh the index, call `lawbook_investigate`, explore the top suspect, then offer `draft --bug`.
@@ -76,3 +76,11 @@ touched — and therefore which reports are owed, including `api.md` for any
76
76
  API-touching change — is the agent's responsibility to judge and satisfy before
77
77
  archiving; the engine gate counts files but cannot infer the set of concerns a
78
78
  change exercised.
79
+
80
+ ## 5. Bug changes must show the regression test failing first
81
+
82
+ When `changeType` is **bug**, the discipline report MUST include the output of
83
+ the regression test **failing before the fix** (or document why instrumentation
84
+ substitutes for a red-green cycle when reproduction is `unreproducible:`). A test
85
+ that only passes after the fix — with no evidence it ever failed — does not
86
+ satisfy the bug gate.
@@ -13,6 +13,7 @@ Create under `lawbook/changes/<name>/` only what the level needs:
13
13
  - **Level 2** — `proposal.md`, `tasks.md`, delta specs, `reports/`;
14
14
  `design.md` optional only with justification in `record.md`.
15
15
  - **Level 3** — `proposal.md`, `design.md`, `tasks.md`, delta specs, `reports/`.
16
+ - **Bug (`draft --bug`)** — `bugfix.md` instead of proposal/design; see the investigate skill for RCA first.
16
17
 
17
18
  For every level that needs delta specs:
18
19
 
@@ -0,0 +1,10 @@
1
+ ---
2
+ name: investigate
3
+ description: Forensic bug triage — rank suspects via lawbook_investigate before draft --bug. Use with a stack trace or symptom when starting RCA.
4
+ ---
5
+
6
+ # investigate — Bug RCA from the graph
7
+
8
+ Use when work is **"this is broken"**, not **"build X"**.
9
+
10
+ Read `steps/01-investigate.md` and do only what it says.
@@ -0,0 +1,7 @@
1
+ # Investigate the bug
2
+
3
+ - Refresh the index (`compass_index`).
4
+ - Call **`lawbook_investigate`** with `stackTrace` and/or `symptom`.
5
+ - **`compass_explore`** the top suspect — read the code yourself.
6
+
7
+ Next: read `steps/02-hand-off.md` and do only what it says.
@@ -0,0 +1,6 @@
1
+ # Hand off to draft
2
+
3
+ - **`compass_impact`** on the confirmed root cause for blast radius.
4
+ - **`speclaw lawbook draft --bug <name>`** — pre-seed only; fill repro, fix, regression test, prevention.
5
+
6
+ Treat the ranking as **evidence**, not a verdict. No further steps remain — investigate workflow complete.
@@ -0,0 +1,195 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { gatherSignals, loadCeremonyConfig, proposeLevel, setCeremonyLevel, writeCeremonyRecord, } from "./levels.js";
4
+ export const BUGFIX_HEADINGS = [
5
+ "1. Observed symptom",
6
+ "2. Minimal reproduction",
7
+ "3. Root cause",
8
+ "4. Blast radius",
9
+ "5. Proposed fix",
10
+ "6. Regression test",
11
+ "7. Prevention",
12
+ ];
13
+ /** Parsed section bodies keyed by heading label. */
14
+ export function parseBugfixSections(content) {
15
+ const out = new Map();
16
+ for (const part of content.split(/^##\s+/m).slice(1)) {
17
+ const nl = part.indexOf("\n");
18
+ if (nl < 0) {
19
+ out.set(part.trim(), "");
20
+ continue;
21
+ }
22
+ out.set(part.slice(0, nl).trim(), part.slice(nl + 1).trim());
23
+ }
24
+ return out;
25
+ }
26
+ function sectionBody(sections, key) {
27
+ for (const [h, b] of sections) {
28
+ if (h.toLowerCase().startsWith(key.toLowerCase()))
29
+ return b;
30
+ }
31
+ return "";
32
+ }
33
+ function isFilled(body) {
34
+ const t = body.trim();
35
+ if (!t)
36
+ return false;
37
+ if (/^n\/a\s*:/i.test(t))
38
+ return true;
39
+ return t.length > 2;
40
+ }
41
+ /** True when prevention says a canonical requirement was missing. */
42
+ export function preventionRequiresDelta(content) {
43
+ const prev = sectionBody(parseBugfixSections(content), "7. Prevention");
44
+ if (!prev)
45
+ return false;
46
+ return (/\b(requirement|spec)\b.*\b(miss|missing|absent|incomplete|add|update)\b/i.test(prev) ||
47
+ /\bfaltaba\b/i.test(prev) ||
48
+ /\bmissing requirement\b/i.test(prev));
49
+ }
50
+ /** Infer archive resolution from bugfix.md content. */
51
+ export function inferBugResolution(content) {
52
+ if (/\bnot-a-bug\b/i.test(content) || /resolution:\s*not-a-bug/i.test(content)) {
53
+ return "not-a-bug";
54
+ }
55
+ const repro = sectionBody(parseBugfixSections(content), "2. Minimal reproduction");
56
+ if (/unreproducible\s*:/i.test(repro))
57
+ return "mitigated";
58
+ return "fixed";
59
+ }
60
+ /**
61
+ * Validate bugfix.md sections for a ceremony level.
62
+ *
63
+ * @returns Human-readable issues (empty when valid).
64
+ */
65
+ export function validateBugfixContent(level, content) {
66
+ const issues = [];
67
+ const sections = parseBugfixSections(content);
68
+ for (const h of BUGFIX_HEADINGS) {
69
+ if (!sections.has(h) && ![...sections.keys()].some((k) => k.startsWith(h.split(".")[0]))) {
70
+ issues.push(`bugfix.md missing heading "## ${h}"`);
71
+ }
72
+ }
73
+ const repro = sectionBody(sections, "2. Minimal reproduction");
74
+ if (!isFilled(repro) && !/unreproducible\s*:/i.test(repro)) {
75
+ issues.push("bugfix.md §2 requires reproduction steps or an `unreproducible:` block");
76
+ }
77
+ const requiredAt0 = [
78
+ "1. Observed symptom",
79
+ "2. Minimal reproduction",
80
+ "3. Root cause",
81
+ "5. Proposed fix",
82
+ "6. Regression test",
83
+ ];
84
+ const optionalAt0 = ["4. Blast radius", "7. Prevention"];
85
+ const allRequired = level >= 1 ? [...BUGFIX_HEADINGS] : requiredAt0;
86
+ for (const key of allRequired) {
87
+ const body = sectionBody(sections, key);
88
+ if (!isFilled(body) && !/unreproducible\s*:/i.test(body)) {
89
+ issues.push(`bugfix.md §${key.split(".")[0]} (${key}) is empty`);
90
+ }
91
+ }
92
+ if (level === 0) {
93
+ for (const key of optionalAt0) {
94
+ const body = sectionBody(sections, key);
95
+ if (body && !isFilled(body) && !/^n\/a\s*:/i.test(body)) {
96
+ issues.push(`bugfix.md §${key.split(".")[0]} must be filled or start with \`n/a:\``);
97
+ }
98
+ }
99
+ }
100
+ const prevention = sectionBody(sections, "7. Prevention");
101
+ const resolution = inferBugResolution(content);
102
+ if (resolution === "not-a-bug" && !isFilled(prevention)) {
103
+ issues.push("not-a-bug resolution requires a prevention entry (usually a spec clarity fix)");
104
+ }
105
+ else if (level >= 1 && !isFilled(prevention)) {
106
+ issues.push("bugfix.md §7 Prevention must be answered (law, spec gap, or explicit none with reason)");
107
+ }
108
+ const regression = sectionBody(sections, "6. Regression test");
109
+ const mitigated = /unreproducible\s*:/i.test(repro);
110
+ if (!mitigated && !isFilled(regression)) {
111
+ issues.push("bugfix.md §6 Regression test is required unless reproduction is unreproducible");
112
+ }
113
+ if (mitigated && !isFilled(regression) && !/\binstrument/i.test(content)) {
114
+ issues.push("unreproducible bugs require instrumentation (§6 or explicit instrumentation reference)");
115
+ }
116
+ return issues;
117
+ }
118
+ function bugfixTemplate(name, level, seed) {
119
+ const symptom = seed?.inputSymptom ??
120
+ "<What you see: error message, wrong value, screenshot reference. Do not interpret yet.>";
121
+ const root = seed?.suspects?.[0] != null
122
+ ? `${seed.suspects[0].name} (${seed.suspects[0].file}:${seed.suspects[0].startLine}) **(candidate — verify)**`
123
+ : "<symbol (file:line) — must resolve against the graph>";
124
+ const blast = seed?.blastRadiusSummary ??
125
+ "<Run compass_impact on the confirmed root cause; list modules and call sites.>";
126
+ return `# Bugfix: ${name}
127
+
128
+ **Level:** ${level} · **Type:** bug · **Severity:** normal
129
+
130
+ ## 1. Observed symptom
131
+ ${symptom}
132
+
133
+ ## 2. Minimal reproduction
134
+ <!-- Steps to reproduce, or \`unreproducible: <reason and what was tried>\` -->
135
+
136
+ ## 3. Root cause
137
+ ${root}
138
+
139
+ ## 4. Blast radius
140
+ ${blast}
141
+
142
+ ## 5. Proposed fix
143
+ <!-- The change; note discarded alternatives if any. -->
144
+
145
+ ## 6. Regression test
146
+ <!-- test/path.test.ts::case name — must fail BEFORE the fix -->
147
+
148
+ ## 7. Prevention
149
+ <!-- New law (executable-laws block), missing spec requirement, or "none: <reason>" -->
150
+ `;
151
+ }
152
+ /**
153
+ * Scaffold a bug change: `bugfix.md`, `change.json`, and `reports/`.
154
+ */
155
+ export function scaffoldBugfix(projectPath, name, opts = {}) {
156
+ const changeDir = path.join(projectPath, "lawbook", "changes", name);
157
+ if (fs.existsSync(changeDir)) {
158
+ throw new Error(`change "${name}" already exists under lawbook/changes/`);
159
+ }
160
+ const targets = opts.targets ?? { paths: [], symbols: [] };
161
+ const { thresholds } = loadCeremonyConfig(projectPath);
162
+ const signals = gatherSignals(projectPath, targets, thresholds);
163
+ const proposal = proposeLevel(signals, thresholds);
164
+ const level = opts.level ?? (proposal.level !== null && proposal.level <= 1 ? proposal.level : 1);
165
+ fs.mkdirSync(path.join(changeDir, "reports"), { recursive: true });
166
+ fs.writeFileSync(path.join(changeDir, "bugfix.md"), bugfixTemplate(name, level, opts.seed));
167
+ fs.writeFileSync(path.join(changeDir, "reports", "README.md"), `# Reports — ${name}\n\nBug reports MUST include the regression test **failing before the fix**.\n`);
168
+ if (level >= 1) {
169
+ fs.writeFileSync(path.join(changeDir, "tasks.md"), `- [ ] Reproduce and confirm root cause\n- [ ] Implement fix\n- [ ] Add regression test (red before, green after)\n- [ ] Complete prevention §7\n- [ ] Write discipline report under reports/\n`);
170
+ }
171
+ if (level >= 2) {
172
+ fs.writeFileSync(path.join(changeDir, "design.md"), `# Design — ${name}\n\n## Approach\n\n(structural bugfix — document the fix architecture)\n`);
173
+ }
174
+ const record = setCeremonyLevel(projectPath, name, {
175
+ proposal,
176
+ level,
177
+ confirmedBy: "human",
178
+ });
179
+ const updated = { ...record, changeType: "bug" };
180
+ writeCeremonyRecord(projectPath, name, updated);
181
+ return { change: name, proposal, record: updated, dir: changeDir };
182
+ }
183
+ /** Read change type from an archived folder path. */
184
+ export function readChangeTypeFromDir(changeDir) {
185
+ const p = path.join(changeDir, "change.json");
186
+ if (!fs.existsSync(p))
187
+ return "feature";
188
+ try {
189
+ const raw = JSON.parse(fs.readFileSync(p, "utf8"));
190
+ return raw.changeType === "bug" ? "bug" : "feature";
191
+ }
192
+ catch {
193
+ return "feature";
194
+ }
195
+ }
@@ -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) => {
@@ -6,6 +6,7 @@ import { assetsDir } from "../../shared/paths.js";
6
6
  import { copyRendered } from "../../shared/install.js";
7
7
  import { specInit, specValidate, specSync, specArchive, specList } from "./engine.js";
8
8
  import { handleLevel } from "./quick.js";
9
+ import { investigate, formatInvestigateResult } from "./investigate.js";
9
10
  import { buildCoverageReport, loadCoverageConfig, renderCoverageAgent } from "./coverage.js";
10
11
  import { buildDriftReport, renderDriftAgent } from "./drift.js";
11
12
  const ASSETS = assetsDir(import.meta.url);
@@ -39,6 +40,13 @@ export function registerSpec(server, opts = {}) {
39
40
  level: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]).optional(),
40
41
  reason: z.string().optional(),
41
42
  }, async (args) => text(handleLevel(args)));
43
+ add("lawbook_investigate", "Rank bug origins from the graph. Pass stackTrace or symptom. Returns suspects with reasons — evidence, not a verdict.", {
44
+ projectPath: z.string(),
45
+ stackTrace: z.string().optional(),
46
+ symptom: z.string().optional(),
47
+ hintPaths: z.array(z.string()).optional(),
48
+ maxSuspects: z.number().int().min(1).max(25).optional(),
49
+ }, async (args) => text(formatInvestigateResult(await investigate(args))));
42
50
  add("lawbook_validate", "Validate a change's proposal, tasks, and delta specs before build or sync.", { projectPath: z.string(), change: z.string() }, async ({ projectPath, change }) => text(specValidate(projectPath, change)));
43
51
  add("lawbook_sync", "Promote a change's delta specs into canonical lawbook/specs/ without archiving.", { projectPath: z.string(), change: z.string() }, async ({ projectPath, change }) => text(specSync(projectPath, change)));
44
52
  add("lawbook_archive", "Sync a change into canonical specs, then move it under changes/archive/.", {
@@ -0,0 +1,135 @@
1
+ import path from "node:path";
2
+ const V8_NAMED = /^\s*at\s+(?:async\s+)?(?:(.+?)\s+\()?([^\s()]+):(\d+):(\d+)\)?\s*$/;
3
+ const V8_ANON = /^\s*at\s+([^\s()]+):(\d+):(\d+)\s*$/;
4
+ const PY_FRAME = /^\s*File\s+"([^"]+)",\s*line\s+(\d+)(?:,\s*in\s+(.+))?\s*$/;
5
+ function isExternal(rawPath) {
6
+ const n = rawPath.replace(/\\/g, "/");
7
+ return (n.includes("node_modules/") ||
8
+ n.includes("node:internal") ||
9
+ n.startsWith("node:") ||
10
+ n.includes("/lib/python") ||
11
+ n.includes("site-packages/"));
12
+ }
13
+ /**
14
+ * Map `dist/foo.js` → `src/foo.ts` when the basename matches (no silent guess
15
+ * beyond that convention).
16
+ */
17
+ export function mapDistToSrc(rel) {
18
+ const n = rel.replace(/\\/g, "/");
19
+ if (!n.startsWith("dist/"))
20
+ return n;
21
+ const base = path.basename(n, path.extname(n));
22
+ return `src/${base}.ts`;
23
+ }
24
+ /**
25
+ * Normalize an absolute or relative trace path to a project-relative path.
26
+ *
27
+ * @param projectPath - Project root used to strip prefixes.
28
+ * @param rawPath - Path as it appears in the trace.
29
+ */
30
+ export function normalizeTracePath(projectPath, rawPath) {
31
+ let p = rawPath.replace(/\\/g, "/");
32
+ const root = projectPath.replace(/\\/g, "/");
33
+ if (p.startsWith(root + "/"))
34
+ p = p.slice(root.length + 1);
35
+ if (p.startsWith("file://")) {
36
+ try {
37
+ p = decodeURIComponent(new URL(p).pathname);
38
+ if (p.startsWith(root + "/"))
39
+ p = p.slice(root.length + 1);
40
+ }
41
+ catch {
42
+ /* keep raw */
43
+ }
44
+ }
45
+ p = p.replace(/^\.\//, "");
46
+ p = mapDistToSrc(p);
47
+ return p;
48
+ }
49
+ function parseV8Line(line, projectPath) {
50
+ const named = V8_NAMED.exec(line);
51
+ if (named) {
52
+ const rawPath = named[2];
53
+ if (isExternal(rawPath))
54
+ return { raw: line.trim(), reason: "external" };
55
+ const fn = named[1]?.trim() ?? "";
56
+ const file = normalizeTracePath(projectPath, rawPath);
57
+ return { fn, file, line: Number(named[3]), rawPath };
58
+ }
59
+ const anon = V8_ANON.exec(line);
60
+ if (anon) {
61
+ const rawPath = anon[1];
62
+ if (isExternal(rawPath))
63
+ return { raw: line.trim(), reason: "external" };
64
+ return {
65
+ fn: "",
66
+ file: normalizeTracePath(projectPath, rawPath),
67
+ line: Number(anon[2]),
68
+ rawPath,
69
+ };
70
+ }
71
+ return null;
72
+ }
73
+ function parsePythonLines(lines, projectPath) {
74
+ const pyFrames = [];
75
+ const unresolved = [];
76
+ for (const line of lines) {
77
+ const m = PY_FRAME.exec(line);
78
+ if (!m)
79
+ continue;
80
+ const rawPath = m[1];
81
+ if (isExternal(rawPath)) {
82
+ unresolved.push({ raw: line.trim(), reason: "external" });
83
+ continue;
84
+ }
85
+ pyFrames.push({
86
+ fn: m[3]?.trim() ?? "",
87
+ file: normalizeTracePath(projectPath, rawPath),
88
+ line: Number(m[2]),
89
+ rawPath,
90
+ });
91
+ }
92
+ // Python traces list shallow→deep; invert to deepest-first like V8.
93
+ pyFrames.reverse();
94
+ return { frames: pyFrames, unresolved, format: "python" };
95
+ }
96
+ /**
97
+ * Parse a V8 (Node) or Python stack trace into own-project frames.
98
+ *
99
+ * @param projectPath - Project root for path normalization.
100
+ * @param stackTrace - Raw stack trace text.
101
+ */
102
+ export function parseStackTrace(projectPath, stackTrace) {
103
+ const lines = stackTrace.split("\n");
104
+ const pyProbe = lines.some((l) => PY_FRAME.test(l));
105
+ if (pyProbe)
106
+ return parsePythonLines(lines, projectPath);
107
+ const frames = [];
108
+ const unresolved = [];
109
+ let sawV8 = false;
110
+ for (const line of lines) {
111
+ const parsed = parseV8Line(line, projectPath);
112
+ if (!parsed)
113
+ continue;
114
+ sawV8 = true;
115
+ if ("reason" in parsed)
116
+ unresolved.push(parsed);
117
+ else
118
+ frames.push(parsed);
119
+ }
120
+ if (!sawV8 && stackTrace.trim()) {
121
+ for (const line of lines) {
122
+ if (line.trim())
123
+ unresolved.push({ raw: line.trim(), reason: "unparseable" });
124
+ }
125
+ return { frames: [], unresolved, format: "unknown" };
126
+ }
127
+ return { frames, unresolved, format: "v8" };
128
+ }
129
+ /** Extract a simple method name from `Class.method` frames. */
130
+ export function frameSymbolName(frame) {
131
+ if (!frame.fn)
132
+ return "";
133
+ const dot = frame.fn.lastIndexOf(".");
134
+ return dot >= 0 ? frame.fn.slice(dot + 1) : frame.fn;
135
+ }
@@ -20,6 +20,7 @@ export const MINIMAL_OMIT = new Set([
20
20
  "lawbook_archive",
21
21
  "lawbook_list",
22
22
  "lawbook_level",
23
+ "lawbook_investigate",
23
24
  "init_project",
24
25
  "scaffold",
25
26
  "configure_agent",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "0.3.12",
3
+ "version": "0.3.13",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },