@esneiderbravo/speclaw 0.3.0 → 0.3.2

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/README.md CHANGED
@@ -55,7 +55,8 @@ The `speclaw` command is now available everywhere — run `speclaw index`,
55
55
 
56
56
  1. **Ask which agents you use** (Claude Code, Cursor, Codex, …) — and configure
57
57
  only those. Add more later; nothing is forced on you.
58
- 2. Write the **foundation** (constitution + standards) and the **lawbook workflow**.
58
+ 2. Write the **foundation** (constitution + standards) and the **lawbook workflow**,
59
+ and compile your blocking laws into **agent hooks** for the agents that support them.
59
60
  3. **Index your code** with a live progress bar and a summary of what it found.
60
61
  4. Register the speclaw **MCP server** in each chosen agent's config.
61
62
  5. Print a prompt to paste into your agent so it fills the constitution with your
@@ -81,7 +82,7 @@ too (also `pnpm dlx` / `yarn dlx`) — but installing globally means you can run
81
82
 
82
83
  | Module | What it does |
83
84
  | :-- | :-- |
84
- | **Foundation** | The project's constitution: `LAWS.md` binding a set of granular standards under `docs/standards/` (base, architecture, backend, frontend, testing, documentation, conventions, lawbook), plus strict `CLAUDE.md` / `AGENTS.md` agent contracts — filled from your real codebase. |
85
+ | **Foundation** | The project's constitution: `LAWS.md` binding a set of granular standards under `docs/standards/` (base, architecture, backend, frontend, testing, documentation, conventions, lawbook), plus strict `CLAUDE.md` / `AGENTS.md` agent contracts — filled from your real codebase. It also **enforces** them: blocking laws compile into agent hooks that deny a forbidden edit at the keystroke (`speclaw check` / `speclaw_check`). |
85
86
  | **Compass** | speclaw's own local code graph. Parses your code (tree-sitter) into nodes + edges plus a local vector store, so an agent finds and understands code with a fraction of the tokens a grep/read loop would cost. No LLM, 100% local, lives in `.speclaw/` (gitignored). |
86
87
  | **Lawbook** | speclaw's own spec-driven workflow: `draft → build → sync → archive` (and `explore`), backed by `lawbook_*` engine tools. No external CLI. |
87
88
  | **Tools** | Opt-in packs of skills and subagents (currently the dev-agents) that agents use for specific tasks. |
@@ -175,6 +176,12 @@ ai-specs` command to stop tracking it (they never touch your git index
175
176
  themselves). The agent directories (`.claude/`, `.cursor/`, …) are **left to
176
177
  you** — commit your own skills and commands there if you want to.
177
178
 
179
+ **Enforcement artifacts.** For agents that support hooks, speclaw merges its law
180
+ hooks into that agent's settings (e.g. `.claude/settings.json`) **by identity** —
181
+ it never touches hooks you added yourself. The compiled law manifest lives in
182
+ `.speclaw/laws-manifest.json` (gitignored, regenerated on `init`/`update`), and a
183
+ context-coverage log in `.speclaw/context-log.jsonl` feeds `speclaw doctor`.
184
+
178
185
  <br/>
179
186
 
180
187
  ## <img src="https://raw.githubusercontent.com/esneiderbravo/speclaw/main/brand/diamond.png" height="20" alt="◆" align="absmiddle">&nbsp; Philosophy — why "laws"?
@@ -186,6 +193,21 @@ you** — commit your own skills and commands there if you want to.
186
193
  > knowledge explicit, executable, and binding, and gives agents a local map
187
194
  > (Compass) and a disciplined workflow (Lawbook) to act on it — without burning tokens.
188
195
 
196
+ This is why "enforced" is literal, not a metaphor. Anthropic's own guidance puts
197
+ it plainly:
198
+
199
+ > _"An instruction like 'never edit .env' in CLAUDE.md or a skill is **a request,
200
+ > not a guarantee**. A `PreToolUse` hook that blocks the edit is enforcement. If a
201
+ > rule must hold every time, make it a hook rather than a prompt instruction."_
202
+ > — [Claude Code — Hooks](https://code.claude.com/docs/en/hooks)
203
+
204
+ So `speclaw init` compiles your blocking laws into agent hooks: a law marked
205
+ `bloqueo` is denied at the keystroke (`PreToolUse`), citing the law's id, text,
206
+ and source. `speclaw check --dry-run --path <file>` previews what would block, and
207
+ `speclaw doctor` reports how many of your laws actually reached the agent's
208
+ context. Agents without hooks (Cursor, Codex) enforce the same laws in CI via
209
+ `speclaw verify`.
210
+
189
211
  <br/>
190
212
 
191
213
  ## <img src="https://raw.githubusercontent.com/esneiderbravo/speclaw/main/brand/diamond.png" height="20" alt="◆" align="absmiddle">&nbsp; Staying up to date
@@ -0,0 +1,100 @@
1
+ import { ui, c } from "../lib/ui.js";
2
+ import { checkAction } from "../../modules/foundation/check.js";
3
+ import { hasBackend, readLawManifest } from "../../modules/foundation/laws.js";
4
+ /** Read all of stdin as UTF-8 text (used for `--hook-payload -`). */
5
+ async function readStdin() {
6
+ const chunks = [];
7
+ for await (const chunk of process.stdin)
8
+ chunks.push(chunk);
9
+ return Buffer.concat(chunks).toString("utf8");
10
+ }
11
+ /**
12
+ * `speclaw check` — the CLI twin of the `speclaw_check` MCP tool, and the
13
+ * command-hook fallback for agents driven purely by the CLI.
14
+ *
15
+ * - `--hook-payload -` reads a hook event JSON from stdin, evaluates it, prints
16
+ * the `hookSpecificOutput` contract, and exits 2 on `deny` (the documented
17
+ * command-hook block signal).
18
+ * - `--dry-run [--path P] [--event E]` previews the verdict for a synthetic
19
+ * action against path `P`, without blocking anything (always exits 0).
20
+ * - with no flags, prints a summary of the project's declared laws.
21
+ *
22
+ * @param flags - Parsed CLI flags.
23
+ */
24
+ export async function runCheck(flags) {
25
+ const cwd = process.cwd();
26
+ if (flags["hook-payload"]) {
27
+ const raw = await readStdin();
28
+ let payload;
29
+ try {
30
+ payload = JSON.parse(raw);
31
+ }
32
+ catch {
33
+ // Fail open: an unreadable payload must never block the agent.
34
+ console.log(JSON.stringify({ hookSpecificOutput: { permissionDecision: "allow" } }));
35
+ return;
36
+ }
37
+ const event = (payload.hook_event_name ?? payload.event ?? "PreToolUse");
38
+ const toolName = (payload.tool_name ?? payload.toolName);
39
+ const result = checkAction({ projectPath: cwd, event, toolName, payload });
40
+ const decision = result.verdict === "deny" ? "deny" : "allow";
41
+ console.log(JSON.stringify({
42
+ hookSpecificOutput: {
43
+ hookEventName: event,
44
+ permissionDecision: decision,
45
+ permissionDecisionReason: result.reason ?? "",
46
+ },
47
+ }));
48
+ if (result.verdict === "deny")
49
+ process.exit(2);
50
+ return;
51
+ }
52
+ if (flags["dry-run"]) {
53
+ const target = typeof flags.path === "string" ? flags.path : "";
54
+ const event = (typeof flags.event === "string" ? flags.event : "PreToolUse");
55
+ if (!target) {
56
+ ui.err(`Pass ${ui.code("--path <file>")} to preview what a law would do to that path.`);
57
+ process.exit(1);
58
+ }
59
+ const result = checkAction({
60
+ projectPath: cwd,
61
+ event,
62
+ payload: { tool_input: { file_path: target } },
63
+ });
64
+ ui.heading(`speclaw check --dry-run (${event})`);
65
+ ui.info(`target: ${c.cream(target)}`);
66
+ if (result.diagnostic)
67
+ ui.warn(result.diagnostic);
68
+ if (result.evaluated.length === 0) {
69
+ ui.ok("No law applies to this path.");
70
+ }
71
+ else {
72
+ for (const e of result.evaluated) {
73
+ // The message already opens with the law id (see check.ts `cite`).
74
+ const line = e.message ?? e.lawId;
75
+ if (e.passed)
76
+ ui.info(line);
77
+ else
78
+ ui.warn(line);
79
+ }
80
+ }
81
+ ui.plain();
82
+ const verb = result.verdict === "deny" ? c.red("would BLOCK") : c.green("would allow");
83
+ ui.plain(` verdict: ${verb} · evaluated in ${result.elapsedMs.toFixed(1)} ms`);
84
+ return;
85
+ }
86
+ // Default: summarize the declared laws.
87
+ const manifest = readLawManifest(cwd);
88
+ ui.heading("speclaw check");
89
+ if (!manifest) {
90
+ ui.warn("No law manifest — run `speclaw init` to seed .speclaw/laws-manifest.json.");
91
+ return;
92
+ }
93
+ ui.info(`${manifest.laws.length} law(s) declared:`);
94
+ for (const law of manifest.laws) {
95
+ const backend = hasBackend(law)
96
+ ? law.verification.kind
97
+ : `${law.verification.kind} (no backend yet)`;
98
+ ui.plain(` · ${c.cream(law.id)} — ${law.enforcement} · ${backend} · [${law.scope.join(", ")}]`);
99
+ }
100
+ }
package/dist/cli/index.js CHANGED
@@ -34,6 +34,7 @@ Lawbook (spec-driven workflow)
34
34
 
35
35
  Other
36
36
  doctor Verify the installation
37
+ check Evaluate an action against the laws (hooks call this; --dry-run to preview)
37
38
  mcp Start the MCP server (used by your agent's config)
38
39
  help Show this help
39
40
  --version Print the installed speclaw version
@@ -110,6 +111,8 @@ async function dispatch(cmd, flags) {
110
111
  return (await import("./commands/lawbook.js")).runSpec(flags);
111
112
  case "doctor":
112
113
  return (await import("./commands/doctor.js")).runDoctor(flags);
114
+ case "check":
115
+ return (await import("./commands/check.js")).runCheck(flags);
113
116
  default:
114
117
  ui.err(`Unknown command: ${cmd}`);
115
118
  console.log(HELP);
@@ -47,9 +47,17 @@ CREATE TABLE IF NOT EXISTS node_embeddings (
47
47
  model TEXT NOT NULL,
48
48
  vec BLOB NOT NULL
49
49
  );
50
+ -- git_history_cache: memoized results of the expensive git-history scans
51
+ -- (churn, co-change), keyed by query and invalidated when HEAD moves.
52
+ CREATE TABLE IF NOT EXISTS git_history_cache (
53
+ query_key TEXT PRIMARY KEY,
54
+ head_sha TEXT NOT NULL,
55
+ payload TEXT NOT NULL,
56
+ computed_at INTEGER NOT NULL
57
+ );
50
58
  `;
