@openpome/local-gateway 0.22.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";
@@ -15,6 +15,8 @@ import { createDefaultWorkItemSourceRegistry, createJiraCloudOAuthLogin, exchang
15
15
  const execAsync = promisify(exec);
16
16
  const execFileAsync = promisify(execFile);
17
17
  const jiraOAuthCredentialAccount = "jira-cloud/oauth";
18
+ const openAiCredentialAccount = "model/openai/api-key";
19
+ const anthropicCredentialAccount = "model/anthropic/api-key";
18
20
  const workspaceIndexFileName = "workspace-index.json";
19
21
  const workspaceLinksFileName = "workspace-links.json";
20
22
  const activeTaskSessionFileName = "active-task-session.json";
@@ -37,7 +39,7 @@ const maxWorkspaceScanRepositories = 200;
37
39
  export function getGatewayHealth() {
38
40
  return {
39
41
  status: "ok",
40
- version: "0.22.0-alpha.0"
42
+ version: "0.26.0-alpha.0"
41
43
  };
42
44
  }
43
45
  export async function initOpenPome() {
@@ -85,6 +87,76 @@ export async function resetOpenPomeConfig() {
85
87
  resetAt
86
88
  };
87
89
  }
90
+ export async function getModelProviderStatus(env = process.env) {
91
+ const paths = getOpenPomePaths();
92
+ const config = await readConfigIfPresent(paths.configFile);
93
+ const activeProvider = normalizeModelProviderId(config?.activeModelProvider ?? defaultConfig.activeModelProvider);
94
+ const [openaiConfigured, anthropicConfigured] = await Promise.all([
95
+ hasModelProviderApiKey("openai", env),
96
+ hasModelProviderApiKey("anthropic", env)
97
+ ]);
98
+ return {
99
+ activeProvider,
100
+ providers: [
101
+ {
102
+ provider: "manual-copy",
103
+ displayName: "Manual copy",
104
+ configured: true,
105
+ active: activeProvider === "manual-copy",
106
+ detail: "Ready without an API key. OpenPome prepares safe context for a developer-controlled AI session."
107
+ },
108
+ {
109
+ provider: "openai",
110
+ displayName: "OpenAI",
111
+ configured: openaiConfigured,
112
+ active: activeProvider === "openai",
113
+ detail: openaiConfigured
114
+ ? "OpenAI API key is configured."
115
+ : "Set up with `pome auth ai openai`."
116
+ },
117
+ {
118
+ provider: "anthropic",
119
+ displayName: "Claude",
120
+ configured: anthropicConfigured,
121
+ active: activeProvider === "anthropic",
122
+ detail: anthropicConfigured
123
+ ? "Anthropic Claude API key is configured."
124
+ : "Set up with `pome auth ai claude`."
125
+ }
126
+ ]
127
+ };
128
+ }
129
+ export async function configureModelProvider(provider, apiKey, env = process.env) {
130
+ const providerId = normalizeModelProviderId(provider);
131
+ const paths = getOpenPomePaths();
132
+ const existingConfig = await readConfigIfPresent(paths.configFile);
133
+ const config = {
134
+ ...defaultConfig,
135
+ ...existingConfig,
136
+ activeModelProvider: providerId
137
+ };
138
+ if (providerId !== "manual-copy") {
139
+ const key = apiKey?.trim() || getModelProviderEnvKey(providerId, env);
140
+ if (!key) {
141
+ throw new Error(`${getModelProviderDisplayName(providerId)} API key is required.`);
142
+ }
143
+ const store = createCredentialStore();
144
+ if (!store.isAvailable()) {
145
+ throw new Error(`Credential store is unavailable: ${store.backend}`);
146
+ }
147
+ await setJsonCredential(store, getModelProviderCredentialAccount(providerId), { apiKey: key });
148
+ }
149
+ await writeConfig(paths.configFile, config);
150
+ return {
151
+ provider: providerId,
152
+ displayName: getModelProviderDisplayName(providerId),
153
+ configured: true,
154
+ configFile: paths.configFile,
155
+ detail: providerId === "manual-copy"
156
+ ? "Manual-copy AI mode is active."
157
+ : `${getModelProviderDisplayName(providerId)} is connected and active for AI planning.`
158
+ };
159
+ }
88
160
  export async function runDoctor(env = process.env) {
89
161
  const paths = getOpenPomePaths();
90
162
  const config = await readConfigIfPresent(paths.configFile);
@@ -92,6 +164,8 @@ export async function runDoctor(env = process.env) {
92
164
  const authStatus = jiraSource.getAuthStatus();
93
165
  const reachability = await jiraSource.checkReachability();
94
166
  const credentialStore = createCredentialStore();
167
+ const modelStatus = await getModelProviderStatus(env);
168
+ const activeModel = modelStatus.providers.find((provider) => provider.active);
95
169
  const checks = [
96
170
  {
97
171
  name: "Local data directory",
@@ -134,8 +208,8 @@ export async function runDoctor(env = process.env) {
134
208
  },
135
209
  {
136
210
  name: "Model provider",
137
- status: "ok",
138
- detail: "manual-copy"
211
+ status: activeModel?.configured ? "ok" : "attention",
212
+ detail: activeModel?.detail ?? "Run `pome auth ai status` to inspect AI setup."
139
213
  }
140
214
  ];
141
215
  return {
@@ -391,8 +465,11 @@ export async function getTaskSessionStatus() {
391
465
  testRunEvidence: persisted.testRunEvidence ?? [],
392
466
  prDraft: persisted.prDraft,
393
467
  workItemUpdateDraft: persisted.workItemUpdateDraft,
468
+ prCreation: persisted.prCreation,
469
+ workItemUpdatePost: persisted.workItemUpdatePost,
394
470
  aiContext: persisted.aiContext,
395
- diffSummary: persisted.diffSummary
471
+ diffSummary: persisted.diffSummary,
472
+ aiPatchProposal: persisted.aiPatchProposal
396
473
  };
397
474
  }
398
475
  export async function getTaskSessionTimeline() {
@@ -549,7 +626,7 @@ export async function createTaskSessionPlan() {
549
626
  title: `${persisted.workItem.key} ${persisted.workItem.title}`,
550
627
  context: buildPlanningContext(persisted)
551
628
  });
552
- const plan = buildInitialImplementationPlan(persisted.workItem, persisted.workspaceCandidate);
629
+ const plan = await buildImplementationPlan(persisted, prompt);
553
630
  const now = new Date().toISOString();
554
631
  const session = {
555
632
  ...persisted.session,
@@ -675,6 +752,157 @@ export async function rejectTaskSessionPlan(reason = "Plan rejected by developer
675
752
  nextStep: "Revise the work item context or workspace link, then run `pome plan` again."
676
753
  };
677
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
+ }
678
906
  export async function discoverTestCommands() {
679
907
  const paths = getOpenPomePaths();
680
908
  const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
@@ -937,12 +1165,6 @@ export async function getGitHubAuthStatus() {
937
1165
  };
938
1166
  }
939
1167
  }
940
- export async function createPullRequestExternalGuard() {
941
- return createExternalActionGuard("create_pr");
942
- }
943
- export async function postWorkItemUpdateExternalGuard() {
944
- return createExternalActionGuard("update_work_item");
945
- }
946
1168
  export async function createPullRequestDraft() {
947
1169
  const paths = getOpenPomePaths();
948
1170
  const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
@@ -953,7 +1175,9 @@ export async function createPullRequestDraft() {
953
1175
  };
954
1176
  }
955
1177
  const now = new Date().toISOString();
956
- const draft = buildPullRequestDraft(persisted, now);
1178
+ const draft = buildPullRequestDraft(persisted, now, persisted.workspaceCandidate?.workspace.path
1179
+ ? await detectPullRequestBaseBranch(persisted.workspaceCandidate.workspace.path)
1180
+ : "main");
957
1181
  await writeActiveTaskSession(paths.homeDirectory, {
958
1182
  ...persisted,
959
1183
  prDraft: draft,
@@ -972,6 +1196,104 @@ export async function createPullRequestDraft() {
972
1196
  draft
973
1197
  };
974
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
+ }
975
1297
  export async function createWorkItemUpdateDraft() {
976
1298
  const paths = getOpenPomePaths();
977
1299
  const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
@@ -1001,6 +1323,61 @@ export async function createWorkItemUpdateDraft() {
1001
1323
  draft
1002
1324
  };
1003
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
+ }
1004
1381
  export async function getJiraAuthStatus(env = process.env) {
1005
1382
  const source = await createJiraSource(env);
1006
1383
  const status = source.getAuthStatus();
@@ -1126,6 +1503,51 @@ async function createJiraSource(env) {
1126
1503
  connectorCredentials: storedOAuth ? { [jiraOAuthCredentialAccount]: storedOAuth } : undefined
1127
1504
  });
1128
1505
  }
1506
+ function normalizeModelProviderId(provider) {
1507
+ switch ((provider ?? "manual-copy").toLowerCase()) {
1508
+ case "openai":
1509
+ return "openai";
1510
+ case "anthropic":
1511
+ case "claude":
1512
+ return "anthropic";
1513
+ case "manual":
1514
+ case "manual-copy":
1515
+ return "manual-copy";
1516
+ default:
1517
+ throw new Error(`Unsupported AI provider: ${provider}`);
1518
+ }
1519
+ }
1520
+ function getModelProviderDisplayName(provider) {
1521
+ switch (provider) {
1522
+ case "openai":
1523
+ return "OpenAI";
1524
+ case "anthropic":
1525
+ return "Claude";
1526
+ case "manual-copy":
1527
+ return "Manual copy";
1528
+ }
1529
+ }
1530
+ function getModelProviderCredentialAccount(provider) {
1531
+ return provider === "openai" ? openAiCredentialAccount : anthropicCredentialAccount;
1532
+ }
1533
+ function getModelProviderEnvKey(provider, env) {
1534
+ return provider === "openai" ? env["OPENAI_API_KEY"] : env["ANTHROPIC_API_KEY"];
1535
+ }
1536
+ async function hasModelProviderApiKey(provider, env) {
1537
+ return Boolean(await getModelProviderApiKey(provider, env));
1538
+ }
1539
+ async function getModelProviderApiKey(provider, env = process.env) {
1540
+ const envKey = getModelProviderEnvKey(provider, env);
1541
+ if (envKey) {
1542
+ return envKey;
1543
+ }
1544
+ const store = createCredentialStore();
1545
+ if (!store.isAvailable()) {
1546
+ return undefined;
1547
+ }
1548
+ const stored = await getJsonCredential(store, getModelProviderCredentialAccount(provider));
1549
+ return stored?.apiKey;
1550
+ }
1129
1551
  function getActiveJiraBoardScope(config) {
1130
1552
  if (config?.activeWorkItemScope?.providerId !== "jira-cloud" || config.activeWorkItemScope.kind !== "board") {
1131
1553
  return undefined;
@@ -1689,11 +2111,356 @@ function buildInitialImplementationPlan(workItem, workspaceCandidate) {
1689
2111
  commandsToRun: ["pome approve", "pnpm validate"],
1690
2112
  risks: [
1691
2113
  "Workspace resolution may be incomplete until real GitHub and historical session signals are added.",
1692
- "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."
1693
2115
  ],
1694
2116
  missingInfo: hasWorkspace ? [] : ["No workspace candidate is selected yet."]
1695
2117
  };
1696
2118
  }
2119
+ async function buildImplementationPlan(persisted, prompt) {
2120
+ const config = await readConfigIfPresent(getOpenPomePaths().configFile);
2121
+ const provider = normalizeModelProviderId(config?.activeModelProvider ?? defaultConfig.activeModelProvider);
2122
+ if (provider === "manual-copy") {
2123
+ return buildInitialImplementationPlan(persisted.workItem, persisted.workspaceCandidate);
2124
+ }
2125
+ const apiKey = await getModelProviderApiKey(provider);
2126
+ if (!apiKey) {
2127
+ throw new Error(`${getModelProviderDisplayName(provider)} is active, but no API key is configured. Run \`pome auth ai ${provider === "anthropic" ? "claude" : provider}\`.`);
2128
+ }
2129
+ const response = provider === "openai"
2130
+ ? await completeOpenAIPlan(prompt, apiKey)
2131
+ : await completeAnthropicPlan(prompt, apiKey);
2132
+ return parseImplementationPlan(response, persisted.workItem, persisted.workspaceCandidate, provider);
2133
+ }
2134
+ async function completeOpenAIPlan(prompt, apiKey) {
2135
+ return completeOpenAIText(buildStructuredPlanPrompt(prompt), apiKey);
2136
+ }
2137
+ async function completeOpenAIText(prompt, apiKey) {
2138
+ const response = await fetch("https://api.openai.com/v1/responses", {
2139
+ method: "POST",
2140
+ headers: {
2141
+ "authorization": `Bearer ${apiKey}`,
2142
+ "content-type": "application/json"
2143
+ },
2144
+ body: JSON.stringify({
2145
+ model: process.env["OPENPOME_OPENAI_MODEL"] ?? "gpt-5",
2146
+ input: prompt
2147
+ })
2148
+ });
2149
+ if (!response.ok) {
2150
+ throw new Error(`OpenAI request failed: ${response.status} ${response.statusText}`);
2151
+ }
2152
+ const body = await response.json();
2153
+ if (typeof body.output_text === "string") {
2154
+ return body.output_text;
2155
+ }
2156
+ const output = Array.isArray(body.output) ? body.output : [];
2157
+ return output
2158
+ .flatMap((item) => typeof item === "object" && item && "content" in item && Array.isArray(item.content) ? item.content : [])
2159
+ .map((content) => typeof content === "object" && content && "text" in content ? String(content.text) : "")
2160
+ .filter(Boolean)
2161
+ .join("\n");
2162
+ }
2163
+ async function completeAnthropicPlan(prompt, apiKey) {
2164
+ return completeAnthropicText(buildStructuredPlanPrompt(prompt), apiKey);
2165
+ }
2166
+ async function completeAnthropicText(prompt, apiKey) {
2167
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
2168
+ method: "POST",
2169
+ headers: {
2170
+ "x-api-key": apiKey,
2171
+ "anthropic-version": "2023-06-01",
2172
+ "content-type": "application/json"
2173
+ },
2174
+ body: JSON.stringify({
2175
+ model: process.env["OPENPOME_ANTHROPIC_MODEL"] ?? "claude-sonnet-4-20250514",
2176
+ max_tokens: 1800,
2177
+ messages: [
2178
+ {
2179
+ role: "user",
2180
+ content: prompt
2181
+ }
2182
+ ]
2183
+ })
2184
+ });
2185
+ if (!response.ok) {
2186
+ throw new Error(`Claude request failed: ${response.status} ${response.statusText}`);
2187
+ }
2188
+ const body = await response.json();
2189
+ const content = Array.isArray(body.content) ? body.content : [];
2190
+ return content
2191
+ .map((item) => typeof item === "object" && item && "text" in item ? String(item.text) : "")
2192
+ .filter(Boolean)
2193
+ .join("\n");
2194
+ }
2195
+ function buildStructuredPlanPrompt(prompt) {
2196
+ return [
2197
+ "You are OpenPome's planning engine.",
2198
+ "Return only compact JSON with this exact shape:",
2199
+ "{\"summary\":\"...\",\"assumptions\":[\"...\"],\"steps\":[{\"id\":\"1\",\"title\":\"...\",\"detail\":\"...\"}],\"filesLikelyChanged\":[\"...\"],\"commandsToRun\":[\"...\"],\"risks\":[\"...\"],\"missingInfo\":[\"...\"]}",
2200
+ "Do not include source code, full diffs, secrets, or markdown fences.",
2201
+ "",
2202
+ prompt
2203
+ ].join("\n");
2204
+ }
2205
+ function parseImplementationPlan(value, workItem, workspaceCandidate, provider) {
2206
+ const fallback = buildInitialImplementationPlan(workItem, workspaceCandidate);
2207
+ const json = extractJsonObject(value);
2208
+ if (!json) {
2209
+ return {
2210
+ ...fallback,
2211
+ risks: [`${getModelProviderDisplayName(provider)} returned a non-JSON plan; deterministic fallback was used.`, ...fallback.risks]
2212
+ };
2213
+ }
2214
+ try {
2215
+ const parsed = JSON.parse(json);
2216
+ const steps = Array.isArray(parsed.steps)
2217
+ ? parsed.steps
2218
+ .map((step, index) => ({
2219
+ id: typeof step?.id === "string" ? step.id : String(index + 1),
2220
+ title: typeof step?.title === "string" ? step.title : `Step ${index + 1}`,
2221
+ detail: typeof step?.detail === "string" ? step.detail : undefined
2222
+ }))
2223
+ .filter((step) => step.title.trim().length > 0)
2224
+ : fallback.steps;
2225
+ return {
2226
+ summary: typeof parsed.summary === "string" ? parsed.summary : fallback.summary,
2227
+ assumptions: stringArrayOr(parsed.assumptions, fallback.assumptions),
2228
+ steps: steps.length > 0 ? steps : fallback.steps,
2229
+ filesLikelyChanged: stringArrayOr(parsed.filesLikelyChanged, fallback.filesLikelyChanged),
2230
+ commandsToRun: stringArrayOr(parsed.commandsToRun, fallback.commandsToRun),
2231
+ risks: stringArrayOr(parsed.risks, fallback.risks),
2232
+ missingInfo: stringArrayOr(parsed.missingInfo, fallback.missingInfo)
2233
+ };
2234
+ }
2235
+ catch {
2236
+ return {
2237
+ ...fallback,
2238
+ risks: [`${getModelProviderDisplayName(provider)} returned invalid JSON; deterministic fallback was used.`, ...fallback.risks]
2239
+ };
2240
+ }
2241
+ }
2242
+ function extractJsonObject(value) {
2243
+ const start = value.indexOf("{");
2244
+ const end = value.lastIndexOf("}");
2245
+ return start >= 0 && end > start ? value.slice(start, end + 1) : undefined;
2246
+ }
2247
+ function stringArrayOr(value, fallback) {
2248
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : fallback;
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
+ }
1697
2464
  async function discoverTestCommandCandidates(workspacePath) {
1698
2465
  const scripts = await readPackageScripts(join(workspacePath, "package.json"));
1699
2466
  const packageManager = detectPackageManager(workspacePath);
@@ -1784,7 +2551,24 @@ function createCommandApproval(session, candidate, now) {
1784
2551
  status: "approved"
1785
2552
  };
1786
2553
  }
1787
- 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") {
1788
2572
  const workItem = session.workItem;
1789
2573
  const workspace = session.workspaceCandidate?.workspace;
1790
2574
  const title = `${workItem.key}: ${workItem.title}`;
@@ -1808,12 +2592,69 @@ function buildPullRequestDraft(session, createdAt) {
1808
2592
  return {
1809
2593
  title,
1810
2594
  body,
1811
- baseBranch: "main",
1812
- headBranch: session.session.branchName ?? `openpome/${workItem.key.toLowerCase()}`,
2595
+ baseBranch,
2596
+ headBranch: selectPullRequestBranchName(session),
1813
2597
  remoteUrl: workspace?.remoteUrls[0],
1814
2598
  createdAt
1815
2599
  };
1816
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
+ }
1817
2658
  function buildWorkItemUpdateDraft(session, createdAt) {
1818
2659
  const lines = [
1819
2660
  `OpenPome update for ${session.workItem.key}`,
@@ -2013,21 +2854,22 @@ function summarizeExecError(error) {
2013
2854
  const message = typeof maybeError.message === "string" ? maybeError.message : "";
2014
2855
  return stderr || stdout || message || undefined;
2015
2856
  }
2016
- async function createExternalActionGuard(action) {
2017
- const paths = getOpenPomePaths();
2018
- const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
2857
+ function createExternalActionApproval(session, type, now, details) {
2019
2858
  return {
2020
- active: Boolean(persisted),
2021
- sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
2022
- session: persisted?.session,
2023
- action,
2024
- allowed: false,
2025
- detail: action === "create_pr"
2026
- ? "PR creation is not enabled in this alpha. Use `pome pr draft` and create the PR manually."
2027
- : "Work item update posting is not enabled in this alpha. Use `pome work-item update-draft` and post manually.",
2028
- nextStep: action === "create_pr"
2029
- ? "Run `pome pr draft`, review the body, then create the PR yourself."
2030
- : "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"
2031
2873
  };
2032
2874
  }
2033
2875
  function createPlanApproval(session, status, now, reason = "Developer reviewed the implementation plan.") {