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.
- package/LICENSE +21 -0
- package/README.md +102 -2
- package/package.json +56 -5
- package/schema/config.schema.json +206 -0
- package/src/agents/load.ts +83 -0
- package/src/agents/sync.ts +216 -0
- package/src/cli.ts +5 -0
- package/src/commands/board.ts +140 -0
- package/src/commands/completion.ts +24 -0
- package/src/commands/doctor.ts +250 -0
- package/src/commands/init.ts +102 -0
- package/src/commands/run.ts +32 -0
- package/src/commands/skills.ts +63 -0
- package/src/commands/status.ts +61 -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 +213 -0
- package/src/daemon/derive.ts +413 -0
- package/src/daemon/git.ts +187 -0
- package/src/daemon/lock.ts +24 -0
- package/src/daemon/loop.ts +207 -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/github/api.ts +332 -0
- package/src/github/gh.ts +110 -0
- package/src/github/markers.ts +82 -0
- package/src/github/project.ts +278 -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
package/src/cli.ts
ADDED
|
@@ -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,24 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { defineCommand } from "citty";
|
|
3
|
+
import { buildSpec, render, SHELLS, type Shell } from "../completion.ts";
|
|
4
|
+
|
|
5
|
+
export default defineCommand({
|
|
6
|
+
meta: {
|
|
7
|
+
name: "completion",
|
|
8
|
+
description: "Print a shell completion script (zsh, bash or fish)",
|
|
9
|
+
},
|
|
10
|
+
args: {
|
|
11
|
+
shell: {
|
|
12
|
+
type: "enum",
|
|
13
|
+
options: [...SHELLS],
|
|
14
|
+
description: "Target shell (default: $SHELL)",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
async run({ args }) {
|
|
18
|
+
const shell = args.shell ?? basename(process.env.SHELL ?? "");
|
|
19
|
+
if (!SHELLS.includes(shell as Shell))
|
|
20
|
+
throw new Error(`Pass --shell ${SHELLS.join("|")}`);
|
|
21
|
+
const { main } = await import("../main.ts");
|
|
22
|
+
process.stdout.write(render(shell as Shell, await buildSpec(main)));
|
|
23
|
+
},
|
|
24
|
+
});
|
|
@@ -0,0 +1,250 @@
|
|
|
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 { copilotReviewRule } from "../github/api.ts";
|
|
9
|
+
import { Gh, run } from "../github/gh.ts";
|
|
10
|
+
import {
|
|
11
|
+
boardWorkflows,
|
|
12
|
+
loadBoard,
|
|
13
|
+
missingColumns,
|
|
14
|
+
} from "../github/project.ts";
|
|
15
|
+
import { NEW_SUFFIX, pendingMerges, planUpgrade } from "../templates.ts";
|
|
16
|
+
import { REPO_LABELS } from "./board.ts";
|
|
17
|
+
|
|
18
|
+
type Check = { name: string; ok: boolean; detail?: string; fix?: string };
|
|
19
|
+
|
|
20
|
+
async function cmd(args: string[]) {
|
|
21
|
+
const p = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
|
|
22
|
+
const [out, err, code] = await Promise.all([
|
|
23
|
+
new Response(p.stdout).text(),
|
|
24
|
+
new Response(p.stderr).text(),
|
|
25
|
+
p.exited,
|
|
26
|
+
]);
|
|
27
|
+
return { ok: code === 0, out: `${out}${err}`.trim() };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default defineCommand({
|
|
31
|
+
meta: {
|
|
32
|
+
name: "doctor",
|
|
33
|
+
description: "Check that everything kanbot needs is in place",
|
|
34
|
+
},
|
|
35
|
+
async run() {
|
|
36
|
+
const root = process.cwd();
|
|
37
|
+
const checks: Check[] = [];
|
|
38
|
+
const add = (c: Check) => {
|
|
39
|
+
checks.push(c);
|
|
40
|
+
console.log(
|
|
41
|
+
`${c.ok ? "✔" : "✖"} ${c.name}${c.detail ? ` — ${c.detail}` : ""}${!c.ok && c.fix ? `\n fix: ${c.fix}` : ""}`,
|
|
42
|
+
);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const auth = await cmd(["gh", "auth", "status"]);
|
|
46
|
+
add({ name: "gh logged in", ok: auth.ok, fix: "gh auth login" });
|
|
47
|
+
add({
|
|
48
|
+
name: "gh token has project scope",
|
|
49
|
+
ok: /'project'/.test(auth.out),
|
|
50
|
+
fix: "gh auth refresh -s project",
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
let config: Awaited<ReturnType<typeof loadConfig>>;
|
|
54
|
+
try {
|
|
55
|
+
config = await loadConfig(root);
|
|
56
|
+
add({
|
|
57
|
+
name: "config",
|
|
58
|
+
ok: true,
|
|
59
|
+
detail: `${config.repo}, provider ${config.provider}`,
|
|
60
|
+
});
|
|
61
|
+
} catch (e) {
|
|
62
|
+
add({ name: "config", ok: false, detail: String(e), fix: "kanbot init" });
|
|
63
|
+
return process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (config.provider === "claude") {
|
|
67
|
+
const s = await cmd(["claude", "auth", "status"]);
|
|
68
|
+
const sub = /"authMethod":\s*"claude\.ai"/.test(s.out);
|
|
69
|
+
add({
|
|
70
|
+
name: "claude CLI logged in with a subscription",
|
|
71
|
+
ok: s.ok && sub,
|
|
72
|
+
detail: sub
|
|
73
|
+
? undefined
|
|
74
|
+
: "not using a claude.ai login (would bill API usage)",
|
|
75
|
+
fix: "claude auth login",
|
|
76
|
+
});
|
|
77
|
+
} else {
|
|
78
|
+
const s = await cmd(["codex", "login", "status"]);
|
|
79
|
+
add({
|
|
80
|
+
name: "codex CLI logged in with ChatGPT",
|
|
81
|
+
ok: s.ok && /ChatGPT/i.test(s.out),
|
|
82
|
+
detail: s.out.split("\n")[0],
|
|
83
|
+
fix: "codex login",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const agents = await loadAgents(root);
|
|
89
|
+
add({
|
|
90
|
+
name: "agents",
|
|
91
|
+
ok: true,
|
|
92
|
+
detail: `${agents.filter((a) => a.tier === "column").length} column, ${agents.filter((a) => a.tier === "sub").length} sub`,
|
|
93
|
+
});
|
|
94
|
+
const needed = [
|
|
95
|
+
"planner",
|
|
96
|
+
"reviewer-code",
|
|
97
|
+
"reviewer-spec",
|
|
98
|
+
"fixer",
|
|
99
|
+
...Object.values(config.implementers),
|
|
100
|
+
];
|
|
101
|
+
const missing = [...new Set(needed)].filter(
|
|
102
|
+
(n) => !agents.some((a) => a.name === n),
|
|
103
|
+
);
|
|
104
|
+
add({
|
|
105
|
+
name: "agents referenced by config exist",
|
|
106
|
+
ok: !missing.length,
|
|
107
|
+
detail: missing.join(", ") || undefined,
|
|
108
|
+
});
|
|
109
|
+
const stale = await sync(root, agents, { check: true });
|
|
110
|
+
add({
|
|
111
|
+
name: "generated agent files up to date",
|
|
112
|
+
ok: !stale.length,
|
|
113
|
+
detail: stale.join(", ") || undefined,
|
|
114
|
+
fix: "kanbot sync",
|
|
115
|
+
});
|
|
116
|
+
add({
|
|
117
|
+
name: "config schema up to date",
|
|
118
|
+
ok: !(await writeConfigSchema(root, { check: true })),
|
|
119
|
+
fix: "kanbot sync",
|
|
120
|
+
});
|
|
121
|
+
} catch (e) {
|
|
122
|
+
add({ name: "agents", ok: false, detail: String(e) });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const plan = await planUpgrade(root);
|
|
126
|
+
const behind = plan.changes.filter((c) =>
|
|
127
|
+
["add", "update", "conflict"].includes(c.action),
|
|
128
|
+
);
|
|
129
|
+
add({
|
|
130
|
+
name: "templates up to date",
|
|
131
|
+
ok: !behind.length,
|
|
132
|
+
detail: behind.length
|
|
133
|
+
? `${behind.length} file(s) differ from this kanbot version`
|
|
134
|
+
: undefined,
|
|
135
|
+
fix: "kanbot upgrade",
|
|
136
|
+
});
|
|
137
|
+
const merges = await pendingMerges(root);
|
|
138
|
+
add({
|
|
139
|
+
name: `no unmerged ${NEW_SUFFIX} files`,
|
|
140
|
+
ok: !merges.length,
|
|
141
|
+
detail: merges.join(", ") || undefined,
|
|
142
|
+
fix: `merge each into the file next to it and delete it (kanbot-admin skill can help)`,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await git(root, "fetch", "origin", config.baseBranch).catch(() => {});
|
|
146
|
+
const tree = await git(
|
|
147
|
+
root,
|
|
148
|
+
"ls-tree",
|
|
149
|
+
"-r",
|
|
150
|
+
"--name-only",
|
|
151
|
+
`origin/${config.baseBranch}`,
|
|
152
|
+
).catch(() => "");
|
|
153
|
+
for (const path of [
|
|
154
|
+
".agents/skills/",
|
|
155
|
+
".claude/agents/",
|
|
156
|
+
".codex/agents/",
|
|
157
|
+
"AGENTS.md",
|
|
158
|
+
]) {
|
|
159
|
+
add({
|
|
160
|
+
name: `${path} committed on origin/${config.baseBranch}`,
|
|
161
|
+
ok: tree.split("\n").some((f) => f.startsWith(path)),
|
|
162
|
+
detail: "worktrees are created from the base branch",
|
|
163
|
+
fix: "commit and push the kanbot files",
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
add({
|
|
167
|
+
name: ".github/workflows/kanbot-merged.yml",
|
|
168
|
+
ok: existsSync(join(root, ".github/workflows/kanbot-merged.yml")),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const gh = new Gh(config.repo);
|
|
172
|
+
try {
|
|
173
|
+
const board = await loadBoard(gh, config);
|
|
174
|
+
const missing = missingColumns(board);
|
|
175
|
+
add({
|
|
176
|
+
name: "project board",
|
|
177
|
+
ok: !missing.length,
|
|
178
|
+
detail: missing.length
|
|
179
|
+
? `missing columns: ${missing.join(", ")}`
|
|
180
|
+
: board.url,
|
|
181
|
+
fix: "kanbot board create",
|
|
182
|
+
});
|
|
183
|
+
// Moving cards to Done on close/merge and adding new issues are left to GitHub.
|
|
184
|
+
const workflows = await boardWorkflows(gh, board);
|
|
185
|
+
for (const name of [
|
|
186
|
+
"Item closed",
|
|
187
|
+
"Pull request merged",
|
|
188
|
+
"Auto-add to project",
|
|
189
|
+
]) {
|
|
190
|
+
add({
|
|
191
|
+
name: `board workflow "${name}" enabled`,
|
|
192
|
+
ok: workflows.some((w) => w.name === name && w.enabled),
|
|
193
|
+
fix: `enable it in ${board.url} → ⋯ → Workflows`,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
} catch (e) {
|
|
197
|
+
add({
|
|
198
|
+
name: "project board",
|
|
199
|
+
ok: false,
|
|
200
|
+
detail: String(e),
|
|
201
|
+
fix: "kanbot board create",
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const labels = (
|
|
206
|
+
JSON.parse(
|
|
207
|
+
await run([
|
|
208
|
+
"label",
|
|
209
|
+
"list",
|
|
210
|
+
"--repo",
|
|
211
|
+
config.repo,
|
|
212
|
+
"--limit",
|
|
213
|
+
"200",
|
|
214
|
+
"--json",
|
|
215
|
+
"name",
|
|
216
|
+
]),
|
|
217
|
+
) as { name: string }[]
|
|
218
|
+
).map((l) => l.name);
|
|
219
|
+
const missingLabels = REPO_LABELS.map(([n]) => n).filter(
|
|
220
|
+
(n) => !labels.includes(n),
|
|
221
|
+
);
|
|
222
|
+
add({
|
|
223
|
+
name: "labels",
|
|
224
|
+
ok: !missingLabels.length,
|
|
225
|
+
detail: missingLabels.join(", ") || undefined,
|
|
226
|
+
fix: "kanbot board create",
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
if (config.review.copilot) {
|
|
230
|
+
const rule = await copilotReviewRule(gh, config.baseBranch).catch(
|
|
231
|
+
() => null,
|
|
232
|
+
);
|
|
233
|
+
add({
|
|
234
|
+
name: "Copilot review requested on PRs",
|
|
235
|
+
ok: rule !== null,
|
|
236
|
+
detail: rule
|
|
237
|
+
? `re-review on push: ${rule.parameters?.review_on_push ? "on" : "off"}`
|
|
238
|
+
: "review.copilot is on, but no ruleset requests Copilot review",
|
|
239
|
+
fix: "kanbot board create",
|
|
240
|
+
});
|
|
241
|
+
add({
|
|
242
|
+
name: ".github/copilot-instructions.md committed",
|
|
243
|
+
ok: tree.split("\n").includes(".github/copilot-instructions.md"),
|
|
244
|
+
fix: "kanbot sync, then commit and push",
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (checks.some((c) => !c.ok)) process.exit(1);
|
|
249
|
+
},
|
|
250
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
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 { configPath, saveConfig, writeConfigSchema } from "../config.ts";
|
|
7
|
+
import { run } from "../github/gh.ts";
|
|
8
|
+
import { log } from "../log.ts";
|
|
9
|
+
import { applyUpgrade, NEW_SUFFIX, planUpgrade } from "../templates.ts";
|
|
10
|
+
|
|
11
|
+
const GITIGNORE = [".kanbot/state.json", ".kanbot/logs/", ".kanbot/daemon.pid"];
|
|
12
|
+
|
|
13
|
+
export default defineCommand({
|
|
14
|
+
meta: {
|
|
15
|
+
name: "init",
|
|
16
|
+
description:
|
|
17
|
+
"Scaffold kanbot agents, skills and GitHub files into this repo",
|
|
18
|
+
},
|
|
19
|
+
args: {
|
|
20
|
+
provider: {
|
|
21
|
+
type: "enum",
|
|
22
|
+
options: ["claude", "codex"],
|
|
23
|
+
description: "Agent provider",
|
|
24
|
+
default: "claude",
|
|
25
|
+
},
|
|
26
|
+
force: {
|
|
27
|
+
type: "boolean",
|
|
28
|
+
description: "Overwrite existing files, including your edits",
|
|
29
|
+
default: false,
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
async run({ args }) {
|
|
33
|
+
const root = process.cwd();
|
|
34
|
+
if (!existsSync(join(root, ".git")))
|
|
35
|
+
throw new Error("Run inside a git repository.");
|
|
36
|
+
if (!["claude", "codex"].includes(args.provider))
|
|
37
|
+
throw new Error("--provider must be claude or codex");
|
|
38
|
+
|
|
39
|
+
const plan = await planUpgrade(root, { force: args.force });
|
|
40
|
+
await applyUpgrade(root, plan);
|
|
41
|
+
for (const c of plan.changes) {
|
|
42
|
+
if (c.action === "add" || c.action === "update") log.info(`+ ${c.path}`);
|
|
43
|
+
if (c.action === "conflict")
|
|
44
|
+
log.warn(
|
|
45
|
+
`! ${c.path} exists; kanbot's version is in ${c.path}${NEW_SUFFIX}`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
log.info("+ .kanbot/manifest.json");
|
|
49
|
+
|
|
50
|
+
if (!existsSync(configPath(root)) || args.force) {
|
|
51
|
+
const repo = JSON.parse(
|
|
52
|
+
await run(["repo", "view", "--json", "nameWithOwner,defaultBranchRef"]),
|
|
53
|
+
) as {
|
|
54
|
+
nameWithOwner: string;
|
|
55
|
+
defaultBranchRef: { name: string };
|
|
56
|
+
};
|
|
57
|
+
await saveConfig(root, {
|
|
58
|
+
provider: args.provider as "claude" | "codex",
|
|
59
|
+
repo: repo.nameWithOwner,
|
|
60
|
+
baseBranch: repo.defaultBranchRef.name,
|
|
61
|
+
});
|
|
62
|
+
log.info(
|
|
63
|
+
`+ .kanbot/config.yml (${repo.nameWithOwner}, base ${repo.defaultBranchRef.name})`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const codeowners = join(root, ".github", "CODEOWNERS");
|
|
68
|
+
if (!existsSync(codeowners)) {
|
|
69
|
+
const login = (await run(["api", "user", "--jq", ".login"])).trim();
|
|
70
|
+
await Bun.write(
|
|
71
|
+
codeowners,
|
|
72
|
+
`# Human reviewers. kanbot acts as your account, so this documents ownership\n# rather than triggering review requests for your own PRs.\n* @${login}\n`,
|
|
73
|
+
);
|
|
74
|
+
log.info(`+ .github/CODEOWNERS (@${login})`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await appendGitignore(root);
|
|
78
|
+
const changed = await sync(root, await loadAgents(root));
|
|
79
|
+
if (await writeConfigSchema(root))
|
|
80
|
+
changed.push(".kanbot/config.schema.json");
|
|
81
|
+
for (const f of changed) log.info(`~ ${f} (generated)`);
|
|
82
|
+
|
|
83
|
+
console.log(`
|
|
84
|
+
Next steps:
|
|
85
|
+
1. Review AGENTS.md and fill in .agents/skills/project-conventions/SKILL.md
|
|
86
|
+
2. Adjust .github/workflows/ci.yml to your stack (agents fix failing CI)
|
|
87
|
+
3. kanbot board create # creates the project board and labels
|
|
88
|
+
4. kanbot doctor # checks auth, board, generated files
|
|
89
|
+
5. Commit and push these files to the default branch (agents work from it)
|
|
90
|
+
6. kanbot run # start the loop (or kanbot run --once --dry-run)`);
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
async function appendGitignore(root: string) {
|
|
95
|
+
const path = join(root, ".gitignore");
|
|
96
|
+
const current = existsSync(path) ? await Bun.file(path).text() : "";
|
|
97
|
+
const missing = GITIGNORE.filter((l) => !current.split("\n").includes(l));
|
|
98
|
+
if (!missing.length) return;
|
|
99
|
+
const sep = current && !current.endsWith("\n") ? "\n" : "";
|
|
100
|
+
await Bun.write(path, `${current}${sep}\n# kanbot\n${missing.join("\n")}\n`);
|
|
101
|
+
log.info("~ .gitignore");
|
|
102
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { runDaemon } from "../daemon/loop.ts";
|
|
3
|
+
import { log } from "../log.ts";
|
|
4
|
+
|
|
5
|
+
export default defineCommand({
|
|
6
|
+
meta: { name: "run", description: "Run the kanbot loop for this repo" },
|
|
7
|
+
args: {
|
|
8
|
+
once: {
|
|
9
|
+
type: "boolean",
|
|
10
|
+
description: "Run a single tick, wait for its jobs, exit",
|
|
11
|
+
default: false,
|
|
12
|
+
},
|
|
13
|
+
"dry-run": {
|
|
14
|
+
type: "boolean",
|
|
15
|
+
description: "Print what would happen; change nothing",
|
|
16
|
+
default: false,
|
|
17
|
+
},
|
|
18
|
+
verbose: {
|
|
19
|
+
type: "boolean",
|
|
20
|
+
alias: "v",
|
|
21
|
+
description: "Verbose logging",
|
|
22
|
+
default: false,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
async run({ args }) {
|
|
26
|
+
log.verbose = args.verbose;
|
|
27
|
+
await runDaemon(process.cwd(), {
|
|
28
|
+
once: args.once || args["dry-run"],
|
|
29
|
+
dryRun: args["dry-run"],
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { defineCommand } from "citty";
|
|
5
|
+
import { log } from "../log.ts";
|
|
6
|
+
import { listTemplates } from "../templates.ts";
|
|
7
|
+
|
|
8
|
+
/** Skills that work outside a kanbot repo, so they can be installed per user. */
|
|
9
|
+
export const GLOBAL_SKILLS = ["kanbot-idea"];
|
|
10
|
+
|
|
11
|
+
/** User-level skill folders: Claude Code reads the first, Codex the second. */
|
|
12
|
+
export const globalSkillDirs = (home = homedir()) => ({
|
|
13
|
+
claude: join(home, ".claude", "skills"),
|
|
14
|
+
codex: join(home, ".agents", "skills"),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export async function installGlobal(
|
|
18
|
+
providers: ("claude" | "codex")[],
|
|
19
|
+
home = homedir(),
|
|
20
|
+
) {
|
|
21
|
+
const dirs = globalSkillDirs(home);
|
|
22
|
+
const templates = await listTemplates();
|
|
23
|
+
const written: string[] = [];
|
|
24
|
+
for (const [path, content] of templates) {
|
|
25
|
+
const m = path.match(/^\.agents\/skills\/([^/]+)\/(.+)$/);
|
|
26
|
+
if (!m?.[1] || !m[2] || !GLOBAL_SKILLS.includes(m[1])) continue;
|
|
27
|
+
for (const p of providers) {
|
|
28
|
+
const to = join(dirs[p], m[1], m[2]);
|
|
29
|
+
await mkdir(dirname(to), { recursive: true });
|
|
30
|
+
await Bun.write(to, content);
|
|
31
|
+
written.push(to);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return written;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const install = defineCommand({
|
|
38
|
+
meta: {
|
|
39
|
+
name: "install",
|
|
40
|
+
description: `Install ${GLOBAL_SKILLS.join(", ")} for your user, to use from any directory`,
|
|
41
|
+
},
|
|
42
|
+
args: {
|
|
43
|
+
provider: {
|
|
44
|
+
type: "enum",
|
|
45
|
+
options: ["claude", "codex", "both"],
|
|
46
|
+
description: "Which agent CLI to install for",
|
|
47
|
+
default: "both",
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
async run({ args }) {
|
|
51
|
+
const providers =
|
|
52
|
+
args.provider === "both"
|
|
53
|
+
? (["claude", "codex"] as const)
|
|
54
|
+
: [args.provider as "claude" | "codex"];
|
|
55
|
+
for (const f of await installGlobal([...providers])) log.info(`+ ${f}`);
|
|
56
|
+
log.info("Rerun after upgrading kanbot to get the latest version.");
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export default defineCommand({
|
|
61
|
+
meta: { name: "skills", description: "Manage kanbot skills" },
|
|
62
|
+
subCommands: { install },
|
|
63
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { defineCommand } from "citty";
|
|
4
|
+
import { COLUMN_KEYS, LABELS, loadConfig } from "../config.ts";
|
|
5
|
+
import { State } from "../daemon/state.ts";
|
|
6
|
+
import { Gh } from "../github/gh.ts";
|
|
7
|
+
import { listItems, loadBoard } from "../github/project.ts";
|
|
8
|
+
|
|
9
|
+
export default defineCommand({
|
|
10
|
+
meta: {
|
|
11
|
+
name: "status",
|
|
12
|
+
description: "Show board columns, daemon state and failures",
|
|
13
|
+
},
|
|
14
|
+
async run() {
|
|
15
|
+
const root = process.cwd();
|
|
16
|
+
const config = await loadConfig(root);
|
|
17
|
+
const gh = new Gh(config.repo);
|
|
18
|
+
const board = await loadBoard(gh, config);
|
|
19
|
+
const items = await listItems(gh, board);
|
|
20
|
+
|
|
21
|
+
const pidFile = join(root, ".kanbot", "daemon.pid");
|
|
22
|
+
const pid = existsSync(pidFile)
|
|
23
|
+
? (await Bun.file(pidFile).text()).trim()
|
|
24
|
+
: null;
|
|
25
|
+
console.log(
|
|
26
|
+
`${config.repo} · ${board.url}\ndaemon: ${pid ? `pid ${pid}` : "not running"} · provider ${config.provider}\n`,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
for (const key of COLUMN_KEYS) {
|
|
30
|
+
const inCol = items.filter(
|
|
31
|
+
(i) => i.column === key && (key === "done" || i.state === "OPEN"),
|
|
32
|
+
);
|
|
33
|
+
const shown =
|
|
34
|
+
key === "done"
|
|
35
|
+
? `${inCol.length}`
|
|
36
|
+
: inCol.map((i) => `#${i.number}`).join(" ") || "—";
|
|
37
|
+
console.log(`${config.columns[key].padEnd(12)} ${shown}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const needsHuman = JSON.parse(
|
|
41
|
+
await Bun.$`gh issue list --repo ${config.repo} --label ${LABELS.needsHuman} --json number,title`.text(),
|
|
42
|
+
) as { number: number; title: string }[];
|
|
43
|
+
if (needsHuman.length) {
|
|
44
|
+
console.log(
|
|
45
|
+
`\nneeds-human:\n${needsHuman.map((i) => ` #${i.number} ${i.title}`).join("\n")}`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const state = await new State(root).load();
|
|
50
|
+
if (state.failures.size) {
|
|
51
|
+
console.log(
|
|
52
|
+
`\nfailures:\n${[...state.failures].map(([k, n]) => ` ${k}: ${n}`).join("\n")}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (state.pausedUntil > Date.now()) {
|
|
56
|
+
console.log(
|
|
57
|
+
`\npaused (rate limit) until ${new Date(state.pausedUntil).toLocaleString()}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
});
|