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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +102 -2
  3. package/package.json +56 -5
  4. package/schema/config.schema.json +206 -0
  5. package/src/agents/load.ts +83 -0
  6. package/src/agents/sync.ts +216 -0
  7. package/src/cli.ts +5 -0
  8. package/src/commands/board.ts +140 -0
  9. package/src/commands/completion.ts +24 -0
  10. package/src/commands/doctor.ts +250 -0
  11. package/src/commands/init.ts +102 -0
  12. package/src/commands/run.ts +32 -0
  13. package/src/commands/skills.ts +63 -0
  14. package/src/commands/status.ts +61 -0
  15. package/src/commands/sync.ts +31 -0
  16. package/src/commands/upgrade.ts +70 -0
  17. package/src/completion.ts +217 -0
  18. package/src/config.ts +213 -0
  19. package/src/daemon/derive.ts +413 -0
  20. package/src/daemon/git.ts +187 -0
  21. package/src/daemon/lock.ts +24 -0
  22. package/src/daemon/loop.ts +207 -0
  23. package/src/daemon/notify.ts +44 -0
  24. package/src/daemon/snapshot.ts +109 -0
  25. package/src/daemon/state.ts +60 -0
  26. package/src/daemon/types.ts +73 -0
  27. package/src/github/api.ts +332 -0
  28. package/src/github/gh.ts +110 -0
  29. package/src/github/markers.ts +82 -0
  30. package/src/github/project.ts +278 -0
  31. package/src/github/trust.ts +38 -0
  32. package/src/jobs/prompts.ts +205 -0
  33. package/src/jobs/run.ts +569 -0
  34. package/src/known-hashes.json +132 -0
  35. package/src/log.ts +11 -0
  36. package/src/main.ts +21 -0
  37. package/src/providers/claude.ts +122 -0
  38. package/src/providers/codex.ts +98 -0
  39. package/src/providers/types.ts +94 -0
  40. package/src/schemas/results.ts +116 -0
  41. package/src/templates.ts +192 -0
  42. package/templates/.agents/skills/accessibility/SKILL.md +76 -0
  43. package/templates/.agents/skills/approach-designer/SKILL.md +79 -0
  44. package/templates/.agents/skills/code-review/SKILL.md +83 -0
  45. package/templates/.agents/skills/frontend-design/LICENSE.txt +177 -0
  46. package/templates/.agents/skills/frontend-design/SKILL.md +76 -0
  47. package/templates/.agents/skills/issue-splitting/SKILL.md +78 -0
  48. package/templates/.agents/skills/kanbot-admin/SKILL.md +57 -0
  49. package/templates/.agents/skills/kanbot-idea/SKILL.md +56 -0
  50. package/templates/.agents/skills/plan-template/SKILL.md +85 -0
  51. package/templates/.agents/skills/pr-description/SKILL.md +44 -0
  52. package/templates/.agents/skills/project-conventions/SKILL.md +79 -0
  53. package/templates/.agents/skills/project-memory/SKILL.md +39 -0
  54. package/templates/.agents/skills/project-memory/facts/.gitkeep +0 -0
  55. package/templates/.agents/skills/spec-review/SKILL.md +73 -0
  56. package/templates/.agents/skills/tdd/SKILL.md +51 -0
  57. package/templates/.agents/skills/user-stories/SKILL.md +82 -0
  58. package/templates/.agents/skills/verify-ui/SKILL.md +104 -0
  59. package/templates/.github/ISSUE_TEMPLATE/config.yml +1 -0
  60. package/templates/.github/ISSUE_TEMPLATE/technical.yml +15 -0
  61. package/templates/.github/ISSUE_TEMPLATE/user-story.yml +15 -0
  62. package/templates/.github/pull_request_template.md +15 -0
  63. package/templates/.github/workflows/ci.yml +23 -0
  64. package/templates/.github/workflows/kanbot-merged.yml +30 -0
  65. package/templates/.kanbot/agents/approach-designer.md +31 -0
  66. package/templates/.kanbot/agents/explorer.md +35 -0
  67. package/templates/.kanbot/agents/fixer.md +54 -0
  68. package/templates/.kanbot/agents/implementer-backend.md +57 -0
  69. package/templates/.kanbot/agents/implementer-docs.md +55 -0
  70. package/templates/.kanbot/agents/implementer-frontend.md +54 -0
  71. package/templates/.kanbot/agents/implementer-technical.md +53 -0
  72. package/templates/.kanbot/agents/plan-critic.md +36 -0
  73. package/templates/.kanbot/agents/planner.md +52 -0
  74. package/templates/.kanbot/agents/reviewer-code.md +46 -0
  75. package/templates/.kanbot/agents/reviewer-spec.md +49 -0
  76. package/templates/.kanbot/agents/test-writer.md +35 -0
  77. package/templates/.kanbot/agents/ui-verifier.md +35 -0
  78. package/templates/AGENTS.md +39 -0
  79. package/templates/CLAUDE.md +1 -0
