@ipul-labs/forge 1.14.0 → 1.15.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 (75) hide show
  1. package/README.md +152 -96
  2. package/package.json +14 -22
  3. package/server/forge/core/ForgeAgent.js +42 -15
  4. package/server/forge/core/ForgeAgent.test.js +179 -0
  5. package/server/forge/core/claudeClient.js +27 -5
  6. package/server/forge/core/configStore.js +15 -0
  7. package/server/forge/core/configStore.test.js +89 -0
  8. package/server/forge/core/localRepo.js +4 -2
  9. package/server/forge/core/prompts.js +124 -17
  10. package/server/forge/core/prompts.test.js +219 -0
  11. package/server/forge/core/providers/azuredevops.js +4 -3
  12. package/server/forge/core/providers/github.js +3 -4
  13. package/server/forge/core/providers/gitlab.js +6 -6
  14. package/server/forge/core/validator/clone.js +3 -2
  15. package/server/forge/core/validator.js +3 -2
  16. package/server/forge/routes.js +10 -7
  17. package/dist-lib/favicon.svg +0 -8
  18. package/dist-lib/index.d.ts +0 -1
  19. package/dist-lib/style.css +0 -1
  20. package/dist-lib/ui.d.ts +0 -2
  21. package/dist-lib/ui.js +0 -5946
  22. package/dist-lib/views/Forge/ForgeDashboard.d.ts +0 -6
  23. package/dist-lib/views/Forge/activeJobs.d.ts +0 -29
  24. package/dist-lib/views/Forge/analysisCache.d.ts +0 -64
  25. package/dist-lib/views/Forge/api.d.ts +0 -4
  26. package/dist-lib/views/Forge/components/AnalysisProgress.d.ts +0 -13
  27. package/dist-lib/views/Forge/components/CITerminal.d.ts +0 -44
  28. package/dist-lib/views/Forge/components/DiffViewer.d.ts +0 -37
  29. package/dist-lib/views/Forge/components/ForgeConnect.d.ts +0 -6
  30. package/dist-lib/views/Forge/components/IssueList.d.ts +0 -28
  31. package/dist-lib/views/Forge/components/MRStatus.d.ts +0 -9
  32. package/dist-lib/views/Forge/components/NewTaskModal.d.ts +0 -1
  33. package/dist-lib/views/Forge/components/OpenMRList.d.ts +0 -11
  34. package/dist-lib/views/Forge/components/PipelineStatus.d.ts +0 -12
  35. package/dist-lib/views/Forge/components/PipelinesList.d.ts +0 -9
  36. package/dist-lib/views/Forge/components/RolloutStatus.d.ts +0 -5
  37. package/dist-lib/views/Forge/components/UsageWidget.d.ts +0 -8
  38. package/dist-lib/views/Forge/components/ValidationPanel.d.ts +0 -22
  39. package/dist-lib/views/Forge/context/ForgeProvider.d.ts +0 -23
  40. package/dist-lib/views/Forge/context/ForgeRouterContext.d.ts +0 -22
  41. package/dist-lib/views/Forge/hooks/useConfig.d.ts +0 -54
  42. package/dist-lib/views/Forge/hooks/useForgeDashboard.d.ts +0 -107
  43. package/dist-lib/views/Forge/hooks/useForgeQuota.d.ts +0 -22
  44. package/dist-lib/views/Forge/phases/AnalyzingView.d.ts +0 -1
  45. package/dist-lib/views/Forge/phases/IdleView.d.ts +0 -1
  46. package/dist-lib/views/Forge/phases/MonitoringIssueTab.d.ts +0 -1
  47. package/dist-lib/views/Forge/phases/MonitoringPipelineTab.d.ts +0 -1
  48. package/dist-lib/views/Forge/phases/MonitoringView.d.ts +0 -1
  49. package/dist-lib/views/Forge/phases/ReviewingView.d.ts +0 -1
  50. package/dist-lib/views/Forge/phases/SelectedIssueTab.d.ts +0 -11
  51. package/dist-lib/views/Forge/phases/SelectedMRTab.d.ts +0 -1
  52. package/dist-lib/views/Forge/phases/SelectedView.d.ts +0 -1
  53. package/dist-lib/views/Forge/phases/shared/CloseIssueConfirm.d.ts +0 -1
  54. package/dist-lib/views/Forge/phases/shared/DetailTabs.d.ts +0 -14
  55. package/dist-lib/views/Forge/phases/shared/EnvironmentsPanel.d.ts +0 -9
  56. package/dist-lib/views/Forge/phases/shared/ForgeActivity.d.ts +0 -1
  57. package/dist-lib/views/Forge/phases/shared/IssueHeaderBlock.d.ts +0 -13
  58. package/dist-lib/views/Forge/phases/shared/MergedMRList.d.ts +0 -4
  59. package/dist-lib/views/Forge/phases/shared/PreviousAnalyses.d.ts +0 -1
  60. package/dist-lib/views/Forge/phases/shared/ResolutionCheckPanel.d.ts +0 -6
  61. package/dist-lib/views/Forge/settings/ConnectionRow.d.ts +0 -16
  62. package/dist-lib/views/Forge/settings/ConnectionsSection.d.ts +0 -7
  63. package/dist-lib/views/Forge/settings/DefaultsSection.d.ts +0 -7
  64. package/dist-lib/views/Forge/settings/ReposSection.d.ts +0 -7
  65. package/dist-lib/views/Forge/settings/SettingsView.d.ts +0 -1
  66. package/dist-lib/views/Forge/shell/ConfigBar.d.ts +0 -1
  67. package/dist-lib/views/Forge/shell/ForgeRouter.d.ts +0 -9
  68. package/dist-lib/views/Forge/shell/ForgeShell.d.ts +0 -7
  69. package/dist-lib/views/Forge/shell/Sidebar.d.ts +0 -1
  70. package/dist-lib/views/Forge/shell/SidebarFooterMenu.d.ts +0 -1
  71. package/dist-lib/views/Forge/shell/SidebarNav.d.ts +0 -1
  72. package/dist-lib/views/Forge/shell/SidebarNavItem.d.ts +0 -10
  73. package/dist-lib/views/Forge/shell/TopBar.d.ts +0 -5
  74. package/dist-lib/views/Forge/types.d.ts +0 -235
  75. package/dist-lib/views/Forge/utils.d.ts +0 -11
