@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,36 @@
1
+ /**
2
+ * server/forge/core/agentClient.js
3
+ *
4
+ * Thin dispatcher — routes agent calls to the selected backend (claude | openclaw).
5
+ * ForgeAgent always calls this instead of importing claudeClient directly.
6
+ *
7
+ * The trailing `opts` argument may carry `{ signal }` — when present, it is
8
+ * forwarded to the backend so a cancelled job tears down the underlying CLI
9
+ * subprocess or HTTP request instead of running to completion.
10
+ */
11
+
12
+ import * as claude from "./claudeClient.js";
13
+ import * as openclaw from "./openclawClient.js";
14
+
15
+ /**
16
+ * @param {string} [agent] - "claude" | "openclaw"
17
+ */
18
+ function backend(agent) {
19
+ return agent === "openclaw" ? openclaw : claude;
20
+ }
21
+
22
+ export function identifyRelevantFiles(issue, fileTree, model, agent, opts) {
23
+ return backend(agent).identifyRelevantFiles(issue, fileTree, model, opts);
24
+ }
25
+
26
+ export function proposeFix(issue, fileContents, options, model, agent, opts) {
27
+ return backend(agent).proposeFix(issue, fileContents, options, model, opts);
28
+ }
29
+
30
+ export function refineOrAsk(issue, fileContents, options, model, agent, onEvent, opts) {
31
+ return backend(agent).refineOrAsk(issue, fileContents, options, model, onEvent, opts);
32
+ }
33
+
34
+ export function checkResolution(issue, diffs, model, agent, opts) {
35
+ return backend(agent).checkResolution(issue, diffs, model, opts);
36
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * server/forge/core/branchName.js
3
+ *
4
+ * Shared branch-name derivation so the validator's local workspace checkout
5
+ * and the provider's remote branch end up on the same name — "follow the code
6
+ * change intention, no timestamps".
7
+ */
8
+
9
+ const CONV_PREFIX = /^(?:feat|fix|chore|docs|style|refactor|perf|test|build|ci|revert)(?:\([^)]+\))?!?:\s*/i;
10
+
11
+ const MAX_SLUG_LEN = 32;
12
+
13
+ export function slugifyIntent(text) {
14
+ const stripped = String(text || "")
15
+ .replace(CONV_PREFIX, "")
16
+ .toLowerCase()
17
+ .normalize("NFKD")
18
+ .replace(/[^\w\s-]/g, "")
19
+ .trim()
20
+ .replace(/[\s_]+/g, "-")
21
+ .replace(/-+/g, "-")
22
+ .replace(/^-+|-+$/g, "");
23
+ if (stripped.length <= MAX_SLUG_LEN) return stripped;
24
+ // Truncate on a word boundary so the branch name reads like a phrase,
25
+ // not a mid-word cutoff ("...div-attri"). Fall back to hard cut if the
26
+ // first word is already too long.
27
+ const hardCut = stripped.slice(0, MAX_SLUG_LEN);
28
+ const lastDash = hardCut.lastIndexOf("-");
29
+ const soft = lastDash > 0 ? hardCut.slice(0, lastDash) : hardCut;
30
+ return soft.replace(/-+$/g, "");
31
+ }
32
+
33
+ /**
34
+ * Branch name derives from the *issue title* rather than the model's mrTitle,
35
+ * so the branch stays stable across refines / CI-fix loops (which rewrite
36
+ * mrTitle to describe the latest edit instead of the overall change body).
37
+ */
38
+ export function branchNameFor(issueIid, analysis) {
39
+ const slug = slugifyIntent(analysis?.issue?.title || analysis?.mrTitle || "");
40
+ return slug ? `forge/issue-${issueIid}-${slug}` : `forge/issue-${issueIid}`;
41
+ }
@@ -0,0 +1,356 @@
1
+ /**
2
+ * server/forge/core/claudeClient.js
3
+ *
4
+ * Shells out to the `claude` CLI (Claude Code) instead of the Anthropic SDK.
5
+ * Uses your Claude Code subscription — no separate API credits needed.
6
+ *
7
+ * Requires: `claude` CLI installed and authenticated (`claude auth login`)
8
+ *
9
+ * Quota headers are NOT available via the CLI — see `quotaClient.js` for a
10
+ * user-triggered probe that hits the Messages API once to read them.
11
+ */
12
+
13
+ import { spawn } from "child_process";
14
+ import {
15
+ SELECT_FILES_SCHEMA,
16
+ PROPOSE_FIX_SCHEMA,
17
+ REFINE_OR_ASK_SCHEMA,
18
+ RESOLUTION_CHECK_SCHEMA,
19
+ buildIdentifyFilesPrompt,
20
+ buildProposeFixPrompt,
21
+ buildRefineOrAskPrompt,
22
+ buildRefineResumePrompt,
23
+ buildCheckResolutionPrompt,
24
+ } from "./prompts.js";
25
+
26
+ // FORGE_MODEL env var overrides the default. Set to a cheaper model to reduce cost.
27
+ // e.g. FORGE_MODEL=claude-haiku-4-5-20251001
28
+ const MODEL = process.env.FORGE_MODEL ?? "claude-sonnet-4-6";
29
+ const FALLBACK_MODEL = process.env.FORGE_FALLBACK_MODEL ?? "claude-haiku-4-5-20251001";
30
+ const CLAUDE_BIN = process.env.CLAUDE_BIN ?? "claude";
31
+
32
+ // ─── Internal helpers ─────────────────────────────────────────────────────────
33
+
34
+ function extractJSON(text) {
35
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
36
+ if (fenced) return JSON.parse(fenced[1].trim());
37
+ const start = text.indexOf("{");
38
+ const end = text.lastIndexOf("}");
39
+ if (start !== -1 && end !== -1) return JSON.parse(text.slice(start, end + 1));
40
+ throw new Error("No JSON found in response");
41
+ }
42
+
43
+ function shortenPath(p, cwd) {
44
+ if (!p || typeof p !== "string") return p;
45
+ if (cwd && p.startsWith(cwd + "/")) return p.slice(cwd.length + 1);
46
+ if (cwd && p === cwd) return ".";
47
+ return p;
48
+ }
49
+
50
+ /**
51
+ * Translate a stream-json `tool_use` block into a user-friendly one-line log.
52
+ * Returns null for tools we don't want to surface (filters out noise).
53
+ */
54
+ function formatToolUse(block, cwd) {
55
+ const name = block?.name;
56
+ const input = block?.input ?? {};
57
+ switch (name) {
58
+ case "Read": {
59
+ const path = shortenPath(input.file_path, cwd);
60
+ return path ? `Reading ${path}` : null;
61
+ }
62
+ case "Glob": {
63
+ if (!input.pattern) return null;
64
+ const where = input.path ? ` in ${shortenPath(input.path, cwd)}` : "";
65
+ return `Globbing ${input.pattern}${where}`;
66
+ }
67
+ case "Grep": {
68
+ if (!input.pattern) return null;
69
+ const where = input.path ? ` in ${shortenPath(input.path, cwd)}` : "";
70
+ return `Grepping ${JSON.stringify(input.pattern)}${where}`;
71
+ }
72
+ default:
73
+ return null;
74
+ }
75
+ }
76
+
77
+ async function runClaude(prompt, jsonSchema, systemPrompt, model, options = {}) {
78
+ const { cwd, allowedTools, onEvent, sessionId, resumeSession, signal } = options;
79
+ if (signal?.aborted) {
80
+ const err = new Error("Claude CLI call cancelled before start.");
81
+ err.name = "AbortError";
82
+ throw err;
83
+ }
84
+ const streaming = typeof onEvent === "function";
85
+ const args = [
86
+ "--print",
87
+ "--output-format", streaming ? "stream-json" : "json",
88
+ ...(streaming ? ["--verbose"] : []),
89
+ "--model", model ?? MODEL,
90
+ "--fallback-model", FALLBACK_MODEL,
91
+ // Kill every tool by default: without this, Claude sometimes picks up
92
+ // TodoWrite/Task/WebSearch and spends turns on them, returning
93
+ // stop_reason=end_turn with an empty result. When the caller explicitly
94
+ // passes allowedTools (e.g. refine with repo access), allow only those.
95
+ // NOTE: we can't use `--bare` — it disables keychain/OAuth reads, which
96
+ // breaks the Claude Code subscription auth path.
97
+ ...(allowedTools?.length
98
+ ? ["--allowed-tools", allowedTools.join(",")]
99
+ : ["--tools", ""]),
100
+ // Skip user/project/local settings (hooks, agents, CLAUDE.md cascade,
101
+ // auto-mode, memory). Loading those was causing extra turns that came
102
+ // back with stop_reason=end_turn and result="". Verified: same prompt
103
+ // goes from failing at turns=2/3 → passing at turns=1 with this flag.
104
+ "--setting-sources", "",
105
+ ];
106
+
107
+ // Session continuity for multi-turn refine:
108
+ // resumeSession=true → --resume <uuid> (replays the conversation so Claude
109
+ // keeps its prior reasoning, tool-call history, and prompt cache).
110
+ // sessionId without resume → --session-id <uuid> (first turn; fixes the
111
+ // UUID so subsequent turns can resume by the id we own).
112
+ if (resumeSession && sessionId) {
113
+ args.push("--resume", sessionId);
114
+ } else if (sessionId) {
115
+ args.push("--session-id", sessionId);
116
+ }
117
+
118
+ // NOTE: we deliberately do NOT pass --json-schema anymore. The CLI's schema
119
+ // validator rejects the model's first turn and retries on a second turn; if
120
+ // that also fails, we get stop_reason=end_turn with result="". The prompts
121
+ // already tell Claude to emit raw JSON and `extractJSON` is lenient enough
122
+ // to unwrap a stray fence or preamble. We validate shape on our side instead.
123
+ void jsonSchema;
124
+ // On resume, the system prompt is baked into the session from the first
125
+ // turn — re-passing it would stack a second system message.
126
+ if (systemPrompt && !resumeSession) args.push("--system-prompt", systemPrompt);
127
+
128
+ return new Promise((resolve, reject) => {
129
+ // Strip ANTHROPIC_API_KEY so the CLI uses the OAuth session
130
+ // (Claude Code subscription) rather than falling back to API credits.
131
+ const { ANTHROPIC_API_KEY: _removed, ...cleanEnv } = process.env;
132
+ const proc = spawn(CLAUDE_BIN, args, {
133
+ env: { ...cleanEnv, FORCE_COLOR: "0" },
134
+ stdio: ["pipe", "pipe", "pipe"],
135
+ ...(cwd ? { cwd } : {}),
136
+ });
137
+
138
+ let stdout = "";
139
+ let stderr = "";
140
+ let streamBuffer = "";
141
+ let streamedResult = null;
142
+
143
+ const timer = setTimeout(() => {
144
+ proc.kill();
145
+ reject(new Error("claude CLI timed out after 5 minutes"));
146
+ }, 300_000);
147
+
148
+ // External abort (e.g. user cancelled the analysis). Kill the subprocess
149
+ // so we stop billing the subscription / token window for work the user
150
+ // has already discarded.
151
+ const onAbort = () => {
152
+ try { proc.kill(); } catch { /* ignore */ }
153
+ clearTimeout(timer);
154
+ const err = new Error("Claude CLI call cancelled.");
155
+ err.name = "AbortError";
156
+ reject(err);
157
+ };
158
+ signal?.addEventListener("abort", onAbort, { once: true });
159
+ proc.on("close", () => { signal?.removeEventListener("abort", onAbort); });
160
+
161
+ proc.stdout.on("data", (d) => {
162
+ stdout += d;
163
+ if (!streaming) return;
164
+ streamBuffer += d.toString("utf8");
165
+ let nl;
166
+ while ((nl = streamBuffer.indexOf("\n")) !== -1) {
167
+ const line = streamBuffer.slice(0, nl).trim();
168
+ streamBuffer = streamBuffer.slice(nl + 1);
169
+ if (!line) continue;
170
+ let evt;
171
+ try { evt = JSON.parse(line); } catch { continue; }
172
+ if (evt.type === "assistant") {
173
+ const blocks = evt.message?.content ?? [];
174
+ for (const block of blocks) {
175
+ if (block.type !== "tool_use") continue;
176
+ const msg = formatToolUse(block, cwd);
177
+ if (msg) {
178
+ try { onEvent({ type: "log", message: msg }); } catch { /* swallow */ }
179
+ }
180
+ }
181
+ } else if (evt.type === "result") {
182
+ streamedResult = evt;
183
+ }
184
+ }
185
+ });
186
+ proc.stderr.on("data", (d) => { stderr += d; });
187
+
188
+ proc.on("close", (code) => {
189
+ clearTimeout(timer);
190
+ if (code !== 0) {
191
+ try {
192
+ const wrapper = streamedResult ?? JSON.parse(stdout.trim());
193
+ const msg = wrapper.result || wrapper.error || stderr.trim() || stdout.trim();
194
+ reject(new Error(`claude CLI error: ${msg}`));
195
+ } catch {
196
+ reject(new Error(`claude CLI error: ${stderr.trim() || stdout.trim()}`));
197
+ }
198
+ return;
199
+ }
200
+ let wrapper;
201
+ if (streaming) {
202
+ if (!streamedResult) {
203
+ reject(new Error(`claude CLI stream ended without a result event (${stdout.length} bytes). First 200 chars: ${stdout.slice(0, 200)}`));
204
+ return;
205
+ }
206
+ wrapper = streamedResult;
207
+ } else {
208
+ try {
209
+ wrapper = JSON.parse(stdout.trim());
210
+ } catch {
211
+ reject(new Error(`claude CLI returned non-JSON output (${stdout.length} bytes). First 200 chars: ${stdout.slice(0, 200)}`));
212
+ return;
213
+ }
214
+ }
215
+
216
+ const result = wrapper.result ?? "";
217
+ if (!result.trim()) {
218
+ // Claude succeeded but emitted no content. Usually means the model
219
+ // got stuck in a tool-use loop or was blocked by --disallowed-tools.
220
+ // Surface a clean, actionable message instead of dumping the wrapper.
221
+ const stopReason = wrapper.stop_reason ?? "unknown";
222
+ const turns = wrapper.num_turns ?? 0;
223
+ const hint = stopReason === "tool_use"
224
+ ? "model tried to use a blocked tool — retry or allow the tool."
225
+ : "model produced empty output — retry or switch model.";
226
+ reject(new Error(`Claude returned no content (stop_reason: ${stopReason}, turns: ${turns}). ${hint}`));
227
+ return;
228
+ }
229
+
230
+ let data;
231
+ try {
232
+ data = extractJSON(result);
233
+ } catch (err) {
234
+ reject(new Error(`Claude response was not valid JSON — ${err.message}. First 200 chars of result: ${result.slice(0, 200)}`));
235
+ return;
236
+ }
237
+
238
+ // The CLI echoes the session id it actually used. If it doesn't match
239
+ // the one we passed (CLI version drift, resume silently forked to a
240
+ // new session, etc.), warn — the caller should persist the CLI's id
241
+ // going forward or we'll resume the wrong conversation next turn.
242
+ const wrapperSessionId = wrapper.session_id ?? null;
243
+ if (sessionId && wrapperSessionId && wrapperSessionId !== sessionId) {
244
+ console.warn(
245
+ `[claudeClient] session_id mismatch: passed ${sessionId}, CLI returned ${wrapperSessionId}. Using CLI value.`,
246
+ );
247
+ }
248
+
249
+ resolve({
250
+ data,
251
+ cost: wrapper.total_cost_usd ?? 0,
252
+ serviceTier: wrapper.usage?.service_tier ?? "unknown",
253
+ inputTokens: wrapper.usage?.input_tokens ?? 0,
254
+ outputTokens: wrapper.usage?.output_tokens ?? 0,
255
+ cacheCreationTokens: wrapper.usage?.cache_creation_input_tokens ?? 0,
256
+ cacheReadTokens: wrapper.usage?.cache_read_input_tokens ?? 0,
257
+ sessionId: wrapperSessionId ?? sessionId ?? null,
258
+ });
259
+ });
260
+
261
+ proc.stdin.write(prompt, "utf8");
262
+ proc.stdin.end();
263
+ });
264
+ }
265
+
266
+ // ─── Public API ───────────────────────────────────────────────────────────────
267
+
268
+ export async function checkResolution(issue, diffs, model, opts = {}) {
269
+ const { data, cost, serviceTier, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens } = await runClaude(
270
+ buildCheckResolutionPrompt(issue, diffs),
271
+ RESOLUTION_CHECK_SCHEMA,
272
+ "You are Forge, a code review assistant. Assess whether code changes actually fix the reported issue. Be concise and direct.",
273
+ model,
274
+ { signal: opts.signal },
275
+ );
276
+ return { ...data, cost, serviceTier, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens };
277
+ }
278
+
279
+ export async function identifyRelevantFiles(issue, fileTree, model, opts = {}) {
280
+ const { data, cost, serviceTier, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens } = await runClaude(
281
+ buildIdentifyFilesPrompt(issue, fileTree),
282
+ SELECT_FILES_SCHEMA,
283
+ "You are Forge, a code review assistant. You only output raw JSON — never markdown or prose.",
284
+ model,
285
+ { signal: opts.signal },
286
+ );
287
+ return { ...data, cost, serviceTier, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens };
288
+ }
289
+
290
+ export async function refineOrAsk(issue, fileContents, options = {}, model, onEvent, opts = {}) {
291
+ const { repoCwd, sessionId, resumeSession } = options;
292
+ const hasRepoAccess = Boolean(repoCwd);
293
+ const accessLine = hasRepoAccess
294
+ ? `You ARE running inside a checkout of the repo at the current working directory, with the user's proposed changes already overlaid on the working tree. You can use Read, Glob, and Grep to inspect any file in the repo. Use them when you need context beyond the files listed in the prompt.`
295
+ : `The prompt lists the file paths in the repo and the full content of the files currently in scope. Treat those as authoritative — do NOT claim you lack repo access. If you need a file that isn't attached, name the path and ask the user to share it.`;
296
+
297
+ // When resuming, the system prompt + issue/files/previousChanges are already
298
+ // in Claude's context from the initial turn — send only what's new.
299
+ const prompt = resumeSession
300
+ ? buildRefineResumePrompt(options)
301
+ : buildRefineOrAskPrompt(issue, fileContents, options);
302
+
303
+ const runOptions = hasRepoAccess
304
+ ? { cwd: repoCwd, allowedTools: ["Read", "Glob", "Grep"], onEvent }
305
+ : { onEvent };
306
+ if (sessionId) runOptions.sessionId = sessionId;
307
+ if (resumeSession) runOptions.resumeSession = true;
308
+ if (opts.signal) runOptions.signal = opts.signal;
309
+
310
+ const { data, cost, serviceTier, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, sessionId: returnedSessionId } = await runClaude(
311
+ prompt,
312
+ REFINE_OR_ASK_SCHEMA,
313
+ `You are Forge, an AI code assistant in a production React/TypeScript/Express codebase.
314
+ ${accessLine}
315
+ You auto-classify the user's message: answer questions about state, or produce a refined code change when given a directive.
316
+ Rules when producing changes:
317
+ - Changes must be minimal and surgical.
318
+ - Match the existing code style exactly (TypeScript strict, no barrel imports, Tailwind for UI).
319
+ - For "proposed", always return the COMPLETE file content.
320
+ - Maximum 5 files changed.
321
+ - Never add console.* — use the existing log utility.
322
+ - Never add comments unless the logic is non-obvious.
323
+ Rules when answering:
324
+ - Ground statements in the validation state, the proposed changes, and (when available) files you read from the working tree.
325
+ - If validation hasn't been run yet, say so plainly.
326
+ - Be concise — one to four short paragraphs at most.
327
+ Your final message MUST be a single raw JSON object matching the schema — no prose, no code fences, no preamble.`,
328
+ model,
329
+ runOptions,
330
+ );
331
+ return { ...data, cost, serviceTier, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, sessionId: returnedSessionId };
332
+ }
333
+
334
+ export async function proposeFix(issue, fileContents, options = {}, model, opts = {}) {
335
+ const { data, cost, serviceTier, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens } = await runClaude(
336
+ buildProposeFixPrompt(issue, fileContents, options),
337
+ PROPOSE_FIX_SCHEMA,
338
+ `You are Forge, an AI code assistant in a production React/TypeScript/Express codebase.
339
+ Rules:
340
+ - Changes must be minimal and surgical.
341
+ - Match the existing code style exactly (TypeScript strict, no barrel imports, Tailwind for UI).
342
+ - For "proposed", always return the COMPLETE file content, not a diff or snippet.
343
+ - Maximum 5 files changed.
344
+ - Never add console.* — use the existing log utility.
345
+ - Never add comments unless the logic is non-obvious.
346
+ - Before returning, verify TypeScript correctness: no implicit any, no missing imports, no type mismatches, all imported symbols must actually be exported from their source.
347
+ - If fixing a CI failure, address only what the logs report — do not change unrelated code.
348
+ Root-cause discipline:
349
+ - A fix that only improves an error message or adds a guard at the point of failure is a symptom suppressor, not a fix. Reject it and find the upstream origin of the bad input.
350
+ - When a value is undefined/null at a check, trace one or two frames up (the caller of the function, the prop passed into the component, the handler bound to an onClick). Default function parameters are defeated when the caller passes an unexpected argument (e.g. React forwarding a MouseEvent) — look for that class of bug before patching the consumer.
351
+ - In "explanation", name the origin of the bad value and state why the reported symptom can no longer occur after your change.`,
352
+ model,
353
+ { signal: opts.signal },
354
+ );
355
+ return { ...data, cost, serviceTier, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens };
356
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Forge persistent config.
3
+ *
4
+ * Storage contract
5
+ * ────────────────
6
+ * A single JSON blob on disk (default: ~/.forge/config.json). Loaded once at
7
+ * process start into an in-memory cache so route handlers read synchronously.
8
+ * Writes are atomic (tmp-file + rename) and serialized to avoid interleaving.
9
+ *
10
+ * Swap to Redis or another backend by re-exporting a different instance from
11
+ * this module — callers import `get()` / `patch()` and don't know the source.
12
+ */
13
+
14
+ import fsSync from "node:fs";
15
+ import fs from "node:fs/promises";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+
19
+ /** @typedef {{ id: string, label: string, projectType?: "auto" | "node" | "go" }} ForgeRepo */
20
+
21
+ /**
22
+ * @typedef {{ baseUrl: string, token: string }} GitLabConnection
23
+ * @typedef {{ token: string }} GitHubConnection
24
+ * @typedef {{ token: string }} AzureDevOpsConnection
25
+ *
26
+ * @typedef {{
27
+ * repos: ForgeRepo[],
28
+ * defaults: {
29
+ * model: string | null,
30
+ * agent: string | null,
31
+ * autoPushOnValidatePass: boolean,
32
+ * refineRepoAccess: boolean,
33
+ * },
34
+ * connections: {
35
+ * gitlab: GitLabConnection,
36
+ * github: GitHubConnection,
37
+ * azuredevops: AzureDevOpsConnection,
38
+ * }
39
+ * }} ForgeConfig
40
+ */
41
+
42
+ const BUILTIN_BASE_URLS = {
43
+ gitlab: "https://gitlab.com/api/v4",
44
+ github: "https://api.github.com",
45
+ };
46
+
47
+ /** @type {ForgeConfig} */
48
+ const DEFAULT_CONFIG = {
49
+ repos: [],
50
+ defaults: {
51
+ model: null,
52
+ agent: null,
53
+ autoPushOnValidatePass: false,
54
+ // When true (default), Refine gets Read/Glob/Grep access to a local
55
+ // clone of the primary repo. Off → lighter "file tree in prompt" flow.
56
+ // The FORGE_REFINE_REPO_ACCESS env var, when set, overrides this.
57
+ refineRepoAccess: true,
58
+ // Test-deploy URL surfaced next to "Approve & Create MR" when the
59
+ // branch pipeline is green. Plain URL (e.g. "https://test.example.com")
60
+ // for a shared env; supports "{branch}" / "{slug}" placeholders when
61
+ // moving to ephemeral per-branch deploys.
62
+ testDeployUrlTemplate: "",
63
+ },
64
+ connections: {
65
+ gitlab: { baseUrl: BUILTIN_BASE_URLS.gitlab, token: "" },
66
+ github: { token: "" },
67
+ azuredevops: { token: "" },
68
+ },
69
+ };
70
+
71
+ function configPath() {
72
+ return process.env.FORGE_CONFIG_PATH ?? path.join(os.homedir(), ".forge", "config.json");
73
+ }
74
+
75
+ /**
76
+ * Synchronous load — called once at startup so the rest of the routes can
77
+ * read config without awaiting. If the file is missing we return defaults;
78
+ * if it's malformed we warn and return defaults (don't crash the server over
79
+ * a bad config).
80
+ */
81
+ function loadSync() {
82
+ const p = configPath();
83
+ try {
84
+ const raw = fsSync.readFileSync(p, "utf8");
85
+ const parsed = JSON.parse(raw);
86
+ return mergeDeep(DEFAULT_CONFIG, parsed);
87
+ } catch (err) {
88
+ if (err.code === "ENOENT") return structuredClone(DEFAULT_CONFIG);
89
+ console.warn(`[forge] config file at ${p} is unreadable (${err.message}); using defaults`);
90
+ return structuredClone(DEFAULT_CONFIG);
91
+ }
92
+ }
93
+
94
+ /** @type {ForgeConfig} */
95
+ let cache = loadSync();
96
+
97
+ /** Serializes writes so two concurrent PUTs can't interleave. */
98
+ let inFlight = Promise.resolve();
99
+
100
+ async function writeToDisk(cfg) {
101
+ const p = configPath();
102
+ await fs.mkdir(path.dirname(p), { recursive: true });
103
+ const tmp = `${p}.${process.pid}.${Date.now()}.tmp`;
104
+ await fs.writeFile(tmp, JSON.stringify(cfg, null, 2), "utf8");
105
+ // 0600 — owner read/write only. Belt-and-suspenders for when tokens land here.
106
+ await fs.chmod(tmp, 0o600);
107
+ await fs.rename(tmp, p);
108
+ }
109
+
110
+ function mergeDeep(base, patch) {
111
+ const out = Array.isArray(base) ? [...base] : { ...base };
112
+ for (const [k, v] of Object.entries(patch)) {
113
+ const bv = base?.[k];
114
+ if (
115
+ v !== null &&
116
+ typeof v === "object" &&
117
+ !Array.isArray(v) &&
118
+ bv !== null &&
119
+ typeof bv === "object" &&
120
+ !Array.isArray(bv)
121
+ ) {
122
+ out[k] = mergeDeep(bv, v);
123
+ } else {
124
+ out[k] = v;
125
+ }
126
+ }
127
+ return out;
128
+ }
129
+
130
+ /** Returns the current cached config. Synchronous. */
131
+ export function get() {
132
+ return cache;
133
+ }
134
+
135
+ /**
136
+ * Deep-merges `patch` into the current config, persists, and updates the
137
+ * cache. Arrays are replaced (not merged) so callers send the whole repos
138
+ * list when editing it.
139
+ */
140
+ export function patch(delta) {
141
+ inFlight = inFlight.then(async () => {
142
+ const next = mergeDeep(cache, delta);
143
+ await writeToDisk(next);
144
+ cache = next;
145
+ return next;
146
+ });
147
+ return inFlight;
148
+ }
149
+
150
+ /** Escape hatch for tests / explicit reload from disk. */
151
+ export function reload() {
152
+ cache = loadSync();
153
+ return cache;
154
+ }
155
+
156
+ const ENV_TOKEN_KEYS = {
157
+ gitlab: "GITLAB_TOKEN",
158
+ github: "GITHUB_TOKEN",
159
+ azuredevops: "AZURE_DEVOPS_TOKEN",
160
+ };
161
+
162
+ /**
163
+ * Resolved provider connection: config value wins, env var fills gaps, builtin
164
+ * URL is the last fallback. Providers call this instead of reading process.env.
165
+ *
166
+ * @param {"gitlab"|"github"|"azuredevops"} provider
167
+ * @returns {{ baseUrl?: string, token: string }}
168
+ */
169
+ export function getConnection(provider) {
170
+ const fromCfg = cache.connections?.[provider] ?? {};
171
+ const envToken = process.env[ENV_TOKEN_KEYS[provider]] ?? "";
172
+ const baseUrl = fromCfg.baseUrl || BUILTIN_BASE_URLS[provider];
173
+ return {
174
+ ...(baseUrl ? { baseUrl } : {}),
175
+ token: fromCfg.token || envToken,
176
+ };
177
+ }
178
+
179
+ function maskToken(t) {
180
+ if (!t) return "";
181
+ if (t.length <= 4) return "••••";
182
+ return `••••${t.slice(-4)}`;
183
+ }
184
+
185
+ /**
186
+ * Safe view for the API — raw tokens are replaced with a short hint and a
187
+ * boolean so the UI can render state without ever seeing a secret.
188
+ */
189
+ export function getPublic() {
190
+ const cfg = cache;
191
+ const conn = cfg.connections ?? {};
192
+ return {
193
+ repos: cfg.repos,
194
+ defaults: cfg.defaults,
195
+ connections: {
196
+ gitlab: {
197
+ baseUrl: conn.gitlab?.baseUrl ?? BUILTIN_BASE_URLS.gitlab,
198
+ hasToken: Boolean(conn.gitlab?.token || process.env.GITLAB_TOKEN),
199
+ tokenHint: maskToken(conn.gitlab?.token || process.env.GITLAB_TOKEN || ""),
200
+ tokenSource: conn.gitlab?.token ? "config" : process.env.GITLAB_TOKEN ? "env" : "none",
201
+ },
202
+ github: {
203
+ hasToken: Boolean(conn.github?.token || process.env.GITHUB_TOKEN),
204
+ tokenHint: maskToken(conn.github?.token || process.env.GITHUB_TOKEN || ""),
205
+ tokenSource: conn.github?.token ? "config" : process.env.GITHUB_TOKEN ? "env" : "none",
206
+ },
207
+ azuredevops: {
208
+ hasToken: Boolean(conn.azuredevops?.token || process.env.AZURE_DEVOPS_TOKEN),
209
+ tokenHint: maskToken(conn.azuredevops?.token || process.env.AZURE_DEVOPS_TOKEN || ""),
210
+ tokenSource: conn.azuredevops?.token ? "config" : process.env.AZURE_DEVOPS_TOKEN ? "env" : "none",
211
+ },
212
+ },
213
+ };
214
+ }