@@ -0,0 +1,278 @@
1
+ import { COLUMN_KEYS, type ColumnKey, type Config } from "../config.ts";
2
+ import type { Gh } from "./gh.ts";
3
+
4
+ export type Board = {
5
+ id: string;
6
+ url: string;
7
+ statusFieldId: string;
8
+ /** column key → single-select option id */
9
+ options: Partial<Record<ColumnKey, string>>;
10
+ /** option id → column key */
11
+ columnOf: Map<string, ColumnKey>;
12
+ };
13
+
14
+ const COLUMN_COLORS: Record<ColumnKey, string> = {
15
+ ideas: "GRAY",
16
+ planning: "PURPLE",
17
+ ready: "BLUE",
18
+ inProgress: "YELLOW",
19
+ review: "ORANGE",
20
+ done: "GREEN",
21
+ };
22
+
23
+ const COLUMN_DESCRIPTIONS: Record<ColumnKey, string> = {
24
+ ideas: "Raw ideas and notes. Agents leave these alone.",
25
+ planning:
26
+ "Agent writes and refines the plan; comment to steer. Drag to Ready to approve.",
27
+ ready: "Approved. Picked from the top when a worker is free.",
28
+ inProgress: "An agent is implementing this.",
29
+ review: "PRs open. Agent review passes, then human testing and merge.",
30
+ done: "Merged and closed.",
31
+ };
32
+
33
+ const PROJECT_FIELDS = `
34
+ id url
35
+ field(name: "Status") {
36
+ ... on ProjectV2SingleSelectField { id options { id name } }
37
+ }`;
38
+
39
+ type RawProject = {
40
+ id: string;
41
+ url: string;
42
+ field: { id: string; options: { id: string; name: string }[] } | null;
43
+ };
44
+
45
+ function toBoard(p: RawProject, config: Config): Board {
46
+ if (!p.field) throw new Error("Project has no single-select Status field");
47
+ const options: Board["options"] = {};
48
+ const columnOf = new Map<string, ColumnKey>();
49
+ for (const key of COLUMN_KEYS) {
50
+ const opt = p.field.options.find(
51
+ (o) => o.name.toLowerCase() === config.columns[key].toLowerCase(),
52
+ );
53
+ if (opt) {
54
+ options[key] = opt.id;
55
+ columnOf.set(opt.id, key);
56
+ }
57
+ }
58
+ return { id: p.id, url: p.url, statusFieldId: p.field.id, options, columnOf };
59
+ }
60
+
61
+ export async function loadBoard(gh: Gh, config: Config): Promise<Board> {
62
+ if (!config.project) {
63
+ throw new Error("No project configured. Run `kanbot board create`.");
64
+ }
65
+ const data = await gh.query<{
66
+ repositoryOwner: { projectV2: RawProject | null } | null;
67
+ }>(
68
+ `query($login: String!, $number: Int!) {
69
+ repositoryOwner(login: $login) {
70
+ ... on ProjectV2Owner { projectV2(number: $number) { ${PROJECT_FIELDS} } }
71
+ }
72
+ }`,
73
+ { login: config.project.owner, number: config.project.number },
74
+ );
75
+ const p = data.repositoryOwner?.projectV2;
76
+ if (!p) {
77
+ throw new Error(
78
+ `Project ${config.project.owner}#${config.project.number} not found (does gh have the "project" scope? run: gh auth refresh -s project)`,
79
+ );
80
+ }
81
+ return toBoard(p, config);
82
+ }
83
+
84
+ export function missingColumns(board: Board): ColumnKey[] {
85
+ return COLUMN_KEYS.filter((k) => !board.options[k]);
86
+ }
87
+
88
+ /** Create a project linked to the repo with the kanbot columns. */
89
+ export async function createBoard(gh: Gh, config: Config, title: string) {
90
+ const repo = await gh.query<{
91
+ repository: { id: string; owner: { id: string; login: string } };
92
+ }>(
93
+ `query($owner: String!, $name: String!) {
94
+ repository(owner: $owner, name: $name) { id owner { id login } }
95
+ }`,
96
+ { owner: gh.owner, name: gh.name },
97
+ );
98
+ const created = await gh.mutate<{
99
+ createProjectV2: {
100
+ projectV2: RawProject & {
101
+ number: number;
102
+ views: { nodes: { id: string }[] };
103
+ };
104
+ };
105
+ }>(
106
+ `create project "${title}"`,
107
+ `mutation($ownerId: ID!, $repositoryId: ID!, $title: String!) {
108
+ createProjectV2(input: { ownerId: $ownerId, repositoryId: $repositoryId, title: $title }) {
109
+ projectV2 { number views(first: 1) { nodes { id } } ${PROJECT_FIELDS} }
110
+ }
111
+ }`,
112
+ {
113
+ ownerId: repo.repository.owner.id,
114
+ repositoryId: repo.repository.id,
115
+ title,
116
+ },
117
+ );
118
+ if (!created) return null;
119
+ const project = created.createProjectV2.projectV2;
120
+ if (!project.field) throw new Error("New project has no Status field");
121
+ await gh.mutate(
122
+ "configure Status columns",
123
+ `mutation($fieldId: ID!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {
124
+ updateProjectV2Field(input: { fieldId: $fieldId, singleSelectOptions: $options }) {
125
+ projectV2Field { ... on ProjectV2SingleSelectField { id } }
126
+ }
127
+ }`,
128
+ {
129
+ fieldId: project.field.id,
130
+ options: COLUMN_KEYS.map((k) => ({
131
+ name: config.columns[k],
132
+ color: COLUMN_COLORS[k],
133
+ description: COLUMN_DESCRIPTIONS[k],
134
+ })),
135
+ },
136
+ );
137
+ // New projects open as a table; make the default view a board (grouped by Status).
138
+ const view = project.views.nodes[0];
139
+ if (view) {
140
+ await gh.mutate(
141
+ "make default view a board",
142
+ `mutation($v: ID!) {
143
+ updateProjectV2View(input: { viewId: $v, name: "Board", layout: BOARD_LAYOUT }) {
144
+ projectV2View { id }
145
+ }
146
+ }`,
147
+ { v: view.id },
148
+ );
149
+ }
150
+ return {
151
+ owner: repo.repository.owner.login,
152
+ number: project.number,
153
+ url: project.url,
154
+ };
155
+ }
156
+
157
+ export type RawItem = {
158
+ id: string;
159
+ status: { optionId: string } | null;
160
+ content: {
161
+ __typename: string;
162
+ number?: number;
163
+ state?: string;
164
+ repository?: { nameWithOwner: string };
165
+ } | null;
166
+ };
167
+
168
+ /** All board items for this repo's issues, in board order. */
169
+ export async function listItems(gh: Gh, board: Board) {
170
+ const items: {
171
+ itemId: string;
172
+ column: ColumnKey | null;
173
+ number: number;
174
+ state: string;
175
+ }[] = [];
176
+ let after: string | null = null;
177
+ do {
178
+ const data: {
179
+ node: {
180
+ items: {
181
+ nodes: RawItem[];
182
+ pageInfo: { hasNextPage: boolean; endCursor: string };
183
+ };
184
+ };
185
+ } = await gh.query(
186
+ `query($id: ID!, $after: String) {
187
+ node(id: $id) {
188
+ ... on ProjectV2 {
189
+ items(first: 100, after: $after) {
190
+ nodes {
191
+ id
192
+ status: fieldValueByName(name: "Status") {
193
+ ... on ProjectV2ItemFieldSingleSelectValue { optionId }
194
+ }
195
+ content {
196
+ __typename
197
+ ... on Issue { number state repository { nameWithOwner } }
198
+ }
199
+ }
200
+ pageInfo { hasNextPage endCursor }
201
+ }
202
+ }
203
+ }
204
+ }`,
205
+ { id: board.id, after },
206
+ );
207
+ for (const n of data.node.items.nodes) {
208
+ if (n.content?.__typename !== "Issue") continue;
209
+ if (
210
+ n.content.repository?.nameWithOwner.toLowerCase() !==
211
+ gh.repo.toLowerCase()
212
+ )
213
+ continue;
214
+ items.push({
215
+ itemId: n.id,
216
+ column: n.status
217
+ ? (board.columnOf.get(n.status.optionId) ?? null)
218
+ : null,
219
+ number: n.content.number as number,
220
+ state: n.content.state as string,
221
+ });
222
+ }
223
+ after = data.node.items.pageInfo.hasNextPage
224
+ ? data.node.items.pageInfo.endCursor
225
+ : null;
226
+ } while (after);
227
+ return items;
228
+ }
229
+
230
+ export async function moveItem(
231
+ gh: Gh,
232
+ board: Board,
233
+ itemId: string,
234
+ column: ColumnKey,
235
+ ) {
236
+ const optionId = board.options[column];
237
+ if (!optionId) throw new Error(`Board has no "${column}" column`);
238
+ await gh.mutate(
239
+ `move item ${itemId} → ${column}`,
240
+ `mutation($p: ID!, $i: ID!, $f: ID!, $o: String!) {
241
+ updateProjectV2ItemFieldValue(input: { projectId: $p, itemId: $i, fieldId: $f, value: { singleSelectOptionId: $o } }) {
242
+ projectV2Item { id }
243
+ }
244
+ }`,
245
+ { p: board.id, i: itemId, f: board.statusFieldId, o: optionId },
246
+ );
247
+ }
248
+
249
+ export async function addToBoard(
250
+ gh: Gh,
251
+ board: Board,
252
+ contentId: string,
253
+ column: ColumnKey,
254
+ ) {
255
+ const res = await gh.mutate<{
256
+ addProjectV2ItemById: { item: { id: string } };
257
+ }>(
258
+ `add ${contentId} to board`,
259
+ `mutation($p: ID!, $c: ID!) {
260
+ addProjectV2ItemById(input: { projectId: $p, contentId: $c }) { item { id } }
261
+ }`,
262
+ { p: board.id, c: contentId },
263
+ );
264
+ if (res) await moveItem(gh, board, res.addProjectV2ItemById.item.id, column);
265
+ }
266
+
267
+ /** Built-in board workflows; they can be read but not enabled via the API. */
268
+ export async function boardWorkflows(gh: Gh, board: Board) {
269
+ const data = await gh.query<{
270
+ node: { workflows: { nodes: { name: string; enabled: boolean }[] } };
271
+ }>(
272
+ `query($id: ID!) {
273
+ node(id: $id) { ... on ProjectV2 { workflows(first: 50) { nodes { name enabled } } } }
274
+ }`,
275
+ { id: board.id },
276
+ );
277
+ return data.node.workflows.nodes;
278
+ }
@@ -0,0 +1,38 @@
1
+ import type { Gh } from "./gh.ts";
2
+
3
+ const WRITE = new Set(["admin", "maintain", "write"]);
4
+ const TTL_MS = 60 * 60 * 1000;
5
+
6
+ /**
7
+ * Only comments from collaborators with write access may steer agents. This is
8
+ * the main prompt-injection guard on public repos.
9
+ */
10
+ export class Trust {
11
+ private cache = new Map<string, { ok: boolean; at: number }>();
12
+
13
+ constructor(private gh: Gh) {}
14
+
15
+ async isTrusted(login: string): Promise<boolean> {
16
+ const hit = this.cache.get(login);
17
+ if (hit && Date.now() - hit.at < TTL_MS) return hit.ok;
18
+ let ok = false;
19
+ try {
20
+ const res = await this.gh.rest<{ permission: string }>(
21
+ this.gh.r(`/collaborators/${encodeURIComponent(login)}/permission`),
22
+ );
23
+ ok = WRITE.has(res.permission);
24
+ } catch {
25
+ ok = false;
26
+ }
27
+ this.cache.set(login, { ok, at: Date.now() });
28
+ return ok;
29
+ }
30
+
31
+ async resolve(logins: Iterable<string>): Promise<Set<string>> {
32
+ const unique = [...new Set(logins)];
33
+ const results = await Promise.all(
34
+ unique.map(async (l) => [l, await this.isTrusted(l)] as const),
35
+ );
36
+ return new Set(results.filter(([, ok]) => ok).map(([l]) => l));
37
+ }
38
+ }
@@ -0,0 +1,205 @@
1
+ import {
2
+ type FixJob,
3
+ type ImplementJob,
4
+ type PlanJob,
5
+ type ReviewJob,
6
+ reviewPasses,
7
+ } from "../daemon/derive.ts";
8
+ import type { Comment, Issue, PR } from "../daemon/types.ts";
9
+ import { extractPlan, isKanbot, stripPlan } from "../github/markers.ts";
10
+ import type { Finding } from "../schemas/results.ts";
11
+
12
+ /*
13
+ * Job prompts carry the *task*; the agent's own instructions (from
14
+ * .kanbot/agents) carry the *how*. Keep these factual and structured.
15
+ */
16
+
17
+ const RULES = `## Ground rules
18
+ - You are running headless inside a git worktree. Nobody will answer questions mid-run.
19
+ - Never use \`gh\`, never \`git push\`, never contact GitHub. kanbot applies your output.
20
+ - Your final answer must be the structured JSON result. Put everything the human should see in it.
21
+ - Write short and plain: only what the reader needs to act or understand. Don't restate the input or narrate your process.`;
22
+
23
+ function quote(text: string) {
24
+ return text.trim() ? text.trim() : "_(empty)_";
25
+ }
26
+
27
+ function conversation(comments: Comment[], limit = 30) {
28
+ const recent = comments.slice(-limit);
29
+ if (!recent.length) return "_(no comments)_";
30
+ return recent
31
+ .map(
32
+ (c) =>
33
+ `**${isKanbot(c.body) ? "kanbot (agent)" : `@${c.author}`}** — ${c.createdAt}\n${stripMarkers(c.body)}`,
34
+ )
35
+ .join("\n\n---\n\n");
36
+ }
37
+
38
+ function stripMarkers(body: string) {
39
+ return body.replace(/<!--\s*kanbot[^>]*-->/g, "").trim();
40
+ }
41
+
42
+ /**
43
+ * Earlier human feedback on the PR, resolved or not. Without it, review and fix
44
+ * agents see only the plan and "correct" decisions the human made on the PR.
45
+ */
46
+ function humanDecisions(pr: PR) {
47
+ const threads = pr.threads.filter((t) =>
48
+ t.comments.some((c) => !isKanbot(c.body)),
49
+ );
50
+ const comments = [...pr.comments, ...pr.reviews].filter(
51
+ (c) => !isKanbot(c.body),
52
+ );
53
+ if (!threads.length && !comments.length) return null;
54
+ const parts = [
55
+ "## Human decisions on this PR\nFeedback the human already gave on this PR. It outranks the plan and acceptance criteria where they conflict; do not undo or flag these changes.",
56
+ ...threads.map(
57
+ (t) =>
58
+ `### Review thread on ${t.path ?? "?"}${t.line ? `:${t.line}` : ""}${t.isResolved ? " (resolved)" : ""}\n${conversation(t.comments, 10)}`,
59
+ ),
60
+ ];
61
+ if (comments.length)
62
+ parts.push(`### PR comments\n${conversation(comments, 10)}`);
63
+ return parts.join("\n\n");
64
+ }
65
+
66
+ function issueHeader(issue: Issue) {
67
+ if (issue.prOnly)
68
+ return `# Pull request #${issue.number}: ${issue.title}
69
+ No issue or plan: the human opened this PR and asked for review. Its description is the spec.
70
+ URL: ${issue.url}`;
71
+ return `# Issue #${issue.number}: ${issue.title}
72
+ Labels: ${issue.labels.join(", ") || "none"}
73
+ URL: ${issue.url}`;
74
+ }
75
+
76
+ export function planPrompt(job: PlanJob, explore: number | null) {
77
+ const { issue } = job.item;
78
+ const plan = extractPlan(issue.body);
79
+ const parts = [
80
+ issueHeader(issue),
81
+ `## Original description (written by the human)\n${quote(stripPlan(issue.body))}`,
82
+ plan
83
+ ? `## Current plan\n${plan}`
84
+ : "## Current plan\n_None yet. Write the first version._",
85
+ `## Conversation so far\n${conversation(issue.comments)}`,
86
+ ];
87
+ if (job.comments.length) {
88
+ parts.push(
89
+ `## New human comments to address\nReply to each one by its id in \`replies\`, and update the plan to reflect them. If a comment asks to split work or start a new issue, use \`newIssues\`.\n\n${job.comments
90
+ .map((c) => `### commentId: ${c.id} (@${c.author})\n${c.body.trim()}`)
91
+ .join("\n\n")}`,
92
+ );
93
+ }
94
+ if (explore) {
95
+ parts.push(
96
+ `## Exploratory issue\nLabel explore:${explore}. The plan MUST have an "## Approaches" section with exactly ${explore} genuinely different approaches (### Approach 1: <name> … ### Approach ${explore}: <name>). Each will be implemented as a separate PR by a separate agent.`,
97
+ );
98
+ }
99
+ if (issue.labels.includes("technical")) {
100
+ parts.push(
101
+ "## Technical issue\nLabeled `technical`: write a technical goal instead of a user story.",
102
+ );
103
+ }
104
+ parts.push(RULES);
105
+ return parts.join("\n\n");
106
+ }
107
+
108
+ export function implementPrompt(job: ImplementJob, base: string) {
109
+ const { issue } = job.item;
110
+ const parts = [
111
+ issueHeader(issue),
112
+ `## Approved plan\n${extractPlan(issue.body) ?? stripPlan(issue.body)}`,
113
+ ];
114
+ if (job.variant !== null) {
115
+ parts.push(
116
+ `## Your variant: Approach ${job.variant} of ${job.variants}\nImplement ONLY "Approach ${job.variant}" from the plan. Other agents implement the other approaches in parallel; do not hedge toward them. The human will compare the PRs and merge one.`,
117
+ );
118
+ }
119
+ const humanNotes = issue.comments.filter((c) => !isKanbot(c.body)).slice(-10);
120
+ if (humanNotes.length)
121
+ parts.push(
122
+ `## Recent human comments on the issue\n${conversation(humanNotes)}`,
123
+ );
124
+ parts.push(
125
+ `## 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
+ RULES,
127
+ );
128
+ return parts.join("\n\n");
129
+ }
130
+
131
+ export function reviewPrompt(job: ReviewJob, base: string) {
132
+ const { issue } = job.item;
133
+ const { pr } = job;
134
+ return [
135
+ issueHeader(issue),
136
+ `## Plan / acceptance criteria\n${extractPlan(issue.body) ?? stripPlan(issue.body)}`,
137
+ `## 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
+ humanDecisions(pr),
139
+ `## Review pass ${job.pass} of ${reviewPasses(issue).length}\nYou only review. Do not edit files or commit.`,
140
+ RULES,
141
+ ]
142
+ .filter(Boolean)
143
+ .join("\n\n");
144
+ }
145
+
146
+ /** `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) {
148
+ const { issue } = job.item;
149
+ const { pr } = job;
150
+ const parts = [
151
+ issueHeader(issue),
152
+ `## Plan / acceptance criteria\n${extractPlan(issue.body) ?? stripPlan(issue.body)}`,
153
+ `## 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
+ ];
155
+ const r = job.reason;
156
+ const decisions = r.type === "feedback" ? null : humanDecisions(pr);
157
+ if (decisions) parts.push(decisions);
158
+ if (r.type === "review") {
159
+ parts.push(
160
+ `## Agent review pass ${r.pass} findings\nAddress each finding (use its id as \`ref\`). Nits are optional. If you disagree with a finding, leave the code and explain in \`reply\` with \`resolved: false\`.\n\n${findingsList(r.findings)}`,
161
+ );
162
+ } else if (r.type === "feedback") {
163
+ parts.push(
164
+ "## Human feedback\nThis is feedback from the human reviewer. It outranks the plan when they conflict. Answer every item below using its id as `ref`. Set `resolved: true` only when the change fully addresses it.",
165
+ );
166
+ for (const t of r.threads) {
167
+ parts.push(
168
+ `### ref: ${t.id} (review thread on ${t.path ?? "?"}${t.line ? `:${t.line}` : ""})\n${conversation(t.comments, 10)}`,
169
+ );
170
+ }
171
+ for (const c of r.comments) {
172
+ parts.push(
173
+ `### ref: ${c.id} (comment by @${c.author})\n${c.body.trim()}`,
174
+ );
175
+ }
176
+ } else if (r.type === "copilot") {
177
+ parts.push(
178
+ "## GitHub Copilot review\nAutomated review comments from GitHub Copilot. Treat them like agent findings: fix real problems, and decline with a reason where Copilot is wrong. They do not outrank the plan or the human's decisions. Answer every thread below using its id as `ref`.",
179
+ );
180
+ for (const t of r.threads) {
181
+ parts.push(
182
+ `### ref: ${t.id} (Copilot on ${t.path ?? "?"}${t.line ? `:${t.line}` : ""})\n${conversation(t.comments, 10)}`,
183
+ );
184
+ }
185
+ } else if (r.type === "conflict") {
186
+ parts.push(
187
+ `## Merge conflicts\n\`origin/${base}\` has moved and conflicts with this branch. A merge of it is in progress in the worktree. Resolve every conflict, keeping the intent of both sides; the PR's plan and the base branch's changes both stand. Then run tests, lint and typecheck. Stage your resolution; committing the merge is optional. Do not abort the merge or rebase. Use \`ref: "conflict"\` in \`addressed\`.\n\nConflicted files:\n${details}`,
188
+ );
189
+ } else {
190
+ parts.push(
191
+ `## CI is failing\nMake CI pass without weakening tests. Use \`ref: "ci"\` in \`addressed\`.\n\n${details || "_No logs available; reproduce locally with the project's test/lint commands._"}`,
192
+ );
193
+ }
194
+ parts.push(RULES);
195
+ return parts.join("\n\n");
196
+ }
197
+
198
+ export function findingsList(findings: Finding[]) {
199
+ return findings
200
+ .map(
201
+ (f) =>
202
+ `- **${f.id}** \`${f.severity}\`${f.path ? ` ${f.path}${f.line ? `:${f.line}` : ""}` : ""} — ${f.body}`,
203
+ )
204
+ .join("\n");
205
+ }