@esneiderbravo/speclaw 2.0.8 → 2.0.10
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 +38 -9
- package/dist/cli/commands/index-build.js +2 -15
- package/dist/cli/commands/init.js +11 -3
- package/dist/cli/commands/lawbook.js +2 -0
- package/dist/cli/commands/laws.js +149 -19
- package/dist/cli/commands/query.js +12 -2
- package/dist/cli/commands/update.js +110 -24
- package/dist/cli/index.js +24 -66
- package/dist/cli/lib/help.js +489 -0
- package/dist/cli/lib/self-update.js +107 -0
- package/dist/cli/lib/update-check.js +17 -10
- package/dist/modules/compass/affected-config.js +5 -0
- package/dist/modules/compass/affected.js +344 -27
- package/dist/modules/compass/db.js +88 -16
- package/dist/modules/compass/diff-context.js +4 -2
- package/dist/modules/compass/explore-rich.js +14 -7
- package/dist/modules/compass/extract.js +163 -4
- package/dist/modules/compass/hotspots.js +11 -6
- package/dist/modules/compass/hybrid.js +9 -4
- package/dist/modules/compass/indexer.js +365 -95
- package/dist/modules/compass/map.js +2 -2
- package/dist/modules/compass/query.js +70 -24
- package/dist/modules/compass/register.js +1 -1
- package/dist/modules/compass/visualize.js +5 -3
- package/dist/modules/cortex/brief.js +3 -1
- package/dist/modules/cortex/harness.js +51 -5
- package/dist/modules/cortex/paths.js +39 -0
- package/dist/modules/cortex/status.js +4 -1
- package/dist/modules/foundation/assets/docs/compass.template.md +16 -0
- package/dist/modules/foundation/compile-laws.js +23 -6
- package/dist/modules/foundation/doctor.js +11 -1
- package/dist/modules/foundation/lock.js +224 -20
- package/dist/modules/foundation/scaffold.js +24 -8
- package/dist/modules/lawbook/assets/agents/archiver.md +5 -2
- package/dist/modules/lawbook/assets/skills/cortex/steps/02-dispatch-loop.md +4 -1
- package/dist/modules/lawbook/drift.js +2 -2
- package/dist/modules/lawbook/engine.js +24 -2
- package/dist/modules/lawbook/investigate.js +2 -1
- package/dist/shared/agents.js +56 -7
- package/dist/shared/output-budget.js +2 -0
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { openDb, indexExists } from "./db.js";
|
|
3
|
+
import { openDb, indexExists, FILE_NODE_KIND } from "./db.js";
|
|
4
|
+
import { BUILTIN_SQL_LIST } from "./indexer.js";
|
|
4
5
|
import { getEmbedder, fromBlob, cosine } from "./embedder.js";
|
|
5
6
|
import { loadAffectedConfig, filterFilesForTarget, matchGlobalFiles } from "./affected-config.js";
|
|
6
7
|
function requireIndex(projectPath) {
|
|
@@ -32,8 +33,8 @@ export function search(projectPath, query, limit = 25) {
|
|
|
32
33
|
const rows = db
|
|
33
34
|
.prepare(`SELECT s.name, s.kind, f.path AS file, s.start_line AS line, s.signature
|
|
34
35
|
FROM nodes s JOIN files f ON f.id = s.file_id
|
|
35
|
-
WHERE s.
|
|
36
|
-
|
|
36
|
+
WHERE s.kind <> '${FILE_NODE_KIND}'
|
|
37
|
+
AND (s.name LIKE ? ESCAPE '\\' OR (? != '' AND f.path LIKE ? ESCAPE '\\'))
|
|
37
38
|
ORDER BY (s.name = ?) DESC, length(s.name) ASC, f.path ASC, s.start_line ASC
|
|
38
39
|
LIMIT ?`)
|
|
39
40
|
.all(`%${escapeLike(query)}%`, normalized, pathPattern, query, limit);
|
|
@@ -129,15 +130,18 @@ function resolveIndexedFile(db, projectPath, query) {
|
|
|
129
130
|
return { status: "ambiguous", basename: normalized, files: suffix };
|
|
130
131
|
return { status: "none" };
|
|
131
132
|
}
|
|
132
|
-
/**
|
|
133
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Nodes in one indexed file, ordered by source position: the visible
|
|
135
|
+
* definitions, or with `fileNode` only its file-owner node.
|
|
136
|
+
*/
|
|
137
|
+
function fileSymbols(db, file, fileNode = false) {
|
|
134
138
|
return db
|
|
135
139
|
.prepare(`SELECT s.id, s.name, s.kind, s.start_line, s.end_line, s.start_byte, s.end_byte,
|
|
136
140
|
s.signature, f.path AS file
|
|
137
141
|
FROM nodes s JOIN files f ON f.id = s.file_id
|
|
138
|
-
WHERE f.path = ?
|
|
142
|
+
WHERE f.path = ? AND (s.kind = '${FILE_NODE_KIND}') = ?
|
|
139
143
|
ORDER BY s.start_line ASC, s.id ASC`)
|
|
140
|
-
.all(file);
|
|
144
|
+
.all(file, fileNode ? 1 : 0);
|
|
141
145
|
}
|
|
142
146
|
/** Symbols defined in `files`, stable across files. Empty when none exist. */
|
|
143
147
|
function symbolsInFiles(db, files) {
|
|
@@ -147,7 +151,7 @@ function symbolsInFiles(db, files) {
|
|
|
147
151
|
return db
|
|
148
152
|
.prepare(`SELECT s.name, s.kind, f.path AS file, s.start_line AS line, s.signature
|
|
149
153
|
FROM nodes s JOIN files f ON f.id = s.file_id
|
|
150
|
-
WHERE f.path IN (${placeholders})
|
|
154
|
+
WHERE f.path IN (${placeholders}) AND s.kind <> '${FILE_NODE_KIND}'
|
|
151
155
|
ORDER BY f.path ASC, s.start_line ASC, s.name ASC`)
|
|
152
156
|
.all(...files);
|
|
153
157
|
}
|
|
@@ -171,24 +175,43 @@ function pickPrimary(symbols) {
|
|
|
171
175
|
* `otherMatches` (same-name siblings, or the rest of a file).
|
|
172
176
|
*/
|
|
173
177
|
function assembleExplore(db, projectPath, best, others) {
|
|
178
|
+
// Covers: req~compass-mcp-surface~1
|
|
179
|
+
// Resolved callees only, one row per target node at its first call line, so
|
|
180
|
+
// unresolved names (builtins, member calls) never crowd out project callees.
|
|
174
181
|
const callees = db
|
|
175
|
-
.prepare(`SELECT
|
|
176
|
-
FROM edges e
|
|
177
|
-
|
|
182
|
+
.prepare(`SELECT s.name AS name, MIN(e.line) AS line, f.path AS file
|
|
183
|
+
FROM edges e JOIN nodes s ON s.id = e.dst_node_id
|
|
184
|
+
JOIN files f ON f.id = s.file_id
|
|
178
185
|
WHERE e.src_node_id = ? AND e.kind = 'call'
|
|
179
|
-
|
|
186
|
+
GROUP BY s.id
|
|
187
|
+
ORDER BY line, s.id`)
|
|
180
188
|
.all(best.id);
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
189
|
+
const unresolved = db
|
|
190
|
+
.prepare(`SELECT e.dst_name AS name, COUNT(*) AS n
|
|
191
|
+
FROM edges e
|
|
192
|
+
WHERE e.src_node_id = ? AND e.kind = 'call' AND e.dst_node_id IS NULL
|
|
193
|
+
GROUP BY e.dst_name
|
|
194
|
+
ORDER BY n DESC, e.dst_name ASC`)
|
|
195
|
+
.all(best.id);
|
|
196
|
+
// Callers: match both the resolved edge AND any non-member (`is_member = 0`)
|
|
197
|
+
// call by this name, so dynamic dispatch (a function called by name across sites) is not
|
|
198
|
+
// missed. Over-approximates conservatively — the point of a blast radius. A
|
|
199
|
+
// builtin global name (`test`, `fetch`) matches by name only within the
|
|
200
|
+
// definition's own file. A caller outside any definition is its file-owner
|
|
201
|
+
// node (`kind: "file"`).
|
|
184
202
|
const callers = db
|
|
185
203
|
.prepare(`SELECT DISTINCT owner.name AS name, owner.kind AS kind, f.path AS file, e.line
|
|
186
204
|
FROM edges e
|
|
187
205
|
JOIN nodes owner ON owner.id = e.src_node_id
|
|
188
206
|
JOIN files f ON f.id = owner.file_id
|
|
189
|
-
WHERE (e.dst_node_id = ? OR
|
|
207
|
+
WHERE (e.dst_node_id = ? OR (
|
|
208
|
+
e.dst_name = ? AND e.is_member = 0
|
|
209
|
+
AND (e.dst_name NOT IN (${BUILTIN_SQL_LIST})
|
|
210
|
+
OR e.src_file_id = (SELECT file_id FROM nodes WHERE id = ?))
|
|
211
|
+
))
|
|
212
|
+
AND e.kind = 'call'
|
|
190
213
|
ORDER BY f.path, e.line`)
|
|
191
|
-
.all(best.id, best.name);
|
|
214
|
+
.all(best.id, best.name, best.id);
|
|
192
215
|
return {
|
|
193
216
|
found: true,
|
|
194
217
|
symbol: {
|
|
@@ -200,7 +223,11 @@ function assembleExplore(db, projectPath, best, others) {
|
|
|
200
223
|
signature: best.signature,
|
|
201
224
|
source: readSource(projectPath, best.file, best.start_byte, best.end_byte),
|
|
202
225
|
},
|
|
203
|
-
callees: callees.map((c) => ({ name: c.name, file: c.file
|
|
226
|
+
callees: callees.map((c) => ({ name: c.name, file: c.file, line: c.line })),
|
|
227
|
+
unresolvedCallees: {
|
|
228
|
+
count: unresolved.reduce((sum, u) => sum + Number(u.n), 0),
|
|
229
|
+
sample: unresolved.slice(0, 10).map((u) => u.name),
|
|
230
|
+
},
|
|
204
231
|
callers,
|
|
205
232
|
otherMatches: others.length > 0
|
|
206
233
|
? others.map((m) => ({
|
|
@@ -215,14 +242,23 @@ function assembleExplore(db, projectPath, best, others) {
|
|
|
215
242
|
}
|
|
216
243
|
/**
|
|
217
244
|
* Name miss: resolve a file path to one symbol, or list symbols when the
|
|
218
|
-
* basename is ambiguous or the file defines nothing. A
|
|
219
|
-
*
|
|
245
|
+
* basename is ambiguous or the file defines nothing. A file with no visible
|
|
246
|
+
* symbol resolves to its file-owner node (the indexer gives every symbol-less
|
|
247
|
+
* 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
|
+
* matches `files.path`.
|
|
220
249
|
*/
|
|
221
250
|
function exploreWhenNameMisses(db, projectPath, query) {
|
|
222
|
-
// Covers: req~explore-file-path~
|
|
251
|
+
// Covers: req~explore-file-path~2
|
|
223
252
|
const resolved = resolveIndexedFile(db, projectPath, query);
|
|
224
253
|
if (resolved.status === "one") {
|
|
225
254
|
const symbols = fileSymbols(db, resolved.file);
|
|
255
|
+
const owner = symbols.length === 0 ? fileSymbols(db, resolved.file, true)[0] : undefined;
|
|
256
|
+
if (owner) {
|
|
257
|
+
return {
|
|
258
|
+
...assembleExplore(db, projectPath, owner, []),
|
|
259
|
+
message: `Path "${resolved.file}" declares no symbols; resolved to its file-owner node.`,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
226
262
|
if (symbols.length === 0) {
|
|
227
263
|
const near = search(projectPath, query, 10);
|
|
228
264
|
return {
|
|
@@ -276,7 +312,7 @@ export function explore(projectPath, query) {
|
|
|
276
312
|
.prepare(`SELECT s.id, s.name, s.kind, s.start_line, s.end_line, s.start_byte, s.end_byte,
|
|
277
313
|
s.signature, f.path AS file
|
|
278
314
|
FROM nodes s JOIN files f ON f.id = s.file_id
|
|
279
|
-
WHERE s.name = ?
|
|
315
|
+
WHERE s.name = ? AND s.kind <> '${FILE_NODE_KIND}'
|
|
280
316
|
ORDER BY s.kind = 'function' DESC, s.kind = 'class' DESC
|
|
281
317
|
LIMIT 10`)
|
|
282
318
|
.all(query);
|
|
@@ -391,6 +427,12 @@ export function impact(projectPath, symbolOrOpts, maxDepth = 4) {
|
|
|
391
427
|
for (const d of definitions) {
|
|
392
428
|
seedArgs.push(d.nodeId, d.name);
|
|
393
429
|
}
|
|
430
|
+
// Covers: req~impact-id-first~1
|
|
431
|
+
// A member call (`is_member` 1 or 2) never matches by name: `items.push()` is
|
|
432
|
+
// not a call to a project function named `push`, and a call on an import
|
|
433
|
+
// binding (2) counts only through the id resolveEdges gave it, so a package
|
|
434
|
+
// receiver (`path.parse()`) never reaches a project `parse`. A builtin global name
|
|
435
|
+
// (`test`, `fetch`) matches by name only within the frontier node's file.
|
|
394
436
|
// Sticky by_name: MAX(frontier.by_name, CASE WHEN edge unresolved THEN 1 ELSE 0).
|
|
395
437
|
// Import edges that resolve to ANY node in the frontier node's file count as hits.
|
|
396
438
|
const sql = `
|
|
@@ -412,7 +454,11 @@ export function impact(projectPath, symbolOrOpts, maxDepth = 4) {
|
|
|
412
454
|
AND (
|
|
413
455
|
(e.kind = 'call' AND (
|
|
414
456
|
e.dst_node_id = f.node_id
|
|
415
|
-
OR (
|
|
457
|
+
OR (
|
|
458
|
+
e.dst_node_id IS NULL AND e.is_member = 0 AND e.dst_name = f.node_name
|
|
459
|
+
AND (e.dst_name NOT IN (${BUILTIN_SQL_LIST})
|
|
460
|
+
OR e.src_file_id = (SELECT file_id FROM nodes WHERE id = f.node_id))
|
|
461
|
+
)
|
|
416
462
|
))
|
|
417
463
|
OR (
|
|
418
464
|
e.kind = 'import'
|
|
@@ -536,7 +582,7 @@ function resolveImpactSeeds(db, opts, seedFiles, warnings) {
|
|
|
536
582
|
const rows = db
|
|
537
583
|
.prepare(`SELECT n.id AS nodeId, n.name, f.path AS file, n.start_line AS line
|
|
538
584
|
FROM nodes n JOIN files f ON f.id = n.file_id
|
|
539
|
-
WHERE n.name = ?
|
|
585
|
+
WHERE n.name = ? AND n.kind <> '${FILE_NODE_KIND}'
|
|
540
586
|
ORDER BY n.kind = 'function' DESC, n.kind = 'class' DESC, n.id ASC
|
|
541
587
|
LIMIT 50`)
|
|
542
588
|
.all(opts.symbol);
|
|
@@ -151,7 +151,7 @@ export function registerCompass(server, opts = {}) {
|
|
|
151
151
|
});
|
|
152
152
|
defineAliasTool(server, {
|
|
153
153
|
name: "compass_affected_tests",
|
|
154
|
-
description: "Deprecated alias — use compass_diff_context or explore.",
|
|
154
|
+
description: "Deprecated alias — use compass_diff_context or explore; command may be null.",
|
|
155
155
|
inputSchema: {
|
|
156
156
|
projectPath: z.string(),
|
|
157
157
|
files: z.array(z.string()).optional(),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { openDb, indexExists } from "./db.js";
|
|
3
|
+
import { openDb, indexExists, FILE_NODE_KIND } from "./db.js";
|
|
4
4
|
/**
|
|
5
5
|
* Build the graph payload from the Compass index: nodes (definitions) and the
|
|
6
6
|
* resolved call links between them. With a focus node, returns its neighborhood
|
|
@@ -17,11 +17,13 @@ export function graphData(projectPath, opts = {}) {
|
|
|
17
17
|
try {
|
|
18
18
|
const allNodes = db
|
|
19
19
|
.prepare(`SELECT n.id, n.name, n.kind, f.path AS file, n.start_line AS line
|
|
20
|
-
FROM nodes n JOIN files f ON f.id = n.file_id
|
|
20
|
+
FROM nodes n JOIN files f ON f.id = n.file_id
|
|
21
|
+
WHERE n.kind <> '${FILE_NODE_KIND}'`)
|
|
21
22
|
.all();
|
|
22
23
|
const allEdges = db
|
|
23
24
|
.prepare(`SELECT src_node_id AS s, dst_node_id AS t FROM edges
|
|
24
|
-
WHERE kind = 'call' AND dst_node_id IS NOT NULL AND src_node_id IS NOT NULL
|
|
25
|
+
WHERE kind = 'call' AND dst_node_id IS NOT NULL AND src_node_id IS NOT NULL
|
|
26
|
+
AND src_node_id NOT IN (SELECT id FROM nodes WHERE kind = '${FILE_NODE_KIND}')`)
|
|
25
27
|
.all();
|
|
26
28
|
// adjacency (undirected) + degree
|
|
27
29
|
const adj = new Map();
|
|
@@ -57,7 +57,9 @@ export function briefForStage(stage) {
|
|
|
57
57
|
role: "archiver",
|
|
58
58
|
agentPath: "ai-specs/agents/archiver.md",
|
|
59
59
|
skillHints: ["sync", "archive"],
|
|
60
|
-
|
|
60
|
+
// A successful `lawbook_change` archive completes the harness itself;
|
|
61
|
+
// no Cortex op follows (confirm `done` with `status`).
|
|
62
|
+
nextOps: [],
|
|
61
63
|
};
|
|
62
64
|
case "done":
|
|
63
65
|
case null:
|
|
@@ -12,6 +12,8 @@ import fs from "node:fs";
|
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import { GATED_STAGES, compassGateError, compassGateWarning, evaluateCompassGate, readCompassGateMode, } from "./compass-gate.js";
|
|
14
14
|
import { buildStatusSummary } from "./status.js";
|
|
15
|
+
import { resolveChangeDir } from "./paths.js";
|
|
16
|
+
export { resolveChangeDir } from "./paths.js";
|
|
15
17
|
// Harness types live in the leaf `types.ts` so `compass-gate.ts` can use them
|
|
16
18
|
// without importing this file back (no file-level import cycle).
|
|
17
19
|
export { HARNESS_STAGES, } from "./types.js";
|
|
@@ -21,7 +23,8 @@ function specRoot(projectPath) {
|
|
|
21
23
|
return path.join(projectPath, "lawbook");
|
|
22
24
|
}
|
|
23
25
|
function changeDir(projectPath, change) {
|
|
24
|
-
return
|
|
26
|
+
return (resolveChangeDir(projectPath, change)?.dir ??
|
|
27
|
+
path.join(specRoot(projectPath), "changes", change));
|
|
25
28
|
}
|
|
26
29
|
function harnessPath(projectPath, change) {
|
|
27
30
|
return path.join(changeDir(projectPath, change), "harness.json");
|
|
@@ -30,11 +33,11 @@ function nowIso() {
|
|
|
30
33
|
return new Date().toISOString();
|
|
31
34
|
}
|
|
32
35
|
function requireChangeDir(projectPath, change) {
|
|
33
|
-
const
|
|
34
|
-
if (!
|
|
36
|
+
const resolved = resolveChangeDir(projectPath, change);
|
|
37
|
+
if (!resolved) {
|
|
35
38
|
throw new Error(`change "${change}" not found under lawbook/changes/`);
|
|
36
39
|
}
|
|
37
|
-
return
|
|
40
|
+
return resolved;
|
|
38
41
|
}
|
|
39
42
|
/**
|
|
40
43
|
* Read confirmed ceremony level from change.json without importing lawbook.
|
|
@@ -120,11 +123,17 @@ function allowedAdvance(from, level, verdict) {
|
|
|
120
123
|
*/
|
|
121
124
|
export function handleHarness(args) {
|
|
122
125
|
const { projectPath, change, harnessOp } = args;
|
|
123
|
-
requireChangeDir(projectPath, change);
|
|
126
|
+
const resolved = requireChangeDir(projectPath, change);
|
|
124
127
|
if (harnessOp === "status") {
|
|
125
128
|
const state = readHarness(projectPath, change);
|
|
126
129
|
return { summary: state ? buildStatusSummary(projectPath, state) : null, state };
|
|
127
130
|
}
|
|
131
|
+
// An archived change is read-only: reject mutating ops before any write.
|
|
132
|
+
// Covers: req~harness-archive-completes~1
|
|
133
|
+
if (resolved.archived) {
|
|
134
|
+
throw new Error(`change ${change} is archived (${path.relative(projectPath, resolved.dir)}); ` +
|
|
135
|
+
`Cortex ops are read-only`);
|
|
136
|
+
}
|
|
128
137
|
if (harnessOp === "start") {
|
|
129
138
|
const existing = readHarness(projectPath, change);
|
|
130
139
|
if (existing && existing.stage !== "done") {
|
|
@@ -258,6 +267,43 @@ export function handleHarness(args) {
|
|
|
258
267
|
}
|
|
259
268
|
throw new Error(`unknown harnessOp '${String(harnessOp)}'`);
|
|
260
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* Complete a change's harness as part of archiving: move stage `archiving` to
|
|
272
|
+
* `done` with one history entry. Called by the lawbook archive **before** the
|
|
273
|
+
* change directory moves, so a crash cannot leave the harness stuck; the
|
|
274
|
+
* returned `restore` puts the old bytes back if the move fails. A missing
|
|
275
|
+
* harness or one already `done` is left untouched.
|
|
276
|
+
*
|
|
277
|
+
* @param projectPath - Absolute path to the project root.
|
|
278
|
+
* @param change - Change name (an active change).
|
|
279
|
+
* @param note - History note naming the archive.
|
|
280
|
+
* @returns Whether the harness was completed and how to undo it.
|
|
281
|
+
* @throws If the harness is in any other stage (the archive gate prevents this).
|
|
282
|
+
*/
|
|
283
|
+
// Covers: req~harness-archive-completes~1
|
|
284
|
+
export function completeHarnessOnArchive(projectPath, change, note) {
|
|
285
|
+
const noop = { completed: false, restore: () => { } };
|
|
286
|
+
const p = harnessPath(projectPath, change);
|
|
287
|
+
if (!fs.existsSync(p))
|
|
288
|
+
return noop;
|
|
289
|
+
const original = fs.readFileSync(p, "utf8");
|
|
290
|
+
const state = JSON.parse(original);
|
|
291
|
+
if (state.stage === "done")
|
|
292
|
+
return noop;
|
|
293
|
+
if (state.stage !== "archiving") {
|
|
294
|
+
throw new Error(`harness stage is ${state.stage} — advance to archiving before archive (${change})`);
|
|
295
|
+
}
|
|
296
|
+
const next = {
|
|
297
|
+
...state,
|
|
298
|
+
stage: "done",
|
|
299
|
+
history: [
|
|
300
|
+
...state.history,
|
|
301
|
+
{ at: nowIso(), from: "archiving", to: "done", op: "advance", note },
|
|
302
|
+
],
|
|
303
|
+
};
|
|
304
|
+
fs.writeFileSync(p, JSON.stringify(next, null, 2) + "\n");
|
|
305
|
+
return { completed: true, restore: () => fs.writeFileSync(p, original) };
|
|
306
|
+
}
|
|
261
307
|
/**
|
|
262
308
|
* Archive blockers derived from harness verdicts.
|
|
263
309
|
*
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Change-directory resolution shared by the Cortex harness and its status
|
|
3
|
+
* summary. A leaf module (no Cortex imports) so both can use it without an
|
|
4
|
+
* import cycle; Cortex never imports the lawbook module.
|
|
5
|
+
*/
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
function escapeRegExp(s) {
|
|
9
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Resolve a change name to its directory: the active `lawbook/changes/<name>/`
|
|
13
|
+
* when it exists, otherwise the newest `lawbook/changes/archive/<YYYY-MM-DD>-<name>/`
|
|
14
|
+
* (matched exactly, so `<date>-other-<name>` never matches `<name>`).
|
|
15
|
+
*
|
|
16
|
+
* @param projectPath - Absolute path to the project root.
|
|
17
|
+
* @param change - Change name.
|
|
18
|
+
* @returns The directory and whether it is archived, or null when neither exists.
|
|
19
|
+
*/
|
|
20
|
+
// Covers: req~harness-archive-completes~1
|
|
21
|
+
export function resolveChangeDir(projectPath, change) {
|
|
22
|
+
const active = path.join(projectPath, "lawbook", "changes", change);
|
|
23
|
+
if (fs.existsSync(active))
|
|
24
|
+
return { dir: active, archived: false };
|
|
25
|
+
const archiveRoot = path.join(projectPath, "lawbook", "changes", "archive");
|
|
26
|
+
let entries;
|
|
27
|
+
try {
|
|
28
|
+
entries = fs.readdirSync(archiveRoot);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const re = new RegExp(`^\\d{4}-\\d{2}-\\d{2}-${escapeRegExp(change)}$`);
|
|
34
|
+
const newest = entries
|
|
35
|
+
.filter((e) => re.test(e))
|
|
36
|
+
.sort()
|
|
37
|
+
.at(-1);
|
|
38
|
+
return newest ? { dir: path.join(archiveRoot, newest), archived: true } : null;
|
|
39
|
+
}
|
|
@@ -12,6 +12,7 @@ import path from "node:path";
|
|
|
12
12
|
import { briefForStage } from "./brief.js";
|
|
13
13
|
import { stageStartedAt } from "./compass-gate.js";
|
|
14
14
|
import { OUTPUT_BUDGET, estimateTokens } from "../../shared/output-budget.js";
|
|
15
|
+
import { resolveChangeDir } from "./paths.js";
|
|
15
16
|
/** Interval used when the key is missing or invalid. */
|
|
16
17
|
export const DEFAULT_STATUS_INTERVAL_MINUTES = 5;
|
|
17
18
|
/**
|
|
@@ -84,7 +85,9 @@ export function readStatusIntervalMinutes(projectPath) {
|
|
|
84
85
|
return DEFAULT_STATUS_INTERVAL_MINUTES;
|
|
85
86
|
}
|
|
86
87
|
function readTaskCounts(projectPath, change) {
|
|
87
|
-
|
|
88
|
+
// An archived change still reports its tasks from the archive directory.
|
|
89
|
+
const dir = resolveChangeDir(projectPath, change)?.dir ??
|
|
90
|
+
path.join(projectPath, "lawbook", "changes", change);
|
|
88
91
|
for (const file of ["tasks.md", "record.md"]) {
|
|
89
92
|
let text;
|
|
90
93
|
try {
|
|
@@ -34,6 +34,22 @@ a request, not whole files.
|
|
|
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
|
|
|
37
|
+
Callees list only references resolved to a project symbol; the rest are counted
|
|
38
|
+
in `unresolvedCallees`. A call inside a test callback or a top-level import
|
|
39
|
+
shows up as a caller of kind `file`, so tests written as bare `test(...)`
|
|
40
|
+
blocks are still selected. The affected-test `command` is `null` (with
|
|
41
|
+
`commandReason`) when no test is reachable; otherwise run it from the repository
|
|
42
|
+
root, or use `commands` (one `{ cwd, command, files }` per package, with the
|
|
43
|
+
runner detected from each `package.json`: vitest, jest, or `node --test`).
|
|
44
|
+
Method calls on local variables and parameters (`svc.run()`) are not bound to a
|
|
45
|
+
project method, so they do not appear as callers. Calls through an imported
|
|
46
|
+
re-export barrel (`index.ts` holding `export * from "./lib/core"`) bind by
|
|
47
|
+
name, preferring a definition under the barrel's directory.
|
|
48
|
+
|
|
49
|
+
Compass uses index schema 11. speclaw 2.0.9 or older, including an MCP entry
|
|
50
|
+
pinned at `@2.0.9`, rebuilds a schema-11 index from scratch (losing the
|
|
51
|
+
embedding cache). Run `speclaw update` so the pinned MCP entry moves to 2.0.10.
|
|
52
|
+
|
|
37
53
|
`compass_index` also takes `action: start|stop|status` to keep the index fresh
|
|
38
54
|
automatically (a debounced incremental re-index on file change).
|
|
39
55
|
|
|
@@ -4,7 +4,7 @@ import { globError, isActiveLaw, readLawManifest, seedManifestFor, mergeSeedLaws
|
|
|
4
4
|
import { parseLawsFromStandards } from "./laws-parse.js";
|
|
5
5
|
import { agentsmdDialect, claudeRulesDialect, coderabbitDialect, copilotDialect, cursorMdcDialect, patchDelimited, } from "./dialects/index.js";
|
|
6
6
|
import { detectConfiguredAgents } from "../../shared/agents.js";
|
|
7
|
-
import { digestText, provenanceBlock, refreshLockfile } from "./lock.js";
|
|
7
|
+
import { digestText, driftedStrictPaths, provenanceBlock, refreshLockfile } from "./lock.js";
|
|
8
8
|
const DIALECTS = [
|
|
9
9
|
claudeRulesDialect,
|
|
10
10
|
cursorMdcDialect,
|
|
@@ -159,7 +159,21 @@ export function compileLaws(opts) {
|
|
|
159
159
|
failed: [],
|
|
160
160
|
lawCount: 0,
|
|
161
161
|
draftCount: 0,
|
|
162
|
+
lockPreserved: [],
|
|
162
163
|
};
|
|
164
|
+
// Snapshot drift before any rule artifact is written: compilation rewrites
|
|
165
|
+
// the speclaw regions of CLAUDE.md / AGENTS.md, which must not launder edits.
|
|
166
|
+
// An existing lock that cannot be read is reported and left untouched, never
|
|
167
|
+
// rebuilt from disk (that would launder drift); compilation itself proceeds.
|
|
168
|
+
let drifted = opts.drifted ?? [];
|
|
169
|
+
if (opts.drifted === undefined && opts.refreshLock !== false) {
|
|
170
|
+
try {
|
|
171
|
+
drifted = driftedStrictPaths(projectPath);
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
report.lockError = err.message;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
163
177
|
const laws = mergeLawSources(projectPath);
|
|
164
178
|
validateScopes(laws);
|
|
165
179
|
report.lawCount = laws.filter(isActiveLaw).length;
|
|
@@ -185,11 +199,14 @@ export function compileLaws(opts) {
|
|
|
185
199
|
if (agents.includes("claude"))
|
|
186
200
|
ensureClaudeRulesSymlink(projectPath, report);
|
|
187
201
|
// Covers: req~lock-refresh-update~1
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
202
|
+
if (opts.refreshLock !== false && report.lockError === undefined) {
|
|
203
|
+
try {
|
|
204
|
+
report.lockPreserved = refreshLockfile(projectPath, { drifted }).preserved;
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
// refreshLockfile throws before writing; the caller surfaces lockError.
|
|
208
|
+
report.lockError = err.message;
|
|
209
|
+
}
|
|
193
210
|
}
|
|
194
211
|
return report;
|
|
195
212
|
}
|
|
@@ -557,6 +557,15 @@ function specsOrphansCheck(projectPath) {
|
|
|
557
557
|
remedy: `speclaw lawbook archive ${active[0]}`,
|
|
558
558
|
};
|
|
559
559
|
}
|
|
560
|
+
/**
|
|
561
|
+
* Fix hint for an unreadable or invalid `speclaw.lock`. `laws lock` refuses
|
|
562
|
+
* such a lock, and deleting it re-baselines every pinned file, silently
|
|
563
|
+
* accepting any pending drift — so that is offered only as a last resort.
|
|
564
|
+
*/
|
|
565
|
+
export const UNREADABLE_LOCK_REMEDY = "resolve the merge conflict in speclaw.lock or restore it from git " +
|
|
566
|
+
"(git checkout -- speclaw.lock); upgrade speclaw if the lockfileVersion is newer. " +
|
|
567
|
+
"Last resort: delete it and run speclaw laws lock — this re-baselines every " +
|
|
568
|
+
"pinned file and accepts any pending drift";
|
|
560
569
|
function integrityChecks(projectPath) {
|
|
561
570
|
// Covers: req~doctor-integrity~1
|
|
562
571
|
const out = [];
|
|
@@ -591,7 +600,8 @@ function integrityChecks(projectPath) {
|
|
|
591
600
|
title: "rule lockfile",
|
|
592
601
|
status: "error",
|
|
593
602
|
detail: err.message,
|
|
594
|
-
|
|
603
|
+
// `laws lock` refuses an unreadable lock, so point at repairing it.
|
|
604
|
+
remedy: UNREADABLE_LOCK_REMEDY,
|
|
595
605
|
});
|
|
596
606
|
}
|
|
597
607
|
}
|