@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.
@@ -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 archive.
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
- ui.info(`active changes: ${r.activeChanges.join(", ") || "none"}`);
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, "spec validate <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, "spec sync <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, "spec archive <change>"), today());
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), `mcp` (a long-running stdio
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 into canonical, then archive it.
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 is done and gates are green, run
8
- the reconciliation review (recommend a sync with short insights if the code
9
- drifted past the contracts), run `lawbook_validate`, then `lawbook_archive` with
10
- today's date (YYYY-MM-DD). It syncs the specs and moves the change to
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 (proposal, delta specs, tasks) before coding.
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 the
8
- code with `compass_explore`/`compass_recall`, read the governing
9
- `docs/standards/`, then write `proposal.md`, `specs/<capability>/spec.md`
10
- (normative `SHALL`/`MUST` + `#### Scenario:`), `design.md`, and
11
- `tasks.md` (with the mandatory steps from `lawbook/config.yaml`). Finish by
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`, then `lawbook_sync` to promote the delta specs into
4
- `lawbook/specs/`. This is required, not optional: `lawbook_archive` refuses
5
- unless the canonical specs already match the delta specs.
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 — proposal, delta specs, and tasks — 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).
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
- Create under `lawbook/changes/<name>/`:
4
-
5
- - **proposal.md** the why, the what, non-goals, and whether migrations are
6
- needed. Reference the team's tracker ticket if there is one.
7
- - **specs/<capability>/spec.md** the delta spec for each affected capability.
8
- `sync` promotes this by overwriting the whole canonical file, so the delta must
9
- carry the capability's **full** intended spec. When you are updating an existing
10
- capability, **start from the current `lawbook/specs/<capability>/spec.md`** and
11
- edit on top of it, so its existing requirements are carried forward — do not
12
- author it from scratch, or promotion will silently drop them. Use normative
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
- - **design.md** — always: approach, alternatives weighed, and the trade-offs
26
- behind the decision. For a small change, keep it short but write it.
27
- - **tasks.md** ordered, checkable steps. MUST include the mandatory steps
28
- from `lawbook/config.yaml` (feature branch first; tests reviewed and run;
29
- manual verification executed by the agent; discipline reports produced; docs
30
- updated; archive within the PR).
31
- - **reports/** create the folder with a short `reports/README.md` naming the
32
- discipline reports the change will need — one per discipline it touches, from an
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,6 @@
1
+ # Scaffold the level-0 change
2
+
3
+ Run `speclaw quick <name>` (or the library scaffold) so `record.md`,
4
+ `change.json`, and `reports/` exist. Pass `--path` / `--symbol` when known.
5
+
6
+ Next: read `steps/02-implement.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 need not have one.
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: \`proposal.md\`, \`tasks.md\`,
44
- \`design.md\`, and \`specs/<capability>/spec.md\` delta specs.
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 workflow rules.
53
+ - \`config.yaml\` — mandatory task steps, coverage, and optional ceremony cuts.
47
54
 
48
55
  ## Workflow
49
56
 
50
- 1. \`lawbook:draft\` — describe the change; generates proposal, delta specs, tasks.
51
- 2. \`lawbook:build\` — implement the tasks.
52
- 3. \`lawbook:sync\` — promote the change's delta specs into \`specs/\`.
53
- 4. \`lawbook:archive\` — sync + move the change to \`changes/archive/\`.
54
- 5. \`lawbook:explore\` — think through an idea before or during a change.
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: proposal.md and tasks.md must be present, and
150
- * each delta spec must use normative language (SHALL/MUST), a "### Requirement:"
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
- * @returns The validation verdict and the list of issues to fix; never throws
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
- if (!fs.existsSync(path.join(changeDir, "proposal.md")))
171
- issues.push("missing proposal.md");
172
- const tasksPath = path.join(changeDir, "tasks.md");
173
- if (!fs.existsSync(tasksPath))
174
- issues.push("missing tasks.md");
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("no delta specs under specs/ (a change should specify what it changes)");
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
- * A change is blocked when any task is still unchecked, when it has no discipline
272
- * report under reports/, or when its delta specs are not synced — the canonical
273
- * spec is missing for, or differs from, a delta (meaning sync was not run after
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
- // 1. Every task must be checked.
287
- const tasksPath = path.join(changeDir, "tasks.md");
288
- if (!fs.existsSync(tasksPath)) {
289
- blockers.push("missing tasks.md");
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 unchecked = (fs.readFileSync(tasksPath, "utf8").match(/^\s*[-*]\s+\[ \]/gm) ?? []).length;
293
- if (unchecked > 0)
294
- blockers.push(`${unchecked} unchecked task(s) in tasks.md`);
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
- const reportsDir = path.join(changeDir, "reports");
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 into the canonical specs.
305
- for (const file of deltaSpecFiles(changeDir)) {
306
- const rel = path.relative(path.join(changeDir, "specs"), file);
307
- const canonical = path.join(root, "specs", rel);
308
- if (!fs.existsSync(canonical)) {
309
- blockers.push(`spec not synced: lawbook/specs/${rel} missing (run sync first)`);
310
- }
311
- else if (fs.readFileSync(file, "utf8") !== fs.readFileSync(canonical, "utf8")) {
312
- blockers.push(`spec not synced: lawbook/specs/${rel} differs from the delta (run sync first)`);
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}), then move
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 { promoted, created, updated } = specSync(projectPath, change);
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 { initialized: false, activeChanges: [], archivedChanges: [], capabilities: [] };
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: dirsIn("changes"),
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/.", {
@@ -19,6 +19,7 @@ export const MINIMAL_OMIT = new Set([
19
19
  "lawbook_init",
20
20
  "lawbook_archive",
21
21
  "lawbook_list",
22
+ "lawbook_level",
22
23
  "init_project",
23
24
  "scaffold",
24
25
  "configure_agent",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "0.3.11",
3
+ "version": "0.3.12",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },