@wardrail/plugin 0.1.5 → 0.2.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "wardrail",
3
3
  "displayName": "Wardrail",
4
- "version": "0.1.0",
4
+ "version": "0.2.0",
5
5
  "description": "Keep your coding agent on the rails: consult your project's Wardrail contract while it writes code, plus a checkpoint->clear->resume workflow with checkpoints that are machine-verified, not self-reported.",
6
6
  "author": { "name": "Ghostables Ltd", "url": "https://wardrail.ghostables.io" },
7
7
  "homepage": "https://wardrail.ghostables.io",
package/README.md CHANGED
@@ -10,12 +10,17 @@ The **Wardrail plugin for Claude Code**. One install gives your coding agent two
10
10
  toolset: consult your project's guardrails before editing, pull the latest security findings, get
11
11
  an independent verdict on a diff, and **query the code graph** (who calls what, blast radius)
12
12
  instead of reading files. The agent stays on the rails *before* a violation lands.
13
- 2. **A context-saving workflow** — `/task` and `/checkpoint` slash commands, a SessionStart resume
14
- listing, and a machine **verifier** so a checkpoint *can't lie*: it reconciles a task file's
15
- claims against `git`, a real test run, and a diff scan before it can be marked done.
13
+ 2. **A context-saving workflow that makes `/compact` safe** — `/task` and `/checkpoint` commands, plus
14
+ hooks that wrap compaction itself: the moment Claude compacts (manual **or** auto), a `PreCompact`
15
+ hook captures verified **ground truth** to disk (branch, commits, working tree, your task's next
16
+ step), and `SessionStart` re-injects it afterwards — so the compacted session trusts reality, not
17
+ a lossy summary. A machine **verifier** means a checkpoint *can't lie*: it reconciles a task
18
+ file's claims against `git`, a real test run, a typecheck, and a diff scan before it can be marked
19
+ done.
16
20
 
17
21
  The same anti-drift, verify-don't-trust thesis Wardrail applies to your *code*, turned on the
18
- agent's own working memory. Zero-knowledge: your key and code stay on your machine.
22
+ agent's own working memory. `/compact` summarizes and hopes; this re-grounds every compaction in
23
+ checked facts. Zero-knowledge: your key and code stay on your machine.
19
24
 
20
25
  ## Install
21
26
 
@@ -47,9 +52,10 @@ fails its checks is downgraded to FAIL — so "done" actually means done.
47
52
  .claude-plugin/plugin.json manifest + setup prompts
48
53
  skills/task/SKILL.md /task new|resume <slug>
49
54
  skills/checkpoint/SKILL.md /checkpoint (runs the verifier)
50
- hooks/hooks.json SessionStart resume listing
51
- hooks/tasks-session-start.mjs
52
- hooks/verify-checkpoint.mjs the machine verifier
55
+ hooks/hooks.json SessionStart + PreCompact wiring
56
+ hooks/tasks-session-start.mjs resume listing, repo reality + blast radius, post-compact re-inject
57
+ hooks/precompact-snapshot.mjs captures verified ground truth before any compaction
58
+ hooks/verify-checkpoint.mjs the machine verifier (git · tests · typecheck · honesty)
53
59
  .mcp.json the Wardrail MCP server (npx -y @wardrail/mcp)
