@esneiderbravo/speclaw 2.0.19 → 2.0.21
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/ship-on-stop.js +22 -2
- package/dist/cli/commands/ship.js +2 -1
- package/dist/cli/commands/update.js +1 -1
- package/dist/cli/index.js +3 -1
- package/dist/cli/lib/help.js +11 -0
- package/dist/modules/compass/explore-rich.js +72 -0
- package/dist/modules/compass/indexer.js +2 -0
- package/dist/modules/compass/register.js +10 -8
- package/dist/modules/cortex/register.js +1 -1
- package/dist/modules/foundation/assets/AGENTS.template.md +6 -3
- package/dist/modules/foundation/assets/CLAUDE.template.md +6 -3
- package/dist/modules/foundation/check.js +24 -1
- package/dist/modules/foundation/compass-nudge.js +71 -3
- package/dist/modules/foundation/doctor.js +1 -1
- package/dist/modules/foundation/hooks.js +20 -7
- package/dist/modules/foundation/register-core.js +12 -3
- package/dist/modules/lawbook/assets/skills/cortex/SKILL.md +11 -11
- package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +2 -1
- package/dist/modules/lawbook/assets/skills/test/steps/01-quality-gates.md +3 -1
- package/dist/modules/lawbook/engine.js +2 -2
- package/dist/modules/lawbook/investigate.js +85 -4
- package/dist/modules/lawbook/levels.js +9 -3
- package/dist/modules/lawbook/quick.js +5 -2
- package/dist/modules/lawbook/register.js +7 -5
- package/dist/modules/lawbook/scaffold-change.js +17 -11
- package/dist/modules/lawbook/ship.js +422 -26
- package/dist/modules/lawbook/stack-parse.js +21 -5
- package/dist/server.js +15 -1
- package/dist/shared/exposure.js +4 -7
- package/dist/shared/index-stats.js +40 -0
- package/dist/shared/mcp.js +3 -2
- package/package.json +1 -1
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { shipOnStop } from "../../modules/lawbook/ship.js";
|
|
1
|
+
import { measureBranchDiff, shipOnStop } from "../../modules/lawbook/ship.js";
|
|
2
2
|
/**
|
|
3
3
|
* `speclaw ship-on-stop`: the Claude Code `Stop` hook. Ships the branch's
|
|
4
4
|
* change when the work changed since the last ship; prints nothing on stdout.
|
|
5
|
-
*
|
|
5
|
+
* Artifacts the change's level still owes, or a failing gate, go to stderr with
|
|
6
|
+
* exit 2 so the agent sees them and writes or fixes them
|
|
6
7
|
* — once: when the hook already blocked this stop (`stop_hook_active`), it
|
|
7
8
|
* exits 0 so the agent can never loop. Any other failure exits 0.
|
|
8
9
|
*
|
|
@@ -13,6 +14,10 @@ export async function runShipOnStop(cwd = process.cwd()) {
|
|
|
13
14
|
const active = await stopHookActive();
|
|
14
15
|
try {
|
|
15
16
|
const out = shipOnStop(cwd);
|
|
17
|
+
if (out.skipped === null && out.result.pending.length && !active) {
|
|
18
|
+
process.stderr.write(`speclaw: ${out.result.next.join("\n- ")}\n`);
|
|
19
|
+
process.exit(2);
|
|
20
|
+
}
|
|
16
21
|
if (out.skipped === null && !out.result.gatesPassed && !active) {
|
|
17
22
|
const failed = out.result.gates[out.result.gates.length - 1];
|
|
18
23
|
process.stderr.write(`speclaw: gate failed — ${failed.command} (exit ${failed.exitCode}). Fix it before finishing.\n${failed.tail}\n`);
|
|
@@ -23,6 +28,21 @@ export async function runShipOnStop(cwd = process.cwd()) {
|
|
|
23
28
|
// A hook must never break the session.
|
|
24
29
|
}
|
|
25
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* `speclaw measure-diff`: the edit hook's detached background job. Measures the
|
|
33
|
+
* branch diff's ceremony level into `.speclaw/level-cache.json` so the next
|
|
34
|
+
* hook call and the stop reuse it. Silent and fail-safe.
|
|
35
|
+
*
|
|
36
|
+
* @param cwd - Project root (defaults to the process working directory).
|
|
37
|
+
*/
|
|
38
|
+
export async function runMeasureDiff(cwd = process.cwd()) {
|
|
39
|
+
try {
|
|
40
|
+
measureBranchDiff(cwd);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// A background job must never surface anywhere.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
26
46
|
/** Whether this stop was already blocked by the hook once (`stop_hook_active`). */
|
|
27
47
|
async function stopHookActive() {
|
|
28
48
|
try {
|
|
@@ -31,7 +31,8 @@ export async function runShip(flags) {
|
|
|
31
31
|
else
|
|
32
32
|
ui.err(`${g.command} failed, exit ${g.exitCode} (${t})`);
|
|
33
33
|
}
|
|
34
|
-
|
|
34
|
+
if (result.report)
|
|
35
|
+
ui.ok(`report ${ui.code(result.report)}`);
|
|
35
36
|
if (result.archivedTo)
|
|
36
37
|
ui.ok(`archived to ${result.archivedTo}`);
|
|
37
38
|
for (const n of result.next)
|
|
@@ -143,7 +143,7 @@ const MIGRATIONS = [
|
|
|
143
143
|
agentPrompt: "- Mention ceremony levels 0–3 (`change.json`), `speclaw quick` for level-0 scaffolds, and " +
|
|
144
144
|
"`lawbook_level` / `speclaw lawbook level` for propose/set/promote. Artifact volume follows " +
|
|
145
145
|
"the confirmed level; missing `change.json` still means full ceremony (level 3). Optional " +
|
|
146
|
-
"`ceremony:` block in `lawbook/config.yaml` (cuts default [
|
|
146
|
+
"`ceremony:` block in `lawbook/config.yaml` (cuts default [5, 16, 25]). Update LAWS / " +
|
|
147
147
|
"docs/standards/lawbook.md wording if the project still says every change needs all four artifacts.\n" +
|
|
148
148
|
"- Preserve all project-specific wording; only apply these speclaw-authored changes.",
|
|
149
149
|
},
|
package/dist/cli/index.js
CHANGED
|
@@ -131,6 +131,8 @@ async function dispatch(cmd, flags) {
|
|
|
131
131
|
return (await import("./commands/ship.js")).runShip(flags);
|
|
132
132
|
case "ship-on-stop":
|
|
133
133
|
return (await import("./commands/ship-on-stop.js")).runShipOnStop();
|
|
134
|
+
case "measure-diff":
|
|
135
|
+
return (await import("./commands/ship-on-stop.js")).runMeasureDiff();
|
|
134
136
|
case "cortex":
|
|
135
137
|
return (await import("./commands/cortex.js")).runCortex(flags);
|
|
136
138
|
case "lawbook":
|
|
@@ -177,7 +179,7 @@ async function main() {
|
|
|
177
179
|
await maybeHeader(cmd, flags);
|
|
178
180
|
await dispatch(cmd, flags);
|
|
179
181
|
// The SessionStart and edit hooks must stay silent: no update notice either.
|
|
180
|
-
if (
|
|
182
|
+
if (["session-start", "reindex-file", "ship-on-stop", "measure-diff"].includes(cmd ?? ""))
|
|
181
183
|
return;
|
|
182
184
|
await maybeNotifyUpdate(cmd);
|
|
183
185
|
}
|
package/dist/cli/lib/help.js
CHANGED
|
@@ -25,6 +25,7 @@ Compass (code intelligence — the same surface agents use via MCP)
|
|
|
25
25
|
index (Re)build the local code graph (--force / --prune / --json)
|
|
26
26
|
session-start Silent, fail-safe refresh of an existing index (SessionStart hook)
|
|
27
27
|
ship-on-stop Ship the branch's change when work changed (Stop hook)
|
|
28
|
+
measure-diff Measure the branch diff's level into the cache (edit hook job)
|
|
28
29
|
reindex-file [paths...] Silent re-index of edited files (PostToolUse hook; stdin JSON)
|
|
29
30
|
watch Keep the index fresh on file changes
|
|
30
31
|
explore <node> A node's source + callers/callees
|
|
@@ -176,6 +177,16 @@ Keep the Compass index fresh: re-index changed files until interrupted (Ctrl-C).
|
|
|
176
177
|
The Claude Code Stop hook. On a feature branch whose work changed since the
|
|
177
178
|
last ship, runs \`speclaw ship\` for the change named after the branch. Prints
|
|
178
179
|
nothing on stdout; a failing gate goes to stderr with exit 2 once.
|
|
180
|
+
`,
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
name: "measure-diff",
|
|
184
|
+
usage: `Usage: speclaw measure-diff
|
|
185
|
+
|
|
186
|
+
Measure the branch diff's ceremony level into .speclaw/level-cache.json. The
|
|
187
|
+
edit hook starts it as a detached background job when the changed files change,
|
|
188
|
+
so the hook never waits on a slow measurement; ship reuses the cached result.
|
|
189
|
+
Silent and fail-safe.
|
|
179
190
|
`,
|
|
180
191
|
},
|
|
181
192
|
{
|
|
@@ -4,6 +4,72 @@ import { FILE_NODE_KIND, indexExists, openDb } from "./db.js";
|
|
|
4
4
|
import { hotspots } from "./hotspots.js";
|
|
5
5
|
import { summarizeImpact } from "./impact-summary.js";
|
|
6
6
|
import { budgetExploreShape, applyTextBudget, OUTPUT_BUDGET, } from "../../shared/output-budget.js";
|
|
7
|
+
/** Chain limits per output mode: nodes, and source lines per node. */
|
|
8
|
+
const CHAIN_LIMITS = { brief: { nodes: 8, lines: 25 }, full: { nodes: 20, lines: 80 } };
|
|
9
|
+
function chainNode(projectPath, name, depth, withSource, maxLines) {
|
|
10
|
+
const ex = explore(projectPath, name);
|
|
11
|
+
if (!ex.found || !ex.symbol)
|
|
12
|
+
return null;
|
|
13
|
+
const node = {
|
|
14
|
+
name: ex.symbol.name,
|
|
15
|
+
file: ex.symbol.file,
|
|
16
|
+
startLine: ex.symbol.startLine,
|
|
17
|
+
depth,
|
|
18
|
+
};
|
|
19
|
+
if (withSource && ex.symbol.source) {
|
|
20
|
+
const lines = ex.symbol.source.split("\n");
|
|
21
|
+
node.source =
|
|
22
|
+
lines.length > maxLines ? lines.slice(0, maxLines).join("\n") + "\n…" : ex.symbol.source;
|
|
23
|
+
}
|
|
24
|
+
return { node, callees: (ex.callees ?? []).map((c) => c.name) };
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Breadth-first callee tree below `root` to `maxDepth`, capped per mode; or,
|
|
28
|
+
* with `path`, the symbols along that call path. Records a truncation when the
|
|
29
|
+
* node cap cut the walk short.
|
|
30
|
+
*/
|
|
31
|
+
function buildChain(projectPath, root, rootCallees, opts, truncated) {
|
|
32
|
+
const limits = CHAIN_LIMITS[opts.mode];
|
|
33
|
+
const out = [];
|
|
34
|
+
if (opts.path) {
|
|
35
|
+
opts.path.slice(1).forEach((name, i) => {
|
|
36
|
+
const hit = chainNode(projectPath, name, i + 1, opts.withSource, limits.lines);
|
|
37
|
+
if (hit && out.length < limits.nodes)
|
|
38
|
+
out.push(hit.node);
|
|
39
|
+
});
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
const seen = new Set([root]);
|
|
43
|
+
let frontier = rootCallees.map((name) => ({ name, depth: 1 }));
|
|
44
|
+
let skipped = 0;
|
|
45
|
+
while (frontier.length) {
|
|
46
|
+
const next = [];
|
|
47
|
+
for (const { name, depth } of frontier) {
|
|
48
|
+
if (seen.has(name))
|
|
49
|
+
continue;
|
|
50
|
+
seen.add(name);
|
|
51
|
+
if (out.length >= limits.nodes) {
|
|
52
|
+
skipped++;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const hit = chainNode(projectPath, name, depth, opts.withSource, limits.lines);
|
|
56
|
+
if (!hit)
|
|
57
|
+
continue;
|
|
58
|
+
out.push(hit.node);
|
|
59
|
+
if (depth < opts.maxDepth)
|
|
60
|
+
next.push(...hit.callees.map((c) => ({ name: c, depth: depth + 1 })));
|
|
61
|
+
}
|
|
62
|
+
frontier = next;
|
|
63
|
+
}
|
|
64
|
+
if (skipped) {
|
|
65
|
+
truncated.push({
|
|
66
|
+
field: "chain",
|
|
67
|
+
omitted: skipped,
|
|
68
|
+
hint: 'use mode:"full", a lower maxDepth, or to:<symbol> for one path',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
7
73
|
const DEFAULT_INCLUDES = [
|
|
8
74
|
"source",
|
|
9
75
|
"callers",
|
|
@@ -38,6 +104,9 @@ export async function exploreRich(query) {
|
|
|
38
104
|
? `Call path ${query.node} → ${query.to} (${pathResult.hops} hop(s))`
|
|
39
105
|
: `No call path found within depth limit`,
|
|
40
106
|
};
|
|
107
|
+
if (pathResult.path && pathResult.path.length > 1) {
|
|
108
|
+
out.chain = buildChain(query.projectPath, query.node, [], { maxDepth: 0, path: pathResult.path, withSource: includes.includes("source"), mode }, truncated);
|
|
109
|
+
}
|
|
41
110
|
if (!includes.includes("source") && out.symbol)
|
|
42
111
|
out.symbol = withoutSource(out.symbol);
|
|
43
112
|
if (!includes.includes("callers"))
|
|
@@ -51,6 +120,9 @@ export async function exploreRich(query) {
|
|
|
51
120
|
}
|
|
52
121
|
const base = explore(query.projectPath, query.node, { includeRefs: true });
|
|
53
122
|
const out = { ...base, truncated, degraded };
|
|
123
|
+
if (base.found && base.symbol && includes.includes("callees") && (query.maxDepth ?? 1) > 1) {
|
|
124
|
+
out.chain = buildChain(query.projectPath, base.symbol.name, (base.callees ?? []).map((c) => c.name), { maxDepth: query.maxDepth ?? 1, withSource: includes.includes("source"), mode }, truncated);
|
|
125
|
+
}
|
|
54
126
|
if (!includes.includes("source") && out.symbol)
|
|
55
127
|
out.symbol = withoutSource(out.symbol);
|
|
56
128
|
if (!includes.includes("callers"))
|
|
@@ -9,6 +9,7 @@ import { contentHashFor, defaultEmbedText } from "./embed-input.js";
|
|
|
9
9
|
import { buildDirHashMap, dirHash, HASH_EMPTY } from "./merkle.js";
|
|
10
10
|
import { loadAffectedConfig, isTestPath, inferModule, } from "./affected-config.js";
|
|
11
11
|
import { realPathOf } from "../../shared/paths.js";
|
|
12
|
+
import { writeIndexStats } from "../../shared/index-stats.js";
|
|
12
13
|
import { personalizedPageRank, edgeWeightMul } from "./pagerank.js";
|
|
13
14
|
const SKIP_DIRS = new Set([
|
|
14
15
|
".git",
|
|
@@ -937,6 +938,7 @@ export async function buildIndex(projectPath, onProgressOrOpts) {
|
|
|
937
938
|
stats.totals = countTotals(db);
|
|
938
939
|
stats.nextStep = indexNextStep(stats.totals);
|
|
939
940
|
db.exec("COMMIT");
|
|
941
|
+
writeIndexStats(projectPath, stats.totals);
|
|
940
942
|
}
|
|
941
943
|
catch (err) {
|
|
942
944
|
// SQLite may already have rolled back (or BEGIN never succeeded): an
|
|
@@ -16,7 +16,7 @@ const includeEnum = z.array(z.enum(["source", "callers", "callees", "blast_radiu
|
|
|
16
16
|
/** Register Compass MCP tools on the given server. */
|
|
17
17
|
export function registerCompass(server, opts = {}) {
|
|
18
18
|
const minimal = Boolean(opts.minimal);
|
|
19
|
-
const add = (name, description, inputSchema, handler) => {
|
|
19
|
+
const add = (name, description, inputSchema, handler, alwaysLoad = false) => {
|
|
20
20
|
if (!shouldExpose(name, minimal))
|
|
21
21
|
return;
|
|
22
22
|
// Every canonical call lands in the call log the Cortex gate and the
|
|
@@ -26,9 +26,9 @@ export function registerCompass(server, opts = {}) {
|
|
|
26
26
|
recordCompassCall(args.projectPath, name);
|
|
27
27
|
return call(args, extra);
|
|
28
28
|
});
|
|
29
|
-
defineTool(server, { name, description, inputSchema, handler: logged });
|
|
29
|
+
defineTool(server, { name, description, inputSchema, handler: logged, alwaysLoad });
|
|
30
30
|
};
|
|
31
|
-
add("compass_explore", "Symbol context in one call: source, callers, callees, blast radius, tests
|
|
31
|
+
add("compass_explore", "Symbol context in one call: source, callers, callees, blast radius, tests. maxDepth>1 returns the whole callee chain with source.", {
|
|
32
32
|
projectPath: z.string(),
|
|
33
33
|
node: z.string(),
|
|
34
34
|
to: z.string().optional(),
|
|
@@ -45,7 +45,9 @@ export function registerCompass(server, opts = {}) {
|
|
|
45
45
|
maxDepth,
|
|
46
46
|
});
|
|
47
47
|
return text(formatExploreRich(result, (mode ?? "brief")), mode ?? "brief");
|
|
48
|
-
}
|
|
48
|
+
},
|
|
49
|
+
// The two code-reading tools load up front: deferred, agents `cat` instead.
|
|
50
|
+
true);
|
|
49
51
|
add("compass_find", "Hybrid search over the index: BM25, vectors, and name match. mode sets weights; pass focus for edited files.", {
|
|
50
52
|
projectPath: z.string(),
|
|
51
53
|
query: z.string(),
|
|
@@ -56,8 +58,8 @@ export function registerCompass(server, opts = {}) {
|
|
|
56
58
|
}, async ({ projectPath, query, mode, limit, focus, maxTokens }) => {
|
|
57
59
|
const found = await findSymbols(projectPath, query, mode, limit, { focus, maxTokens });
|
|
58
60
|
return text(formatFindResponse(found), { maxTokens: found.cap });
|
|
59
|
-
});
|
|
60
|
-
add("compass_diff_context", "
|
|
61
|
+
}, true);
|
|
62
|
+
add("compass_diff_context", "Use before testing or finishing: what the diff touches — symbols, blast radius, covering tests.", {
|
|
61
63
|
projectPath: z.string(),
|
|
62
64
|
rev: z.string().optional(),
|
|
63
65
|
paths: z.array(z.string()).optional(),
|
|
@@ -72,8 +74,8 @@ export function registerCompass(server, opts = {}) {
|
|
|
72
74
|
maxDepth,
|
|
73
75
|
});
|
|
74
76
|
return text(formatDiffContext(result, (mode ?? "brief")), mode ?? "brief");
|
|
75
|
-
});
|
|
76
|
-
add("compass_index", "
|
|
77
|
+
}, true);
|
|
78
|
+
add("compass_index", "Use when the index is missing or stale: build or refresh the code graph; watch re-indexes live.", {
|
|
77
79
|
projectPath: z.string(),
|
|
78
80
|
action: z.enum(["index", "start", "stop", "status"]).optional(),
|
|
79
81
|
force: z.boolean().optional(),
|
|
@@ -23,7 +23,7 @@ export function registerCortex(server, opts = {}) {
|
|
|
23
23
|
return;
|
|
24
24
|
defineTool(server, { name, description, inputSchema, handler });
|
|
25
25
|
};
|
|
26
|
-
add("cortex", "
|
|
26
|
+
add("cortex", "Only when work splits into 3+ large independent parts: the multi-agent loop — start, brief, advance, rework, status.", {
|
|
27
27
|
projectPath: z.string(),
|
|
28
28
|
change: z.string(),
|
|
29
29
|
action: z.enum(cortexActions),
|
|
@@ -27,9 +27,12 @@ Claude-specific notes: [`CLAUDE.md`](CLAUDE.md). The law: [`LAWS.md`](LAWS.md).
|
|
|
27
27
|
agents.*). Branch `<type>/<slug>`, locate with Compass, implement the
|
|
28
28
|
change and its test yourself, run the tests, finish. The `Stop` hook
|
|
29
29
|
(`speclaw ship-on-stop`) — or `speclaw ship <change> --summary "<what and
|
|
30
|
-
why>"` as your last step on agents without hooks —
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
why>"` as your last step on agents without hooks — sizes the change from
|
|
31
|
+
its diff, names the artifacts that level owes while you edit (the why,
|
|
32
|
+
tasks, delta specs, proposal/design; write them in the same turn), runs the
|
|
33
|
+
gates once, writes the report
|
|
34
|
+
from their real output, and archives level-0 work. Commit with a message
|
|
35
|
+
whose body says why. Review happens on the PR and never blocks the agent. Add
|
|
33
36
|
agents only to run three or more large, independent parts in parallel.
|
|
34
37
|
Details: the `cortex` skill and
|
|
35
38
|
[`docs/standards/lawbook.md`](docs/standards/lawbook.md).
|
|
@@ -55,9 +55,12 @@ See [`docs/compass.md`](docs/compass.md).
|
|
|
55
55
|
Every change is built with **Cortex** (*One brain. Many agents.*), and Cortex
|
|
56
56
|
costs no more time than working alone: branch `<type>/<slug>`, locate with
|
|
57
57
|
Compass, implement the change and its test yourself, run the tests, finish.
|
|
58
|
-
Then stop: the `Stop` hook (`speclaw ship-on-stop`)
|
|
59
|
-
the gates once, writes the report from their real output, and
|
|
60
|
-
level-0 work with no agent turns
|
|
58
|
+
Then stop: the `Stop` hook (`speclaw ship-on-stop`) sizes the change from its
|
|
59
|
+
diff, runs the gates once, writes the report from their real output, and
|
|
60
|
+
archives level-0 work with no agent turns. What the level owes (the why — a
|
|
61
|
+
commit body counts — tasks, delta specs, proposal/design) is named while you
|
|
62
|
+
edit: write it in the same turn. A missing piece or a failing gate comes back
|
|
63
|
+
to you at the stop. Do not run
|
|
61
64
|
`speclaw ship` yourself, check the hook, open the archive, edit the generated
|
|
62
65
|
report, read the process docs, or drive the harness by hand. Review happens on the PR, never blocking the
|
|
63
66
|
agent. Add agents only to run three or more large, independent parts in
|
|
@@ -3,6 +3,27 @@ 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
5
|
import { compassNudge, isNudgeEvent } from "./compass-nudge.js";
|
|
6
|
+
/**
|
|
7
|
+
* Whether a check call should carry the documentation hint: its own mutation
|
|
8
|
+
* hook group, or a Bash call (agents edit through the shell too). The hint
|
|
9
|
+
* itself lives in the lawbook module and is loaded by the MCP handler only, so
|
|
10
|
+
* this hot path never loads the index database.
|
|
11
|
+
*/
|
|
12
|
+
export function wantsDocHint(args) {
|
|
13
|
+
if (args.event !== "PostToolUse")
|
|
14
|
+
return false;
|
|
15
|
+
const payload = (args.payload ?? {});
|
|
16
|
+
return payload.speclaw_hint === "doc" || (args.toolName ?? payload.tool_name) === "Bash";
|
|
17
|
+
}
|
|
18
|
+
/** Append a hint to a `PostToolUse` result's reason and the agent's additional context. */
|
|
19
|
+
export function withHint(result, hint) {
|
|
20
|
+
const reason = result.reason ? `${result.reason}\n${hint}` : hint;
|
|
21
|
+
return {
|
|
22
|
+
...result,
|
|
23
|
+
reason,
|
|
24
|
+
hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: reason },
|
|
25
|
+
};
|
|
26
|
+
}
|
|
6
27
|
const cache = new Map();
|
|
7
28
|
/**
|
|
8
29
|
* Load the project's compiled law index, using the in-process cache when the
|
|
@@ -92,6 +113,8 @@ export function checkAction(args) {
|
|
|
92
113
|
// Runs before the manifest load so the nudge works with no laws at all; it
|
|
93
114
|
// swallows its own errors and returns null.
|
|
94
115
|
const nudge = compassNudge(args);
|
|
116
|
+
// The documentation-hint group never evaluates a law (see wantsDocHint).
|
|
117
|
+
const docGroup = args.payload?.speclaw_hint === "doc";
|
|
95
118
|
const done = (r) => {
|
|
96
119
|
const reason = nudge ? (r.reason ? `${r.reason}\n${nudge}` : nudge) : r.reason;
|
|
97
120
|
return {
|
|
@@ -107,7 +130,7 @@ export function checkAction(args) {
|
|
|
107
130
|
try {
|
|
108
131
|
// Laws govern mutations: a PostToolUse Read/Grep/Glob carries only the
|
|
109
132
|
// nudge and never evaluates a law, so reads stay as silent as on main.
|
|
110
|
-
if (isNudgeEvent(args))
|
|
133
|
+
if (isNudgeEvent(args) || docGroup)
|
|
111
134
|
return done({ verdict: "allow", evaluated: [] });
|
|
112
135
|
const laws = loadLaws(args.projectPath);
|
|
113
136
|
if (!laws) {
|
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { NUDGE_ENTRY, isEvidenceTool, readCompassCalls, recordCompassCall, } from "../../shared/compass-calls.js";
|
|
4
4
|
// Pure extension → language table: loads no grammar and opens no index DB.
|
|
5
5
|
import { langForPath } from "../compass/languages.js";
|
|
6
|
+
import { readIndexStats } from "../../shared/index-stats.js";
|
|
6
7
|
// Covers: req~compass-nudge~1
|
|
7
8
|
//
|
|
8
9
|
// The Compass-first nudge: on `PostToolUse` for Read/Grep/Glob of indexed code,
|
|
@@ -11,7 +12,63 @@ import { langForPath } from "../compass/languages.js";
|
|
|
11
12
|
// touches the verdict. It reads only the bounded call-log tail and the
|
|
12
13
|
// in-memory extension table, so it stays inside the hook latency budget.
|
|
13
14
|
/** Tools whose completed calls the nudge watches. */
|
|
14
|
-
export const NUDGE_TOOLS = new Set(["Read", "Grep", "Glob"]);
|
|
15
|
+
export const NUDGE_TOOLS = new Set(["Read", "Grep", "Glob", "Bash"]);
|
|
16
|
+
/** Shell commands that print a file's contents: a Bash `Read`. */
|
|
17
|
+
const SHELL_READERS = new Set(["cat", "head", "tail", "sed", "less", "more", "nl", "bat", "awk"]);
|
|
18
|
+
/** Shell commands that search contents: a Bash `Grep`. */
|
|
19
|
+
const SHELL_SEARCHERS = new Set(["grep", "egrep", "rg", "ag", "ack", "git-grep"]);
|
|
20
|
+
/**
|
|
21
|
+
* The code read a Bash command performs, as the Read/Grep call it stands in
|
|
22
|
+
* for, or null when it reads no indexed code. A reader counts only with an
|
|
23
|
+
* existing indexed file argument (so `cat > f <<EOF` writes stay silent); a
|
|
24
|
+
* searcher counts on an indexed file, a directory, or the whole repo.
|
|
25
|
+
*/
|
|
26
|
+
export function bashCodeRead(projectPath, command) {
|
|
27
|
+
// Split on command separators first, then pipes: a command after `|` reads
|
|
28
|
+
// the previous command's output, not a file (`npm test | grep fail`).
|
|
29
|
+
const commands = command.split(/\|\||&&|;|\n/).map((c) => c.split("|")[0] ?? "");
|
|
30
|
+
for (const segment of commands) {
|
|
31
|
+
const words = segment.trim().split(/\s+/).filter(Boolean);
|
|
32
|
+
let verb = words[0] ? path.basename(words[0]) : "";
|
|
33
|
+
let rest = words.slice(1);
|
|
34
|
+
if (verb === "git" && rest[0] === "grep") {
|
|
35
|
+
verb = "git-grep";
|
|
36
|
+
rest = rest.slice(1);
|
|
37
|
+
}
|
|
38
|
+
const reader = SHELL_READERS.has(verb);
|
|
39
|
+
if (!reader && !SHELL_SEARCHERS.has(verb))
|
|
40
|
+
continue;
|
|
41
|
+
// A redirect or heredoc writes; `sed -i` edits in place.
|
|
42
|
+
if (reader && rest.some((w) => w.startsWith(">") || w.startsWith("<<")))
|
|
43
|
+
continue;
|
|
44
|
+
if (verb === "sed" && rest.some((w) => /^-[a-zA-Z]*i/.test(w)))
|
|
45
|
+
continue;
|
|
46
|
+
const args = rest.filter((w) => !w.startsWith("-")).map((w) => w.replace(/^['"]|['"]$/g, ""));
|
|
47
|
+
const existing = args.filter((a) => {
|
|
48
|
+
const target = resolveTarget(projectPath, a);
|
|
49
|
+
return target !== null && target !== ROOT && isDirectory(projectPath, target) !== undefined;
|
|
50
|
+
});
|
|
51
|
+
if (reader) {
|
|
52
|
+
const file = existing.find((a) => indexedExt(path.extname(a) || "."));
|
|
53
|
+
if (file)
|
|
54
|
+
return { tool: "Read", raw: file };
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const where = existing.find((a) => isDirectory(projectPath, a) === true || indexedExt(path.extname(a) || "."));
|
|
58
|
+
// Shell quoting and escapes are not part of the searched text.
|
|
59
|
+
const pattern = args[0]?.replace(/^[\\'"]+|[\\'"]+$/g, "") || undefined;
|
|
60
|
+
// No path argument: a search of the working directory, i.e. the repo.
|
|
61
|
+
if (where || existing.length === 0)
|
|
62
|
+
return { tool: "Grep", raw: where ?? ".", pattern };
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Below this many indexed files, reading a file costs less than a graph query
|
|
68
|
+
* (measured: in a 2-file repo a nudged agent spent an extra turn on Compass and
|
|
69
|
+
* still read the file), so the nudge stays silent.
|
|
70
|
+
*/
|
|
71
|
+
export const NUDGE_MIN_INDEXED_FILES = 40;
|
|
15
72
|
/** No nudge when a Compass evidence call landed within this window. */
|
|
16
73
|
export const NUDGE_EVIDENCE_WINDOW_MS = 10 * 60 * 1000;
|
|
17
74
|
/** At most one nudge per this window. */
|
|
@@ -160,10 +217,21 @@ export function compassNudge(args, now = Date.now()) {
|
|
|
160
217
|
if (args.event !== "PostToolUse")
|
|
161
218
|
return null;
|
|
162
219
|
const payload = args.payload ?? {};
|
|
163
|
-
|
|
220
|
+
let tool = usable(args.toolName) ?? usable(payload.tool_name);
|
|
164
221
|
if (!tool || !NUDGE_TOOLS.has(tool))
|
|
165
222
|
return null;
|
|
166
|
-
const
|
|
223
|
+
const indexed = readIndexStats(args.projectPath);
|
|
224
|
+
if (indexed && indexed.files < NUDGE_MIN_INDEXED_FILES)
|
|
225
|
+
return null;
|
|
226
|
+
let input = (payload.tool_input ?? payload.toolInput ?? payload);
|
|
227
|
+
if (tool === "Bash") {
|
|
228
|
+
const command = usable(input.command);
|
|
229
|
+
const read = command ? bashCodeRead(args.projectPath, command) : null;
|
|
230
|
+
if (!read)
|
|
231
|
+
return null;
|
|
232
|
+
tool = read.tool;
|
|
233
|
+
input = { path: read.raw, ...(read.pattern ? { pattern: read.pattern } : {}) };
|
|
234
|
+
}
|
|
167
235
|
// No path on Grep/Glob means the cwd: a repo-wide search.
|
|
168
236
|
const raw = usable(input.file_path) ?? usable(input.path) ?? ".";
|
|
169
237
|
const target = resolveTarget(args.projectPath, raw);
|
|
@@ -724,7 +724,7 @@ function ceremonyChecks(projectPath) {
|
|
|
724
724
|
id: "cfg.ceremony.cuts",
|
|
725
725
|
title: "ceremony thresholds",
|
|
726
726
|
status: "warn",
|
|
727
|
-
detail: "invalid ceremony.cuts — using built-in defaults [
|
|
727
|
+
detail: "invalid ceremony.cuts — using built-in defaults [5, 16, 25]",
|
|
728
728
|
remedy: "fix cuts in lawbook/config.yaml so they are strictly increasing, or remove the block",
|
|
729
729
|
});
|
|
730
730
|
}
|
|
@@ -48,9 +48,9 @@ export const REINDEX_FILE_COMMAND = speclawCommand("reindex-file");
|
|
|
48
48
|
export const SHIP_ON_STOP_MARKER = "speclaw ship-on-stop";
|
|
49
49
|
/**
|
|
50
50
|
* The POSIX `sh` command the `Stop` hook runs: when the agent finishes a turn
|
|
51
|
-
* with new changes on a feature branch, speclaw
|
|
52
|
-
*
|
|
53
|
-
* work — with no agent turn. Guarded on `lawbook/` instead of the index. Its
|
|
51
|
+
* with new changes on a feature branch, speclaw sizes the change from its diff,
|
|
52
|
+
* returns the artifacts that level owes, runs the gates once, writes the report
|
|
53
|
+
* from their real output, and archives level-0 work — with no agent turn. Guarded on `lawbook/` instead of the index. Its
|
|
54
54
|
* stderr is kept so a failing gate reaches the agent (exit 2).
|
|
55
55
|
*/
|
|
56
56
|
export const SHIP_ON_STOP_COMMAND = speclawCommand("ship-on-stop");
|
|
@@ -98,6 +98,7 @@ const SPECLAW_HOOK_INPUT = {
|
|
|
98
98
|
pattern: "${tool_input.pattern}",
|
|
99
99
|
glob: "${tool_input.glob}",
|
|
100
100
|
type: "${tool_input.type}",
|
|
101
|
+
command: "${tool_input.command}",
|
|
101
102
|
},
|
|
102
103
|
},
|
|
103
104
|
};
|
|
@@ -109,6 +110,15 @@ const SPECLAW_HOOK = {
|
|
|
109
110
|
timeout: 5,
|
|
110
111
|
input: SPECLAW_HOOK_INPUT,
|
|
111
112
|
};
|
|
113
|
+
/**
|
|
114
|
+
* The documentation-hint hook: `speclaw_check` with `speclaw_hint: "doc"`, so
|
|
115
|
+
* an edit tells the agent what its change's level owes while it still works,
|
|
116
|
+
* without evaluating any law.
|
|
117
|
+
*/
|
|
118
|
+
const DOC_HINT_HOOK = {
|
|
119
|
+
...SPECLAW_HOOK,
|
|
120
|
+
input: { ...SPECLAW_HOOK_INPUT, payload: { ...SPECLAW_HOOK_INPUT.payload, speclaw_hint: "doc" } },
|
|
121
|
+
};
|
|
112
122
|
/** Tool-name matcher for the file-mutating tools the `path` backend can evaluate. */
|
|
113
123
|
const MUTATION_MATCHER = "Write|Edit|MultiEdit|NotebookEdit";
|
|
114
124
|
/**
|
|
@@ -116,7 +126,7 @@ const MUTATION_MATCHER = "Write|Edit|MultiEdit|NotebookEdit";
|
|
|
116
126
|
* Installed on `PostToolUse` only: there the result is context the agent reads
|
|
117
127
|
* and the event cannot gate the tool; a `PreToolUse` "allow" would auto-approve.
|
|
118
128
|
*/
|
|
119
|
-
export const NUDGE_MATCHER = "Read|Grep|Glob";
|
|
129
|
+
export const NUDGE_MATCHER = "Read|Grep|Glob|Bash";
|
|
120
130
|
/**
|
|
121
131
|
* True when a hook object is one speclaw owns (safe to replace on merge): an
|
|
122
132
|
* `mcp_tool` hook on the `speclaw` server, or a `command` hook whose command
|
|
@@ -142,9 +152,10 @@ export function isSpeclawHook(h) {
|
|
|
142
152
|
* Compile a law manifest into the hook groups speclaw contributes, one per event
|
|
143
153
|
* the laws demand: `PreToolUse` when any `bloqueo` law exists, `PostToolUse` for
|
|
144
154
|
* `feedback`, `Stop` for `gate`, and `InstructionsLoaded` whenever any law exists
|
|
145
|
-
* (the context-coverage audit).
|
|
146
|
-
*
|
|
147
|
-
* nudge
|
|
155
|
+
* (the context-coverage audit). Groups always emitted, even with no laws: one
|
|
156
|
+
* `PostToolUse` group matching `Read|Grep|Glob|Bash` for the Compass-first
|
|
157
|
+
* nudge (Bash also carries the documentation hint), one matching the mutating
|
|
158
|
+
* tools for the documentation hint, a separate `PostToolUse` group matching the mutating tools whose
|
|
148
159
|
* `command` hook re-indexes the edited file in the background, and one
|
|
149
160
|
* `SessionStart` group whose `command` hook refreshes the index silently when a
|
|
150
161
|
* session starts. A law whose scope contains a malformed glob is
|
|
@@ -174,6 +185,8 @@ export function compileHooks(manifest) {
|
|
|
174
185
|
byEvent.PostToolUse = [{ matcher: NUDGE_MATCHER, hooks: [{ ...SPECLAW_HOOK }] }];
|
|
175
186
|
if (hasFeedback)
|
|
176
187
|
byEvent.PostToolUse.unshift({ matcher: MUTATION_MATCHER, hooks: [{ ...SPECLAW_HOOK }] });
|
|
188
|
+
// Always present: edits learn what their change's level owes in the same turn.
|
|
189
|
+
byEvent.PostToolUse.push({ matcher: MUTATION_MATCHER, hooks: [{ ...DOC_HINT_HOOK }] });
|
|
177
190
|
// Covers: req~edit-reindex-hook~1
|
|
178
191
|
// Its own group, never folded into the feedback `mcp_tool` group, and only
|
|
179
192
|
// the keys the agent's hook schema defines (no `async`).
|
|
@@ -3,7 +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 { checkAction } from "./check.js";
|
|
6
|
+
import { checkAction, wantsDocHint, withHint } from "./check.js";
|
|
7
7
|
import { handleSpeclawSetup, speclawSetupSchema } from "./setup-tool.js";
|
|
8
8
|
function makeAdd(server, minimal) {
|
|
9
9
|
return (name, description, inputSchema, handler) => {
|
|
@@ -19,13 +19,22 @@ function makeAdd(server, minimal) {
|
|
|
19
19
|
export function registerFoundationCore(server, opts = {}) {
|
|
20
20
|
const minimal = Boolean(opts.minimal);
|
|
21
21
|
const add = makeAdd(server, minimal);
|
|
22
|
-
add("speclaw_setup", "
|
|
22
|
+
add("speclaw_setup", "Use to install or reconfigure speclaw: init questionnaire, configure an agent, list or add packs.", speclawSetupSchema, async (args) => text(handleSpeclawSetup(args)));
|
|
23
23
|
add("speclaw_check", "Invoked by speclaw's hooks to enforce laws — do not call directly.", {
|
|
24
24
|
projectPath: z.string(),
|
|
25
25
|
event: z.enum(["PreToolUse", "PostToolUse", "Stop", "InstructionsLoaded"]),
|
|
26
26
|
toolName: z.string().optional(),
|
|
27
27
|
payload: z.record(z.unknown()),
|
|
28
|
-
}, async ({ projectPath, event, toolName, payload }) =>
|
|
28
|
+
}, async ({ projectPath, event, toolName, payload }) => {
|
|
29
|
+
const args = { projectPath, event: event, toolName, payload };
|
|
30
|
+
const result = checkAction(args);
|
|
31
|
+
if (!wantsDocHint(args))
|
|
32
|
+
return text(result);
|
|
33
|
+
// Loaded here, not in checkAction: the hint measures the diff with the index.
|
|
34
|
+
const { docHint } = await import("../lawbook/ship.js");
|
|
35
|
+
const hint = docHint(projectPath);
|
|
36
|
+
return text(hint ? withHint(result, hint) : result);
|
|
37
|
+
});
|
|
29
38
|
if (minimal || !aliasesEnabled())
|
|
30
39
|
return;
|
|
31
40
|
defineAliasTool(server, {
|
|
@@ -8,18 +8,18 @@ description: Cortex — One brain. Many agents. The standard way to build any ch
|
|
|
8
8
|
One brain does the critical path; mechanical work costs no agent turns.
|
|
9
9
|
|
|
10
10
|
1. Branch `<type>/<slug>` (the slug is the change name).
|
|
11
|
-
2. Locate with
|
|
12
|
-
LAWS.md, config,
|
|
13
|
-
3.
|
|
14
|
-
4.
|
|
15
|
-
5.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
<change> --summary "<
|
|
11
|
+
2. Locate with `compass_find`, read with `compass_explore` (`include:
|
|
12
|
+
["source"]`), not cat/grep. No LAWS.md, config, or `--help`.
|
|
13
|
+
3. Public-API or multi-module work: `draft` skill first.
|
|
14
|
+
4. Implement the change and its test yourself; ask only when blocked.
|
|
15
|
+
5. Run the tests once; commit with a body that says why.
|
|
16
|
+
6. Stop. The `Stop` hook sizes the change from its diff and lists what its
|
|
17
|
+
level owes in `lawbook/changes/<change>/` (why, checked tasks, delta spec,
|
|
18
|
+
proposal, design). Write just that and stop again: gates run once, level 0
|
|
19
|
+
archives. Never run ship or edit its report. Without hooks: `speclaw ship <change> --summary "<why>"`, last.
|
|
20
20
|
|
|
21
|
-
Never drive the harness by hand
|
|
22
|
-
|
|
21
|
+
Never drive the harness by hand or post status updates. Review happens on the
|
|
22
|
+
PR and never blocks you.
|
|
23
23
|
|
|
24
24
|
Only when the work splits into three or more large, independent parts, read
|
|
25
25
|
`steps/01-load-or-start.md` (fan-out lane: parallel agents, never a chain).
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Implement and evidence
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Locate and read the code with `compass_find` / `compass_explore` (include
|
|
4
|
+
`source`), not cat/grep. Make the fix, tick every `- [ ]` in `record.md`, and write at least one
|
|
4
5
|
discipline report under `reports/`. Archive with `lawbook_change` (action: archive) (no sync at
|
|
5
6
|
level 0). Promote via `lawbook_change` (action: level, mode: promote) if scope grew.
|
|
6
7
|
|
|
@@ -5,6 +5,8 @@ Run the repo's gates from `docs/standards/testing-standards.md`:
|
|
|
5
5
|
- Tests: `{{test_commands}}`
|
|
6
6
|
- Lint / type-check: `{{lint_commands}}`
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Call `compass_diff_context` first: it lists the tests that cover the diff, so a
|
|
9
|
+
failure points at the right suite instead of a re-read of the code. Then run
|
|
10
|
+
the gates yourself and report real output. A red gate blocks completion.
|
|
9
11
|
|
|
10
12
|
Next: read `steps/02-manual-verification.md` and do only what it says.
|