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.
Files changed (54) hide show
  1. package/README.md +8 -8
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +38 -8
  4. package/src/agents/load.ts +11 -0
  5. package/src/agents/sync.ts +8 -8
  6. package/src/capabilities.ts +157 -0
  7. package/src/color.ts +16 -0
  8. package/src/commands/board.ts +14 -7
  9. package/src/commands/doctor.ts +206 -19
  10. package/src/commands/init.ts +15 -3
  11. package/src/commands/sync.ts +1 -1
  12. package/src/commands/upgrade.ts +20 -1
  13. package/src/config.ts +38 -8
  14. package/src/daemon/derive.ts +41 -0
  15. package/src/daemon/loop.ts +2 -0
  16. package/src/jobs/prompts.ts +25 -5
  17. package/src/jobs/run.ts +61 -22
  18. package/src/known-hashes.json +42 -57
  19. package/src/providers/claude.ts +40 -30
  20. package/src/providers/codex.ts +60 -30
  21. package/src/providers/mcp.ts +71 -0
  22. package/src/schemas/results.ts +2 -5
  23. package/templates/.agents/skills/approach-designer/SKILL.md +1 -1
  24. package/templates/.agents/skills/kanbot-admin/SKILL.md +21 -4
  25. package/templates/.agents/skills/kanbot-guide/SKILL.md +26 -7
  26. package/templates/.agents/skills/kanbot-idea/SKILL.md +1 -1
  27. package/templates/.agents/skills/plan-template/SKILL.md +14 -6
  28. package/templates/.agents/skills/pr-description/SKILL.md +7 -3
  29. package/templates/.agents/skills/project-conventions/SKILL.md +23 -35
  30. package/templates/.agents/skills/review-findings/SKILL.md +47 -0
  31. package/templates/.agents/skills/spec-review/SKILL.md +10 -27
  32. package/templates/.agents/skills/user-stories/SKILL.md +1 -1
  33. package/templates/.github/pull_request_template.md +6 -0
  34. package/templates/.kanbot/agents/acceptance-reviewer.md +43 -0
  35. package/templates/.kanbot/agents/fixer.md +7 -5
  36. package/templates/.kanbot/agents/implementer.md +49 -0
  37. package/templates/.kanbot/agents/plan-critic.md +3 -3
  38. package/templates/.kanbot/agents/planner.md +10 -9
  39. package/templates/.kanbot/agents/reviewer.md +42 -0
  40. package/templates/AGENTS.md +2 -3
  41. package/templates/.agents/skills/accessibility/SKILL.md +0 -76
  42. package/templates/.agents/skills/code-review/SKILL.md +0 -83
  43. package/templates/.agents/skills/frontend-design/LICENSE.txt +0 -177
  44. package/templates/.agents/skills/frontend-design/SKILL.md +0 -76
  45. package/templates/.agents/skills/tdd/SKILL.md +0 -51
  46. package/templates/.agents/skills/verify-ui/SKILL.md +0 -104
  47. package/templates/.kanbot/agents/implementer-backend.md +0 -57
  48. package/templates/.kanbot/agents/implementer-docs.md +0 -55
  49. package/templates/.kanbot/agents/implementer-frontend.md +0 -54
  50. package/templates/.kanbot/agents/implementer-technical.md +0 -53
  51. package/templates/.kanbot/agents/reviewer-code.md +0 -46
  52. package/templates/.kanbot/agents/reviewer-spec.md +0 -49
  53. package/templates/.kanbot/agents/test-writer.md +0 -35
  54. package/templates/.kanbot/agents/ui-verifier.md +0 -35
@@ -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 = { name: string; ok: boolean; detail?: string; fix?: string };
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: detail.replace(/\n/g, "\n "),
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
- console.log(
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({ name: "gh logged in", ok: auth.ok, fix: "gh auth login" });
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
- return process.exit(1);
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
- ? undefined
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-code",
109
- "reviewer-spec",
209
+ "reviewer",
210
+ "acceptance-reviewer",
110
211
  "fixer",
111
- ...Object.values(config.implementers),
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
- detail: "worktrees are created from the base branch",
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.join(", ") || undefined,
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
- if (checks.some((c) => !c.ok)) process.exit(1);
480
+ summary(checks);
294
481
  },
295
482
  });
@@ -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. kanbot board create # creates the project board and labels
105
- 4. kanbot doctor # checks auth, board, generated files
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 run # start the loop (or kanbot run --once --dry-run)`);
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
 
@@ -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: {
@@ -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 { writeConfigSchema } from "../config.ts";
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
- .default({
82
- frontend: "implementer-frontend",
83
- backend: "implementer-backend",
84
- docs: "implementer-docs",
85
- infra: "implementer-technical",
86
- default: "implementer-backend",
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
- "Maps a type:<key> label to an agent. `default` is the fallback.",
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({
@@ -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);
@@ -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":