ikanban 0.4.9 → 0.5.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.
@@ -0,0 +1,221 @@
1
+ // ikanban plan check FILE [--done]: one shared definition of "a plan that passes" (plan 7 P1).
2
+ // The plan gate (before code), the deploy and merge gate (--done) and the runner's plan stage
3
+ // all call it. The plan file is repository input: it is parsed as JSON and never run.
4
+ const fs = require("node:fs");
5
+ // Only the status words at load: the plan gate runs check() from a plain checkout. The
6
+ // network parts (roadmap.js, ikanban-safe.js) load when --done --task needs them.
7
+ const { TASK_STATUS, PHASE_STATUS } = require("./statuswords.js");
8
+
9
+ // The Roadmap sync's own words, plus the two it reads as "waiting".
10
+ const PHASE_WORDS = new Set([...Object.keys(PHASE_STATUS), "pending", "waiting"]);
11
+ const SHORT_KINDS = new Set(["bug", "chore"]);
12
+ const GUIDE = "wiki/06-Templates/0-Shared/3-Feature-Plan/feature-plan-guide.md (in the kit: .iKanban/prompts/feature-plan.md)";
13
+
14
+ // An expect names something a reader can compare: a number or status code, a quoted
15
+ // string, a file or a URL. "is correct" names nothing.
16
+ const EXACT = /\d|["'`“”‘’]|https?:\/\/|\/|\.[a-z]{1,5}\b/i;
17
+ // Evidence is what was seen: output with a number, a status code, a link or a file.
18
+ const SEEN = /\d|https?:\/\/|\/|\.[a-z]{1,5}\b/i;
19
+ const EMPTY_WORDS = /^(ok|okay|done|pass(ed)?|works|working|yes|verified|checked|fine)\.?$/i;
20
+
21
+ const text = (v) => (typeof v === "string" ? v.trim() : "");
22
+
23
+ /** Every problem in `plan`, each with where it is, what is wrong and the fix. */
24
+ function check(plan, { done = false, merge = null } = {}) {
25
+ const errors = [];
26
+ const add = (where, problem, fix) => errors.push({ where, problem, fix });
27
+ if (!plan || typeof plan !== "object" || Array.isArray(plan)) {
28
+ add("plan", "not a JSON object", `start from the template in ${GUIDE}`);
29
+ return { errors, summary: null };
30
+ }
31
+ const short = SHORT_KINDS.has(text(plan.kind).toLowerCase());
32
+ const phases = Array.isArray(plan.phases) ? plan.phases : [];
33
+ if (!short && plan.schema_version !== 2) {
34
+ add("schema_version", `is ${JSON.stringify(plan.schema_version ?? null)}, not 2`,
35
+ `set "schema_version": 2 and use the template's sections, or "kind": "bug" or "chore" for a short plan`);
36
+ }
37
+ if (phases.length === 0) add("phases", "none", "add at least one phase with tasks and criteria");
38
+ if (short && phases.length !== 1) {
39
+ add("phases", `a short plan (kind ${plan.kind}) has one phase, this one has ${phases.length}`,
40
+ "keep one phase for a bug or chore, or write a full plan (schema_version 2)");
41
+ }
42
+
43
+ const criteria = new Map();
44
+ for (const ph of phases) {
45
+ const id = text(ph && ph.id) || "(phase with no id)";
46
+ const word = text(ph && ph.status).toLowerCase();
47
+ if (word && !PHASE_WORDS.has(word)) {
48
+ add(id, `status "${ph.status}" is not a status the Roadmap knows`, `use one of ${[...PHASE_WORDS].join(", ")}`);
49
+ }
50
+ for (const t of Array.isArray(ph && ph.tasks) ? ph.tasks : []) {
51
+ const tw = text(t && t.status || "pending").toLowerCase();
52
+ if (!TASK_STATUS[tw]) {
53
+ add(text(t && t.id) || `${id} task`, `status "${t.status}" is not a status the Roadmap knows`,
54
+ `use one of ${Object.keys(TASK_STATUS).join(", ")}`);
55
+ }
56
+ }
57
+ const list = Array.isArray(ph && ph.criteria) ? ph.criteria : [];
58
+ if (list.length === 0) {
59
+ add(id, "no criteria", "add at least one criterion with check, how (the exact test, command or page) and expect (the exact result)");
60
+ }
61
+ for (const c of list) {
62
+ const cid = text(c && c.id) || `${id} criterion`;
63
+ criteria.set(cid, c);
64
+ const claim = text(c && c.check).replace(/^\[[ xX]?\]\s*/, "");
65
+ if (/^works\.?$/i.test(claim) || /^it works\.?$/i.test(claim)) {
66
+ add(cid, "the check says only 'works'", "say what is true when it works: the result a test, command or page shows");
67
+ }
68
+ if (!text(c && c.how)) add(cid, "no how", "name the exact test file, command, API call or page and its steps");
69
+ const expect = text(c && c.expect);
70
+ if (!expect) add(cid, "no expect", "write the exact result: output, status code, field or what the page shows");
71
+ else if (!EXACT.test(expect)) {
72
+ add(cid, `expect "${expect}" names nothing a reader can compare`,
73
+ "name a number, a status code, an exact string in quotes, a file or a URL");
74
+ }
75
+ if (done) {
76
+ const ev = text(c && c.evidence);
77
+ if (!ev) add(cid, "no evidence", "run the check and paste what it showed (output, status code, URL or file)");
78
+ else if (EMPTY_WORDS.test(ev) || !SEEN.test(ev)) {
79
+ add(cid, `evidence "${ev}" shows nothing that was seen`, "paste the command output, status code, URL or file that proves it");
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ // A decision written as a sentence is a recorded answer (older plans); an object with a
86
+ // question is open until its "decision" holds the answer.
87
+ for (const d of Array.isArray(plan.decisions) ? plan.decisions : []) {
88
+ if (d && typeof d === "object" && (d.decision === null || d.decision === undefined || text(String(d.decision)) === "")) {
89
+ add(text(d.id) || "decision", "not answered", "ask the owner and write the answer in \"decision\" before building");
90
+ }
91
+ }
92
+ for (const k of Array.isArray(plan.claims) ? plan.claims : []) {
93
+ const ids = text(k && k.prove_by).match(/\b[A-Z]+\d+-C\d+\b/g) || [];
94
+ const missing = ids.filter((x) => !criteria.has(x));
95
+ if (ids.length === 0) add(text(k && k.id) || "claim", "no criterion proves it", "set prove_by to the criterion ids that prove the claim, e.g. P2-C1");
96
+ else if (missing.length) add(text(k.id) || "claim", `prove_by names ${missing.join(", ")}, which no phase has`, "fix the ids or add those criteria");
97
+ }
98
+ if (done) {
99
+ const gaps = (plan.critic && plan.critic.before_done && plan.critic.before_done.gaps) || [];
100
+ for (const g of Array.isArray(gaps) ? gaps : []) {
101
+ const st = text(g && g.status).toLowerCase();
102
+ if (st !== "closed" && st !== "accepted") {
103
+ add(text(g && g.id) || "critic gap", "critic gap still open", "close it with proof, or have the owner accept it with a reason");
104
+ }
105
+ }
106
+ }
107
+ // D6: before a merge or deploy, the task's own phase proves every criterion a test or a
108
+ // command can prove; criteria proved on dev (browser, api) get their evidence after the deploy.
109
+ // P5-C4: `merge: true` (no phase named) asks it of every phase, as the runner does for a task's plan.
110
+ if (merge) {
111
+ const ph = merge === true ? null : phases.find((x) => text(x && x.id) === merge);
112
+ if (merge !== true && !ph) {
113
+ add("phase", `no phase ${merge} in this plan`, `set "phase" in the branch's plan pointer to one of ${phases.map((x) => x.id).join(", ")}`);
114
+ return { errors, summary: null };
115
+ }
116
+ const list = (ph ? [ph] : phases).flatMap((x) => (Array.isArray(x && x.criteria) ? x.criteria : []));
117
+ const before = list.filter((c) => ["test", "command"].includes(text(c && c.kind).toLowerCase()));
118
+ for (const c of before) {
119
+ const ev = text(c.evidence);
120
+ if (!ev) add(text(c.id), "no evidence", "run the test or command and paste what it showed, before the merge");
121
+ else if (EMPTY_WORDS.test(ev) || !SEEN.test(ev)) add(text(c.id), `evidence "${ev}" shows nothing that was seen`, "paste the output, status code or file that proves it");
122
+ }
123
+ if (!errors.length) {
124
+ return { errors, summary: `merge ok: ${merge === true ? "every phase" : merge}, ${before.length} of ${list.length} criteria proved before the deploy, ${list.length - before.length} on dev after` };
125
+ }
126
+ }
127
+ const n = [...criteria.keys()].length;
128
+ return { errors, summary: errors.length ? null : `plan ok: ${phases.length} ${phases.length === 1 ? "phase" : "phases"}, ${n} ${n === 1 ? "criterion" : "criteria"}` };
129
+ }
130
+
131
+ /** The shared fetch helper: copied into lib/ at pack and test time; in a plain checkout it is
132
+ * read from the kit, where it lives (.iKanban/hooks/ikanban-safe.js). */
133
+ function safe() {
134
+ try {
135
+ return require("./ikanban-safe.js");
136
+ } catch {
137
+ return require("../../.iKanban/hooks/ikanban-safe.js");
138
+ }
139
+ }
140
+
141
+ /** "--task IKA-1" → { task: "IKA-1" }; flags without a value are true. */
142
+ function flags(args) {
143
+ const out = {};
144
+ for (let i = 0; i < args.length; i++) {
145
+ if (!args[i].startsWith("--")) continue;
146
+ const next = args[i + 1];
147
+ out[args[i].slice(2)] = next && !next.startsWith("--") ? args[++i] : true;
148
+ }
149
+ return out;
150
+ }
151
+
152
+ /** D5: the task's open "before Done" gaps on iKanban (the runner's critic files them there).
153
+ * Fails closed: no key, no team or no answer is a problem, never a pass. */
154
+ async function serverGaps(file, plan, opts, stages = ["before_done"]) {
155
+ const { connection } = require("./roadmap.js");
156
+ const { ikanbanFetch } = safe();
157
+ const c = connection({ token: opts.token, "base-url": opts["base-url"] }, file);
158
+ const team = opts.team || c.team || (plan.roadmap && plan.roadmap.team) || "";
159
+ const task = String(opts.task);
160
+ if (!c.token) return [{ where: task, problem: "no API key to read its critic gaps", fix: "set VIBE_API_TOKEN, pass --token, or set api_key in .iKanban/settings-local.json" }];
161
+ if (!/^[0-9a-f-]{36}$/i.test(team)) return [{ where: task, problem: `team "${team}" is not a team id`, fix: "pass --team <team id> or set team in .iKanban/settings-local.json" }];
162
+ let res;
163
+ try {
164
+ res = await ikanbanFetch(`/api/teams/${team}/tasks/${encodeURIComponent(task)}/critic-reviews`, { base: c.base, token: c.token });
165
+ } catch (e) {
166
+ return [{ where: task, problem: `critic gaps not read: ${e.message}`, fix: "check the network and the API address, then run the check again" }];
167
+ }
168
+ const body = await res.json().catch(() => ({}));
169
+ if (!res.ok || body.success === false) {
170
+ return [{ where: task, problem: `critic gaps not read: HTTP ${res.status} ${body.message || ""}`.trim(), fix: "check the task key and that the key's account is in the team" }];
171
+ }
172
+ const gaps = (body.data.reviews || []).filter((rv) => stages.includes(rv.stage))
173
+ .flatMap((rv) => rv.gaps || []).filter((g) => g.state === "open");
174
+ return gaps.map((g) => ({ where: `${task} gap ${g.id}`, problem: `${g.gap || "critic gap"} (still open on iKanban)`, fix: "close it with proof, or have the owner accept it with a reason (MCP close_critic_gap / accept_critic_gap)" }));
175
+ }
176
+
177
+ /** `ikanban plan check FILE [--done [--task KEY]]`: exit 0 with the summary, 1 with each problem. */
178
+ function run(args) {
179
+ const [sub, ...rest] = args;
180
+ const file = rest.find((a) => !a.startsWith("--"));
181
+ if (sub !== "check" || !file || rest.includes("--help")) {
182
+ console.log("Usage: ikanban plan check FILE [--done [--task KEY] [--team ID]]\n --done also every criterion has evidence and every before_done critic gap is closed or accepted\n --task with --done: also the task's open before_done gaps on iKanban (the runner's critic)");
183
+ return sub === "check" && file ? 0 : 1;
184
+ }
185
+ let raw;
186
+ try {
187
+ raw = fs.readFileSync(file, "utf8");
188
+ } catch {
189
+ console.error(`plan file not found: ${file}\nWrite it from the template: ${GUIDE}`);
190
+ return 1;
191
+ }
192
+ let plan;
193
+ try {
194
+ plan = JSON.parse(raw);
195
+ } catch (e) {
196
+ console.error(`${file} is not valid JSON (${e.message}). Fix it, then run: ikanban plan check ${file}`);
197
+ return 1;
198
+ }
199
+ const opts = flags(rest);
200
+ const merge = typeof opts.merge === "string" ? opts.merge : typeof opts.phase === "string" && opts.merge ? opts.phase : opts.merge === true ? true : null;
201
+ const r = check(plan, { done: Boolean(opts.done), merge });
202
+ const report = (errors, summary) => {
203
+ if (errors.length) {
204
+ for (const e of errors) console.error(`${e.where}: ${e.problem}. Fix: ${e.fix}.`);
205
+ console.error(`${errors.length} ${errors.length === 1 ? "problem" : "problems"} in ${file}. Guide: ${GUIDE}`);
206
+ return 1;
207
+ }
208
+ console.log(summary);
209
+ return 0;
210
+ };
211
+ if (merge && typeof opts.task === "string") {
212
+ // D6: any open critic gap of the task blocks the merge, before the build or before Done.
213
+ return serverGaps(file, plan, opts, ["before_build", "before_done"]).then((gaps) =>
214
+ report([...r.errors, ...gaps], `${r.summary}; ${opts.task}: 0 open critic gaps on iKanban`));
215
+ }
216
+ if (!(opts.done && typeof opts.task === "string")) return report(r.errors, r.summary);
217
+ return serverGaps(file, plan, opts).then((gaps) =>
218
+ report([...r.errors, ...gaps], `${r.summary}; ${opts.task}: 0 open critic gaps on iKanban`));
219
+ }
220
+
221
+ module.exports = { check, run };
package/lib/roadmap.js CHANGED
@@ -6,19 +6,22 @@
6
6
  // "roadmap": {"team": "<team id or identifier>", "plan_id": "<plan feature id>",
7
7
  // "project": "<project id or name for new tasks>", "name": "<plan name>"}
8
8
  // plan_id is written back after the first sync creates the plan. Phases:
9
- // [{id, title, status, depends_on, tasks: [{id, description|title, status}]}]
9
+ // [{id, title, status, depends_on, criteria: [...], tasks: [{id, description|title, status}]}]
10
10
  const fs = require("node:fs");
11
11
  const path = require("node:path");
12
- const { runCommand, ikanbanFetch, apiOrigin } = require("./ikanban-safe.js");
12
+ // Copied into lib/ at pack and test time; a plain checkout reads it from the kit (plan 7: the
13
+ // gates run `ikanban plan check` there).
14
+ const { runCommand, ikanbanFetch, apiOrigin } = (() => {
15
+ try {
16
+ return require("./ikanban-safe.js");
17
+ } catch {
18
+ return require("../../.iKanban/hooks/ikanban-safe.js");
19
+ }
20
+ })();
13
21
 
14
- const TASK_STATUS = {
15
- done: "done", complete: "done", completed: "done",
16
- in_progress: "inprogress", inprogress: "inprogress", "in-progress": "inprogress", started: "inprogress",
17
- in_review: "inreview", inreview: "inreview", review: "inreview",
18
- pending: "todo", todo: "todo", waiting: "todo", deferred: "todo", blocked: "todo",
19
- cancelled: "cancelled", canceled: "cancelled", skipped: "cancelled", dropped: "cancelled",
20
- };
21
- const PHASE_STATUS = { done: "done", in_progress: "in_progress", inprogress: "in_progress", "in-progress": "in_progress" };
22
+ const { TASK_STATUS, PHASE_STATUS } = require("./statuswords.js");
23
+ // Plan 34 R3-T6: the words a plan file uses to hold a task on purpose.
24
+ const HOLD_WORDS = new Set(["blocked", "deferred", "waiting"]);
22
25
 
23
26
  /** First clause of a task text, cut at a word; no links or email addresses. */
24
27
  function short(text, limit = 60) {
@@ -47,13 +50,19 @@ function toDocument(plan) {
47
50
  // Only an explicit "name" renames an existing phase; a name made from the
48
51
  // title only names a new one, so a rename on the Roadmap (the pencil) stays.
49
52
  rename: Boolean(ph.name),
53
+ // IKA-2675: only the count goes; the server warns for a phase with none. Old
54
+ // "acceptance_criteria" strings do not count, as in `ikanban plan check`.
55
+ criteria: Array.isArray(ph.criteria) ? ph.criteria.length : 0,
50
56
  tasks: (ph.tasks || []).map((t) => {
51
57
  const word = String(t.status || "pending").toLowerCase();
52
58
  let status = TASK_STATUS[word];
53
59
  if (!status) errors.push(`task ${t.id}: unknown status "${t.status}"`);
54
60
  // A deferred task in a phase the plan calls done is out of its scope.
55
61
  if (word === "deferred" && phaseWord === "done") status = "cancelled";
56
- return { key: t.id, title: `${t.id}: ${short(t.title || t.description)}`, status, rename: Boolean(t.title) };
62
+ // Plan 34 R3-T6: a task held on purpose may go back to To do on the Roadmap; a file that
63
+ // only says less ("pending", "todo") never pulls a task back.
64
+ const hold = status === "todo" && HOLD_WORDS.has(word);
65
+ return { key: t.id, title: `${t.id}: ${short(t.title || t.description)}`, status, rename: Boolean(t.title), hold };
57
66
  }),
58
67
  };
59
68
  });
@@ -171,12 +180,20 @@ function mergeJson(file, update) {
171
180
  fs.writeFileSync(file, JSON.stringify(next, null, 2) + "\n");
172
181
  }
173
182
 
174
- /** Adds one entry to hooks[event]; an entry that already runs roadmap-sync.js is replaced
175
- * by it (IKA-2244: so a re-install fixes an older install's command), never doubled. */
176
- function addHook(settings, event, entry) {
183
+ // Plan 35 V1-T4: the deploy check runs after each shell command (it acts only on the deploy
184
+ // commands named in settings-local.json "deploy_check"). Not async: its answer is the context.
185
+ const DEPLOY_COMMAND = "node .iKanban/hooks/deploy-check.js";
186
+ const CLAUDE_DEPLOY_COMMAND = 'node "$CLAUDE_PROJECT_DIR/.iKanban/hooks/deploy-check.js"';
187
+ // Plan 7 P4-T3: before each edit and shell command, the task's plan must pass (no bypass, D2).
188
+ const GATE_COMMAND = "node .iKanban/hooks/plan-gate.js";
189
+ const CLAUDE_GATE_COMMAND = 'node "$CLAUDE_PROJECT_DIR/.iKanban/hooks/plan-gate.js"';
190
+
191
+ /** Adds one entry to hooks[event]; an entry that already runs the same script (`script`) is
192
+ * replaced by it (IKA-2244: so a re-install fixes an older install's command), never doubled. */
193
+ function addHook(settings, event, entry, script = "roadmap-sync.js") {
177
194
  settings.hooks = settings.hooks || {};
178
195
  const list = (settings.hooks[event] = settings.hooks[event] || []);
179
- const at = list.findIndex((e) => JSON.stringify(e).includes("roadmap-sync.js"));
196
+ const at = list.findIndex((e) => JSON.stringify(e).includes(script));
180
197
  if (at < 0) list.push(entry);
181
198
  else list[at] = entry;
182
199
  return settings;
@@ -184,14 +201,20 @@ function addHook(settings, event, entry) {
184
201
 
185
202
  const AGENTS = {
186
203
  claude: (root) => {
187
- mergeJson(path.join(root, ".claude", "settings.json"), (s) =>
188
- addHook(s, "PostToolUse", { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: CLAUDE_COMMAND, timeout: 90 }] }));
189
- return ".claude/settings.json (PostToolUse)";
204
+ mergeJson(path.join(root, ".claude", "settings.json"), (s) => {
205
+ addHook(s, "PostToolUse", { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: CLAUDE_COMMAND, timeout: 90 }] });
206
+ addHook(s, "PostToolUse", { matcher: "Bash", hooks: [{ type: "command", command: CLAUDE_DEPLOY_COMMAND, timeout: 20 }] }, "deploy-check.js");
207
+ return addHook(s, "PreToolUse", { matcher: "Edit|Write|MultiEdit|NotebookEdit|Bash|PowerShell", hooks: [{ type: "command", command: CLAUDE_GATE_COMMAND, timeout: 20 }] }, "plan-gate.js");
208
+ });
209
+ return ".claude/settings.json (PreToolUse, PostToolUse)";
190
210
  },
191
211
  gemini: (root) => {
192
- mergeJson(path.join(root, ".gemini", "settings.json"), (s) =>
193
- addHook(s, "AfterTool", { matcher: "write_file|replace", hooks: [{ name: "ikanban-roadmap-sync", type: "command", command: AGENT_COMMAND, timeout: 90000 }] }));
194
- return ".gemini/settings.json (AfterTool)";
212
+ mergeJson(path.join(root, ".gemini", "settings.json"), (s) => {
213
+ addHook(s, "AfterTool", { matcher: "write_file|replace", hooks: [{ name: "ikanban-roadmap-sync", type: "command", command: AGENT_COMMAND, timeout: 90000 }] });
214
+ addHook(s, "AfterTool", { matcher: "run_shell_command", hooks: [{ name: "ikanban-deploy-check", type: "command", command: DEPLOY_COMMAND, timeout: 20000 }] }, "deploy-check.js");
215
+ return addHook(s, "BeforeTool", { matcher: "write_file|replace|run_shell_command", hooks: [{ name: "ikanban-plan-gate", type: "command", command: GATE_COMMAND, timeout: 20000 }] }, "plan-gate.js");
216
+ });
217
+ return ".gemini/settings.json (BeforeTool, AfterTool)";
195
218
  },
196
219
  copilot: (root) => {
197
220
  const file = path.join(root, ".github", "hooks", "ikanban-roadmap.json");
@@ -199,7 +222,24 @@ const AGENTS = {
199
222
  s.version = 1;
200
223
  return addHook(s, "postToolUse", { type: "command", bash: AGENT_COMMAND, powershell: AGENT_COMMAND, cwd: ".", timeoutSec: 90 });
201
224
  });
202
- return ".github/hooks/ikanban-roadmap.json (postToolUse)";
225
+ mergeJson(path.join(root, ".github", "hooks", "ikanban-deploy-check.json"), (s) => {
226
+ s.version = 1;
227
+ return addHook(s, "postToolUse", { type: "command", bash: DEPLOY_COMMAND, powershell: DEPLOY_COMMAND, cwd: ".", timeoutSec: 20 }, "deploy-check.js");
228
+ });
229
+ mergeJson(path.join(root, ".github", "hooks", "ikanban-plan-gate.json"), (s) => {
230
+ s.version = 1;
231
+ return addHook(s, "preToolUse", { type: "command", matcher: "edit|create|bash|powershell", bash: GATE_COMMAND, powershell: GATE_COMMAND, cwd: ".", timeoutSec: 20 }, "plan-gate.js");
232
+ });
233
+ return ".github/hooks/ikanban-roadmap.json, ikanban-deploy-check.json (postToolUse) and ikanban-plan-gate.json (preToolUse)";
234
+ },
235
+ // Antigravity CLI (agy, where Gemini users went) reads .agents/hooks.json, named hooks, and runs
236
+ // them from .agents/ (antigravity.google/docs/hooks). Its edit tools are not listed, so ".*".
237
+ agy: (root) => {
238
+ mergeJson(path.join(root, ".agents", "hooks.json"), (s) => {
239
+ s["ikanban-plan-gate"] = { PreToolUse: [{ matcher: ".*", hooks: [{ type: "command", command: "node ../.iKanban/hooks/plan-gate.js", timeout: 20 }] }] };
240
+ return s;
241
+ });
242
+ return ".agents/hooks.json (PreToolUse, Antigravity)";
203
243
  },
204
244
  };
205
245
 
@@ -211,10 +251,15 @@ function installHooks(opts) {
211
251
  const script = path.join(root, ".iKanban", "hooks", "roadmap-sync.js");
212
252
  fs.mkdirSync(path.dirname(script), { recursive: true });
213
253
  // The hook runs its commands through ikanban-safe.js, so both files go together.
214
- for (const name of ["roadmap-sync.js", "ikanban-safe.js"]) {
254
+ for (const name of ["roadmap-sync.js", "deploy-check.js", "ikanban-safe.js", "plan-gate.js"]) {
215
255
  fs.copyFileSync(path.join(__dirname, "..", "hooks", name), path.join(path.dirname(script), name));
216
256
  done.push(`.iKanban/hooks/${name}`);
217
257
  }
258
+ // The plan gate checks with this package's own checker, so a repository needs no network for it.
259
+ for (const name of ["plancheck.js", "statuswords.js"]) {
260
+ fs.copyFileSync(path.join(__dirname, name), path.join(path.dirname(script), name));
261
+ done.push(`.iKanban/hooks/${name}`);
262
+ }
218
263
 
219
264
  const hooksDir = runCommand("git", ["rev-parse", "--git-path", "hooks"], { cwd: root, encoding: "utf8" }).stdout.trim();
220
265
  const prePush = path.resolve(root, hooksDir, "pre-push");
@@ -227,13 +272,15 @@ function installHooks(opts) {
227
272
  done.push("git pre-push hook");
228
273
 
229
274
  const wanted = opts.agent ? String(opts.agent).split(",") : Object.keys(AGENTS).filter((a) =>
230
- fs.existsSync(path.join(root, { claude: ".claude", gemini: ".gemini", copilot: ".github" }[a])));
275
+ fs.existsSync(path.join(root, { claude: ".claude", gemini: ".gemini", copilot: ".github", agy: ".agents" }[a])));
231
276
  for (const a of wanted) {
232
- if (!AGENTS[a]) throw new Error(`unknown agent "${a}" (claude, gemini, copilot)`);
277
+ if (!AGENTS[a]) throw new Error(`unknown agent "${a}" (claude, gemini, copilot, agy)`);
233
278
  done.push(AGENTS[a](root));
234
279
  }
235
280
  for (const d of done) console.log(` installed: ${d}`);
236
281
  console.log("Plan files (JSON with a top-level \"roadmap\" block) now reach the Roadmap on every agent write and every git push.");
282
+ console.log("Before an edit or a writing shell command, the plan gate needs the task's plan, docs/plans/<TASK-KEY>.json (the key from the branch name), to pass: node .iKanban/hooks/plan-gate.js check docs/plans/<TASK-KEY>.json. How to write one: .iKanban/prompts/feature-plan.md.");
283
+ console.log("After a deploy, the deploy check reads your site's build marker; name your deploy commands in .iKanban/settings-local.json \"deploy_check\" (README).");
237
284
  // IKA-2244: the sync's default server is prod; say which one this repository will use.
238
285
  const base = connection({}, path.join(root, "plan.json")).base;
239
286
  console.log(` server: ${base} (set api_url in .iKanban/settings-local.json, or IKANBAN_BASE_URL, to use another)`);
@@ -290,4 +337,4 @@ async function run(cmd, args) {
290
337
  return code;
291
338
  }
292
339
 
293
- module.exports = { run, toDocument, short, writePlanId, addHook, apiOrigin };
340
+ module.exports = { run, toDocument, short, writePlanId, addHook, apiOrigin, connection, TASK_STATUS, PHASE_STATUS };