@esneiderbravo/speclaw 2.0.18 → 2.0.20

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 (32) hide show
  1. package/dist/cli/commands/ship-on-stop.js +6 -1
  2. package/dist/cli/commands/ship.js +2 -1
  3. package/dist/cli/commands/update.js +1 -1
  4. package/dist/cli/index.js +6 -4
  5. package/dist/cli/lib/ui.js +3 -0
  6. package/dist/modules/compass/explore-rich.js +72 -0
  7. package/dist/modules/compass/indexer.js +2 -0
  8. package/dist/modules/compass/register.js +10 -8
  9. package/dist/modules/cortex/register.js +1 -1
  10. package/dist/modules/foundation/assets/AGENTS.template.md +6 -3
  11. package/dist/modules/foundation/assets/CLAUDE.template.md +6 -3
  12. package/dist/modules/foundation/check.js +24 -1
  13. package/dist/modules/foundation/compass-nudge.js +69 -3
  14. package/dist/modules/foundation/doctor.js +1 -1
  15. package/dist/modules/foundation/hooks.js +20 -7
  16. package/dist/modules/foundation/register-core.js +12 -3
  17. package/dist/modules/lawbook/assets/skills/cortex/SKILL.md +11 -11
  18. package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +2 -1
  19. package/dist/modules/lawbook/assets/skills/test/steps/01-quality-gates.md +3 -1
  20. package/dist/modules/lawbook/engine.js +2 -2
  21. package/dist/modules/lawbook/investigate.js +85 -4
  22. package/dist/modules/lawbook/levels.js +9 -3
  23. package/dist/modules/lawbook/quick.js +5 -2
  24. package/dist/modules/lawbook/register.js +7 -5
  25. package/dist/modules/lawbook/scaffold-change.js +17 -11
  26. package/dist/modules/lawbook/ship.js +369 -36
  27. package/dist/modules/lawbook/stack-parse.js +21 -5
  28. package/dist/server.js +15 -1
  29. package/dist/shared/exposure.js +4 -7
  30. package/dist/shared/index-stats.js +40 -0
  31. package/dist/shared/mcp.js +3 -2
  32. package/package.json +1 -1
@@ -16,7 +16,50 @@ const WEIGHTS = {
16
16
  "semantic-match": 10,
17
17
  "hint-path": 8,
18
18
  "recently-changed": 10,
19
+ "test-reachable": 20,
20
+ leaf: 10,
19
21
  };