@@ -0,0 +1,179 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+
3
+ // We test the module-level helpers and ForgeAgent internals by importing the
4
+ // module. The class constructor requires a project, so we mock the provider.
5
+ // For the pure functions (buildCommitSubject, FILE_CAPS), we test them through
6
+ // a thin wrapper or by constructing a ForgeAgent with mocked providers.
7
+
8
+ // ─── Module-level helpers ────────────────────────────────────────────────────
9
+
10
+ // buildCommitSubject and FILE_CAPS are not exported, so we test them through
11
+ // ForgeAgent's public surface or by importing the module and inspecting.
12
+ // Since they're internal, we test via the class methods that use them.
13
+
14
+ import * as configStore from "./configStore.js";
15
+
16
+ // Mock configStore.getPrimaryProject for ForgeAgent constructor
17
+ vi.spyOn(configStore, "getPrimaryProject").mockReturnValue("group/web-app");
18
+ vi.spyOn(configStore, "get").mockReturnValue({
19
+ repos: [{ id: "group/web-app", label: "web-app" }],
20
+ defaults: { promptRules: "" },
21
+ connections: {},
22
+ });
23
+
24
+ // Mock the provider factory
25
+ vi.mock("./providers/index.js", () => ({
26
+ createProvider: () => ({
27
+ getIssue: vi.fn(),
28
+ getFileTree: vi.fn().mockResolvedValue([]),
29
+ getFileContent: vi.fn(),
30
+ createBranch: vi.fn(),
31
+ commitFiles: vi.fn(),
32
+ createMR: vi.fn(),
33
+ listAllForgeMRs: vi.fn().mockResolvedValue([]),
34
+ getMRPipelines: vi.fn().mockResolvedValue([]),
35
+ }),
36
+ }));
37
+
38
+ // Dynamic import after mocks are set up
39
+ const { ForgeAgent } = await import("./ForgeAgent.js");
40
+
41
+ describe("ForgeAgent", () => {
42
+ describe("_capSelections", () => {
43
+ let agent;
44
+
45
+ beforeEach(() => {
46
+ agent = new ForgeAgent({ project: "group/web-app" });
47
+ });
48
+
49
+ it("applies fix caps (12 total, 8 per repo)", () => {
50
+ const entries = Array.from({ length: 15 }, (_, i) => ({
51
+ repo: "group/web-app",
52
+ path: `src/file${i}.ts`,
53
+ }));
54
+
55
+ const capped = agent._capSelections(entries, "fix");
56
+ expect(capped).toHaveLength(8);
57
+ });
58
+
59
+ it("applies feature caps (18 total, 15 per repo)", () => {
60
+ const entries = Array.from({ length: 20 }, (_, i) => ({
61
+ repo: "group/web-app",
62
+ path: `src/file${i}.ts`,
63
+ }));
64
+
65
+ const capped = agent._capSelections(entries, "feature");
66
+ expect(capped).toHaveLength(15);
67
+ });
68
+
69
+ it("enforces total cap across multiple repos for features", () => {
70
+ const entries = [
71
+ ...Array.from({ length: 12 }, (_, i) => ({
72
+ repo: "group/web-app",
73
+ path: `src/file${i}.ts`,
74
+ })),
75
+ ...Array.from({ length: 12 }, (_, i) => ({
76
+ repo: "group/api",
77
+ path: `internal/file${i}.go`,
78
+ })),
79
+ ];
80
+
81
+ const capped = agent._capSelections(entries, "feature");
82
+ expect(capped).toHaveLength(18);
83
+ });
84
+
85
+ it("defaults to fix caps for unknown kinds", () => {
86
+ const entries = Array.from({ length: 15 }, (_, i) => ({
87
+ repo: "group/web-app",
88
+ path: `src/file${i}.ts`,
89
+ }));
90
+
91
+ const capped = agent._capSelections(entries, "unknown");
92
+ expect(capped).toHaveLength(8);
93
+ });
94
+
95
+ it("refactor gets same caps as feature", () => {
96
+ const entries = Array.from({ length: 20 }, (_, i) => ({
97
+ repo: "group/web-app",
98
+ path: `src/file${i}.ts`,
99
+ }));
100
+
101
+ const capped = agent._capSelections(entries, "refactor");
102
+ expect(capped).toHaveLength(15);
103
+ });
104
+ });
105
+
106
+ describe("_normalizeFileSelections", () => {
107
+ let agent;
108
+
109
+ beforeEach(() => {
110
+ agent = new ForgeAgent({ project: "group/web-app" });
111
+ });
112
+
113
+ it("normalizes string entries to primary repo", () => {
114
+ const result = agent._normalizeFileSelections(["src/App.tsx"]);
115
+ expect(result).toEqual([{ repo: "group/web-app", path: "src/App.tsx" }]);
116
+ });
117
+
118
+ it("passes through {repo, path} objects", () => {
119
+ const result = agent._normalizeFileSelections([
120
+ { repo: "group/api", path: "main.go" },
121
+ ]);
122
+ expect(result).toEqual([{ repo: "group/api", path: "main.go" }]);
123
+ });
124
+
125
+ it("defaults repo when only path is present", () => {
126
+ const result = agent._normalizeFileSelections([{ path: "src/a.ts" }]);
127
+ expect(result).toEqual([{ repo: "group/web-app", path: "src/a.ts" }]);
128
+ });
129
+
130
+ it("filters out null and invalid entries", () => {
131
+ const result = agent._normalizeFileSelections([null, undefined, {}, "src/a.ts"]);
132
+ expect(result).toHaveLength(1);
133
+ expect(result[0].path).toBe("src/a.ts");
134
+ });
135
+ });
136
+
137
+ describe("_validateSelections", () => {
138
+ let agent;
139
+
140
+ beforeEach(() => {
141
+ agent = new ForgeAgent({ project: "group/web-app" });
142
+ });
143
+
144
+ it("keeps files that exist in the tree", () => {
145
+ const entries = [{ repo: "group/web-app", path: "src/App.tsx" }];
146
+ const trees = [{ repo: "group/web-app", files: ["src/App.tsx", "src/index.ts"] }];
147
+ const { valid, skipped } = agent._validateSelections(entries, trees);
148
+ expect(valid).toHaveLength(1);
149
+ expect(skipped).toHaveLength(0);
150
+ });
151
+
152
+ it("skips files not in the tree", () => {
153
+ const entries = [{ repo: "group/web-app", path: "src/Missing.tsx" }];
154
+ const trees = [{ repo: "group/web-app", files: ["src/App.tsx"] }];
155
+ const { valid, skipped } = agent._validateSelections(entries, trees);
156
+ expect(valid).toHaveLength(0);
157
+ expect(skipped).toHaveLength(1);
158
+ expect(skipped[0]).toContain("not in tree");
159
+ });
160
+
161
+ it("skips files from unknown repos", () => {
162
+ const entries = [{ repo: "group/unknown", path: "src/a.ts" }];
163
+ const trees = [{ repo: "group/web-app", files: ["src/a.ts"] }];
164
+ const { valid, skipped } = agent._validateSelections(entries, trees);
165
+ expect(valid).toHaveLength(0);
166
+ expect(skipped[0]).toContain("unknown repo");
167
+ });
168
+
169
+ it("deduplicates entries", () => {
170
+ const entries = [
171
+ { repo: "group/web-app", path: "src/App.tsx" },
172
+ { repo: "group/web-app", path: "src/App.tsx" },
173
+ ];
174
+ const trees = [{ repo: "group/web-app", files: ["src/App.tsx"] }];
175
+ const { valid } = agent._validateSelections(entries, trees);
176
+ expect(valid).toHaveLength(1);
177
+ });
178
+ });
179
+ });
@@ -14,6 +14,7 @@ import { spawn } from "child_process";
14
14
  import {
15
15
  SELECT_FILES_SCHEMA,
16
16
  PROPOSE_FIX_SCHEMA,
17
+ CI_FIX_SCHEMA,
17
18
  REFINE_OR_ASK_SCHEMA,
18
19
  RESOLUTION_CHECK_SCHEMA,
19
20
  buildIdentifyFilesPrompt,
@@ -21,6 +22,7 @@ import {
21
22
  buildRefineOrAskPrompt,
22
23
  buildRefineResumePrompt,
23
24
  buildCheckResolutionPrompt,
25
+ classifyIssue,
24
26
  } from "./prompts.js";
25
27
 
26
28
  // FORGE_MODEL env var overrides the default. Set to a cheaper model to reduce cost.
@@ -332,10 +334,25 @@ Your final message MUST be a single raw JSON object matching the schema — no p
332
334
  }
