@esneiderbravo/speclaw 2.0.23 → 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.
- package/dist/cli/commands/ship-on-stop.js +24 -15
- package/dist/modules/compass/map.js +15 -9
- package/dist/modules/foundation/hooks.js +6 -4
- package/dist/modules/lawbook/api-surface.js +130 -0
- package/dist/modules/lawbook/levels.js +11 -27
- package/dist/modules/lawbook/ship.js +240 -41
- package/dist/shared/package-entries.js +56 -0
- package/package.json +5 -3
|
@@ -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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* — once: when the hook already blocked
|
|
8
|
-
*
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
+
entryLine,
|
|
76
80
|
"(entries omitted to fit map budget)",
|
|
77
|
-
]
|
|
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
|
-
*
|
|
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
|
|
76
|
-
//
|
|
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 + "}
|
|
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) {
|
|
@@ -115,6 +117,18 @@ function runGate(projectPath, command) {
|
|
|
115
117
|
tail: combined.slice(-GATE_TAIL_LINES).join("\n"),
|
|
116
118
|
};
|
|
117
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* What the lawbook writes itself: change folders and reports, the canonical
|
|
122
|
+
* specs an archive promotes, the anchors it seals, and the local index. None
|
|
123
|
+
* of it is the agent's work, so none of it may trigger, size or re-run a ship
|
|
124
|
+
* — archiving used to read as new work and re-run every gate.
|
|
125
|
+
*/
|
|
126
|
+
const OWN_OUTPUT = ["lawbook/changes/", "lawbook/specs/", "lawbook/anchors/", ".speclaw/"];
|
|
127
|
+
function isOwnOutput(file) {
|
|
128
|
+
return OWN_OUTPUT.some((dir) => file.startsWith(dir));
|
|
129
|
+
}
|
|
130
|
+
/** The same paths as git pathspecs, for commands that list or log the work. */
|
|
131
|
+
const OWN_OUTPUT_EXCLUDES = OWN_OUTPUT.map((dir) => `:(exclude)${dir.replace(/\/$/, "")}`);
|
|
118
132
|
function branchFiles(projectPath) {
|
|
119
133
|
if (!isGitRepo(projectPath))
|
|
120
134
|
return [];
|
|
@@ -127,7 +141,7 @@ function branchFiles(projectPath) {
|
|
|
127
141
|
...untrackedFiles(projectPath),
|
|
128
142
|
]),
|
|
129
143
|
]
|
|
130
|
-
.filter((f) => !f
|
|
144
|
+
.filter((f) => !isOwnOutput(f))
|
|
131
145
|
.filter((f) => f !== COMPASS_DOC || !onlyMapChanged(projectPath, base))
|
|
132
146
|
.sort();
|
|
133
147
|
}
|
|
@@ -150,7 +164,7 @@ function onlyMapChanged(projectPath, base) {
|
|
|
150
164
|
return false;
|
|
151
165
|
}
|
|
152
166
|
}
|
|
153
|
-
/** New files not yet added, outside
|
|
167
|
+
/** New files not yet added, outside the lawbook's own output ({@link OWN_OUTPUT}). */
|
|
154
168
|
function untrackedFiles(projectPath) {
|
|
155
169
|
return git(projectPath, [
|
|
156
170
|
"-c",
|
|
@@ -160,12 +174,12 @@ function untrackedFiles(projectPath) {
|
|
|
160
174
|
"--exclude-standard",
|
|
161
175
|
])
|
|
162
176
|
.split("\n")
|
|
163
|
-
.filter((f) => f && !
|
|
177
|
+
.filter((f) => f && !isOwnOutput(f));
|
|
164
178
|
}
|
|
165
179
|
function secs(ms) {
|
|
166
180
|
return `${(ms / 1000).toFixed(1)} s`;
|
|
167
181
|
}
|
|
168
|
-
function renderReport(change, discipline, files, gates, passed, date) {
|
|
182
|
+
function renderReport(change, discipline, files, gates, passed, date, others = []) {
|
|
169
183
|
const rows = gates
|
|
170
184
|
.map((g) => `| \`${g.command}\`${g.scope ? ` (${g.scope})` : ""} | ${g.exitCode === 0 ? "pass" : `FAIL (exit ${g.exitCode})`} | ${secs(g.durationMs)} |`)
|
|
171
185
|
.join("\n");
|
|
@@ -184,7 +198,12 @@ ${rows || "| (no gates configured) | — | — |"}
|
|
|
184
198
|
|
|
185
199
|
## Files changed
|
|
186
200
|
|
|
187
|
-
${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
|
+
: ""}
|
|
188
207
|
|
|
189
208
|
## Gate output (tail)
|
|
190
209
|
|
|
@@ -203,6 +222,74 @@ None.
|
|
|
203
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."}
|
|
204
223
|
`;
|
|
205
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
|
+
}
|
|
206
293
|
/**
|
|
207
294
|
* The archived folder of a change (`lawbook/changes/archive/<date>-<name>`),
|
|
208
295
|
* newest first, or null.
|
|
@@ -243,8 +330,7 @@ function commitSummary(projectPath) {
|
|
|
243
330
|
`${base}..HEAD`,
|
|
244
331
|
"--",
|
|
245
332
|
".",
|
|
246
|
-
|
|
247
|
-
":(exclude).speclaw",
|
|
333
|
+
...OWN_OUTPUT_EXCLUDES,
|
|
248
334
|
])
|
|
249
335
|
.split("\n")
|
|
250
336
|
.filter((l) => !/^[\w-]+-by:/i.test(l))
|
|
@@ -274,8 +360,8 @@ const SMALL_FIX_LINES = 10;
|
|
|
274
360
|
* small fix stays at level 0 however central the code it touches: its blast
|
|
275
361
|
* radius is guarded by the gates and its test, which ceremony would not add to.
|
|
276
362
|
*/
|
|
277
|
-
function measureDiff(projectPath, files) {
|
|
278
|
-
const proposal = measureDiffSignals(projectPath, files);
|
|
363
|
+
function measureDiff(projectPath, files, measured) {
|
|
364
|
+
const proposal = measured ?? measureDiffSignals(projectPath, files);
|
|
279
365
|
if (!proposal.level || proposal.signals.touchesPublicApi || proposal.signals.touchesGlobalFile) {
|
|
280
366
|
return proposal;
|
|
281
367
|
}
|
|
@@ -331,7 +417,7 @@ function measureDiffSignals(projectPath, files) {
|
|
|
331
417
|
try {
|
|
332
418
|
const file = path.join(projectPath, LEVEL_CACHE);
|
|
333
419
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
334
|
-
fs.writeFileSync(file, JSON.stringify({ key: fileSetKey(files), proposal }) + "\n");
|
|
420
|
+
fs.writeFileSync(file, JSON.stringify({ key: fileSetKey(files), files, proposal }) + "\n");
|
|
335
421
|
}
|
|
336
422
|
catch {
|
|
337
423
|
// The cache only saves time; the measurement stands without it.
|
|
@@ -357,6 +443,24 @@ function cachedMeasure(projectPath, files) {
|
|
|
357
443
|
return null;
|
|
358
444
|
}
|
|
359
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
|
+
}
|
|
360
464
|
/**
|
|
361
465
|
* Measure the branch's current diff into the level cache (`speclaw
|
|
362
466
|
* measure-diff`, the edit hook's detached background job).
|
|
@@ -403,11 +507,11 @@ function scaffoldMeasured(projectPath, name, files, measured) {
|
|
|
403
507
|
}
|
|
404
508
|
}
|
|
405
509
|
/** Raise a measured level when the branch diff has grown past it; a human-set level stands. */
|
|
406
|
-
function remeasure(projectPath, name, files) {
|
|
510
|
+
function remeasure(projectPath, name, files, measured) {
|
|
407
511
|
const rec = readCeremonyRecord(projectPath, name);
|
|
408
512
|
if (rec?.confirmedBy !== "measured")
|
|
409
513
|
return;
|
|
410
|
-
const proposal = measureDiff(projectPath, files);
|
|
514
|
+
const proposal = measured ?? measureDiff(projectPath, files);
|
|
411
515
|
if (proposal.level !== null && proposal.level > rec.confirmedLevel) {
|
|
412
516
|
promoteCeremonyLevel(projectPath, name, proposal.level, "the branch diff grew", proposal);
|
|
413
517
|
}
|
|
@@ -420,7 +524,7 @@ function remeasure(projectPath, name, files) {
|
|
|
420
524
|
*
|
|
421
525
|
* @returns Whether the change was reopened.
|
|
422
526
|
*/
|
|
423
|
-
function reopenIfGrown(projectPath, name, archivedRel, files) {
|
|
527
|
+
function reopenIfGrown(projectPath, name, archivedRel, files, measured) {
|
|
424
528
|
const archivedDir = path.join(projectPath, archivedRel);
|
|
425
529
|
let rec = {};
|
|
426
530
|
try {
|
|
@@ -441,7 +545,7 @@ function reopenIfGrown(projectPath, name, archivedRel, files) {
|
|
|
441
545
|
});
|
|
442
546
|
if (atBase.status === 0)
|
|
443
547
|
return false;
|
|
444
|
-
const proposal = measureDiff(projectPath, files);
|
|
548
|
+
const proposal = measured ?? measureDiff(projectPath, files);
|
|
445
549
|
if (proposal.level === null || proposal.level <= rec.confirmedLevel)
|
|
446
550
|
return false;
|
|
447
551
|
const changeDir = path.join(projectPath, "lawbook", "changes", name);
|
|
@@ -475,11 +579,16 @@ const STUBS = {
|
|
|
475
579
|
* its record falls back to the commits or the file list, so it costs no turn. Bug-shaped changes keep
|
|
476
580
|
* their own checks (`bugfix.md`) and are not judged here.
|
|
477
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
|
+
*
|
|
478
585
|
* @param projectPath - Project root with `lawbook/`.
|
|
479
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.
|
|
480
589
|
* @returns One instruction per missing artifact; empty when the change is documented.
|
|
481
590
|
*/
|
|
482
|
-
export function pendingArtifacts(projectPath, name) {
|
|
591
|
+
export function pendingArtifacts(projectPath, name, files) {
|
|
483
592
|
const dir = path.join(projectPath, "lawbook", "changes", name);
|
|
484
593
|
if (!fs.existsSync(dir) || readChangeType(projectPath, name) === "bug")
|
|
485
594
|
return [];
|
|
@@ -522,6 +631,19 @@ export function pendingArtifacts(projectPath, name) {
|
|
|
522
631
|
}
|
|
523
632
|
}
|
|
524
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
|
+
}
|
|
525
647
|
return out;
|
|
526
648
|
}
|
|
527
649
|
/**
|
|
@@ -582,7 +704,7 @@ export function shipChange(projectPath, name, opts = {}) {
|
|
|
582
704
|
? `Changed ${files.length} file(s): ${files.slice(0, 8).join(", ")}${files.length > 8 ? ", …" : ""}`
|
|
583
705
|
: "No file changes detected.";
|
|
584
706
|
fillRecord(path.join(changeDir, "record.md"), opts.summary ?? (commits || (confirmedLevel(projectPath, name) === 0 ? fileList : "")));
|
|
585
|
-
const pending = pendingArtifacts(projectPath, name);
|
|
707
|
+
const pending = pendingArtifacts(projectPath, name, files);
|
|
586
708
|
const t1 = Date.now();
|
|
587
709
|
if (pending.length) {
|
|
588
710
|
const level = confirmedLevel(projectPath, name);
|
|
@@ -593,6 +715,7 @@ export function shipChange(projectPath, name, opts = {}) {
|
|
|
593
715
|
gates: [],
|
|
594
716
|
files,
|
|
595
717
|
report: null,
|
|
718
|
+
reports: [],
|
|
596
719
|
pending,
|
|
597
720
|
archivedTo: null,
|
|
598
721
|
next: [
|
|
@@ -610,6 +733,10 @@ export function shipChange(projectPath, name, opts = {}) {
|
|
|
610
733
|
};
|
|
611
734
|
}
|
|
612
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();
|
|
613
740
|
const gates = [];
|
|
614
741
|
for (const command of commands) {
|
|
615
742
|
const scoped = cfg.tests !== "full" && TEST_GATE_RE.test(command.trim())
|
|
@@ -619,6 +746,23 @@ export function shipChange(projectPath, name, opts = {}) {
|
|
|
619
746
|
gates.push({ command, exitCode: 0, durationMs: 0, tail: "", scope: scoped.scope });
|
|
620
747
|
continue;
|
|
621
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
|
+
}
|
|
622
766
|
const g = runGate(projectPath, scoped?.command ?? command);
|
|
623
767
|
gates.push(scoped ? { ...g, scope: scoped.scope } : g);
|
|
624
768
|
if (g.exitCode !== 0)
|
|
@@ -626,9 +770,16 @@ export function shipChange(projectPath, name, opts = {}) {
|
|
|
626
770
|
}
|
|
627
771
|
const gatesPassed = gates.every((g) => g.exitCode === 0);
|
|
628
772
|
const t2 = Date.now();
|
|
629
|
-
const
|
|
630
|
-
fs.mkdirSync(path.
|
|
631
|
-
|
|
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];
|
|
632
783
|
const t3 = Date.now();
|
|
633
784
|
let archivedTo = null;
|
|
634
785
|
const next = [];
|
|
@@ -661,6 +812,7 @@ export function shipChange(projectPath, name, opts = {}) {
|
|
|
661
812
|
gates,
|
|
662
813
|
files,
|
|
663
814
|
report: reportRel,
|
|
815
|
+
reports,
|
|
664
816
|
pending,
|
|
665
817
|
archivedTo,
|
|
666
818
|
next,
|
|
@@ -674,6 +826,40 @@ export function shipChange(projectPath, name, opts = {}) {
|
|
|
674
826
|
},
|
|
675
827
|
};
|
|
676
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
|
+
}
|
|
677
863
|
/** Where {@link docHint} remembers what it last measured and told. */
|
|
678
864
|
const DOC_HINT_STATE = path.join(".speclaw", "doc-hint.json");
|
|
679
865
|
/** The change the hook would ship on this branch: the one last shipped here, else the branch's. */
|
|
@@ -712,7 +898,7 @@ export function docHint(projectPath) {
|
|
|
712
898
|
return null;
|
|
713
899
|
const quick = status
|
|
714
900
|
.split("\n")
|
|
715
|
-
.filter((l) => !
|
|
901
|
+
.filter((l) => !OWN_OUTPUT.some((dir) => l.includes(` ${dir}`)))
|
|
716
902
|
.join("\n");
|
|
717
903
|
const statePath = path.join(projectPath, DOC_HINT_STATE);
|
|
718
904
|
let prev = {};
|
|
@@ -741,36 +927,50 @@ export function docHint(projectPath) {
|
|
|
741
927
|
return null;
|
|
742
928
|
}
|
|
743
929
|
// Measuring a large diff takes seconds — longer than the hook may run —
|
|
744
|
-
// so it happens in the background
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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)
|
|
748
939
|
measureInBackground(projectPath);
|
|
749
|
-
|
|
750
|
-
|
|
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
|
+
}
|
|
751
946
|
}
|
|
947
|
+
const proposal = measureDiff(projectPath, files, measured);
|
|
752
948
|
const change = branchChange(projectPath, branch);
|
|
753
949
|
const changeDir = path.join(projectPath, "lawbook", "changes", change);
|
|
754
950
|
const archived = findArchived(projectPath, change);
|
|
755
951
|
if (fs.existsSync(changeDir))
|
|
756
|
-
remeasure(projectPath, change, files);
|
|
952
|
+
remeasure(projectPath, change, files, proposal);
|
|
757
953
|
else if (archived)
|
|
758
|
-
reopenIfGrown(projectPath, change, archived, files);
|
|
759
|
-
else
|
|
760
|
-
|
|
761
|
-
if ((proposal.level ?? 0) > 0)
|
|
762
|
-
scaffoldMeasured(projectPath, change, files, proposal);
|
|
763
|
-
}
|
|
954
|
+
reopenIfGrown(projectPath, change, archived, files, proposal);
|
|
955
|
+
else if ((proposal.level ?? 0) > 0)
|
|
956
|
+
scaffoldMeasured(projectPath, change, files, proposal);
|
|
764
957
|
const level = fs.existsSync(changeDir) ? confirmedLevel(projectPath, change) : 0;
|
|
765
|
-
const pending = level > 0 ? pendingArtifacts(projectPath, change) : [];
|
|
766
|
-
|
|
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 } : {}) };
|
|
767
967
|
if (pending.length === 0 || told) {
|
|
768
|
-
save({
|
|
968
|
+
save({ ...keep, ...(prev.told ? { told: prev.told } : {}) });
|
|
769
969
|
return null;
|
|
770
970
|
}
|
|
771
971
|
// "Told" is recorded only with the hint actually returned, so a level is
|
|
772
972
|
// never marked as told by a call that said nothing.
|
|
773
|
-
save({
|
|
973
|
+
save({ ...keep, told: { change, level, owed } });
|
|
774
974
|
const why = readCeremonyRecord(projectPath, change)?.rationale ?? "";
|
|
775
975
|
return (`speclaw: this change measures level ${level}${why ? ` (${why})` : ""}. ` +
|
|
776
976
|
`In this same turn, before you stop, write briefly and truthfully:\n- ${pending.join("\n- ")}\n` +
|
|
@@ -865,11 +1065,11 @@ function readMarker(projectPath) {
|
|
|
865
1065
|
return { branch: "", change: "", fingerprint: raw };
|
|
866
1066
|
}
|
|
867
1067
|
/**
|
|
868
|
-
* Fingerprint of the branch's work outside
|
|
1068
|
+
* Fingerprint of the branch's work outside the lawbook's own output: every path that
|
|
869
1069
|
* differs from the merge base with `main`/`master` or is untracked, with its
|
|
870
1070
|
* working-tree contents. A file hashes the same whether it is untracked, staged
|
|
871
1071
|
* or committed, so committing work — new files included — is not new work; ship
|
|
872
|
-
*
|
|
1072
|
+
* and archive write only {@link OWN_OUTPUT}, so their output never changes it either.
|
|
873
1073
|
* Without a merge base it falls back to the paths changed against HEAD plus HEAD itself.
|
|
874
1074
|
*/
|
|
875
1075
|
function workFingerprint(projectPath) {
|
|
@@ -882,8 +1082,7 @@ function workFingerprint(projectPath) {
|
|
|
882
1082
|
base ?? "HEAD",
|
|
883
1083
|
"--",
|
|
884
1084
|
".",
|
|
885
|
-
|
|
886
|
-
":(exclude).speclaw",
|
|
1085
|
+
...OWN_OUTPUT_EXCLUDES,
|
|
887
1086
|
]).split("\n");
|
|
888
1087
|
const hash = createHash("sha256");
|
|
889
1088
|
const files = [...new Set([...tracked, ...untrackedFiles(projectPath)])]
|
|
@@ -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.
|
|
3
|
+
"version": "2.0.25",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -43,8 +43,10 @@
|
|
|
43
43
|
"check": "prettier --check . && eslint .",
|
|
44
44
|
"budget:calibrate": "node scripts/budget-calibrate.mjs",
|
|
45
45
|
"pretest": "tsc -p tsconfig.test.json && node scripts/prep-test-assets.mjs",
|
|
46
|
-
"test": "node --test
|
|
47
|
-
"prepublishOnly": "npm run build"
|
|
46
|
+
"test": "node --test 'dist-test/test/**/*.test.js'",
|
|
47
|
+
"prepublishOnly": "npm run build",
|
|
48
|
+
"test:ci": "node --test --test-concurrency=1 --experimental-test-coverage --test-coverage-lines=80 --test-coverage-functions=80 --test-coverage-branches=80 --test-coverage-exclude='dist-test/test/**' --test-coverage-exclude='dist/**' 'dist-test/test/**/*.test.js'",
|
|
49
|
+
"pretest:ci": "npm run pretest"
|
|
48
50
|
},
|
|
49
51
|
"engines": {
|
|
50
52
|
"node": ">=22.16"
|