22
+ /** Test files: their frames show where a failure surfaced, never its cause. */
23
+ const TEST_FILE_RE = /(^|\/)(tests?|__tests__|spec)\/|\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)test_[^/]+\.py$/;
24
+ /** Call depth and node cap for the walk from the failing test's file. */
25
+ const REACH_DEPTH = 8;
26
+ const REACH_CAP = 80;
27
+ /**
28
+ * Every project symbol reachable by calls from `files` (a failing test, a
29
+ * stack frame's file), breadth-first, with its depth and whether it calls
30
+ * anything else in the project. Only this code can make that test fail.
31
+ */
32
+ function reachableFrom(projectPath, files) {
33
+ const out = new Map();
34
+ for (const seed of files) {
35
+ const root = explore(projectPath, seed);
36
+ if (!root.found)
37
+ continue;
38
+ let frontier = (root.callees ?? []).filter((c) => c.file).map((c) => ({ ...c, depth: 1 }));
39
+ while (frontier.length && out.size < REACH_CAP) {
40
+ const next = [];
41
+ for (const c of frontier) {
42
+ const key = `${c.file}:${c.name}`;
43
+ if (out.has(key) || out.size >= REACH_CAP)
44
+ continue;
45
+ const ex = explore(projectPath, c.name);
46
+ const callees = (ex.callees ?? []).filter((x) => x.file);
47
+ out.set(key, {
48
+ name: c.name,
49
+ file: c.file,
50
+ line: c.line,
51
+ depth: c.depth,
52
+ leaf: callees.length === 0,
53
+ from: seed,
54
+ });
55
+ if (c.depth < REACH_DEPTH)
56
+ next.push(...callees.map((x) => ({ ...x, depth: c.depth + 1 })));
57
+ }
58
+ frontier = next;
59
+ }
60
+ }
61
+ return out;
62
+ }
20
63
  function resolveAtLine(projectPath, file, line) {
21
64
  if (!indexExists(projectPath))
22
65
  return null;
@@ -94,7 +137,7 @@ function scanArchivedRootCauses(projectPath, symbol) {
94
137
  * Rank likely bug origins from the code graph and git history.
95
138
  */
96
139
  export async function investigate(args) {
97
- const maxSuspects = args.maxSuspects ?? 8;
140
+ const maxSuspects = args.maxSuspects ?? 5;
98
141
  const degraded = [];
99
142
  const hintPaths = (args.hintPaths ?? []).map((p) => p.replace(/^\.\//, ""));
100
143
  if (!args.stackTrace?.trim() && !args.symptom?.trim()) {
@@ -138,6 +181,10 @@ export async function investigate(args) {
138
181
  };
139
182
  }
140
183
  frames.forEach((frame, idx) => {
184
+ // A test's own frame is where the failure surfaced, not where it was
185
+ // caused: it seeds the reachable walk below but is never a suspect.
186
+ if (TEST_FILE_RE.test(frame.file))
187
+ return;
141
188
  const dist = idx;
142
189
  const atLine = resolveAtLine(args.projectPath, frame.file, frame.line);
143
190
  const symName = atLine?.name ?? frameSymbolName(frame) ?? frame.fn;
@@ -189,10 +236,38 @@ export async function investigate(args) {
189
236
  }
190
237
  });
191
238
  }
192
- if (args.symptom?.trim() && candidates.size === 0) {
239
+ // A failing test (hinted, or a frame's file) bounds the search: only code it
240
+ // reaches can fail it, so look-alike symbols elsewhere never outrank that path.
241
+ const reach = reachableFrom(args.projectPath, [
242
+ ...new Set([...hintPaths, ...frames.map((f) => f.file)]),
243
+ ]);
244
+ for (const [key, r] of reach) {
245
+ addCandidate(candidates, key, {
246
+ name: r.name,
247
+ kind: "function",
248
+ file: r.file,
249
+ startLine: r.line,
250
+ reason: "test-reachable",
251
+ detail: `reached from ${r.from} in ${r.depth} call(s)`,
252
+ distanceFromFrame: r.depth,
253
+ callerCount: callerCount(args.projectPath, r.name),
254
+ });
255
+ if (r.leaf) {
256
+ const c = candidates.get(key);
257
+ addCandidate(candidates, key, {
258
+ ...c,
259
+ reason: "leaf",
260
+ detail: "calls nothing else in the project",
261
+ });
262
+ }
263
+ }
264
+ if (args.symptom?.trim() && (candidates.size === 0 || reach.size > 0)) {
193
265
  try {
194
266
  const hits = await recall(args.projectPath, args.symptom, 15);
195
267
  for (const h of hits) {
268
+ // With a reachable set, a semantic hit only corroborates code on it.
269
+ if (reach.size > 0 && !candidates.has(`${h.file}:${h.name}`))
270
+ continue;
196
271
  addCandidate(candidates, `${h.file}:${h.name}`, {
197
272
  name: h.name,
198
273
  kind: h.kind,
@@ -295,7 +370,11 @@ export async function investigate(args) {
295
370
  }
296
371
  }
297
372
  }
298
- let suspects = [...candidates.values()]
373
+ let suspects = [...candidates.entries()]
374
+ .filter(([key, c]) => reach.size === 0 ||
375
+ reach.has(key) ||
376
+ c.reasons.some((r) => r.reason === "stack-frame" || r.reason === "frame-caller"))
377
+ .map(([, c]) => c)
299
378
  .map((c) => ({
300
379
  name: c.name,
301
380
  kind: c.kind,
@@ -345,7 +424,9 @@ export async function investigate(args) {
345
424
  : "No matching archived bugfix root cause found.");
346
425
  return {
347
426
  suspects,
348
- unresolvedFrames,
427
+ // Runtime and dependency frames are excluded by design; listing each one
428
+ // only spends the reader's tokens.
429
+ unresolvedFrames: unresolvedFrames.filter((f) => f.reason !== "external"),
349
430
  degraded: [...new Set(degraded)],
350
431
  guidance,
351
432
  inputSymptom: args.symptom ?? args.stackTrace?.split("\n")[0]?.trim(),
@@ -13,11 +13,11 @@ export const DEFAULT_THRESHOLDS = {
13
13
  modulesTouched: [0, 2, 4, 6],
14
14
  affectedTests: [0, 1, 2, 4],
15
15
  blastRadiusNodes: [0, 1, 3, 5],
16
- publicApi: 4,
16
+ publicApi: 5,
17
17
  globalFile: 5,
18
18
  hotspot: 3,
19
19
  hotspotFloor: 0.7,
20
- cuts: [3, 8, 15],
20
+ cuts: [5, 16, 25],
21
21
  globalGlobs: [
22
22
  "package.json",
23
23
  "package-lock.json",
@@ -29,6 +29,8 @@ export const DEFAULT_THRESHOLDS = {
29
29
  docGlobs: ["**/*.md", "docs/**", "assets/**"],
30
30
  moduleRoots: ["src"],
31
31
  };
32
+ /** Commits in the hotspot window a file needs before it can count as a hotspot. */
33
+ const MIN_HOTSPOT_COMMITS = 3;
32
34
  const BUCKETS = {
33
35
  filesTouched: [1, 3, 10, Infinity],
34
36
  modulesTouched: [1, 2, 4, Infinity],
@@ -301,7 +303,11 @@ export function gatherSignals(projectPath, targets, t = DEFAULT_THRESHOLDS) {
301
303
  }
302
304
  try {
303
305
  const hs = hotspots(projectPath, { days: 90, sortBy: "combined", limit: 200 });
304
- const byFile = new Map(hs.hotspots.map((h) => [h.file, h.combinedScore]));
306
+ // The score is relative to the hottest file, so in a young repo any file
307
+ // is "the hottest"; only files with real churn count as hotspots.
308
+ const byFile = new Map(hs.hotspots
309
+ .filter((h) => h.activity.commits >= MIN_HOTSPOT_COMMITS)
310
+ .map((h) => [h.file, h.combinedScore]));
305
311
  let maxCombined = 0;
306
312
  for (const h of hs.hotspots)
307
313
  maxCombined = Math.max(maxCombined, h.combinedScore);
@@ -6,8 +6,9 @@ import { scaffoldChange } from "./scaffold-change.js";
6
6
  * @param projectPath - Project root with `lawbook/`.
7
7
  * @param name - Change folder name (kebab-case).
8
8
  * @param targets - Optional paths/symbols used to propose the level (default empty → score 0).
9
+ * @param measured - A proposal already measured from `targets`, and who confirmed the level.
9
10
  */
10
- export function scaffoldQuick(projectPath, name, targets = { paths: [], symbols: [] }) {
11
+ export function scaffoldQuick(projectPath, name, targets = { paths: [], symbols: [] }, measured = {}) {
11
12
  // quick always records level 0; if measurement says higher, still allow but note it.
12
13
  const quickRationale = (proposal) => proposal.level === null
13
14
  ? proposal.rationale
@@ -16,6 +17,8 @@ export function scaffoldQuick(projectPath, name, targets = { paths: [], symbols:
16
17
  : proposal.rationale;
17
18
  const r = scaffoldChange(projectPath, name, {
18
19
  targets,
20
+ proposal: measured.proposal,
21
+ confirmedBy: measured.confirmedBy,
19
22
  level: 0,
20
23
  recordProposal: (proposal) => ({ ...proposal, rationale: quickRationale(proposal) }),
21
24
  reason: (proposal) => proposal.level !== null && proposal.level > 0 ? "speclaw quick" : undefined,
@@ -23,7 +26,7 @@ export function scaffoldQuick(projectPath, name, targets = { paths: [], symbols:
23
26
  artifacts: ({ proposal }) => ({
24
27
  "record.md": `# ${name}
25
28
 
26
- **Level:** 0 (proposed: ${proposal.level ?? "n/a"}, confirmed by: human)
29
+ **Level:** 0 (proposed: ${proposal.level ?? "n/a"}, confirmed by: ${measured.confirmedBy ?? "human"})
27
30
  **Why:** ${quickRationale(proposal)}
28
31
 
29
32
  ## What changes
@@ -28,19 +28,21 @@ export function installWorkflow(projectPath, vars, report, opts) {
28
28
  /** Register the spec workflow MCP tools. */
29
29
  export function registerSpec(server, opts = {}) {
30
30
  const minimal = Boolean(opts.minimal);
31
- const add = (name, description, inputSchema, handler) => {
31
+ const add = (name, description, inputSchema, handler, alwaysLoad = false) => {
32
32
  if (!shouldExpose(name, minimal))
33
33
  return;
34
- defineTool(server, { name, description, inputSchema, handler });
34
+ defineTool(server, { name, description, inputSchema, handler, alwaysLoad });
35
35
  };
36
- add("lawbook_change", "Lawbook lifecycle: init, list, draft, validate, sync, archive, level, coverage, drift.", lawbookChangeSchema, async (args) => text(handleLawbookChange(args)));
37
- add("lawbook_investigate", "Rank bug origins from the graph. Pass stackTrace or symptom — evidence, not verdict.", {
36
+ add("lawbook_change", "Specs: draft before multi-module or public-API work, validate specs you write; level, sync, archive, list, coverage, drift.", lawbookChangeSchema, async (args) => text(handleLawbookChange(args)));
37
+ add("lawbook_investigate", "Use first on a bug, failing test, or stack trace: ranks likely origins from the graph.", {
38
38
  projectPath: z.string(),
39
39
  stackTrace: z.string().optional(),
40
40
  symptom: z.string().optional(),
41
41
  hintPaths: z.array(z.string()).optional(),
42
42
  maxSuspects: z.number().int().min(1).max(25).optional(),
43
- }, async (args) => text(formatInvestigateResult(await investigate(args))));
43
+ }, async (args) => text(formatInvestigateResult(await investigate(args))),
44
+ // Bugs are the commonest task; deferred, agents read files to triage.
45
+ true);
44
46
  if (minimal || !aliasesEnabled())
45
47
  return;
46
48
  const aliasHandler = (alias, action) => async (args) => {
@@ -25,9 +25,7 @@ export function scaffoldChange(projectPath, name, opts) {
25
25
  throw new Error(`change "${name}" already exists under lawbook/changes/`);
26
26
  }
27
27
  const targets = opts.targets ?? { paths: [], symbols: [] };
28
- const { thresholds } = loadCeremonyConfig(projectPath);
29
- const signals = gatherSignals(projectPath, targets, thresholds);
30
- const proposal = proposeLevel(signals, thresholds);
28
+ const proposal = opts.proposal ?? measureTargets(projectPath, targets);
31
29
  const level = typeof opts.level === "function" ? opts.level(proposal) : opts.level;
32
30
  const stored = opts.recordProposal ? opts.recordProposal(proposal) : proposal;
33
31
  const reason = typeof opts.reason === "function" ? opts.reason(proposal) : opts.reason;
@@ -52,7 +50,7 @@ export function scaffoldChange(projectPath, name, opts) {
52
50
  let confirmed = setCeremonyLevel(projectPath, name, {
53
51
  proposal: stored,
54
52
  level,
55
- confirmedBy: "human",
53
+ confirmedBy: opts.confirmedBy ?? "human",
56
54
  reason,
57
55
  });
58
56
  if (opts.changeType) {
@@ -71,9 +69,13 @@ export function scaffoldChange(projectPath, name, opts) {
71
69
  files: [...Object.keys(files), "reports/README.md", "change.json"],
72
70
  };
73
71
  }
74
- const featureRecordMd = (name, level, proposal) => `# ${name}
72
+ function measureTargets(projectPath, targets) {
73
+ const { thresholds } = loadCeremonyConfig(projectPath);
74
+ return proposeLevel(gatherSignals(projectPath, targets, thresholds), thresholds);
75
+ }
76
+ const featureRecordMd = (name, level, proposal, confirmedBy) => `# ${name}
75
77
 
76
- **Level:** ${level} (proposed: ${proposal.level ?? "n/a"}, confirmed by: human)
78
+ **Level:** ${level} (proposed: ${proposal.level ?? "n/a"}, confirmed by: ${confirmedBy})
77
79
  **Why:** ${proposal.rationale}
78
80
 
79
81
  ## What changes
@@ -172,18 +174,19 @@ The system SHALL provide the behavior described in \`proposal.md\` for ${change}
172
174
  * @param level - Confirmed level, or `undefined` for no stubs.
173
175
  * @param proposal - Proposal quoted in `record.md`.
174
176
  * @param delta - Capability and starting content for the delta (levels 1–3).
177
+ * @param confirmedBy - Who confirmed the level, quoted in `record.md`.
175
178
  */
176
179
  export function featureStubs(name, level, proposal, delta = {
177
180
  capability: name,
178
181
  content: featureDeltaMd(name, name),
179
- }) {
182
+ }, confirmedBy = "human") {
180
183
  const spec = { [`specs/${delta.capability}/spec.md`]: delta.content };
181
184
  switch (level) {
182
185
  case 0:
183
- return { "record.md": featureRecordMd(name, 0, proposal) };
186
+ return { "record.md": featureRecordMd(name, 0, proposal, confirmedBy) };
184
187
  case 1:
185
188
  return {
186
- "record.md": featureRecordMd(name, 1, proposal),
189
+ "record.md": featureRecordMd(name, 1, proposal, confirmedBy),
187
190
  "tasks.md": featureTasksMd(name),
188
191
  ...spec,
189
192
  };
@@ -211,7 +214,8 @@ export function featureStubs(name, level, proposal, delta = {
211
214
  *
212
215
  * @param projectPath - Project root with `lawbook/`.
213
216
  * @param name - Change folder name (kebab-case).
214
- * @param opts - Optional level, override reason, delta capability, and targets.
217
+ * @param opts - Optional level, override reason, delta capability, targets, a proposal
218
+ * already measured from them, and who confirmed the level.
215
219
  * @throws When `capability` is not kebab-case (plus every `scaffoldChange` refusal).
216
220
  */
217
221
  export function scaffoldFeature(projectPath, name, opts = {}) {
@@ -231,9 +235,11 @@ export function scaffoldFeature(projectPath, name, opts = {}) {
231
235
  return scaffoldChange(projectPath, name, {
232
236
  changeType: "feature",
233
237
  targets: opts.targets,
238
+ proposal: opts.proposal,
239
+ confirmedBy: opts.confirmedBy,
234
240
  level: opts.level,
235
241
  reason: opts.reason,
236
242
  reportsReadme: `# Reports — ${name}\n\nAdd at least one discipline report before archive.\n`,
237
- artifacts: ({ level, proposal }) => featureStubs(name, level, proposal, { capability, content }),
243
+ artifacts: ({ level, proposal }) => featureStubs(name, level, proposal, { capability, content }, opts.confirmedBy),
238
244
  });
239
245
  }