@openpome/local-gateway 0.34.0-alpha.0 → 0.38.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
@@ -9,6 +9,7 @@ import { promisify } from "node:util";
9
9
  import { defaultConfig } from "@openpome/configuration";
10
10
  import { createCredentialStore, getJsonCredential, setJsonCredential } from "@openpome/credentials";
11
11
  import { groupWorkItemsByType } from "@openpome/work-items";
12
+ import { getLocalPersistenceInfo, openSessionSnapshotStore } from "@openpome/persistence";
12
13
  import { buildPlanningPrompt } from "@openpome/prompt-engine";
13
14
  import { rankWorkspaceCandidates } from "@openpome/workspaces";
14
15
  import { createDefaultWorkItemSourceRegistry, createJiraCloudOAuthLogin, exchangeJiraCloudOAuthCode, refreshJiraCloudOAuthToken } from "./connectors/work-item-registry.js";
@@ -40,7 +41,7 @@ const maxWorkspaceScanRepositories = 200;
40
41
  export function getGatewayHealth() {
41
42
  return {
42
43
  status: "ok",
43
- version: "0.34.0-alpha.0"
44
+ version: "0.38.0-alpha.0"
44
45
  };
45
46
  }
46
47
  export async function initOpenPome() {
@@ -231,6 +232,11 @@ export async function runDoctor(env = process.env) {
231
232
  name: "Model provider",
232
233
  status: activeModel?.configured ? "ok" : "attention",
233
234
  detail: activeModel?.detail ?? "Run `pome auth ai status` to inspect AI setup."
235
+ },
236
+ {
237
+ name: "Telemetry",
238
+ status: "ok",
239
+ detail: "Disabled by default. OpenPome does not send analytics, prompts, source code, diffs, or crash data."
234
240
  }
235
241
  ];
