ikanban 0.4.9 → 0.5.1

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,231 @@
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
+ // IKA-2839: --team, then the plan's own team, then the settings team (another team's plan
159
+ // was read from the settings team and refused with a 404).
160
+ let team = String(opts.team || (plan.roadmap && plan.roadmap.team) || c.team || "");
161
+ const task = String(opts.task);
162
+ 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" }];
163
+ let res;
164
+ try {
165
+ // The route takes only a team id: an identifier such as "TUT" is looked up among the key's teams.
166
+ if (team && !/^[0-9a-f-]{36}$/i.test(team)) {
167
+ const t = await ikanbanFetch("/api/teams", { base: c.base, token: c.token });
168
+ const body = await t.json().catch(() => ({}));
169
+ const hit = t.ok && Array.isArray(body.data) && body.data.find((x) => String(x.identifier || "").toLowerCase() === team.toLowerCase());
170
+ if (!hit) return [{ where: task, problem: `team "${team}" not found among your teams${t.ok ? "" : ` (HTTP ${t.status})`}`, fix: "set roadmap.team to a team identifier or id the key's account is in, or pass --team <team id>" }];
171
+ team = hit.id;
172
+ }
173
+ 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" }];
174
+ res = await ikanbanFetch(`/api/teams/${team}/tasks/${encodeURIComponent(task)}/critic-reviews`, { base: c.base, token: c.token });
175
+ } catch (e) {
176
+ return [{ where: task, problem: `critic gaps not read: ${e.message}`, fix: "check the network and the API address, then run the check again" }];
177
+ }
178
+ const body = await res.json().catch(() => ({}));
179
+ if (!res.ok || body.success === false) {
180
+ 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" }];
181
+ }
182
+ const gaps = (body.data.reviews || []).filter((rv) => stages.includes(rv.stage))
183
+ .flatMap((rv) => rv.gaps || []).filter((g) => g.state === "open");
184
+ 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)" }));
185
+ }
186
+
187
+ /** `ikanban plan check FILE [--done [--task KEY]]`: exit 0 with the summary, 1 with each problem. */
188
+ function run(args) {
189
+ const [sub, ...rest] = args;
190
+ const file = rest.find((a) => !a.startsWith("--"));
191
+ if (sub !== "check" || !file || rest.includes("--help")) {
192
+ 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)");
193
+ return sub === "check" && file ? 0 : 1;
194
+ }
195
+ let raw;
196
+ try {
197
+ raw = fs.readFileSync(file, "utf8");
198
+ } catch {
199
+ console.error(`plan file not found: ${file}\nWrite it from the template: ${GUIDE}`);
200
+ return 1;
201
+ }
202
+ let plan;
203
+ try {
204
+ plan = JSON.parse(raw);
205
+ } catch (e) {
206
+ console.error(`${file} is not valid JSON (${e.message}). Fix it, then run: ikanban plan check ${file}`);
207
+ return 1;
208
+ }
209
+ const opts = flags(rest);
210
+ const merge = typeof opts.merge === "string" ? opts.merge : typeof opts.phase === "string" && opts.merge ? opts.phase : opts.merge === true ? true : null;
211
+ const r = check(plan, { done: Boolean(opts.done), merge });
212
+ const report = (errors, summary) => {
213
+ if (errors.length) {
214
+ for (const e of errors) console.error(`${e.where}: ${e.problem}. Fix: ${e.fix}.`);
215
+ console.error(`${errors.length} ${errors.length === 1 ? "problem" : "problems"} in ${file}. Guide: ${GUIDE}`);
216
+ return 1;
217
+ }
218
+ console.log(summary);
219
+ return 0;
220
+ };
221
+ if (merge && typeof opts.task === "string") {
222
+ // D6: any open critic gap of the task blocks the merge, before the build or before Done.
223
+ return serverGaps(file, plan, opts, ["before_build", "before_done"]).then((gaps) =>
224
+ report([...r.errors, ...gaps], `${r.summary}; ${opts.task}: 0 open critic gaps on iKanban`));
225
+ }
226
+ if (!(opts.done && typeof opts.task === "string")) return report(r.errors, r.summary);
227
+ return serverGaps(file, plan, opts).then((gaps) =>
228
+ report([...r.errors, ...gaps], `${r.summary}; ${opts.task}: 0 open critic gaps on iKanban`));
229
+ }
230
+
231
+ 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
  });
@@ -65,6 +74,17 @@ function toDocument(plan) {
65
74
  };
66
75
  }
67
76
 
