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.
- package/README.md +59 -10
- package/bin/ikanban.js +49 -6
- package/hooks/deploy-check.js +221 -0
- package/hooks/ikanban-safe.js +26 -3
- package/hooks/plan-gate.js +206 -0
- package/hooks/roadmap-sync.js +5 -2
- package/hooks/trace.js +384 -0
- package/lib/ikanban-safe.js +26 -3
- package/lib/plancheck.js +231 -0
- package/lib/roadmap.js +135 -28
- package/lib/scanstore.js +403 -0
- package/lib/statuswords.js +13 -0
- package/package.json +3 -3
- package/scanners/ikanban-aiscan.py +121 -30
- package/scanners/ikanban-cloudscan.py +110 -41
- package/scanners/ikanban-controlscan.py +783 -0
- package/scanners/ikanban-dastscan.py +108 -40
- package/scanners/ikanban-import.py +597 -0
- package/scanners/ikanban-infrascan.py +116 -51
- package/scanners/ikanban-secscan.py +116 -104
package/lib/plancheck.js
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|