kanbot 0.0.0-stage → 0.2.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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -2
  3. package/package.json +75 -5
  4. package/schema/config.schema.json +206 -0
  5. package/src/agents/load.ts +105 -0
  6. package/src/agents/sync.ts +219 -0
  7. package/src/cli.ts +27 -0
  8. package/src/commands/board.ts +140 -0
  9. package/src/commands/completion.ts +30 -0
  10. package/src/commands/doctor.ts +260 -0
  11. package/src/commands/init.ts +119 -0
  12. package/src/commands/run.ts +32 -0
  13. package/src/commands/skills.ts +63 -0
  14. package/src/commands/status.ts +70 -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 +240 -0
  19. package/src/daemon/derive.ts +413 -0
  20. package/src/daemon/git.ts +187 -0
  21. package/src/daemon/lock.ts +29 -0
  22. package/src/daemon/loop.ts +212 -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/errors.ts +171 -0
  28. package/src/github/api.ts +332 -0
  29. package/src/github/gh.ts +110 -0
  30. package/src/github/markers.ts +82 -0
  31. package/src/github/project.ts +288 -0
  32. package/src/github/trust.ts +38 -0
  33. package/src/jobs/prompts.ts +205 -0
  34. package/src/jobs/run.ts +569 -0
  35. package/src/known-hashes.json +132 -0
  36. package/src/log.ts +11 -0
  37. package/src/main.ts +21 -0
  38. package/src/providers/claude.ts +122 -0
  39. package/src/providers/codex.ts +98 -0
  40. package/src/providers/types.ts +94 -0
  41. package/src/schemas/results.ts +116 -0
  42. package/src/templates.ts +192 -0
  43. package/templates/.agents/skills/accessibility/SKILL.md +76 -0
  44. package/templates/.agents/skills/approach-designer/SKILL.md +79 -0
  45. package/templates/.agents/skills/code-review/SKILL.md +83 -0
  46. package/templates/.agents/skills/frontend-design/LICENSE.txt +177 -0
  47. package/templates/.agents/skills/frontend-design/SKILL.md +76 -0
  48. package/templates/.agents/skills/issue-splitting/SKILL.md +78 -0
  49. package/templates/.agents/skills/kanbot-admin/SKILL.md +57 -0
  50. package/templates/.agents/skills/kanbot-idea/SKILL.md +56 -0
  51. package/templates/.agents/skills/plan-template/SKILL.md +85 -0
  52. package/templates/.agents/skills/pr-description/SKILL.md +44 -0
  53. package/templates/.agents/skills/project-conventions/SKILL.md +79 -0
  54. package/templates/.agents/skills/project-memory/SKILL.md +39 -0
  55. package/templates/.agents/skills/project-memory/facts/.gitkeep +0 -0
  56. package/templates/.agents/skills/spec-review/SKILL.md +73 -0
  57. package/templates/.agents/skills/tdd/SKILL.md +51 -0
  58. package/templates/.agents/skills/user-stories/SKILL.md +82 -0
  59. package/templates/.agents/skills/verify-ui/SKILL.md +104 -0
  60. package/templates/.github/ISSUE_TEMPLATE/config.yml +1 -0
  61. package/templates/.github/ISSUE_TEMPLATE/technical.yml +15 -0
  62. package/templates/.github/ISSUE_TEMPLATE/user-story.yml +15 -0
  63. package/templates/.github/pull_request_template.md +15 -0
  64. package/templates/.github/workflows/ci.yml +23 -0
  65. package/templates/.github/workflows/kanbot-merged.yml +30 -0
  66. package/templates/.kanbot/agents/approach-designer.md +31 -0
  67. package/templates/.kanbot/agents/explorer.md +35 -0
  68. package/templates/.kanbot/agents/fixer.md +54 -0
  69. package/templates/.kanbot/agents/implementer-backend.md +57 -0
  70. package/templates/.kanbot/agents/implementer-docs.md +55 -0
  71. package/templates/.kanbot/agents/implementer-frontend.md +54 -0
  72. package/templates/.kanbot/agents/implementer-technical.md +53 -0
  73. package/templates/.kanbot/agents/plan-critic.md +36 -0
  74. package/templates/.kanbot/agents/planner.md +52 -0
  75. package/templates/.kanbot/agents/reviewer-code.md +46 -0
  76. package/templates/.kanbot/agents/reviewer-spec.md +49 -0
  77. package/templates/.kanbot/agents/test-writer.md +35 -0
  78. package/templates/.kanbot/agents/ui-verifier.md +35 -0
  79. package/templates/AGENTS.md +39 -0
  80. package/templates/CLAUDE.md +1 -0
