kanbot 0.0.0-stage → 0.1.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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +102 -2
  3. package/package.json +56 -5
  4. package/schema/config.schema.json +206 -0
  5. package/src/agents/load.ts +83 -0
  6. package/src/agents/sync.ts +216 -0
  7. package/src/cli.ts +5 -0
  8. package/src/commands/board.ts +140 -0
  9. package/src/commands/completion.ts +24 -0
  10. package/src/commands/doctor.ts +250 -0
  11. package/src/commands/init.ts +102 -0
  12. package/src/commands/run.ts +32 -0
  13. package/src/commands/skills.ts +63 -0
  14. package/src/commands/status.ts +61 -0
  15. package/src/commands/sync.ts +31 -0
  16. package/src/commands/upgrade.ts +70 -0
  17. package/src/completion.ts +217 -0
  18. package/src/config.ts +213 -0
  19. package/src/daemon/derive.ts +413 -0
  20. package/src/daemon/git.ts +187 -0
  21. package/src/daemon/lock.ts +24 -0
  22. package/src/daemon/loop.ts +207 -0
  23. package/src/daemon/notify.ts +44 -0
  24. package/src/daemon/snapshot.ts +109 -0
  25. package/src/daemon/state.ts +60 -0
  26. package/src/daemon/types.ts +73 -0
  27. package/src/github/api.ts +332 -0
  28. package/src/github/gh.ts +110 -0
  29. package/src/github/markers.ts +82 -0
  30. package/src/github/project.ts +278 -0
  31. package/src/github/trust.ts +38 -0
  32. package/src/jobs/prompts.ts +205 -0
  33. package/src/jobs/run.ts +569 -0
  34. package/src/known-hashes.json +132 -0
  35. package/src/log.ts +11 -0
  36. package/src/main.ts +21 -0
  37. package/src/providers/claude.ts +122 -0
  38. package/src/providers/codex.ts +98 -0
  39. package/src/providers/types.ts +94 -0
  40. package/src/schemas/results.ts +116 -0
  41. package/src/templates.ts +192 -0
  42. package/templates/.agents/skills/accessibility/SKILL.md +76 -0
  43. package/templates/.agents/skills/approach-designer/SKILL.md +79 -0
  44. package/templates/.agents/skills/code-review/SKILL.md +83 -0
  45. package/templates/.agents/skills/frontend-design/LICENSE.txt +177 -0
  46. package/templates/.agents/skills/frontend-design/SKILL.md +76 -0
  47. package/templates/.agents/skills/issue-splitting/SKILL.md +78 -0
  48. package/templates/.agents/skills/kanbot-admin/SKILL.md +57 -0
  49. package/templates/.agents/skills/kanbot-idea/SKILL.md +56 -0
  50. package/templates/.agents/skills/plan-template/SKILL.md +85 -0
  51. package/templates/.agents/skills/pr-description/SKILL.md +44 -0
  52. package/templates/.agents/skills/project-conventions/SKILL.md +79 -0
  53. package/templates/.agents/skills/project-memory/SKILL.md +39 -0
  54. package/templates/.agents/skills/project-memory/facts/.gitkeep +0 -0
  55. package/templates/.agents/skills/spec-review/SKILL.md +73 -0
  56. package/templates/.agents/skills/tdd/SKILL.md +51 -0
  57. package/templates/.agents/skills/user-stories/SKILL.md +82 -0
  58. package/templates/.agents/skills/verify-ui/SKILL.md +104 -0
  59. package/templates/.github/ISSUE_TEMPLATE/config.yml +1 -0
  60. package/templates/.github/ISSUE_TEMPLATE/technical.yml +15 -0
  61. package/templates/.github/ISSUE_TEMPLATE/user-story.yml +15 -0
  62. package/templates/.github/pull_request_template.md +15 -0
  63. package/templates/.github/workflows/ci.yml +23 -0
  64. package/templates/.github/workflows/kanbot-merged.yml +30 -0
  65. package/templates/.kanbot/agents/approach-designer.md +31 -0
  66. package/templates/.kanbot/agents/explorer.md +35 -0
  67. package/templates/.kanbot/agents/fixer.md +54 -0
  68. package/templates/.kanbot/agents/implementer-backend.md +57 -0
  69. package/templates/.kanbot/agents/implementer-docs.md +55 -0
  70. package/templates/.kanbot/agents/implementer-frontend.md +54 -0
  71. package/templates/.kanbot/agents/implementer-technical.md +53 -0
  72. package/templates/.kanbot/agents/plan-critic.md +36 -0
  73. package/templates/.kanbot/agents/planner.md +52 -0
  74. package/templates/.kanbot/agents/reviewer-code.md +46 -0
  75. package/templates/.kanbot/agents/reviewer-spec.md +49 -0
  76. package/templates/.kanbot/agents/test-writer.md +35 -0
  77. package/templates/.kanbot/agents/ui-verifier.md +35 -0
  78. package/templates/AGENTS.md +39 -0
  79. package/templates/CLAUDE.md +1 -0
