@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.
Files changed (32) hide show
  1. package/dist/cli/commands/ship-on-stop.js +22 -2
  2. package/dist/cli/commands/ship.js +2 -1
  3. package/dist/cli/commands/update.js +1 -1
  4. package/dist/cli/index.js +3 -1
  5. package/dist/cli/lib/help.js +11 -0
  6. package/dist/modules/compass/explore-rich.js +72 -0
  7. package/dist/modules/compass/indexer.js +2 -0
  8. package/dist/modules/compass/register.js +10 -8
  9. package/dist/modules/cortex/register.js +1 -1
  10. package/dist/modules/foundation/assets/AGENTS.template.md +6 -3
  11. package/dist/modules/foundation/assets/CLAUDE.template.md +6 -3
  12. package/dist/modules/foundation/check.js +24 -1
  13. package/dist/modules/foundation/compass-nudge.js +71 -3
  14. package/dist/modules/foundation/doctor.js +1 -1
  15. package/dist/modules/foundation/hooks.js +20 -7
  16. package/dist/modules/foundation/register-core.js +12 -3
  17. package/dist/modules/lawbook/assets/skills/cortex/SKILL.md +11 -11
  18. package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +2 -1
  19. package/dist/modules/lawbook/assets/skills/test/steps/01-quality-gates.md +3 -1
  20. package/dist/modules/lawbook/engine.js +2 -2
  21. package/dist/modules/lawbook/investigate.js +85 -4
  22. package/dist/modules/lawbook/levels.js +9 -3
  23. package/dist/modules/lawbook/quick.js +5 -2
  24. package/dist/modules/lawbook/register.js +7 -5
  25. package/dist/modules/lawbook/scaffold-change.js +17 -11
  26. package/dist/modules/lawbook/ship.js +422 -26
  27. package/dist/modules/lawbook/stack-parse.js +21 -5
  28. package/dist/server.js +15 -1
  29. package/dist/shared/exposure.js +4 -7
  30. package/dist/shared/index-stats.js +40 -0
  31. package/dist/shared/mcp.js +3 -2
  32. 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
- * A failing gate goes to stderr with exit 2 so the agent sees it and fixes it
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
- ui.ok(`report ${ui.code(result.report)}`);
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 [3, 8, 15]). Update LAWS / " +
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 (cmd === "session-start" || cmd === "reindex-file" || cmd === "ship-on-stop")
182
+ if (["session-start", "reindex-file", "ship-on-stop", "measure-diff"].includes(cmd ?? ""))
181
183
  return;
182
184
  await maybeNotifyUpdate(cmd);
183
185
  }
@@ -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, hotspot. Prefer before grep.", {
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", "Graph context of changes in one call: symbols, blast radius, tests, hotspots. Default: working tree.", {
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", "Build or refresh the code graph index; optional watch action for live re-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", "Cortex multi-agent loop: status with summary, start, advance, rework, brief for a change.", {
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 — records the change, runs
31
- the gates once, writes the report from their real output, and archives
32
- level-0 work. Review happens on the PR and never blocks the agent. Add
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`) records the change, runs
59
- the gates once, writes the report from their real output, and archives
60
- level-0 work with no agent turns; a failing gate comes back to you. Do not run
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
- const tool = usable(args.toolName) ?? usable(payload.tool_name);
220
+ let tool = usable(args.toolName) ?? usable(payload.tool_name);
164
221
  if (!tool || !NUDGE_TOOLS.has(tool))
165
222
  return null;
166
- const input = (payload.tool_input ?? payload.toolInput ?? payload);
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 [3, 8, 15]",
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 records the change, runs the
52
- * gates once, writes the report from their real output, and archives level-0
53
- * work — with no agent turn. Guarded on `lawbook/` instead of the index. Its
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). Two groups are always emitted, even with no
146
- * laws: one `PostToolUse` group matching `Read|Grep|Glob` for the Compass-first
147
- * nudge, a separate `PostToolUse` group matching the mutating tools whose
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", "Project setup: init questionnaire, configure agent, list or add packs.", speclawSetupSchema, async (args) => text(handleSpeclawSetup(args)));
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 }) => text(checkAction({ projectPath, event: 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 Compass (`compass_find` / `compass_explore`). Do not read
12
- LAWS.md, config, standards, or `--help` to learn the process — this is it.
13
- 3. Implement the change and its test yourself; ask the human only when blocked.
14
- 4. Run the tests once.
15
- 5. Stop. In Claude Code the `Stop` hook (`speclaw ship-on-stop`) records the
16
- change, runs the gates once, writes the report from real output, and
17
- archives level-0 work; a failing gate comes back to you. Do not run ship,
18
- check the hook, or edit its output. Agents without hooks run `speclaw ship
19
- <change> --summary "<what and why>"` once, last.
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, write proposal/design/reports for small work,
22
- or post status updates. Review happens on the PR and never blocks you.
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
- Make the fix, tick every `- [ ]` in `record.md`, and write at least one
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
- Run them yourself and report real output. A red gate blocks completion.
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.