@esneiderbravo/speclaw 2.0.11 → 2.0.13

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 (43) hide show
  1. package/README.md +5 -1
  2. package/dist/cli/commands/cortex.js +3 -2
  3. package/dist/cli/commands/lawbook.js +7 -3
  4. package/dist/cli/commands/laws.js +10 -2
  5. package/dist/cli/commands/query.js +2 -2
  6. package/dist/cli/commands/ship-on-stop.js +46 -0
  7. package/dist/cli/commands/ship.js +44 -0
  8. package/dist/cli/commands/update.js +25 -0
  9. package/dist/cli/index.js +8 -3
  10. package/dist/cli/lib/args.js +35 -5
  11. package/dist/cli/lib/help.js +36 -4
  12. package/dist/modules/compass/db.js +47 -9
  13. package/dist/modules/compass/explore-rich.js +50 -9
  14. package/dist/modules/compass/extract.js +220 -0
  15. package/dist/modules/compass/find-output.js +172 -0
  16. package/dist/modules/compass/hybrid.js +149 -21
  17. package/dist/modules/compass/indexer.js +26 -2
  18. package/dist/modules/compass/query.js +48 -12
  19. package/dist/modules/compass/register.js +13 -7
  20. package/dist/modules/cortex/harness.js +17 -4
  21. package/dist/modules/cortex/status.js +1 -1
  22. package/dist/modules/foundation/assets/AGENTS.template.md +10 -9
  23. package/dist/modules/foundation/assets/CLAUDE.template.md +13 -10
  24. package/dist/modules/foundation/assets/docs/compass.template.md +11 -4
  25. package/dist/modules/foundation/deps.js +5 -3
  26. package/dist/modules/foundation/doctor.js +1 -1
  27. package/dist/modules/foundation/graph.js +5 -5
  28. package/dist/modules/foundation/hooks.js +30 -4
  29. package/dist/modules/foundation/integrity.js +11 -3
  30. package/dist/modules/foundation/laws.js +5 -0
  31. package/dist/modules/lawbook/assets/commands/cortex.md +1 -1
  32. package/dist/modules/lawbook/assets/rules/spec-reports-disciplines.md +6 -0
  33. package/dist/modules/lawbook/assets/rules/spec-tasks-mandatory-steps.md +6 -0
  34. package/dist/modules/lawbook/assets/skills/cortex/SKILL.md +17 -5
  35. package/dist/modules/lawbook/bugfix.js +12 -4
  36. package/dist/modules/lawbook/change-tool.js +7 -0
  37. package/dist/modules/lawbook/engine.js +3 -0
  38. package/dist/modules/lawbook/levels.js +35 -4
  39. package/dist/modules/lawbook/quick.js +17 -6
  40. package/dist/modules/lawbook/ship.js +325 -0
  41. package/dist/shared/mcp.js +6 -4
  42. package/dist/shared/output-budget.js +36 -12
  43. package/package.json +1 -1
@@ -1,13 +1,25 @@
1
1
  ---
2
2
  name: cortex
3
- description: Coordinate Cortex — One brain. Many agents. Spawn or adopt explorer, planner, implementer, reviewer, tester, archiver until archive. Use for non-trivial work: "build X", "implement the change", "run cortex", "orchestrate X".
3
+ description: Cortex — One brain. Many agents. The standard way to build any change, as fast as one agent: Compass + laws as guardrails, evidence shipped by hook, extra agents only in parallel.
4
4
  ---
5
5
 
6
6
  # Cortex — One brain. Many agents.
7
7
 
8
- You are the **coordinator**. Do not implement product code yourself.
8
+ One brain does the critical path; mechanical work costs no agent turns.
9
9
 
10
- Drive state with the `cortex` MCP tool (`status` / `start` / `advance` /
11
- `rework` / `brief`) or `speclaw cortex …`. Command: `/lawbook/cortex`.
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.
12
20
 
