ikanban 0.5.0 → 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.
- package/README.md +2 -1
- package/hooks/roadmap-sync.js +5 -2
- package/hooks/trace.js +21 -2
- package/lib/plancheck.js +12 -2
- package/lib/roadmap.js +63 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,7 +31,8 @@ exits 1 when the Roadmap differs. With no file it syncs every tracked plan file.
|
|
|
31
31
|
|
|
32
32
|
`ikanban hooks install` adds a git `pre-push` hook (any git host, never blocks a push) and an
|
|
33
33
|
agent hook for Claude Code, Gemini CLI and Copilot CLI (`.iKanban/hooks/roadmap-sync.js`).
|
|
34
|
-
No CI is needed.
|
|
34
|
+
No CI is needed. `--trace` also wires the kit's trace hook (`.iKanban/hooks/trace.js`) for the
|
|
35
|
+
same agents; tracing stays off until `node .iKanban/hooks/trace.js --on`.
|
|
35
36
|
|
|
36
37
|
## Infrastructure scans
|
|
37
38
|
|
package/hooks/roadmap-sync.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// after each file write (Claude Code PostToolUse, Gemini CLI AfterTool, Copilot
|
|
4
4
|
// CLI postToolUse; `npx ikanban hooks install` wires them). It reads the hook's
|
|
5
5
|
// JSON on stdin, and only when the written file is a plan file (a JSON file with
|
|
6
|
-
// a top-level "roadmap" block) it runs `ikanban roadmap sync <file>`.
|
|
6
|
+
// a top-level "roadmap" block) it runs `ikanban roadmap sync <file>`. A written
|
|
7
|
+
// <plan>.graph.json (the plan's diagram) syncs <plan>.json beside it.
|
|
7
8
|
// It never blocks the agent: it always exits 0 and prints "{}" (the hook answer
|
|
8
9
|
// every agent accepts); messages go to stderr.
|
|
9
10
|
const fs = require("node:fs");
|
|
@@ -71,7 +72,9 @@ function main(input) {
|
|
|
71
72
|
const root = repoRoot(cwd);
|
|
72
73
|
if (!root) return;
|
|
73
74
|
const args = event.tool_input || event.toolArgs || event.tool_args || {};
|
|
74
|
-
|
|
75
|
+
// IKA-2870 (plan 45 P7): a written <plan>.graph.json syncs <plan>.json beside it, which sends it.
|
|
76
|
+
const written = filePaths(args).map((p) => path.resolve(cwd, p).replace(/\.graph\.json$/, ".json"));
|
|
77
|
+
const plans = [...new Set(written)].filter((f) => isPlan(f, root));
|
|
75
78
|
const [cmd, pre] = cliCommand();
|
|
76
79
|
for (const plan of plans) {
|
|
77
80
|
// An argument array and no shell: nothing from the hook input or the plan is
|
package/hooks/trace.js
CHANGED
|
@@ -294,6 +294,23 @@ async function record(cfg, cwd, session, e) {
|
|
|
294
294
|
const MAX_PLAN = 256 * 1024;
|
|
295
295
|
const planState = (key) => path.join(os.homedir(), ".ikanban", "plans", `${key}.sha256`);
|
|
296
296
|
|
|
297
|
+
/** IKA-2854 (plan 45): the plan's diagram at docs/plans/<KEY>.graph.json, sent with the plan. Only a
|
|
298
|
+
* regular file inside the repository, at most 256 KB, holding a JSON object; anything else is no
|
|
299
|
+
* diagram (the server and the browser check it again). Returns [text, object] or []. */
|
|
300
|
+
function readGraph(root, key) {
|
|
301
|
+
const file = path.join(root, "docs", "plans", `${key}.graph.json`);
|
|
302
|
+
try {
|
|
303
|
+
const st = fs.lstatSync(file);
|
|
304
|
+
if (!st.isFile() || st.size > MAX_PLAN) return [];
|
|
305
|
+
if (!fs.realpathSync(file).startsWith(fs.realpathSync(root) + path.sep)) return [];
|
|
306
|
+
const text = fs.readFileSync(file, "utf8");
|
|
307
|
+
const doc = JSON.parse(text);
|
|
308
|
+
return doc && typeof doc === "object" && !Array.isArray(doc) ? [text, doc] : [];
|
|
309
|
+
} catch {
|
|
310
|
+
return [];
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
297
314
|
/** IKA-2741 (plan 7 P6-T6): the task's plan, checked with the kit's own checker, goes to the Plan
|
|
298
315
|
* stage in Pipelines ("checked on laptop"). Only a regular file at docs/plans/<KEY>.json, and only
|
|
299
316
|
* when it changed since the last send. It is never a merge verdict: only the runner gives one. */
|
|
@@ -310,7 +327,9 @@ async function sendPlan(cfg, cwd, team, agent) {
|
|
|
310
327
|
}
|
|
311
328
|
if (!st.isFile() || st.size > MAX_PLAN) return;
|
|
312
329
|
const text = fs.readFileSync(file, "utf8");
|
|
313
|
-
const
|
|
330
|
+
const [graphText = "", graph] = readGraph(root, key);
|
|
331
|
+
// The diagram is in the checksum, so a changed diagram alone is sent too.
|
|
332
|
+
const sha = require("node:crypto").createHash("sha256").update(text).update("\0").update(graphText).digest("hex");
|
|
314
333
|
try {
|
|
315
334
|
if (fs.readFileSync(planState(key), "utf8") === sha) return;
|
|
316
335
|
} catch {
|
|
@@ -327,7 +346,7 @@ async function sendPlan(cfg, cwd, team, agent) {
|
|
|
327
346
|
const first = errors[0];
|
|
328
347
|
const detail = first ? `${first.where}: ${first.problem}`.replace(/\s+/g, " ").slice(0, 300) : undefined;
|
|
329
348
|
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 });
|
|
349
|
+
decisions: Array.isArray(plan.decisions) ? plan.decisions : undefined, graph });
|
|
331
350
|
fs.mkdirSync(path.dirname(planState(key)), { recursive: true, mode: 0o700 });
|
|
332
351
|
fs.writeFileSync(planState(key), sha, { mode: 0o600 });
|
|
333
352
|
}
|
package/lib/plancheck.js
CHANGED
|
@@ -155,12 +155,22 @@ async function serverGaps(file, plan, opts, stages = ["before_done"]) {
|
|
|
155
155
|
const { connection } = require("./roadmap.js");
|
|
156
156
|
const { ikanbanFetch } = safe();
|
|
157
157
|
const c = connection({ token: opts.token, "base-url": opts["base-url"] }, file);
|
|
158
|
-
|
|
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 || "");
|
|
159
161
|
const task = String(opts.task);
|
|
160
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" }];
|
|
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
163
|
let res;
|
|
163
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" }];
|
|
164
174
|
res = await ikanbanFetch(`/api/teams/${team}/tasks/${encodeURIComponent(task)}/critic-reviews`, { base: c.base, token: c.token });
|
|
165
175
|
} catch (e) {
|
|
166
176
|
return [{ where: task, problem: `critic gaps not read: ${e.message}`, fix: "check the network and the API address, then run the check again" }];
|
package/lib/roadmap.js
CHANGED
|
@@ -74,6 +74,17 @@ function toDocument(plan) {
|
|
|
74
74
|
};
|
|
75
75
|
}
|
|
76
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
|
+
|
|
77
88
|
/** .iKanban/settings-local.json (or the older .i-kanban/) nearest to `dir`. */
|
|
78
89
|
function localSettings(dir) {
|
|
79
90
|
for (let d = path.resolve(dir); ; d = path.dirname(d)) {
|
|
@@ -125,6 +136,7 @@ async function syncFile(file, opts) {
|
|
|
125
136
|
const doc = toDocument(plan);
|
|
126
137
|
doc.project = doc.project || c.project;
|
|
127
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
|
|
128
140
|
// Deleting an empty phase the sync created needs an explicit flag; MCP never deletes.
|
|
129
141
|
if (opts["allow-delete"]) doc.allow_delete = true;
|
|
130
142
|
let res;
|
|
@@ -199,6 +211,49 @@ function addHook(settings, event, entry, script = "roadmap-sync.js") {
|
|
|
199
211
|
return settings;
|
|
200
212
|
}
|
|
201
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
|
+
|
|
202
257
|
const AGENTS = {
|
|
203
258
|
claude: (root) => {
|
|
204
259
|
mergeJson(path.join(root, ".claude", "settings.json"), (s) => {
|
|
@@ -251,7 +306,7 @@ function installHooks(opts) {
|
|
|
251
306
|
const script = path.join(root, ".iKanban", "hooks", "roadmap-sync.js");
|
|
252
307
|
fs.mkdirSync(path.dirname(script), { recursive: true });
|
|
253
308
|
// The hook runs its commands through ikanban-safe.js, so both files go together.
|
|
254
|
-
for (const name of ["roadmap-sync.js", "deploy-check.js", "ikanban-safe.js", "plan-gate.js"]) {
|
|
309
|
+
for (const name of ["roadmap-sync.js", "deploy-check.js", "ikanban-safe.js", "plan-gate.js", ...(opts.trace ? ["trace.js"] : [])]) {
|
|
255
310
|
fs.copyFileSync(path.join(__dirname, "..", "hooks", name), path.join(path.dirname(script), name));
|
|
256
311
|
done.push(`.iKanban/hooks/${name}`);
|
|
257
312
|
}
|
|
@@ -276,11 +331,14 @@ function installHooks(opts) {
|
|
|
276
331
|
for (const a of wanted) {
|
|
277
332
|
if (!AGENTS[a]) throw new Error(`unknown agent "${a}" (claude, gemini, copilot, agy)`);
|
|
278
333
|
done.push(AGENTS[a](root));
|
|
334
|
+
if (opts.trace) done.push(TRACE_AGENTS[a](root));
|
|
279
335
|
}
|
|
280
336
|
for (const d of done) console.log(` installed: ${d}`);
|
|
281
337
|
console.log("Plan files (JSON with a top-level \"roadmap\" block) now reach the Roadmap on every agent write and every git push.");
|
|
282
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.");
|
|
283
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");
|
|
284
342
|
// IKA-2244: the sync's default server is prod; say which one this repository will use.
|
|
285
343
|
const base = connection({}, path.join(root, "plan.json")).base;
|
|
286
344
|
console.log(` server: ${base} (set api_url in .iKanban/settings-local.json, or IKANBAN_BASE_URL, to use another)`);
|
|
@@ -292,9 +350,11 @@ const HELP = `Usage:
|
|
|
292
350
|
Make each plan's Roadmap match the file. No file: every tracked JSON file
|
|
293
351
|
with a top-level "roadmap" block. --check: change nothing, exit 1 on drift.
|
|
294
352
|
--allow-delete: also delete an emptied phase the sync created (else it stays).
|
|
295
|
-
ikanban hooks install [--agent claude,gemini,copilot]
|
|
353
|
+
ikanban hooks install [--agent claude,gemini,copilot,agy] [--trace]
|
|
296
354
|
Keep the Roadmap in step without CI: a git pre-push hook (any git host) and
|
|
297
|
-
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
|
|
298
358
|
|
|
299
359
|
Key: VIBE_API_TOKEN, --token, or api_key in .iKanban/settings-local.json.
|
|
300
360
|
Server: IKANBAN_BASE_URL, --base-url, or api_url there (default https://api.i-kanban.com).`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ikanban",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "iKanban CLI: AI and security scans of your own repo; findings filed into iKanban, reports kept encrypted on your machine",
|
|
5
5
|
"bin": { "ikanban": "bin/ikanban.js" },
|
|
6
6
|
"files": ["bin", "lib", "hooks", "scanners", "README.md", "LICENSE.md"],
|