236
242
  return {
@@ -534,9 +540,11 @@ export async function getAssistantDecision() {
534
540
  if (!status.aiPatchProposal && !status.diffSummary) {
535
541
  const model = await getModelProviderStatus();
536
542
  const activeModel = model.providers.find((provider) => provider.active);
537
- const blockers = activeModel && activeModel.configured ? collectPlanReadinessWarnings(status) : [
543
+ const aiCanProposePatch = activeModel?.provider !== "manual-copy" && Boolean(activeModel?.configured);
544
+ const blockers = aiCanProposePatch ? collectPlanReadinessWarnings(status) : [
538
545
  activeModel?.detail ?? "No AI provider is active.",
539
- "Connect Claude CLI, Claude API, or OpenAI for AI patch proposals."
546
+ "Connect Claude CLI, Claude API, or OpenAI before AI patch proposals.",
547
+ "Run `pome auth ai claude-cli`, `pome auth ai claude`, or `pome auth ai openai`."
540
548
  ];
541
549
  return buildAssistantDecision(status, "propose_patch", "Ask AI for the smallest safe patch", "OpenPome will collect bounded repo context, ask the active AI provider for changes, and prepare an approval checkpoint.", [
542
550
  "pome next",
@@ -635,11 +643,13 @@ function getLatestTestRunAfterStatus(status, since) {
635
643
  export async function stopTaskSession() {
636
644
  const paths = getOpenPomePaths();
637
645
  const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
646
+ const databaseFile = getSessionDatabaseFile(paths.homeDirectory);
638
647
  if (!persisted) {
639
648
  return {
640
649
  active: false,
641
650
  sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
642
651
  historyFile: getTaskSessionHistoryFile(paths.homeDirectory),
652
+ databaseFile,
643
653
  message: "No active task session to stop."
644
654
  };
645
655
  }
@@ -666,6 +676,7 @@ export async function stopTaskSession() {
666
676
  active: false,
667
677
  sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
668
678
  historyFile: getTaskSessionHistoryFile(paths.homeDirectory),
679
+ databaseFile,
669
680
  session,
670
681
  message: "Stopped active task session and archived it locally."
671
682
  };
@@ -673,22 +684,25 @@ export async function stopTaskSession() {
673
684
  export async function resumeTaskSession(sessionId) {
674
685
  const paths = getOpenPomePaths();
675
686
  const active = await readActiveTaskSessionIfPresent(paths.homeDirectory);
687
+ const databaseFile = getSessionDatabaseFile(paths.homeDirectory);
676
688
  if (active) {
677
689
  return {
678
690
  active: true,
679
691
  sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
680
692
  historyFile: getTaskSessionHistoryFile(paths.homeDirectory),
693
+ databaseFile,
681
694
  session: active.session,
682
695
  message: "Active task session is already available."
683
696
  };
684
697
  }
685
698
  const history = await readTaskSessionHistoryIfPresent(paths.homeDirectory);
686
- const archived = selectArchivedTaskSession(history?.sessions ?? [], sessionId);
699
+ const archived = selectArchivedTaskSession(await listArchivedTaskSessions(paths.homeDirectory, history?.sessions ?? []), sessionId);
687
700
  if (!archived) {
688
701
  return {
689
702
  active: false,
690
703
  sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
691
704
  historyFile: getTaskSessionHistoryFile(paths.homeDirectory),
705
+ databaseFile,
692
706
  message: sessionId ? `No archived task session found: ${sessionId}` : "No archived task session is available to resume."
693
707
  };
694
708
  }
@@ -714,6 +728,7 @@ export async function resumeTaskSession(sessionId) {
714
728
  active: true,
715
729
  sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
716
730
  historyFile: getTaskSessionHistoryFile(paths.homeDirectory),
731
+ databaseFile,
717
732
  session,
718
733
  message: "Resumed archived task session."
719
734
  };
@@ -721,11 +736,13 @@ export async function resumeTaskSession(sessionId) {
721
736
  export async function resetTaskSession() {
722
737
  const paths = getOpenPomePaths();
723
738
  const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
739
+ const databaseFile = getSessionDatabaseFile(paths.homeDirectory);
724
740
  if (!persisted) {
725
741
  return {
726
742
  active: false,
727
743
  sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
728
744
  historyFile: getTaskSessionHistoryFile(paths.homeDirectory),
745
+ databaseFile,
729
746
  message: "No active task session to reset."
730
747
  };
731
748
  }
@@ -752,10 +769,21 @@ export async function resetTaskSession() {
752
769
  active: false,
753
770
  sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
754
771
  historyFile: getTaskSessionHistoryFile(paths.homeDirectory),
772
+ databaseFile,
755
773
  session,
756
774
  message: "Reset active task session and archived it locally."
757
775
  };
758
776
  }
777
+ export async function getTaskSessionHistory(limit = 25) {
778
+ const paths = getOpenPomePaths();
779
+ const history = await readTaskSessionHistoryIfPresent(paths.homeDirectory);
780
+ const sessions = await listArchivedTaskSessions(paths.homeDirectory, history?.sessions ?? [], limit);
781
+ return {
782
+ historyFile: getTaskSessionHistoryFile(paths.homeDirectory),
783
+ databaseFile: getSessionDatabaseFile(paths.homeDirectory),
784
+ sessions: sessions.map(summarizeTaskSessionHistory)
785
+ };
786
+ }
759
787
  export async function createTaskSessionPlan() {
760
788
  const paths = getOpenPomePaths();
761
789
  const persisted = await refreshActiveTaskSessionWorkItem(paths.homeDirectory);
@@ -1053,7 +1081,7 @@ export async function discoverTestCommands() {
1053
1081
  }
1054
1082
  const workspace = persisted.workspaceCandidate?.workspace;
1055
1083
  const discoveredAt = new Date().toISOString();
1056
- const candidates = workspace?.path ? await discoverTestCommandCandidates(workspace.path) : getFallbackTestCommandCandidates();
1084
+ const candidates = workspace?.path ? await discoverTestCommandCandidates(workspace.path, persisted) : getFallbackTestCommandCandidates();
1057
1085
  await writeActiveTaskSession(paths.homeDirectory, {
1058
1086
  ...persisted,
1059
1087
  testCommandCandidates: candidates,
@@ -1083,7 +1111,7 @@ export async function approveTestCommand(command) {
1083
1111
  const candidates = persisted.testCommandCandidates?.length
1084
1112
  ? persisted.testCommandCandidates
1085
1113
  : persisted.workspaceCandidate?.workspace.path
1086
- ? await discoverTestCommandCandidates(persisted.workspaceCandidate.workspace.path)
1114
+ ? await discoverTestCommandCandidates(persisted.workspaceCandidate.workspace.path, persisted)
1087
1115
  : getFallbackTestCommandCandidates();
1088
1116
  const selected = selectTestCommandCandidate(candidates, command);
1089
1117
  if (!selected) {
@@ -1311,16 +1339,16 @@ export async function createGitHubDeviceLogin(env = process.env) {
1311
1339
  client_id: clientId,
1312
1340
  scope
1313
1341
  });
1314
- const response = await fetch("https://github.com/login/device/code", {
1342
+ const response = await fetchGitHub("https://github.com/login/device/code", {
1315
1343
  method: "POST",
1316
1344
  headers: {
1317
1345
  Accept: "application/json",
1318
1346
  "Content-Type": "application/x-www-form-urlencoded"
1319
1347
  },
1320
1348
  body
1321
- });
1349
+ }, "start browser login");
1322
1350
  if (!response.ok) {
1323
- throw new Error(`GitHub device login failed: ${response.status} ${response.statusText}`);
1351
+ throw new Error(await getGitHubStatusGuidance(response, "start browser login"));
1324
1352
  }
1325
1353
  const payload = (await response.json());
1326
1354
  if (!payload.device_code || !payload.user_code || !payload.verification_uri) {
@@ -1484,7 +1512,7 @@ export async function createPullRequest(options = {}) {
1484
1512
  }
1485
1513
  await runGitStrict(workspacePath, ["add", "-A"]);
1486
1514
  await runGitStrict(workspacePath, ["commit", "-m", commitMessage]);
1487
- await runGitStrict(workspacePath, ["push", "-u", "origin", branch]);
1515
+ await pushPullRequestBranch(workspacePath, branch);
1488
1516
  const storedGitHubToken = github.tokenSource === "openpome" ? await readStoredGitHubOAuth() : undefined;
1489
1517
  const prProvider = storedGitHubToken?.accessToken ? "github-api" : "github-cli";
1490
1518
  const prUrl = storedGitHubToken?.accessToken
@@ -1875,15 +1903,15 @@ async function isGitHubCliAuthenticated() {
1875
1903
  }
1876
1904
  }
1877
1905
  async function fetchGitHubAuthenticatedUser(accessToken) {
1878
- const response = await fetch("https://api.github.com/user", {
1906
+ const response = await fetchGitHub("https://api.github.com/user", {
1879
1907
  headers: {
1880
1908
  Accept: "application/vnd.github+json",
1881
1909
  Authorization: `Bearer ${accessToken}`,
1882
1910
  "X-GitHub-Api-Version": "2022-11-28"
1883
1911
  }
1884
- });
1912
+ }, "verify authenticated user");
1885
1913
  if (!response.ok) {
1886
- throw new Error(`GitHub user lookup failed: ${response.status} ${response.statusText}`);
1914
+ throw new Error(await getGitHubStatusGuidance(response, "verify authenticated user"));
1887
1915
  }
1888
1916
  const payload = (await response.json());
1889
1917
  if (!payload.login) {
@@ -1896,7 +1924,7 @@ async function fetchGitHubAuthenticatedUser(accessToken) {
1896
1924
  };
1897
1925
  }
1898
1926
  async function pollGitHubDeviceAccessToken(clientId, deviceCode) {
1899
- const response = await fetch("https://github.com/login/oauth/access_token", {
1927
+ const response = await fetchGitHub("https://github.com/login/oauth/access_token", {
1900
1928
  method: "POST",
1901
1929
  headers: {
1902
1930
  Accept: "application/json",
@@ -1907,11 +1935,11 @@ async function pollGitHubDeviceAccessToken(clientId, deviceCode) {
1907
1935
  device_code: deviceCode,
1908
1936
  grant_type: "urn:ietf:params:oauth:grant-type:device_code"
1909
1937
  })
1910
- });
1938
+ }, "complete browser login");
1911
1939
  if (!response.ok) {
1912
1940
  return {
1913
1941
  status: "error",
1914
- detail: `GitHub device token polling failed: ${response.status} ${response.statusText}`
1942
+ detail: await getGitHubStatusGuidance(response, "complete browser login")
1915
1943
  };
1916
1944
  }
1917
1945
  const payload = (await response.json());
@@ -1949,6 +1977,48 @@ function parseGitHubScopes(scope) {
1949
1977
  .map((value) => value.trim())
1950
1978
  .filter((value) => value.length > 0);
1951
1979
  }
1980
+ async function fetchGitHub(input, init, action) {
1981
+ try {
1982
+ return await fetch(input, init);
1983
+ }
1984
+ catch (error) {
1985
+ throw new Error(getGitHubNetworkGuidance(action, error));
1986
+ }
1987
+ }
1988
+ async function getGitHubStatusGuidance(response, action) {
1989
+ const body = await safeResponseText(response);
1990
+ const detail = body ? ` Detail: ${summarizeProviderBody(body)}` : "";
1991
+ if (response.status === 401) {
1992
+ return `GitHub ${action} was unauthorized (401). Run \`pome auth github login\` again, or \`gh auth login\` if you use the GitHub CLI fallback.${detail}`;
1993
+ }
1994
+ if (response.status === 403) {
1995
+ return `GitHub ${action} was forbidden (403). Check repository permission, organization SSO, token scopes, branch protection, and whether the token has \`repo\` access.${detail}`;
1996
+ }
1997
+ if (response.status === 404) {
1998
+ return `GitHub ${action} could not find the repository or resource (404). Check the git remote, repository visibility, GitHub Enterprise host, and account access.${detail}`;
1999
+ }
2000
+ if (response.status === 422) {
2001
+ return `GitHub ${action} was rejected (422). Check whether the branch already has an open PR, base/head branch names are valid, and the repo allows PRs.${detail}`;
2002
+ }
2003
+ if (response.status === 429 || response.headers.get("x-ratelimit-remaining") === "0") {
2004
+ const reset = response.headers.get("x-ratelimit-reset");
2005
+ const resetDetail = reset ? ` Rate limit resets at ${new Date(Number(reset) * 1000).toISOString()}.` : "";
2006
+ return `GitHub rate limit reached while trying to ${action}.${resetDetail} Wait and retry, or use a token with the right organization access.${detail}`;
2007
+ }
2008
+ if (response.status >= 500) {
2009
+ return `GitHub ${action} failed with ${response.status} ${response.statusText}. GitHub may be unavailable, blocked by a proxy, or unreachable from this network.${detail}`;
2010
+ }
2011
+ return `GitHub ${action} failed: ${response.status} ${response.statusText}.${detail}`;
2012
+ }
2013
+ function getGitHubNetworkGuidance(action, error) {
2014
+ const detail = error instanceof Error ? error.message : String(error);
2015
+ return [
2016
+ `GitHub ${action} could not reach GitHub.`,
2017
+ "Check internet access, VPN split tunneling, proxy/firewall rules, corporate certificate trust, and GitHub Enterprise host configuration.",
2018
+ "Run `pome auth github status` after fixing network access.",
2019
+ `Detail: ${detail}`
2020
+ ].join(" ");
2021
+ }
1952
2022
  function summarizeUnknownError(error) {
1953
2023
  return error instanceof Error ? error.message : String(error);
1954
2024
  }
@@ -1997,7 +2067,11 @@ function getOpenPomePaths() {
1997
2067
  async function readConfigIfPresent(configFile) {
1998
2068
  try {
1999
2069
  const content = await readFile(configFile, "utf8");
2000
- return JSON.parse(content);
2070
+ return {
2071
+ ...defaultConfig,
2072
+ ...JSON.parse(content),
2073
+ telemetryEnabled: false
2074
+ };
2001
2075
  }
2002
2076
  catch (error) {
2003
2077
  if (error instanceof Error && "code" in error && error.code === "ENOENT") {
@@ -2544,6 +2618,7 @@ async function readActiveTaskSessionIfPresent(homeDirectory) {
2544
2618
  async function writeActiveTaskSession(homeDirectory, session) {
2545
2619
  await mkdir(homeDirectory, { recursive: true });
2546
2620
  await writeFile(getActiveTaskSessionFile(homeDirectory), `${JSON.stringify(session, null, 2)}\n`, "utf8");
2621
+ persistTaskSessionSnapshot(homeDirectory, session, true);
2547
2622
  }
2548
2623
  async function removeActiveTaskSession(homeDirectory) {
2549
2624
  await rm(getActiveTaskSessionFile(homeDirectory), { force: true });
@@ -2571,6 +2646,88 @@ async function archiveTaskSession(homeDirectory, session) {
2571
2646
  };
2572
2647
  await mkdir(homeDirectory, { recursive: true });
2573
2648
  await writeFile(getTaskSessionHistoryFile(homeDirectory), `${JSON.stringify(history, null, 2)}\n`, "utf8");
2649
+ persistTaskSessionSnapshot(homeDirectory, session, false);
2650
+ }
2651
+ async function listArchivedTaskSessions(homeDirectory, jsonSessions, limit = 25) {
2652
+ const sqliteSessions = tryListSessionSnapshots(homeDirectory, limit);
2653
+ if (sqliteSessions.length > 0) {
2654
+ return sqliteSessions;
2655
+ }
2656
+ return jsonSessions.slice(0, limit);
2657
+ }
2658
+ function persistTaskSessionSnapshot(homeDirectory, session, active) {
2659
+ try {
2660
+ const store = openSessionSnapshotStore(homeDirectory);
2661
+ store.upsertSessionSnapshot(buildSessionSnapshotInput(session, active));
2662
+ }
2663
+ catch {
2664
+ // JSON active/history files remain the alpha compatibility fallback. SQLite
2665
+ // persistence must improve resume reliability without blocking daily work.
2666
+ }
2667
+ }
2668
+ function tryListSessionSnapshots(homeDirectory, limit) {
2669
+ try {
2670
+ const store = openSessionSnapshotStore(homeDirectory);
2671
+ return store.listSessionSnapshots(limit)
2672
+ .map((record) => record.snapshot)
2673
+ .filter(isPersistedTaskSession);
2674
+ }
2675
+ catch {
2676
+ return [];
2677
+ }
2678
+ }
2679
+ function buildSessionSnapshotInput(session, active) {
2680
+ const latestEvent = session.events?.[session.events.length - 1];
2681
+ const latestTestRun = session.testRunEvidence?.[session.testRunEvidence.length - 1];
2682
+ return {
2683
+ sessionId: session.session.id,
2684
+ workItemKey: session.workItem.key,
2685
+ workItemTitle: session.workItem.title,
2686
+ status: session.session.status,
2687
+ updatedAt: session.session.updatedAt,
2688
+ active,
2689
+ workspaceName: session.workspaceCandidate?.workspace.name,
2690
+ workspacePath: session.workspaceCandidate?.workspace.path,
2691
+ latestEventTitle: latestEvent?.title,
2692
+ latestEventAt: latestEvent?.createdAt,
2693
+ latestTestStatus: latestTestRun?.status,
2694
+ latestTestCommand: latestTestRun?.command,
2695
+ latestPatchAppliedAt: session.aiPatchProposal?.appliedAt,
2696
+ prUrl: session.prCreation?.prUrl,
2697
+ jiraCommentId: session.workItemUpdatePost?.commentId,
2698
+ snapshot: session
2699
+ };
2700
+ }
2701
+ function summarizeTaskSessionHistory(session) {
2702
+ const latestEvent = session.events?.[session.events.length - 1];
2703
+ const latestTestRun = session.testRunEvidence?.[session.testRunEvidence.length - 1];
2704
+ return {
2705
+ sessionId: session.session.id,
2706
+ active: session.session.status !== "completed" && session.session.status !== "blocked",
2707
+ workItemKey: session.workItem.key,
2708
+ workItemTitle: session.workItem.title,
2709
+ status: session.session.status,
2710
+ updatedAt: session.session.updatedAt,
2711
+ workspaceName: session.workspaceCandidate?.workspace.name,
2712
+ workspacePath: session.workspaceCandidate?.workspace.path,
2713
+ latestEventTitle: latestEvent?.title,
2714
+ latestEventAt: latestEvent?.createdAt,
2715
+ latestTestStatus: latestTestRun?.status,
2716
+ latestTestCommand: latestTestRun?.command,
2717
+ latestPatchAppliedAt: session.aiPatchProposal?.appliedAt,
2718
+ prUrl: session.prCreation?.prUrl,
2719
+ jiraCommentId: session.workItemUpdatePost?.commentId
2720
+ };
2721
+ }
2722
+ function isPersistedTaskSession(value) {
2723
+ return typeof value === "object"
2724
+ && value !== null
2725
+ && "version" in value
2726
+ && "session" in value
2727
+ && "workItem" in value;
2728
+ }
2729
+ function getSessionDatabaseFile(homeDirectory) {
2730
+ return getLocalPersistenceInfo(homeDirectory).databaseFile;
2574
2731
  }
2575
2732
  function selectArchivedTaskSession(sessions, sessionId) {
2576
2733
  if (sessionId) {
@@ -2580,28 +2737,39 @@ function selectArchivedTaskSession(sessions, sessionId) {
2580
2737
  }
2581
2738
  function buildPlanningContext(session) {
2582
2739
  const workspace = session.workspaceCandidate?.workspace;
2740
+ const missingRequirementSignals = detectMissingRequirementSignals(session.workItem);
2583
2741
  const context = [
2584
2742
  `Work item type: ${session.workItem.type}`,
2585
2743
  `Status: ${session.workItem.status}`,
2586
2744
  session.workItem.priority ? `Priority: ${session.workItem.priority}` : undefined,
2745
+ session.workItem.description ? `Description length: ${session.workItem.description.length} characters` : "Description: not provided",
2587
2746
  hasExplicitAcceptanceCriteria(session.workItem)
2588
2747
  ? "Acceptance criteria: detected in work item text"
2589
2748
  : "Acceptance criteria: not explicit; identify missing acceptance criteria before implementation",
2749
+ missingRequirementSignals.length ? `Missing requirement signals: ${missingRequirementSignals.join("; ")}` : undefined,
2590
2750
  session.workItem.labels?.length ? `Labels: ${session.workItem.labels.join(", ")}` : undefined,
2591
2751
  session.workItem.components?.length ? `Components: ${session.workItem.components.join(", ")}` : undefined,
2752
+ session.workItem.links?.length ? `Linked references: ${session.workItem.links.map((link) => `${link.kind}:${link.title ?? link.url}`).join("; ")}` : undefined,
2753
+ session.workItem.subtasks?.length ? `Subtasks: ${session.workItem.subtasks.map((subtask) => `${subtask.key} ${subtask.status} ${subtask.title}`).join("; ")}` : undefined,
2592
2754
  workspace ? `Workspace: ${workspace.name}` : "Workspace: unresolved",
2593
2755
  workspace?.path ? `Workspace path: ${workspace.path}` : undefined,
2594
2756
  session.workspaceCandidate ? `Workspace confidence: ${Math.round(session.workspaceCandidate.confidence * 100)}%` : undefined,
2595
- session.workspaceCandidate?.reasons.length ? `Workspace reasons: ${session.workspaceCandidate.reasons.join("; ")}` : undefined
2757
+ session.workspaceCandidate?.reasons.length ? `Workspace reasons: ${session.workspaceCandidate.reasons.join("; ")}` : undefined,
2758
+ workspace?.packageNames?.length ? `Workspace packages: ${workspace.packageNames.slice(0, 8).join(", ")}` : undefined,
2759
+ workspace?.readmeKeywords?.length ? `README signals: ${workspace.readmeKeywords.slice(0, 12).join(", ")}` : undefined,
2760
+ workspace?.codeownersKeywords?.length ? `Ownership signals: ${workspace.codeownersKeywords.slice(0, 12).join(", ")}` : undefined,
2761
+ workspace?.recentBranches?.length ? `Recent branches: ${workspace.recentBranches.slice(0, 8).join(", ")}` : undefined,
2762
+ workspace?.recentCommitRefs?.length ? `Recent work refs: ${workspace.recentCommitRefs.slice(0, 12).join(", ")}` : undefined
2596
2763
  ];
2597
2764
  return context.filter((item) => Boolean(item));
2598
2765
  }
2599
2766
  function buildInitialImplementationPlan(workItem, workspaceCandidate) {
2600
2767
  const workspace = workspaceCandidate?.workspace;
2601
2768
  const hasWorkspace = Boolean(workspace?.path);
2769
+ const missingRequirementSignals = detectMissingRequirementSignals(workItem);
2602
2770
  const missingInfo = [
2603
2771
  hasWorkspace ? undefined : "No workspace candidate is selected yet.",
2604
- hasExplicitAcceptanceCriteria(workItem) ? undefined : "Acceptance criteria are not explicit in the work item."
2772
+ ...missingRequirementSignals
2605
2773
  ].filter((item) => Boolean(item));
2606
2774
  return {
2607
2775
  summary: `Prepare implementation for ${workItem.key}: ${workItem.title}`,
@@ -2647,7 +2815,36 @@ function buildInitialImplementationPlan(workItem, workspaceCandidate) {
2647
2815
  }
2648
2816
  function hasExplicitAcceptanceCriteria(workItem) {
2649
2817
  const text = [workItem.title, workItem.description].filter(Boolean).join("\n").toLowerCase();
2650
- return /\b(acceptance criteria|acceptance|criteria|given\b.*\bwhen\b.*\bthen|expected result|definition of done|done when|should)\b/su.test(text);
2818
+ return /\b(acceptance criteria|acceptance|criteria|given\b.*\bwhen\b.*\bthen|expected result|definition of done|done when|should|expected behavior|success criteria|verify|validation)\b/su.test(text);
2819
+ }
2820
+ function detectMissingRequirementSignals(workItem) {
2821
+ const text = [workItem.title, workItem.description].filter(Boolean).join("\n").trim();
2822
+ const lower = text.toLowerCase();
2823
+ const signals = [];
2824
+ if (!workItem.description || workItem.description.trim().length < 40) {
2825
+ signals.push("Clarify the exact scope because the work item description is short.");
2826
+ }
2827
+ if (!hasExplicitAcceptanceCriteria(workItem)) {
2828
+ signals.push("Ask what acceptance criteria prove this story is complete.");
2829
+ }
2830
+ if (workItem.type === "bug") {
2831
+ const hasExpected = /\b(expected|should happen|desired behavior|correct behavior)\b/u.test(lower);
2832
+ const hasActual = /\b(actual|currently|observed|happens now|error|failure|failed)\b/u.test(lower);
2833
+ const hasRepro = /\b(steps to reproduce|repro|reproduce|given\b.*\bwhen\b.*\bthen)\b/su.test(lower);
2834
+ if (!hasExpected || !hasActual) {
2835
+ signals.push("Ask for clear expected behavior and actual behavior for this bug.");
2836
+ }
2837
+ if (!hasRepro) {
2838
+ signals.push("Ask for reproduction steps or a failing scenario for this bug.");
2839
+ }
2840
+ }
2841
+ if (!workItem.labels?.length && !workItem.components?.length) {
2842
+ signals.push("Ask which component, service, or package owns this change.");
2843
+ }
2844
+ if (!workItem.links?.some((link) => link.kind === "code" || link.kind === "pull_request" || link.kind === "document")) {
2845
+ signals.push("Ask whether there is linked code, a prior pull request, design doc, or reference issue.");
2846
+ }
2847
+ return Array.from(new Set(signals)).slice(0, 6);
2651
2848
  }
2652
2849
  async function buildImplementationPlan(persisted, prompt) {
2653
2850
  const config = await readConfigIfPresent(getOpenPomePaths().configFile);
@@ -2674,7 +2871,7 @@ async function completeModelText(provider, prompt) {
2674
2871
  return completeClaudeCliText(prompt);
2675
2872
  }
2676
2873
  async function completeOpenAIText(prompt, apiKey) {
2677
- const response = await fetch("https://api.openai.com/v1/responses", {
2874
+ const response = await fetchModelProvider("OpenAI", "https://api.openai.com/v1/responses", {
2678
2875
  method: "POST",
2679
2876
  headers: {
2680
2877
  "authorization": `Bearer ${apiKey}`,
@@ -2686,7 +2883,7 @@ async function completeOpenAIText(prompt, apiKey) {
2686
2883
  })
2687
2884
  });
2688
2885
  if (!response.ok) {
2689
- throw new Error(`OpenAI request failed: ${response.status} ${response.statusText}`);
2886
+ throw new Error(await getModelProviderStatusGuidance("OpenAI", response, "generate a plan or patch"));
2690
2887
  }
2691
2888
  const body = await response.json();
2692
2889
  if (typeof body.output_text === "string") {
@@ -2700,7 +2897,7 @@ async function completeOpenAIText(prompt, apiKey) {
2700
2897
  .join("\n");
2701
2898
  }
2702
2899
  async function completeAnthropicText(prompt, apiKey) {
2703
- const response = await fetch("https://api.anthropic.com/v1/messages", {
2900
+ const response = await fetchModelProvider("Claude", "https://api.anthropic.com/v1/messages", {
2704
2901
  method: "POST",
2705
2902
  headers: {
2706
2903
  "x-api-key": apiKey,
@@ -2719,7 +2916,7 @@ async function completeAnthropicText(prompt, apiKey) {
2719
2916
  })
2720
2917
  });
2721
2918
  if (!response.ok) {
2722
- throw new Error(`Claude request failed: ${response.status} ${response.statusText}`);
2919
+ throw new Error(await getModelProviderStatusGuidance("Claude", response, "generate a plan or patch"));
2723
2920
  }
2724
2921
  const body = await response.json();
2725
2922
  const content = Array.isArray(body.content) ? body.content : [];
@@ -2761,12 +2958,50 @@ async function completeClaudeCliText(prompt) {
2761
2958
  throw new Error(`Claude CLI request failed: ${summarizeExecError(error) || String(error)}`);
2762
2959
  }
2763
2960
  }
2961
+ async function fetchModelProvider(provider, input, init) {
2962
+ try {
2963
+ return await fetch(input, init);
2964
+ }
2965
+ catch (error) {
2966
+ const detail = error instanceof Error ? error.message : String(error);
2967
+ throw new Error(`${provider} could not be reached. Check internet/VPN/proxy access, corporate certificate trust, and provider allowlists. Use \`pome auth ai status\` to verify setup. Detail: ${detail}`);
2968
+ }
2969
+ }
2970
+ async function getModelProviderStatusGuidance(provider, response, action) {
2971
+ const body = await safeResponseText(response);
2972
+ const detail = body ? ` Detail: ${summarizeProviderBody(body)}` : "";
2973
+ const authCommand = provider === "OpenAI" ? "pome auth ai openai" : "pome auth ai claude";
2974
+ if (response.status === 401) {
2975
+ return `${provider} ${action} was unauthorized (401). Reconnect with \`${authCommand}\` or verify the provider API key in your OS credential store/environment.${detail}`;
2976
+ }
2977
+ if (response.status === 403) {
2978
+ return `${provider} ${action} was forbidden (403). Check organization policy, model access, provider project permissions, and corporate egress rules.${detail}`;
2979
+ }
2980
+ if (response.status === 404) {
2981
+ return `${provider} ${action} could not find the configured model or endpoint (404). Check OPENPOME_${provider === "OpenAI" ? "OPENAI_MODEL" : "ANTHROPIC_MODEL"} and provider access.${detail}`;
2982
+ }
2983
+ if (response.status === 408 || response.status === 409 || response.status === 429) {
2984
+ return `${provider} is busy or rate limited (${response.status}). Wait and retry, or choose a smaller model/context. OpenPome has not written files.${detail}`;
2985
+ }
2986
+ if (response.status >= 500) {
2987
+ return `${provider} failed with ${response.status} ${response.statusText}. Provider service may be unavailable or blocked by your network/proxy. Retry later.${detail}`;
2988
+ }
2989
+ return `${provider} ${action} failed: ${response.status} ${response.statusText}.${detail}`;
2990
+ }
2764
2991
  function buildStructuredPlanPrompt(prompt) {
2765
2992
  return [
2766
2993
  "You are OpenPome's planning engine.",
2994
+ "Plan like a senior developer assistant working from a live corporate work item.",
2995
+ "Prefer the smallest repo-aware change that satisfies the work item. Call out unclear scope instead of inventing requirements.",
2996
+ "Use workspace metadata, labels, linked references, ownership signals, and recent branch/commit refs to rank likely files.",
2997
+ "Suggest targeted validation commands before broad commands when the work item points to a specific component.",
2767
2998
  "Return only compact JSON with this exact shape:",
2768
2999
  "{\"summary\":\"...\",\"assumptions\":[\"...\"],\"steps\":[{\"id\":\"1\",\"title\":\"...\",\"detail\":\"...\"}],\"filesLikelyChanged\":[\"...\"],\"commandsToRun\":[\"...\"],\"risks\":[\"...\"],\"missingInfo\":[\"...\"]}",
2769
- "Do not include source code, full diffs, secrets, or markdown fences.",
3000
+ "Rules:",
3001
+ "- Do not include source code, full diffs, secrets, or markdown fences.",
3002
+ "- Put missing acceptance criteria, missing repro steps, unclear expected behavior, and missing code links in missingInfo.",
3003
+ "- Keep filesLikelyChanged to relative paths or package/module hints when exact files are unknown.",
3004
+ "- Keep commandsToRun executable from the selected workspace.",
2770
3005
  "",
2771
3006
  prompt
2772
3007
  ].join("\n");
@@ -2825,33 +3060,65 @@ const modelProviderTimeoutMs = 120_000;
2825
3060
  const modelProviderMaxBufferBytes = 2 * 1024 * 1024;
2826
3061
  const sensitivePathFragments = [
2827
3062
  ".env",
3063
+ ".env.local",
3064
+ ".env.production",
3065
+ ".env.development",
2828
3066
  ".npmrc",
3067
+ ".yarnrc",
3068
+ ".pnpmrc",
2829
3069
  ".pypirc",
2830
3070
  ".netrc",
2831
3071
  ".ssh",
2832
3072
  ".aws",
2833
3073
  ".gcp",
2834
3074
  ".azure",
3075
+ ".kube",
3076
+ ".docker",
3077
+ "credentials",
3078
+ "credential",
3079
+ "secrets",
3080
+ "secret",
3081
+ "private-key",
3082
+ "private_key",
2835
3083
  "id_rsa",
2836
3084
  "id_dsa",
2837
- "id_ed25519"
3085
+ "id_ed25519",
3086
+ ".pem",
3087
+ ".key",
3088
+ ".crt",
3089
+ ".p12",
3090
+ ".pfx"
3091
+ ];
3092
+ const sensitiveContentPatterns = [
3093
+ /-----BEGIN (?:RSA |DSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/u,
3094
+ /\b(?:OPENAI_API_KEY|ANTHROPIC_API_KEY|OPENPOME_JIRA_API_TOKEN|OPENPOME_GITHUB_TOKEN|GITHUB_TOKEN|NPM_TOKEN)\s*=\s*['"]?[^'"\s]+/iu,
3095
+ /\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|private[_-]?key)\s*[:=]\s*['"][^'"]{12,}['"]/iu,
3096
+ /\b(?:ghp|github_pat|npm|sk|sk-ant|xox[baprs])-?[A-Za-z0-9_=-]{20,}\b/u
2838
3097
  ];
2839
3098
  async function collectPatchContextFiles(workspacePath, session) {
2840
3099
  const candidates = [];
2841
3100
  for (const filePath of session.plan?.filesLikelyChanged ?? []) {
2842
3101
  const normalized = normalizeWorkspaceRelativePath(workspacePath, filePath, "skip");
2843
3102
  if (normalized && normalized !== ".") {
2844
- candidates.push(normalized);
3103
+ candidates.push({
3104
+ filePath: normalized,
3105
+ score: 80,
3106
+ reason: "AI plan marked this file as likely impacted."
3107
+ });
2845
3108
  }
2846
3109
  }
2847
- candidates.push("package.json", "README.md", "AGENTS.md");
3110
+ candidates.push({ filePath: "package.json", score: 24, reason: "Package metadata helps infer scripts, package boundaries, and runtime." }, { filePath: "README.md", score: 18, reason: "README gives repository purpose and local validation hints." }, { filePath: "AGENTS.md", score: 18, reason: "Agent instructions constrain safe implementation style." }, { filePath: "CODEOWNERS", score: 14, reason: "Ownership metadata helps identify relevant domains and review paths." });
2848
3111
  const trackedFiles = await listTrackedWorkspaceFiles(workspacePath);
2849
3112
  const tokens = tokenizePatchSearchText([
2850
3113
  session.workItem.key,
2851
3114
  session.workItem.title,
2852
3115
  session.workItem.description,
3116
+ session.plan?.summary,
3117
+ ...(session.plan?.steps.map((step) => `${step.title} ${step.detail ?? ""}`) ?? []),
2853
3118
  ...(session.workItem.labels ?? []),
2854
- ...(session.workItem.components ?? [])
3119
+ ...(session.workItem.components ?? []),
3120
+ ...(session.workspaceCandidate?.workspace.packageNames ?? []),
3121
+ ...(session.workspaceCandidate?.workspace.readmeKeywords ?? [])
2855
3122
  ].filter((value) => Boolean(value)).join(" "));
2856
3123
  const planHints = new Set((session.plan?.filesLikelyChanged ?? [])
2857
3124
  .map((filePath) => normalizeWorkspaceRelativePath(workspacePath, filePath, "skip"))
@@ -2859,12 +3126,13 @@ async function collectPatchContextFiles(workspacePath, session) {
2859
3126
  const rankedTrackedFiles = trackedFiles
2860
3127
  .map((filePath) => ({
2861
3128
  filePath,
2862
- score: scorePatchContextFile(filePath, tokens, planHints)
3129
+ score: scorePatchContextFile(filePath, tokens, planHints),
3130
+ reason: describePatchContextReason(filePath, tokens, planHints)
2863
3131
  }))
2864
3132
  .filter((candidate) => candidate.score > 0)
2865
3133
  .sort((left, right) => right.score - left.score || left.filePath.localeCompare(right.filePath));
2866
3134
  for (const candidate of rankedTrackedFiles.slice(0, 40)) {
2867
- candidates.push(candidate.filePath);
3135
+ candidates.push(candidate);
2868
3136
  }
2869
3137
  const selected = [];
2870
3138
  const seen = new Set();
@@ -2873,7 +3141,7 @@ async function collectPatchContextFiles(workspacePath, session) {
2873
3141
  if (selected.length >= maxPatchContextFiles || totalBytes >= maxPatchContextTotalBytes) {
2874
3142
  break;
2875
3143
  }
2876
- const relativePath = normalizeWorkspaceRelativePath(workspacePath, candidate, "skip");
3144
+ const relativePath = normalizeWorkspaceRelativePath(workspacePath, candidate.filePath, "skip");
2877
3145
  if (!relativePath || seen.has(relativePath) || isSensitiveWorkspacePath(relativePath)) {
2878
3146
  continue;
2879
3147
  }
@@ -2881,7 +3149,7 @@ async function collectPatchContextFiles(workspacePath, session) {
2881
3149
  const absolutePath = resolve(workspacePath, relativePath);
2882
3150
  try {
2883
3151
  const content = await readFile(absolutePath, "utf8");
2884
- if (content.includes("\u0000")) {
3152
+ if (content.includes("\u0000") || containsSensitiveContent(content)) {
2885
3153
  continue;
2886
3154
  }
2887
3155
  const remainingBytes = maxPatchContextTotalBytes - totalBytes;
@@ -2891,7 +3159,9 @@ async function collectPatchContextFiles(workspacePath, session) {
2891
3159
  selected.push({
2892
3160
  path: relativePath,
2893
3161
  content: sliced,
2894
- truncated: Buffer.byteLength(content, "utf8") > Buffer.byteLength(sliced, "utf8")
3162
+ truncated: Buffer.byteLength(content, "utf8") > Buffer.byteLength(sliced, "utf8"),
3163
+ score: candidate.score,
3164
+ reason: candidate.reason
2895
3165
  });
2896
3166
  }
2897
3167
  catch {
@@ -2902,12 +3172,45 @@ async function collectPatchContextFiles(workspacePath, session) {
2902
3172
  }
2903
3173
  async function listTrackedWorkspaceFiles(workspacePath) {
2904
3174
  const output = await runGit(workspacePath, ["ls-files"]);
2905
- return output
3175
+ const trackedFiles = output
2906
3176
  .split(/\r?\n/u)
2907
3177
  .map((line) => line.trim())
2908
3178
  .filter(Boolean)
2909
3179
  .filter((filePath) => !isSensitiveWorkspacePath(filePath))
2910
3180
  .slice(0, 1000);
3181
+ return trackedFiles.length > 0 ? trackedFiles : listWorkspaceFilesFallback(workspacePath);
3182
+ }
3183
+ async function listWorkspaceFilesFallback(workspacePath) {
3184
+ const collected = [];
3185
+ const queue = ["."];
3186
+ const ignoredDirectories = new Set([".git", "node_modules", "dist", "build", "coverage", ".next", ".turbo", ".cache", "vendor"]);
3187
+ while (queue.length > 0 && collected.length < 1000) {
3188
+ const current = queue.shift() ?? ".";
3189
+ const absoluteCurrent = resolve(workspacePath, current);
3190
+ let directory;
3191
+ try {
3192
+ directory = await opendir(absoluteCurrent);
3193
+ }
3194
+ catch {
3195
+ continue;
3196
+ }
3197
+ for await (const entry of directory) {
3198
+ const relativePath = current === "." ? entry.name : `${current}/${entry.name}`;
3199
+ if (entry.isDirectory()) {
3200
+ if (!ignoredDirectories.has(entry.name) && !isSensitiveWorkspacePath(relativePath)) {
3201
+ queue.push(relativePath);
3202
+ }
3203
+ continue;
3204
+ }
3205
+ if (entry.isFile() && !isSensitiveWorkspacePath(relativePath)) {
3206
+ collected.push(relativePath);
3207
+ if (collected.length >= 1000) {
3208
+ break;
3209
+ }
3210
+ }
3211
+ }
3212
+ }
3213
+ return collected;
2911
3214
  }
2912
3215
  function tokenizePatchSearchText(value) {
2913
3216
  return Array.from(new Set(value.toLowerCase().split(/[^a-z0-9]+/u).filter((token) => token.length >= 3))).slice(0, 20);
@@ -2916,7 +3219,7 @@ function scorePatchContextFile(filePath, tokens, planHints) {
2916
3219
  const lower = filePath.toLowerCase();
2917
3220
  let score = 0;
2918
3221
  if (planHints.has(filePath)) {
2919
- score += 20;
3222
+ score += 40;
2920
3223
  }
2921
3224
  for (const token of tokens) {
2922
3225
  if (lower.includes(token)) {
@@ -2926,32 +3229,64 @@ function scorePatchContextFile(filePath, tokens, planHints) {
2926
3229
  if (/\.(ts|tsx|js|jsx|mjs|cjs|py|java|kt|go|rs|rb|php|cs|swift)$/u.test(lower)) {
2927
3230
  score += 4;
2928
3231
  }
3232
+ if (/(src|app|lib|packages|services|connectors|components|routes|api)\//u.test(lower)) {
3233
+ score += 4;
3234
+ }
2929
3235
  if (/(test|spec|__tests__|tests)\b/u.test(lower)) {
2930
- score += 3;
3236
+ score += 5;
2931
3237
  }
2932
3238
  if (/(readme|package\.json|codeowners|agents\.md|tsconfig|vite|jest|vitest|pytest|gradle|pom\.xml|go\.mod|cargo\.toml)/u.test(lower)) {
2933
3239
  score += 2;
2934
3240
  }
2935
- if (/(dist|build|coverage|node_modules|vendor|generated|\.lock$|lockfile)/u.test(lower)) {
2936
- score -= 10;
3241
+ if (/(dist|build|coverage|node_modules|vendor|generated|\.lock$|lockfile|\.min\.)/u.test(lower)) {
3242
+ score -= 16;
3243
+ }
3244
+ if (/(snapshot|snapshots|fixtures|fixture|mock|mocks)\//u.test(lower)) {
3245
+ score -= 2;
2937
3246
  }
2938
3247
  return score;
2939
3248
  }
3249
+ function describePatchContextReason(filePath, tokens, planHints) {
3250
+ const lower = filePath.toLowerCase();
3251
+ const reasons = [
3252
+ planHints.has(filePath) ? "named by the approved plan" : undefined,
3253
+ tokens.filter((token) => lower.includes(token)).slice(0, 4).length
3254
+ ? `matches task token(s): ${tokens.filter((token) => lower.includes(token)).slice(0, 4).join(", ")}`
3255
+ : undefined,
3256
+ /(test|spec|__tests__|tests)\b/u.test(lower) ? "is a related validation file" : undefined,
3257
+ /(package\.json|tsconfig|vite|jest|vitest|pytest|gradle|pom\.xml|go\.mod|cargo\.toml)/u.test(lower) ? "contains project or test configuration" : undefined,
3258
+ /(readme|codeowners|agents\.md)/u.test(lower) ? "contains repository guidance" : undefined,
3259
+ /\.(ts|tsx|js|jsx|mjs|cjs|py|java|kt|go|rs|rb|php|cs|swift)$/u.test(lower) ? "is source code in a supported language" : undefined
3260
+ ].filter((reason) => Boolean(reason));
3261
+ return reasons.length ? reasons.join("; ") : "ranked from repository metadata and work item text";
3262
+ }
2940
3263
  function buildStructuredPatchPrompt(session, workspacePath, contextFiles) {
2941
3264
  const plan = session.plan;
2942
3265
  const context = contextFiles.map((file) => [
2943
3266
  `FILE: ${file.path}${file.truncated ? " (truncated)" : ""}`,
3267
+ `RANK: ${file.score}`,
3268
+ `WHY_INCLUDED: ${file.reason}`,
2944
3269
  "```",
2945
3270
  file.content,
2946
3271
  "```"
2947
3272
  ].join("\n")).join("\n\n");
2948
3273
  const failedTestContext = getFailedTestContextAfterLatestPatch(session);
3274
+ const missingRequirementSignals = Array.from(new Set([
3275
+ ...detectMissingRequirementSignals(session.workItem),
3276
+ ...(plan?.missingInfo ?? [])
3277
+ ])).slice(0, 8);
3278
+ const workspace = session.workspaceCandidate?.workspace;
2949
3279
  return [
2950
3280
  "You are OpenPome's implementation engine.",
3281
+ failedTestContext.length
3282
+ ? "This is a retry after approved validation failed. Repair only the failure using the evidence below."
3283
+ : "This is the first implementation patch for the approved plan.",
2951
3284
  "Return only compact JSON. Do not include markdown fences outside JSON.",
2952
3285
  "Only propose a minimal safe file patch for the approved work item.",
2953
3286
  "Do not include secrets, credentials, generated dependency folders, lockfile rewrites, or unrelated refactors.",
2954
3287
  "Use full replacement file content for each proposed file.",
3288
+ "If requirements are unclear, prefer a small diagnostic or guardrail change over a speculative broad rewrite.",
3289
+ "Keep existing style, imports, formatting, and public contracts unless the work item clearly requires a change.",
2955
3290
  "Allowed JSON shape:",
2956
3291
  "{\"summary\":\"...\",\"files\":[{\"path\":\"relative/path\",\"action\":\"create|update\",\"content\":\"full file content\"}],\"risks\":[\"...\"]}",
2957
3292
  "",
@@ -2964,7 +3299,11 @@ function buildStructuredPatchPrompt(session, workspacePath, contextFiles) {
2964
3299
  session.workItem.priority ? `- Priority: ${session.workItem.priority}` : undefined,
2965
3300
  session.workItem.labels?.length ? `- Labels: ${session.workItem.labels.join(", ")}` : undefined,
2966
3301
  session.workItem.components?.length ? `- Components: ${session.workItem.components.join(", ")}` : undefined,
3302
+ session.workItem.links?.length ? `- Links: ${session.workItem.links.map((link) => `${link.kind}:${link.title ?? link.url}`).join("; ")}` : undefined,
2967
3303
  "",
3304
+ missingRequirementSignals.length ? "Known missing or unclear requirements:" : undefined,
3305
+ ...missingRequirementSignals.map((signal) => `- ${signal}`),
3306
+ missingRequirementSignals.length ? "" : undefined,
2968
3307
  "Approved plan:",
2969
3308
  plan?.summary ? `- Summary: ${plan.summary}` : "- Summary: unavailable",
2970
3309
  ...(plan?.steps.map((step) => `- ${step.title}${step.detail ? `: ${step.detail}` : ""}`) ?? []),
@@ -2975,7 +3314,13 @@ function buildStructuredPatchPrompt(session, workspacePath, contextFiles) {
2975
3314
  failedTestContext.length ? "" : undefined,
2976
3315
  "Workspace:",
2977
3316
  `- Path: ${workspacePath}`,
2978
- session.workspaceCandidate?.workspace.name ? `- Name: ${session.workspaceCandidate.workspace.name}` : undefined,
3317
+ workspace?.name ? `- Name: ${workspace.name}` : undefined,
3318
+ workspace?.currentBranch ? `- Current branch: ${workspace.currentBranch}` : undefined,
3319
+ workspace?.packageNames?.length ? `- Packages: ${workspace.packageNames.slice(0, 8).join(", ")}` : undefined,
3320
+ workspace?.readmeKeywords?.length ? `- README signals: ${workspace.readmeKeywords.slice(0, 12).join(", ")}` : undefined,
3321
+ workspace?.codeownersKeywords?.length ? `- Ownership signals: ${workspace.codeownersKeywords.slice(0, 12).join(", ")}` : undefined,
3322
+ workspace?.recentBranches?.length ? `- Recent branches: ${workspace.recentBranches.slice(0, 8).join(", ")}` : undefined,
3323
+ workspace?.recentCommitRefs?.length ? `- Recent work refs: ${workspace.recentCommitRefs.slice(0, 12).join(", ")}` : undefined,
2979
3324
  "",
2980
3325
  "Readable context files:",
2981
3326
  context || "No source files were safely included. You may propose small new files only if the task clearly asks for them."
@@ -2986,14 +3331,41 @@ function getFailedTestContextAfterLatestPatch(session) {
2986
3331
  if (!failedRun) {
2987
3332
  return [];
2988
3333
  }
3334
+ const rootCauseHint = inferFailedTestRootCause(failedRun);
2989
3335
  return [
2990
3336
  `- Command: ${failedRun.command}`,
2991
3337
  `- Exit code: ${failedRun.exitCode}`,
3338
+ rootCauseHint ? `- Root cause hint: ${rootCauseHint}` : undefined,
2992
3339
  failedRun.cwd ? `- Working directory: ${failedRun.cwd}` : undefined,
2993
3340
  ...failedRun.stdoutSummary.map((line) => `- stdout: ${line}`),
2994
3341
  ...failedRun.stderrSummary.map((line) => `- stderr: ${line}`)
2995
3342
  ].filter((line) => Boolean(line)).slice(0, 48);
2996
3343
  }
3344
+ function inferFailedTestRootCause(run) {
3345
+ const text = [...run.stdoutSummary, ...run.stderrSummary].join("\n").toLowerCase();
3346
+ if (!text.trim()) {
3347
+ return undefined;
3348
+ }
3349
+ if (/\b(assertionerror|expected .* received|expected .* got|toequal|tobe|assert)\b/su.test(text)) {
3350
+ return "Assertion mismatch; inspect the changed behavior and expected output before editing more files.";
3351
+ }
3352
+ if (/\b(type error|ts\d{4}|typescript|cannot find name|property .* does not exist)\b/su.test(text)) {
3353
+ return "Type or compile failure; prefer the smallest type-safe fix in the impacted file.";
3354
+ }
3355
+ if (/\b(module not found|cannot find module|enoent|no such file|import .* not found)\b/su.test(text)) {
3356
+ return "Missing module/file/import; verify paths, exports, and package boundaries.";
3357
+ }
3358
+ if (/\b(timeout|timed out|exceeded timeout|deadline)\b/su.test(text)) {
3359
+ return "Timeout or hanging validation; inspect async flow, retries, and long-running operations.";
3360
+ }
3361
+ if (/\b(permission denied|eacces|forbidden|unauthorized)\b/su.test(text)) {
3362
+ return "Permission or environment failure; avoid code changes until credentials, filesystem access, or network access are confirmed.";
3363
+ }
3364
+ if (/\b(snapshot|snapshots)\b/su.test(text)) {
3365
+ return "Snapshot mismatch; inspect whether output changed intentionally before updating snapshots.";
3366
+ }
3367
+ return "Validation failed; use the command output to target a narrow repair patch.";
3368
+ }
2997
3369
  function parseAIPatchProposal(value, session, provider, workspacePath, createdAt) {
2998
3370
  const json = extractJsonObject(value);
2999
3371
  if (!json) {
@@ -3022,7 +3394,7 @@ function parseAIPatchProposal(value, session, provider, workspacePath, createdAt
3022
3394
  const relativePath = normalizeWorkspaceRelativePath(workspacePath, maybe.path, "throw");
3023
3395
  const action = maybe.action === "create" ? "create" : "update";
3024
3396
  const content = maybe.content;
3025
- if (!relativePath || isSensitiveWorkspacePath(relativePath) || content.includes("\u0000")) {
3397
+ if (!relativePath || isSensitiveWorkspacePath(relativePath) || content.includes("\u0000") || containsSensitiveContent(content)) {
3026
3398
  return undefined;
3027
3399
  }
3028
3400
  if (Buffer.byteLength(content, "utf8") > maxPatchProposalBytesPerFile) {
@@ -3070,6 +3442,9 @@ function isSensitiveWorkspacePath(filePath) {
3070
3442
  }
3071
3443
  return sensitivePathFragments.some((fragment) => lower === fragment || lower.includes(`/${fragment}`) || lower.endsWith(fragment));
3072
3444
  }
3445
+ function containsSensitiveContent(content) {
3446
+ return sensitiveContentPatterns.some((pattern) => pattern.test(content));
3447
+ }
3073
3448
  async function applyPatchFiles(workspacePath, files) {
3074
3449
  for (const file of files) {
3075
3450
  const relativePath = normalizeWorkspaceRelativePath(workspacePath, file.path, "throw");
@@ -3081,7 +3456,7 @@ async function applyPatchFiles(workspacePath, files) {
3081
3456
  await writeFile(absolutePath, file.content, "utf8");
3082
3457
  }
3083
3458
  }
3084
- async function discoverTestCommandCandidates(workspacePath) {
3459
+ async function discoverTestCommandCandidates(workspacePath, session) {
3085
3460
  const scripts = await readPackageScripts(join(workspacePath, "package.json"));
3086
3461
  const packageManager = detectPackageManager(workspacePath);
3087
3462
  const candidates = [];
@@ -3097,6 +3472,17 @@ async function discoverTestCommandCandidates(workspacePath) {
3097
3472
  cwd: workspacePath
3098
3473
  });
3099
3474
  }
3475
+ const relatedTestFiles = session ? await findRelatedTestFiles(workspacePath, session) : [];
3476
+ const testScript = scripts["test"] ? buildPackageScriptCommand(packageManager, "test") : undefined;
3477
+ for (const testFile of relatedTestFiles.slice(0, 5)) {
3478
+ candidates.push({
3479
+ id: `related_${createHash("sha256").update(testFile).digest("hex").slice(0, 8)}`,
3480
+ command: testScript ? `${testScript} -- ${quoteShellArg(testFile)}` : buildLanguageSpecificTestCommand(packageManager, testFile),
3481
+ source: "related_file",
3482
+ reason: `Related test file matched likely impacted work: ${testFile}.`,
3483
+ cwd: workspacePath
3484
+ });
3485
+ }
3100
3486
  if (candidates.length > 0) {
3101
3487
  return candidates;
3102
3488
  }
@@ -3113,6 +3499,74 @@ async function discoverTestCommandCandidates(workspacePath) {
3113
3499
  }
3114
3500
  return getFallbackTestCommandCandidates(workspacePath);
3115
3501
  }
3502
+ async function findRelatedTestFiles(workspacePath, session) {
3503
+ let trackedFiles = [];
3504
+ try {
3505
+ trackedFiles = await listTrackedWorkspaceFiles(workspacePath);
3506
+ }
3507
+ catch {
3508
+ return [];
3509
+ }
3510
+ const impactHints = new Set([
3511
+ ...(session.plan?.filesLikelyChanged ?? [])
3512
+ .map((filePath) => normalizeWorkspaceRelativePath(workspacePath, filePath, "skip"))
3513
+ .filter((filePath) => Boolean(filePath))
3514
+ .flatMap((filePath) => [filePath, basename(filePath).replace(/\.[^.]+$/u, "")]),
3515
+ ...tokenizePatchSearchText([
3516
+ session.workItem.key,
3517
+ session.workItem.title,
3518
+ session.workItem.description,
3519
+ ...(session.workItem.labels ?? []),
3520
+ ...(session.workItem.components ?? []),
3521
+ session.plan?.summary
3522
+ ].filter((value) => Boolean(value)).join(" "))
3523
+ ]);
3524
+ return trackedFiles
3525
+ .filter((filePath) => isTestLikeFile(filePath))
3526
+ .map((filePath) => ({
3527
+ filePath,
3528
+ score: scoreRelatedTestFile(filePath, impactHints)
3529
+ }))
3530
+ .filter((candidate) => candidate.score > 0)
3531
+ .sort((left, right) => right.score - left.score || left.filePath.localeCompare(right.filePath))
3532
+ .map((candidate) => candidate.filePath);
3533
+ }
3534
+ function isTestLikeFile(filePath) {
3535
+ return /(^|\/)(__tests__|tests?|specs?)\//u.test(filePath.toLowerCase())
3536
+ || /\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs|py|java|kt|go|rs|rb|php|cs)$/u.test(filePath.toLowerCase());
3537
+ }
3538
+ function scoreRelatedTestFile(filePath, impactHints) {
3539
+ const lower = filePath.toLowerCase();
3540
+ let score = 0;
3541
+ for (const hint of impactHints) {
3542
+ const normalized = hint.toLowerCase();
3543
+ if (normalized.length >= 3 && lower.includes(normalized)) {
3544
+ score += normalized.includes("/") ? 10 : 4;
3545
+ }
3546
+ }
3547
+ if (score === 0) {
3548
+ return 0;
3549
+ }
3550
+ if (/\.(test|spec)\./u.test(lower)) {
3551
+ score += 3;
3552
+ }
3553
+ if (/(__tests__|tests?)\//u.test(lower)) {
3554
+ score += 2;
3555
+ }
3556
+ if (/(snapshot|fixtures|mocks)\//u.test(lower)) {
3557
+ score -= 2;
3558
+ }
3559
+ return score;
3560
+ }
3561
+ function buildLanguageSpecificTestCommand(packageManager, testFile) {
3562
+ if (/\.(py)$/u.test(testFile)) {
3563
+ return `python -m pytest ${quoteShellArg(testFile)}`;
3564
+ }
3565
+ if (/\.(go)$/u.test(testFile)) {
3566
+ return "go test ./...";
3567
+ }
3568
+ return `${buildPackageScriptCommand(packageManager, "test")} -- ${quoteShellArg(testFile)}`;
3569
+ }
3116
3570
  function detectPackageManager(workspacePath) {
3117
3571
  if (existsSync(join(workspacePath, "pnpm-lock.yaml"))) {
3118
3572
  return "pnpm";
@@ -3137,6 +3591,9 @@ function buildPackageScriptCommand(packageManager, scriptName) {
3137
3591
  }
3138
3592
  return `pnpm ${scriptName}`;
3139
3593
  }
3594
+ function quoteShellArg(value) {
3595
+ return `'${value.replace(/'/gu, "'\\''")}'`;
3596
+ }
3140
3597
  function getFallbackTestCommandCandidates(cwd) {
3141
3598
  return [
3142
3599
  {
@@ -3237,6 +3694,14 @@ async function ensurePullRequestBranch(workspacePath, branch) {
3237
3694
  }
3238
3695
  return branch;
3239
3696
  }
3697
+ async function pushPullRequestBranch(workspacePath, branch) {
3698
+ try {
3699
+ await runGitStrict(workspacePath, ["push", "-u", "origin", branch]);
3700
+ }
3701
+ catch (error) {
3702
+ throw new Error(getGitHubCliGuidance("push pull request branch", error));
3703
+ }
3704
+ }
3240
3705
  async function createGitHubPullRequestWithCli(workspacePath, draft, branch, draftPr) {
3241
3706
  const ghArgs = [
3242
3707
  "pr",
@@ -3253,14 +3718,19 @@ async function createGitHubPullRequestWithCli(workspacePath, draft, branch, draf
3253
3718
  if (draftPr) {
3254
3719
  ghArgs.push("--draft");
3255
3720
  }
3256
- return (await execFileStrict("gh", ghArgs, workspacePath)).trim();
3721
+ try {
3722
+ return (await execFileStrict("gh", ghArgs, workspacePath)).trim();
3723
+ }
3724
+ catch (error) {
3725
+ throw new Error(getGitHubCliGuidance("create pull request", error));
3726
+ }
3257
3727
  }
3258
3728
  async function createGitHubPullRequestWithApi(accessToken, draft, branch, draftPr) {
3259
3729
  const repository = parseGitHubRepositoryCoordinates(draft.remoteUrl);
3260
3730
  if (!repository) {
3261
3731
  throw new Error("Unable to determine GitHub owner/repo from the workspace remote URL.");
3262
3732
  }
3263
- const response = await fetch(`${getGitHubApiBaseUrl()}/repos/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}/pulls`, {
3733
+ const response = await fetchGitHub(`${getGitHubApiBaseUrl()}/repos/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}/pulls`, {
3264
3734
  method: "POST",
3265
3735
  headers: {
3266
3736
  Accept: "application/vnd.github+json",
@@ -3275,14 +3745,22 @@ async function createGitHubPullRequestWithApi(accessToken, draft, branch, draftP
3275
3745
  base: draft.baseBranch,
3276
3746
  draft: draftPr
3277
3747
  })
3278
- });
3748
+ }, "create pull request");
3279
3749
  if (!response.ok) {
3280
- const body = await safeResponseText(response);
3281
- throw new Error(`GitHub PR creation failed: ${response.status} ${response.statusText}${body ? `: ${body}` : ""}`);
3750
+ throw new Error(await getGitHubStatusGuidance(response, "create pull request"));
3282
3751
  }
3283
3752
  const payload = (await response.json());
3284
3753
  return payload.html_url ?? `https://github.com/${repository.owner}/${repository.repo}/pull/${payload.number ?? ""}`;
3285
3754
  }
3755
+ function getGitHubCliGuidance(action, error) {
3756
+ const detail = summarizeExecError(error) ?? String(error);
3757
+ return [
3758
+ `GitHub ${action} failed.`,
3759
+ "Check repository write permission, organization SSO authorization, branch protection, remote URL, and whether your token/SSH key can push to origin.",
3760
+ "Run `pome auth github status` and `git remote -v` to verify the account and repository.",
3761
+ `Detail: ${detail}`
3762
+ ].join(" ");
3763
+ }
3286
3764
  function parseGitHubRepositoryCoordinates(remoteUrl) {
3287
3765
  if (!remoteUrl) {
3288
3766
  return undefined;
@@ -3331,6 +3809,33 @@ async function safeResponseText(response) {
3331
3809
  return "";
3332
3810
  }
3333
3811
  }
3812
+ function summarizeProviderBody(value) {
3813
+ const trimmed = value.trim();
3814
+ if (!trimmed) {
3815
+ return "";
3816
+ }
3817
+ try {
3818
+ const parsed = JSON.parse(trimmed);
3819
+ const errorObject = typeof parsed.error === "object" && parsed.error
3820
+ ? parsed.error
3821
+ : undefined;
3822
+ const messages = [
3823
+ typeof parsed.message === "string" ? parsed.message : undefined,
3824
+ typeof parsed.error === "string" ? parsed.error : undefined,
3825
+ typeof errorObject?.message === "string" ? errorObject.message : undefined,
3826
+ typeof errorObject?.type === "string" ? `type=${errorObject.type}` : undefined,
3827
+ typeof errorObject?.code === "string" ? `code=${errorObject.code}` : undefined,
3828
+ typeof parsed.documentation_url === "string" ? parsed.documentation_url : undefined
3829
+ ].filter((item) => Boolean(item));
3830
+ if (messages.length) {
3831
+ return messages.join("; ").slice(0, 500);
3832
+ }
3833
+ }
3834
+ catch {
3835
+ // Fall through to plain-text summary.
3836
+ }
3837
+ return trimmed.replace(/\s+/gu, " ").slice(0, 500);
3838
+ }
3334
3839
  async function hasWorkspaceChanges(workspacePath) {
3335
3840
  const output = await runGit(workspacePath, ["status", "--porcelain"]);
3336
3841
  return output.trim().length > 0;