@esneiderbravo/speclaw 2.0.12 → 2.0.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.
- package/README.md +1 -1
- package/dist/cli/commands/lawbook.js +4 -1
- package/dist/cli/commands/query.js +2 -2
- package/dist/cli/commands/ship-on-stop.js +46 -0
- package/dist/cli/commands/ship.js +44 -0
- package/dist/cli/commands/update.js +25 -0
- package/dist/cli/index.js +6 -1
- package/dist/cli/lib/help.js +35 -3
- package/dist/modules/compass/db.js +47 -9
- package/dist/modules/compass/explore-rich.js +50 -9
- package/dist/modules/compass/extract.js +220 -0
- package/dist/modules/compass/find-output.js +172 -0
- package/dist/modules/compass/hybrid.js +149 -21
- package/dist/modules/compass/indexer.js +26 -2
- package/dist/modules/compass/query.js +48 -12
- package/dist/modules/compass/register.js +13 -7
- package/dist/modules/cortex/harness.js +17 -4
- package/dist/modules/cortex/status.js +1 -1
- package/dist/modules/foundation/assets/AGENTS.template.md +10 -9
- package/dist/modules/foundation/assets/CLAUDE.template.md +13 -10
- package/dist/modules/foundation/assets/docs/compass.template.md +11 -4
- package/dist/modules/foundation/deps.js +5 -3
- package/dist/modules/foundation/graph.js +5 -5
- package/dist/modules/foundation/hooks.js +30 -4
- package/dist/modules/foundation/laws.js +5 -0
- package/dist/modules/lawbook/assets/commands/cortex.md +1 -1
- package/dist/modules/lawbook/assets/rules/spec-reports-disciplines.md +6 -0
- package/dist/modules/lawbook/assets/rules/spec-tasks-mandatory-steps.md +6 -0
- package/dist/modules/lawbook/assets/skills/cortex/SKILL.md +17 -5
- package/dist/modules/lawbook/bugfix.js +12 -4
- package/dist/modules/lawbook/change-tool.js +7 -0
- package/dist/modules/lawbook/engine.js +3 -0
- package/dist/modules/lawbook/levels.js +35 -4
- package/dist/modules/lawbook/quick.js +17 -6
- package/dist/modules/lawbook/ship.js +325 -0
- package/dist/shared/mcp.js +6 -4
- package/dist/shared/output-budget.js +36 -12
- package/package.json +1 -1
|
@@ -197,8 +197,9 @@ export const BUILTIN_SQL_LIST = BUILTIN_GLOBALS.map((n) => `'${n}'`).join(", ");
|
|
|
197
197
|
/** Most bind parameters a scoped {@link resolveEdges} call uses before running unscoped. */
|
|
198
198
|
const MAX_SCOPE_PARAMS = 10_000;
|
|
199
199
|
/**
|
|
200
|
-
* Resolve call and
|
|
201
|
-
* lives, shared by the full index run and incremental
|
|
200
|
+
* Resolve call, import, and type-reference (`ref`) edges to node ids — the one
|
|
201
|
+
* place edge resolution lives, shared by the full index run and incremental
|
|
202
|
+
* re-indexing.
|
|
202
203
|
*
|
|
203
204
|
* - Edges whose `dst_node_id` points at a node that no longer exists (its file
|
|
204
205
|
* was re-extracted) are reset to unresolved first.
|
|
@@ -216,6 +217,10 @@ const MAX_SCOPE_PARAMS = 10_000;
|
|
|
216
217
|
* never call targets. An import of a symbol-less or re-exporting file (a
|
|
217
218
|
* barrel) resolves to that file's file-owner node, so member calls through
|
|
218
219
|
* it bind by name like this.
|
|
220
|
+
* - A `ref` resolves only to the definition its same-file import binding's
|
|
221
|
+
* target file holds (barrel subtree as for calls), else to a same-file
|
|
222
|
+
* definition; never by a bare global name (`ref` edges are not counted in
|
|
223
|
+
* the returned stats).
|
|
219
224
|
* - Imports resolve their specifier relative to the importing file, then
|
|
220
225
|
* through the nearest `tsconfig.json`/`jsconfig.json` `paths`/`baseUrl`, and
|
|
221
226
|
* point at the target file's file-owner node when it has one, otherwise at
|
|
@@ -287,6 +292,25 @@ export function resolveEdges(db, fileIds) {
|
|
|
287
292
|
OR (is_member = 2 AND edges.spec IS NOT NULL AND ${importTarget} IS NOT NULL))
|
|
288
293
|
${callScope}`).run(...(narrow ? [...ids, ...importScope, ...ids] : []));
|
|
289
294
|
const calls = resolvedCalls() - before;
|
|
295
|
+
// Covers: req~type-ref-edges~1
|
|
296
|
+
// A type reference resolves only through its own file: the definition its
|
|
297
|
+
// import binding's target file (or that barrel's subtree) holds, else a
|
|
298
|
+
// same-file definition. Never by a bare global name, so a generic `Props`
|
|
299
|
+
// in an unrelated file stays NULL. `is_member = 1` (`a.b.Type`, a
|
|
300
|
+
// non-import qualifier) never resolves.
|
|
301
|
+
db.prepare(`UPDATE edges SET dst_node_id = (
|
|
302
|
+
SELECT n.id FROM nodes n
|
|
303
|
+
WHERE n.name = edges.dst_name
|
|
304
|
+
AND n.kind <> '${FILE_NODE_KIND}'
|
|
305
|
+
AND ((edges.spec IS NOT NULL AND (n.file_id = ${importTarget} OR ${underTargetDir}))
|
|
306
|
+
OR (edges.is_member = 0 AND n.file_id = edges.src_file_id))
|
|
307
|
+
ORDER BY CASE WHEN n.file_id = ${importTarget} THEN 0
|
|
308
|
+
WHEN edges.spec IS NOT NULL AND ${underTargetDir} THEN 1
|
|
309
|
+
ELSE 2 END, n.id
|
|
310
|
+
LIMIT 1
|
|
311
|
+
)
|
|
312
|
+
WHERE kind = 'ref' AND dst_node_id IS NULL AND is_member <> 1
|
|
313
|
+
${callScope}`).run(...(narrow ? [...ids, ...importScope, ...ids] : []));
|
|
290
314
|
return { calls, imports };
|
|
291
315
|
}
|
|
292
316
|
/** Project root of an index database stored at `<root>/.speclaw/index.db`. */
|
|
@@ -170,11 +170,37 @@ function pickPrimary(symbols) {
|
|
|
170
170
|
}
|
|
171
171
|
return symbols.find((s) => s.kind === "function") ?? symbols[0];
|
|
172
172
|
}
|
|
173
|
+
/** Rows de-duplicated on every field (the call-only caller list, as before refs). */
|
|
174
|
+
function uniqueRows(rows) {
|
|
175
|
+
const seen = new Set();
|
|
176
|
+
return rows.filter((r) => {
|
|
177
|
+
const key = `${r.name}\0${r.kind}\0${r.file}\0${r.line}`;
|
|
178
|
+
if (seen.has(key))
|
|
179
|
+
return false;
|
|
180
|
+
seen.add(key);
|
|
181
|
+
return true;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* One caller entry per owner node with `via`: `call` (at its first call line)
|
|
186
|
+
* when the node calls the symbol, else `ref` (at its first reference line).
|
|
187
|
+
*/
|
|
188
|
+
// Covers: req~type-ref-edges~1
|
|
189
|
+
function callersWithVia(rows) {
|
|
190
|
+
const byNode = new Map();
|
|
191
|
+
for (const r of rows) {
|
|
192
|
+
const prev = byNode.get(r.id);
|
|
193
|
+
if (!prev || (prev.via === "ref" && r.via === "call")) {
|
|
194
|
+
byNode.set(r.id, { name: r.name, kind: r.kind, file: r.file, line: r.line, via: r.via });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return [...byNode.values()].sort((a, b) => a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1);
|
|
198
|
+
}
|
|
173
199
|
/**
|
|
174
200
|
* Source, callers, and callees for one definition. `others` are the
|
|
175
201
|
* `otherMatches` (same-name siblings, or the rest of a file).
|
|
176
202
|
*/
|
|
177
|
-
function assembleExplore(db, projectPath, best, others) {
|
|
203
|
+
function assembleExplore(db, projectPath, best, others, includeRefs = false) {
|
|
178
204
|
// Covers: req~compass-mcp-surface~1
|
|
179
205
|
// Resolved callees only, one row per target node at its first call line, so
|
|
180
206
|
// unresolved names (builtins, member calls) never crowd out project callees.
|
|
@@ -199,19 +225,25 @@ function assembleExplore(db, projectPath, best, others) {
|
|
|
199
225
|
// builtin global name (`test`, `fetch`) matches by name only within the
|
|
200
226
|
// definition's own file. A caller outside any definition is its file-owner
|
|
201
227
|
// node (`kind: "file"`).
|
|
202
|
-
|
|
203
|
-
|
|
228
|
+
// A `ref` edge (type reference) counts only through its resolved id, never by name.
|
|
229
|
+
const kinds = includeRefs ? "e.kind IN ('call', 'ref')" : "e.kind = 'call'";
|
|
230
|
+
const rows = db
|
|
231
|
+
.prepare(`SELECT DISTINCT owner.id AS id, owner.name AS name, owner.kind AS kind, f.path AS file,
|
|
232
|
+
e.line, e.kind AS via
|
|
204
233
|
FROM edges e
|
|
205
234
|
JOIN nodes owner ON owner.id = e.src_node_id
|
|
206
235
|
JOIN files f ON f.id = owner.file_id
|
|
207
|
-
WHERE
|
|
208
|
-
|
|
236
|
+
WHERE ${kinds}
|
|
237
|
+
AND (e.dst_node_id = ? OR (
|
|
238
|
+
e.kind = 'call' AND e.dst_name = ? AND e.is_member = 0
|
|
209
239
|
AND (e.dst_name NOT IN (${BUILTIN_SQL_LIST})
|
|
210
240
|
OR e.src_file_id = (SELECT file_id FROM nodes WHERE id = ?))
|
|
211
241
|
))
|
|
212
|
-
AND e.kind = 'call'
|
|
213
242
|
ORDER BY f.path, e.line`)
|
|
214
243
|
.all(best.id, best.name, best.id);
|
|
244
|
+
const callers = includeRefs
|
|
245
|
+
? callersWithVia(rows)
|
|
246
|
+
: uniqueRows(rows.map(({ name, kind, file, line }) => ({ name, kind, file, line })));
|
|
215
247
|
return {
|
|
216
248
|
found: true,
|
|
217
249
|
symbol: {
|
|
@@ -247,7 +279,7 @@ function assembleExplore(db, projectPath, best, others) {
|
|
|
247
279
|
* file one); a file with no node at all reports that it has no symbols. A non-unique suffix falls through to {@link search}, which also
|
|
248
280
|
* matches `files.path`.
|
|
249
281
|
*/
|
|
250
|
-
function exploreWhenNameMisses(db, projectPath, query) {
|
|
282
|
+
function exploreWhenNameMisses(db, projectPath, query, includeRefs) {
|
|
251
283
|
// Covers: req~explore-file-path~2
|
|
252
284
|
const resolved = resolveIndexedFile(db, projectPath, query);
|
|
253
285
|
if (resolved.status === "one") {
|
|
@@ -255,7 +287,7 @@ function exploreWhenNameMisses(db, projectPath, query) {
|
|
|
255
287
|
const owner = symbols.length === 0 ? fileSymbols(db, resolved.file, true)[0] : undefined;
|
|
256
288
|
if (owner) {
|
|
257
289
|
return {
|
|
258
|
-
...assembleExplore(db, projectPath, owner, []),
|
|
290
|
+
...assembleExplore(db, projectPath, owner, [], includeRefs),
|
|
259
291
|
message: `Path "${resolved.file}" declares no symbols; resolved to its file-owner node.`,
|
|
260
292
|
};
|
|
261
293
|
}
|
|
@@ -268,7 +300,7 @@ function exploreWhenNameMisses(db, projectPath, query) {
|
|
|
268
300
|
};
|
|
269
301
|
}
|
|
270
302
|
const primary = pickPrimary(symbols);
|
|
271
|
-
const result = assembleExplore(db, projectPath, primary, symbols.filter((s) => s.id !== primary.id));
|
|
303
|
+
const result = assembleExplore(db, projectPath, primary, symbols.filter((s) => s.id !== primary.id), includeRefs);
|
|
272
304
|
return {
|
|
273
305
|
...result,
|
|
274
306
|
message: `Path "${resolved.file}" resolved to ${primary.name}.`,
|
|
@@ -301,10 +333,14 @@ function exploreWhenNameMisses(db, projectPath, query) {
|
|
|
301
333
|
*
|
|
302
334
|
* @param projectPath - Absolute path to the indexed project.
|
|
303
335
|
* @param query - Exact node name, or a file path when no symbol has that name.
|
|
336
|
+
* @param opts - `includeRefs` (default false) also lists type-reference
|
|
337
|
+
* (`ref`) callers and tags every caller with `via`; the MCP and CLI explore
|
|
338
|
+
* opt in, investigation and ceremony signals keep the call-only list.
|
|
304
339
|
* @returns The explore result; `found` is `false` when no single symbol is selected.
|
|
305
340
|
* @throws If no index exists for the project.
|
|
306
341
|
*/
|
|
307
|
-
export function explore(projectPath, query) {
|
|
342
|
+
export function explore(projectPath, query, opts = {}) {
|
|
343
|
+
const includeRefs = opts.includeRefs ?? false;
|
|
308
344
|
requireIndex(projectPath);
|
|
309
345
|
const db = openDb(projectPath);
|
|
310
346
|
try {
|
|
@@ -317,8 +353,8 @@ export function explore(projectPath, query) {
|
|
|
317
353
|
LIMIT 10`)
|
|
318
354
|
.all(query);
|
|
319
355
|
if (matches.length === 0)
|
|
320
|
-
return exploreWhenNameMisses(db, projectPath, query);
|
|
321
|
-
return assembleExplore(db, projectPath, matches[0], matches.slice(1));
|
|
356
|
+
return exploreWhenNameMisses(db, projectPath, query, includeRefs);
|
|
357
|
+
return assembleExplore(db, projectPath, matches[0], matches.slice(1), includeRefs);
|
|
322
358
|
}
|
|
323
359
|
finally {
|
|
324
360
|
db.close();
|
|
@@ -11,6 +11,7 @@ import { hotspots, coupling } from "./hotspots.js";
|
|
|
11
11
|
import { startWatch, stopWatch, watchStatus } from "./watcher.js";
|
|
12
12
|
import { exploreRich, findSymbols, formatExploreRich, } from "./explore-rich.js";
|
|
13
13
|
import { diffContext, formatDiffContext } from "./diff-context.js";
|
|
14
|
+
import { formatFindResponse } from "./find-output.js";
|
|
14
15
|
const includeEnum = z.array(z.enum(["source", "callers", "callees", "blast_radius", "tests", "hotspot"]));
|
|
15
16
|
/** Register Compass MCP tools on the given server. */
|
|
16
17
|
export function registerCompass(server, opts = {}) {
|
|
@@ -43,7 +44,7 @@ export function registerCompass(server, opts = {}) {
|
|
|
43
44
|
mode: (mode ?? "brief"),
|
|
44
45
|
maxDepth,
|
|
45
46
|
});
|
|
46
|
-
return text(formatExploreRich(result, (mode ?? "brief")));
|
|
47
|
+
return text(formatExploreRich(result, (mode ?? "brief")), mode ?? "brief");
|
|
47
48
|
});
|
|
48
49
|
add("compass_find", "Hybrid search over the index: BM25, vectors, and name match. mode sets weights; pass focus for edited files.", {
|
|
49
50
|
projectPath: z.string(),
|
|
@@ -52,7 +53,10 @@ export function registerCompass(server, opts = {}) {
|
|
|
52
53
|
limit: z.number().optional(),
|
|
53
54
|
focus: z.array(z.string()).optional(),
|
|
54
55
|
maxTokens: z.number().int().min(256).max(32_000).optional(),
|
|
55
|
-
}, async ({ projectPath, query, mode, limit, focus, maxTokens }) =>
|
|
56
|
+
}, async ({ projectPath, query, mode, limit, focus, maxTokens }) => {
|
|
57
|
+
const found = await findSymbols(projectPath, query, mode, limit, { focus, maxTokens });
|
|
58
|
+
return text(formatFindResponse(found), { maxTokens: found.cap });
|
|
59
|
+
});
|
|
56
60
|
add("compass_diff_context", "Graph context of changes in one call: symbols, blast radius, tests, hotspots. Default: working tree.", {
|
|
57
61
|
projectPath: z.string(),
|
|
58
62
|
rev: z.string().optional(),
|
|
@@ -67,7 +71,7 @@ export function registerCompass(server, opts = {}) {
|
|
|
67
71
|
mode: (mode ?? "brief"),
|
|
68
72
|
maxDepth,
|
|
69
73
|
});
|
|
70
|
-
return text(formatDiffContext(result, (mode ?? "brief")));
|
|
74
|
+
return text(formatDiffContext(result, (mode ?? "brief")), mode ?? "brief");
|
|
71
75
|
});
|
|
72
76
|
add("compass_index", "Build or refresh the code graph index; optional watch action for live re-index.", {
|
|
73
77
|
projectPath: z.string(),
|
|
@@ -94,8 +98,9 @@ export function registerCompass(server, opts = {}) {
|
|
|
94
98
|
handler: async ({ projectPath, query, limit }) => {
|
|
95
99
|
logDeprecatedCall(projectPath, "compass_search");
|
|
96
100
|
recordCompassCall(projectPath, "compass_search");
|
|
97
|
-
const
|
|
98
|
-
|
|
101
|
+
const found = await findSymbols(projectPath, query, "exact", limit);
|
|
102
|
+
const prefix = prefixDeprecated("compass_search", "");
|
|
103
|
+
return text(formatFindResponse(found, { prefix }), { maxTokens: found.cap });
|
|
99
104
|
},
|
|
100
105
|
});
|
|
101
106
|
defineAliasTool(server, {
|
|
@@ -105,8 +110,9 @@ export function registerCompass(server, opts = {}) {
|
|
|
105
110
|
handler: async ({ projectPath, query, limit }) => {
|
|
106
111
|
logDeprecatedCall(projectPath, "compass_recall");
|
|
107
112
|
recordCompassCall(projectPath, "compass_recall");
|
|
108
|
-
const
|
|
109
|
-
|
|
113
|
+
const found = await findSymbols(projectPath, query, "concept", limit);
|
|
114
|
+
const prefix = prefixDeprecated("compass_recall", "");
|
|
115
|
+
return text(formatFindResponse(found, { prefix }), { maxTokens: found.cap });
|
|
110
116
|
},
|
|
111
117
|
});
|
|
112
118
|
defineAliasTool(server, {
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* ops via `speclaw cortex` or the `cortex` MCP tool.
|
|
7
7
|
*
|
|
8
8
|
* Cortex must not import from the lawbook module (no cycles). Ceremony level
|
|
9
|
-
* is read locally from `change.json` `confirmedLevel` (default 3)
|
|
9
|
+
* is read locally from `change.json` `confirmedLevel` (default 3) at start and
|
|
10
|
+
* again on every advance and rework.
|
|
10
11
|
*/
|
|
11
12
|
import fs from "node:fs";
|
|
12
13
|
import path from "node:path";
|
|
@@ -166,9 +167,18 @@ export function handleHarness(args) {
|
|
|
166
167
|
if (!current) {
|
|
167
168
|
throw new Error(`no harness.json for "${change}" — run cortex start first`);
|
|
168
169
|
}
|
|
170
|
+
// Covers: req~harness-level-current~1
|
|
171
|
+
// The level is re-read on every mutating op, so a `level set` after start
|
|
172
|
+
// governs the next route; unconfirmed or missing → 3 (never skips planning).
|
|
173
|
+
const level = readConfirmedLevel(projectPath, change);
|
|
169
174
|
if (harnessOp === "advance") {
|
|
170
|
-
//
|
|
171
|
-
|
|
175
|
+
// Covers: req~harness-pause-questions~1
|
|
176
|
+
// Only the planner may pause for questions; anywhere else the pause would
|
|
177
|
+
// be dropped silently, so it is rejected before any write.
|
|
178
|
+
if (args.pauseForQuestions && current.stage !== "planning") {
|
|
179
|
+
throw new Error(`pauseForQuestions is only valid from stage planning (current: ${current.stage})`);
|
|
180
|
+
}
|
|
181
|
+
if (args.pauseForQuestions) {
|
|
172
182
|
const questions = args.openQuestions ?? [];
|
|
173
183
|
if (questions.length === 0) {
|
|
174
184
|
throw new Error(`pauseForQuestions requires at least one openQuestions entry`);
|
|
@@ -176,6 +186,7 @@ export function handleHarness(args) {
|
|
|
176
186
|
const next = {
|
|
177
187
|
...current,
|
|
178
188
|
stage: "questions",
|
|
189
|
+
level,
|
|
179
190
|
openQuestions: questions,
|
|
180
191
|
history: [
|
|
181
192
|
...current.history,
|
|
@@ -191,7 +202,7 @@ export function handleHarness(args) {
|
|
|
191
202
|
writeHarness(projectPath, change, next);
|
|
192
203
|
return next;
|
|
193
204
|
}
|
|
194
|
-
const to = allowedAdvance(current.stage,
|
|
205
|
+
const to = allowedAdvance(current.stage, level, args.verdict ?? undefined);
|
|
195
206
|
// Compass-first gate: runs before any write, so a strict rejection leaves
|
|
196
207
|
// harness.json byte-identical.
|
|
197
208
|
let compassEvidence;
|
|
@@ -213,6 +224,7 @@ export function handleHarness(args) {
|
|
|
213
224
|
const next = {
|
|
214
225
|
...current,
|
|
215
226
|
stage: to,
|
|
227
|
+
level,
|
|
216
228
|
verdicts,
|
|
217
229
|
openQuestions: current.stage === "questions" ? [] : current.openQuestions,
|
|
218
230
|
history: [
|
|
@@ -249,6 +261,7 @@ export function handleHarness(args) {
|
|
|
249
261
|
const next = {
|
|
250
262
|
...current,
|
|
251
263
|
stage: "implementing",
|
|
264
|
+
level,
|
|
252
265
|
iteration: current.iteration + 1,
|
|
253
266
|
verdicts,
|
|
254
267
|
history: [
|
|
@@ -14,7 +14,7 @@ import { stageStartedAt } from "./compass-gate.js";
|
|
|
14
14
|
import { OUTPUT_BUDGET, estimateTokens } from "../../shared/output-budget.js";
|
|
15
15
|
import { resolveChangeDir } from "./paths.js";
|
|
16
16
|
/** Interval used when the key is missing or invalid. */
|
|
17
|
-
export const DEFAULT_STATUS_INTERVAL_MINUTES =
|
|
17
|
+
export const DEFAULT_STATUS_INTERVAL_MINUTES = 0;
|
|
18
18
|
/**
|
|
19
19
|
* Largest interval reported. A session timer is a cron expression, and
|
|
20
20
|
* an every-N-minutes step only exists for N ≤ 59; 60 is the hourly ping.
|
|
@@ -23,17 +23,18 @@ Claude-specific notes: [`CLAUDE.md`](CLAUDE.md). The law: [`LAWS.md`](LAWS.md).
|
|
|
23
23
|
first; Claude Code sessions refresh an existing index at start), or the
|
|
24
24
|
target isn't indexed code (stylesheets, config, logs). Cheat
|
|
25
25
|
sheet: [`docs/compass.md`](docs/compass.md).
|
|
26
|
-
3. **
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
3. **Cortex, always — one brain on the critical path** (*One brain. Many
|
|
27
|
+
agents.*). Branch `<type>/<slug>`, locate with Compass, implement the
|
|
28
|
+
change and its test yourself, run the tests, finish. The `Stop` hook
|
|
29
|
+
(`speclaw ship-on-stop`) — or `speclaw ship <change> --summary "<what and
|
|
30
|
+
why>"` as your last step on agents without hooks — records the change, runs
|
|
31
|
+
the gates once, writes the report from their real output, and archives
|
|
32
|
+
level-0 work. Review happens on the PR and never blocks the agent. Add
|
|
33
|
+
agents only to run three or more large, independent parts in parallel.
|
|
34
|
+
Details: the `cortex` skill and
|
|
33
35
|
[`docs/standards/lawbook.md`](docs/standards/lawbook.md).
|
|
34
36
|
4. **Run the quality gates yourself** before declaring anything done — see
|
|
35
|
-
[`docs/standards/testing-standards.md`](docs/standards/testing-standards.md)
|
|
36
|
-
(owned by the **tester** role under Cortex):
|
|
37
|
+
[`docs/standards/testing-standards.md`](docs/standards/testing-standards.md):
|
|
37
38
|
- Tests: `{{test_commands}}`
|
|
38
39
|
- Lint / type-check: `{{lint_commands}}`
|
|
39
40
|
5. **Respect the conventions** — branches `{{branch_pattern}}`, commits
|
|
@@ -50,16 +50,19 @@ target is not indexed code — stylesheets, JSON/config, markdown, logs,
|
|
|
50
50
|
generated files, lockfiles. Never skip Compass because grep "feels faster."
|
|
51
51
|
See [`docs/compass.md`](docs/compass.md).
|
|
52
52
|
|
|
53
|
-
## Rule 2 —
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
53
|
+
## Rule 2 — Cortex, always — one brain on the critical path
|
|
54
|
+
|
|
55
|
+
Every change is built with **Cortex** (*One brain. Many agents.*), and Cortex
|
|
56
|
+
costs no more time than working alone: branch `<type>/<slug>`, locate with
|
|
57
|
+
Compass, implement the change and its test yourself, run the tests, finish.
|
|
58
|
+
Then stop: the `Stop` hook (`speclaw ship-on-stop`) records the change, runs
|
|
59
|
+
the gates once, writes the report from their real output, and archives
|
|
60
|
+
level-0 work with no agent turns; a failing gate comes back to you. Do not run
|
|
61
|
+
`speclaw ship` yourself, check the hook, open the archive, edit the generated
|
|
62
|
+
report, read the process docs, or drive the harness by hand. Review happens on the PR, never blocking the
|
|
63
|
+
agent. Add agents only to run three or more large, independent parts in
|
|
64
|
+
parallel. Details: the `cortex` skill and
|
|
65
|
+
[`docs/standards/lawbook.md`](docs/standards/lawbook.md).
|
|
63
66
|
|
|
64
67
|
## Rule 3 — Quality gates are non-negotiable
|
|
65
68
|
|
|
@@ -30,7 +30,7 @@ a request, not whole files.
|
|
|
30
30
|
| Tool | Use it to |
|
|
31
31
|
|------|-----------|
|
|
32
32
|
| `compass_index` | Build/refresh the graph (`.speclaw/index.db`). Incremental — unchanged files are skipped by hash. Run once after init and after significant edits. |
|
|
33
|
-
| `compass_find` | Find code: `mode: exact` by name
|
|
33
|
+
| `compass_find` | Find code: `mode: exact` by exact name (one or more identifiers; a missing name returns `found: false` and up to five `nearest` names), `mode: concept` by meaning in natural language. Returns compact hits (`name`, `kind`, `file`, `line`) in one JSON document that fits `maxTokens` (default 1500); `truncated: true` only when it trimmed. |
|
|
34
34
|
| `compass_explore` | Read a node's verbatim source plus its callers and callees. The default before editing. Add `include: ["blast_radius"]` for what could break, or `to: <symbol>` for the call path between two nodes. |
|
|
35
35
|
| `compass_diff_context` | Symbols, blast radius, and tests for a diff (working tree or a rev). |
|
|
36
36
|
|
|
@@ -46,9 +46,16 @@ project method, so they do not appear as callers. Calls through an imported
|
|
|
46
46
|
re-export barrel (`index.ts` holding `export * from "./lib/core"`) bind by
|
|
47
47
|
name, preferring a definition under the barrel's directory.
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
Explore callers carry `via`: `call` for calls, `ref` for type references
|
|
50
|
+
(annotations and `extends`/`implements` clauses that resolve through the file's
|
|
51
|
+
own import or a same-file definition). Type references never change blast
|
|
52
|
+
radius, affected tests, or ranking. `compass_find` keeps only indexed files in
|
|
53
|
+
`focus` and lists the others in `focusIgnored`.
|
|
54
|
+
|
|
55
|
+
Compass uses index schema 12 (11→12 forces one reindex; embeddings reused).
|
|
56
|
+
speclaw 2.0.12 or older, including an MCP entry pinned at `@2.0.12`, rebuilds a
|
|
57
|
+
schema-12 index from scratch (losing the embedding cache). Pin every speclaw to
|
|
58
|
+
2.0.13: run `speclaw update` so the pinned MCP entry moves to 2.0.13.
|
|
52
59
|
|
|
53
60
|
`compass_index` also takes `action: start|stop|status` to keep the index fresh
|
|
54
61
|
automatically (a debounced incremental re-index on file change).
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DEFAULT_EDGE_KINDS } from "./laws.js";
|
|
1
2
|
import { underPaths } from "./verify-model.js";
|
|
2
3
|
/** Substitute `$1`, `$2`, … in a pattern with capture groups from a match. */
|
|
3
4
|
function applyGroups(pattern, match) {
|
|
@@ -5,9 +6,10 @@ function applyGroups(pattern, match) {
|
|
|
5
6
|
}
|
|
6
7
|
/** The `IN (?, ?)` clause and params for an optional edge-kind filter. */
|
|
7
8
|
function edgeKindClause(edgeKinds) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
// Type-reference (`ref`) edges feed explore callers only; a law without
|
|
10
|
+
// explicit kinds keeps reading calls and imports, as before refs existed.
|
|
11
|
+
const kinds = edgeKinds && edgeKinds.length > 0 ? edgeKinds : DEFAULT_EDGE_KINDS;
|
|
12
|
+
return { sql: ` AND e.kind IN (${kinds.map(() => "?").join(", ")})`, params: [...kinds] };
|
|
11
13
|
}
|
|
12
14
|
/** Load resolved file→file edges (earliest line per pair) from the index. */
|
|
13
15
|
function resolvedEdges(db, edgeKinds) {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { matchesScope } from "./laws.js";
|
|
1
|
+
import { DEFAULT_EDGE_KINDS, matchesScope } from "./laws.js";
|
|
2
2
|
import { underPaths } from "./verify-model.js";
|
|
3
3
|
/** Build the cross-file dependency graph, restricted to `paths` and `scope`. */
|
|
4
4
|
function buildGraph(db, paths, edgeKinds, scope) {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
// No explicit kinds: calls and imports only (`ref` edges never form a law cycle).
|
|
6
|
+
const kinds = edgeKinds && edgeKinds.length > 0 ? edgeKinds : DEFAULT_EDGE_KINDS;
|
|
7
|
+
const kindFilter = ` AND e.kind IN (${kinds.map(() => "?").join(", ")})`;
|
|
8
8
|
const rows = db
|
|
9
9
|
.prepare(`SELECT DISTINCT sf.path AS src, df.path AS dst
|
|
10
10
|
FROM edges e
|
|
@@ -12,7 +12,7 @@ function buildGraph(db, paths, edgeKinds, scope) {
|
|
|
12
12
|
JOIN nodes dn ON dn.id = e.dst_node_id
|
|
13
13
|
JOIN files df ON df.id = dn.file_id
|
|
14
14
|
WHERE e.dst_node_id IS NOT NULL AND sf.path <> df.path${kindFilter}`)
|
|
15
|
-
.all(...
|
|
15
|
+
.all(...kinds);
|
|
16
16
|
const adj = new Map();
|
|
17
17
|
for (const { src, dst } of rows) {
|
|
18
18
|
if (!underPaths(src, paths) || !underPaths(dst, paths))
|
|
@@ -41,6 +41,21 @@ export const REINDEX_FILE_MARKER = "speclaw reindex-file";
|
|
|
41
41
|
* than the command rejects it as unknown (exit 1) before touching the index.
|
|
42
42
|
*/
|
|
43
43
|
export const REINDEX_FILE_COMMAND = speclawCommand("reindex-file");
|
|
44
|
+
/**
|
|
45
|
+
* Substring every ship-on-stop command contains, in each of its resolution
|
|
46
|
+
* branches. It is the merge identity of the `Stop` ship hook.
|
|
47
|
+
*/
|
|
48
|
+
export const SHIP_ON_STOP_MARKER = "speclaw ship-on-stop";
|
|
49
|
+
/**
|
|
50
|
+
* The POSIX `sh` command the `Stop` hook runs: when the agent finishes a turn
|
|
51
|
+
* with new changes on a feature branch, speclaw records the change, runs the
|
|
52
|
+
* gates once, writes the report from their real output, and archives level-0
|
|
53
|
+
* work — with no agent turn. Guarded on `lawbook/` instead of the index. Its
|
|
54
|
+
* stderr is kept so a failing gate reaches the agent (exit 2).
|
|
55
|
+
*/
|
|
56
|
+
export const SHIP_ON_STOP_COMMAND = speclawCommand("ship-on-stop");
|
|
57
|
+
/** Seconds the Stop hook may run: the project's own gates run inside it. */
|
|
58
|
+
const SHIP_ON_STOP_TIMEOUT = 600;
|
|
44
59
|
/** Seconds the edit hook may run; the command returns once it has spawned the work. */
|
|
45
60
|
const REINDEX_TIMEOUT_SECONDS = 10;
|
|
46
61
|
/**
|
|
@@ -53,11 +68,17 @@ const REINDEX_TIMEOUT_SECONDS = 10;
|
|
|
53
68
|
*/
|
|
54
69
|
export function speclawCommand(sub) {
|
|
55
70
|
// Covers: req~edit-reindex-hook~1
|
|
56
|
-
|
|
57
|
-
|
|
71
|
+
const ship = sub === "ship-on-stop";
|
|
72
|
+
const run = `if [ -x node_modules/.bin/speclaw ]; then node_modules/.bin/speclaw ${sub}; ` +
|
|
58
73
|
`elif command -v speclaw >/dev/null 2>&1; then speclaw ${sub}; ` +
|
|
59
|
-
`else npm_config_update_notifier=false npm_config_offline=true npx --no-install @esneiderbravo/speclaw ${sub}; fi;
|
|
60
|
-
|
|
74
|
+
`else npm_config_update_notifier=false npm_config_offline=true npx --no-install @esneiderbravo/speclaw ${sub}; fi; `;
|
|
75
|
+
// The ship hook keeps its exit code (2 feeds a failing gate back to the
|
|
76
|
+
// agent) and its stderr; stdout is discarded like the other hooks.
|
|
77
|
+
return ship
|
|
78
|
+
? 'cd "${CLAUDE_PROJECT_DIR:-.}" 2>/dev/null && [ -d lawbook ] && { ' + run + "} >/dev/null"
|
|
79
|
+
: 'cd "${CLAUDE_PROJECT_DIR:-.}" 2>/dev/null && [ -f .speclaw/index.db ] && { ' +
|
|
80
|
+
run +
|
|
81
|
+
"} >/dev/null 2>&1 || true";
|
|
61
82
|
}
|
|
62
83
|
/** `SessionStart` matcher covering every session source Claude Code reports. */
|
|
63
84
|
const SESSION_START_MATCHER = "startup|resume|clear|compact";
|
|
@@ -113,6 +134,7 @@ export function isSpeclawHook(h) {
|
|
|
113
134
|
return (o?.type === "command" &&
|
|
114
135
|
typeof o?.command === "string" &&
|
|
115
136
|
(o.command.includes(SESSION_START_MARKER) ||
|
|
137
|
+
o.command.includes(SHIP_ON_STOP_MARKER) ||
|
|
116
138
|
o.command.includes(REINDEX_FILE_MARKER) ||
|
|
117
139
|
o.command.includes(LEGACY_SESSION_START_MARKER)));
|
|
118
140
|
}
|
|
@@ -161,6 +183,10 @@ export function compileHooks(manifest) {
|
|
|
161
183
|
});
|
|
162
184
|
if (hasGate)
|
|
163
185
|
byEvent.Stop = [{ hooks: [{ ...SPECLAW_HOOK }] }];
|
|
186
|
+
// Covers: req~ship-on-stop-hook~1
|
|
187
|
+
(byEvent.Stop ??= []).push({
|
|
188
|
+
hooks: [{ type: "command", command: SHIP_ON_STOP_COMMAND, timeout: SHIP_ON_STOP_TIMEOUT }],
|
|
189
|
+
});
|
|
164
190
|
if (valid.length > 0)
|
|
165
191
|
byEvent.InstructionsLoaded = [{ hooks: [{ ...SPECLAW_HOOK }] }];
|
|
166
192
|
// Covers: req~session-start-hook~1
|
|
@@ -2,6 +2,11 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { adaptedLaws, catalogLaws, mergeAdaptedSeed } from "./seed-laws.js";
|
|
5
|
+
/**
|
|
6
|
+
* Edge kinds a `deps`/`graph` law reads when it names none: calls and
|
|
7
|
+
* imports. Compass type-reference (`ref`) edges only feed explore callers.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_EDGE_KINDS = ["call", "import"];
|
|
5
10
|
const depsRuleSchema = z.object({
|
|
6
11
|
name: z.string().optional(),
|
|
7
12
|
from: z.string(),
|
|
@@ -10,4 +10,4 @@ with the `cortex` MCP tool (`status`/`start`/`advance`/`rework`/`brief`) or
|
|
|
10
10
|
`speclaw cortex …`. Surface planner questions to the human. Do not implement
|
|
11
11
|
product code yourself.
|
|
12
12
|
The run posts status updates every `cortex.statusIntervalMinutes` minutes
|
|
13
|
-
(`lawbook/config.yaml`, default
|
|
13
|
+
(`lawbook/config.yaml`, default 0, `0` disables, at most 60).
|
|
@@ -5,6 +5,12 @@ alwaysApply: true
|
|
|
5
5
|
|
|
6
6
|
# Spec Reports: Discipline Coverage
|
|
7
7
|
|
|
8
|
+
> **Scope.** This rule governs spec-lane changes — the ones that carry a
|
|
9
|
+
> `tasks.md` (level 2+, or the Cortex fan-out lane). In the default Cortex lane,
|
|
10
|
+
> `speclaw ship` (or the `Stop` hook) writes the record and the report from the
|
|
11
|
+
> gates' real output: do not hand-write, edit, or restructure them, and do not
|
|
12
|
+
> re-run ship or archive yourself.
|
|
13
|
+
|
|
8
14
|
A change's evidence of testing lives under `lawbook/changes/<name>/reports/`, as
|
|
9
15
|
**one file per discipline the change actually touched**, named for that
|
|
10
16
|
discipline (`<discipline>.md`) — never a single lumped report.
|
|
@@ -5,6 +5,12 @@ alwaysApply: true
|
|
|
5
5
|
|
|
6
6
|
# Spec Tasks: Mandatory Steps
|
|
7
7
|
|
|
8
|
+
> **Scope.** This rule governs spec-lane changes — the ones that carry a
|
|
9
|
+
> `tasks.md` (level 2+, or the Cortex fan-out lane). In the default Cortex lane,
|
|
10
|
+
> `speclaw ship` (or the `Stop` hook) writes the record and the report from the
|
|
11
|
+
> gates' real output: do not hand-write, edit, or restructure them, and do not
|
|
12
|
+
> re-run ship or archive yourself.
|
|
13
|
+
|
|
8
14
|
When creating or updating a `tasks.md` inside a `lawbook/changes/<name>/`, you MUST:
|
|
9
15
|
|
|
10
16
|
## 1. Read lawbook/config.yaml first
|
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: cortex
|
|
3
|
-
description:
|
|
3
|
+
description: Cortex — One brain. Many agents. The standard way to build any change, as fast as one agent: Compass + laws as guardrails, evidence shipped by hook, extra agents only in parallel.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Cortex — One brain. Many agents.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
One brain does the critical path; mechanical work costs no agent turns.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
`
|
|
10
|
+
1. Branch `<type>/<slug>` (the slug is the change name).
|
|
11
|
+
2. Locate with Compass (`compass_find` / `compass_explore`). Do not read
|
|
12
|
+
LAWS.md, config, standards, or `--help` to learn the process — this is it.
|
|
13
|
+
3. Implement the change and its test yourself; ask the human only when blocked.
|
|
14
|
+
4. Run the tests once.
|
|
15
|
+
5. Stop. In Claude Code the `Stop` hook (`speclaw ship-on-stop`) records the
|
|
16
|
+
change, runs the gates once, writes the report from real output, and
|
|
17
|
+
archives level-0 work; a failing gate comes back to you. Do not run ship,
|
|
18
|
+
check the hook, or edit its output. Agents without hooks run `speclaw ship
|
|
19
|
+
<change> --summary "<what and why>"` once, last.
|
|
12
20
|
|
|
13
|
-
|
|
21
|
+
Never drive the harness by hand, write proposal/design/reports for small work,
|
|
22
|
+
or post status updates. Review happens on the PR and never blocks you.
|
|
23
|
+
|
|
24
|
+
Only when the work splits into three or more large, independent parts, read
|
|
25
|
+
`steps/01-load-or-start.md` (fan-out lane: parallel agents, never a chain).
|
|
@@ -125,7 +125,7 @@ function bugfixTemplate(name, level, seed) {
|
|
|
125
125
|
"<Run compass_impact on the confirmed root cause; list modules and call sites.>";
|
|
126
126
|
return `# Bugfix: ${name}
|
|
127
127
|
|
|
128
|
-
**Level:** ${level} · **Type:** bug · **Severity:** normal
|
|
128
|
+
**Level:** ${level ?? "unconfirmed"} · **Type:** bug · **Severity:** normal
|
|
129
129
|
|
|
130
130
|
## 1. Observed symptom
|
|
131
131
|
${symptom}
|
|
@@ -150,18 +150,26 @@ ${blast}
|
|
|
150
150
|
`;
|
|
151
151
|
}
|
|
152
152
|
/**
|
|
153
|
-
* Scaffold a bug change: `bugfix.md`, `
|
|
153
|
+
* Scaffold a bug change: `bugfix.md`, `tasks.md` (level ≥ 1 or unconfirmed),
|
|
154
|
+
* `design.md` (level ≥ 2), `change.json`, and `reports/`.
|
|
155
|
+
*
|
|
156
|
+
* A supplied `level` is recorded as the confirmed level. Without one the draft
|
|
157
|
+
* stays unconfirmed: `change.json` holds the proposal and `changeType` only, so
|
|
158
|
+
* validate and Cortex treat it as level 3 until a human sets a level.
|
|
154
159
|
*/
|
|
160
|
+
// Covers: req~bug-draft-unconfirmed~1
|
|
155
161
|
export function scaffoldBugfix(projectPath, name, opts = {}) {
|
|
156
162
|
const r = scaffoldChange(projectPath, name, {
|
|
157
163
|
changeType: "bug",
|
|
158
164
|
targets: opts.targets,
|
|
159
|
-
level:
|
|
165
|
+
level: opts.level,
|
|
160
166
|
reportsReadme: `# Reports — ${name}\n\nBug reports MUST include the regression test **failing before the fix**.\n`,
|
|
161
167
|
artifacts: ({ level }) => {
|
|
168
|
+
// Unconfirmed: the level-1 set (bugfix.md + tasks.md); design.md only
|
|
169
|
+
// once a level of 2 or more is confirmed.
|
|
162
170
|
const lvl = level ?? 1;
|
|
163
171
|
const files = {
|
|
164
|
-
"bugfix.md": bugfixTemplate(name,
|
|
172
|
+
"bugfix.md": bugfixTemplate(name, level, opts.seed),
|
|
165
173
|
};
|
|
166
174
|
if (lvl >= 1) {
|
|
167
175
|
files["tasks.md"] =
|