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