kanbot 0.2.0 → 0.4.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/README.md +15 -9
- package/package.json +1 -1
- package/schema/config.schema.json +92 -8
- package/src/agents/load.ts +11 -0
- package/src/agents/sync.ts +8 -8
- package/src/capabilities.ts +157 -0
- package/src/commands/app.ts +169 -0
- package/src/commands/board.ts +14 -7
- package/src/commands/doctor.ts +115 -6
- package/src/commands/init.ts +15 -3
- package/src/commands/skills.ts +1 -1
- package/src/commands/sync.ts +1 -1
- package/src/commands/upgrade.ts +20 -1
- package/src/config.ts +72 -8
- package/src/daemon/derive.ts +79 -1
- package/src/daemon/loop.ts +10 -1
- package/src/daemon/snapshot.ts +1 -0
- package/src/daemon/types.ts +17 -0
- package/src/github/api.ts +47 -2
- package/src/github/app.ts +153 -0
- package/src/github/gh.ts +29 -3
- package/src/jobs/prompts.ts +30 -6
- package/src/jobs/run.ts +135 -56
- package/src/known-hashes.json +47 -57
- package/src/main.ts +1 -0
- package/src/providers/claude.ts +40 -30
- package/src/providers/codex.ts +60 -30
- package/src/providers/mcp.ts +71 -0
- package/src/schemas/results.ts +2 -5
- package/src/templates.ts +1 -0
- package/templates/.agents/skills/approach-designer/SKILL.md +1 -1
- package/templates/.agents/skills/kanbot-admin/SKILL.md +39 -7
- package/templates/.agents/skills/kanbot-guide/SKILL.md +110 -0
- package/templates/.agents/skills/kanbot-idea/SKILL.md +1 -1
- package/templates/.agents/skills/plan-template/SKILL.md +14 -6
- package/templates/.agents/skills/pr-description/SKILL.md +7 -3
- package/templates/.agents/skills/project-conventions/SKILL.md +23 -35
- package/templates/.agents/skills/review-findings/SKILL.md +47 -0
- package/templates/.agents/skills/spec-review/SKILL.md +10 -27
- package/templates/.agents/skills/user-stories/SKILL.md +1 -1
- package/templates/.github/pull_request_template.md +6 -0
- package/templates/.kanbot/agents/acceptance-reviewer.md +43 -0
- package/templates/.kanbot/agents/fixer.md +7 -5
- package/templates/.kanbot/agents/implementer.md +49 -0
- package/templates/.kanbot/agents/plan-critic.md +3 -3
- package/templates/.kanbot/agents/planner.md +10 -9
- package/templates/.kanbot/agents/reviewer.md +42 -0
- package/templates/AGENTS.md +3 -4
- package/templates/.agents/skills/accessibility/SKILL.md +0 -76
- package/templates/.agents/skills/code-review/SKILL.md +0 -83
- package/templates/.agents/skills/frontend-design/LICENSE.txt +0 -177
- package/templates/.agents/skills/frontend-design/SKILL.md +0 -76
- package/templates/.agents/skills/tdd/SKILL.md +0 -51
- package/templates/.agents/skills/verify-ui/SKILL.md +0 -104
- package/templates/.kanbot/agents/implementer-backend.md +0 -57
- package/templates/.kanbot/agents/implementer-docs.md +0 -55
- package/templates/.kanbot/agents/implementer-frontend.md +0 -54
- package/templates/.kanbot/agents/implementer-technical.md +0 -53
- package/templates/.kanbot/agents/reviewer-code.md +0 -46
- package/templates/.kanbot/agents/reviewer-spec.md +0 -49
- package/templates/.kanbot/agents/test-writer.md +0 -35
- package/templates/.kanbot/agents/ui-verifier.md +0 -35
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { createSign } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { Config } from "../config.ts";
|
|
6
|
+
import { UserError } from "../errors.ts";
|
|
7
|
+
import { Gh, type TokenSource } from "./gh.ts";
|
|
8
|
+
|
|
9
|
+
const API = "https://api.github.com";
|
|
10
|
+
/** Renew installation tokens this long before GitHub expires them (1 h). */
|
|
11
|
+
const RENEW_MS = 5 * 60_000;
|
|
12
|
+
|
|
13
|
+
export type AppConfig = NonNullable<Config["identity"]["app"]>;
|
|
14
|
+
|
|
15
|
+
/** Where `kanbot app create` stores the app's private key. Never in the repo. */
|
|
16
|
+
export function appKeyPath(appId: number) {
|
|
17
|
+
return join(homedir(), ".kanbot", "apps", `${appId}.pem`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const b64url = (s: string) => Buffer.from(s).toString("base64url");
|
|
21
|
+
|
|
22
|
+
/** A short-lived JWT that authenticates as the app itself. */
|
|
23
|
+
export function appJwt(appId: number, key: string, now = Date.now()) {
|
|
24
|
+
const iat = Math.floor(now / 1000) - 60;
|
|
25
|
+
const head = b64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
|
26
|
+
const body = b64url(
|
|
27
|
+
JSON.stringify({ iat, exp: iat + 9 * 60, iss: String(appId) }),
|
|
28
|
+
);
|
|
29
|
+
const sig = createSign("RSA-SHA256")
|
|
30
|
+
.update(`${head}.${body}`)
|
|
31
|
+
.sign(key, "base64url");
|
|
32
|
+
return `${head}.${body}.${sig}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function api<T>(
|
|
36
|
+
path: string,
|
|
37
|
+
jwt: string,
|
|
38
|
+
method = "GET",
|
|
39
|
+
body?: unknown,
|
|
40
|
+
): Promise<T> {
|
|
41
|
+
const res = await fetch(`${API}${path}`, {
|
|
42
|
+
method,
|
|
43
|
+
headers: {
|
|
44
|
+
accept: "application/vnd.github+json",
|
|
45
|
+
authorization: `Bearer ${jwt}`,
|
|
46
|
+
"x-github-api-version": "2022-11-28",
|
|
47
|
+
},
|
|
48
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
49
|
+
});
|
|
50
|
+
if (!res.ok)
|
|
51
|
+
throw new Error(
|
|
52
|
+
`GitHub ${method} ${path} failed (${res.status}): ${await res.text()}`,
|
|
53
|
+
);
|
|
54
|
+
return (await res.json()) as T;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Installation tokens for one repo, cached until shortly before they expire.
|
|
59
|
+
* Scoped to that repo only.
|
|
60
|
+
*/
|
|
61
|
+
export function appTokens(app: AppConfig, repo: string): TokenSource {
|
|
62
|
+
const keyFile = appKeyPath(app.id);
|
|
63
|
+
let cached: { token: string; expires: number } | null = null;
|
|
64
|
+
let installation: number | null = null;
|
|
65
|
+
return async () => {
|
|
66
|
+
if (cached && Date.now() < cached.expires - RENEW_MS) return cached.token;
|
|
67
|
+
if (!existsSync(keyFile))
|
|
68
|
+
throw new UserError(`No private key for the kanbot app ${app.slug}.`, {
|
|
69
|
+
why: `Expected it at ${keyFile}.`,
|
|
70
|
+
fix: "Run `kanbot app create`, or copy the key there from the machine that created the app.",
|
|
71
|
+
docs: "identity#app-mode",
|
|
72
|
+
});
|
|
73
|
+
const jwt = appJwt(app.id, await Bun.file(keyFile).text());
|
|
74
|
+
installation ??= await api<{ id: number }>(
|
|
75
|
+
`/repos/${repo}/installation`,
|
|
76
|
+
jwt,
|
|
77
|
+
)
|
|
78
|
+
.then((r) => r.id)
|
|
79
|
+
.catch((e) => {
|
|
80
|
+
throw new UserError(`The kanbot app ${app.slug} can't reach ${repo}.`, {
|
|
81
|
+
why: String(e),
|
|
82
|
+
fix: `Install it on the repo: https://github.com/apps/${app.slug}/installations/new`,
|
|
83
|
+
docs: "identity#app-mode",
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
const res = await api<{ token: string; expires_at: string }>(
|
|
87
|
+
`/app/installations/${installation}/access_tokens`,
|
|
88
|
+
jwt,
|
|
89
|
+
"POST",
|
|
90
|
+
{ repositories: [repo.split("/")[1]] },
|
|
91
|
+
);
|
|
92
|
+
cached = { token: res.token, expires: Date.parse(res.expires_at) };
|
|
93
|
+
return res.token;
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The client kanbot talks as: the app in app mode, otherwise the user's own.
|
|
99
|
+
* Commits, pushes, board moves and merges always stay on the user's client.
|
|
100
|
+
*/
|
|
101
|
+
export function botClient(config: Config, user: Gh): Gh {
|
|
102
|
+
const { mode, app } = config.identity;
|
|
103
|
+
if (mode !== "app") return user;
|
|
104
|
+
if (!app)
|
|
105
|
+
throw new UserError("identity.mode is app, but no app is set.", {
|
|
106
|
+
fix: "kanbot app create",
|
|
107
|
+
docs: "identity#app-mode",
|
|
108
|
+
});
|
|
109
|
+
return new Gh(config.repo, user.dryRun, appTokens(app, config.repo));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---- manifest flow --------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
/** Permissions the app needs to talk on issues and PRs. Nothing else. */
|
|
115
|
+
export const APP_PERMISSIONS = {
|
|
116
|
+
issues: "write",
|
|
117
|
+
pull_requests: "write",
|
|
118
|
+
contents: "read",
|
|
119
|
+
metadata: "read",
|
|
120
|
+
} as const;
|
|
121
|
+
|
|
122
|
+
export function appManifest(name: string, redirectUrl: string) {
|
|
123
|
+
return {
|
|
124
|
+
name,
|
|
125
|
+
url: "https://github.com/mikaelbr/kanbot",
|
|
126
|
+
redirect_url: redirectUrl,
|
|
127
|
+
description:
|
|
128
|
+
"kanbot's voice on GitHub: plans, reviews and replies. Commits stay yours.",
|
|
129
|
+
public: false,
|
|
130
|
+
hook_attributes: {
|
|
131
|
+
url: "https://github.com/mikaelbr/kanbot",
|
|
132
|
+
active: false,
|
|
133
|
+
},
|
|
134
|
+
default_permissions: APP_PERMISSIONS,
|
|
135
|
+
default_events: [],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export type CreatedApp = { id: number; slug: string; pem: string };
|
|
140
|
+
|
|
141
|
+
/** Trade the manifest flow's one-time code for the new app's credentials. */
|
|
142
|
+
export function convertManifest(code: string): Promise<CreatedApp> {
|
|
143
|
+
return fetch(`${API}/app-manifests/${code}/conversions`, {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers: { accept: "application/vnd.github+json" },
|
|
146
|
+
}).then(async (res) => {
|
|
147
|
+
if (!res.ok)
|
|
148
|
+
throw new Error(
|
|
149
|
+
`Creating the app failed (${res.status}): ${await res.text()}`,
|
|
150
|
+
);
|
|
151
|
+
return (await res.json()) as CreatedApp;
|
|
152
|
+
});
|
|
153
|
+
}
|
package/src/github/gh.ts
CHANGED
|
@@ -1,15 +1,29 @@
|
|
|
1
1
|
import { log } from "../log.ts";
|
|
2
2
|
|
|
3
|
+
/** Returns a GitHub token to use instead of `gh`'s own login. */
|
|
4
|
+
export type TokenSource = () => Promise<string>;
|
|
5
|
+
|
|
3
6
|
/**
|
|
4
7
|
* Thin wrapper over the `gh` CLI so kanbot reuses the user's existing auth.
|
|
8
|
+
* With a `token`, calls run as that identity instead (the kanbot app).
|
|
5
9
|
* Reads always run; writes are skipped (and logged) in dry-run mode.
|
|
6
10
|
*/
|
|
7
11
|
export class Gh {
|
|
8
12
|
constructor(
|
|
9
13
|
readonly repo: string,
|
|
10
14
|
readonly dryRun = false,
|
|
15
|
+
private readonly token?: TokenSource,
|
|
11
16
|
) {}
|
|
12
17
|
|
|
18
|
+
/** True when calls run as the kanbot app, not the user. */
|
|
19
|
+
get isBot() {
|
|
20
|
+
return this.token !== undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private async env() {
|
|
24
|
+
return this.token ? { GH_TOKEN: await this.token() } : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
13
27
|
get owner() {
|
|
14
28
|
return this.repo.split("/")[0] as string;
|
|
15
29
|
}
|
|
@@ -35,7 +49,11 @@ export class Gh {
|
|
|
35
49
|
}
|
|
36
50
|
|
|
37
51
|
private async queryOnce<T>(input: string): Promise<T> {
|
|
38
|
-
const out = await run(
|
|
52
|
+
const out = await run(
|
|
53
|
+
["api", "graphql", "--input", "-"],
|
|
54
|
+
input,
|
|
55
|
+
await this.env(),
|
|
56
|
+
);
|
|
39
57
|
const json = JSON.parse(out) as { data: T; errors?: { message: string }[] };
|
|
40
58
|
if (json.errors?.length) {
|
|
41
59
|
throw new Error(
|
|
@@ -58,7 +76,9 @@ export class Gh {
|
|
|
58
76
|
}
|
|
59
77
|
|
|
60
78
|
async rest<T = unknown>(path: string): Promise<T> {
|
|
61
|
-
return JSON.parse(
|
|
79
|
+
return JSON.parse(
|
|
80
|
+
await run(["api", path], undefined, await this.env()),
|
|
81
|
+
) as T;
|
|
62
82
|
}
|
|
63
83
|
|
|
64
84
|
async restWrite<T = unknown>(
|
|
@@ -80,6 +100,7 @@ export class Gh {
|
|
|
80
100
|
...(body === undefined ? [] : ["--input", "-"]),
|
|
81
101
|
],
|
|
82
102
|
body === undefined ? undefined : JSON.stringify(body),
|
|
103
|
+
await this.env(),
|
|
83
104
|
);
|
|
84
105
|
return (out.trim() ? JSON.parse(out) : null) as T;
|
|
85
106
|
}
|
|
@@ -90,9 +111,14 @@ export class Gh {
|
|
|
90
111
|
}
|
|
91
112
|
}
|
|
92
113
|
|
|
93
|
-
export async function run(
|
|
114
|
+
export async function run(
|
|
115
|
+
args: string[],
|
|
116
|
+
input?: string,
|
|
117
|
+
env?: Record<string, string>,
|
|
118
|
+
): Promise<string> {
|
|
94
119
|
const proc = Bun.spawn(["gh", ...args], {
|
|
95
120
|
stdin: input === undefined ? "ignore" : new Blob([input]),
|
|
121
|
+
env: env ? { ...process.env, ...env } : undefined,
|
|
96
122
|
stdout: "pipe",
|
|
97
123
|
stderr: "pipe",
|
|
98
124
|
});
|
package/src/jobs/prompts.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Capabilities, capabilitiesSection } from "../capabilities.ts";
|
|
1
2
|
import {
|
|
2
3
|
type FixJob,
|
|
3
4
|
type ImplementJob,
|
|
@@ -6,6 +7,7 @@ import {
|
|
|
6
7
|
reviewPasses,
|
|
7
8
|
} from "../daemon/derive.ts";
|
|
8
9
|
import type { Comment, Issue, PR } from "../daemon/types.ts";
|
|
10
|
+
import { SIGNATURE } from "../github/api.ts";
|
|
9
11
|
import { extractPlan, isKanbot, stripPlan } from "../github/markers.ts";
|
|
10
12
|
import type { Finding } from "../schemas/results.ts";
|
|
11
13
|
|
|
@@ -20,6 +22,8 @@ const RULES = `## Ground rules
|
|
|
20
22
|
- Your final answer must be the structured JSON result. Put everything the human should see in it.
|
|
21
23
|
- Write short and plain: only what the reader needs to act or understand. Don't restate the input or narrate your process.`;
|
|
22
24
|
|
|
25
|
+
const VALIDATION = "List what you used under Validation in the PR body.";
|
|
26
|
+
|
|
23
27
|
function quote(text: string) {
|
|
24
28
|
return text.trim() ? text.trim() : "_(empty)_";
|
|
25
29
|
}
|
|
@@ -36,7 +40,10 @@ function conversation(comments: Comment[], limit = 30) {
|
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
function stripMarkers(body: string) {
|
|
39
|
-
return body
|
|
43
|
+
return body
|
|
44
|
+
.replace(SIGNATURE, "")
|
|
45
|
+
.replace(/<!--\s*kanbot[^>]*-->/g, "")
|
|
46
|
+
.trim();
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
/**
|
|
@@ -105,7 +112,11 @@ export function planPrompt(job: PlanJob, explore: number | null) {
|
|
|
105
112
|
return parts.join("\n\n");
|
|
106
113
|
}
|
|
107
114
|
|
|
108
|
-
export function implementPrompt(
|
|
115
|
+
export function implementPrompt(
|
|
116
|
+
job: ImplementJob,
|
|
117
|
+
base: string,
|
|
118
|
+
capabilities: Capabilities = {},
|
|
119
|
+
) {
|
|
109
120
|
const { issue } = job.item;
|
|
110
121
|
const parts = [
|
|
111
122
|
issueHeader(issue),
|
|
@@ -122,13 +133,18 @@ export function implementPrompt(job: ImplementJob, base: string) {
|
|
|
122
133
|
`## Recent human comments on the issue\n${conversation(humanNotes)}`,
|
|
123
134
|
);
|
|
124
135
|
parts.push(
|
|
136
|
+
capabilitiesSection(capabilities, VALIDATION),
|
|
125
137
|
`## Workspace\n- Branch \`${job.branch}\`, created from \`origin/${base}\`.\n- Commit your work with conventional commit messages. Uncommitted changes are committed for you, but prefer small commits.\n- Run the project's tests and linters before finishing and report them in \`testsRun\`.`,
|
|
126
138
|
RULES,
|
|
127
139
|
);
|
|
128
|
-
return parts.join("\n\n");
|
|
140
|
+
return parts.filter(Boolean).join("\n\n");
|
|
129
141
|
}
|
|
130
142
|
|
|
131
|
-
export function reviewPrompt(
|
|
143
|
+
export function reviewPrompt(
|
|
144
|
+
job: ReviewJob,
|
|
145
|
+
base: string,
|
|
146
|
+
capabilities: Capabilities = {},
|
|
147
|
+
) {
|
|
132
148
|
const { issue } = job.item;
|
|
133
149
|
const { pr } = job;
|
|
134
150
|
return [
|
|
@@ -136,7 +152,8 @@ export function reviewPrompt(job: ReviewJob, base: string) {
|
|
|
136
152
|
`## Plan / acceptance criteria\n${extractPlan(issue.body) ?? stripPlan(issue.body)}`,
|
|
137
153
|
`## Pull request #${pr.number}: ${pr.title}\nBranch \`${pr.headRefName}\` (checked out). Diff with: \`git diff origin/${base}...HEAD\`. CI status: ${pr.checks ?? "none"}.\n\n### PR description\n${quote(stripMarkers(pr.body))}`,
|
|
138
154
|
humanDecisions(pr),
|
|
139
|
-
|
|
155
|
+
capabilitiesSection(capabilities),
|
|
156
|
+
`## Review pass ${job.pass} of ${reviewPasses(issue).length}: ${job.pass === 1 ? "technical" : "acceptance"}\nYou only review. Do not edit files or commit.${job.pass === 2 && Object.values(capabilities).some((c) => c.required) ? " A required capability that applies but is missing from the PR body's Validation section is a blocker." : ""}`,
|
|
140
157
|
RULES,
|
|
141
158
|
]
|
|
142
159
|
.filter(Boolean)
|
|
@@ -144,7 +161,12 @@ export function reviewPrompt(job: ReviewJob, base: string) {
|
|
|
144
161
|
}
|
|
145
162
|
|
|
146
163
|
/** `details`: CI logs for a CI fix, the conflicted files for a conflict fix. */
|
|
147
|
-
export function fixPrompt(
|
|
164
|
+
export function fixPrompt(
|
|
165
|
+
job: FixJob,
|
|
166
|
+
base: string,
|
|
167
|
+
details: string | null,
|
|
168
|
+
capabilities: Capabilities = {},
|
|
169
|
+
) {
|
|
148
170
|
const { issue } = job.item;
|
|
149
171
|
const { pr } = job;
|
|
150
172
|
const parts = [
|
|
@@ -152,6 +174,8 @@ export function fixPrompt(job: FixJob, base: string, details: string | null) {
|
|
|
152
174
|
`## Plan / acceptance criteria\n${extractPlan(issue.body) ?? stripPlan(issue.body)}`,
|
|
153
175
|
`## Pull request #${pr.number}: ${pr.title}\nBranch \`${pr.headRefName}\` (checked out). Diff with: \`git diff origin/${base}...HEAD\`.\n\n### Current PR description\n${quote(stripMarkers(pr.body))}`,
|
|
154
176
|
];
|
|
177
|
+
const caps = capabilitiesSection(capabilities, VALIDATION);
|
|
178
|
+
if (caps) parts.push(caps);
|
|
155
179
|
const r = job.reason;
|
|
156
180
|
const decisions = r.type === "feedback" ? null : humanDecisions(pr);
|
|
157
181
|
if (decisions) parts.push(decisions);
|