package/src/main.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { defineCommand } from "citty";
2
+ import pkg from "../package.json" with { type: "json" };
3
+
4
+ export const main = defineCommand({
5
+ meta: {
6
+ name: "kanbot",
7
+ version: pkg.version,
8
+ description: "Run an autonomous dev team from a GitHub Project board",
9
+ },
10
+ subCommands: {
11
+ init: () => import("./commands/init.ts").then((m) => m.default),
12
+ board: () => import("./commands/board.ts").then((m) => m.default),
13
+ upgrade: () => import("./commands/upgrade.ts").then((m) => m.default),
14
+ skills: () => import("./commands/skills.ts").then((m) => m.default),
15
+ sync: () => import("./commands/sync.ts").then((m) => m.default),
16
+ doctor: () => import("./commands/doctor.ts").then((m) => m.default),
17
+ run: () => import("./commands/run.ts").then((m) => m.default),
18
+ status: () => import("./commands/status.ts").then((m) => m.default),
19
+ completion: () => import("./commands/completion.ts").then((m) => m.default),
20
+ },
21
+ });
@@ -0,0 +1,122 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { AgentDef } from "../agents/load.ts";
5
+ import {
6
+ agentInstructions,
7
+ CLAUDE_ATTRIBUTION,
8
+ CLAUDE_DENY,
9
+ claudeTools,
10
+ } from "../agents/sync.ts";
11
+ import {
12
+ looksRateLimited,
13
+ type Provider,
14
+ type RunInput,
15
+ type RunResult,
16
+ spawnWorker,
17
+ } from "./types.ts";
18
+
19
+ type ResultEvent = {
20
+ type: "result";
21
+ is_error: boolean;
22
+ subtype: string;
23
+ result?: string;
24
+ structured_output?: unknown;
25
+ api_error_status?: number | null;
26
+ };
27
+
28
+ /** Claude Code headless (`claude -p`), using the logged-in subscription. */
29
+ export const claude: Provider = {
30
+ name: "claude",
31
+ async run(input: RunInput): Promise<RunResult> {
32
+ const { agent } = input;
33
+ // Pass agent definitions explicitly so runs don't depend on what is
34
+ // committed in the worktree's .claude/agents.
35
+ const tmp = await mkdtemp(join(tmpdir(), "kanbot-claude-"));
36
+ const agentsFile = join(tmp, "agents.json");
37
+ await Bun.write(agentsFile, JSON.stringify(claudeAgentsJson(input.agents)));
38
+ const cmd = [
39
+ "claude",
40
+ "-p",
41
+ "--agents",
42
+ agentsFile,
43
+ "--agent",
44
+ agent.name,
45
+ "--output-format",
46
+ "stream-json",
47
+ "--verbose",
48
+ "--json-schema",
49
+ JSON.stringify(input.schema),
50
+ "--permission-mode",
51
+ "acceptEdits",
52
+ "--allowedTools",
53
+ ...headlessTools(agent),
54
+ "--disallowedTools",
55
+ ...CLAUDE_DENY,
56
+ "--no-session-persistence",
57
+ // Isolate from the user's own setup: no user-level plugins, skills or
58
+ // MCP servers, so workers only see the repo's agents and skills.
59
+ "--setting-sources",
60
+ "project,local",
61
+ "--strict-mcp-config",
62
+ "--settings",
63
+ JSON.stringify(CLAUDE_ATTRIBUTION),
64
+ ];
65
+ if (agent.models.claude) cmd.push("--model", agent.models.claude);
66
+ if (agent.effort) cmd.push("--effort", agent.effort);
67
+
68
+ const res = await spawnWorker(cmd, {
69
+ cwd: input.cwd,
70
+ stdin: input.prompt,
71
+ timeoutMs: input.timeoutMs,
72
+ logFile: input.logFile,
73
+ }).finally(() => rm(tmp, { recursive: true, force: true }));
74
+ if (res.timedOut)
75
+ return { ok: false, error: "timed out", rateLimited: false };
76
+
77
+ const result = res.stdout
78
+ .split("\n")
79
+ .reverse()
80
+ .map((line) => {
81
+ try {
82
+ return JSON.parse(line) as ResultEvent;
83
+ } catch {
84
+ return null;
85
+ }
86
+ })
87
+ .find((e) => e?.type === "result");
88
+
89
+ if (!result || result.is_error || result.structured_output === undefined) {
90
+ const detail =
91
+ result?.result ?? res.stderr.slice(-2000) ?? `exit ${res.code}`;
92
+ return {
93
+ ok: false,
94
+ error: `claude ${result?.subtype ?? "failed"}: ${detail}`,
95
+ rateLimited:
96
+ result?.api_error_status === 429 || looksRateLimited(detail),
97
+ };
98
+ }
99
+ return { ok: true, output: result.structured_output };
100
+ },
101
+ };
102
+
103
+ export function claudeAgentsJson(agents: AgentDef[]) {
104
+ return Object.fromEntries(
105
+ agents.map((a) => [
106
+ a.name,
107
+ {
108
+ description: a.description,
109
+ prompt: agentInstructions(a),
110
+ tools: headlessTools(a),
111
+ ...(a.models.claude ? { model: a.models.claude } : {}),
112
+ ...(a.skills.length ? { skills: a.skills } : {}),
113
+ },
114
+ ]),
115
+ );
116
+ }
117
+
118
+ /** Headless column agents answer through the StructuredOutput tool. */
119
+ function headlessTools(agent: AgentDef) {
120
+ const tools = claudeTools(agent);
121
+ return agent.output ? [...tools, "StructuredOutput"] : tools;
122
+ }
@@ -0,0 +1,98 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { agentInstructions } from "../agents/sync.ts";
5
+ import { git } from "../daemon/git.ts";
6
+ import {
7
+ looksRateLimited,
8
+ type Provider,
9
+ type RunInput,
10
+ type RunResult,
11
+ spawnWorker,
12
+ } from "./types.ts";
13
+
14
+ /** Codex CLI headless (`codex exec`), using the logged-in ChatGPT plan. */
15
+ export function codex({ networkAccess }: { networkAccess: boolean }): Provider {
16
+ return {
17
+ name: "codex",
18
+ async run(input: RunInput): Promise<RunResult> {
19
+ const { agent } = input;
20
+ const tmp = await mkdtemp(join(tmpdir(), "kanbot-codex-"));
21
+ try {
22
+ const schemaFile = join(tmp, "schema.json");
23
+ const lastFile = join(tmp, "last.json");
24
+ await Bun.write(schemaFile, JSON.stringify(input.schema));
25
+ // Worktree commits write into the main repo's .git dir.
26
+ const gitDir = await git(
27
+ input.cwd,
28
+ "rev-parse",
29
+ "--path-format=absolute",
30
+ "--git-common-dir",
31
+ );
32
+ const sandbox =
33
+ agent.tools.includes("edit") || agent.tools.includes("bash")
34
+ ? "workspace-write"
35
+ : "read-only";
36
+ const cmd = [
37
+ "codex",
38
+ "exec",
39
+ "--json",
40
+ "--ephemeral",
41
+ "--sandbox",
42
+ sandbox,
43
+ "--cd",
44
+ input.cwd,
45
+ "--add-dir",
46
+ gitDir,
47
+ "--output-schema",
48
+ schemaFile,
49
+ "--output-last-message",
50
+ lastFile,
51
+ "-c",
52
+ `developer_instructions=${JSON.stringify(agentInstructions(agent))}`,
53
+ ];
54
+ if (networkAccess)
55
+ cmd.push("-c", "sandbox_workspace_write.network_access=true");
56
+ if (agent.models.codex && agent.models.codex !== "default")
57
+ cmd.push("--model", agent.models.codex);
58
+ if (agent.effort) {
59
+ cmd.push(
60
+ "-c",
61
+ `model_reasoning_effort="${agent.effort === "max" ? "xhigh" : agent.effort}"`,
62
+ );
63
+ }
64
+ cmd.push("-");
65
+
66
+ const res = await spawnWorker(cmd, {
67
+ cwd: input.cwd,
68
+ stdin: input.prompt,
69
+ timeoutMs: input.timeoutMs,
70
+ logFile: input.logFile,
71
+ });
72
+ if (res.timedOut)
73
+ return { ok: false, error: "timed out", rateLimited: false };
74
+
75
+ const last = Bun.file(lastFile);
76
+ if (res.code !== 0 || !(await last.exists())) {
77
+ const detail = (res.stderr || res.stdout).slice(-2000);
78
+ return {
79
+ ok: false,
80
+ error: `codex exit ${res.code}: ${detail}`,
81
+ rateLimited: looksRateLimited(detail),
82
+ };
83
+ }
84
+ try {
85
+ return { ok: true, output: JSON.parse(await last.text()) };
86
+ } catch {
87
+ return {
88
+ ok: false,
89
+ error: "codex final message was not JSON",
90
+ rateLimited: false,
91
+ };
92
+ }
93
+ } finally {
94
+ await rm(tmp, { recursive: true, force: true });
95
+ }
96
+ },
97
+ };
98
+ }
@@ -0,0 +1,94 @@
1
+ import type { AgentDef } from "../agents/load.ts";
2
+ import type { Config } from "../config.ts";
3
+
4
+ export type RunInput = {
5
+ agent: AgentDef;
6
+ /** All agents, so the worker can delegate to subagents. */
7
+ agents: AgentDef[];
8
+ prompt: string;
9
+ cwd: string;
10
+ /** JSON Schema the final answer must match. */
11
+ schema: object;
12
+ timeoutMs: number;
13
+ logFile: string;
14
+ /** Main repo checkout, e.g. so sandboxes can write the shared .git dir. */
15
+ repoRoot: string;
16
+ };
17
+
18
+ export type RunResult =
19
+ | { ok: true; output: unknown }
20
+ | { ok: false; error: string; rateLimited: boolean };
21
+
22
+ export interface Provider {
23
+ readonly name: Config["provider"];
24
+ run(input: RunInput): Promise<RunResult>;
25
+ }
26
+
27
+ const RATE_LIMIT =
28
+ /usage limit|rate limit|rate_limit|429|quota|limit reached|try again later/i;
29
+
30
+ export function looksRateLimited(text: string) {
31
+ return RATE_LIMIT.test(text);
32
+ }
33
+
34
+ /** Env for workers: never hand them GitHub credentials. */
35
+ export function workerEnv(): Record<string, string> {
36
+ const env: Record<string, string> = {};
37
+ for (const [k, v] of Object.entries(process.env)) {
38
+ if (v === undefined) continue;
39
+ if (/^(GH|GITHUB)_.*TOKEN$|^GH_ENTERPRISE_TOKEN$/.test(k)) continue;
40
+ env[k] = v;
41
+ }
42
+ env.KANBOT_WORKER = "1";
43
+ return env;
44
+ }
45
+
46
+ export type Spawned = {
47
+ stdout: string;
48
+ stderr: string;
49
+ code: number;
50
+ timedOut: boolean;
51
+ };
52
+
53
+ /** Spawn a worker, tee stdout to the job log, enforce the timeout. */
54
+ export async function spawnWorker(
55
+ cmd: string[],
56
+ {
57
+ cwd,
58
+ stdin,
59
+ timeoutMs,
60
+ logFile,
61
+ }: { cwd: string; stdin: string; timeoutMs: number; logFile: string },
62
+ ): Promise<Spawned> {
63
+ const proc = Bun.spawn(cmd, {
64
+ cwd,
65
+ env: workerEnv(),
66
+ stdin: new Blob([stdin]),
67
+ stdout: "pipe",
68
+ stderr: "pipe",
69
+ });
70
+ let timedOut = false;
71
+ const timer = setTimeout(() => {
72
+ timedOut = true;
73
+ proc.kill("SIGTERM");
74
+ setTimeout(() => proc.kill("SIGKILL"), 10_000);
75
+ }, timeoutMs);
76
+ const sink = Bun.file(logFile).writer();
77
+ const decoder = new TextDecoder();
78
+ let stdout = "";
79
+ const readOut = (async () => {
80
+ for await (const chunk of proc.stdout) {
81
+ sink.write(chunk);
82
+ stdout += decoder.decode(chunk, { stream: true });
83
+ }
84
+ })();
85
+ const [stderr, code] = await Promise.all([
86
+ new Response(proc.stderr).text(),
87
+ proc.exited,
88
+ readOut,
89
+ ]);
90
+ clearTimeout(timer);
91
+ if (stderr) sink.write(`\n--- stderr ---\n${stderr}`);
92
+ await sink.end();
93
+ return { stdout, stderr, code, timedOut };
94
+ }
@@ -0,0 +1,116 @@
1
+ import { z } from "zod";
2
+
3
+ /*
4
+ * Output contracts for column agents. Both CLIs enforce these natively
5
+ * (claude --json-schema, codex --output-schema). Codex uses strict schemas, so
6
+ * every field is required; use null or empty arrays for "nothing".
7
+ */
8
+
9
+ export const PlanResult = z.strictObject({
10
+ plan: z
11
+ .string()
12
+ .describe(
13
+ "Full markdown plan per plan-template; replaces the previous plan. Aim for under 30 lines.",
14
+ ),
15
+ summary: z.string().describe("One sentence to the human on what changed."),
16
+ replies: z
17
+ .array(
18
+ z.strictObject({
19
+ commentId: z.string().describe("id of the human comment answered"),
20
+ body: z.string().describe("One or two sentences."),
21
+ }),
22
+ )
23
+ .describe("One reply per human comment given as input."),
24
+ newIssues: z
25
+ .array(
26
+ z.strictObject({
27
+ title: z.string(),
28
+ body: z.string(),
29
+ subIssue: z
30
+ .boolean()
31
+ .describe("true when it is split out of this issue"),
32
+ column: z.enum(["ideas", "planning"]),
33
+ }),
34
+ )
35
+ .describe("Issues to create, e.g. when the human asks to split work."),
36
+ labels: z
37
+ .array(z.string())
38
+ .describe("Labels the issue should have, e.g. type:frontend or technical."),
39
+ questions: z
40
+ .array(z.string())
41
+ .describe(
42
+ "Only questions blocking approval, one line each, with the default you assume.",
43
+ ),
44
+ });
45
+ export type PlanResult = z.infer<typeof PlanResult>;
46
+
47
+ export const ImplementResult = z.strictObject({
48
+ status: z.enum(["done", "blocked"]),
49
+ prTitle: z.string().describe("Conventional commit, under 72 characters."),
50
+ prBody: z
51
+ .string()
52
+ .describe(
53
+ "Short markdown PR description per pr-description, under ~20 lines. No 'Closes' line, risks or test results.",
54
+ ),
55
+ testsRun: z
56
+ .array(z.string())
57
+ .describe("One line each: command and result, e.g. `bun test`: 5 pass."),
58
+ screenshots: z
59
+ .array(z.string())
60
+ .describe("Repo-relative paths of screenshots committed for review."),
61
+ risks: z
62
+ .array(z.string())
63
+ .describe("Real risks only, one line each. Empty when none."),
64
+ blockedReason: z.string().nullable(),
65
+ });
66
+ export type ImplementResult = z.infer<typeof ImplementResult>;
67
+
68
+ export const Finding = z.strictObject({
69
+ id: z.string().describe("Short stable id like F1"),
70
+ severity: z.enum(["blocker", "major", "minor", "nit"]),
71
+ path: z.string().nullable(),
72
+ line: z.number().int().nullable(),
73
+ body: z
74
+ .string()
75
+ .describe("One to three sentences: the problem, why it matters, the fix."),
76
+ });
77
+ export type Finding = z.infer<typeof Finding>;
78
+
79
+ export const ReviewResult = z.strictObject({
80
+ verdict: z.enum(["approve", "changes"]),
81
+ summary: z.string().describe("One or two sentences."),
82
+ findings: z.array(Finding),
83
+ });
84
+ export type ReviewResult = z.infer<typeof ReviewResult>;
85
+
86
+ export const FixResult = z.strictObject({
87
+ addressed: z.array(
88
+ z.strictObject({
89
+ ref: z.string().describe("Finding id or comment/thread id handled"),
90
+ reply: z
91
+ .string()
92
+ .describe("One or two sentences: what changed, or why not."),
93
+ resolved: z.boolean(),
94
+ }),
95
+ ),
96
+ summary: z.string().describe("One sentence."),
97
+ prBody: z
98
+ .string()
99
+ .nullable()
100
+ .describe(
101
+ "Full updated PR description when your changes made the current one wrong (behaviour, copy, screenshots); null otherwise.",
102
+ ),
103
+ });
104
+ export type FixResult = z.infer<typeof FixResult>;
105
+
106
+ export const RESULT_SCHEMAS = {
107
+ "plan-result": PlanResult,
108
+ "implement-result": ImplementResult,
109
+ "review-result": ReviewResult,
110
+ "fix-result": FixResult,
111
+ } as const;
112
+ export type ResultSchemaName = keyof typeof RESULT_SCHEMAS;
113
+
114
+ export function jsonSchema(name: ResultSchemaName) {
115
+ return z.toJSONSchema(RESULT_SCHEMAS[name], { target: "draft-7" });
116
+ }
@@ -0,0 +1,192 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readdir } from "node:fs/promises";
3
+ import { dirname, join, relative, sep } from "node:path";
4
+ import pkg from "../package.json" with { type: "json" };
5
+ import knownHashes from "./known-hashes.json" with { type: "json" };
6
+
7
+ /*
8
+ * Template files installed into consumer repos, and how upgrades treat them.
9
+ * `.kanbot/manifest.json` records the hash of each template as installed, so an
10
+ * upgrade can tell files you edited from files it may replace.
11
+ */
12
+
13
+ export const TEMPLATES = join(import.meta.dir, "..", "templates");
14
+ const MANIFEST = join(".kanbot", "manifest.json");
15
+ export const NEW_SUFFIX = ".kanbot-new";
16
+
17
+ /**
18
+ * - `kanbot`: belongs to kanbot; always replaced.
19
+ * - `user`: yours after install; never touched again.
20
+ * - `shared`: replaced while unedited; your edits get a `.kanbot-new` copy.
21
+ */
22
+ export type FileClass = "kanbot" | "user" | "shared";
23
+
24
+ const CLASSES: [prefix: string, FileClass][] = [
25
+ [".github/workflows/kanbot-", "kanbot"],
26
+ [".agents/skills/kanbot-admin/", "kanbot"],
27
+ [".agents/skills/kanbot-idea/", "kanbot"],
28
+ ["AGENTS.md", "user"],
29
+ ["CLAUDE.md", "user"],
30
+ [".agents/skills/project-conventions/", "user"],
31
+ [".agents/skills/project-memory/", "user"],
32
+ [".github/workflows/ci.yml", "user"],
33
+ [".github/ISSUE_TEMPLATE/", "user"],
34
+ [".github/pull_request_template.md", "user"],
35
+ ];
36
+
37
+ export function fileClass(path: string): FileClass {
38
+ return CLASSES.find(([prefix]) => path.startsWith(prefix))?.[1] ?? "shared";
39
+ }
40
+
41
+ /** Git blob id, so hashes of past versions can be read from git history. */
42
+ export function hash(content: Uint8Array) {
43
+ const h = new Bun.CryptoHasher("sha1");
44
+ h.update(`blob ${content.length}\0`);
45
+ h.update(content);
46
+ return h.digest("hex");
47
+ }
48
+
49
+ export type Manifest = { version: string; files: Record<string, string> };
50
+
51
+ export async function readManifest(root: string): Promise<Manifest | null> {
52
+ const file = Bun.file(join(root, MANIFEST));
53
+ return (await file.exists()) ? ((await file.json()) as Manifest) : null;
54
+ }
55
+
56
+ async function writeManifest(root: string, files: Record<string, string>) {
57
+ const sorted = Object.fromEntries(
58
+ Object.entries(files).sort(([a], [b]) => a.localeCompare(b)),
59
+ );
60
+ const manifest: Manifest = { version: pkg.version, files: sorted };
61
+ await Bun.write(
62
+ join(root, MANIFEST),
63
+ `${JSON.stringify(manifest, null, "\t")}\n`,
64
+ );
65
+ }
66
+
67
+ /** Template files as repo-relative paths with forward slashes. */
68
+ export async function listTemplates(dir = TEMPLATES) {
69
+ const out = new Map<string, Uint8Array>();
70
+ const walk = async (d: string) => {
71
+ for (const e of await readdir(d, { withFileTypes: true })) {
72
+ const p = join(d, e.name);
73
+ if (e.isDirectory()) await walk(p);
74
+ else
75
+ out.set(
76
+ relative(dir, p).split(sep).join("/"),
77
+ await Bun.file(p).bytes(),
78
+ );
79
+ }
80
+ };
81
+ await walk(dir);
82
+ return out;
83
+ }
84
+
85
+ export type Change = {
86
+ path: string;
87
+ action:
88
+ | "add" // new template, written
89
+ | "update" // unedited, replaced
90
+ | "conflict" // edited by you; new version goes to <path>.kanbot-new
91
+ | "deleted" // you deleted it; left out
92
+ | "removed"; // no longer shipped; left in place
93
+ content?: Uint8Array;
94
+ };
95
+
96
+ export type Plan = { changes: Change[]; files: Record<string, string> };
97
+
98
+ type Options = {
99
+ templates?: string;
100
+ known?: Record<string, string[]>;
101
+ /** `init --force`: overwrite everything. */
102
+ force?: boolean;
103
+ };
104
+
105
+ /**
106
+ * Work out what an upgrade would change. Without a manifest, a file that
107
+ * matches any past template version counts as unedited.
108
+ */
109
+ export async function planUpgrade(
110
+ root: string,
111
+ opts: Options = {},
112
+ ): Promise<Plan> {
113
+ const templates = await listTemplates(opts.templates);
114
+ const known: Record<string, string[]> = opts.known ?? knownHashes;
115
+ const manifest = await readManifest(root);
116
+ const files: Record<string, string> = {};
117
+ const changes: Change[] = [];
118
+
119
+ for (const [path, content] of templates) {
120
+ const next = hash(content);
121
+ const target = join(root, path);
122
+ const recorded = manifest?.files[path];
123
+ const write = (action: Change["action"]) => {
124
+ changes.push({ path, action, content });
125
+ files[path] = next;
126
+ };
127
+
128
+ if (!existsSync(target)) {
129
+ if (recorded && !opts.force) {
130
+ changes.push({ path, action: "deleted" });
131
+ files[path] = recorded;
132
+ } else write("add");
133
+ continue;
134
+ }
135
+ const current = hash(await Bun.file(target).bytes());
136
+ if (current === next) {
137
+ files[path] = next;
138
+ continue;
139
+ }
140
+ if (opts.force) {
141
+ write("update");
142
+ continue;
143
+ }
144
+ const cls = fileClass(path);
145
+ if (cls === "user") {
146
+ files[path] = recorded ?? current;
147
+ continue;
148
+ }
149
+ if (cls === "shared" && recorded === next) {
150
+ // Template unchanged since install: your edits stand.
151
+ files[path] = next;
152
+ continue;
153
+ }
154
+ const unedited = recorded
155
+ ? current === recorded
156
+ : (known[path] ?? []).includes(current);
157
+ if (cls === "kanbot" || unedited) {
158
+ write("update");
159
+ continue;
160
+ }
161
+ // Record the new version as the base, so once you merge it, later
162
+ // upgrades only flag the file when the template changes again.
163
+ changes.push({ path, action: "conflict", content });
164
+ files[path] = next;
165
+ }
166
+
167
+ for (const path of Object.keys(manifest?.files ?? {})) {
168
+ if (!templates.has(path) && existsSync(join(root, path)))
169
+ changes.push({ path, action: "removed" });
170
+ }
171
+ return { changes, files };
172
+ }
173
+
174
+ /** Write the planned files and the manifest. */
175
+ export async function applyUpgrade(root: string, plan: Plan) {
176
+ for (const c of plan.changes) {
177
+ if (!c.content) continue;
178
+ const to = join(
179
+ root,
180
+ c.action === "conflict" ? `${c.path}${NEW_SUFFIX}` : c.path,
181
+ );
182
+ await mkdir(dirname(to), { recursive: true });
183
+ await Bun.write(to, c.content);
184
+ }
185
+ await writeManifest(root, plan.files);
186
+ }
187
+
188
+ /** Leftover `.kanbot-new` files waiting to be merged. */
189
+ export async function pendingMerges(root: string, dir = TEMPLATES) {
190
+ const paths = [...(await listTemplates(dir)).keys()];
191
+ return paths.filter((p) => existsSync(join(root, `${p}${NEW_SUFFIX}`)));
192
+ }