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,413 @@
1
+ import {
2
+ type ColumnKey,
3
+ type Config,
4
+ type JobKind,
5
+ LABELS,
6
+ } from "../config.ts";
7
+ import {
8
+ extractPlan,
9
+ hasMarker,
10
+ isKanbot,
11
+ markerValues,
12
+ readJson,
13
+ } from "../github/markers.ts";
14
+ import type { Finding } from "../schemas/results.ts";
15
+ import type {
16
+ Comment,
17
+ Issue,
18
+ Item,
19
+ PR,
20
+ ReviewThread,
21
+ Snapshot,
22
+ } from "./types.ts";
23
+
24
+ /*
25
+ * Pure decision core: board snapshot in, work out. Everything the daemon does
26
+ * is decided here, so it can be tested exhaustively without GitHub or LLMs.
27
+ * State lives on GitHub (columns, labels, markers); `ctx` only carries what is
28
+ * running right now and local failure counts.
29
+ */
30
+
31
+ export type PlanJob = {
32
+ kind: "plan";
33
+ key: string;
34
+ item: Item;
35
+ /** Human comments not yet answered. */
36
+ comments: Comment[];
37
+ initial: boolean;
38
+ };
39
+
40
+ export type ImplementJob = {
41
+ kind: "implement";
42
+ key: string;
43
+ item: Item;
44
+ variant: number | null;
45
+ variants: number;
46
+ branch: string;
47
+ };
48
+
49
+ export type ReviewJob = {
50
+ kind: "review";
51
+ key: string;
52
+ item: Item;
53
+ pr: PR;
54
+ pass: 1 | 2;
55
+ };
56
+
57
+ export type FixJob = {
58
+ kind: "fix";
59
+ key: string;
60
+ item: Item;
61
+ pr: PR;
62
+ reason:
63
+ | { type: "review"; pass: 1 | 2; findings: Finding[] }
64
+ | { type: "feedback"; threads: ReviewThread[]; comments: Comment[] }
65
+ | { type: "ci" }
66
+ | { type: "conflict" }
67
+ | { type: "copilot"; threads: ReviewThread[] };
68
+ };
69
+
70
+ export type Job = PlanJob | ImplementJob | ReviewJob | FixJob;
71
+
72
+ export type Action =
73
+ | { kind: "move"; item: Item; to: ColumnKey; why: string }
74
+ | { kind: "label"; number: number; add: string[]; remove: string[] }
75
+ | { kind: "agentReviewed"; item: Item; pr: PR }
76
+ | { kind: "compare"; item: Item; prs: PR[] }
77
+ | { kind: "needsHuman"; number: number; why: string }
78
+ | { kind: "resetFailures"; prefix: string }
79
+ | { kind: "cleanup"; number: number };
80
+
81
+ export type DeriveContext = {
82
+ config: Config;
83
+ /** Keys of jobs currently running. */
84
+ running: ReadonlySet<string>;
85
+ /** Failure count per job key. */
86
+ failures: ReadonlyMap<string, number>;
87
+ /** Current time in ms, for waiting on Copilot. */
88
+ now: number;
89
+ };
90
+
91
+ export const REVIEW_PASSES = [1, 2] as const;
92
+
93
+ /** PRs without a card have no plan to check, so they skip the spec pass. */
94
+ export const reviewPasses = (issue: Issue) =>
95
+ issue.prOnly ? REVIEW_PASSES.slice(0, 1) : REVIEW_PASSES;
96
+ const MAX_CI_FIXES = 3;
97
+ const MAX_CONFLICT_FIXES = 3;
98
+
99
+ export function exploreCount(labels: string[]): number | null {
100
+ for (const l of labels) {
101
+ if (!l.startsWith(LABELS.explorePrefix)) continue;
102
+ const n = Number(l.slice(LABELS.explorePrefix.length));
103
+ if (Number.isInteger(n) && n >= 2 && n <= 4) return n;
104
+ }
105
+ return null;
106
+ }
107
+
108
+ export function branchFor(issue: number, variant: number | null) {
109
+ return variant === null ? `kanbot/${issue}` : `kanbot/${issue}-v${variant}`;
110
+ }
111
+
112
+ export function variantOf(branch: string): number | null {
113
+ const m = branch.match(/^kanbot\/\d+-v(\d)$/);
114
+ return m ? Number(m[1]) : null;
115
+ }
116
+
117
+ const isHuman = (c: Comment, trusted: ReadonlySet<string>) =>
118
+ !isKanbot(c.body) && trusted.has(c.author);
119
+
120
+ /** Comment ids already answered, via `<!-- kanbot:re:<id> -->` markers. */
121
+ function answered(comments: Comment[]): Set<string> {
122
+ return new Set(comments.flatMap((c) => markerValues(c.body, "re")));
123
+ }
124
+
125
+ export function pendingComments(
126
+ comments: Comment[],
127
+ trusted: ReadonlySet<string>,
128
+ ) {
129
+ const done = answered(comments);
130
+ return comments.filter((c) => isHuman(c, trusted) && !done.has(c.id));
131
+ }
132
+
133
+ export function reviewComment(pr: PR, pass: number) {
134
+ return pr.comments.find((c) => hasMarker(c.body, "review", pass));
135
+ }
136
+
137
+ export function reviewFindings(pr: PR, pass: number): Finding[] {
138
+ const c = reviewComment(pr, pass);
139
+ return c ? (readJson<Finding[]>(c.body, "findings") ?? []) : [];
140
+ }
141
+
142
+ export function openFeedback(pr: PR, trusted: ReadonlySet<string>) {
143
+ const threads = pr.threads.filter((t) => {
144
+ const last = t.comments.at(-1);
145
+ return !t.isResolved && last !== undefined && isHuman(last, trusted);
146
+ });
147
+ // Top-level comments and review bodies are answered by PR comments with re-markers.
148
+ const comments = pendingComments([...pr.comments, ...pr.reviews], trusted);
149
+ return { threads, comments };
150
+ }
151
+
152
+ /** GitHub Copilot code review's login, with or without the `[bot]` suffix. */
153
+ export const isCopilot = (login: string) =>
154
+ login.startsWith("copilot-pull-request-reviewer");
155
+
156
+ /** Unresolved Copilot threads nobody has answered yet. */
157
+ export function copilotThreads(pr: PR) {
158
+ return pr.threads.filter((t) => {
159
+ const last = t.comments.at(-1);
160
+ return !t.isResolved && last !== undefined && isCopilot(last.author);
161
+ });
162
+ }
163
+
164
+ const fixCount = (pr: PR, kind: "ci" | "conflict") =>
165
+ pr.comments.reduce((n, c) => n + markerValues(c.body, kind).length, 0);
166
+
167
+ export function derive(snapshot: Snapshot, ctx: DeriveContext) {
168
+ const jobs: Job[] = [];
169
+ const actions: Action[] = [];
170
+ const { trusted } = snapshot;
171
+ const { config } = ctx;
172
+
173
+ const blocked = (issue: Issue, key: string) => {
174
+ if (ctx.running.has(key)) return true;
175
+ if (issue.labels.includes(LABELS.needsHuman)) return true;
176
+ return (ctx.failures.get(key) ?? 0) >= config.maxAttempts;
177
+ };
178
+
179
+ const items = [...snapshot.items].sort((a, b) => a.position - b.position);
180
+
181
+ for (const item of items) {
182
+ const { issue } = item;
183
+
184
+ if (issue.state === "CLOSED" || item.column === "done") {
185
+ actions.push({ kind: "cleanup", number: issue.number });
186
+ continue;
187
+ }
188
+
189
+ // Human removed needs-human: give the agents fresh attempts.
190
+ if (!issue.labels.includes(LABELS.needsHuman)) {
191
+ const prefix = `#${issue.number}:`;
192
+ for (const [key, n] of ctx.failures) {
193
+ if (key.includes(prefix) && n >= config.maxAttempts) {
194
+ actions.push({ kind: "resetFailures", prefix });
195
+ break;
196
+ }
197
+ }
198
+ }
199
+
200
+ switch (item.column) {
201
+ case "planning": {
202
+ const key = `plan:#${issue.number}:`;
203
+ if (blocked(issue, key)) break;
204
+ const comments = pendingComments(issue.comments, trusted);
205
+ const initial = extractPlan(issue.body) === null;
206
+ if (initial || comments.length) {
207
+ jobs.push({ kind: "plan", key, item, comments, initial });
208
+ }
209
+ break;
210
+ }
211
+
212
+ case "ready":
213
+ case "inProgress": {
214
+ const n = exploreCount(issue.labels);
215
+ const variants =
216
+ n === null ? [null] : Array.from({ length: n }, (_, i) => i + 1);
217
+ let missing = 0;
218
+ for (const variant of variants) {
219
+ const branch = branchFor(issue.number, variant);
220
+ if (
221
+ issue.prs.some(
222
+ (p) => p.headRefName === branch && p.state !== "CLOSED",
223
+ )
224
+ ) {
225
+ continue;
226
+ }
227
+ missing++;
228
+ const key = `implement:#${issue.number}:${variant ?? 1}`;
229
+ if (blocked(issue, key)) continue;
230
+ jobs.push({
231
+ kind: "implement",
232
+ key,
233
+ item,
234
+ variant,
235
+ variants: variants.length,
236
+ branch,
237
+ });
238
+ }
239
+ if (missing === 0 && item.column === "inProgress") {
240
+ actions.push({
241
+ kind: "move",
242
+ item,
243
+ to: "review",
244
+ why: "all PRs opened",
245
+ });
246
+ actions.push({
247
+ kind: "label",
248
+ number: issue.number,
249
+ add: [],
250
+ remove: [LABELS.working],
251
+ });
252
+ }
253
+ break;
254
+ }
255
+
256
+ case "review": {
257
+ const open = issue.prs.filter(
258
+ (p) =>
259
+ p.state === "OPEN" &&
260
+ (issue.prOnly || p.headRefName.startsWith("kanbot/")),
261
+ );
262
+ for (const pr of open) {
263
+ const job = prJob(item, pr, trusted, ctx, blocked);
264
+ if (job === "agentReviewed")
265
+ actions.push({ kind: "agentReviewed", item, pr });
266
+ else if (job === "ciExhausted") {
267
+ actions.push({
268
+ kind: "needsHuman",
269
+ number: issue.number,
270
+ why: `CI still failing on #${pr.number} after ${MAX_CI_FIXES} fix attempts`,
271
+ });
272
+ } else if (job === "conflictExhausted") {
273
+ actions.push({
274
+ kind: "needsHuman",
275
+ number: issue.number,
276
+ why: `#${pr.number} still conflicts with the base branch after ${MAX_CONFLICT_FIXES} merge attempts`,
277
+ });
278
+ } else if (job) jobs.push(job);
279
+ }
280
+ if (
281
+ exploreCount(issue.labels) !== null &&
282
+ open.length > 1 &&
283
+ open.every((p) => p.labels.includes(LABELS.agentReviewed)) &&
284
+ !issue.comments.some((c) => hasMarker(c.body, "compare"))
285
+ ) {
286
+ actions.push({ kind: "compare", item, prs: open });
287
+ }
288
+ break;
289
+ }
290
+ }
291
+ }
292
+
293
+ return { jobs, actions };
294
+ }
295
+
296
+ function prJob(
297
+ item: Item,
298
+ pr: PR,
299
+ trusted: ReadonlySet<string>,
300
+ ctx: DeriveContext,
301
+ blocked: (issue: Issue, key: string) => boolean,
302
+ ): Job | "agentReviewed" | "ciExhausted" | "conflictExhausted" | null {
303
+ const { issue } = item;
304
+ const prKey = `#${issue.number}:pr${pr.number}`;
305
+ // One job per PR at a time, whatever its kind.
306
+ if ([...ctx.running].some((k) => k.endsWith(prKey))) return null;
307
+
308
+ // 1. Human feedback always comes first.
309
+ const feedback = openFeedback(pr, trusted);
310
+ if (feedback.threads.length || feedback.comments.length) {
311
+ const key = `fix:${prKey}`;
312
+ if (blocked(issue, key)) return null;
313
+ return {
314
+ kind: "fix",
315
+ key,
316
+ item,
317
+ pr,
318
+ reason: { type: "feedback", ...feedback },
319
+ };
320
+ }
321
+
322
+ // 2. Conflicts with the base branch, e.g. after commits made outside kanbot.
323
+ if (pr.mergeable === "CONFLICTING") {
324
+ if (!pr.comments.some((c) => hasMarker(c.body, "conflict", pr.headSha))) {
325
+ if (fixCount(pr, "conflict") >= MAX_CONFLICT_FIXES) {
326
+ return issue.labels.includes(LABELS.needsHuman)
327
+ ? null
328
+ : "conflictExhausted";
329
+ }
330
+ const key = `fix:${prKey}`;
331
+ if (blocked(issue, key)) return null;
332
+ return { kind: "fix", key, item, pr, reason: { type: "conflict" } };
333
+ }
334
+ }
335
+
336
+ // 3. Failing CI.
337
+ if (pr.checks === "FAILURE" || pr.checks === "ERROR") {
338
+ if (!pr.comments.some((c) => hasMarker(c.body, "ci", pr.headSha))) {
339
+ if (fixCount(pr, "ci") >= MAX_CI_FIXES) {
340
+ return issue.labels.includes(LABELS.needsHuman) ? null : "ciExhausted";
341
+ }
342
+ const key = `fix:${prKey}`;
343
+ if (blocked(issue, key)) return null;
344
+ return { kind: "fix", key, item, pr, reason: { type: "ci" } };
345
+ }
346
+ }
347
+
348
+ // 4. Agent review passes, each followed by a fix when it found problems.
349
+ for (const pass of reviewPasses(issue)) {
350
+ if (!reviewComment(pr, pass)) {
351
+ const key = `review${pass}:${prKey}`;
352
+ if (blocked(issue, key)) return null;
353
+ return { kind: "review", key, item, pr, pass };
354
+ }
355
+ const findings = reviewFindings(pr, pass).filter(
356
+ (f) => f.severity !== "nit",
357
+ );
358
+ const fixed = pr.comments.some((c) => hasMarker(c.body, "fixed", pass));
359
+ if (findings.length && !fixed) {
360
+ const key = `fix:${prKey}`;
361
+ if (blocked(issue, key)) return null;
362
+ return {
363
+ kind: "fix",
364
+ key,
365
+ item,
366
+ pr,
367
+ reason: { type: "review", pass, findings },
368
+ };
369
+ }
370
+ }
371
+
372
+ // 5. GitHub Copilot's review, when enabled. It arrives on its own schedule.
373
+ if (ctx.config.review.copilot) {
374
+ const threads = copilotThreads(pr);
375
+ if (threads.length) {
376
+ const key = `fix:${prKey}`;
377
+ if (blocked(issue, key)) return null;
378
+ return {
379
+ kind: "fix",
380
+ key,
381
+ item,
382
+ pr,
383
+ reason: { type: "copilot", threads },
384
+ };
385
+ }
386
+ const reviewed =
387
+ pr.reviews.some((r) => isCopilot(r.author)) ||
388
+ pr.threads.some((t) => t.comments.some((c) => isCopilot(c.author)));
389
+ const waited =
390
+ ctx.now - Date.parse(pr.createdAt) >=
391
+ ctx.config.review.copilotWaitMinutes * 60_000;
392
+ if (!reviewed && !waited) return null;
393
+ }
394
+
395
+ if (!pr.labels.includes(LABELS.agentReviewed)) return "agentReviewed";
396
+ return null;
397
+ }
398
+
399
+ /** Choose which waiting jobs get the free slots. Pure. */
400
+ export function schedule(
401
+ jobs: Job[],
402
+ freeSlots: number,
403
+ priority: JobKind[],
404
+ ): Job[] {
405
+ const rank = (j: Job) => {
406
+ const i = priority.indexOf(j.kind);
407
+ return i === -1 ? priority.length : i;
408
+ };
409
+ // Stable sort keeps board order inside each kind.
410
+ return [...jobs]
411
+ .sort((a, b) => rank(a) - rank(b))
412
+ .slice(0, Math.max(0, freeSlots));
413
+ }
@@ -0,0 +1,187 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readdir } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { log } from "../log.ts";
6
+
7
+ export async function git(cwd: string, ...args: string[]): Promise<string> {
8
+ const proc = Bun.spawn(["git", ...args], {
9
+ cwd,
10
+ stdout: "pipe",
11
+ stderr: "pipe",
12
+ });
13
+ const [out, err, code] = await Promise.all([
14
+ new Response(proc.stdout).text(),
15
+ new Response(proc.stderr).text(),
16
+ proc.exited,
17
+ ]);
18
+ if (code !== 0)
19
+ throw new Error(
20
+ `git ${args.join(" ")} failed: ${err.trim() || out.trim()}`,
21
+ );
22
+ return out.trim();
23
+ }
24
+
25
+ /**
26
+ * One git worktree per job, outside the repo so the human's checkout and
27
+ * tooling never see agent work in progress. Worktrees are reused per key.
28
+ */
29
+ export class Worktrees {
30
+ readonly dir: string;
31
+ private queue: Promise<unknown> = Promise.resolve();
32
+
33
+ constructor(
34
+ readonly root: string,
35
+ repo: string,
36
+ readonly base: string,
37
+ readonly dryRun = false,
38
+ ) {
39
+ this.dir = join(homedir(), ".kanbot", "worktrees", repo.replace("/", "__"));
40
+ }
41
+
42
+ /**
43
+ * Run git operations that touch the shared repo (.git/config, refs, the
44
+ * worktree list) one at a time; parallel jobs otherwise race on its locks.
45
+ */
46
+ private serial<T>(fn: () => Promise<T>): Promise<T> {
47
+ const run = this.queue.then(fn, fn);
48
+ this.queue = run.catch(() => {});
49
+ return run;
50
+ }
51
+
52
+ private path(name: string) {
53
+ return join(this.dir, name.replace(/[^a-zA-Z0-9._-]/g, "_"));
54
+ }
55
+
56
+ fetch() {
57
+ return this.serial(() => git(this.root, "fetch", "--prune", "origin"));
58
+ }
59
+
60
+ async remoteHas(branch: string) {
61
+ return (
62
+ (await git(this.root, "ls-remote", "--heads", "origin", branch)).length >
63
+ 0
64
+ );
65
+ }
66
+
67
+ /**
68
+ * Check out `branch` in its own worktree. Starts from origin/<branch> when it
69
+ * exists remotely (resuming), otherwise from origin/<base>.
70
+ */
71
+ checkout(name: string, branch: string): Promise<string> {
72
+ return this.serial(() => this.checkoutNow(name, branch));
73
+ }
74
+
75
+ private async checkoutNow(name: string, branch: string) {
76
+ const path = this.path(name);
77
+ const from = (await this.remoteHas(branch))
78
+ ? `origin/${branch}`
79
+ : `origin/${this.base}`;
80
+ await mkdir(this.dir, { recursive: true });
81
+ if (!existsSync(path)) {
82
+ await git(
83
+ this.root,
84
+ "worktree",
85
+ "add",
86
+ "--force",
87
+ "--no-track",
88
+ "-B",
89
+ branch,
90
+ path,
91
+ from,
92
+ );
93
+ } else {
94
+ await git(path, "checkout", "--force", "--no-track", "-B", branch, from);
95
+ await git(path, "reset", "--hard", from);
96
+ // Keep ignored files (node_modules) so installs stay warm.
97
+ await git(path, "clean", "-fd");
98
+ }
99
+ return path;
100
+ }
101
+
102
+ /** Read-only snapshot of the base branch for planning. */
103
+ detached(name: string): Promise<string> {
104
+ return this.serial(() => this.detachedNow(name));
105
+ }
106
+
107
+ private async detachedNow(name: string) {
108
+ const path = this.path(name);
109
+ await mkdir(this.dir, { recursive: true });
110
+ const ref = `origin/${this.base}`;
111
+ if (!existsSync(path)) {
112
+ await git(this.root, "worktree", "add", "--force", "--detach", path, ref);
113
+ } else {
114
+ await git(path, "checkout", "--force", "--detach", ref);
115
+ await git(path, "clean", "-fd");
116
+ }
117
+ return path;
118
+ }
119
+
120
+ /** Commit anything the agent left uncommitted. */
121
+ async commitAll(path: string, message: string) {
122
+ if (!(await git(path, "status", "--porcelain"))) return;
123
+ await git(path, "add", "-A");
124
+ await git(path, "commit", "-m", message, "--no-verify");
125
+ }
126
+
127
+ /**
128
+ * Merge `ref` into HEAD. Returns the conflicted files, leaving the merge in
129
+ * progress for an agent to resolve; empty when it merged cleanly.
130
+ */
131
+ async merge(path: string, ref: string): Promise<string[]> {
132
+ try {
133
+ await git(path, "merge", "--no-edit", "--no-verify", ref);
134
+ return [];
135
+ } catch (e) {
136
+ const files = await git(path, "diff", "--name-only", "--diff-filter=U");
137
+ if (!files) throw e;
138
+ return files.split("\n");
139
+ }
140
+ }
141
+
142
+ /** Stage everything and commit the merge in progress, if any. */
143
+ async concludeMerge(path: string) {
144
+ await git(path, "add", "-A");
145
+ const merging = await git(path, "rev-parse", "-q", "--verify", "MERGE_HEAD")
146
+ .then(() => true)
147
+ .catch(() => false);
148
+ if (merging) await git(path, "commit", "--no-edit", "--no-verify");
149
+ }
150
+
151
+ async isAncestor(path: string, ref: string) {
152
+ return git(path, "merge-base", "--is-ancestor", ref, "HEAD")
153
+ .then(() => true)
154
+ .catch(() => false);
155
+ }
156
+
157
+ async commitsAhead(path: string, of: string) {
158
+ return Number(await git(path, "rev-list", "--count", `${of}..HEAD`));
159
+ }
160
+
161
+ async head(path: string) {
162
+ return git(path, "rev-parse", "HEAD");
163
+ }
164
+
165
+ async push(path: string, branch: string) {
166
+ if (this.dryRun) return log.info(`[dry-run] push ${branch}`);
167
+ await this.serial(() =>
168
+ git(
169
+ path,
170
+ "push",
171
+ "--force-with-lease",
172
+ "origin",
173
+ `HEAD:refs/heads/${branch}`,
174
+ ),
175
+ );
176
+ }
177
+
178
+ async remove(prefix: string) {
179
+ if (!existsSync(this.dir)) return;
180
+ for (const name of await readdir(this.dir)) {
181
+ if (!name.startsWith(prefix)) continue;
182
+ await this.serial(() =>
183
+ git(this.root, "worktree", "remove", "--force", join(this.dir, name)),
184
+ ).catch((e) => log.warn(String(e)));
185
+ }
186
+ }
187
+ }
@@ -0,0 +1,24 @@
1
+ import { existsSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /** One daemon per repo. Stale locks (dead pid) are taken over. */
5
+ export async function acquireLock(root: string): Promise<() => void> {
6
+ const file = join(root, ".kanbot", "daemon.pid");
7
+ if (existsSync(file)) {
8
+ const pid = Number(await Bun.file(file).text());
9
+ if (pid && isAlive(pid)) {
10
+ throw new Error(`kanbot is already running for this repo (pid ${pid})`);
11
+ }
12
+ }
13
+ await Bun.write(file, String(process.pid));
14
+ return () => rmSync(file, { force: true });
15
+ }
16
+
17
+ function isAlive(pid: number) {
18
+ try {
19
+ process.kill(pid, 0);
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }