kanbot 0.4.1 → 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 CHANGED
@@ -64,19 +64,19 @@ For a guided setup, run `bunx kanbot skills install` and ask your agent "help me
64
64
 
65
65
  - **Capture ideas by chatting.** Ask your agent to file an idea; the `kanbot-idea` skill shapes it and puts it in Ideas. [More](./docs/03-capturing-ideas.md)
66
66
  - **Your own PRs.** Label a PR `kanbot-review` for agent review without a card. Commits to `main` that break kanbot PRs get merged in and fixed. [More](./docs/04-outside-the-board.md)
67
- - **Project memory.** Corrections you give on PRs become facts agents read on every job. [More](./docs/06-skills.md#project-memory)
68
- - **Copilot review.** Optionally add GitHub Copilot as a cloud reviewer. [More](./docs/09-copilot-review.md)
67
+ - **Project memory.** Corrections you give on PRs become facts agents read on every job. [More](./docs/09-project-memory.md)
68
+ - **Copilot review.** Optionally add GitHub Copilot as a cloud reviewer. [More](./docs/13-copilot-review.md)
69
69
 
70
70
  ## Customizing
71
71
 
72
72
  - `.kanbot/config.yml`: concurrency, timeouts, notifications, column names.
73
73
  - `.kanbot/agents/*.md`: agent definitions. Run `kanbot sync` after editing; don't edit the generated `.claude/` or `.codex/` files.
74
- - `.agents/skills/`: skills shared by agents. Fill in `project-conventions` first.
75
- - `capabilities` in config: your own skills and agents for building, testing and reviewing. [More](./docs/13-capabilities.md)
74
+ - `.agents/skills/`: skills shared by agents. Fill in [`project-conventions`](./docs/08-project-conventions.md) first.
75
+ - `capabilities` in config: your own skills and agents for building, testing and reviewing. [More](./docs/05-capabilities.md)
76
76
 
77
77
  ## Upgrading
78
78
 
79
- `bunx kanbot@latest upgrade` updates agents and skills you haven't edited. For edited ones, it writes the new version next to yours as `<file>.kanbot-new` to merge. See [Upgrading](./docs/10-upgrading.md).
79
+ `bunx kanbot@latest upgrade` updates agents and skills you haven't edited. For edited ones, it writes the new version next to yours as `<file>.kanbot-new` to merge. See [Upgrading](./docs/14-upgrading.md).
80
80
 
81
81
  ## Shell completion
82
82
 
@@ -89,7 +89,7 @@ The shell is detected from `$SHELL`; pass `--shell zsh|bash|fish` to override. F
89
89
 
90
90
  ## Operating
91
91
 
92
- - `kanbot status` shows the board, daemon and failures.
92
+ - `kanbot status` shows the board, what waits on you, daemon and failures.
93
93
  - `.kanbot/logs/*.jsonl` has every agent transcript.
94
94
 
95
95
  ## Limitations
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kanbot",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Run an autonomous dev team (Claude Code or Codex) from a GitHub Project board",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env bun
2
2
  import { runCommand, runMain } from "citty";
3
3
  import { formatError } from "./errors.ts";
4
+ import { log } from "./log.ts";
4
5
  import { main } from "./main.ts";
6
+ import { versionNotice } from "./templates.ts";
5
7
 
6
8
  const args = process.argv.slice(2);
7
9
  const usage =
@@ -13,6 +15,11 @@ const usage =
13
15
  if (usage) {
14
16
  await runMain(main);
15
17
  } else {
18
+ // These set the version themselves, report it, or print shell code.
19
+ if (!["init", "upgrade", "doctor", "completion"].includes(args[0] ?? "")) {
20
+ const notice = await versionNotice(process.cwd());
21
+ if (notice) log.warn(notice);
22
+ }
16
23
  try {
17
24
  await runCommand(main, { rawArgs: args });
18
25
  } catch (e) {
@@ -21,7 +21,13 @@ import {
21
21
  type McpServer,
22
22
  readMcpServers,
23
23
  } from "../providers/mcp.ts";
24
- import { NEW_SUFFIX, pendingMerges, planUpgrade } from "../templates.ts";
24
+ import {
25
+ FILL_IN,
26
+ NEW_SUFFIX,
27
+ pendingMerges,
28
+ planUpgrade,
29
+ unfilledTemplates,
30
+ } from "../templates.ts";
25
31
  import { REPO_LABELS } from "./board.ts";
26
32
 
27
33
  type Check = {
@@ -323,6 +329,31 @@ export default defineCommand({
323
329
  fix: `merge each into the file next to it and delete it (kanbot-admin skill can help)`,
324
330
  });
325
331
 
332
+ const unfilled = await unfilledTemplates(root);
333
+ for (const path of FILL_IN) {
334
+ const u = unfilled.find((x) => x.path === path);
335
+ const conventions = path.includes("project-conventions");
336
+ add({
337
+ name: `${path} filled in`,
338
+ ok: true,
339
+ warn: !!u,
340
+ detail: u?.detail,
341
+ why: u
342
+ ? conventions
343
+ ? "Every agent reads it first for commands and conventions. Until it's filled in, they guess."
344
+ : "Agents fix failing CI, so it should run your real install, lint and test commands."
345
+ : undefined,
346
+ fix: u
347
+ ? conventions
348
+ ? "replace each <placeholder> and delete the TEMPLATE note (kanbot-guide skill can help)"
349
+ : "replace the starter steps with your stack's commands"
350
+ : undefined,
351
+ docs: conventions
352
+ ? "project-conventions#fill-it-in"
353
+ : "getting-started#set-up-a-repository",
354
+ });
355
+ }
356
+
326
357
  section(`Pushed to origin/${config.baseBranch}`);
327
358
  await git(root, "fetch", "origin", config.baseBranch).catch(() => {});
328
359
  const tree = await git(
@@ -5,7 +5,7 @@ import { loadAgents } from "../agents/load.ts";
5
5
  import { sync } from "../agents/sync.ts";
6
6
  import { askCapabilities, compact, ownWorkers } from "../capabilities.ts";
7
7
  import { configPath, saveConfig, writeConfigSchema } from "../config.ts";
8
- import { UserError } from "../errors.ts";
8
+ import { DOCS_URL, UserError } from "../errors.ts";
9
9
  import { run } from "../github/gh.ts";
10
10
  import { log } from "../log.ts";
11
11
  import { applyUpgrade, NEW_SUFFIX, planUpgrade } from "../templates.ts";
@@ -111,6 +111,7 @@ export default defineCommand({
111
111
  console.log(`
112
112
  Next steps:
113
113
  1. Review AGENTS.md and fill in .agents/skills/project-conventions/SKILL.md
114
+ (ask your agent to draft it; see ${DOCS_URL}/project-conventions)
114
115
  2. Adjust .github/workflows/ci.yml to your stack (agents fix failing CI)
115
116
  3. Map your own skills under capabilities in .kanbot/config.yml (kanbot-guide can help)
116
117
  4. kanbot board create # board and labels; enable the workflows it prints
@@ -1,15 +1,27 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { defineCommand } from "citty";
4
- import { COLUMN_KEYS, LABELS, loadConfig } from "../config.ts";
4
+ import { COLUMN_KEYS, loadConfig } from "../config.ts";
5
+ import { waitingOnHuman } from "../daemon/derive.ts";
6
+ import { takeSnapshot } from "../daemon/snapshot.ts";
5
7
  import { State } from "../daemon/state.ts";
6
- import { Gh, run } from "../github/gh.ts";
8
+ import { Gh } from "../github/gh.ts";
7
9
  import { listItems, loadBoard } from "../github/project.ts";
10
+ import { Trust } from "../github/trust.ts";
11
+
12
+ /** Short age like `5m`, `3h` or `2d`. */
13
+ export function age(since: string, now = Date.now()) {
14
+ const m = Math.max(0, Math.floor((now - Date.parse(since)) / 60_000));
15
+ if (m < 60) return `${m}m`;
16
+ if (m < 60 * 24) return `${Math.floor(m / 60)}h`;
17
+ return `${Math.floor(m / (60 * 24))}d`;
18
+ }
8
19
 
9
20
  export default defineCommand({
10
21
  meta: {
11
22
  name: "status",
12
- description: "Show board columns, daemon state and failures",
23
+ description:
24
+ "Show board columns, what waits on you, daemon state and failures",
13
25
  },
14
26
  async run() {
15
27
  const root = process.cwd();
@@ -37,22 +49,14 @@ export default defineCommand({
37
49
  console.log(`${config.columns[key].padEnd(12)} ${shown}`);
38
50
  }
39
51
 
40
- const needsHuman = JSON.parse(
41
- await run([
42
- "issue",
43
- "list",
44
- "--repo",
45
- config.repo,
46
- "--label",
47
- LABELS.needsHuman,
48
- "--json",
49
- "number,title",
50
- ]),
51
- ) as { number: number; title: string }[];
52
- if (needsHuman.length) {
53
- console.log(
54
- `\nneeds-human:\n${needsHuman.map((i) => ` #${i.number} ${i.title}`).join("\n")}`,
55
- );
52
+ const snapshot = await takeSnapshot(gh, board, new Trust(gh), items);
53
+ const waiting = waitingOnHuman(snapshot, config);
54
+ if (waiting.length) {
55
+ const rows = waiting.map(({ item, why, url, since }) => {
56
+ const ago = since ? age(since) : "?";
57
+ return ` ${ago.padStart(4)} #${item.issue.number} ${why} · ${item.issue.title}\n ${url}`;
58
+ });
59
+ console.log(`\nwaiting on you:\n${rows.join("\n")}`);
56
60
  }
57
61
 
58
62
  const state = await new State(root).load();
@@ -77,6 +77,7 @@ export type Action =
77
77
  | { kind: "compare"; item: Item; prs: PR[] }
78
78
  | { kind: "needsHuman"; number: number; why: string }
79
79
  | { kind: "openQuestions"; item: Item; questions: string[] }
80
+ | { kind: "waiting"; item: Item; blockers: string[] }
80
81
  | { kind: "resetFailures"; prefix: string }
81
82
  | { kind: "cleanup"; number: number };
82
83
 
@@ -199,6 +200,22 @@ export function openQuestions(plan: string | null): string[] {
199
200
  .filter((q): q is string => Boolean(q));
200
201
  }
201
202
 
203
+ /** Open "blocked by" issues, as `#12` or `owner/repo#12`. */
204
+ export function openBlockers(issue: Issue): string[] {
205
+ return issue.blockedBy
206
+ .filter((b) => b.state === "OPEN")
207
+ .map((b) => `${b.repo ?? ""}#${b.number}`);
208
+ }
209
+
210
+ /** Was the waiting comment already posted for these blockers? */
211
+ function waitingPosted(comments: Comment[], blockers: string[]) {
212
+ for (let i = comments.length - 1; i >= 0; i--) {
213
+ const [last] = markerValues((comments[i] as Comment).body, "waiting");
214
+ if (last) return last === blockers.join(",");
215
+ }
216
+ return false;
217
+ }
218
+
202
219
  /** Was the open-questions gate already posted for the latest plan? */
203
220
  function gatePosted(comments: Comment[]) {
204
221
  for (let i = comments.length - 1; i >= 0; i--) {
@@ -269,6 +286,16 @@ export function derive(snapshot: Snapshot, ctx: DeriveContext) {
269
286
  actions.push({ kind: "openQuestions", item, questions });
270
287
  break;
271
288
  }
289
+ // Open blockers keep it waiting; the next Ready card is picked.
290
+ const blockers =
291
+ item.column === "ready" && !issue.prs.length
292
+ ? openBlockers(issue)
293
+ : [];
294
+ if (blockers.length) {
295
+ if (!waitingPosted(issue.comments, blockers))
296
+ actions.push({ kind: "waiting", item, blockers });
297
+ break;
298
+ }
272
299
  const n = exploreCount(issue.labels);
273
300
  const variants =
274
301
  n === null ? [null] : Array.from({ length: n }, (_, i) => i + 1);
@@ -489,3 +516,108 @@ export function schedule(
489
516
  .sort((a, b) => rank(a) - rank(b))
490
517
  .slice(0, Math.max(0, freeSlots));
491
518
  }
519
+
520
+ /** A card that needs a human before it can move on. */
521
+ export type Waiting = {
522
+ item: Item;
523
+ why: string;
524
+ /** Link to act on: the PR, or the issue. */
525
+ url: string;
526
+ /** When it started waiting, if known. */
527
+ since: string | null;
528
+ };
529
+
530
+ /** Does the body carry `<!-- kanbot:<kind> -->` or `<!-- kanbot:<kind>:x -->`? */
531
+ const markedAs = (body: string, kind: string) =>
532
+ body.includes(`<!-- kanbot:${kind} -->`) ||
533
+ body.includes(`<!-- kanbot:${kind}:`);
534
+
535
+ const lastMarked = (comments: Comment[], ...kinds: string[]) =>
536
+ comments.findLast((c) => kinds.some((k) => markedAs(c.body, k)));
537
+
538
+ /** First line of a kanbot comment, without bold. */
539
+ const headline = (c: Comment | undefined) =>
540
+ c?.body.split("\n")[0]?.replaceAll("**", "").trim() || null;
541
+
542
+ /** Cards waiting on a human, oldest first. Pure, like `derive`. */
543
+ export function waitingOnHuman(snapshot: Snapshot, config: Config): Waiting[] {
544
+ const { trusted } = snapshot;
545
+ const out: Waiting[] = [];
546
+
547
+ for (const item of snapshot.items) {
548
+ const { issue } = item;
549
+ if (issue.state === "CLOSED" || item.column === "done") continue;
550
+ const add = (why: string, c?: Comment, url = issue.url) =>
551
+ out.push({ item, why, url, since: c?.createdAt ?? null });
552
+
553
+ if (issue.labels.includes(LABELS.needsHuman)) {
554
+ const c = lastMarked(
555
+ [...issue.comments, ...issue.prs.flatMap((p) => p.comments)].sort(
556
+ (a, b) => a.createdAt.localeCompare(b.createdAt),
557
+ ),
558
+ "needs-human",
559
+ "blocked",
560
+ );
561
+ add(headline(c) ?? LABELS.needsHuman, c);
562
+ continue;
563
+ }
564
+
565
+ switch (item.column) {
566
+ case "planning": {
567
+ const plan = extractPlan(issue.body);
568
+ if (plan === null || pendingComments(issue.comments, trusted).length)
569
+ break;
570
+ const c = lastMarked(issue.comments, "plan");
571
+ add(
572
+ openQuestions(plan).length
573
+ ? "answer the plan's questions"
574
+ : "approve the plan",
575
+ c,
576
+ );
577
+ break;
578
+ }
579
+
580
+ case "ready": {
581
+ if (issue.prs.length || !gatePosted(issue.comments)) break;
582
+ if (!openQuestions(extractPlan(issue.body)).length) break;
583
+ add("answer open questions", lastMarked(issue.comments, "questions"));
584
+ break;
585
+ }
586
+
587
+ case "review": {
588
+ const open = issue.prs.filter(
589
+ (p) =>
590
+ p.state === "OPEN" &&
591
+ (issue.prOnly || p.headRefName.startsWith("kanbot/")),
592
+ );
593
+ const compare = lastMarked(issue.comments, "compare");
594
+ if (exploreCount(issue.labels) !== null && compare) {
595
+ add("pick an approach", compare);
596
+ break;
597
+ }
598
+ for (const pr of open) {
599
+ if (!pr.labels.includes(LABELS.agentReviewed)) continue;
600
+ const feedback = openFeedback(pr, trusted);
601
+ if (feedback.threads.length || feedback.comments.length) continue;
602
+ const approved = approvedBy(pr, trusted) !== null;
603
+ // Approved kanbot PRs are merged by the daemon.
604
+ const autoMerge =
605
+ config.merge.onApproval && pr.headRefName.startsWith("kanbot/");
606
+ if (approved && autoMerge) continue;
607
+ const c = lastMarked(pr.comments, "ready");
608
+ out.push({
609
+ item,
610
+ why: `${approved ? "merge" : "review"} PR #${pr.number}`,
611
+ url: pr.url,
612
+ since: c?.createdAt ?? pr.createdAt,
613
+ });
614
+ }
615
+ break;
616
+ }
617
+ }
618
+ }
619
+
620
+ // Oldest first; unknown age last.
621
+ const key = (w: Waiting) => w.since ?? "\uffff";
622
+ return out.sort((a, b) => key(a).localeCompare(key(b)));
623
+ }
@@ -213,6 +213,8 @@ function describeAction(a: Action) {
213
213
  return `needs-human #${a.number}`;
214
214
  case "openQuestions":
215
215
  return `open questions on #${a.item.issue.number}`;
216
+ case "waiting":
217
+ return `#${a.item.issue.number} waits on ${a.blockers.join(", ")}`;
216
218
  case "resetFailures":
217
219
  return `reset failures ${a.prefix}`;
218
220
  case "cleanup":
@@ -18,8 +18,10 @@ export async function takeSnapshot(
18
18
  gh: Gh,
19
19
  board: Board,
20
20
  trust: Trust,
21
+ /** Board items already listed, to save a query. */
22
+ listed?: Awaited<ReturnType<typeof listItems>>,
21
23
  ): Promise<Snapshot> {
22
- const raw = await listItems(gh, board);
24
+ const raw = listed ?? (await listItems(gh, board));
23
25
  const items = await mapLimit(
24
26
  raw,
25
27
  4,
@@ -50,6 +52,7 @@ export async function takeSnapshot(
50
52
  labels: [],
51
53
  comments: [],
52
54
  prs: [],
55
+ blockedBy: [],
53
56
  },
54
57
  };
55
58
  },
@@ -86,6 +89,7 @@ export function prItem(pr: PR, position: number): Item {
86
89
  labels: pr.labels,
87
90
  comments: [],
88
91
  prs: [pr],
92
+ blockedBy: [],
89
93
  prOnly: true,
90
94
  },
91
95
  };
@@ -61,6 +61,13 @@ export type PR = {
61
61
  mergeable: "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
62
62
  };
63
63
 
64
+ /** An issue this one links to. `repo` is null for the same repo. */
65
+ export type IssueRef = {
66
+ number: number;
67
+ repo: string | null;
68
+ state: "OPEN" | "CLOSED";
69
+ };
70
+
64
71
  export type Issue = {
65
72
  id: string;
66
73
  number: number;
@@ -71,6 +78,8 @@ export type Issue = {
71
78
  labels: string[];
72
79
  comments: Comment[];
73
80
  prs: PR[];
81
+ /** GitHub "blocked by" links. */
82
+ blockedBy: IssueRef[];
74
83
  /** A PR labeled `kanbot-review`, reviewed without a card. Fields mirror the PR. */
75
84
  prOnly?: boolean;
76
85
  };
package/src/github/api.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Checks, Comment, Issue, PR } from "../daemon/types.ts";
1
+ import type { Checks, Comment, Issue, IssueRef, PR } from "../daemon/types.ts";
2
2
  import type { Gh } from "./gh.ts";
3
3
 
4
4
  const COMMENT = "id databaseId author { login } body createdAt";
@@ -24,6 +24,7 @@ query($owner: String!, $name: String!, $number: Int!) {
24
24
  id number url title body state
25
25
  labels(first: 50) { nodes { name } }
26
26
  comments(last: 100) { nodes { ${COMMENT} } }
27
+ blockedBy(first: 20) { nodes { number state repository { nameWithOwner } } }
27
28
  closedByPullRequestsReferences(first: 10, includeClosedPrs: true) {
28
29
  nodes { ${PR_FIELDS} }
29
30
  }
@@ -69,6 +70,15 @@ export async function fetchIssue(gh: Gh, number: number): Promise<Issue> {
69
70
  labels: names(i.labels),
70
71
  comments: i.comments.nodes.map(comment),
71
72
  prs: i.closedByPullRequestsReferences.nodes.map(mapPR),
73
+ // biome-ignore lint/suspicious/noExplicitAny: raw GraphQL shape
74
+ blockedBy: i.blockedBy.nodes.map((b: any): IssueRef => {
75
+ const repo = b.repository.nameWithOwner;
76
+ return {
77
+ number: b.number,
78
+ repo: repo === gh.repo ? null : repo,
79
+ state: b.state,
80
+ };
81
+ }),
72
82
  };
73
83
  }
74
84
 
@@ -205,6 +215,14 @@ export function addSubIssue(gh: Gh, parentId: string, childId: string) {
205
215
  );
206
216
  }
207
217
 
218
+ export function addBlockedBy(gh: Gh, issueId: string, blockerId: string) {
219
+ return gh.mutate(
220
+ "add blocked-by",
221
+ `mutation($i: ID!, $b: ID!) { addBlockedBy(input: { issueId: $i, blockingIssueId: $b }) { issue { id } } }`,
222
+ { i: issueId, b: blockerId },
223
+ );
224
+ }
225
+
208
226
  export function createPR(
209
227
  gh: Gh,
210
228
  pr: {
package/src/jobs/run.ts CHANGED
@@ -19,6 +19,7 @@ import type { Worktrees } from "../daemon/git.ts";
19
19
  import type { Notifier } from "../daemon/notify.ts";
20
20
  import type { State } from "../daemon/state.ts";
21
21
  import {
22
+ addBlockedBy,
22
23
  addLabels,
23
24
  addSubIssue,
24
25
  createIssue,
@@ -152,6 +153,25 @@ export function runJob(job: Job, deps: JobDeps): Promise<void> {
152
153
 
153
154
  // ---- plan -------------------------------------------------------------------
154
155
 
156
+ /**
157
+ * [issue, blocker] node id pairs for the planner's `blockedBy` indexes.
158
+ * `ids[0]` is the planned issue, `ids[n]` the nth new issue (undefined if not
159
+ * created). Bad indexes and self-links are dropped.
160
+ */
161
+ export function blockedByLinks(
162
+ newIssues: { blockedBy: number[] }[],
163
+ ids: (string | undefined)[],
164
+ ): [string, string][] {
165
+ return newIssues.flatMap((ni, i) => {
166
+ const id = ids[i + 1];
167
+ if (!id) return [];
168
+ return ni.blockedBy
169
+ .map((b) => ids[b])
170
+ .filter((b): b is string => !!b && b !== id)
171
+ .map((b): [string, string] => [id, b]);
172
+ });
173
+ }
174
+
155
175
  async function plan(job: PlanJob, deps: JobDeps) {
156
176
  const { gh, bot } = deps;
157
177
  const { issue } = job.item;
@@ -183,17 +203,25 @@ async function plan(job: PlanJob, deps: JobDeps) {
183
203
  );
184
204
 
185
205
  const created: string[] = [];
206
+ // Node ids by blockedBy index: 0 is this issue, n the nth new issue.
207
+ const ids: (string | undefined)[] = [issue.id];
186
208
  for (const ni of result.newIssues) {
187
209
  const res = await createIssue(
188
210
  bot,
189
211
  ni.title,
190
212
  `${ni.body}\n\n_Split from #${issue.number} by kanbot._\n${marker("created", issue.number)}`,
191
213
  );
214
+ ids.push(res?.node_id);
192
215
  if (!res) continue;
193
216
  if (ni.subIssue) await addSubIssue(bot, issue.id, res.node_id);
194
217
  await addToBoard(gh, deps.board, res.node_id, ni.column);
195
218
  created.push(`#${res.number}`);
196
219
  }
220
+ for (const [id, blocker] of blockedByLinks(result.newIssues, ids)) {
221
+ await addBlockedBy(bot, id, blocker).catch((e) =>
222
+ log.warn(`blocked-by link: ${e}`),
223
+ );
224
+ }
197
225
 
198
226
  const lines = [
199
227
  `**Plan ${job.initial ? "drafted" : "updated"}.** ${result.summary}`,
@@ -590,6 +618,12 @@ export async function applyAction(action: Action, deps: JobDeps) {
590
618
  action.item.issue.number,
591
619
  `**Not started: the plan has open questions.**\n\n${action.questions.map((q) => `- ${q}`).join("\n")}\n\nMove the card to **Planning** and answer them, or remove them from the plan. Then move it back to **Ready**.\n${marker("questions")}`,
592
620
  );
621
+ case "waiting":
622
+ return postComment(
623
+ bot,
624
+ action.item.issue.number,
625
+ `**Waiting: blocked by ${action.blockers.join(", ")}.** kanbot starts it when they close.\n${marker("waiting", action.blockers.join(","))}`,
626
+ );
593
627
  case "merge": {
594
628
  const { pr } = action;
595
629
  log.info(`merging #${pr.number}, approved by ${action.by}`);
@@ -4,7 +4,8 @@
4
4
  "d932ed30a58cb32dd91ec03ddd2b1c6c59f77714"
5
5
  ],
6
6
  ".agents/skills/issue-splitting/SKILL.md": [
7
- "01625372d68a81347bf401e8603084420922d04e"
7
+ "01625372d68a81347bf401e8603084420922d04e",
8
+ "8d8651621721c530522d83edb9530b622df275df"
8
9
  ],
9
10
  ".agents/skills/kanbot-admin/SKILL.md": [
10
11
  "3789c559be9fb6a78f82708ae9857395a5556f6b",
@@ -17,10 +18,15 @@
17
18
  ],
18
19
  ".agents/skills/kanbot-guide/SKILL.md": [
19
20
  "1251194ba34a8b6eae8508037b0a76c60d7a26b8",
21
+ "1329454d5b81912e856591caa036c93f88ef8c69",
20
22
  "2c6c77a225c5b880eff023d4b3c1305bfc91bfb3",
21
23
  "2ccb86b12e5af121b79ef9f53c2a35dbbdb2ddeb",
24
+ "53c73b1be4a1e8d863a52043eae4ca911c8be71b",
22
25
  "a24c0a655d6485e65bd5dea2a71f3afe67f04404",
23
- "cf3e95d137a5a58a273262bca6ba25fbc95fa037"
26
+ "b5a2d074ea61f2552890ca1d78bf8a043d7b73b3",
27
+ "bde0364d60525598bbb28f1648d06d1407c62a73",
28
+ "cf3e95d137a5a58a273262bca6ba25fbc95fa037",
29
+ "f9b76db4afbad14ad9d14dff805c71703099bdeb"
24
30
  ],
25
31
  ".agents/skills/kanbot-idea/SKILL.md": [
26
32
  "8ab28cec8d3f30250ffe080106d0de8e3c2eaa8a",
@@ -104,6 +110,7 @@
104
110
  ".kanbot/agents/planner.md": [
105
111
  "2283cf8bf6d27b8bbf721f97bb45cd40eaa6d39d",
106
112
  "6500bea6691685d2b74ea73095f11643e0fcc937",
113
+ "6def83eade7a3e8ca3bde6a3458c3b4921b3b3ac",
107
114
  "a4a65d240ab4b23737f4d68d8765cca89cf08864",
108
115
  "bdb111213cced70583157edad658247972dcbe45",
109
116
  "dc50d048164ade2926b58dee48c3919c6b7d9558"
@@ -30,6 +30,11 @@ export const PlanResult = z.strictObject({
30
30
  .boolean()
31
31
  .describe("true when it is split out of this issue"),
32
32
  column: z.enum(["ideas", "planning"]),
33
+ blockedBy: z
34
+ .array(z.number().int().min(0))
35
+ .describe(
36
+ "What must merge first: 0 is this issue, n is the nth entry in newIssues (1-based). Empty when independent.",
37
+ ),
33
38
  }),
34
39
  )
35
40
  .describe("Issues to create, e.g. when the human asks to split work."),
package/src/templates.ts CHANGED
@@ -54,6 +54,18 @@ export async function readManifest(root: string): Promise<Manifest | null> {
54
54
  return (await file.exists()) ? ((await file.json()) as Manifest) : null;
55
55
  }
56
56
 
57
+ /** Warning when this repo's templates and the running kanbot differ in version. */
58
+ export async function versionNotice(root: string, current = pkg.version) {
59
+ const installed = (await readManifest(root).catch(() => null))?.version;
60
+ if (!installed) return null;
61
+ const order = Bun.semver.order(installed, current);
62
+ if (order < 0)
63
+ return `kanbot ${current} is out, this repo has ${installed}. Run \`kanbot upgrade\` to get the new agents, skills and workflows.`;
64
+ if (order > 0)
65
+ return `This repo uses kanbot ${installed}, but you run ${current}. Use \`bunx kanbot@latest\`.`;
66
+ return null;
67
+ }
68
+
57
69
  async function writeManifest(root: string, files: Record<string, string>) {
58
70
  const sorted = Object.fromEntries(
59
71
  Object.entries(files).sort(([a], [b]) => a.localeCompare(b)),
@@ -191,3 +203,54 @@ export async function pendingMerges(root: string, dir = TEMPLATES) {
191
203
  const paths = [...(await listTemplates(dir)).keys()];
192
204
  return paths.filter((p) => existsSync(join(root, `${p}${NEW_SUFFIX}`)));
193
205
  }
206
+
207
+ /** User templates you are meant to fill in after install. */
208
+ export const FILL_IN = [
209
+ ".agents/skills/project-conventions/SKILL.md",
210
+ ".github/workflows/ci.yml",
211
+ ];
212
+
213
+ const clip = (s: string) => (s.length > 72 ? `${s.slice(0, 71)}…` : s);
214
+
215
+ export type Unfilled = { path: string; detail: string };
216
+
217
+ /**
218
+ * Fill-in templates still unedited, or still holding the template's
219
+ * `<placeholder>`s. Missing files are left to other checks.
220
+ */
221
+ export async function unfilledTemplates(
222
+ root: string,
223
+ opts: Pick<Options, "templates" | "known"> = {},
224
+ ): Promise<Unfilled[]> {
225
+ const templates = await listTemplates(opts.templates);
226
+ const known: Record<string, string[]> = opts.known ?? knownHashes;
227
+ const out: Unfilled[] = [];
228
+ for (const path of FILL_IN) {
229
+ const tpl = templates.get(path);
230
+ const file = Bun.file(join(root, path));
231
+ if (!tpl || !(await file.exists())) continue;
232
+ const bytes = await file.bytes();
233
+ const current = hash(bytes);
234
+ if (current === hash(tpl) || (known[path] ?? []).includes(current)) {
235
+ out.push({ path, detail: "unchanged from the template" });
236
+ continue;
237
+ }
238
+ const placeholders = new Set(
239
+ new TextDecoder().decode(tpl).match(/<[^<>`\n]+>/g) ?? [],
240
+ );
241
+ const left = new TextDecoder()
242
+ .decode(bytes)
243
+ .split("\n")
244
+ .flatMap((line, i) =>
245
+ [...placeholders].some((p) => line.includes(p))
246
+ ? [`line ${i + 1}: ${clip(line.trim())}`]
247
+ : [],
248
+ );
249
+ if (left.length)
250
+ out.push({
251
+ path,
252
+ detail: `${left.length} line(s) with placeholders left:\n${left.slice(0, 5).join("\n")}${left.length > 5 ? "\n…" : ""}`,
253
+ });
254
+ }
255
+ return out;
256
+ }
@@ -21,7 +21,7 @@ Split when any of these hold:
21
21
 
22
22
  ## INVEST check for each slice
23
23
 
24
- - **Independent**: can merge in any order relative to sibling slices, or the order is explicit.
24
+ - **Independent**: can merge in any order relative to sibling slices, or the order is explicit in `blockedBy`.
25
25
  - **Negotiable**: describes the need, leaves room in how.
26
26
  - **Valuable**: a user or operator notices something, even if small.
27
27
  - **Estimable**: no unbounded unknowns; otherwise spike first.
@@ -61,6 +61,10 @@ Ask: what is the least we could ship that someone would use or that teaches us s
61
61
  - **Idea** (`subIssue: false`, column `ideas`): related but a different need, a nice-to-have you noticed, or tech debt discovered along the way.
62
62
  - **Neither**: things that are just part of doing this slice well. Keep them in the plan.
63
63
 
64
+ ## Order
65
+
66
+ kanbot won't start a Ready card while its `blockedBy` issues are open. Set `blockedBy` on each slice to what must merge first; usually the previous slice. Leave it empty only when the slice truly stands alone.
67
+
64
68
  When splitting, this issue keeps the first slice. Rewrite its plan to cover only that slice and list the other slices under `## Out of scope` with a note that they were proposed as new issues.
65
69
 
66
70
  ## Body for each new slice
@@ -12,27 +12,29 @@ Explain in plain words. Ask before choosing for them; give a recommendation with
12
12
 
13
13
  ## Docs
14
14
 
15
- | Question | Slug |
16
- | --------------------------------------------- | ------------------- |
17
- | How do I install and set up a repo? | `getting-started` |
18
- | How does a card move across the board? | `workflow` |
19
- | How do I file ideas by chatting? | `capturing-ideas` |
20
- | My own PRs, local sessions, direct commits? | `outside-the-board` |
21
- | Which agents are there, how do I change them? | `agents` |
22
- | Skills and project memory? | `skills` |
23
- | Claude or Codex? | `providers` |
24
- | Every config field, labels, notifications | `configuration` |
25
- | Using our own skills and agents for the work | `capabilities` |
26
- | Copilot as an extra reviewer | `copilot-review` |
27
- | Bot or user identity, merge by approving | `identity` |
28
- | Upgrading and `.kanbot-new` files | `upgrading` |
29
- | Every command and flag | `usage` |
15
+ | Question | Slug |
16
+ | --------------------------------------------- | --------------------- |
17
+ | How do I install and set up a repo? | `getting-started` |
18
+ | How does a card move across the board? | `workflow` |
19
+ | How do I add or file ideas? | `capturing-ideas` |
20
+ | My own PRs, local sessions, direct commits? | `outside-the-board` |
21
+ | Which agents are there, how do I change them? | `agents` |
22
+ | Skills? | `skills` |
23
+ | Filling in stack, commands and rules | `project-conventions` |
24
+ | Project memory, facts from corrections | `project-memory` |
25
+ | Claude or Codex? | `providers` |
26
+ | Every config field, labels, notifications | `configuration` |
27
+ | Using our own skills and agents for the work | `capabilities` |
28
+ | Copilot as an extra reviewer | `copilot-review` |
29
+ | Bot or user identity, merge by approving | `identity` |
30
+ | Upgrading and `.kanbot-new` files | `upgrading` |
31
+ | Every command and flag | `usage` |
30
32
 
31
33
  ## Board flow
32
34
 
33
35
  Ideas (you write) → Planning (agent plans, you comment) → Ready (you approve by moving the card) → In Progress (agent builds, opens a PR) → Review (technical, then acceptance review, with fixes; you comment) → Done (you merge, or approve and kanbot merges).
34
36
 
35
- kanbot runs the process; the repo's own skills and agents do the work on code, through capabilities. Plans list assumptions about user need; open questions keep a card from starting in Ready.
37
+ kanbot runs the process; the repo's own skills and agents do the work on code, through capabilities. Plans list assumptions about user need; open questions and open "blocked by" issues keep a card from starting in Ready.
36
38
 
37
39
  Agents never get a GitHub token. The daemon does every comment, push and PR. Only collaborators with write access can steer agents. Workers see only what's committed, including MCP servers in `.mcp.json`; never the user's own plugins or servers.
38
40
 
@@ -42,7 +44,7 @@ Go step by step. Run each command, check its output, then go on. If `.kanbot/con
42
44
 
43
45
  1. **Requirements.** Bun 1.3+, `gh` logged in, `claude` or `codex` logged in with a subscription (not an API key). Run `gh auth refresh -s project`.
44
46
  2. **Provider.** Ask which subscription they have, then `bunx kanbot init --provider claude|codex` in the repo root.
45
- 3. **Conventions.** Fill in `.agents/skills/project-conventions/SKILL.md` with them: stack, commands to test, lint and typecheck. Adjust `.github/workflows/ci.yml` to run the same. Every agent reads this first.
47
+ 3. **Conventions.** Fill in `.agents/skills/project-conventions/SKILL.md` with them: stack, commands to test, lint and typecheck. Adjust `.github/workflows/ci.yml` to run the same. Every agent reads this first. Draft it from manifests and CI, ask what you can't find, and have them run each command. Link `project-conventions`.
46
48
  4. **Capabilities.** kanbot runs the process; the repo's own skills and agents do the work. Ask which skills they have for proving acceptance criteria (`verify`), technical review (`review`) and something a human can try (`preview`), and map them under `capabilities` in `.kanbot/config.yml`. See [Capabilities](#capabilities).
47
49
  5. **Board.** `kanbot board create`, or `kanbot board link --owner <login> --number <n>` for an existing project. Have them enable the four built-in project workflows it prints; they can't be set through the API.
48
50
  6. **Options.** Go through [Choose options](#choose-options). For app mode, run `kanbot app create` and have them install the app on the repo.
@@ -102,9 +104,9 @@ Warn: never enable the branch rule "Require approval of the most recent reviewab
102
104
  ## Troubleshoot
103
105
 
104
106
  1. `kanbot doctor`: prints a fix for each failed check.
105
- 2. `kanbot status`: board by column, daemon, failures, rate-limit pause.
107
+ 2. `kanbot status`: board by column, what waits on the user (oldest first), daemon, failures, rate-limit pause.
106
108
  3. `.kanbot/logs/*.jsonl`: full agent transcripts.
107
109
  4. `needs-human` label: the issue has a comment saying why. Fix the cause, then remove the label to retry.
108
110
  5. Nothing happens: is the daemon running, are kanbot files pushed to the base branch, is the card in an active column?
109
- 6. Card stays in Ready: the plan has open questions. kanbot commented on the issue. Answer them in Planning, or remove them from the plan.
111
+ 6. Card stays in Ready: the plan has open questions, or the issue is blocked by open issues. kanbot commented why. Answer questions in Planning; blocked cards start when the blockers close.
110
112
  7. Agent didn't use a skill: is it mapped under `capabilities`, does its `when` fit, and is it committed?
@@ -48,6 +48,6 @@ Return the structured result; it is your only channel to GitHub.
48
48
  - `plan`: the complete plan markdown. It replaces the previous plan entirely, so include unchanged sections too.
49
49
  - `summary`: one sentence to the human on what changed this round.
50
50
  - `replies`: exactly one entry per input human comment, keyed by its `commentId`. Answer the question or say what you changed in the plan. One or two sentences; disagreeing is fine if you give the reason.
51
- - `newIssues`: slices split from this issue (`subIssue: true`, `column: planning` when ready to plan next, `ideas` otherwise) and unrelated ideas you noticed (`subIssue: false`, `column: ideas`). Each body is a user story, a one-line need and draft acceptance criteria. Empty when there is nothing to split.
51
+ - `newIssues`: slices split from this issue (`subIssue: true`, `column: planning` when ready to plan next, `ideas` otherwise) and unrelated ideas you noticed (`subIssue: false`, `column: ideas`). Each body is a user story, a one-line need and draft acceptance criteria. Empty when there is nothing to split. `blockedBy` says what must merge first: `0` is this issue, `n` the nth new issue. Slices default to the previous one (slice 2: `[0]`, slice 3: `[1]`); use `[]` only when a slice can truly merge in any order. Ideas get `[]`.
52
52
  - `labels`: `technical` when it is not a user-facing story; otherwise empty. Keep any `explore:N` label that is present.
53
53
  - `questions`: questions only the human can answer, each answerable in one line. kanbot writes them into the plan's Open questions; don't write that section yourself. Empty if none.