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