@ipul-labs/forge 1.13.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 (113) hide show
  1. package/README.md +273 -0
  2. package/bin/README.md +210 -0
  3. package/bin/cli/analyze.js +516 -0
  4. package/bin/cli/apply.js +264 -0
  5. package/bin/cli/cifix.js +300 -0
  6. package/bin/cli/close.js +125 -0
  7. package/bin/cli/doctor.js +161 -0
  8. package/bin/cli/init.js +306 -0
  9. package/bin/cli/ink.js +57 -0
  10. package/bin/cli/issue.js +116 -0
  11. package/bin/cli/issues.js +155 -0
  12. package/bin/cli/merge.js +175 -0
  13. package/bin/cli/refine.js +253 -0
  14. package/bin/cli/repos.js +91 -0
  15. package/bin/cli/resolution.js +126 -0
  16. package/bin/cli/session.js +230 -0
  17. package/bin/cli/sessionStore.js +214 -0
  18. package/bin/cli/ship.js +762 -0
  19. package/bin/cli/utils.js +145 -0
  20. package/bin/cli/validate.js +280 -0
  21. package/bin/cli/watch.js +274 -0
  22. package/bin/forge-agent.js +174 -0
  23. package/dist/assets/index-CNTCMbW9.js +80 -0
  24. package/dist/assets/index-InpYS-zg.css +1 -0
  25. package/dist/favicon.svg +8 -0
  26. package/dist/index.html +19 -0
  27. package/dist-lib/favicon.svg +8 -0
  28. package/dist-lib/index.d.ts +1 -0
  29. package/dist-lib/style.css +1 -0
  30. package/dist-lib/ui.d.ts +2 -0
  31. package/dist-lib/ui.js +7390 -0
  32. package/dist-lib/views/Forge/ForgeDashboard.d.ts +5 -0
  33. package/dist-lib/views/Forge/activeJobs.d.ts +29 -0
  34. package/dist-lib/views/Forge/analysisCache.d.ts +64 -0
  35. package/dist-lib/views/Forge/api.d.ts +4 -0
  36. package/dist-lib/views/Forge/components/AnalysisProgress.d.ts +13 -0
  37. package/dist-lib/views/Forge/components/CITerminal.d.ts +44 -0
  38. package/dist-lib/views/Forge/components/DiffViewer.d.ts +37 -0
  39. package/dist-lib/views/Forge/components/ForgeConnect.d.ts +6 -0
  40. package/dist-lib/views/Forge/components/IssueList.d.ts +28 -0
  41. package/dist-lib/views/Forge/components/MRStatus.d.ts +9 -0
  42. package/dist-lib/views/Forge/components/NewTaskModal.d.ts +1 -0
  43. package/dist-lib/views/Forge/components/OpenMRList.d.ts +11 -0
  44. package/dist-lib/views/Forge/components/PipelineStatus.d.ts +12 -0
  45. package/dist-lib/views/Forge/components/PipelinesList.d.ts +9 -0
  46. package/dist-lib/views/Forge/components/RolloutStatus.d.ts +5 -0
  47. package/dist-lib/views/Forge/components/UsageWidget.d.ts +8 -0
  48. package/dist-lib/views/Forge/components/ValidationPanel.d.ts +22 -0
  49. package/dist-lib/views/Forge/context/ForgeProvider.d.ts +23 -0
  50. package/dist-lib/views/Forge/hooks/useConfig.d.ts +54 -0
  51. package/dist-lib/views/Forge/hooks/useForgeDashboard.d.ts +107 -0
  52. package/dist-lib/views/Forge/hooks/useForgeQuota.d.ts +22 -0
  53. package/dist-lib/views/Forge/phases/AnalyzingView.d.ts +1 -0
  54. package/dist-lib/views/Forge/phases/IdleView.d.ts +1 -0
  55. package/dist-lib/views/Forge/phases/MonitoringIssueTab.d.ts +1 -0
  56. package/dist-lib/views/Forge/phases/MonitoringPipelineTab.d.ts +1 -0
  57. package/dist-lib/views/Forge/phases/MonitoringView.d.ts +1 -0
  58. package/dist-lib/views/Forge/phases/ReviewingView.d.ts +1 -0
  59. package/dist-lib/views/Forge/phases/SelectedIssueTab.d.ts +11 -0
  60. package/dist-lib/views/Forge/phases/SelectedMRTab.d.ts +1 -0
  61. package/dist-lib/views/Forge/phases/SelectedView.d.ts +1 -0
  62. package/dist-lib/views/Forge/phases/shared/CloseIssueConfirm.d.ts +1 -0
  63. package/dist-lib/views/Forge/phases/shared/DetailTabs.d.ts +14 -0
  64. package/dist-lib/views/Forge/phases/shared/EnvironmentsPanel.d.ts +9 -0
  65. package/dist-lib/views/Forge/phases/shared/ForgeActivity.d.ts +1 -0
  66. package/dist-lib/views/Forge/phases/shared/IssueHeaderBlock.d.ts +13 -0
  67. package/dist-lib/views/Forge/phases/shared/MergedMRList.d.ts +4 -0
  68. package/dist-lib/views/Forge/phases/shared/PreviousAnalyses.d.ts +1 -0
  69. package/dist-lib/views/Forge/phases/shared/ResolutionCheckPanel.d.ts +6 -0
  70. package/dist-lib/views/Forge/settings/ConnectionRow.d.ts +16 -0
  71. package/dist-lib/views/Forge/settings/ConnectionsSection.d.ts +7 -0
  72. package/dist-lib/views/Forge/settings/DefaultsSection.d.ts +7 -0
  73. package/dist-lib/views/Forge/settings/ReposSection.d.ts +7 -0
  74. package/dist-lib/views/Forge/settings/SettingsView.d.ts +1 -0
  75. package/dist-lib/views/Forge/shell/ConfigBar.d.ts +1 -0
  76. package/dist-lib/views/Forge/shell/ForgeRouter.d.ts +9 -0
  77. package/dist-lib/views/Forge/shell/ForgeShell.d.ts +7 -0
  78. package/dist-lib/views/Forge/shell/Sidebar.d.ts +1 -0
  79. package/dist-lib/views/Forge/shell/SidebarFooterMenu.d.ts +1 -0
  80. package/dist-lib/views/Forge/shell/SidebarNav.d.ts +1 -0
  81. package/dist-lib/views/Forge/shell/SidebarNavItem.d.ts +10 -0
  82. package/dist-lib/views/Forge/shell/TopBar.d.ts +5 -0
  83. package/dist-lib/views/Forge/types.d.ts +235 -0
  84. package/dist-lib/views/Forge/utils.d.ts +11 -0
  85. package/package.json +88 -0
  86. package/server/forge/core/ForgeAgent.js +1144 -0
  87. package/server/forge/core/agentClient.js +36 -0
  88. package/server/forge/core/branchName.js +41 -0
  89. package/server/forge/core/claudeClient.js +356 -0
  90. package/server/forge/core/configStore.js +214 -0
  91. package/server/forge/core/gitlabClient.js +340 -0
  92. package/server/forge/core/jobs.js +180 -0
  93. package/server/forge/core/k8s.js +54 -0
  94. package/server/forge/core/localRepo.js +136 -0
  95. package/server/forge/core/openclawClient.js +125 -0
  96. package/server/forge/core/prompts.js +369 -0
  97. package/server/forge/core/providers/azuredevops.js +408 -0
  98. package/server/forge/core/providers/base.js +208 -0
  99. package/server/forge/core/providers/github.js +349 -0
  100. package/server/forge/core/providers/gitlab.js +529 -0
  101. package/server/forge/core/providers/index.js +47 -0
  102. package/server/forge/core/quotaClient.js +101 -0
  103. package/server/forge/core/validator/attribute.js +84 -0
  104. package/server/forge/core/validator/clone.js +91 -0
  105. package/server/forge/core/validator/cmd.js +98 -0
  106. package/server/forge/core/validator/fs.js +9 -0
  107. package/server/forge/core/validator/overlay.js +52 -0
  108. package/server/forge/core/validator/projectType.js +87 -0
  109. package/server/forge/core/validator/runners/go.js +107 -0
  110. package/server/forge/core/validator/runners/node.js +84 -0
  111. package/server/forge/core/validator/tdd.js +105 -0
  112. package/server/forge/core/validator.js +166 -0
  113. package/server/forge/routes.js +1012 -0
