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.
Files changed (62) hide show
  1. package/README.md +15 -9
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +92 -8
  4. package/src/agents/load.ts +11 -0
  5. package/src/agents/sync.ts +8 -8
  6. package/src/capabilities.ts +157 -0
  7. package/src/commands/app.ts +169 -0
  8. package/src/commands/board.ts +14 -7
  9. package/src/commands/doctor.ts +115 -6
  10. package/src/commands/init.ts +15 -3
  11. package/src/commands/skills.ts +1 -1
  12. package/src/commands/sync.ts +1 -1
  13. package/src/commands/upgrade.ts +20 -1
  14. package/src/config.ts +72 -8
  15. package/src/daemon/derive.ts +79 -1
  16. package/src/daemon/loop.ts +10 -1
  17. package/src/daemon/snapshot.ts +1 -0
  18. package/src/daemon/types.ts +17 -0
  19. package/src/github/api.ts +47 -2
  20. package/src/github/app.ts +153 -0
  21. package/src/github/gh.ts +29 -3
  22. package/src/jobs/prompts.ts +30 -6
  23. package/src/jobs/run.ts +135 -56
  24. package/src/known-hashes.json +47 -57
  25. package/src/main.ts +1 -0
  26. package/src/providers/claude.ts +40 -30
  27. package/src/providers/codex.ts +60 -30
  28. package/src/providers/mcp.ts +71 -0
  29. package/src/schemas/results.ts +2 -5
  30. package/src/templates.ts +1 -0
  31. package/templates/.agents/skills/approach-designer/SKILL.md +1 -1
  32. package/templates/.agents/skills/kanbot-admin/SKILL.md +39 -7
  33. package/templates/.agents/skills/kanbot-guide/SKILL.md +110 -0
  34. package/templates/.agents/skills/kanbot-idea/SKILL.md +1 -1
  35. package/templates/.agents/skills/plan-template/SKILL.md +14 -6
  36. package/templates/.agents/skills/pr-description/SKILL.md +7 -3
  37. package/templates/.agents/skills/project-conventions/SKILL.md +23 -35
  38. package/templates/.agents/skills/review-findings/SKILL.md +47 -0
  39. package/templates/.agents/skills/spec-review/SKILL.md +10 -27
  40. package/templates/.agents/skills/user-stories/SKILL.md +1 -1
  41. package/templates/.github/pull_request_template.md +6 -0
  42. package/templates/.kanbot/agents/acceptance-reviewer.md +43 -0
  43. package/templates/.kanbot/agents/fixer.md +7 -5
  44. package/templates/.kanbot/agents/implementer.md +49 -0
  45. package/templates/.kanbot/agents/plan-critic.md +3 -3
  46. package/templates/.kanbot/agents/planner.md +10 -9
  47. package/templates/.kanbot/agents/reviewer.md +42 -0
  48. package/templates/AGENTS.md +3 -4
  49. package/templates/.agents/skills/accessibility/SKILL.md +0 -76
  50. package/templates/.agents/skills/code-review/SKILL.md +0 -83
  51. package/templates/.agents/skills/frontend-design/LICENSE.txt +0 -177
  52. package/templates/.agents/skills/frontend-design/SKILL.md +0 -76
  53. package/templates/.agents/skills/tdd/SKILL.md +0 -51
  54. package/templates/.agents/skills/verify-ui/SKILL.md +0 -104
  55. package/templates/.kanbot/agents/implementer-backend.md +0 -57
  56. package/templates/.kanbot/agents/implementer-docs.md +0 -55
  57. package/templates/.kanbot/agents/implementer-frontend.md +0 -54
  58. package/templates/.kanbot/agents/implementer-technical.md +0 -53
  59. package/templates/.kanbot/agents/reviewer-code.md +0 -46
  60. package/templates/.kanbot/agents/reviewer-spec.md +0 -49
  61. package/templates/.kanbot/agents/test-writer.md +0 -35
  62. 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(["api", "graphql", "--input", "-"], input);
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(await run(["api", path])) as T;
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(args: string[], input?: string): Promise<string> {
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
  });
@@ -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.replace(/<!--\s*kanbot[^>]*-->/g, "").trim();
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(job: ImplementJob, base: string) {
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(job: ReviewJob, base: string) {
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
- `## Review pass ${job.pass} of ${reviewPasses(issue).length}\nYou only review. Do not edit files or commit.`,
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(job: FixJob, base: string, details: string | null) {
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);