@esneiderbravo/speclaw 0.3.13 → 1.0.0

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.
Files changed (74) hide show
  1. package/README.md +88 -72
  2. package/dist/cli/commands/index-build.js +12 -3
  3. package/dist/cli/commands/lawbook.js +1 -0
  4. package/dist/cli/commands/laws.js +149 -8
  5. package/dist/cli/commands/owners.js +44 -0
  6. package/dist/cli/commands/query.js +52 -10
  7. package/dist/cli/commands/update.js +35 -5
  8. package/dist/cli/commands/verify.js +8 -0
  9. package/dist/cli/index.js +15 -4
  10. package/dist/modules/compass/budget.js +128 -0
  11. package/dist/modules/compass/db.js +290 -30
  12. package/dist/modules/compass/diff-context.js +134 -0
  13. package/dist/modules/compass/embed-input.js +28 -0
  14. package/dist/modules/compass/embedder.js +3 -1
  15. package/dist/modules/compass/explore-rich.js +134 -0
  16. package/dist/modules/compass/extract.js +86 -0
  17. package/dist/modules/compass/hybrid.js +318 -0
  18. package/dist/modules/compass/impact-summary.js +33 -0
  19. package/dist/modules/compass/indexer.js +204 -33
  20. package/dist/modules/compass/merkle.js +76 -0
  21. package/dist/modules/compass/pagerank.js +122 -0
  22. package/dist/modules/compass/rank.js +95 -0
  23. package/dist/modules/compass/register.js +169 -75
  24. package/dist/modules/foundation/check.js +4 -2
  25. package/dist/modules/foundation/compile-laws.js +212 -0
  26. package/dist/modules/foundation/context-budget.js +1 -14
  27. package/dist/modules/foundation/dialects/agentsmd.js +95 -0
  28. package/dist/modules/foundation/dialects/claude-cursor.js +45 -0
  29. package/dist/modules/foundation/dialects/coderabbit.js +27 -0
  30. package/dist/modules/foundation/dialects/copilot.js +35 -0
  31. package/dist/modules/foundation/dialects/index.js +5 -0
  32. package/dist/modules/foundation/dialects/types.js +58 -0
  33. package/dist/modules/foundation/doctor.js +266 -14
  34. package/dist/modules/foundation/import-rules.js +67 -0
  35. package/dist/modules/foundation/integrity.js +307 -0
  36. package/dist/modules/foundation/laws-parse.js +131 -0
  37. package/dist/modules/foundation/laws.js +5 -0
  38. package/dist/modules/foundation/lock.js +283 -0
  39. package/dist/modules/foundation/ownership.js +4 -0
  40. package/dist/modules/foundation/register-core.js +57 -88
  41. package/dist/modules/foundation/register.js +1 -21
  42. package/dist/modules/foundation/scaffold.js +25 -0
  43. package/dist/modules/foundation/scan.js +227 -0
  44. package/dist/modules/foundation/setup-tool.js +96 -0
  45. package/dist/modules/foundation/verify.js +9 -1
  46. package/dist/modules/lawbook/assets/commands/archive.md +1 -1
  47. package/dist/modules/lawbook/assets/commands/draft.md +1 -1
  48. package/dist/modules/lawbook/assets/commands/explore.md +1 -1
  49. package/dist/modules/lawbook/assets/commands/sync.md +2 -2
  50. package/dist/modules/lawbook/assets/skills/archive/SKILL.md +1 -1
  51. package/dist/modules/lawbook/assets/skills/archive/steps/03-validate-and-sync.md +3 -3
  52. package/dist/modules/lawbook/assets/skills/archive/steps/04-archive.md +1 -1
  53. package/dist/modules/lawbook/assets/skills/draft/steps/02-understand.md +1 -1
  54. package/dist/modules/lawbook/assets/skills/draft/steps/05-validate.md +1 -1
  55. package/dist/modules/lawbook/assets/skills/explore/steps/01-investigate.md +1 -1
  56. package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +1 -1
  57. package/dist/modules/lawbook/assets/skills/sync/SKILL.md +1 -1
  58. package/dist/modules/lawbook/assets/skills/sync/steps/03-validate.md +1 -1
  59. package/dist/modules/lawbook/assets/skills/sync/steps/04-promote.md +1 -1
  60. package/dist/modules/lawbook/change-tool.js +90 -0
  61. package/dist/modules/lawbook/coverage.js +45 -6
  62. package/dist/modules/lawbook/ears.js +417 -0
  63. package/dist/modules/lawbook/engine.js +29 -0
  64. package/dist/modules/lawbook/register.js +96 -54
  65. package/dist/modules/lawbook/spec-items.js +4 -1
  66. package/dist/modules/team/owners.js +464 -0
  67. package/dist/modules/tools/register.js +4 -26
  68. package/dist/shared/deprecation.js +99 -0
  69. package/dist/shared/exposure.js +4 -19
  70. package/dist/shared/git.js +25 -0
  71. package/dist/shared/mcp.js +29 -3
  72. package/dist/shared/output-budget.js +68 -0
  73. package/dist/shared/tool-catalog.js +49 -0
  74. package/package.json +4 -3
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Prompt-injection scanners for rule / skill files.
3
+ * Complements digests: hashes catch any edit; scanners catch known payloads.
4
+ */
5
+ // Covers: req~injection-scan~1
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ const OVERRIDE = /\b(ignore\s+previous\s+instructions|disregard\s+the\s+above|you\s+are\s+now|new\s+system\s+prompt|ignora\s+las\s+instrucciones\s+anteriores)\b/i;
9
+ const SHELL = /\b(curl\s+[^\n|]*\|\s*(ba)?sh|bash\s+-c|Invoke-Expression|iex\b|eval\s*\(|npm\s+run\s+[^\s]+.*\|\s*sh)\b/i;
10
+ const EXFIL = /\b(send\s+(this|the)\s+(repo|code|contents?)\s+to|exfiltrat|upload\s+to\s+https?:\/\/|read\s+(\.env|~\/\.ssh|~\/\.aws|\.npmrc)|exfiltra)\b/i;
11
+ const URL_RE = /https?:\/\/[^\s)>\]]+/gi;
12
+ const ZERO_WIDTH = /[\u200B-\u200D\uFEFF\u202A-\u202E]/;
13
+ const IMPERATIVE_HTML = /<!--[\s\S]{0,200}\b(run|execute|ignore|disregard|curl|bash|send)\b[\s\S]{0,200}-->/i;
14
+ /**
15
+ * Normalize text before detection (NFKC, strip zero-width/bidi, fold common
16
+ * Cyrillic lookalikes, collapse whitespace).
17
+ */
18
+ export function normalizeForScan(text) {
19
+ let t = text.normalize("NFKC");
20
+ t = t.replace(ZERO_WIDTH, "");
21
+ t = t.replace(/[\u0400-\u04FF]/g, (ch) => CYRILLIC_FOLD[ch] ?? ch);
22
+ t = t.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
23
+ t = t.replace(/\\\*|\\_|\\`/g, (m) => m.slice(1));
24
+ t = t.replace(/\s+/g, " ").trim();
25
+ return t;
26
+ }
27
+ const CYRILLIC_FOLD = {
28
+ а: "a",
29
+ е: "e",
30
+ о: "o",
31
+ р: "p",
32
+ с: "c",
33
+ у: "y",
34
+ х: "x",
35
+ А: "A",
36
+ Е: "E",
37
+ О: "O",
38
+ Р: "P",
39
+ С: "C",
40
+ У: "Y",
41
+ Х: "X",
42
+ };
43
+ /** Scan one file's raw contents; returns findings with original line numbers. */
44
+ export function scanText(relPath, raw, opts = {}) {
45
+ const suppressions = opts.suppressions ?? [];
46
+ const allow = new Set((opts.allowHosts ?? []).map((h) => h.toLowerCase()));
47
+ const out = [];
48
+ const lines = raw.split(/\r?\n/);
49
+ const push = (f) => {
50
+ if (suppressions.some((s) => s.detector === f.detector && matchPath(s.path, f.path) && s.note.trim().length > 0)) {
51
+ return;
52
+ }
53
+ out.push(f);
54
+ };
55
+ if (ZERO_WIDTH.test(raw)) {
56
+ push({
57
+ detector: "injection/hidden-text",
58
+ severity: "error",
59
+ path: relPath,
60
+ line: 1,
61
+ excerpt: "zero-width or bidi control characters",
62
+ message: "Hidden / bidi control characters in a rule file.",
63
+ });
64
+ }
65
+ for (let i = 0; i < lines.length; i++) {
66
+ const line = lines[i];
67
+ const norm = normalizeForScan(line);
68
+ const ln = i + 1;
69
+ if (OVERRIDE.test(norm) || OVERRIDE.test(line)) {
70
+ push({
71
+ detector: "injection/instruction-override",
72
+ severity: "error",
73
+ path: relPath,
74
+ line: ln,
75
+ excerpt: clip(line),
76
+ message: "Instruction-override phrasing in a rule file.",
77
+ });
78
+ }
79
+ if (SHELL.test(norm) || SHELL.test(line)) {
80
+ push({
81
+ detector: "injection/shell-execution",
82
+ severity: "error",
83
+ path: relPath,
84
+ line: ln,
85
+ excerpt: clip(line),
86
+ message: "Shell-execution instruction in a rule file.",
87
+ });
88
+ }
89
+ if (EXFIL.test(norm) || EXFIL.test(line)) {
90
+ push({
91
+ detector: "injection/exfiltration",
92
+ severity: "error",
93
+ path: relPath,
94
+ line: ln,
95
+ excerpt: clip(line),
96
+ message: "Possible exfiltration instruction in a rule file.",
97
+ });
98
+ }
99
+ for (const m of line.matchAll(URL_RE)) {
100
+ try {
101
+ const host = new URL(m[0]).hostname.toLowerCase();
102
+ if (allow.size > 0 && !allow.has(host) && !host.endsWith(".github.com")) {
103
+ push({
104
+ detector: "injection/unallowlisted-url",
105
+ severity: "warn",
106
+ path: relPath,
107
+ line: ln,
108
+ excerpt: clip(m[0]),
109
+ message: `URL host not on allowlist: ${host}`,
110
+ });
111
+ }
112
+ }
113
+ catch {
114
+ /* ignore */
115
+ }
116
+ }
117
+ }
118
+ if (IMPERATIVE_HTML.test(raw)) {
119
+ const idx = raw.search(IMPERATIVE_HTML);
120
+ const snippet = raw.slice(Math.max(0, idx), idx + 220);
121
+ // Data-only speclaw markers (map/laws/provenance) are not agent instructions.
122
+ if (!/<!--\s*speclaw:/.test(snippet)) {
123
+ const line = lineOf(raw, idx);
124
+ push({
125
+ detector: "injection/imperative-html-comment",
126
+ severity: "warn",
127
+ path: relPath,
128
+ line,
129
+ excerpt: clip(raw.slice(idx, idx + 120)),
130
+ message: "HTML comment contains imperative language (visible to some agents).",
131
+ });
132
+ }
133
+ }
134
+ // External @import / @~/ / @C:\…
135
+ // External @import / @~/ / @C:\…
136
+ for (let i = 0; i < lines.length; i++) {
137
+ const line = lines[i];
138
+ const m = /@([~/][^\s)\]>"']+)/.exec(line) ??
139
+ /@([A-Za-z]:[^\s)\]>"']+)/.exec(line) ??
140
+ /@import\s+["']([^"']+)["']/.exec(line);
141
+ if (!m)
142
+ continue;
143
+ const target = m[1];
144
+ push({
145
+ detector: "injection/external-import",
146
+ severity: "warn",
147
+ path: relPath,
148
+ line: i + 1,
149
+ excerpt: clip(line),
150
+ message: `Import may resolve outside the working directory: ${target}`,
151
+ });
152
+ }
153
+ // Frontmatter ↔ body mismatch (skills)
154
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(raw);
155
+ if (fm) {
156
+ const front = fm[1];
157
+ const body = fm[2];
158
+ const frontDesc = /(?:^|\n)description:\s*["']?([^\n"']+)/i.exec(front)?.[1] ?? "";
159
+ if (frontDesc && SHELL.test(body) && !SHELL.test(frontDesc)) {
160
+ push({
161
+ detector: "injection/manifest-prose-mismatch",
162
+ severity: "warn",
163
+ path: relPath,
164
+ line: 1,
165
+ excerpt: clip(frontDesc),
166
+ message: "Skill frontmatter description and body disagree on shell risk.",
167
+ });
168
+ }
169
+ }
170
+ return out;
171
+ }
172
+ function matchPath(pattern, file) {
173
+ if (pattern === file)
174
+ return true;
175
+ if (pattern.endsWith("/**")) {
176
+ const prefix = pattern.slice(0, -3);
177
+ return file === prefix || file.startsWith(prefix + "/");
178
+ }
179
+ if (pattern.includes("*")) {
180
+ const re = new RegExp("^" + pattern.split("*").map(escapeRegExp).join(".*") + "$");
181
+ return re.test(file);
182
+ }
183
+ return false;
184
+ }
185
+ function escapeRegExp(s) {
186
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
187
+ }
188
+ function clip(s, n = 120) {
189
+ const t = s.trim();
190
+ return t.length <= n ? t : t.slice(0, n - 1) + "…";
191
+ }
192
+ function lineOf(text, index) {
193
+ if (index <= 0)
194
+ return 1;
195
+ return text.slice(0, index).split(/\r?\n/).length;
196
+ }
197
+ /** Load optional scan suppressions from lawbook/config.yaml (line-oriented). */
198
+ export function loadScanSuppressions(projectPath) {
199
+ const cfgPath = path.join(projectPath, "lawbook", "config.yaml");
200
+ if (!fs.existsSync(cfgPath))
201
+ return [];
202
+ const text = fs.readFileSync(cfgPath, "utf8");
203
+ // Very small subset: repeated blocks under scanSuppressions are not fully
204
+ // parsed; support a flat JSON-ish list in a comment-free line for v1:
205
+ // scanSuppressions: [{"detector":"…","path":"…","note":"…"}]
206
+ const m = /^\s*scanSuppressions\s*:\s*(\[[\s\S]*?\])\s*$/m.exec(text);
207
+ if (!m)
208
+ return [];
209
+ try {
210
+ const arr = JSON.parse(m[1]);
211
+ return arr.filter((s) => s.detector && s.path && s.note);
212
+ }
213
+ catch {
214
+ return [];
215
+ }
216
+ }
217
+ /** Scan a list of project-relative files that exist. */
218
+ export function scanPaths(projectPath, relPaths, opts = {}) {
219
+ const out = [];
220
+ for (const rel of relPaths) {
221
+ const abs = path.join(projectPath, rel);
222
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile())
223
+ continue;
224
+ out.push(...scanText(rel, fs.readFileSync(abs, "utf8"), opts));
225
+ }
226
+ return out;
227
+ }
@@ -0,0 +1,96 @@
1
+ import { z } from "zod";
2
+ import { loadPacks } from "../tools/packs.js";
3
+ import { AGENTS, configureAgent } from "../../shared/agents.js";
4
+ import { emptyReport } from "../../shared/install.js";
5
+ import { refreshAgents } from "../../shared/agents.js";
6
+ import { installPack } from "../tools/packs.js";
7
+ /** Human help text for init questionnaire (not embedded in MCP schemas). */
8
+ const profileFieldHelp = {
9
+ project_name: "Short project name, e.g. the repo name",
10
+ project_description: "One-line description of what the project does",
11
+ organization: "Company/team name",
12
+ stack_summary: "e.g. 'Next.js 15 + TypeScript frontend, FastAPI + PostgreSQL backend'",
13
+ architecture: "e.g. 'hexagonal architecture with bounded contexts'",
14
+ test_commands: "Real commands, e.g. 'pytest backend/tests && npm run test'",
15
+ lint_commands: "Real commands, e.g. 'ruff check . && npm run lint && tsc --noEmit'",
16
+ branch_pattern: "e.g. 'feature/<ticket-id>-<slug>'",
17
+ commit_style: "e.g. 'conventional commits, imperative, English'",
18
+ custom_laws: "Extra markdown for LAWS.md — project-specific binding rules",
19
+ compass_hints: "Markdown bullets with real entrypoints for docs/compass.md",
20
+ base_standards_extra: "Extra cross-cutting rules for base-standards.md",
21
+ modules_table: "Markdown table of modules/bounded contexts",
22
+ layering_rules: "Layers and allowed dependencies for architecture.md",
23
+ backend_layers: "Backend layer table for backend-standards.md",
24
+ frontend_layers: "Frontend layer table for frontend-standards.md",
25
+ versioning_rules: "Versioning/release convention for conventions.md",
26
+ documentation_extra: "Repo-specific docstring notes for documentation.md",
27
+ };
28
+ const profileShape = {
29
+ project_name: z.string(),
30
+ project_description: z.string().optional(),
31
+ organization: z.string().optional(),
32
+ stack_summary: z.string().optional(),
33
+ architecture: z.string().optional(),
34
+ test_commands: z.string().optional(),
35
+ lint_commands: z.string().optional(),
36
+ branch_pattern: z.string().optional(),
37
+ commit_style: z.string().optional(),
38
+ custom_laws: z.string().optional(),
39
+ compass_hints: z.string().optional(),
40
+ base_standards_extra: z.string().optional(),
41
+ modules_table: z.string().optional(),
42
+ layering_rules: z.string().optional(),
43
+ backend_layers: z.string().optional(),
44
+ frontend_layers: z.string().optional(),
45
+ versioning_rules: z.string().optional(),
46
+ documentation_extra: z.string().optional(),
47
+ };
48
+ export const setupActions = ["init", "configure-agent", "add-pack", "list-packs"];
49
+ export const speclawSetupSchema = {
50
+ projectPath: z.string(),
51
+ action: z.enum(setupActions),
52
+ agent: z.enum(AGENTS.map((a) => a.id)).optional(),
53
+ pack: z.string().optional(),
54
+ vars: z.record(z.string()).optional(),
55
+ };
56
+ /**
57
+ * Dispatch `speclaw_setup` by action. Scaffold is CLI-only — not exposed here.
58
+ *
59
+ * @param args - Setup action and parameters.
60
+ */
61
+ export function handleSpeclawSetup(args) {
62
+ switch (args.action) {
63
+ case "init":
64
+ return {
65
+ instructions: [
66
+ "1. Analyze the repository at projectPath and fill profile fields from the real codebase.",
67
+ "2. Call speclaw_setup with action configure-agent / add-pack as needed.",
68
+ "3. Run lawbook_change action init and compass_index when scaffold completes via CLI if needed.",
69
+ ],
70
+ profileFields: profileFieldHelp,
71
+ packs: loadPacks(),
72
+ note: "Full scaffold runs via CLI: speclaw init — not MCP.",
73
+ };
74
+ case "configure-agent": {
75
+ if (!args.agent)
76
+ throw new Error(`speclaw_setup: action 'configure-agent' requires 'agent'`);
77
+ const report = emptyReport();
78
+ configureAgent(args.projectPath, args.agent, report);
79
+ return report;
80
+ }
81
+ case "list-packs":
82
+ return loadPacks();
83
+ case "add-pack": {
84
+ if (!args.pack)
85
+ throw new Error(`speclaw_setup: action 'add-pack' requires 'pack'`);
86
+ const report = emptyReport();
87
+ installPack(args.projectPath, args.pack, args.vars ?? {}, report);
88
+ refreshAgents(args.projectPath, report);
89
+ return report;
90
+ }
91
+ default:
92
+ throw new Error(`speclaw_setup: unknown action '${String(args.action)}'`);
93
+ }
94
+ }
95
+ /** Zod profile shape for CLI scaffold (not in MCP schema). */
96
+ export { profileShape as setupProfileShape };
@@ -1,6 +1,6 @@
1
1
  import { performance } from "node:perf_hooks";
2
2
  import { openDb, indexExists } from "../compass/db.js";
3
- import { hasBatchBackend, loadManifestForVerify } from "./laws.js";
3
+ import { hasBatchBackend, isActiveLaw, loadManifestForVerify } from "./laws.js";
4
4
  import { runDepsLaw } from "./deps.js";
5
5
  import { runGraphLaw } from "./graph.js";
6
6
  export { underPaths } from "./verify-model.js";
@@ -50,6 +50,14 @@ export function verifyLaws(args) {
50
50
  const manifest = loadManifestForVerify(args.projectPath);
51
51
  const engines = args.engines;
52
52
  const selected = manifest.laws.filter((law) => {
53
+ if (!isActiveLaw(law)) {
54
+ skipped.push({
55
+ lawId: law.id,
56
+ reason: "draft",
57
+ detail: "status=draft — pending human activation; does not gate",
58
+ });
59
+ return false;
60
+ }
53
61
  if (!hasBatchBackend(law))
54
62
  return false;
55
63
  if (args.lawIds && !args.lawIds.includes(law.id))
@@ -6,5 +6,5 @@ Archive the completed change: $ARGUMENTS
6
6
 
7
7
  Follow the `archive` skill: confirm every task (or level-0 checklist) is done
8
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
9
+ `lawbook_change` (action: validate), then `lawbook_change` (action: archive) with today's date (YYYY-MM-DD). Sync
10
10
  runs only when the ceremony level requires specs. Never move the folder by hand.
@@ -8,4 +8,4 @@ Follow the `draft` skill: ensure `lawbook/` exists (`lawbook_init`), investigate
8
8
  with Compass, propose a ceremony level (`lawbook_level` mode `propose`) and
9
9
  **confirm** it with the human (`set`), then scaffold only the artifacts that
10
10
  level requires. For true one-liners use `speclaw quick` / the `quick` skill
11
- instead. Finish by running `lawbook_validate` and fixing every issue.
11
+ instead. Finish by running `lawbook_change` (action: validate) and fixing every issue.
@@ -4,7 +4,7 @@ description: Enter explore mode — a thinking partner before or during a change
4
4
 
5
5
  Explore: $ARGUMENTS
6
6
 
7
- Follow the `explore` skill: use `compass_recall`/`compass_explore` to understand
7
+ Follow the `explore` skill: use `compass_find` (mode: concept)/`compass_explore` to understand
8
8
  the code, ask sharp questions, check the relevant `docs/standards/`, weigh
9
9
  approaches with trade-offs, and give a recommendation. Write nothing to
10
10
  `lawbook/`; when the direction is clear, offer to `draft` the change.
@@ -5,6 +5,6 @@ description: Promote a change's delta specs into the canonical specs, without ar
5
5
  Sync the change's specs into canonical: $ARGUMENTS
6
6
 
7
7
  Follow the `sync` skill: reconcile the delta specs against what was actually
8
- built (branch diff + code graph), validate the change (`lawbook_validate`), then
9
- run `lawbook_sync` to promote each delta spec into `lawbook/specs/`. Report what
8
+ built (branch diff + code graph), validate the change (`lawbook_change` (action: validate)), then
9
+ run `lawbook_change` (action: sync) to promote each delta spec into `lawbook/specs/`. Report what
10
10
  you reconciled and what was promoted; leave the change active.
@@ -9,7 +9,7 @@ Close out a completed change: its delta specs become canonical and the change
9
9
  folder moves to `lawbook/changes/archive/`. This is part of the PR that
10
10
  implements the change, not a post-merge chore.
11
11
 
12
- `lawbook_archive` is **gated** — the engine refuses to archive (and reports the
12
+ `lawbook_change` (action: archive) is **gated** — the engine refuses to archive (and reports the
13
13
  reason) while any task is unchecked, while `reports/` holds no discipline report,
14
14
  or while the delta specs are not yet synced into the canonical specs. So archive
15
15
  is the last step of a completed change: reconcile, sync, then archive.
@@ -1,8 +1,8 @@
1
1
  # Validate and sync
2
2
 
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
3
+ Run `lawbook_change` (action: validate). If the confirmed ceremony level requires delta specs
4
+ (levels 1–3), run `lawbook_change` (action: sync) to promote them into `lawbook/specs/` —
5
+ `lawbook_change` (action: archive) refuses unless the canonical specs already match. At **level
6
6
  0**, skip sync (there are no deltas).
7
7
 
8
8
  Next: read `steps/04-archive.md` and do only what it says.
@@ -1,6 +1,6 @@
1
1
  # Archive
2
2
 
3
- Run the `lawbook_archive` tool with the change name and today's date
3
+ Run the `lawbook_change` (action: archive) tool with the change name and today's date
4
4
  (`YYYY-MM-DD`). It re-checks the gate deterministically and, if it passes,
5
5
  moves `lawbook/changes/<name>/` to `lawbook/changes/archive/<date>-<name>/`.
6
6
  If it refuses, resolve the reported blockers (unchecked tasks, missing
@@ -4,7 +4,7 @@
4
4
  code — it is incremental (unchanged files are skipped by hash), so this is
5
5
  cheap and guarantees your decisions rest on the current graph, not a stale one.
6
6
  - Clarify what the user wants (feature / fix / refactor) and confirm scope.
7
- - Use `compass_explore` and `compass_recall` (speclaw's code index) BEFORE
7
+ - Use `compass_explore` and `compass_find` (mode: concept) (speclaw's code index) BEFORE
8
8
  grep/read to locate the real code the change touches and its blast radius.
9
9
  - **Propose a ceremony level** with `lawbook_level` (mode `propose`) using the
10
10
  paths/symbols you found; **confirm with the human** (mode `set`) before
@@ -1,6 +1,6 @@
1
1
  # Validate
2
2
 
3
- Run the `lawbook_validate` tool for the change and fix every issue it reports
3
+ Run the `lawbook_change` (action: validate) tool for the change and fix every issue it reports
4
4
  (missing artifacts, non-normative specs, missing scenarios) before handing off
5
5
  to implementation. Read its advisory **warnings** too: a near-duplicate
6
6
  capability name usually means you should reuse the existing capability's exact
@@ -3,7 +3,7 @@
3
3
  - **Refresh the index first.** Run `compass_index` before investigating — it is
4
4
  incremental (unchanged files skipped by hash), so it is cheap and keeps your
5
5
  reasoning on the current graph rather than a stale one.
6
- - **Understand the code first.** Use `compass_recall` to find relevant code by
6
+ - **Understand the code first.** Use `compass_find` (mode: concept) to find relevant code by
7
7
  meaning and `compass_explore` to read a symbol's source plus its callers and
8
8
  callees — before grep/read.
9
9
  - **Ask sharp questions** to surface hidden assumptions, constraints, and edge
@@ -1,7 +1,7 @@
1
1
  # Implement and evidence
2
2
 
3
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
4
+ discipline report under `reports/`. Archive with `lawbook_change` (action: archive) (no sync at
5
5
  level 0). Promote via `lawbook_level` if scope grew.
6
6
 
7
7
  No further steps — workflow complete.
@@ -9,7 +9,7 @@ Update the project's canonical specifications (`lawbook/specs/`) with a change's
9
9
  delta specs, without archiving the change. Use this when the specs should
10
10
  become the source of truth but the change isn't finished (e.g. multi-PR work).
11
11
 
12
- `lawbook_sync` is a deterministic copy — it is blind to the code. So before
12
+ `lawbook_change` (action: sync) is a deterministic copy — it is blind to the code. So before
13
13
  promoting, YOU reconcile the delta specs against what was actually built, so the
14
14
  specs that become canonical describe reality, not just the original draft.
15
15
 
@@ -1,6 +1,6 @@
1
1
  # Validate
2
2
 
3
- Run `lawbook_validate` for the change; do not sync a change whose specs are
3
+ Run `lawbook_change` (action: validate) for the change; do not sync a change whose specs are
4
4
  invalid.
5
5
 
6
6
  Next: read `steps/04-promote.md` and do only what it says.
@@ -1,6 +1,6 @@
1
1
  # Promote
2
2
 
3
- Run the `lawbook_sync` tool for the change. It copies each
3
+ Run the `lawbook_change` (action: sync) tool for the change. It copies each
4
4
  `lawbook/changes/<name>/specs/<capability>/spec.md` over the canonical
5
5
  `lawbook/specs/<capability>/spec.md` and reports what it promoted, flagging
6
6
  each as **created** (new capability) or **updated** (overwrote an existing
@@ -0,0 +1,90 @@
1
+ import { z } from "zod";
2
+ import { specInit, specValidate, specSync, specArchive, specList } from "./engine.js";
3
+ import { handleLevel } from "./quick.js";
4
+ import { buildCoverageReport, loadCoverageConfig, renderCoverageAgent } from "./coverage.js";
5
+ import { buildDriftReport, renderDriftAgent } from "./drift.js";
6
+ export const lawbookChangeActions = [
7
+ "init",
8
+ "list",
9
+ "validate",
10
+ "sync",
11
+ "archive",
12
+ "level",
13
+ "coverage",
14
+ "drift",
15
+ ];
16
+ export const lawbookChangeSchema = {
17
+ projectPath: z.string(),
18
+ action: z.enum(lawbookChangeActions),
19
+ change: z.string().optional(),
20
+ date: z
21
+ .string()
22
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
23
+ .optional(),
24
+ mode: z.enum(["propose", "set", "promote", "explain"]).optional(),
25
+ paths: z.array(z.string()).optional(),
26
+ symbols: z.array(z.string()).optional(),
27
+ level: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]).optional(),
28
+ reason: z.string().optional(),
29
+ onlyDefects: z.boolean().optional(),
30
+ json: z.boolean().optional(),
31
+ capability: z.string().optional(),
32
+ includeReverse: z.boolean().optional(),
33
+ maxItems: z.number().int().min(1).max(50).optional(),
34
+ };
35
+ function requireField(args, field) {
36
+ const v = args[field];
37
+ if (typeof v === "string" && v.length > 0)
38
+ return v;
39
+ throw new Error(`lawbook_change: action '${args.action}' requires '${String(field)}'`);
40
+ }
41
+ /**
42
+ * Dispatch `lawbook_change` by action.
43
+ *
44
+ * @param args - Unified lawbook lifecycle arguments.
45
+ */
46
+ export function handleLawbookChange(args) {
47
+ switch (args.action) {
48
+ case "init":
49
+ return specInit(args.projectPath);
50
+ case "list":
51
+ return specList(args.projectPath);
52
+ case "validate":
53
+ return specValidate(args.projectPath, requireField(args, "change"));
54
+ case "sync":
55
+ return specSync(args.projectPath, requireField(args, "change"));
56
+ case "archive":
57
+ return specArchive(args.projectPath, requireField(args, "change"), requireField(args, "date"));
58
+ case "level":
59
+ if (!args.mode)
60
+ throw new Error(`lawbook_change: action 'level' requires 'mode'`);
61
+ return handleLevel({
62
+ projectPath: args.projectPath,
63
+ mode: args.mode,
64
+ change: args.change,
65
+ paths: args.paths,
66
+ symbols: args.symbols,
67
+ level: args.level,
68
+ reason: args.reason,
69
+ });
70
+ case "coverage": {
71
+ const cfg = loadCoverageConfig(args.projectPath);
72
+ const report = buildCoverageReport(args.projectPath, { change: args.change, cfg });
73
+ if (args.json)
74
+ return report;
75
+ return renderCoverageAgent(report, args.onlyDefects !== false);
76
+ }
77
+ case "drift": {
78
+ const report = buildDriftReport(args.projectPath, {
79
+ capability: args.capability,
80
+ reverse: args.includeReverse === true,
81
+ failOn: "semantic",
82
+ });
83
+ if (args.json)
84
+ return report;
85
+ return renderDriftAgent(report, args.maxItems ?? 10);
86
+ }
87
+ default:
88
+ throw new Error(`lawbook_change: unknown action '${String(args.action)}'`);
89
+ }
90
+ }