333
335
 
334
336
  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.
337
+ const schema = options.ciContext ? CI_FIX_SCHEMA : PROPOSE_FIX_SCHEMA;
338
+ const kind = classifyIssue(issue);
339
+ const isFeature = kind === "feature" || kind === "refactor";
340
+
341
+ const systemPrompt = isFeature
342
+ ? `You are Forge, an AI code assistant in a production React/TypeScript/Express codebase.
343
+ Rules:
344
+ - Match the existing code style exactly (TypeScript strict, no barrel imports, Tailwind for UI).
345
+ - For "proposed", always return the COMPLETE file content, not a diff or snippet.
346
+ - Maximum 12 files changed. Prioritise core changes over secondary ones — if the feature touches more files than the cap, implement the most important changes and note the rest in "explanation".
347
+ - Never add console.* — use the existing log utility.
348
+ - Never add comments unless the logic is non-obvious.
349
+ - Before returning, verify TypeScript correctness: no implicit any, no missing imports, no type mismatches, all imported symbols must actually be exported from their source.
350
+ Feature discipline:
351
+ - Follow the issue specification. Only make changes the issue asks for — do not clean up, refactor, or "improve" unrelated code.
352
+ - When the issue says to ADD an entry to an existing list or config (nav items, routes, type unions), add the entry — do not restructure the existing code around it.
353
+ - Base new files on the patterns and conventions visible in the relevant files.
354
+ - Use a "feat:" conventional-commit prefix in the title, not "fix:".`
355
+ : `You are Forge, an AI code assistant in a production React/TypeScript/Express codebase.
339
356
  Rules:
340
357
  - Changes must be minimal and surgical.
341
358
  - Match the existing code style exactly (TypeScript strict, no barrel imports, Tailwind for UI).
@@ -348,7 +365,12 @@ Rules:
348
365
  Root-cause discipline:
349
366
  - 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
367
  - 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.`,
