kanbot 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/package.json +1 -1
- package/src/color.ts +16 -0
- package/src/commands/doctor.ts +127 -14
- package/src/commands/status.ts +23 -19
- package/src/daemon/derive.ts +132 -0
- package/src/daemon/loop.ts +2 -0
- package/src/daemon/snapshot.ts +5 -1
- package/src/daemon/types.ts +9 -0
- package/src/github/api.ts +19 -1
- package/src/jobs/run.ts +34 -0
- package/src/known-hashes.json +6 -1
- package/src/schemas/results.ts +5 -0
- package/templates/.agents/skills/issue-splitting/SKILL.md +5 -1
- package/templates/.agents/skills/kanbot-guide/SKILL.md +5 -4
- package/templates/.kanbot/agents/planner.md +1 -1
package/README.md
CHANGED
|
@@ -26,11 +26,11 @@ Full docs: [docs/](./docs/00-index.md). Demo video: [docs/assets/demo.mp4](./doc
|
|
|
26
26
|
| **Ready** | Moving a card here approves the plan. Cards are picked top-down. |
|
|
27
27
|
| **In Progress** | An agent implements it in its own worktree and opens a PR. |
|
|
28
28
|
| **Review** | Two agent reviews, with fixes. Then you're notified. Comment on the PR and an agent addresses it. CI failures and merge conflicts are fixed too. |
|
|
29
|
-
| **Done** | You merge, or approve and kanbot merges ([`merge.onApproval`](./docs/
|
|
29
|
+
| **Done** | You merge, or approve and kanbot merges ([`merge.onApproval`](./docs/11-identity.md#merge-on-approval)). |
|
|
30
30
|
|
|
31
31
|
A Bun daemon polls the board and runs one `claude -p` / `codex exec` process per job. GitHub is the only state, so you can restart it any time. Agents never get a GitHub token; the daemon makes every comment, push and PR. Only collaborators with write access can steer agents.
|
|
32
32
|
|
|
33
|
-
kanbot talks as you by default. Run `kanbot app create` to have it talk as its own bot instead, so you can tell its messages apart and approve its PRs. Commits stay yours. With `merge.onApproval`, approving a PR merges it. [More](./docs/
|
|
33
|
+
kanbot talks as you by default. Run `kanbot app create` to have it talk as its own bot instead, so you can tell its messages apart and approve its PRs. Commits stay yours. With `merge.onApproval`, approving a PR merges it. [More](./docs/11-identity.md)
|
|
34
34
|
|
|
35
35
|
## Setup
|
|
36
36
|
|
|
@@ -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/
|
|
68
|
-
- **Copilot review.** Optionally add GitHub Copilot as a cloud reviewer. [More](./docs/
|
|
67
|
+
- **Project memory.** Corrections you give on PRs become facts agents read on every job. [More](./docs/08-project-memory.md)
|
|
68
|
+
- **Copilot review.** Optionally add GitHub Copilot as a cloud reviewer. [More](./docs/12-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
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/
|
|
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/
|
|
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/13-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
package/src/color.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// ANSI colors for TTY output. Off when piped or NO_COLOR is set; FORCE_COLOR turns them on.
|
|
2
|
+
const on =
|
|
3
|
+
!process.env.NO_COLOR &&
|
|
4
|
+
(!!process.env.FORCE_COLOR || !!process.stdout.isTTY);
|
|
5
|
+
|
|
6
|
+
const wrap = (open: number, close: number) => (s: string) =>
|
|
7
|
+
on ? `\x1b[${open}m${s}\x1b[${close}m` : s;
|
|
8
|
+
|
|
9
|
+
export const color = {
|
|
10
|
+
bold: wrap(1, 22),
|
|
11
|
+
dim: wrap(2, 22),
|
|
12
|
+
red: wrap(31, 39),
|
|
13
|
+
green: wrap(32, 39),
|
|
14
|
+
yellow: wrap(33, 39),
|
|
15
|
+
cyan: wrap(36, 39),
|
|
16
|
+
};
|
package/src/commands/doctor.ts
CHANGED
|
@@ -4,9 +4,10 @@ import { defineCommand } from "citty";
|
|
|
4
4
|
import { loadAgents, missingSkills } from "../agents/load.ts";
|
|
5
5
|
import { sync } from "../agents/sync.ts";
|
|
6
6
|
import { capabilitySummary, findCapability } from "../capabilities.ts";
|
|
7
|
+
import { color } from "../color.ts";
|
|
7
8
|
import { loadConfig, writeConfigSchema } from "../config.ts";
|
|
8
9
|
import { git } from "../daemon/git.ts";
|
|
9
|
-
import { describeError } from "../errors.ts";
|
|
10
|
+
import { DOCS_URL, describeError } from "../errors.ts";
|
|
10
11
|
import { copilotReviewRule } from "../github/api.ts";
|
|
11
12
|
import { appKeyPath, appTokens } from "../github/app.ts";
|
|
12
13
|
import { Gh, run } from "../github/gh.ts";
|
|
@@ -29,7 +30,11 @@ type Check = {
|
|
|
29
30
|
/** Shown with a warning mark; never fails doctor. */
|
|
30
31
|
warn?: boolean;
|
|
31
32
|
detail?: string;
|
|
33
|
+
/** Why it matters. Shown only when the check fails or warns. */
|
|
34
|
+
why?: string;
|
|
32
35
|
fix?: string;
|
|
36
|
+
/** Docs page and anchor, e.g. "configuration#config-file". */
|
|
37
|
+
docs?: string;
|
|
33
38
|
};
|
|
34
39
|
|
|
35
40
|
async function cmd(args: string[]) {
|
|
@@ -45,13 +50,69 @@ async function cmd(args: string[]) {
|
|
|
45
50
|
/** Failure detail and fix for a check, from a thrown error. */
|
|
46
51
|
function explain(e: unknown, fallback?: string) {
|
|
47
52
|
const d = describeError(e);
|
|
48
|
-
const detail = [d.message, d.why].filter(Boolean).join("\n");
|
|
49
53
|
return {
|
|
50
|
-
detail:
|
|
54
|
+
detail: d.message,
|
|
55
|
+
why: d.why,
|
|
51
56
|
fix: d.fix ?? fallback,
|
|
57
|
+
docs: d.docs,
|
|
52
58
|
};
|
|
53
59
|
}
|
|
54
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
|
+
|
|
55
116
|
export default defineCommand({
|
|
56
117
|
meta: {
|
|
57
118
|
name: "doctor",
|
|
@@ -62,19 +123,32 @@ export default defineCommand({
|
|
|
62
123
|
const checks: Check[] = [];
|
|
63
124
|
const add = (c: Check) => {
|
|
64
125
|
checks.push(c);
|
|
65
|
-
|
|
66
|
-
`${c.warn ? "!" : c.ok ? "✔" : "✖"} ${c.name}${c.detail ? ` — ${c.detail}` : ""}${!c.ok && c.fix ? `\n fix: ${c.fix}` : ""}`,
|
|
67
|
-
);
|
|
126
|
+
print(c);
|
|
68
127
|
};
|
|
69
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");
|
|
70
134
|
const auth = await cmd(["gh", "auth", "status"]);
|
|
71
|
-
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
|
+
});
|
|
72
142
|
add({
|
|
73
143
|
name: "gh token has project scope",
|
|
74
144
|
ok: /'project'/.test(auth.out),
|
|
145
|
+
why: "Needed to read and move cards on the project board.",
|
|
75
146
|
fix: "gh auth refresh -s project",
|
|
147
|
+
docs: "getting-started#requirements",
|
|
76
148
|
});
|
|
77
149
|
|
|
150
|
+
section("Config");
|
|
151
|
+
|
|
78
152
|
let config: Awaited<ReturnType<typeof loadConfig>>;
|
|
79
153
|
try {
|
|
80
154
|
config = await loadConfig(root);
|
|
@@ -85,27 +159,34 @@ export default defineCommand({
|
|
|
85
159
|
});
|
|
86
160
|
} catch (e) {
|
|
87
161
|
add({ name: "config", ok: false, ...explain(e, "kanbot init") });
|
|
88
|
-
|
|
162
|
+
console.log(
|
|
163
|
+
color.dim("\n The other checks need a config, so doctor stops here."),
|
|
164
|
+
);
|
|
165
|
+
return summary(checks);
|
|
89
166
|
}
|
|
90
167
|
|
|
91
168
|
if (config.provider === "claude") {
|
|
169
|
+
section("Claude Code");
|
|
92
170
|
const s = await cmd(["claude", "auth", "status"]);
|
|
93
171
|
const sub = /"authMethod":\s*"claude\.ai"/.test(s.out);
|
|
94
172
|
add({
|
|
95
173
|
name: "claude CLI logged in with a subscription",
|
|
96
174
|
ok: s.ok && sub,
|
|
97
|
-
detail: sub
|
|
98
|
-
|
|
99
|
-
: "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.",
|
|
100
177
|
fix: "claude auth login",
|
|
178
|
+
docs: "providers#claude-code",
|
|
101
179
|
});
|
|
102
180
|
} else {
|
|
181
|
+
section("Codex");
|
|
103
182
|
const s = await cmd(["codex", "login", "status"]);
|
|
104
183
|
add({
|
|
105
184
|
name: "codex CLI logged in with ChatGPT",
|
|
106
185
|
ok: s.ok && /ChatGPT/i.test(s.out),
|
|
107
186
|
detail: s.out.split("\n")[0],
|
|
187
|
+
why: "Workers run `codex` on your ChatGPT plan.",
|
|
108
188
|
fix: "codex login",
|
|
189
|
+
docs: "providers#codex",
|
|
109
190
|
});
|
|
110
191
|
add({
|
|
111
192
|
name: "codex workers ignore ~/.codex/config.toml",
|
|
@@ -115,6 +196,7 @@ export default defineCommand({
|
|
|
115
196
|
});
|
|
116
197
|
}
|
|
117
198
|
|
|
199
|
+
section("Agents and skills");
|
|
118
200
|
try {
|
|
119
201
|
const agents = await loadAgents(root);
|
|
120
202
|
add({
|
|
@@ -137,6 +219,7 @@ export default defineCommand({
|
|
|
137
219
|
name: "agents referenced by config exist",
|
|
138
220
|
ok: !missing.length,
|
|
139
221
|
detail: missing.join(", ") || undefined,
|
|
222
|
+
why: "Columns hand work to these agents, but .kanbot/agents has no file for them.",
|
|
140
223
|
fix: "kanbot upgrade",
|
|
141
224
|
});
|
|
142
225
|
if (config.implementers)
|
|
@@ -153,6 +236,7 @@ export default defineCommand({
|
|
|
153
236
|
name: "skills referenced by agents exist",
|
|
154
237
|
ok: !noSkill.length,
|
|
155
238
|
detail: noSkill.join(", ") || undefined,
|
|
239
|
+
why: "Agents list these skills, but .agents/skills has none by that name.",
|
|
156
240
|
fix: "add them to .agents/skills or remove them from the agent's `skills`",
|
|
157
241
|
});
|
|
158
242
|
add({
|
|
@@ -171,6 +255,9 @@ export default defineCommand({
|
|
|
171
255
|
detail: found
|
|
172
256
|
? `${found} agent, works on ${found} only`
|
|
173
257
|
: "not found",
|
|
258
|
+
why: found
|
|
259
|
+
? undefined
|
|
260
|
+
: "Jobs that need this capability can't load it.",
|
|
174
261
|
fix: "add the skill to .agents/skills or the agent to .kanbot/agents",
|
|
175
262
|
});
|
|
176
263
|
}
|
|
@@ -179,12 +266,15 @@ export default defineCommand({
|
|
|
179
266
|
name: "generated agent files up to date",
|
|
180
267
|
ok: !stale.length,
|
|
181
268
|
detail: stale.join(", ") || undefined,
|
|
269
|
+
why: "Workers read .claude/agents and .codex/agents, which are generated from .kanbot/agents.",
|
|
182
270
|
fix: "kanbot sync",
|
|
183
271
|
});
|
|
184
272
|
add({
|
|
185
273
|
name: "config schema up to date",
|
|
186
274
|
ok: !(await writeConfigSchema(root, { check: true })),
|
|
275
|
+
why: "Editors use it for autocomplete and errors in config.yml.",
|
|
187
276
|
fix: "kanbot sync",
|
|
277
|
+
docs: "configuration",
|
|
188
278
|
});
|
|
189
279
|
} catch (e) {
|
|
190
280
|
add({ name: "agents", ok: false, ...explain(e) });
|
|
@@ -208,6 +298,7 @@ export default defineCommand({
|
|
|
208
298
|
.join("; "),
|
|
209
299
|
});
|
|
210
300
|
|
|
301
|
+
section("Templates");
|
|
211
302
|
const plan = await planUpgrade(root);
|
|
212
303
|
const behind = plan.changes.filter((c) =>
|
|
213
304
|
["add", "update", "conflict"].includes(c.action),
|
|
@@ -218,16 +309,21 @@ export default defineCommand({
|
|
|
218
309
|
detail: behind.length
|
|
219
310
|
? `${behind.length} file(s) differ from this kanbot version`
|
|
220
311
|
: undefined,
|
|
312
|
+
why: "You get fixes to agents, skills and workflows by upgrading.",
|
|
221
313
|
fix: "kanbot upgrade",
|
|
314
|
+
docs: "upgrading",
|
|
222
315
|
});
|
|
223
316
|
const merges = await pendingMerges(root);
|
|
224
317
|
add({
|
|
225
318
|
name: `no unmerged ${NEW_SUFFIX} files`,
|
|
226
319
|
ok: !merges.length,
|
|
227
320
|
detail: merges.join(", ") || undefined,
|
|
321
|
+
why: "upgrade put the new version next to files you changed.",
|
|
322
|
+
docs: "upgrading",
|
|
228
323
|
fix: `merge each into the file next to it and delete it (kanbot-admin skill can help)`,
|
|
229
324
|
});
|
|
230
325
|
|
|
326
|
+
section(`Pushed to origin/${config.baseBranch}`);
|
|
231
327
|
await git(root, "fetch", "origin", config.baseBranch).catch(() => {});
|
|
232
328
|
const tree = await git(
|
|
233
329
|
root,
|
|
@@ -245,15 +341,18 @@ export default defineCommand({
|
|
|
245
341
|
add({
|
|
246
342
|
name: `${path} committed on origin/${config.baseBranch}`,
|
|
247
343
|
ok: tree.split("\n").some((f) => f.startsWith(path)),
|
|
248
|
-
|
|
344
|
+
why: "Workers run in worktrees made from the base branch, so they only see what is pushed.",
|
|
249
345
|
fix: "commit and push the kanbot files",
|
|
250
346
|
});
|
|
251
347
|
}
|
|
252
348
|
add({
|
|
253
349
|
name: ".github/workflows/kanbot-merged.yml",
|
|
254
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",
|
|
255
353
|
});
|
|
256
354
|
|
|
355
|
+
section("Project board");
|
|
257
356
|
const gh = new Gh(config.repo);
|
|
258
357
|
try {
|
|
259
358
|
const board = await loadBoard(gh, config);
|
|
@@ -264,7 +363,11 @@ export default defineCommand({
|
|
|
264
363
|
detail: missing.length
|
|
265
364
|
? `missing columns: ${missing.join(", ")}`
|
|
266
365
|
: board.url,
|
|
366
|
+
why: missing.length
|
|
367
|
+
? "kanbot moves cards by the column names in config.yml."
|
|
368
|
+
: undefined,
|
|
267
369
|
fix: "kanbot board create",
|
|
370
|
+
docs: "getting-started#create-the-board",
|
|
268
371
|
});
|
|
269
372
|
// Moving cards to Done on close/merge and adding new issues are left to GitHub.
|
|
270
373
|
const workflows = await boardWorkflows(gh, board);
|
|
@@ -276,6 +379,7 @@ export default defineCommand({
|
|
|
276
379
|
add({
|
|
277
380
|
name: `board workflow "${name}" enabled`,
|
|
278
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.",
|
|
279
383
|
fix: `enable it in ${board.url} → ⋯ → Workflows`,
|
|
280
384
|
});
|
|
281
385
|
}
|
|
@@ -307,16 +411,21 @@ export default defineCommand({
|
|
|
307
411
|
add({
|
|
308
412
|
name: "labels",
|
|
309
413
|
ok: !missingLabels.length,
|
|
310
|
-
detail: missingLabels.
|
|
414
|
+
detail: missingLabels.length
|
|
415
|
+
? `missing: ${missingLabels.join(", ")}`
|
|
416
|
+
: undefined,
|
|
417
|
+
why: "kanbot tags issues and PRs with these labels.",
|
|
311
418
|
fix: "kanbot board create",
|
|
312
419
|
});
|
|
313
420
|
|
|
314
421
|
const { mode, app } = config.identity;
|
|
315
422
|
if (mode === "app") {
|
|
423
|
+
section("GitHub App");
|
|
316
424
|
add({
|
|
317
425
|
name: "kanbot app set",
|
|
318
426
|
ok: app !== null,
|
|
319
427
|
detail: app ? `${app.slug} (id ${app.id})` : undefined,
|
|
428
|
+
why: "identity.mode is app, so kanbot acts as a GitHub App.",
|
|
320
429
|
fix: "kanbot app create",
|
|
321
430
|
});
|
|
322
431
|
if (app) {
|
|
@@ -325,6 +434,7 @@ export default defineCommand({
|
|
|
325
434
|
name: "kanbot app private key",
|
|
326
435
|
ok: existsSync(key),
|
|
327
436
|
detail: key,
|
|
437
|
+
why: "kanbot signs in as the app with this key.",
|
|
328
438
|
fix: "kanbot app create, or copy the key from the machine that created the app",
|
|
329
439
|
});
|
|
330
440
|
if (existsSync(key)) {
|
|
@@ -346,6 +456,7 @@ export default defineCommand({
|
|
|
346
456
|
}
|
|
347
457
|
|
|
348
458
|
if (config.review.copilot) {
|
|
459
|
+
section("Copilot review");
|
|
349
460
|
const rule = await copilotReviewRule(gh, config.baseBranch).catch(
|
|
350
461
|
() => null,
|
|
351
462
|
);
|
|
@@ -356,14 +467,16 @@ export default defineCommand({
|
|
|
356
467
|
? `re-review on push: ${rule.parameters?.review_on_push ? "on" : "off"}`
|
|
357
468
|
: "review.copilot is on, but no ruleset requests Copilot review",
|
|
358
469
|
fix: "kanbot board create",
|
|
470
|
+
docs: "copilot-review",
|
|
359
471
|
});
|
|
360
472
|
add({
|
|
361
473
|
name: ".github/copilot-instructions.md committed",
|
|
474
|
+
why: "Copilot reads it to review the way your team wants.",
|
|
362
475
|
ok: tree.split("\n").includes(".github/copilot-instructions.md"),
|
|
363
476
|
fix: "kanbot sync, then commit and push",
|
|
364
477
|
});
|
|
365
478
|
}
|
|
366
479
|
|
|
367
|
-
|
|
480
|
+
summary(checks);
|
|
368
481
|
},
|
|
369
482
|
});
|
package/src/commands/status.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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:
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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();
|
package/src/daemon/derive.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/daemon/loop.ts
CHANGED
|
@@ -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":
|
package/src/daemon/snapshot.ts
CHANGED
|
@@ -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
|
};
|
package/src/daemon/types.ts
CHANGED
|
@@ -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}`);
|
package/src/known-hashes.json
CHANGED
|
@@ -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",
|
|
@@ -19,7 +20,10 @@
|
|
|
19
20
|
"1251194ba34a8b6eae8508037b0a76c60d7a26b8",
|
|
20
21
|
"2c6c77a225c5b880eff023d4b3c1305bfc91bfb3",
|
|
21
22
|
"2ccb86b12e5af121b79ef9f53c2a35dbbdb2ddeb",
|
|
23
|
+
"53c73b1be4a1e8d863a52043eae4ca911c8be71b",
|
|
22
24
|
"a24c0a655d6485e65bd5dea2a71f3afe67f04404",
|
|
25
|
+
"b5a2d074ea61f2552890ca1d78bf8a043d7b73b3",
|
|
26
|
+
"bde0364d60525598bbb28f1648d06d1407c62a73",
|
|
23
27
|
"cf3e95d137a5a58a273262bca6ba25fbc95fa037"
|
|
24
28
|
],
|
|
25
29
|
".agents/skills/kanbot-idea/SKILL.md": [
|
|
@@ -104,6 +108,7 @@
|
|
|
104
108
|
".kanbot/agents/planner.md": [
|
|
105
109
|
"2283cf8bf6d27b8bbf721f97bb45cd40eaa6d39d",
|
|
106
110
|
"6500bea6691685d2b74ea73095f11643e0fcc937",
|
|
111
|
+
"6def83eade7a3e8ca3bde6a3458c3b4921b3b3ac",
|
|
107
112
|
"a4a65d240ab4b23737f4d68d8765cca89cf08864",
|
|
108
113
|
"bdb111213cced70583157edad658247972dcbe45",
|
|
109
114
|
"dc50d048164ade2926b58dee48c3919c6b7d9558"
|
package/src/schemas/results.ts
CHANGED
|
@@ -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."),
|
|
@@ -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
|
|
@@ -19,7 +19,8 @@ Explain in plain words. Ask before choosing for them; give a recommendation with
|
|
|
19
19
|
| How do I file ideas by chatting? | `capturing-ideas` |
|
|
20
20
|
| My own PRs, local sessions, direct commits? | `outside-the-board` |
|
|
21
21
|
| Which agents are there, how do I change them? | `agents` |
|
|
22
|
-
| Skills
|
|
22
|
+
| Skills? | `skills` |
|
|
23
|
+
| Project memory, facts from corrections | `project-memory` |
|
|
23
24
|
| Claude or Codex? | `providers` |
|
|
24
25
|
| Every config field, labels, notifications | `configuration` |
|
|
25
26
|
| Using our own skills and agents for the work | `capabilities` |
|
|
@@ -32,7 +33,7 @@ Explain in plain words. Ask before choosing for them; give a recommendation with
|
|
|
32
33
|
|
|
33
34
|
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
35
|
|
|
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.
|
|
36
|
+
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
37
|
|
|
37
38
|
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
39
|
|
|
@@ -102,9 +103,9 @@ Warn: never enable the branch rule "Require approval of the most recent reviewab
|
|
|
102
103
|
## Troubleshoot
|
|
103
104
|
|
|
104
105
|
1. `kanbot doctor`: prints a fix for each failed check.
|
|
105
|
-
2. `kanbot status`: board by column, daemon, failures, rate-limit pause.
|
|
106
|
+
2. `kanbot status`: board by column, what waits on the user (oldest first), daemon, failures, rate-limit pause.
|
|
106
107
|
3. `.kanbot/logs/*.jsonl`: full agent transcripts.
|
|
107
108
|
4. `needs-human` label: the issue has a comment saying why. Fix the cause, then remove the label to retry.
|
|
108
109
|
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
|
|
110
|
+
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
111
|
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.
|