@@ -0,0 +1,207 @@
1
+ import { loadAgents } from "../agents/load.ts";
2
+ import { sync } from "../agents/sync.ts";
3
+ import { type Config, loadConfig } from "../config.ts";
4
+ import { Gh } from "../github/gh.ts";
5
+ import { loadBoard, missingColumns } from "../github/project.ts";
6
+ import { Trust } from "../github/trust.ts";
7
+ import {
8
+ applyAction,
9
+ type JobDeps,
10
+ NeedsHuman,
11
+ RateLimited,
12
+ runJob,
13
+ } from "../jobs/run.ts";
14
+ import { log } from "../log.ts";
15
+ import { claude } from "../providers/claude.ts";
16
+ import { codex } from "../providers/codex.ts";
17
+ import type { Provider } from "../providers/types.ts";
18
+ import { type Action, derive, type Job, schedule } from "./derive.ts";
19
+ import { Worktrees } from "./git.ts";
20
+ import { acquireLock } from "./lock.ts";
21
+ import { Notifier } from "./notify.ts";
22
+ import { takeSnapshot } from "./snapshot.ts";
23
+ import { State } from "./state.ts";
24
+
25
+ export type RunOptions = { once: boolean; dryRun: boolean };
26
+
27
+ const RATE_LIMIT_PAUSE_MS = 30 * 60_000;
28
+
29
+ export function providerFor(config: Config): Provider {
30
+ return config.provider === "codex"
31
+ ? codex({ networkAccess: config.codex.networkAccess })
32
+ : claude;
33
+ }
34
+
35
+ export async function runDaemon(root: string, opts: RunOptions) {
36
+ const config = await loadConfig(root);
37
+ const agents = await loadAgents(root);
38
+ const changed = await sync(root, agents, { check: true });
39
+ if (changed.length) {
40
+ log.warn(
41
+ `Generated agent files are stale (${changed.join(", ")}). Run \`kanbot sync\` and commit.`,
42
+ );
43
+ }
44
+ const gh = new Gh(config.repo, opts.dryRun);
45
+ const board = await loadBoard(gh, config);
46
+ const missing = missingColumns(board);
47
+ if (missing.length)
48
+ throw new Error(`Board is missing columns: ${missing.join(", ")}`);
49
+
50
+ const release = opts.dryRun ? () => {} : await acquireLock(root);
51
+ const state = await new State(root).load();
52
+ const deps: JobDeps = {
53
+ root,
54
+ config,
55
+ gh,
56
+ board,
57
+ agents,
58
+ provider: providerFor(config),
59
+ worktrees: new Worktrees(root, config.repo, config.baseBranch, opts.dryRun),
60
+ notify: new Notifier(config.notify),
61
+ state,
62
+ };
63
+ const trust = new Trust(gh);
64
+ const running = new Map<string, Promise<void>>();
65
+ let wake: () => void = () => {};
66
+ // kanbot-review PRs vanish from the snapshot when closed; clean up after them.
67
+ let prOnly = new Set<number>();
68
+ let stopping = false;
69
+
70
+ const stop = () => {
71
+ if (stopping) process.exit(1);
72
+ stopping = true;
73
+ log.info("Stopping after running jobs finish (Ctrl-C again to force)…");
74
+ wake();
75
+ };
76
+ process.on("SIGINT", stop);
77
+ process.on("SIGTERM", stop);
78
+
79
+ log.info(
80
+ `kanbot: ${config.repo} · ${board.url} · provider ${config.provider} · ${config.concurrency} workers${opts.dryRun ? " · DRY RUN" : ""}`,
81
+ );
82
+
83
+ try {
84
+ while (!stopping) {
85
+ if (Date.now() < state.pausedUntil) {
86
+ log.info(
87
+ `Paused for rate limits until ${new Date(state.pausedUntil).toLocaleTimeString()}`,
88
+ );
89
+ } else {
90
+ await tick().catch((e) =>
91
+ log.error(`tick failed: ${e instanceof Error ? e.message : e}`),
92
+ );
93
+ }
94
+ if (opts.once) break;
95
+ await new Promise<void>((resolve) => {
96
+ // Clear the timer on wake so Ctrl-C doesn't wait out the poll interval.
97
+ const timer = setTimeout(resolve, config.pollSeconds * 1000);
98
+ wake = () => {
99
+ clearTimeout(timer);
100
+ resolve();
101
+ };
102
+ });
103
+ }
104
+ await Promise.allSettled(running.values());
105
+ } finally {
106
+ release();
107
+ }
108
+
109
+ async function tick() {
110
+ if (!opts.dryRun) await deps.worktrees.fetch();
111
+ const snapshot = await takeSnapshot(gh, board, trust);
112
+ const { jobs, actions } = derive(snapshot, {
113
+ config,
114
+ running: new Set(running.keys()),
115
+ failures: state.failures,
116
+ now: Date.now(),
117
+ });
118
+ const nowPrOnly = new Set(
119
+ snapshot.items.filter((i) => i.issue.prOnly).map((i) => i.issue.number),
120
+ );
121
+ for (const n of prOnly) {
122
+ if (nowPrOnly.has(n)) continue;
123
+ // Retry next tick while a job still uses the worktree.
124
+ if ([...running.keys()].some((k) => k.includes(`#${n}:`)))
125
+ nowPrOnly.add(n);
126
+ else actions.push({ kind: "cleanup", number: n });
127
+ }
128
+ prOnly = nowPrOnly;
129
+ for (const a of actions) {
130
+ if (a.kind === "cleanup" && opts.dryRun) continue;
131
+ if (opts.dryRun) log.info(`[dry-run] action ${describeAction(a)}`);
132
+ await applyAction(a, deps).catch((e) =>
133
+ log.error(`action ${a.kind} failed: ${e}`),
134
+ );
135
+ }
136
+ const picked = schedule(
137
+ jobs,
138
+ config.concurrency - running.size,
139
+ config.priority,
140
+ );
141
+ log.debug(
142
+ `${jobs.length} job(s) waiting, ${running.size} running, starting ${picked.length}`,
143
+ );
144
+ for (const job of picked) {
145
+ if (opts.dryRun) {
146
+ log.info(`[dry-run] would start ${job.key}`);
147
+ continue;
148
+ }
149
+ running.set(job.key, start(job));
150
+ }
151
+ }
152
+
153
+ async function start(job: Job) {
154
+ try {
155
+ await runJob(job, deps);
156
+ await state.succeed(job.key);
157
+ log.info(`✔ ${job.key}`);
158
+ } catch (e) {
159
+ const msg = e instanceof Error ? e.message : String(e);
160
+ if (e instanceof RateLimited) {
161
+ log.warn(
162
+ `${job.key}: rate limited; pausing all work for ${RATE_LIMIT_PAUSE_MS / 60_000} min`,
163
+ );
164
+ await state.pause(RATE_LIMIT_PAUSE_MS);
165
+ } else if (e instanceof NeedsHuman) {
166
+ log.warn(`${job.key}: needs human (${msg})`);
167
+ } else {
168
+ const n = await state.fail(job.key);
169
+ log.error(
170
+ `${job.key} failed (attempt ${n}/${config.maxAttempts}): ${msg}`,
171
+ );
172
+ if (n >= config.maxAttempts) {
173
+ await applyAction(
174
+ {
175
+ kind: "needsHuman",
176
+ number: job.item.issue.number,
177
+ why: `\`${job.key}\` failed ${n} times. Last error:\n\n\`\`\`\n${msg.slice(0, 1500)}\n\`\`\``,
178
+ },
179
+ deps,
180
+ ).catch(() => {});
181
+ }
182
+ }
183
+ } finally {
184
+ running.delete(job.key);
185
+ wake();
186
+ }
187
+ }
188
+ }
189
+
190
+ function describeAction(a: Action) {
191
+ switch (a.kind) {
192
+ case "move":
193
+ return `move #${a.item.issue.number} → ${a.to}`;
194
+ case "label":
195
+ return `label #${a.number} +[${a.add}] -[${a.remove}]`;
196
+ case "agentReviewed":
197
+ return `mark PR #${a.pr.number} agent-reviewed`;
198
+ case "compare":
199
+ return `post comparison on #${a.item.issue.number}`;
200
+ case "needsHuman":
201
+ return `needs-human #${a.number}`;
202
+ case "resetFailures":
203
+ return `reset failures ${a.prefix}`;
204
+ case "cleanup":
205
+ return `cleanup worktrees of #${a.number}`;
206
+ }
207
+ }
@@ -0,0 +1,44 @@
1
+ import notifier from "node-notifier";
2
+ import type { Config } from "../config.ts";
3
+ import { log } from "../log.ts";
4
+
5
+ /**
6
+ * Agents act as your own account, so GitHub never notifies you about their
7
+ * work. This is the replacement: desktop notifications and/or a webhook.
8
+ */
9
+ export class Notifier {
10
+ constructor(private config: Config["notify"]) {}
11
+
12
+ send(title: string, message: string, url?: string) {
13
+ log.info(`🔔 ${title} — ${message}${url ? ` ${url}` : ""}`);
14
+ if (this.config.desktop)
15
+ desktop(title, message, url).catch((e) =>
16
+ log.warn(`desktop notification failed: ${e}`),
17
+ );
18
+ if (this.config.webhook) {
19
+ fetch(this.config.webhook, {
20
+ method: "POST",
21
+ headers: {
22
+ "content-type": "application/json",
23
+ Title: title,
24
+ ...(url ? { Click: url } : {}),
25
+ },
26
+ body: JSON.stringify({ title, message, url }),
27
+ }).catch((e) => log.warn(`webhook failed: ${e}`));
28
+ }
29
+ }
30
+ }
31
+
32
+ function desktop(title: string, message: string, url?: string) {
33
+ return new Promise<void>((resolve, reject) =>
34
+ notifier.notify(
35
+ {
36
+ title: "kanbot",
37
+ subtitle: title,
38
+ message,
39
+ ...(url ? { open: url } : {}),
40
+ },
41
+ (err) => (err ? reject(err) : resolve()),
42
+ ),
43
+ );
44
+ }
@@ -0,0 +1,109 @@
1
+ import type { ColumnKey } from "../config.ts";
2
+ import { LABELS } from "../config.ts";
3
+ import { fetchIssue, fetchLabeledPRs } from "../github/api.ts";
4
+ import type { Gh } from "../github/gh.ts";
5
+ import { type Board, listItems } from "../github/project.ts";
6
+ import type { Trust } from "../github/trust.ts";
7
+ import type { Item, PR, Snapshot } from "./types.ts";
8
+
9
+ const ACTIVE: ReadonlySet<ColumnKey> = new Set([
10
+ "planning",
11
+ "ready",
12
+ "inProgress",
13
+ "review",
14
+ ]);
15
+
16
+ /** Board items plus full detail for issues in columns agents work on. */
17
+ export async function takeSnapshot(
18
+ gh: Gh,
19
+ board: Board,
20
+ trust: Trust,
21
+ ): Promise<Snapshot> {
22
+ const raw = await listItems(gh, board);
23
+ const items = await mapLimit(
24
+ raw,
25
+ 4,
26
+ async (r, position): Promise<Item | null> => {
27
+ const active =
28
+ r.state === "OPEN" && r.column !== null && ACTIVE.has(r.column);
29
+ if (active) {
30
+ return {
31
+ itemId: r.itemId,
32
+ column: r.column,
33
+ position,
34
+ issue: await fetchIssue(gh, r.number),
35
+ };
36
+ }
37
+ if (r.state !== "CLOSED") return null;
38
+ // Closed issues only need enough shape for cleanup.
39
+ return {
40
+ itemId: r.itemId,
41
+ column: r.column,
42
+ position,
43
+ issue: {
44
+ id: "",
45
+ number: r.number,
46
+ url: "",
47
+ title: "",
48
+ body: "",
49
+ state: "CLOSED",
50
+ labels: [],
51
+ comments: [],
52
+ prs: [],
53
+ },
54
+ };
55
+ },
56
+ );
57
+ const labeled = (await fetchLabeledPRs(gh, LABELS.review))
58
+ .filter((p) => !p.headRefName.startsWith("kanbot/"))
59
+ .map((p, i) => prItem(p, raw.length + i));
60
+ const present = [...items.filter((i): i is Item => i !== null), ...labeled];
61
+ const authors = present.flatMap(({ issue }) => [
62
+ ...issue.comments.map((c) => c.author),
63
+ ...issue.prs.flatMap((p) => [
64
+ ...p.comments.map((c) => c.author),
65
+ ...p.reviews.map((c) => c.author),
66
+ ...p.threads.flatMap((t) => t.comments.map((c) => c.author)),
67
+ ]),
68
+ ]);
69
+ return { items: present, trusted: await trust.resolve(authors) };
70
+ }
71
+
72
+ /** A `kanbot-review` PR as a Review item, so it gets the same review jobs. */
73
+ export function prItem(pr: PR, position: number): Item {
74
+ return {
75
+ itemId: "",
76
+ column: "review",
77
+ position,
78
+ issue: {
79
+ id: pr.id,
80
+ number: pr.number,
81
+ url: pr.url,
82
+ title: pr.title,
83
+ body: pr.body,
84
+ state: "OPEN",
85
+ labels: pr.labels,
86
+ comments: [],
87
+ prs: [pr],
88
+ prOnly: true,
89
+ },
90
+ };
91
+ }
92
+
93
+ async function mapLimit<T, R>(
94
+ xs: T[],
95
+ limit: number,
96
+ fn: (x: T, i: number) => Promise<R>,
97
+ ): Promise<R[]> {
98
+ const out: R[] = new Array(xs.length);
99
+ let next = 0;
100
+ await Promise.all(
101
+ Array.from({ length: Math.min(limit, xs.length) }, async () => {
102
+ while (next < xs.length) {
103
+ const i = next++;
104
+ out[i] = await fn(xs[i] as T, i);
105
+ }
106
+ }),
107
+ );
108
+ return out;
109
+ }
@@ -0,0 +1,60 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ type Data = { failures: Record<string, number>; pausedUntil: number };
5
+
6
+ /**
7
+ * The only local state: failure counts and a rate-limit pause. Everything else
8
+ * is derived from GitHub on each tick, so deleting this file is always safe.
9
+ */
10
+ export class State {
11
+ private data: Data = { failures: {}, pausedUntil: 0 };
12
+ private readonly file: string;
13
+
14
+ constructor(root: string) {
15
+ this.file = join(root, ".kanbot", "state.json");
16
+ }
17
+
18
+ async load() {
19
+ if (existsSync(this.file)) {
20
+ this.data = { ...this.data, ...(await Bun.file(this.file).json()) };
21
+ }
22
+ return this;
23
+ }
24
+
25
+ private save() {
26
+ return Bun.write(this.file, JSON.stringify(this.data, null, 2));
27
+ }
28
+
29
+ get failures(): ReadonlyMap<string, number> {
30
+ return new Map(Object.entries(this.data.failures));
31
+ }
32
+
33
+ async fail(key: string) {
34
+ this.data.failures[key] = (this.data.failures[key] ?? 0) + 1;
35
+ await this.save();
36
+ return this.data.failures[key];
37
+ }
38
+
39
+ async succeed(key: string) {
40
+ if (!(key in this.data.failures)) return;
41
+ delete this.data.failures[key];
42
+ await this.save();
43
+ }
44
+
45
+ async resetFailures(prefix: string) {
46
+ for (const k of Object.keys(this.data.failures)) {
47
+ if (k.includes(prefix)) delete this.data.failures[k];
48
+ }
49
+ await this.save();
50
+ }
51
+
52
+ get pausedUntil() {
53
+ return this.data.pausedUntil;
54
+ }
55
+
56
+ async pause(ms: number) {
57
+ this.data.pausedUntil = Date.now() + ms;
58
+ await this.save();
59
+ }
60
+ }
@@ -0,0 +1,73 @@
1
+ import type { ColumnKey } from "../config.ts";
2
+
3
+ export type Comment = {
4
+ id: string;
5
+ databaseId: number;
6
+ author: string;
7
+ body: string;
8
+ createdAt: string;
9
+ };
10
+
11
+ export type ReviewThread = {
12
+ id: string;
13
+ isResolved: boolean;
14
+ path: string | null;
15
+ line: number | null;
16
+ comments: Comment[];
17
+ };
18
+
19
+ export type Checks =
20
+ | "SUCCESS"
21
+ | "FAILURE"
22
+ | "ERROR"
23
+ | "PENDING"
24
+ | "EXPECTED"
25
+ | null;
26
+
27
+ export type PR = {
28
+ id: string;
29
+ number: number;
30
+ url: string;
31
+ title: string;
32
+ body: string;
33
+ state: "OPEN" | "CLOSED" | "MERGED";
34
+ headRefName: string;
35
+ headSha: string;
36
+ createdAt: string;
37
+ labels: string[];
38
+ comments: Comment[];
39
+ /** Submitted reviews that have a body. */
40
+ reviews: Comment[];
41
+ threads: ReviewThread[];
42
+ checks: Checks;
43
+ /** UNKNOWN while GitHub is still computing it. */
44
+ mergeable: "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
45
+ };
46
+
47
+ export type Issue = {
48
+ id: string;
49
+ number: number;
50
+ url: string;
51
+ title: string;
52
+ body: string;
53
+ state: "OPEN" | "CLOSED";
54
+ labels: string[];
55
+ comments: Comment[];
56
+ prs: PR[];
57
+ /** A PR labeled `kanbot-review`, reviewed without a card. Fields mirror the PR. */
58
+ prOnly?: boolean;
59
+ };
60
+
61
+ export type Item = {
62
+ itemId: string;
63
+ column: ColumnKey | null;
64
+ /** Index in board order; lower is higher priority. */
65
+ position: number;
66
+ issue: Issue;
67
+ };
68
+
69
+ export type Snapshot = {
70
+ items: Item[];
71
+ /** Logins whose comments agents act on (collaborators with write access). */
72
+ trusted: ReadonlySet<string>;
73
+ };