@esneiderbravo/speclaw 2.0.24 → 2.0.25

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.
@@ -1,11 +1,12 @@
1
- import { measureBranchDiff, shipOnStop } from "../../modules/lawbook/ship.js";
1
+ import { measureBranchDiff, shipOnStop, stopSummary } from "../../modules/lawbook/ship.js";
2
2
  /**
3
3
  * `speclaw ship-on-stop`: the Claude Code `Stop` hook. Ships the branch's
4
- * change when the work changed since the last ship; prints nothing on stdout.
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
7
- * — once: when the hook already blocked this stop (`stop_hook_active`), it
8
- * exits 0 so the agent can never loop. Any other failure exits 0.
4
+ * change when the work changed since the last ship, and prints one JSON line
5
+ * on stdout whose `systemMessage` tells the user what happened. Artifacts the
6
+ * change's level still owes, or a failing gate, also set `decision: "block"`
7
+ * with the `reason` the agent acts on — once: when the hook already blocked
8
+ * this stop (`stop_hook_active`), it does not block again, so the agent can
9
+ * never loop. A skipped stop prints nothing. It always exits 0.
9
10
  *
10
11
  * @param cwd - Project root (defaults to the process working directory).
11
12
  */
@@ -14,15 +15,23 @@ export async function runShipOnStop(cwd = process.cwd()) {
14
15
  const active = await stopHookActive();
15
16
  try {
16
17
  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
- }
21
- if (out.skipped === null && !out.result.gatesPassed && !active) {
22
- const failed = out.result.gates[out.result.gates.length - 1];
23
- process.stderr.write(`speclaw: gate failed — ${failed.command} (exit ${failed.exitCode}). Fix it before finishing.\n${failed.tail}\n`);
24
- process.exit(2);
25
- }
18
+ if (out.skipped !== null)
19
+ return;
20
+ const r = out.result;
21
+ const block = !active && (r.pending.length > 0 || !r.gatesPassed);
22
+ // Claude Code reads stdout JSON only on exit 0, so a blocked stop says
23
+ // `decision: block` there too: the reason goes back to the agent and the
24
+ // systemMessage reaches the user either way.
25
+ const failed = r.gates[r.gates.length - 1];
26
+ const reason = !block
27
+ ? undefined
28
+ : r.pending.length
29
+ ? `speclaw: ${r.next.join("\n- ")}`
30
+ : `speclaw: gate failed — ${failed.command} (exit ${failed.exitCode}). Fix it before finishing.\n${failed.tail}`;
31
+ process.stdout.write(JSON.stringify({
32
+ ...(reason ? { decision: "block", reason } : {}),
33
+ systemMessage: stopSummary(r, block),
34
+ }) + "\n");
26
35
  }
