@esneiderbravo/speclaw 2.0.19 → 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/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 +328 -24
- 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
|
|
@@ -157,14 +158,201 @@ function findArchived(projectPath, name) {
|
|
|
157
158
|
.pop();
|
|
158
159
|
return hit ? path.join("lawbook", "changes", "archive", hit) : null;
|
|
159
160
|
}
|
|
161
|
+
const RECORD_STUB = "<!-- 2–5 lines: what and why. -->";
|
|
160
162
|
function fillRecord(recordPath, summary) {
|
|
161
163
|
if (!fs.existsSync(recordPath))
|
|
162
164
|
return;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
.replace(
|
|
167
|
-
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;
|
|
168
356
|
}
|
|
169
357
|
/**
|
|
170
358
|
* Record the test verdict derived from gate exit codes. Ship never records a
|
|
@@ -191,12 +379,13 @@ function recordGateVerdict(projectPath, change) {
|
|
|
191
379
|
}
|
|
192
380
|
}
|
|
193
381
|
/**
|
|
194
|
-
* Fast path for finished work, in one call:
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
* 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.
|
|
200
389
|
*
|
|
201
390
|
* @param projectPath - Project root with `lawbook/`.
|
|
202
391
|
* @param name - Change name (kebab-case).
|
|
@@ -210,16 +399,46 @@ export function shipChange(projectPath, name, opts = {}) {
|
|
|
210
399
|
const date = opts.date ?? new Date().toISOString().slice(0, 10);
|
|
211
400
|
const changeDir = path.join(projectPath, "lawbook", "changes", name);
|
|
212
401
|
const files = branchFiles(projectPath);
|
|
213
|
-
|
|
214
|
-
if (!fs.existsSync(changeDir) &&
|
|
215
|
-
|
|
402
|
+
let archived = findArchived(projectPath, name);
|
|
403
|
+
if (archived && !fs.existsSync(changeDir) && reopenIfGrown(projectPath, name, archived, files)) {
|
|
404
|
+
archived = null;
|
|
216
405
|
}
|
|
217
|
-
|
|
218
|
-
(files
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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);
|
|
222
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
|
+
}
|
|
223
442
|
const commands = opts.gates ?? cfg.gates ?? detectGates(projectPath);
|
|
224
443
|
const gates = [];
|
|
225
444
|
for (const command of commands) {
|
|
@@ -265,6 +484,7 @@ export function shipChange(projectPath, name, opts = {}) {
|
|
|
265
484
|
gates,
|
|
266
485
|
files,
|
|
267
486
|
report: reportRel,
|
|
487
|
+
pending,
|
|
268
488
|
archivedTo,
|
|
269
489
|
next,
|
|
270
490
|
timings: {
|
|
@@ -277,6 +497,92 @@ export function shipChange(projectPath, name, opts = {}) {
|
|
|
277
497
|
},
|
|
278
498
|
};
|
|
279
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
|
+
}
|
|
280
586
|
/**
|
|
281
587
|
* Change name for a branch: the part after the last `/`, kebab-cased
|
|
282
588
|
* (`fix/compass-query-output` → `compass-query-output`).
|
|
@@ -388,8 +694,6 @@ export function shipOnStop(projectPath) {
|
|
|
388
694
|
}
|
|
389
695
|
// A change shipped by name on this branch keeps receiving the branch's later
|
|
390
696
|
// work; the branch name would point at another (possibly archived) change.
|
|
391
|
-
const change =
|
|
392
|
-
? marker.change
|
|
393
|
-
: changeNameForBranch(branch);
|
|
697
|
+
const change = branchChange(projectPath, branch);
|
|
394
698
|
return { skipped: null, change, result: shipChange(projectPath, change) };
|
|
395
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;
|