368
+ - In "explanation", name the origin of the bad value and state why the reported symptom can no longer occur after your change.`;
369
+
370
+ const { data, cost, serviceTier, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens } = await runClaude(
371
+ buildProposeFixPrompt(issue, fileContents, options),
372
+ schema,
373
+ systemPrompt,
352
374
  model,
353
375
  { signal: opts.signal },
354
376
  );
@@ -30,6 +30,7 @@ import path from "node:path";
30
30
  * agent: string | null,
31
31
  * autoPushOnValidatePass: boolean,
32
32
  * refineRepoAccess: boolean,
33
+ * promptRules: string,
33
34
  * },
34
35
  * connections: {
35
36
  * gitlab: GitLabConnection,
@@ -55,6 +56,9 @@ const DEFAULT_CONFIG = {
55
56
  // clone of the primary repo. Off → lighter "file tree in prompt" flow.
56
57
  // The FORGE_REFINE_REPO_ACCESS env var, when set, overrides this.
57
58
  refineRepoAccess: true,
59
+ // Free-form rules injected into every analysis and refine prompt.
60
+ // Lets the user steer the model without touching code.
61
+ promptRules: "",
58
62
  // Test-deploy URL surfaced next to "Approve & Create MR" when the
59
63
  // branch pipeline is green. Plain URL (e.g. "https://test.example.com")
60
64
  // for a shared env; supports "{branch}" / "{slug}" placeholders when
@@ -153,6 +157,17 @@ export function reload() {
153
157
  return cache;
154
158
  }
155
159
 
160
+ /**
161
+ * Resolves the primary project identifier.
162
+ * Priority: persisted config repos[0] → FORGE_REPOS first entry → FORGE_GITLAB_PROJECT.
163
+ */
164
+ export function getPrimaryProject() {
165
+ const repos = cache.repos;
166
+ if (repos.length > 0) return repos[0].id;
167
+ const raw = process.env.FORGE_REPOS ?? process.env.FORGE_GITLAB_PROJECT ?? "";
168
+ return raw.split(",")[0]?.trim() || null;
169
+ }
170
+
156
171
  const ENV_TOKEN_KEYS = {
157
172
  gitlab: "GITLAB_TOKEN",
158
173
  github: "GITHUB_TOKEN",
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+
3
+ // We test getPrimaryProject and getConnection which resolve from
4
+ // cache (configStore state) → env vars → defaults.
5
+
6
+ import { getPrimaryProject, getConnection, get, reload } from "./configStore.js";
7
+
8
+ describe("getPrimaryProject", () => {
9
+ const originalEnv = { ...process.env };
10
+
11
+ afterEach(() => {
12
+ process.env = { ...originalEnv };
13
+ });
14
+
15
+ it("returns first config repo when config has repos", () => {
16
+ const cfg = get();
17
+ // If the test env has repos in config, this tests the real path.
18
+ // Otherwise we test the env fallback paths below.
19
+ if (cfg.repos.length > 0) {
20
+ expect(getPrimaryProject()).toBe(cfg.repos[0].id);
21
+ }
22
+ });
23
+
24
+ it("falls back to FORGE_REPOS first entry", () => {
25
+ // Temporarily clear config repos by checking current state
26
+ const cfg = get();
27
+ if (cfg.repos.length === 0) {
28
+ process.env.FORGE_REPOS = "group/web-app,group/api";
29
+ expect(getPrimaryProject()).toBe("group/web-app");
30
+ }
31
+ });
32
+
33
+ it("falls back to FORGE_GITLAB_PROJECT when FORGE_REPOS is unset", () => {
34
+ const cfg = get();
35
+ if (cfg.repos.length === 0) {
36
+ delete process.env.FORGE_REPOS;
37
+ process.env.FORGE_GITLAB_PROJECT = "legacy/project";
38
+ expect(getPrimaryProject()).toBe("legacy/project");
39
+ }
40
+ });
41
+
42
+ it("returns null when nothing is configured", () => {
43
+ const cfg = get();
44
+ if (cfg.repos.length === 0) {
45
+ delete process.env.FORGE_REPOS;
46
+ delete process.env.FORGE_GITLAB_PROJECT;
47
+ expect(getPrimaryProject()).toBeNull();
48
+ }
49
+ });
50
+
51
+ it("trims whitespace from FORGE_REPOS entries", () => {
52
+ const cfg = get();
53
+ if (cfg.repos.length === 0) {
54
+ process.env.FORGE_REPOS = " group/web-app , group/api ";
55
+ expect(getPrimaryProject()).toBe("group/web-app");
56
+ }
57
+ });
58
+ });
59
+
60
+ describe("getConnection", () => {
61
+ const originalEnv = { ...process.env };
62
+
63
+ afterEach(() => {
64
+ process.env = { ...originalEnv };
65
+ });
66
+
67
+ it("returns token from env when config has none", () => {
68
+ process.env.GITLAB_TOKEN = "test-token-123";
69
+ const conn = getConnection("gitlab");
70
+ // Config token takes precedence, so if config has one we get that.
71
+ // Either way, we should get a non-empty token.
72
+ expect(conn.token).toBeTruthy();
73
+ });
74
+
75
+ it("returns a baseUrl for gitlab", () => {
76
+ const conn = getConnection("gitlab");
77
+ expect(conn.baseUrl).toContain("gitlab.com");
78
+ });
79
+
80
+ it("returns empty token when nothing is configured", () => {
81
+ delete process.env.GITLAB_TOKEN;
82
+ const cfg = get();
83
+ // Only test if config also has no token
84
+ if (!cfg.connections?.gitlab?.token) {
85
+ const conn = getConnection("gitlab");
86
+ expect(conn.token).toBe("");
87
+ }
88
+ });
89
+ });
@@ -18,6 +18,8 @@ import fs from "fs/promises";
18
18
  import os from "os";
19
19
  import path from "path";
20
20
 
21
+ import { getConnection } from "./configStore.js";
22
+
21
23
  const CACHE_ROOT = process.env.FORGE_VALIDATE_CACHE
22
24
  ?? path.join(os.homedir(), ".forge", "cache");
23
25
 
@@ -53,8 +55,8 @@ function sanitizeRepo(repo) {
53
55
  }
54
56
 
55
57
  function gitlabCloneUrl(project) {
56
- const token = process.env.GITLAB_TOKEN;
57
- if (!token) throw new Error("GITLAB_TOKEN is required to clone the repo locally.");
58
+ const { token } = getConnection("gitlab");
59
+ if (!token) throw new Error("GitLab token is required to clone the repo locally. Set it in Settings → Connections or via GITLAB_TOKEN env var.");
58
60
  return `https://oauth2:${token}@gitlab.com/${project}.git`;
