@openpome/local-gateway 0.28.0-alpha.0 → 0.32.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ 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 githubOAuthCredentialAccount = "github/oauth";
18
19
  const openAiCredentialAccount = "model/openai/api-key";
19
20
  const anthropicCredentialAccount = "model/anthropic/api-key";
20
21
  const workspaceIndexFileName = "workspace-index.json";
@@ -39,7 +40,7 @@ const maxWorkspaceScanRepositories = 200;
39
40
  export function getGatewayHealth() {
40
41
  return {
41
42
  status: "ok",
42
- version: "0.28.0-alpha.0"
43
+ version: "0.32.0-alpha.0"
43
44
  };
44
45
  }
45
46
  export async function initOpenPome() {
@@ -463,7 +464,7 @@ export async function startTaskSession(key, env = process.env) {
463
464
  }
464
465
  export async function getTaskSessionStatus() {
465
466
  const paths = getOpenPomePaths();
466
- const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
467
+ const persisted = await refreshActiveTaskSessionWorkItem(paths.homeDirectory);
467
468
  if (!persisted) {
468
469
  return {
469
470
  active: false,
@@ -494,7 +495,7 @@ export async function getTaskSessionStatus() {
494
495
  }
495
496
  export async function getTaskSessionTimeline() {
496
497
  const paths = getOpenPomePaths();
497
- const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
498
+ const persisted = await refreshActiveTaskSessionWorkItem(paths.homeDirectory);
498
499
  return {
499
500
  active: Boolean(persisted),
500
501
  sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
@@ -504,7 +505,7 @@ export async function getTaskSessionTimeline() {
504
505
  }
505
506
  export async function getTaskSessionApprovalHistory() {
506
507
  const paths = getOpenPomePaths();
507
- const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
508
+ const persisted = await refreshActiveTaskSessionWorkItem(paths.homeDirectory);
508
509
  return {
509
510
  active: Boolean(persisted),
510
511
  sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
@@ -638,7 +639,7 @@ export async function resetTaskSession() {
638
639
  }
639
640
  export async function createTaskSessionPlan() {
640
641
  const paths = getOpenPomePaths();
641
- const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
642
+ const persisted = await refreshActiveTaskSessionWorkItem(paths.homeDirectory);
642
643
  if (!persisted) {
643
644
  return undefined;
644
645
  }
@@ -686,7 +687,7 @@ export async function createTaskSessionPlan() {
686
687
  }
687
688
  export async function approveTaskSessionPlan() {
688
689
  const paths = getOpenPomePaths();
689
- const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
690
+ const persisted = await refreshActiveTaskSessionWorkItem(paths.homeDirectory);
690
691
  if (!persisted) {
691
692
  return undefined;
692
693
  }
@@ -730,7 +731,7 @@ export async function approveTaskSessionPlan() {
730
731
  }
731
732
  export async function rejectTaskSessionPlan(reason = "Plan rejected by developer.") {
732
733
  const paths = getOpenPomePaths();
733
- const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
734
+ const persisted = await refreshActiveTaskSessionWorkItem(paths.homeDirectory);
734
735
  if (!persisted) {
735
736
  return undefined;
736
737
  }
@@ -774,7 +775,7 @@ export async function rejectTaskSessionPlan(reason = "Plan rejected by developer
774
775
  }
775
776
  export async function createAIPatchProposal() {
776
777
  const paths = getOpenPomePaths();
777
- const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
778
+ const persisted = await refreshActiveTaskSessionWorkItem(paths.homeDirectory);
778
779
  if (!persisted) {
779
780
  return {
780
781
  active: false,
@@ -808,18 +809,21 @@ export async function createAIPatchProposal() {
808
809
  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
  }
810
811
  const createdAt = new Date().toISOString();
812
+ const retryingFailedTest = hasFailedTestAfterLatestAppliedPatch(persisted);
811
813
  const fileContext = await collectPatchContextFiles(workspacePath, persisted);
812
814
  const prompt = buildStructuredPatchPrompt(persisted, workspacePath, fileContext);
813
815
  const response = await completeModelText(provider, prompt);
814
816
  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.");
817
+ const approval = createFileEditApproval(persisted, proposalDraft, createdAt, retryingFailedTest
818
+ ? "Developer approval is required before OpenPome writes AI-proposed fixes for the failed test."
819
+ : "Developer approval is required before OpenPome writes AI-proposed file changes.");
816
820
  const proposal = {
817
821
  ...proposalDraft,
818
822
  approval
819
823
  };
820
824
  const session = {
821
825
  ...persisted.session,
822
- status: "awaiting_approval",
826
+ status: retryingFailedTest ? "fixing" : "awaiting_approval",
823
827
  updatedAt: createdAt
824
828
  };
825
829
  await writeActiveTaskSession(paths.homeDirectory, {
@@ -828,7 +832,7 @@ export async function createAIPatchProposal() {
828
832
  aiPatchProposal: proposal,
829
833
  approvalHistory: appendApprovalHistory(persisted.approvalHistory, approval),
830
834
  events: appendSessionEvents(persisted.events, [
831
- createSessionEvent(session, persisted.workItem.key, "approval_requested", "AI file changes proposed", createdAt, [
835
+ createSessionEvent(session, persisted.workItem.key, "approval_requested", retryingFailedTest ? "AI test-failure fix proposed" : "AI file changes proposed", createdAt, [
832
836
  `Provider: ${getModelProviderDisplayName(provider)}`,
833
837
  `Files proposed: ${proposal.files.map((file) => file.path).join(", ") || "none"}`,
834
838
  ...approval.details
@@ -849,7 +853,7 @@ export async function createAIPatchProposal() {
849
853
  }
850
854
  export async function approveAndApplyAIPatchProposal() {
851
855
  const paths = getOpenPomePaths();
852
- const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
856
+ const persisted = await refreshActiveTaskSessionWorkItem(paths.homeDirectory);
853
857
  if (!persisted) {
854
858
  return undefined;
855
859
  }
@@ -919,7 +923,7 @@ export async function approveAndApplyAIPatchProposal() {
919
923
  }
920
924
  export async function discoverTestCommands() {
921
925
  const paths = getOpenPomePaths();
922
- const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
926
+ const persisted = await refreshActiveTaskSessionWorkItem(paths.homeDirectory);
923
927
  if (!persisted) {
924
928
  return {
925
929
  active: false,
@@ -953,7 +957,7 @@ export async function discoverTestCommands() {
953
957
  }
954
958
  export async function approveTestCommand(command) {
955
959
  const paths = getOpenPomePaths();
956
- const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
960
+ const persisted = await refreshActiveTaskSessionWorkItem(paths.homeDirectory);
957
961
  if (!persisted) {
958
962
  return undefined;
959
963
  }
@@ -996,7 +1000,7 @@ export async function approveTestCommand(command) {
996
1000
  }
997
1001
  export async function getTestCommandHistory() {
998
1002
  const paths = getOpenPomePaths();
999
- const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
1003
+ const persisted = await refreshActiveTaskSessionWorkItem(paths.homeDirectory);
1000
1004
  return {
1001
1005
  active: Boolean(persisted),
1002
1006
  sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
@@ -1007,7 +1011,7 @@ export async function getTestCommandHistory() {
1007
1011
  }
1008
1012
  export async function runApprovedTestCommand(command) {
1009
1013
  const paths = getOpenPomePaths();
1010
- const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
1014
+ const persisted = await refreshActiveTaskSessionWorkItem(paths.homeDirectory);
1011
1015
  if (!persisted) {
1012
1016
  return undefined;
1013
1017
  }
@@ -1049,7 +1053,7 @@ export async function runApprovedTestCommand(command) {
1049
1053
  }
1050
1054
  export async function createManualCopyAIContext() {
1051
1055
  const paths = getOpenPomePaths();
1052
- const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
1056
+ const persisted = await refreshActiveTaskSessionWorkItem(paths.homeDirectory);
1053
1057
  if (!persisted) {
1054
1058
  return {
1055
1059
  active: false,
@@ -1083,7 +1087,7 @@ export async function createManualCopyAIContext() {
1083
1087
  }
1084
1088
  export async function createManualCopyAIPrompt() {
1085
1089
  const paths = getOpenPomePaths();
1086
- const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
1090
+ const persisted = await refreshActiveTaskSessionWorkItem(paths.homeDirectory);
1087
1091
  if (!persisted) {
1088
1092
  return {
1089
1093
  active: false,
@@ -1119,7 +1123,7 @@ export async function createManualCopyAIPrompt() {
1119
1123
  }
1120
1124
  export async function getDiffSummary() {
1121
1125
  const paths = getOpenPomePaths();
1122
- const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
1126
+ const persisted = await refreshActiveTaskSessionWorkItem(paths.homeDirectory);
1123
1127
  if (!persisted) {
1124
1128
  return {
1125
1129
  active: false,
@@ -1150,6 +1154,106 @@ export async function getDiffSummary() {
1150
1154
  };
1151
1155
  }
1152
1156
  export async function getGitHubAuthStatus() {
1157
+ const storedToken = await readStoredGitHubOAuth();
1158
+ if (storedToken?.accessToken) {
1159
+ try {
1160
+ const user = await fetchGitHubAuthenticatedUser(storedToken.accessToken);
1161
+ return {
1162
+ provider: "github",
1163
+ cliAvailable: await isGitHubCliAvailable(),
1164
+ cliAuthenticated: await isGitHubCliAuthenticated(),
1165
+ nativeAuthenticated: true,
1166
+ authenticated: true,
1167
+ username: user.login,
1168
+ tokenSource: "openpome",
1169
+ detail: `OpenPome GitHub browser login is connected as ${user.login}.`
1170
+ };
1171
+ }
1172
+ catch (error) {
1173
+ const fallback = await getGitHubCliAuthStatus();
1174
+ if (fallback.authenticated) {
1175
+ return {
1176
+ ...fallback,
1177
+ detail: `OpenPome GitHub token could not be verified (${summarizeUnknownError(error)}). ${fallback.detail}`
1178
+ };
1179
+ }
1180
+ return {
1181
+ ...fallback,
1182
+ detail: `OpenPome GitHub token could not be verified (${summarizeUnknownError(error)}). Run \`pome auth github login\` again.`
1183
+ };
1184
+ }
1185
+ }
1186
+ return getGitHubCliAuthStatus();
1187
+ }
1188
+ export async function createGitHubDeviceLogin(env = process.env) {
1189
+ const clientId = getGitHubOAuthClientId(env);
1190
+ const scope = getGitHubOAuthScope(env);
1191
+ const body = new URLSearchParams({
1192
+ client_id: clientId,
1193
+ scope
1194
+ });
1195
+ const response = await fetch("https://github.com/login/device/code", {
1196
+ method: "POST",
1197
+ headers: {
1198
+ Accept: "application/json",
1199
+ "Content-Type": "application/x-www-form-urlencoded"
1200
+ },
1201
+ body
1202
+ });
1203
+ if (!response.ok) {
1204
+ throw new Error(`GitHub device login failed: ${response.status} ${response.statusText}`);
1205
+ }
1206
+ const payload = (await response.json());
1207
+ if (!payload.device_code || !payload.user_code || !payload.verification_uri) {
1208
+ throw new Error("GitHub device login response was incomplete.");
1209
+ }
1210
+ const expiresIn = typeof payload.expires_in === "number" ? payload.expires_in : 900;
1211
+ const intervalSeconds = Math.max(1, typeof payload.interval === "number" ? payload.interval : 5);
1212
+ return {
1213
+ provider: "github",
1214
+ deviceCode: payload.device_code,
1215
+ userCode: payload.user_code,
1216
+ verificationUri: payload.verification_uri,
1217
+ expiresIn,
1218
+ expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
1219
+ intervalSeconds,
1220
+ scope,
1221
+ detail: "Open the GitHub verification URL, enter the code, then keep this terminal open."
1222
+ };
1223
+ }
1224
+ export async function completeGitHubDeviceLogin(login, env = process.env, options = {}) {
1225
+ const clientId = getGitHubOAuthClientId(env);
1226
+ const deadline = Date.now() + login.expiresIn * 1000;
1227
+ let intervalSeconds = login.intervalSeconds;
1228
+ while (Date.now() < deadline) {
1229
+ await delay(options.pollDelayMilliseconds ?? intervalSeconds * 1000);
1230
+ const token = await pollGitHubDeviceAccessToken(clientId, login.deviceCode);
1231
+ if (token.status === "pending") {
1232
+ continue;
1233
+ }
1234
+ if (token.status === "slow_down") {
1235
+ intervalSeconds += 5;
1236
+ continue;
1237
+ }
1238
+ if (token.status === "error") {
1239
+ throw new Error(token.detail);
1240
+ }
1241
+ const store = createCredentialStore();
1242
+ if (!store.isAvailable()) {
1243
+ throw new Error(`Credential store is unavailable: ${store.backend}`);
1244
+ }
1245
+ await setJsonCredential(store, githubOAuthCredentialAccount, token.token);
1246
+ const user = await fetchGitHubAuthenticatedUser(token.token.accessToken);
1247
+ return {
1248
+ provider: "github",
1249
+ authenticated: true,
1250
+ username: user.login,
1251
+ detail: `GitHub browser login completed for ${user.login}.`
1252
+ };
1253
+ }
1254
+ throw new Error("Timed out waiting for GitHub browser login approval.");
1255
+ }
1256
+ async function getGitHubCliAuthStatus() {
1153
1257
  try {
1154
1258
  await execFileAsync("gh", ["--version"]);
1155
1259
  }
@@ -1157,7 +1261,10 @@ export async function getGitHubAuthStatus() {
1157
1261
  return {
1158
1262
  provider: "github",
1159
1263
  cliAvailable: false,
1264
+ cliAuthenticated: false,
1265
+ nativeAuthenticated: false,
1160
1266
  authenticated: false,
1267
+ tokenSource: "none",
1161
1268
  detail: "GitHub CLI is not installed or is not on PATH."
1162
1269
  };
1163
1270
  }
@@ -1166,7 +1273,10 @@ export async function getGitHubAuthStatus() {
1166
1273
  return {
1167
1274
  provider: "github",
1168
1275
  cliAvailable: true,
1276
+ cliAuthenticated: true,
1277
+ nativeAuthenticated: false,
1169
1278
  authenticated: true,
1279
+ tokenSource: "github-cli",
1170
1280
  detail: "GitHub CLI is authenticated for github.com."
1171
1281
  };
1172
1282
  }
@@ -1174,14 +1284,17 @@ export async function getGitHubAuthStatus() {
1174
1284
  return {
1175
1285
  provider: "github",
1176
1286
  cliAvailable: true,
1287
+ cliAuthenticated: false,
1288
+ nativeAuthenticated: false,
1177
1289
  authenticated: false,
1290
+ tokenSource: "none",
1178
1291
  detail: summarizeExecError(error) || "GitHub CLI is installed but not authenticated for github.com."
1179
1292
  };
1180
1293
  }
1181
1294
  }
1182
1295
  export async function createPullRequestDraft() {
1183
1296
  const paths = getOpenPomePaths();
1184
- const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
1297
+ const persisted = await refreshActiveTaskSessionWorkItem(paths.homeDirectory);
1185
1298
  if (!persisted) {
1186
1299
  return {
1187
1300
  active: false,
@@ -1212,7 +1325,7 @@ export async function createPullRequestDraft() {
1212
1325
  }
1213
1326
  export async function createPullRequest(options = {}) {
1214
1327
  const paths = getOpenPomePaths();
1215
- const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
1328
+ const persisted = await refreshActiveTaskSessionWorkItem(paths.homeDirectory);
1216
1329
  if (!persisted) {
1217
1330
  return {
1218
1331
  active: false,
@@ -1310,7 +1423,7 @@ export async function createPullRequest(options = {}) {
1310
1423
  }
1311
1424
  export async function createWorkItemUpdateDraft() {
1312
1425
  const paths = getOpenPomePaths();
1313
- const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
1426
+ const persisted = await refreshActiveTaskSessionWorkItem(paths.homeDirectory);
1314
1427
  if (!persisted) {
1315
1428
  return {
1316
1429
  active: false,
@@ -1339,7 +1452,7 @@ export async function createWorkItemUpdateDraft() {
1339
1452
  }
1340
1453
  export async function postWorkItemUpdate() {
1341
1454
  const paths = getOpenPomePaths();
1342
- const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
1455
+ const persisted = await refreshActiveTaskSessionWorkItem(paths.homeDirectory);
1343
1456
  if (!persisted) {
1344
1457
  return {
1345
1458
  active: false,
@@ -1614,6 +1727,122 @@ async function readStoredJiraOAuth() {
1614
1727
  }
1615
1728
  return getJsonCredential(store, jiraOAuthCredentialAccount);
1616
1729
  }
1730
+ async function readStoredGitHubOAuth() {
1731
+ const store = createCredentialStore();
1732
+ if (!store.isAvailable()) {
1733
+ return undefined;
1734
+ }
1735
+ return getJsonCredential(store, githubOAuthCredentialAccount);
1736
+ }
1737
+ function getGitHubOAuthClientId(env) {
1738
+ const clientId = env["OPENPOME_GITHUB_OAUTH_CLIENT_ID"]?.trim();
1739
+ if (!clientId) {
1740
+ throw new Error("OPENPOME_GITHUB_OAUTH_CLIENT_ID is required for native GitHub browser login. Without it, use `gh auth login` as the fallback.");
1741
+ }
1742
+ return clientId;
1743
+ }
1744
+ function getGitHubOAuthScope(env) {
1745
+ return env["OPENPOME_GITHUB_OAUTH_SCOPE"]?.trim() || "repo read:user";
1746
+ }
1747
+ async function isGitHubCliAvailable() {
1748
+ try {
1749
+ await execFileAsync("gh", ["--version"]);
1750
+ return true;
1751
+ }
1752
+ catch {
1753
+ return false;
1754
+ }
1755
+ }
1756
+ async function isGitHubCliAuthenticated() {
1757
+ try {
1758
+ await execFileAsync("gh", ["auth", "status", "-h", "github.com"]);
1759
+ return true;
1760
+ }
1761
+ catch {
1762
+ return false;
1763
+ }
1764
+ }
1765
+ async function fetchGitHubAuthenticatedUser(accessToken) {
1766
+ const response = await fetch("https://api.github.com/user", {
1767
+ headers: {
1768
+ Accept: "application/vnd.github+json",
1769
+ Authorization: `Bearer ${accessToken}`,
1770
+ "X-GitHub-Api-Version": "2022-11-28"
1771
+ }
1772
+ });
1773
+ if (!response.ok) {
1774
+ throw new Error(`GitHub user lookup failed: ${response.status} ${response.statusText}`);
1775
+ }
1776
+ const payload = (await response.json());
1777
+ if (!payload.login) {
1778
+ throw new Error("GitHub user lookup response was incomplete.");
1779
+ }
1780
+ return {
1781
+ login: payload.login,
1782
+ id: payload.id,
1783
+ name: payload.name
1784
+ };
1785
+ }
1786
+ async function pollGitHubDeviceAccessToken(clientId, deviceCode) {
1787
+ const response = await fetch("https://github.com/login/oauth/access_token", {
1788
+ method: "POST",
1789
+ headers: {
1790
+ Accept: "application/json",
1791
+ "Content-Type": "application/x-www-form-urlencoded"
1792
+ },
1793
+ body: new URLSearchParams({
1794
+ client_id: clientId,
1795
+ device_code: deviceCode,
1796
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code"
1797
+ })
1798
+ });
1799
+ if (!response.ok) {
1800
+ return {
1801
+ status: "error",
1802
+ detail: `GitHub device token polling failed: ${response.status} ${response.statusText}`
1803
+ };
1804
+ }
1805
+ const payload = (await response.json());
1806
+ if (payload.error) {
1807
+ if (payload.error === "authorization_pending") {
1808
+ return { status: "pending" };
1809
+ }
1810
+ if (payload.error === "slow_down") {
1811
+ return { status: "slow_down" };
1812
+ }
1813
+ return {
1814
+ status: "error",
1815
+ detail: payload.error_description || `GitHub device login failed: ${payload.error}`
1816
+ };
1817
+ }
1818
+ if (!payload.access_token) {
1819
+ return {
1820
+ status: "error",
1821
+ detail: "GitHub device token response did not include an access token."
1822
+ };
1823
+ }
1824
+ return {
1825
+ status: "complete",
1826
+ token: {
1827
+ accessToken: payload.access_token,
1828
+ tokenType: payload.token_type || "bearer",
1829
+ scopes: parseGitHubScopes(payload.scope),
1830
+ createdAt: new Date().toISOString()
1831
+ }
1832
+ };
1833
+ }
1834
+ function parseGitHubScopes(scope) {
1835
+ return (scope ?? "")
1836
+ .split(",")
1837
+ .map((value) => value.trim())
1838
+ .filter((value) => value.length > 0);
1839
+ }
1840
+ function summarizeUnknownError(error) {
1841
+ return error instanceof Error ? error.message : String(error);
1842
+ }
1843
+ function delay(milliseconds) {
1844
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
1845
+ }
1617
1846
  async function refreshStoredJiraOAuthIfNeeded(tokenSet, env) {
1618
1847
  if (!tokenSet || !shouldRefreshOAuthToken(tokenSet)) {
1619
1848
  return tokenSet;
@@ -1999,6 +2228,134 @@ function appendSessionEvents(existing, events) {
1999
2228
  function appendApprovalHistory(existing, approval) {
2000
2229
  return [...(existing ?? []), approval];
2001
2230
  }
2231
+ async function refreshActiveTaskSessionWorkItem(homeDirectory) {
2232
+ const persisted = await readActiveTaskSessionIfPresent(homeDirectory);
2233
+ if (!persisted) {
2234
+ return undefined;
2235
+ }
2236
+ return refreshPersistedTaskSessionWorkItem(homeDirectory, persisted);
2237
+ }
2238
+ async function refreshPersistedTaskSessionWorkItem(homeDirectory, persisted) {
2239
+ let latest;
2240
+ try {
2241
+ const source = await createJiraSource(process.env);
2242
+ latest = await source.getWorkItem(persisted.workItem.key);
2243
+ }
2244
+ catch {
2245
+ return persisted;
2246
+ }
2247
+ if (!latest) {
2248
+ return persisted;
2249
+ }
2250
+ const previousFullFingerprint = getWorkItemFullFingerprint(persisted.workItem);
2251
+ const latestFullFingerprint = getWorkItemFullFingerprint(latest);
2252
+ if (previousFullFingerprint === latestFullFingerprint) {
2253
+ return persisted;
2254
+ }
2255
+ const materialChanged = getWorkItemMaterialFingerprint(persisted.workItem) !== getWorkItemMaterialFingerprint(latest);
2256
+ const shouldInvalidate = materialChanged && persisted.session.status !== "completed";
2257
+ const now = new Date().toISOString();
2258
+ const session = {
2259
+ ...persisted.session,
2260
+ status: shouldInvalidate ? "planning" : persisted.session.status,
2261
+ updatedAt: now
2262
+ };
2263
+ const refreshDetails = [
2264
+ "OpenPome refreshed the active story from Jira before continuing.",
2265
+ ...summarizeWorkItemRefreshChanges(persisted.workItem, latest),
2266
+ ...(shouldInvalidate
2267
+ ? ["Story scope changed, so the current plan and pending AI outputs were reset."]
2268
+ : ["Only non-planning fields changed; existing plan state was kept."])
2269
+ ];
2270
+ const common = {
2271
+ version: persisted.version,
2272
+ session,
2273
+ workItem: latest,
2274
+ workspaceCandidate: persisted.workspaceCandidate,
2275
+ events: appendSessionEvents(persisted.events, [
2276
+ createSessionEvent(session, latest.key, "work_item_refreshed", "Jira story refreshed", now, refreshDetails, {
2277
+ materialChanged: String(materialChanged),
2278
+ previousStatus: persisted.workItem.status,
2279
+ latestStatus: latest.status
2280
+ })
2281
+ ]),
2282
+ approvalHistory: persisted.approvalHistory ?? [],
2283
+ prCreation: persisted.prCreation,
2284
+ workItemUpdatePost: persisted.workItemUpdatePost
2285
+ };
2286
+ const refreshed = shouldInvalidate
2287
+ ? common
2288
+ : {
2289
+ ...persisted,
2290
+ session,
2291
+ workItem: latest,
2292
+ events: common.events
2293
+ };
2294
+ await writeActiveTaskSession(homeDirectory, refreshed);
2295
+ return refreshed;
2296
+ }
2297
+ function getWorkItemMaterialFingerprint(item) {
2298
+ return stableStringify({
2299
+ key: item.key,
2300
+ source: item.source,
2301
+ type: item.type,
2302
+ title: item.title,
2303
+ description: item.description ?? "",
2304
+ priority: item.priority ?? "",
2305
+ iteration: item.iteration ?? "",
2306
+ parentKey: item.parentKey ?? "",
2307
+ labels: normalizeStringList(item.labels),
2308
+ components: normalizeStringList(item.components),
2309
+ links: normalizeWorkItemLinks(item.links),
2310
+ subtasks: normalizeWorkItemSummaries(item.subtasks)
2311
+ });
2312
+ }
2313
+ function getWorkItemFullFingerprint(item) {
2314
+ return stableStringify({
2315
+ material: JSON.parse(getWorkItemMaterialFingerprint(item)),
2316
+ status: item.status,
2317
+ assignee: item.assignee ?? ""
2318
+ });
2319
+ }
2320
+ function summarizeWorkItemRefreshChanges(previous, latest) {
2321
+ const changes = [
2322
+ previous.title !== latest.title ? "Title changed." : undefined,
2323
+ (previous.description ?? "") !== (latest.description ?? "") ? "Description or acceptance criteria changed." : undefined,
2324
+ previous.status !== latest.status ? `Status changed: ${previous.status} -> ${latest.status}.` : undefined,
2325
+ (previous.priority ?? "") !== (latest.priority ?? "") ? `Priority changed: ${previous.priority ?? "none"} -> ${latest.priority ?? "none"}.` : undefined,
2326
+ (previous.assignee ?? "") !== (latest.assignee ?? "") ? `Assignee changed: ${previous.assignee ?? "none"} -> ${latest.assignee ?? "none"}.` : undefined,
2327
+ stableStringify(normalizeStringList(previous.labels)) !== stableStringify(normalizeStringList(latest.labels)) ? "Labels changed." : undefined,
2328
+ stableStringify(normalizeStringList(previous.components)) !== stableStringify(normalizeStringList(latest.components)) ? "Components changed." : undefined,
2329
+ stableStringify(normalizeWorkItemLinks(previous.links)) !== stableStringify(normalizeWorkItemLinks(latest.links)) ? "Linked work changed." : undefined,
2330
+ stableStringify(normalizeWorkItemSummaries(previous.subtasks)) !== stableStringify(normalizeWorkItemSummaries(latest.subtasks)) ? "Subtasks changed." : undefined
2331
+ ].filter((change) => Boolean(change));
2332
+ return changes.length ? changes.slice(0, 8) : ["Jira returned updated story metadata."];
2333
+ }
2334
+ function normalizeStringList(values) {
2335
+ return [...new Set((values ?? []).map((value) => value.trim()).filter(Boolean))].sort((left, right) => left.localeCompare(right));
2336
+ }
2337
+ function normalizeWorkItemLinks(links) {
2338
+ return (links ?? [])
2339
+ .map((link) => ({
2340
+ kind: link.kind,
2341
+ url: link.url,
2342
+ title: link.title ?? ""
2343
+ }))
2344
+ .sort((left, right) => `${left.kind}:${left.url}:${left.title}`.localeCompare(`${right.kind}:${right.url}:${right.title}`));
2345
+ }
2346
+ function normalizeWorkItemSummaries(summaries) {
2347
+ return (summaries ?? [])
2348
+ .map((summary) => ({
2349
+ key: summary.key,
2350
+ type: summary.type,
2351
+ title: summary.title,
2352
+ status: summary.status
2353
+ }))
2354
+ .sort((left, right) => left.key.localeCompare(right.key));
2355
+ }
2356
+ function stableStringify(value) {
2357
+ return JSON.stringify(value);
2358
+ }
2002
2359
  function createWorkspaceId(path) {
2003
2360
  const hash = createHash("sha256").update(path).digest("hex").slice(0, 12);
2004
2361
  return `${basename(path).toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "")}-${hash}`;
@@ -2433,6 +2790,7 @@ function buildStructuredPatchPrompt(session, workspacePath, contextFiles) {
2433
2790
  file.content,
2434
2791
  "```"
2435
2792
  ].join("\n")).join("\n\n");
2793
+ const failedTestContext = getFailedTestContextAfterLatestPatch(session);
2436
2794
  return [
2437
2795
  "You are OpenPome's implementation engine.",
2438
2796
  "Return only compact JSON. Do not include markdown fences outside JSON.",
@@ -2457,6 +2815,9 @@ function buildStructuredPatchPrompt(session, workspacePath, contextFiles) {
2457
2815
  ...(plan?.steps.map((step) => `- ${step.title}${step.detail ? `: ${step.detail}` : ""}`) ?? []),
2458
2816
  plan?.commandsToRun.length ? `- Checks: ${plan.commandsToRun.join(", ")}` : undefined,
2459
2817
  "",
2818
+ failedTestContext.length ? "Recent failed validation after the latest approved patch:" : undefined,
2819
+ ...failedTestContext,
2820
+ failedTestContext.length ? "" : undefined,
2460
2821
  "Workspace:",
2461
2822
  `- Path: ${workspacePath}`,
2462
2823
  session.workspaceCandidate?.workspace.name ? `- Name: ${session.workspaceCandidate.workspace.name}` : undefined,
@@ -2465,6 +2826,19 @@ function buildStructuredPatchPrompt(session, workspacePath, contextFiles) {
2465
2826
  context || "No source files were safely included. You may propose small new files only if the task clearly asks for them."
2466
2827
  ].filter((line) => Boolean(line)).join("\n");
2467
2828
  }
2829
+ function getFailedTestContextAfterLatestPatch(session) {
2830
+ const failedRun = getLatestFailedTestRunAfterLatestPatch(session);
2831
+ if (!failedRun) {
2832
+ return [];
2833
+ }
2834
+ return [
2835
+ `- Command: ${failedRun.command}`,
2836
+ `- Exit code: ${failedRun.exitCode}`,
2837
+ failedRun.cwd ? `- Working directory: ${failedRun.cwd}` : undefined,
2838
+ ...failedRun.stdoutSummary.map((line) => `- stdout: ${line}`),
2839
+ ...failedRun.stderrSummary.map((line) => `- stderr: ${line}`)
2840
+ ].filter((line) => Boolean(line)).slice(0, 48);
2841
+ }
2468
2842
  function parseAIPatchProposal(value, session, provider, workspacePath, createdAt) {
2469
2843
  const json = extractJsonObject(value);
2470
2844
  if (!json) {
@@ -2726,7 +3100,21 @@ async function detectPullRequestBaseBranch(workspacePath) {
2726
3100
  return headBranch || "main";
2727
3101
  }
2728
3102
  function hasPassedTestEvidence(session) {
2729
- return (session.testRunEvidence ?? []).some((run) => run.status === "passed");
3103
+ const latestRunAfterPatch = getLatestTestRunAfterLatestPatch(session);
3104
+ return latestRunAfterPatch?.status === "passed" || (!session.aiPatchProposal?.appliedAt && (session.testRunEvidence ?? []).some((run) => run.status === "passed"));
3105
+ }
3106
+ function hasFailedTestAfterLatestAppliedPatch(session) {
3107
+ return Boolean(getLatestFailedTestRunAfterLatestPatch(session));
3108
+ }
3109
+ function getLatestFailedTestRunAfterLatestPatch(session) {
3110
+ const latestRun = getLatestTestRunAfterLatestPatch(session);
3111
+ return latestRun?.status === "failed" ? latestRun : undefined;
3112
+ }
3113
+ function getLatestTestRunAfterLatestPatch(session) {
3114
+ const appliedAt = session.aiPatchProposal?.appliedAt;
3115
+ const runs = session.testRunEvidence ?? [];
3116
+ const filtered = appliedAt ? runs.filter((run) => run.finishedAt >= appliedAt) : runs;
3117
+ return filtered[filtered.length - 1];
2730
3118
  }
2731
3119
  async function runGitStrict(cwd, args) {
2732
3120
  return execFileStrict("git", args, cwd);