77
+ /** IKA-2870 (plan 45 P7): <name>.graph.json beside the plan, one view per phase. Only a regular file
78
+ * of at most 256 KB holding a JSON object; else no diagram, never a failed sync (the server checks). */
79
+ function readGraph(file) {
80
+ const g = file.replace(/\.json$/i, "") + ".graph.json";
81
+ try {
82
+ const st = fs.lstatSync(g);
83
+ const doc = st.isFile() && st.size <= 256 * 1024 ? JSON.parse(fs.readFileSync(g, "utf8")) : null;
84
+ return doc && typeof doc === "object" && !Array.isArray(doc) ? doc : undefined;
85
+ } catch { return undefined; }
86
+ }
87
+
68
88
  /** .iKanban/settings-local.json (or the older .i-kanban/) nearest to `dir`. */
69
89
  function localSettings(dir) {
70
90
  for (let d = path.resolve(dir); ; d = path.dirname(d)) {
@@ -116,6 +136,7 @@ async function syncFile(file, opts) {
116
136
  const doc = toDocument(plan);
117
137
  doc.project = doc.project || c.project;
118
138
  doc.dry_run = Boolean(opts.check || opts["dry-run"]);
139
+ doc.graph = readGraph(file) ?? null; // null clears a diagram the file no longer has
119
140
  // Deleting an empty phase the sync created needs an explicit flag; MCP never deletes.
120
141
  if (opts["allow-delete"]) doc.allow_delete = true;
121
142
  let res;
@@ -171,27 +192,84 @@ function mergeJson(file, update) {
171
192
  fs.writeFileSync(file, JSON.stringify(next, null, 2) + "\n");
172
193
  }
173
194
 
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) {
195
+ // Plan 35 V1-T4: the deploy check runs after each shell command (it acts only on the deploy
196
+ // commands named in settings-local.json "deploy_check"). Not async: its answer is the context.
197
+ const DEPLOY_COMMAND = "node .iKanban/hooks/deploy-check.js";
198
+ const CLAUDE_DEPLOY_COMMAND = 'node "$CLAUDE_PROJECT_DIR/.iKanban/hooks/deploy-check.js"';
199
+ // Plan 7 P4-T3: before each edit and shell command, the task's plan must pass (no bypass, D2).
200
+ const GATE_COMMAND = "node .iKanban/hooks/plan-gate.js";
201
+ const CLAUDE_GATE_COMMAND = 'node "$CLAUDE_PROJECT_DIR/.iKanban/hooks/plan-gate.js"';
202
+
203
+ /** Adds one entry to hooks[event]; an entry that already runs the same script (`script`) is
204
+ * replaced by it (IKA-2244: so a re-install fixes an older install's command), never doubled. */
205
+ function addHook(settings, event, entry, script = "roadmap-sync.js") {
177
206
  settings.hooks = settings.hooks || {};
178
207
  const list = (settings.hooks[event] = settings.hooks[event] || []);
179
- const at = list.findIndex((e) => JSON.stringify(e).includes("roadmap-sync.js"));
208
+ const at = list.findIndex((e) => JSON.stringify(e).includes(script));
180
209
  if (at < 0) list.push(entry);
181
210
  else list[at] = entry;
182
211
  return settings;
183
212
  }
184
213
 
214
+ // Plan 43 P4-T2: `--trace` also wires the trace hook, exactly as .iKanban/README.md shows it.
215
+ // Wiring only: the hook does nothing until `node .iKanban/hooks/trace.js --on`.
216
+ const TRACE = "node .iKanban/hooks/trace.js";
217
+ const CLAUDE_TRACE = 'node "$CLAUDE_PROJECT_DIR/.iKanban/hooks/trace.js"';
218
+ const TRACE_AGENTS = {
219
+ claude: (root) => {
220
+ mergeJson(path.join(root, ".claude", "settings.json"), (s) => {
221
+ addHook(s, "PostToolUse", { hooks: [{ type: "command", command: CLAUDE_TRACE, async: true }] }, "trace.js");
222
+ addHook(s, "PostToolUseFailure", { hooks: [{ type: "command", command: CLAUDE_TRACE, async: true }] }, "trace.js");
223
+ // Stop in the foreground: a background Stop hook is stopped when Claude Code exits (README).
224
+ return addHook(s, "Stop", { hooks: [{ type: "command", command: CLAUDE_TRACE }] }, "trace.js");
225
+ });
226
+ return ".claude/settings.json trace (PostToolUse, PostToolUseFailure, Stop)";
227
+ },
228
+ gemini: (root) => {
229
+ mergeJson(path.join(root, ".gemini", "settings.json"), (s) => {
230
+ addHook(s, "AfterTool", { matcher: "*", hooks: [{ name: "ikanban-trace", type: "command", command: TRACE, timeout: 10000 }] }, "trace.js");
231
+ return addHook(s, "AfterAgent", { hooks: [{ name: "ikanban-trace", type: "command", command: TRACE, timeout: 10000 }] }, "trace.js");
232
+ });
233
+ return ".gemini/settings.json trace (AfterTool, AfterAgent)";
234
+ },
235
+ copilot: (root) => {
236
+ mergeJson(path.join(root, ".github", "hooks", "ikanban-trace.json"), (s) => {
237
+ s.version = 1;
238
+ for (const event of ["postToolUse", "postToolUseFailure", "agentStop"]) {
239
+ addHook(s, event, { type: "command", bash: `${TRACE} copilot:${event}`, cwd: ".", timeoutSec: 10 }, "trace.js");
240
+ }
241
+ return s;
242
+ });
243
+ return ".github/hooks/ikanban-trace.json (postToolUse, postToolUseFailure, agentStop)";
244
+ },
245
+ agy: (root) => {
246
+ mergeJson(path.join(root, ".agents", "hooks.json"), (s) => {
247
+ s["ikanban-trace"] = {
248
+ PostToolUse: [{ matcher: ".*", hooks: [{ type: "command", command: "node ../.iKanban/hooks/trace.js agy:PostToolUse", timeout: 10 }] }],
249
+ Stop: [{ type: "command", command: "node ../.iKanban/hooks/trace.js agy:Stop", timeout: 10 }],
250
+ };
251
+ return s;
252
+ });
253
+ return ".agents/hooks.json trace (PostToolUse, Stop)";
254
+ },
255
+ };
256
+
185
257
  const AGENTS = {
186
258
  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)";
259
+ mergeJson(path.join(root, ".claude", "settings.json"), (s) => {
260
+ addHook(s, "PostToolUse", { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: CLAUDE_COMMAND, timeout: 90 }] });
261
+ addHook(s, "PostToolUse", { matcher: "Bash", hooks: [{ type: "command", command: CLAUDE_DEPLOY_COMMAND, timeout: 20 }] }, "deploy-check.js");
262
+ return addHook(s, "PreToolUse", { matcher: "Edit|Write|MultiEdit|NotebookEdit|Bash|PowerShell", hooks: [{ type: "command", command: CLAUDE_GATE_COMMAND, timeout: 20 }] }, "plan-gate.js");
263
+ });
264
+ return ".claude/settings.json (PreToolUse, PostToolUse)";
190
265
  },