13
- Read `steps/01-load-or-start.md` and do only what it says.
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.
23
+
24
+ Only when the work splits into three or more large, independent parts, read
25
+ `steps/01-load-or-start.md` (fan-out lane: parallel agents, never a chain).
@@ -125,7 +125,7 @@ function bugfixTemplate(name, level, seed) {
125
125
  "<Run compass_impact on the confirmed root cause; list modules and call sites.>";
126
126
  return `# Bugfix: ${name}
127
127
 
128
- **Level:** ${level} · **Type:** bug · **Severity:** normal
128
+ **Level:** ${level ?? "unconfirmed"} · **Type:** bug · **Severity:** normal
129
129
 
130
130
  ## 1. Observed symptom
131
131
  ${symptom}
@@ -150,18 +150,26 @@ ${blast}
150
150
  `;
151
151
  }
152
152
  /**
153
- * Scaffold a bug change: `bugfix.md`, `change.json`, and `reports/`.
153
+ * Scaffold a bug change: `bugfix.md`, `tasks.md` (level ≥ 1 or unconfirmed),
154
+ * `design.md` (level ≥ 2), `change.json`, and `reports/`.
155
+ *
156
+ * A supplied `level` is recorded as the confirmed level. Without one the draft
157
+ * stays unconfirmed: `change.json` holds the proposal and `changeType` only, so
158
+ * validate and Cortex treat it as level 3 until a human sets a level.
154
159
  */