59
61
  }
60
62
 
@@ -61,6 +61,43 @@ export const PROPOSE_FIX_SCHEMA = JSON.stringify({
61
61
  required: ["title", "explanation", "changes"],
62
62
  });
63
63
 
64
+ export const CI_FIX_SCHEMA = JSON.stringify({
65
+ type: "object",
66
+ properties: {
67
+ unfixable: {
68
+ type: "boolean",
69
+ description: "True ONLY when the failure cannot be resolved by any source-code or package.json change (e.g. Docker registry outage, runner OOM, flaky network). Default false.",
70
+ },
71
+ unfixableReason: {
72
+ type: "string",
73
+ description: "Required when unfixable=true. One sentence explaining why no code change can fix this.",
74
+ },
75
+ title: {
76
+ type: "string",
77
+ 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.",
78
+ },
79
+ explanation: {
80
+ type: "string",
81
+ description: "2-4 sentences on root cause and approach. If changes span multiple repos, describe how the pieces fit together.",
82
+ },
83
+ changes: {
84
+ type: "array",
85
+ items: {
86
+ type: "object",
87
+ properties: {
88
+ repo: { type: "string", description: "Repository id this change belongs to, exactly as given in the 'Available repositories' list." },
89
+ file: { type: "string", description: "Path from that repo's root." },
90
+ action: { type: "string", enum: ["create", "modify", "delete"] },
91
+ proposed: { type: "string", description: "For create/modify: the COMPLETE new file content. For delete: empty string." },
92
+ summary: { type: "string", description: "One sentence describing this file's change." },
93
+ },
94
+ required: ["repo", "file", "action", "proposed", "summary"],
95
+ },
96
+ },
97
+ },
98
+ required: ["title", "explanation", "changes"],
99
+ });
100
+
64
101
  export const REFINE_OR_ASK_SCHEMA = JSON.stringify({
65
102
  type: "object",
66
103
  properties: {
@@ -111,6 +148,30 @@ export const RESOLUTION_CHECK_SCHEMA = JSON.stringify({
111
148
  required: ["resolved", "reasoning"],
112
149
  });
113
150
 
151
+ /**
152
+ * Classifies an issue as "feature", "fix", or "chore" from its title, labels,
153
+ * and description. Used to select the right prompt guidance and file caps.
154
+ *
155
+ * Priority: conventional-commit title prefix > labels > description heuristics > default "fix".
156
+ */
157
+ export function classifyIssue(issue) {
158
+ const title = (issue.title ?? "").toLowerCase();
159
+ const labels = (issue.labels ?? []).map((l) => (typeof l === "string" ? l : l.name ?? "").toLowerCase());
160
+
161
+ if (/^feat[\s(:/!]/.test(title)) return "feature";
162
+ if (/^fix[\s(:/!]/.test(title)) return "fix";
163
+ if (/^chore[\s(:/!]/.test(title)) return "chore";
164
+ if (/^refactor[\s(:/!]/.test(title)) return "refactor";
165
+
166
+ if (labels.some((l) => l.includes("feature") || l.includes("enhancement"))) return "feature";
167
+ if (labels.some((l) => l.includes("bug") || l.includes("defect"))) return "fix";
168
+
169
+ const desc = (issue.description ?? "").toLowerCase();
170
+ if (desc.includes("## approach") || desc.includes("## frontend changes") || desc.includes("## backend")) return "feature";
171
+
172
+ return "fix";
173
+ }
174
+
114
175
  /**
115
176
  * Builds a human-readable block listing each repo's file tree.
116
177
  * Accepts either:
@@ -161,17 +222,32 @@ export function buildIdentifyFilesPrompt(issue, repoTrees) {
161
222
  ? `\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
223
  : "";
163
224
 
225
+ const kind = classifyIssue(issue);
226
+ const isFeature = kind === "feature" || kind === "refactor";
227
+
228
+ const selectionRules = isFeature
229
+ ? `Selection rules (feature implementation):
230
+ - Focus on files that ALREADY EXIST and need modification to support the new feature.
231
+ - Include files that serve as PATTERNS for new files the issue asks you to create (e.g. an existing page component when a new page route is needed, an existing hook when a new hook is needed).
232
+ - Include shared registries and type files that need new entries (routes, nav items, type definitions, API endpoints).
233
+ - When the issue specifies exact file paths to modify, prefer those paths.
234
+ - Do NOT select files for changes the issue doesn't mention — follow the spec, don't invent work.
235
+ - If the issue references an existing UI element (e.g. sidebar, navbar) and only asks to ADD an entry, select the data/config file that drives that element, not the rendering component (unless the issue explicitly says to change rendering logic).`
236
+ : `Selection rules (bug fix):
237
+ - 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.
238
+ - When a bug points at an event handler or React component, include the component that renders the handler AND the handler's implementation.
239
+ - Prefer fewer files over more, but never omit a caller when the reported symptom is a downstream consequence of input received from that caller.`;
240
+
241
+ const maxFiles = isFeature ? 15 : 10;
242
+
164
243
  return `Respond with ONLY a raw JSON object — no markdown, no prose, no code fences.
165
244
 
166
245
  Schema: {"files": [{"repo": "<repo id>", "path": "src/foo/bar.ts"}], "reasoning": "one or two sentences"}
167
246
 
168
- Identify the minimum set of source files (max 10 across all repos) relevant to fixing this issue.
247
+ Identify the ${isFeature ? "set" : "minimum set"} of source files (max ${maxFiles} across all repos) relevant to ${isFeature ? "implementing" : "fixing"} this issue.
169
248
  Always use an exact repo id from the "Available repositories" list below — do not invent or abbreviate.
170
249
 
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.
250
+ ${selectionRules}
175
251
 
176
252
  ## Issue #${issue.iid}: ${issue.title}
177
253
  ${issue.description || "(no description)"}
@@ -185,9 +261,11 @@ ${renderRepoTrees(repoTrees)}`;
185
261
  }
186
262
 
187
263
  export function buildProposeFixPrompt(issue, fileContents, options = {}) {
188
- const { feedback, previousChanges, ciContext, userPrompt, repos } = options;
264
+ const { feedback, previousChanges, ciContext, userPrompt, repos, promptRules } = options;
189
265
 
190
266
  const filesBlock = renderFileContents(fileContents);
267
+ const kind = classifyIssue(issue);
268
+ const isFeature = kind === "feature" || kind === "refactor";
191
269
 
192
270
  const previousBlock = previousChanges?.length
193
271
  ? `\n\n## Previous proposed changes\n${renderPreviousChanges(previousChanges)}`
@@ -198,7 +276,14 @@ export function buildProposeFixPrompt(issue, fileContents, options = {}) {
198
276
  : "";
199
277
 
200
278
  const ciBlock = ciContext?.trim()
201
- ? `\n\n## CI pipeline failure logs\nThe pipeline failed. Propose changes that fix the pipeline.\n\n${ciContext.trim()}`
279
+ ? `\n\n## CI pipeline failure logs
280
+ The pipeline failed. Read the logs carefully and classify the failure:
281
+
282
+ 1. **Code/test failure** — broken imports, type errors, failing assertions → propose the source-code fix.
283
+ 2. **Dependency audit failure** (npm audit, yarn audit) — a package has a known vulnerability → update the version constraint in package.json. Do NOT touch package-lock.json; the lock file will be regenerated automatically.
284
+ 3. **Unfixable infrastructure failure** — Docker registry outage, runner OOM, network timeout, flaky CI runner → set "unfixable": true and explain why in "unfixableReason". Return an empty "changes" array.
285
+
286
+ ${ciContext.trim()}`
202
287
  : "";
203
288
 
204
289
  const userBlock = userPrompt?.trim()
@@ -213,21 +298,39 @@ export function buildProposeFixPrompt(issue, fileContents, options = {}) {
213
298
  ? `\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
299
  : "";
215
300
 
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.
301
+ const rulesBlock = promptRules?.trim()
302
+ ? `\n\n## Custom rules (from project settings — always follow these)\n${promptRules.trim()}`
303
+ : "";
221
304
 
222
- Root-cause discipline (read BEFORE writing any change):
305
+ const titlePrefix = isFeature ? "feat" : "fix";
306
+ const titleExample = isFeature
307
+ ? `"feat: add browseable mixes section with platinum-gated publishing"`
308
+ : `"fix: resolve token refresh race condition in AuthService"`;
309
+
310
+ const discipline = isFeature
311
+ ? `Feature implementation rules (read BEFORE writing any change):
312
+ 1. Follow the issue specification precisely. If the issue says to modify a specific file, modify that file. If it says to create a new file, create it. Do NOT invent changes the issue doesn't ask for.
313
+ 2. When the issue references existing components or patterns to reuse, base your implementation on the existing patterns in the "Relevant files" section.
314
+ 3. For new files, follow the naming conventions, import style, and structure of the existing codebase.
315
+ 4. Be aware of existing routes, tabs, and UI sections that may OVERLAP with what you're implementing. If the issue creates a new "/mixes" route, check whether the codebase already has mixes-related UI elsewhere (e.g. a tab inside another page) and note any conflict in "explanation".
316
+ 5. In your "explanation", state (a) which existing patterns you followed, (b) what new files you created, and (c) any conflicts or considerations the reviewer should know about.`
317
+ : `Root-cause discipline (read BEFORE writing any change):
223
318
  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
319
  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
320
  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.
321
+ 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.`;
322
+
323
+ return `Respond with ONLY a raw JSON object — no markdown, no prose, no code fences.
324
+
325
+ Schema: {"title": "${titlePrefix}: concise description of the actual change (max 72 chars)", "explanation": "...", "changes": [{"repo": "<repo id>", "file": "...", "action": "modify|create|delete", "proposed": "<full file content>", "summary": "..."}]}
326
+
327
+ ${isFeature ? "Implement this feature." : "Propose a targeted fix for this issue."}
328
+
329
+ ${discipline}
227
330
 
228
331
  ## Issue #${issue.iid}: ${issue.title}
229
332
  ${issue.description || "(no description)"}
230
- Labels: ${issue.labels?.join(", ") || "none"}${reposLine}${multiNote}${previousBlock}${feedbackBlock}${ciBlock}${userBlock}
333
+ Labels: ${issue.labels?.join(", ") || "none"}${reposLine}${multiNote}${rulesBlock}${previousBlock}${feedbackBlock}${ciBlock}${userBlock}
231
334
 
232
335
  ## Relevant files
233
336
  ${filesBlock}`;
@@ -264,7 +367,7 @@ function renderValidationState(state) {
264
367
  * responds with EITHER a grounded answer OR a fresh changes[] payload.
265
368
  */
266
369
  export function buildRefineOrAskPrompt(issue, fileContents, options = {}) {
267
- const { feedback, previousChanges, validationState, repos, repoTrees, repoAccessMode } = options;
370
+ const { feedback, previousChanges, validationState, repos, repoTrees, repoAccessMode, promptRules } = options;
268
371
 
269
372
  const filesBlock = renderFileContents(fileContents);
270
373
  const previousBlock = previousChanges?.length
@@ -283,6 +386,10 @@ export function buildRefineOrAskPrompt(issue, fileContents, options = {}) {
283
386
  ? `\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
387
  : `\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
388
 
389
+ const rulesBlock = promptRules?.trim()
390
+ ? `\n\n## Custom rules (from project settings — always follow these)\n${promptRules.trim()}`
391
+ : "";
392
+
286
393
  return `Respond with ONLY a raw JSON object — no markdown, no prose, no code fences.
287
394
 
288
395
  Schema: {
@@ -312,7 +419,7 @@ If the message is ambiguous, prefer "answer" and ask a clarifying question.
312
419
 
313
420
  ## Issue #${issue.iid}: ${issue.title}
314
421
  ${issue.description || "(no description)"}
315
- Labels: ${issue.labels?.join(", ") || "none"}${reposLine}${accessBlock}${treeBlock}${previousBlock}${validationBlock}
422
+ Labels: ${issue.labels?.join(", ") || "none"}${reposLine}${accessBlock}${treeBlock}${rulesBlock}${previousBlock}${validationBlock}
316
423
 
317
424
  ## Relevant files (current state as of the proposed changes)
318
425
  ${filesBlock}