191
266
  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)";
267
+ mergeJson(path.join(root, ".gemini", "settings.json"), (s) => {
268
+ addHook(s, "AfterTool", { matcher: "write_file|replace", hooks: [{ name: "ikanban-roadmap-sync", type: "command", command: AGENT_COMMAND, timeout: 90000 }] });
269
+ addHook(s, "AfterTool", { matcher: "run_shell_command", hooks: [{ name: "ikanban-deploy-check", type: "command", command: DEPLOY_COMMAND, timeout: 20000 }] }, "deploy-check.js");
270
+ 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");
271
+ });
272
+ return ".gemini/settings.json (BeforeTool, AfterTool)";
195
273
  },
196
274
  copilot: (root) => {
197
275
  const file = path.join(root, ".github", "hooks", "ikanban-roadmap.json");
@@ -199,7 +277,24 @@ const AGENTS = {
199
277
  s.version = 1;
200
278
  return addHook(s, "postToolUse", { type: "command", bash: AGENT_COMMAND, powershell: AGENT_COMMAND, cwd: ".", timeoutSec: 90 });
201
279
  });
202
- return ".github/hooks/ikanban-roadmap.json (postToolUse)";
280
+ mergeJson(path.join(root, ".github", "hooks", "ikanban-deploy-check.json"), (s) => {
281
+ s.version = 1;
282
+ return addHook(s, "postToolUse", { type: "command", bash: DEPLOY_COMMAND, powershell: DEPLOY_COMMAND, cwd: ".", timeoutSec: 20 }, "deploy-check.js");
283
+ });
284
+ mergeJson(path.join(root, ".github", "hooks", "ikanban-plan-gate.json"), (s) => {
285
+ s.version = 1;
286
+ return addHook(s, "preToolUse", { type: "command", matcher: "edit|create|bash|powershell", bash: GATE_COMMAND, powershell: GATE_COMMAND, cwd: ".", timeoutSec: 20 }, "plan-gate.js");
287
+ });
288
+ return ".github/hooks/ikanban-roadmap.json, ikanban-deploy-check.json (postToolUse) and ikanban-plan-gate.json (preToolUse)";
289
+ },
290
+ // Antigravity CLI (agy, where Gemini users went) reads .agents/hooks.json, named hooks, and runs
291
+ // them from .agents/ (antigravity.google/docs/hooks). Its edit tools are not listed, so ".*".
292
+ agy: (root) => {
293
+ mergeJson(path.join(root, ".agents", "hooks.json"), (s) => {
294
+ s["ikanban-plan-gate"] = { PreToolUse: [{ matcher: ".*", hooks: [{ type: "command", command: "node ../.iKanban/hooks/plan-gate.js", timeout: 20 }] }] };
295
+ return s;
296
+ });
297
+ return ".agents/hooks.json (PreToolUse, Antigravity)";
203
298
  },
