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
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { loadAgents } from "../agents/load.ts";
|
|
3
|
+
import { sync } from "../agents/sync.ts";
|
|
4
|
+
import { writeConfigSchema } from "../config.ts";
|
|
5
|
+
import { log } from "../log.ts";
|
|
6
|
+
|
|
7
|
+
export default defineCommand({
|
|
8
|
+
meta: {
|
|
9
|
+
name: "sync",
|
|
10
|
+
description:
|
|
11
|
+
"Generate .claude/agents, .codex/agents, permissions and the config schema",
|
|
12
|
+
},
|
|
13
|
+
args: {
|
|
14
|
+
check: {
|
|
15
|
+
type: "boolean",
|
|
16
|
+
description: "Exit 1 if generated files are stale",
|
|
17
|
+
default: false,
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
async run({ args }) {
|
|
21
|
+
const root = process.cwd();
|
|
22
|
+
const changed = await sync(root, await loadAgents(root), {
|
|
23
|
+
check: args.check,
|
|
24
|
+
});
|
|
25
|
+
if (await writeConfigSchema(root, { check: args.check }))
|
|
26
|
+
changed.push(".kanbot/config.schema.json");
|
|
27
|
+
if (!changed.length) return log.info("Generated files are up to date.");
|
|
28
|
+
for (const f of changed) log.info(`${args.check ? "stale" : "wrote"} ${f}`);
|
|
29
|
+
if (args.check) process.exit(1);
|
|
30
|
+
},
|
|
31
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import pkg from "../../package.json" with { type: "json" };
|
|
3
|
+
import { loadAgents } from "../agents/load.ts";
|
|
4
|
+
import { sync } from "../agents/sync.ts";
|
|
5
|
+
import { writeConfigSchema } from "../config.ts";
|
|
6
|
+
import { log } from "../log.ts";
|
|
7
|
+
import {
|
|
8
|
+
applyUpgrade,
|
|
9
|
+
type Change,
|
|
10
|
+
NEW_SUFFIX,
|
|
11
|
+
planUpgrade,
|
|
12
|
+
readManifest,
|
|
13
|
+
} from "../templates.ts";
|
|
14
|
+
|
|
15
|
+
const HEADINGS: Record<Change["action"], string> = {
|
|
16
|
+
add: "Added",
|
|
17
|
+
update: "Updated",
|
|
18
|
+
conflict: `You edited these; kanbot's new version is next to each as *${NEW_SUFFIX}`,
|
|
19
|
+
deleted: "You deleted these; left out",
|
|
20
|
+
removed: "No longer shipped by kanbot; left in place",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export default defineCommand({
|
|
24
|
+
meta: {
|
|
25
|
+
name: "upgrade",
|
|
26
|
+
description:
|
|
27
|
+
"Update kanbot's agents, skills and workflows in this repo, keeping your edits",
|
|
28
|
+
},
|
|
29
|
+
args: {
|
|
30
|
+
"dry-run": {
|
|
31
|
+
type: "boolean",
|
|
32
|
+
description: "Show what would change without writing",
|
|
33
|
+
default: false,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
async run({ args }) {
|
|
37
|
+
const root = process.cwd();
|
|
38
|
+
const from = (await readManifest(root))?.version ?? "no manifest";
|
|
39
|
+
const plan = await planUpgrade(root);
|
|
40
|
+
log.info(`kanbot ${from} → ${pkg.version}`);
|
|
41
|
+
for (const action of Object.keys(HEADINGS) as Change["action"][]) {
|
|
42
|
+
const paths = plan.changes
|
|
43
|
+
.filter((c) => c.action === action)
|
|
44
|
+
.map((c) => c.path);
|
|
45
|
+
if (paths.length)
|
|
46
|
+
console.log(
|
|
47
|
+
`\n${HEADINGS[action]}:\n${paths.map((p) => ` ${p}`).join("\n")}`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (!plan.changes.length) log.info("Templates are up to date.");
|
|
51
|
+
if (args["dry-run"]) return;
|
|
52
|
+
|
|
53
|
+
await applyUpgrade(root, plan);
|
|
54
|
+
const generated = await sync(root, await loadAgents(root));
|
|
55
|
+
if (await writeConfigSchema(root))
|
|
56
|
+
generated.push(".kanbot/config.schema.json");
|
|
57
|
+
for (const f of generated) log.info(`~ ${f} (generated)`);
|
|
58
|
+
|
|
59
|
+
const conflicts = plan.changes.some((c) => c.action === "conflict");
|
|
60
|
+
console.log(`
|
|
61
|
+
Next steps:
|
|
62
|
+
${
|
|
63
|
+
conflicts
|
|
64
|
+
? `1. Merge each *${NEW_SUFFIX} file into the file next to it, then delete it.
|
|
65
|
+
Ask your agent: "use kanbot-admin to merge the kanbot upgrade".
|
|
66
|
+
2. `
|
|
67
|
+
: "1. "
|
|
68
|
+
}Review the diff and commit. Changes are not committed for you.`);
|
|
69
|
+
},
|
|
70
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { ArgsDef, CommandDef, Resolvable } from "citty";
|
|
2
|
+
|
|
3
|
+
export const SHELLS = ["zsh", "bash", "fish"] as const;
|
|
4
|
+
export type Shell = (typeof SHELLS)[number];
|
|
5
|
+
|
|
6
|
+
type Flag = {
|
|
7
|
+
name: string;
|
|
8
|
+
alias: string[];
|
|
9
|
+
description: string;
|
|
10
|
+
takesValue: boolean;
|
|
11
|
+
options: string[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type Spec = {
|
|
15
|
+
name: string;
|
|
16
|
+
description: string;
|
|
17
|
+
flags: Flag[];
|
|
18
|
+
subs: Spec[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
async function resolve<T>(
|
|
22
|
+
v: Resolvable<T> | undefined,
|
|
23
|
+
): Promise<T | undefined> {
|
|
24
|
+
return typeof v === "function" ? (v as () => T | Promise<T>)() : v;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const HELP: Flag = {
|
|
28
|
+
name: "help",
|
|
29
|
+
alias: ["h"],
|
|
30
|
+
description: "Show help",
|
|
31
|
+
takesValue: false,
|
|
32
|
+
options: [],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const VERSION: Flag = {
|
|
36
|
+
name: "version",
|
|
37
|
+
alias: [],
|
|
38
|
+
description: "Show version",
|
|
39
|
+
takesValue: false,
|
|
40
|
+
options: [],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Walks the citty command tree, loading lazy subcommands. */
|
|
44
|
+
export async function buildSpec(
|
|
45
|
+
cmd: CommandDef<ArgsDef>,
|
|
46
|
+
name?: string,
|
|
47
|
+
): Promise<Spec> {
|
|
48
|
+
const meta = await resolve(cmd.meta);
|
|
49
|
+
const args = (await resolve(cmd.args)) ?? {};
|
|
50
|
+
const subs = (await resolve(cmd.subCommands)) ?? {};
|
|
51
|
+
const flags: Flag[] = Object.entries(args)
|
|
52
|
+
.filter(([, a]) => a.type !== "positional")
|
|
53
|
+
.map(([key, a]) => ({
|
|
54
|
+
name: key,
|
|
55
|
+
alias: "alias" in a && a.alias !== undefined ? [a.alias].flat() : [],
|
|
56
|
+
description: a.description ?? "",
|
|
57
|
+
takesValue: a.type !== "boolean",
|
|
58
|
+
options: a.type === "enum" ? (a.options ?? []) : [],
|
|
59
|
+
}));
|
|
60
|
+
flags.push(HELP);
|
|
61
|
+
if (meta?.version) flags.push(VERSION);
|
|
62
|
+
return {
|
|
63
|
+
name: name ?? meta?.name ?? "",
|
|
64
|
+
description: meta?.description ?? "",
|
|
65
|
+
flags,
|
|
66
|
+
subs: await Promise.all(
|
|
67
|
+
Object.entries(subs).map(async ([key, sub]) =>
|
|
68
|
+
buildSpec((await resolve(sub)) as CommandDef<ArgsDef>, key),
|
|
69
|
+
),
|
|
70
|
+
),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function render(shell: Shell, spec: Spec): string {
|
|
75
|
+
return { zsh, bash, fish }[shell](spec);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const dashed = (f: string) => (f.length === 1 ? `-${f}` : `--${f}`);
|
|
79
|
+
const sq = (s: string) => `'${s.replaceAll("'", `'\\''`)}'`;
|
|
80
|
+
|
|
81
|
+
function walk(spec: Spec, path: string[] = []): [string[], Spec][] {
|
|
82
|
+
return [
|
|
83
|
+
[path, spec],
|
|
84
|
+
...spec.subs.flatMap((s) => walk(s, [...path, s.name])),
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function zsh(root: Spec): string {
|
|
89
|
+
const esc = (s: string) => s.replace(/([[\]:\\])/g, "\\$1");
|
|
90
|
+
const fn = (path: string[]) => [`_${root.name}`, ...path].join("_");
|
|
91
|
+
const funcs = walk(root).map(([path, spec]) => {
|
|
92
|
+
const flags = spec.flags.map((f) => {
|
|
93
|
+
const names = [f.name, ...f.alias].map(dashed);
|
|
94
|
+
const excl = names.length > 1 ? `(${names.join(" ")})` : "";
|
|
95
|
+
const value = !f.takesValue
|
|
96
|
+
? ""
|
|
97
|
+
: f.options.length
|
|
98
|
+
? `:${f.name}:(${f.options.join(" ")})`
|
|
99
|
+
: `:${f.name}: `;
|
|
100
|
+
const spec = `[${esc(f.description)}]${value}`;
|
|
101
|
+
return names.length > 1
|
|
102
|
+
? `${sq(excl)}{${names.join(",")}}${sq(spec)}`
|
|
103
|
+
: sq(names[0] + spec);
|
|
104
|
+
});
|
|
105
|
+
if (!spec.subs.length)
|
|
106
|
+
return `${fn(path)}() {\n\t_arguments -s \\\n\t\t${flags.join(" \\\n\t\t")}\n}`;
|
|
107
|
+
const cmds = spec.subs.map((s) => sq(`${esc(s.name)}:${s.description}`));
|
|
108
|
+
const cases = spec.subs.map(
|
|
109
|
+
(s) => `\t\t${s.name}) ${fn([...path, s.name])} ;;`,
|
|
110
|
+
);
|
|
111
|
+
return `${fn(path)}() {
|
|
112
|
+
local curcontext="$curcontext" state line
|
|
113
|
+
_arguments -C \\
|
|
114
|
+
${flags.join(" \\\n\t\t")} \\
|
|
115
|
+
'1: :->cmd' \\
|
|
116
|
+
'*:: :->args'
|
|
117
|
+
case $state in
|
|
118
|
+
cmd)
|
|
119
|
+
local -a cmds=(${cmds.join(" ")})
|
|
120
|
+
_describe command cmds ;;
|
|
121
|
+
args)
|
|
122
|
+
case $line[1] in
|
|
123
|
+
${cases.join("\n")}
|
|
124
|
+
esac ;;
|
|
125
|
+
esac
|
|
126
|
+
}`;
|
|
127
|
+
});
|
|
128
|
+
return `#compdef ${root.name}
|
|
129
|
+
|
|
130
|
+
${funcs.join("\n\n")}
|
|
131
|
+
|
|
132
|
+
if [ "$funcstack[1]" = "_${root.name}" ]; then
|
|
133
|
+
_${root.name} "$@"
|
|
134
|
+
else
|
|
135
|
+
compdef _${root.name} ${root.name}
|
|
136
|
+
fi
|
|
137
|
+
`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function bash(root: Spec): string {
|
|
141
|
+
const nodes = walk(root);
|
|
142
|
+
const specCases = nodes.map(([path, spec]) => {
|
|
143
|
+
const subs = spec.subs.map((s) => s.name).join(" ");
|
|
144
|
+
const flags = spec.flags
|
|
145
|
+
.flatMap((f) => [f.name, ...f.alias].map(dashed))
|
|
146
|
+
.join(" ");
|
|
147
|
+
return `\t${JSON.stringify(path.join(" "))}) subs=${sq(subs)}; flags=${sq(flags)} ;;`;
|
|
148
|
+
});
|
|
149
|
+
const valueCases = nodes.flatMap(([path, spec]) =>
|
|
150
|
+
spec.flags
|
|
151
|
+
.filter((f) => f.takesValue)
|
|
152
|
+
.map((f) => {
|
|
153
|
+
const pattern = [f.name, ...f.alias]
|
|
154
|
+
.map((n) => JSON.stringify(`${path.join(" ")}:${dashed(n)}`))
|
|
155
|
+
.join("|");
|
|
156
|
+
const action = f.options.length
|
|
157
|
+
? `COMPREPLY=($(compgen -W ${sq(f.options.join(" "))} -- "$cur")); return`
|
|
158
|
+
: "return";
|
|
159
|
+
return `\t${pattern}) ${action} ;;`;
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
const n = root.name;
|
|
163
|
+
return `_${n}_spec() {
|
|
164
|
+
case "$1" in
|
|
165
|
+
${specCases.join("\n")}
|
|
166
|
+
esac
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
_${n}() {
|
|
170
|
+
local cur=\${COMP_WORDS[COMP_CWORD]} prev=\${COMP_WORDS[COMP_CWORD-1]}
|
|
171
|
+
local cmdpath="" subs flags i
|
|
172
|
+
_${n}_spec ""
|
|
173
|
+
for ((i = 1; i < COMP_CWORD; i++)); do
|
|
174
|
+
if [[ " $subs " == *" \${COMP_WORDS[i]} "* ]]; then
|
|
175
|
+
cmdpath="\${cmdpath:+$cmdpath }\${COMP_WORDS[i]}"
|
|
176
|
+
_${n}_spec "$cmdpath"
|
|
177
|
+
fi
|
|
178
|
+
done
|
|
179
|
+
case "$cmdpath:$prev" in
|
|
180
|
+
${valueCases.join("\n")}
|
|
181
|
+
esac
|
|
182
|
+
COMPREPLY=($(compgen -W "$subs $flags" -- "$cur"))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
complete -F _${n} ${n}
|
|
186
|
+
`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function fish(root: Spec): string {
|
|
190
|
+
const n = root.name;
|
|
191
|
+
const lines = [`complete -c ${n} -f`];
|
|
192
|
+
for (const [path, spec] of walk(root)) {
|
|
193
|
+
const seen = path.map((p) => `__fish_seen_subcommand_from ${p}`);
|
|
194
|
+
if (spec.subs.length) {
|
|
195
|
+
const names = spec.subs.map((s) => s.name).join(" ");
|
|
196
|
+
const cond = path.length
|
|
197
|
+
? [...seen, `not __fish_seen_subcommand_from ${names}`].join("; and ")
|
|
198
|
+
: "__fish_use_subcommand";
|
|
199
|
+
for (const s of spec.subs)
|
|
200
|
+
lines.push(
|
|
201
|
+
`complete -c ${n} -n ${sq(cond)} -a ${s.name} -d ${sq(s.description)}`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
for (const f of spec.flags) {
|
|
205
|
+
const parts = [`complete -c ${n}`];
|
|
206
|
+
if (seen.length) parts.push(`-n ${sq(seen.join("; and "))}`);
|
|
207
|
+
parts.push(`-l ${f.name}`);
|
|
208
|
+
for (const a of f.alias)
|
|
209
|
+
parts.push(a.length === 1 ? `-s ${a}` : `-l ${a}`);
|
|
210
|
+
if (f.takesValue) parts.push("-x");
|
|
211
|
+
if (f.options.length) parts.push(`-a ${sq(f.options.join(" "))}`);
|
|
212
|
+
if (f.description) parts.push(`-d ${sq(f.description)}`);
|
|
213
|
+
lines.push(parts.join(" "));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return `${lines.join("\n")}\n`;
|
|
217
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parse, parseDocument } from "yaml";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
export const COLUMN_KEYS = [
|
|
7
|
+
"ideas",
|
|
8
|
+
"planning",
|
|
9
|
+
"ready",
|
|
10
|
+
"inProgress",
|
|
11
|
+
"review",
|
|
12
|
+
"done",
|
|
13
|
+
] as const;
|
|
14
|
+
export type ColumnKey = (typeof COLUMN_KEYS)[number];
|
|
15
|
+
|
|
16
|
+
export const JOB_KINDS = ["fix", "review", "plan", "implement"] as const;
|
|
17
|
+
export type JobKind = (typeof JOB_KINDS)[number];
|
|
18
|
+
|
|
19
|
+
export const ConfigSchema = z.object({
|
|
20
|
+
provider: z
|
|
21
|
+
.enum(["claude", "codex"])
|
|
22
|
+
.default("claude")
|
|
23
|
+
.describe("Agent CLI that runs workers."),
|
|
24
|
+
repo: z
|
|
25
|
+
.string()
|
|
26
|
+
.regex(/^[^/]+\/[^/]+$/, "expected owner/name")
|
|
27
|
+
.describe("GitHub repo as owner/name."),
|
|
28
|
+
baseBranch: z.string().default("main").describe("Branch agents work from."),
|
|
29
|
+
project: z
|
|
30
|
+
.object({ owner: z.string(), number: z.number().int() })
|
|
31
|
+
.nullable()
|
|
32
|
+
.default(null)
|
|
33
|
+
.describe("GitHub Project board. Set by `kanbot board create`."),
|
|
34
|
+
columns: z
|
|
35
|
+
.object({
|
|
36
|
+
ideas: z.string().default("Ideas"),
|
|
37
|
+
planning: z.string().default("Planning"),
|
|
38
|
+
ready: z.string().default("Ready"),
|
|
39
|
+
inProgress: z.string().default("In Progress"),
|
|
40
|
+
review: z.string().default("Review"),
|
|
41
|
+
done: z.string().default("Done"),
|
|
42
|
+
})
|
|
43
|
+
.prefault({})
|
|
44
|
+
.describe("Names of the board's Status options."),
|
|
45
|
+
concurrency: z
|
|
46
|
+
.number()
|
|
47
|
+
.int()
|
|
48
|
+
.min(1)
|
|
49
|
+
.default(3)
|
|
50
|
+
.describe(
|
|
51
|
+
"Max concurrent agent workers. Each exploratory variant is one worker.",
|
|
52
|
+
),
|
|
53
|
+
pollSeconds: z
|
|
54
|
+
.number()
|
|
55
|
+
.int()
|
|
56
|
+
.min(10)
|
|
57
|
+
.default(60)
|
|
58
|
+
.describe("Seconds between board polls."),
|
|
59
|
+
priority: z
|
|
60
|
+
.array(z.enum(JOB_KINDS))
|
|
61
|
+
.default([...JOB_KINDS])
|
|
62
|
+
.describe("Order in which waiting jobs get free slots."),
|
|
63
|
+
maxAttempts: z
|
|
64
|
+
.number()
|
|
65
|
+
.int()
|
|
66
|
+
.min(1)
|
|
67
|
+
.default(2)
|
|
68
|
+
.describe("Attempts per job before the issue is labeled needs-human."),
|
|
69
|
+
timeoutMinutes: z
|
|
70
|
+
.object({
|
|
71
|
+
plan: z.number().default(20),
|
|
72
|
+
implement: z.number().default(90),
|
|
73
|
+
review: z.number().default(30),
|
|
74
|
+
fix: z.number().default(45),
|
|
75
|
+
})
|
|
76
|
+
.prefault({})
|
|
77
|
+
.describe("Per-job timeout in minutes."),
|
|
78
|
+
implementers: z
|
|
79
|
+
.record(z.string(), z.string())
|
|
80
|
+
.default({
|
|
81
|
+
frontend: "implementer-frontend",
|
|
82
|
+
backend: "implementer-backend",
|
|
83
|
+
docs: "implementer-docs",
|
|
84
|
+
infra: "implementer-technical",
|
|
85
|
+
default: "implementer-backend",
|
|
86
|
+
})
|
|
87
|
+
.describe(
|
|
88
|
+
"Maps a type:<key> label to an agent. `default` is the fallback.",
|
|
89
|
+
),
|
|
90
|
+
review: z
|
|
91
|
+
.object({
|
|
92
|
+
copilot: z
|
|
93
|
+
.boolean()
|
|
94
|
+
.default(false)
|
|
95
|
+
.describe(
|
|
96
|
+
"Wait for GitHub Copilot's code review and have the fixer address its comments. Needs a paid Copilot plan; each review uses credits.",
|
|
97
|
+
),
|
|
98
|
+
copilotOnPush: z
|
|
99
|
+
.boolean()
|
|
100
|
+
.default(false)
|
|
101
|
+
.describe(
|
|
102
|
+
"Have Copilot review again after every push. One review per push.",
|
|
103
|
+
),
|
|
104
|
+
copilotWaitMinutes: z
|
|
105
|
+
.number()
|
|
106
|
+
.int()
|
|
107
|
+
.min(1)
|
|
108
|
+
.default(15)
|
|
109
|
+
.describe(
|
|
110
|
+
"Minutes to wait for Copilot's review before going on without it.",
|
|
111
|
+
),
|
|
112
|
+
})
|
|
113
|
+
.prefault({}),
|
|
114
|
+
codex: z
|
|
115
|
+
.object({
|
|
116
|
+
networkAccess: z
|
|
117
|
+
.boolean()
|
|
118
|
+
.default(false)
|
|
119
|
+
.describe("Let codex workers reach the network (package installs)."),
|
|
120
|
+
})
|
|
121
|
+
.prefault({}),
|
|
122
|
+
notify: z
|
|
123
|
+
.object({
|
|
124
|
+
desktop: z
|
|
125
|
+
.boolean()
|
|
126
|
+
.default(true)
|
|
127
|
+
.describe("Show desktop notifications."),
|
|
128
|
+
webhook: z
|
|
129
|
+
.string()
|
|
130
|
+
.url()
|
|
131
|
+
.nullable()
|
|
132
|
+
.default(null)
|
|
133
|
+
.describe(
|
|
134
|
+
"POSTs {title, message, url} as JSON, e.g. an ntfy.sh topic URL.",
|
|
135
|
+
),
|
|
136
|
+
})
|
|
137
|
+
.prefault({}),
|
|
138
|
+
});
|
|
139
|
+
export type Config = z.infer<typeof ConfigSchema>;
|
|
140
|
+
|
|
141
|
+
export const LABELS = {
|
|
142
|
+
technical: "technical",
|
|
143
|
+
working: "kanbot:working",
|
|
144
|
+
agentReviewed: "agent-reviewed",
|
|
145
|
+
needsHuman: "needs-human",
|
|
146
|
+
review: "kanbot-review",
|
|
147
|
+
explorePrefix: "explore:",
|
|
148
|
+
typePrefix: "type:",
|
|
149
|
+
variantPrefix: "variant:",
|
|
150
|
+
} as const;
|
|
151
|
+
|
|
152
|
+
export function configPath(root: string) {
|
|
153
|
+
return join(root, ".kanbot", "config.yml");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function configSchemaPath(root: string) {
|
|
157
|
+
return join(root, ".kanbot", "config.schema.json");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Points editors (yaml-language-server) at the schema next to the config. */
|
|
161
|
+
const SCHEMA_HEADER = "# yaml-language-server: $schema=./config.schema.json\n";
|
|
162
|
+
|
|
163
|
+
/** JSON Schema for config.yml. Fields with defaults are optional. */
|
|
164
|
+
export function configJsonSchema() {
|
|
165
|
+
return {
|
|
166
|
+
...z.toJSONSchema(ConfigSchema, { io: "input", target: "draft-7" }),
|
|
167
|
+
title: "kanbot config",
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Ignores formatting, so a formatter touching the file doesn't make it stale. */
|
|
172
|
+
export function sameJson(a: string, b: string) {
|
|
173
|
+
try {
|
|
174
|
+
return JSON.stringify(JSON.parse(a)) === JSON.stringify(JSON.parse(b));
|
|
175
|
+
} catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function renderConfigSchema(): string {
|
|
181
|
+
return `${JSON.stringify(configJsonSchema(), null, "\t")}\n`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Writes .kanbot/config.schema.json. Returns true if it was missing or stale. */
|
|
185
|
+
export async function writeConfigSchema(
|
|
186
|
+
root: string,
|
|
187
|
+
opts: { check?: boolean } = {},
|
|
188
|
+
): Promise<boolean> {
|
|
189
|
+
const path = configSchemaPath(root);
|
|
190
|
+
const next = renderConfigSchema();
|
|
191
|
+
if (existsSync(path) && sameJson(await Bun.file(path).text(), next))
|
|
192
|
+
return false;
|
|
193
|
+
if (!opts.check) await Bun.write(path, next);
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function loadConfig(root: string): Promise<Config> {
|
|
198
|
+
const path = configPath(root);
|
|
199
|
+
if (!existsSync(path)) {
|
|
200
|
+
throw new Error(`No ${path}. Run \`kanbot init\` first.`);
|
|
201
|
+
}
|
|
202
|
+
return ConfigSchema.parse(parse(await Bun.file(path).text()) ?? {});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Sets top-level keys, keeping the user's comments and formatting. */
|
|
206
|
+
export async function saveConfig(root: string, patch: Partial<Config>) {
|
|
207
|
+
const path = configPath(root);
|
|
208
|
+
const text = existsSync(path) ? await Bun.file(path).text() : SCHEMA_HEADER;
|
|
209
|
+
const doc = parseDocument(text);
|
|
210
|
+
for (const [key, value] of Object.entries(patch)) doc.set(key, value);
|
|
211
|
+
await Bun.write(path, String(doc));
|
|
212
|
+
await writeConfigSchema(root);
|
|
213
|
+
}
|