160
+ // Covers: req~bug-draft-unconfirmed~1
155
161
  export function scaffoldBugfix(projectPath, name, opts = {}) {
156
162
  const r = scaffoldChange(projectPath, name, {
157
163
  changeType: "bug",
158
164
  targets: opts.targets,
159
- level: (proposal) => opts.level ?? (proposal.level !== null && proposal.level <= 1 ? proposal.level : 1),
165
+ level: opts.level,
160
166
  reportsReadme: `# Reports — ${name}\n\nBug reports MUST include the regression test **failing before the fix**.\n`,
161
167
  artifacts: ({ level }) => {
168
+ // Unconfirmed: the level-1 set (bugfix.md + tasks.md); design.md only
169
+ // once a level of 2 or more is confirmed.
162
170
  const lvl = level ?? 1;
163
171
  const files = {
164
- "bugfix.md": bugfixTemplate(name, lvl, opts.seed),
172
+ "bugfix.md": bugfixTemplate(name, level, opts.seed),
165
173
  };
166
174
  if (lvl >= 1) {
167
175
  files["tasks.md"] =
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { specInit, specValidate, specSync, specArchive, specList } from "./engine.js";
3
3
  import { handleLevel } from "./quick.js";
4
+ import { shipChange } from "./ship.js";
4
5
  import { scaffoldBugfix } from "./bugfix.js";
5
6
  import { scaffoldFeature } from "./scaffold-change.js";
6
7
  import { buildCoverageReport, loadCoverageConfig, renderCoverageAgent } from "./coverage.js";
@@ -18,6 +19,7 @@ export const lawbookChangeActions = [
18
19
  "coverage",
19
20
  "drift",
20
21
  "harness",
22
+ "ship",
21
23
  ];
22
24
  export const lawbookChangeSchema = {
23
25
  projectPath: z.string(),
@@ -78,6 +80,11 @@ export function handleLawbookChange(args) {
78
80
  return specSync(args.projectPath, requireField(args, "change"));
79
81
  case "archive":
80
82
  return specArchive(args.projectPath, requireField(args, "change"), requireField(args, "date"));
83
+ case "ship":
84
+ return shipChange(args.projectPath, requireField(args, "change"), {
85
+ summary: args.note,
86
+ date: args.date,
87
+ });
81
88
  case "level":
82
89
  if (!args.mode)
83
90
  throw new Error(`lawbook_change: action 'level' requires 'mode'`);
@@ -484,7 +484,10 @@ export function specArchive(projectPath, change, date) {
484
484
  }
485
485
  }
486
486
  const bugPath = path.join(changeDir, "bugfix.md");
487
+ // A change that carries delta specs always promotes them; skipping would
488
+ // archive the change while canonical specs silently stay stale.
487
489
  const shouldSync = needs.deltaSpecs ||
490
+ fs.existsSync(path.join(changeDir, "specs")) ||
488
491
  (changeType === "bug" &&
489
492
  fs.existsSync(bugPath) &&
490
493
  preventionRequiresDelta(fs.readFileSync(bugPath, "utf8")));
@@ -173,7 +173,10 @@ export function explain(s, t, score, level) {
173
173
  return `${parts.join(", ")} → score ${score} → level ${lvl} (cuts ${t.cuts.join("/")})`;
174
174
  }
175
175
  export function proposeLevel(s, t = DEFAULT_THRESHOLDS) {
176
- if (s.degraded.includes("no-index") && s.filesTouched === 0 && s.blastRadiusNodes === 0) {
176
+ // Covers: req~level-proposal-preserved~1
177
+ // An empty target set measured nothing: never a "small level from ignorance".
178
+ if (s.degraded.includes("no-targets") ||
179
+ (s.degraded.includes("no-index") && s.filesTouched === 0 && s.blastRadiusNodes === 0)) {
177
180
  return {
178
181
  level: null,
179
182
  score: 0,
@@ -214,10 +217,13 @@ export function countModules(paths) {
214
217
  }
215
218
  /**
216
219
  * Build signals from an explicit target list. When the Compass index is missing,
217
- * marks `no-index` and does not invent a small blast radius.
220
+ * marks `no-index` and does not invent a small blast radius; an empty target
221
+ * list marks `no-targets` (so {@link proposeLevel} proposes no level).
218
222
  */
219
223
  export function gatherSignals(projectPath, targets, t = DEFAULT_THRESHOLDS) {
220
224
  const degraded = [];
225
+ if (targets.paths.length === 0 && targets.symbols.length === 0)
226
+ degraded.push("no-targets");
221
227
  const paths = new Set(targets.paths.map((p) => p.replace(/^\.\//, "").split("\\").join("/")));
222
228
  if (!indexExists(projectPath)) {
223
229
  degraded.push("no-index");
@@ -372,6 +378,22 @@ export function writeCeremonyRecord(projectPath, change, record) {
372
378
  fs.mkdirSync(path.dirname(p), { recursive: true });
373
379
  fs.writeFileSync(p, JSON.stringify(record, null, 2) + "\n");
374
380
  }
381
+ /**
382
+ * The proposal fields stored in a change's `change.json`, or null when there
383
+ * is none (no file, or a record without a proposal).
384
+ */
385
+ export function readStoredProposal(projectPath, change) {
386
+ const rec = readCeremonyRecord(projectPath, change);
387
+ if (!rec || rec.signals === undefined)
388
+ return null;
389
+ return {
390
+ level: rec.level ?? null,
391
+ score: rec.score ?? 0,
392
+ signals: rec.signals,
393
+ rationale: rec.rationale ?? "",
394
+ degraded: rec.degraded ?? [],
395
+ };
396
+ }
375
397
  export function setCeremonyLevel(projectPath, change, opts) {
376
398
  const proposed = opts.proposal.level;
377
399
  if (proposed !== null && opts.level < proposed && !opts.reason) {
@@ -391,19 +413,28 @@ export function setCeremonyLevel(projectPath, change, opts) {
391
413
  writeCeremonyRecord(projectPath, change, record);
392
414
  return record;
393
415
  }
394
- export function promoteCeremonyLevel(projectPath, change, to, reason) {
416
+ /**
417
+ * Raise a confirmed level, appending a promotion. The stored proposal fields
418
+ * are kept unless `proposal` (measured from explicit targets) replaces them.
419
+ */
420
+ // Covers: req~level-proposal-preserved~1
421
+ export function promoteCeremonyLevel(projectPath, change, to, reason, proposal) {
395
422
  const prev = readCeremonyRecord(projectPath, change);
396
423
  if (!prev)
397
424
  throw new Error(`change "${change}" has no change.json to promote`);
425
+ if (prev.confirmedLevel === undefined) {
426
+ throw new Error(`change "${change}" has no confirmed level to promote — use mode 'set'`);
427
+ }
398
428
  if (to <= prev.confirmedLevel) {
399
429
  throw new Error(`promote requires a higher level than ${prev.confirmedLevel}`);
400
430
  }
401
431
  const record = {
402
432
  ...prev,
433
+ ...(proposal ?? {}),
403
434
  confirmedLevel: to,
404
435
  confirmedAt: new Date().toISOString(),
405
436
  promotions: [
406
- ...prev.promotions,
437
+ ...(prev.promotions ?? []),
407
438
  { from: prev.confirmedLevel, to, at: new Date().toISOString(), reason },
408
439
  ],
409
440
  };
@@ -1,4 +1,4 @@
1
- import { gatherSignals, loadCeremonyConfig, promoteCeremonyLevel, proposeLevel, setCeremonyLevel, } from "./levels.js";
1
+ import { gatherSignals, loadCeremonyConfig, promoteCeremonyLevel, proposeLevel, readStoredProposal, setCeremonyLevel, } from "./levels.js";
2
2
  import { scaffoldChange } from "./scaffold-change.js";
3
3
  /**
4
4
  * Scaffold a level-0 change: `record.md`, `change.json`, and `reports/`.
@@ -46,23 +46,34 @@ export function scaffoldQuick(projectPath, name, targets = { paths: [], symbols:
46
46
  }
47
47
  /**
48
48
  * Handle `lawbook_level` modes: propose / set / promote / explain.
49
+ *
50
+ * A proposal is measured only when the call names paths or symbols. Without
51
+ * targets, `propose`/`explain` return the `no-targets` proposal (level null),
52
+ * and `set`/`promote` keep the proposal already stored in `change.json`
53
+ * (`set` falls back to the `no-targets` proposal when none is stored).
49
54
  */
55
+ // Covers: req~level-proposal-preserved~1
50
56
  export function handleLevel(args) {
51
57
  const targets = {
52
58
  paths: args.paths ?? [],
53
59
  symbols: args.symbols ?? [],
54
60
  };
55
- const { thresholds } = loadCeremonyConfig(args.projectPath);
56
- const signals = gatherSignals(args.projectPath, targets, thresholds);
57
- const proposal = proposeLevel(signals, thresholds);
61
+ const measured = targets.paths.length > 0 || targets.symbols.length > 0;
62
+ const measure = () => {
63
+ const { thresholds } = loadCeremonyConfig(args.projectPath);
64
+ return proposeLevel(gatherSignals(args.projectPath, targets, thresholds), thresholds);
65
+ };
58
66
  if (args.mode === "propose" || args.mode === "explain") {
59
- return { mode: args.mode, proposal };
67
+ return { mode: args.mode, proposal: measure() };
60
68
  }
61
69
  if (!args.change)
62
70
  throw new Error(`mode '${args.mode}' requires 'change'`);
63
71
  if (args.mode === "set") {
64
72
  if (args.level === undefined)
65
73
  throw new Error("mode 'set' requires 'level'");
74
+ const proposal = measured
75
+ ? measure()
76
+ : (readStoredProposal(args.projectPath, args.change) ?? measure());
66
77
  return setCeremonyLevel(args.projectPath, args.change, {
67
78
  proposal,
68
79
  level: args.level,
@@ -73,5 +84,5 @@ export function handleLevel(args) {
73
84
  // promote
74
85
  if (args.level === undefined)
75
86
  throw new Error("mode 'promote' requires 'level'");
76
- return promoteCeremonyLevel(args.projectPath, args.change, args.level, args.reason ?? "scope grew");
87
+ return promoteCeremonyLevel(args.projectPath, args.change, args.level, args.reason ?? "scope grew", measured ? measure() : undefined);
77
88
  }
@@ -0,0 +1,325 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { createHash } from "node:crypto";
5
+ import { changedFiles, isGitRepo, mergeBase, worktreeChangedFiles } from "../../shared/git.js";
6
+ import { handleHarness, readHarness } from "../cortex/harness.js";
7
+ import { specArchive, specArchivePreconditions } from "./engine.js";
8
+ import { confirmedLevel } from "./levels.js";
9
+ import { scaffoldQuick } from "./quick.js";
10
+ const GATE_TAIL_LINES = 12;
11
+ /**
12
+ * Read the `ship:` block of lawbook/config.yaml (line-oriented, like the other
13
+ * config readers): `gates: [...]` and `discipline: <name>`.
14
+ *
15
+ * @param projectPath - Project root.
16
+ * @returns The configured gates and discipline, each undefined when absent.
17
+ */
18
+ export function readShipConfig(projectPath) {
19
+ const cfg = path.join(projectPath, "lawbook", "config.yaml");
20
+ if (!fs.existsSync(cfg))
21
+ return {};
22
+ const out = {};
23
+ let inShip = false;
24
+ for (const line of fs.readFileSync(cfg, "utf8").split("\n")) {
25
+ if (/^ship:\s*$/.test(line)) {
26
+ inShip = true;
27
+ continue;
28
+ }
29
+ if (inShip && /^\S/.test(line))
30
+ break;
31
+ if (!inShip)
32
+ continue;
33
+ const g = /^\s+gates:\s*\[(.*)\]\s*$/.exec(line);
34
+ if (g) {
35
+ out.gates = g[1]
36
+ .split(",")
37
+ .map((s) => s.trim().replace(/^["']|["']$/g, ""))
38
+ .filter(Boolean);
39
+ }
40
+ const d = /^\s+discipline:\s*["']?([\w-]+)["']?\s*$/.exec(line);
41
+ if (d)
42
+ out.discipline = d[1];
43
+ }
44
+ return out;
45
+ }
46
+ /**
47
+ * Default gates: the project's own `check`, `lint`, `build` and `test`
48
+ * package.json scripts, in that order, when present.
49
+ *
50
+ * @param projectPath - Project root.
51
+ * @returns npm commands for the scripts that exist (empty when none).
52
+ */
53
+ export function detectGates(projectPath) {
54
+ const pkg = path.join(projectPath, "package.json");
55
+ if (!fs.existsSync(pkg))
56
+ return [];
57
+ const scripts = JSON.parse(fs.readFileSync(pkg, "utf8"))
58
+ .scripts;
59
+ if (!scripts)
60
+ return [];
61
+ const picked = ["check", "lint", "build", "test"].filter((s) => s in scripts);
62
+ // `check` usually already runs lint; avoid running it twice.
63
+ const deduped = picked.includes("check") ? picked.filter((s) => s !== "lint") : picked;
64
+ return deduped.map((s) => (s === "test" ? "npm test" : `npm run ${s}`));
65
+ }
66
+ function runGate(projectPath, command) {
67
+ const start = Date.now();
68
+ const r = spawnSync(command, { cwd: projectPath, shell: true, encoding: "utf8" });
69
+ const combined = `${r.stdout ?? ""}${r.stderr ?? ""}`.trimEnd().split("\n");
70
+ return {
71
+ command,
72
+ exitCode: r.status ?? 1,
73
+ durationMs: Date.now() - start,
74
+ tail: combined.slice(-GATE_TAIL_LINES).join("\n"),
75
+ };
76
+ }
77
+ function branchFiles(projectPath) {
78
+ if (!isGitRepo(projectPath))
79
+ return [];
80
+ const base = mergeBase(projectPath, "main") ?? mergeBase(projectPath, "master");
81
+ const committed = base ? changedFiles(projectPath, base) : [];
82
+ return [...new Set([...committed, ...worktreeChangedFiles(projectPath)])]
83
+ .filter((f) => !f.startsWith("lawbook/changes/"))
84
+ .sort();
85
+ }
86
+ function secs(ms) {
87
+ return `${(ms / 1000).toFixed(1)} s`;
88
+ }
89
+ function renderReport(change, discipline, files, gates, passed, date) {
90
+ const rows = gates
91
+ .map((g) => `| \`${g.command}\` | ${g.exitCode === 0 ? "pass" : `FAIL (exit ${g.exitCode})`} | ${secs(g.durationMs)} |`)
92
+ .join("\n");
93
+ const outputs = gates
94
+ .map((g) => `### \`${g.command}\`\n\n\`\`\`text\n${g.tail}\n\`\`\``)
95
+ .join("\n\n");
96
+ return `# ${discipline} report — ${change}
97
+
98
+ **Discipline:** ${discipline} · **Change:** ${change} · **Date:** ${date} · **Generated by:** \`speclaw ship\` from real gate output
99
+
100
+ ## Gates and results
101
+
102
+ | Check | Result | Time |
103
+ |-------|--------|------|
104
+ ${rows || "| (no gates configured) | — | — |"}
105
+
106
+ ## Files changed
107
+
108
+ ${files.length ? files.map((f) => `- \`${f}\``).join("\n") : "- (none detected)"}
109
+
110
+ ## Gate output (tail)
111
+
112
+ ${outputs || "No gate output."}
113
+
114
+ ## Pre-existing failures
115
+
116
+ None recorded.
117
+
118
+ ## Pending manual steps
119
+
120
+ None.
121
+
122
+ ## Verdict
123
+
124
+ ${passed ? "**Gates PASS** — every gate exited 0. Review is not decided here: it happens on the PR." : "**Gates FAIL** — at least one gate failed; nothing was archived."}
125
+ `;
126
+ }
127
+ /**
128
+ * The archived folder of a change (`lawbook/changes/archive/<date>-<name>`),
129
+ * newest first, or null.
130
+ */
131
+ function findArchived(projectPath, name) {
132
+ const dir = path.join(projectPath, "lawbook", "changes", "archive");
133
+ if (!fs.existsSync(dir))
134
+ return null;
135
+ const hit = fs
136
+ .readdirSync(dir)
137
+ .filter((d) => /^\d{4}-\d{2}-\d{2}-/.test(d) && d.slice(11) === name)
138
+ .sort()
139
+ .pop();
140
+ return hit ? path.join("lawbook", "changes", "archive", hit) : null;
141
+ }
142
+ function fillRecord(recordPath, summary) {
143
+ if (!fs.existsSync(recordPath))
144
+ return;
145
+ const body = fs
146
+ .readFileSync(recordPath, "utf8")
147
+ .replace("<!-- 2–5 lines: what and why. -->", summary)
148
+ .replace(/^- \[ \] /gm, "- [x] ");
149
+ fs.writeFileSync(recordPath, body);
150
+ }
151
+ /**
152
+ * Record the test verdict derived from gate exit codes. Ship never records a
153
+ * review verdict: review belongs to a reviewer or the PR, never to the tool
154
+ * that produced the change. Stage moves are plain transitions; the only
155
+ * verdict written is the one at `testing`, and only from passing gates.
156
+ */
157
+ function recordGateVerdict(projectPath, change) {
158
+ if (!readHarness(projectPath, change)) {
159
+ handleHarness({ projectPath, change, harnessOp: "start" });
160
+ }
161
+ for (let i = 0; i < 4; i++) {
162
+ const state = readHarness(projectPath, change);
163
+ if (!state ||
164
+ (state.stage !== "exploring" && state.stage !== "implementing" && state.stage !== "testing")) {
165
+ return;
166
+ }
167
+ handleHarness({
168
+ projectPath,
169
+ change,
170
+ harnessOp: "advance",
171
+ verdict: state.stage === "testing" ? "PASS" : undefined,
172
+ });
173
+ }
174
+ }
175
+ /**
176
+ * Fast path for finished work, in one call: scaffold a level-0 record when the
177
+ * change does not exist, run the project's gates once, and write a discipline
178
+ * report from their real output. At level 0 passing gates are the whole
179
+ * verification, so the change is archived. At level 1+ ship stops after the
180
+ * evidence: review is a human (or reviewer-role) decision taken on the PR, and
181
+ * the archive follows it.
182
+ *
183
+ * @param projectPath - Project root with `lawbook/`.
184
+ * @param name - Change name (kebab-case).
185
+ * @param opts - Summary, gates, discipline, date, and `noArchive`.
186
+ * @returns Gate results, the report path, the archive destination, next steps, and timings.
187
+ */
188
+ export function shipChange(projectPath, name, opts = {}) {
189
+ const t0 = Date.now();
190
+ const cfg = readShipConfig(projectPath);
191
+ const discipline = opts.discipline ?? cfg.discipline ?? "change";
192
+ const date = opts.date ?? new Date().toISOString().slice(0, 10);
193
+ const changeDir = path.join(projectPath, "lawbook", "changes", name);
194
+ const files = branchFiles(projectPath);
195
+ const archived = findArchived(projectPath, name);
196
+ if (!fs.existsSync(changeDir) && !archived) {
197
+ scaffoldQuick(projectPath, name, { paths: [], symbols: [] });
198
+ }
199
+ const summary = opts.summary ??
200
+ (files.length
201
+ ? `Changed ${files.length} file(s): ${files.slice(0, 8).join(", ")}${files.length > 8 ? ", …" : ""}`
202
+ : "No file changes detected.");
203
+ fillRecord(path.join(changeDir, "record.md"), summary);
204
+ const t1 = Date.now();
205
+ const commands = opts.gates ?? cfg.gates ?? detectGates(projectPath);
206
+ const gates = [];
207
+ for (const command of commands) {
208
+ const g = runGate(projectPath, command);
209
+ gates.push(g);
210
+ if (g.exitCode !== 0)
211
+ break;
212
+ }
213
+ const gatesPassed = gates.every((g) => g.exitCode === 0);
214
+ const t2 = Date.now();
215
+ const reportRel = path.join(archived && !fs.existsSync(changeDir) ? archived : path.join("lawbook", "changes", name), "reports", `${discipline}.md`);
216
+ fs.mkdirSync(path.dirname(path.join(projectPath, reportRel)), { recursive: true });
217
+ fs.writeFileSync(path.join(projectPath, reportRel), renderReport(name, discipline, files, gates, gatesPassed, date));
218
+ const t3 = Date.now();
219
+ let archivedTo = null;
220
+ const next = [];
221
+ const level = confirmedLevel(projectPath, name);
222
+ if (!gatesPassed) {
223
+ next.push(`fix the failing gate: ${gates[gates.length - 1].command}`);
224
+ }
225
+ else if (archived && !fs.existsSync(changeDir)) {
226
+ // Already archived on this branch: the refreshed report is the update.
227
+ archivedTo = archived;
228
+ }
229
+ else if (level !== 0) {
230
+ next.push(`level ${level ?? "unconfirmed"}: open the PR — review happens there; archive after approval`);
231
+ }
232
+ else if (!opts.noArchive) {
233
+ recordGateVerdict(projectPath, name);
234
+ const pre = specArchivePreconditions(projectPath, name);
235
+ if (pre.length)
236
+ next.push(...pre);
237
+ else
238
+ archivedTo = specArchive(projectPath, name, date).archivedTo;
239
+ }
240
+ if (gatesPassed && isGitRepo(projectPath))
241
+ markShipped(projectPath);
242
+ const t4 = Date.now();
243
+ const gatesMs = t2 - t1;
244
+ return {
245
+ change: name,
246
+ gatesPassed,
247
+ gates,
248
+ files,
249
+ report: reportRel,
250
+ archivedTo,
251
+ next,
252
+ timings: {
253
+ scaffold: t1 - t0,
254
+ gates: gatesMs,
255
+ report: t3 - t2,
256
+ archive: t4 - t3,
257
+ overhead: t4 - t0 - gatesMs,
258
+ total: t4 - t0,
259
+ },
260
+ };
261
+ }
262
+ /**
263
+ * Change name for a branch: the part after the last `/`, kebab-cased
264
+ * (`fix/compass-query-output` → `compass-query-output`).
265
+ *
266
+ * @param branch - Git branch name.
267
+ * @returns The change name.
268
+ */
269
+ export function changeNameForBranch(branch) {
270
+ return (branch
271
+ .split("/")
272
+ .pop()
273
+ .toLowerCase()
274
+ .replace(/[^a-z0-9-]+/g, "-")
275
+ .replace(/^-+|-+$/g, "") || "change");
276
+ }
277
+ function git(projectPath, args) {
278
+ const r = spawnSync("git", args, { cwd: projectPath, encoding: "utf8" });
279
+ return r.status === 0 ? r.stdout : "";
280
+ }
281
+ /** Fingerprint of the last shipped work; a manual ship and the hook share it. */
282
+ const SHIP_MARKER = path.join(".speclaw", "ship-last");
283
+ function markShipped(projectPath) {
284
+ const marker = path.join(projectPath, SHIP_MARKER);
285
+ fs.mkdirSync(path.dirname(marker), { recursive: true });
286
+ fs.writeFileSync(marker, workFingerprint(projectPath));
287
+ }
288
+ /**
289
+ * Fingerprint of the work outside `lawbook/changes/`: the diff against HEAD
290
+ * plus untracked file names. Ship writes under `lawbook/changes/`, so its own
291
+ * output never changes the fingerprint.
292
+ */
293
+ function workFingerprint(projectPath) {
294
+ const diff = git(projectPath, ["diff", "HEAD", "--", ".", ":(exclude)lawbook/changes"]);
295
+ const untracked = git(projectPath, ["ls-files", "--others", "--exclude-standard"])
296
+ .split("\n")
297
+ .filter((f) => f && !f.startsWith("lawbook/changes/") && !f.startsWith(".speclaw/"))
298
+ .join("\n");
299
+ const head = git(projectPath, ["rev-parse", "HEAD"]).trim();
300
+ return createHash("sha256").update(`${head}\n${diff}\n${untracked}`).digest("hex");
301
+ }
302
+ /**
303
+ * The `Stop` hook body: when the agent ends a turn on a feature branch with
304
+ * work that changed since the last ship, run {@link shipChange} for the change
305
+ * named after the branch. Skips in milliseconds otherwise, so a turn that only
306
+ * answered a question costs nothing.
307
+ *
308
+ * @param projectPath - Project root.
309
+ * @returns Why it skipped, or the ship result.
310
+ */
311
+ export function shipOnStop(projectPath) {
312
+ if (!isGitRepo(projectPath))
313
+ return { skipped: "not-git" };
314
+ const branch = git(projectPath, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
315
+ if (!branch || ["main", "master", "HEAD"].includes(branch))
316
+ return { skipped: "base-branch" };
317
+ if (branchFiles(projectPath).length === 0)
318
+ return { skipped: "no-changes" };
319
+ const marker = path.join(projectPath, SHIP_MARKER);
320
+ if (fs.existsSync(marker) && fs.readFileSync(marker, "utf8") === workFingerprint(projectPath)) {
321
+ return { skipped: "unchanged-since-last-ship" };
322
+ }
323
+ const change = changeNameForBranch(branch);
324
+ return { skipped: null, change, result: shipChange(projectPath, change) };
325
+ }
@@ -3,15 +3,17 @@ import { toolDefinitionTokens } from "./schema-tokens.js";
3
3
  import { countWords } from "./tokens.js";
4
4
  import { applyTextBudget } from "./output-budget.js";
5
5
  /**
6
- * Wrap a value as an MCP text tool-result, optionally applying an output budget.
6
+ * Wrap a value as an MCP text tool-result, applying an output budget.
7
7
  *
8
8
  * @param value - Payload to return; strings are emitted verbatim, other values are pretty-printed as JSON.
9
- * @param mode - Output mode (`brief` default) for token budgeting.
9
+ * @param budget - Output mode (`brief` default, unchanged for callers that pass
10
+ * none) or an explicit `{ maxTokens }` cap a handler opted into.
10
11
  * @returns An MCP result object with a single text content block.
11
12
  */
12
- export function text(value, mode = "brief") {
13
+ // Covers: req~find-response-budget~1
14
+ export function text(value, budget = "brief") {
13
15
  const raw = typeof value === "string" ? value : JSON.stringify(value, null, 2);
14
- const budgeted = applyTextBudget(raw, mode);
16
+ const budgeted = applyTextBudget(raw, budget);
15
17
  return {
16
18
  content: [
17
19
  {