@@ -0,0 +1,125 @@
1
+ /**
2
+ * server/forge/core/openclawClient.js
3
+ *
4
+ * Communicates with a locally-running OpenClaw Gateway over HTTP.
5
+ * Configure with:
6
+ * OPENCLAW_GATEWAY_URL — e.g. http://192.168.x.x:18789 (Tailscale) or SSH tunnel
7
+ * OPENCLAW_TOKEN — Bearer token if your Gateway requires auth
8
+ *
9
+ * The Gateway runs on 127.0.0.1:18789 by default. Expose it remotely via:
10
+ * Tailscale: bind to tailnet interface
11
+ * SSH tunnel: ssh -N -L 18789:127.0.0.1:18789 user@host
12
+ */
13
+
14
+ import {
15
+ buildIdentifyFilesPrompt,
16
+ buildProposeFixPrompt,
17
+ buildRefineOrAskPrompt,
18
+ buildCheckResolutionPrompt,
19
+ } from "./prompts.js";
20
+
21
+ const GATEWAY_URL = process.env.OPENCLAW_GATEWAY_URL?.replace(/\/$/, "");
22
+ const GATEWAY_TOKEN = process.env.OPENCLAW_TOKEN;
23
+ const TIMEOUT_MS = 120_000;
24
+
25
+ // ─── JSON output helpers ──────────────────────────────────────────────────────
26
+
27
+ function extractJSON(text) {
28
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
29
+ if (fenced) return JSON.parse(fenced[1].trim());
30
+ const start = text.indexOf("{");
31
+ const end = text.lastIndexOf("}");
32
+ if (start !== -1 && end !== -1) return JSON.parse(text.slice(start, end + 1));
33
+ throw new Error("No JSON found in OpenClaw response");
34
+ }
35
+
36
+ // ─── Gateway transport ────────────────────────────────────────────────────────
37
+
38
+ /**
39
+ * Send a prompt to the OpenClaw Gateway and return the text response.
40
+ *
41
+ * @param {string} prompt
42
+ * @param {string} [model]
43
+ * @param {{ signal?: AbortSignal }} [opts]
44
+ * @returns {Promise<string>}
45
+ */
46
+ async function callGateway(prompt, model, opts = {}) {
47
+ if (!GATEWAY_URL) {
48
+ throw new Error(
49
+ "OPENCLAW_GATEWAY_URL is not set. Add it to your .env and point it at your OpenClaw Gateway.",
50
+ );
51
+ }
52
+
53
+ const controller = new AbortController();
54
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
55
+ // Chain the external cancel signal so a job cancel aborts the fetch in
56
+ // addition to the timeout.
57
+ const external = opts.signal;
58
+ const onExternalAbort = () => controller.abort();
59
+ if (external) {
60
+ if (external.aborted) controller.abort();
61
+ else external.addEventListener("abort", onExternalAbort, { once: true });
62
+ }
63
+
64
+ try {
65
+ const body = { message: prompt };
66
+ if (model) body.model = model;
67
+
68
+ const res = await fetch(`${GATEWAY_URL}/api/v1/agent`, {
69
+ method: "POST",
70
+ headers: {
71
+ "Content-Type": "application/json",
72
+ ...(GATEWAY_TOKEN ? { Authorization: `Bearer ${GATEWAY_TOKEN}` } : {}),
73
+ },
74
+ body: JSON.stringify(body),
75
+ signal: controller.signal,
76
+ });
77
+
78
+ if (!res.ok) {
79
+ const text = await res.text().catch(() => "");
80
+ throw new Error(`OpenClaw gateway ${res.status}: ${text.slice(0, 300)}`);
81
+ }
82
+
83
+ const data = await res.json();
84
+ return data.result ?? data.message ?? data.content ?? data.text ?? JSON.stringify(data);
85
+ } catch (err) {
86
+ if (err.name === "AbortError") {
87
+ // External cancellation wins over "timeout" framing so the terminal
88
+ // event the job emits reflects the user action, not a stale timer.
89
+ if (external?.aborted) throw err;
90
+ throw new Error("OpenClaw gateway timed out after 2 minutes");
91
+ }
92
+ throw err;
93
+ } finally {
94
+ clearTimeout(timer);
95
+ external?.removeEventListener("abort", onExternalAbort);
96
+ }
97
+ }
98
+
99
+ // ─── Public API (mirrors claudeClient.js) ────────────────────────────────────
100
+
101
+ export async function checkResolution(issue, diffs, model, opts = {}) {
102
+ const text = await callGateway(buildCheckResolutionPrompt(issue, diffs), model, opts);
103
+ const data = extractJSON(text);
104
+ return { ...data, cost: 0, serviceTier: "openclaw", inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 };
105
+ }
106
+
107
+ export async function identifyRelevantFiles(issue, fileTree, model, opts = {}) {
108
+ const text = await callGateway(buildIdentifyFilesPrompt(issue, fileTree), model, opts);
109
+ const data = extractJSON(text);
110
+ return { ...data, cost: 0, serviceTier: "openclaw", inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 };
111
+ }
112
+
113
+ export async function proposeFix(issue, fileContents, options = {}, model, opts = {}) {
114
+ const text = await callGateway(buildProposeFixPrompt(issue, fileContents, options), model, opts);
115
+ const data = extractJSON(text);
116
+ return { ...data, cost: 0, serviceTier: "openclaw", inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 };
117
+ }
118
+
119
+ export async function refineOrAsk(issue, fileContents, options = {}, model, _onEvent, opts = {}) {
120
+ // OpenClaw is a single HTTP round-trip — no streaming surface. The onEvent
121
+ // callback is accepted for signature parity with claudeClient and ignored.
122
+ const text = await callGateway(buildRefineOrAskPrompt(issue, fileContents, options), model, opts);
123
+ const data = extractJSON(text);
124
+ return { ...data, cost: 0, serviceTier: "openclaw", inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 };
125
+ }
@@ -0,0 +1,369 @@
1
+ /**
2
+ * server/forge/core/prompts.js
3
+ *
4
+ * Shared prompt builders used by both claudeClient and openclawClient.
5
+ *
6
+ * Multi-repo awareness: when more than one repo is in play (full-stack fixes),
7
+ * every file reference carries a `repo` field so the model can target changes
8
+ * to the correct repository. Single-repo callers can still pass file trees /
9
+ * contents without a repo field — they are normalised before rendering.
10
+ */
11
+
12
+ export const SELECT_FILES_SCHEMA = JSON.stringify({
13
+ type: "object",
14
+ properties: {
15
+ files: {
16
+ type: "array",
17
+ items: {
18
+ type: "object",
19
+ properties: {
20
+ repo: { type: "string", description: "Repository id, as given in the 'Available repositories' list." },
21
+ path: { type: "string", description: "Path from that repo's root." },
22
+ },
23
+ required: ["repo", "path"],
24
+ },
25
+ description: "Minimum set of files relevant to the fix. Maximum 10 across all repos.",
26
+ },
27
+ reasoning: {
28
+ type: "string",
29
+ description: "One or two sentences explaining why these files are relevant and, if the fix spans multiple repos, why both are needed.",
30
+ },
31
+ },
32
+ required: ["files", "reasoning"],
33
+ });
34
+
35
+ export const PROPOSE_FIX_SCHEMA = JSON.stringify({
36
+ type: "object",
37
+ properties: {
38
+ title: {
39
+ type: "string",
40
+ description: "Conventional-commit MR title describing the actual fix, e.g. 'fix: resolve token refresh race condition in AuthService'. Max 72 chars. No issue number.",
41
+ },
42
+ explanation: {
43
+ type: "string",
44
+ description: "2-4 sentences on root cause and approach. If changes span multiple repos, describe how the pieces fit together.",
45
+ },
46
+ changes: {
47
+ type: "array",
48
+ items: {
49
+ type: "object",
50
+ properties: {
51
+ repo: { type: "string", description: "Repository id this change belongs to, exactly as given in the 'Available repositories' list." },
52
+ file: { type: "string", description: "Path from that repo's root." },
53
+ action: { type: "string", enum: ["create", "modify", "delete"] },
54
+ proposed: { type: "string", description: "For create/modify: the COMPLETE new file content. For delete: empty string." },
55
+ summary: { type: "string", description: "One sentence describing this file's change." },
56
+ },
57
+ required: ["repo", "file", "action", "proposed", "summary"],
58
+ },
59
+ },
60
+ },
61
+ required: ["title", "explanation", "changes"],
62
+ });
63
+
64
+ export const REFINE_OR_ASK_SCHEMA = JSON.stringify({
65
+ type: "object",
66
+ properties: {
67
+ kind: {
68
+ type: "string",
69
+ enum: ["answer", "refine"],
70
+ description: "\"answer\" when the user is asking about state (lint status, readiness, rationale); \"refine\" when they are directing a code change.",
71
+ },
72
+ answer: {
73
+ type: "string",
74
+ description: "User-facing response (markdown OK). Always present. For refine, summarise what you changed; for answer, directly address the question grounded in validation results and the proposed changes.",
75
+ },
76
+ changes: {
77
+ type: "array",
78
+ description: "Only present when kind=refine. Same shape as PROPOSE_FIX_SCHEMA.changes.",
79
+ items: {
80
+ type: "object",
81
+ properties: {
82
+ repo: { type: "string" },
83
+ file: { type: "string" },
84
+ action: { type: "string", enum: ["create", "modify", "delete"] },
85
+ proposed: { type: "string" },
86
+ summary: { type: "string" },
87
+ },
88
+ required: ["repo", "file", "action", "proposed", "summary"],
89
+ },
90
+ },
91
+ title: {
92
+ type: "string",
93
+ description: "Only when kind=refine. Updated conventional-commit MR title for the revised fix.",
94
+ },
95
+ },
96
+ required: ["kind", "answer"],
97
+ });
98
+
99
+ export const RESOLUTION_CHECK_SCHEMA = JSON.stringify({
100
+ type: "object",
101
+ properties: {
102
+ resolved: {
103
+ type: "boolean",
104
+ description: "True if the changes fully address the issue.",
105
+ },
106
+ reasoning: {
107
+ type: "string",
108
+ description: "2-3 sentences explaining why the issue is or isn't resolved.",
109
+ },
110
+ },
111
+ required: ["resolved", "reasoning"],
112
+ });
113
+
114
+ /**
115
+ * Builds a human-readable block listing each repo's file tree.
116
+ * Accepts either:
117
+ * - a flat string[] (single-repo, legacy callers) — wrapped as one repo
118
+ * - an array of { repo, files: string[] } (multi-repo)
119
+ */
120
+ function renderRepoTrees(repoTrees) {
121
+ const normalized = Array.isArray(repoTrees) && typeof repoTrees[0] === "string"
122
+ ? [{ repo: "repo", files: repoTrees }]
123
+ : repoTrees;
124
+
125
+ return normalized
126
+ .map(({ repo, files }) =>
127
+ `### ${repo} (${files.length} files)\n${files.join("\n")}`,
128
+ )
129
+ .join("\n\n");
130
+ }
131
+
132
+ /**
133
+ * Builds the file-contents block. Accepts either a flat array
134
+ * (legacy: { path, content }) or multi-repo ({ repo, path, content }).
135
+ */
136
+ function renderFileContents(fileContents) {
137
+ return fileContents
138
+ .map((f) => {
139
+ const header = f.repo ? `${f.repo} — ${f.path}` : f.path;
140
+ return `### ${header}\n\`\`\`\n${f.content}\n\`\`\``;
141
+ })
142
+ .join("\n\n");
143
+ }
144
+
145
+ function renderPreviousChanges(previousChanges) {
146
+ return previousChanges
147
+ .map((c) => {
148
+ const header = c.repo ? `${c.repo} — ${c.file} (${c.action})` : `${c.file} (${c.action})`;
149
+ return `### ${header}\n${c.summary}\n\`\`\`\n${c.proposed}\n\`\`\``;
150
+ })
151
+ .join("\n\n");
152
+ }
153
+
154
+ export function buildIdentifyFilesPrompt(issue, repoTrees) {
155
+ const multi = Array.isArray(repoTrees) && typeof repoTrees[0] === "object";
156
+ const repoIds = multi
157
+ ? repoTrees.map((r) => r.repo).join(", ")
158
+ : "repo";
159
+
160
+ const multiBlock = multi && repoTrees.length > 1
161
+ ? `\n\n## Multi-repo guidance\nThese repositories are deployed together as a full-stack application. If the issue genuinely requires coordinated changes in more than one repo (e.g. a new API endpoint in the backend AND its client in the frontend), select files from each affected repo. If the fix is self-contained, pick files from only one repo.`
162
+ : "";
163
+
164
+ return `Respond with ONLY a raw JSON object — no markdown, no prose, no code fences.
165
+
166
+ Schema: {"files": [{"repo": "<repo id>", "path": "src/foo/bar.ts"}], "reasoning": "one or two sentences"}
167
+
168
+ Identify the minimum set of source files (max 10 across all repos) relevant to fixing this issue.
169
+ Always use an exact repo id from the "Available repositories" list below — do not invent or abbreviate.
170
+
171
+ Selection rules:
172
+ - When a bug points at a failing assertion, guard, or validation (e.g. "undefined priceId", "X is not defined", "cannot read property of undefined"), include the IMMEDIATE CALLER(S) of that function — not just the file where the error is thrown. The bad value usually originates upstream.
173
+ - When a bug points at an event handler or React component, include the component that renders the handler AND the handler's implementation.
174
+ - Prefer fewer files over more, but never omit a caller when the reported symptom is a downstream consequence of input received from that caller.
175
+
176
+ ## Issue #${issue.iid}: ${issue.title}
177
+ ${issue.description || "(no description)"}
178
+ Labels: ${issue.labels?.join(", ") || "none"}
179
+
180
+ ## Available repositories
181
+ ${repoIds}${multiBlock}
182
+
183
+ ## Repository file trees
184
+ ${renderRepoTrees(repoTrees)}`;
185
+ }
186
+
187
+ export function buildProposeFixPrompt(issue, fileContents, options = {}) {
188
+ const { feedback, previousChanges, ciContext, userPrompt, repos } = options;
189
+
190
+ const filesBlock = renderFileContents(fileContents);
191
+
192
+ const previousBlock = previousChanges?.length
193
+ ? `\n\n## Previous proposed changes\n${renderPreviousChanges(previousChanges)}`
194
+ : "";
195
+
196
+ const feedbackBlock = feedback?.trim()
197
+ ? `\n\n## Reviewer feedback\n${feedback.trim()}\n\nAdjust the proposed changes to address this feedback.`
198
+ : "";
199
+
200
+ const ciBlock = ciContext?.trim()
201
+ ? `\n\n## CI pipeline failure logs\nThe pipeline failed. Propose changes that fix the pipeline.\n\n${ciContext.trim()}`
202
+ : "";
203
+
204
+ const userBlock = userPrompt?.trim()
205
+ ? `\n\n## Additional instructions\n${userPrompt.trim()}`
206
+ : "";
207
+
208
+ const reposLine = repos?.length
209
+ ? `\n\n## Available repositories\n${repos.join(", ")}\n\nEvery "repo" field in your response MUST be one of these exact ids.`
210
+ : "";
211
+
212
+ const multiNote = repos && repos.length > 1
213
+ ? `\n\n## Multi-repo guidance\nThis is a full-stack change. If the fix requires coordinated updates in more than one repo, produce changes tagged with the correct repo for each. Make sure API shapes, types, and field names agree across repos.`
214
+ : "";
215
+
216
+ return `Respond with ONLY a raw JSON object — no markdown, no prose, no code fences.
217
+
218
+ Schema: {"title": "fix: concise description of the actual change (max 72 chars)", "explanation": "...", "changes": [{"repo": "<repo id>", "file": "...", "action": "modify|create|delete", "proposed": "<full file content>", "summary": "..."}]}
219
+
220
+ Propose a targeted fix for this issue.
221
+
222
+ Root-cause discipline (read BEFORE writing any change):
223
+ 1. Identify the ORIGIN of the bad value, not just where it's observed. If a guard, assertion, or validation is failing, trace the value one or two frames up and name the caller that produced the bad input.
224
+ 2. A fix that only makes an error message clearer (e.g. replacing \`assertIsDefined(x)\` with \`if (!x) toast.error(…)\`) is NOT a fix — it is a symptom suppressor. Reject it and propose the upstream fix instead.
225
+ 3. In your "explanation", state (a) where the bad value originates, (b) why the fix closes the reported symptom end-to-end, and (c) what would have to be true for the reported error to still appear after your change.
226
+ 4. If the origin is in a file that was not selected in pass 1, your "changes" should include that file. Do not constrain yourself to only the files listed under "Relevant files" when doing so would force a symptom-level patch.
227
+
228
+ ## Issue #${issue.iid}: ${issue.title}
229
+ ${issue.description || "(no description)"}
230
+ Labels: ${issue.labels?.join(", ") || "none"}${reposLine}${multiNote}${previousBlock}${feedbackBlock}${ciBlock}${userBlock}
231
+
232
+ ## Relevant files
233
+ ${filesBlock}`;
234
+ }
235
+
236
+ /**
237
+ * Renders the latest pre-flight validation state (lint/typecheck/test) so the
238
+ * refine model can ground answers like "are lint issues fixed?" in real data.
239
+ * Omits silently when no state is available — the prompt will tell the model
240
+ * to say "validation hasn't been run yet" in that case.
241
+ */
242
+ function renderValidationState(state) {
243
+ if (!state) return "";
244
+ const lines = [`## Latest pre-flight validation (${state.checkedAt ?? "unknown time"})`];
245
+ if (state.allPassed === true) lines.push("Overall: **all checks passed**");
246
+ else if (state.allPassed === false) lines.push("Overall: **some checks failed**");
247
+ const steps = state.steps ?? {};
248
+ for (const name of ["lint", "typecheck", "test"]) {
249
+ const s = steps[name];
250
+ if (!s) continue;
251
+ const status = s.skipped ? "skipped" : s.status ?? (s.passed ? "passed" : "failed");
252
+ const detail = s.exitCode != null ? ` (exit ${s.exitCode})` : "";
253
+ lines.push(`- **${name}**: ${status}${detail}`);
254
+ if (!s.passed && !s.skipped && s.tail) {
255
+ lines.push(` Last output:\n\`\`\`\n${String(s.tail).split("\n").slice(-20).join("\n")}\n\`\`\``);
256
+ }
257
+ }
258
+ return "\n\n" + lines.join("\n");
259
+ }
260
+
261
+ /**
262
+ * Single-prompt conversational refine: the model auto-classifies the user's
263
+ * message as a question about state or a directive to change code, then
264
+ * responds with EITHER a grounded answer OR a fresh changes[] payload.
265
+ */
266
+ export function buildRefineOrAskPrompt(issue, fileContents, options = {}) {
267
+ const { feedback, previousChanges, validationState, repos, repoTrees, repoAccessMode } = options;
268
+
269
+ const filesBlock = renderFileContents(fileContents);
270
+ const previousBlock = previousChanges?.length
271
+ ? `\n\n## Proposed changes so far\n${renderPreviousChanges(previousChanges)}`
272
+ : "";
273
+ const validationBlock = renderValidationState(validationState);
274
+ const reposLine = repos?.length
275
+ ? `\n\n## Available repositories\n${repos.join(", ")}\n\nEvery "repo" field in a refine response MUST be one of these exact ids.`
276
+ : "";
277
+
278
+ const treeBlock = repoTrees?.length
279
+ ? `\n\n## Repository file trees\n${renderRepoTrees(repoTrees)}`
280
+ : "";
281
+
282
+ const accessBlock = repoAccessMode === "tools"
283
+ ? `\n\n## Repo access\nThe current working directory is a checkout of the primary repo with the proposed changes already overlaid. Use the Read, Glob, and Grep tools to inspect any file you need before answering or refining. Do NOT claim you lack repo access.`
284
+ : `\n\n## Repo access\nYou have the full file tree above and the complete content of every file currently in scope. Treat these as authoritative. If you need a file not attached, name the path and ask the user to share it — do NOT claim you lack repo access.`;
285
+
286
+ return `Respond with ONLY a raw JSON object — no markdown, no prose, no code fences.
287
+
288
+ Schema: {
289
+ "kind": "answer" | "refine",
290
+ "answer": "your user-facing reply (markdown ok)",
291
+ "title": "fix: ...", // refine only
292
+ "changes": [ { "repo": "...", "file": "...", "action": "modify|create|delete", "proposed": "<full file>", "summary": "..." } ] // refine only
293
+ }
294
+
295
+ ## How to classify
296
+
297
+ Set kind="answer" when the user is asking about the current state of the work. Examples:
298
+ - "are lint issues fixed?"
299
+ - "is this safe to merge?"
300
+ - "what did you change?"
301
+ - "why this approach?"
302
+ Ground your answer in the validation state below, the proposed changes, and any file you read from the working tree. Be direct. If validation hasn't been run, say so and recommend running it. Do NOT produce a "changes" array on this path.
303
+
304
+ Set kind="refine" when the user is directing a code change. Examples:
305
+ - "use the existing log utility"
306
+ - "split this into two functions"
307
+ - "handle null here"
308
+ - "fix the typecheck error: Foo is not assignable to Bar"
309
+ Produce a complete updated "changes" array addressing the feedback, plus an "answer" that briefly summarises what you changed (one or two sentences). Every file you modify must be a COMPLETE file in "proposed" — never a diff.
310
+
311
+ If the message is ambiguous, prefer "answer" and ask a clarifying question.
312
+
313
+ ## Issue #${issue.iid}: ${issue.title}
314
+ ${issue.description || "(no description)"}
315
+ Labels: ${issue.labels?.join(", ") || "none"}${reposLine}${accessBlock}${treeBlock}${previousBlock}${validationBlock}
316
+
317
+ ## Relevant files (current state as of the proposed changes)
318
+ ${filesBlock}
319
+
320
+ ## User message
321
+ ${feedback?.trim() || "(empty)"}`;
322
+ }
323
+
324
+ /**
325
+ * Slim continuation prompt for refine turns 2+ when the Claude session is
326
+ * being resumed via `--resume <id>`. The model already has the issue, repo
327
+ * access, prior proposed changes, and full file contents from the first
328
+ * turn — repeating them would waste tokens and dilute the cache hit. We only
329
+ * inject what's new: the user's message and the latest validation state.
330
+ */
331
+ export function buildRefineResumePrompt(options = {}) {
332
+ const { feedback, validationState } = options;
333
+ const validationBlock = renderValidationState(validationState);
334
+ return `Continuing the same refine session. Same schema as before — respond with ONLY a raw JSON object matching { kind, answer, title?, changes? }. No prose, no code fences.
335
+
336
+ If you need a file you haven't read yet, use Read / Glob / Grep against the working tree as before.${validationBlock}
337
+
338
+ ## User message
339
+ ${feedback?.trim() || "(empty)"}`;
340
+ }
341
+
342
+ export function buildCheckResolutionPrompt(issue, diffs) {
343
+ // Budget: 24K chars total for diffs, split evenly across files so small
344
+ // MRs (1-3 files) get enough per-file context to see route registrations,
345
+ // handler bodies, etc. — previously 800/file caused false negatives on
346
+ // any file >20 lines.
347
+ const TOTAL_DIFF_BUDGET = 24000;
348
+ const perFile = Math.max(2000, Math.floor(TOTAL_DIFF_BUDGET / diffs.length));
349
+ const diffBlock = diffs
350
+ .map((d) => {
351
+ const path = d.new_path !== d.old_path ? `${d.old_path} → ${d.new_path}` : d.new_path;
352
+ const label = d.new_file ? "created" : d.deleted_file ? "deleted" : "modified";
353
+ const snippet = d.diff ? d.diff.slice(0, perFile) + (d.diff.length > perFile ? "\n… (truncated)" : "") : "(binary or empty)";
354
+ return `### ${path} (${label})\n\`\`\`diff\n${snippet}\n\`\`\``;
355
+ })
356
+ .join("\n\n");
357
+
358
+ return `Respond with ONLY a raw JSON object — no markdown, no prose, no code fences.
359
+
360
+ Schema: {"resolved": true|false, "reasoning": "2-3 sentences"}
361
+
362
+ Determine whether the following code changes fully resolve the reported issue.
363
+
364
+ ## Issue #${issue.iid}: ${issue.title}
365
+ ${issue.description || "(no description)"}
366
+
367
+ ## Changes made in the merged MR
368
+ ${diffBlock}`;
369
+ }