@openpome/local-gateway 0.23.0-alpha.0 → 0.28.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.28.0-alpha.0"
43
43
  };
44
44
  }
45
45
  export async function initOpenPome() {
@@ -91,9 +91,10 @@ export async function getModelProviderStatus(env = process.env) {
91
91
  const paths = getOpenPomePaths();
92
92
  const config = await readConfigIfPresent(paths.configFile);
93
93
  const activeProvider = normalizeModelProviderId(config?.activeModelProvider ?? defaultConfig.activeModelProvider);
94
- const [openaiConfigured, anthropicConfigured] = await Promise.all([
94
+ const [openaiConfigured, anthropicConfigured, claudeCliStatus] = await Promise.all([
95
95
  hasModelProviderApiKey("openai", env),
96
- hasModelProviderApiKey("anthropic", env)
96
+ hasModelProviderApiKey("anthropic", env),
97
+ getClaudeCliStatus()
97
98
  ]);
98
99
  return {
99
100
  activeProvider,
@@ -122,6 +123,15 @@ export async function getModelProviderStatus(env = process.env) {
122
123
  detail: anthropicConfigured
123
124
  ? "Anthropic Claude API key is configured."
124
125
  : "Set up with `pome auth ai claude`."
126
+ },
127
+ {
128
+ provider: "claude-cli",
129
+ displayName: "Claude CLI",
130
+ configured: claudeCliStatus.available,
131
+ active: activeProvider === "claude-cli",
132
+ detail: claudeCliStatus.available
133
+ ? `Claude CLI is available${claudeCliStatus.path ? ` at ${claudeCliStatus.path}` : " on PATH"}.`
134
+ : "Set up Claude Code, then run `pome auth ai claude-cli`."
125
135
  }
126
136
  ]
127
137
  };
@@ -135,7 +145,13 @@ export async function configureModelProvider(provider, apiKey, env = process.env
135
145
  ...existingConfig,
136
146
  activeModelProvider: providerId
137
147
  };
138
- if (providerId !== "manual-copy") {
148
+ if (providerId === "claude-cli") {
149
+ const status = await getClaudeCliStatus();
150
+ if (!status.available) {
151
+ throw new Error("Claude CLI is not available on PATH. Install Claude Code and run `claude auth`, then retry `pome auth ai claude-cli`.");
152
+ }
153
+ }
154
+ if (isApiKeyModelProvider(providerId)) {
139
155
  const key = apiKey?.trim() || getModelProviderEnvKey(providerId, env);
140
156
  if (!key) {
141
157
  throw new Error(`${getModelProviderDisplayName(providerId)} API key is required.`);
@@ -154,7 +170,9 @@ export async function configureModelProvider(provider, apiKey, env = process.env
154
170
  configFile: paths.configFile,
155
171
  detail: providerId === "manual-copy"
156
172
  ? "Manual-copy AI mode is active."
157
- : `${getModelProviderDisplayName(providerId)} is connected and active for AI planning.`
173
+ : providerId === "claude-cli"
174
+ ? "Claude CLI is connected and active for AI planning and approval-gated patches."
175
+ : `${getModelProviderDisplayName(providerId)} is connected and active for AI planning.`
158
176
  };
159
177
  }
160
178
  export async function runDoctor(env = process.env) {
@@ -187,7 +205,9 @@ export async function runDoctor(env = process.env) {
187
205
  {
188
206
  name: "Work item source",
189
207
  status: authStatus.configured ? "ok" : "attention",
190
- detail: authStatus.detail
208
+ detail: authStatus.configured
209
+ ? authStatus.detail
210
+ : "Jira is not connected. Run `pome onboard` to connect Jira, or `pome demo` to try sample work."
191
211
  },
192
212
  {
193
213
  name: "Work item scope",
@@ -465,8 +485,11 @@ export async function getTaskSessionStatus() {
465
485
  testRunEvidence: persisted.testRunEvidence ?? [],
466
486
  prDraft: persisted.prDraft,
467
487
  workItemUpdateDraft: persisted.workItemUpdateDraft,
488
+ prCreation: persisted.prCreation,
489
+ workItemUpdatePost: persisted.workItemUpdatePost,
468
490
  aiContext: persisted.aiContext,
469
- diffSummary: persisted.diffSummary
491
+ diffSummary: persisted.diffSummary,
492
+ aiPatchProposal: persisted.aiPatchProposal
470
493
  };
471
494
  }
472
495
  export async function getTaskSessionTimeline() {
@@ -749,6 +772,151 @@ export async function rejectTaskSessionPlan(reason = "Plan rejected by developer
749
772
  nextStep: "Revise the work item context or workspace link, then run `pome plan` again."
750
773
  };
751
774
  }
775
+ export async function createAIPatchProposal() {
776
+ const paths = getOpenPomePaths();
777
+ const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
778
+ if (!persisted) {
779
+ return {
780
+ active: false,
781
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
782
+ nextStep: "Run `pome start <KEY>` first."
783
+ };
784
+ }
785
+ if (persisted.aiPatchProposal && !persisted.aiPatchProposal.appliedAt) {
786
+ return {
787
+ active: true,
788
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
789
+ session: persisted.session,
790
+ proposal: persisted.aiPatchProposal,
791
+ workspacePath: persisted.workspaceCandidate?.workspace.path,
792
+ nextStep: "Review the proposed file changes, then run `pome approve` to apply them."
793
+ };
794
+ }
795
+ if (!persisted.plan) {
796
+ throw new Error("No implementation plan is available. Run `pome plan` first.");
797
+ }
798
+ if (persisted.planApproval?.status !== "approved") {
799
+ throw new Error("The implementation plan is not approved yet. Run `pome approve` first.");
800
+ }
801
+ const workspacePath = persisted.workspaceCandidate?.workspace.path;
802
+ if (!workspacePath) {
803
+ 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>`.");
804
+ }
805
+ const config = await readConfigIfPresent(paths.configFile);
806
+ const provider = normalizeModelProviderId(config?.activeModelProvider ?? defaultConfig.activeModelProvider);
807
+ if (provider === "manual-copy") {
808
+ throw new Error("Manual-copy mode cannot apply code. Run `pome auth ai openai`, `pome auth ai claude`, or `pome auth ai claude-cli` to enable approval-gated AI patches.");
809
+ }
810
+ const createdAt = new Date().toISOString();
811
+ const fileContext = await collectPatchContextFiles(workspacePath, persisted);
812
+ const prompt = buildStructuredPatchPrompt(persisted, workspacePath, fileContext);
813
+ const response = await completeModelText(provider, prompt);
814
+ const proposalDraft = parseAIPatchProposal(response, persisted, provider, workspacePath, createdAt);
815
+ const approval = createFileEditApproval(persisted, proposalDraft, createdAt, "Developer approval is required before OpenPome writes AI-proposed file changes.");
816
+ const proposal = {
817
+ ...proposalDraft,
818
+ approval
819
+ };
820
+ const session = {
821
+ ...persisted.session,
822
+ status: "awaiting_approval",
823
+ updatedAt: createdAt
824
+ };
825
+ await writeActiveTaskSession(paths.homeDirectory, {
826
+ ...persisted,
827
+ session,
828
+ aiPatchProposal: proposal,
829
+ approvalHistory: appendApprovalHistory(persisted.approvalHistory, approval),
830
+ events: appendSessionEvents(persisted.events, [
831
+ createSessionEvent(session, persisted.workItem.key, "approval_requested", "AI file changes proposed", createdAt, [
832
+ `Provider: ${getModelProviderDisplayName(provider)}`,
833
+ `Files proposed: ${proposal.files.map((file) => file.path).join(", ") || "none"}`,
834
+ ...approval.details
835
+ ], {
836
+ approvalId: approval.id,
837
+ approvalType: approval.type
838
+ })
839
+ ])
840
+ });
841
+ return {
842
+ active: true,
843
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
844
+ session,
845
+ proposal,
846
+ workspacePath,
847
+ nextStep: "Review the proposed file changes, then run `pome approve` to apply them."
848
+ };
849
+ }
850
+ export async function approveAndApplyAIPatchProposal() {
851
+ const paths = getOpenPomePaths();
852
+ const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
853
+ if (!persisted) {
854
+ return undefined;
855
+ }
856
+ const proposal = persisted.aiPatchProposal;
857
+ if (!proposal) {
858
+ throw new Error("No AI file changes are waiting for approval. Run `pome next` first.");
859
+ }
860
+ if (proposal.appliedAt) {
861
+ return {
862
+ active: true,
863
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
864
+ session: persisted.session,
865
+ proposal,
866
+ summary: persisted.diffSummary,
867
+ nextStep: "Run `pome done` to prepare the PR and Jira update drafts."
868
+ };
869
+ }
870
+ const workspacePath = persisted.workspaceCandidate?.workspace.path;
871
+ if (!workspacePath) {
872
+ throw new Error("No workspace path is available for the active task session.");
873
+ }
874
+ const now = new Date().toISOString();
875
+ const approvedProposal = {
876
+ ...proposal,
877
+ approval: {
878
+ ...proposal.approval,
879
+ status: "approved"
880
+ },
881
+ appliedAt: now
882
+ };
883
+ await applyPatchFiles(workspacePath, approvedProposal.files);
884
+ const summary = await buildDiffSummary(workspacePath, now);
885
+ const session = {
886
+ ...persisted.session,
887
+ status: "implementing",
888
+ updatedAt: now
889
+ };
890
+ await writeActiveTaskSession(paths.homeDirectory, {
891
+ ...persisted,
892
+ session,
893
+ aiPatchProposal: approvedProposal,
894
+ diffSummary: summary,
895
+ approvalHistory: appendApprovalHistory(persisted.approvalHistory, approvedProposal.approval),
896
+ events: appendSessionEvents(persisted.events, [
897
+ createSessionEvent(session, persisted.workItem.key, "approval_approved", "AI file changes approved and applied", now, [
898
+ `Files applied: ${approvedProposal.files.map((file) => file.path).join(", ")}`,
899
+ `Changed files in git diff: ${summary.files.length}`
900
+ ], {
901
+ approvalId: approvedProposal.approval.id,
902
+ approvalType: approvedProposal.approval.type
903
+ }),
904
+ createSessionEvent(session, persisted.workItem.key, "session_status_changed", "AI patch applied", now, [
905
+ "OpenPome wrote only the approved files and captured a diff summary."
906
+ ], {
907
+ status: session.status
908
+ })
909
+ ])
910
+ });
911
+ return {
912
+ active: true,
913
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
914
+ session,
915
+ proposal: approvedProposal,
916
+ summary,
917
+ nextStep: "Review the diff, run approved tests, then run `pome done`."
918
+ };
919
+ }
752
920
  export async function discoverTestCommands() {
753
921
  const paths = getOpenPomePaths();
754
922
  const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
@@ -1011,12 +1179,6 @@ export async function getGitHubAuthStatus() {
1011
1179
  };
1012
1180
  }
1013
1181
  }
1014
- export async function createPullRequestExternalGuard() {
1015
- return createExternalActionGuard("create_pr");
1016
- }
1017
- export async function postWorkItemUpdateExternalGuard() {
1018
- return createExternalActionGuard("update_work_item");
1019
- }
1020
1182
  export async function createPullRequestDraft() {
1021
1183
  const paths = getOpenPomePaths();
1022
1184
  const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
@@ -1027,7 +1189,9 @@ export async function createPullRequestDraft() {
1027
1189
  };
1028
1190
  }
1029
1191
  const now = new Date().toISOString();
1030
- const draft = buildPullRequestDraft(persisted, now);
1192
+ const draft = buildPullRequestDraft(persisted, now, persisted.workspaceCandidate?.workspace.path
1193
+ ? await detectPullRequestBaseBranch(persisted.workspaceCandidate.workspace.path)
1194
+ : "main");
1031
1195
  await writeActiveTaskSession(paths.homeDirectory, {
1032
1196
  ...persisted,
1033
1197
  prDraft: draft,
@@ -1046,6 +1210,104 @@ export async function createPullRequestDraft() {
1046
1210
  draft
1047
1211
  };
1048
1212
  }
1213
+ export async function createPullRequest(options = {}) {
1214
+ const paths = getOpenPomePaths();
1215
+ const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
1216
+ if (!persisted) {
1217
+ return {
1218
+ active: false,
1219
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
1220
+ pushed: false,
1221
+ draftPr: Boolean(options.draft)
1222
+ };
1223
+ }
1224
+ if (persisted.planApproval?.status !== "approved") {
1225
+ throw new Error("Plan approval is required before creating a PR. Run `pome approve` first.");
1226
+ }
1227
+ const workspacePath = persisted.workspaceCandidate?.workspace.path;
1228
+ if (!workspacePath) {
1229
+ throw new Error("No workspace path is available for PR creation.");
1230
+ }
1231
+ if (!persisted.diffSummary) {
1232
+ throw new Error("Review the final diff summary before creating a PR. Run `pome diff` first.");
1233
+ }
1234
+ if (!options.allowUntested && !hasPassedTestEvidence(persisted)) {
1235
+ 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`.");
1236
+ }
1237
+ const github = await getGitHubAuthStatus();
1238
+ if (!github.authenticated) {
1239
+ throw new Error(`${github.detail} Run \`pome auth github login\` first.`);
1240
+ }
1241
+ const now = new Date().toISOString();
1242
+ const baseBranch = options.baseBranch?.trim() || await detectPullRequestBaseBranch(workspacePath);
1243
+ const draft = {
1244
+ ...(persisted.prDraft ?? buildPullRequestDraft(persisted, now, baseBranch)),
1245
+ baseBranch
1246
+ };
1247
+ const branch = await ensurePullRequestBranch(workspacePath, draft.headBranch);
1248
+ const commitMessage = `${persisted.workItem.key}: ${persisted.workItem.title}`;
1249
+ const hasChanges = await hasWorkspaceChanges(workspacePath);
1250
+ if (!hasChanges) {
1251
+ throw new Error("No local changes are available to commit for this PR.");
1252
+ }
1253
+ await runGitStrict(workspacePath, ["add", "-A"]);
1254
+ await runGitStrict(workspacePath, ["commit", "-m", commitMessage]);
1255
+ await runGitStrict(workspacePath, ["push", "-u", "origin", branch]);
1256
+ const ghArgs = [
1257
+ "pr",
1258
+ "create",
1259
+ "--title",
1260
+ draft.title,
1261
+ "--body",
1262
+ draft.body,
1263
+ "--base",
1264
+ draft.baseBranch,
1265
+ "--head",
1266
+ branch
1267
+ ];
1268
+ if (options.draft) {
1269
+ ghArgs.push("--draft");
1270
+ }
1271
+ const prUrl = (await execFileStrict("gh", ghArgs, workspacePath)).trim();
1272
+ const approval = createExternalActionApproval(persisted, "create_pr", now, [
1273
+ `Branch: ${branch}`,
1274
+ `Commit: ${commitMessage}`,
1275
+ `PR: ${prUrl || "created"}`,
1276
+ options.draft ? "Draft PR: yes" : "Draft PR: no"
1277
+ ]);
1278
+ const result = {
1279
+ active: true,
1280
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
1281
+ session: persisted.session,
1282
+ draft,
1283
+ approval,
1284
+ prUrl: prUrl || undefined,
1285
+ branch,
1286
+ commitMessage,
1287
+ pushed: true,
1288
+ draftPr: Boolean(options.draft),
1289
+ createdAt: now
1290
+ };
1291
+ await writeActiveTaskSession(paths.homeDirectory, {
1292
+ ...persisted,
1293
+ prDraft: draft,
1294
+ prCreation: result,
1295
+ approvalHistory: appendApprovalHistory(persisted.approvalHistory, approval),
1296
+ events: appendSessionEvents(persisted.events, [
1297
+ createSessionEvent(persisted.session, persisted.workItem.key, "approval_approved", options.draft ? "GitHub draft PR created" : "GitHub PR created", now, [
1298
+ `Branch: ${branch}`,
1299
+ prUrl ? `PR: ${prUrl}` : "PR created by GitHub CLI",
1300
+ options.draft ? "Draft PR: yes" : "Draft PR: no"
1301
+ ], {
1302
+ approvalId: approval.id,
1303
+ approvalType: approval.type,
1304
+ branch,
1305
+ prUrl
1306
+ })
1307
+ ])
1308
+ });
1309
+ return result;
1310
+ }
1049
1311
  export async function createWorkItemUpdateDraft() {
1050
1312
  const paths = getOpenPomePaths();
1051
1313
  const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
@@ -1075,6 +1337,61 @@ export async function createWorkItemUpdateDraft() {
1075
1337
  draft
1076
1338
  };
1077
1339
  }
1340
+ export async function postWorkItemUpdate() {
1341
+ const paths = getOpenPomePaths();
1342
+ const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
1343
+ if (!persisted) {
1344
+ return {
1345
+ active: false,
1346
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
1347
+ posted: false
1348
+ };
1349
+ }
1350
+ if (persisted.planApproval?.status !== "approved") {
1351
+ throw new Error("Plan approval is required before posting a work item update. Run `pome approve` first.");
1352
+ }
1353
+ const now = new Date().toISOString();
1354
+ const draft = persisted.workItemUpdateDraft ?? buildWorkItemUpdateDraft(persisted, now);
1355
+ const source = await createJiraSource(process.env);
1356
+ if (!source.postUpdate) {
1357
+ throw new Error(`Work item source ${source.displayName} does not support posting updates yet.`);
1358
+ }
1359
+ const posted = await source.postUpdate(persisted.workItem.key, draft.body);
1360
+ const approval = createExternalActionApproval(persisted, "update_work_item", now, [
1361
+ `Work item: ${persisted.workItem.key}`,
1362
+ `Comment: ${posted.commentId ?? "posted"}`,
1363
+ posted.self ? `URL: ${posted.self}` : "URL: unavailable"
1364
+ ]);
1365
+ const result = {
1366
+ active: true,
1367
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
1368
+ session: persisted.session,
1369
+ workItem: persisted.workItem,
1370
+ draft,
1371
+ approval,
1372
+ posted: true,
1373
+ commentId: posted.commentId,
1374
+ url: posted.self,
1375
+ postedAt: posted.createdAt ?? now
1376
+ };
1377
+ await writeActiveTaskSession(paths.homeDirectory, {
1378
+ ...persisted,
1379
+ workItemUpdateDraft: draft,
1380
+ workItemUpdatePost: result,
1381
+ approvalHistory: appendApprovalHistory(persisted.approvalHistory, approval),
1382
+ events: appendSessionEvents(persisted.events, [
1383
+ createSessionEvent(persisted.session, persisted.workItem.key, "approval_approved", "Work item update posted", now, [
1384
+ `Work item: ${persisted.workItem.key}`,
1385
+ posted.commentId ? `Comment: ${posted.commentId}` : "Comment posted"
1386
+ ], {
1387
+ approvalId: approval.id,
1388
+ approvalType: approval.type,
1389
+ commentId: posted.commentId ?? ""
1390
+ })
1391
+ ])
1392
+ });
1393
+ return result;
1394
+ }
1078
1395
  export async function getJiraAuthStatus(env = process.env) {
1079
1396
  const source = await createJiraSource(env);
1080
1397
  const status = source.getAuthStatus();
@@ -1207,6 +1524,10 @@ function normalizeModelProviderId(provider) {
1207
1524
  case "anthropic":
1208
1525
  case "claude":
1209
1526
  return "anthropic";
1527
+ case "claude-cli":
1528
+ case "claude_code":
1529
+ case "claude-code":
1530
+ return "claude-cli";
1210
1531
  case "manual":
1211
1532
  case "manual-copy":
1212
1533
  return "manual-copy";
@@ -1220,6 +1541,8 @@ function getModelProviderDisplayName(provider) {
1220
1541
  return "OpenAI";
1221
1542
  case "anthropic":
1222
1543
  return "Claude";
1544
+ case "claude-cli":
1545
+ return "Claude CLI";
1223
1546
  case "manual-copy":
1224
1547
  return "Manual copy";
1225
1548
  }
@@ -1245,6 +1568,39 @@ async function getModelProviderApiKey(provider, env = process.env) {
1245
1568
  const stored = await getJsonCredential(store, getModelProviderCredentialAccount(provider));
1246
1569
  return stored?.apiKey;
1247
1570
  }
1571
+ function isApiKeyModelProvider(provider) {
1572
+ return provider === "openai" || provider === "anthropic";
1573
+ }
1574
+ async function getClaudeCliStatus() {
1575
+ try {
1576
+ const lookupCommand = process.platform === "win32" ? "where claude" : "command -v claude";
1577
+ const { stdout } = await execAsync(lookupCommand, {
1578
+ timeout: 5_000,
1579
+ maxBuffer: 64 * 1024
1580
+ });
1581
+ const path = stdout.split(/\r?\n/u).map((line) => line.trim()).find(Boolean);
1582
+ return {
1583
+ available: Boolean(path),
1584
+ path
1585
+ };
1586
+ }
1587
+ catch {
1588
+ try {
1589
+ await execFileAsync("claude", ["--version"], {
1590
+ timeout: 5_000,
1591
+ maxBuffer: 64 * 1024
1592
+ });
1593
+ return {
1594
+ available: true
1595
+ };
1596
+ }
1597
+ catch {
1598
+ return {
1599
+ available: false
1600
+ };
1601
+ }
1602
+ }
1603
+ }
1248
1604
  function getActiveJiraBoardScope(config) {
1249
1605
  if (config?.activeWorkItemScope?.providerId !== "jira-cloud" || config.activeWorkItemScope.kind !== "board") {
1250
1606
  return undefined;
@@ -1808,7 +2164,7 @@ function buildInitialImplementationPlan(workItem, workspaceCandidate) {
1808
2164
  commandsToRun: ["pome approve", "pnpm validate"],
1809
2165
  risks: [
1810
2166
  "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."
2167
+ "Manual-copy mode uses deterministic planning; connect OpenAI or Claude for AI-assisted planning and patch proposals."
1812
2168
  ],
1813
2169
  missingInfo: hasWorkspace ? [] : ["No workspace candidate is selected yet."]
1814
2170
  };
@@ -1819,16 +2175,25 @@ async function buildImplementationPlan(persisted, prompt) {
1819
2175
  if (provider === "manual-copy") {
1820
2176
  return buildInitialImplementationPlan(persisted.workItem, persisted.workspaceCandidate);
1821
2177
  }
1822
- const apiKey = await getModelProviderApiKey(provider);
1823
- if (!apiKey) {
1824
- throw new Error(`${getModelProviderDisplayName(provider)} is active, but no API key is configured. Run \`pome auth ai ${provider === "anthropic" ? "claude" : provider}\`.`);
1825
- }
1826
- const response = provider === "openai"
1827
- ? await completeOpenAIPlan(prompt, apiKey)
1828
- : await completeAnthropicPlan(prompt, apiKey);
2178
+ const response = await completeModelPlan(provider, prompt);
1829
2179
  return parseImplementationPlan(response, persisted.workItem, persisted.workspaceCandidate, provider);
1830
2180
  }
1831
- async function completeOpenAIPlan(prompt, apiKey) {
2181
+ async function completeModelPlan(provider, prompt) {
2182
+ return completeModelText(provider, buildStructuredPlanPrompt(prompt));
2183
+ }
2184
+ async function completeModelText(provider, prompt) {
2185
+ if (provider === "openai" || provider === "anthropic") {
2186
+ const apiKey = await getModelProviderApiKey(provider);
2187
+ if (!apiKey) {
2188
+ throw new Error(`${getModelProviderDisplayName(provider)} is active, but no API key is configured. Run \`pome auth ai ${provider === "anthropic" ? "claude" : provider}\`.`);
2189
+ }
2190
+ return provider === "openai"
2191
+ ? completeOpenAIText(prompt, apiKey)
2192
+ : completeAnthropicText(prompt, apiKey);
2193
+ }
2194
+ return completeClaudeCliText(prompt);
2195
+ }
2196
+ async function completeOpenAIText(prompt, apiKey) {
1832
2197
  const response = await fetch("https://api.openai.com/v1/responses", {
1833
2198
  method: "POST",
1834
2199
  headers: {
@@ -1837,11 +2202,11 @@ async function completeOpenAIPlan(prompt, apiKey) {
1837
2202
  },
1838
2203
  body: JSON.stringify({
1839
2204
  model: process.env["OPENPOME_OPENAI_MODEL"] ?? "gpt-5",
1840
- input: buildStructuredPlanPrompt(prompt)
2205
+ input: prompt
1841
2206
  })
1842
2207
  });
1843
2208
  if (!response.ok) {
1844
- throw new Error(`OpenAI planning request failed: ${response.status} ${response.statusText}`);
2209
+ throw new Error(`OpenAI request failed: ${response.status} ${response.statusText}`);
1845
2210
  }
1846
2211
  const body = await response.json();
1847
2212
  if (typeof body.output_text === "string") {
@@ -1854,7 +2219,7 @@ async function completeOpenAIPlan(prompt, apiKey) {
1854
2219
  .filter(Boolean)
1855
2220
  .join("\n");
1856
2221
  }
1857
- async function completeAnthropicPlan(prompt, apiKey) {
2222
+ async function completeAnthropicText(prompt, apiKey) {
1858
2223
  const response = await fetch("https://api.anthropic.com/v1/messages", {
1859
2224
  method: "POST",
1860
2225
  headers: {
@@ -1868,13 +2233,13 @@ async function completeAnthropicPlan(prompt, apiKey) {
1868
2233
  messages: [
1869
2234
  {
1870
2235
  role: "user",
1871
- content: buildStructuredPlanPrompt(prompt)
2236
+ content: prompt
1872
2237
  }
1873
2238
  ]
1874
2239
  })
1875
2240
  });
1876
2241
  if (!response.ok) {
1877
- throw new Error(`Claude planning request failed: ${response.status} ${response.statusText}`);
2242
+ throw new Error(`Claude request failed: ${response.status} ${response.statusText}`);
1878
2243
  }
1879
2244
  const body = await response.json();
1880
2245
  const content = Array.isArray(body.content) ? body.content : [];
@@ -1883,6 +2248,39 @@ async function completeAnthropicPlan(prompt, apiKey) {
1883
2248
  .filter(Boolean)
1884
2249
  .join("\n");
1885
2250
  }
2251
+ async function completeClaudeCliText(prompt) {
2252
+ const status = await getClaudeCliStatus();
2253
+ if (!status.available) {
2254
+ throw new Error("Claude CLI is not available on PATH. Install Claude Code and run `claude auth`, then retry `pome auth ai claude-cli`.");
2255
+ }
2256
+ const args = [
2257
+ "--print",
2258
+ "--output-format",
2259
+ "text",
2260
+ "--permission-mode",
2261
+ "plan",
2262
+ "--no-session-persistence",
2263
+ "--tools",
2264
+ "",
2265
+ "--model",
2266
+ process.env["OPENPOME_CLAUDE_CLI_MODEL"] ?? "sonnet",
2267
+ prompt
2268
+ ];
2269
+ try {
2270
+ const { stdout } = await execFileAsync("claude", args, {
2271
+ timeout: modelProviderTimeoutMs,
2272
+ maxBuffer: modelProviderMaxBufferBytes
2273
+ });
2274
+ const text = stdout.trim();
2275
+ if (!text) {
2276
+ throw new Error("Claude CLI returned an empty response.");
2277
+ }
2278
+ return text;
2279
+ }
2280
+ catch (error) {
2281
+ throw new Error(`Claude CLI request failed: ${summarizeExecError(error) || String(error)}`);
2282
+ }
2283
+ }
1886
2284
  function buildStructuredPlanPrompt(prompt) {
1887
2285
  return [
1888
2286
  "You are OpenPome's planning engine.",
@@ -1938,6 +2336,222 @@ function extractJsonObject(value) {
1938
2336
  function stringArrayOr(value, fallback) {
1939
2337
  return Array.isArray(value) ? value.filter((item) => typeof item === "string") : fallback;
1940
2338
  }
2339
+ const maxPatchContextFiles = 12;
2340
+ const maxPatchContextBytesPerFile = 16 * 1024;
2341
+ const maxPatchContextTotalBytes = 64 * 1024;
2342
+ const maxPatchProposalFiles = 8;
2343
+ const maxPatchProposalBytesPerFile = 256 * 1024;
2344
+ const modelProviderTimeoutMs = 120_000;
2345
+ const modelProviderMaxBufferBytes = 2 * 1024 * 1024;
2346
+ const sensitivePathFragments = [
2347
+ ".env",
2348
+ ".npmrc",
2349
+ ".pypirc",
2350
+ ".netrc",
2351
+ ".ssh",
2352
+ ".aws",
2353
+ ".gcp",
2354
+ ".azure",
2355
+ "id_rsa",
2356
+ "id_dsa",
2357
+ "id_ed25519"
2358
+ ];
2359
+ async function collectPatchContextFiles(workspacePath, session) {
2360
+ const candidates = [];
2361
+ for (const filePath of session.plan?.filesLikelyChanged ?? []) {
2362
+ const normalized = normalizeWorkspaceRelativePath(workspacePath, filePath, "skip");
2363
+ if (normalized && normalized !== ".") {
2364
+ candidates.push(normalized);
2365
+ }
2366
+ }
2367
+ candidates.push("package.json", "README.md", "AGENTS.md");
2368
+ const trackedFiles = await listTrackedWorkspaceFiles(workspacePath);
2369
+ const tokens = tokenizePatchSearchText([
2370
+ session.workItem.key,
2371
+ session.workItem.title,
2372
+ session.workItem.description,
2373
+ ...(session.workItem.labels ?? []),
2374
+ ...(session.workItem.components ?? [])
2375
+ ].filter((value) => Boolean(value)).join(" "));
2376
+ for (const filePath of trackedFiles) {
2377
+ const lower = filePath.toLowerCase();
2378
+ if (tokens.some((token) => lower.includes(token))) {
2379
+ candidates.push(filePath);
2380
+ }
2381
+ }
2382
+ const selected = [];
2383
+ const seen = new Set();
2384
+ let totalBytes = 0;
2385
+ for (const candidate of candidates) {
2386
+ if (selected.length >= maxPatchContextFiles || totalBytes >= maxPatchContextTotalBytes) {
2387
+ break;
2388
+ }
2389
+ const relativePath = normalizeWorkspaceRelativePath(workspacePath, candidate, "skip");
2390
+ if (!relativePath || seen.has(relativePath) || isSensitiveWorkspacePath(relativePath)) {
2391
+ continue;
2392
+ }
2393
+ seen.add(relativePath);
2394
+ const absolutePath = resolve(workspacePath, relativePath);
2395
+ try {
2396
+ const content = await readFile(absolutePath, "utf8");
2397
+ if (content.includes("\u0000")) {
2398
+ continue;
2399
+ }
2400
+ const remainingBytes = maxPatchContextTotalBytes - totalBytes;
2401
+ const maxBytes = Math.min(maxPatchContextBytesPerFile, remainingBytes);
2402
+ const sliced = content.slice(0, maxBytes);
2403
+ totalBytes += Buffer.byteLength(sliced, "utf8");
2404
+ selected.push({
2405
+ path: relativePath,
2406
+ content: sliced,
2407
+ truncated: Buffer.byteLength(content, "utf8") > Buffer.byteLength(sliced, "utf8")
2408
+ });
2409
+ }
2410
+ catch {
2411
+ // Missing files from the AI plan are still useful as create candidates, but not as context.
2412
+ }
2413
+ }
2414
+ return selected;
2415
+ }
2416
+ async function listTrackedWorkspaceFiles(workspacePath) {
2417
+ const output = await runGit(workspacePath, ["ls-files"]);
2418
+ return output
2419
+ .split(/\r?\n/u)
2420
+ .map((line) => line.trim())
2421
+ .filter(Boolean)
2422
+ .filter((filePath) => !isSensitiveWorkspacePath(filePath))
2423
+ .slice(0, 1000);
2424
+ }
2425
+ function tokenizePatchSearchText(value) {
2426
+ return Array.from(new Set(value.toLowerCase().split(/[^a-z0-9]+/u).filter((token) => token.length >= 3))).slice(0, 20);
2427
+ }
2428
+ function buildStructuredPatchPrompt(session, workspacePath, contextFiles) {
2429
+ const plan = session.plan;
2430
+ const context = contextFiles.map((file) => [
2431
+ `FILE: ${file.path}${file.truncated ? " (truncated)" : ""}`,
2432
+ "```",
2433
+ file.content,
2434
+ "```"
2435
+ ].join("\n")).join("\n\n");
2436
+ return [
2437
+ "You are OpenPome's implementation engine.",
2438
+ "Return only compact JSON. Do not include markdown fences outside JSON.",
2439
+ "Only propose a minimal safe file patch for the approved work item.",
2440
+ "Do not include secrets, credentials, generated dependency folders, lockfile rewrites, or unrelated refactors.",
2441
+ "Use full replacement file content for each proposed file.",
2442
+ "Allowed JSON shape:",
2443
+ "{\"summary\":\"...\",\"files\":[{\"path\":\"relative/path\",\"action\":\"create|update\",\"content\":\"full file content\"}],\"risks\":[\"...\"]}",
2444
+ "",
2445
+ "Work item:",
2446
+ `- Key: ${session.workItem.key}`,
2447
+ `- Type: ${session.workItem.type}`,
2448
+ `- Status: ${session.workItem.status}`,
2449
+ `- Title: ${session.workItem.title}`,
2450
+ session.workItem.description ? `- Description: ${session.workItem.description}` : undefined,
2451
+ session.workItem.priority ? `- Priority: ${session.workItem.priority}` : undefined,
2452
+ session.workItem.labels?.length ? `- Labels: ${session.workItem.labels.join(", ")}` : undefined,
2453
+ session.workItem.components?.length ? `- Components: ${session.workItem.components.join(", ")}` : undefined,
2454
+ "",
2455
+ "Approved plan:",
2456
+ plan?.summary ? `- Summary: ${plan.summary}` : "- Summary: unavailable",
2457
+ ...(plan?.steps.map((step) => `- ${step.title}${step.detail ? `: ${step.detail}` : ""}`) ?? []),
2458
+ plan?.commandsToRun.length ? `- Checks: ${plan.commandsToRun.join(", ")}` : undefined,
2459
+ "",
2460
+ "Workspace:",
2461
+ `- Path: ${workspacePath}`,
2462
+ session.workspaceCandidate?.workspace.name ? `- Name: ${session.workspaceCandidate.workspace.name}` : undefined,
2463
+ "",
2464
+ "Readable context files:",
2465
+ context || "No source files were safely included. You may propose small new files only if the task clearly asks for them."
2466
+ ].filter((line) => Boolean(line)).join("\n");
2467
+ }
2468
+ function parseAIPatchProposal(value, session, provider, workspacePath, createdAt) {
2469
+ const json = extractJsonObject(value);
2470
+ if (!json) {
2471
+ throw new Error(`${getModelProviderDisplayName(provider)} did not return a JSON patch proposal.`);
2472
+ }
2473
+ let parsed;
2474
+ try {
2475
+ parsed = JSON.parse(json);
2476
+ }
2477
+ catch {
2478
+ throw new Error(`${getModelProviderDisplayName(provider)} returned invalid JSON for the patch proposal.`);
2479
+ }
2480
+ if (!Array.isArray(parsed.files)) {
2481
+ throw new Error(`${getModelProviderDisplayName(provider)} patch proposal did not include files.`);
2482
+ }
2483
+ const files = parsed.files
2484
+ .slice(0, maxPatchProposalFiles)
2485
+ .map((file) => {
2486
+ if (typeof file !== "object" || !file) {
2487
+ return undefined;
2488
+ }
2489
+ const maybe = file;
2490
+ if (typeof maybe.path !== "string" || typeof maybe.content !== "string") {
2491
+ return undefined;
2492
+ }
2493
+ const relativePath = normalizeWorkspaceRelativePath(workspacePath, maybe.path, "throw");
2494
+ const action = maybe.action === "create" ? "create" : "update";
2495
+ const content = maybe.content;
2496
+ if (!relativePath || isSensitiveWorkspacePath(relativePath) || content.includes("\u0000")) {
2497
+ return undefined;
2498
+ }
2499
+ if (Buffer.byteLength(content, "utf8") > maxPatchProposalBytesPerFile) {
2500
+ throw new Error(`AI patch proposal for ${relativePath} is too large.`);
2501
+ }
2502
+ return {
2503
+ path: relativePath,
2504
+ action,
2505
+ content
2506
+ };
2507
+ })
2508
+ .filter((file) => Boolean(file));
2509
+ if (files.length === 0) {
2510
+ throw new Error(`${getModelProviderDisplayName(provider)} did not propose any safe file changes.`);
2511
+ }
2512
+ return {
2513
+ id: `patch_${createHash("sha256").update(`${session.session.id}:${provider}:${createdAt}`).digest("hex").slice(0, 12)}`,
2514
+ createdAt,
2515
+ provider,
2516
+ summary: typeof parsed.summary === "string" && parsed.summary.trim() ? parsed.summary.trim() : `AI patch proposal for ${session.workItem.key}`,
2517
+ files,
2518
+ risks: stringArrayOr(parsed.risks, [])
2519
+ };
2520
+ }
2521
+ function normalizeWorkspaceRelativePath(workspacePath, requestedPath, mode) {
2522
+ const trimmed = requestedPath.trim();
2523
+ if (!trimmed) {
2524
+ return undefined;
2525
+ }
2526
+ const absolutePath = isAbsolute(trimmed) ? resolve(trimmed) : resolve(workspacePath, trimmed);
2527
+ const relativePath = relative(workspacePath, absolutePath).replace(/\\/gu, "/");
2528
+ const invalid = relativePath === "" || relativePath.startsWith("../") || relativePath === ".." || isAbsolute(relativePath);
2529
+ if (invalid) {
2530
+ if (mode === "throw") {
2531
+ throw new Error(`AI patch path is outside the workspace: ${requestedPath}`);
2532
+ }
2533
+ return undefined;
2534
+ }
2535
+ return relativePath;
2536
+ }
2537
+ function isSensitiveWorkspacePath(filePath) {
2538
+ const lower = filePath.toLowerCase();
2539
+ if (lower.includes("/.git/") || lower.startsWith(".git/") || lower.includes("/node_modules/") || lower.startsWith("node_modules/")) {
2540
+ return true;
2541
+ }
2542
+ return sensitivePathFragments.some((fragment) => lower === fragment || lower.includes(`/${fragment}`) || lower.endsWith(fragment));
2543
+ }
2544
+ async function applyPatchFiles(workspacePath, files) {
2545
+ for (const file of files) {
2546
+ const relativePath = normalizeWorkspaceRelativePath(workspacePath, file.path, "throw");
2547
+ if (!relativePath || isSensitiveWorkspacePath(relativePath)) {
2548
+ throw new Error(`Refusing to write unsafe AI patch path: ${file.path}`);
2549
+ }
2550
+ const absolutePath = resolve(workspacePath, relativePath);
2551
+ await mkdir(dirname(absolutePath), { recursive: true });
2552
+ await writeFile(absolutePath, file.content, "utf8");
2553
+ }
2554
+ }
1941
2555
  async function discoverTestCommandCandidates(workspacePath) {
1942
2556
  const scripts = await readPackageScripts(join(workspacePath, "package.json"));
1943
2557
  const packageManager = detectPackageManager(workspacePath);
@@ -2028,7 +2642,24 @@ function createCommandApproval(session, candidate, now) {
2028
2642
  status: "approved"
2029
2643
  };
2030
2644
  }
2031
- function buildPullRequestDraft(session, createdAt) {
2645
+ function createFileEditApproval(session, proposal, now, reason) {
2646
+ return {
2647
+ id: `approval_${createHash("sha256").update(`${session.session.id}:edit_files:${proposal.id}`).digest("hex").slice(0, 12)}`,
2648
+ type: "edit_files",
2649
+ title: `File edit approval for ${session.workItem.key}`,
2650
+ reason,
2651
+ details: [
2652
+ `Session: ${session.session.id}`,
2653
+ `Work item: ${session.workItem.key}`,
2654
+ `Workspace: ${session.workspaceCandidate?.workspace.name ?? "unresolved"}`,
2655
+ `Provider: ${getModelProviderDisplayName(proposal.provider)}`,
2656
+ `Files: ${proposal.files.map((file) => `${file.action} ${file.path}`).join(", ")}`,
2657
+ `Recorded at: ${now}`
2658
+ ],
2659
+ status: "pending"
2660
+ };
2661
+ }
2662
+ function buildPullRequestDraft(session, createdAt, baseBranch = "main") {
2032
2663
  const workItem = session.workItem;
2033
2664
  const workspace = session.workspaceCandidate?.workspace;
2034
2665
  const title = `${workItem.key}: ${workItem.title}`;
@@ -2052,12 +2683,69 @@ function buildPullRequestDraft(session, createdAt) {
2052
2683
  return {
2053
2684
  title,
2054
2685
  body,
2055
- baseBranch: "main",
2056
- headBranch: session.session.branchName ?? `openpome/${workItem.key.toLowerCase()}`,
2686
+ baseBranch,
2687
+ headBranch: selectPullRequestBranchName(session),
2057
2688
  remoteUrl: workspace?.remoteUrls[0],
2058
2689
  createdAt
2059
2690
  };
2060
2691
  }
2692
+ function selectPullRequestBranchName(session) {
2693
+ const currentBranch = session.session.branchName?.trim();
2694
+ if (currentBranch && !["main", "master", "develop", "development"].includes(currentBranch)) {
2695
+ return currentBranch;
2696
+ }
2697
+ const slug = session.workItem.title
2698
+ .toLowerCase()
2699
+ .replace(/[^a-z0-9]+/gu, "-")
2700
+ .replace(/^-|-$/gu, "")
2701
+ .slice(0, 48);
2702
+ return `openpome/${session.workItem.key.toLowerCase()}${slug ? `-${slug}` : ""}`;
2703
+ }
2704
+ async function ensurePullRequestBranch(workspacePath, branch) {
2705
+ const currentBranch = (await runGit(workspacePath, ["branch", "--show-current"])).trim();
2706
+ if (currentBranch !== branch) {
2707
+ await runGitStrict(workspacePath, ["checkout", "-B", branch]);
2708
+ }
2709
+ return branch;
2710
+ }
2711
+ async function hasWorkspaceChanges(workspacePath) {
2712
+ const output = await runGit(workspacePath, ["status", "--porcelain"]);
2713
+ return output.trim().length > 0;
2714
+ }
2715
+ async function detectPullRequestBaseBranch(workspacePath) {
2716
+ const originHead = (await runGit(workspacePath, ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"])).trim();
2717
+ if (originHead.startsWith("origin/")) {
2718
+ return originHead.slice("origin/".length);
2719
+ }
2720
+ const remoteDefault = await runGit(workspacePath, ["remote", "show", "origin"]);
2721
+ const headLine = remoteDefault
2722
+ .split(/\r?\n/u)
2723
+ .map((line) => line.trim())
2724
+ .find((line) => line.startsWith("HEAD branch:"));
2725
+ const headBranch = headLine?.replace("HEAD branch:", "").trim();
2726
+ return headBranch || "main";
2727
+ }
2728
+ function hasPassedTestEvidence(session) {
2729
+ return (session.testRunEvidence ?? []).some((run) => run.status === "passed");
2730
+ }
2731
+ async function runGitStrict(cwd, args) {
2732
+ return execFileStrict("git", args, cwd);
2733
+ }
2734
+ async function execFileStrict(command, args, cwd) {
2735
+ try {
2736
+ const result = await execFileAsync(command, args, {
2737
+ cwd,
2738
+ timeout: 2 * 60 * 1000,
2739
+ maxBuffer: 1024 * 1024,
2740
+ windowsHide: true
2741
+ });
2742
+ return result.stdout;
2743
+ }
2744
+ catch (error) {
2745
+ const detail = summarizeExecError(error);
2746
+ throw new Error(`${command} ${args.join(" ")} failed${detail ? `: ${detail}` : "."}`);
2747
+ }
2748
+ }
2061
2749
  function buildWorkItemUpdateDraft(session, createdAt) {
2062
2750
  const lines = [
2063
2751
  `OpenPome update for ${session.workItem.key}`,
@@ -2257,21 +2945,22 @@ function summarizeExecError(error) {
2257
2945
  const message = typeof maybeError.message === "string" ? maybeError.message : "";
2258
2946
  return stderr || stdout || message || undefined;
2259
2947
  }
2260
- async function createExternalActionGuard(action) {
2261
- const paths = getOpenPomePaths();
2262
- const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
2948
+ function createExternalActionApproval(session, type, now, details) {
2263
2949
  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."
2950
+ id: `approval_${createHash("sha256").update(`${session.session.id}:${type}:${now}`).digest("hex").slice(0, 12)}`,
2951
+ type,
2952
+ title: type === "create_pr" ? `PR creation approval for ${session.workItem.key}` : `Work item update approval for ${session.workItem.key}`,
2953
+ reason: type === "create_pr"
2954
+ ? "Developer explicitly requested OpenPome to create the GitHub pull request."
2955
+ : "Developer explicitly requested OpenPome to post the work item update.",
2956
+ details: [
2957
+ `Session: ${session.session.id}`,
2958
+ `Work item: ${session.workItem.key}`,
2959
+ `Workspace: ${session.workspaceCandidate?.workspace.name ?? "unresolved"}`,
2960
+ ...details,
2961
+ `Recorded at: ${now}`
2962
+ ],
2963
+ status: "approved"
2275
2964
  };
2276
2965
  }
2277
2966
  function createPlanApproval(session, status, now, reason = "Developer reviewed the implementation plan.") {