27
36
  catch {
28
37
  // A hook must never break the session.
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { openDb, FILE_NODE_KIND } from "./db.js";
4
4
  import { estimateTokens } from "../../shared/tokens.js";
5
5
  import { loadDeclaredBudget } from "../../shared/exposure.js";
6
+ import { packageEntries } from "../../shared/package-entries.js";
6
7
  export const MAP_START = "<!-- speclaw:map:start -->";
7
8
  export const MAP_END = "<!-- speclaw:map:end -->";
8
9
  /**
@@ -45,12 +46,15 @@ export function generateCompactMap(projectPath) {
45
46
  ? "hubs: (none yet)"
46
47
  : `hubs: ${hubs.map((h) => `${h.name} ${h.fan_in}`).join(" · ")}`;
47
48
  const dirLine = topDirs.map((d) => `${d.top}/ (${d.c})`).join(" ");
48
- let body = [
49
- `speclaw · ${fileCount} files · ${nodeCount} nodes`,
50
- dirLine,
51
- hubLine,
52
- "entry: src/server.ts (mcp) · src/cli/index.ts (bin)",
53
- ].join("\n");
49
+ // Only what this project's package.json declares; a workspace root or an
50
+ // app declares no entry, and the map then names none.
51
+ const entries = packageEntries(projectPath);
52
+ const entryLine = entries.length
53
+ ? `entry: ${entries.map((e) => `${e.file} (${e.kind})`).join(" · ")}`
54
+ : "";
55
+ let body = [`speclaw · ${fileCount} files · ${nodeCount} nodes`, dirLine, hubLine, entryLine]
56
+ .filter(Boolean)
57
+ .join("\n");
54
58
  const cap = loadDeclaredBudget().map;
55
59
  let omitted = false;
56
60
  while (estimateTokens(body) > cap && hubs.length > 3) {
@@ -63,7 +67,7 @@ export function generateCompactMap(projectPath) {
63
67
  `speclaw · ${fileCount} files · ${nodeCount} nodes`,
64
68
  dirLine,
65
69
  shorter,
66
- "entry: src/server.ts (mcp) · src/cli/index.ts (bin)",
70
+ entryLine,
67
71
  omitted ? "(entries omitted to fit map budget)" : "",
68
72
  ]
69
73
  .filter(Boolean)
@@ -72,9 +76,11 @@ export function generateCompactMap(projectPath) {
72
76
  if (estimateTokens(body) > cap) {
73
77
  body = [
74
78
  `speclaw · ${fileCount} files · ${nodeCount} nodes`,
75
- "entry: src/server.ts (mcp) · src/cli/index.ts (bin)",
79
+ entryLine,
76
80
  "(entries omitted to fit map budget)",
77
- ].join("\n");
81
+ ]
82
+ .filter(Boolean)
83
+ .join("\n");
78
84
  }
79
85
  return body;
80
86
  }
@@ -51,7 +51,8 @@ export const SHIP_ON_STOP_MARKER = "speclaw ship-on-stop";
51
51
  * with new changes on a feature branch, speclaw sizes the change from its diff,
52
52
  * returns the artifacts that level owes, runs the gates once, writes the report
53
53
  * from their real output, and archives level-0 work — with no agent turn. Guarded on `lawbook/` instead of the index. Its
54
- * stderr is kept so a failing gate reaches the agent (exit 2).
54
+ * stdout is kept: one JSON line blocks the stop with the reason the agent acts
55
+ * on (owed artifacts, a failing gate) and tells the user what the stop did.
55
56
  */
56
57
  export const SHIP_ON_STOP_COMMAND = speclawCommand("ship-on-stop");
57
58
  /** Seconds the Stop hook may run: the project's own gates run inside it. */
@@ -72,10 +73,11 @@ export function speclawCommand(sub) {
72
73
  const run = `if [ -x node_modules/.bin/speclaw ]; then node_modules/.bin/speclaw ${sub}; ` +
73
74
  `elif command -v speclaw >/dev/null 2>&1; then speclaw ${sub}; ` +
74
75
  `else npm_config_update_notifier=false npm_config_offline=true npx --no-install @esneiderbravo/speclaw ${sub}; fi; `;
75
- // The ship hook keeps its exit code (2 feeds a failing gate back to the
76
- // agent) and its stderr; stdout is discarded like the other hooks.
76
+ // The ship hook keeps its stdout and stderr: the one JSON line it prints
77
+ // blocks the stop when the agent owes something and tells the user what
78
+ // the stop did.
77
79
  return ship
78
- ? 'cd "${CLAUDE_PROJECT_DIR:-.}" 2>/dev/null && [ -d lawbook ] && { ' + run + "} >/dev/null"
80
+ ? 'cd "${CLAUDE_PROJECT_DIR:-.}" 2>/dev/null && [ -d lawbook ] && { ' + run + "}"
79
81
  : 'cd "${CLAUDE_PROJECT_DIR:-.}" 2>/dev/null && [ -f .speclaw/index.db ] && { ' +
80
82
  run +
81
83
  "} >/dev/null 2>&1 || true";
@@ -0,0 +1,130 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { mergeBase } from "../../shared/git.js";
5
+ import { isTestPath, loadAffectedConfig } from "../compass/affected-config.js";
6
+ /**
7
+ * Lines that declare an HTTP route or a request/response contract, across the
8
+ * frameworks a repo is likely to use. A diff line matching one of them adds,
9
+ * changes or removes an endpoint, so the change owes an API report. Lines are
10
+ * trimmed; decorators must open the line, as they do in code, so a string or a
11
+ * regex that merely mentions one is not a route.
12
+ */
13
+ const ROUTE_LINE = [
14
+ // NestJS controllers and handlers.
15
+ /^@(Controller|Get|Post|Put|Patch|Delete|Head|Options|All|HttpCode|Header|Redirect|Sse)\s*\(/,
16
+ // Spring.
17
+ /^@(RestController|RequestMapping|GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping)\b/,
18
+ // FastAPI, Flask and similar decorator routers.
19
+ /^@\w+\.(get|post|put|patch|delete|route|api_route|websocket)\s*\(/,
20
+ // Express, Koa, Fastify, Hono: `router.get("/x", handler)` — a path and a
21
+ // handler, so a client call (`api.get("/users")`) or `app.get("port")` is not one.
22
+ /^(\w+\.)?(app|router|server|fastify|routes?)\.(get|post|put|patch|delete|all)\s*\(\s*["'`]\/[^"'`]*["'`]\s*,/,
23
+ // Go net/http, gin, echo, chi: `r.GET("/x", …)`, `mux.HandleFunc("/x", …)`.
24
+ /^\w+\.(GET|POST|PUT|PATCH|DELETE|HandleFunc|Handle)\s*\(\s*"\//,
25
+ ];
26
+ /** Next.js App Router handlers: an exported HTTP-verb function in a `route.*` file. */
27
+ const NEXT_ROUTE_FILE = /(^|\/)app\/(.+\/)?route\.[cm]?[jt]sx?$/;
28
+ const NEXT_HANDLER = /export\s+(async\s+)?(function|const)\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b/;
29
+ /** Files that are the contract themselves: any change to them is an API change. */
30
+ const CONTRACT_FILE = /(^|\/)(openapi|swagger)[^/]*\.(ya?ml|json)$|\.(proto|graphql|gql)$/i;
31
+ /** Request/response shapes: a code (not comment) change in a DTO file. */
32
+ const DTO_FILE = /(^|\/)dtos?\/|\.dto\.[cm]?[jt]s$|(^|\/)schemas?\/.+\.py$/;
33
+ const COMMENT_OR_BLANK = /^\s*(\/\/|\/\*|\*|#|$)/;
34
+ const LINES_PER_FILE = 4;
35
+ /**
36
+ * The changed files whose diff against the branch base adds, changes or
37
+ * removes an API surface: a route declaration, a contract file, or a DTO. Test
38
+ * files never count. Reads one `git diff -U0` for every candidate at once and
39
+ * untracked files whole; never throws.
40
+ *
41
+ * @param projectPath - Project root (a git repository).
42
+ * @param files - Changed files, relative to the root.
43
+ * @returns The files that touch the API surface, in the order given.
44
+ */
45
+ export function apiSurfaceChanges(projectPath, files) {
46
+ try {
47
+ const { testGlobs } = loadAffectedConfig(projectPath);
48
+ const candidates = files.filter((f) => !isTestPath(f, testGlobs));
49
+ if (candidates.length === 0)
50
+ return [];
51
+ const changed = changedLines(projectPath, candidates);
52
+ const hits = [];
53
+ for (const file of candidates) {
54
+ const lines = changed.get(file) ?? [];
55
+ if (CONTRACT_FILE.test(file)) {
56
+ hits.push({ file, lines: [] });
57
+ continue;
58
+ }
59
+ const surface = lines.filter((l) => declaresSurface(file, l));
60
+ if (surface.length)
61
+ hits.push({ file, lines: surface.slice(0, LINES_PER_FILE) });
62
+ }
63
+ return hits;
64
+ }
65
+ catch {
66
+ return [];
67
+ }
68
+ }
69
+ function declaresSurface(file, line) {
70
+ if (ROUTE_LINE.some((re) => re.test(line)))
71
+ return true;
72
+ if (NEXT_ROUTE_FILE.test(file) && NEXT_HANDLER.test(line))
73
+ return true;
74
+ return DTO_FILE.test(file) && !COMMENT_OR_BLANK.test(line);
75
+ }
76
+ /**
77
+ * Added and removed lines per file against the merge base with `main`/`master`
78
+ * (working tree included); an untracked file contributes every line.
79
+ */
80
+ function changedLines(projectPath, files) {
81
+ const out = new Map();
82
+ const base = mergeBase(projectPath, "main") ?? mergeBase(projectPath, "master");
83
+ if (base) {
84
+ const r = spawnSync("git", [
85
+ "-c",
86
+ "core.quotePath=false",
87
+ "diff",
88
+ "-U0",
89
+ "--no-color",
90
+ "--no-ext-diff",
91
+ "--no-renames",
92
+ "--src-prefix=a/",
93
+ "--dst-prefix=b/",
94
+ base,
95
+ "--",
96
+ ...files,
97
+ ], { cwd: projectPath, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
98
+ let current = null;
99
+ for (const line of (r.status === 0 ? r.stdout : "").split("\n")) {
100
+ // A path with a space ends its header with a tab.
101
+ const head = /^(---|\+\+\+) (?:[ab]\/(.+?)|\/dev\/null)\t?$/.exec(line);
102
+ if (head) {
103
+ // A deleted file's `+++ /dev/null` keeps the name its `--- a/` line set.
104
+ if (head[2])
105
+ current = head[2];
106
+ else if (head[1] === "---")
107
+ current = null;
108
+ continue;
109
+ }
110
+ if (current && /^[+-](?![+-]{2} )/.test(line)) {
111
+ out.set(current, [...(out.get(current) ?? []), line.slice(1).trim()]);
112
+ }
113
+ }
114
+ }
115
+ const untracked = spawnSync("git", ["-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard", "--", ...files], { cwd: projectPath, encoding: "utf8" });
116
+ for (const f of (untracked.status === 0 ? untracked.stdout : "").split("\n")) {
117
+ if (!f || out.has(f))
118
+ continue;
119
+ try {
120
+ out.set(f, fs
121
+ .readFileSync(path.join(projectPath, f), "utf8")
122
+ .split("\n")
123
+ .map((l) => l.trim()));
124
+ }
125
+ catch {
126
+ // Vanished since it was listed: nothing to read.
127
+ }
128
+ }
129
+ return out;
130
+ }
@@ -7,6 +7,8 @@ import { affectedTests } from "../compass/affected.js";
7
7
  import { hotspots } from "../compass/hotspots.js";
8
8
  import { loadAffectedConfig, matchGlob, matchesAny, inferModule, } from "../compass/affected-config.js";
9
9
  import { formatJson } from "../../shared/json.js";
10
+ import { packageEntries } from "../../shared/package-entries.js";
11
+ import { apiSurfaceChanges } from "./api-surface.js";
10
12
  /** Default thresholds from the adaptive-ceremony roadmap. */
11
13
  export const DEFAULT_THRESHOLDS = {
12
14
  filesTouched: [0, 1, 3, 5],
@@ -256,8 +258,15 @@ export function gatherSignals(projectPath, targets, t = DEFAULT_THRESHOLDS) {
256
258
  }
257
259
  let blastRadiusNodes = 0;
258
260
  let affected = 0;
259
- let touchesPublicApi = false;
260
261
  let maxHotspotScore = 0;
262
+ // A published entry point (package.json `main`/`bin`) or an HTTP route,
263
+ // DTO or contract file in the diff: either is a surface other code calls.
264
+ const entries = packageEntries(projectPath).map((e) => e.file);
265
+ const apiSurface = pathList.length
266
+ ? apiSurfaceChanges(projectPath, pathList).map((h) => h.file)
267
+ : [];
268
+ const touchesPublicApi = apiSurface.length > 0 ||
269
+ pathList.some((p) => entries.some((e) => p === e || e.endsWith(`/${p}`) || p.endsWith(`/${e}`)));
261
270
  if (indexExists(projectPath) && pathList.length > 0) {
262
271
  try {
263
272
  const imp = impact(projectPath, { files: pathList, format: "grouped", maxDepth: 4 });
@@ -275,32 +284,6 @@ export function gatherSignals(projectPath, targets, t = DEFAULT_THRESHOLDS) {
275
284
  catch {
276
285
  /* soft */
277
286
  }
278
- try {
279
- const pkgPath = path.join(projectPath, "package.json");
280
- if (fs.existsSync(pkgPath)) {
281
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
282
- const entries = new Set();
283
- if (typeof pkg.main === "string")
284
- entries.add(pkg.main.replace(/^\.\//, ""));
285
- if (typeof pkg.bin === "string")
286
- entries.add(pkg.bin.replace(/^\.\//, ""));
287
- else if (pkg.bin && typeof pkg.bin === "object") {
288
- for (const v of Object.values(pkg.bin))
289
- entries.add(String(v).replace(/^\.\//, ""));
290
- }
291
- for (const e of entries) {
292
- if (pathList.some((p) => p === e || e.endsWith(p) || p.endsWith(e))) {
293
- touchesPublicApi = true;
294
- }
295
- }
296
- if (pathList.some((p) => p === "src/cli/index.ts" || p === "src/server.ts")) {
297
- touchesPublicApi = true;
298
- }
299
- }
300
- }
301
- catch {
302
- /* soft */
303
- }
304
287
  try {
305
288
  const hs = hotspots(projectPath, { days: 90, sortBy: "combined", limit: 200 });
306
289
  // The score is relative to the hottest file, so in a young repo any file
@@ -330,6 +313,7 @@ export function gatherSignals(projectPath, targets, t = DEFAULT_THRESHOLDS) {
330
313
  blastRadiusNodes,
331
314
  affectedTests: affected,
332
315
  touchesPublicApi,
316
+ ...(apiSurface.length ? { apiSurface } : {}),
333
317
  maxHotspotScore,
334
318
  touchesGlobalFile,
335
319
  onlyDocs,
@@ -12,6 +12,7 @@ import { deltaSpecFiles, specArchive, specArchivePreconditions, specList } from
12
12
  import { artifactNeeds, confirmedLevel, gatherSignals, loadCeremonyConfig, promoteCeremonyLevel, proposeLevel, readCeremonyRecord, readChangeType, } from "./levels.js";
13
13
  import { scaffoldQuick } from "./quick.js";
14
14
  import { CHANGE_NAME_RE, isPlaceholderDelta, scaffoldFeature } from "./scaffold-change.js";
15
+ import { apiSurfaceChanges } from "./api-surface.js";
15
16
  const GATE_TAIL_LINES = 12;
16
17
  /**
17
18
  * Read the `ship:` block of lawbook/config.yaml (line-oriented, like the other
@@ -98,6 +99,7 @@ function scopedTestGate(projectPath, fullCommand, files) {
98
99
  return {
99
100
  command: at.command,
100
101
  scope: `${at.tests.length} affected test file(s), ${at.skipped.files} skipped; the full suite runs in CI`,
102
+ parts: at.commands.map((c) => ({ cwd: c.cwd, command: c.command, tests: c.files.length })),
101
103
  };
102
104
  }
103
105
  catch (e) {
@@ -177,7 +179,7 @@ function untrackedFiles(projectPath) {
177
179
  function secs(ms) {
178
180
  return `${(ms / 1000).toFixed(1)} s`;
179
181
  }
180
- function renderReport(change, discipline, files, gates, passed, date) {
182
+ function renderReport(change, discipline, files, gates, passed, date, others = []) {
181
183
  const rows = gates
182
184
  .map((g) => `| \`${g.command}\`${g.scope ? ` (${g.scope})` : ""} | ${g.exitCode === 0 ? "pass" : `FAIL (exit ${g.exitCode})`} | ${secs(g.durationMs)} |`)
183
185
  .join("\n");
@@ -196,7 +198,12 @@ ${rows || "| (no gates configured) | — | — |"}
196
198
 
197
199
  ## Files changed
198
200
 
199
- ${files.length ? files.map((f) => `- \`${f}\``).join("\n") : "- (none detected)"}
201
+ ${files.length ? files.map((f) => `- \`${f}\``).join("\n") : "- (none detected)"}${others.length
202
+ ? `\n\nPlus ${others.length} file(s) outside this discipline's package: ${others
203
+ .slice(0, 8)
204
+ .map((f) => `\`${f}\``)
205
+ .join(", ")}${others.length > 8 ? ", …" : ""}`
206
+ : ""}
200
207
 
201
208
  ## Gate output (tail)
202
209
 
@@ -215,6 +222,74 @@ None.
215
222
  ${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."}
216
223
  `;
217
224
  }
225
+ /**
226
+ * The discipline a package's tests report under, named for the package
227
+ * directory: `apps/backend` → `backend`, `apps/web` → `frontend`. A generated
228
+ * report is never `api.md`: that one documents the contract and the agent writes it.
229
+ *
230
+ * @param cwd - Package directory relative to the root (`.` for the root).
231
+ * @param fallback - The discipline for the root package.
232
+ */
233
+ function disciplineOf(cwd, fallback) {
234
+ if (cwd === ".")
235
+ return fallback;
236
+ const leaf = cwd.split("/").pop().toLowerCase();
237
+ if (/^(backend|api|server|service|services)$/.test(leaf))
238
+ return "backend";
239
+ if (/^(web|frontend|client|ui|app|site|webapp)$/.test(leaf))
240
+ return "frontend";
241
+ if (/^(e2e|playwright|cypress|acceptance)$/.test(leaf))
242
+ return "e2e";
243
+ if (/^(mobile|ios|android)$/.test(leaf))
244
+ return "mobile";
245
+ return leaf.replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || fallback;
246
+ }
247
+ /** A path as one shell word. */
248
+ function shellArg(s) {
249
+ return /^[\w./-]+$/.test(s) ? s : `'${s.replace(/'/g, "'\\''")}'`;
250
+ }
251
+ /**
252
+ * Group the gates into reports. Without per-package gates there is one report
253
+ * with every gate and file. Otherwise each discipline gets its own test gates
254
+ * plus the repo-wide ones (lint, build), and the files under its packages.
255
+ */
256
+ function reportGroups(gates, files, fallback, cwds) {
257
+ if (cwds.size === 0)
258
+ return [{ discipline: fallback, gates, files, others: [] }];
259
+ const shared = gates.filter((g) => !g.discipline);
260
+ const under = (f, cwd) => cwd !== "." && f.startsWith(`${cwd}/`);
261
+ const claimed = new Set(files.filter((f) => [...cwds.values()].flat().some((c) => under(f, c))));
262
+ return [...cwds.entries()].map(([d, dirs]) => {
263
+ const own = dirs.includes(".")
264
+ ? files.filter((f) => !claimed.has(f) || dirs.some((c) => under(f, c)))
265
+ : files.filter((f) => dirs.some((c) => under(f, c)));
266
+ return {
267
+ discipline: d,
268
+ gates: [...shared, ...gates.filter((g) => g.discipline === d)],
269
+ files: own,
270
+ others: files.filter((f) => !own.includes(f) && !claimed.has(f)),
271
+ };
272
+ });
273
+ }
274
+ /** Marks a report ship wrote, so a later ship may replace or remove it. */
275
+ const GENERATED_MARK = "**Generated by:** `speclaw ship`";
276
+ /** Remove reports an earlier ship generated that this ship no longer writes. */
277
+ function dropStaleReports(projectPath, reportsRel, kept) {
278
+ const dir = path.join(projectPath, reportsRel);
279
+ for (const f of fs.readdirSync(dir)) {
280
+ const rel = path.join(reportsRel, f);
281
+ if (!f.endsWith(".md") || kept.includes(rel))
282
+ continue;
283
+ try {
284
+ if (fs.readFileSync(path.join(dir, f), "utf8").includes(GENERATED_MARK)) {
285
+ fs.rmSync(path.join(dir, f));
286
+ }
287
+ }
288
+ catch {
289
+ // A report that cannot be read is left as it is.
290
+ }
291
+ }
292
+ }
218
293
  /**
219
294
  * The archived folder of a change (`lawbook/changes/archive/<date>-<name>`),
220
295
  * newest first, or null.
@@ -285,8 +360,8 @@ const SMALL_FIX_LINES = 10;
285
360
  * small fix stays at level 0 however central the code it touches: its blast
286
361
  * radius is guarded by the gates and its test, which ceremony would not add to.
287
362
  */
288
- function measureDiff(projectPath, files) {
289
- const proposal = measureDiffSignals(projectPath, files);
363
+ function measureDiff(projectPath, files, measured) {
364
+ const proposal = measured ?? measureDiffSignals(projectPath, files);
290
365
  if (!proposal.level || proposal.signals.touchesPublicApi || proposal.signals.touchesGlobalFile) {
291
366
  return proposal;
292
367
  }
@@ -342,7 +417,7 @@ function measureDiffSignals(projectPath, files) {
342
417
  try {
343
418
  const file = path.join(projectPath, LEVEL_CACHE);
344
419
  fs.mkdirSync(path.dirname(file), { recursive: true });
345
- fs.writeFileSync(file, JSON.stringify({ key: fileSetKey(files), proposal }) + "\n");
420
+ fs.writeFileSync(file, JSON.stringify({ key: fileSetKey(files), files, proposal }) + "\n");
346
421
  }
347
422
  catch {
348
423
  // The cache only saves time; the measurement stands without it.
@@ -368,6 +443,24 @@ function cachedMeasure(projectPath, files) {
368
443
  return null;
369
444
  }
370
445
  }
446
+ /**
447
+ * The last measurement when the diff has only grown since it was taken: its
448
+ * level is a floor for the current one, since a measured level only rises. A
449
+ * large diff takes seconds to measure and an agent at work outdates every
450
+ * measurement before it lands, so waiting for an exact one never tells it.
451
+ */
452
+ function grownFromCache(projectPath, files) {
453
+ try {
454
+ const c = JSON.parse(fs.readFileSync(path.join(projectPath, LEVEL_CACHE), "utf8"));
455
+ if (!c.proposal || !Array.isArray(c.files) || c.files.length === 0)
456
+ return null;
457
+ const now = new Set(files);
458
+ return c.files.every((f) => now.has(f)) ? c.proposal : null;
459
+ }
460
+ catch {
461
+ return null;
462
+ }
463
+ }
371
464
  /**
372
465
  * Measure the branch's current diff into the level cache (`speclaw
373
466
  * measure-diff`, the edit hook's detached background job).
@@ -414,11 +507,11 @@ function scaffoldMeasured(projectPath, name, files, measured) {
414
507
  }
415
508
  }
416
509
  /** Raise a measured level when the branch diff has grown past it; a human-set level stands. */
417
- function remeasure(projectPath, name, files) {
510
+ function remeasure(projectPath, name, files, measured) {
418
511
  const rec = readCeremonyRecord(projectPath, name);
419
512
  if (rec?.confirmedBy !== "measured")
420
513
  return;
421
- const proposal = measureDiff(projectPath, files);
514
+ const proposal = measured ?? measureDiff(projectPath, files);
422
515
  if (proposal.level !== null && proposal.level > rec.confirmedLevel) {
423
516
  promoteCeremonyLevel(projectPath, name, proposal.level, "the branch diff grew", proposal);
424
517
  }
@@ -431,7 +524,7 @@ function remeasure(projectPath, name, files) {
431
524
  *
432
525
  * @returns Whether the change was reopened.
433
526
  */
434
- function reopenIfGrown(projectPath, name, archivedRel, files) {
527
+ function reopenIfGrown(projectPath, name, archivedRel, files, measured) {
435
528
  const archivedDir = path.join(projectPath, archivedRel);
436
529
  let rec = {};
437
530
  try {
@@ -452,7 +545,7 @@ function reopenIfGrown(projectPath, name, archivedRel, files) {
452
545
  });
453
546
  if (atBase.status === 0)
454
547
  return false;
455
- const proposal = measureDiff(projectPath, files);
548
+ const proposal = measured ?? measureDiff(projectPath, files);
456
549
  if (proposal.level === null || proposal.level <= rec.confirmedLevel)
457
550
  return false;
458
551
  const changeDir = path.join(projectPath, "lawbook", "changes", name);
@@ -486,11 +579,16 @@ const STUBS = {
486
579
  * its record falls back to the commits or the file list, so it costs no turn. Bug-shaped changes keep
487
580
  * their own checks (`bugfix.md`) and are not judged here.
488
581
  *
582
+ * A change whose diff touches an API surface (a route, a DTO, a contract file)
583
+ * also owes `reports/api.md`: the contract is evidence no gate output carries.
584
+ *
489
585
  * @param projectPath - Project root with `lawbook/`.
490
586
  * @param name - Change name.
587
+ * @param files - The branch's changed files; when given, the API surface is
588
+ * read from their diff, else from the change's recorded signals.
491
589
  * @returns One instruction per missing artifact; empty when the change is documented.
492
590
  */
493
- export function pendingArtifacts(projectPath, name) {
591
+ export function pendingArtifacts(projectPath, name, files) {
494
592
  const dir = path.join(projectPath, "lawbook", "changes", name);
495
593
  if (!fs.existsSync(dir) || readChangeType(projectPath, name) === "bug")
496
594
  return [];
@@ -533,6 +631,19 @@ export function pendingArtifacts(projectPath, name) {
533
631
  }
534
632
  }
535
633
  }
634
+ // A report ship generated (`ship.discipline: api`) is gate output, not the contract.
635
+ const apiReport = path.join(dir, "reports", "api.md");
636
+ const apiWritten = fs.existsSync(apiReport) && !fs.readFileSync(apiReport, "utf8").includes(GENERATED_MARK);
637
+ if (level > 0 && !apiWritten) {
638
+ const api = files
639
+ ? apiSurfaceChanges(projectPath, files).map((h) => h.file)
640
+ : (readCeremonyRecord(projectPath, name)?.signals.apiSurface ?? []);
641
+ if (api.length) {
642
+ out.push(`${rel("reports/api.md")}: document the API contract this change touches (${api
643
+ .slice(0, 4)
644
+ .join(", ")}${api.length > 4 ? ", …" : ""}) — method and path, auth, response shape, every status code, and how it was exercised (test client or request against an isolated store)`);
645
+ }
646
+ }
536
647
  return out;
537
648
  }
538
649
  /**
@@ -593,7 +704,7 @@ export function shipChange(projectPath, name, opts = {}) {
593
704
  ? `Changed ${files.length} file(s): ${files.slice(0, 8).join(", ")}${files.length > 8 ? ", …" : ""}`
594
705
  : "No file changes detected.";
595
706
  fillRecord(path.join(changeDir, "record.md"), opts.summary ?? (commits || (confirmedLevel(projectPath, name) === 0 ? fileList : "")));
596
- const pending = pendingArtifacts(projectPath, name);
707
+ const pending = pendingArtifacts(projectPath, name, files);
597
708
  const t1 = Date.now();
598
709
  if (pending.length) {
599
710
  const level = confirmedLevel(projectPath, name);
@@ -604,6 +715,7 @@ export function shipChange(projectPath, name, opts = {}) {
604
715
  gates: [],
605
716
  files,
606
717
  report: null,
718
+ reports: [],
607
719
  pending,
608
720
  archivedTo: null,
609
721
  next: [
@@ -621,6 +733,10 @@ export function shipChange(projectPath, name, opts = {}) {
621
733
  };
622
734
  }
623
735
  const commands = opts.gates ?? cfg.gates ?? detectGates(projectPath);
736
+ // Level 2+ is the spec lane: its evidence is one report per discipline, so
737
+ // tests that span several packages run (and report) package by package.
738
+ const split = (confirmedLevel(projectPath, name) ?? 0) >= 2;
739
+ const cwds = new Map();
624
740
  const gates = [];
625
741
  for (const command of commands) {
626
742
  const scoped = cfg.tests !== "full" && TEST_GATE_RE.test(command.trim())
@@ -630,6 +746,23 @@ export function shipChange(projectPath, name, opts = {}) {
630
746
  gates.push({ command, exitCode: 0, durationMs: 0, tail: "", scope: scoped.scope });
631
747
  continue;
632
748
  }
749
+ if (split && scoped?.parts && scoped.parts.length > 1) {
750
+ for (const part of scoped.parts) {
751
+ const d = disciplineOf(part.cwd, discipline);
752
+ cwds.set(d, [...(cwds.get(d) ?? []), part.cwd]);
753
+ const g = runGate(projectPath, part.cwd === "." ? part.command : `cd ${shellArg(part.cwd)} && ${part.command}`);
754
+ gates.push({
755
+ ...g,
756
+ scope: `${part.tests} affected test file(s) in ${part.cwd}; the full suite runs in CI`,
757
+ discipline: d,
758
+ });
759
+ if (g.exitCode !== 0)
760
+ break;
761
+ }
762
+ if (gates[gates.length - 1].exitCode !== 0)
763
+ break;
764
+ continue;
765
+ }
633
766
  const g = runGate(projectPath, scoped?.command ?? command);
634
767
  gates.push(scoped ? { ...g, scope: scoped.scope } : g);
635
768
  if (g.exitCode !== 0)
@@ -637,9 +770,16 @@ export function shipChange(projectPath, name, opts = {}) {
637
770
  }
638
771
  const gatesPassed = gates.every((g) => g.exitCode === 0);
639
772
  const t2 = Date.now();
640
- const reportRel = path.join(archived && !fs.existsSync(changeDir) ? archived : path.join("lawbook", "changes", name), "reports", `${discipline}.md`);
641
- fs.mkdirSync(path.dirname(path.join(projectPath, reportRel)), { recursive: true });
642
- fs.writeFileSync(path.join(projectPath, reportRel), renderReport(name, discipline, files, gates, gatesPassed, date));
773
+ const reportsRel = path.join(archived && !fs.existsSync(changeDir) ? archived : path.join("lawbook", "changes", name), "reports");
774
+ fs.mkdirSync(path.join(projectPath, reportsRel), { recursive: true });
775
+ const reports = [];
776
+ for (const r of reportGroups(gates, files, discipline, cwds)) {
777
+ const rel = path.join(reportsRel, `${r.discipline}.md`);
778
+ fs.writeFileSync(path.join(projectPath, rel), renderReport(name, r.discipline, r.files, r.gates, gatesPassed, date, r.others));
779
+ reports.push(rel);
780
+ }
781
+ dropStaleReports(projectPath, reportsRel, reports);
782
+ const reportRel = reports[0];
643
783
  const t3 = Date.now();
644
784
  let archivedTo = null;
645
785
  const next = [];
@@ -672,6 +812,7 @@ export function shipChange(projectPath, name, opts = {}) {
672
812
  gates,
673
813
  files,
674
814
  report: reportRel,
815
+ reports,
675
816
  pending,
676
817
  archivedTo,
677
818
  next,
@@ -685,6 +826,40 @@ export function shipChange(projectPath, name, opts = {}) {
685
826
  },
686
827
  };
687
828
  }
829
+ /** A gate's short name for a one-line summary: `lint`, `build`, `tests backend`. */
830
+ function gateLabel(g) {
831
+ if (g.scope)
832
+ return g.discipline ? `tests ${g.discipline}` : "tests";
833
+ const short = g.command.replace(/^(npm|pnpm|yarn)( run)? /, "");
834
+ return short.length > 40 ? `${short.slice(0, 39)}…` : short;
835
+ }
836
+ /**
837
+ * The one line the `Stop` hook shows the user after a ship: what ran, how it
838
+ * went, and what is left — so a stop that blocks nothing is never silent.
839
+ *
840
+ * @param r - The ship result.
841
+ * @param blocked - Whether this stop was sent back to the agent.
842
+ * @returns The summary, prefixed `speclaw:`.
843
+ */
844
+ export function stopSummary(r, blocked) {
845
+ if (r.pending.length) {
846
+ const owed = [...new Set(r.pending.map((p) => path.basename(p.split(":")[0])))].join(", ");
847
+ return blocked
848
+ ? `speclaw: ${/^level \d/.exec(r.next[0] ?? "")?.[0] ?? "the change"} owes ${owed} — sent back to the agent; the gates run once they are written`
849
+ : `speclaw: still owed after a second stop: ${owed} — the gates did not run`;
850
+ }
851
+ const ran = r.gates.map((g) => `${gateLabel(g)} ${secs(g.durationMs)}`).join(" · ");
852
+ if (!r.gatesPassed) {
853
+ const failed = r.gates[r.gates.length - 1];
854
+ return `speclaw: gate FAILED — ${gateLabel(failed)} (exit ${failed.exitCode})${blocked ? " — sent back to the agent" : "; fix it, then stop again"}`;
855
+ }
856
+ const where = r.archivedTo
857
+ ? `archived to ${r.archivedTo}`
858
+ : r.next.length
859
+ ? r.next.join("; ")
860
+ : `report ${r.report}`;
861
+ return `speclaw: gates PASS${ran ? ` (${ran})` : ""} · ${where}`;
862
+ }
688
863
  /** Where {@link docHint} remembers what it last measured and told. */
689
864
  const DOC_HINT_STATE = path.join(".speclaw", "doc-hint.json");
690
865
  /** The change the hook would ship on this branch: the one last shipped here, else the branch's. */
@@ -752,36 +927,50 @@ export function docHint(projectPath) {
752
927
  return null;
753
928
  }
754
929
  // Measuring a large diff takes seconds — longer than the hook may run —
755
- // so it happens in the background and the hint follows on a later call.
756
- if (!cachedMeasure(projectPath, files)) {
757
- const fresh = prev.pending?.key === key && Date.now() - prev.pending.at < MEASURE_GRACE_MS;
758
- if (!fresh)
930
+ // so it happens in the background, one job at a time; meanwhile a diff
931
+ // that only grew is told from its last measurement, a floor for its level.
932
+ let measured = cachedMeasure(projectPath, files);
933
+ let inFlight;
934
+ if (!measured) {
935
+ const running = prev.pending !== undefined &&
936
+ Date.now() - prev.pending.at < MEASURE_GRACE_MS &&
937
+ !measuredSince(projectPath, prev.pending.at);
938
+ if (!running)
759
939
  measureInBackground(projectPath);
760
- save({ ...prev, quick, pending: fresh ? prev.pending : { key, at: Date.now() } });
761
- return null;
940
+ inFlight = running ? prev.pending : { key, at: Date.now() };
941
+ measured = grownFromCache(projectPath, files);
942
+ if (!measured) {
943
+ save({ ...prev, quick, pending: inFlight });
944
+ return null;
945
+ }
762
946
  }
947
+ const proposal = measureDiff(projectPath, files, measured);
763
948
  const change = branchChange(projectPath, branch);
764
949
  const changeDir = path.join(projectPath, "lawbook", "changes", change);
765
950
  const archived = findArchived(projectPath, change);
766
951
  if (fs.existsSync(changeDir))
767
- remeasure(projectPath, change, files);
952
+ remeasure(projectPath, change, files, proposal);
768
953
  else if (archived)
769
- reopenIfGrown(projectPath, change, archived, files);
770
- else {
771
- const proposal = measureDiff(projectPath, files);
772
- if ((proposal.level ?? 0) > 0)
773
- scaffoldMeasured(projectPath, change, files, proposal);
774
- }
954
+ reopenIfGrown(projectPath, change, archived, files, proposal);
955
+ else if ((proposal.level ?? 0) > 0)
956
+ scaffoldMeasured(projectPath, change, files, proposal);
775
957
  const level = fs.existsSync(changeDir) ? confirmedLevel(projectPath, change) : 0;
776
- const pending = level > 0 ? pendingArtifacts(projectPath, change) : [];
777
- const told = prev.told?.change === change && prev.told.level === level;
958
+ const pending = level > 0 ? pendingArtifacts(projectPath, change, files) : [];
959
+ // Re-told when the change, its level, or the kind of artifact owed changes
960
+ // (an API report owed later at the same level still has to be said).
961
+ const owed = [...new Set(pending.map((p) => p.split(":")[0]))].sort().join(",");
962
+ const told = prev.told?.change === change &&
963
+ prev.told.level === level &&
964
+ (prev.told.owed === undefined ||
965
+ owed.split(",").every((o) => prev.told.owed.split(",").includes(o)));
966
+ const keep = { quick, key, ...(inFlight ? { pending: inFlight } : {}) };
778
967
  if (pending.length === 0 || told) {
779
- save({ quick, key, ...(prev.told ? { told: prev.told } : {}) });
968
+ save({ ...keep, ...(prev.told ? { told: prev.told } : {}) });
780
969
  return null;
781
970
  }
782
971
  // "Told" is recorded only with the hint actually returned, so a level is
783
972
  // never marked as told by a call that said nothing.
784
- save({ quick, key, told: { change, level } });
973
+ save({ ...keep, told: { change, level, owed } });
785
974
  const why = readCeremonyRecord(projectPath, change)?.rationale ?? "";
786
975
  return (`speclaw: this change measures level ${level}${why ? ` (${why})` : ""}. ` +
787
976
  `In this same turn, before you stop, write briefly and truthfully:\n- ${pending.join("\n- ")}\n` +
@@ -0,0 +1,56 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * The entry points the root `package.json` declares in `main` and `bin`. A
5
+ * built path is mapped back to the source file that produces it when that
6
+ * file exists, so the entries name code the agent edits. A repo without a
7
+ * root package, or one that declares neither field (an app or a workspace
8
+ * root), has none.
9
+ *
10
+ * @param projectPath - Project root.
11
+ * @returns The declared entries, deduplicated by file; empty when none.
12
+ */
13
+ export function packageEntries(projectPath) {
14
+ let pkg;
15
+ try {
16
+ pkg = JSON.parse(fs.readFileSync(path.join(projectPath, "package.json"), "utf8"));
17
+ }
18
+ catch {
19
+ return [];
20
+ }
21
+ const declared = [];
22
+ if (typeof pkg.main === "string")
23
+ declared.push({ file: pkg.main, kind: "main" });
24
+ if (typeof pkg.bin === "string")
25
+ declared.push({ file: pkg.bin, kind: "bin" });
26
+ else if (pkg.bin && typeof pkg.bin === "object") {
27
+ const bins = Object.entries(pkg.bin);
28
+ for (const [name, file] of bins) {
29
+ if (typeof file === "string") {
30
+ declared.push({ file, kind: bins.length > 1 ? `bin ${name}` : "bin" });
31
+ }
32
+ }
33
+ }
34
+ const seen = new Set();
35
+ const out = [];
36
+ for (const e of declared) {
37
+ const file = sourceOf(projectPath, e.file.replace(/^\.\//, ""));
38
+ if (seen.has(file))
39
+ continue;
40
+ seen.add(file);
41
+ out.push({ file, kind: e.kind });
42
+ }
43
+ return out;
44
+ }
45
+ /** `dist/cli/index.js` → `src/cli/index.ts` when that source exists, else the path unchanged. */
46
+ function sourceOf(projectPath, file) {
47
+ const m = /^(?:dist|build|lib|out)\/(.+)\.[cm]?js$/.exec(file);
48
+ if (!m)
49
+ return file;
50
+ for (const ext of [".ts", ".tsx", ".mts", ".js", ".mjs"]) {
51
+ const candidate = `src/${m[1]}${ext}`;
52
+ if (fs.existsSync(path.join(projectPath, candidate)))
53
+ return candidate;
54
+ }
55
+ return file;
56
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "2.0.24",
3
+ "version": "2.0.25",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },