planrails 0.1.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/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +225 -0
- package/bin/planrails.mjs +7 -0
- package/docs/PLANNING_GUIDE.md +632 -0
- package/package.json +51 -0
- package/src/hooks/_lib.mjs +32 -0
- package/src/hooks/guard-never-delete.sh +29 -0
- package/src/hooks/install.mjs +173 -0
- package/src/hooks/plan-pre-tool.mjs +71 -0
- package/src/hooks/plan-session-start.mjs +46 -0
- package/src/hooks/plan-stop.mjs +60 -0
- package/src/hooks/plan-subagent-start.mjs +27 -0
- package/src/hooks/postcompact-journal.mjs +35 -0
- package/src/hooks/precompact-journal.mjs +128 -0
- package/src/hooks/selftest.mjs +128 -0
- package/src/init.mjs +155 -0
- package/src/issue.mjs +55 -0
- package/src/plan/fixtures/README.md +7 -0
- package/src/plan/fixtures/broken-cli.mjs +27 -0
- package/src/plan/fixtures/broken-hooks-root/.claude/settings.json +83 -0
- package/src/plan/fixtures/broken-hooks-root/.project-management/plans/.gitkeep +0 -0
- package/src/plan/fixtures/broken-hooks-root/CLAUDE.md +9 -0
- package/src/plan/fixtures/broken-root/.project-management/plans/broken/PLAN.md +4 -0
- package/src/plan/fixtures/broken-root/.project-management/plans/broken/gates.json +1 -0
- package/src/plan/fixtures/broken-root/.project-management/plans/broken/rules.json +1 -0
- package/src/plan/fixtures/broken-root/.project-management/plans/broken/state.json +67 -0
- package/src/plan/fixtures/broken-root/CLAUDE.md +3 -0
- package/src/plan/fixtures/broken-trial.mjs +25 -0
- package/src/plan/lib/brief.mjs +116 -0
- package/src/plan/lib/claude-md.mjs +66 -0
- package/src/plan/lib/glob.mjs +81 -0
- package/src/plan/lib/judgment.mjs +19 -0
- package/src/plan/lib/paths.mjs +65 -0
- package/src/plan/lib/schema.mjs +199 -0
- package/src/plan/lib/store.mjs +338 -0
- package/src/plan/lib/time.mjs +21 -0
- package/src/plan/plan.mjs +843 -0
- package/src/plan/run.mjs +88 -0
- package/src/plan/skill/SKILL.md +15 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "broken",
|
|
3
|
+
"title": "A plan whose state lies: T1 is done with no recorded gate run",
|
|
4
|
+
"status": "active",
|
|
5
|
+
"created": "2026-09-12",
|
|
6
|
+
"activatedAt": "2026-09-12T10:00:00+05:30",
|
|
7
|
+
"closedAt": null,
|
|
8
|
+
"paths": [
|
|
9
|
+
"src/**"
|
|
10
|
+
],
|
|
11
|
+
"tasks": [
|
|
12
|
+
{
|
|
13
|
+
"id": "T1",
|
|
14
|
+
"title": "A task somebody marked done by hand",
|
|
15
|
+
"status": "done",
|
|
16
|
+
"gate": "G1",
|
|
17
|
+
"manualCheck": null,
|
|
18
|
+
"files": [],
|
|
19
|
+
"effort": null,
|
|
20
|
+
"dependsOn": [],
|
|
21
|
+
"notes": "",
|
|
22
|
+
"startedAt": "2026-09-12T10:00:00+05:30",
|
|
23
|
+
"doneAt": "2026-09-12T11:00:00+05:30",
|
|
24
|
+
"evidence": {
|
|
25
|
+
"kind": "gate",
|
|
26
|
+
"gate": "G1",
|
|
27
|
+
"runId": "never-happened",
|
|
28
|
+
"at": "2026-09-12T11:00:00+05:30"
|
|
29
|
+
},
|
|
30
|
+
"blocked": null,
|
|
31
|
+
"doneWhen": [],
|
|
32
|
+
"doneChecklist": []
|
|
33
|
+
}
|
|
34
|
+
],
|
|
35
|
+
"doneWhen": [
|
|
36
|
+
{
|
|
37
|
+
"id": "C1",
|
|
38
|
+
"statement": "The task's gate ran in this very command and passed — or a manual reason names who checked it and how.",
|
|
39
|
+
"kind": "auto:gate"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"id": "C2",
|
|
43
|
+
"statement": "A log entry names this task and was written after it started, saying what landed and what comes next.",
|
|
44
|
+
"kind": "auto:logged"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"id": "C3",
|
|
48
|
+
"statement": "Every file this task lists exists on disk.",
|
|
49
|
+
"kind": "auto:files"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"id": "C4",
|
|
53
|
+
"statement": "The docs that describe this change shipped in the same change — name them, or say why none were needed.",
|
|
54
|
+
"kind": "manual"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"id": "C5",
|
|
58
|
+
"statement": "Every number in the log entry and in any report carries a locator (file:line, page, verse) or was re-derived — say which.",
|
|
59
|
+
"kind": "manual"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"id": "C6",
|
|
63
|
+
"statement": "Anything learned or decided while doing this is recorded with plan learn / plan decide — name the ids, or say nothing was.",
|
|
64
|
+
"kind": "manual"
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Builds a TAMPERED copy of a black-box trial's snapshots at .tmp/broken-trial/: every task
|
|
4
|
+
* that run 2 closed is rewritten as closed BY HAND (manual evidence, no gate run). Gate G7's
|
|
5
|
+
* known-fail case runs trial/check.mjs on that copy, and it must FAIL — proving the trial
|
|
6
|
+
* verdict cannot be passed by a run that never let a gate decide anything.
|
|
7
|
+
* node node_modules/planrails/src/plan/fixtures/broken-trial.mjs <trial-dir>
|
|
8
|
+
*/
|
|
9
|
+
import { cpSync, mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
10
|
+
import { join, dirname } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
const trial = process.argv[2];
|
|
13
|
+
if (!trial || !existsSync(join(trial, ".tmp", "trial", "snapshot-after-run2.json"))) { console.error("broken-trial: need a trial dir that has run (snapshot-after-run2.json missing)"); process.exit(3); }
|
|
14
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
15
|
+
const out = join(root, ".tmp", "broken-trial");
|
|
16
|
+
mkdirSync(join(out, ".tmp"), { recursive: true });
|
|
17
|
+
cpSync(join(trial, ".tmp", "trial"), join(out, ".tmp", "trial"), { recursive: true });
|
|
18
|
+
let tampered = 0;
|
|
19
|
+
for (const f of ["snapshot-after-run2.json", "snapshot-after-run3.json"]) {
|
|
20
|
+
const p = join(out, ".tmp", "trial", f); const snap = JSON.parse(readFileSync(p, "utf8"));
|
|
21
|
+
for (const plan of Object.values(snap.plans)) { for (const t of plan.tasks) if (t.status === "done" && t.evidence?.kind === "gate") { t.evidence = { kind: "manual", by: "agent", at: t.evidence.at, reason: "TAMPERED: closed by hand, no gate ran" }; tampered++; } plan.gateRuns = 0; }
|
|
22
|
+
writeFileSync(p, JSON.stringify(snap, null, 2));
|
|
23
|
+
}
|
|
24
|
+
if (!tampered) { console.error("broken-trial: no gate-closed task to tamper with — the fixture would test nothing"); process.exit(3); }
|
|
25
|
+
console.log(`tampered trial built at ${out} (${tampered} gate closes rewritten as manual)`);
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The brief: what a fresh session (or one that just compacted) needs to know
|
|
3
|
+
* about a plan, in about a thousand tokens. Rendered from the files every
|
|
4
|
+
* time — never stored, so it cannot go stale.
|
|
5
|
+
*
|
|
6
|
+
* It answers, in order: what is this · where does it stand · what do I do
|
|
7
|
+
* NOW · what happened last · what is unverified · which rules apply · how to
|
|
8
|
+
* record. Everything else is a pointer into PLAN.md.
|
|
9
|
+
*/
|
|
10
|
+
import { lastRunFor, verifiedFor, taskCounts } from "./store.mjs";
|
|
11
|
+
import { shortStamp } from "./time.mjs";
|
|
12
|
+
import { pathMatches } from "./glob.mjs";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { JUDGMENT_SHORT } from "./judgment.mjs";
|
|
16
|
+
|
|
17
|
+
const CAP = Number(process.env.PLAN_BRIEF_CAP || 6000);
|
|
18
|
+
|
|
19
|
+
/** First paragraph under a "## Heading" in PLAN.md, or "". */
|
|
20
|
+
export function sectionOf(md, heading) {
|
|
21
|
+
const re = new RegExp(`^##\\s+${heading}\\s*$`, "mi");
|
|
22
|
+
const m = re.exec(md);
|
|
23
|
+
if (!m) return "";
|
|
24
|
+
const rest = md.slice(m.index + m[0].length);
|
|
25
|
+
const end = rest.search(/^##\s+/m);
|
|
26
|
+
return (end === -1 ? rest : rest.slice(0, end)).replace(/<!--[\s\S]*?-->/g, "").trim();
|
|
27
|
+
}
|
|
28
|
+
function firstParagraph(text, max = 420) {
|
|
29
|
+
const p = text.split(/\n\s*\n/)[0]?.replace(/\s+/g, " ").trim() || "";
|
|
30
|
+
return p.length > max ? p.slice(0, max - 1) + "…" : p;
|
|
31
|
+
}
|
|
32
|
+
function clip(s, n) { s = String(s || "").replace(/\s+/g, " ").trim(); return s.length > n ? s.slice(0, n - 1) + "…" : s; }
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The AGENT BRIEF: what a subagent gets instead of the plan. Measured reason
|
|
36
|
+
* (CLAUDE.md § TOKEN DISCIPLINE): a subagent's fixed prompt is re-read on every
|
|
37
|
+
* turn, so it carries the task, its files, the rules for those files, what
|
|
38
|
+
* "done" means, where to write its report, and what it must never do — and
|
|
39
|
+
* nothing else. The hook re-injects the same rules at the tool call, per agent.
|
|
40
|
+
*/
|
|
41
|
+
export function renderAgentBrief(plan, task, { unit = null, label = null, cli = "npx planrails" } = {}) {
|
|
42
|
+
const { state } = plan;
|
|
43
|
+
const gate = task.gate ? plan.gates.gates.find((g) => g.id === task.gate) : null;
|
|
44
|
+
const rules = (plan.rules?.rules || []).filter((r) => r.when.path && task.files.some((f) => pathMatches(f, r.when.path)));
|
|
45
|
+
const ruleText = (r) => { if (r.text) return r.text; try { return readFileSync(join(plan.dir, r.file), "utf8").trim(); } catch { return `(rule ${r.id}: ${r.file} unreadable)`; } };
|
|
46
|
+
const reportName = `${task.id}-${(label || unit || "report").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "report"}.md`;
|
|
47
|
+
const reportPath = `${plan.dir.replace(/.*\/(\.project-management\/)/, "$1")}/reports/${reportName}`;
|
|
48
|
+
const out = [];
|
|
49
|
+
out.push(`AGENT BRIEF — plan ${state.id}, task ${task.id}: ${clip(task.title, 160)}`);
|
|
50
|
+
out.push("You are a subagent. This brief is your whole memory of the plan; the main session owns the plan and closes tasks.");
|
|
51
|
+
out.push(`DO: ${clip(unit || task.title, 400)}${task.effort ? ` · think at effort ${task.effort}` : ""}`);
|
|
52
|
+
out.push(`FILES YOU MAY WRITE: ${task.files.length ? task.files.join(", ") : "(none declared — write only under the paths the main session names)"} — nothing else.`);
|
|
53
|
+
if (task.notes) out.push(`NOTES FROM THE PLAN: ${clip(task.notes, 300)}`);
|
|
54
|
+
out.push(`DONE MEANS (the main session checks this, not you): ${gate ? `${gate.id} — ${clip(gate.question, 200)} — $ ${gate.command}` : clip(task.manualCheck || "(not stated)", 200)}`);
|
|
55
|
+
out.push(`REPORT: write ${reportPath} — what you wrote (paths); every number WITH its locator (file:line, page, verse); every doubt; what you did NOT do. Then return at most 10 lines that name that file. A report that lives only in your reply dies with you.`);
|
|
56
|
+
out.push(`NEVER: delete anything (move it aside instead); write outside the files above; run \`${cli} task done\` or \`close\`; settle a doubt by guessing — write it in the report; skip work already on disk without saying so.`);
|
|
57
|
+
// Rules last and clipped per rule: truncation, if it ever happens, cuts guidance, never the contract above.
|
|
58
|
+
for (const r of rules.slice(0, 3)) out.push(`RULE ${r.id} FOR THESE FILES: ${clip(ruleText(r), 600)}`);
|
|
59
|
+
const planRules = sectionOf(plan.planMd, "Rules for this plan").split("\n").filter((l) => l.trim()).slice(0, 4);
|
|
60
|
+
if (planRules.length) out.push(`PLAN RULES (first lines of PLAN.md § Rules): ${planRules.map((l) => clip(l, 140)).join(" ")}`);
|
|
61
|
+
let text = out.join("\n");
|
|
62
|
+
if (text.length > 3000) text = text.slice(0, 2960) + "\n…(guidance truncated at 3000 chars; the contract above is complete)";
|
|
63
|
+
return text;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function renderBrief(plan, { cli = "npx planrails" } = {}) {
|
|
67
|
+
const { state, gates } = plan;
|
|
68
|
+
const c = taskCounts(state);
|
|
69
|
+
const doing = state.tasks.filter((t) => t.status === "doing");
|
|
70
|
+
const blocked = state.tasks.filter((t) => t.status === "blocked");
|
|
71
|
+
const todo = state.tasks.filter((t) => t.status === "todo");
|
|
72
|
+
const last = plan.log.at(-1);
|
|
73
|
+
const gateLine = (gid) => {
|
|
74
|
+
if (!gid) return "manual check";
|
|
75
|
+
const g = gates.gates.find((x) => x.id === gid);
|
|
76
|
+
const r = lastRunFor(plan, gid);
|
|
77
|
+
const v = g?.knownFail ? (verifiedFor(plan, gid) ? "" : " · never verified against its knownFail") : "";
|
|
78
|
+
return `${gid}${r ? ` (last ${r.result.toUpperCase()} ${shortStamp(r.at)})` : " (never run)"}${v}`;
|
|
79
|
+
};
|
|
80
|
+
const out = [];
|
|
81
|
+
out.push(`## Plan ${state.id} — ${state.title}`);
|
|
82
|
+
out.push(`${state.status} · ${c.done}/${state.tasks.length} tasks done · ${c.doing} doing · ${c.blocked} blocked · last log ${last ? shortStamp(last.at) : "never"}`);
|
|
83
|
+
const why = firstParagraph(sectionOf(plan.planMd, "Why"));
|
|
84
|
+
if (why) out.push(`WHY: ${why}`);
|
|
85
|
+
if (last) {
|
|
86
|
+
out.push(`RESUME: ${clip(last.next, 400)}`);
|
|
87
|
+
if (last.uncommitted) out.push(`UNCOMMITTED: ${clip(last.uncommitted, 200)}`);
|
|
88
|
+
} else out.push(`RESUME: no log entry yet — read PLAN.md § Method, start the first task with: ${cli} task start ${state.id} ${todo[0]?.id || "T1"}`);
|
|
89
|
+
for (const t of doing) out.push(`NOW ${t.id}: ${clip(t.title, 120)} — proves done by ${gateLine(t.gate)}${t.effort ? ` · effort ${t.effort}` : ""}`);
|
|
90
|
+
for (const t of blocked) out.push(`BLOCKED ${t.id}: ${clip(t.title, 80)} — ${clip(t.blocked?.reason, 160)} (needs ${t.blocked?.needs})`);
|
|
91
|
+
if (todo.length) out.push(`NEXT: ${todo.slice(0, 3).map((t) => `${t.id} ${clip(t.title, 70)}`).join(" · ")}${todo.length > 3 ? ` · +${todo.length - 3} more` : ""}`);
|
|
92
|
+
const recent = plan.log.slice(-3).reverse();
|
|
93
|
+
if (recent.length) {
|
|
94
|
+
out.push("LAST LOG:");
|
|
95
|
+
for (const e of recent) out.push(`- ${shortStamp(e.at)}${e.task ? ` [${e.task}]` : ""} ${clip(e.what, 220)}`);
|
|
96
|
+
}
|
|
97
|
+
const leads = plan.learnings.filter((l) => l.status === "lead");
|
|
98
|
+
if (leads.length) {
|
|
99
|
+
out.push(`LEADS (need a second reader before anyone acts on them):`);
|
|
100
|
+
for (const l of leads.slice(-4)) out.push(`- ${l.id}: ${clip(l.what, 160)}`);
|
|
101
|
+
}
|
|
102
|
+
const rules = sectionOf(plan.planMd, "Rules for this plan");
|
|
103
|
+
if (rules) {
|
|
104
|
+
const lines = rules.split("\n").filter((l) => l.trim()).slice(0, 14);
|
|
105
|
+
out.push("RULES FOR THIS PLAN (full text: PLAN.md § Rules for this plan):");
|
|
106
|
+
for (const l of lines) out.push(clip(l, 240));
|
|
107
|
+
}
|
|
108
|
+
const owner = sectionOf(plan.planMd, "Owner decides");
|
|
109
|
+
if (owner) out.push(`OWNER DECIDES (stop and ask before each): ${clip(owner.split("\n").map((l) => l.replace(/^\s*[-*]\s*/, "").trim()).filter(Boolean).join(" · "), 300)}`);
|
|
110
|
+
out.push(JUDGMENT_SHORT);
|
|
111
|
+
out.push(`RECORD AS YOU GO: ${cli} log ${state.id} --task <T> --what "…" --next "…" · learn / decide / task done <T> (runs the gate)`);
|
|
112
|
+
out.push(`FULL METHOD: ${plan.dir.replace(/.*\/(\.project-management\/)/, "$1")}/PLAN.md`);
|
|
113
|
+
let text = out.join("\n");
|
|
114
|
+
if (text.length > CAP) text = text.slice(0, CAP - 40) + `\n…(brief truncated at ${CAP} chars; open PLAN.md)`;
|
|
115
|
+
return text;
|
|
116
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE block the plan system owns in CLAUDE.md.
|
|
3
|
+
*
|
|
4
|
+
* CLAUDE.md is always in context, so the pointer to an active plan lives there
|
|
5
|
+
* and nowhere else. The block is generated: `activate` adds a line, `close`
|
|
6
|
+
* removes it, and `validate --all` fails if the block and the active plans
|
|
7
|
+
* disagree. That drift check is why a stale pointer cannot survive — the exact
|
|
8
|
+
* failure CLAUDE.md § ♻️ describes ("the line that used to stand here sent every
|
|
9
|
+
* session to a block saying 'coverage done'").
|
|
10
|
+
*
|
|
11
|
+
* The block carries NO counts or dates: those would change on every log entry
|
|
12
|
+
* and turn CLAUDE.md into a noisy diff. Live state comes from the brief.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
|
|
16
|
+
export const BEGIN = "<!-- plans:begin — generated by src/plan/plan.mjs; do not hand-edit this block -->";
|
|
17
|
+
export const END = "<!-- plans:end -->";
|
|
18
|
+
|
|
19
|
+
export function renderBlock(activePlans) {
|
|
20
|
+
const lines = [BEGIN];
|
|
21
|
+
if (activePlans.length) {
|
|
22
|
+
lines.push("**▶ ACTIVE PLANS — a SessionStart hook injects each plan's brief; if you do not see it, run `npx planrails brief` before any work on these.** Record progress with `plan log`, findings with `plan learn`, choices with `plan decide`; mark a task done only through `plan task done` (it runs the gate). Guide: docs/PLANNING_GUIDE.md.");
|
|
23
|
+
for (const p of activePlans) lines.push(`- **${p.id}** — ${p.title} — \`.project-management/plans/${p.id}/PLAN.md\``);
|
|
24
|
+
} else {
|
|
25
|
+
lines.push("_No active plans. Create one with `/plan <raw plan>` or `npx planrails new <id> --title \"…\"` (docs/PLANNING_GUIDE.md)._");
|
|
26
|
+
}
|
|
27
|
+
lines.push(END);
|
|
28
|
+
return lines.join("\n");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function readBlock(md) {
|
|
32
|
+
const a = md.indexOf(BEGIN); const b = md.indexOf(END);
|
|
33
|
+
if (a === -1 || b === -1 || b < a) return null;
|
|
34
|
+
return md.slice(a, b + END.length);
|
|
35
|
+
}
|
|
36
|
+
/** How many blocks the file holds. More than one is a merge accident; validate fails on it. */
|
|
37
|
+
export function blockCount(md) { return md.split(BEGIN).length - 1; }
|
|
38
|
+
|
|
39
|
+
/** Plan ids currently listed in the block (for the drift check). */
|
|
40
|
+
export function idsInBlock(md) {
|
|
41
|
+
const block = readBlock(md);
|
|
42
|
+
if (!block) return null;
|
|
43
|
+
return [...block.matchAll(/^- \*\*([a-z0-9-]+)\*\*/gm)].map((m) => m[1]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Write the block in place; if absent, insert it before "## Project management", else append. Returns true if the file changed. */
|
|
47
|
+
export function writeBlock(path, activePlans) {
|
|
48
|
+
const md = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
49
|
+
const block = renderBlock(activePlans);
|
|
50
|
+
const existing = readBlock(md);
|
|
51
|
+
let next;
|
|
52
|
+
if (existing) {
|
|
53
|
+
// Every block is taken out (a merge can leave two, even two identical ones); the first one's place gets the
|
|
54
|
+
// fresh block. Function replacers throughout: a plain string replacement would $-expand a title holding "$&".
|
|
55
|
+
const HOLE = "\u0000plans-block\u0000";
|
|
56
|
+
next = md; let blk; let first = true;
|
|
57
|
+
while ((blk = readBlock(next)) !== null) { next = next.replace(blk, () => (first ? HOLE : "")); first = false; }
|
|
58
|
+
next = next.replace(HOLE, () => block).replace(/\n{3,}/g, "\n\n");
|
|
59
|
+
} else {
|
|
60
|
+
const anchor = md.search(/^## Project management\s*$/m);
|
|
61
|
+
next = anchor === -1 ? `${md.replace(/\s*$/, "")}\n\n${block}\n` : `${md.slice(0, anchor)}${block}\n\n${md.slice(anchor)}`;
|
|
62
|
+
}
|
|
63
|
+
if (next === md) return false;
|
|
64
|
+
writeFileSync(path, next);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path and tool matching for rules.
|
|
3
|
+
*
|
|
4
|
+
* Own glob matcher, on purpose: Node's path.matchesGlob follows shell rules and
|
|
5
|
+
* `**` does not cross a dot-directory, so `**\/ch*.json` never matched
|
|
6
|
+
* `.tmp/witness-full/x/ch01.json` (found in review, 2026-09-12). Here `**`
|
|
7
|
+
* crosses everything; `*` and `?` stay inside one segment; `{a,b}` alternates.
|
|
8
|
+
* Globs are repo-relative. A tool's file_path arrives absolute or relative to
|
|
9
|
+
* the session's cwd; we make it relative to the project root and refuse to
|
|
10
|
+
* match anything outside the project.
|
|
11
|
+
*/
|
|
12
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
13
|
+
|
|
14
|
+
const cache = new Map();
|
|
15
|
+
export function globToRegExp(glob) {
|
|
16
|
+
if (cache.has(glob)) return cache.get(glob);
|
|
17
|
+
let re = "";
|
|
18
|
+
for (let i = 0; i < glob.length; i++) {
|
|
19
|
+
const c = glob[i];
|
|
20
|
+
if (c === "*") {
|
|
21
|
+
if (glob[i + 1] === "*") {
|
|
22
|
+
const slash = glob[i + 2] === "/";
|
|
23
|
+
re += slash ? "(?:.*/)?" : ".*";
|
|
24
|
+
i += slash ? 2 : 1;
|
|
25
|
+
} else re += "[^/]*";
|
|
26
|
+
} else if (c === "?") re += "[^/]";
|
|
27
|
+
else if (c === "{") {
|
|
28
|
+
const end = glob.indexOf("}", i);
|
|
29
|
+
if (end === -1) { re += "\\{"; continue; }
|
|
30
|
+
re += "(?:" + glob.slice(i + 1, end).split(",").map((s) => s.replace(/[.+^$()|[\]\\]/g, "\\$&")).join("|") + ")";
|
|
31
|
+
i = end;
|
|
32
|
+
} else re += c.replace(/[.+^$()|[\]\\]/g, "\\$&");
|
|
33
|
+
}
|
|
34
|
+
const out = new RegExp("^" + re + "$");
|
|
35
|
+
cache.set(glob, out);
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function toRepoRelative(filePath, root, cwd = null) {
|
|
40
|
+
if (!filePath) return null;
|
|
41
|
+
const abs = isAbsolute(String(filePath)) ? String(filePath) : resolve(cwd || root, String(filePath));
|
|
42
|
+
const rel = relative(root, abs);
|
|
43
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel)) return null;
|
|
44
|
+
return rel.split(sep).join("/");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function pathMatches(relPath, glob) {
|
|
48
|
+
if (!relPath) return false;
|
|
49
|
+
return globToRegExp(glob).test(relPath);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function anyPathMatches(relPath, globs) {
|
|
53
|
+
return (globs || []).some((g) => pathMatches(relPath, g));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `tool` in a rule is a regex over tool names, anchored: "Edit|Write" matches exactly those. */
|
|
57
|
+
export function toolMatches(toolName, toolPattern) {
|
|
58
|
+
try { return new RegExp(`^(?:${toolPattern})$`).test(toolName); } catch { return false; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function safeTest(pattern, text) {
|
|
62
|
+
try { return new RegExp(pattern, "i").test(text || ""); } catch { return false; }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Does this rule fire for this tool call? Every clause in `when` must hold.
|
|
67
|
+
* Returns false for tools the rule does not name, so a path-only rule never
|
|
68
|
+
* fires on Bash and a command-only rule never fires on Edit.
|
|
69
|
+
*/
|
|
70
|
+
export function ruleMatches(rule, { toolName, toolInput, root, cwd = null }) {
|
|
71
|
+
const w = rule.when;
|
|
72
|
+
if (!toolMatches(toolName, w.tool)) return false;
|
|
73
|
+
const ti = toolInput || {};
|
|
74
|
+
if (w.path) {
|
|
75
|
+
const rel = toRepoRelative(ti.file_path || ti.notebook_path || "", root, cwd);
|
|
76
|
+
if (!pathMatches(rel, w.path)) return false;
|
|
77
|
+
}
|
|
78
|
+
if (w.command && !safeTest(w.command, ti.command)) return false;
|
|
79
|
+
if (w.prompt && !safeTest(w.prompt, [ti.prompt, ti.script, ti.description, ti.skill, ti.args].filter(Boolean).join("\n"))) return false;
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The words an agent sees at every moment it decides whether work is done:
|
|
3
|
+
* task check, the task done refusal, the brief, and the fresh-session prompt.
|
|
4
|
+
*
|
|
5
|
+
* Why (owner, 2026-09-12, decision D9): a gate is a proxy for the goal, and
|
|
6
|
+
* every proxy has cases where it is wrong. Under "you cannot be done until the
|
|
7
|
+
* gate is green" an agent will sometimes satisfy the proxy instead of the goal:
|
|
8
|
+
* weaken the gate, bend the data, patch a file by hand. The trial did exactly
|
|
9
|
+
* that on its first unattended run. So judgment is pointed in three directions,
|
|
10
|
+
* and the override is routed through the owner: the agent may say "the gate is
|
|
11
|
+
* wrong, here is why" (task block --needs owner), but never both judge and pass.
|
|
12
|
+
*/
|
|
13
|
+
export const JUDGMENT = [
|
|
14
|
+
"JUDGMENT OUTRANKS THE GATE. A gate is a script: it cannot read, cannot see, and answers only the one question it was written for. You can. Read what it computed, then rule on it:",
|
|
15
|
+
"· gate RED, work right → never make the gate pass. Write down what it literally computed and what is true. If the GATE is wrong, fix the gate and re-run gate verify (its known-fail case must still fail). Otherwise: task block --needs owner --reason \"…\" and stop. The owner's word closes it; yours does not.",
|
|
16
|
+
"· gate GREEN, work wrong → refuse to close. Say what the gate cannot see, and fix the work.",
|
|
17
|
+
"· anything crashed → never edit a plan file by hand. Log it, block, stop.",
|
|
18
|
+
];
|
|
19
|
+
export const JUDGMENT_SHORT = "JUDGMENT OUTRANKS THE GATE: a red gate is never made to pass, a green gate is never trusted blind, a crash is never patched by hand. task check prints the rest.";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where planrails keeps things, and how it finds the project.
|
|
3
|
+
*
|
|
4
|
+
* One root, everything hangs off it. The root is, in order:
|
|
5
|
+
* 1. PLAN_PROJECT_ROOT — set by tests, which point it at a throwaway directory
|
|
6
|
+
* 2. CLAUDE_PROJECT_DIR — set by Claude Code for every hook command (measured on 2.1.269)
|
|
7
|
+
* 3. the nearest directory above the cwd holding .project-management/ or CLAUDE.md
|
|
8
|
+
* 4. the nearest directory above the cwd holding package.json or .git
|
|
9
|
+
* 5. the cwd
|
|
10
|
+
* So `npx planrails …` works from any subdirectory of a project, and a hook
|
|
11
|
+
* always sees the project the session was started in.
|
|
12
|
+
*/
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { dirname, join, resolve } from "node:path";
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
|
|
17
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
|
|
19
|
+
/** The installed package itself: src/plan/lib → ../../.. */
|
|
20
|
+
export function packageRoot() { return resolve(HERE, "..", "..", ".."); }
|
|
21
|
+
|
|
22
|
+
function findUp(start, markers) {
|
|
23
|
+
let d = resolve(start);
|
|
24
|
+
for (;;) {
|
|
25
|
+
if (markers.some((m) => existsSync(join(d, m)))) return d;
|
|
26
|
+
const up = dirname(d);
|
|
27
|
+
if (up === d) return null;
|
|
28
|
+
d = up;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function projectRoot() {
|
|
33
|
+
if (process.env.PLAN_PROJECT_ROOT) return resolve(process.env.PLAN_PROJECT_ROOT);
|
|
34
|
+
if (process.env.CLAUDE_PROJECT_DIR) return resolve(process.env.CLAUDE_PROJECT_DIR);
|
|
35
|
+
return findUp(process.cwd(), [".project-management", "CLAUDE.md"]) || findUp(process.cwd(), ["package.json", ".git"]) || process.cwd();
|
|
36
|
+
}
|
|
37
|
+
/** How the CLI is invoked in messages the tool prints for the agent. */
|
|
38
|
+
export function cliName() { return process.env.PLAN_CLI_NAME || "npx planrails"; }
|
|
39
|
+
|
|
40
|
+
export function plansRoot() { return join(projectRoot(), ".project-management", "plans"); }
|
|
41
|
+
export function planDir(id) { return join(plansRoot(), id); }
|
|
42
|
+
export function claudeMdPath() { return join(projectRoot(), "CLAUDE.md"); }
|
|
43
|
+
export function claudeSettingsPath() { return join(projectRoot(), ".claude", "settings.json"); }
|
|
44
|
+
/** Everything planrails writes that is NOT the plan itself lives under .planrails/ (gitignored by init). */
|
|
45
|
+
export function stateRoot() { return join(projectRoot(), ".planrails"); }
|
|
46
|
+
/** Per-session scratch for the hooks: injected rules, unlogged edits, reminders. */
|
|
47
|
+
export function hookStateRoot() { return join(stateRoot(), "hooks"); }
|
|
48
|
+
/** The pre-compaction progress journals, one file per session. */
|
|
49
|
+
export function journalRoot() { return join(stateRoot(), "journal"); }
|
|
50
|
+
/** Backups of .claude/settings.json taken before the installer writes it. */
|
|
51
|
+
export function backupRoot() { return join(stateRoot(), "backups"); }
|
|
52
|
+
/** What `planrails run` recorded about each fresh session. */
|
|
53
|
+
export function runsRoot() { return join(stateRoot(), "runs"); }
|
|
54
|
+
|
|
55
|
+
/** The files of one plan. Each has ONE job and ONE writer (see docs/PLANNING_GUIDE.md § The files). */
|
|
56
|
+
export const FILES = {
|
|
57
|
+
planMd: "PLAN.md", // narrative — hand-written, stable
|
|
58
|
+
state: "state.json", // manifest + tasks — CLI-written after creation
|
|
59
|
+
gates: "gates.json", // gate definitions — planner-written, validated
|
|
60
|
+
rules: "rules.json", // just-in-time rules — planner-written, validated
|
|
61
|
+
log: "log.jsonl", // progress — CLI append
|
|
62
|
+
learnings: "learnings.jsonl", // CLI append
|
|
63
|
+
decisions: "decisions.jsonl", // CLI append
|
|
64
|
+
gateRuns: "gate-runs.jsonl", // CLI append — every gate execution
|
|
65
|
+
};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape of every plan file, declared ONCE.
|
|
3
|
+
*
|
|
4
|
+
* Why strict objects: an unregistered key is refused, the same rule the corpus
|
|
5
|
+
* gate applies (scripts/corpus/structure.mjs). Before that rule, a field could
|
|
6
|
+
* appear in one book and be invisible in the other thirty. Same risk here: a
|
|
7
|
+
* plan that spells "evidence" as "proof" would pass a loose schema and the
|
|
8
|
+
* done-needs-evidence check would never see it.
|
|
9
|
+
*
|
|
10
|
+
* Why the minimum lengths: a task titled "fix" and a gate whose question is
|
|
11
|
+
* "works?" are the shapes that let a completion claim through unread.
|
|
12
|
+
*/
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
|
|
15
|
+
export const PLAN_ID = z.string().regex(/^[a-z0-9][a-z0-9-]{1,63}$/, "kebab-case, 2–64 chars");
|
|
16
|
+
export const TASK_ID = z.string().regex(/^T\d+$/, "T1, T2 …");
|
|
17
|
+
export const GATE_ID = z.string().regex(/^G\d+$/, "G1, G2 …");
|
|
18
|
+
export const RULE_ID = z.string().regex(/^R\d+$/, "R1, R2 …");
|
|
19
|
+
export const LEARNING_ID = z.string().regex(/^L\d+$/, "L1, L2 …");
|
|
20
|
+
export const DECISION_ID = z.string().regex(/^D\d+$/, "D1, D2 …");
|
|
21
|
+
const ISO = z.iso.datetime({ offset: true });
|
|
22
|
+
const DATE = z.iso.date();
|
|
23
|
+
|
|
24
|
+
export const EFFORT = z.enum(["max", "xhigh", "high"]);
|
|
25
|
+
export const TASK_STATUS = z.enum(["todo", "doing", "done", "blocked", "dropped"]);
|
|
26
|
+
export const PLAN_STATUS = z.enum(["draft", "active", "paused", "done", "abandoned"]);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* How a task got to `done`. A gate run the CLI recorded, or a person's word
|
|
30
|
+
* with a reason. Nothing else. `by: "owner"` means the executor asked the
|
|
31
|
+
* owner in chat and the owner said yes — the guide says so, the schema
|
|
32
|
+
* cannot check it.
|
|
33
|
+
*/
|
|
34
|
+
export const Evidence = z.discriminatedUnion("kind", [
|
|
35
|
+
z.strictObject({ kind: z.literal("gate"), gate: GATE_ID, runId: z.string().min(8), at: ISO }),
|
|
36
|
+
z.strictObject({
|
|
37
|
+
kind: z.literal("manual"),
|
|
38
|
+
by: z.enum(["owner", "agent"]),
|
|
39
|
+
at: ISO,
|
|
40
|
+
reason: z.string().min(20, "say what was checked and how (≥ 20 chars)"),
|
|
41
|
+
}),
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
export const STATEMENT_ID = z.string().regex(/^C\d+$/, "C1, C2 …");
|
|
45
|
+
export const STATEMENT_KIND = z.enum(["auto:gate", "auto:logged", "auto:files", "manual"]);
|
|
46
|
+
/** A condition of done. auto:* kinds are evaluated by the CLI; manual ones need an answer in words. */
|
|
47
|
+
/** since: when a plan-level condition was added with `plan condition add`; a task closed before then is not held to it. Absent on the defaults. */
|
|
48
|
+
export const Statement = z.strictObject({ id: STATEMENT_ID, statement: z.string().min(10), kind: STATEMENT_KIND, since: ISO.nullable().optional() });
|
|
49
|
+
/** What was actually answered when the task was closed. Stored on the task so `plan review` can show it. */
|
|
50
|
+
export const ChecklistEntry = z.strictObject({ id: STATEMENT_ID, statement: z.string().min(10), kind: STATEMENT_KIND, answer: z.string().min(10, "answer in at least 10 characters — say what you checked"), at: ISO });
|
|
51
|
+
/** Every plan starts with these; a plan may edit them, a task may add its own. */
|
|
52
|
+
export const DEFAULT_DONE_WHEN = [
|
|
53
|
+
{ id: "C1", statement: "The task's gate ran in this very command and passed — or a manual reason names who checked it and how.", kind: "auto:gate" },
|
|
54
|
+
{ id: "C2", statement: "A log entry names this task and was written after it started, saying what landed and what comes next.", kind: "auto:logged" },
|
|
55
|
+
{ id: "C3", statement: "Every file this task lists exists on disk.", kind: "auto:files" },
|
|
56
|
+
{ id: "C4", statement: "The docs that describe this change shipped in the same change — name them, or say why none were needed.", kind: "manual" },
|
|
57
|
+
{ id: "C5", statement: "Every number in the log entry and in any report carries a locator (file:line, page, verse) or was re-derived — say which.", kind: "manual" },
|
|
58
|
+
{ id: "C6", statement: "Anything learned or decided while doing this is recorded with plan learn / plan decide — name the ids, or say nothing was.", kind: "manual" },
|
|
59
|
+
{ id: "C7", statement: "You read the changed files whole, against the task's purpose and the app they live in, and judged the result right yourself — say what you read, what you looked for, and what the gate could not see.", kind: "manual" },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
export const Task = z.strictObject({
|
|
63
|
+
id: TASK_ID,
|
|
64
|
+
title: z.string().min(8),
|
|
65
|
+
status: TASK_STATUS,
|
|
66
|
+
/** The gate whose pass means done. null only when manualCheck says who verifies and how. */
|
|
67
|
+
gate: GATE_ID.nullable(),
|
|
68
|
+
manualCheck: z.string().min(12).nullable(),
|
|
69
|
+
files: z.array(z.string()),
|
|
70
|
+
effort: EFFORT.nullable(),
|
|
71
|
+
dependsOn: z.array(TASK_ID),
|
|
72
|
+
notes: z.string(),
|
|
73
|
+
startedAt: ISO.nullable(),
|
|
74
|
+
doneAt: ISO.nullable(),
|
|
75
|
+
evidence: Evidence.nullable(),
|
|
76
|
+
blocked: z.strictObject({ reason: z.string().min(8), since: ISO, needs: z.enum(["owner", "external", "self"]) }).nullable(),
|
|
77
|
+
/** Task-specific conditions of done, on top of the plan's doneWhen. */
|
|
78
|
+
doneWhen: z.array(Statement).default([]),
|
|
79
|
+
/** The answers given when the task was closed. Empty on a task that is not done. */
|
|
80
|
+
doneChecklist: z.array(ChecklistEntry).default([]),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
export const State = z.strictObject({
|
|
84
|
+
id: PLAN_ID,
|
|
85
|
+
title: z.string().min(8),
|
|
86
|
+
status: PLAN_STATUS,
|
|
87
|
+
created: DATE,
|
|
88
|
+
activatedAt: ISO.nullable(),
|
|
89
|
+
closedAt: ISO.nullable(),
|
|
90
|
+
/** Repo-relative globs the plan's work lands in. The Stop hook watches them. */
|
|
91
|
+
paths: z.array(z.string().min(1)),
|
|
92
|
+
/** Conditions every task must satisfy before `task done` accepts it. Seeded with DEFAULT_DONE_WHEN. */
|
|
93
|
+
doneWhen: z.array(Statement).default(() => structuredClone(DEFAULT_DONE_WHEN)),
|
|
94
|
+
tasks: z.array(Task),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
export const Gate = z.strictObject({
|
|
98
|
+
id: GATE_ID,
|
|
99
|
+
/** What the command LITERALLY answers. Not what you hope it answers. */
|
|
100
|
+
question: z.string().min(20),
|
|
101
|
+
/** The nearby question it does NOT answer — the one a reader will assume it does. */
|
|
102
|
+
notTheSameAs: z.string().min(10),
|
|
103
|
+
command: z.string().min(3),
|
|
104
|
+
passWhen: z.union([z.literal("exit0"), z.strictObject({ stdoutMatches: z.string().min(1) })]),
|
|
105
|
+
timeoutSec: z.number().int().positive().max(3600),
|
|
106
|
+
/** A case that MUST fail. Run by `gate verify`. A gate that has never failed has never been tested. */
|
|
107
|
+
/** expectExit: the exit code the failing case must produce (default: any non-zero). A usage error (127, 2) must not count as "the gate saw the problem". */
|
|
108
|
+
knownFail: z.strictObject({ command: z.string().min(3), description: z.string().min(10), expectExit: z.number().int().nullable().optional() }).nullable(),
|
|
109
|
+
/** The honest answer to "what would still pass if the thing this protects broke?" */
|
|
110
|
+
couldPassWhileWrongIf: z.string().min(20),
|
|
111
|
+
/** static reads files · runtime executes code · reality observes the live system · report never fails, only informs */
|
|
112
|
+
kind: z.enum(["static", "runtime", "reality", "report"]),
|
|
113
|
+
});
|
|
114
|
+
export const Gates = z.strictObject({ gates: z.array(Gate) });
|
|
115
|
+
|
|
116
|
+
/** When a rule fires. `tool` is a regex over tool names; the others narrow it. */
|
|
117
|
+
export const RuleWhen = z.strictObject({
|
|
118
|
+
tool: z.string().min(1),
|
|
119
|
+
path: z.string().min(1).optional(), // glob, repo-relative, for Edit/Write/Read/NotebookEdit file_path
|
|
120
|
+
command: z.string().min(1).optional(), // regex, for Bash command
|
|
121
|
+
prompt: z.string().min(1).optional(), // regex, for Agent prompt / Workflow script
|
|
122
|
+
});
|
|
123
|
+
export const Rule = z.strictObject({
|
|
124
|
+
id: RULE_ID,
|
|
125
|
+
when: RuleWhen,
|
|
126
|
+
/** ≤ 1200 chars (~300 tokens). Longer belongs in `file`, and a file should still be short. */
|
|
127
|
+
text: z.string().min(10).max(1200).optional(),
|
|
128
|
+
file: z.string().min(1).optional(),
|
|
129
|
+
/** once per session (default) · always · every:N calls */
|
|
130
|
+
repeat: z.union([z.literal("once"), z.literal("always"), z.string().regex(/^every:\d+$/)]).default("once"),
|
|
131
|
+
/** The case that taught it. Kept for the reader; not injected. */
|
|
132
|
+
why: z.string().min(10),
|
|
133
|
+
learning: LEARNING_ID.nullable(),
|
|
134
|
+
}).refine((r) => Boolean(r.text) !== Boolean(r.file), { message: "a rule has exactly one of text or file" });
|
|
135
|
+
export const Rules = z.strictObject({ rules: z.array(Rule) });
|
|
136
|
+
|
|
137
|
+
export const LogEntry = z.strictObject({
|
|
138
|
+
at: ISO,
|
|
139
|
+
session: z.string().nullable(),
|
|
140
|
+
task: TASK_ID.nullable(),
|
|
141
|
+
what: z.string().min(10),
|
|
142
|
+
/** The exact next action, executable by a stranger. This is what the brief shows as RESUME. */
|
|
143
|
+
next: z.string().min(5),
|
|
144
|
+
refs: z.array(z.string()),
|
|
145
|
+
uncommitted: z.string().nullable(),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
export const Learning = z.strictObject({
|
|
149
|
+
id: LEARNING_ID,
|
|
150
|
+
at: ISO,
|
|
151
|
+
session: z.string().nullable(),
|
|
152
|
+
task: TASK_ID.nullable(),
|
|
153
|
+
/** The case, with its numbers and paths. */
|
|
154
|
+
what: z.string().min(20),
|
|
155
|
+
/** The general rule it teaches. */
|
|
156
|
+
rule: z.string().min(10),
|
|
157
|
+
appliesWhen: z.string().min(5),
|
|
158
|
+
/** prose = must be read · rule = injected by the hook (rules.json) · gate = machine-checked · docs = written into a guide */
|
|
159
|
+
enforcement: z.enum(["prose", "rule", "gate", "docs"]),
|
|
160
|
+
ref: z.string().nullable(),
|
|
161
|
+
/** lead = needs a second reader before anyone acts on it */
|
|
162
|
+
status: z.enum(["lead", "confirmed", "retracted"]),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
export const Decision = z.strictObject({
|
|
166
|
+
id: DECISION_ID,
|
|
167
|
+
at: ISO,
|
|
168
|
+
session: z.string().nullable(),
|
|
169
|
+
task: TASK_ID.nullable(),
|
|
170
|
+
decision: z.string().min(10),
|
|
171
|
+
why: z.string().min(10),
|
|
172
|
+
rejected: z.array(z.strictObject({ option: z.string().min(1), why: z.string().min(1) })),
|
|
173
|
+
by: z.enum(["owner", "agent"]),
|
|
174
|
+
supersedes: DECISION_ID.nullable(),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
export const GateRun = z.strictObject({
|
|
178
|
+
runId: z.string().min(8),
|
|
179
|
+
gate: GATE_ID,
|
|
180
|
+
/** run = the gate itself · verify = its knownFail case (which must FAIL) */
|
|
181
|
+
kind: z.enum(["run", "verify"]),
|
|
182
|
+
at: ISO,
|
|
183
|
+
session: z.string().nullable(),
|
|
184
|
+
command: z.string(),
|
|
185
|
+
exit: z.number().int().nullable(),
|
|
186
|
+
durationMs: z.number().int().nonnegative(),
|
|
187
|
+
result: z.enum(["pass", "fail", "error"]),
|
|
188
|
+
/** Last lines of output — enough to see WHY, never the whole log. */
|
|
189
|
+
tail: z.string().max(4000),
|
|
190
|
+
/** verify runs only: the gate's MAIN command at the time it was verified. A gate whose command changed since is not verified until gate verify runs again. */
|
|
191
|
+
gateCommand: z.string().nullable().optional(),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
export const SCHEMAS = { State, Gates, Rules, LogEntry, Learning, Decision, GateRun, Task, Gate, Rule, Statement, ChecklistEntry };
|
|
195
|
+
|
|
196
|
+
/** "state.json › tasks[2].evidence: expected …" — one line per issue, paths a reader can follow. */
|
|
197
|
+
export function formatIssues(file, error) {
|
|
198
|
+
return error.issues.map((i) => `${file} › ${i.path.length ? i.path.join(".") : "(root)"}: ${i.message}`);
|
|
199
|
+
}
|