@esneiderbravo/speclaw 2.0.3 → 2.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/check.js +11 -1
- package/dist/cli/commands/cortex.js +17 -0
- package/dist/cli/commands/index-build.js +13 -2
- package/dist/cli/commands/lawbook.js +31 -10
- package/dist/cli/commands/query.js +13 -0
- package/dist/cli/index.js +4 -1
- package/dist/modules/compass/indexer.js +14 -0
- package/dist/modules/compass/register.js +17 -1
- package/dist/modules/cortex/compass-gate.js +84 -0
- package/dist/modules/cortex/harness.js +20 -11
- package/dist/modules/cortex/types.js +14 -0
- package/dist/modules/foundation/assets/AGENTS.template.md +2 -2
- package/dist/modules/foundation/assets/CLAUDE.template.md +4 -3
- package/dist/modules/foundation/assets/docs/compass.template.md +7 -7
- package/dist/modules/foundation/assets/docs/standards/lawbook.template.md +2 -2
- package/dist/modules/foundation/check.js +23 -5
- package/dist/modules/foundation/compass-nudge.js +189 -0
- package/dist/modules/foundation/hooks.js +18 -3
- package/dist/modules/foundation/integrity.js +20 -7
- package/dist/modules/foundation/lock.js +24 -7
- package/dist/modules/lawbook/assets/agents/archiver.md +1 -1
- package/dist/modules/lawbook/assets/agents/explorer.md +7 -4
- package/dist/modules/lawbook/assets/agents/implementer.md +1 -1
- package/dist/modules/lawbook/assets/agents/planner.md +7 -4
- package/dist/modules/lawbook/assets/agents/reviewer.md +1 -1
- package/dist/modules/lawbook/assets/agents/tester.md +1 -1
- package/dist/modules/lawbook/assets/commands/draft.md +2 -2
- package/dist/modules/lawbook/assets/commands/quick.md +2 -2
- package/dist/modules/lawbook/assets/rules/spec-reports-disciplines.md +1 -1
- package/dist/modules/lawbook/assets/rules/spec-tasks-mandatory-steps.md +4 -4
- package/dist/modules/lawbook/assets/skills/archive/steps/02-reconcile.md +1 -1
- package/dist/modules/lawbook/assets/skills/build/steps/01-load-change.md +1 -1
- package/dist/modules/lawbook/assets/skills/cortex/steps/02-dispatch-loop.md +22 -6
- package/dist/modules/lawbook/assets/skills/draft/steps/01-ensure-workspace.md +1 -1
- package/dist/modules/lawbook/assets/skills/draft/steps/02-understand.md +7 -4
- package/dist/modules/lawbook/assets/skills/draft/steps/03-name-capabilities.md +3 -1
- package/dist/modules/lawbook/assets/skills/draft/steps/04-write-artifacts.md +12 -2
- package/dist/modules/lawbook/assets/skills/explore/steps/01-investigate.md +15 -6
- package/dist/modules/lawbook/assets/skills/explore/steps/02-summarize.md +2 -0
- package/dist/modules/lawbook/assets/skills/investigate/steps/02-hand-off.md +1 -1
- package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +1 -1
- package/dist/modules/lawbook/assets/skills/sync/steps/01-confirm.md +1 -1
- package/dist/modules/lawbook/assets/skills/sync/steps/02-reconcile.md +1 -1
- package/dist/modules/lawbook/bugfix.js +22 -26
- package/dist/modules/lawbook/change-tool.js +15 -0
- package/dist/modules/lawbook/engine.js +11 -0
- package/dist/modules/lawbook/levels.js +2 -0
- package/dist/modules/lawbook/quick.js +14 -23
- package/dist/modules/lawbook/register.js +1 -1
- package/dist/modules/lawbook/scaffold-change.js +238 -0
- package/dist/shared/compass-calls.js +197 -0
- package/package.json +1 -1
|
@@ -14,7 +14,9 @@ async function readStdin() {
|
|
|
14
14
|
*
|
|
15
15
|
* - `--hook-payload -` reads a hook event JSON from stdin, evaluates it, prints
|
|
16
16
|
* the `hookSpecificOutput` contract, and exits 2 on `deny` (the documented
|
|
17
|
-
* command-hook block signal).
|
|
17
|
+
* command-hook block signal). `PostToolUse` never carries a
|
|
18
|
+
* `permissionDecision`: it prints `additionalContext` when the result has a
|
|
19
|
+
* reason (nothing otherwise) and always exits 0.
|
|
18
20
|
* - `--dry-run [--path P] [--event E]` previews the verdict for a synthetic
|
|
19
21
|
* action against path `P`, without blocking anything (always exits 0).
|
|
20
22
|
* - with no flags, prints a summary of the project's declared laws.
|
|
@@ -37,6 +39,14 @@ export async function runCheck(flags) {
|
|
|
37
39
|
const event = (payload.hook_event_name ?? payload.event ?? "PreToolUse");
|
|
38
40
|
const toolName = (payload.tool_name ?? payload.toolName);
|
|
39
41
|
const result = checkAction({ projectPath: cwd, event, toolName, payload });
|
|
42
|
+
if (event === "PostToolUse") {
|
|
43
|
+
// PostToolUse (feedback laws, the Compass-first nudge) carries context
|
|
44
|
+
// only and never a permission decision. Other events keep their output.
|
|
45
|
+
if (result.hookSpecificOutput) {
|
|
46
|
+
console.log(JSON.stringify({ hookSpecificOutput: result.hookSpecificOutput }));
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
40
50
|
const decision = result.verdict === "deny" ? "deny" : "allow";
|
|
41
51
|
console.log(JSON.stringify({
|
|
42
52
|
hookSpecificOutput: {
|
|
@@ -40,6 +40,7 @@ export async function runCortex(flags) {
|
|
|
40
40
|
pauseForQuestions: Boolean(flags["pause-questions"]),
|
|
41
41
|
note: typeof flags.note === "string" ? flags.note : undefined,
|
|
42
42
|
});
|
|
43
|
+
printHarnessWarnings(result);
|
|
43
44
|
console.log(JSON.stringify(result, null, 2));
|
|
44
45
|
}
|
|
45
46
|
catch (err) {
|
|
@@ -47,3 +48,19 @@ export async function runCortex(flags) {
|
|
|
47
48
|
process.exit(1);
|
|
48
49
|
}
|
|
49
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Print the Compass-first gate outcome of a harness result to stderr — the
|
|
53
|
+
* `compassEvidence` count, then any warning — keeping stdout pure JSON.
|
|
54
|
+
*
|
|
55
|
+
* @param result - Any `handleHarness` result.
|
|
56
|
+
*/
|
|
57
|
+
export function printHarnessWarnings(result) {
|
|
58
|
+
const { compassEvidence: ev, warnings } = result;
|
|
59
|
+
if (ev) {
|
|
60
|
+
process.stderr.write(` compass-first: ${ev.calls} Compass evidence call(s) in stage "${ev.stage}" (compassGate: ${ev.mode})\n`);
|
|
61
|
+
}
|
|
62
|
+
if (!Array.isArray(warnings))
|
|
63
|
+
return;
|
|
64
|
+
for (const w of warnings)
|
|
65
|
+
process.stderr.write(` ! ${String(w)}\n`);
|
|
66
|
+
}
|
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
import { buildIndex } from "../../modules/compass/indexer.js";
|
|
2
2
|
import { startWatch } from "../../modules/compass/watcher.js";
|
|
3
|
+
import { recordCompassCall } from "../../shared/compass-calls.js";
|
|
3
4
|
import { ui, renderProgress, clearProgress } from "../lib/ui.js";
|
|
4
5
|
/** (Re)build the Compass code graph for the cwd, showing progress and final stats. */
|
|
5
6
|
export async function runIndex(flags) {
|
|
6
7
|
const cwd = process.cwd();
|
|
7
|
-
|
|
8
|
+
recordCompassCall(cwd, "compass_index");
|
|
9
|
+
const json = Boolean(flags.json);
|
|
10
|
+
if (!json)
|
|
11
|
+
ui.step("Indexing with Compass");
|
|
8
12
|
const start = Date.now();
|
|
9
13
|
const stats = await buildIndex(cwd, {
|
|
10
14
|
force: Boolean(flags.force),
|
|
11
15
|
prune: Boolean(flags.prune),
|
|
12
16
|
maxCacheMB: flags["max-cache-mb"] ? Number(flags["max-cache-mb"]) : undefined,
|
|
13
17
|
retentionDays: flags.retention ? Number(flags.retention) : undefined,
|
|
14
|
-
onProgress: (e) => renderProgress(e.done, e.total, e.file),
|
|
18
|
+
onProgress: json ? undefined : (e) => renderProgress(e.done, e.total, e.file),
|
|
15
19
|
});
|
|
20
|
+
if (json) {
|
|
21
|
+
process.stdout.write(JSON.stringify(stats, null, 2) + "\n");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
16
24
|
clearProgress();
|
|
17
25
|
const secs = ((Date.now() - start) / 1000).toFixed(1);
|
|
18
26
|
const root = stats.rootUnchanged ? " · root unchanged" : "";
|
|
@@ -20,6 +28,9 @@ export async function runIndex(flags) {
|
|
|
20
28
|
`${stats.computed} computed · ${stats.fromCache} fromCache · ` +
|
|
21
29
|
`${stats.unchanged} unchanged · ${stats.skippedByStat} skippedByStat` +
|
|
22
30
|
`${root} (${secs}s)`);
|
|
31
|
+
const t = stats.totals;
|
|
32
|
+
ui.info(`Totals: ${t.files} files · ${t.nodes} nodes · ${t.edges} edges`);
|
|
33
|
+
ui.info(stats.nextStep);
|
|
23
34
|
}
|
|
24
35
|
/** Build the initial index, then watch for file changes to keep it fresh. */
|
|
25
36
|
export async function runWatch(_flags) {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { specInit, specValidate, specSync, specArchive, specList, } from "../../modules/lawbook/engine.js";
|
|
2
2
|
import { handleLevel } from "../../modules/lawbook/quick.js";
|
|
3
3
|
import { scaffoldBugfix } from "../../modules/lawbook/bugfix.js";
|
|
4
|
+
import { scaffoldFeature } from "../../modules/lawbook/scaffold-change.js";
|
|
4
5
|
import { investigate, formatInvestigateResult } from "../../modules/lawbook/investigate.js";
|
|
5
6
|
import { handleHarness } from "../../modules/cortex/harness.js";
|
|
7
|
+
import { printHarnessWarnings } from "./cortex.js";
|
|
6
8
|
import { list } from "../lib/args.js";
|
|
7
9
|
import { ui } from "../lib/ui.js";
|
|
8
10
|
function today() {
|
|
@@ -115,21 +117,32 @@ export async function runSpec(flags) {
|
|
|
115
117
|
return;
|
|
116
118
|
}
|
|
117
119
|
case "draft": {
|
|
118
|
-
if (!flags.bug) {
|
|
119
|
-
ui.err("Usage: speclaw lawbook draft --bug <name> [--level N] [--json]");
|
|
120
|
-
process.exit(1);
|
|
121
|
-
}
|
|
122
|
-
const name = typeof flags.bug === "string" ? flags.bug : req(change, "lawbook draft --bug <name>");
|
|
123
120
|
const levelFlag = flags.level;
|
|
124
|
-
const level = levelFlag === undefined || levelFlag === true
|
|
125
|
-
|
|
126
|
-
:
|
|
127
|
-
|
|
121
|
+
const level = levelFlag === undefined || levelFlag === true ? undefined : parseLevel(levelFlag);
|
|
122
|
+
if (flags.bug) {
|
|
123
|
+
const name = typeof flags.bug === "string" ? flags.bug : req(change, "lawbook draft --bug <name>");
|
|
124
|
+
const result = scaffoldBugfix(cwd, name, { level });
|
|
125
|
+
if (flags.json) {
|
|
126
|
+
console.log(JSON.stringify(result, null, 2));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
ui.ok(`bug change "${name}" scaffolded at ${result.dir}`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const name = req(change, "lawbook draft <name> [--level N] [--capability C] [--json] | --bug <name>");
|
|
133
|
+
const result = scaffoldFeature(cwd, name, {
|
|
134
|
+
level,
|
|
135
|
+
reason: typeof flags.reason === "string" ? flags.reason : undefined,
|
|
136
|
+
capability: typeof flags.capability === "string" ? flags.capability : undefined,
|
|
137
|
+
});
|
|
128
138
|
if (flags.json) {
|
|
129
139
|
console.log(JSON.stringify(result, null, 2));
|
|
130
140
|
return;
|
|
131
141
|
}
|
|
132
|
-
ui.ok(`
|
|
142
|
+
ui.ok(`feature change "${name}" scaffolded at ${result.dir} ` +
|
|
143
|
+
(result.level === null
|
|
144
|
+
? "(level unconfirmed — run `speclaw lawbook level set`)"
|
|
145
|
+
: `(level ${result.level})`));
|
|
133
146
|
return;
|
|
134
147
|
}
|
|
135
148
|
case "archive": {
|
|
@@ -170,6 +183,7 @@ export async function runSpec(flags) {
|
|
|
170
183
|
pauseForQuestions: Boolean(flags["pause-questions"]),
|
|
171
184
|
note: typeof flags.note === "string" ? flags.note : undefined,
|
|
172
185
|
});
|
|
186
|
+
printHarnessWarnings(result);
|
|
173
187
|
if (flags.json) {
|
|
174
188
|
console.log(JSON.stringify(result, null, 2));
|
|
175
189
|
return;
|
|
@@ -187,6 +201,13 @@ export async function runSpec(flags) {
|
|
|
187
201
|
process.exit(1);
|
|
188
202
|
}
|
|
189
203
|
}
|
|
204
|
+
/** Parse a `--level` flag value into a ceremony level; throws on anything but 0–3. */
|
|
205
|
+
function parseLevel(raw) {
|
|
206
|
+
const n = Number(Array.isArray(raw) ? raw[raw.length - 1] : raw);
|
|
207
|
+
if (n === 0 || n === 1 || n === 2 || n === 3)
|
|
208
|
+
return n;
|
|
209
|
+
throw new Error(`--level must be 0, 1, 2, or 3 (got "${String(raw)}")`);
|
|
210
|
+
}
|
|
190
211
|
/** Return the value or print a usage error and exit if it is missing. */
|
|
191
212
|
function req(value, usage) {
|
|
192
213
|
if (!value) {
|
|
@@ -5,6 +5,16 @@ import { hotspots, coupling } from "../../modules/compass/hotspots.js";
|
|
|
5
5
|
import { diffContext, formatDiffContext } from "../../modules/compass/diff-context.js";
|
|
6
6
|
import { list } from "../lib/args.js";
|
|
7
7
|
import { ui } from "../lib/ui.js";
|
|
8
|
+
import { recordCompassCall } from "../../shared/compass-calls.js";
|
|
9
|
+
/** CLI query verbs that are Compass call-log twins of an MCP tool. */
|
|
10
|
+
const LOGGED_VERBS = {
|
|
11
|
+
explore: "compass_explore",
|
|
12
|
+
search: "compass_search",
|
|
13
|
+
recall: "compass_recall",
|
|
14
|
+
impact: "compass_impact",
|
|
15
|
+
trace: "compass_trace",
|
|
16
|
+
"diff-context": "compass_diff_context",
|
|
17
|
+
};
|
|
8
18
|
/**
|
|
9
19
|
* Run a Compass query from the shell — the same surface agents call via MCP.
|
|
10
20
|
*
|
|
@@ -20,6 +30,9 @@ export async function runQuery(cmd, flags) {
|
|
|
20
30
|
const focus = list(flags.focus);
|
|
21
31
|
const maxTokens = flags["max-tokens"] ? Number(flags["max-tokens"]) : undefined;
|
|
22
32
|
const explain = Boolean(flags.explain);
|
|
33
|
+
const logged = LOGGED_VERBS[cmd];
|
|
34
|
+
if (logged)
|
|
35
|
+
recordCompassCall(cwd, logged);
|
|
23
36
|
try {
|
|
24
37
|
switch (cmd) {
|
|
25
38
|
case "explore": {
|
package/dist/cli/index.js
CHANGED
|
@@ -18,7 +18,7 @@ Setup
|
|
|
18
18
|
agent add <id> Configure another agent later (symlinks + MCP)
|
|
19
19
|
|
|
20
20
|
Compass (code intelligence — the same surface agents use via MCP)
|
|
21
|
-
index (Re)build the local code graph (--force / --prune)
|
|
21
|
+
index (Re)build the local code graph (--force / --prune / --json)
|
|
22
22
|
watch Keep the index fresh on file changes
|
|
23
23
|
explore <node> A node's source + callers/callees
|
|
24
24
|
search <query> Hybrid find (BM25+vector+name); --focus --max-tokens --explain
|
|
@@ -39,6 +39,7 @@ Lawbook (spec-driven workflow)
|
|
|
39
39
|
lawbook init Create the lawbook/ workspace
|
|
40
40
|
lawbook list Active/archived changes and capabilities
|
|
41
41
|
lawbook level <mode> Propose/set/promote/explain ceremony level (--json)
|
|
42
|
+
lawbook draft <name> Scaffold a feature change (--level N, --capability C, --json)
|
|
42
43
|
lawbook draft --bug <c> Scaffold a bug change (bugfix.md + reports)
|
|
43
44
|
lawbook investigate Rank bug suspects from graph (--symptom / --stack-trace, --json)
|
|
44
45
|
lawbook validate <c> Validate a change's artifacts
|
|
@@ -115,6 +116,8 @@ function maybeHeader(cmd, flags) {
|
|
|
115
116
|
return;
|
|
116
117
|
if (cmd === "quick" && flags.json)
|
|
117
118
|
return;
|
|
119
|
+
if (cmd === "index" && flags.json)
|
|
120
|
+
return;
|
|
118
121
|
if (cmd === "lawbook" && flags.json && flags._[0] === "level")
|
|
119
122
|
return;
|
|
120
123
|
if (cmd === "lawbook" && flags.json && flags._[0] === "investigate")
|
|
@@ -212,6 +212,8 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
|
|
|
212
212
|
removed: 0,
|
|
213
213
|
rootUnchanged: false,
|
|
214
214
|
embedder: embedder.id,
|
|
215
|
+
totals: { files: 0, nodes: 0, edges: 0 },
|
|
216
|
+
nextStep: "",
|
|
215
217
|
};
|
|
216
218
|
const cfg = loadAffectedConfig(projectPath);
|
|
217
219
|
const existing = new Map();
|
|
@@ -398,6 +400,8 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
|
|
|
398
400
|
evictCacheBySize(db, maxCacheMB);
|
|
399
401
|
db.prepare("INSERT INTO meta(key, value) VALUES ('indexed_at', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(new Date().toISOString());
|
|
400
402
|
clearNeedsReindex(db);
|
|
403
|
+
stats.totals = countTotals(db);
|
|
404
|
+
stats.nextStep = indexNextStep(stats.totals);
|
|
401
405
|
db.exec("COMMIT");
|
|
402
406
|
}
|
|
403
407
|
catch (err) {
|
|
@@ -416,6 +420,16 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
|
|
|
416
420
|
}
|
|
417
421
|
return stats;
|
|
418
422
|
}
|
|
423
|
+
function countTotals(db) {
|
|
424
|
+
const count = (table) => Number(db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n);
|
|
425
|
+
return { files: count("files"), nodes: count("nodes"), edges: count("edges") };
|
|
426
|
+
}
|
|
427
|
+
/** Next-step hint printed after an index run; names the query tools, not grep. */
|
|
428
|
+
export function indexNextStep(totals) {
|
|
429
|
+
const fmt = (n) => n.toLocaleString("en-US");
|
|
430
|
+
return (`Index ready: ${fmt(totals.files)} files, ${fmt(totals.nodes)} symbols. ` +
|
|
431
|
+
`Next: compass_find "<concept>" or compass_explore <symbol> — do not grep.`);
|
|
432
|
+
}
|
|
419
433
|
function evictCacheBySize(db, maxCacheMB) {
|
|
420
434
|
const limitBytes = maxCacheMB * 1024 * 1024;
|
|
421
435
|
const row = db
|
|
@@ -3,6 +3,7 @@ import { defineTool, defineAliasTool, text } from "../../shared/mcp.js";
|
|
|
3
3
|
import { shouldExpose } from "../../shared/exposure.js";
|
|
4
4
|
import { aliasesEnabled } from "../../shared/tool-catalog.js";
|
|
5
5
|
import { logDeprecatedCall, prefixDeprecated } from "../../shared/deprecation.js";
|
|
6
|
+
import { recordCompassCall } from "../../shared/compass-calls.js";
|
|
6
7
|
import { buildIndex } from "./indexer.js";
|
|
7
8
|
import { impact } from "./query.js";
|
|
8
9
|
import { affectedTests } from "./affected.js";
|
|
@@ -17,7 +18,14 @@ export function registerCompass(server, opts = {}) {
|
|
|
17
18
|
const add = (name, description, inputSchema, handler) => {
|
|
18
19
|
if (!shouldExpose(name, minimal))
|
|
19
20
|
return;
|
|
20
|
-
|
|
21
|
+
// Every canonical call lands in the call log the Cortex gate and the
|
|
22
|
+
// Compass-first nudge read; the write is best-effort and never throws.
|
|
23
|
+
const call = handler;
|
|
24
|
+
const logged = ((args, extra) => {
|
|
25
|
+
recordCompassCall(args.projectPath, name);
|
|
26
|
+
return call(args, extra);
|
|
27
|
+
});
|
|
28
|
+
defineTool(server, { name, description, inputSchema, handler: logged });
|
|
21
29
|
};
|
|
22
30
|
add("compass_explore", "Symbol context in one call: source, callers, callees, blast radius, tests, hotspot. Prefer before grep.", {
|
|
23
31
|
projectPath: z.string(),
|
|
@@ -85,6 +93,7 @@ export function registerCompass(server, opts = {}) {
|
|
|
85
93
|
inputSchema: { projectPath: z.string(), query: z.string(), limit: z.number().optional() },
|
|
86
94
|
handler: async ({ projectPath, query, limit }) => {
|
|
87
95
|
logDeprecatedCall(projectPath, "compass_search");
|
|
96
|
+
recordCompassCall(projectPath, "compass_search");
|
|
88
97
|
const body = JSON.stringify(await findSymbols(projectPath, query, "exact", limit), null, 2);
|
|
89
98
|
return text(prefixDeprecated("compass_search", body));
|
|
90
99
|
},
|
|
@@ -95,6 +104,7 @@ export function registerCompass(server, opts = {}) {
|
|
|
95
104
|
inputSchema: { projectPath: z.string(), query: z.string(), limit: z.number().optional() },
|
|
96
105
|
handler: async ({ projectPath, query, limit }) => {
|
|
97
106
|
logDeprecatedCall(projectPath, "compass_recall");
|
|
107
|
+
recordCompassCall(projectPath, "compass_recall");
|
|
98
108
|
const body = JSON.stringify(await findSymbols(projectPath, query, "concept", limit), null, 2);
|
|
99
109
|
return text(prefixDeprecated("compass_recall", body));
|
|
100
110
|
},
|
|
@@ -111,6 +121,7 @@ export function registerCompass(server, opts = {}) {
|
|
|
111
121
|
},
|
|
112
122
|
handler: async (args) => {
|
|
113
123
|
logDeprecatedCall(args.projectPath, "compass_impact");
|
|
124
|
+
recordCompassCall(args.projectPath, "compass_impact");
|
|
114
125
|
const sym = args.symbol ?? args.node;
|
|
115
126
|
const body = sym
|
|
116
127
|
? JSON.stringify(await exploreRich({
|
|
@@ -133,6 +144,7 @@ export function registerCompass(server, opts = {}) {
|
|
|
133
144
|
},
|
|
134
145
|
handler: async ({ projectPath, from, to, maxDepth }) => {
|
|
135
146
|
logDeprecatedCall(projectPath, "compass_trace");
|
|
147
|
+
recordCompassCall(projectPath, "compass_trace");
|
|
136
148
|
const body = JSON.stringify(await exploreRich({ projectPath, node: from, to, maxDepth }), null, 2);
|
|
137
149
|
return text(prefixDeprecated("compass_trace", body));
|
|
138
150
|
},
|
|
@@ -148,6 +160,7 @@ export function registerCompass(server, opts = {}) {
|
|
|
148
160
|
},
|
|
149
161
|
handler: async (args) => {
|
|
150
162
|
logDeprecatedCall(args.projectPath, "compass_affected_tests");
|
|
163
|
+
recordCompassCall(args.projectPath, "compass_affected_tests");
|
|
151
164
|
const body = JSON.stringify(affectedTests(args.projectPath, args), null, 2);
|
|
152
165
|
return text(prefixDeprecated("compass_affected_tests", body));
|
|
153
166
|
},
|
|
@@ -158,6 +171,7 @@ export function registerCompass(server, opts = {}) {
|
|
|
158
171
|
inputSchema: { projectPath: z.string(), limit: z.number().optional() },
|
|
159
172
|
handler: async ({ projectPath, limit }) => {
|
|
160
173
|
logDeprecatedCall(projectPath, "compass_hotspots");
|
|
174
|
+
recordCompassCall(projectPath, "compass_hotspots");
|
|
161
175
|
const body = JSON.stringify(hotspots(projectPath, { limit }), null, 2);
|
|
162
176
|
return text(prefixDeprecated("compass_hotspots", body));
|
|
163
177
|
},
|
|
@@ -168,6 +182,7 @@ export function registerCompass(server, opts = {}) {
|
|
|
168
182
|
inputSchema: { projectPath: z.string(), file: z.string() },
|
|
169
183
|
handler: async ({ projectPath, file }) => {
|
|
170
184
|
logDeprecatedCall(projectPath, "compass_coupling");
|
|
185
|
+
recordCompassCall(projectPath, "compass_coupling");
|
|
171
186
|
const body = JSON.stringify(coupling(projectPath, file, {}), null, 2);
|
|
172
187
|
return text(prefixDeprecated("compass_coupling", body));
|
|
173
188
|
},
|
|
@@ -181,6 +196,7 @@ export function registerCompass(server, opts = {}) {
|
|
|
181
196
|
},
|
|
182
197
|
handler: async ({ projectPath, action }) => {
|
|
183
198
|
logDeprecatedCall(projectPath, "compass_watch");
|
|
199
|
+
recordCompassCall(projectPath, "compass_watch");
|
|
184
200
|
const result = action === "start"
|
|
185
201
|
? startWatch(projectPath)
|
|
186
202
|
: action === "stop"
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compass-first evidence gate for Cortex `advance`.
|
|
3
|
+
*
|
|
4
|
+
* Leaving `exploring` or `implementing` requires (strict) or recommends (warn)
|
|
5
|
+
* at least one Compass evidence call in the shared call log since the current
|
|
6
|
+
* stage started. Cortex must not import from the lawbook module, so the
|
|
7
|
+
* `compassGate` key is read with a local regex scan of `lawbook/config.yaml`.
|
|
8
|
+
*
|
|
9
|
+
* // Covers: req~compass-evidence-gate~1
|
|
10
|
+
*/
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { EVIDENCE_TOOLS, countEvidence, readCompassCalls } from "../../shared/compass-calls.js";
|
|
14
|
+
/** Mode used when the key is missing or invalid. */
|
|
15
|
+
export const DEFAULT_COMPASS_GATE = "warn";
|
|
16
|
+
/** Stages whose exit is gated. */
|
|
17
|
+
export const GATED_STAGES = new Set(["exploring", "implementing"]);
|
|
18
|
+
const GATE_KEY = /^compassGate:[ \t]*(["']?)([A-Za-z]*)\1[ \t]*(?:#.*)?$/m;
|
|
19
|
+
/**
|
|
20
|
+
* Read the top-level `compassGate` key from `lawbook/config.yaml`.
|
|
21
|
+
*
|
|
22
|
+
* Quotes are optional and a trailing comment is allowed. A missing file or
|
|
23
|
+
* key, or any value other than `off`/`warn`/`strict`, yields `warn`.
|
|
24
|
+
*
|
|
25
|
+
* @param projectPath - Project root.
|
|
26
|
+
*/
|
|
27
|
+
export function readCompassGateMode(projectPath) {
|
|
28
|
+
let raw;
|
|
29
|
+
try {
|
|
30
|
+
raw = fs.readFileSync(path.join(projectPath, "lawbook", "config.yaml"), "utf8");
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return DEFAULT_COMPASS_GATE;
|
|
34
|
+
}
|
|
35
|
+
const value = GATE_KEY.exec(raw)?.[2];
|
|
36
|
+
return value === "off" || value === "warn" || value === "strict" ? value : DEFAULT_COMPASS_GATE;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* When the current stage started: the `at` of the newest history entry whose
|
|
40
|
+
* `to` is the current stage. `start` enters `exploring` and `rework` enters
|
|
41
|
+
* `implementing`, so a rework restarts the window. Null when none matches.
|
|
42
|
+
*
|
|
43
|
+
* @param state - Harness state.
|
|
44
|
+
*/
|
|
45
|
+
export function stageStartedAt(state) {
|
|
46
|
+
for (let i = state.history.length - 1; i >= 0; i--) {
|
|
47
|
+
const entry = state.history[i];
|
|
48
|
+
if (entry.to === state.stage)
|
|
49
|
+
return entry.at;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Count Compass evidence calls since the current stage started.
|
|
55
|
+
*
|
|
56
|
+
* Reads only the bounded call-log tail. With no matching history entry every
|
|
57
|
+
* log entry is eligible.
|
|
58
|
+
*
|
|
59
|
+
* @param projectPath - Project root.
|
|
60
|
+
* @param state - Harness state before the advance.
|
|
61
|
+
* @param mode - Gate mode; defaults to {@link readCompassGateMode}.
|
|
62
|
+
*/
|
|
63
|
+
export function evaluateCompassGate(projectPath, state, mode = readCompassGateMode(projectPath)) {
|
|
64
|
+
const since = stageStartedAt(state);
|
|
65
|
+
const sinceMs = since === null ? Number.NaN : Date.parse(since);
|
|
66
|
+
const calls = countEvidence(readCompassCalls(projectPath, Number.isNaN(sinceMs) ? {} : { sinceMs }));
|
|
67
|
+
return { mode, stage: state.stage, since, calls, satisfied: calls > 0 };
|
|
68
|
+
}
|
|
69
|
+
function evidenceToolList() {
|
|
70
|
+
return [...EVIDENCE_TOOLS].join(", ");
|
|
71
|
+
}
|
|
72
|
+
/** Error text for a strict rejection. */
|
|
73
|
+
export function compassGateError(result) {
|
|
74
|
+
return (`compass-first: cannot leave stage "${result.stage}" — no Compass evidence call ` +
|
|
75
|
+
`(${evidenceToolList()}) since ${result.since ?? "the stage started"}. ` +
|
|
76
|
+
`Locate and read the code with compass_find / compass_explore first, ` +
|
|
77
|
+
`or relax compassGate (off|warn) in lawbook/config.yaml.`);
|
|
78
|
+
}
|
|
79
|
+
/** Warning text for a warn-mode advance without evidence. */
|
|
80
|
+
export function compassGateWarning(result) {
|
|
81
|
+
return (`compass-first: left stage "${result.stage}" with no Compass evidence call ` +
|
|
82
|
+
`(${evidenceToolList()}) since ${result.since ?? "the stage started"}. ` +
|
|
83
|
+
`Set compassGate: strict in lawbook/config.yaml to enforce.`);
|
|
84
|
+
}
|
|
@@ -10,16 +10,10 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import fs from "node:fs";
|
|
12
12
|
import path from "node:path";
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"implementing",
|
|
18
|
-
"reviewing",
|
|
19
|
-
"testing",
|
|
20
|
-
"archiving",
|
|
21
|
-
"done",
|
|
22
|
-
];
|
|
13
|
+
import { GATED_STAGES, compassGateError, compassGateWarning, evaluateCompassGate, readCompassGateMode, } from "./compass-gate.js";
|
|
14
|
+
// Harness types live in the leaf `types.ts` so `compass-gate.ts` can use them
|
|
15
|
+
// without importing this file back (no file-level import cycle).
|
|
16
|
+
export { HARNESS_STAGES, } from "./types.js";
|
|
23
17
|
export const harnessOps = ["status", "start", "advance", "rework"];
|
|
24
18
|
const DEFAULT_MAX_REWORK = 3;
|
|
25
19
|
function specRoot(projectPath) {
|
|
@@ -186,6 +180,19 @@ export function handleHarness(args) {
|
|
|
186
180
|
return next;
|
|
187
181
|
}
|
|
188
182
|
const to = allowedAdvance(current.stage, current.level, args.verdict ?? undefined);
|
|
183
|
+
// Compass-first gate: runs before any write, so a strict rejection leaves
|
|
184
|
+
// harness.json byte-identical.
|
|
185
|
+
let compassEvidence;
|
|
186
|
+
const warnings = [];
|
|
187
|
+
const mode = GATED_STAGES.has(current.stage) ? readCompassGateMode(projectPath) : "off";
|
|
188
|
+
if (mode !== "off") {
|
|
189
|
+
const { satisfied, ...evidence } = evaluateCompassGate(projectPath, current, mode);
|
|
190
|
+
compassEvidence = evidence;
|
|
191
|
+
if (!satisfied && mode === "strict")
|
|
192
|
+
throw new Error(compassGateError(evidence));
|
|
193
|
+
if (!satisfied)
|
|
194
|
+
warnings.push(compassGateWarning(evidence));
|
|
195
|
+
}
|
|
189
196
|
const verdicts = { ...current.verdicts };
|
|
190
197
|
if (current.stage === "reviewing" && args.verdict)
|
|
191
198
|
verdicts.review = args.verdict;
|
|
@@ -208,7 +215,9 @@ export function handleHarness(args) {
|
|
|
208
215
|
],
|
|
209
216
|
};
|
|
210
217
|
writeHarness(projectPath, change, next);
|
|
211
|
-
|
|
218
|
+
if (!compassEvidence)
|
|
219
|
+
return next;
|
|
220
|
+
return warnings.length ? { ...next, compassEvidence, warnings } : { ...next, compassEvidence };
|
|
212
221
|
}
|
|
213
222
|
if (harnessOp === "rework") {
|
|
214
223
|
if (current.stage !== "reviewing" && current.stage !== "testing") {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness state types shared by `harness.ts` and `compass-gate.ts`. A leaf
|
|
3
|
+
* module: it imports nothing, so neither consumer forms an import cycle.
|
|
4
|
+
*/
|
|
5
|
+
export const HARNESS_STAGES = [
|
|
6
|
+
"exploring",
|
|
7
|
+
"planning",
|
|
8
|
+
"questions",
|
|
9
|
+
"implementing",
|
|
10
|
+
"reviewing",
|
|
11
|
+
"testing",
|
|
12
|
+
"archiving",
|
|
13
|
+
"done",
|
|
14
|
+
];
|
|
@@ -16,8 +16,8 @@ Claude-specific notes: [`CLAUDE.md`](CLAUDE.md). The law: [`LAWS.md`](LAWS.md).
|
|
|
16
16
|
standards below. Open the standard that governs your change before making
|
|
17
17
|
it. Conflicts resolve in favor of the standard; amendments go through a
|
|
18
18
|
spec change, never silent deviation.
|
|
19
|
-
2. **Compass first, always** — for any code question call `
|
|
20
|
-
`
|
|
19
|
+
2. **Compass first, always** — for any code question call `compass_find` /
|
|
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
23
|
first), or the target isn't indexed code (stylesheets, config, logs). Cheat
|
|
@@ -31,9 +31,10 @@ ignore it.
|
|
|
31
31
|
|
|
32
32
|
This repo is indexed by Compass, speclaw's local code graph (`.speclaw/`). For
|
|
33
33
|
**any** question about code — what a symbol is, what it uses, who calls it,
|
|
34
|
-
where it lives, how a value flows — call Compass **first**: `
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
where it lives, how a value flows — call Compass **first**: `compass_find` to
|
|
35
|
+
locate, `compass_explore` to read a symbol with its callers and callees (include
|
|
36
|
+
`blast_radius` for what could break, `to` for a call path), and
|
|
37
|
+
`compass_diff_context` for the context of a diff.
|
|
37
38
|
Run `compass_index` first if the graph is missing.
|
|
38
39
|
|
|
39
40
|
This includes files you already know the name of: to learn what `Foo` imports,
|
|
@@ -18,7 +18,7 @@ is nothing extra to install.
|
|
|
18
18
|
| Without Compass | With Compass |
|
|
19
19
|
|-----------------|--------------|
|
|
20
20
|
| Many `Grep` + `Read` round-trips (tokens spent scanning) | One `compass_explore` call returns just the relevant node |
|
|
21
|
-
| Guess which file matters | `
|
|
21
|
+
| Guess which file matters | `compass_find` finds code by name or meaning |
|
|
22
22
|
| Edit without knowing the blast radius | callers/callees returned with the node |
|
|
23
23
|
| Whole files dumped into context | verbatim source of the node + its neighbors only |
|
|
24
24
|
|
|
@@ -30,12 +30,12 @@ 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
|
-
| `
|
|
34
|
-
| `
|
|
35
|
-
| `
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
33
|
+
| `compass_find` | Find code: `mode: exact` by name/keyword, `mode: concept` by meaning in natural language. |
|
|
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
|
+
| `compass_diff_context` | Symbols, blast radius, and tests for a diff (working tree or a rev). |
|
|
36
|
+
|
|
37
|
+
`compass_index` also takes `action: start|stop|status` to keep the index fresh
|
|
38
|
+
automatically (a debounced incremental re-index on file change).
|
|
39
39
|
|
|
40
40
|
If the graph is missing (no `.speclaw/index.db`), run `compass_index` first —
|
|
41
41
|
a missing graph is not license to skip Compass. The only legitimate fallbacks
|
|
@@ -43,11 +43,11 @@ verdicts are complete.
|
|
|
43
43
|
- Scenario headers use exactly `#### Scenario:`.
|
|
44
44
|
- Acceptance criteria are testable without production integrations.
|
|
45
45
|
- The implemented code must match what the delta spec promises. Validate with
|
|
46
|
-
|
|
46
|
+
`lawbook_change` (action: validate) before syncing or archiving.
|
|
47
47
|
|
|
48
48
|
## Archiving discipline
|
|
49
49
|
|
|
50
|
-
Always archive with the `archive` command / `
|
|
50
|
+
Always archive with the `archive` command / `lawbook_change` (action: archive), never a manual
|
|
51
51
|
`mv`. The engine refuses archive while tasks are unchecked, reports are missing,
|
|
52
52
|
specs are out of sync when required, or harness review/test verdicts are not PASS.
|
|
53
53
|
|
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { performance } from "node:perf_hooks";
|
|
4
4
|
import { compileScope, hasBackend, isActiveLaw, manifestPath, matchCompiled, readLawManifest, } from "./laws.js";
|
|
5
|
+
import { compassNudge, isNudgeEvent } from "./compass-nudge.js";
|
|
5
6
|
const cache = new Map();
|
|
6
7
|
/**
|
|
7
8
|
* Load the project's compiled law index, using the in-process cache when the
|
|
@@ -79,18 +80,35 @@ function recordContextCoverage(projectPath, laws, file) {
|
|
|
79
80
|
* another (unimplemented) backend are skipped. The evaluator fails open: a
|
|
80
81
|
* missing or unparseable manifest, or any exception, yields `allow` with a
|
|
81
82
|
* diagnostic — an enforcement layer that blocks when its own checker crashes is
|
|
82
|
-
* worse than none.
|
|
83
|
+
* worse than none. On `PostToolUse` Read/Grep/Glob it evaluates no law and carries
|
|
84
|
+
* only the advisory Compass-first `nudge` (see `compass-nudge.ts`), which never
|
|
85
|
+
* alters the verdict.
|
|
83
86
|
*
|
|
84
87
|
* @param args - The project, hook event, optional tool name, and raw payload.
|
|
85
88
|
* @returns The verdict, the laws evaluated, an optional reason, and `elapsedMs`.
|
|
86
89
|
*/
|
|
87
90
|
export function checkAction(args) {
|
|
88
91
|
const start = performance.now();
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
// Runs before the manifest load so the nudge works with no laws at all; it
|
|
93
|
+
// swallows its own errors and returns null.
|
|
94
|
+
const nudge = compassNudge(args);
|
|
95
|
+
const done = (r) => {
|
|
96
|
+
const reason = nudge ? (r.reason ? `${r.reason}\n${nudge}` : nudge) : r.reason;
|
|
97
|
+
return {
|
|
98
|
+
...r,
|
|
99
|
+
...(nudge ? { nudge, reason } : {}),
|
|
100
|
+
// Only PostToolUse: Stop / InstructionsLoaded keep their pre-change shape.
|
|
101
|
+
...(reason && args.event === "PostToolUse"
|
|
102
|
+
? { hookSpecificOutput: { hookEventName: args.event, additionalContext: reason } }
|
|
103
|
+
: {}),
|
|
104
|
+
elapsedMs: performance.now() - start,
|
|
105
|
+
};
|
|
106
|
+
};
|
|
93
107
|
try {
|
|
108
|
+
// Laws govern mutations: a PostToolUse Read/Grep/Glob carries only the
|
|
109
|
+
// nudge and never evaluates a law, so reads stay as silent as on main.
|
|
110
|
+
if (isNudgeEvent(args))
|
|
111
|
+
return done({ verdict: "allow", evaluated: [] });
|
|
94
112
|
const laws = loadLaws(args.projectPath);
|
|
95
113
|
if (!laws) {
|
|
96
114
|
return done({
|