@esneiderbravo/speclaw 0.3.11 → 0.3.12
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/lawbook.js +43 -6
- package/dist/cli/commands/quick.js +35 -0
- package/dist/cli/commands/update.js +10 -0
- package/dist/cli/index.js +11 -1
- package/dist/modules/foundation/doctor.js +63 -0
- package/dist/modules/lawbook/assets/commands/archive.md +5 -6
- package/dist/modules/lawbook/assets/commands/draft.md +6 -7
- package/dist/modules/lawbook/assets/commands/quick.md +14 -0
- package/dist/modules/lawbook/assets/skills/archive/steps/03-validate-and-sync.md +4 -3
- package/dist/modules/lawbook/assets/skills/draft/SKILL.md +1 -1
- package/dist/modules/lawbook/assets/skills/draft/steps/02-understand.md +3 -0
- package/dist/modules/lawbook/assets/skills/draft/steps/04-write-artifacts.md +28 -25
- package/dist/modules/lawbook/assets/skills/quick/SKILL.md +11 -0
- package/dist/modules/lawbook/assets/skills/quick/steps/01-scaffold.md +6 -0
- package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +7 -0
- package/dist/modules/lawbook/engine.js +115 -55
- package/dist/modules/lawbook/levels.js +421 -0
- package/dist/modules/lawbook/quick.js +86 -0
- package/dist/modules/lawbook/register.js +10 -0
- package/dist/shared/exposure.js +1 -0
- package/package.json +1 -1
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { specInit, specValidate, specSync, specArchive, specList, } from "../../modules/lawbook/engine.js";
|
|
2
|
+
import { handleLevel } from "../../modules/lawbook/quick.js";
|
|
3
|
+
import { list } from "../lib/args.js";
|
|
2
4
|
import { ui } from "../lib/ui.js";
|
|
3
5
|
function today() {
|
|
4
6
|
// The MCP path passes the date in; the CLL runs on a real machine, so read it here.
|
|
5
7
|
return new Date().toISOString().slice(0, 10);
|
|
6
8
|
}
|
|
7
9
|
/**
|
|
8
|
-
* Run a spec-workflow subcommand: init, list, validate, sync, or
|
|
10
|
+
* Run a spec-workflow subcommand: init, list, validate, sync, archive, or level.
|
|
9
11
|
*
|
|
10
12
|
* @param flags - Parsed flags; `_[0]` is the subcommand and `_[1]` the change name where required.
|
|
11
13
|
* @throws Exits the process with code 1 on unknown subcommands, missing arguments, or engine errors.
|
|
@@ -27,13 +29,48 @@ export async function runSpec(flags) {
|
|
|
27
29
|
if (!r.initialized)
|
|
28
30
|
return ui.warn("No lawbook/ — run `speclaw lawbook init`.");
|
|
29
31
|
ui.heading("Lawbook workspace");
|
|
30
|
-
|
|
32
|
+
if (r.activeChanges.length === 0)
|
|
33
|
+
ui.info("active changes: none");
|
|
34
|
+
else {
|
|
35
|
+
ui.info("active changes:");
|
|
36
|
+
for (const name of r.activeChanges) {
|
|
37
|
+
const lvl = r.activeLevels[name] ?? 3;
|
|
38
|
+
ui.info(` ${name} (level ${lvl})`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
31
41
|
ui.info(`archived: ${r.archivedChanges.join(", ") || "none"}`);
|
|
32
42
|
ui.info(`capabilities: ${r.capabilities.join(", ") || "none"}`);
|
|
33
43
|
return;
|
|
34
44
|
}
|
|
45
|
+
case "level": {
|
|
46
|
+
const modeRaw = change ?? "propose";
|
|
47
|
+
const mode = modeRaw;
|
|
48
|
+
if (!["propose", "set", "promote", "explain"].includes(mode)) {
|
|
49
|
+
ui.err("Usage: speclaw lawbook level <propose|set|promote|explain> [--change <c>] [--path …] [--level N] [--reason …] [--json]");
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
const levelFlag = flags.level;
|
|
53
|
+
const level = levelFlag === undefined || levelFlag === true
|
|
54
|
+
? undefined
|
|
55
|
+
: Number(levelFlag);
|
|
56
|
+
const result = handleLevel({
|
|
57
|
+
projectPath: cwd,
|
|
58
|
+
mode,
|
|
59
|
+
change: typeof flags.change === "string" ? flags.change : flags._[2],
|
|
60
|
+
paths: list(flags.path),
|
|
61
|
+
symbols: list(flags.symbol),
|
|
62
|
+
level,
|
|
63
|
+
reason: typeof flags.reason === "string" ? flags.reason : undefined,
|
|
64
|
+
});
|
|
65
|
+
if (flags.json) {
|
|
66
|
+
console.log(JSON.stringify(result, null, 2));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
console.log(JSON.stringify(result, null, 2));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
35
72
|
case "validate": {
|
|
36
|
-
const r = specValidate(cwd, req(change, "
|
|
73
|
+
const r = specValidate(cwd, req(change, "lawbook validate <change>"));
|
|
37
74
|
if (r.valid)
|
|
38
75
|
ui.ok(`${r.change} is valid (${r.deltaSpecs.length} delta spec(s))`);
|
|
39
76
|
else {
|
|
@@ -47,13 +84,13 @@ export async function runSpec(flags) {
|
|
|
47
84
|
return;
|
|
48
85
|
}
|
|
49
86
|
case "sync": {
|
|
50
|
-
const r = specSync(cwd, req(change, "
|
|
87
|
+
const r = specSync(cwd, req(change, "lawbook sync <change>"));
|
|
51
88
|
ui.ok(`promoted ${r.promoted.length} spec(s)`);
|
|
52
89
|
r.promoted.forEach((p) => ui.info(`${r.created.includes(p) ? "created" : "updated"}: ${p}`));
|
|
53
90
|
return;
|
|
54
91
|
}
|
|
55
92
|
case "archive": {
|
|
56
|
-
const r = specArchive(cwd, req(change, "
|
|
93
|
+
const r = specArchive(cwd, req(change, "lawbook archive <change>"), today());
|
|
57
94
|
ui.ok(`archived to ${r.archivedTo} (${r.promoted.length} spec(s) promoted)`);
|
|
58
95
|
r.promoted.forEach((p) => ui.info(`${r.created.includes(p) ? "created" : "updated"}: ${p}`));
|
|
59
96
|
for (const s of r.seals) {
|
|
@@ -66,7 +103,7 @@ export async function runSpec(flags) {
|
|
|
66
103
|
return;
|
|
67
104
|
}
|
|
68
105
|
default:
|
|
69
|
-
ui.err("Usage: speclaw lawbook <init|list|validate|sync|archive> [change]");
|
|
106
|
+
ui.err("Usage: speclaw lawbook <init|list|validate|sync|archive|level> [change]");
|
|
70
107
|
process.exit(1);
|
|
71
108
|
}
|
|
72
109
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { list } from "../lib/args.js";
|
|
2
|
+
import { ui } from "../lib/ui.js";
|
|
3
|
+
import { scaffoldQuick } from "../../modules/lawbook/quick.js";
|
|
4
|
+
/**
|
|
5
|
+
* Scaffold a level-0 change (`speclaw quick <name>`).
|
|
6
|
+
*
|
|
7
|
+
* @param flags - `_[0]` is the change name; optional `--path` / `--symbol` / `--json`.
|
|
8
|
+
*/
|
|
9
|
+
export async function runQuick(flags) {
|
|
10
|
+
const cwd = process.cwd();
|
|
11
|
+
const name = flags._[0];
|
|
12
|
+
if (!name || typeof name !== "string") {
|
|
13
|
+
ui.err("Usage: speclaw quick <name> [--path <file>] [--symbol <sym>] [--json]");
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const result = scaffoldQuick(cwd, name, {
|
|
18
|
+
paths: list(flags.path),
|
|
19
|
+
symbols: list(flags.symbol),
|
|
20
|
+
});
|
|
21
|
+
if (flags.json) {
|
|
22
|
+
console.log(JSON.stringify(result, null, 2));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
ui.ok(`level-0 change ${ui.code(result.change)} at ${result.dir}`);
|
|
26
|
+
ui.info(result.proposal.rationale);
|
|
27
|
+
if (result.proposal.level !== null && result.proposal.level > 0) {
|
|
28
|
+
ui.warn(`measured proposal was level ${result.proposal.level} — promote if the fix grows`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
ui.err(err.message);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -125,6 +125,16 @@ const MIGRATIONS = [
|
|
|
125
125
|
"(`node_metrics`) — reindex with `speclaw index`. Default history window is 90 days.\n" +
|
|
126
126
|
"- Preserve all project-specific wording; only apply these speclaw-authored changes.",
|
|
127
127
|
},
|
|
128
|
+
{
|
|
129
|
+
version: "0.3.12",
|
|
130
|
+
describe: "Adaptive ceremony levels 0–3, speclaw quick, lawbook_level",
|
|
131
|
+
agentPrompt: "- Mention ceremony levels 0–3 (`change.json`), `speclaw quick` for level-0 scaffolds, and " +
|
|
132
|
+
"`lawbook_level` / `speclaw lawbook level` for propose/set/promote. Artifact volume follows " +
|
|
133
|
+
"the confirmed level; missing `change.json` still means full ceremony (level 3). Optional " +
|
|
134
|
+
"`ceremony:` block in `lawbook/config.yaml` (cuts default [3, 8, 15]). Update LAWS / " +
|
|
135
|
+
"docs/standards/lawbook.md wording if the project still says every change needs all four artifacts.\n" +
|
|
136
|
+
"- Preserve all project-specific wording; only apply these speclaw-authored changes.",
|
|
137
|
+
},
|
|
128
138
|
];
|
|
129
139
|
/**
|
|
130
140
|
* Update speclaw and bring the current project up to date without a full re-init:
|
package/dist/cli/index.js
CHANGED
|
@@ -31,8 +31,10 @@ Compass (code intelligence — the same surface agents use via MCP)
|
|
|
31
31
|
visualize [node] Interactive HTML graph → .speclaw/graph.html
|
|
32
32
|
|
|
33
33
|
Lawbook (spec-driven workflow)
|
|
34
|
+
quick <name> Scaffold a level-0 change (record.md + reports)
|
|
34
35
|
lawbook init Create the lawbook/ workspace
|
|
35
36
|
lawbook list Active/archived changes and capabilities
|
|
37
|
+
lawbook level <mode> Propose/set/promote/explain ceremony level (--json)
|
|
36
38
|
lawbook validate <c> Validate a change's artifacts
|
|
37
39
|
lawbook sync <c> Promote delta specs to canonical
|
|
38
40
|
lawbook archive <c> Finalize and archive a change
|
|
@@ -54,7 +56,8 @@ Other
|
|
|
54
56
|
// interactive, human-facing commands whose stdout is prose. Deliberately
|
|
55
57
|
// excluded: `version`/`--version`/`-v` (bare scriptable value), the Compass
|
|
56
58
|
// query family (`explore`/`search`/`recall`/`impact`/`trace`/`affected-tests`/
|
|
57
|
-
// `hotspots`/`coupling`, machine-consumed output), `
|
|
59
|
+
// `hotspots`/`coupling`, machine-consumed output), `quick` (often --json),
|
|
60
|
+
// `mcp` (a long-running stdio
|
|
58
61
|
// server), and `init` (already opens with the fuller `banner()`).
|
|
59
62
|
const HEADER_COMMANDS = new Set([
|
|
60
63
|
undefined,
|
|
@@ -71,6 +74,7 @@ const HEADER_COMMANDS = new Set([
|
|
|
71
74
|
"index",
|
|
72
75
|
"watch",
|
|
73
76
|
"lawbook",
|
|
77
|
+
"quick",
|
|
74
78
|
]);
|
|
75
79
|
/**
|
|
76
80
|
* Print the branded header once, ahead of a command's output, when it is a
|
|
@@ -94,6 +98,10 @@ function maybeHeader(cmd, flags) {
|
|
|
94
98
|
return;
|
|
95
99
|
if (cmd === "drift" && flags.json)
|
|
96
100
|
return;
|
|
101
|
+
if (cmd === "quick" && flags.json)
|
|
102
|
+
return;
|
|
103
|
+
if (cmd === "lawbook" && flags.json && flags._[0] === "level")
|
|
104
|
+
return;
|
|
97
105
|
header();
|
|
98
106
|
}
|
|
99
107
|
/** Run the handler for a single command. Returns when the command completes. */
|
|
@@ -135,6 +143,8 @@ async function dispatch(cmd, flags) {
|
|
|
135
143
|
return (await import("./commands/query.js")).runQuery(cmd, flags);
|
|
136
144
|
case "visualize":
|
|
137
145
|
return (await import("./commands/visualize.js")).runVisualize(flags);
|
|
146
|
+
case "quick":
|
|
147
|
+
return (await import("./commands/quick.js")).runQuick(flags);
|
|
138
148
|
case "lawbook":
|
|
139
149
|
return (await import("./commands/lawbook.js")).runSpec(flags);
|
|
140
150
|
case "doctor":
|
|
@@ -8,6 +8,7 @@ import { pkgName, pkgVersion } from "../../shared/version.js";
|
|
|
8
8
|
import { indexExists, openDb } from "../compass/db.js";
|
|
9
9
|
import { specList } from "../lawbook/engine.js";
|
|
10
10
|
import { doctorDriftCheck } from "../lawbook/drift.js";
|
|
11
|
+
import { loadCeremonyConfig } from "../lawbook/levels.js";
|
|
11
12
|
import { globError, hasBackend, hasBatchBackend, readLawManifest } from "./laws.js";
|
|
12
13
|
import { redactValue } from "../../shared/redact.js";
|
|
13
14
|
const STATUS_RANK = {
|
|
@@ -467,6 +468,67 @@ function specsOrphansCheck(projectPath) {
|
|
|
467
468
|
remedy: `speclaw lawbook archive ${active[0]}`,
|
|
468
469
|
};
|
|
469
470
|
}
|
|
471
|
+
/** Ceremony config validity + archived level histogram. */
|
|
472
|
+
function ceremonyChecks(projectPath) {
|
|
473
|
+
const out = [];
|
|
474
|
+
const { invalidCuts } = loadCeremonyConfig(projectPath);
|
|
475
|
+
if (invalidCuts) {
|
|
476
|
+
out.push({
|
|
477
|
+
id: "cfg.ceremony.cuts",
|
|
478
|
+
title: "ceremony thresholds",
|
|
479
|
+
status: "warn",
|
|
480
|
+
detail: "invalid ceremony.cuts — using built-in defaults [3, 8, 15]",
|
|
481
|
+
remedy: "fix cuts in lawbook/config.yaml so they are strictly increasing, or remove the block",
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
out.push({
|
|
486
|
+
id: "cfg.ceremony.cuts",
|
|
487
|
+
title: "ceremony thresholds",
|
|
488
|
+
status: "ok",
|
|
489
|
+
detail: "ceremony cuts valid (or using defaults)",
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
const archiveRoot = path.join(projectPath, "lawbook", "changes", "archive");
|
|
493
|
+
const counts = { "0": 0, "1": 0, "2": 0, "3": 0, missing: 0 };
|
|
494
|
+
if (fs.existsSync(archiveRoot)) {
|
|
495
|
+
for (const name of fs.readdirSync(archiveRoot)) {
|
|
496
|
+
const dir = path.join(archiveRoot, name);
|
|
497
|
+
if (!fs.statSync(dir).isDirectory())
|
|
498
|
+
continue;
|
|
499
|
+
// archived folder is YYYY-MM-DD-name; change.json lives inside
|
|
500
|
+
const rec = (() => {
|
|
501
|
+
try {
|
|
502
|
+
const p = path.join(dir, "change.json");
|
|
503
|
+
if (!fs.existsSync(p))
|
|
504
|
+
return null;
|
|
505
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
})();
|
|
511
|
+
if (!rec || rec.confirmedLevel === undefined) {
|
|
512
|
+
counts.missing += 1;
|
|
513
|
+
counts["3"] += 1;
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
counts[String(rec.confirmedLevel)] = (counts[String(rec.confirmedLevel)] ?? 0) + 1;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const total = (counts["0"] ?? 0) + (counts["1"] ?? 0) + (counts["2"] ?? 0) + (counts["3"] ?? 0);
|
|
521
|
+
out.push({
|
|
522
|
+
id: "cfg.ceremony.levels",
|
|
523
|
+
title: "ceremony level distribution",
|
|
524
|
+
status: "ok",
|
|
525
|
+
value: JSON.stringify(counts),
|
|
526
|
+
detail: total === 0
|
|
527
|
+
? "no archived changes"
|
|
528
|
+
: `archived levels: 0=${counts["0"]}, 1=${counts["1"]}, 2=${counts["2"]}, 3=${counts["3"]} (missing change.json=${counts.missing})`,
|
|
529
|
+
});
|
|
530
|
+
return out;
|
|
531
|
+
}
|
|
470
532
|
function configurationChecks(projectPath, initialised) {
|
|
471
533
|
if (!initialised) {
|
|
472
534
|
const ids = [
|
|
@@ -559,6 +621,7 @@ export async function doctor(projectPath, opts = {}) {
|
|
|
559
621
|
configuration.push(await budgetCheck(projectPath));
|
|
560
622
|
configuration.push(freshnessCheck(projectPath));
|
|
561
623
|
configuration.push(specsOrphansCheck(projectPath));
|
|
624
|
+
configuration.push(...ceremonyChecks(projectPath));
|
|
562
625
|
{
|
|
563
626
|
const d = doctorDriftCheck(projectPath);
|
|
564
627
|
addCheck(configuration, {
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Finalize a completed change — sync specs
|
|
2
|
+
description: Finalize a completed change — sync specs when needed, then archive it.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
Archive the completed change: $ARGUMENTS
|
|
6
6
|
|
|
7
|
-
Follow the `archive` skill: confirm every task
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
`lawbook/changes/archive/`. Never move the folder by hand.
|
|
7
|
+
Follow the `archive` skill: confirm every task (or level-0 checklist) is done
|
|
8
|
+
and gates are green, reconcile if the level has delta specs, run
|
|
9
|
+
`lawbook_validate`, then `lawbook_archive` with today's date (YYYY-MM-DD). Sync
|
|
10
|
+
runs only when the ceremony level requires specs. Never move the folder by hand.
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Draft a new spec-driven change
|
|
2
|
+
description: Draft a new spec-driven change at the confirmed ceremony level before coding.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
Draft a new change under `lawbook/changes/<name>/` for: $ARGUMENTS
|
|
6
6
|
|
|
7
|
-
Follow the `draft` skill: ensure `lawbook/` exists (`lawbook_init`), investigate
|
|
8
|
-
|
|
9
|
-
`
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
running `lawbook_validate` and fixing every issue.
|
|
7
|
+
Follow the `draft` skill: ensure `lawbook/` exists (`lawbook_init`), investigate
|
|
8
|
+
with Compass, propose a ceremony level (`lawbook_level` mode `propose`) and
|
|
9
|
+
**confirm** it with the human (`set`), then scaffold only the artifacts that
|
|
10
|
+
level requires. For true one-liners use `speclaw quick` / the `quick` skill
|
|
11
|
+
instead. Finish by running `lawbook_validate` and fixing every issue.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Scaffold a level-0 lawbook change (record.md + reports) for a tiny fix.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Scaffold a **ceremony level 0** change named `$ARGUMENTS` with `speclaw quick`
|
|
6
|
+
(or the equivalent `scaffoldQuick` path). Do **not** invent `proposal.md` /
|
|
7
|
+
`design.md` / delta specs for a true one-liner.
|
|
8
|
+
|
|
9
|
+
1. Ensure `lawbook/` exists (`lawbook_init` if needed).
|
|
10
|
+
2. Prefer passing `--path` / `--symbol` so the proposal rationale is measured.
|
|
11
|
+
3. Confirm with the human if the measured proposal is higher than 0 — promote
|
|
12
|
+
(`lawbook_level` mode `promote`) instead of staying at quick.
|
|
13
|
+
4. Implement, check the checklist in `record.md`, write a discipline report
|
|
14
|
+
under `reports/`, then archive (no sync required at level 0).
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Validate and sync
|
|
2
2
|
|
|
3
|
-
Run `lawbook_validate
|
|
4
|
-
`
|
|
5
|
-
unless the canonical specs already match
|
|
3
|
+
Run `lawbook_validate`. If the confirmed ceremony level requires delta specs
|
|
4
|
+
(levels 1–3), run `lawbook_sync` to promote them into `lawbook/specs/` —
|
|
5
|
+
`lawbook_archive` refuses unless the canonical specs already match. At **level
|
|
6
|
+
0**, skip sync (there are no deltas).
|
|
6
7
|
|
|
7
8
|
Next: read `steps/04-archive.md` and do only what it says.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: draft
|
|
3
|
-
description: Draft a new spec-driven change —
|
|
3
|
+
description: Draft a new spec-driven change — propose a ceremony level, then write only the artifacts that level needs — before writing any code. Use when the user wants to start, plan, or propose a new feature, fix, or refactor: "draft a change for X", "propose X", "let's plan X", "spec out X", "new change". Part of speclaw's lawbook module (draft → build → sync → archive).
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# draft — Draft a new change
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
- Clarify what the user wants (feature / fix / refactor) and confirm scope.
|
|
7
7
|
- Use `compass_explore` and `compass_recall` (speclaw's code index) BEFORE
|
|
8
8
|
grep/read to locate the real code the change touches and its blast radius.
|
|
9
|
+
- **Propose a ceremony level** with `lawbook_level` (mode `propose`) using the
|
|
10
|
+
paths/symbols you found; **confirm with the human** (mode `set`) before
|
|
11
|
+
writing artifacts. For an obvious one-liner, offer `speclaw quick` instead.
|
|
9
12
|
- Read the governing standards in `docs/standards/` (architecture, backend,
|
|
10
13
|
frontend, testing) so the change complies with the project's law.
|
|
11
14
|
|
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
# Write the artifacts
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
3
|
+
Artifact volume follows the **confirmed ceremony level** in `change.json`
|
|
4
|
+
(propose + confirm with `lawbook_level` / the human **before** scaffolding).
|
|
5
|
+
Missing `change.json` means level 3.
|
|
6
|
+
|
|
7
|
+
Create under `lawbook/changes/<name>/` only what the level needs:
|
|
8
|
+
|
|
9
|
+
- **Level 0** — prefer `speclaw quick` / the `quick` skill: `record.md`
|
|
10
|
+
(inline checklist) + `reports/` + `change.json`. No proposal/design/deltas.
|
|
11
|
+
- **Level 1** — `record.md`, `tasks.md`, ≥1 delta under
|
|
12
|
+
`specs/<capability>/spec.md`, `reports/`, `change.json`.
|
|
13
|
+
- **Level 2** — `proposal.md`, `tasks.md`, delta specs, `reports/`;
|
|
14
|
+
`design.md` optional only with justification in `record.md`.
|
|
15
|
+
- **Level 3** — `proposal.md`, `design.md`, `tasks.md`, delta specs, `reports/`.
|
|
16
|
+
|
|
17
|
+
For every level that needs delta specs:
|
|
18
|
+
|
|
19
|
+
- **specs/<capability>/spec.md** — the delta for each affected capability.
|
|
20
|
+
`sync` promotes by overwriting the whole canonical file, so the delta must
|
|
21
|
+
carry the capability's **full** intended spec. When updating an existing
|
|
22
|
+
capability, **start from** `lawbook/specs/<capability>/spec.md`. Use normative
|
|
13
23
|
language and testable scenarios:
|
|
14
24
|
```markdown
|
|
15
25
|
# <Capability>
|
|
@@ -22,20 +32,13 @@ Create under `lawbook/changes/<name>/`:
|
|
|
22
32
|
- When <action>
|
|
23
33
|
- Then <observable outcome>
|
|
24
34
|
```
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
open set (`backend.md`, `frontend.md`, `api.md`, `database.md`, `infra.md`,
|
|
34
|
-
`security.md`, … — and `api.md` is required when the change touches any API
|
|
35
|
-
surface) that `build` will fill, following the required report structure
|
|
36
|
-
(header · gates table · tests added · spec-scenario coverage · pre-existing
|
|
37
|
-
failures · pending manual · verdict — see the `build` skill's discipline-reports
|
|
38
|
-
step). Every change ships this folder; archive is blocked until it holds at
|
|
39
|
-
least one discipline report.
|
|
35
|
+
|
|
36
|
+
- **tasks.md** (levels 1–3) — ordered, checkable steps. MUST include the
|
|
37
|
+
mandatory steps from `lawbook/config.yaml` (feature branch first; tests;
|
|
38
|
+
manual verification by the agent; discipline reports; docs; archive in PR).
|
|
39
|
+
- **reports/** — always scaffold with `reports/README.md` naming expected
|
|
40
|
+
disciplines (`backend.md`, `frontend.md`, `api.md`, … — `api.md` when an API
|
|
41
|
+
surface is touched). Archive is blocked until at least one discipline report
|
|
42
|
+
exists.
|
|
40
43
|
|
|
41
44
|
Next: read `steps/05-validate.md` and do only what it says.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: quick
|
|
3
|
+
description: Scaffold a level-0 lawbook change (record.md + reports) for a tiny fix. Use when the user wants a one-line fix, typo, or docs-only tweak without full ceremony — "quick change", "speclaw quick", "level 0", "skip proposal".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# quick — Level-0 change
|
|
7
|
+
|
|
8
|
+
Create `lawbook/changes/<name>/` with `record.md`, `change.json` (confirmed
|
|
9
|
+
level 0), and `reports/`. No proposal, design, or delta specs.
|
|
10
|
+
|
|
11
|
+
Read `steps/01-scaffold.md` and do only what it says.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Implement and evidence
|
|
2
|
+
|
|
3
|
+
Make the fix, tick every `- [ ]` in `record.md`, and write at least one
|
|
4
|
+
discipline report under `reports/`. Archive with `lawbook_archive` (no sync at
|
|
5
|
+
level 0). Promote via `lawbook_level` if scope grew.
|
|
6
|
+
|
|
7
|
+
No further steps — workflow complete.
|
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { coverageArchiveBlockers } from "./coverage.js";
|
|
4
4
|
import { sealCapability } from "./anchors.js";
|
|
5
|
+
import { artifactNeeds, confirmedLevel, countUncheckedTasks, gatherSignals, hasDisciplineReport, loadCeremonyConfig, proposeLevel, } from "./levels.js";
|
|
5
6
|
// speclaw's own spec-driven workflow engine. Inspired by OpenSpec's model
|
|
6
7
|
// (proposals, delta specs, changes, archive) but implemented from scratch and
|
|
7
8
|
// deliberately simpler: a change's specs/ holds the full intended spec for each
|
|
@@ -33,25 +34,33 @@ mandatory_task_steps:
|
|
|
33
34
|
- "Archive the change within the same PR (lawbook:archive)."
|
|
34
35
|
|
|
35
36
|
# A change is required for new behavior, endpoints, schema changes, or UI flows;
|
|
36
|
-
# one-line fixes
|
|
37
|
+
# one-line fixes may use ceremony level 0 (\`speclaw quick\`) instead of full artifacts.
|
|
38
|
+
|
|
39
|
+
# Ceremony levels (adaptive). Defaults match speclaw's built-in thresholds.
|
|
40
|
+
ceremony:
|
|
41
|
+
cuts: [3, 8, 15]
|
|
42
|
+
hotspotFloor: 0.7
|
|
37
43
|
`;
|
|
38
44
|
const README_MD = `# lawbook/ — the spec-driven workflow (speclaw)
|
|
39
45
|
|
|
40
46
|
This directory is managed by speclaw's **lawbook** module.
|
|
41
47
|
|
|
42
48
|
- \`specs/\` — the canonical specifications (the current source of truth).
|
|
43
|
-
- \`changes/<name>/\` — an in-flight change
|
|
44
|
-
|
|
49
|
+
- \`changes/<name>/\` — an in-flight change. Artifact volume follows the
|
|
50
|
+
confirmed ceremony level in \`change.json\` (0=quick … 3=full). Missing
|
|
51
|
+
\`change.json\` means level 3 (proposal, design, tasks, delta specs).
|
|
45
52
|
- \`changes/archive/\` — completed, archived changes.
|
|
46
|
-
- \`config.yaml\` — mandatory task steps and
|
|
53
|
+
- \`config.yaml\` — mandatory task steps, coverage, and optional ceremony cuts.
|
|
47
54
|
|
|
48
55
|
## Workflow
|
|
49
56
|
|
|
50
|
-
1. \`lawbook:
|
|
51
|
-
2. \`lawbook:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
57
|
+
1. \`lawbook:explore\` — think through an idea before or during a change.
|
|
58
|
+
2. \`lawbook:draft\` / \`speclaw quick\` — propose/confirm a ceremony level, then
|
|
59
|
+
scaffold only the artifacts that level requires.
|
|
60
|
+
3. \`lawbook:build\` — implement the tasks.
|
|
61
|
+
4. \`lawbook:sync\` — promote the change's delta specs into \`specs/\` (when the
|
|
62
|
+
level requires specs).
|
|
63
|
+
5. \`lawbook:archive\` — sync (if needed) + move the change to \`changes/archive/\`.
|
|
55
64
|
`;
|
|
56
65
|
/**
|
|
57
66
|
* Initialize the spec/ workspace, creating the specs/, changes/, and archive
|
|
@@ -146,18 +155,17 @@ function deltaSpecFiles(changeDir) {
|
|
|
146
155
|
return out;
|
|
147
156
|
}
|
|
148
157
|
/**
|
|
149
|
-
* Validate a change's artifacts
|
|
150
|
-
*
|
|
151
|
-
* header, and a "#### Scenario:" acceptance criterion.
|
|
158
|
+
* Validate a change's artifacts against its confirmed ceremony level
|
|
159
|
+
* (missing `change.json` ⇒ level 3 / full ceremony).
|
|
152
160
|
*
|
|
153
161
|
* @param projectPath - Absolute path to the project root.
|
|
154
162
|
* @param change - Change name (folder under lawbook/changes/).
|
|
155
|
-
* @
|
|
156
|
-
* for a missing change — it is reported as an issue with `valid: false`.
|
|
163
|
+
* @param remeasure - Optional targets to re-score scope growth (paths/symbols).
|
|
157
164
|
*/
|
|
158
|
-
export function specValidate(projectPath, change) {
|
|
165
|
+
export function specValidate(projectPath, change, remeasure) {
|
|
159
166
|
const changeDir = path.join(specRoot(projectPath), "changes", change);
|
|
160
167
|
const issues = [];
|
|
168
|
+
const warnings = [];
|
|
161
169
|
if (!fs.existsSync(changeDir)) {
|
|
162
170
|
return {
|
|
163
171
|
change,
|
|
@@ -167,18 +175,43 @@ export function specValidate(projectPath, change) {
|
|
|
167
175
|
deltaSpecs: [],
|
|
168
176
|
};
|
|
169
177
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
178
|
+
const level = confirmedLevel(projectPath, change);
|
|
179
|
+
const needs = artifactNeeds(level);
|
|
180
|
+
if (needs.record && !fs.existsSync(path.join(changeDir, "record.md"))) {
|
|
181
|
+
issues.push(`missing record.md (required at ceremony level ${level})`);
|
|
182
|
+
}
|
|
183
|
+
if (needs.proposal && !fs.existsSync(path.join(changeDir, "proposal.md"))) {
|
|
184
|
+
issues.push(`missing proposal.md (required at ceremony level ${level})`);
|
|
185
|
+
}
|
|
186
|
+
if (needs.design && !fs.existsSync(path.join(changeDir, "design.md"))) {
|
|
187
|
+
issues.push(`missing design.md (required at ceremony level ${level})`);
|
|
188
|
+
}
|
|
189
|
+
if (needs.designOptionalWithJustification && !fs.existsSync(path.join(changeDir, "design.md"))) {
|
|
190
|
+
const record = path.join(changeDir, "record.md");
|
|
191
|
+
const text = fs.existsSync(record) ? fs.readFileSync(record, "utf8") : "";
|
|
192
|
+
if (!/design\s*(omitted|skipped|n\/a)/i.test(text) && !/why.*design/i.test(text)) {
|
|
193
|
+
issues.push(`level ${level}: design.md omitted without justification in record.md`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (needs.tasksFile && !fs.existsSync(path.join(changeDir, "tasks.md"))) {
|
|
197
|
+
issues.push(`missing tasks.md (required at ceremony level ${level})`);
|
|
198
|
+
}
|
|
175
199
|
const deltas = deltaSpecFiles(changeDir);
|
|
176
|
-
if (deltas.length === 0)
|
|
177
|
-
issues.push(
|
|
200
|
+
if (needs.deltaSpecs && deltas.length === 0) {
|
|
201
|
+
issues.push(`no delta specs under specs/ (required at ceremony level ${level})`);
|
|
202
|
+
}
|
|
203
|
+
// Scope-growth: when remeasure targets provided (or change.json has prior signals
|
|
204
|
+
// with paths we cannot recover), only check if caller passes targets.
|
|
205
|
+
if (remeasure && (remeasure.paths.length > 0 || remeasure.symbols.length > 0)) {
|
|
206
|
+
const { thresholds } = loadCeremonyConfig(projectPath);
|
|
207
|
+
const measured = proposeLevel(gatherSignals(projectPath, remeasure, thresholds), thresholds);
|
|
208
|
+
if (measured.level !== null && measured.level >= level + 2) {
|
|
209
|
+
issues.push(`scope grew: measured level ${measured.level}, recorded ${level} — run promote or justify (${measured.rationale})`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
178
212
|
const root = specRoot(projectPath);
|
|
179
213
|
const changeSpecs = path.join(changeDir, "specs");
|
|
180
214
|
const capabilities = canonicalCapabilities(root);
|
|
181
|
-
const warnings = [];
|
|
182
215
|
for (const file of deltas) {
|
|
183
216
|
const rel = path.relative(changeDir, file);
|
|
184
217
|
const content = fs.readFileSync(file, "utf8");
|
|
@@ -191,7 +224,6 @@ export function specValidate(projectPath, change) {
|
|
|
191
224
|
if (!/^###\s+Requirement:/m.test(content)) {
|
|
192
225
|
issues.push(`${rel}: no "### Requirement:" header`);
|
|
193
226
|
}
|
|
194
|
-
// Advisory divergence checks against the canonical specs.
|
|
195
227
|
const relFromSpecs = path.relative(changeSpecs, file);
|
|
196
228
|
const capability = relFromSpecs.split(path.sep)[0];
|
|
197
229
|
const nearMatch = nearMatchCapability(capability, capabilities);
|
|
@@ -268,10 +300,9 @@ export function specSync(projectPath, change) {
|
|
|
268
300
|
* Deterministic completeness checks that gate archiving a change. Returns the
|
|
269
301
|
* blocking reasons; an empty array means the change may be archived.
|
|
270
302
|
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
* the last spec edit). The reports/README.md scaffold does not count as a report.
|
|
303
|
+
* Gates respect the confirmed ceremony level (missing `change.json` ⇒ level 3).
|
|
304
|
+
* Every level still requires checked tasks and a discipline report. Delta-spec
|
|
305
|
+
* sync is required only when the level demands delta specs.
|
|
275
306
|
*
|
|
276
307
|
* @param projectPath - Absolute path to the project root.
|
|
277
308
|
* @param change - Change name (folder under lawbook/changes/).
|
|
@@ -283,33 +314,46 @@ export function specArchivePreconditions(projectPath, change) {
|
|
|
283
314
|
if (!fs.existsSync(changeDir))
|
|
284
315
|
return [`change "${change}" not found under lawbook/changes/`];
|
|
285
316
|
const blockers = [];
|
|
286
|
-
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
317
|
+
const level = confirmedLevel(projectPath, change);
|
|
318
|
+
const needs = artifactNeeds(level);
|
|
319
|
+
// 1. Every task must be checked (tasks.md, or checklist in record.md at level 0).
|
|
320
|
+
if (needs.tasksFile) {
|
|
321
|
+
const tasksPath = path.join(changeDir, "tasks.md");
|
|
322
|
+
if (!fs.existsSync(tasksPath)) {
|
|
323
|
+
blockers.push("missing tasks.md");
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
const unchecked = countUncheckedTasks(fs.readFileSync(tasksPath, "utf8"));
|
|
327
|
+
if (unchecked > 0)
|
|
328
|
+
blockers.push(`${unchecked} unchecked task(s) in tasks.md`);
|
|
329
|
+
}
|
|
290
330
|
}
|
|
291
|
-
else {
|
|
292
|
-
const
|
|
293
|
-
if (
|
|
294
|
-
blockers.push(
|
|
331
|
+
else if (needs.record) {
|
|
332
|
+
const recordPath = path.join(changeDir, "record.md");
|
|
333
|
+
if (!fs.existsSync(recordPath)) {
|
|
334
|
+
blockers.push("missing record.md");
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
const unchecked = countUncheckedTasks(fs.readFileSync(recordPath, "utf8"));
|
|
338
|
+
if (unchecked > 0)
|
|
339
|
+
blockers.push(`${unchecked} unchecked task(s) in record.md`);
|
|
340
|
+
}
|
|
295
341
|
}
|
|
296
342
|
// 2. At least one discipline report must exist (README.md scaffold aside).
|
|
297
|
-
|
|
298
|
-
const reports = fs.existsSync(reportsDir)
|
|
299
|
-
? fs.readdirSync(reportsDir).filter((n) => n.endsWith(".md") && n.toLowerCase() !== "readme.md")
|
|
300
|
-
: [];
|
|
301
|
-
if (reports.length === 0) {
|
|
343
|
+
if (!hasDisciplineReport(changeDir)) {
|
|
302
344
|
blockers.push("no discipline report under reports/ (build must record what was tested)");
|
|
303
345
|
}
|
|
304
|
-
// 3. Delta specs must already be synced
|
|
305
|
-
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
346
|
+
// 3. Delta specs must already be synced — only when the level requires them.
|
|
347
|
+
if (needs.deltaSpecs) {
|
|
348
|
+
for (const file of deltaSpecFiles(changeDir)) {
|
|
349
|
+
const rel = path.relative(path.join(changeDir, "specs"), file);
|
|
350
|
+
const canonical = path.join(root, "specs", rel);
|
|
351
|
+
if (!fs.existsSync(canonical)) {
|
|
352
|
+
blockers.push(`spec not synced: lawbook/specs/${rel} missing (run sync first)`);
|
|
353
|
+
}
|
|
354
|
+
else if (fs.readFileSync(file, "utf8") !== fs.readFileSync(canonical, "utf8")) {
|
|
355
|
+
blockers.push(`spec not synced: lawbook/specs/${rel} differs from the delta (run sync first)`);
|
|
356
|
+
}
|
|
313
357
|
}
|
|
314
358
|
}
|
|
315
359
|
// 4. Opt-in coverage gate: only when the change's delta specs declare ids.
|
|
@@ -317,8 +361,8 @@ export function specArchivePreconditions(projectPath, change) {
|
|
|
317
361
|
return blockers;
|
|
318
362
|
}
|
|
319
363
|
/**
|
|
320
|
-
* Finalize a change: promote its delta specs (via {@link specSync})
|
|
321
|
-
* it to changes/archive/<date>-<name>/.
|
|
364
|
+
* Finalize a change: promote its delta specs (via {@link specSync}) when the
|
|
365
|
+
* ceremony level requires them, then move it to changes/archive/<date>-<name>/.
|
|
322
366
|
*
|
|
323
367
|
* @param projectPath - Absolute path to the project root.
|
|
324
368
|
* @param change - Change name (folder under lawbook/changes/).
|
|
@@ -336,7 +380,11 @@ export function specArchive(projectPath, change, date) {
|
|
|
336
380
|
if (blockers.length > 0) {
|
|
337
381
|
throw new Error(`cannot archive "${change}" — resolve first:\n${blockers.map((b) => ` - ${b}`).join("\n")}`);
|
|
338
382
|
}
|
|
339
|
-
const
|
|
383
|
+
const level = confirmedLevel(projectPath, change);
|
|
384
|
+
const sync = artifactNeeds(level).deltaSpecs
|
|
385
|
+
? specSync(projectPath, change)
|
|
386
|
+
: { change, promoted: [], created: [], updated: [] };
|
|
387
|
+
const { promoted, created, updated } = sync;
|
|
340
388
|
const seals = sealPromotedCapabilities(projectPath, change, [
|
|
341
389
|
...promoted,
|
|
342
390
|
...created,
|
|
@@ -391,7 +439,13 @@ function sealPromotedCapabilities(projectPath, change, promotedPaths) {
|
|
|
391
439
|
export function specList(projectPath) {
|
|
392
440
|
const root = specRoot(projectPath);
|
|
393
441
|
if (!fs.existsSync(root)) {
|
|
394
|
-
return {
|
|
442
|
+
return {
|
|
443
|
+
initialized: false,
|
|
444
|
+
activeChanges: [],
|
|
445
|
+
activeLevels: {},
|
|
446
|
+
archivedChanges: [],
|
|
447
|
+
capabilities: [],
|
|
448
|
+
};
|
|
395
449
|
}
|
|
396
450
|
const dirsIn = (rel) => {
|
|
397
451
|
const abs = path.join(root, rel);
|
|
@@ -402,9 +456,15 @@ export function specList(projectPath) {
|
|
|
402
456
|
.filter((e) => e.isDirectory() && e.name !== "archive")
|
|
403
457
|
.map((e) => e.name);
|
|
404
458
|
};
|
|
459
|
+
const activeChanges = dirsIn("changes");
|
|
460
|
+
const activeLevels = {};
|
|
461
|
+
for (const name of activeChanges) {
|
|
462
|
+
activeLevels[name] = confirmedLevel(projectPath, name);
|
|
463
|
+
}
|
|
405
464
|
return {
|
|
406
465
|
initialized: true,
|
|
407
|
-
activeChanges
|
|
466
|
+
activeChanges,
|
|
467
|
+
activeLevels,
|
|
408
468
|
archivedChanges: dirsIn("changes/archive"),
|
|
409
469
|
capabilities: dirsIn("specs"),
|
|
410
470
|
};
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { indexExists } from "../compass/db.js";
|
|
4
|
+
import { explore } from "../compass/query.js";
|
|
5
|
+
import { impact } from "../compass/query.js";
|
|
6
|
+
import { affectedTests } from "../compass/affected.js";
|
|
7
|
+
import { hotspots } from "../compass/hotspots.js";
|
|
8
|
+
import { loadAffectedConfig, matchGlob, matchesAny, inferModule, } from "../compass/affected-config.js";
|
|
9
|
+
/** Default thresholds from the adaptive-ceremony roadmap. */
|
|
10
|
+
export const DEFAULT_THRESHOLDS = {
|
|
11
|
+
filesTouched: [0, 1, 3, 5],
|
|
12
|
+
modulesTouched: [0, 2, 4, 6],
|
|
13
|
+
affectedTests: [0, 1, 2, 4],
|
|
14
|
+
blastRadiusNodes: [0, 1, 3, 5],
|
|
15
|
+
publicApi: 4,
|
|
16
|
+
globalFile: 5,
|
|
17
|
+
hotspot: 3,
|
|
18
|
+
hotspotFloor: 0.7,
|
|
19
|
+
cuts: [3, 8, 15],
|
|
20
|
+
globalGlobs: [
|
|
21
|
+
"package.json",
|
|
22
|
+
"package-lock.json",
|
|
23
|
+
"tsconfig*.json",
|
|
24
|
+
".github/workflows/**",
|
|
25
|
+
"src/modules/compass/db.ts",
|
|
26
|
+
"lawbook/config.yaml",
|
|
27
|
+
],
|
|
28
|
+
docGlobs: ["**/*.md", "docs/**", "assets/**"],
|
|
29
|
+
moduleRoots: ["src"],
|
|
30
|
+
};
|
|
31
|
+
const BUCKETS = {
|
|
32
|
+
filesTouched: [1, 3, 10, Infinity],
|
|
33
|
+
modulesTouched: [1, 2, 4, Infinity],
|
|
34
|
+
affectedTests: [0, 3, 15, Infinity],
|
|
35
|
+
blastRadiusNodes: [2, 10, 50, Infinity],
|
|
36
|
+
};
|
|
37
|
+
export function artifactNeeds(level) {
|
|
38
|
+
switch (level) {
|
|
39
|
+
case 0:
|
|
40
|
+
return {
|
|
41
|
+
record: true,
|
|
42
|
+
proposal: false,
|
|
43
|
+
design: false,
|
|
44
|
+
tasksFile: false,
|
|
45
|
+
deltaSpecs: false,
|
|
46
|
+
reports: true,
|
|
47
|
+
designOptionalWithJustification: false,
|
|
48
|
+
};
|
|
49
|
+
case 1:
|
|
50
|
+
return {
|
|
51
|
+
record: true,
|
|
52
|
+
proposal: false,
|
|
53
|
+
design: false,
|
|
54
|
+
tasksFile: true,
|
|
55
|
+
deltaSpecs: true,
|
|
56
|
+
reports: true,
|
|
57
|
+
designOptionalWithJustification: false,
|
|
58
|
+
};
|
|
59
|
+
case 2:
|
|
60
|
+
return {
|
|
61
|
+
record: false,
|
|
62
|
+
proposal: true,
|
|
63
|
+
design: false,
|
|
64
|
+
tasksFile: true,
|
|
65
|
+
deltaSpecs: true,
|
|
66
|
+
reports: true,
|
|
67
|
+
designOptionalWithJustification: true,
|
|
68
|
+
};
|
|
69
|
+
case 3:
|
|
70
|
+
return {
|
|
71
|
+
record: false,
|
|
72
|
+
proposal: true,
|
|
73
|
+
design: true,
|
|
74
|
+
tasksFile: true,
|
|
75
|
+
deltaSpecs: true,
|
|
76
|
+
reports: true,
|
|
77
|
+
designOptionalWithJustification: false,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function bucketPoints(value, edges, points) {
|
|
82
|
+
const i = edges.findIndex((max) => value <= max);
|
|
83
|
+
return points[i < 0 ? 3 : i];
|
|
84
|
+
}
|
|
85
|
+
/** Pure scoring; `onlyDocs` short-circuits to 0. */
|
|
86
|
+
export function scoreSignals(s, t = DEFAULT_THRESHOLDS) {
|
|
87
|
+
if (s.onlyDocs)
|
|
88
|
+
return 0;
|
|
89
|
+
let score = 0;
|
|
90
|
+
score += bucketPoints(s.filesTouched, BUCKETS.filesTouched, t.filesTouched);
|
|
91
|
+
score += bucketPoints(s.modulesTouched, BUCKETS.modulesTouched, t.modulesTouched);
|
|
92
|
+
score += bucketPoints(s.affectedTests, BUCKETS.affectedTests, t.affectedTests);
|
|
93
|
+
score += bucketPoints(s.blastRadiusNodes, BUCKETS.blastRadiusNodes, t.blastRadiusNodes);
|
|
94
|
+
if (s.touchesPublicApi)
|
|
95
|
+
score += t.publicApi;
|
|
96
|
+
if (s.touchesGlobalFile)
|
|
97
|
+
score += t.globalFile;
|
|
98
|
+
if (s.maxHotspotScore >= t.hotspotFloor)
|
|
99
|
+
score += t.hotspot;
|
|
100
|
+
return score;
|
|
101
|
+
}
|
|
102
|
+
export function levelFromScore(score, cuts = DEFAULT_THRESHOLDS.cuts) {
|
|
103
|
+
if (score < cuts[0])
|
|
104
|
+
return 0;
|
|
105
|
+
if (score < cuts[1])
|
|
106
|
+
return 1;
|
|
107
|
+
if (score < cuts[2])
|
|
108
|
+
return 2;
|
|
109
|
+
return 3;
|
|
110
|
+
}
|
|
111
|
+
export function explain(s, t, score, level) {
|
|
112
|
+
const parts = [
|
|
113
|
+
`${s.filesTouched} file(s)`,
|
|
114
|
+
`${s.modulesTouched} module(s)`,
|
|
115
|
+
`${s.affectedTests} affected test(s)`,
|
|
116
|
+
`${s.blastRadiusNodes} blast node(s)`,
|
|
117
|
+
s.touchesPublicApi ? "public API" : "no public API",
|
|
118
|
+
s.touchesGlobalFile ? "global file" : "no global file",
|
|
119
|
+
`hotspot=${s.maxHotspotScore.toFixed(2)}`,
|
|
120
|
+
];
|
|
121
|
+
if (s.onlyDocs)
|
|
122
|
+
parts.push("docs-only");
|
|
123
|
+
if (s.degraded.length)
|
|
124
|
+
parts.push(`degraded:[${s.degraded.join(",")}]`);
|
|
125
|
+
const lvl = level === null ? "none" : String(level);
|
|
126
|
+
return `${parts.join(", ")} → score ${score} → level ${lvl} (cuts ${t.cuts.join("/")})`;
|
|
127
|
+
}
|
|
128
|
+
export function proposeLevel(s, t = DEFAULT_THRESHOLDS) {
|
|
129
|
+
if (s.degraded.includes("no-index") && s.filesTouched === 0 && s.blastRadiusNodes === 0) {
|
|
130
|
+
return {
|
|
131
|
+
level: null,
|
|
132
|
+
score: 0,
|
|
133
|
+
signals: s,
|
|
134
|
+
rationale: explain(s, t, 0, null),
|
|
135
|
+
degraded: s.degraded,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
if (s.filesTouched === 0 &&
|
|
139
|
+
s.blastRadiusNodes === 0 &&
|
|
140
|
+
s.degraded.includes("unresolved-symbols")) {
|
|
141
|
+
return {
|
|
142
|
+
level: null,
|
|
143
|
+
score: 0,
|
|
144
|
+
signals: s,
|
|
145
|
+
rationale: explain(s, t, 0, null),
|
|
146
|
+
degraded: s.degraded,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const score = scoreSignals(s, t);
|
|
150
|
+
const level = levelFromScore(score, t.cuts);
|
|
151
|
+
return {
|
|
152
|
+
level,
|
|
153
|
+
score,
|
|
154
|
+
signals: s,
|
|
155
|
+
rationale: explain(s, t, score, level),
|
|
156
|
+
degraded: s.degraded,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function isSpecPath(rel) {
|
|
160
|
+
const n = rel.split("\\").join("/");
|
|
161
|
+
return n.startsWith("lawbook/specs/") || n.includes("/lawbook/specs/");
|
|
162
|
+
}
|
|
163
|
+
/** Resolve modules for paths using configured roots / inferModule. */
|
|
164
|
+
export function countModules(paths) {
|
|
165
|
+
const mods = new Set(paths.map((p) => inferModule(p.split("\\").join("/")) || p.split("/")[0] || p));
|
|
166
|
+
return mods.size;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Build signals from an explicit target list. When the Compass index is missing,
|
|
170
|
+
* marks `no-index` and does not invent a small blast radius.
|
|
171
|
+
*/
|
|
172
|
+
export function gatherSignals(projectPath, targets, t = DEFAULT_THRESHOLDS) {
|
|
173
|
+
const degraded = [];
|
|
174
|
+
const paths = new Set(targets.paths.map((p) => p.replace(/^\.\//, "").split("\\").join("/")));
|
|
175
|
+
if (!indexExists(projectPath)) {
|
|
176
|
+
degraded.push("no-index");
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
for (const sym of targets.symbols) {
|
|
180
|
+
const ex = explore(projectPath, sym);
|
|
181
|
+
if (ex.found && ex.symbol?.file)
|
|
182
|
+
paths.add(ex.symbol.file.split("\\").join("/"));
|
|
183
|
+
else
|
|
184
|
+
degraded.push("unresolved-symbols");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const pathList = [...paths];
|
|
188
|
+
const onlyDocs = pathList.length > 0 &&
|
|
189
|
+
pathList.every((p) => matchesAny(p, t.docGlobs)) &&
|
|
190
|
+
!pathList.some(isSpecPath);
|
|
191
|
+
let touchesGlobalFile = pathList.some((p) => matchesAny(p, t.globalGlobs));
|
|
192
|
+
try {
|
|
193
|
+
const cfg = loadAffectedConfig(projectPath);
|
|
194
|
+
if (pathList.some((p) => cfg.globalFiles.some((g) => matchGlob(p, g)))) {
|
|
195
|
+
touchesGlobalFile = true;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
/* soft */
|
|
200
|
+
}
|
|
201
|
+
let blastRadiusNodes = 0;
|
|
202
|
+
let affected = 0;
|
|
203
|
+
let touchesPublicApi = false;
|
|
204
|
+
let maxHotspotScore = 0;
|
|
205
|
+
if (indexExists(projectPath) && pathList.length > 0) {
|
|
206
|
+
try {
|
|
207
|
+
const imp = impact(projectPath, { files: pathList, format: "grouped", maxDepth: 4 });
|
|
208
|
+
blastRadiusNodes = imp.totals.nodes;
|
|
209
|
+
if (imp.global)
|
|
210
|
+
touchesGlobalFile = true;
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
/* soft */
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
const at = affectedTests(projectPath, { files: pathList });
|
|
217
|
+
affected = at.mode === "all" ? Math.max(at.tests.length, 50) : at.tests.length;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
/* soft */
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
const pkgPath = path.join(projectPath, "package.json");
|
|
224
|
+
if (fs.existsSync(pkgPath)) {
|
|
225
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
226
|
+
const entries = new Set();
|
|
227
|
+
if (typeof pkg.main === "string")
|
|
228
|
+
entries.add(pkg.main.replace(/^\.\//, ""));
|
|
229
|
+
if (typeof pkg.bin === "string")
|
|
230
|
+
entries.add(pkg.bin.replace(/^\.\//, ""));
|
|
231
|
+
else if (pkg.bin && typeof pkg.bin === "object") {
|
|
232
|
+
for (const v of Object.values(pkg.bin))
|
|
233
|
+
entries.add(String(v).replace(/^\.\//, ""));
|
|
234
|
+
}
|
|
235
|
+
for (const e of entries) {
|
|
236
|
+
if (pathList.some((p) => p === e || e.endsWith(p) || p.endsWith(e))) {
|
|
237
|
+
touchesPublicApi = true;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (pathList.some((p) => p === "src/cli/index.ts" || p === "src/server.ts")) {
|
|
241
|
+
touchesPublicApi = true;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
/* soft */
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
const hs = hotspots(projectPath, { days: 90, sortBy: "combined", limit: 200 });
|
|
250
|
+
const byFile = new Map(hs.hotspots.map((h) => [h.file, h.combinedScore]));
|
|
251
|
+
let maxCombined = 0;
|
|
252
|
+
for (const h of hs.hotspots)
|
|
253
|
+
maxCombined = Math.max(maxCombined, h.combinedScore);
|
|
254
|
+
if (maxCombined <= 0)
|
|
255
|
+
degraded.push("no-hotspots");
|
|
256
|
+
else {
|
|
257
|
+
for (const p of pathList) {
|
|
258
|
+
const c = byFile.get(p) ?? 0;
|
|
259
|
+
maxHotspotScore = Math.max(maxHotspotScore, c / maxCombined);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
degraded.push("no-hotspots");
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
filesTouched: pathList.length,
|
|
269
|
+
modulesTouched: pathList.length ? countModules(pathList) : 0,
|
|
270
|
+
blastRadiusNodes,
|
|
271
|
+
affectedTests: affected,
|
|
272
|
+
touchesPublicApi,
|
|
273
|
+
maxHotspotScore,
|
|
274
|
+
touchesGlobalFile,
|
|
275
|
+
onlyDocs,
|
|
276
|
+
degraded: [...new Set(degraded)],
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
/** Load ceremony thresholds from lawbook/config.yaml (line-oriented). */
|
|
280
|
+
export function loadCeremonyConfig(projectPath) {
|
|
281
|
+
const thresholds = structuredClone(DEFAULT_THRESHOLDS);
|
|
282
|
+
const cfgPath = path.join(projectPath, "lawbook", "config.yaml");
|
|
283
|
+
if (!fs.existsSync(cfgPath))
|
|
284
|
+
return { thresholds, invalidCuts: false };
|
|
285
|
+
const text = fs.readFileSync(cfgPath, "utf8");
|
|
286
|
+
const cuts = /^\s*cuts\s*:\s*\[([^\]]*)\]\s*$/im.exec(text);
|
|
287
|
+
let invalidCuts = false;
|
|
288
|
+
if (cuts) {
|
|
289
|
+
const nums = cuts[1]
|
|
290
|
+
.split(",")
|
|
291
|
+
.map((s) => Number(s.trim()))
|
|
292
|
+
.filter((n) => Number.isFinite(n));
|
|
293
|
+
if (nums.length === 3 && nums[0] < nums[1] && nums[1] < nums[2]) {
|
|
294
|
+
thresholds.cuts = [nums[0], nums[1], nums[2]];
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
invalidCuts = true;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const floor = /^\s*hotspotFloor\s*:\s*([0-9.]+)\s*$/im.exec(text);
|
|
301
|
+
if (floor)
|
|
302
|
+
thresholds.hotspotFloor = Number(floor[1]);
|
|
303
|
+
return { thresholds, invalidCuts };
|
|
304
|
+
}
|
|
305
|
+
export function changeJsonPath(projectPath, change) {
|
|
306
|
+
return path.join(projectPath, "lawbook", "changes", change, "change.json");
|
|
307
|
+
}
|
|
308
|
+
export function readCeremonyRecord(projectPath, change) {
|
|
309
|
+
const p = changeJsonPath(projectPath, change);
|
|
310
|
+
if (!fs.existsSync(p))
|
|
311
|
+
return null;
|
|
312
|
+
try {
|
|
313
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/** Confirmed level, or 3 when change.json is missing. */
|
|
320
|
+
export function confirmedLevel(projectPath, change) {
|
|
321
|
+
return readCeremonyRecord(projectPath, change)?.confirmedLevel ?? 3;
|
|
322
|
+
}
|
|
323
|
+
export function writeCeremonyRecord(projectPath, change, record) {
|
|
324
|
+
const p = changeJsonPath(projectPath, change);
|
|
325
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
326
|
+
fs.writeFileSync(p, JSON.stringify(record, null, 2) + "\n");
|
|
327
|
+
}
|
|
328
|
+
export function setCeremonyLevel(projectPath, change, opts) {
|
|
329
|
+
const proposed = opts.proposal.level;
|
|
330
|
+
if (proposed !== null && opts.level < proposed && !opts.reason) {
|
|
331
|
+
throw new Error(`mode 'set' to a lower level than proposed (${proposed}) requires 'reason'`);
|
|
332
|
+
}
|
|
333
|
+
const prev = readCeremonyRecord(projectPath, change);
|
|
334
|
+
const record = {
|
|
335
|
+
...opts.proposal,
|
|
336
|
+
confirmedLevel: opts.level,
|
|
337
|
+
confirmedBy: opts.confirmedBy,
|
|
338
|
+
confirmedAt: new Date().toISOString(),
|
|
339
|
+
overrideReason: opts.reason,
|
|
340
|
+
promotions: prev?.promotions ?? [],
|
|
341
|
+
};
|
|
342
|
+
writeCeremonyRecord(projectPath, change, record);
|
|
343
|
+
return record;
|
|
344
|
+
}
|
|
345
|
+
export function promoteCeremonyLevel(projectPath, change, to, reason) {
|
|
346
|
+
const prev = readCeremonyRecord(projectPath, change);
|
|
347
|
+
if (!prev)
|
|
348
|
+
throw new Error(`change "${change}" has no change.json to promote`);
|
|
349
|
+
if (to <= prev.confirmedLevel) {
|
|
350
|
+
throw new Error(`promote requires a higher level than ${prev.confirmedLevel}`);
|
|
351
|
+
}
|
|
352
|
+
const record = {
|
|
353
|
+
...prev,
|
|
354
|
+
confirmedLevel: to,
|
|
355
|
+
confirmedAt: new Date().toISOString(),
|
|
356
|
+
promotions: [
|
|
357
|
+
...prev.promotions,
|
|
358
|
+
{ from: prev.confirmedLevel, to, at: new Date().toISOString(), reason },
|
|
359
|
+
],
|
|
360
|
+
};
|
|
361
|
+
writeCeremonyRecord(projectPath, change, record);
|
|
362
|
+
scaffoldArtifactsForLevel(projectPath, change, to);
|
|
363
|
+
return record;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Create missing higher-level artifacts when promoting. Never deletes `record.md`.
|
|
367
|
+
* Seeds `proposal.md` / `tasks.md` from `record.md` when present.
|
|
368
|
+
*/
|
|
369
|
+
export function scaffoldArtifactsForLevel(projectPath, change, level) {
|
|
370
|
+
const changeDir = path.join(projectPath, "lawbook", "changes", change);
|
|
371
|
+
if (!fs.existsSync(changeDir))
|
|
372
|
+
return;
|
|
373
|
+
const needs = artifactNeeds(level);
|
|
374
|
+
const recordPath = path.join(changeDir, "record.md");
|
|
375
|
+
const recordText = fs.existsSync(recordPath) ? fs.readFileSync(recordPath, "utf8") : "";
|
|
376
|
+
const ensure = (rel, content) => {
|
|
377
|
+
const abs = path.join(changeDir, rel);
|
|
378
|
+
if (!fs.existsSync(abs)) {
|
|
379
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
380
|
+
fs.writeFileSync(abs, content);
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
if (needs.proposal) {
|
|
384
|
+
ensure("proposal.md", `# ${change}\n\n## Why\n\n${extractWhy(recordText) || "(promoted — fill in why)"}\n\n## What\n\n(promoted from level ${level})\n`);
|
|
385
|
+
}
|
|
386
|
+
if (needs.design && !needs.designOptionalWithJustification) {
|
|
387
|
+
ensure("design.md", `# Design — ${change}\n\n## Approach\n\n(promoted — fill in)\n`);
|
|
388
|
+
}
|
|
389
|
+
if (needs.tasksFile) {
|
|
390
|
+
const steps = extractChecklist(recordText);
|
|
391
|
+
ensure("tasks.md", steps.length
|
|
392
|
+
? steps.map((s) => `- [ ] ${s}`).join("\n") + "\n"
|
|
393
|
+
: `- [ ] Implement\n- [ ] Add or update tests\n- [ ] Write discipline report under reports/\n`);
|
|
394
|
+
}
|
|
395
|
+
ensure("reports/README.md", `# Reports — ${change}\n\nAdd at least one discipline report before archive.\n`);
|
|
396
|
+
}
|
|
397
|
+
function extractWhy(recordMd) {
|
|
398
|
+
const m = /\*\*Why:\*\*\s*(.+)/i.exec(recordMd);
|
|
399
|
+
return m?.[1]?.trim() ?? "";
|
|
400
|
+
}
|
|
401
|
+
function extractChecklist(recordMd) {
|
|
402
|
+
const out = [];
|
|
403
|
+
for (const line of recordMd.split("\n")) {
|
|
404
|
+
const m = /^\s*[-*]\s+\[[ xX]\]\s+(.+)$/.exec(line);
|
|
405
|
+
if (m)
|
|
406
|
+
out.push(m[1].trim());
|
|
407
|
+
}
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
/** Count unchecked `- [ ]` tasks in markdown (tasks.md or record.md Steps). */
|
|
411
|
+
export function countUncheckedTasks(markdown) {
|
|
412
|
+
return (markdown.match(/^\s*[-*]\s+\[ \]/gm) ?? []).length;
|
|
413
|
+
}
|
|
414
|
+
export function hasDisciplineReport(changeDir) {
|
|
415
|
+
const reportsDir = path.join(changeDir, "reports");
|
|
416
|
+
if (!fs.existsSync(reportsDir))
|
|
417
|
+
return false;
|
|
418
|
+
return fs
|
|
419
|
+
.readdirSync(reportsDir)
|
|
420
|
+
.some((n) => n.endsWith(".md") && n.toLowerCase() !== "readme.md");
|
|
421
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { gatherSignals, loadCeremonyConfig, promoteCeremonyLevel, proposeLevel, setCeremonyLevel, } from "./levels.js";
|
|
4
|
+
/**
|
|
5
|
+
* Scaffold a level-0 change: `record.md`, `change.json`, and `reports/`.
|
|
6
|
+
*
|
|
7
|
+
* @param projectPath - Project root with `lawbook/`.
|
|
8
|
+
* @param name - Change folder name (kebab-case).
|
|
9
|
+
* @param targets - Optional paths/symbols used to propose the level (default empty → score 0).
|
|
10
|
+
*/
|
|
11
|
+
export function scaffoldQuick(projectPath, name, targets = { paths: [], symbols: [] }) {
|
|
12
|
+
const changeDir = path.join(projectPath, "lawbook", "changes", name);
|
|
13
|
+
if (fs.existsSync(changeDir)) {
|
|
14
|
+
throw new Error(`change "${name}" already exists under lawbook/changes/`);
|
|
15
|
+
}
|
|
16
|
+
const { thresholds } = loadCeremonyConfig(projectPath);
|
|
17
|
+
const signals = gatherSignals(projectPath, targets, thresholds);
|
|
18
|
+
const proposal = proposeLevel(signals, thresholds);
|
|
19
|
+
// quick always records level 0; if measurement says higher, still allow but note it.
|
|
20
|
+
const level = 0;
|
|
21
|
+
fs.mkdirSync(path.join(changeDir, "reports"), { recursive: true });
|
|
22
|
+
const rationale = proposal.level === null
|
|
23
|
+
? proposal.rationale
|
|
24
|
+
: proposal.level > 0
|
|
25
|
+
? `${proposal.rationale} — quick forced level 0; promote if scope grows`
|
|
26
|
+
: proposal.rationale;
|
|
27
|
+
const recordMd = `# ${name}
|
|
28
|
+
|
|
29
|
+
**Level:** 0 (proposed: ${proposal.level ?? "n/a"}, confirmed by: human)
|
|
30
|
+
**Why:** ${rationale}
|
|
31
|
+
|
|
32
|
+
## What changes
|
|
33
|
+
|
|
34
|
+
<!-- 2–5 lines: what and why. -->
|
|
35
|
+
|
|
36
|
+
## Steps
|
|
37
|
+
|
|
38
|
+
- [ ] Make the fix
|
|
39
|
+
- [ ] Add or update a regression test
|
|
40
|
+
- [ ] Record evidence under reports/
|
|
41
|
+
|
|
42
|
+
## Evidence
|
|
43
|
+
|
|
44
|
+
- \`reports/\` — add a discipline report before archive
|
|
45
|
+
`;
|
|
46
|
+
fs.writeFileSync(path.join(changeDir, "record.md"), recordMd);
|
|
47
|
+
fs.writeFileSync(path.join(changeDir, "reports", "README.md"), `# Reports — ${name}\n\nAdd at least one discipline report before archive.\n`);
|
|
48
|
+
const record = setCeremonyLevel(projectPath, name, {
|
|
49
|
+
proposal: { ...proposal, rationale },
|
|
50
|
+
level,
|
|
51
|
+
confirmedBy: "human",
|
|
52
|
+
reason: proposal.level !== null && proposal.level > 0 ? "speclaw quick" : undefined,
|
|
53
|
+
});
|
|
54
|
+
return { change: name, proposal, record, dir: changeDir };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Handle `lawbook_level` modes: propose / set / promote / explain.
|
|
58
|
+
*/
|
|
59
|
+
export function handleLevel(args) {
|
|
60
|
+
const targets = {
|
|
61
|
+
paths: args.paths ?? [],
|
|
62
|
+
symbols: args.symbols ?? [],
|
|
63
|
+
};
|
|
64
|
+
const { thresholds } = loadCeremonyConfig(args.projectPath);
|
|
65
|
+
const signals = gatherSignals(args.projectPath, targets, thresholds);
|
|
66
|
+
const proposal = proposeLevel(signals, thresholds);
|
|
67
|
+
if (args.mode === "propose" || args.mode === "explain") {
|
|
68
|
+
return { mode: args.mode, proposal };
|
|
69
|
+
}
|
|
70
|
+
if (!args.change)
|
|
71
|
+
throw new Error(`mode '${args.mode}' requires 'change'`);
|
|
72
|
+
if (args.mode === "set") {
|
|
73
|
+
if (args.level === undefined)
|
|
74
|
+
throw new Error("mode 'set' requires 'level'");
|
|
75
|
+
return setCeremonyLevel(args.projectPath, args.change, {
|
|
76
|
+
proposal,
|
|
77
|
+
level: args.level,
|
|
78
|
+
confirmedBy: "human",
|
|
79
|
+
reason: args.reason,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
// promote
|
|
83
|
+
if (args.level === undefined)
|
|
84
|
+
throw new Error("mode 'promote' requires 'level'");
|
|
85
|
+
return promoteCeremonyLevel(args.projectPath, args.change, args.level, args.reason ?? "scope grew");
|
|
86
|
+
}
|
|
@@ -5,6 +5,7 @@ import { shouldExpose } from "../../shared/exposure.js";
|
|
|
5
5
|
import { assetsDir } from "../../shared/paths.js";
|
|
6
6
|
import { copyRendered } from "../../shared/install.js";
|
|
7
7
|
import { specInit, specValidate, specSync, specArchive, specList } from "./engine.js";
|
|
8
|
+
import { handleLevel } from "./quick.js";
|
|
8
9
|
import { buildCoverageReport, loadCoverageConfig, renderCoverageAgent } from "./coverage.js";
|
|
9
10
|
import { buildDriftReport, renderDriftAgent } from "./drift.js";
|
|
10
11
|
const ASSETS = assetsDir(import.meta.url);
|
|
@@ -29,6 +30,15 @@ export function registerSpec(server, opts = {}) {
|
|
|
29
30
|
};
|
|
30
31
|
add("lawbook_init", "Create the lawbook/ workspace (specs, changes, archive, config). Idempotent.", { projectPath: z.string() }, async ({ projectPath }) => text(specInit(projectPath)));
|
|
31
32
|
add("lawbook_list", "List active changes, archives, and canonical capabilities under lawbook/.", { projectPath: z.string() }, async ({ projectPath }) => text(specList(projectPath)));
|
|
33
|
+
add("lawbook_level", "Propose, set, promote, or explain a change's ceremony level (0–3).", {
|
|
34
|
+
projectPath: z.string(),
|
|
35
|
+
mode: z.enum(["propose", "set", "promote", "explain"]),
|
|
36
|
+
change: z.string().optional(),
|
|
37
|
+
paths: z.array(z.string()).optional(),
|
|
38
|
+
symbols: z.array(z.string()).optional(),
|
|
39
|
+
level: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]).optional(),
|
|
40
|
+
reason: z.string().optional(),
|
|
41
|
+
}, async (args) => text(handleLevel(args)));
|
|
32
42
|
add("lawbook_validate", "Validate a change's proposal, tasks, and delta specs before build or sync.", { projectPath: z.string(), change: z.string() }, async ({ projectPath, change }) => text(specValidate(projectPath, change)));
|
|
33
43
|
add("lawbook_sync", "Promote a change's delta specs into canonical lawbook/specs/ without archiving.", { projectPath: z.string(), change: z.string() }, async ({ projectPath, change }) => text(specSync(projectPath, change)));
|
|
34
44
|
add("lawbook_archive", "Sync a change into canonical specs, then move it under changes/archive/.", {
|
package/dist/shared/exposure.js
CHANGED