@openpome/local-gateway 0.23.0-alpha.0 → 0.26.0-alpha.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.
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { existsSync } from "node:fs";
4
4
  import { createServer } from "node:http";
5
5
  import { mkdir, opendir, readFile, rm, writeFile } from "node:fs/promises";
6
6
  import { homedir } from "node:os";
7
- import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:path";
7
+ import { basename, delimiter, dirname, isAbsolute, join, relative, resolve } from "node:path";
8
8
  import { promisify } from "node:util";
9
9
  import { defaultConfig } from "@openpome/configuration";
10
10
  import { createCredentialStore, getJsonCredential, setJsonCredential } from "@openpome/credentials";
@@ -39,7 +39,7 @@ const maxWorkspaceScanRepositories = 200;
39
39
  export function getGatewayHealth() {
40
40
  return {
41
41
  status: "ok",
42
- version: "0.23.0-alpha.0"
42
+ version: "0.26.0-alpha.0"
43
43
  };
44
44
  }
45
45
  export async function initOpenPome() {
@@ -465,8 +465,11 @@ export async function getTaskSessionStatus() {
465
465
  testRunEvidence: persisted.testRunEvidence ?? [],
466
466
  prDraft: persisted.prDraft,
467
467
  workItemUpdateDraft: persisted.workItemUpdateDraft,
468
+ prCreation: persisted.prCreation,
469
+ workItemUpdatePost: persisted.workItemUpdatePost,
468
470
  aiContext: persisted.aiContext,
469
- diffSummary: persisted.diffSummary
471
+ diffSummary: persisted.diffSummary,
472
+ aiPatchProposal: persisted.aiPatchProposal
470
473
  };
471
474
  }
472
475
  export async function getTaskSessionTimeline() {
@@ -749,6 +752,157 @@ export async function rejectTaskSessionPlan(reason = "Plan rejected by developer
749
752
  nextStep: "Revise the work item context or workspace link, then run `pome plan` again."
750
753
  };
751
754
  }
