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