@esneiderbravo/speclaw 2.0.12 → 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 (38) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/commands/lawbook.js +4 -1
  3. package/dist/cli/commands/query.js +2 -2
  4. package/dist/cli/commands/ship-on-stop.js +46 -0
  5. package/dist/cli/commands/ship.js +44 -0
  6. package/dist/cli/commands/update.js +25 -0
  7. package/dist/cli/index.js +6 -1
  8. package/dist/cli/lib/help.js +35 -3
  9. package/dist/modules/compass/db.js +47 -9
  10. package/dist/modules/compass/explore-rich.js +50 -9
  11. package/dist/modules/compass/extract.js +220 -0
  12. package/dist/modules/compass/find-output.js +172 -0
  13. package/dist/modules/compass/hybrid.js +149 -21
  14. package/dist/modules/compass/indexer.js +26 -2
  15. package/dist/modules/compass/query.js +48 -12
  16. package/dist/modules/compass/register.js +13 -7
  17. package/dist/modules/cortex/harness.js +17 -4
  18. package/dist/modules/cortex/status.js +1 -1
  19. package/dist/modules/foundation/assets/AGENTS.template.md +10 -9
  20. package/dist/modules/foundation/assets/CLAUDE.template.md +13 -10
  21. package/dist/modules/foundation/assets/docs/compass.template.md +11 -4
  22. package/dist/modules/foundation/deps.js +5 -3
  23. package/dist/modules/foundation/graph.js +5 -5
  24. package/dist/modules/foundation/hooks.js +30 -4
  25. package/dist/modules/foundation/laws.js +5 -0
  26. package/dist/modules/lawbook/assets/commands/cortex.md +1 -1
  27. package/dist/modules/lawbook/assets/rules/spec-reports-disciplines.md +6 -0
  28. package/dist/modules/lawbook/assets/rules/spec-tasks-mandatory-steps.md +6 -0
  29. package/dist/modules/lawbook/assets/skills/cortex/SKILL.md +17 -5
  30. package/dist/modules/lawbook/bugfix.js +12 -4
  31. package/dist/modules/lawbook/change-tool.js +7 -0
  32. package/dist/modules/lawbook/engine.js +3 -0
  33. package/dist/modules/lawbook/levels.js +35 -4
  34. package/dist/modules/lawbook/quick.js +17 -6
  35. package/dist/modules/lawbook/ship.js +325 -0
  36. package/dist/shared/mcp.js +6 -4
  37. package/dist/shared/output-budget.js +36 -12
  38. package/package.json +1 -1
@@ -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
  {
@@ -3,26 +3,43 @@ export const OUTPUT_BUDGET = {
3
3
  brief: 1500,
4
4
  full: 4500,
5
5
  };
6
+ /** Brief-mode suffix: the caller can still widen the response with `mode:"full"`. */
6
7
  const TRUNCATION_SUFFIX = `\n… [truncated — use mode:"full" or narrow includes]`;
8
+ /** Suffix under the full ceiling or an explicit cap: widening the mode would not help. */
9
+ const FINAL_TRUNCATION_SUFFIX = `\n… [truncated — narrow includes or the query]`;
7
10
  /** Stable offline token estimate for budgeting (not a real tokenizer). */
8
11
  export function estimateTokens(text) {
9
12
  return Math.ceil(text.length / 4);
10
13
  }
11
14
  /**
12
- * Trim `text` to fit `mode` budget; returns the possibly shortened text and
13
- * whether truncation occurred.
15
+ * Token ceiling of a {@link TextBudget}.
16
+ *
17
+ * @param budget - Output mode or explicit cap.
18
+ */
19
+ export function budgetTokens(budget) {
20
+ return typeof budget === "string" ? OUTPUT_BUDGET[budget] : Math.max(1, budget.maxTokens);
21
+ }
22
+ /**
23
+ * Trim `text` to fit `budget`; returns the possibly shortened text and whether
24
+ * truncation occurred. Under `brief` the suffix suggests `mode:"full"`; under
25
+ * `full` or an explicit cap it never does, since that would not widen anything.
26
+ *
27
+ * @param text - Text to budget.
28
+ * @param budget - Output mode (`brief` default) or `{ maxTokens }`.
14
29
  */
15
- export function applyTextBudget(text, mode = "brief") {
16
- const budget = OUTPUT_BUDGET[mode];
30
+ // Covers: req~find-response-budget~1
31
+ export function applyTextBudget(text, budget = "brief") {
32
+ const cap = budgetTokens(budget);
17
33
  const tokens = estimateTokens(text);
18
- if (tokens <= budget)
34
+ if (tokens <= cap)
19
35
  return { text, truncated: false, omittedChars: 0 };
20
- const suffixTokens = estimateTokens(TRUNCATION_SUFFIX);
21
- const bodyBudget = Math.max(1, budget - suffixTokens);
36
+ const suffix = budget === "brief" ? TRUNCATION_SUFFIX : FINAL_TRUNCATION_SUFFIX;
37
+ const suffixTokens = estimateTokens(suffix);
38
+ const bodyBudget = Math.max(1, cap - suffixTokens);
22
39
  const maxChars = bodyBudget * 4;
23
40
  const trimmed = text.slice(0, maxChars);
24
41
  return {
25
- text: trimmed + TRUNCATION_SUFFIX,
42
+ text: trimmed + suffix,
26
43
  truncated: true,
27
44
  omittedChars: text.length - maxChars,
28
45
  };
@@ -37,8 +54,11 @@ export function applyTextBudget(text, mode = "brief") {
37
54
  * @param truncated - Collector for explicit truncation records.
38
55
  */
39
56
  export function budgetExploreShape(value, mode, truncated) {
40
- const maxCallers = mode === "full" ? 40 : 12;
41
- const maxSourceLines = mode === "full" ? 120 : 40;
57
+ // Full mode sizes source to the full-mode token ceiling (~4500 tokens), so a
58
+ // typical long function arrives whole; its hints cannot point at mode:"full".
59
+ const full = mode === "full";
60
+ const maxCallers = full ? 40 : 12;
61
+ const maxSourceLines = full ? 400 : 40;
42
62
  const symbol = value.symbol;
43
63
  if (symbol && typeof symbol.source === "string") {
44
64
  const lines = symbol.source.split("\n");
@@ -48,7 +68,9 @@ export function budgetExploreShape(value, mode, truncated) {
48
68
  truncated.push({
49
69
  field: "symbol.source",
50
70
  omitted,
51
- hint: 'use mode:"full" or omit source from include',
71
+ hint: full
72
+ ? "read the remaining lines from the file by startLine/endLine"
73
+ : 'use mode:"full" or omit source from include',
52
74
  });
53
75
  }
54
76
  }
@@ -63,7 +85,9 @@ export function budgetExploreShape(value, mode, truncated) {
63
85
  truncated.push({
64
86
  field,
65
87
  omitted,
66
- hint: 'use mode:"full" or narrow with include',
88
+ hint: full
89
+ ? "use compass_impact for the full list"
90
+ : 'use mode:"full" or narrow with include',
67
91
  });
68
92
  }
69
93
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "2.0.12",
3
+ "version": "2.0.13",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },