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.
- package/LICENSE +21 -0
- package/README.md +112 -2
- package/package.json +75 -5
- package/schema/config.schema.json +206 -0
- package/src/agents/load.ts +105 -0
- package/src/agents/sync.ts +219 -0
- package/src/cli.ts +27 -0
- package/src/commands/board.ts +140 -0
- package/src/commands/completion.ts +30 -0
- package/src/commands/doctor.ts +260 -0
- package/src/commands/init.ts +119 -0
- package/src/commands/run.ts +32 -0
- package/src/commands/skills.ts +63 -0
- package/src/commands/status.ts +70 -0
- package/src/commands/sync.ts +31 -0
- package/src/commands/upgrade.ts +70 -0
- package/src/completion.ts +217 -0
- package/src/config.ts +240 -0
- package/src/daemon/derive.ts +413 -0
- package/src/daemon/git.ts +187 -0
- package/src/daemon/lock.ts +29 -0
- package/src/daemon/loop.ts +212 -0
- package/src/daemon/notify.ts +44 -0
- package/src/daemon/snapshot.ts +109 -0
- package/src/daemon/state.ts +60 -0
- package/src/daemon/types.ts +73 -0
- package/src/errors.ts +171 -0
- package/src/github/api.ts +332 -0
- package/src/github/gh.ts +110 -0
- package/src/github/markers.ts +82 -0
- package/src/github/project.ts +288 -0
- package/src/github/trust.ts +38 -0
- package/src/jobs/prompts.ts +205 -0
- package/src/jobs/run.ts +569 -0
- package/src/known-hashes.json +132 -0
- package/src/log.ts +11 -0
- package/src/main.ts +21 -0
- package/src/providers/claude.ts +122 -0
- package/src/providers/codex.ts +98 -0
- package/src/providers/types.ts +94 -0
- package/src/schemas/results.ts +116 -0
- package/src/templates.ts +192 -0
- package/templates/.agents/skills/accessibility/SKILL.md +76 -0
- package/templates/.agents/skills/approach-designer/SKILL.md +79 -0
- package/templates/.agents/skills/code-review/SKILL.md +83 -0
- package/templates/.agents/skills/frontend-design/LICENSE.txt +177 -0
- package/templates/.agents/skills/frontend-design/SKILL.md +76 -0
- package/templates/.agents/skills/issue-splitting/SKILL.md +78 -0
- package/templates/.agents/skills/kanbot-admin/SKILL.md +57 -0
- package/templates/.agents/skills/kanbot-idea/SKILL.md +56 -0
- package/templates/.agents/skills/plan-template/SKILL.md +85 -0
- package/templates/.agents/skills/pr-description/SKILL.md +44 -0
- package/templates/.agents/skills/project-conventions/SKILL.md +79 -0
- package/templates/.agents/skills/project-memory/SKILL.md +39 -0
- package/templates/.agents/skills/project-memory/facts/.gitkeep +0 -0
- package/templates/.agents/skills/spec-review/SKILL.md +73 -0
- package/templates/.agents/skills/tdd/SKILL.md +51 -0
- package/templates/.agents/skills/user-stories/SKILL.md +82 -0
- package/templates/.agents/skills/verify-ui/SKILL.md +104 -0
- package/templates/.github/ISSUE_TEMPLATE/config.yml +1 -0
- package/templates/.github/ISSUE_TEMPLATE/technical.yml +15 -0
- package/templates/.github/ISSUE_TEMPLATE/user-story.yml +15 -0
- package/templates/.github/pull_request_template.md +15 -0
- package/templates/.github/workflows/ci.yml +23 -0
- package/templates/.github/workflows/kanbot-merged.yml +30 -0
- package/templates/.kanbot/agents/approach-designer.md +31 -0
- package/templates/.kanbot/agents/explorer.md +35 -0
- package/templates/.kanbot/agents/fixer.md +54 -0
- package/templates/.kanbot/agents/implementer-backend.md +57 -0
- package/templates/.kanbot/agents/implementer-docs.md +55 -0
- package/templates/.kanbot/agents/implementer-frontend.md +54 -0
- package/templates/.kanbot/agents/implementer-technical.md +53 -0
- package/templates/.kanbot/agents/plan-critic.md +36 -0
- package/templates/.kanbot/agents/planner.md +52 -0
- package/templates/.kanbot/agents/reviewer-code.md +46 -0
- package/templates/.kanbot/agents/reviewer-spec.md +49 -0
- package/templates/.kanbot/agents/test-writer.md +35 -0
- package/templates/.kanbot/agents/ui-verifier.md +35 -0
- package/templates/AGENTS.md +39 -0
- package/templates/CLAUDE.md +1 -0
|
@@ -0,0 +1,219 @@
|
|
|
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 { UserError } from "../errors.ts";
|
|
7
|
+
import type { AgentDef } from "./load.ts";
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* .kanbot/agents/*.md is the single source of truth. This module renders it
|
|
11
|
+
* into each CLI's native format so agents work both headless (driven by the
|
|
12
|
+
* daemon) and in interactive sessions:
|
|
13
|
+
* Claude: .claude/agents/<name>.md + .claude/settings.json permissions
|
|
14
|
+
* Codex: .codex/agents/<name>.toml
|
|
15
|
+
* Skills live in .agents/skills (Codex native); .claude/skills symlinks to it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const GENERATED =
|
|
19
|
+
"generated by kanbot — edit .kanbot/agents/ and run `kanbot sync`";
|
|
20
|
+
|
|
21
|
+
const CLAUDE_TOOLS: Record<string, string[]> = {
|
|
22
|
+
read: ["Read", "Grep", "Glob"],
|
|
23
|
+
edit: ["Edit", "Write", "NotebookEdit"],
|
|
24
|
+
bash: ["Bash"],
|
|
25
|
+
web: ["WebFetch", "WebSearch"],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Agents never touch GitHub; the daemon owns all remote side effects. */
|
|
29
|
+
export const CLAUDE_DENY = [
|
|
30
|
+
"Bash(gh *)",
|
|
31
|
+
"Bash(git push *)",
|
|
32
|
+
"Bash(git remote *)",
|
|
33
|
+
"Bash(git config *)",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** Agents act as the human's account: no Claude co-author trailers or PR footers. */
|
|
37
|
+
export const CLAUDE_ATTRIBUTION = {
|
|
38
|
+
attribution: { commit: "", pr: "" },
|
|
39
|
+
includeCoAuthoredBy: false,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function claudeTools(agent: AgentDef): string[] {
|
|
43
|
+
const tools = agent.tools.flatMap((t) => CLAUDE_TOOLS[t] ?? []);
|
|
44
|
+
if (agent.skills.length) tools.push("Skill");
|
|
45
|
+
if (agent.subagents.length) tools.push("Task");
|
|
46
|
+
return [...new Set(tools)];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function renderClaudeAgent(agent: AgentDef): string {
|
|
50
|
+
const fm: Record<string, unknown> = {
|
|
51
|
+
name: agent.name,
|
|
52
|
+
description: agent.description,
|
|
53
|
+
tools: claudeTools(agent).join(", "),
|
|
54
|
+
};
|
|
55
|
+
if (agent.models.claude) fm.model = agent.models.claude;
|
|
56
|
+
if (agent.effort) fm.effort = agent.effort;
|
|
57
|
+
if (agent.skills.length) fm.skills = agent.skills;
|
|
58
|
+
return `---\n${stringify(fm).trim()}\n---\n<!-- ${GENERATED} -->\n\n${agentInstructions(agent)}\n`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function renderClaudeSettings(agents: AgentDef[]): string {
|
|
62
|
+
const allow = [...new Set(agents.flatMap(claudeTools))].sort();
|
|
63
|
+
return `${JSON.stringify(
|
|
64
|
+
{
|
|
65
|
+
$comment: GENERATED,
|
|
66
|
+
permissions: { allow, deny: CLAUDE_DENY },
|
|
67
|
+
...CLAUDE_ATTRIBUTION,
|
|
68
|
+
},
|
|
69
|
+
null,
|
|
70
|
+
2,
|
|
71
|
+
)}\n`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const tomlString = (s: string) => JSON.stringify(s);
|
|
75
|
+
|
|
76
|
+
export function renderCodexAgent(agent: AgentDef): string {
|
|
77
|
+
const lines = [
|
|
78
|
+
`# ${GENERATED}`,
|
|
79
|
+
`name = ${tomlString(agent.name)}`,
|
|
80
|
+
`description = ${tomlString(agent.description)}`,
|
|
81
|
+
];
|
|
82
|
+
if (agent.models.codex && agent.models.codex !== "default") {
|
|
83
|
+
lines.push(`model = ${tomlString(agent.models.codex)}`);
|
|
84
|
+
}
|
|
85
|
+
if (agent.effort) {
|
|
86
|
+
const effort = agent.effort === "max" ? "xhigh" : agent.effort;
|
|
87
|
+
lines.push(`model_reasoning_effort = ${tomlString(effort)}`);
|
|
88
|
+
}
|
|
89
|
+
lines.push(
|
|
90
|
+
`developer_instructions = ${tomlString(agentInstructions(agent))}`,
|
|
91
|
+
);
|
|
92
|
+
return `${lines.join("\n")}\n`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Body plus the parts of the frontmatter that a CLI can't express natively, so
|
|
97
|
+
* the agent knows which skills and helpers it is expected to use.
|
|
98
|
+
*/
|
|
99
|
+
export function agentInstructions(agent: AgentDef): string {
|
|
100
|
+
const extra: string[] = [];
|
|
101
|
+
if (agent.skills.length) {
|
|
102
|
+
extra.push(
|
|
103
|
+
`Skills to load when relevant: ${agent.skills.map((s) => `\`${s}\``).join(", ")}.`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
if (agent.subagents.length) {
|
|
107
|
+
extra.push(
|
|
108
|
+
`Subagents you can delegate to: ${agent.subagents.map((s) => `\`${s}\``).join(", ")}. Delegate focused, independent work to them; keep the final decisions yourself.`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return extra.length
|
|
112
|
+
? `${agent.body}\n\n## Toolbox\n\n${extra.join("\n")}`
|
|
113
|
+
: agent.body;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export type SyncFile = { path: string; content: string };
|
|
117
|
+
|
|
118
|
+
const COPILOT_INSTRUCTIONS = join(".github", "copilot-instructions.md");
|
|
119
|
+
|
|
120
|
+
/** Points Copilot code review at the same rules the kanbot reviewers use. */
|
|
121
|
+
export const renderCopilotInstructions =
|
|
122
|
+
() => `<!-- generated by kanbot from review.copilot in .kanbot/config.yml -->
|
|
123
|
+
|
|
124
|
+
# Code review
|
|
125
|
+
|
|
126
|
+
AI agents write most changes in this repo, from a plan in the linked issue. When you review a pull request:
|
|
127
|
+
|
|
128
|
+
- Use the checklist and severity rubric in \`.agents/skills/code-review/SKILL.md\`.
|
|
129
|
+
- Check the change against the plan in the linked issue and the PR description.
|
|
130
|
+
- Hold it to \`AGENTS.md\`, \`.agents/skills/project-conventions/SKILL.md\` and the facts in \`.agents/skills/project-memory/\`.
|
|
131
|
+
- Comment on real problems: bugs, missing tests, security, broken conventions. Leave style to the linter.
|
|
132
|
+
`;
|
|
133
|
+
|
|
134
|
+
export function renderAll(agents: AgentDef[]): SyncFile[] {
|
|
135
|
+
return [
|
|
136
|
+
...agents.map((a) => ({
|
|
137
|
+
path: join(".claude", "agents", `${a.name}.md`),
|
|
138
|
+
content: renderClaudeAgent(a),
|
|
139
|
+
})),
|
|
140
|
+
{
|
|
141
|
+
path: join(".claude", "settings.json"),
|
|
142
|
+
content: renderClaudeSettings(agents),
|
|
143
|
+
},
|
|
144
|
+
...agents.map((a) => ({
|
|
145
|
+
path: join(".codex", "agents", `${a.name}.toml`),
|
|
146
|
+
content: renderCodexAgent(a),
|
|
147
|
+
})),
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Writes generated files. Returns paths whose content changed. */
|
|
152
|
+
export async function sync(
|
|
153
|
+
root: string,
|
|
154
|
+
agents: AgentDef[],
|
|
155
|
+
{ check = false } = {},
|
|
156
|
+
): Promise<string[]> {
|
|
157
|
+
const files = renderAll(agents);
|
|
158
|
+
const copilot =
|
|
159
|
+
existsSync(configPath(root)) && (await loadConfig(root)).review.copilot;
|
|
160
|
+
if (copilot)
|
|
161
|
+
files.push({
|
|
162
|
+
path: COPILOT_INSTRUCTIONS,
|
|
163
|
+
content: renderCopilotInstructions(),
|
|
164
|
+
});
|
|
165
|
+
const changed: string[] = [];
|
|
166
|
+
for (const f of files) {
|
|
167
|
+
const abs = join(root, f.path);
|
|
168
|
+
const existing = existsSync(abs) ? await Bun.file(abs).text() : null;
|
|
169
|
+
if (existing === f.content) continue;
|
|
170
|
+
changed.push(f.path);
|
|
171
|
+
if (!check) await Bun.write(abs, f.content);
|
|
172
|
+
}
|
|
173
|
+
// Remove generated agents whose source was deleted.
|
|
174
|
+
const keep = new Set(files.map((f) => f.path));
|
|
175
|
+
for (const [dir, ext] of [
|
|
176
|
+
[join(".claude", "agents"), ".md"],
|
|
177
|
+
[join(".codex", "agents"), ".toml"],
|
|
178
|
+
] as const) {
|
|
179
|
+
const abs = join(root, dir);
|
|
180
|
+
if (!existsSync(abs)) continue;
|
|
181
|
+
for (const name of await readdir(abs)) {
|
|
182
|
+
const rel = join(dir, name);
|
|
183
|
+
if (!name.endsWith(ext) || keep.has(rel)) continue;
|
|
184
|
+
const text = await Bun.file(join(abs, name)).text();
|
|
185
|
+
if (!text.includes(GENERATED)) continue;
|
|
186
|
+
changed.push(rel);
|
|
187
|
+
if (!check) await rm(join(abs, name));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const instructions = join(root, COPILOT_INSTRUCTIONS);
|
|
191
|
+
if (
|
|
192
|
+
!copilot &&
|
|
193
|
+
existsSync(instructions) &&
|
|
194
|
+
(await Bun.file(instructions).text()).includes("generated by kanbot")
|
|
195
|
+
) {
|
|
196
|
+
changed.push(COPILOT_INSTRUCTIONS);
|
|
197
|
+
if (!check) await rm(instructions);
|
|
198
|
+
}
|
|
199
|
+
if (!(await linkSkills(root, check))) changed.push(join(".claude", "skills"));
|
|
200
|
+
return changed;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Ensure .claude/skills -> ../.agents/skills. Returns true when already correct. */
|
|
204
|
+
async function linkSkills(root: string, check: boolean) {
|
|
205
|
+
const link = join(root, ".claude", "skills");
|
|
206
|
+
const ok = existsSync(link) && lstatSync(link).isSymbolicLink();
|
|
207
|
+
if (ok || check) return ok;
|
|
208
|
+
if (existsSync(link)) {
|
|
209
|
+
throw new UserError("Cannot link .claude/skills.", {
|
|
210
|
+
why: ".claude/skills exists and is not a symlink. kanbot keeps skills in .agents/skills and links to them.",
|
|
211
|
+
fix: "Move its skills into .agents/skills, delete .claude/skills, then rerun.",
|
|
212
|
+
docs: "skills#add-a-skill",
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
await mkdir(join(root, ".agents", "skills"), { recursive: true });
|
|
216
|
+
await mkdir(join(root, ".claude"), { recursive: true });
|
|
217
|
+
await symlink(join("..", ".agents", "skills"), link);
|
|
218
|
+
return false;
|
|
219
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { runCommand, runMain } from "citty";
|
|
3
|
+
import { formatError } from "./errors.ts";
|
|
4
|
+
import { main } from "./main.ts";
|
|
5
|
+
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
const usage =
|
|
8
|
+
!args.length ||
|
|
9
|
+
args.some((a) => a === "--help" || a === "-h") ||
|
|
10
|
+
(args.length === 1 && (args[0] === "--version" || args[0] === "-v"));
|
|
11
|
+
|
|
12
|
+
// citty prints usage for help, version and a bare `kanbot`.
|
|
13
|
+
if (usage) {
|
|
14
|
+
await runMain(main);
|
|
15
|
+
} else {
|
|
16
|
+
try {
|
|
17
|
+
await runCommand(main, { rawArgs: args });
|
|
18
|
+
} catch (e) {
|
|
19
|
+
// Leading positionals name the command, e.g. "board create".
|
|
20
|
+
const end = args.findIndex((a) => a.startsWith("-"));
|
|
21
|
+
const command = args.slice(0, end < 0 ? undefined : end).join(" ");
|
|
22
|
+
console.error(
|
|
23
|
+
formatError(e, { debug: !!process.env.KANBOT_DEBUG, command }),
|
|
24
|
+
);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { LABELS, loadConfig, saveConfig } from "../config.ts";
|
|
3
|
+
import { ensureCopilotRuleset, ensureLabel } from "../github/api.ts";
|
|
4
|
+
import { Gh } from "../github/gh.ts";
|
|
5
|
+
import { createBoard, loadBoard, missingColumns } from "../github/project.ts";
|
|
6
|
+
import { log } from "../log.ts";
|
|
7
|
+
|
|
8
|
+
export const REPO_LABELS: [name: string, color: string, description: string][] =
|
|
9
|
+
[
|
|
10
|
+
[
|
|
11
|
+
LABELS.technical,
|
|
12
|
+
"5319e7",
|
|
13
|
+
"Technical work; planned as a technical goal instead of a user story",
|
|
14
|
+
],
|
|
15
|
+
["type:frontend", "1d76db", "User-facing UI work"],
|
|
16
|
+
["type:backend", "0e8a16", "Server, data or API work"],
|
|
17
|
+
["type:docs", "c5def5", "Documentation"],
|
|
18
|
+
["type:infra", "bfd4f2", "Tooling, CI, infrastructure"],
|
|
19
|
+
[
|
|
20
|
+
"explore:2",
|
|
21
|
+
"fbca04",
|
|
22
|
+
"Implement 2 alternative approaches as separate PRs",
|
|
23
|
+
],
|
|
24
|
+
[
|
|
25
|
+
"explore:3",
|
|
26
|
+
"fbca04",
|
|
27
|
+
"Implement 3 alternative approaches as separate PRs",
|
|
28
|
+
],
|
|
29
|
+
[
|
|
30
|
+
"explore:4",
|
|
31
|
+
"fbca04",
|
|
32
|
+
"Implement 4 alternative approaches as separate PRs",
|
|
33
|
+
],
|
|
34
|
+
[LABELS.working, "ededed", "An kanbot agent is working on this"],
|
|
35
|
+
[
|
|
36
|
+
LABELS.review,
|
|
37
|
+
"5319e7",
|
|
38
|
+
"Have kanbot agents review this PR, without a board card",
|
|
39
|
+
],
|
|
40
|
+
[
|
|
41
|
+
LABELS.agentReviewed,
|
|
42
|
+
"0e8a16",
|
|
43
|
+
"Passed both agent review passes; ready for human testing",
|
|
44
|
+
],
|
|
45
|
+
[
|
|
46
|
+
LABELS.needsHuman,
|
|
47
|
+
"d93f0b",
|
|
48
|
+
"Agents stopped; remove the label to let them retry",
|
|
49
|
+
],
|
|
50
|
+
["variant:1", "f9d0c4", "Exploratory approach 1"],
|
|
51
|
+
["variant:2", "f9d0c4", "Exploratory approach 2"],
|
|
52
|
+
["variant:3", "f9d0c4", "Exploratory approach 3"],
|
|
53
|
+
["variant:4", "f9d0c4", "Exploratory approach 4"],
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
export async function ensureLabels(gh: Gh) {
|
|
57
|
+
for (const [name, color, description] of REPO_LABELS)
|
|
58
|
+
await ensureLabel(gh, name, color, description);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const create = defineCommand({
|
|
62
|
+
meta: {
|
|
63
|
+
name: "create",
|
|
64
|
+
description:
|
|
65
|
+
"Create a GitHub Project board for this repo with the kanbot columns",
|
|
66
|
+
},
|
|
67
|
+
args: {
|
|
68
|
+
title: {
|
|
69
|
+
type: "string",
|
|
70
|
+
description: "Project title (default: repo name)",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
async run({ args }) {
|
|
74
|
+
const root = process.cwd();
|
|
75
|
+
const config = await loadConfig(root);
|
|
76
|
+
const gh = new Gh(config.repo);
|
|
77
|
+
if (config.project) {
|
|
78
|
+
log.info(
|
|
79
|
+
`Project already configured (${config.project.owner} #${config.project.number}); only ensuring labels.`,
|
|
80
|
+
);
|
|
81
|
+
} else {
|
|
82
|
+
const project = await createBoard(gh, config, args.title ?? gh.name);
|
|
83
|
+
if (!project) return;
|
|
84
|
+
await saveConfig(root, {
|
|
85
|
+
project: { owner: project.owner, number: project.number },
|
|
86
|
+
});
|
|
87
|
+
log.info(`Created project ${project.url}`);
|
|
88
|
+
}
|
|
89
|
+
await ensureLabels(gh);
|
|
90
|
+
log.info(`Labels ensured: ${REPO_LABELS.map(([n]) => n).join(", ")}`);
|
|
91
|
+
if (config.review.copilot) {
|
|
92
|
+
await ensureCopilotRuleset(gh, config.review.copilotOnPush);
|
|
93
|
+
log.info("Copilot review ruleset ensured.");
|
|
94
|
+
}
|
|
95
|
+
const board = await loadBoard(gh, await loadConfig(root));
|
|
96
|
+
const missing = missingColumns(board);
|
|
97
|
+
if (missing.length)
|
|
98
|
+
log.warn(
|
|
99
|
+
`Board is missing columns: ${missing.join(", ")} — add them to the Status field.`,
|
|
100
|
+
);
|
|
101
|
+
console.log(`
|
|
102
|
+
Enable these built-in project workflows (Project → ⋯ → Workflows), they can't be set via the API:
|
|
103
|
+
• Item closed → set Status: ${config.columns.done}
|
|
104
|
+
• Pull request merged → set Status: ${config.columns.done}
|
|
105
|
+
• Auto-add to project → filter is:issue repo:${config.repo} (new issues land in the board)
|
|
106
|
+
• Item added to project → set Status: ${config.columns.ideas}
|
|
107
|
+
${board.url}`);
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const link = defineCommand({
|
|
112
|
+
meta: { name: "link", description: "Use an existing project board" },
|
|
113
|
+
args: {
|
|
114
|
+
owner: {
|
|
115
|
+
type: "string",
|
|
116
|
+
required: true,
|
|
117
|
+
description: "User or org login that owns the project",
|
|
118
|
+
},
|
|
119
|
+
number: { type: "string", required: true, description: "Project number" },
|
|
120
|
+
},
|
|
121
|
+
async run({ args }) {
|
|
122
|
+
const root = process.cwd();
|
|
123
|
+
await saveConfig(root, {
|
|
124
|
+
project: { owner: args.owner, number: Number(args.number) },
|
|
125
|
+
});
|
|
126
|
+
const config = await loadConfig(root);
|
|
127
|
+
const board = await loadBoard(new Gh(config.repo), config);
|
|
128
|
+
const missing = missingColumns(board);
|
|
129
|
+
log.info(
|
|
130
|
+
missing.length
|
|
131
|
+
? `Linked, but missing columns: ${missing.join(", ")}`
|
|
132
|
+
: `Linked ${board.url}`,
|
|
133
|
+
);
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
export default defineCommand({
|
|
138
|
+
meta: { name: "board", description: "Manage the GitHub Project board" },
|
|
139
|
+
subCommands: { create, link },
|
|
140
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { defineCommand } from "citty";
|
|
3
|
+
import { buildSpec, render, SHELLS, type Shell } from "../completion.ts";
|
|
4
|
+
import { UserError } from "../errors.ts";
|
|
5
|
+
|
|
6
|
+
export default defineCommand({
|
|
7
|
+
meta: {
|
|
8
|
+
name: "completion",
|
|
9
|
+
description: "Print a shell completion script (zsh, bash or fish)",
|
|
10
|
+
},
|
|
11
|
+
args: {
|
|
12
|
+
shell: {
|
|
13
|
+
type: "enum",
|
|
14
|
+
options: [...SHELLS],
|
|
15
|
+
description: "Target shell (default: $SHELL)",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
async run({ args }) {
|
|
19
|
+
const shell = args.shell ?? basename(process.env.SHELL ?? "");
|
|
20
|
+
if (!SHELLS.includes(shell as Shell))
|
|
21
|
+
throw new UserError("Could not detect your shell.", {
|
|
22
|
+
why: `$SHELL is "${shell}", which is not supported.`,
|
|
23
|
+
fix: `kanbot completion --shell ${SHELLS.join("|")}`,
|
|
24
|
+
docs: "getting-started#shell-completion",
|
|
25
|
+
more: "",
|
|
26
|
+
});
|
|
27
|
+
const { main } = await import("../main.ts");
|
|
28
|
+
process.stdout.write(render(shell as Shell, await buildSpec(main)));
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { defineCommand } from "citty";
|
|
4
|
+
import { loadAgents } from "../agents/load.ts";
|
|
5
|
+
import { sync } from "../agents/sync.ts";
|
|
6
|
+
import { loadConfig, writeConfigSchema } from "../config.ts";
|
|
7
|
+
import { git } from "../daemon/git.ts";
|
|
8
|
+
import { describeError } from "../errors.ts";
|
|
9
|
+
import { copilotReviewRule } from "../github/api.ts";
|
|
10
|
+
import { Gh, run } from "../github/gh.ts";
|
|
11
|
+
import {
|
|
12
|
+
boardWorkflows,
|
|
13
|
+
loadBoard,
|
|
14
|
+
missingColumns,
|
|
15
|
+
} from "../github/project.ts";
|
|
16
|
+
import { NEW_SUFFIX, pendingMerges, planUpgrade } from "../templates.ts";
|
|
17
|
+
import { REPO_LABELS } from "./board.ts";
|
|
18
|
+
|
|
19
|
+
type Check = { name: string; ok: boolean; detail?: string; fix?: string };
|
|
20
|
+
|
|
21
|
+
async function cmd(args: string[]) {
|
|
22
|
+
const p = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
|
|
23
|
+
const [out, err, code] = await Promise.all([
|
|
24
|
+
new Response(p.stdout).text(),
|
|
25
|
+
new Response(p.stderr).text(),
|
|
26
|
+
p.exited,
|
|
27
|
+
]);
|
|
28
|
+
return { ok: code === 0, out: `${out}${err}`.trim() };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Failure detail and fix for a check, from a thrown error. */
|
|
32
|
+
function explain(e: unknown, fallback?: string) {
|
|
33
|
+
const d = describeError(e);
|
|
34
|
+
const detail = [d.message, d.why].filter(Boolean).join("\n");
|
|
35
|
+
return {
|
|
36
|
+
detail: detail.replace(/\n/g, "\n "),
|
|
37
|
+
fix: d.fix ?? fallback,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default defineCommand({
|
|
42
|
+
meta: {
|
|
43
|
+
name: "doctor",
|
|
44
|
+
description: "Check that everything kanbot needs is in place",
|
|
45
|
+
},
|
|
46
|
+
async run() {
|
|
47
|
+
const root = process.cwd();
|
|
48
|
+
const checks: Check[] = [];
|
|
49
|
+
const add = (c: Check) => {
|
|
50
|
+
checks.push(c);
|
|
51
|
+
console.log(
|
|
52
|
+
`${c.ok ? "✔" : "✖"} ${c.name}${c.detail ? ` — ${c.detail}` : ""}${!c.ok && c.fix ? `\n fix: ${c.fix}` : ""}`,
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const auth = await cmd(["gh", "auth", "status"]);
|
|
57
|
+
add({ name: "gh logged in", ok: auth.ok, fix: "gh auth login" });
|
|
58
|
+
add({
|
|
59
|
+
name: "gh token has project scope",
|
|
60
|
+
ok: /'project'/.test(auth.out),
|
|
61
|
+
fix: "gh auth refresh -s project",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
let config: Awaited<ReturnType<typeof loadConfig>>;
|
|
65
|
+
try {
|
|
66
|
+
config = await loadConfig(root);
|
|
67
|
+
add({
|
|
68
|
+
name: "config",
|
|
69
|
+
ok: true,
|
|
70
|
+
detail: `${config.repo}, provider ${config.provider}`,
|
|
71
|
+
});
|
|
72
|
+
} catch (e) {
|
|
73
|
+
add({ name: "config", ok: false, ...explain(e, "kanbot init") });
|
|
74
|
+
return process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (config.provider === "claude") {
|
|
78
|
+
const s = await cmd(["claude", "auth", "status"]);
|
|
79
|
+
const sub = /"authMethod":\s*"claude\.ai"/.test(s.out);
|
|
80
|
+
add({
|
|
81
|
+
name: "claude CLI logged in with a subscription",
|
|
82
|
+
ok: s.ok && sub,
|
|
83
|
+
detail: sub
|
|
84
|
+
? undefined
|
|
85
|
+
: "not using a claude.ai login (would bill API usage)",
|
|
86
|
+
fix: "claude auth login",
|
|
87
|
+
});
|
|
88
|
+
} else {
|
|
89
|
+
const s = await cmd(["codex", "login", "status"]);
|
|
90
|
+
add({
|
|
91
|
+
name: "codex CLI logged in with ChatGPT",
|
|
92
|
+
ok: s.ok && /ChatGPT/i.test(s.out),
|
|
93
|
+
detail: s.out.split("\n")[0],
|
|
94
|
+
fix: "codex login",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const agents = await loadAgents(root);
|
|
100
|
+
add({
|
|
101
|
+
name: "agents",
|
|
102
|
+
ok: true,
|
|
103
|
+
detail: `${agents.filter((a) => a.tier === "column").length} column, ${agents.filter((a) => a.tier === "sub").length} sub`,
|
|
104
|
+
});
|
|
105
|
+
const needed = [
|
|
106
|
+
"planner",
|
|
107
|
+
"reviewer-code",
|
|
108
|
+
"reviewer-spec",
|
|
109
|
+
"fixer",
|
|
110
|
+
...Object.values(config.implementers),
|
|
111
|
+
];
|
|
112
|
+
const missing = [...new Set(needed)].filter(
|
|
113
|
+
(n) => !agents.some((a) => a.name === n),
|
|
114
|
+
);
|
|
115
|
+
add({
|
|
116
|
+
name: "agents referenced by config exist",
|
|
117
|
+
ok: !missing.length,
|
|
118
|
+
detail: missing.join(", ") || undefined,
|
|
119
|
+
});
|
|
120
|
+
const stale = await sync(root, agents, { check: true });
|
|
121
|
+
add({
|
|
122
|
+
name: "generated agent files up to date",
|
|
123
|
+
ok: !stale.length,
|
|
124
|
+
detail: stale.join(", ") || undefined,
|
|
125
|
+
fix: "kanbot sync",
|
|
126
|
+
});
|
|
127
|
+
add({
|
|
128
|
+
name: "config schema up to date",
|
|
129
|
+
ok: !(await writeConfigSchema(root, { check: true })),
|
|
130
|
+
fix: "kanbot sync",
|
|
131
|
+
});
|
|
132
|
+
} catch (e) {
|
|
133
|
+
add({ name: "agents", ok: false, ...explain(e) });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const plan = await planUpgrade(root);
|
|
137
|
+
const behind = plan.changes.filter((c) =>
|
|
138
|
+
["add", "update", "conflict"].includes(c.action),
|
|
139
|
+
);
|
|
140
|
+
add({
|
|
141
|
+
name: "templates up to date",
|
|
142
|
+
ok: !behind.length,
|
|
143
|
+
detail: behind.length
|
|
144
|
+
? `${behind.length} file(s) differ from this kanbot version`
|
|
145
|
+
: undefined,
|
|
146
|
+
fix: "kanbot upgrade",
|
|
147
|
+
});
|
|
148
|
+
const merges = await pendingMerges(root);
|
|
149
|
+
add({
|
|
150
|
+
name: `no unmerged ${NEW_SUFFIX} files`,
|
|
151
|
+
ok: !merges.length,
|
|
152
|
+
detail: merges.join(", ") || undefined,
|
|
153
|
+
fix: `merge each into the file next to it and delete it (kanbot-admin skill can help)`,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await git(root, "fetch", "origin", config.baseBranch).catch(() => {});
|
|
157
|
+
const tree = await git(
|
|
158
|
+
root,
|
|
159
|
+
"ls-tree",
|
|
160
|
+
"-r",
|
|
161
|
+
"--name-only",
|
|
162
|
+
`origin/${config.baseBranch}`,
|
|
163
|
+
).catch(() => "");
|
|
164
|
+
for (const path of [
|
|
165
|
+
".agents/skills/",
|
|
166
|
+
".claude/agents/",
|
|
167
|
+
".codex/agents/",
|
|
168
|
+
"AGENTS.md",
|
|
169
|
+
]) {
|
|
170
|
+
add({
|
|
171
|
+
name: `${path} committed on origin/${config.baseBranch}`,
|
|
172
|
+
ok: tree.split("\n").some((f) => f.startsWith(path)),
|
|
173
|
+
detail: "worktrees are created from the base branch",
|
|
174
|
+
fix: "commit and push the kanbot files",
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
add({
|
|
178
|
+
name: ".github/workflows/kanbot-merged.yml",
|
|
179
|
+
ok: existsSync(join(root, ".github/workflows/kanbot-merged.yml")),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const gh = new Gh(config.repo);
|
|
183
|
+
try {
|
|
184
|
+
const board = await loadBoard(gh, config);
|
|
185
|
+
const missing = missingColumns(board);
|
|
186
|
+
add({
|
|
187
|
+
name: "project board",
|
|
188
|
+
ok: !missing.length,
|
|
189
|
+
detail: missing.length
|
|
190
|
+
? `missing columns: ${missing.join(", ")}`
|
|
191
|
+
: board.url,
|
|
192
|
+
fix: "kanbot board create",
|
|
193
|
+
});
|
|
194
|
+
// Moving cards to Done on close/merge and adding new issues are left to GitHub.
|
|
195
|
+
const workflows = await boardWorkflows(gh, board);
|
|
196
|
+
for (const name of [
|
|
197
|
+
"Item closed",
|
|
198
|
+
"Pull request merged",
|
|
199
|
+
"Auto-add to project",
|
|
200
|
+
]) {
|
|
201
|
+
add({
|
|
202
|
+
name: `board workflow "${name}" enabled`,
|
|
203
|
+
ok: workflows.some((w) => w.name === name && w.enabled),
|
|
204
|
+
fix: `enable it in ${board.url} → ⋯ → Workflows`,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
} catch (e) {
|
|
208
|
+
add({
|
|
209
|
+
name: "project board",
|
|
210
|
+
ok: false,
|
|
211
|
+
...explain(e, "kanbot board create"),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const labels = (
|
|
216
|
+
JSON.parse(
|
|
217
|
+
await run([
|
|
218
|
+
"label",
|
|
219
|
+
"list",
|
|
220
|
+
"--repo",
|
|
221
|
+
config.repo,
|
|
222
|
+
"--limit",
|
|
223
|
+
"200",
|
|
224
|
+
"--json",
|
|
225
|
+
"name",
|
|
226
|
+
]),
|
|
227
|
+
) as { name: string }[]
|
|
228
|
+
).map((l) => l.name);
|
|
229
|
+
const missingLabels = REPO_LABELS.map(([n]) => n).filter(
|
|
230
|
+
(n) => !labels.includes(n),
|
|
231
|
+
);
|
|
232
|
+
add({
|
|
233
|
+
name: "labels",
|
|
234
|
+
ok: !missingLabels.length,
|
|
235
|
+
detail: missingLabels.join(", ") || undefined,
|
|
236
|
+
fix: "kanbot board create",
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
if (config.review.copilot) {
|
|
240
|
+
const rule = await copilotReviewRule(gh, config.baseBranch).catch(
|
|
241
|
+
() => null,
|
|
242
|
+
);
|
|
243
|
+
add({
|
|
244
|
+
name: "Copilot review requested on PRs",
|
|
245
|
+
ok: rule !== null,
|
|
246
|
+
detail: rule
|
|
247
|
+
? `re-review on push: ${rule.parameters?.review_on_push ? "on" : "off"}`
|
|
248
|
+
: "review.copilot is on, but no ruleset requests Copilot review",
|
|
249
|
+
fix: "kanbot board create",
|
|
250
|
+
});
|
|
251
|
+
add({
|
|
252
|
+
name: ".github/copilot-instructions.md committed",
|
|
253
|
+
ok: tree.split("\n").includes(".github/copilot-instructions.md"),
|
|
254
|
+
fix: "kanbot sync, then commit and push",
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (checks.some((c) => !c.ok)) process.exit(1);
|
|
259
|
+
},
|
|
260
|
+
});
|