204
299
  };
205
300
 
@@ -211,10 +306,15 @@ function installHooks(opts) {
211
306
  const script = path.join(root, ".iKanban", "hooks", "roadmap-sync.js");
212
307
  fs.mkdirSync(path.dirname(script), { recursive: true });
213
308
  // 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"]) {
309
+ for (const name of ["roadmap-sync.js", "deploy-check.js", "ikanban-safe.js", "plan-gate.js", ...(opts.trace ? ["trace.js"] : [])]) {
215
310
  fs.copyFileSync(path.join(__dirname, "..", "hooks", name), path.join(path.dirname(script), name));
216
311
  done.push(`.iKanban/hooks/${name}`);
217
312
  }
313
+ // The plan gate checks with this package's own checker, so a repository needs no network for it.
314
+ for (const name of ["plancheck.js", "statuswords.js"]) {
315
+ fs.copyFileSync(path.join(__dirname, name), path.join(path.dirname(script), name));
316
+ done.push(`.iKanban/hooks/${name}`);
317
+ }
218
318
 
219
319
  const hooksDir = runCommand("git", ["rev-parse", "--git-path", "hooks"], { cwd: root, encoding: "utf8" }).stdout.trim();
220
320
  const prePush = path.resolve(root, hooksDir, "pre-push");
@@ -227,13 +327,18 @@ function installHooks(opts) {
227
327
  done.push("git pre-push hook");
228
328
 
229
329
  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])));
330
+ fs.existsSync(path.join(root, { claude: ".claude", gemini: ".gemini", copilot: ".github", agy: ".agents" }[a])));
231
331
  for (const a of wanted) {
232
- if (!AGENTS[a]) throw new Error(`unknown agent "${a}" (claude, gemini, copilot)`);
332
+ if (!AGENTS[a]) throw new Error(`unknown agent "${a}" (claude, gemini, copilot, agy)`);
233
333
  done.push(AGENTS[a](root));
334
+ if (opts.trace) done.push(TRACE_AGENTS[a](root));
234
335
  }
235
336
  for (const d of done) console.log(` installed: ${d}`);
236
337
  console.log("Plan files (JSON with a top-level \"roadmap\" block) now reach the Roadmap on every agent write and every git push.");
338
+ 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.");
339
+ 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).");
340
+ if (opts.trace && wanted.length) console.log("The trace hook is wired but still off. Turn it on in this repository with: node .iKanban/hooks/trace.js --on");
341
+ else if (opts.trace) console.log("Trace hook not wired: no agent folder found (looked for .claude, .gemini, .github, .agents). Create the agent's folder, or name it: ikanban hooks install --trace --agent claude");
237
342
  // IKA-2244: the sync's default server is prod; say which one this repository will use.
238
343
  const base = connection({}, path.join(root, "plan.json")).base;
239
344
  console.log(` server: ${base} (set api_url in .iKanban/settings-local.json, or IKANBAN_BASE_URL, to use another)`);
@@ -245,9 +350,11 @@ const HELP = `Usage:
245
350
  Make each plan's Roadmap match the file. No file: every tracked JSON file
246
351
  with a top-level "roadmap" block. --check: change nothing, exit 1 on drift.
247
352
  --allow-delete: also delete an emptied phase the sync created (else it stays).
248
- ikanban hooks install [--agent claude,gemini,copilot]
353
+ ikanban hooks install [--agent claude,gemini,copilot,agy] [--trace]
249
354
  Keep the Roadmap in step without CI: a git pre-push hook (any git host) and
250
- a hook for each agent whose folder exists (.claude, .gemini, .github).
355
+ a hook for each agent whose folder exists (.claude, .gemini, .github, .agents).
356
+ --trace: also wire .iKanban/hooks/trace.js for those agents. Tracing stays
357
+ off until: node .iKanban/hooks/trace.js --on
251
358
 
252
359
  Key: VIBE_API_TOKEN, --token, or api_key in .iKanban/settings-local.json.
253
360
  Server: IKANBAN_BASE_URL, --base-url, or api_url there (default https://api.i-kanban.com).`;
@@ -290,4 +397,4 @@ async function run(cmd, args) {
290
397
  return code;
291
398
  }
292
399
 
293
- module.exports = { run, toDocument, short, writePlanId, addHook, apiOrigin };
400
+ module.exports = { run, toDocument, short, writePlanId, addHook, apiOrigin, connection, TASK_STATUS, PHASE_STATUS };