@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.
- 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 +220 -31
- package/dist/shared/package-entries.js +56 -0
- package/package.json +1 -1
|
@@ -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) {
|
|
@@ -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
|
|
641
|
-
fs.mkdirSync(path.
|
|
642
|
-
|
|
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
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
|
|
761
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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({
|
|
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
|
+
}
|