755
+ export async function createAIPatchProposal() {
756
+ const paths = getOpenPomePaths();
757
+ const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
758
+ if (!persisted) {
759
+ return {
760
+ active: false,
761
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
762
+ nextStep: "Run `pome start <KEY>` first."
763
+ };
764
+ }
765
+ if (persisted.aiPatchProposal && !persisted.aiPatchProposal.appliedAt) {
766
+ return {
767
+ active: true,
768
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
769
+ session: persisted.session,
770
+ proposal: persisted.aiPatchProposal,
771
+ workspacePath: persisted.workspaceCandidate?.workspace.path,
772
+ nextStep: "Review the proposed file changes, then run `pome approve` to apply them."
773
+ };
774
+ }
775
+ if (!persisted.plan) {
776
+ throw new Error("No implementation plan is available. Run `pome plan` first.");
777
+ }
778
+ if (persisted.planApproval?.status !== "approved") {
779
+ throw new Error("The implementation plan is not approved yet. Run `pome approve` first.");
780
+ }
781
+ const workspacePath = persisted.workspaceCandidate?.workspace.path;
782
+ if (!workspacePath) {
783
+ throw new Error("No workspace path is available for AI implementation. Open the repo and run `pome start <KEY>` again, or link it with `pome workspace link <KEY> <PATH>`.");
784
+ }
785
+ const config = await readConfigIfPresent(paths.configFile);
786
+ const provider = normalizeModelProviderId(config?.activeModelProvider ?? defaultConfig.activeModelProvider);
787
+ if (provider === "manual-copy") {
788
+ throw new Error("Manual-copy mode cannot apply code. Run `pome auth ai openai` or `pome auth ai claude` to enable approval-gated AI patches.");
789
+ }
790
+ const apiKey = await getModelProviderApiKey(provider);
791
+ if (!apiKey) {
792
+ throw new Error(`${getModelProviderDisplayName(provider)} is active, but no API key is configured. Run \`pome auth ai ${provider === "anthropic" ? "claude" : provider}\`.`);
793
+ }
794
+ const createdAt = new Date().toISOString();
795
+ const fileContext = await collectPatchContextFiles(workspacePath, persisted);
796
+ const prompt = buildStructuredPatchPrompt(persisted, workspacePath, fileContext);
797
+ const response = provider === "openai"
798
+ ? await completeOpenAIText(prompt, apiKey)
799
+ : await completeAnthropicText(prompt, apiKey);
800
+ const proposalDraft = parseAIPatchProposal(response, persisted, provider, workspacePath, createdAt);
801
+ const approval = createFileEditApproval(persisted, proposalDraft, createdAt, "Developer approval is required before OpenPome writes AI-proposed file changes.");
802
+ const proposal = {
803
+ ...proposalDraft,
804
+ approval
805
+ };
806
+ const session = {
807
+ ...persisted.session,
808
+ status: "awaiting_approval",
809
+ updatedAt: createdAt
810
+ };
811
+ await writeActiveTaskSession(paths.homeDirectory, {
812
+ ...persisted,
813
+ session,
814
+ aiPatchProposal: proposal,
815
+ approvalHistory: appendApprovalHistory(persisted.approvalHistory, approval),
816
+ events: appendSessionEvents(persisted.events, [
817
+ createSessionEvent(session, persisted.workItem.key, "approval_requested", "AI file changes proposed", createdAt, [
818
+ `Provider: ${getModelProviderDisplayName(provider)}`,
819
+ `Files proposed: ${proposal.files.map((file) => file.path).join(", ") || "none"}`,
820
+ ...approval.details
821
+ ], {
822
+ approvalId: approval.id,
823
+ approvalType: approval.type
824
+ })
825
+ ])
826
+ });
827
+ return {
828
+ active: true,
829
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
830
+ session,
831
+ proposal,
832
+ workspacePath,
833
+ nextStep: "Review the proposed file changes, then run `pome approve` to apply them."
834
+ };
835
+ }
836
+ export async function approveAndApplyAIPatchProposal() {
837
+ const paths = getOpenPomePaths();
838
+ const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
839
+ if (!persisted) {
840
+ return undefined;
841
+ }
842
+ const proposal = persisted.aiPatchProposal;
843
+ if (!proposal) {
844
+ throw new Error("No AI file changes are waiting for approval. Run `pome next` first.");
845
+ }
846
+ if (proposal.appliedAt) {
847
+ return {
848
+ active: true,
849
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
850
+ session: persisted.session,
851
+ proposal,
852
+ summary: persisted.diffSummary,
853
+ nextStep: "Run `pome done` to prepare the PR and Jira update drafts."
854
+ };
855
+ }
856
+ const workspacePath = persisted.workspaceCandidate?.workspace.path;
857
+ if (!workspacePath) {
858
+ throw new Error("No workspace path is available for the active task session.");
859
+ }
860
+ const now = new Date().toISOString();
861
+ const approvedProposal = {
862
+ ...proposal,
863
+ approval: {
864
+ ...proposal.approval,
865
+ status: "approved"
866
+ },
867
+ appliedAt: now
868
+ };
869
+ await applyPatchFiles(workspacePath, approvedProposal.files);
870
+ const summary = await buildDiffSummary(workspacePath, now);
871
+ const session = {
872
+ ...persisted.session,
873
+ status: "implementing",
874
+ updatedAt: now
875
+ };
876
+ await writeActiveTaskSession(paths.homeDirectory, {
877
+ ...persisted,
878
+ session,
879
+ aiPatchProposal: approvedProposal,
880
+ diffSummary: summary,
881
+ approvalHistory: appendApprovalHistory(persisted.approvalHistory, approvedProposal.approval),
882
+ events: appendSessionEvents(persisted.events, [
883
+ createSessionEvent(session, persisted.workItem.key, "approval_approved", "AI file changes approved and applied", now, [
884
+ `Files applied: ${approvedProposal.files.map((file) => file.path).join(", ")}`,
885
+ `Changed files in git diff: ${summary.files.length}`
886
+ ], {
887
+ approvalId: approvedProposal.approval.id,
888
+ approvalType: approvedProposal.approval.type
889
+ }),
890
+ createSessionEvent(session, persisted.workItem.key, "session_status_changed", "AI patch applied", now, [
891
+ "OpenPome wrote only the approved files and captured a diff summary."
892
+ ], {
893
+ status: session.status
894
+ })
895
+ ])
896
+ });
897
+ return {
898
+ active: true,
899
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
900
+ session,
901
+ proposal: approvedProposal,
902
+ summary,
903
+ nextStep: "Review the diff, run approved tests, then run `pome done`."
904
+ };
905
+ }
752
906
  export async function discoverTestCommands() {
753
907
  const paths = getOpenPomePaths();
754
908
  const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
@@ -1011,12 +1165,6 @@ export async function getGitHubAuthStatus() {
1011
1165
  };
1012
1166
  }
1013
1167
  }
1014
- export async function createPullRequestExternalGuard() {
1015
- return createExternalActionGuard("create_pr");
1016
- }
1017
- export async function postWorkItemUpdateExternalGuard() {
1018
- return createExternalActionGuard("update_work_item");
1019
- }
1020
1168
  export async function createPullRequestDraft() {
1021
1169
  const paths = getOpenPomePaths();
1022
1170
  const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
@@ -1027,7 +1175,9 @@ export async function createPullRequestDraft() {
1027
1175
  };
1028
1176
  }
1029
1177
  const now = new Date().toISOString();
