@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +13 -7
- package/hooks/hooks.json +26 -15
- package/hooks/precompact-snapshot.mjs +85 -0
- package/hooks/tasks-session-start.mjs +179 -58
- package/hooks/verify-checkpoint.mjs +46 -1
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wardrail",
|
|
3
3
|
"displayName": "Wardrail",
|
|
4
|
-
"version": "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`
|
|
14
|
-
|
|
15
|
-
|
|
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.
|
|
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
|
|
51
|
-
hooks/tasks-session-start.mjs
|
|
52
|
-
hooks/
|
|
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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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.
|
|
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": [
|