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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mikael Brevik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,103 @@
1
- # Temporary Holding Version
1
+ <img src="docs/public/logo.svg#gh-light-mode-only" alt="kanbot logo" width="96">
2
+ <img src="docs/public/logo-dark.svg#gh-dark-mode-only" alt="kanbot logo" width="96">
2
3
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
4
+ # kanbot
5
+
6
+ Run an autonomous dev team from a GitHub Project board. You define, specify and prioritize work while Claude or Codex agents work as your team.
7
+ Uses subscriptions for Claude or Codex, and does not require API usage. The agents are fully autonomous, but you can comment on their work and steer them as needed.
8
+
9
+ ```mermaid
10
+ flowchart LR
11
+ Ideas["Ideas<br/><i>you write</i>"] --> Planning["Planning<br/><i>agent plans, you comment</i>"]
12
+ Planning --> Ready["Ready<br/><i>you approve</i>"]
13
+ Ready --> InProgress["In Progress<br/><i>agents build</i>"]
14
+ InProgress --> Review["Review<br/><i>agents review, you comment</i>"]
15
+ Review --> Done["Done<br/><i>you merge</i>"]
16
+ ```
17
+
18
+ Full docs: [docs/](./docs/00-index.md). Demo video: [docs/assets/demo.mp4](./docs/assets/demo.mp4).
19
+
20
+ ## How it works
21
+
22
+ | Column | What happens |
23
+ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
24
+ | **Ideas** | Backlog of ideas and tasks. Not touched by any agents. |
25
+ | **Planning** | An agent writes a plan into the issue. Comment to revise it. |
26
+ | **Ready** | Moving a card here approves the plan. Cards are picked top-down. |
27
+ | **In Progress** | An agent implements it in its own worktree and opens a PR. |
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. |
30
+
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
+
33
+ ## Setup
34
+
35
+ Requires [Bun](https://bun.sh) ≥ 1.3, `gh` logged in, and `claude` or `codex` logged in.
36
+
37
+ ```sh
38
+ gh auth refresh -s project
39
+ cd your-repo
40
+ bunx kanbot init --provider claude # or codex
41
+ kanbot board create # then enable the built-in workflows it prints
42
+ git add -A && git commit -m "chore: add kanbot" && git push
43
+ kanbot doctor
44
+ kanbot run
45
+ ```
46
+
47
+ Use `kanbot run --once --dry-run` to see what it would do. Run one daemon per repo.
48
+
49
+ ## Labels
50
+
51
+ | Label | Meaning |
52
+ | ------------------------------------- | ------------------------------------------------- |
53
+ | `technical` | Plan as a technical goal, not a user story |
54
+ | `type:frontend\|backend\|docs\|infra` | Picks the implementer agent (the planner sets it) |
55
+ | `explore:2..4` | Build N alternative approaches as N PRs |
56
+ | `agent-reviewed` | Passed agent review; ready for you |
57
+ | `needs-human` | Agents gave up. Remove it to retry. |
58
+ | `kanbot-review` | Agent review for your own PR, without a card |
59
+
60
+ ## Beyond the board
61
+
62
+ - **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)
63
+ - **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)
64
+ - **Project memory.** Corrections you give on PRs become facts agents read on every job. [More](./docs/06-skills.md#project-memory)
65
+ - **Copilot review.** Optionally add GitHub Copilot as a cloud reviewer. [More](./docs/09-copilot-review.md)
66
+
67
+ ## Customizing
68
+
69
+ - `.kanbot/config.yml`: concurrency, timeouts, notifications, column names.
70
+ - `.kanbot/agents/*.md`: agent definitions. Run `kanbot sync` after editing; don't edit the generated `.claude/` or `.codex/` files.
71
+ - `.agents/skills/`: skills shared by agents. Fill in `project-conventions` first.
72
+
73
+ ## Upgrading
74
+
75
+ `bunx kanbot@latest upgrade` updates agents and skills you haven't edited. For edited ones, it writes the new version next to yours as `<file>.kanbot-new` to merge. See [Upgrading](./docs/10-upgrading.md).
76
+
77
+ ## Shell completion
78
+
79
+ ```sh
80
+ echo 'source <(kanbot completion)' >> ~/.zshrc # or ~/.bashrc
81
+ kanbot completion --shell fish > ~/.config/fish/completions/kanbot.fish
82
+ ```
83
+
84
+ The shell is detected from `$SHELL`; pass `--shell zsh|bash|fish` to override. For zsh, `compinit` must run first.
85
+
86
+ ## Operating
87
+
88
+ - `kanbot status` shows the board, daemon and failures.
89
+ - `.kanbot/logs/*.jsonl` has every agent transcript.
90
+
91
+ ## Limitations
92
+
93
+ - Ready-column order is the project's manual item order, not a view's sort.
94
+ - Workers run as your user. `gh` and `git push` are blocked by command patterns, not missing credentials, so a determined prompt injection could get around them.
95
+
96
+ ## Development
97
+
98
+ ```sh
99
+ pnpm install
100
+ bun test
101
+ pnpm check
102
+ pnpm typecheck
103
+ ```
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
- "name": "kanbot",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "kanbot",
3
+ "version": "0.1.0",
4
+ "description": "Run an autonomous dev team (Claude Code or Codex) from a GitHub Project board",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Mikael Brevik",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/mikaelbr/kanbot.git"
11
+ },
12
+ "homepage": "https://github.com/mikaelbr/kanbot#readme",
13
+ "bugs": "https://github.com/mikaelbr/kanbot/issues",
14
+ "keywords": [
15
+ "github",
16
+ "kanban",
17
+ "agents",
18
+ "claude-code",
19
+ "codex",
20
+ "automation",
21
+ "cli"
22
+ ],
23
+ "bin": {
24
+ "kanbot": "src/cli.ts"
25
+ },
26
+ "files": [
27
+ "src",
28
+ "templates",
29
+ "schema"
30
+ ],
31
+ "engines": {
32
+ "bun": ">=1.3"
33
+ },
34
+ "scripts": {
35
+ "kanbot": "bun src/cli.ts",
36
+ "test": "bun test",
37
+ "check": "biome check .",
38
+ "fix": "biome check --write .",
39
+ "typecheck": "tsc --noEmit",
40
+ "schema": "bun -e \"await Bun.write('schema/config.schema.json', (await import('./src/config.ts')).renderConfigSchema())\"",
41
+ "docs:dev": "pnpm --dir site dev",
42
+ "docs:build": "pnpm --dir site build",
43
+ "hashes": "bun scripts/known-hashes.ts",
44
+ "prepublishOnly": "bun run check && bun run typecheck && bun test"
45
+ },
46
+ "dependencies": {
47
+ "citty": "^0.2.2",
48
+ "node-notifier": "^12.1.0",
49
+ "yaml": "^2.9.1",
50
+ "zod": "^4.6.7"
51
+ },
52
+ "devDependencies": {
53
+ "@biomejs/biome": "^2.6.4",
54
+ "@types/bun": "^1.4.4",
55
+ "typescript": "^7.0.2"
56
+ }
57
+ }
@@ -0,0 +1,206 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "type": "object",
4
+ "properties": {
5
+ "provider": {
6
+ "default": "claude",
7
+ "description": "Agent CLI that runs workers.",
8
+ "type": "string",
9
+ "enum": ["claude", "codex"]
10
+ },
11
+ "repo": {
12
+ "type": "string",
13
+ "pattern": "^[^/]+\\/[^/]+$",
14
+ "description": "GitHub repo as owner/name."
15
+ },
16
+ "baseBranch": {
17
+ "default": "main",
18
+ "description": "Branch agents work from.",
19
+ "type": "string"
20
+ },
21
+ "project": {
22
+ "default": null,
23
+ "description": "GitHub Project board. Set by `kanbot board create`.",
24
+ "anyOf": [
25
+ {
26
+ "type": "object",
27
+ "properties": {
28
+ "owner": {
29
+ "type": "string"
30
+ },
31
+ "number": {
32
+ "type": "integer",
33
+ "minimum": -9007199254740991,
34
+ "maximum": 9007199254740991
35
+ }
36
+ },
37
+ "required": ["owner", "number"]
38
+ },
39
+ {
40
+ "type": "null"
41
+ }
42
+ ]
43
+ },
44
+ "columns": {
45
+ "description": "Names of the board's Status options.",
46
+ "default": {},
47
+ "type": "object",
48
+ "properties": {
49
+ "ideas": {
50
+ "default": "Ideas",
51
+ "type": "string"
52
+ },
53
+ "planning": {
54
+ "default": "Planning",
55
+ "type": "string"
56
+ },
57
+ "ready": {
58
+ "default": "Ready",
59
+ "type": "string"
60
+ },
61
+ "inProgress": {
62
+ "default": "In Progress",
63
+ "type": "string"
64
+ },
65
+ "review": {
66
+ "default": "Review",
67
+ "type": "string"
68
+ },
69
+ "done": {
70
+ "default": "Done",
71
+ "type": "string"
72
+ }
73
+ }
74
+ },
75
+ "concurrency": {
76
+ "default": 3,
77
+ "description": "Max concurrent agent workers. Each exploratory variant is one worker.",
78
+ "type": "integer",
79
+ "minimum": 1,
80
+ "maximum": 9007199254740991
81
+ },
82
+ "pollSeconds": {
83
+ "default": 60,
84
+ "description": "Seconds between board polls.",
85
+ "type": "integer",
86
+ "minimum": 10,
87
+ "maximum": 9007199254740991
88
+ },
89
+ "priority": {
90
+ "default": ["fix", "review", "plan", "implement"],
91
+ "description": "Order in which waiting jobs get free slots.",
92
+ "type": "array",
93
+ "items": {
94
+ "type": "string",
95
+ "enum": ["fix", "review", "plan", "implement"]
96
+ }
97
+ },
98
+ "maxAttempts": {
99
+ "default": 2,
100
+ "description": "Attempts per job before the issue is labeled needs-human.",
101
+ "type": "integer",
102
+ "minimum": 1,
103
+ "maximum": 9007199254740991
104
+ },
105
+ "timeoutMinutes": {
106
+ "description": "Per-job timeout in minutes.",
107
+ "default": {},
108
+ "type": "object",
109
+ "properties": {
110
+ "plan": {
111
+ "default": 20,
112
+ "type": "number"
113
+ },
114
+ "implement": {
115
+ "default": 90,
116
+ "type": "number"
117
+ },
118
+ "review": {
119
+ "default": 30,
120
+ "type": "number"
121
+ },
122
+ "fix": {
123
+ "default": 45,
124
+ "type": "number"
125
+ }
126
+ }
127
+ },
128
+ "implementers": {
129
+ "default": {
130
+ "frontend": "implementer-frontend",
131
+ "backend": "implementer-backend",
132
+ "docs": "implementer-docs",
133
+ "infra": "implementer-technical",
134
+ "default": "implementer-backend"
135
+ },
136
+ "description": "Maps a type:<key> label to an agent. `default` is the fallback.",
137
+ "type": "object",
138
+ "propertyNames": {
139
+ "type": "string"
140
+ },
141
+ "additionalProperties": {
142
+ "type": "string"
143
+ }
144
+ },
145
+ "review": {
146
+ "default": {},
147
+ "type": "object",
148
+ "properties": {
149
+ "copilot": {
150
+ "default": false,
151
+ "description": "Wait for GitHub Copilot's code review and have the fixer address its comments. Needs a paid Copilot plan; each review uses credits.",
152
+ "type": "boolean"
153
+ },
154
+ "copilotOnPush": {
155
+ "default": false,
156
+ "description": "Have Copilot review again after every push. One review per push.",
157
+ "type": "boolean"
158
+ },
159
+ "copilotWaitMinutes": {
160
+ "default": 15,
161
+ "description": "Minutes to wait for Copilot's review before going on without it.",
162
+ "type": "integer",
163
+ "minimum": 1,
164
+ "maximum": 9007199254740991
165
+ }
166
+ }
167
+ },
168
+ "codex": {
169
+ "default": {},
170
+ "type": "object",
171
+ "properties": {
172
+ "networkAccess": {
173
+ "default": false,
174
+ "description": "Let codex workers reach the network (package installs).",
175
+ "type": "boolean"
176
+ }
177
+ }
178
+ },
179
+ "notify": {
180
+ "default": {},
181
+ "type": "object",
182
+ "properties": {
183
+ "desktop": {
184
+ "default": true,
185
+ "description": "Show desktop notifications.",
186
+ "type": "boolean"
187
+ },
188
+ "webhook": {
189
+ "default": null,
190
+ "description": "POSTs {title, message, url} as JSON, e.g. an ntfy.sh topic URL.",
191
+ "anyOf": [
192
+ {
193
+ "type": "string",
194
+ "format": "uri"
195
+ },
196
+ {
197
+ "type": "null"
198
+ }
199
+ ]
200
+ }
201
+ }
202
+ }
203
+ },
204
+ "required": ["repo"],
205
+ "title": "kanbot config"
206
+ }
@@ -0,0 +1,83 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { parse } from "yaml";
4
+ import { z } from "zod";
5
+ import { RESULT_SCHEMAS } from "../schemas/results.ts";
6
+
7
+ export const TOOL_CAPABILITIES = ["read", "edit", "bash", "web"] as const;
8
+
9
+ const Frontmatter = z.object({
10
+ name: z.string().regex(/^[a-z][a-z0-9-]*$/),
11
+ tier: z.enum(["column", "sub"]),
12
+ description: z.string(),
13
+ models: z
14
+ .object({ claude: z.string().optional(), codex: z.string().optional() })
15
+ .default({}),
16
+ effort: z.enum(["low", "medium", "high", "xhigh", "max"]).optional(),
17
+ tools: z.array(z.enum(TOOL_CAPABILITIES)).default(["read"]),
18
+ skills: z.array(z.string()).default([]),
19
+ subagents: z.array(z.string()).default([]),
20
+ output: z
21
+ .enum(Object.keys(RESULT_SCHEMAS) as [keyof typeof RESULT_SCHEMAS])
22
+ .optional(),
23
+ });
24
+
25
+ export type AgentDef = z.infer<typeof Frontmatter> & {
26
+ /** Markdown instructions after the frontmatter. */
27
+ body: string;
28
+ file: string;
29
+ };
30
+
31
+ export function agentsDir(root: string) {
32
+ return join(root, ".kanbot", "agents");
33
+ }
34
+
35
+ export function parseAgent(text: string, file: string): AgentDef {
36
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
37
+ if (!m) throw new Error(`${file}: missing YAML frontmatter`);
38
+ const fm = Frontmatter.safeParse(parse(m[1] as string));
39
+ if (!fm.success) {
40
+ throw new Error(`${file}: ${z.prettifyError(fm.error)}`);
41
+ }
42
+ return { ...fm.data, body: (m[2] as string).trim(), file };
43
+ }
44
+
45
+ export async function loadAgents(root: string): Promise<AgentDef[]> {
46
+ const dir = agentsDir(root);
47
+ const files = (await readdir(dir)).filter((f) => f.endsWith(".md")).sort();
48
+ const agents = await Promise.all(
49
+ files.map(async (f) =>
50
+ parseAgent(await Bun.file(join(dir, f)).text(), join(dir, f)),
51
+ ),
52
+ );
53
+ validate(agents);
54
+ return agents;
55
+ }
56
+
57
+ function validate(agents: AgentDef[]) {
58
+ const names = new Set(agents.map((a) => a.name));
59
+ if (names.size !== agents.length) {
60
+ throw new Error("Duplicate agent names in .kanbot/agents");
61
+ }
62
+ for (const a of agents) {
63
+ if (!a.file.endsWith(`${a.name}.md`)) {
64
+ throw new Error(`${a.file}: name "${a.name}" must match the filename`);
65
+ }
66
+ if (a.tier === "column" && !a.output) {
67
+ throw new Error(`${a.file}: column agents must declare an output schema`);
68
+ }
69
+ for (const s of a.subagents) {
70
+ const sub = agents.find((x) => x.name === s);
71
+ if (!sub) throw new Error(`${a.file}: unknown subagent "${s}"`);
72
+ if (sub.tier !== "sub") {
73
+ throw new Error(`${a.file}: "${s}" is not a tier: sub agent`);
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ export function getAgent(agents: AgentDef[], name: string): AgentDef {
80
+ const a = agents.find((x) => x.name === name);
81
+ if (!a) throw new Error(`No agent named "${name}" in .kanbot/agents`);
82
+ return a;
83
+ }
@@ -0,0 +1,216 @@
1
+ import { existsSync, lstatSync } from "node:fs";
2
+ import { mkdir, readdir, rm, symlink } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { stringify } from "yaml";
5
+ import { configPath, loadConfig } from "../config.ts";
6
+ import type { AgentDef } from "./load.ts";
7
+
8
+ /*
9
+ * .kanbot/agents/*.md is the single source of truth. This module renders it
10
+ * into each CLI's native format so agents work both headless (driven by the
11
+ * daemon) and in interactive sessions:
12
+ * Claude: .claude/agents/<name>.md + .claude/settings.json permissions
13
+ * Codex: .codex/agents/<name>.toml
14
+ * Skills live in .agents/skills (Codex native); .claude/skills symlinks to it.
15
+ */
16
+
17
+ export const GENERATED =
18
+ "generated by kanbot — edit .kanbot/agents/ and run `kanbot sync`";
19
+
20
+ const CLAUDE_TOOLS: Record<string, string[]> = {
21
+ read: ["Read", "Grep", "Glob"],
22
+ edit: ["Edit", "Write", "NotebookEdit"],
23
+ bash: ["Bash"],
24
+ web: ["WebFetch", "WebSearch"],
25
+ };
26
+
27
+ /** Agents never touch GitHub; the daemon owns all remote side effects. */
28
+ export const CLAUDE_DENY = [
29
+ "Bash(gh *)",
30
+ "Bash(git push *)",
31
+ "Bash(git remote *)",
32
+ "Bash(git config *)",
33
+ ];
34
+
35
+ /** Agents act as the human's account: no Claude co-author trailers or PR footers. */
36
+ export const CLAUDE_ATTRIBUTION = {
37
+ attribution: { commit: "", pr: "" },
38
+ includeCoAuthoredBy: false,
39
+ };
40
+
41
+ export function claudeTools(agent: AgentDef): string[] {
42
+ const tools = agent.tools.flatMap((t) => CLAUDE_TOOLS[t] ?? []);
43
+ if (agent.skills.length) tools.push("Skill");
44
+ if (agent.subagents.length) tools.push("Task");
45
+ return [...new Set(tools)];
46
+ }
47
+
48
+ export function renderClaudeAgent(agent: AgentDef): string {
49
+ const fm: Record<string, unknown> = {
50
+ name: agent.name,
51
+ description: agent.description,
52
+ tools: claudeTools(agent).join(", "),
53
+ };
54
+ if (agent.models.claude) fm.model = agent.models.claude;
55
+ if (agent.effort) fm.effort = agent.effort;
56
+ if (agent.skills.length) fm.skills = agent.skills;
57
+ return `---\n${stringify(fm).trim()}\n---\n<!-- ${GENERATED} -->\n\n${agentInstructions(agent)}\n`;
58
+ }
59
+
60
+ export function renderClaudeSettings(agents: AgentDef[]): string {
61
+ const allow = [...new Set(agents.flatMap(claudeTools))].sort();
62
+ return `${JSON.stringify(
63
+ {
64
+ $comment: GENERATED,
65
+ permissions: { allow, deny: CLAUDE_DENY },
66
+ ...CLAUDE_ATTRIBUTION,
67
+ },
68
+ null,
69
+ 2,
70
+ )}\n`;
71
+ }
72
+
73
+ const tomlString = (s: string) => JSON.stringify(s);
74
+
75
+ export function renderCodexAgent(agent: AgentDef): string {
76
+ const lines = [
77
+ `# ${GENERATED}`,
78
+ `name = ${tomlString(agent.name)}`,
79
+ `description = ${tomlString(agent.description)}`,
80
+ ];
81
+ if (agent.models.codex && agent.models.codex !== "default") {
82
+ lines.push(`model = ${tomlString(agent.models.codex)}`);
83
+ }
84
+ if (agent.effort) {
85
+ const effort = agent.effort === "max" ? "xhigh" : agent.effort;
86
+ lines.push(`model_reasoning_effort = ${tomlString(effort)}`);
87
+ }
88
+ lines.push(
89
+ `developer_instructions = ${tomlString(agentInstructions(agent))}`,
90
+ );
91
+ return `${lines.join("\n")}\n`;
92
+ }
93
+
94
+ /**
95
+ * Body plus the parts of the frontmatter that a CLI can't express natively, so
96
+ * the agent knows which skills and helpers it is expected to use.
97
+ */
98
+ export function agentInstructions(agent: AgentDef): string {
99
+ const extra: string[] = [];
100
+ if (agent.skills.length) {
101
+ extra.push(
102
+ `Skills to load when relevant: ${agent.skills.map((s) => `\`${s}\``).join(", ")}.`,
103
+ );
104
+ }
105
+ if (agent.subagents.length) {
106
+ extra.push(
107
+ `Subagents you can delegate to: ${agent.subagents.map((s) => `\`${s}\``).join(", ")}. Delegate focused, independent work to them; keep the final decisions yourself.`,
108
+ );
109
+ }
110
+ return extra.length
111
+ ? `${agent.body}\n\n## Toolbox\n\n${extra.join("\n")}`
112
+ : agent.body;
113
+ }
114
+
115
+ export type SyncFile = { path: string; content: string };
116
+
117
+ const COPILOT_INSTRUCTIONS = join(".github", "copilot-instructions.md");
118
+
119
+ /** Points Copilot code review at the same rules the kanbot reviewers use. */
120
+ export const renderCopilotInstructions =
121
+ () => `<!-- generated by kanbot from review.copilot in .kanbot/config.yml -->
122
+
123
+ # Code review
124
+
125
+ AI agents write most changes in this repo, from a plan in the linked issue. When you review a pull request:
126
+
127
+ - Use the checklist and severity rubric in \`.agents/skills/code-review/SKILL.md\`.
128
+ - Check the change against the plan in the linked issue and the PR description.
129
+ - Hold it to \`AGENTS.md\`, \`.agents/skills/project-conventions/SKILL.md\` and the facts in \`.agents/skills/project-memory/\`.
130
+ - Comment on real problems: bugs, missing tests, security, broken conventions. Leave style to the linter.
131
+ `;
132
+
133
+ export function renderAll(agents: AgentDef[]): SyncFile[] {
134
+ return [
135
+ ...agents.map((a) => ({
136
+ path: join(".claude", "agents", `${a.name}.md`),
137
+ content: renderClaudeAgent(a),
138
+ })),
139
+ {
140
+ path: join(".claude", "settings.json"),
141
+ content: renderClaudeSettings(agents),
142
+ },
143
+ ...agents.map((a) => ({
144
+ path: join(".codex", "agents", `${a.name}.toml`),
145
+ content: renderCodexAgent(a),
146
+ })),
147
+ ];
148
+ }
149
+
150
+ /** Writes generated files. Returns paths whose content changed. */
151
+ export async function sync(
152
+ root: string,
153
+ agents: AgentDef[],
154
+ { check = false } = {},
155
+ ): Promise<string[]> {
156
+ const files = renderAll(agents);
157
+ const copilot =
158
+ existsSync(configPath(root)) && (await loadConfig(root)).review.copilot;
159
+ if (copilot)
160
+ files.push({
161
+ path: COPILOT_INSTRUCTIONS,
162
+ content: renderCopilotInstructions(),
163
+ });
164
+ const changed: string[] = [];
165
+ for (const f of files) {
166
+ const abs = join(root, f.path);
167
+ const existing = existsSync(abs) ? await Bun.file(abs).text() : null;
168
+ if (existing === f.content) continue;
169
+ changed.push(f.path);
170
+ if (!check) await Bun.write(abs, f.content);
171
+ }
172
+ // Remove generated agents whose source was deleted.
173
+ const keep = new Set(files.map((f) => f.path));
174
+ for (const [dir, ext] of [
175
+ [join(".claude", "agents"), ".md"],
176
+ [join(".codex", "agents"), ".toml"],
177
+ ] as const) {
178
+ const abs = join(root, dir);
179
+ if (!existsSync(abs)) continue;
180
+ for (const name of await readdir(abs)) {
181
+ const rel = join(dir, name);
182
+ if (!name.endsWith(ext) || keep.has(rel)) continue;
183
+ const text = await Bun.file(join(abs, name)).text();
184
+ if (!text.includes(GENERATED)) continue;
185
+ changed.push(rel);
186
+ if (!check) await rm(join(abs, name));
187
+ }
188
+ }
189
+ const instructions = join(root, COPILOT_INSTRUCTIONS);
190
+ if (
191
+ !copilot &&
192
+ existsSync(instructions) &&
193
+ (await Bun.file(instructions).text()).includes("generated by kanbot")
194
+ ) {
195
+ changed.push(COPILOT_INSTRUCTIONS);
196
+ if (!check) await rm(instructions);
197
+ }
198
+ if (!(await linkSkills(root, check))) changed.push(join(".claude", "skills"));
199
+ return changed;
200
+ }
201
+
202
+ /** Ensure .claude/skills -> ../.agents/skills. Returns true when already correct. */
203
+ async function linkSkills(root: string, check: boolean) {
204
+ const link = join(root, ".claude", "skills");
205
+ const ok = existsSync(link) && lstatSync(link).isSymbolicLink();
206
+ if (ok || check) return ok;
207
+ if (existsSync(link)) {
208
+ throw new Error(
209
+ ".claude/skills exists and is not a symlink. Move its skills into .agents/skills.",
210
+ );
211
+ }
212
+ await mkdir(join(root, ".agents", "skills"), { recursive: true });
213
+ await mkdir(join(root, ".claude"), { recursive: true });
214
+ await symlink(join("..", ".agents", "skills"), link);
215
+ return false;
216
+ }