kanbot 0.3.0 → 0.4.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 +8 -8
- package/package.json +1 -1
- package/schema/config.schema.json +38 -8
- package/src/agents/load.ts +11 -0
- package/src/agents/sync.ts +8 -8
- package/src/capabilities.ts +157 -0
- package/src/color.ts +16 -0
- package/src/commands/board.ts +14 -7
- package/src/commands/doctor.ts +206 -19
- package/src/commands/init.ts +15 -3
- package/src/commands/sync.ts +1 -1
- package/src/commands/upgrade.ts +20 -1
- package/src/config.ts +38 -8
- package/src/daemon/derive.ts +41 -0
- package/src/daemon/loop.ts +2 -0
- package/src/jobs/prompts.ts +25 -5
- package/src/jobs/run.ts +61 -22
- package/src/known-hashes.json +42 -57
- package/src/providers/claude.ts +40 -30
- package/src/providers/codex.ts +60 -30
- package/src/providers/mcp.ts +71 -0
- package/src/schemas/results.ts +2 -5
- package/templates/.agents/skills/approach-designer/SKILL.md +1 -1
- package/templates/.agents/skills/kanbot-admin/SKILL.md +21 -4
- package/templates/.agents/skills/kanbot-guide/SKILL.md +26 -7
- package/templates/.agents/skills/kanbot-idea/SKILL.md +1 -1
- package/templates/.agents/skills/plan-template/SKILL.md +14 -6
- package/templates/.agents/skills/pr-description/SKILL.md +7 -3
- package/templates/.agents/skills/project-conventions/SKILL.md +23 -35
- package/templates/.agents/skills/review-findings/SKILL.md +47 -0
- package/templates/.agents/skills/spec-review/SKILL.md +10 -27
- package/templates/.agents/skills/user-stories/SKILL.md +1 -1
- package/templates/.github/pull_request_template.md +6 -0
- package/templates/.kanbot/agents/acceptance-reviewer.md +43 -0
- package/templates/.kanbot/agents/fixer.md +7 -5
- package/templates/.kanbot/agents/implementer.md +49 -0
- package/templates/.kanbot/agents/plan-critic.md +3 -3
- package/templates/.kanbot/agents/planner.md +10 -9
- package/templates/.kanbot/agents/reviewer.md +42 -0
- package/templates/AGENTS.md +2 -3
- package/templates/.agents/skills/accessibility/SKILL.md +0 -76
- package/templates/.agents/skills/code-review/SKILL.md +0 -83
- package/templates/.agents/skills/frontend-design/LICENSE.txt +0 -177
- package/templates/.agents/skills/frontend-design/SKILL.md +0 -76
- package/templates/.agents/skills/tdd/SKILL.md +0 -51
- package/templates/.agents/skills/verify-ui/SKILL.md +0 -104
- package/templates/.kanbot/agents/implementer-backend.md +0 -57
- package/templates/.kanbot/agents/implementer-docs.md +0 -55
- package/templates/.kanbot/agents/implementer-frontend.md +0 -54
- package/templates/.kanbot/agents/implementer-technical.md +0 -53
- package/templates/.kanbot/agents/reviewer-code.md +0 -46
- package/templates/.kanbot/agents/reviewer-spec.md +0 -49
- package/templates/.kanbot/agents/test-writer.md +0 -35
- package/templates/.kanbot/agents/ui-verifier.md +0 -35
package/src/commands/doctor.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
|
-
import { loadAgents } from "../agents/load.ts";
|
|
4
|
+
import { loadAgents, missingSkills } from "../agents/load.ts";
|
|
5
5
|
import { sync } from "../agents/sync.ts";
|
|
6
|
+
import { capabilitySummary, findCapability } from "../capabilities.ts";
|
|
7
|
+
import { color } from "../color.ts";
|
|
6
8
|
import { loadConfig, writeConfigSchema } from "../config.ts";
|
|
7
9
|
import { git } from "../daemon/git.ts";
|
|
8
|
-
import { describeError } from "../errors.ts";
|
|
10
|
+
import { DOCS_URL, describeError } from "../errors.ts";
|
|
9
11
|
import { copilotReviewRule } from "../github/api.ts";
|
|
10
12
|
import { appKeyPath, appTokens } from "../github/app.ts";
|
|
11
13
|
import { Gh, run } from "../github/gh.ts";
|
|
@@ -14,10 +16,26 @@ import {
|
|
|
14
16
|
loadBoard,
|
|
15
17
|
missingColumns,
|
|
16
18
|
} from "../github/project.ts";
|
|
19
|
+
import {
|
|
20
|
+
codexSupports,
|
|
21
|
+
type McpServer,
|
|
22
|
+
readMcpServers,
|
|
23
|
+
} from "../providers/mcp.ts";
|
|
17
24
|
import { NEW_SUFFIX, pendingMerges, planUpgrade } from "../templates.ts";
|
|
18
25
|
import { REPO_LABELS } from "./board.ts";
|
|
19
26
|
|
|
20
|
-
type Check = {
|
|
27
|
+
type Check = {
|
|
28
|
+
name: string;
|
|
29
|
+
ok: boolean;
|
|
30
|
+
/** Shown with a warning mark; never fails doctor. */
|
|
31
|
+
warn?: boolean;
|
|
32
|
+
detail?: string;
|
|
33
|
+
/** Why it matters. Shown only when the check fails or warns. */
|
|
34
|
+
why?: string;
|
|
35
|
+
fix?: string;
|
|
36
|
+
/** Docs page and anchor, e.g. "configuration#config-file". */
|
|
37
|
+
docs?: string;
|
|
38
|
+
};
|
|
21
39
|
|
|
22
40
|
async function cmd(args: string[]) {
|
|
23
41
|
const p = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
|
|
@@ -32,13 +50,69 @@ async function cmd(args: string[]) {
|
|
|
32
50
|
/** Failure detail and fix for a check, from a thrown error. */
|
|
33
51
|
function explain(e: unknown, fallback?: string) {
|
|
34
52
|
const d = describeError(e);
|
|
35
|
-
const detail = [d.message, d.why].filter(Boolean).join("\n");
|
|
36
53
|
return {
|
|
37
|
-
detail:
|
|
54
|
+
detail: d.message,
|
|
55
|
+
why: d.why,
|
|
38
56
|
fix: d.fix ?? fallback,
|
|
57
|
+
docs: d.docs,
|
|
39
58
|
};
|
|
40
59
|
}
|
|
41
60
|
|
|
61
|
+
const indent = (s: string, pad: string) => s.replace(/^(?=.)/gm, pad);
|
|
62
|
+
|
|
63
|
+
function print(c: Check) {
|
|
64
|
+
const state = c.warn ? "warn" : c.ok ? "ok" : "fail";
|
|
65
|
+
const mark = {
|
|
66
|
+
ok: color.green("✔"),
|
|
67
|
+
warn: color.yellow("!"),
|
|
68
|
+
fail: color.red("✖"),
|
|
69
|
+
}[state];
|
|
70
|
+
if (state === "ok") {
|
|
71
|
+
const detail = c.detail ? color.dim(` — ${c.detail}`) : "";
|
|
72
|
+
console.log(` ${mark} ${c.name}${detail}`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const paint = state === "warn" ? color.yellow : color.red;
|
|
76
|
+
const lines = [` ${mark} ${paint(c.name)}`];
|
|
77
|
+
if (c.detail) lines.push(indent(c.detail, " "));
|
|
78
|
+
if (c.why) lines.push(color.dim(indent(c.why, " ")));
|
|
79
|
+
if (c.fix) lines.push(` ${color.bold("Fix:")} ${color.cyan(c.fix)}`);
|
|
80
|
+
if (c.docs)
|
|
81
|
+
lines.push(
|
|
82
|
+
` ${color.bold("Docs:")} ${color.dim(`${DOCS_URL}/${c.docs}`)}`,
|
|
83
|
+
);
|
|
84
|
+
console.log(lines.join("\n"));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function section(title: string) {
|
|
88
|
+
console.log(`\n${color.bold(title)}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Print totals and next step, then exit 1 if anything failed. */
|
|
92
|
+
function summary(checks: Check[]) {
|
|
93
|
+
const failed = checks.filter((c) => !c.ok).length;
|
|
94
|
+
const warned = checks.filter((c) => c.ok && c.warn).length;
|
|
95
|
+
const passed = checks.length - failed - warned;
|
|
96
|
+
const parts = [color.green(`${passed} passed`)];
|
|
97
|
+
if (warned)
|
|
98
|
+
parts.push(
|
|
99
|
+
color.yellow(`${warned} ${warned === 1 ? "warning" : "warnings"}`),
|
|
100
|
+
);
|
|
101
|
+
if (failed) parts.push(color.red(`${failed} failed`));
|
|
102
|
+
console.log(`\n${parts.join(color.dim(" · "))}`);
|
|
103
|
+
if (failed) {
|
|
104
|
+
console.log(
|
|
105
|
+
`Fix the ${color.red("✖")} items above, top to bottom, then run ${color.cyan("kanbot doctor")} again.`,
|
|
106
|
+
);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
if (warned)
|
|
110
|
+
console.log(
|
|
111
|
+
`Ready to run. The ${color.yellow("!")} items are worth a look, but won't stop kanbot.`,
|
|
112
|
+
);
|
|
113
|
+
else console.log(`All set. Start kanbot with ${color.cyan("kanbot run")}.`);
|
|
114
|
+
}
|
|
115
|
+
|
|
42
116
|
export default defineCommand({
|
|
43
117
|
meta: {
|
|
44
118
|
name: "doctor",
|
|
@@ -49,19 +123,32 @@ export default defineCommand({
|
|
|
49
123
|
const checks: Check[] = [];
|
|
50
124
|
const add = (c: Check) => {
|
|
51
125
|
checks.push(c);
|
|
52
|
-
|
|
53
|
-
`${c.ok ? "✔" : "✖"} ${c.name}${c.detail ? ` — ${c.detail}` : ""}${!c.ok && c.fix ? `\n fix: ${c.fix}` : ""}`,
|
|
54
|
-
);
|
|
126
|
+
print(c);
|
|
55
127
|
};
|
|
56
128
|
|
|
129
|
+
console.log(
|
|
130
|
+
`${color.bold("kanbot doctor")} ${color.dim("checks that everything kanbot needs is in place.")}`,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
section("GitHub CLI");
|
|
57
134
|
const auth = await cmd(["gh", "auth", "status"]);
|
|
58
|
-
add({
|
|
135
|
+
add({
|
|
136
|
+
name: "gh logged in",
|
|
137
|
+
ok: auth.ok,
|
|
138
|
+
why: "kanbot talks to GitHub through gh.",
|
|
139
|
+
fix: "gh auth login",
|
|
140
|
+
docs: "getting-started#requirements",
|
|
141
|
+
});
|
|
59
142
|
add({
|
|
60
143
|
name: "gh token has project scope",
|
|
61
144
|
ok: /'project'/.test(auth.out),
|
|
145
|
+
why: "Needed to read and move cards on the project board.",
|
|
62
146
|
fix: "gh auth refresh -s project",
|
|
147
|
+
docs: "getting-started#requirements",
|
|
63
148
|
});
|
|
64
149
|
|
|
150
|
+
section("Config");
|
|
151
|
+
|
|
65
152
|
let config: Awaited<ReturnType<typeof loadConfig>>;
|
|
66
153
|
try {
|
|
67
154
|
config = await loadConfig(root);
|
|
@@ -72,30 +159,44 @@ export default defineCommand({
|
|
|
72
159
|
});
|
|
73
160
|
} catch (e) {
|
|
74
161
|
add({ name: "config", ok: false, ...explain(e, "kanbot init") });
|
|
75
|
-
|
|
162
|
+
console.log(
|
|
163
|
+
color.dim("\n The other checks need a config, so doctor stops here."),
|
|
164
|
+
);
|
|
165
|
+
return summary(checks);
|
|
76
166
|
}
|
|
77
167
|
|
|
78
168
|
if (config.provider === "claude") {
|
|
169
|
+
section("Claude Code");
|
|
79
170
|
const s = await cmd(["claude", "auth", "status"]);
|
|
80
171
|
const sub = /"authMethod":\s*"claude\.ai"/.test(s.out);
|
|
81
172
|
add({
|
|
82
173
|
name: "claude CLI logged in with a subscription",
|
|
83
174
|
ok: s.ok && sub,
|
|
84
|
-
detail: sub
|
|
85
|
-
|
|
86
|
-
: "not using a claude.ai login (would bill API usage)",
|
|
175
|
+
detail: sub ? undefined : "Not using a claude.ai login.",
|
|
176
|
+
why: "Workers run `claude`. An API key login bills per token instead of your plan.",
|
|
87
177
|
fix: "claude auth login",
|
|
178
|
+
docs: "providers#claude-code",
|
|
88
179
|
});
|
|
89
180
|
} else {
|
|
181
|
+
section("Codex");
|
|
90
182
|
const s = await cmd(["codex", "login", "status"]);
|
|
91
183
|
add({
|
|
92
184
|
name: "codex CLI logged in with ChatGPT",
|
|
93
185
|
ok: s.ok && /ChatGPT/i.test(s.out),
|
|
94
186
|
detail: s.out.split("\n")[0],
|
|
187
|
+
why: "Workers run `codex` on your ChatGPT plan.",
|
|
95
188
|
fix: "codex login",
|
|
189
|
+
docs: "providers#codex",
|
|
190
|
+
});
|
|
191
|
+
add({
|
|
192
|
+
name: "codex workers ignore ~/.codex/config.toml",
|
|
193
|
+
ok: true,
|
|
194
|
+
detail:
|
|
195
|
+
"models come from agent frontmatter, MCP servers from .mcp.json",
|
|
96
196
|
});
|
|
97
197
|
}
|
|
98
198
|
|
|
199
|
+
section("Agents and skills");
|
|
99
200
|
try {
|
|
100
201
|
const agents = await loadAgents(root);
|
|
101
202
|
add({
|
|
@@ -105,10 +206,11 @@ export default defineCommand({
|
|
|
105
206
|
});
|
|
106
207
|
const needed = [
|
|
107
208
|
"planner",
|
|
108
|
-
"reviewer
|
|
109
|
-
"reviewer
|
|
209
|
+
"reviewer",
|
|
210
|
+
"acceptance-reviewer",
|
|
110
211
|
"fixer",
|
|
111
|
-
|
|
212
|
+
"implementer",
|
|
213
|
+
...Object.values(config.implementers ?? {}),
|
|
112
214
|
];
|
|
113
215
|
const missing = [...new Set(needed)].filter(
|
|
114
216
|
(n) => !agents.some((a) => a.name === n),
|
|
@@ -117,23 +219,86 @@ export default defineCommand({
|
|
|
117
219
|
name: "agents referenced by config exist",
|
|
118
220
|
ok: !missing.length,
|
|
119
221
|
detail: missing.join(", ") || undefined,
|
|
222
|
+
why: "Columns hand work to these agents, but .kanbot/agents has no file for them.",
|
|
223
|
+
fix: "kanbot upgrade",
|
|
224
|
+
});
|
|
225
|
+
if (config.implementers)
|
|
226
|
+
add({
|
|
227
|
+
name: "implementers is deprecated",
|
|
228
|
+
ok: true,
|
|
229
|
+
warn: true,
|
|
230
|
+
detail:
|
|
231
|
+
"implement uses `implementer`; map craft with capabilities instead",
|
|
232
|
+
fix: "remove implementers from .kanbot/config.yml",
|
|
233
|
+
});
|
|
234
|
+
const noSkill = missingSkills(root, agents);
|
|
235
|
+
add({
|
|
236
|
+
name: "skills referenced by agents exist",
|
|
237
|
+
ok: !noSkill.length,
|
|
238
|
+
detail: noSkill.join(", ") || undefined,
|
|
239
|
+
why: "Agents list these skills, but .agents/skills has none by that name.",
|
|
240
|
+
fix: "add them to .agents/skills or remove them from the agent's `skills`",
|
|
120
241
|
});
|
|
242
|
+
add({
|
|
243
|
+
name: "capabilities",
|
|
244
|
+
ok: true,
|
|
245
|
+
detail: capabilitySummary(config.capabilities),
|
|
246
|
+
});
|
|
247
|
+
// Only problems get their own line.
|
|
248
|
+
for (const [name, c] of Object.entries(config.capabilities)) {
|
|
249
|
+
const found = findCapability(root, agents, c.use);
|
|
250
|
+
if (found === "skill" || found === "agent") continue;
|
|
251
|
+
add({
|
|
252
|
+
name: `capability ${name} → ${c.use}`,
|
|
253
|
+
ok: found !== null,
|
|
254
|
+
warn: found !== null,
|
|
255
|
+
detail: found
|
|
256
|
+
? `${found} agent, works on ${found} only`
|
|
257
|
+
: "not found",
|
|
258
|
+
why: found
|
|
259
|
+
? undefined
|
|
260
|
+
: "Jobs that need this capability can't load it.",
|
|
261
|
+
fix: "add the skill to .agents/skills or the agent to .kanbot/agents",
|
|
262
|
+
});
|
|
263
|
+
}
|
|
121
264
|
const stale = await sync(root, agents, { check: true });
|
|
122
265
|
add({
|
|
123
266
|
name: "generated agent files up to date",
|
|
124
267
|
ok: !stale.length,
|
|
125
268
|
detail: stale.join(", ") || undefined,
|
|
269
|
+
why: "Workers read .claude/agents and .codex/agents, which are generated from .kanbot/agents.",
|
|
126
270
|
fix: "kanbot sync",
|
|
127
271
|
});
|
|
128
272
|
add({
|
|
129
273
|
name: "config schema up to date",
|
|
130
274
|
ok: !(await writeConfigSchema(root, { check: true })),
|
|
275
|
+
why: "Editors use it for autocomplete and errors in config.yml.",
|
|
131
276
|
fix: "kanbot sync",
|
|
277
|
+
docs: "configuration",
|
|
132
278
|
});
|
|
133
279
|
} catch (e) {
|
|
134
280
|
add({ name: "agents", ok: false, ...explain(e) });
|
|
135
281
|
}
|
|
136
282
|
|
|
283
|
+
const servers = await readMcpServers(root);
|
|
284
|
+
const names = Object.keys(servers);
|
|
285
|
+
const skipped =
|
|
286
|
+
config.provider === "codex"
|
|
287
|
+
? names.filter((n) => !codexSupports(servers[n] as McpServer))
|
|
288
|
+
: [];
|
|
289
|
+
add({
|
|
290
|
+
name: "MCP servers for workers (.mcp.json)",
|
|
291
|
+
ok: true,
|
|
292
|
+
warn: skipped.length > 0,
|
|
293
|
+
detail: [
|
|
294
|
+
names.join(", ") || "none",
|
|
295
|
+
skipped.length ? `codex can't run: ${skipped.join(", ")}` : "",
|
|
296
|
+
]
|
|
297
|
+
.filter(Boolean)
|
|
298
|
+
.join("; "),
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
section("Templates");
|
|
137
302
|
const plan = await planUpgrade(root);
|
|
138
303
|
const behind = plan.changes.filter((c) =>
|
|
139
304
|
["add", "update", "conflict"].includes(c.action),
|
|
@@ -144,16 +309,21 @@ export default defineCommand({
|
|
|
144
309
|
detail: behind.length
|
|
145
310
|
? `${behind.length} file(s) differ from this kanbot version`
|
|
146
311
|
: undefined,
|
|
312
|
+
why: "You get fixes to agents, skills and workflows by upgrading.",
|
|
147
313
|
fix: "kanbot upgrade",
|
|
314
|
+
docs: "upgrading",
|
|
148
315
|
});
|
|
149
316
|
const merges = await pendingMerges(root);
|
|
150
317
|
add({
|
|
151
318
|
name: `no unmerged ${NEW_SUFFIX} files`,
|
|
152
319
|
ok: !merges.length,
|
|
153
320
|
detail: merges.join(", ") || undefined,
|
|
321
|
+
why: "upgrade put the new version next to files you changed.",
|
|
322
|
+
docs: "upgrading",
|
|
154
323
|
fix: `merge each into the file next to it and delete it (kanbot-admin skill can help)`,
|
|
155
324
|
});
|
|
156
325
|
|
|
326
|
+
section(`Pushed to origin/${config.baseBranch}`);
|
|
157
327
|
await git(root, "fetch", "origin", config.baseBranch).catch(() => {});
|
|
158
328
|
const tree = await git(
|
|
159
329
|
root,
|
|
@@ -171,15 +341,18 @@ export default defineCommand({
|
|
|
171
341
|
add({
|
|
172
342
|
name: `${path} committed on origin/${config.baseBranch}`,
|
|
173
343
|
ok: tree.split("\n").some((f) => f.startsWith(path)),
|
|
174
|
-
|
|
344
|
+
why: "Workers run in worktrees made from the base branch, so they only see what is pushed.",
|
|
175
345
|
fix: "commit and push the kanbot files",
|
|
176
346
|
});
|
|
177
347
|
}
|
|
178
348
|
add({
|
|
179
349
|
name: ".github/workflows/kanbot-merged.yml",
|
|
180
350
|
ok: existsSync(join(root, ".github/workflows/kanbot-merged.yml")),
|
|
351
|
+
why: "Closes the other approaches' PRs when one is merged.",
|
|
352
|
+
fix: "kanbot upgrade",
|
|
181
353
|
});
|
|
182
354
|
|
|
355
|
+
section("Project board");
|
|
183
356
|
const gh = new Gh(config.repo);
|
|
184
357
|
try {
|
|
185
358
|
const board = await loadBoard(gh, config);
|
|
@@ -190,7 +363,11 @@ export default defineCommand({
|
|
|
190
363
|
detail: missing.length
|
|
191
364
|
? `missing columns: ${missing.join(", ")}`
|
|
192
365
|
: board.url,
|
|
366
|
+
why: missing.length
|
|
367
|
+
? "kanbot moves cards by the column names in config.yml."
|
|
368
|
+
: undefined,
|
|
193
369
|
fix: "kanbot board create",
|
|
370
|
+
docs: "getting-started#create-the-board",
|
|
194
371
|
});
|
|
195
372
|
// Moving cards to Done on close/merge and adding new issues are left to GitHub.
|
|
196
373
|
const workflows = await boardWorkflows(gh, board);
|
|
@@ -202,6 +379,7 @@ export default defineCommand({
|
|
|
202
379
|
add({
|
|
203
380
|
name: `board workflow "${name}" enabled`,
|
|
204
381
|
ok: workflows.some((w) => w.name === name && w.enabled),
|
|
382
|
+
why: "kanbot leaves adding new issues and moving closed ones to Done to GitHub.",
|
|
205
383
|
fix: `enable it in ${board.url} → ⋯ → Workflows`,
|
|
206
384
|
});
|
|
207
385
|
}
|
|
@@ -233,16 +411,21 @@ export default defineCommand({
|
|
|
233
411
|
add({
|
|
234
412
|
name: "labels",
|
|
235
413
|
ok: !missingLabels.length,
|
|
236
|
-
detail: missingLabels.
|
|
414
|
+
detail: missingLabels.length
|
|
415
|
+
? `missing: ${missingLabels.join(", ")}`
|
|
416
|
+
: undefined,
|
|
417
|
+
why: "kanbot tags issues and PRs with these labels.",
|
|
237
418
|
fix: "kanbot board create",
|
|
238
419
|
});
|
|
239
420
|
|
|
240
421
|
const { mode, app } = config.identity;
|
|
241
422
|
if (mode === "app") {
|
|
423
|
+
section("GitHub App");
|
|
242
424
|
add({
|
|
243
425
|
name: "kanbot app set",
|
|
244
426
|
ok: app !== null,
|
|
245
427
|
detail: app ? `${app.slug} (id ${app.id})` : undefined,
|
|
428
|
+
why: "identity.mode is app, so kanbot acts as a GitHub App.",
|
|
246
429
|
fix: "kanbot app create",
|
|
247
430
|
});
|
|
248
431
|
if (app) {
|
|
@@ -251,6 +434,7 @@ export default defineCommand({
|
|
|
251
434
|
name: "kanbot app private key",
|
|
252
435
|
ok: existsSync(key),
|
|
253
436
|
detail: key,
|
|
437
|
+
why: "kanbot signs in as the app with this key.",
|
|
254
438
|
fix: "kanbot app create, or copy the key from the machine that created the app",
|
|
255
439
|
});
|
|
256
440
|
if (existsSync(key)) {
|
|
@@ -272,6 +456,7 @@ export default defineCommand({
|
|
|
272
456
|
}
|
|
273
457
|
|
|
274
458
|
if (config.review.copilot) {
|
|
459
|
+
section("Copilot review");
|
|
275
460
|
const rule = await copilotReviewRule(gh, config.baseBranch).catch(
|
|
276
461
|
() => null,
|
|
277
462
|
);
|
|
@@ -282,14 +467,16 @@ export default defineCommand({
|
|
|
282
467
|
? `re-review on push: ${rule.parameters?.review_on_push ? "on" : "off"}`
|
|
283
468
|
: "review.copilot is on, but no ruleset requests Copilot review",
|
|
284
469
|
fix: "kanbot board create",
|
|
470
|
+
docs: "copilot-review",
|
|
285
471
|
});
|
|
286
472
|
add({
|
|
287
473
|
name: ".github/copilot-instructions.md committed",
|
|
474
|
+
why: "Copilot reads it to review the way your team wants.",
|
|
288
475
|
ok: tree.split("\n").includes(".github/copilot-instructions.md"),
|
|
289
476
|
fix: "kanbot sync, then commit and push",
|
|
290
477
|
});
|
|
291
478
|
}
|
|
292
479
|
|
|
293
|
-
|
|
480
|
+
summary(checks);
|
|
294
481
|
},
|
|
295
482
|
});
|
package/src/commands/init.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
import { loadAgents } from "../agents/load.ts";
|
|
5
5
|
import { sync } from "../agents/sync.ts";
|
|
6
|
+
import { askCapabilities, compact, ownWorkers } from "../capabilities.ts";
|
|
6
7
|
import { configPath, saveConfig, writeConfigSchema } from "../config.ts";
|
|
7
8
|
import { UserError } from "../errors.ts";
|
|
8
9
|
import { run } from "../github/gh.ts";
|
|
@@ -79,6 +80,16 @@ export default defineCommand({
|
|
|
79
80
|
log.info(
|
|
80
81
|
`+ .kanbot/config.yml (${repo.nameWithOwner}, base ${repo.defaultBranchRef.name})`,
|
|
81
82
|
);
|
|
83
|
+
// Only ask when someone can answer.
|
|
84
|
+
if (process.stdin.isTTY) {
|
|
85
|
+
const caps = askCapabilities(await ownWorkers(root), prompt);
|
|
86
|
+
if (Object.keys(caps).length) {
|
|
87
|
+
await saveConfig(root, { capabilities: compact(caps) } as never);
|
|
88
|
+
log.info(
|
|
89
|
+
`~ .kanbot/config.yml (capabilities: ${Object.keys(caps).join(", ")})`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
82
93
|
}
|
|
83
94
|
|
|
84
95
|
const codeowners = join(root, ".github", "CODEOWNERS");
|
|
@@ -101,10 +112,11 @@ export default defineCommand({
|
|
|
101
112
|
Next steps:
|
|
102
113
|
1. Review AGENTS.md and fill in .agents/skills/project-conventions/SKILL.md
|
|
103
114
|
2. Adjust .github/workflows/ci.yml to your stack (agents fix failing CI)
|
|
104
|
-
3.
|
|
105
|
-
4. kanbot
|
|
115
|
+
3. Map your own skills under capabilities in .kanbot/config.yml (kanbot-guide can help)
|
|
116
|
+
4. kanbot board create # board and labels; enable the workflows it prints
|
|
106
117
|
5. Commit and push these files to the default branch (agents work from it)
|
|
107
|
-
6. kanbot
|
|
118
|
+
6. kanbot doctor # checks auth, board, generated files
|
|
119
|
+
7. kanbot run # start the loop (or kanbot run --once --dry-run)`);
|
|
108
120
|
},
|
|
109
121
|
});
|
|
110
122
|
|
package/src/commands/sync.ts
CHANGED
|
@@ -8,7 +8,7 @@ export default defineCommand({
|
|
|
8
8
|
meta: {
|
|
9
9
|
name: "sync",
|
|
10
10
|
description:
|
|
11
|
-
"Generate .claude/agents, .codex/agents, permissions and the config schema",
|
|
11
|
+
"Generate .claude/agents, .codex/agents, permissions and the config schema from .kanbot/agents",
|
|
12
12
|
},
|
|
13
13
|
args: {
|
|
14
14
|
check: {
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
1
2
|
import { defineCommand } from "citty";
|
|
2
3
|
import pkg from "../../package.json" with { type: "json" };
|
|
3
4
|
import { loadAgents } from "../agents/load.ts";
|
|
4
5
|
import { sync } from "../agents/sync.ts";
|
|
5
|
-
import {
|
|
6
|
+
import { compact, legacyCapabilities } from "../capabilities.ts";
|
|
7
|
+
import {
|
|
8
|
+
configPath,
|
|
9
|
+
loadConfig,
|
|
10
|
+
saveConfig,
|
|
11
|
+
writeConfigSchema,
|
|
12
|
+
} from "../config.ts";
|
|
6
13
|
import { log } from "../log.ts";
|
|
7
14
|
import {
|
|
8
15
|
applyUpgrade,
|
|
@@ -48,9 +55,21 @@ export default defineCommand({
|
|
|
48
55
|
);
|
|
49
56
|
}
|
|
50
57
|
if (!plan.changes.length) log.info("Templates are up to date.");
|
|
58
|
+
const caps = existsSync(configPath(root))
|
|
59
|
+
? legacyCapabilities(root, (await loadConfig(root)).capabilities)
|
|
60
|
+
: null;
|
|
61
|
+
if (caps)
|
|
62
|
+
console.log(
|
|
63
|
+
`\nMapped skills you have to capabilities in .kanbot/config.yml:\n${Object.entries(
|
|
64
|
+
caps,
|
|
65
|
+
)
|
|
66
|
+
.map(([n, c]) => ` ${n}: ${c.use}`)
|
|
67
|
+
.join("\n")}`,
|
|
68
|
+
);
|
|
51
69
|
if (args["dry-run"]) return;
|
|
52
70
|
|
|
53
71
|
await applyUpgrade(root, plan);
|
|
72
|
+
if (caps) await saveConfig(root, { capabilities: compact(caps) } as never);
|
|
54
73
|
const generated = await sync(root, await loadAgents(root));
|
|
55
74
|
if (await writeConfigSchema(root))
|
|
56
75
|
generated.push(".kanbot/config.schema.json");
|
package/src/config.ts
CHANGED
|
@@ -17,6 +17,36 @@ export type ColumnKey = (typeof COLUMN_KEYS)[number];
|
|
|
17
17
|
export const JOB_KINDS = ["fix", "review", "plan", "implement"] as const;
|
|
18
18
|
export type JobKind = (typeof JOB_KINDS)[number];
|
|
19
19
|
|
|
20
|
+
export const RESERVED_CAPABILITIES = ["verify", "review", "preview"] as const;
|
|
21
|
+
|
|
22
|
+
const CapabilityObject = z.object({
|
|
23
|
+
use: z
|
|
24
|
+
.string()
|
|
25
|
+
.min(1)
|
|
26
|
+
.describe(
|
|
27
|
+
"Skill in .agents/skills or agent in .kanbot/agents that does the work.",
|
|
28
|
+
),
|
|
29
|
+
when: z
|
|
30
|
+
.string()
|
|
31
|
+
.optional()
|
|
32
|
+
.describe("When it applies, e.g. `Changes to UI users see`."),
|
|
33
|
+
required: z
|
|
34
|
+
.boolean()
|
|
35
|
+
.default(false)
|
|
36
|
+
.describe("A miss when it applies blocks acceptance review."),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** `verify: verify-ui` is short for `verify: { use: verify-ui }`. */
|
|
40
|
+
export const Capability = z.union([
|
|
41
|
+
z
|
|
42
|
+
.string()
|
|
43
|
+
.min(1)
|
|
44
|
+
.describe("Skill or agent name, short for `{ use: <name> }`.")
|
|
45
|
+
.transform((use) => CapabilityObject.parse({ use })),
|
|
46
|
+
CapabilityObject,
|
|
47
|
+
]);
|
|
48
|
+
export type Capability = z.output<typeof Capability>;
|
|
49
|
+
|
|
20
50
|
export const ConfigSchema = z.object({
|
|
21
51
|
provider: z
|
|
22
52
|
.enum(["claude", "codex"])
|
|
@@ -78,15 +108,15 @@ export const ConfigSchema = z.object({
|
|
|
78
108
|
.describe("Per-job timeout in minutes."),
|
|
79
109
|
implementers: z
|
|
80
110
|
.record(z.string(), z.string())
|
|
81
|
-
.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
})
|
|
111
|
+
.optional()
|
|
112
|
+
.describe(
|
|
113
|
+
"Deprecated: implement always uses `implementer`. While set, maps a type:<key> label to an agent, with `default` as the fallback. Use capabilities instead.",
|
|
114
|
+
),
|
|
115
|
+
capabilities: z
|
|
116
|
+
.record(z.string().regex(/^[a-z][a-z0-9-]*$/), Capability)
|
|
117
|
+
.default({})
|
|
88
118
|
.describe(
|
|
89
|
-
"
|
|
119
|
+
"Needs mapped to the repo's own skills or agents. Reserved: verify (prove acceptance criteria), review (technical review), preview (something a human can try). Other names are yours.",
|
|
90
120
|
),
|
|
91
121
|
review: z
|
|
92
122
|
.object({
|
package/src/daemon/derive.ts
CHANGED
|
@@ -76,6 +76,7 @@ export type Action =
|
|
|
76
76
|
| { kind: "merge"; item: Item; pr: PR; by: string }
|
|
77
77
|
| { kind: "compare"; item: Item; prs: PR[] }
|
|
78
78
|
| { kind: "needsHuman"; number: number; why: string }
|
|
79
|
+
| { kind: "openQuestions"; item: Item; questions: string[] }
|
|
79
80
|
| { kind: "resetFailures"; prefix: string }
|
|
80
81
|
| { kind: "cleanup"; number: number };
|
|
81
82
|
|
|
@@ -178,6 +179,36 @@ export function approvedBy(
|
|
|
178
179
|
);
|
|
179
180
|
}
|
|
180
181
|
|
|
182
|
+
const OPEN_QUESTIONS = /^##\s+Open questions\s*$[\s\S]*?(?=^##\s|(?![\s\S]))/im;
|
|
183
|
+
|
|
184
|
+
/** The plan with its "## Open questions" section set to `questions`, last. */
|
|
185
|
+
export function withOpenQuestions(plan: string, questions: string[]): string {
|
|
186
|
+
const rest = plan.replace(OPEN_QUESTIONS, "").trimEnd();
|
|
187
|
+
if (!questions.length) return rest;
|
|
188
|
+
return `${rest}\n\n## Open questions\n\n${questions.map((q) => `- ${q}`).join("\n")}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Bullets under the plan's "## Open questions". */
|
|
192
|
+
export function openQuestions(plan: string | null): string[] {
|
|
193
|
+
const m = plan?.match(OPEN_QUESTIONS);
|
|
194
|
+
if (!m) return [];
|
|
195
|
+
return m[0]
|
|
196
|
+
.split("\n")
|
|
197
|
+
.slice(1)
|
|
198
|
+
.map((l) => l.match(/^\s*[-*]\s+(.+)$/)?.[1]?.trim())
|
|
199
|
+
.filter((q): q is string => Boolean(q));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Was the open-questions gate already posted for the latest plan? */
|
|
203
|
+
function gatePosted(comments: Comment[]) {
|
|
204
|
+
for (let i = comments.length - 1; i >= 0; i--) {
|
|
205
|
+
const body = (comments[i] as Comment).body;
|
|
206
|
+
if (hasMarker(body, "questions")) return true;
|
|
207
|
+
if (hasMarker(body, "plan")) return false;
|
|
208
|
+
}
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
181
212
|
const fixCount = (pr: PR, kind: "ci" | "conflict") =>
|
|
182
213
|
pr.comments.reduce((n, c) => n + markerValues(c.body, kind).length, 0);
|
|
183
214
|
|
|
@@ -228,6 +259,16 @@ export function derive(snapshot: Snapshot, ctx: DeriveContext) {
|
|
|
228
259
|
|
|
229
260
|
case "ready":
|
|
230
261
|
case "inProgress": {
|
|
262
|
+
// Open questions keep a plan out of Ready. Once started, it runs on.
|
|
263
|
+
const questions =
|
|
264
|
+
item.column === "ready" && !issue.prs.length
|
|
265
|
+
? openQuestions(extractPlan(issue.body))
|
|
266
|
+
: [];
|
|
267
|
+
if (questions.length) {
|
|
268
|
+
if (!gatePosted(issue.comments))
|
|
269
|
+
actions.push({ kind: "openQuestions", item, questions });
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
231
272
|
const n = exploreCount(issue.labels);
|
|
232
273
|
const variants =
|
|
233
274
|
n === null ? [null] : Array.from({ length: n }, (_, i) => i + 1);
|
package/src/daemon/loop.ts
CHANGED
|
@@ -211,6 +211,8 @@ function describeAction(a: Action) {
|
|
|
211
211
|
return `post comparison on #${a.item.issue.number}`;
|
|
212
212
|
case "needsHuman":
|
|
213
213
|
return `needs-human #${a.number}`;
|
|
214
|
+
case "openQuestions":
|
|
215
|
+
return `open questions on #${a.item.issue.number}`;
|
|
214
216
|
case "resetFailures":
|
|
215
217
|
return `reset failures ${a.prefix}`;
|
|
216
218
|
case "cleanup":
|