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,206 @@
1
+ #!/usr/bin/env node
2
+ // iKanban: no code change before the task has a checked plan (plan 7 P4-T3, decisions D1, D2, D7).
3
+ // An agent hook runs this before each tool call (Claude Code PreToolUse, Gemini CLI BeforeTool,
4
+ // Copilot CLI preToolUse; `npx ikanban hooks install` wires them). An edit, or a shell command that
5
+ // is not on the read list below, needs the task's plan to pass the plan check:
6
+ // the plan is docs/plans/<KEY>.json, KEY is the task key in the branch name (feat/ABC-12-name).
7
+ // Writing the plan itself, reading, and files outside the repository are never refused. There is
8
+ // no bypass (D2). Input the gate cannot read is refused with exit 2, never passed.
9
+ // `node .iKanban/hooks/plan-gate.js check <plan.json>` runs the same check by hand.
10
+ // It runs no git command: it reads .git directly, as deploy-check.js does.
11
+ const fs = require("node:fs");
12
+ const path = require("node:path");
13
+
14
+ function checker() {
15
+ try {
16
+ return require("./plancheck.js"); // the kit: `ikanban hooks install` copies it next to this file
17
+ } catch {
18
+ return require("../../vibe-cli/lib/plancheck.js"); // iKanban's own checkout, where it lives
19
+ }
20
+ }
21
+
22
+ const PLAN_DIR = "docs/plans";
23
+ const PROMPT = ".iKanban/prompts/feature-plan.md";
24
+ const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit", "write_file", "replace", "edit", "create"]);
25
+ const SHELL_TOOLS = new Set(["Bash", "PowerShell", "run_shell_command", "bash", "powershell", "run_command"]);
26
+ // Antigravity (agy) documents no list of its tools: these reads pass, every other agy tool is judged as an edit.
27
+ const AGY_READS = new Set(["read_file", "view_file", "list_dir", "grep_search", "find_by_name"]);
28
+ // A shell command passes without a plan only when every part starts with one of these and has no
29
+ // redirect, command substitution or writing flag. shortcut: a list, not a parser; anything else needs the plan.
30
+ const READS = new Set(["ls", "cat", "head", "tail", "wc", "grep", "rg", "pwd", "echo", "which", "stat", "tree", "diff", "cd"]);
31
+ // Long options are allowed only from this list: git and rg accept abbreviations (--out for --output),
32
+ // so a list of bad spellings always leaks. Short-flag groups may not hold a letter that writes or runs
33
+ // a program: git grep -O (a pager), tree -o (a file).
34
+ const SAFE_LONG = new Set(["--", "--oneline", "--stat", "--short", "--porcelain", "--name-only", "--name-status", "--cached", "--staged"]);
35
+ const BAD_SHORT = { git: /O/, tree: /o/ };
36
+ const GIT_READS = new Set(["status", "diff", "log", "show", "rev-parse", "ls-files", "blame", "grep"]);
37
+
38
+ function repoRoot(cwd) {
39
+ let start = cwd;
40
+ while (!fs.existsSync(start) && path.dirname(start) !== start) start = path.dirname(start); // a new file's folder
41
+ for (let dir = fs.realpathSync(start); ; dir = path.dirname(dir)) {
42
+ if (fs.existsSync(path.join(dir, ".git"))) return dir;
43
+ if (path.dirname(dir) === dir) return null;
44
+ }
45
+ }
46
+
47
+ function gitDir(root) {
48
+ const dot = path.join(root, ".git");
49
+ if (fs.statSync(dot).isDirectory()) return dot;
50
+ const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dot, "utf8"));
51
+ return m ? path.resolve(root, m[1].trim()) : null;
52
+ }
53
+
54
+ /** The branch, or null on a detached HEAD; mid-rebase, the branch being rebased. */
55
+ function branchOf(root) {
56
+ const dir = gitDir(root);
57
+ const head = fs.readFileSync(path.join(dir, "HEAD"), "utf8");
58
+ const m = /^ref:\s*refs\/heads\/(.+)$/m.exec(head);
59
+ if (m) return m[1].trim();
60
+ for (const state of ["rebase-merge", "rebase-apply"]) {
61
+ try {
62
+ const name = fs.readFileSync(path.join(dir, state, "head-name"), "utf8").trim();
63
+ if (name.startsWith("refs/heads/")) return name.slice("refs/heads/".length);
64
+ } catch { /* not this kind of rebase */ }
65
+ }
66
+ return null;
67
+ }
68
+
69
+ const keyOf = (branch) => ((/(?:^|[/_-])([A-Za-z][A-Za-z0-9]{0,9}-\d+)(?=$|[/_-])/.exec(branch) || [])[1] || "").toUpperCase();
70
+
71
+ /** The tool's arguments from any of the three agents (Copilot may send them as a JSON string). */
72
+ function argsOf(event) {
73
+ let args = event.tool_input || event.toolArgs || event.tool_args || (event.toolCall && event.toolCall.args) || {};
74
+ if (typeof args === "string") args = JSON.parse(args);
75
+ return args && typeof args === "object" ? args : {};
76
+ }
77
+
78
+ /** The real path of a file that may not exist yet: its nearest existing folder, resolved through links. */
79
+ function real(file) {
80
+ let dir = file;
81
+ while (!fs.existsSync(dir) && path.dirname(dir) !== dir) dir = path.dirname(dir);
82
+ return path.join(fs.realpathSync(dir), path.relative(dir, file));
83
+ }
84
+
85
+ function filesOf(args) {
86
+ return ["file_path", "path", "notebook_path", "filePath", "absolute_path", "Path", "TargetFile", "AbsolutePath"]
87
+ .map((k) => args[k]).filter((v) => typeof v === "string");
88
+ }
89
+
90
+ function readOnly(command) {
91
+ // Shell syntax the gate does not model is a write: any $ ($VAR, ${x}, $'\x2d', $(…)), backticks, process
92
+ // substitution, and any brace or glob ({--output=x,} and --out* expand into flags; ls *.js needs the plan too).
93
+ if (/[`${}*?[\]]|<\(|>\(/.test(command)) return false;
94
+ // Any redirect other than to /dev/null or between streams writes; a ">" in quotes counts too (safe side).
95
+ const plain = command.replace(/(?:\d|&)?>{1,2}\s*\/dev\/null(?=$|[\s;&|)])|\d*>&(?:\d+|-)(?=$|[\s;&|)])/g, "");
96
+ if (/>/.test(plain)) return false;
97
+ if (/&/.test(plain.replace(/&&/g, ""))) return false; // a lone & runs the next command where the split cannot see it
98
+ return command.split(/&&|\|\||[;|\n]/).map((p) => p.trim()).filter(Boolean).every((part) => {
99
+ // Words as the shell sees them: quotes and backslashes removed ("-exec", -e\xec and '--out'put all count).
100
+ const w = part.split(/\s+/).map((x) => x.replace(/["'\\]/g, ""));
101
+ part = w.join(" ");
102
+ if (/^(node\s+\.iKanban\/hooks\/plan-gate\.js\s+check|npx\s+(-y\s+)?ikanban\s+plan\s+check|ikanban\s+plan\s+check)\s/.test(part + " ")) return true;
103
+ // Making the plan folder is part of writing the plan: mkdir [-p] of paths inside docs/plans only.
104
+ if (w[0] === "mkdir") return w.length > 1 && w.slice(1).every((x) => x === "-p" || /^(\.\/)?docs\/plans(\/[\w.-]+)*\/?$/.test(x) && !x.includes(".."));
105
+ if (w[0] === "find") return !w.some((x) => /^-(exec|ok|delete|fprint|fls)/.test(x));
106
+ for (const x of w.slice(1)) {
107
+ if (x.startsWith("--") && !SAFE_LONG.has(x)) return false;
108
+ // Every character of a short-flag word: -nO./run.sh and -1O hide the letter (a value with it is refused too, safe side).
109
+ if (/^-[^-]/.test(x) && BAD_SHORT[w[0]] && BAD_SHORT[w[0]].test(x.slice(1))) return false;
110
+ }
111
+ if (w[0] === "git") return GIT_READS.has(w[1]) || (w[1] === "branch" && !w.some((x) => /^-[dDmMcC]$|^--(delete|move|copy)/.test(x)));
112
+ return READS.has(w[0]);
113
+ });
114
+ }
115
+
116
+ /** Each repository this call writes to, with why it needs that repository's plan. Each edited file
117
+ * is judged in its own repository (walking up to its .git), a shell command in its own folder. */
118
+ function targets(event, cwd) {
119
+ const agy = Boolean(event.toolCall);
120
+ const tool = event.tool_name || event.toolName || (agy && event.toolCall.name) || "";
121
+ const args = argsOf(event);
122
+ if (SHELL_TOOLS.has(tool)) {
123
+ // One command field per agent; two of them is input the gate cannot judge.
124
+ if (typeof args.command === "string" && typeof args.CommandLine === "string") throw new Error("the call names two commands");
125
+ const command = agy ? args.CommandLine : args.command;
126
+ if (typeof command === "string" && readOnly(command)) return [];
127
+ // The agent names the folder (agy Cwd): judge the command in the workspace repository and in that one.
128
+ const roots = new Set([repoRoot(cwd), agy && typeof args.Cwd === "string" ? repoRoot(args.Cwd) : null].filter(Boolean));
129
+ if (!roots.size) throw new Error("the shell command runs outside every repository the gate knows");
130
+ return [...roots].map((root) => ({ root, why: "this shell command can write" }));
131
+ }
132
+ // agy lists no edit tools: any agy tool that is not a known read is judged as an edit (deny by default).
133
+ if (agy ? AGY_READS.has(tool) : !EDIT_TOOLS.has(tool)) return [];
134
+ const files = filesOf(args).map((f) => real(path.resolve(cwd, f)));
135
+ if (!files.length) {
136
+ const root = repoRoot(cwd);
137
+ return root ? [{ root, why: `this ${tool || "tool"} call names no file the gate can read` }] : [];
138
+ }
139
+ const out = [];
140
+ for (const f of files) {
141
+ const root = repoRoot(path.dirname(f));
142
+ if (!root) continue; // outside every repository
143
+ const plans = path.join(root, PLAN_DIR);
144
+ const rel = path.relative(plans, f);
145
+ if (rel && !rel.startsWith("..") && !path.isAbsolute(rel) && f.endsWith(".json")) continue; // the plan itself
146
+ out.push({ root, why: `this edit changes ${path.relative(root, f)}` });
147
+ }
148
+ return out;
149
+ }
150
+
151
+ /** The refusal, or null when the task's plan passes. */
152
+ function refusal(root, why) {
153
+ const branch = branchOf(root);
154
+ if (!branch) return `${why}, and the repository is on a detached HEAD, so no task's plan can open it. Run: git switch -c feat/<TASK-KEY>-<short-name>`;
155
+ const key = keyOf(branch);
156
+ if (!key) return `${why}, and branch ${branch} names no task key, so no plan can open it. Run: git switch -c feat/<TASK-KEY>-<short-name> (for example feat/ABC-12-login)`;
157
+ const rel = `${PLAN_DIR}/${key}.json`;
158
+ const how = `Write the plan for ${key} from ${PROMPT} (template: .iKanban/templates/feature-plan-template.json) at ${rel}, then run: node .iKanban/hooks/plan-gate.js check ${rel} (the same check as ikanban plan check). There is no setting that turns the gate off: a passing plan is the only way through`;
159
+ let plan;
160
+ try {
161
+ plan = JSON.parse(fs.readFileSync(path.join(root, rel), "utf8"));
162
+ } catch (e) {
163
+ return `${why}, and task ${key} has no plan the gate can read at ${rel} (${e.code === "ENOENT" ? "not found" : e.message}). ${how}`;
164
+ }
165
+ const { errors } = checker().check(plan);
166
+ if (!errors.length) return null;
167
+ const list = errors.slice(0, 8).map((e) => `${e.where}: ${e.problem}. Fix: ${e.fix}.`).join("\n");
168
+ return `${why}, and the plan ${rel} does not pass the check yet:\n${list}${errors.length > 8 ? `\n…and ${errors.length - 8} more` : ""}\n${how}`;
169
+ }
170
+
171
+ function answer(event, reason) {
172
+ if (event.toolName !== undefined || event.toolArgs !== undefined) return { permissionDecision: "deny", permissionDecisionReason: `iKanban plan gate: ${reason}` };
173
+ if (event.hook_event_name === "BeforeTool" || event.toolCall) return { decision: "deny", reason: `iKanban plan gate: ${reason}` };
174
+ return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: `iKanban plan gate: ${reason}` } };
175
+ }
176
+
177
+ function main(input) {
178
+ const event = JSON.parse(input);
179
+ if (!event || typeof event !== "object") throw new Error("the hook input is not a JSON object");
180
+ // agy sends no cwd, only its workspace folders (and runs the hook from .agents/); a relative path
181
+ // there resolves against the first, and each file is then judged in its own repository.
182
+ const given = typeof event.cwd === "string" ? event.cwd : Array.isArray(event.workspacePaths) ? event.workspacePaths[0] : undefined;
183
+ const dir = typeof given === "string" && fs.existsSync(given) ? given : process.cwd();
184
+ for (const { root, why } of targets(event, dir)) {
185
+ const reason = refusal(root, why);
186
+ if (reason) return answer(event, reason);
187
+ }
188
+ return {};
189
+ }
190
+
191
+ if (process.argv[2] === "check") {
192
+ process.exit(checker().run(process.argv.slice(2)));
193
+ } else {
194
+ let input = "";
195
+ process.stdin.on("data", (c) => { input += c; });
196
+ process.stdin.on("end", () => {
197
+ try {
198
+ process.stdout.write(`${JSON.stringify(main(input))}\n`);
199
+ process.exit(0);
200
+ } catch (e) {
201
+ // Fail closed: every agent treats exit 2 as a refusal and shows stderr.
202
+ process.stderr.write(`iKanban plan gate: could not check this call (${e.message}), so it is refused. Run: node .iKanban/hooks/plan-gate.js check ${PLAN_DIR}/<TASK-KEY>.json\n`);
203
+ process.exit(2);
204
+ }
205
+ });
206
+ }
package/hooks/trace.js ADDED
@@ -0,0 +1,365 @@
1
+ #!/usr/bin/env node
2
+ // iKanban: record what the agent did (plan 35 V6-T3, V13-T4). Each turn (the first tool call up to
3
+ // the end of the turn) becomes one trace on iKAgent → Agentification → What agent did: every tool
4
+ // call, its result or error, and the reply, with times (and the turn's tokens for Claude Code).
5
+ // - Claude Code runs it on PostToolUse, PostToolUseFailure and Stop.
6
+ // - Gemini CLI on AfterTool and AfterAgent (geminicli.com/docs/hooks/reference).
7
+ // - Copilot CLI on postToolUse, postToolUseFailure and agentStop; its input names no event, so the
8
+ // wiring passes it: `trace.js copilot:postToolUse` (docs.github.com/en/copilot/reference/hooks-configuration).
9
+ // - Antigravity CLI (agy, where Gemini users went) on PostToolUse and Stop, passed the same way:
10
+ // `trace.js agy:PostToolUse` (antigravity.google/docs/hooks). Recorded as agent "gemini", as the runner does.
11
+ //
12
+ // - Off until the developer runs `node .iKanban/hooks/trace.js --on` in the repository (owner Q6).
13
+ // The opt-in and the key are copied to ~/.ikanban/trace.json, outside the repository; a
14
+ // repository cannot turn tracing on by committing a settings file (security review).
15
+ // - The hook runs no git command: a repository's own git config could run one.
16
+ // - The hook records, never the model: the trace's write token is kept in ~/.ikanban/traces/,
17
+ // outside the repository, and sent only to iKanban's API (ikanbanFetch). The server scrubs
18
+ // secrets from every step and cuts it at 8 KB.
19
+ // - The task is IKANBAN_TASK, else the key in the branch name (ika-123-fix → IKA-123), read from .git/HEAD.
20
+ // - It never blocks the agent: it always exits 0 and prints "{}"; problems go to stderr.
21
+ const fs = require("node:fs");
22
+ const os = require("node:os");
23
+ const path = require("node:path");
24
+ const { runCommand, ikanbanFetch, teamId } = require("./ikanban-safe.js");
25
+
26
+ const MAX_BODY = 8 * 1024;
27
+ const TIMEOUT_MS = 5000;
28
+
29
+ /** The repository root: the nearest folder up from cwd that holds `.git` (a folder, or the
30
+ * file a worktree has). Found without running git: a repository's own git config (for example
31
+ * core.fsmonitor) can make git run a command, and the hook must run nothing before the opt-in. */
32
+ function repoRoot(cwd) {
33
+ for (let dir = fs.realpathSync(cwd); ; dir = path.dirname(dir)) {
34
+ if (fs.existsSync(path.join(dir, ".git"))) return dir;
35
+ if (path.dirname(dir) === dir) return null;
36
+ }
37
+ }
38
+
39
+ /** The git folder of a repository (a worktree's `.git` file names it). */
40
+ function gitDir(root) {
41
+ const dot = path.join(root, ".git");
42
+ if (fs.statSync(dot).isDirectory()) return dot;
43
+ const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dot, "utf8"));
44
+ return m ? path.resolve(root, m[1].trim()) : null;
45
+ }
46
+
47
+ const optInFile = () => path.join(os.homedir(), ".ikanban", "trace.json");
48
+
49
+ function readOptIn() {
50
+ try {
51
+ return JSON.parse(fs.readFileSync(optInFile(), "utf8"));
52
+ } catch {
53
+ return { repos: {} };
54
+ }
55
+ }
56
+
57
+ function writeOptIn(doc) {
58
+ fs.mkdirSync(path.dirname(optInFile()), { recursive: true, mode: 0o700 });
59
+ fs.writeFileSync(optInFile(), JSON.stringify(doc, null, 2), { mode: 0o600 });
60
+ fs.chmodSync(optInFile(), 0o600);
61
+ }
62
+
63
+ /** What this work sends to, or null: only a repository the developer turned on with
64
+ * `trace.js --on`, kept outside the repository (owner Q6; security review). V13-T1: or a
65
+ * folder above it turned on with `--on --nested` (a workspace that holds project repositories). */
66
+ function settings(cwd) {
67
+ const repos = readOptIn().repos || {};
68
+ const root = repoRoot(cwd);
69
+ if (root && repos[root]) return repos[root];
70
+ // Only a folder turned on with --nested covers the repositories inside it.
71
+ for (let dir = root ? path.dirname(root) : fs.realpathSync(cwd); ; dir = path.dirname(dir)) {
72
+ if (repos[dir] && repos[dir].nested) return repos[dir];
73
+ if (path.dirname(dir) === dir) return null;
74
+ }
75
+ }
76
+
77
+ /** git with the repository's command hooks switched off, for `--on` only. */
78
+ function safeGit(root, args) {
79
+ return runCommand("git", ["-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", ...args], {
80
+ cwd: root, encoding: "utf8", env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1" },
81
+ });
82
+ }
83
+
84
+ /** `trace.js --on`: the developer turns tracing on for this repository. The settings file must
85
+ * be the developer's own: not committed (in any letter case), not a symlink. */
86
+ function turnOn(cwd, nested) {
87
+ const root = repoRoot(cwd);
88
+ if (!root) throw new Error("not inside a git repository");
89
+ let file = null;
90
+ for (const kit of [".iKanban", ".i-kanban"]) {
91
+ const dir = path.join(root, kit);
92
+ const f = path.join(dir, "settings-local.json");
93
+ if (!fs.existsSync(f)) continue;
94
+ if (fs.lstatSync(dir).isSymbolicLink() || fs.lstatSync(f).isSymbolicLink()) {
95
+ throw new Error(`${kit}/settings-local.json is a symlink; use the file Set up wrote`);
96
+ }
97
+ file = f;
98
+ break;
99
+ }
100
+ if (!file) throw new Error("no .iKanban/settings-local.json here; run Set up in the desktop app first");
101
+ const tracked = safeGit(root, ["ls-files", "-z"]);
102
+ if (tracked.status !== 0) throw new Error("git could not list this repository's files");
103
+ if (tracked.stdout.split("\0").some((p) => /^\.i-?kanban\/settings-local\.json$/i.test(p))) {
104
+ throw new Error("settings-local.json is committed to this repository, so it is not used; it must stay git-ignored");
105
+ }
106
+ const cfg = JSON.parse(fs.readFileSync(file, "utf8"));
107
+ if (!cfg.api_url || !cfg.api_key || !cfg.team) throw new Error("settings-local.json has no api_url, api_key or team");
108
+ const doc = readOptIn();
109
+ doc.repos = doc.repos || {};
110
+ doc.repos[root] = { api_url: cfg.api_url, api_key: cfg.api_key, team: cfg.team, trace_plan: cfg.trace_plan, trace_phase: cfg.trace_phase, nested: nested || undefined };
111
+ writeOptIn(doc);
112
+ return `What agent did is on for ${root}${nested ? " and the repositories inside it" : ""}: traces go to team ${cfg.team} at ${cfg.api_url}`;
113
+ }
114
+
115
+ function turnOff(cwd) {
116
+ const root = repoRoot(cwd);
117
+ const doc = readOptIn();
118
+ if (root && doc.repos) delete doc.repos[root];
119
+ writeOptIn(doc);
120
+ return `What agent did is off for ${root}`;
121
+ }
122
+
123
+ function taskKey(root) {
124
+ const own = (process.env.IKANBAN_TASK || "").trim();
125
+ if (/^[A-Za-z][A-Za-z0-9]{0,9}-\d+$/.test(own)) return own.toUpperCase();
126
+ let head = "";
127
+ try {
128
+ head = fs.readFileSync(path.join(gitDir(root), "HEAD"), "utf8");
129
+ } catch {
130
+ return null;
131
+ }
132
+ const branch = (/^ref:\s*refs\/heads\/(.+)$/m.exec(head) || [])[1] || "";
133
+ const m = /(?:^|[/_-])([A-Za-z][A-Za-z0-9]{0,9}-\d+)(?=$|[/_-])/.exec(branch.trim());
134
+ return m ? m[1].toUpperCase() : null;
135
+ }
136
+
137
+ const stateDir = () => path.join(os.homedir(), ".ikanban", "traces");
138
+ const stateFile = (session) => path.join(stateDir(), `${String(session).replace(/[^A-Za-z0-9-]/g, "").slice(0, 80) || "session"}.json`);
139
+
140
+ function readState(session) {
141
+ try {
142
+ return JSON.parse(fs.readFileSync(stateFile(session), "utf8"));
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ function writeState(session, state) {
149
+ fs.mkdirSync(stateDir(), { recursive: true, mode: 0o700 });
150
+ fs.writeFileSync(stateFile(session), JSON.stringify(state), { mode: 0o600 });
151
+ }
152
+
153
+ /** One hook at a time per session: Claude Code runs the hooks of parallel tool calls side by
154
+ * side, and two that read an empty state would start two traces. A lock older than the
155
+ * hook's own time limit is from a hook that died, so it is taken over. */
156
+ async function withLock(session, fn) {
157
+ fs.mkdirSync(stateDir(), { recursive: true, mode: 0o700 });
158
+ const lock = `${stateFile(session)}.lock`;
159
+ for (let waited = 0; ; waited += 25) {
160
+ try {
161
+ fs.closeSync(fs.openSync(lock, "wx", 0o600));
162
+ break;
163
+ } catch (e) {
164
+ if (e.code !== "EEXIST") throw e;
165
+ let age = 0;
166
+ try { age = Date.now() - fs.statSync(lock).mtimeMs; } catch { continue; }
167
+ if (age > 3 * TIMEOUT_MS || waited > 3 * TIMEOUT_MS) fs.rmSync(lock, { force: true });
168
+ await new Promise((r) => setTimeout(r, 25));
169
+ }
170
+ }
171
+ try {
172
+ return await fn();
173
+ } finally {
174
+ fs.rmSync(lock, { force: true });
175
+ }
176
+ }
177
+
178
+ /** Text for a step, cut before it leaves the laptop (the server scrubs and cuts again). */
179
+ function text(value) {
180
+ const s = typeof value === "string" ? value : JSON.stringify(value ?? "");
181
+ return s.length > MAX_BODY ? `${s.slice(0, MAX_BODY)}…[cut]` : s;
182
+ }
183
+
184
+ /** This turn's tokens and model from the transcript: assistant messages after the last prompt,
185
+ * each message once (the transcript repeats a message's usage on each of its blocks). */
186
+ function turnUsage(transcriptPath) {
187
+ let lines = [];
188
+ try {
189
+ lines = fs.readFileSync(transcriptPath, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
190
+ } catch {
191
+ return {};
192
+ }
193
+ let start = 0;
194
+ lines.forEach((r, i) => {
195
+ const c = r.type === "user" && r.message && r.message.content;
196
+ if (typeof c === "string" || (Array.isArray(c) && c.some((x) => x && x.type === "text"))) start = i;
197
+ });
198
+ const seen = new Map();
199
+ let model;
200
+ for (const r of lines.slice(start)) {
201
+ if (r.type !== "assistant" || !r.message) continue;
202
+ model = r.message.model || model;
203
+ seen.set(r.message.id, r.message.usage || {});
204
+ }
205
+ let tokensIn = 0, tokensOut = 0;
206
+ for (const u of seen.values()) {
207
+ tokensIn += (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0);
208
+ tokensOut += u.output_tokens || 0;
209
+ }
210
+ return seen.size ? { tokensIn, tokensOut, model } : {};
211
+ }
212
+
213
+ async function api(cfg, apiPath, body, token) {
214
+ const res = await ikanbanFetch(apiPath, {
215
+ base: cfg.api_url, token: cfg.api_key, method: "POST", body, timeoutMs: TIMEOUT_MS,
216
+ headers: token ? { "X-Trace-Token": token } : {},
217
+ });
218
+ if (!res.ok) throw new Error(`iKanban answered ${res.status}`);
219
+ return (await res.json()).data;
220
+ }
221
+
222
+ /** One shape for the three agents' hook inputs: which agent, what happened (tool, failed or
223
+ * end), and its parts. Anything else is null and records nothing. */
224
+ function normalize(event, arg) {
225
+ const agy = /^agy:(PostToolUse|Stop)$/.exec(arg || "");
226
+ if (agy) {
227
+ const call = event.toolCall && typeof event.toolCall === "object" ? event.toolCall : {};
228
+ const what = agy[1] === "Stop" ? "end" : event.error ? "failed" : "tool";
229
+ return { agent: "gemini", what, session: event.conversationId, name: call.name, input: call.args, output: "", error: event.error, reply: "" };
230
+ }
231
+ const copilot = /^copilot:(postToolUse|postToolUseFailure|agentStop)$/.exec(arg || "");
232
+ if (copilot) {
233
+ const what = { postToolUse: "tool", postToolUseFailure: "failed", agentStop: "end" }[copilot[1]];
234
+ const result = event.toolResult && typeof event.toolResult === "object" ? event.toolResult.textResultForLlm : event.toolResult;
235
+ return { agent: "copilot", what, session: event.sessionId, name: event.toolName, input: event.toolArgs, output: result, error: event.error, reply: "" };
236
+ }
237
+ const what = { PostToolUse: "tool", PostToolUseFailure: "failed", Stop: "end", AfterTool: "tool", AfterAgent: "end" }[event.hook_event_name];
238
+ if (!what) return null;
239
+ const gemini = event.hook_event_name === "AfterTool" || event.hook_event_name === "AfterAgent";
240
+ return {
241
+ agent: gemini ? "gemini" : "claude", what, session: event.session_id, name: event.tool_name || event.toolName,
242
+ input: event.tool_input, output: event.tool_response, error: event.error, duration_ms: event.duration_ms,
243
+ reply: gemini ? event.prompt_response : event.last_assistant_message, transcript: gemini ? null : event.transcript_path,
244
+ };
245
+ }
246
+
247
+ async function main(input, arg) {
248
+ const event = JSON.parse(input || "{}");
249
+ const dir = typeof event.cwd === "string" ? event.cwd : Array.isArray(event.workspacePaths) ? event.workspacePaths[0] : undefined;
250
+ const cwd = typeof dir === "string" && fs.existsSync(dir) ? dir : process.cwd();
251
+ const cfg = settings(cwd);
252
+ if (!cfg || !cfg.api_url || !cfg.api_key || !cfg.team) return;
253
+ const e = normalize(event, arg);
254
+ if (!e) return;
255
+ const session = e.session || "session";
256
+ return withLock(session, () => record(cfg, cwd, session, e));
257
+ }
258
+
259
+ async function record(cfg, cwd, session, e) {
260
+ const team = encodeURIComponent(await teamId({ base: cfg.api_url, token: cfg.api_key, team: cfg.team }));
261
+ const now = new Date().toISOString();
262
+
263
+ let state = readState(session);
264
+ if (!state) {
265
+ const started = await api(cfg, `/api/teams/${team}/agent-traces`, {
266
+ task: taskKey(repoRoot(cwd)), run: session, agent: e.agent, source: "kit",
267
+ plan: cfg.trace_plan || undefined, phase: cfg.trace_phase || undefined,
268
+ });
269
+ state = { id: started.id, token: started.write_token, seq: 0 };
270
+ writeState(session, state);
271
+ }
272
+ const spans = [];
273
+ const add = (span) => spans.push({ seq: ++state.seq, at: now, ...span });
274
+ const name = e.name || "tool";
275
+ // Claude Code's transcript gives the turn's tokens and model; the other agents' are not read.
276
+ const u = e.transcript ? turnUsage(e.transcript) : {};
277
+ if (e.what === "end") {
278
+ add({ kind: "reply", name: "reply", body: text(e.reply || ""), tokens_in: u.tokensIn, tokens_out: u.tokensOut });
279
+ } else {
280
+ add({ kind: "tool_call", name, body: text(e.input), duration_ms: e.duration_ms });
281
+ add(e.what === "tool" ? { kind: "tool_result", name, body: text(e.output) } : { kind: "error", name, body: text(e.error) });
282
+ }
283
+ const base = `/api/teams/${team}/agent-traces/${encodeURIComponent(state.id)}`;
284
+ await api(cfg, `${base}/spans`, { spans }, state.token);
285
+ if (e.what === "end") {
286
+ await api(cfg, `${base}/end`, { outcome: "passed", model: u.model }, state.token);
287
+ fs.rmSync(stateFile(session), { force: true });
288
+ await sendPlan(cfg, cwd, team, e.agent).catch((err) => process.stderr.write(`iKanban trace hook: plan not sent: ${err.message}\n`));
289
+ } else {
290
+ writeState(session, state);
291
+ }
292
+ }
293
+
294
+ const MAX_PLAN = 256 * 1024;
295
+ const planState = (key) => path.join(os.homedir(), ".ikanban", "plans", `${key}.sha256`);
296
+
297
+ /** IKA-2741 (plan 7 P6-T6): the task's plan, checked with the kit's own checker, goes to the Plan
298
+ * stage in Pipelines ("checked on laptop"). Only a regular file at docs/plans/<KEY>.json, and only
299
+ * when it changed since the last send. It is never a merge verdict: only the runner gives one. */
300
+ async function sendPlan(cfg, cwd, team, agent) {
301
+ const root = repoRoot(cwd);
302
+ const key = root && taskKey(root);
303
+ if (!key) return;
304
+ const file = path.join(root, "docs", "plans", `${key}.json`);
305
+ let st;
306
+ try {
307
+ st = fs.lstatSync(file);
308
+ } catch {
309
+ return; // no plan for this task yet
310
+ }
311
+ if (!st.isFile() || st.size > MAX_PLAN) return;
312
+ const text = fs.readFileSync(file, "utf8");
313
+ const sha = require("node:crypto").createHash("sha256").update(text).digest("hex");
314
+ try {
315
+ if (fs.readFileSync(planState(key), "utf8") === sha) return;
316
+ } catch {
317
+ // never sent
318
+ }
319
+ let plan;
320
+ try {
321
+ plan = JSON.parse(text);
322
+ } catch {
323
+ return; // the plan gate reports an unreadable plan; nothing to show
324
+ }
325
+ if (!Array.isArray(plan.phases)) return;
326
+ const { errors } = checker().check(plan);
327
+ const first = errors[0];
328
+ const detail = first ? `${first.where}: ${first.problem}`.replace(/\s+/g, " ").slice(0, 300) : undefined;
329
+ await api(cfg, `/api/teams/${team}/laptop-plans`, { task: key, agent, status: errors.length ? "failed" : "passed", detail, plan: plan.phases,
330
+ decisions: Array.isArray(plan.decisions) ? plan.decisions : undefined });
331
+ fs.mkdirSync(path.dirname(planState(key)), { recursive: true, mode: 0o700 });
332
+ fs.writeFileSync(planState(key), sha, { mode: 0o600 });
333
+ }
334
+
335
+ function checker() {
336
+ try {
337
+ return require("./plancheck.js"); // the kit: `ikanban hooks install` copies it next to this file
338
+ } catch {
339
+ return require("../../vibe-cli/lib/plancheck.js"); // iKanban's own checkout, where it lives
340
+ }
341
+ }
342
+
343
+ const mode = process.argv[2];
344
+ if (mode === "--on" || mode === "--off") {
345
+ try {
346
+ process.stdout.write(`${mode === "--on" ? turnOn(process.cwd(), process.argv[3] === "--nested") : turnOff(process.cwd())}\n`);
347
+ process.exit(0);
348
+ } catch (e) {
349
+ process.stderr.write(`iKanban trace: ${e.message}\n`);
350
+ process.exit(1);
351
+ }
352
+ }
353
+
354
+ let input = "";
355
+ process.stdin.on("data", (c) => { input += c; });
356
+ process.stdin.on("end", async () => {
357
+ try {
358
+ await main(input, process.argv[2]);
359
+ } catch (e) {
360
+ process.stderr.write(`iKanban trace hook: ${e.message}\n`);
361
+ }
362
+ // Antigravity's Stop needs a decision; any value but "continue" lets the agent stop.
363
+ process.stdout.write(process.argv[2] === "agy:Stop" ? '{"decision":"allow"}\n' : "{}\n");
364
+ process.exit(0);
365
+ });
@@ -64,16 +64,20 @@ function apiOrigin(base) {
64
64
 
65
65
  /**
66
66
  * fetch() to iKanban's API. opts: base (the signed-in api_url), token, method, body (sent as
67
- * JSON), timeoutMs. Refuses another host, an absolute or protocol-relative path, and redirects.
67
+ * JSON), timeoutMs, headers (only X-Trace-Token is passed on). Refuses another host, an absolute or protocol-relative path, and redirects.
68
68
  */
69
- async function ikanbanFetch(apiPath, { base, token, method = "GET", body, timeoutMs = 60000 } = {}) {
69
+ async function ikanbanFetch(apiPath, { base, token, method = "GET", body, timeoutMs = 60000, headers: extra = {} } = {}) {
70
70
  const origin = apiOrigin(base);
71
71
  if (typeof apiPath !== "string" || !apiPath.startsWith("/") || apiPath.startsWith("//")) {
72
72
  throw new Error(`"${apiPath}" is not a path on the iKanban API; the key was not sent`);
73
73
  }
74
74
  const url = new URL(apiPath, origin);
75
75
  if (url.origin !== origin) throw new Error(`"${apiPath}" leaves ${origin}; the key was not sent`);
76
+ // Plan 35: X-Trace-Token (a trace's write token) is the one extra header; nothing may replace the key's.
76
77
  const headers = { Authorization: `Bearer ${token}` };
78
+ for (const [k, v] of Object.entries(extra)) {
79
+ if (k.toLowerCase() === "x-trace-token" && typeof v === "string" && /^[A-Za-z0-9]{16,128}$/.test(v)) headers["X-Trace-Token"] = v;
80
+ }
77
81
  if (body !== undefined) headers["Content-Type"] = "application/json";
78
82
  let res;
79
83
  try {
@@ -93,4 +97,23 @@ async function ikanbanFetch(apiPath, { base, token, method = "GET", body, timeou
93
97
  return res;
94
98
  }
95
99
 
96
- module.exports = { runCommand, startCommand, ikanbanFetch, apiOrigin };
100
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
101
+
102
+ /** The team's id for API paths. Set up writes the team's key ("IKA") into settings-local.json,
103
+ * and the API's routes take the id, so a key is looked up among the caller's teams. */
104
+ async function teamId({ base, token, team }) {
105
+ if (UUID.test(String(team || ""))) return team;
106
+ const res = await ikanbanFetch("/api/teams", { base, token, timeoutMs: 5000 });
107
+ if (!res.ok) throw new Error(`iKanban answered ${res.status} for the team list`);
108
+ const json = await res.json();
109
+ const list = Array.isArray(json.data) ? json.data : (json.data && json.data.teams) || [];
110
+ // Security review: the key first; a name only when no key matches; never a guess between two.
111
+ const want = String(team || "").toLowerCase();
112
+ const byKey = list.filter((t) => String(t.identifier || "").toLowerCase() === want);
113
+ const hits = byKey.length ? byKey : list.filter((t) => String(t.name || "").toLowerCase() === want);
114
+ if (hits.length > 1) throw new Error(`more than one team is called "${team}"; put the team's id in settings-local.json`);
115
+ if (!hits.length || !UUID.test(String(hits[0].id))) throw new Error(`no team "${team}" for this key`);
116
+ return hits[0].id;
117
+ }
118
+
119
+ module.exports = { runCommand, startCommand, ikanbanFetch, apiOrigin, teamId };