@esneiderbravo/speclaw 2.0.6 → 2.0.8
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 +5 -2
- package/dist/cli/commands/cortex.js +4 -0
- package/dist/cli/commands/index-build.js +18 -0
- package/dist/cli/commands/session-start.js +29 -0
- package/dist/cli/index.js +7 -1
- package/dist/modules/compass/db.js +2 -0
- package/dist/modules/compass/hash.js +3 -3
- package/dist/modules/compass/indexer.js +55 -31
- package/dist/modules/compass/map.js +24 -0
- package/dist/modules/compass/query.js +18 -3
- package/dist/modules/cortex/harness.js +5 -2
- package/dist/modules/cortex/register.js +6 -3
- package/dist/modules/cortex/status.js +197 -0
- package/dist/modules/foundation/assets/AGENTS.template.md +2 -1
- package/dist/modules/foundation/assets/CLAUDE.template.md +2 -1
- package/dist/modules/foundation/assets/docs/compass.template.md +6 -0
- package/dist/modules/foundation/hooks.js +61 -6
- package/dist/modules/lawbook/assets/agents/explorer.md +3 -2
- package/dist/modules/lawbook/assets/commands/cortex.md +2 -0
- package/dist/modules/lawbook/assets/skills/build/steps/03-implement.md +2 -1
- package/dist/modules/lawbook/assets/skills/cortex/steps/01-load-or-start.md +27 -0
- package/dist/modules/lawbook/assets/skills/cortex/steps/02-dispatch-loop.md +15 -0
- package/dist/modules/lawbook/assets/skills/cortex/steps/03-complete.md +3 -1
- package/dist/modules/lawbook/assets/skills/draft/steps/02-understand.md +5 -3
- package/dist/modules/lawbook/assets/skills/explore/steps/01-investigate.md +3 -1
- package/dist/modules/lawbook/assets/skills/investigate/steps/01-investigate.md +3 -1
- package/dist/modules/lawbook/change-tool.js +4 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -116,7 +116,7 @@ gh attestation verify <tarball> --owner esneiderbravo
|
|
|
116
116
|
| Module | What it does |
|
|
117
117
|
| :-- | :-- |
|
|
118
118
|
| **Foundation** | The project's constitution: `LAWS.md` binding granular standards under `docs/standards/`, plus `CLAUDE.md` / `AGENTS.md`. **Enforced** via agent hooks (`speclaw check`), deterministic graph laws (`speclaw verify` / `laws verify`), multidialect compile (`laws compile`), and committed **`speclaw.lock`** digests + injection scan (`laws lock` / `accept` / `scan`). |
|
|
119
|
-
| **Compass** | Local code graph (tree-sitter → `node:sqlite`): hybrid find (FTS5 + vectors + RRF + PageRank), impact, affected-tests, hotspots, coupling, visualize. Schema **10**. No LLM — lives in `.speclaw/` (gitignored). |
|
|
119
|
+
| **Compass** | Local code graph (tree-sitter → `node:sqlite`): hybrid find (FTS5 + vectors + RRF + PageRank), impact, affected-tests, hotspots, coupling, visualize. Schema **10**. Refreshed silently at every Claude Code session start once built. No LLM — lives in `.speclaw/` (gitignored). |
|
|
120
120
|
| **Lawbook** | Spec-driven workflow with **adaptive ceremony** (levels 0–3), EARS linting, requirement **coverage**, sealed **drift** anchors, bugfix + `lawbook_investigate`. Artifacts under `lawbook/`. |
|
|
121
121
|
| **Cortex** | **One brain. Many agents.** The multi-agent loop: durable `harness.json`, MCP tool `cortex` (`status`/`start`/`advance`/`rework`/`brief`), CLI `speclaw cortex`, skill `/lawbook/cortex`. Dispatches explorer → planner → implementer → reviewer → tester → archiver. |
|
|
122
122
|
| **Team** | Declare `team.owners` in `lawbook/config.yaml`; `speclaw owners --write` compiles a managed trailing block in `.github/CODEOWNERS` (GitHub: last match wins). Doctor checks the posture. CLI-only — no MCP tool. |
|
|
@@ -247,7 +247,10 @@ regenerate `ai-specs/` locally. Optional **`team.owners`** in
|
|
|
247
247
|
|
|
248
248
|
**Enforcement artifacts.** For agents that support hooks, speclaw merges its law
|
|
249
249
|
hooks into that agent's settings **by identity** — it never touches hooks you
|
|
250
|
-
added yourself. The
|
|
250
|
+
added yourself. The same merge adds one `SessionStart` command hook that
|
|
251
|
+
silently refreshes an existing Compass index when a session starts
|
|
252
|
+
(`speclaw session-start`; it skips when no index exists, never downloads or
|
|
253
|
+
contacts the registry, and always exits 0). The compiled law manifest lives in `.speclaw/laws-manifest.json`
|
|
251
254
|
(gitignored) and is **adapted to the target tree** on `init`/`update` — speclaw's
|
|
252
255
|
own architecture laws are seeded only when those paths exist, and the cycle law
|
|
253
256
|
follows `apps/*/src`, `packages/*/src`, or `src/` rather than copying
|
|
@@ -41,6 +41,10 @@ export async function runCortex(flags) {
|
|
|
41
41
|
note: typeof flags.note === "string" ? flags.note : undefined,
|
|
42
42
|
});
|
|
43
43
|
printHarnessWarnings(result);
|
|
44
|
+
// The human line goes to stderr so stdout stays one JSON document.
|
|
45
|
+
if (op === "status" && !flags.json && "summary" in result && result.summary) {
|
|
46
|
+
process.stderr.write(` ${result.summary.line}\n`);
|
|
47
|
+
}
|
|
44
48
|
console.log(JSON.stringify(result, null, 2));
|
|
45
49
|
}
|
|
46
50
|
catch (err) {
|
|
@@ -2,9 +2,27 @@ import { buildIndex } from "../../modules/compass/indexer.js";
|
|
|
2
2
|
import { startWatch } from "../../modules/compass/watcher.js";
|
|
3
3
|
import { recordCompassCall } from "../../shared/compass-calls.js";
|
|
4
4
|
import { ui, renderProgress, clearProgress } from "../lib/ui.js";
|
|
5
|
+
/** Usage text for `speclaw index --help`. */
|
|
6
|
+
export const INDEX_HELP = `Usage: speclaw index [options]
|
|
7
|
+
|
|
8
|
+
(Re)build the local Compass code graph for the current directory. Runs are
|
|
9
|
+
incremental: unchanged files are skipped, and an unchanged project skips the
|
|
10
|
+
global post-processing.
|
|
11
|
+
|
|
12
|
+
Options
|
|
13
|
+
--force Re-extract every file
|
|
14
|
+
--prune Also evict embedding-cache rows unused for --retention days
|
|
15
|
+
--retention <days> Retention window for --prune (default 30)
|
|
16
|
+
--max-cache-mb <mb> Embedding-cache size cap (default 256)
|
|
17
|
+
--json Print the index statistics as JSON
|
|
18
|
+
`;
|
|
5
19
|
/** (Re)build the Compass code graph for the cwd, showing progress and final stats. */
|
|
6
20
|
export async function runIndex(flags) {
|
|
7
21
|
const cwd = process.cwd();
|
|
22
|
+
if (flags.help || flags.h) {
|
|
23
|
+
process.stdout.write(INDEX_HELP);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
8
26
|
recordCompassCall(cwd, "compass_index");
|
|
9
27
|
const json = Boolean(flags.json);
|
|
10
28
|
if (!json)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* `speclaw session-start`: refresh an existing Compass index incrementally,
|
|
5
|
+
* print nothing, and never fail. It is a top-level command rather than an
|
|
6
|
+
* `index` flag so an older speclaw that resolves first rejects it as unknown
|
|
7
|
+
* before indexing anything. With no `.speclaw/index.db` it returns at once,
|
|
8
|
+
* before `node:sqlite` is even loaded — the first build stays an explicit
|
|
9
|
+
* `speclaw index` / `compass_index`. It is not logged as a Compass call: the
|
|
10
|
+
* hook would otherwise append an entry per session and rotate real evidence out.
|
|
11
|
+
*
|
|
12
|
+
* @param cwd - Project root (defaults to the process working directory).
|
|
13
|
+
*/
|
|
14
|
+
// Covers: req~session-start-index~1
|
|
15
|
+
export async function runSessionStart(cwd = process.cwd()) {
|
|
16
|
+
try {
|
|
17
|
+
if (!fs.existsSync(path.join(cwd, ".speclaw", "index.db")))
|
|
18
|
+
return;
|
|
19
|
+
// Node lines that still flag `node:sqlite` as experimental would print an
|
|
20
|
+
// ExperimentalWarning to stderr; this command must print nothing.
|
|
21
|
+
process.removeAllListeners("warning");
|
|
22
|
+
const { buildIndex } = await import("../../modules/compass/indexer.js");
|
|
23
|
+
await buildIndex(cwd, {});
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// A concurrent writer (SQLITE_BUSY after busy_timeout) or any other failure
|
|
27
|
+
// must never fail or print into a starting session.
|
|
28
|
+
}
|
|
29
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -19,6 +19,7 @@ Setup
|
|
|
19
19
|
|
|
20
20
|
Compass (code intelligence — the same surface agents use via MCP)
|
|
21
21
|
index (Re)build the local code graph (--force / --prune / --json)
|
|
22
|
+
session-start Silent, fail-safe refresh of an existing index (SessionStart hook)
|
|
22
23
|
watch Keep the index fresh on file changes
|
|
23
24
|
explore <node> A node's source + callers/callees
|
|
24
25
|
search <query> Hybrid find (BM25+vector+name); --focus --max-tokens --explain
|
|
@@ -99,7 +100,7 @@ const HEADER_COMMANDS = new Set([
|
|
|
99
100
|
* forced-color signal counts as interactive so the header is exercisable in a
|
|
100
101
|
* child process. `budget --json`, `doctor --json`, and `coverage` when emitting
|
|
101
102
|
* TAP/JSON (or when stdout is not a TTY) are machine-consumed and suppress the
|
|
102
|
-
* header.
|
|
103
|
+
* header. `session-start` is not header-eligible: it must print nothing.
|
|
103
104
|
*/
|
|
104
105
|
function maybeHeader(cmd, flags) {
|
|
105
106
|
if (!process.stdout.isTTY && process.env.FORCE_COLOR !== "1")
|
|
@@ -156,6 +157,8 @@ async function dispatch(cmd, flags) {
|
|
|
156
157
|
return (await import("./commands/index-build.js")).runIndex(flags);
|
|
157
158
|
case "watch":
|
|
158
159
|
return (await import("./commands/index-build.js")).runWatch(flags);
|
|
160
|
+
case "session-start":
|
|
161
|
+
return (await import("./commands/session-start.js")).runSessionStart();
|
|
159
162
|
case "explore":
|
|
160
163
|
case "search":
|
|
161
164
|
case "recall":
|
|
@@ -204,6 +207,9 @@ async function main() {
|
|
|
204
207
|
const flags = parseFlags(rest);
|
|
205
208
|
maybeHeader(cmd, flags);
|
|
206
209
|
await dispatch(cmd, flags);
|
|
210
|
+
// The SessionStart hook's refresh must stay silent: no update notice either.
|
|
211
|
+
if (cmd === "session-start")
|
|
212
|
+
return;
|
|
207
213
|
await maybeNotifyUpdate(cmd);
|
|
208
214
|
}
|
|
209
215
|
main().catch((err) => {
|
|
@@ -19,6 +19,8 @@ CREATE TABLE IF NOT EXISTS files (
|
|
|
19
19
|
);
|
|
20
20
|
CREATE INDEX IF NOT EXISTS idx_files_is_test ON files(is_test);
|
|
21
21
|
-- nodes: the definitions in the codebase (functions, classes, methods, types).
|
|
22
|
+
-- start_byte/end_byte are UTF-16 code-unit offsets into the decoded source
|
|
23
|
+
-- (tree-sitter's startIndex/endIndex), not UTF-8 byte offsets, despite the names.
|
|
22
24
|
CREATE TABLE IF NOT EXISTS nodes (
|
|
23
25
|
id INTEGER PRIMARY KEY,
|
|
24
26
|
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
|
@@ -36,9 +36,9 @@ function digest(parts) {
|
|
|
36
36
|
/**
|
|
37
37
|
* Hash of the exact source bytes for a symbol range (detects cosmetic edits).
|
|
38
38
|
*
|
|
39
|
-
* @param source - Full file source
|
|
40
|
-
* @param startByte - Inclusive start offset.
|
|
41
|
-
* @param endByte - Exclusive end offset.
|
|
39
|
+
* @param source - Full file source, decoded from UTF-8.
|
|
40
|
+
* @param startByte - Inclusive start offset: a UTF-16 code-unit index into `source`, despite the name.
|
|
41
|
+
* @param endByte - Exclusive end offset: a UTF-16 code-unit index into `source`, despite the name.
|
|
42
42
|
*/
|
|
43
43
|
export function rawHash(source, startByte, endByte) {
|
|
44
44
|
return createHash("sha256").update(source.slice(startByte, endByte)).digest("hex").slice(0, 32);
|
|
@@ -186,6 +186,13 @@ function* walkFiles(root) {
|
|
|
186
186
|
* and an embedding cache keyed by embedder-input hash so renames/moves do not
|
|
187
187
|
* recompute vectors. The whole run executes in a single transaction.
|
|
188
188
|
*
|
|
189
|
+
* A run without force, prune, or an explicit `maxCacheMB` that re-extracts and
|
|
190
|
+
* removes nothing and finds the root hash unchanged is a no-op: it skips the
|
|
191
|
+
* dir-hash rewrite, edge/import resolution, PageRank, and the embedding-cache
|
|
192
|
+
* touch and eviction, writes only `meta.indexed_at`, and rewrites
|
|
193
|
+
* `docs/compass.md` only when its map block is empty. It still returns full
|
|
194
|
+
* statistics (`totals`, `nextStep`).
|
|
195
|
+
*
|
|
189
196
|
* @param projectPath - Absolute path to the project root.
|
|
190
197
|
* @param onProgressOrOpts - Progress callback (legacy) or {@link BuildIndexOptions}.
|
|
191
198
|
*/
|
|
@@ -251,6 +258,8 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
|
|
|
251
258
|
signature = excluded.signature,
|
|
252
259
|
doc = excluded.doc`);
|
|
253
260
|
const allFiles = [...walkFiles(projectPath)];
|
|
261
|
+
// Set inside the transaction; read after it to decide whether the map is written.
|
|
262
|
+
let noop;
|
|
254
263
|
db.exec("BEGIN");
|
|
255
264
|
try {
|
|
256
265
|
let done = 0;
|
|
@@ -369,37 +378,49 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
|
|
|
369
378
|
const dirMap = buildDirHashMap(fileHashes);
|
|
370
379
|
const rootHash = dirMap.get("") ?? "";
|
|
371
380
|
stats.rootUnchanged = Boolean(prevRoot && prevRoot.hash === rootHash && !force && stats.files === 0);
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
db
|
|
397
|
-
|
|
398
|
-
|
|
381
|
+
// Covers: req~index-noop-fast-path~1
|
|
382
|
+
// No-op fast path: nothing re-extracted or removed and the same root means
|
|
383
|
+
// dir hashes, edges, PageRank, and the cache are already what a full pass
|
|
384
|
+
// would write — skip them so a run on every session start stays cheap.
|
|
385
|
+
// `rootUnchanged` already implies no force (explicit or needs_reindex); an
|
|
386
|
+
// explicit prune or cache cap is a maintenance request and runs in full.
|
|
387
|
+
noop = stats.rootUnchanged && !prune && opts.maxCacheMB === undefined && stats.removed === 0;
|
|
388
|
+
if (!noop) {
|
|
389
|
+
const now = Date.now();
|
|
390
|
+
db.prepare("DELETE FROM dir_hashes").run();
|
|
391
|
+
const insDir = db.prepare("INSERT INTO dir_hashes(path, hash, n_files, updated_at) VALUES (?, ?, ?, ?)");
|
|
392
|
+
for (const [dir, hash] of dirMap) {
|
|
393
|
+
const nFiles = [...fileHashes.keys()].filter((f) => dir === "" ? true : f === dir || f.startsWith(dir + "/")).length;
|
|
394
|
+
insDir.run(dir, hash, nFiles, now);
|
|
395
|
+
}
|
|
396
|
+
db.exec(`
|
|
397
|
+
UPDATE edges SET dst_node_id = (
|
|
398
|
+
SELECT n.id FROM nodes n
|
|
399
|
+
WHERE n.name = edges.dst_name
|
|
400
|
+
ORDER BY CASE WHEN n.file_id = edges.src_file_id THEN 0 ELSE 1 END, n.id
|
|
401
|
+
LIMIT 1
|
|
402
|
+
)
|
|
403
|
+
WHERE kind = 'call' AND dst_node_id IS NULL
|
|
404
|
+
`);
|
|
405
|
+
resolveImportEdges(db, projectPath);
|
|
406
|
+
recomputeGlobalPagerank(db);
|
|
407
|
+
// Touch last_seen for all live content hashes under active model
|
|
408
|
+
db.prepare(`UPDATE embedding_cache SET last_seen_at = ?
|
|
409
|
+
WHERE model = ?
|
|
410
|
+
AND content_hash IN (SELECT content_hash FROM nodes WHERE content_hash IS NOT NULL)`).run(now, embedder.id);
|
|
411
|
+
if (prune) {
|
|
412
|
+
const cutoff = now - retentionDays * 24 * 60 * 60 * 1000;
|
|
413
|
+
db.prepare(`DELETE FROM embedding_cache
|
|
414
|
+
WHERE last_seen_at < ?
|
|
415
|
+
AND content_hash NOT IN (SELECT content_hash FROM nodes WHERE content_hash IS NOT NULL)`).run(cutoff);
|
|
416
|
+
}
|
|
417
|
+
evictCacheBySize(db, maxCacheMB);
|
|
399
418
|
}
|
|
400
|
-
|
|
419
|
+
// Written on every run, no-op included, so doctor's freshness check sees a
|
|
420
|
+
// verified-current index as fresh.
|
|
401
421
|
db.prepare("INSERT INTO meta(key, value) VALUES ('indexed_at', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(new Date().toISOString());
|
|
402
|
-
|
|
422
|
+
if (!noop)
|
|
423
|
+
clearNeedsReindex(db);
|
|
403
424
|
stats.totals = countTotals(db);
|
|
404
425
|
stats.nextStep = indexNextStep(stats.totals);
|
|
405
426
|
db.exec("COMMIT");
|
|
@@ -412,8 +433,11 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
|
|
|
412
433
|
db.close();
|
|
413
434
|
}
|
|
414
435
|
try {
|
|
415
|
-
const { writeCompactMap } = await import("./map.js");
|
|
416
|
-
|
|
436
|
+
const { writeCompactMap, compactMapPending } = await import("./map.js");
|
|
437
|
+
// A no-op run leaves the tracked docs/compass.md alone (no working-tree
|
|
438
|
+
// churn) unless its map block is still empty.
|
|
439
|
+
if (!noop || compactMapPending(projectPath))
|
|
440
|
+
writeCompactMap(projectPath);
|
|
417
441
|
}
|
|
418
442
|
catch {
|
|
419
443
|
// Map generation must never fail an index run.
|
|
@@ -82,6 +82,30 @@ export function generateCompactMap(projectPath) {
|
|
|
82
82
|
db.close();
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* True when `docs/compass.md` holds both map markers with nothing but
|
|
87
|
+
* whitespace between them — a freshly scaffolded or reset map block that a
|
|
88
|
+
* no-op index run must still fill. False when the file or a marker is missing
|
|
89
|
+
* (there is nothing a map write could fill) or the block already has a map.
|
|
90
|
+
*
|
|
91
|
+
* @param projectPath - Project root.
|
|
92
|
+
* @returns Whether the compact map block is present but empty.
|
|
93
|
+
*/
|
|
94
|
+
export function compactMapPending(projectPath) {
|
|
95
|
+
const compassPath = path.join(projectPath, "docs", "compass.md");
|
|
96
|
+
let text;
|
|
97
|
+
try {
|
|
98
|
+
text = fs.readFileSync(compassPath, "utf8");
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
const start = text.indexOf(MAP_START);
|
|
104
|
+
const end = text.indexOf(MAP_END);
|
|
105
|
+
if (start < 0 || end < 0 || end < start)
|
|
106
|
+
return false;
|
|
107
|
+
return text.slice(start + MAP_START.length, end).trim() === "";
|
|
108
|
+
}
|
|
85
109
|
/**
|
|
86
110
|
* Replace content between map markers in `docs/compass.md`. Preserves content
|
|
87
111
|
* outside markers. Does nothing (with reason) when markers are missing or the
|
|
@@ -43,10 +43,25 @@ export function search(projectPath, query, limit = 25) {
|
|
|
43
43
|
db.close();
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Read a symbol's source text from disk.
|
|
48
|
+
*
|
|
49
|
+
* @remarks The offsets are UTF-16 code-unit indices into the decoded file text
|
|
50
|
+
* (tree-sitter parses the `utf8`-decoded string), not byte offsets — so the file
|
|
51
|
+
* is decoded before slicing. A byte slice drifts on any preceding non-ASCII text.
|
|
52
|
+
*
|
|
53
|
+
* @param projectPath - Absolute project root.
|
|
54
|
+
* @param file - Repo-relative file path.
|
|
55
|
+
* @param startIndex - Inclusive start offset (`nodes.start_byte`).
|
|
56
|
+
* @param endIndex - Exclusive end offset (`nodes.end_byte`).
|
|
57
|
+
* @returns The symbol's source, or `""` when the file cannot be read.
|
|
58
|
+
*
|
|
59
|
+
* // Covers: req~explore-exact-source~1
|
|
60
|
+
*/
|
|
61
|
+
function readSource(projectPath, file, startIndex, endIndex) {
|
|
47
62
|
try {
|
|
48
|
-
const
|
|
49
|
-
return
|
|
63
|
+
const text = fs.readFileSync(path.join(projectPath, file), "utf8");
|
|
64
|
+
return text.slice(startIndex, endIndex);
|
|
50
65
|
}
|
|
51
66
|
catch {
|
|
52
67
|
return "";
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
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
|
+
import { buildStatusSummary } from "./status.js";
|
|
14
15
|
// Harness types live in the leaf `types.ts` so `compass-gate.ts` can use them
|
|
15
16
|
// without importing this file back (no file-level import cycle).
|
|
16
17
|
export { HARNESS_STAGES, } from "./types.js";
|
|
@@ -112,7 +113,8 @@ function allowedAdvance(from, level, verdict) {
|
|
|
112
113
|
}
|
|
113
114
|
}
|
|
114
115
|
/**
|
|
115
|
-
* Dispatch a harness op for a change.
|
|
116
|
+
* Dispatch a harness op for a change. `status` also returns the status
|
|
117
|
+
* summary (`req~cortex-status-summary~1`); other ops return the state.
|
|
116
118
|
*
|
|
117
119
|
* // Covers: req~harness-state~1
|
|
118
120
|
*/
|
|
@@ -120,7 +122,8 @@ export function handleHarness(args) {
|
|
|
120
122
|
const { projectPath, change, harnessOp } = args;
|
|
121
123
|
requireChangeDir(projectPath, change);
|
|
122
124
|
if (harnessOp === "status") {
|
|
123
|
-
|
|
125
|
+
const state = readHarness(projectPath, change);
|
|
126
|
+
return { summary: state ? buildStatusSummary(projectPath, state) : null, state };
|
|
124
127
|
}
|
|
125
128
|
if (harnessOp === "start") {
|
|
126
129
|
const existing = readHarness(projectPath, change);
|
|
@@ -8,6 +8,7 @@ import { defineTool, text } from "../../shared/mcp.js";
|
|
|
8
8
|
import { shouldExpose } from "../../shared/exposure.js";
|
|
9
9
|
import { handleHarness, harnessOps, readHarness, } from "./harness.js";
|
|
10
10
|
import { briefForStage } from "./brief.js";
|
|
11
|
+
import { fitStatusResult } from "./status.js";
|
|
11
12
|
export const cortexActions = [...harnessOps, "brief"];
|
|
12
13
|
/**
|
|
13
14
|
* Register the canonical `cortex` MCP tool.
|
|
@@ -22,7 +23,7 @@ export function registerCortex(server, opts = {}) {
|
|
|
22
23
|
return;
|
|
23
24
|
defineTool(server, { name, description, inputSchema, handler });
|
|
24
25
|
};
|
|
25
|
-
add("cortex", "Cortex multi-agent loop: status, start, advance, rework, brief for a change.", {
|
|
26
|
+
add("cortex", "Cortex multi-agent loop: status with summary, start, advance, rework, brief for a change.", {
|
|
26
27
|
projectPath: z.string(),
|
|
27
28
|
change: z.string(),
|
|
28
29
|
action: z.enum(cortexActions),
|
|
@@ -36,7 +37,7 @@ export function registerCortex(server, opts = {}) {
|
|
|
36
37
|
const brief = briefForStage(state?.stage ?? null);
|
|
37
38
|
return text(JSON.stringify({ state, ...brief }, null, 2));
|
|
38
39
|
}
|
|
39
|
-
|
|
40
|
+
const result = handleHarness({
|
|
40
41
|
projectPath,
|
|
41
42
|
change,
|
|
42
43
|
harnessOp: action,
|
|
@@ -44,6 +45,8 @@ export function registerCortex(server, opts = {}) {
|
|
|
44
45
|
verdict: verdict ?? null,
|
|
45
46
|
openQuestions,
|
|
46
47
|
pauseForQuestions,
|
|
47
|
-
})
|
|
48
|
+
});
|
|
49
|
+
// `text()` cuts from the end; fit `status` so `summary` and valid JSON survive.
|
|
50
|
+
return text(action === "status" && "summary" in result ? fitStatusResult(result) : result);
|
|
48
51
|
});
|
|
49
52
|
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-change status summary for Cortex `status`: stage, active role, time in
|
|
3
|
+
* stage, task progress, rework, pending verdicts, and the configured interval
|
|
4
|
+
* at which the coordinator posts status updates.
|
|
5
|
+
*
|
|
6
|
+
* Cortex must not import from the lawbook module, so task checkboxes and the
|
|
7
|
+
* `cortex.statusIntervalMinutes` key are read with local text scans, like
|
|
8
|
+
* `readCompassGateMode`.
|
|
9
|
+
*/
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { briefForStage } from "./brief.js";
|
|
13
|
+
import { stageStartedAt } from "./compass-gate.js";
|
|
14
|
+
import { OUTPUT_BUDGET, estimateTokens } from "../../shared/output-budget.js";
|
|
15
|
+
/** Interval used when the key is missing or invalid. */
|
|
16
|
+
export const DEFAULT_STATUS_INTERVAL_MINUTES = 5;
|
|
17
|
+
/**
|
|
18
|
+
* Largest interval reported. A session timer is a cron expression, and
|
|
19
|
+
* an every-N-minutes step only exists for N ≤ 59; 60 is the hourly ping.
|
|
20
|
+
*/
|
|
21
|
+
export const MAX_STATUS_INTERVAL_MINUTES = 60;
|
|
22
|
+
const CHECKBOX = /^\s*[-*]\s+\[( |x|X)\]/;
|
|
23
|
+
/**
|
|
24
|
+
* Count Markdown task-list items (`- [ ]`, `- [x]`, `* [X]`). Brackets outside
|
|
25
|
+
* a list item are ignored. This is a line scan, not a Markdown parser: items
|
|
26
|
+
* inside fenced code blocks are counted, and `+` bullets and numbered
|
|
27
|
+
* (`1. [ ]`) items are not.
|
|
28
|
+
*
|
|
29
|
+
* @param text - Markdown source.
|
|
30
|
+
* @returns Checked and total item counts.
|
|
31
|
+
*/
|
|
32
|
+
export function countTaskCheckboxes(text) {
|
|
33
|
+
let done = 0;
|
|
34
|
+
let total = 0;
|
|
35
|
+
for (const lineText of text.split(/\r?\n/)) {
|
|
36
|
+
const m = CHECKBOX.exec(lineText);
|
|
37
|
+
if (!m)
|
|
38
|
+
continue;
|
|
39
|
+
total++;
|
|
40
|
+
if (m[1] !== " ")
|
|
41
|
+
done++;
|
|
42
|
+
}
|
|
43
|
+
return { done, total };
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Read `statusIntervalMinutes` from the top-level `cortex:` block of
|
|
47
|
+
* `lawbook/config.yaml`. Quotes and a trailing comment are allowed. A missing
|
|
48
|
+
* file, block, or key, or any value that is not a whole number ≥ 0, yields 5.
|
|
49
|
+
* Values above 60 are clamped to 60. Never throws.
|
|
50
|
+
*
|
|
51
|
+
* This is a line scan, not a YAML parser: the key is accepted at any
|
|
52
|
+
* indentation under `cortex:` (nested maps included), and flow style
|
|
53
|
+
* (`cortex: {statusIntervalMinutes: 3}`) is not read, so it yields 5.
|
|
54
|
+
*
|
|
55
|
+
* @param projectPath - Project root.
|
|
56
|
+
*
|
|
57
|
+
* // Covers: req~cortex-status-interval~1
|
|
58
|
+
*/
|
|
59
|
+
export function readStatusIntervalMinutes(projectPath) {
|
|
60
|
+
let raw;
|
|
61
|
+
try {
|
|
62
|
+
raw = fs.readFileSync(path.join(projectPath, "lawbook", "config.yaml"), "utf8");
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return DEFAULT_STATUS_INTERVAL_MINUTES;
|
|
66
|
+
}
|
|
67
|
+
const lines = raw.split(/\r?\n/);
|
|
68
|
+
const start = lines.findIndex((l) => /^cortex:[ \t]*(?:#.*)?$/.test(l));
|
|
69
|
+
if (start < 0)
|
|
70
|
+
return DEFAULT_STATUS_INTERVAL_MINUTES;
|
|
71
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
72
|
+
const l = lines[i];
|
|
73
|
+
// The block ends at the next column-0 key; blank and comment lines are skipped.
|
|
74
|
+
if (/^\S/.test(l) && !l.startsWith("#"))
|
|
75
|
+
break;
|
|
76
|
+
const m = /^\s+statusIntervalMinutes:\s*(\S+)/.exec(l);
|
|
77
|
+
if (!m)
|
|
78
|
+
continue;
|
|
79
|
+
const value = m[1].replace(/#.*$/, "").replace(/^(["'])(.*)\1$/, "$2");
|
|
80
|
+
if (!/^\d+$/.test(value))
|
|
81
|
+
return DEFAULT_STATUS_INTERVAL_MINUTES;
|
|
82
|
+
return Math.min(Number(value), MAX_STATUS_INTERVAL_MINUTES);
|
|
83
|
+
}
|
|
84
|
+
return DEFAULT_STATUS_INTERVAL_MINUTES;
|
|
85
|
+
}
|
|
86
|
+
function readTaskCounts(projectPath, change) {
|
|
87
|
+
const dir = path.join(projectPath, "lawbook", "changes", change);
|
|
88
|
+
for (const file of ["tasks.md", "record.md"]) {
|
|
89
|
+
let text;
|
|
90
|
+
try {
|
|
91
|
+
text = fs.readFileSync(path.join(dir, file), "utf8");
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
return countTaskCheckboxes(text);
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
function elapsedSince(startedAt, now) {
|
|
101
|
+
if (!startedAt)
|
|
102
|
+
return null;
|
|
103
|
+
const t = Date.parse(startedAt);
|
|
104
|
+
if (Number.isNaN(t))
|
|
105
|
+
return null;
|
|
106
|
+
return Math.max(0, Math.floor((now.getTime() - t) / 60000));
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Build the status summary of a running change from its harness state, its
|
|
110
|
+
* task list, and the configured status interval.
|
|
111
|
+
*
|
|
112
|
+
* @param projectPath - Project root.
|
|
113
|
+
* @param state - Harness state of the change.
|
|
114
|
+
* @param now - Clock for `elapsedMinutes`; injectable for tests.
|
|
115
|
+
* @returns The structured summary and its one-line English rendering.
|
|
116
|
+
*
|
|
117
|
+
* // Covers: req~cortex-status-summary~1
|
|
118
|
+
*/
|
|
119
|
+
export function buildStatusSummary(projectPath, state, now = new Date()) {
|
|
120
|
+
const role = briefForStage(state.stage).role;
|
|
121
|
+
const startedAt = stageStartedAt(state);
|
|
122
|
+
const elapsedMinutes = elapsedSince(startedAt, now);
|
|
123
|
+
const tasks = readTaskCounts(projectPath, state.change);
|
|
124
|
+
const pendingVerdicts = [];
|
|
125
|
+
if (state.level >= 1 && state.verdicts.review !== "PASS")
|
|
126
|
+
pendingVerdicts.push("review");
|
|
127
|
+
if (state.verdicts.test !== "PASS")
|
|
128
|
+
pendingVerdicts.push("test");
|
|
129
|
+
const openQuestions = state.openQuestions.length;
|
|
130
|
+
const parts = [
|
|
131
|
+
state.change,
|
|
132
|
+
role ? `${state.stage} (${role})` : state.stage,
|
|
133
|
+
elapsedMinutes === null ? "elapsed n/a" : `${elapsedMinutes}m in stage`,
|
|
134
|
+
tasks ? `tasks ${tasks.done}/${tasks.total}` : "tasks n/a",
|
|
135
|
+
`rework ${state.iteration}/${state.maxRework}`,
|
|
136
|
+
`pending: ${pendingVerdicts.length ? pendingVerdicts.join(", ") : "none"}`,
|
|
137
|
+
];
|
|
138
|
+
if (state.stage === "questions") {
|
|
139
|
+
parts.push(`waiting on human: ${openQuestions} question(s)`);
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
change: state.change,
|
|
143
|
+
stage: state.stage,
|
|
144
|
+
role,
|
|
145
|
+
stageStartedAt: startedAt,
|
|
146
|
+
elapsedMinutes,
|
|
147
|
+
tasks,
|
|
148
|
+
iteration: state.iteration,
|
|
149
|
+
maxRework: state.maxRework,
|
|
150
|
+
pendingVerdicts,
|
|
151
|
+
openQuestions,
|
|
152
|
+
statusIntervalMinutes: readStatusIntervalMinutes(projectPath),
|
|
153
|
+
line: parts.join(" · "),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Fit a `status` result into the brief MCP output budget without ever cutting
|
|
158
|
+
* `summary`. `text()` truncates a payload from the end, which would yield
|
|
159
|
+
* invalid JSON and lose whatever comes last; this keeps the payload under the
|
|
160
|
+
* budget instead, by dropping the oldest `state.history` entries (newest
|
|
161
|
+
* kept) and recording how many were dropped in `historyOmitted`. If an empty
|
|
162
|
+
* history still does not fit, `state` becomes `null` with `stateOmitted`.
|
|
163
|
+
* Only the MCP rendering is trimmed: `harness.json` and the CLI keep the full
|
|
164
|
+
* history.
|
|
165
|
+
*
|
|
166
|
+
* @param result - The `handleHarness` `status` result.
|
|
167
|
+
* @param maxTokens - Token ceiling; defaults to the brief output budget.
|
|
168
|
+
* @returns A result whose pretty-printed JSON fits `maxTokens`.
|
|
169
|
+
*
|
|
170
|
+
* // Covers: req~cortex-status-summary~1
|
|
171
|
+
*/
|
|
172
|
+
export function fitStatusResult(result, maxTokens = OUTPUT_BUDGET.brief) {
|
|
173
|
+
const fits = (value) => estimateTokens(JSON.stringify(value, null, 2)) <= maxTokens;
|
|
174
|
+
const { summary, state } = result;
|
|
175
|
+
const full = { summary, state };
|
|
176
|
+
if (!state || fits(full))
|
|
177
|
+
return full;
|
|
178
|
+
const history = state.history;
|
|
179
|
+
const keepLast = (k) => ({
|
|
180
|
+
summary,
|
|
181
|
+
historyOmitted: history.length - k,
|
|
182
|
+
state: { ...state, history: k > 0 ? history.slice(-k) : [] },
|
|
183
|
+
});
|
|
184
|
+
// Fitting is monotone in the number of kept entries: binary-search the most.
|
|
185
|
+
let lo = 0;
|
|
186
|
+
let hi = history.length - 1;
|
|
187
|
+
if (!fits(keepLast(0)))
|
|
188
|
+
return { summary, stateOmitted: true, state: null };
|
|
189
|
+
while (lo < hi) {
|
|
190
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
191
|
+
if (fits(keepLast(mid)))
|
|
192
|
+
lo = mid;
|
|
193
|
+
else
|
|
194
|
+
hi = mid - 1;
|
|
195
|
+
}
|
|
196
|
+
return keepLast(lo);
|
|
197
|
+
}
|
|
@@ -20,7 +20,8 @@ Claude-specific notes: [`CLAUDE.md`](CLAUDE.md). The law: [`LAWS.md`](LAWS.md).
|
|
|
20
20
|
`compass_explore` **before** any grep/sed/cat/Read,
|
|
21
21
|
including files you already know by name. Fall back to manual file tools only
|
|
22
22
|
after Compass returns nothing useful, the graph is missing (`compass_index`
|
|
23
|
-
first
|
|
23
|
+
first; Claude Code sessions refresh an existing index at start), or the
|
|
24
|
+
target isn't indexed code (stylesheets, config, logs). Cheat
|
|
24
25
|
sheet: [`docs/compass.md`](docs/compass.md).
|
|
25
26
|
3. **Follow Cortex** (*One brain. Many agents.*) for every non-trivial change;
|
|
26
27
|
archive within the same PR. The primary agent is the **coordinator**
|
|
@@ -35,7 +35,8 @@ where it lives, how a value flows — call Compass **first**: `compass_find` to
|
|
|
35
35
|
locate, `compass_explore` to read a symbol with its callers and callees (include
|
|
36
36
|
`blast_radius` for what could break, `to` for a call path), and
|
|
37
37
|
`compass_diff_context` for the context of a diff.
|
|
38
|
-
Run `compass_index` first if the graph is missing.
|
|
38
|
+
Run `compass_index` first if the graph is missing. Under Claude Code a
|
|
39
|
+
`SessionStart` hook refreshes an existing index when each session starts.
|
|
39
40
|
|
|
40
41
|
This includes files you already know the name of: to learn what `Foo` imports,
|
|
41
42
|
uses, or depends on, run `compass_explore Foo` — do **not** `cat`/`sed`/`grep`/
|
|
@@ -37,6 +37,12 @@ a request, not whole files.
|
|
|
37
37
|
`compass_index` also takes `action: start|stop|status` to keep the index fresh
|
|
38
38
|
automatically (a debounced incremental re-index on file change).
|
|
39
39
|
|
|
40
|
+
Under Claude Code, a `SessionStart` hook refreshes an existing index when each
|
|
41
|
+
session starts (`speclaw session-start`: silent, never fails, skips
|
|
42
|
+
when `.speclaw/index.db` is absent, and leaves this file alone when nothing
|
|
43
|
+
changed). The first build is always an explicit `compass_index` /
|
|
44
|
+
`speclaw index`.
|
|
45
|
+
|
|
40
46
|
If the graph is missing (no `.speclaw/index.db`), run `compass_index` first —
|
|
41
47
|
a missing graph is not license to skip Compass. The only legitimate fallbacks
|
|
42
48
|
to Grep/Read: a Compass call returned nothing useful for your query, or the
|
|
@@ -3,6 +3,39 @@ import path from "node:path";
|
|
|
3
3
|
import { agentById } from "../../shared/agents.js";
|
|
4
4
|
import { sha256 } from "../../shared/install.js";
|
|
5
5
|
import { globError, hasBackend } from "./laws.js";
|
|
6
|
+
/**
|
|
7
|
+
* Substring every speclaw session-start command contains, in each of its
|
|
8
|
+
* resolution branches. It is the merge identity of the `SessionStart` hook.
|
|
9
|
+
*/
|
|
10
|
+
export const SESSION_START_MARKER = "speclaw session-start";
|
|
11
|
+
/**
|
|
12
|
+
* The marker of the unreleased `index --session-start` hook shape that
|
|
13
|
+
* pre-release 2.0.7 builds wrote. Still recognized so a merge replaces such an
|
|
14
|
+
* entry instead of keeping it next to the current one.
|
|
15
|
+
*/
|
|
16
|
+
const LEGACY_SESSION_START_MARKER = "speclaw index --session-start";
|
|
17
|
+
/**
|
|
18
|
+
* The POSIX `sh` command the `SessionStart` hook runs. The `index.db` guard runs
|
|
19
|
+
* in the shell, so a project without an index pays no Node start-up. It invokes
|
|
20
|
+
* the top-level `session-start` command, which a speclaw older than 2.0.7
|
|
21
|
+
* rejects as unknown (exit 1) before indexing, logging, or notifying, so a stale
|
|
22
|
+
* binary first in the resolution order does nothing. speclaw resolves as local
|
|
23
|
+
* `node_modules/.bin`, then `PATH`, then `npx --no-install` with
|
|
24
|
+
* `npm_config_offline=true` and `npm_config_update_notifier=false`, so npx
|
|
25
|
+
* neither installs nor contacts the registry (offline alone still lets npm's
|
|
26
|
+
* own update notifier query it).
|
|
27
|
+
* Output is discarded because `SessionStart` stdout enters the agent's context,
|
|
28
|
+
* and `|| true` keeps the exit code 0 in every case.
|
|
29
|
+
*/
|
|
30
|
+
export const SESSION_START_COMMAND = 'cd "${CLAUDE_PROJECT_DIR:-.}" 2>/dev/null && [ -f .speclaw/index.db ] && { ' +
|
|
31
|
+
"if [ -x node_modules/.bin/speclaw ]; then node_modules/.bin/speclaw session-start; " +
|
|
32
|
+
"elif command -v speclaw >/dev/null 2>&1; then speclaw session-start; " +
|
|
33
|
+
"else npm_config_update_notifier=false npm_config_offline=true npx --no-install @esneiderbravo/speclaw session-start; fi; " +
|
|
34
|
+
"} >/dev/null 2>&1 || true";
|
|
35
|
+
/** `SessionStart` matcher covering every session source Claude Code reports. */
|
|
36
|
+
const SESSION_START_MATCHER = "startup|resume|clear|compact";
|
|
37
|
+
/** Seconds the blocking session-start hook may run (index wait plus a real refresh). */
|
|
38
|
+
const SESSION_START_TIMEOUT = 30;
|
|
6
39
|
/** Claude Code `${path}` templates — see https://code.claude.com/docs/en/hooks */
|
|
7
40
|
const SPECLAW_HOOK_INPUT = {
|
|
8
41
|
projectPath: "${cwd}",
|
|
@@ -36,17 +69,32 @@ const MUTATION_MATCHER = "Write|Edit|MultiEdit|NotebookEdit";
|
|
|
36
69
|
* and the event cannot gate the tool; a `PreToolUse` "allow" would auto-approve.
|
|
37
70
|
*/
|
|
38
71
|
export const NUDGE_MATCHER = "Read|Grep|Glob";
|
|
39
|
-
/**
|
|
40
|
-
|
|
72
|
+
/**
|
|
73
|
+
* True when a hook object is one speclaw owns (safe to replace on merge): an
|
|
74
|
+
* `mcp_tool` hook on the `speclaw` server, or a `command` hook whose command
|
|
75
|
+
* contains {@link SESSION_START_MARKER} (or the pre-release
|
|
76
|
+
* `index --session-start` marker). A user `command` hook without the
|
|
77
|
+
* marker is never speclaw's.
|
|
78
|
+
*
|
|
79
|
+
* @param h - A hook object read from an agent's settings (any shape).
|
|
80
|
+
* @returns Whether speclaw owns the hook.
|
|
81
|
+
*/
|
|
82
|
+
export function isSpeclawHook(h) {
|
|
41
83
|
const o = h;
|
|
42
|
-
|
|
84
|
+
if (o?.type === "mcp_tool" && o?.server === "speclaw")
|
|
85
|
+
return true;
|
|
86
|
+
return (o?.type === "command" &&
|
|
87
|
+
typeof o?.command === "string" &&
|
|
88
|
+
(o.command.includes(SESSION_START_MARKER) || o.command.includes(LEGACY_SESSION_START_MARKER)));
|
|
43
89
|
}
|
|
44
90
|
/**
|
|
45
91
|
* Compile a law manifest into the hook groups speclaw contributes, one per event
|
|
46
92
|
* the laws demand: `PreToolUse` when any `bloqueo` law exists, `PostToolUse` for
|
|
47
93
|
* `feedback`, `Stop` for `gate`, and `InstructionsLoaded` whenever any law exists
|
|
48
|
-
* (the context-coverage audit).
|
|
49
|
-
*
|
|
94
|
+
* (the context-coverage audit). Two groups are always emitted, even with no
|
|
95
|
+
* laws: one `PostToolUse` group matching `Read|Grep|Glob` for the Compass-first
|
|
96
|
+
* nudge, and one `SessionStart` group whose `command` hook refreshes the index
|
|
97
|
+
* silently when a session starts. A law whose scope contains a malformed glob is
|
|
50
98
|
* excluded and reported, so a bad pattern fails loudly at generation rather than
|
|
51
99
|
* silently matching nothing at runtime.
|
|
52
100
|
*
|
|
@@ -77,13 +125,20 @@ export function compileHooks(manifest) {
|
|
|
77
125
|
byEvent.Stop = [{ hooks: [{ ...SPECLAW_HOOK }] }];
|
|
78
126
|
if (valid.length > 0)
|
|
79
127
|
byEvent.InstructionsLoaded = [{ hooks: [{ ...SPECLAW_HOOK }] }];
|
|
128
|
+
// Covers: req~session-start-hook~1
|
|
129
|
+
byEvent.SessionStart = [
|
|
130
|
+
{
|
|
131
|
+
matcher: SESSION_START_MATCHER,
|
|
132
|
+
hooks: [{ type: "command", command: SESSION_START_COMMAND, timeout: SESSION_START_TIMEOUT }],
|
|
133
|
+
},
|
|
134
|
+
];
|
|
80
135
|
return { byEvent, invalid };
|
|
81
136
|
}
|
|
82
137
|
/**
|
|
83
138
|
* Merge speclaw's compiled hook groups into an existing `hooks` object by
|
|
84
139
|
* identity: for every event, drop the groups speclaw owns (a group whose hooks
|
|
85
140
|
* are all speclaw's) and re-add the freshly compiled ones, never touching a
|
|
86
|
-
*
|
|
141
|
+
* hook that is not speclaw's (see {@link isSpeclawHook}). Idempotent, and it
|
|
87
142
|
* cannot delete another tool's hooks.
|
|
88
143
|
*
|
|
89
144
|
* @param existing - The current `hooks` object from the agent's settings (any shape).
|
|
@@ -11,8 +11,9 @@ You are the **explorer** role in speclaw's **Cortex** (One brain. Many agents.).
|
|
|
11
11
|
|
|
12
12
|
Investigate the idea or bug with Compass **before** any grep/read: locate with
|
|
13
13
|
`compass_find`, read with `compass_explore`, and run `compass_index` only when
|
|
14
|
-
find returns nothing or reports a missing or stale index
|
|
15
|
-
|
|
14
|
+
find returns nothing or reports a missing or stale index (under Claude Code an
|
|
15
|
+
existing index is already refreshed when the session starts). Read/Grep code
|
|
16
|
+
only after naming which Rule 1 fallback holds. Produce a short brief for the
|
|
16
17
|
planner: relevant symbols, blast radius, risks, open questions, and
|
|
17
18
|
`Compass calls made: N`. Do **not** draft change artifacts or edit source.
|
|
18
19
|
|
|
@@ -9,3 +9,5 @@ explorer → planner → implementer → reviewer → tester → archiver. Drive
|
|
|
9
9
|
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
|
+
The run posts status updates every `cortex.statusIntervalMinutes` minutes
|
|
13
|
+
(`lawbook/config.yaml`, default 5, `0` disables, at most 60).
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
- Use `compass_explore` before editing to see a symbol's callers/callees and
|
|
4
4
|
blast radius; re-run `compass_index` after significant edits to keep the
|
|
5
|
-
graph fresh
|
|
5
|
+
graph fresh (the `SessionStart` hook refreshes it only when a session
|
|
6
|
+
starts).
|
|
6
7
|
- Make the smallest correct change; match the surrounding code.
|
|
7
8
|
- The code must satisfy the delta spec exactly. If reality diverges from the
|
|
8
9
|
spec, update the spec in the change (not silently) — the two must agree.
|
|
@@ -22,4 +22,31 @@ Stage → role:
|
|
|
22
22
|
| archiving | archiver (`sync` + `archive`) |
|
|
23
23
|
| done | stop — report success |
|
|
24
24
|
|
|
25
|
+
## Status updates
|
|
26
|
+
|
|
27
|
+
- Read `summary.statusIntervalMinutes` from the `status` result (after
|
|
28
|
+
`start`, run `status` once). `0` disables unsolicited updates; never parse
|
|
29
|
+
`lawbook/config.yaml` yourself.
|
|
30
|
+
- Create the timer only when the summary is non-null and the stage is not
|
|
31
|
+
`done`, the interval is greater than 0, and the host has a session timer
|
|
32
|
+
(Claude Code `CronCreate`, session-only, at least 1 minute). Then create
|
|
33
|
+
**one** recurring timer every `statusIntervalMinutes` minutes:
|
|
34
|
+
- schedule: for 1–59 minutes use `*/N * * * *`; for 60 (the engine caps the
|
|
35
|
+
interval at 60) use an hourly expression such as `7 * * * *`;
|
|
36
|
+
- prompt: the timer firing is a fresh prompt, so it must carry its context.
|
|
37
|
+
Name the change and the rules: run `cortex` action `status` for that
|
|
38
|
+
change and post the update in the session's language; skip the ping while
|
|
39
|
+
the stage is `questions`; when the stage is `done` or the run was stopped,
|
|
40
|
+
delete this timer (`CronDelete`) and post nothing.
|
|
41
|
+
- Remember the id that `CronCreate` returns; it is the run's status timer.
|
|
42
|
+
Before creating one, check that no status timer exists for this change
|
|
43
|
+
(your remembered id, or `CronList`), so there is exactly one per run.
|
|
44
|
+
`CronDelete` always targets that id.
|
|
45
|
+
- Create the timer only inside a Cortex run, never a second one per run, and
|
|
46
|
+
none when the interval is 0.
|
|
47
|
+
- Prefer background role dispatch: the timer fires only while the session is
|
|
48
|
+
idle, not during a blocking dispatch.
|
|
49
|
+
- On a host without a session timer, the after-op and before-dispatch updates
|
|
50
|
+
of the dispatch loop stand in for it.
|
|
51
|
+
|
|
25
52
|
Next: read `steps/02-dispatch-loop.md` and do only what it says.
|
|
@@ -34,5 +34,20 @@ For the current Cortex stage (`cortex` action `brief` or `status`):
|
|
|
34
34
|
4. Max rework is 3; if the engine rejects further rework, **ask the human**.
|
|
35
35
|
5. Coordinator never edits `src/`, never archives, never pushes, never runs
|
|
36
36
|
`laws accept`.
|
|
37
|
+
6. **Status updates.** Unless `summary.statusIntervalMinutes` is 0, post a
|
|
38
|
+
compact update after every Cortex op and just before every blocking role
|
|
39
|
+
dispatch, built from the `status` summary fields:
|
|
40
|
+
- the change, the stage and active role, the elapsed time in the stage;
|
|
41
|
+
- tasks done/total, rework iteration/max, and the pending verdicts;
|
|
42
|
+
- in `questions`, that the human owes answers.
|
|
43
|
+
|
|
44
|
+
Write it in the session's language (the language of the human's most
|
|
45
|
+
recent messages); keep stage, role, and tool names, file paths, and change
|
|
46
|
+
names in English. `summary.line` is English-only; use it as is only in an
|
|
47
|
+
English session. Skip timer pings while the stage is `questions` (the
|
|
48
|
+
question round already speaks to the human). When the human asks to stop
|
|
49
|
+
the updates, delete the timer (`CronDelete` with the id `CronCreate`
|
|
50
|
+
returned) and post no further unsolicited update for this run. An explicit
|
|
51
|
+
request for status is always answered.
|
|
37
52
|
|
|
38
53
|
Next: read `steps/03-complete.md` and do only what it says.
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Complete
|
|
2
2
|
|
|
3
|
-
When Cortex stage is `done` (or the human stops the run),
|
|
3
|
+
When Cortex stage is `done` (or the human stops the run), first delete the
|
|
4
|
+
status timer if one exists (`CronDelete` with the id `CronCreate` returned,
|
|
5
|
+
or the id `CronList` shows for this change), then summarize:
|
|
4
6
|
|
|
5
7
|
- Change name and final stage
|
|
6
8
|
- Review/test verdicts and iteration count
|
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
`compass_find` or `compass_explore` only for a gap the brief names. Re-read
|
|
7
7
|
a file under `docs/standards/` only when the brief does not cite it.
|
|
8
8
|
- When the handoff has no complete explorer brief, refresh the index with
|
|
9
|
-
`compass_index
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
`compass_index` (an existing index was already refreshed at session start
|
|
10
|
+
under Claude Code; refresh again after edits or when it is missing), then use
|
|
11
|
+
`compass_explore` and `compass_find` (mode: concept) before grep/read to
|
|
12
|
+
locate the code and its blast radius. Read the governing standards in
|
|
13
|
+
`docs/standards/`.
|
|
12
14
|
- Clarify what the user wants (feature / fix / refactor) and confirm scope.
|
|
13
15
|
- When `change.json` already has a `confirmedLevel` (the coordinator recorded
|
|
14
16
|
it in the single question round), use that level and skip the separate level
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
callees. Use `compass_diff_context` when the question is about a diff.
|
|
7
7
|
- **Index only when needed.** Run `compass_index` only when `compass_find`
|
|
8
8
|
returns nothing or reports a missing or stale index, then retry the find. It
|
|
9
|
-
is incremental (unchanged files skipped by hash).
|
|
9
|
+
is incremental (unchanged files skipped by hash). Under Claude Code the
|
|
10
|
+
`SessionStart` hook already refreshes an existing index when the session
|
|
11
|
+
starts, so it is usually current.
|
|
10
12
|
- **Read/Grep code only with a named reason.** Before any Read/Grep/Glob of
|
|
11
13
|
indexed source code, name which Rule 1 fallback holds: (1) a Compass call ran
|
|
12
14
|
and returned nothing useful for the query, (2) the graph is missing and
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Investigate the bug
|
|
2
2
|
|
|
3
|
-
- Refresh the index (`compass_index`).
|
|
3
|
+
- Refresh the index (`compass_index`). Under Claude Code the `SessionStart`
|
|
4
|
+
hook already refreshed an existing index when the session started; refresh
|
|
5
|
+
again when files changed since then or the index is missing.
|
|
4
6
|
- Call **`lawbook_investigate`** with `stackTrace` and/or `symptom`.
|
|
5
7
|
- **`compass_explore`** the top suspect — read the code yourself.
|
|
6
8
|
|
|
@@ -6,6 +6,7 @@ import { scaffoldFeature } from "./scaffold-change.js";
|
|
|
6
6
|
import { buildCoverageReport, loadCoverageConfig, renderCoverageAgent } from "./coverage.js";
|
|
7
7
|
import { buildDriftReport, renderDriftAgent } from "./drift.js";
|
|
8
8
|
import { handleHarness, harnessOps } from "../cortex/harness.js";
|
|
9
|
+
import { fitStatusResult } from "../cortex/status.js";
|
|
9
10
|
export const lawbookChangeActions = [
|
|
10
11
|
"init",
|
|
11
12
|
"list",
|
|
@@ -111,7 +112,7 @@ export function handleLawbookChange(args) {
|
|
|
111
112
|
if (!args.harnessOp) {
|
|
112
113
|
throw new Error(`lawbook_change: action 'harness' requires 'harnessOp'`);
|
|
113
114
|
}
|
|
114
|
-
|
|
115
|
+
const result = handleHarness({
|
|
115
116
|
projectPath: args.projectPath,
|
|
116
117
|
change: requireField(args, "change"),
|
|
117
118
|
harnessOp: args.harnessOp,
|
|
@@ -120,6 +121,8 @@ export function handleLawbookChange(args) {
|
|
|
120
121
|
pauseForQuestions: args.pauseForQuestions,
|
|
121
122
|
note: args.note ?? args.reason,
|
|
122
123
|
});
|
|
124
|
+
// MCP-only path: fit `status` to the output budget like the `cortex` tool.
|
|
125
|
+
return args.harnessOp === "status" && "summary" in result ? fitStatusResult(result) : result;
|
|
123
126
|
}
|
|
124
127
|
default:
|
|
125
128
|
throw new Error(`lawbook_change: unknown action '${String(args.action)}'`);
|