51
59
  /** Schema version stamped into the `meta` table on first creation. */
52
- export const SCHEMA_VERSION = "3";
60
+ export const SCHEMA_VERSION = "4";
53
61
  /** The stamped schema version, or null if the db predates versioning / has no meta table. */
54
62
  function readSchemaVersion(db) {
55
63
  try {
@@ -81,6 +89,7 @@ function isStale(db) {
81
89
  /** Drop every table (children first) so the current schema can be recreated cleanly. */
82
90
  function resetSchema(db) {
83
91
  db.exec(`
92
+ DROP TABLE IF EXISTS git_history_cache;
84
93
  DROP TABLE IF EXISTS node_embeddings;
85
94
  DROP TABLE IF EXISTS edges;
86
95
  DROP TABLE IF EXISTS nodes;
@@ -0,0 +1,65 @@
1
+ import { churn, coChanges, headSha, } from "../../shared/git-history.js";
2
+ import { openDb } from "./db.js";
3
+ /**
4
+ * Look up a cached payload valid at the current HEAD, or compute it and store it.
5
+ *
6
+ * When `head` is `null` (no commits / not a repo) the cache is bypassed entirely
7
+ * and `compute()` runs directly, so an empty repo never poisons the cache.
8
+ *
9
+ * @param projectPath - Project root, whose `.speclaw/index.db` holds the cache.
10
+ * @param head - The current HEAD SHA, or `null` when there is none.
11
+ * @param queryKey - Stable key identifying this query (function + options).
12
+ * @param compute - Produces the fresh result on a miss.
13
+ * @param serialize - Turns the result into a JSON-safe payload string.
14
+ * @param deserialize - Rebuilds the result from a stored payload string.
15
+ * @returns The cached-or-freshly-computed result.
16
+ */
17
+ function readThrough(projectPath, head, queryKey, compute, serialize, deserialize) {
18
+ if (head === null)
19
+ return compute();
20
+ const db = openDb(projectPath);
21
+ try {
22
+ const row = db
23
+ .prepare("SELECT head_sha, payload FROM git_history_cache WHERE query_key = ?")
24
+ .get(queryKey);
25
+ if (row && row.head_sha === head) {
26
+ return deserialize(row.payload);
27
+ }
28
+ const value = compute();
29
+ db.prepare(`INSERT INTO git_history_cache(query_key, head_sha, payload, computed_at)
30
+ VALUES (?, ?, ?, 0)
31
+ ON CONFLICT(query_key) DO UPDATE SET
32
+ head_sha = excluded.head_sha,
33
+ payload = excluded.payload,
34
+ computed_at = excluded.computed_at`).run(queryKey, head, serialize(value));
35
+ return value;
36
+ }
37
+ finally {
38
+ db.close();
39
+ }
40
+ }
41
+ /**
42
+ * {@link churn}, memoized in the Compass index until `HEAD` moves.
43
+ *
44
+ * @param projectPath - Project root to query.
45
+ * @param opts - Same options as {@link churn}.
46
+ * @returns Per-path change counts and the shallow marker, cached per HEAD.
47
+ */
48
+ export function cachedChurn(projectPath, opts = {}) {
49
+ const key = `churn:${JSON.stringify({ since: opts.since ?? null, pathspec: opts.pathspec ?? null })}`;
50
+ return readThrough(projectPath, headSha(projectPath), key, () => churn(projectPath, opts), (value) => JSON.stringify({ shallow: value.shallow, byPath: [...value.byPath] }), (payload) => {
51
+ const parsed = JSON.parse(payload);
52
+ return { shallow: parsed.shallow, byPath: new Map(parsed.byPath) };
53
+ });
54
+ }
55
+ /**
56
+ * {@link coChanges}, memoized in the Compass index until `HEAD` moves.
57
+ *
58
+ * @param projectPath - Project root to query.
59
+ * @param opts - Same options as {@link coChanges}.
60
+ * @returns The co-change pairs and the shallow marker, cached per HEAD.
61
+ */
62
+ export function cachedCoChanges(projectPath, opts = {}) {
63
+ const key = `coChanges:${JSON.stringify({ since: opts.since ?? null, minSupport: opts.minSupport ?? null })}`;
64
+ return readThrough(projectPath, headSha(projectPath), key, () => coChanges(projectPath, opts), (value) => JSON.stringify(value), (payload) => JSON.parse(payload));
65
+ }
@@ -0,0 +1,49 @@
1
+ {
2
+ "version": 1,
3
+ "laws": [
4
+ {
5
+ "id": "law~no-secrets-in-repo~1",
6
+ "title": "No secrets in the repository",
7
+ "rationale": "A committed secret is a leaked secret; enforcement at the keystroke is the only reliable guard.",
8
+ "severity": "error",
9
+ "scope": ["**/.env", "**/.env.*", "**/*.env"],
10
+ "prose": "Never write a .env file into the repository. Secrets live in the environment, not in version control.",
11
+ "verification": { "kind": "path" },
12
+ "enforcement": "bloqueo",
13
+ "source": { "file": "docs/standards/base-standards.md" }
14
+ },
15
+ {
16
+ "id": "law~local-first~1",
17
+ "title": "Local-first is non-negotiable",
18
+ "rationale": "speclaw runs entirely on the user's machine; every new dependency must be justified in the PR.",
19
+ "severity": "warn",
20
+ "scope": ["package.json"],
21
+ "prose": "Justify any new dependency in the PR: it must not break 'runs offline with no API keys'.",
22
+ "verification": { "kind": "path" },
23
+ "enforcement": "feedback",
24
+ "source": { "file": "LAWS.md" }
25
+ },
26
+ {
27
+ "id": "law~protect-templates~1",
28
+ "title": "Protect the templates",
29
+ "rationale": "Assets under src/modules/*/assets/** are product output; the build must copy them and the {{placeholder}} contracts must stay intact.",
30
+ "severity": "warn",
31
+ "scope": ["src/modules/*/assets/**"],
32
+ "prose": "Assets under src/modules/*/assets/** are product output — keep the {{placeholder}} and speclaw-init contracts intact, and let the build copy them (never hand-copy into dist/).",
33
+ "verification": { "kind": "path" },
34
+ "enforcement": "feedback",
35
+ "source": { "file": "LAWS.md" }
36
+ },
37
+ {
38
+ "id": "law~honest-attribution~1",
39
+ "title": "Keep attribution honest",
40
+ "rationale": "Compass reimplements CodeGraph and Lawbook reimplements OpenSpec (both MIT); ATTRIBUTION.md must stay accurate.",
41
+ "severity": "info",
42
+ "scope": ["ATTRIBUTION.md"],
43
+ "prose": "Keep ATTRIBUTION.md accurate as the Compass and Lawbook modules evolve.",
44
+ "verification": { "kind": "path" },
45
+ "enforcement": "feedback",
46
+ "source": { "file": "LAWS.md" }
47
+ }
48
+ ]
49
+ }
@@ -0,0 +1,144 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { performance } from "node:perf_hooks";
4
+ import { compileScope, hasBackend, manifestPath, matchCompiled, readLawManifest, } from "./laws.js";
5
+ const cache = new Map();
6
+ /**
7
+ * Load the project's compiled law index, using the in-process cache when the
8
+ * manifest is unchanged (keeps `PreToolUse` off the disk and off the regex
9
+ * compiler on the hot path). Returns null when the manifest is missing or
10
+ * unparseable, so the caller fails open.
11
+ */
12
+ function loadLaws(projectPath) {
13
+ let mtimeMs;
14
+ try {
15
+ mtimeMs = fs.statSync(manifestPath(projectPath)).mtimeMs;
16
+ }
17
+ catch {
18
+ cache.delete(projectPath);
19
+ return null;
20
+ }
21
+ const hit = cache.get(projectPath);
22
+ if (hit && hit.mtimeMs === mtimeMs)
23
+ return hit.laws;
24
+ const manifest = readLawManifest(projectPath);
25
+ if (!manifest)
26
+ return null;
27
+ const laws = manifest.laws.map((law) => ({ law, scope: compileScope(law.scope) }));
28
+ cache.set(projectPath, { mtimeMs, laws });
29
+ return laws;
30
+ }
31
+ /** Clear the manifest cache (used by tests and by a manifest rewrite in-process). */
32
+ export function clearLawCache() {
33
+ cache.clear();
34
+ }
35
+ /** POSIX-normalize a path and make it project-relative when it is absolute. */
36
+ function toRelPosix(projectPath, p) {
37
+ let rel = p;
38
+ if (path.isAbsolute(p))
39
+ rel = path.relative(projectPath, p);
40
+ return rel.split(path.sep).join("/").replace(/^\.\//, "");
41
+ }
42
+ /** Extract the file the action targets from a tool payload, across common shapes. */
43
+ function targetPath(projectPath, payload) {
44
+ const input = (payload.tool_input ?? payload.toolInput ?? payload);
45
+ const candidate = input.file_path ?? input.filePath ?? input.path ?? input.notebook_path ?? input.notebookPath;
46
+ return typeof candidate === "string" ? toRelPosix(projectPath, candidate) : null;
47
+ }
48
+ /** Extract the instructions file that was loaded, across common payload shapes. */
49
+ function loadedFile(projectPath, payload) {
50
+ const candidate = payload.file ?? payload.filePath ?? payload.path ?? payload.file_path;
51
+ return typeof candidate === "string" ? toRelPosix(projectPath, candidate) : null;
52
+ }
53
+ /** Format the citation for a matched law: id, source, and its literal prose. */
54
+ function cite(law) {
55
+ const at = law.source.line ? `${law.source.file}:${law.source.line}` : law.source.file;
56
+ return `${law.id} (${at}): «${law.prose}»`;
57
+ }
58
+ /** Append the ids of the laws a just-loaded instructions file declares to the context log. */
59
+ function recordContextCoverage(projectPath, laws, file) {
60
+ const ids = laws.filter((l) => (file ? l.source.file === file : true)).map((l) => l.id);
61
+ if (ids.length === 0)
62
+ return ids;
63
+ try {
64
+ const logDir = path.join(projectPath, ".speclaw");
65
+ fs.mkdirSync(logDir, { recursive: true });
66
+ const line = JSON.stringify({ at: new Date().toISOString(), file, lawIds: ids }) + "\n";
67
+ fs.appendFileSync(path.join(logDir, "context-log.jsonl"), line);
68
+ }
69
+ catch {
70
+ // Best-effort audit — never let a logging failure affect the verdict.
71
+ }
72
+ return ids;
73
+ }
74
+ /**
75
+ * Evaluate an agent action against every law whose scope matches its target and
76
+ * return an ACS-aligned verdict. Only the `path` backend is evaluated; laws with
77
+ * another (unimplemented) backend are skipped. The evaluator fails open: a
78
+ * missing or unparseable manifest, or any exception, yields `allow` with a
79
+ * diagnostic — an enforcement layer that blocks when its own checker crashes is
80
+ * worse than none.
81
+ *
82
+ * @param args - The project, hook event, optional tool name, and raw payload.
83
+ * @returns The verdict, the laws evaluated, an optional reason, and `elapsedMs`.
84
+ */
85
+ export function checkAction(args) {
86
+ const start = performance.now();
87
+ const done = (r) => ({
88
+ ...r,
89
+ elapsedMs: performance.now() - start,
90
+ });
91
+ try {
92
+ const laws = loadLaws(args.projectPath);
93
+ if (!laws) {
94
+ return done({
95
+ verdict: "allow",
96
+ evaluated: [],
97
+ diagnostic: "law manifest missing or unparseable — failing open",
98
+ });
99
+ }
100
+ if (args.event === "InstructionsLoaded") {
101
+ const ids = recordContextCoverage(args.projectPath, laws.map((il) => il.law), loadedFile(args.projectPath, args.payload));
102
+ return done({
103
+ verdict: "allow",
104
+ evaluated: ids.map((id) => ({ lawId: id, severity: "info", passed: true })),
105
+ });
106
+ }
107
+ const target = targetPath(args.projectPath, args.payload);
108
+ if (target === null)
109
+ return done({ verdict: "allow", evaluated: [] });
110
+ const matched = laws
111
+ .filter((il) => hasBackend(il.law) && matchCompiled(il.scope, target))
112
+ .map((il) => il.law);
113
+ const evaluated = matched.map((l) => ({
114
+ lawId: l.id,
115
+ severity: l.severity,
116
+ passed: l.enforcement !== "bloqueo" || args.event !== "PreToolUse",
117
+ message: cite(l),
118
+ file: target,
119
+ }));
120
+ // Only a `bloqueo` law, and only on PreToolUse, stops the keystroke. Every
121
+ // other match still enters context as a message the agent reads.
122
+ const blocking = args.event === "PreToolUse" ? matched.filter((l) => l.enforcement === "bloqueo") : [];
123
+ if (blocking.length > 0) {
124
+ return done({
125
+ verdict: "deny",
126
+ evaluated,
127
+ reason: `Blocked by ${blocking.map(cite).join("; ")}`,
128
+ });
129
+ }
130
+ const messages = evaluated.map((e) => e.message).filter(Boolean);
131
+ return done({
132
+ verdict: "allow",
133
+ evaluated,
134
+ reason: messages.length ? messages.join("; ") : undefined,
135
+ });
136
+ }
137
+ catch (err) {
138
+ return done({
139
+ verdict: "allow",
140
+ evaluated: [],
141
+ diagnostic: `check failed open: ${err.message}`,
142
+ });
143
+ }
144
+ }
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { AGENTS, detectConfiguredAgents } from "../../shared/agents.js";
3
+ import { AGENTS, agentById, detectConfiguredAgents } from "../../shared/agents.js";
4
+ import { globError, hasBackend, readLawManifest } from "./laws.js";
4
5
  /**
5
6
  * Run the speclaw installation health checks against a project: ai-specs and
6
7
  * LAWS.md presence, agent contracts, the docs/standards set, per-agent IDE
@@ -99,5 +100,95 @@ export function doctor(projectPath) {
99
100
  ok: has(".mcp.json"),
100
101
  detail: has(".mcp.json") ? "present" : "missing — scaffold writes it",
101
102
  });
103
+ lawEnforcementChecks(projectPath, checks);
102
104
  return checks;
103
105
  }
106
+ /** The law ids recorded as loaded into agent context, from the append-only log. */
107
+ function loadedLawIds(projectPath) {
108
+ const loaded = new Set();
109
+ try {
110
+ const log = fs.readFileSync(path.join(projectPath, ".speclaw", "context-log.jsonl"), "utf8");
111
+ for (const line of log.split(/\r?\n/)) {
112
+ if (!line.trim())
113
+ continue;
114
+ const ids = JSON.parse(line).lawIds ?? [];
115
+ for (const id of ids)
116
+ loaded.add(id);
117
+ }
118
+ }
119
+ catch {
120
+ // No log yet — hooks have not recorded any context loads.
121
+ }
122
+ return loaded;
123
+ }
124
+ /**
125
+ * Append the law-enforcement health checks: manifest presence and backend
126
+ * coverage, glob validity (caught here rather than at runtime), context-coverage
127
+ * with the post-compact caveat, and the agents where blocking laws don't apply.
128
+ */
129
+ function lawEnforcementChecks(projectPath, checks) {
130
+ const manifest = readLawManifest(projectPath);
131
+ if (!manifest) {
132
+ checks.push({
133
+ name: "law manifest",
134
+ ok: false,
135
+ detail: "missing — run `speclaw init`/`update` to seed .speclaw/laws-manifest.json",
136
+ });
137
+ return;
138
+ }
139
+ const withBackend = manifest.laws.filter(hasBackend);
140
+ const noBackend = manifest.laws.filter((l) => !hasBackend(l));
141
+ checks.push({
142
+ name: "law manifest",
143
+ ok: true,
144
+ detail: `${manifest.laws.length} law(s): ${withBackend.length} enforced (path)` +
145
+ (noBackend.length
146
+ ? `, ${noBackend.length} declared without a backend yet (${noBackend
147
+ .map((l) => l.id)
148
+ .join(", ")})`
149
+ : ""),
150
+ });
151
+ // Glob validation — a malformed scope must fail loudly here, never silently
152
+ // match zero files at runtime.
153
+ const badGlobs = [];
154
+ for (const law of manifest.laws) {
155
+ for (const pattern of law.scope) {
156
+ const err = globError(pattern);
157
+ if (err)
158
+ badGlobs.push(`${law.id}: ${pattern} (${err})`);
159
+ }
160
+ }
161
+ checks.push({
162
+ name: "law scope globs",
163
+ ok: badGlobs.length === 0,
164
+ detail: badGlobs.length === 0 ? "all valid" : `malformed: ${badGlobs.join("; ")}`,
165
+ });
166
+ // Context coverage — which laws actually entered the agent's context.
167
+ const loaded = loadedLawIds(projectPath);
168
+ const declared = manifest.laws.map((l) => l.id);
169
+ const missing = declared.filter((id) => !loaded.has(id));
170
+ checks.push({
171
+ name: "law context coverage",
172
+ ok: true,
173
+ detail: `${declared.length - missing.length} of ${declared.length} laws loaded into context` +
174
+ (missing.length ? ` — not yet loaded: ${missing.join(", ")}` : "") +
175
+ ". Note: after a compact, root CLAUDE.md is re-injected but `paths:`-scoped rules are not," +
176
+ " until a matching file is next touched — so a path-scoped law can be out of context" +
177
+ " exactly when it matters, which is why it is also a hook.",
178
+ });
179
+ // Agent asymmetry — where blocking laws cannot be enforced at the keystroke.
180
+ const configured = detectConfiguredAgents(projectPath);
181
+ const unhooked = configured
182
+ .map((id) => agentById(id))
183
+ .filter((a) => a && !a.hooks)
184
+ .map((a) => a.label);
185
+ if (unhooked.length) {
186
+ const blocking = manifest.laws.filter((l) => l.enforcement === "bloqueo").length;
187
+ checks.push({
188
+ name: "hook coverage across agents",
189
+ ok: true,
190
+ detail: `no hook support for ${unhooked.join(", ")} — your ${blocking} blocking law(s) apply ` +
191
+ "there only via `speclaw verify`.",
192
+ });
193
+ }
194
+ }
@@ -0,0 +1,165 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { agentById } from "../../shared/agents.js";
4
+ import { sha256 } from "../../shared/install.js";
5
+ import { globError, hasBackend } from "./laws.js";
6
+ /** The speclaw hook object — its `{type, server}` pair is the merge identity. */
7
+ const SPECLAW_HOOK = {
8
+ type: "mcp_tool",
9
+ server: "speclaw",
10
+ tool: "speclaw_check",
11
+ timeout: 5,
12
+ };
13
+ /** Tool-name matcher for the file-mutating tools the `path` backend can evaluate. */
14
+ const MUTATION_MATCHER = "Write|Edit|MultiEdit|NotebookEdit";
15
+ /** True when a hook object is one speclaw owns (safe to replace on merge). */
16
+ function isSpeclawHook(h) {
17
+ const o = h;
18
+ return o?.type === "mcp_tool" && o?.server === "speclaw";
19
+ }
20
+ /**
21
+ * Compile a law manifest into the hook groups speclaw contributes, one per event
22
+ * the laws demand: `PreToolUse` when any `bloqueo` law exists, `PostToolUse` for
23
+ * `feedback`, `Stop` for `gate`, and `InstructionsLoaded` whenever any law exists
24
+ * (the context-coverage audit). A law whose scope contains a malformed glob is
25
+ * excluded and reported, so a bad pattern fails loudly at generation rather than
26
+ * silently matching nothing at runtime.
27
+ *
28
+ * @param manifest - The project's law manifest.
29
+ * @returns The per-event hook groups and the list of laws rejected for bad globs.
30
+ */
31
+ export function compileHooks(manifest) {
32
+ const invalid = [];
33
+ const valid = [];
34
+ for (const law of manifest.laws) {
35
+ const bad = law.scope.map((p) => ({ p, e: globError(p) })).find((x) => x.e);
36
+ if (bad)
37
+ invalid.push({ lawId: law.id, pattern: bad.p, error: bad.e });
38
+ else
39
+ valid.push(law);
40
+ }
41
+ const byEvent = {};
42
+ const hasBloqueo = valid.some((l) => l.enforcement === "bloqueo" && hasBackend(l));
43
+ const hasFeedback = valid.some((l) => l.enforcement === "feedback" && hasBackend(l));
44
+ const hasGate = valid.some((l) => l.enforcement === "gate");
45
+ if (hasBloqueo)
46
+ byEvent.PreToolUse = [{ matcher: MUTATION_MATCHER, hooks: [{ ...SPECLAW_HOOK }] }];
47
+ if (hasFeedback)
48
+ byEvent.PostToolUse = [{ matcher: MUTATION_MATCHER, hooks: [{ ...SPECLAW_HOOK }] }];
49
+ if (hasGate)
50
+ byEvent.Stop = [{ hooks: [{ ...SPECLAW_HOOK }] }];
51
+ if (valid.length > 0)
52
+ byEvent.InstructionsLoaded = [{ hooks: [{ ...SPECLAW_HOOK }] }];
53
+ return { byEvent, invalid };
54
+ }
55
+ /**
56
+ * Merge speclaw's compiled hook groups into an existing `hooks` object by
57
+ * identity: for every event, drop the groups speclaw owns (a group whose hooks
58
+ * are all speclaw's) and re-add the freshly compiled ones, never touching a
59
+ * group with a foreign `server` or `type`. Idempotent, marker-free, and it
60
+ * cannot delete another tool's hooks.
61
+ *
62
+ * @param existing - The current `hooks` object from the agent's settings (any shape).
63
+ * @param compiled - speclaw's per-event hook groups from {@link compileHooks}.
64
+ * @returns A new `hooks` object with speclaw's entries reconciled in.
65
+ */
66
+ export function mergeHooks(existing, compiled) {
67
+ const out = {};
68
+ const events = new Set([...Object.keys(existing ?? {}), ...Object.keys(compiled)]);
69
+ for (const event of events) {
70
+ const prior = Array.isArray(existing?.[event]) ? existing[event] : [];
71
+ // Keep foreign groups: drop speclaw hooks from each group, then any group left empty.
72
+ const kept = prior
73
+ .map((g) => ({ ...g, hooks: (g.hooks ?? []).filter((h) => !isSpeclawHook(h)) }))
74
+ .filter((g) => g.hooks.length > 0);
75
+ const mine = compiled[event] ?? [];
76
+ const merged = [...kept, ...mine];
77
+ if (merged.length > 0)
78
+ out[event] = merged;
79
+ }
80
+ return out;
81
+ }
82
+ /**
83
+ * Install (or refresh) speclaw's hooks into one agent's settings file, merging by
84
+ * identity and honoring the managed-file baseline: a settings file that diverged
85
+ * from what speclaw last wrote is backed up to `<file>.bak` first when `backup`
86
+ * is set, and always reported. The baseline sha of the written file is recorded.
87
+ *
88
+ * @param projectPath - Project root.
89
+ * @param agent - The agent whose `hooks` capability names the settings file and key.
90
+ * @param compiled - speclaw's compiled hook groups.
91
+ * @param report - Install report mutated in place.
92
+ * @param opts - Managed-file behavior: recorded baselines, backup, and a record sink.
93
+ */
94
+ function installForAgent(projectPath, agent, compiled, report, opts) {
95
+ if (!agent.hooks)
96
+ return;
97
+ const settingsPath = path.join(projectPath, agent.hooks.file);
98
+ const rel = path.relative(projectPath, settingsPath);
99
+ let settings = {};
100
+ let current = null;
101
+ if (fs.existsSync(settingsPath)) {
102
+ current = fs.readFileSync(settingsPath, "utf8");
103
+ try {
104
+ settings = JSON.parse(current);
105
+ }
106
+ catch {
107
+ // A settings file we cannot parse is the user's — never clobber it silently.
108
+ report.skipped.push(`${settingsPath} (unparseable — left untouched)`);
109
+ return;
110
+ }
111
+ }
112
+ settings[agent.hooks.key] = mergeHooks(settings[agent.hooks.key], compiled);
113
+ const content = JSON.stringify(settings, null, 2) + "\n";
114
+ const newSha = sha256(content);
115
+ if (current !== null) {
116
+ if (sha256(current) === newSha) {
117
+ if (opts.record)
118
+ opts.record[rel] = newSha;
119
+ return; // already current — no drift
120
+ }
121
+ const baseline = opts.baselines?.[rel];
122
+ if (!baseline || sha256(current) !== baseline) {
123
+ if (opts.backup) {
124
+ fs.copyFileSync(settingsPath, settingsPath + ".bak");
125
+ report.backedUp.push(settingsPath);
126
+ }
127
+ report.refreshedDiverged.push(settingsPath);
128
+ }
129
+ }
130
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
131
+ fs.writeFileSync(settingsPath, content);
132
+ report.written.push(`${settingsPath} (speclaw hooks)`);
133
+ if (opts.record)
134
+ opts.record[rel] = newSha;
135
+ }
136
+ /**
137
+ * Compile the manifest and install speclaw's hooks into every hook-capable agent
138
+ * among those selected, skipping agents without a `hooks` capability (Cursor,
139
+ * Codex, Windsurf) by construction. A malformed glob excludes only that law and
140
+ * is surfaced in the result.
141
+ *
142
+ * @param projectPath - Project root.
143
+ * @param agentIds - Ids of the agents configured for this project.
144
+ * @param manifest - The project's law manifest.
145
+ * @param report - Install report mutated in place.
146
+ * @param opts - Managed-file behavior: recorded baselines, backup, and a record sink.
147
+ * @returns Which agents were hooked, which were skipped, and any rejected laws.
148
+ */
149
+ export function installHooks(projectPath, agentIds, manifest, report, opts) {
150
+ const { byEvent, invalid } = compileHooks(manifest);
151
+ const hooked = [];
152
+ const unhooked = [];
153
+ for (const id of agentIds) {
154
+ const agent = agentById(id);
155
+ if (!agent)
156
+ continue;
157
+ if (!agent.hooks) {
158
+ unhooked.push(id);
159
+ continue;
160
+ }
161
+ installForAgent(projectPath, agent, byEvent, report, opts);
162
+ hooked.push(id);
163
+ }
164
+ return { hooked, unhooked, invalid };
165
+ }
@@ -0,0 +1,211 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { z } from "zod";
4
+ import { assetsDir } from "../../shared/paths.js";
5
+ // The machine-readable law model and its manifest. This is the contract seam
6
+ // (`.speclaw/laws-manifest.json`) between where laws come from and how they are
7
+ // enforced: check-dispatcher owns the schema and the single `path` verification
8
+ // backend; executable-laws extends the same model with `ast`/`deps`/`process`
9
+ // backends by filling in more `verification.kind` cases — it never rewrites it.
10
+ const ASSETS = assetsDir(import.meta.url);
11
+ const lawSchema = z.object({
12
+ id: z.string().min(1),
13
+ title: z.string().min(1),
14
+ rationale: z.string().optional(),
15
+ severity: z.enum(["error", "warn", "info"]),
16
+ scope: z.array(z.string()),
17
+ prose: z.string().min(1),
18
+ verification: z.object({
19
+ kind: z.enum(["path", "ast", "graph", "deps", "process", "traceability", "semantic", "none"]),
20
+ }),
21
+ enforcement: z.enum(["bloqueo", "feedback", "gate"]),
22
+ source: z.object({ file: z.string(), line: z.number().optional() }),
23
+ });
24
+ const manifestSchema = z.object({
25
+ version: z.number(),
26
+ laws: z.array(lawSchema),
27
+ });
28
+ /** The verification backends this change actually evaluates at runtime. */
29
+ export const IMPLEMENTED_BACKENDS = ["path"];
30
+ /** True when a law's verification backend is evaluated at runtime (only `path` today). */
31
+ export function hasBackend(law) {
32
+ return IMPLEMENTED_BACKENDS.includes(law.verification.kind);
33
+ }
34
+ /** Absolute path to a project's compiled law manifest (under the gitignored `.speclaw/`). */
35
+ export function manifestPath(projectPath) {
36
+ return path.join(projectPath, ".speclaw", "laws-manifest.json");
37
+ }
38
+ /**
39
+ * Read and validate a project's law manifest.
40
+ *
41
+ * @param projectPath - Project root to read from.
42
+ * @returns The parsed manifest, or null if it is missing or unparseable (the
43
+ * caller fails open — a broken manifest never blocks the agent).
44
+ */
45
+ export function readLawManifest(projectPath) {
46
+ try {
47
+ const raw = JSON.parse(fs.readFileSync(manifestPath(projectPath), "utf8"));
48
+ return manifestSchema.parse(raw);
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ /**
55
+ * Write a project's law manifest, validating every law first.
56
+ *
57
+ * @param projectPath - Project root to write into.
58
+ * @param manifest - The manifest to persist; each law is schema-validated.
59
+ * @throws If any law fails validation.
60
+ */
61
+ export function writeLawManifest(projectPath, manifest) {
62
+ const validated = manifestSchema.parse(manifest);
63
+ const p = manifestPath(projectPath);
64
+ fs.mkdirSync(path.dirname(p), { recursive: true });
65
+ fs.writeFileSync(p, JSON.stringify(validated, null, 2) + "\n");
66
+ }
67
+ /**
68
+ * The starter law manifest shipped with speclaw, seeded from a speclaw-style
69
+ * project's own `path`-verifiable Project-specific laws. It is the source the
70
+ * MVP compiles into `.speclaw/laws-manifest.json`; once executable-laws lands,
71
+ * laws are authored in `docs/standards/*` and compiled here instead. Laws whose
72
+ * scope does not match a given repo are simply inert there.
73
+ *
74
+ * @returns The validated seed manifest read from the module's assets.
75
+ * @throws If the seed asset is missing or fails validation.
76
+ */
77
+ export function seedManifest() {
78
+ const raw = JSON.parse(fs.readFileSync(path.join(ASSETS, "laws", "laws-manifest.json"), "utf8"));
79
+ return manifestSchema.parse(raw);
80
+ }
81
+ // ─── Glob matching (the `path` backend) ──────────────────────────────────────
82
+ /**
83
+ * Validate a scope glob without compiling it for use, so generation can fail
84
+ * loudly on a malformed pattern (e.g. an unclosed `[`) rather than silently
85
+ * matching zero files at runtime.
86
+ *
87
+ * @param pattern - A single scope glob (a leading `!` negation is allowed).
88
+ * @returns An error message if the glob is malformed, else null.
89
+ */
90
+ export function globError(pattern) {
91
+ try {
92
+ compileGlob(pattern);
93
+ return null;
94
+ }
95
+ catch (err) {
96
+ return err.message;
97
+ }
98
+ }
99
+ /** Regex-special characters that are literals in a glob and must be escaped. */
100
+ const REGEX_SPECIALS = new Set([".", "+", "^", "$", "(", ")", "|", "\\"]);
101
+ /**
102
+ * Compile a glob into an anchored regular expression matching a POSIX-style
103
+ * relative path. Supports `**` (any run of segments), `*`/`?` (within a
104
+ * segment), `{a,b}` alternation, and `[...]`/`[!...]` character classes.
105
+ *
106
+ * @param pattern - A single scope glob; a leading `!` is stripped by the caller.
107
+ * @returns A `RegExp` anchored to the whole path.
108
+ * @throws If the glob contains an unclosed `[` or `{`.
109
+ */
110
+ export function compileGlob(pattern) {
111
+ let re = "";
112
+ let braceDepth = 0;
113
+ for (let i = 0; i < pattern.length; i++) {
114
+ const ch = pattern[i];
115
+ if (ch === "*") {
116
+ if (pattern[i + 1] === "*") {
117
+ // `**` (optionally followed by `/`) spans any number of segments.
118
+ i++;
119
+ if (pattern[i + 1] === "/")
120
+ i++;
121
+ re += "(?:[^/]*(?:/|$))*";
122
+ }
123
+ else {
124
+ re += "[^/]*";
125
+ }
126
+ }
127
+ else if (ch === "?") {
128
+ re += "[^/]";
129
+ }
130
+ else if (ch === "{") {
131
+ braceDepth++;
132
+ re += "(?:";
133
+ }
134
+ else if (ch === "}") {
135
+ if (braceDepth === 0)
136
+ throw new Error(`unmatched '}' in glob: ${pattern}`);
137
+ braceDepth--;
138
+ re += ")";
139
+ }
140
+ else if (ch === "," && braceDepth > 0) {
141
+ re += "|";
142
+ }
143
+ else if (ch === "[") {
144
+ const close = pattern.indexOf("]", i + 1);
145
+ if (close === -1)
146
+ throw new Error(`unclosed '[' in glob: ${pattern}`);
147
+ let cls = pattern.slice(i + 1, close);
148
+ if (cls.startsWith("!"))
149
+ cls = "^" + cls.slice(1);
150
+ re += `[${cls}]`;
151
+ i = close;
152
+ }
153
+ else if (REGEX_SPECIALS.has(ch)) {
154
+ re += "\\" + ch;
155
+ }
156
+ else {
157
+ re += ch;
158
+ }
159
+ }
160
+ if (braceDepth !== 0)
161
+ throw new Error(`unclosed '{' in glob: ${pattern}`);
162
+ return new RegExp(`^${re}$`);
163
+ }
164
+ /**
165
+ * Compile a law's scope globs into regexes once, so runtime matching does no
166
+ * regex compilation on the critical path. Malformed globs are dropped (they are
167
+ * caught and reported at generation time).
168
+ *
169
+ * @param scope - The law's scope globs.
170
+ * @returns The compiled positive and negative matchers.
171
+ */
172
+ export function compileScope(scope) {
173
+ const positives = [];
174
+ const negatives = [];
175
+ for (const g of scope) {
176
+ const negated = g.startsWith("!");
177
+ try {
178
+ (negated ? negatives : positives).push(compileGlob(negated ? g.slice(1) : g));
179
+ }
180
+ catch {
181
+ // Skip malformed globs — generation already flagged them.
182
+ }
183
+ }
184
+ return { matchAll: scope.length === 0, positives, negatives };
185
+ }
186
+ /**
187
+ * Test a target path against a pre-compiled scope. Positive globs are OR-ed; a
188
+ * `!`-prefixed glob excludes; an empty scope matches everything.
189
+ *
190
+ * @param compiled - The scope compiled by {@link compileScope}.
191
+ * @param target - A POSIX-style project-relative path (forward slashes).
192
+ * @returns True when the target is in scope.
193
+ */
194
+ export function matchCompiled(compiled, target) {
195
+ const included = compiled.matchAll ||
196
+ compiled.positives.length === 0 ||
197
+ compiled.positives.some((r) => r.test(target));
198
+ return included && !compiled.negatives.some((r) => r.test(target));
199
+ }
200
+ /**
201
+ * Test whether a target path matches a law's scope, compiling on the spot.
202
+ * Convenience for non-hot paths (doctor, dry-run); the evaluator uses
203
+ * {@link compileScope} + {@link matchCompiled} to stay off the compiler.
204
+ *
205
+ * @param scope - The law's scope globs.
206
+ * @param target - A POSIX-style project-relative path (forward slashes).
207
+ * @returns True when the target is in scope. Malformed globs never match.
208
+ */
209
+ export function matchesScope(scope, target) {
210
+ return matchCompiled(compileScope(scope), target);
211
+ }
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { text } from "../../shared/mcp.js";
3
3
  import { scaffold } from "./scaffold.js";
4
4
  import { doctor } from "./doctor.js";
5
+ import { checkAction } from "./check.js";
5
6
  import { loadPacks } from "../tools/packs.js";
6
7
  import { AGENTS, configureAgent } from "../../shared/agents.js";
7
8
  import { emptyReport } from "../../shared/install.js";
@@ -116,6 +117,18 @@ export function registerFoundation(server) {
116
117
  configureAgent(projectPath, agent, report);
117
118
  return text(report);
118
119
  });
120
+ server.registerTool("speclaw_check", {
121
+ // ≤12 words: this is invoked by speclaw's hooks, never called directly.
122
+ description: "Invoked by speclaw's hooks to enforce laws — do not call directly.",
123
+ inputSchema: {
124
+ projectPath: z.string().describe("Absolute path to the project"),
125
+ event: z
126
+ .enum(["PreToolUse", "PostToolUse", "Stop", "InstructionsLoaded"])
127
+ .describe("The hook event that fired"),
128
+ toolName: z.string().optional().describe("The tool the agent is invoking, when relevant"),
129
+ payload: z.record(z.unknown()).describe("The raw hook event payload from the agent"),
130
+ },
131
+ }, async ({ projectPath, event, toolName, payload }) => text(checkAction({ projectPath, event: event, toolName, payload })));
119
132
  server.registerTool("doctor", {
120
133
  description: "Verify a speclaw installation: ai-specs presence, the foundation (LAWS.md + standards + agent contracts), IDE symlinks health, the lawbook/ workflow, the Compass index, and .mcp.json wiring. Returns a checklist with remediation hints.",
121
134
  inputSchema: { projectPath: z.string().describe("Absolute path to the project") },
@@ -8,6 +8,8 @@ import { installWorkflow } from "../lawbook/register.js";
8
8
  import { installPack, loadPacks } from "../tools/packs.js";
9
9
  import { readManifest, writeManifest } from "../../shared/manifest.js";
10
10
  import { pkgVersion } from "../../shared/version.js";
11
+ import { readLawManifest, seedManifest, writeLawManifest } from "./laws.js";
12
+ import { installHooks } from "./hooks.js";
11
13
  const ASSETS = assetsDir(import.meta.url);
12
14
  // Every {{var}} the foundation templates may reference. Ones the agent didn't
13
15
  // provide default to empty so a bare `scaffold` never leaves a raw {{tag}}.
@@ -22,6 +24,21 @@ const FOUNDATION_DEFAULTS = {
22
24
  versioning_rules: "",
23
25
  documentation_extra: "",
24
26
  };
27
+ /**
28
+ * Ensure the project has a law manifest, seeding it from the package's starter
29
+ * laws when absent. The manifest is a derived artifact under the gitignored
30
+ * `.speclaw/`; seeding only when missing keeps a curated manifest (the MVP's
31
+ * authoring surface until executable-laws) from being overwritten on update.
32
+ */
33
+ function ensureLawManifest(projectPath, report) {
34
+ const existing = readLawManifest(projectPath);
35
+ if (existing)
36
+ return existing;
37
+ const seed = seedManifest();
38
+ writeLawManifest(projectPath, seed);
39
+ report.written.push(path.join(projectPath, ".speclaw", "laws-manifest.json"));
40
+ return seed;
41
+ }
25
42
  /**
26
43
  * Render the foundation: walk the module's assets/, mirror its structure into
27
44
  * the project, stripping the `.template` marker (foo.template.md -> foo.md).
@@ -110,6 +127,15 @@ export function scaffold(projectPath, profile, packNames, agents = [], opts = {}
110
127
  ensureGitignore(projectPath, "ai-specs/", "speclaw workflow content (regenerated by init/update; never commit)", report);
111
128
  for (const id of agents)
112
129
  configureAgent(projectPath, id, report); // only the chosen agents
130
+ // Compile the declared laws into agent hooks for every hook-capable agent just
131
+ // configured. The seam is the manifest: check-dispatcher enforces `path` laws;
132
+ // executable-laws will extend the same manifest with more backends.
133
+ const lawManifest = ensureLawManifest(projectPath, report);
134
+ report.hooks = installHooks(projectPath, agents, lawManifest, report, {
135
+ baselines: managedOpts.baselines,
136
+ backup: managedOpts.backup,
137
+ record,
138
+ });
113
139
  // Record what was installed so `speclaw update` can re-apply these packs and
114
140
  // gate feature migrations by version, plus the managed-file baselines that let
115
141
  // a later update tell user edits from stale files.
@@ -9,6 +9,7 @@ export const AGENTS = [
9
9
  ideDir: ".claude",
10
10
  linkTargets: ["skills", "commands", "agents"],
11
11
  mcpFile: ".mcp.json",
12
+ hooks: { file: ".claude/settings.json", key: "hooks" },
12
13
  },
13
14
  {
14
15
  id: "cursor",
@@ -0,0 +1,204 @@
1
+ import { spawnSync } from "node:child_process";
2
+ /** ASCII NUL — the record/field separator git emits with `-z` and we request via `%x00`. */
3
+ const NUL = "\0";
4
+ /**
5
+ * Run git in `projectPath` and return stdout, or `null` on any failure.
6
+ *
7
+ * Best-effort like {@link isGitRepo}: git missing, not a repo, or a non-zero
8
+ * exit all yield `null` so callers can fail soft rather than throw.
9
+ */
10
+ function git(projectPath, args) {
11
+ // `core.quotePath=false` keeps non-ASCII paths as raw UTF-8 instead of git's
12
+ // default octal-escaped, double-quoted form — so our path parsing stays exact.
13
+ const res = spawnSync("git", ["-C", projectPath, "-c", "core.quotePath=false", ...args], {
14
+ encoding: "utf8",
15
+ maxBuffer: 64 * 1024 * 1024,
16
+ });
17
+ if (res.status !== 0 || typeof res.stdout !== "string")
18
+ return null;
19
+ return res.stdout;
20
+ }
21
+ /** Parse a `--numstat` count field: `-` (binary) becomes `0`, anything non-numeric too. */
22
+ function numstat(field) {
23
+ if (field === "-")
24
+ return 0;
25
+ const n = Number.parseInt(field, 10);
26
+ return Number.isFinite(n) ? n : 0;
27
+ }
28
+ /**
29
+ * The current `HEAD` commit SHA, or `null` when the repo has no commits yet
30
+ * (or is not a repo / git is unavailable).
31
+ *
32
+ * @param projectPath - Directory inside the work tree to query.
33
+ * @returns The 40-char SHA, or `null`.
34
+ */
35
+ export function headSha(projectPath) {
36
+ const out = git(projectPath, ["rev-parse", "HEAD"]);
37
+ const sha = out?.trim();
38
+ return sha ? sha : null;
39
+ }
40
+ /**
41
+ * Whether `projectPath` is inside a shallow clone (e.g. CI's `--depth=1`), where
42
+ * history is truncated and change/coupling counts would be misleadingly low.
43
+ *
44
+ * @param projectPath - Directory inside the work tree to query.
45
+ * @returns `true` only when git reports the repository is shallow.
46
+ */
47
+ export function isShallowRepo(projectPath) {
48
+ const out = git(projectPath, ["rev-parse", "--is-shallow-repository"]);
49
+ return out?.trim() === "true";
50
+ }
51
+ /**
52
+ * The commits that touched `relPath`, most-recent first, with the line churn
53
+ * each introduced there.
54
+ *
55
+ * Fail-soft: a path with no history, a repo with no commits, or an unavailable
56
+ * git binary all yield an empty list. Records are parsed over NUL separators, so
57
+ * paths containing spaces or unicode are handled correctly.
58
+ *
59
+ * `since`/`until` bound the history as a **revision range** (`since..until`,
60
+ * `until` defaulting to `HEAD`) — the exact, deterministic form drift needs
61
+ * (`<archived-sha>..HEAD`), not an approximate date window. `since` is exclusive.
62
+ *
63
+ * @param projectPath - Project root to query.
64
+ * @param relPath - Project-relative path whose history to read.
65
+ * @param opts - Optional revision bounds: `since` (exclusive lower bound) and
66
+ * `until` (upper bound, default `HEAD`) — any revision git accepts, e.g. a SHA.
67
+ * @returns The touching commits, newest first; empty when there is no history.
68
+ */
69
+ export function logForPath(projectPath, relPath, opts = {}) {
70
+ const range = [];
71
+ if (opts.since)
72
+ range.push(`${opts.since}..${opts.until ?? "HEAD"}`);
73
+ else if (opts.until)
74
+ range.push(opts.until);
75
+ // Per commit: <sha>\0<ts>\0 then one numstat line per file it touched.
76
+ const out = git(projectPath, [
77
+ "log",
78
+ "--format=%x00%H%x00%ct%x00",
79
+ "--numstat",
80
+ ...range,
81
+ "--",
82
+ relPath,
83
+ ]);
84
+ if (out === null)
85
+ return [];
86
+ const touches = [];
87
+ // The stream is a sequence of "\0<sha>\0<ts>\0<numstat lines>" per commit.
88
+ const records = out.split(NUL);
89
+ // records[0] is empty (leading NUL); then repeating [sha, ts, tail...] where
90
+ // `tail` holds the numstat lines for that commit up to the next leading NUL.
91
+ for (let i = 1; i + 1 < records.length; i += 3) {
92
+ const sha = records[i]?.trim();
93
+ const ts = Number.parseInt(records[i + 1] ?? "", 10);
94
+ const tail = records[i + 2] ?? "";
95
+ if (!sha || !Number.isFinite(ts))
96
+ continue;
97
+ let added = 0;
98
+ let deleted = 0;
99
+ for (const line of tail.split("\n")) {
100
+ const cols = line.split("\t");
101
+ if (cols.length < 3)
102
+ continue;
103
+ added += numstat(cols[0]);
104
+ deleted += numstat(cols[1]);
105
+ }
106
+ touches.push({ sha, ts, added, deleted });
107
+ }
108
+ return touches;
109
+ }
110
+ /**
111
+ * How many commits touched each file in the window, summed from `--numstat`.
112
+ *
113
+ * Fail-soft: yields an empty map on any git failure. Does not follow renames —
114
+ * a renamed file is counted under its path as it appears in each commit (a safe
115
+ * superset, not a precise lineage). The result carries the {@link isShallowRepo}
116
+ * marker so consumers can degrade to "insufficient data" on a shallow clone.
117
+ *
118
+ * @param projectPath - Project root to query.
119
+ * @param opts - Optional `since` window (any date/revision git accepts) and a
120
+ * `pathspec` list to restrict which paths are considered.
121
+ * @returns Per-path change counts and the shallow marker.
122
+ */
123
+ export function churn(projectPath, opts = {}) {
124
+ const shallow = isShallowRepo(projectPath);
125
+ const args = ["log", "--numstat", "--format=%x00"];
126
+ if (opts.since)
127
+ args.push(`--since=${opts.since}`);
128
+ if (opts.pathspec && opts.pathspec.length > 0)
129
+ args.push("--", ...opts.pathspec);
130
+ const out = git(projectPath, args);
131
+ const byPath = new Map();
132
+ if (out === null)
133
+ return { shallow, byPath };
134
+ for (const line of out.split("\n")) {
135
+ // Numstat rows are "<added>\t<deleted>\t<path>"; the %x00 format lines and
136
+ // blank lines have no tabs and are skipped.
137
+ const cols = line.split("\t");
138
+ if (cols.length < 3)
139
+ continue;
140
+ const path = cols[2].replace(/^\0+/, "").trim();
141
+ if (!path)
142
+ continue;
143
+ byPath.set(path, (byPath.get(path) ?? 0) + 1);
144
+ }
145
+ return { shallow, byPath };
146
+ }
147
+ /**
148
+ * For every pair of files that changed together, how many commits touched both.
149
+ *
150
+ * Groups each commit's changed files and emits a count per unordered pair. Pairs
151
+ * with fewer than `minSupport` shared commits are omitted. Fail-soft (empty on
152
+ * git failure) and does not follow renames. Carries the shallow marker.
153
+ *
154
+ * @param projectPath - Project root to query.
155
+ * @param opts - Optional `since` window and `minSupport` threshold (default `1`).
156
+ * @returns The qualifying co-change pairs and the shallow marker.
157
+ */
158
+ export function coChanges(projectPath, opts = {}) {
159
+ const shallow = isShallowRepo(projectPath);
160
+ const minSupport = opts.minSupport ?? 1;
161
+ const args = ["log", "--name-only", "--format=%x00"];
162
+ if (opts.since)
163
+ args.push(`--since=${opts.since}`);
164
+ const out = git(projectPath, args);
165
+ if (out === null)
166
+ return { shallow, pairs: [] };
167
+ const counts = new Map();
168
+ // Each commit's file list is the run of lines between two %x00 markers.
169
+ for (const commitBlock of out.split(NUL)) {
170
+ const files = commitBlock
171
+ .split("\n")
172
+ .map((l) => l.trim())
173
+ .filter((l) => l.length > 0);
174
+ const unique = [...new Set(files)].sort();
175
+ for (let i = 0; i < unique.length; i++) {
176
+ for (let j = i + 1; j < unique.length; j++) {
177
+ const key = `${unique[i]}\t${unique[j]}`;
178
+ counts.set(key, (counts.get(key) ?? 0) + 1);
179
+ }
180
+ }
181
+ }
182
+ const pairs = [];
183
+ for (const [key, count] of counts) {
184
+ if (count < minSupport)
185
+ continue;
186
+ const [a, b] = key.split("\t");
187
+ pairs.push({ a: a, b: b, count });
188
+ }
189
+ pairs.sort((x, y) => y.count - x.count || x.a.localeCompare(y.a) || x.b.localeCompare(y.b));
190
+ return { shallow, pairs };
191
+ }
192
+ /**
193
+ * The SHA of the most recent commit that touched `relPath`, or `null` when the
194
+ * path has no history (or on any git failure).
195
+ *
196
+ * @param projectPath - Project root to query.
197
+ * @param relPath - Project-relative path.
198
+ * @returns The last-touching commit SHA, or `null`.
199
+ */
200
+ export function lastTouch(projectPath, relPath) {
201
+ const out = git(projectPath, ["log", "-1", "--format=%H", "--", relPath]);
202
+ const sha = out?.trim();
203
+ return sha ? sha : null;
204
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },