@esneiderbravo/speclaw 2.0.18 → 2.0.20
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 +6 -1
- package/dist/cli/commands/ship.js +2 -1
- package/dist/cli/commands/update.js +1 -1
- package/dist/cli/index.js +6 -4
- package/dist/cli/lib/ui.js +3 -0
- package/dist/modules/compass/explore-rich.js +72 -0
- package/dist/modules/compass/indexer.js +2 -0
- package/dist/modules/compass/register.js +10 -8
- package/dist/modules/cortex/register.js +1 -1
- package/dist/modules/foundation/assets/AGENTS.template.md +6 -3
- package/dist/modules/foundation/assets/CLAUDE.template.md +6 -3
- package/dist/modules/foundation/check.js +24 -1
- package/dist/modules/foundation/compass-nudge.js +69 -3
- package/dist/modules/foundation/doctor.js +1 -1
- package/dist/modules/foundation/hooks.js +20 -7
- package/dist/modules/foundation/register-core.js +12 -3
- package/dist/modules/lawbook/assets/skills/cortex/SKILL.md +11 -11
- package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +2 -1
- package/dist/modules/lawbook/assets/skills/test/steps/01-quality-gates.md +3 -1
- package/dist/modules/lawbook/engine.js +2 -2
- package/dist/modules/lawbook/investigate.js +85 -4
- package/dist/modules/lawbook/levels.js +9 -3
- package/dist/modules/lawbook/quick.js +5 -2
- package/dist/modules/lawbook/register.js +7 -5
- package/dist/modules/lawbook/scaffold-change.js +17 -11
- package/dist/modules/lawbook/ship.js +369 -36
- package/dist/modules/lawbook/stack-parse.js +21 -5
- package/dist/server.js +15 -1
- package/dist/shared/exposure.js +4 -7
- package/dist/shared/index-stats.js +40 -0
- package/dist/shared/mcp.js +3 -2
- package/package.json +1 -1
|
@@ -4,9 +4,10 @@ import { spawnSync } from "node:child_process";
|
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { changedFiles, isGitRepo, mergeBase, worktreeChangedFiles } from "../../shared/git.js";
|
|
6
6
|
import { handleHarness, readHarness } from "../cortex/harness.js";
|
|
7
|
-
import { specArchive, specArchivePreconditions } from "./engine.js";
|
|
8
|
-
import { confirmedLevel } from "./levels.js";
|
|
7
|
+
import { deltaSpecFiles, specArchive, specArchivePreconditions } from "./engine.js";
|
|
8
|
+
import { artifactNeeds, confirmedLevel, gatherSignals, loadCeremonyConfig, promoteCeremonyLevel, proposeLevel, readCeremonyRecord, readChangeType, } from "./levels.js";
|
|
9
9
|
import { scaffoldQuick } from "./quick.js";
|
|
10
|
+
import { CHANGE_NAME_RE, isPlaceholderDelta, scaffoldFeature } from "./scaffold-change.js";
|
|
10
11
|
const GATE_TAIL_LINES = 12;
|
|
11
12
|
/**
|
|
12
13
|
* Read the `ship:` block of lawbook/config.yaml (line-oriented, like the other
|
|
@@ -79,10 +80,28 @@ function branchFiles(projectPath) {
|
|
|
79
80
|
return [];
|
|
80
81
|
const base = mergeBase(projectPath, "main") ?? mergeBase(projectPath, "master");
|
|
81
82
|
const committed = base ? changedFiles(projectPath, base) : [];
|
|
82
|
-
return [
|
|
83
|
+
return [
|
|
84
|
+
...new Set([
|
|
85
|
+
...committed,
|
|
86
|
+
...worktreeChangedFiles(projectPath),
|
|
87
|
+
...untrackedFiles(projectPath),
|
|
88
|
+
]),
|
|
89
|
+
]
|
|
83
90
|
.filter((f) => !f.startsWith("lawbook/changes/"))
|
|
84
91
|
.sort();
|
|
85
92
|
}
|
|
93
|
+
/** New files not yet added, outside ship's own output (`lawbook/changes/`, `.speclaw/`). */
|
|
94
|
+
function untrackedFiles(projectPath) {
|
|
95
|
+
return git(projectPath, [
|
|
96
|
+
"-c",
|
|
97
|
+
"core.quotePath=false",
|
|
98
|
+
"ls-files",
|
|
99
|
+
"--others",
|
|
100
|
+
"--exclude-standard",
|
|
101
|
+
])
|
|
102
|
+
.split("\n")
|
|
103
|
+
.filter((f) => f && !f.startsWith("lawbook/changes/") && !f.startsWith(".speclaw/"));
|
|
104
|
+
}
|
|
86
105
|
function secs(ms) {
|
|
87
106
|
return `${(ms / 1000).toFixed(1)} s`;
|
|
88
107
|
}
|
|
@@ -139,14 +158,201 @@ function findArchived(projectPath, name) {
|
|
|
139
158
|
.pop();
|
|
140
159
|
return hit ? path.join("lawbook", "changes", "archive", hit) : null;
|
|
141
160
|
}
|
|
161
|
+
const RECORD_STUB = "<!-- 2–5 lines: what and why. -->";
|
|
142
162
|
function fillRecord(recordPath, summary) {
|
|
143
163
|
if (!fs.existsSync(recordPath))
|
|
144
164
|
return;
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
.replace(
|
|
149
|
-
fs.writeFileSync(recordPath, body);
|
|
165
|
+
let body = fs.readFileSync(recordPath, "utf8");
|
|
166
|
+
// A function replacer: `$&` or `$'` in a commit body must stay literal.
|
|
167
|
+
if (summary)
|
|
168
|
+
body = body.replace(RECORD_STUB, () => summary);
|
|
169
|
+
fs.writeFileSync(recordPath, body.replace(/^- \[ \] /gm, "- [x] "));
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The branch's own commit messages (subjects and bodies, trailers dropped),
|
|
173
|
+
* skipping commits that only touched ship's output; empty when there are none.
|
|
174
|
+
* A Conventional Commit body is the change's why, so it stands in for one.
|
|
175
|
+
*/
|
|
176
|
+
function commitSummary(projectPath) {
|
|
177
|
+
const base = mergeBase(projectPath, "main") ?? mergeBase(projectPath, "master");
|
|
178
|
+
if (!base)
|
|
179
|
+
return "";
|
|
180
|
+
return git(projectPath, [
|
|
181
|
+
"log",
|
|
182
|
+
"--format=%B",
|
|
183
|
+
`${base}..HEAD`,
|
|
184
|
+
"--",
|
|
185
|
+
".",
|
|
186
|
+
":(exclude)lawbook/changes",
|
|
187
|
+
":(exclude).speclaw",
|
|
188
|
+
])
|
|
189
|
+
.split("\n")
|
|
190
|
+
.filter((l) => !/^[\w-]+-by:/i.test(l))
|
|
191
|
+
.join("\n")
|
|
192
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
193
|
+
.trim()
|
|
194
|
+
.split("\n")
|
|
195
|
+
.slice(0, COMMIT_SUMMARY_LINES)
|
|
196
|
+
.join("\n");
|
|
197
|
+
}
|
|
198
|
+
/** A record's why is a few lines; a long branch history keeps only its start. */
|
|
199
|
+
const COMMIT_SUMMARY_LINES = 15;
|
|
200
|
+
/** A manifest whose only diff against `base` is its `"version"` lines: a release bump. */
|
|
201
|
+
function isVersionBump(projectPath, base, file) {
|
|
202
|
+
if (!base || !/(^|\/)package(-lock)?\.json$/.test(file))
|
|
203
|
+
return false;
|
|
204
|
+
const lines = git(projectPath, ["diff", "-U0", base, "--", file])
|
|
205
|
+
.split("\n")
|
|
206
|
+
.filter((l) => /^[+-](?![+-]{2})/.test(l));
|
|
207
|
+
return lines.length > 0 && lines.every((l) => /^[+-]\s*"version"\s*:/.test(l));
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Propose a ceremony level from the branch diff itself. A release bump of the
|
|
211
|
+
* manifests is not a global change, so it does not raise the level.
|
|
212
|
+
*/
|
|
213
|
+
function measureDiff(projectPath, files) {
|
|
214
|
+
const base = mergeBase(projectPath, "main") ?? mergeBase(projectPath, "master");
|
|
215
|
+
const paths = files.filter((f) => !isVersionBump(projectPath, base, f));
|
|
216
|
+
const { thresholds } = loadCeremonyConfig(projectPath);
|
|
217
|
+
return proposeLevel(gatherSignals(projectPath, { paths, symbols: [] }, thresholds), thresholds);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Create the change at the level its diff measures: level 0 gets a record,
|
|
221
|
+
* level 1+ the feature stubs that level requires. The level is recorded as
|
|
222
|
+
* `measured`, so a later, larger diff raises it.
|
|
223
|
+
*/
|
|
224
|
+
function scaffoldMeasured(projectPath, name, files, measured) {
|
|
225
|
+
const proposal = measured ?? measureDiff(projectPath, files);
|
|
226
|
+
const targets = { paths: files, symbols: [] };
|
|
227
|
+
const level = proposal.level ?? 0;
|
|
228
|
+
if (level > 0 && CHANGE_NAME_RE.test(name)) {
|
|
229
|
+
scaffoldFeature(projectPath, name, { level, targets, proposal, confirmedBy: "measured" });
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
scaffoldQuick(projectPath, name, targets, { proposal, confirmedBy: "measured" });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** Raise a measured level when the branch diff has grown past it; a human-set level stands. */
|
|
236
|
+
function remeasure(projectPath, name, files) {
|
|
237
|
+
const rec = readCeremonyRecord(projectPath, name);
|
|
238
|
+
if (rec?.confirmedBy !== "measured")
|
|
239
|
+
return;
|
|
240
|
+
const proposal = measureDiff(projectPath, files);
|
|
241
|
+
if (proposal.level !== null && proposal.level > rec.confirmedLevel) {
|
|
242
|
+
promoteCeremonyLevel(projectPath, name, proposal.level, "the branch diff grew", proposal);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Reopen a change archived on this branch at a measured level the diff has
|
|
247
|
+
* since outgrown (work shipped at a mid-task stop, then grew): move it back
|
|
248
|
+
* under `lawbook/changes/`, drop its finished harness, and raise its level. An
|
|
249
|
+
* archive that already exists at the merge base belongs to merged work and stays.
|
|
250
|
+
*
|
|
251
|
+
* @returns Whether the change was reopened.
|
|
252
|
+
*/
|
|
253
|
+
function reopenIfGrown(projectPath, name, archivedRel, files) {
|
|
254
|
+
const archivedDir = path.join(projectPath, archivedRel);
|
|
255
|
+
let rec = {};
|
|
256
|
+
try {
|
|
257
|
+
rec = JSON.parse(fs.readFileSync(path.join(archivedDir, "change.json"), "utf8"));
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
if (rec.confirmedBy !== "measured" || rec.confirmedLevel === undefined)
|
|
263
|
+
return false;
|
|
264
|
+
const base = mergeBase(projectPath, "main") ?? mergeBase(projectPath, "master");
|
|
265
|
+
if (!base)
|
|
266
|
+
return false;
|
|
267
|
+
// git object paths are POSIX on every platform.
|
|
268
|
+
const objectPath = `${archivedRel.split(path.sep).join("/")}/change.json`;
|
|
269
|
+
const atBase = spawnSync("git", ["cat-file", "-e", `${base}:${objectPath}`], {
|
|
270
|
+
cwd: projectPath,
|
|
271
|
+
});
|
|
272
|
+
if (atBase.status === 0)
|
|
273
|
+
return false;
|
|
274
|
+
const proposal = measureDiff(projectPath, files);
|
|
275
|
+
if (proposal.level === null || proposal.level <= rec.confirmedLevel)
|
|
276
|
+
return false;
|
|
277
|
+
const changeDir = path.join(projectPath, "lawbook", "changes", name);
|
|
278
|
+
fs.renameSync(archivedDir, changeDir);
|
|
279
|
+
fs.rmSync(path.join(changeDir, "harness.json"), { force: true });
|
|
280
|
+
promoteCeremonyLevel(projectPath, name, proposal.level, "the branch diff grew", proposal);
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Text a scaffold, a promotion, or ship itself leaves in an artifact until
|
|
285
|
+
* someone writes it. Task stubs match whole generated lines only, so a real
|
|
286
|
+
* task that starts the same way ("Make the hook idempotent") is never a stub.
|
|
287
|
+
*/
|
|
288
|
+
const STUBS = {
|
|
289
|
+
// At level 1+ the level-0 file list ship filled in is not a why.
|
|
290
|
+
"record.md": [/<!-- 2–5 lines: what and why\. -->/, /^Changed \d+ file\(s\): /m],
|
|
291
|
+
"proposal.md": [
|
|
292
|
+
/<!-- The problem and who it affects\. -->/,
|
|
293
|
+
/\(promoted — fill in why\)/,
|
|
294
|
+
// A promotion seeds Why from the level rationale and What with this line.
|
|
295
|
+
/\(promoted from level \d\)/,
|
|
296
|
+
],
|
|
297
|
+
"design.md": [/<!-- Module boundaries, data flow, and decisions\. -->/, /\(promoted — fill in\)/],
|
|
298
|
+
"tasks.md": [
|
|
299
|
+
/^\s*- \[[ xX]\] (1\.1 Implement the change|Implement|Make the fix|Make the change|Add or update a regression test|Add or update tests|Record evidence under reports\/|Write discipline report under reports\/)\s*$/m,
|
|
300
|
+
],
|
|
301
|
+
};
|
|
302
|
+
/**
|
|
303
|
+
* Artifacts the change's confirmed level requires that are missing or still a
|
|
304
|
+
* scaffold stub, as instructions the agent can act on. Level 0 owes nothing:
|
|
305
|
+
* its record falls back to the commits or the file list, so it costs no turn. Bug-shaped changes keep
|
|
306
|
+
* their own checks (`bugfix.md`) and are not judged here.
|
|
307
|
+
*
|
|
308
|
+
* @param projectPath - Project root with `lawbook/`.
|
|
309
|
+
* @param name - Change name.
|
|
310
|
+
* @returns One instruction per missing artifact; empty when the change is documented.
|
|
311
|
+
*/
|
|
312
|
+
export function pendingArtifacts(projectPath, name) {
|
|
313
|
+
const dir = path.join(projectPath, "lawbook", "changes", name);
|
|
314
|
+
if (!fs.existsSync(dir) || readChangeType(projectPath, name) === "bug")
|
|
315
|
+
return [];
|
|
316
|
+
const level = confirmedLevel(projectPath, name);
|
|
317
|
+
const needs = artifactNeeds(level, "feature");
|
|
318
|
+
const rel = (f) => path.join("lawbook", "changes", name, f);
|
|
319
|
+
const state = (f) => {
|
|
320
|
+
const abs = path.join(dir, f);
|
|
321
|
+
if (!fs.existsSync(abs))
|
|
322
|
+
return "missing";
|
|
323
|
+
const text = fs.readFileSync(abs, "utf8");
|
|
324
|
+
return (STUBS[f] ?? []).some((m) => m.test(text)) ? "stub" : "written";
|
|
325
|
+
};
|
|
326
|
+
const out = [];
|
|
327
|
+
// Level 0 never waits on prose: the record falls back to commits or the file list.
|
|
328
|
+
if (needs.record && level > 0 && state("record.md") !== "written") {
|
|
329
|
+
out.push(`${rel("record.md")}: write what changed and why (2–5 lines) under "What changes"`);
|
|
330
|
+
}
|
|
331
|
+
if (needs.proposal && state("proposal.md") !== "written") {
|
|
332
|
+
out.push(`${rel("proposal.md")}: write why, what changes, and the impact`);
|
|
333
|
+
}
|
|
334
|
+
const design = state("design.md");
|
|
335
|
+
if (needs.design && !needs.designOptionalWithJustification && design !== "written") {
|
|
336
|
+
out.push(`${rel("design.md")}: write the approach — boundaries, data flow, decisions`);
|
|
337
|
+
}
|
|
338
|
+
else if (design === "stub") {
|
|
339
|
+
out.push(`${rel("design.md")}: write the approach, or delete it (optional at level 2)`);
|
|
340
|
+
}
|
|
341
|
+
if (needs.tasksFile && state("tasks.md") !== "written") {
|
|
342
|
+
out.push(`${rel("tasks.md")}: list the tasks this change did, checked (- [x])`);
|
|
343
|
+
}
|
|
344
|
+
if (needs.deltaSpecs) {
|
|
345
|
+
const specs = deltaSpecFiles(dir);
|
|
346
|
+
if (specs.length === 0) {
|
|
347
|
+
out.push(`${rel("specs/<capability>/spec.md")}: add the full intended spec of the capability this changes`);
|
|
348
|
+
}
|
|
349
|
+
for (const f of specs) {
|
|
350
|
+
if (isPlaceholderDelta(fs.readFileSync(f, "utf8"))) {
|
|
351
|
+
out.push(`${path.relative(projectPath, f)}: replace the placeholder with the capability's full intended spec (or move it to the capability it changes)`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return out;
|
|
150
356
|
}
|
|
151
357
|
/**
|
|
152
358
|
* Record the test verdict derived from gate exit codes. Ship never records a
|
|
@@ -173,12 +379,13 @@ function recordGateVerdict(projectPath, change) {
|
|
|
173
379
|
}
|
|
174
380
|
}
|
|
175
381
|
/**
|
|
176
|
-
* Fast path for finished work, in one call:
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
* the
|
|
382
|
+
* Fast path for finished work, in one call: create the change at the level its
|
|
383
|
+
* branch diff measures (raising a measured level when the diff grew), refuse
|
|
384
|
+
* to run the gates while an artifact that level requires is missing or still a
|
|
385
|
+
* stub, then run the project's gates once and write a discipline report from
|
|
386
|
+
* their real output. At level 0 passing gates are the whole verification, so
|
|
387
|
+
* the change is archived. At level 1+ ship stops after the evidence: review is
|
|
388
|
+
* a human (or reviewer-role) decision taken on the PR, and the archive follows it.
|
|
182
389
|
*
|
|
183
390
|
* @param projectPath - Project root with `lawbook/`.
|
|
184
391
|
* @param name - Change name (kebab-case).
|
|
@@ -192,16 +399,46 @@ export function shipChange(projectPath, name, opts = {}) {
|
|
|
192
399
|
const date = opts.date ?? new Date().toISOString().slice(0, 10);
|
|
193
400
|
const changeDir = path.join(projectPath, "lawbook", "changes", name);
|
|
194
401
|
const files = branchFiles(projectPath);
|
|
195
|
-
|
|
196
|
-
if (!fs.existsSync(changeDir) &&
|
|
197
|
-
|
|
402
|
+
let archived = findArchived(projectPath, name);
|
|
403
|
+
if (archived && !fs.existsSync(changeDir) && reopenIfGrown(projectPath, name, archived, files)) {
|
|
404
|
+
archived = null;
|
|
198
405
|
}
|
|
199
|
-
|
|
200
|
-
(files
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
406
|
+
if (!fs.existsSync(changeDir) && !archived)
|
|
407
|
+
scaffoldMeasured(projectPath, name, files);
|
|
408
|
+
else if (fs.existsSync(changeDir))
|
|
409
|
+
remeasure(projectPath, name, files);
|
|
410
|
+
const commits = isGitRepo(projectPath) ? commitSummary(projectPath) : "";
|
|
411
|
+
const fileList = files.length
|
|
412
|
+
? `Changed ${files.length} file(s): ${files.slice(0, 8).join(", ")}${files.length > 8 ? ", …" : ""}`
|
|
413
|
+
: "No file changes detected.";
|
|
414
|
+
fillRecord(path.join(changeDir, "record.md"), opts.summary ?? (commits || (confirmedLevel(projectPath, name) === 0 ? fileList : "")));
|
|
415
|
+
const pending = pendingArtifacts(projectPath, name);
|
|
204
416
|
const t1 = Date.now();
|
|
417
|
+
if (pending.length) {
|
|
418
|
+
const level = confirmedLevel(projectPath, name);
|
|
419
|
+
const why = readCeremonyRecord(projectPath, name)?.rationale ?? "";
|
|
420
|
+
return {
|
|
421
|
+
change: name,
|
|
422
|
+
gatesPassed: false,
|
|
423
|
+
gates: [],
|
|
424
|
+
files,
|
|
425
|
+
report: null,
|
|
426
|
+
pending,
|
|
427
|
+
archivedTo: null,
|
|
428
|
+
next: [
|
|
429
|
+
`level ${level}${why ? ` (${why})` : ""}: document the change, then stop again — the gates run once it is written`,
|
|
430
|
+
...pending,
|
|
431
|
+
],
|
|
432
|
+
timings: {
|
|
433
|
+
scaffold: t1 - t0,
|
|
434
|
+
gates: 0,
|
|
435
|
+
report: 0,
|
|
436
|
+
archive: 0,
|
|
437
|
+
overhead: t1 - t0,
|
|
438
|
+
total: t1 - t0,
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
}
|
|
205
442
|
const commands = opts.gates ?? cfg.gates ?? detectGates(projectPath);
|
|
206
443
|
const gates = [];
|
|
207
444
|
for (const command of commands) {
|
|
@@ -247,6 +484,7 @@ export function shipChange(projectPath, name, opts = {}) {
|
|
|
247
484
|
gates,
|
|
248
485
|
files,
|
|
249
486
|
report: reportRel,
|
|
487
|
+
pending,
|
|
250
488
|
archivedTo,
|
|
251
489
|
next,
|
|
252
490
|
timings: {
|
|
@@ -259,6 +497,92 @@ export function shipChange(projectPath, name, opts = {}) {
|
|
|
259
497
|
},
|
|
260
498
|
};
|
|
261
499
|
}
|
|
500
|
+
/** Where {@link docHint} remembers what it last measured and told. */
|
|
501
|
+
const DOC_HINT_STATE = path.join(".speclaw", "doc-hint.json");
|
|
502
|
+
/** The change the hook would ship on this branch: the one last shipped here, else the branch's. */
|
|
503
|
+
function branchChange(projectPath, branch) {
|
|
504
|
+
const marker = readMarker(projectPath);
|
|
505
|
+
return marker && marker.branch === branch && marker.change
|
|
506
|
+
? marker.change
|
|
507
|
+
: changeNameForBranch(branch);
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* The documentation a change owes, told while the agent is still working so it
|
|
511
|
+
* writes it in the same turn as the code instead of after a blocked stop. Runs
|
|
512
|
+
* from the `PostToolUse` hook: it re-measures only when the set of changed
|
|
513
|
+
* files changed, creates the change once the diff measures level 1 or more,
|
|
514
|
+
* and speaks once per change and level. Never throws; null means say nothing.
|
|
515
|
+
*
|
|
516
|
+
* @param projectPath - Project root.
|
|
517
|
+
* @returns The hint for the agent, or null.
|
|
518
|
+
*/
|
|
519
|
+
export function docHint(projectPath) {
|
|
520
|
+
try {
|
|
521
|
+
if (!fs.existsSync(path.join(projectPath, "lawbook")))
|
|
522
|
+
return null;
|
|
523
|
+
// One git call answers "anything new?" on most calls (this runs after every
|
|
524
|
+
// edit and Bash call): branch, HEAD, and the worktree outside ship's output.
|
|
525
|
+
const status = git(projectPath, [
|
|
526
|
+
"-c",
|
|
527
|
+
"core.quotePath=false",
|
|
528
|
+
"status",
|
|
529
|
+
"--porcelain=v2",
|
|
530
|
+
"--branch",
|
|
531
|
+
"--untracked-files=all",
|
|
532
|
+
]);
|
|
533
|
+
const branch = /^# branch\.head (.+)$/m.exec(status)?.[1] ?? "";
|
|
534
|
+
if (!branch || ["main", "master", "(detached)"].includes(branch))
|
|
535
|
+
return null;
|
|
536
|
+
const quick = status
|
|
537
|
+
.split("\n")
|
|
538
|
+
.filter((l) => !l.includes(" lawbook/changes/") && !l.includes(" .speclaw/"))
|
|
539
|
+
.join("\n");
|
|
540
|
+
const statePath = path.join(projectPath, DOC_HINT_STATE);
|
|
541
|
+
let prev = {};
|
|
542
|
+
try {
|
|
543
|
+
prev = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
// First hint on this checkout.
|
|
547
|
+
}
|
|
548
|
+
if (prev.quick === quick)
|
|
549
|
+
return null;
|
|
550
|
+
const files = branchFiles(projectPath);
|
|
551
|
+
const key = `${branch}\n${files.join("\n")}`;
|
|
552
|
+
const remember = (extra) => {
|
|
553
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
554
|
+
fs.writeFileSync(statePath, JSON.stringify({ ...prev, quick, key, ...extra }) + "\n");
|
|
555
|
+
};
|
|
556
|
+
if (files.length === 0 || prev.key === key) {
|
|
557
|
+
remember({});
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
const change = branchChange(projectPath, branch);
|
|
561
|
+
const changeDir = path.join(projectPath, "lawbook", "changes", change);
|
|
562
|
+
const archived = findArchived(projectPath, change);
|
|
563
|
+
if (fs.existsSync(changeDir))
|
|
564
|
+
remeasure(projectPath, change, files);
|
|
565
|
+
else if (archived)
|
|
566
|
+
reopenIfGrown(projectPath, change, archived, files);
|
|
567
|
+
else {
|
|
568
|
+
const proposal = measureDiff(projectPath, files);
|
|
569
|
+
if ((proposal.level ?? 0) > 0)
|
|
570
|
+
scaffoldMeasured(projectPath, change, files, proposal);
|
|
571
|
+
}
|
|
572
|
+
const level = fs.existsSync(changeDir) ? confirmedLevel(projectPath, change) : 0;
|
|
573
|
+
remember({ change, level });
|
|
574
|
+
const pending = level > 0 ? pendingArtifacts(projectPath, change) : [];
|
|
575
|
+
if (pending.length === 0 || (prev.change === change && prev.level === level))
|
|
576
|
+
return null;
|
|
577
|
+
const why = readCeremonyRecord(projectPath, change)?.rationale ?? "";
|
|
578
|
+
return (`speclaw: this change measures level ${level}${why ? ` (${why})` : ""}. ` +
|
|
579
|
+
`In this same turn, before you stop, write briefly and truthfully:\n- ${pending.join("\n- ")}\n` +
|
|
580
|
+
`The Stop hook then runs the gates once.`);
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
262
586
|
/**
|
|
263
587
|
* Change name for a branch: the part after the last `/`, kebab-cased
|
|
264
588
|
* (`fix/compass-query-output` → `compass-query-output`).
|
|
@@ -311,30 +635,41 @@ function readMarker(projectPath) {
|
|
|
311
635
|
return { branch: "", change: "", fingerprint: raw };
|
|
312
636
|
}
|
|
313
637
|
/**
|
|
314
|
-
* Fingerprint of the branch's work outside `lawbook/changes/`:
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
638
|
+
* Fingerprint of the branch's work outside `lawbook/changes/` and `.speclaw/`: every path that
|
|
639
|
+
* differs from the merge base with `main`/`master` or is untracked, with its
|
|
640
|
+
* working-tree contents. A file hashes the same whether it is untracked, staged
|
|
641
|
+
* or committed, so committing work — new files included — is not new work; ship
|
|
642
|
+
* writes under `lawbook/changes/`, so its own output never changes it either.
|
|
643
|
+
* Without a merge base it falls back to the paths changed against HEAD plus HEAD itself.
|
|
319
644
|
*/
|
|
320
645
|
function workFingerprint(projectPath) {
|
|
321
646
|
const base = mergeBase(projectPath, "main") ?? mergeBase(projectPath, "master");
|
|
322
|
-
const
|
|
647
|
+
const tracked = git(projectPath, [
|
|
648
|
+
"-c",
|
|
649
|
+
"core.quotePath=false",
|
|
650
|
+
"diff",
|
|
651
|
+
"--name-only",
|
|
652
|
+
base ?? "HEAD",
|
|
653
|
+
"--",
|
|
654
|
+
".",
|
|
655
|
+
":(exclude)lawbook/changes",
|
|
656
|
+
":(exclude).speclaw",
|
|
657
|
+
]).split("\n");
|
|
323
658
|
const hash = createHash("sha256");
|
|
324
|
-
for (const f of
|
|
325
|
-
if (!f
|
|
659
|
+
for (const f of [...new Set([...tracked, ...untrackedFiles(projectPath)])].sort()) {
|
|
660
|
+
if (!f)
|
|
326
661
|
continue;
|
|
327
|
-
// Contents, not just names: editing a new, not-yet-added file is new work.
|
|
328
662
|
hash.update(`${f}\n`);
|
|
329
663
|
try {
|
|
330
664
|
hash.update(fs.readFileSync(path.join(projectPath, f)));
|
|
331
665
|
}
|
|
332
666
|
catch {
|
|
333
|
-
//
|
|
667
|
+
// Deleted, vanished or unreadable: its name stands in.
|
|
668
|
+
hash.update("\0");
|
|
334
669
|
}
|
|
335
670
|
}
|
|
336
671
|
const anchor = base ?? git(projectPath, ["rev-parse", "HEAD"]).trim();
|
|
337
|
-
return hash.update(`\n${anchor}\n
|
|
672
|
+
return hash.update(`\n${anchor}\n`).digest("hex");
|
|
338
673
|
}
|
|
339
674
|
/**
|
|
340
675
|
* The `Stop` hook body: when the agent ends a turn on a feature branch with
|
|
@@ -359,8 +694,6 @@ export function shipOnStop(projectPath) {
|
|
|
359
694
|
}
|
|
360
695
|
// A change shipped by name on this branch keeps receiving the branch's later
|
|
361
696
|
// work; the branch name would point at another (possibly archived) change.
|
|
362
|
-
const change =
|
|
363
|
-
? marker.change
|
|
364
|
-
: changeNameForBranch(branch);
|
|
697
|
+
const change = branchChange(projectPath, branch);
|
|
365
698
|
return { skipped: null, change, result: shipChange(projectPath, change) };
|
|
366
699
|
}
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
3
|
+
function realRoot(projectPath) {
|
|
4
|
+
try {
|
|
5
|
+
return fs.realpathSync(projectPath);
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return projectPath;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
2
11
|
const V8_NAMED = /^\s*at\s+(?:async\s+)?(?:(.+?)\s+\()?([^\s()]+):(\d+):(\d+)\)?\s*$/;
|
|
3
12
|
const V8_ANON = /^\s*at\s+([^\s()]+):(\d+):(\d+)\s*$/;
|
|
4
13
|
const PY_FRAME = /^\s*File\s+"([^"]+)",\s*line\s+(\d+)(?:,\s*in\s+(.+))?\s*$/;
|
|
@@ -29,14 +38,21 @@ export function mapDistToSrc(rel) {
|
|
|
29
38
|
*/
|
|
30
39
|
export function normalizeTracePath(projectPath, rawPath) {
|
|
31
40
|
let p = rawPath.replace(/\\/g, "/");
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
41
|
+
// A runtime prints real paths: under a symlinked root (macOS /tmp → /private/tmp)
|
|
42
|
+
// the trace names the real root, not the one the project was opened by.
|
|
43
|
+
const roots = [
|
|
44
|
+
...new Set([projectPath, realRoot(projectPath)].map((r) => r.replace(/\\/g, "/"))),
|
|
45
|
+
];
|
|
46
|
+
const strip = () => {
|
|
47
|
+
const root = roots.find((r) => p.startsWith(r + "/"));
|
|
48
|
+
if (root)
|
|
49
|
+
p = p.slice(root.length + 1);
|
|
50
|
+
};
|
|
51
|
+
strip();
|
|
35
52
|
if (p.startsWith("file://")) {
|
|
36
53
|
try {
|
|
37
54
|
p = decodeURIComponent(new URL(p).pathname);
|
|
38
|
-
|
|
39
|
-
p = p.slice(root.length + 1);
|
|
55
|
+
strip();
|
|
40
56
|
}
|
|
41
57
|
catch {
|
|
42
58
|
/* keep raw */
|
package/dist/server.js
CHANGED
|
@@ -7,6 +7,20 @@ import { registerFoundation } from "./modules/foundation/register.js";
|
|
|
7
7
|
import { registerTools } from "./modules/tools/register.js";
|
|
8
8
|
import { isMinimalMode } from "./shared/exposure.js";
|
|
9
9
|
import { pkgVersion } from "./shared/version.js";
|
|
10
|
+
/**
|
|
11
|
+
* Sent in the MCP `initialize` response; Claude Code keeps it in the agent's
|
|
12
|
+
* context even while tool definitions are deferred, so it names the one tool
|
|
13
|
+
* per job that replaces a shell read. Kept short: it is paid on every session.
|
|
14
|
+
*/
|
|
15
|
+
export const SERVER_INSTRUCTIONS = [
|
|
16
|
+
"speclaw has a tool for each of these jobs; use it instead of shell reads:",
|
|
17
|
+
'- Locate code: compass_find. Read it: compass_explore (include ["source"]; maxDepth 4 reads a whole call chain; blast_radius before changing a symbol). Not cat/sed/grep/rg.',
|
|
18
|
+
"- Failing test, bug, or stack trace: lawbook_investigate first, with the test output as stackTrace — it ranks the code that test reaches.",
|
|
19
|
+
"- Before running tests or finishing: compass_diff_context (what the diff touches, which tests cover it).",
|
|
20
|
+
"- Multi-module or public-API work: lawbook_change draft before coding; validate any spec you write.",
|
|
21
|
+
"- No or stale index: compass_index. 3+ large independent parts: cortex. Setup: speclaw_setup.",
|
|
22
|
+
"Markdown, JSON, and config files are fine to read directly.",
|
|
23
|
+
].join("\n");
|
|
10
24
|
/**
|
|
11
25
|
* Build the speclaw MCP server with every module's tools registered.
|
|
12
26
|
*
|
|
@@ -14,7 +28,7 @@ import { pkgVersion } from "./shared/version.js";
|
|
|
14
28
|
*/
|
|
15
29
|
export function buildServer(opts = {}) {
|
|
16
30
|
const minimal = opts.minimal ?? isMinimalMode();
|
|
17
|
-
const server = new McpServer({ name: "speclaw", version: pkgVersion() });
|
|
31
|
+
const server = new McpServer({ name: "speclaw", version: pkgVersion() }, { instructions: SERVER_INSTRUCTIONS });
|
|
18
32
|
const reg = { minimal };
|
|
19
33
|
registerFoundation(server, reg);
|
|
20
34
|
registerSpec(server, reg);
|
package/dist/shared/exposure.js
CHANGED
|
@@ -5,14 +5,11 @@ import { readManifest } from "./manifest.js";
|
|
|
5
5
|
/**
|
|
6
6
|
* Tools omitted when the exposure profile is `minimal`. Kept tools are the
|
|
7
7
|
* discovery + law loop: compass_explore/find/diff_context, lawbook_change,
|
|
8
|
-
* cortex
|
|
8
|
+
* lawbook_investigate, cortex, and speclaw_check — the tool every installed
|
|
9
|
+
* hook calls, so omitting it silently disables the laws and the Compass nudge.
|
|
10
|
+
* Deprecated aliases are never registered in minimal mode.
|
|
9
11
|
*/
|
|
10
|
-
export const MINIMAL_OMIT = new Set([
|
|
11
|
-
"compass_index",
|
|
12
|
-
"lawbook_investigate",
|
|
13
|
-
"speclaw_setup",
|
|
14
|
-
"speclaw_check",
|
|
15
|
-
]);
|
|
12
|
+
export const MINIMAL_OMIT = new Set(["compass_index", "speclaw_setup"]);
|
|
16
13
|
/**
|
|
17
14
|
* Whether minimal exposure is active for this process / project.
|
|
18
15
|
* `SPECLAW_MINIMAL=1` wins; otherwise the project manifest's `minimal` flag.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Totals of the last index run, kept beside the index as plain JSON so hot
|
|
5
|
+
* paths (the hooks) can size the repo without opening the database.
|
|
6
|
+
*/
|
|
7
|
+
export const INDEX_STATS_FILE = path.join(".speclaw", "index-stats.json");
|
|
8
|
+
/**
|
|
9
|
+
* Record the index totals after a run. Best-effort: a failed write never fails
|
|
10
|
+
* the index.
|
|
11
|
+
*
|
|
12
|
+
* @param projectPath - Project root.
|
|
13
|
+
* @param totals - Whole-repository counts after the run.
|
|
14
|
+
*/
|
|
15
|
+
export function writeIndexStats(projectPath, totals) {
|
|
16
|
+
try {
|
|
17
|
+
const file = path.join(projectPath, INDEX_STATS_FILE);
|
|
18
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
19
|
+
fs.writeFileSync(file, JSON.stringify({ files: totals.files, nodes: totals.nodes }) + "\n");
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// The stats are an optimization for hooks; the index itself is intact.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The last index run's totals, or null when no run recorded them.
|
|
27
|
+
*
|
|
28
|
+
* @param projectPath - Project root.
|
|
29
|
+
*/
|
|
30
|
+
export function readIndexStats(projectPath) {
|
|
31
|
+
try {
|
|
32
|
+
const raw = JSON.parse(fs.readFileSync(path.join(projectPath, INDEX_STATS_FILE), "utf8"));
|
|
33
|
+
return typeof raw.files === "number"
|
|
34
|
+
? { files: raw.files, nodes: typeof raw.nodes === "number" ? raw.nodes : 0 }
|
|
35
|
+
: null;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
package/dist/shared/mcp.js
CHANGED
|
@@ -25,8 +25,8 @@ export function text(value, budget = "brief") {
|
|
|
25
25
|
}
|
|
26
26
|
/**
|
|
27
27
|
* Register one MCP tool after enforcing the context-budget caps (description
|
|
28
|
-
* word count and estimated definition tokens).
|
|
29
|
-
* `
|
|
28
|
+
* word count and estimated definition tokens). `alwaysLoad` marks the
|
|
29
|
+
* definition `_meta["anthropic/alwaysLoad"]` so Claude Code never defers it.
|
|
30
30
|
*
|
|
31
31
|
* @param server - MCP server to register on.
|
|
32
32
|
* @param spec - Tool name, description, schema, and handler.
|
|
@@ -51,6 +51,7 @@ export function defineTool(server, spec) {
|
|
|
51
51
|
description: spec.description,
|
|
52
52
|
inputSchema,
|
|
53
53
|
...(spec.annotations ? { annotations: spec.annotations } : {}),
|
|
54
|
+
...(spec.alwaysLoad ? { _meta: { "anthropic/alwaysLoad": true } } : {}),
|
|
54
55
|
}, spec.handler);
|
|
55
56
|
}
|
|
56
57
|
const ALIAS_MAX_WORDS = 12;
|