@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.
- package/dist/cli/commands/ship-on-stop.js +6 -1
- package/dist/cli/commands/ship.js +2 -1
- package/dist/cli/commands/update.js +1 -1
- package/dist/cli/index.js +6 -4
- package/dist/cli/lib/ui.js +3 -0
- package/dist/modules/compass/explore-rich.js +72 -0
- package/dist/modules/compass/indexer.js +2 -0
- package/dist/modules/compass/register.js +10 -8
- package/dist/modules/cortex/register.js +1 -1
- package/dist/modules/foundation/assets/AGENTS.template.md +6 -3
- package/dist/modules/foundation/assets/CLAUDE.template.md +6 -3
- package/dist/modules/foundation/check.js +24 -1
- package/dist/modules/foundation/compass-nudge.js +69 -3
- package/dist/modules/foundation/doctor.js +1 -1
- package/dist/modules/foundation/hooks.js +20 -7
- package/dist/modules/foundation/register-core.js +12 -3
- package/dist/modules/lawbook/assets/skills/cortex/SKILL.md +11 -11
- package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +2 -1
- package/dist/modules/lawbook/assets/skills/test/steps/01-quality-gates.md +3 -1
- package/dist/modules/lawbook/engine.js +2 -2
- package/dist/modules/lawbook/investigate.js +85 -4
- package/dist/modules/lawbook/levels.js +9 -3
- package/dist/modules/lawbook/quick.js +5 -2
- package/dist/modules/lawbook/register.js +7 -5
- package/dist/modules/lawbook/scaffold-change.js +17 -11
- package/dist/modules/lawbook/ship.js +369 -36
- package/dist/modules/lawbook/stack-parse.js +21 -5
- package/dist/server.js +15 -1
- package/dist/shared/exposure.js +4 -7
- package/dist/shared/index-stats.js +40 -0
- package/dist/shared/mcp.js +3 -2
- 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 ??
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
16
|
+
publicApi: 5,
|
|
17
17
|
globalFile: 5,
|
|
18
18
|
hotspot: 3,
|
|
19
19
|
hotspotFloor: 0.7,
|
|
20
|
-
cuts: [
|
|
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
|
-
|
|
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", "
|
|
37
|
-
add("lawbook_investigate", "
|
|
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
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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
|
}
|