@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/connectors/work-item-registry.d.ts +3 -2
- package/dist/connectors/work-item-registry.d.ts.map +1 -1
- package/dist/connectors/work-item-registry.js +3 -0
- package/dist/connectors/work-item-registry.js.map +1 -1
- package/dist/index.d.ts +89 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +873 -31
- package/dist/index.js.map +1 -1
- package/package.json +10 -10
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.
|
|
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: "
|
|
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 =
|
|
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
|
-
"
|
|
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
|
|
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
|
|
1812
|
-
headBranch: session
|
|
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
|
-
|
|
2017
|
-
const paths = getOpenPomePaths();
|
|
2018
|
-
const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
|
|
2857
|
+
function createExternalActionApproval(session, type, now, details) {
|
|
2019
2858
|
return {
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
session:
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
:
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
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.") {
|