54
60
  ```
55
61
 
package/hooks/hooks.json CHANGED
@@ -1,15 +1,26 @@
1
- {
2
- "hooks": {
3
- "SessionStart": [
4
- {
5
- "hooks": [
6
- {
7
- "type": "command",
8
- "command": "node",
9
- "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/tasks-session-start.mjs"]
10
- }
11
- ]
12
- }
13
- ]
14
- }
15
- }
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "node",
9
+ "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/tasks-session-start.mjs"]
10
+ }
11
+ ]
12
+ }
13
+ ],
14
+ "PreCompact": [
15
+ {
16
+ "hooks": [
17
+ {
18
+ "type": "command",
19
+ "command": "node",
20
+ "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/precompact-snapshot.mjs"]
21
+ }
22
+ ]
23
+ }
24
+ ]
25
+ }
26
+ }
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ // PreCompact hook — capture verified ground truth the moment before Claude compacts context
3
+ // (manual /compact OR automatic when the window fills). A hook can't author intent, but it CAN
4
+ // snapshot reality: branch, recent commits, working-tree state, diff stat, and the active task's
5
+ // next step. That's exactly what /compact's lossy summary gets wrong. The SessionStart hook
6
+ // (source: compact) re-injects this file afterwards, so the compacted session trusts reality over
7
+ // the summary. Pure side effect — writes .wardrail/last-checkpoint.md, prints nothing, never throws.
8
+
9
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from "node:fs";
10
+ import { execFileSync } from "node:child_process";
11
+ import { join } from "node:path";
12
+
13
+ function readStdin() {
14
+ try {
15
+ return readFileSync(0, "utf8");
16
+ } catch {
17
+ return "";
18
+ }
19
+ }
20
+
21
+ let cwd = process.cwd();
22
+ let trigger = "";
23
+ try {
24
+ const input = JSON.parse(readStdin() || "{}");
25
+ if (input.cwd) cwd = input.cwd;
26
+ trigger = input.trigger || input.matcher || "";
27
+ } catch {
28
+ // non-JSON stdin — fall back to defaults
29
+ }
30
+
31
+ const git = (args) => {
32
+ try {
33
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
34
+ } catch {
35
+ return "";
36
+ }
37
+ };
38
+ const isRepo = git(["rev-parse", "--is-inside-work-tree"]) === "true";
39
+
40
+ // Active task's next step, if the tasks workflow is in use.
41
+ let nextStep = "";
42
+ const tasksDir = join(cwd, "tasks");
43
+ if (existsSync(tasksDir)) {
44
+ for (const name of readdirSync(tasksDir)) {
45
+ if (!name.endsWith(".md")) continue;
46
+ let text = "";
47
+ try {
48
+ text = readFileSync(join(tasksDir, name), "utf8");
49
+ } catch {
50
+ continue;
51
+ }
52
+ const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
53
+ if (!fm || !/^status:\s*active\s*$/m.test(fm[1])) continue;
54
+ const ns = text.match(/##\s*Next step\s*\r?\n+([^\r\n]+)/);
55
+ nextStep = `${name.replace(/\.md$/, "")} — ${ns ? ns[1].trim() : "(no next step recorded)"}`;
56
+ break;
57
+ }
58
+ }
59
+
60
+ const stamp = new Date().toISOString();
61
+ const lines = [
62
+ `# Wardrail checkpoint snapshot (${trigger || "compact"} · ${stamp})`,
63
+ ``,
64
+ `Machine-captured ground truth from the moment before context was compacted. Trust this over the`,
65
+ `summary if they disagree.`,
66
+ ``,
67
+ ];
68
+ if (!isRepo) {
69
+ lines.push(`(not a git repository — no repo state to capture)`);
70
+ } else {
71
+ lines.push(`**Branch:** ${git(["rev-parse", "--abbrev-ref", "HEAD"]) || "(unknown)"}`);
72
+ if (nextStep) lines.push(``, `**Active task:** ${nextStep}`);
73
+ lines.push(``, `**Recent commits:**`, "```", git(["log", "--oneline", "-8"]) || "(none)", "```");
74
+ lines.push(``, `**Uncommitted changes:**`, "```", git(["status", "--porcelain"]) || "(clean working tree)", "```");
75
+ const stat = git(["diff", "--stat", "HEAD"]);
76
+ if (stat) lines.push(``, `**Diff stat (vs HEAD):**`, "```", stat, "```");
77
+ }
78
+
79
+ try {
80
+ mkdirSync(join(cwd, ".wardrail"), { recursive: true });
81
+ writeFileSync(join(cwd, ".wardrail", "last-checkpoint.md"), lines.join("\n") + "\n", "utf8");
82
+ } catch {
83
+ // best-effort — a snapshot failure must never break compaction
84
+ }
85
+ process.exit(0);
@@ -1,58 +1,179 @@
1
- #!/usr/bin/env node
2
- // SessionStart hook for the tasks plugin.
3
- // Prints active task files in the current project's ./tasks/ so resuming is one step.
4
- // Silent (no output) when there is no ./tasks/ dir or no active task — costs nothing
5
- // in projects that don't use the workflow.
6
-
7
- import { readFileSync, readdirSync, existsSync } from "node:fs";
8
- import { join } from "node:path";
9
-
10
- function readStdin() {
11
- try {
12
- return readFileSync(0, "utf8");
13
- } catch {
14
- return "";
15
- }
16
- }
17
-
18
- let cwd = process.cwd();
19
- try {
20
- const input = JSON.parse(readStdin() || "{}");
21
- if (input.cwd) cwd = input.cwd;
22
- } catch {
23
- // no/!json stdin — fall back to process.cwd()
24
- }
25
-
26
- const tasksDir = join(cwd, "tasks");
27
- if (!existsSync(tasksDir)) process.exit(0);
28
-
29
- const active = [];
30
- for (const name of readdirSync(tasksDir)) {
31
- if (!name.endsWith(".md")) continue;
32
- let text;
33
- try {
34
- text = readFileSync(join(tasksDir, name), "utf8");
35
- } catch {
36
- continue;
37
- }
38
- const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
39
- if (!fm || !/^status:\s*active\s*$/m.test(fm[1])) continue;
40
-
41
- const slug = name.replace(/\.md$/, "");
42
- const next = text.match(/##\s*Next step\s*\r?\n+([^\r\n]+)/);
43
- // Last machine verdict from the checkpoint verifier, if any — surfaced so a resume
44
- // sees up front whether the prior checkpoint left open issues (WARN/FAIL).
45
- const verdict = text.match(/^-\s*Verdict:\s*(\S+)\s*(PASS|WARN|FAIL)/m);
46
- active.push({ slug, next: next ? next[1].trim() : "", verdict: verdict ? `${verdict[1]} ${verdict[2]}` : "" });
47
- }
48
-
49
- if (active.length === 0) process.exit(0);
50
-
51
- const lines = ["Active task file(s) in ./tasks/ — resume cheaply with `/task resume <slug>`:"];
52
- for (const t of active) {
53
- let line = `- ${t.slug}`;
54
- if (t.verdict) line += ` [last checkpoint: ${t.verdict}]`;
55
- if (t.next) line += ` — next: ${t.next}`;
56
- lines.push(line);
57
- }
58
- process.stdout.write(lines.join("\n") + "\n");
1
+ #!/usr/bin/env node
2
+ // SessionStart hook for the tasks plugin — resume cheaply and stay grounded in reality.
3
+ // Fires on startup / resume / clear / compact (distinguished by `source`):
4
+ // - compact → re-inject the verified ground-truth snapshot the PreCompact hook wrote, so the
5
+ // compacted session trusts reality over the (lossy) summary.
6
+ // - resume/startup → list active tasks (the project's work index), then re-ground the session in
7
+ // the current repo state and the code-graph blast radius of the task's files, so a
8
+ // fresh window restarts on the rails instead of guessing.
9
+ // Silent when there's nothing to say — costs nothing in projects that don't use the workflow.
10
+
11
+ import { readFileSync, readdirSync, existsSync } from "node:fs";
12
+ import { execFileSync } from "node:child_process";
13
+ import { join } from "node:path";
14
+
15
+ function readStdin() {
16
+ try {
17
+ return readFileSync(0, "utf8");
18
+ } catch {
19
+ return "";
20
+ }
21
+ }
22
+
23
+ let cwd = process.cwd();
24
+ let source = "";
25
+ try {
26
+ const input = JSON.parse(readStdin() || "{}");
27
+ if (input.cwd) cwd = input.cwd;
28
+ source = input.source || "";
29
+ } catch {
30
+ // non-JSON stdin fall back to defaults
31
+ }
32
+
33
+ const out = [];
34
+
35
+ // ---- on compaction: re-inject the verified snapshot captured before compaction ----
36
+ const snapPath = join(cwd, ".wardrail", "last-checkpoint.md");
37
+ if (source === "compact" && existsSync(snapPath)) {
38
+ try {
39
+ out.push(
40
+ "Context was just compacted — the summary above may have dropped or distorted detail. The following is machine-captured ground truth from the moment before compaction; trust it over the summary:\n",
41
+ );
42
+ out.push(readFileSync(snapPath, "utf8").trim());
43
+ } catch {
44
+ // snapshot unreadable fall through to the normal listing
45
+ }
46
+ }
47
+
48
+ // ---- collect active tasks (also serves as the project work index) ----
49
+ function parseFiles(text) {
50
+ const sec = text.match(/##\s*Files touched\s*\r?\n([\s\S]*?)(?:\r?\n##\s|\s*$)/);
51
+ const files = [];
52
+ if (sec) {
53
+ for (const line of sec[1].split(/\r?\n/)) {
54
+ const m = line.match(/^\s*-\s+(.+?)(?:\s+[—-]\s.*)?$/);
55
+ if (!m) continue;
56
+ const p = m[1].trim();
57
+ if (!p || p.startsWith("(")) continue;
58
+ files.push(p.replace(/\\/g, "/"));
59
+ }
60
+ }
61
+ return files;
62
+ }
63
+
64
+ const tasksDir = join(cwd, "tasks");
65
+ const active = [];
66
+ let doneCount = 0;
67
+ if (existsSync(tasksDir)) {
68
+ for (const name of readdirSync(tasksDir)) {
69
+ if (!name.endsWith(".md")) continue;
70
+ let text = "";
71
+ try {
72
+ text = readFileSync(join(tasksDir, name), "utf8");
73
+ } catch {
74
+ continue;
75
+ }
76
+ const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
77
+ if (!fm) continue;
78
+ if (/^status:\s*done\s*$/m.test(fm[1])) {
79
+ doneCount++;
80
+ continue;
81
+ }
82
+ if (!/^status:\s*active\s*$/m.test(fm[1])) continue;
83
+ const slug = name.replace(/\.md$/, "");
84
+ const next = text.match(/##\s*Next step\s*\r?\n+([^\r\n]+)/);
85
+ const verdict = text.match(/^-\s*Verdict:\s*(\S+)\s*(PASS|WARN|FAIL)/m);
86
+ active.push({ slug, next: next ? next[1].trim() : "", verdict: verdict ? `${verdict[1]} ${verdict[2]}` : "", files: parseFiles(text) });
87
+ }
88
+ }
89
+
90
+ if (active.length) {
91
+ out.push("\nActive task file(s) in ./tasks/ — resume with `/task resume <slug>`:");
92
+ for (const t of active) {
93
+ let line = `- ${t.slug}`;
94
+ if (t.verdict) line += ` [last checkpoint: ${t.verdict}]`;
95
+ if (t.next) line += ` — next: ${t.next}`;
96
+ out.push(line);
97
+ }
98
+ if (doneCount) out.push(`(${doneCount} completed task file(s) also in ./tasks/.)`);
99
+ }
100
+
101
+ // ---- on startup/resume: re-ground in current repo reality + code-graph blast radius ----
102
+ if (source !== "compact" && active.length) {
103
+ const git = (args) => {
104
+ try {
105
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
106
+ } catch {
107
+ return "";
108
+ }
109
+ };
110
+ if (git(["rev-parse", "--is-inside-work-tree"]) === "true") {
111
+ const recent = git(["log", "--oneline", "-5"]);
112
+ const status = git(["status", "--porcelain"]);
113
+ if (recent || status) {
114
+ out.push("\nCurrent repo state (resume on reality, not assumptions):");
115
+ if (recent) out.push("Recent commits:\n" + recent);
116
+ out.push(status ? "Uncommitted changes:\n" + status : "Working tree: clean");
117
+ }
118
+ }
119
+ const blast = blastRadius(cwd, active.flatMap((t) => t.files));
120
+ if (blast) out.push("\nCode-graph blast radius for this task's files (what depends on them — check before you change):\n" + blast);
121
+ }
122
+
123
+ // Zero-dependency read of .wardrail/codegraph.json → dependent files of the given source files.
124
+ // Mirrors the server-side impact-of-diff walk; absent graph or files → empty (no-op).
125
+ function blastRadius(root, files) {
126
+ const unique = [...new Set(files)];
127
+ if (!unique.length) return "";
128
+ const p = join(root, ".wardrail", "codegraph.json");
129
+ if (!existsSync(p)) return "";
130
+ let g;
131
+ try {
132
+ g = JSON.parse(readFileSync(p, "utf8"));
133
+ } catch {
134
+ return "";
135
+ }
136
+ const DEP = new Set(["calls", "references", "imports", "extends", "implements"]);
137
+ const nodeById = new Map();
138
+ const inEdges = new Map();
139
+ for (const n of g.nodes ?? []) nodeById.set(n.id, n);
140
+ for (const e of g.edges ?? []) {
141
+ if (!DEP.has(e.kind)) continue;
142
+ let a = inEdges.get(e.to);
143
+ if (!a) {
144
+ a = [];
145
+ inEdges.set(e.to, a);
146
+ }
147
+ a.push(e.from);
148
+ }
149
+ const lines = [];
150
+ for (const f of unique) {
151
+ const symIds = (g.nodes ?? []).filter((n) => n.kind !== "file" && n.filePath === f).map((n) => n.id);
152
+ if (!symIds.length) continue;
153
+ const seen = new Set(symIds);
154
+ const dependents = new Set();
155
+ let frontier = symIds.slice();
156
+ for (let d = 0; d < 6 && frontier.length; d++) {
157
+ const nextFrontier = [];
158
+ for (const id of frontier) {
159
+ for (const from of inEdges.get(id) ?? []) {
160
+ if (seen.has(from)) continue;
161
+ seen.add(from);
162
+ const node = nodeById.get(from);
163
+ if (node) {
164
+ dependents.add(node.filePath);
165
+ nextFrontier.push(from);
166
+ }
167
+ }
168
+ }
169
+ frontier = nextFrontier;
170
+ }
171
+ dependents.delete(f);
172
+ if (dependents.size) {
173
+ lines.push(` ${f} → ${dependents.size} dependent file(s): ${[...dependents].slice(0, 6).join(", ")}${dependents.size > 6 ? " …" : ""}`);
174
+ }
175
+ }
176
+ return lines.join("\n");
177
+ }
178
+
179
+ if (out.length) process.stdout.write(out.join("\n").trim() + "\n");
@@ -155,6 +155,49 @@ if (testCmd === false) {
155
155
  }
156
156
  }
157
157
 
158
+ // ---- check 4: typecheck (optional; from tasks/.verify.json or a package.json `typecheck` script) ----
159
+ let typeCmd = null;
160
+ if (existsSync(cfgPath)) {
161
+ try {
162
+ const cfg = JSON.parse(readFileSync(cfgPath, "utf8"));
163
+ if (cfg.typecheck === false) typeCmd = false;
164
+ else if (typeof cfg.typecheck === "string") typeCmd = cfg.typecheck;
165
+ } catch { /* ignore malformed config */ }
166
+ }
167
+ if (typeCmd === null && existsSync(join(cwd, "package.json"))) {
168
+ try {
169
+ const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
170
+ if (pkg.scripts && pkg.scripts.typecheck) typeCmd = "npm run typecheck";
171
+ } catch { /* ignore */ }
172
+ }
173
+
174
+ let typeLine, typeOk = true;
175
+ if (typeCmd === false) {
176
+ typeLine = "⏭️ typecheck skipped (disabled in tasks/.verify.json)";
177
+ } else if (!typeCmd) {
178
+ typeLine = "⏭️ no typecheck command found — not checked";
179
+ } else {
180
+ try {
181
+ execSync(typeCmd, { cwd, encoding: "utf8", timeout: 120000, stdio: ["ignore", "pipe", "pipe"] });
182
+ typeLine = `✅ \`${typeCmd}\` passed`;
183
+ } catch (e) {
184
+ typeOk = false;
185
+ const tail = String(e.stdout || e.stderr || "").trim().split(/\r?\n/).filter(Boolean).pop() || "";
186
+ typeLine = e.killed
187
+ ? `❌ \`${typeCmd}\` timed out after 120s`
188
+ : `❌ \`${typeCmd}\` failed (exit ${e.status})${tail ? ": " + tail.slice(0, 160) : ""}`;
189
+ }
190
+ }
191
+
192
+ // ---- check 5 (advisory): is the Next step concrete? a vague handoff resumes badly ----
193
+ const nextMatch = doc.match(/##\s*Next step\s*\r?\n+([\s\S]*?)(?:\r?\n##\s|\s*$)/);
194
+ const nextText = nextMatch ? nextMatch[1].replace(/\s+/g, " ").trim() : "";
195
+ const vague = /^(tbd|todo|wip|continue|finish(\s+it)?|make\s+it\s+work|keep\s+going|n\/?a|none)\.?$/i;
196
+ let nextLine;
197
+ if (!nextText) nextLine = "⚠️ no Next step recorded — a resume won't know where to start";
198
+ else if (vague.test(nextText) || nextText.length < 12) nextLine = `⚠️ Next step is vague ("${nextText.slice(0, 60)}") — make it one concrete action`;
199
+ else nextLine = "✅ Next step is concrete";
200
+
158
201
  // ---- check 3: honesty scan of the diff ------------------------------------
159
202
  let honestyLine, honestyOk = true;
160
203
  if (!isRepo) {
@@ -180,7 +223,7 @@ if (!isRepo) {
180
223
  }
181
224
 
182
225
  // ---- verdict --------------------------------------------------------------
183
- const allOk = filesOk && testsOk && honestyOk;
226
+ const allOk = filesOk && testsOk && typeOk && honestyOk;
184
227
  let verdict;
185
228
  if (allOk) verdict = "✅ PASS";
186
229
  else if (status === "done") verdict = "❌ FAIL — marked `done` but checks above did not pass";
@@ -191,7 +234,9 @@ const block =
191
234
  `## Verification (machine-checked ${stamp})\n` +
192
235
  `- Files touched: ${filesLine}\n` +
193
236
  `- Tests: ${testsLine}\n` +
237
+ `- Typecheck: ${typeLine}\n` +
194
238
  `- Honesty scan: ${honestyLine}\n` +
239
+ `- Next step: ${nextLine}\n` +
195
240
  `- Verdict: ${verdict}\n`;
196
241
 
197
242
  // replace any existing Verification section, else append
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wardrail/plugin",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "description": "Wardrail Claude Code plugin — consult your project's contract while coding (MCP) plus a checkpoint->clear->resume workflow with machine-verified checkpoints.",
5
5
  "type": "module",
6
6
  "files": [