1030
- const draft = buildPullRequestDraft(persisted, now);
1178
+ const draft = buildPullRequestDraft(persisted, now, persisted.workspaceCandidate?.workspace.path
1179
+ ? await detectPullRequestBaseBranch(persisted.workspaceCandidate.workspace.path)
1180
+ : "main");
1031
1181
  await writeActiveTaskSession(paths.homeDirectory, {
1032
1182
  ...persisted,
1033
1183
  prDraft: draft,
@@ -1046,6 +1196,104 @@ export async function createPullRequestDraft() {
1046
1196
  draft
1047
1197
  };
1048
1198
  }
1199
+ export async function createPullRequest(options = {}) {
1200
+ const paths = getOpenPomePaths();
1201
+ const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
1202
+ if (!persisted) {
1203
+ return {
1204
+ active: false,
1205
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
1206
+ pushed: false,
1207
+ draftPr: Boolean(options.draft)
1208
+ };
1209
+ }
1210
+ if (persisted.planApproval?.status !== "approved") {
1211
+ throw new Error("Plan approval is required before creating a PR. Run `pome approve` first.");
1212
+ }
1213
+ const workspacePath = persisted.workspaceCandidate?.workspace.path;
1214
+ if (!workspacePath) {
1215
+ throw new Error("No workspace path is available for PR creation.");
1216
+ }
1217
+ if (!persisted.diffSummary) {
1218
+ throw new Error("Review the final diff summary before creating a PR. Run `pome diff` first.");
1219
+ }
1220
+ if (!options.allowUntested && !hasPassedTestEvidence(persisted)) {
1221
+ throw new Error("Passed test evidence is required before creating a PR. Run `pome test discover`, `pome approve command`, and `pome test run`, or pass `--allow-untested`.");
1222
+ }
1223
+ const github = await getGitHubAuthStatus();
1224
+ if (!github.authenticated) {
1225
+ throw new Error(`${github.detail} Run \`pome auth github login\` first.`);
1226
+ }
1227
+ const now = new Date().toISOString();
1228
+ const baseBranch = options.baseBranch?.trim() || await detectPullRequestBaseBranch(workspacePath);
1229
+ const draft = {
1230
+ ...(persisted.prDraft ?? buildPullRequestDraft(persisted, now, baseBranch)),
1231
+ baseBranch
1232
+ };
1233
+ const branch = await ensurePullRequestBranch(workspacePath, draft.headBranch);
1234
+ const commitMessage = `${persisted.workItem.key}: ${persisted.workItem.title}`;
1235
+ const hasChanges = await hasWorkspaceChanges(workspacePath);
1236
+ if (!hasChanges) {
1237
+ throw new Error("No local changes are available to commit for this PR.");
1238
+ }
1239
+ await runGitStrict(workspacePath, ["add", "-A"]);
1240
+ await runGitStrict(workspacePath, ["commit", "-m", commitMessage]);
1241
+ await runGitStrict(workspacePath, ["push", "-u", "origin", branch]);
1242
+ const ghArgs = [
1243
+ "pr",
1244
+ "create",
1245
+ "--title",
1246
+ draft.title,
1247
+ "--body",
1248
+ draft.body,
1249
+ "--base",
1250
+ draft.baseBranch,
1251
+ "--head",
1252
+ branch
1253
+ ];
1254
+ if (options.draft) {
1255
+ ghArgs.push("--draft");
1256
+ }
1257
+ const prUrl = (await execFileStrict("gh", ghArgs, workspacePath)).trim();
1258
+ const approval = createExternalActionApproval(persisted, "create_pr", now, [
1259
+ `Branch: ${branch}`,
1260
+ `Commit: ${commitMessage}`,
1261
+ `PR: ${prUrl || "created"}`,
1262
+ options.draft ? "Draft PR: yes" : "Draft PR: no"
1263
+ ]);
1264
+ const result = {
1265
+ active: true,
1266
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
1267
+ session: persisted.session,
1268
+ draft,
1269
+ approval,
1270
+ prUrl: prUrl || undefined,
1271
+ branch,
1272
+ commitMessage,
1273
+ pushed: true,
1274
+ draftPr: Boolean(options.draft),
1275
+ createdAt: now
1276
+ };
1277
+ await writeActiveTaskSession(paths.homeDirectory, {
1278
+ ...persisted,
1279
+ prDraft: draft,
1280
+ prCreation: result,
1281
+ approvalHistory: appendApprovalHistory(persisted.approvalHistory, approval),
1282
+ events: appendSessionEvents(persisted.events, [
1283
+ createSessionEvent(persisted.session, persisted.workItem.key, "approval_approved", options.draft ? "GitHub draft PR created" : "GitHub PR created", now, [
1284
+ `Branch: ${branch}`,
1285
+ prUrl ? `PR: ${prUrl}` : "PR created by GitHub CLI",
1286
+ options.draft ? "Draft PR: yes" : "Draft PR: no"
1287
+ ], {
1288
+ approvalId: approval.id,
1289
+ approvalType: approval.type,
1290
+ branch,
1291
+ prUrl
1292
+ })
1293
+ ])
1294
+ });
1295
+ return result;
1296
+ }
1049
1297
  export async function createWorkItemUpdateDraft() {
1050
1298
  const paths = getOpenPomePaths();
1051
1299
  const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
@@ -1075,6 +1323,61 @@ export async function createWorkItemUpdateDraft() {
1075
1323
  draft
1076
1324
  };
1077
1325
  }
1326
+ export async function postWorkItemUpdate() {
1327
+ const paths = getOpenPomePaths();
1328
+ const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
1329
+ if (!persisted) {
1330
+ return {
1331
+ active: false,
1332
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
1333
+ posted: false
1334
+ };
1335
+ }
1336
+ if (persisted.planApproval?.status !== "approved") {
1337
+ throw new Error("Plan approval is required before posting a work item update. Run `pome approve` first.");
1338
+ }
1339
+ const now = new Date().toISOString();
1340
+ const draft = persisted.workItemUpdateDraft ?? buildWorkItemUpdateDraft(persisted, now);
1341
+ const source = await createJiraSource(process.env);
1342
+ if (!source.postUpdate) {
1343
+ throw new Error(`Work item source ${source.displayName} does not support posting updates yet.`);
1344
+ }
1345
+ const posted = await source.postUpdate(persisted.workItem.key, draft.body);
1346
+ const approval = createExternalActionApproval(persisted, "update_work_item", now, [
1347
+ `Work item: ${persisted.workItem.key}`,
1348
+ `Comment: ${posted.commentId ?? "posted"}`,
1349
+ posted.self ? `URL: ${posted.self}` : "URL: unavailable"
1350
+ ]);
1351
+ const result = {
1352
+ active: true,
1353
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
1354
+ session: persisted.session,
1355
+ workItem: persisted.workItem,
1356
+ draft,
1357
+ approval,
1358
+ posted: true,
1359
+ commentId: posted.commentId,
1360
+ url: posted.self,
1361
+ postedAt: posted.createdAt ?? now
1362
+ };
1363
+ await writeActiveTaskSession(paths.homeDirectory, {
1364
+ ...persisted,
1365
+ workItemUpdateDraft: draft,
1366
+ workItemUpdatePost: result,
1367
+ approvalHistory: appendApprovalHistory(persisted.approvalHistory, approval),
1368
+ events: appendSessionEvents(persisted.events, [
1369
+ createSessionEvent(persisted.session, persisted.workItem.key, "approval_approved", "Work item update posted", now, [
1370
+ `Work item: ${persisted.workItem.key}`,
1371
+ posted.commentId ? `Comment: ${posted.commentId}` : "Comment posted"
1372
+ ], {
1373
+ approvalId: approval.id,
1374
+ approvalType: approval.type,
1375
+ commentId: posted.commentId ?? ""
1376
+ })
1377
+ ])
1378
+ });
1379
+ return result;
1380
+ }
1078
1381
  export async function getJiraAuthStatus(env = process.env) {
1079
1382
  const source = await createJiraSource(env);
1080
1383
  const status = source.getAuthStatus();
@@ -1808,7 +2111,7 @@ function buildInitialImplementationPlan(workItem, workspaceCandidate) {
1808
2111
  commandsToRun: ["pome approve", "pnpm validate"],
1809
2112
  risks: [
1810
2113
  "Workspace resolution may be incomplete until real GitHub and historical session signals are added.",
1811
- "The first plan is deterministic; model-provider assisted planning will be added later."
2114
+ "Manual-copy mode uses deterministic planning; connect OpenAI or Claude for AI-assisted planning and patch proposals."
1812
2115
  ],
1813
2116
  missingInfo: hasWorkspace ? [] : ["No workspace candidate is selected yet."]
1814
2117
  };
@@ -1829,6 +2132,9 @@ async function buildImplementationPlan(persisted, prompt) {
1829
2132
  return parseImplementationPlan(response, persisted.workItem, persisted.workspaceCandidate, provider);
1830
2133
  }
1831
2134
  async function completeOpenAIPlan(prompt, apiKey) {
2135
+ return completeOpenAIText(buildStructuredPlanPrompt(prompt), apiKey);
2136
+ }
2137
+ async function completeOpenAIText(prompt, apiKey) {
1832
2138
  const response = await fetch("https://api.openai.com/v1/responses", {
1833
2139
  method: "POST",
1834
2140
  headers: {
@@ -1837,11 +2143,11 @@ async function completeOpenAIPlan(prompt, apiKey) {
1837
2143
  },
1838
2144
  body: JSON.stringify({
1839
2145
  model: process.env["OPENPOME_OPENAI_MODEL"] ?? "gpt-5",
1840
- input: buildStructuredPlanPrompt(prompt)
2146
+ input: prompt
1841
2147
  })
1842
2148
  });
1843
2149
  if (!response.ok) {
1844
- throw new Error(`OpenAI planning request failed: ${response.status} ${response.statusText}`);
2150
+ throw new Error(`OpenAI request failed: ${response.status} ${response.statusText}`);
1845
2151
  }
1846
2152
  const body = await response.json();
1847
2153
  if (typeof body.output_text === "string") {
@@ -1855,6 +2161,9 @@ async function completeOpenAIPlan(prompt, apiKey) {
1855
2161
  .join("\n");
1856
2162
  }
1857
2163
  async function completeAnthropicPlan(prompt, apiKey) {
2164
+ return completeAnthropicText(buildStructuredPlanPrompt(prompt), apiKey);
2165
+ }
2166
+ async function completeAnthropicText(prompt, apiKey) {
1858
2167
  const response = await fetch("https://api.anthropic.com/v1/messages", {
1859
2168
  method: "POST",
1860
2169
  headers: {
@@ -1868,13 +2177,13 @@ async function completeAnthropicPlan(prompt, apiKey) {
1868
2177
  messages: [
1869
2178
  {
1870
2179
  role: "user",
1871
- content: buildStructuredPlanPrompt(prompt)
2180
+ content: prompt
1872
2181
  }
1873
2182
  ]
1874
2183
  })
1875
2184
  });
1876
2185
  if (!response.ok) {
1877
- throw new Error(`Claude planning request failed: ${response.status} ${response.statusText}`);
2186
+ throw new Error(`Claude request failed: ${response.status} ${response.statusText}`);
1878
2187
  }
1879
2188
  const body = await response.json();
1880
2189
  const content = Array.isArray(body.content) ? body.content : [];
@@ -1938,6 +2247,220 @@ function extractJsonObject(value) {
1938
2247
  function stringArrayOr(value, fallback) {
1939
2248
  return Array.isArray(value) ? value.filter((item) => typeof item === "string") : fallback;
1940
2249
  }
2250
+ const maxPatchContextFiles = 12;
2251
+ const maxPatchContextBytesPerFile = 16 * 1024;
2252
+ const maxPatchContextTotalBytes = 64 * 1024;
2253
+ const maxPatchProposalFiles = 8;
2254
+ const maxPatchProposalBytesPerFile = 256 * 1024;
2255
+ const sensitivePathFragments = [
2256
+ ".env",
2257
+ ".npmrc",
2258
+ ".pypirc",
2259
+ ".netrc",
2260
+ ".ssh",
2261
+ ".aws",
2262
+ ".gcp",
2263
+ ".azure",
2264
+ "id_rsa",
2265
+ "id_dsa",
2266
+ "id_ed25519"
2267
+ ];
2268
+ async function collectPatchContextFiles(workspacePath, session) {
2269
+ const candidates = [];
2270
+ for (const filePath of session.plan?.filesLikelyChanged ?? []) {
2271
+ const normalized = normalizeWorkspaceRelativePath(workspacePath, filePath, "skip");
2272
+ if (normalized && normalized !== ".") {
2273
+ candidates.push(normalized);
2274
+ }
2275
+ }
2276
+ candidates.push("package.json", "README.md", "AGENTS.md");
2277
+ const trackedFiles = await listTrackedWorkspaceFiles(workspacePath);
2278
+ const tokens = tokenizePatchSearchText([
2279
+ session.workItem.key,
2280
+ session.workItem.title,
2281
+ session.workItem.description,
2282
+ ...(session.workItem.labels ?? []),
2283
+ ...(session.workItem.components ?? [])
2284
+ ].filter((value) => Boolean(value)).join(" "));
2285
+ for (const filePath of trackedFiles) {
2286
+ const lower = filePath.toLowerCase();
2287
+ if (tokens.some((token) => lower.includes(token))) {
2288
+ candidates.push(filePath);
2289
+ }
2290
+ }
2291
+ const selected = [];
2292
+ const seen = new Set();
2293
+ let totalBytes = 0;
2294
+ for (const candidate of candidates) {
2295
+ if (selected.length >= maxPatchContextFiles || totalBytes >= maxPatchContextTotalBytes) {
2296
+ break;
2297
+ }
2298
+ const relativePath = normalizeWorkspaceRelativePath(workspacePath, candidate, "skip");
2299
+ if (!relativePath || seen.has(relativePath) || isSensitiveWorkspacePath(relativePath)) {
2300
+ continue;
2301
+ }
2302
+ seen.add(relativePath);
2303
+ const absolutePath = resolve(workspacePath, relativePath);
2304
+ try {
2305
+ const content = await readFile(absolutePath, "utf8");
2306
+ if (content.includes("\u0000")) {
2307
+ continue;
2308
+ }
2309
+ const remainingBytes = maxPatchContextTotalBytes - totalBytes;
2310
+ const maxBytes = Math.min(maxPatchContextBytesPerFile, remainingBytes);
2311
+ const sliced = content.slice(0, maxBytes);
2312
+ totalBytes += Buffer.byteLength(sliced, "utf8");
2313
+ selected.push({
2314
+ path: relativePath,
2315
+ content: sliced,
2316
+ truncated: Buffer.byteLength(content, "utf8") > Buffer.byteLength(sliced, "utf8")
2317
+ });
2318
+ }
2319
+ catch {
2320
+ // Missing files from the AI plan are still useful as create candidates, but not as context.
2321
+ }
2322
+ }
2323
+ return selected;
2324
+ }
2325
+ async function listTrackedWorkspaceFiles(workspacePath) {
2326
+ const output = await runGit(workspacePath, ["ls-files"]);
2327
+ return output
2328
+ .split(/\r?\n/u)
2329
+ .map((line) => line.trim())
2330
+ .filter(Boolean)
2331
+ .filter((filePath) => !isSensitiveWorkspacePath(filePath))
2332
+ .slice(0, 1000);
2333
+ }
2334
+ function tokenizePatchSearchText(value) {
2335
+ return Array.from(new Set(value.toLowerCase().split(/[^a-z0-9]+/u).filter((token) => token.length >= 3))).slice(0, 20);
2336
+ }
2337
+ function buildStructuredPatchPrompt(session, workspacePath, contextFiles) {
2338
+ const plan = session.plan;
2339
+ const context = contextFiles.map((file) => [
2340
+ `FILE: ${file.path}${file.truncated ? " (truncated)" : ""}`,
2341
+ "```",
2342
+ file.content,
2343
+ "```"
2344
+ ].join("\n")).join("\n\n");
2345
+ return [
2346
+ "You are OpenPome's implementation engine.",
2347
+ "Return only compact JSON. Do not include markdown fences outside JSON.",
2348
+ "Only propose a minimal safe file patch for the approved work item.",
2349
+ "Do not include secrets, credentials, generated dependency folders, lockfile rewrites, or unrelated refactors.",
2350
+ "Use full replacement file content for each proposed file.",
2351
+ "Allowed JSON shape:",
2352
+ "{\"summary\":\"...\",\"files\":[{\"path\":\"relative/path\",\"action\":\"create|update\",\"content\":\"full file content\"}],\"risks\":[\"...\"]}",
2353
+ "",
2354
+ "Work item:",
2355
+ `- Key: ${session.workItem.key}`,
2356
+ `- Type: ${session.workItem.type}`,
2357
+ `- Status: ${session.workItem.status}`,
2358
+ `- Title: ${session.workItem.title}`,
2359
+ session.workItem.description ? `- Description: ${session.workItem.description}` : undefined,
2360
+ session.workItem.priority ? `- Priority: ${session.workItem.priority}` : undefined,
2361
+ session.workItem.labels?.length ? `- Labels: ${session.workItem.labels.join(", ")}` : undefined,
2362
+ session.workItem.components?.length ? `- Components: ${session.workItem.components.join(", ")}` : undefined,
2363
+ "",
2364
+ "Approved plan:",
2365
+ plan?.summary ? `- Summary: ${plan.summary}` : "- Summary: unavailable",
2366
+ ...(plan?.steps.map((step) => `- ${step.title}${step.detail ? `: ${step.detail}` : ""}`) ?? []),
2367
+ plan?.commandsToRun.length ? `- Checks: ${plan.commandsToRun.join(", ")}` : undefined,
2368
+ "",
2369
+ "Workspace:",
2370
+ `- Path: ${workspacePath}`,
2371
+ session.workspaceCandidate?.workspace.name ? `- Name: ${session.workspaceCandidate.workspace.name}` : undefined,
2372
+ "",
2373
+ "Readable context files:",
2374
+ context || "No source files were safely included. You may propose small new files only if the task clearly asks for them."
2375
+ ].filter((line) => Boolean(line)).join("\n");
2376
+ }
2377
+ function parseAIPatchProposal(value, session, provider, workspacePath, createdAt) {
2378
+ const json = extractJsonObject(value);
2379
+ if (!json) {
2380
+ throw new Error(`${getModelProviderDisplayName(provider)} did not return a JSON patch proposal.`);
2381
+ }
2382
+ let parsed;
2383
+ try {
2384
+ parsed = JSON.parse(json);
2385
+ }
2386
+ catch {
2387
+ throw new Error(`${getModelProviderDisplayName(provider)} returned invalid JSON for the patch proposal.`);
2388
+ }
2389
+ if (!Array.isArray(parsed.files)) {
2390
+ throw new Error(`${getModelProviderDisplayName(provider)} patch proposal did not include files.`);
2391
+ }
2392
+ const files = parsed.files
2393
+ .slice(0, maxPatchProposalFiles)
2394
+ .map((file) => {
2395
+ if (typeof file !== "object" || !file) {
2396
+ return undefined;
2397
+ }
2398
+ const maybe = file;
2399
+ if (typeof maybe.path !== "string" || typeof maybe.content !== "string") {
2400
+ return undefined;
2401
+ }
2402
+ const relativePath = normalizeWorkspaceRelativePath(workspacePath, maybe.path, "throw");
2403
+ const action = maybe.action === "create" ? "create" : "update";
2404
+ const content = maybe.content;
2405
+ if (!relativePath || isSensitiveWorkspacePath(relativePath) || content.includes("\u0000")) {
2406
+ return undefined;
2407
+ }
2408
+ if (Buffer.byteLength(content, "utf8") > maxPatchProposalBytesPerFile) {
2409
+ throw new Error(`AI patch proposal for ${relativePath} is too large.`);
2410
+ }
2411
+ return {
2412
+ path: relativePath,
2413
+ action,
2414
+ content
2415
+ };
2416
+ })
2417
+ .filter((file) => Boolean(file));
2418
+ if (files.length === 0) {
2419
+ throw new Error(`${getModelProviderDisplayName(provider)} did not propose any safe file changes.`);
2420
+ }
2421
+ return {
2422
+ id: `patch_${createHash("sha256").update(`${session.session.id}:${provider}:${createdAt}`).digest("hex").slice(0, 12)}`,
2423
+ createdAt,
2424
+ provider,
2425
+ summary: typeof parsed.summary === "string" && parsed.summary.trim() ? parsed.summary.trim() : `AI patch proposal for ${session.workItem.key}`,
2426
+ files,
2427
+ risks: stringArrayOr(parsed.risks, [])
2428
+ };
2429
+ }
2430
+ function normalizeWorkspaceRelativePath(workspacePath, requestedPath, mode) {
2431
+ const trimmed = requestedPath.trim();
2432
+ if (!trimmed) {
2433
+ return undefined;
2434
+ }
2435
+ const absolutePath = isAbsolute(trimmed) ? resolve(trimmed) : resolve(workspacePath, trimmed);
2436
+ const relativePath = relative(workspacePath, absolutePath).replace(/\\/gu, "/");
2437
+ const invalid = relativePath === "" || relativePath.startsWith("../") || relativePath === ".." || isAbsolute(relativePath);
2438
+ if (invalid) {
2439
+ if (mode === "throw") {
2440
+ throw new Error(`AI patch path is outside the workspace: ${requestedPath}`);
2441
+ }
2442
+ return undefined;
2443
+ }
2444
+ return relativePath;
2445
+ }
2446
+ function isSensitiveWorkspacePath(filePath) {
2447
+ const lower = filePath.toLowerCase();
2448
+ if (lower.includes("/.git/") || lower.startsWith(".git/") || lower.includes("/node_modules/") || lower.startsWith("node_modules/")) {
2449
+ return true;
2450
+ }
2451
+ return sensitivePathFragments.some((fragment) => lower === fragment || lower.includes(`/${fragment}`) || lower.endsWith(fragment));
2452
+ }
2453
+ async function applyPatchFiles(workspacePath, files) {
2454
+ for (const file of files) {
2455
+ const relativePath = normalizeWorkspaceRelativePath(workspacePath, file.path, "throw");
2456
+ if (!relativePath || isSensitiveWorkspacePath(relativePath)) {
2457
+ throw new Error(`Refusing to write unsafe AI patch path: ${file.path}`);
2458
+ }
2459
+ const absolutePath = resolve(workspacePath, relativePath);
2460
+ await mkdir(dirname(absolutePath), { recursive: true });
2461
+ await writeFile(absolutePath, file.content, "utf8");
2462
+ }
2463
+ }
1941
2464
  async function discoverTestCommandCandidates(workspacePath) {
1942
2465
  const scripts = await readPackageScripts(join(workspacePath, "package.json"));
1943
2466
  const packageManager = detectPackageManager(workspacePath);
@@ -2028,7 +2551,24 @@ function createCommandApproval(session, candidate, now) {
2028
2551
  status: "approved"
2029
2552
  };
2030
2553
  }
2031
- function buildPullRequestDraft(session, createdAt) {
2554
+ function createFileEditApproval(session, proposal, now, reason) {
2555
+ return {
2556
+ id: `approval_${createHash("sha256").update(`${session.session.id}:edit_files:${proposal.id}`).digest("hex").slice(0, 12)}`,
2557
+ type: "edit_files",
2558
+ title: `File edit approval for ${session.workItem.key}`,
2559
+ reason,
2560
+ details: [
2561
+ `Session: ${session.session.id}`,
2562
+ `Work item: ${session.workItem.key}`,
2563
+ `Workspace: ${session.workspaceCandidate?.workspace.name ?? "unresolved"}`,
2564
+ `Provider: ${getModelProviderDisplayName(proposal.provider)}`,
2565
+ `Files: ${proposal.files.map((file) => `${file.action} ${file.path}`).join(", ")}`,
2566
+ `Recorded at: ${now}`
2567
+ ],
2568
+ status: "pending"
2569
+ };
2570
+ }
2571
+ function buildPullRequestDraft(session, createdAt, baseBranch = "main") {
2032
2572
  const workItem = session.workItem;
2033
2573
  const workspace = session.workspaceCandidate?.workspace;
2034
2574
  const title = `${workItem.key}: ${workItem.title}`;
@@ -2052,12 +2592,69 @@ function buildPullRequestDraft(session, createdAt) {
2052
2592
  return {
2053
2593
  title,
2054
2594
  body,
2055
- baseBranch: "main",
2056
- headBranch: session.session.branchName ?? `openpome/${workItem.key.toLowerCase()}`,
2595
+ baseBranch,
2596
+ headBranch: selectPullRequestBranchName(session),
2057
2597
  remoteUrl: workspace?.remoteUrls[0],
2058
2598
  createdAt
2059
2599
  };
2060
2600
  }
2601
+ function selectPullRequestBranchName(session) {
2602
+ const currentBranch = session.session.branchName?.trim();
2603
+ if (currentBranch && !["main", "master", "develop", "development"].includes(currentBranch)) {
2604
+ return currentBranch;
2605
+ }
2606
+ const slug = session.workItem.title
2607
+ .toLowerCase()
2608
+ .replace(/[^a-z0-9]+/gu, "-")
2609
+ .replace(/^-|-$/gu, "")
2610
+ .slice(0, 48);
2611
+ return `openpome/${session.workItem.key.toLowerCase()}${slug ? `-${slug}` : ""}`;
2612
+ }
2613
+ async function ensurePullRequestBranch(workspacePath, branch) {
2614
+ const currentBranch = (await runGit(workspacePath, ["branch", "--show-current"])).trim();
2615
+ if (currentBranch !== branch) {
2616
+ await runGitStrict(workspacePath, ["checkout", "-B", branch]);
2617
+ }
2618
+ return branch;
2619
+ }
2620
+ async function hasWorkspaceChanges(workspacePath) {
2621
+ const output = await runGit(workspacePath, ["status", "--porcelain"]);
2622
+ return output.trim().length > 0;
2623
+ }
2624
+ async function detectPullRequestBaseBranch(workspacePath) {
2625
+ const originHead = (await runGit(workspacePath, ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"])).trim();
2626
+ if (originHead.startsWith("origin/")) {
2627
+ return originHead.slice("origin/".length);
2628
+ }
2629
+ const remoteDefault = await runGit(workspacePath, ["remote", "show", "origin"]);
2630
+ const headLine = remoteDefault
2631
+ .split(/\r?\n/u)
2632
+ .map((line) => line.trim())
2633
+ .find((line) => line.startsWith("HEAD branch:"));
2634
+ const headBranch = headLine?.replace("HEAD branch:", "").trim();
2635
+ return headBranch || "main";
2636
+ }
2637
+ function hasPassedTestEvidence(session) {
2638
+ return (session.testRunEvidence ?? []).some((run) => run.status === "passed");
2639
+ }
2640
+ async function runGitStrict(cwd, args) {
2641
+ return execFileStrict("git", args, cwd);
2642
+ }
2643
+ async function execFileStrict(command, args, cwd) {
2644
+ try {
2645
+ const result = await execFileAsync(command, args, {
2646
+ cwd,
2647
+ timeout: 2 * 60 * 1000,
2648
+ maxBuffer: 1024 * 1024,
2649
+ windowsHide: true
2650
+ });
2651
+ return result.stdout;
2652
+ }
2653
+ catch (error) {
2654
+ const detail = summarizeExecError(error);
2655
+ throw new Error(`${command} ${args.join(" ")} failed${detail ? `: ${detail}` : "."}`);
2656
+ }
2657
+ }
2061
2658
  function buildWorkItemUpdateDraft(session, createdAt) {
2062
2659
  const lines = [
2063
2660
  `OpenPome update for ${session.workItem.key}`,
@@ -2257,21 +2854,22 @@ function summarizeExecError(error) {
2257
2854
  const message = typeof maybeError.message === "string" ? maybeError.message : "";
2258
2855
  return stderr || stdout || message || undefined;
2259
2856
  }
2260
- async function createExternalActionGuard(action) {
2261
- const paths = getOpenPomePaths();
2262
- const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
2857
+ function createExternalActionApproval(session, type, now, details) {
2263
2858
  return {
2264
- active: Boolean(persisted),
2265
- sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
2266
- session: persisted?.session,
2267
- action,
2268
- allowed: false,
2269
- detail: action === "create_pr"
2270
- ? "PR creation is not enabled in this alpha. Use `pome pr draft` and create the PR manually."
2271
- : "Work item update posting is not enabled in this alpha. Use `pome work-item update-draft` and post manually.",
2272
- nextStep: action === "create_pr"
2273
- ? "Run `pome pr draft`, review the body, then create the PR yourself."
2274
- : "Run `pome work-item update-draft`, review the body, then post the comment yourself."
2859
+ id: `approval_${createHash("sha256").update(`${session.session.id}:${type}:${now}`).digest("hex").slice(0, 12)}`,
2860
+ type,
2861
+ title: type === "create_pr" ? `PR creation approval for ${session.workItem.key}` : `Work item update approval for ${session.workItem.key}`,
2862
+ reason: type === "create_pr"
2863
+ ? "Developer explicitly requested OpenPome to create the GitHub pull request."
2864
+ : "Developer explicitly requested OpenPome to post the work item update.",
2865
+ details: [
2866
+ `Session: ${session.session.id}`,
2867
+ `Work item: ${session.workItem.key}`,
2868
+ `Workspace: ${session.workspaceCandidate?.workspace.name ?? "unresolved"}`,
2869
+ ...details,
2870
+ `Recorded at: ${now}`
2871
+ ],
2872
+ status: "approved"
2275
2873
  };
2276
2874
  }
2277
2875
  function createPlanApproval(session, status, now, reason = "Developer reviewed the implementation plan.") {