@esneiderbravo/speclaw 2.0.12 → 2.0.14
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/init.js +3 -2
- 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 +36 -4
- 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 +12 -11
- package/dist/modules/foundation/assets/CLAUDE.template.md +16 -12
- package/dist/modules/foundation/assets/docs/compass.template.md +11 -4
- package/dist/modules/foundation/deps.js +5 -3
- package/dist/modules/foundation/dialects/copilot.js +3 -5
- 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/foundation/scaffold.js +3 -16
- package/dist/modules/lawbook/anchors.js +38 -1
- 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/agents.js +7 -1
- package/dist/shared/mcp.js +6 -4
- package/dist/shared/output-budget.js +36 -12
- package/package.json +1 -1
- package/dist/modules/foundation/assets/workflows/speclaw.yml +0 -26
|
@@ -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
|
|
@@ -41,8 +42,8 @@ Claude-specific notes: [`CLAUDE.md`](CLAUDE.md). The law: [`LAWS.md`](LAWS.md).
|
|
|
41
42
|
[`docs/standards/base-standards.md`](docs/standards/base-standards.md) and
|
|
42
43
|
[`docs/standards/conventions.md`](docs/standards/conventions.md).
|
|
43
44
|
6. **Use the skills and role agents.** `ai-specs/` is the canonical home for
|
|
44
|
-
skills, commands, rules, and agents
|
|
45
|
-
|
|
45
|
+
skills, commands, rules, and agents; agents read them through the symlinks
|
|
46
|
+
in `.agents/` (plus an IDE folder only for an agent added explicitly).
|
|
46
47
|
7. **Ask before irreversible or outward-facing actions** — destructive commands;
|
|
47
48
|
writing to a real data store (DB rows or files with real user data, including
|
|
48
49
|
for tests — verify against an isolated/throwaway store); publishing
|
|
@@ -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
|
|
|
@@ -79,8 +82,9 @@ its neighbors, comments that carry constraints (never ticket IDs). Full rules:
|
|
|
79
82
|
|
|
80
83
|
## Rule 5 — Skills and role agents are law-adjacent
|
|
81
84
|
|
|
82
|
-
Skills, commands, rules, and agents live in `ai-specs
|
|
83
|
-
|
|
85
|
+
Skills, commands, rules, and agents live in `ai-specs/`, read through the
|
|
86
|
+
symlinks in `.agents/` (an IDE folder such as `.claude/` exists only for an
|
|
87
|
+
agent added explicitly with `speclaw agent add`). When a skill or role matches
|
|
84
88
|
the task, use it — do not improvise a parallel process.
|
|
85
89
|
|
|
86
90
|
## Rule 6 — Stop conditions
|
|
@@ -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) {
|
|
@@ -6,11 +6,9 @@ import { frontmatter, lawSlug, } from "./types.js";
|
|
|
6
6
|
export const copilotDialect = {
|
|
7
7
|
id: "copilot-instructions",
|
|
8
8
|
compile(laws, ctx) {
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
const want = ctx.agents.includes("copilot") ||
|
|
12
|
-
ctx.agents.includes("github-copilot") ||
|
|
13
|
-
ctx.agents.includes("agents");
|
|
9
|
+
// Only for Copilot: the generic agent reads scoped laws from AGENTS.md
|
|
10
|
+
// (agentsmd dialect), so it gets no `.github/instructions/` folder.
|
|
11
|
+
const want = ctx.agents.includes("copilot") || ctx.agents.includes("github-copilot");
|
|
14
12
|
if (!want)
|
|
15
13
|
return [];
|
|
16
14
|
const scoped = laws.filter((l) => (l.status ?? "active") !== "draft" && l.scope.length > 0);
|
|
@@ -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(),
|
|
@@ -50,21 +50,6 @@ function ensureLawManifest(projectPath, report) {
|
|
|
50
50
|
}
|
|
51
51
|
return manifest;
|
|
52
52
|
}
|
|
53
|
-
/**
|
|
54
|
-
* Write `.github/workflows/speclaw.yml` from the shipped template when the
|
|
55
|
-
* path does not exist. Never overwrite — the user's CI is theirs.
|
|
56
|
-
*/
|
|
57
|
-
function ensureVerifyWorkflow(projectPath, report) {
|
|
58
|
-
const dest = path.join(projectPath, ".github", "workflows", "speclaw.yml");
|
|
59
|
-
if (fs.existsSync(dest)) {
|
|
60
|
-
report.skipped.push(dest);
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
const src = path.join(ASSETS, "workflows", "speclaw.yml");
|
|
64
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
65
|
-
fs.copyFileSync(src, dest);
|
|
66
|
-
report.written.push(dest);
|
|
67
|
-
}
|
|
68
53
|
/**
|
|
69
54
|
* Render the foundation: walk the module's assets/, mirror its structure into
|
|
70
55
|
* the project, stripping the `.template` marker (foo.template.md -> foo.md).
|
|
@@ -163,6 +148,9 @@ export function scaffold(projectPath, profile, packNames, agents = [], opts = {}
|
|
|
163
148
|
// it out of the module assets) plus the local .speclaw.json manifest — local,
|
|
164
149
|
// per-checkout content, reconstructed by init/update, never committed.
|
|
165
150
|
ensureGitignore(projectPath, "ai-specs/", "speclaw workflow content (regenerated by init/update; never commit)", report);
|
|
151
|
+
// The integrity lock is local tool state: it guards this checkout's rule
|
|
152
|
+
// files and is rebuilt by init/update, so it is not committed.
|
|
153
|
+
ensureGitignore(projectPath, "speclaw.lock", "speclaw local integrity lock (never commit)", report);
|
|
166
154
|
for (const id of agents)
|
|
167
155
|
configureAgent(projectPath, id, report); // only the chosen agents
|
|
168
156
|
// Compile the declared laws into agent hooks for every hook-capable agent just
|
|
@@ -185,7 +173,6 @@ export function scaffold(projectPath, profile, packNames, agents = [], opts = {}
|
|
|
185
173
|
report.lockError = err.message;
|
|
186
174
|
}
|
|
187
175
|
}
|
|
188
|
-
ensureVerifyWorkflow(projectPath, report);
|
|
189
176
|
report.hooks = installHooks(projectPath, agents, lawManifest, report, {
|
|
190
177
|
baselines: managedOpts.baselines,
|
|
191
178
|
backup: managedOpts.backup,
|