@openpome/local-gateway 0.33.0-alpha.0 → 0.37.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
@@ -40,7 +40,7 @@ const maxWorkspaceScanRepositories = 200;
40
40
  export function getGatewayHealth() {
41
41
  return {
42
42
  status: "ok",
43
- version: "0.33.0-alpha.0"
43
+ version: "0.37.0-alpha.0"
44
44
  };
45
45
  }
46
46
  export async function initOpenPome() {
@@ -231,6 +231,11 @@ export async function runDoctor(env = process.env) {
231
231
  name: "Model provider",
232
232
  status: activeModel?.configured ? "ok" : "attention",
233
233
  detail: activeModel?.detail ?? "Run `pome auth ai status` to inspect AI setup."
234
+ },
235
+ {
236
+ name: "Telemetry",
237
+ status: "ok",
238
+ detail: "Disabled by default. OpenPome does not send analytics, prompts, source code, diffs, or crash data."
234
239
  }
235
240
  ];
236
241
  return {
@@ -493,6 +498,99 @@ export async function getTaskSessionStatus() {
493
498
  aiPatchProposal: persisted.aiPatchProposal
494
499
  };
495
500
  }
501
+ export async function getAssistantDecision() {
502
+ const status = await getTaskSessionStatus();
503
+ if (!status.active || !status.session || !status.workItem) {
504
+ const jira = await getJiraAuthStatus();
505
+ if (!jira.configured) {
506
+ return buildAssistantDecision(status, "connect_jira", "Connect Jira", "OpenPome needs Jira access before it can show assigned stories.", [
507
+ "pome onboard",
508
+ "pome auth jira login --listen",
509
+ "pome demo"
510
+ ], [jira.detail]);
511
+ }
512
+ return buildAssistantDecision(status, "select_work", "Choose assigned work", "Fetch assigned work and start one story.", [
513
+ "pome work",
514
+ "pome start <KEY>"
515
+ ]);
516
+ }
517
+ if (!status.plan) {
518
+ return buildAssistantDecision(status, "create_plan", "Create implementation plan", "Build a repo-aware implementation plan from the latest Jira story.", [
519
+ "pome plan"
520
+ ]);
521
+ }
522
+ if (status.planApproval?.status !== "approved") {
523
+ return buildAssistantDecision(status, "approve_plan", "Approve the plan", "Review the implementation plan before OpenPome asks AI for file changes.", [
524
+ "pome approve"
525
+ ], collectPlanReadinessWarnings(status));
526
+ }
527
+ if (status.aiPatchProposal && !status.aiPatchProposal.appliedAt) {
528
+ return buildAssistantDecision(status, "approve_patch", "Approve AI patch", "Review the proposed file changes. OpenPome writes files only after approval.", [
529
+ "pome approve"
530
+ ]);
531
+ }
532
+ const latestPatchAppliedAt = status.aiPatchProposal?.appliedAt;
533
+ const latestRunAfterPatch = getLatestTestRunAfterStatus(status, latestPatchAppliedAt);
534
+ if (latestRunAfterPatch?.status === "failed") {
535
+ return buildAssistantDecision(status, "retry_failed_tests", "Repair failed validation", "The latest approved test failed. Ask AI for a focused repair patch using the failed test evidence.", [
536
+ "pome next"
537
+ ]);
538
+ }
539
+ if (!status.aiPatchProposal && !status.diffSummary) {
540
+ const model = await getModelProviderStatus();
541
+ const activeModel = model.providers.find((provider) => provider.active);
542
+ const aiCanProposePatch = activeModel?.provider !== "manual-copy" && Boolean(activeModel?.configured);
543
+ const blockers = aiCanProposePatch ? collectPlanReadinessWarnings(status) : [
544
+ activeModel?.detail ?? "No AI provider is active.",
545
+ "Connect Claude CLI, Claude API, or OpenAI before AI patch proposals.",
546
+ "Run `pome auth ai claude-cli`, `pome auth ai claude`, or `pome auth ai openai`."
547
+ ];
548
+ 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.", [
549
+ "pome next",
550
+ "pome auth ai claude-cli"
551
+ ], blockers);
552
+ }
553
+ if (status.aiPatchProposal?.appliedAt && (status.testCommandCandidates?.length ?? 0) === 0) {
554
+ return buildAssistantDecision(status, "discover_tests", "Discover validation commands", "Find the likely test or validation commands for this workspace.", [
555
+ "pome next"
556
+ ]);
557
+ }
558
+ if ((status.testCommandCandidates?.length ?? 0) > 0 && (status.commandApprovalEvidence?.length ?? 0) === 0) {
559
+ return buildAssistantDecision(status, "approve_test", "Approve one validation command", "OpenPome will not run commands until you approve one candidate.", [
560
+ "pome approve"
561
+ ]);
562
+ }
563
+ if (status.aiPatchProposal?.appliedAt && (status.commandApprovalEvidence?.length ?? 0) > 0 && !latestRunAfterPatch) {
564
+ return buildAssistantDecision(status, "run_tests", "Run approved validation", "Run the approved command and store bounded evidence for PR and Jira updates.", [
565
+ "pome next"
566
+ ]);
567
+ }
568
+ if (!status.prDraft || !status.workItemUpdateDraft) {
569
+ return buildAssistantDecision(status, "prepare_completion", "Prepare completion drafts", "Prepare the PR body and Jira update from the approved plan, diff summary, and validation evidence.", [
570
+ "pome done"
571
+ ]);
572
+ }
573
+ if (!status.prCreation) {
574
+ const github = await getGitHubAuthStatus();
575
+ if (!github.authenticated) {
576
+ return buildAssistantDecision(status, "connect_github", "Connect GitHub", "GitHub is needed before OpenPome can create the pull request.", [
577
+ "pome auth github login",
578
+ "pome pr create"
579
+ ], [github.detail]);
580
+ }
581
+ return buildAssistantDecision(status, "create_pr", "Create the pull request", "OpenPome will create the branch/commit/push and open the PR through native GitHub API or GitHub CLI fallback.", [
582
+ "pome pr create"
583
+ ]);
584
+ }
585
+ if (!status.workItemUpdatePost) {
586
+ return buildAssistantDecision(status, "post_work_update", "Post Jira update", "Post the prepared Jira update with PR and validation context.", [
587
+ "pome work-item post-update"
588
+ ]);
589
+ }
590
+ return buildAssistantDecision(status, "complete", "Story handoff ready", "External completion artifacts are created. Review Jira and GitHub for your team's final workflow.", [
591
+ "pome status"
592
+ ]);
593
+ }
496
594
  export async function getTaskSessionTimeline() {
497
595
  const paths = getOpenPomePaths();
498
596
  const persisted = await refreshActiveTaskSessionWorkItem(paths.homeDirectory);
@@ -513,6 +611,34 @@ export async function getTaskSessionApprovalHistory() {
513
611
  approvals: persisted?.approvalHistory ?? []
514
612
  };
515
613
  }
614
+ function buildAssistantDecision(status, action, title, detail, commands, blockers = []) {
615
+ return {
616
+ action,
617
+ title,
618
+ detail,
619
+ commands,
620
+ blockers: blockers.filter((blocker) => blocker.trim().length > 0),
621
+ status
622
+ };
623
+ }
624
+ function collectPlanReadinessWarnings(status) {
625
+ const warnings = [];
626
+ if (!status.workspaceCandidate?.workspace.path) {
627
+ warnings.push("No workspace is resolved yet. Start from inside the repo or link the work item to a workspace.");
628
+ }
629
+ else if (status.workspaceCandidate.confidence < 0.5) {
630
+ warnings.push("Workspace confidence is low. OpenPome can continue, but confirm the repo if the plan looks wrong.");
631
+ }
632
+ if (status.plan?.missingInfo.length) {
633
+ warnings.push(...status.plan.missingInfo.map((item) => `Missing context: ${item}`));
634
+ }
635
+ return warnings;
636
+ }
637
+ function getLatestTestRunAfterStatus(status, since) {
638
+ const runs = status.testRunEvidence ?? [];
639
+ const filtered = since ? runs.filter((run) => run.finishedAt >= since) : runs;
640
+ return filtered[filtered.length - 1];
641
+ }
516
642
  export async function stopTaskSession() {
517
643
  const paths = getOpenPomePaths();
518
644
  const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
@@ -934,7 +1060,7 @@ export async function discoverTestCommands() {
934
1060
  }
935
1061
  const workspace = persisted.workspaceCandidate?.workspace;
936
1062
  const discoveredAt = new Date().toISOString();
937
- const candidates = workspace?.path ? await discoverTestCommandCandidates(workspace.path) : getFallbackTestCommandCandidates();
1063
+ const candidates = workspace?.path ? await discoverTestCommandCandidates(workspace.path, persisted) : getFallbackTestCommandCandidates();
938
1064
  await writeActiveTaskSession(paths.homeDirectory, {
939
1065
  ...persisted,
940
1066
  testCommandCandidates: candidates,
@@ -964,7 +1090,7 @@ export async function approveTestCommand(command) {
964
1090
  const candidates = persisted.testCommandCandidates?.length
965
1091
  ? persisted.testCommandCandidates
966
1092
  : persisted.workspaceCandidate?.workspace.path
967
- ? await discoverTestCommandCandidates(persisted.workspaceCandidate.workspace.path)
1093
+ ? await discoverTestCommandCandidates(persisted.workspaceCandidate.workspace.path, persisted)
968
1094
  : getFallbackTestCommandCandidates();
969
1095
  const selected = selectTestCommandCandidate(candidates, command);
970
1096
  if (!selected) {
@@ -1192,16 +1318,16 @@ export async function createGitHubDeviceLogin(env = process.env) {
1192
1318
  client_id: clientId,
1193
1319
  scope
1194
1320
  });
1195
- const response = await fetch("https://github.com/login/device/code", {
1321
+ const response = await fetchGitHub("https://github.com/login/device/code", {
1196
1322
  method: "POST",
1197
1323
  headers: {
1198
1324
  Accept: "application/json",
1199
1325
  "Content-Type": "application/x-www-form-urlencoded"
1200
1326
  },
1201
1327
  body
1202
- });
1328
+ }, "start browser login");
1203
1329
  if (!response.ok) {
1204
- throw new Error(`GitHub device login failed: ${response.status} ${response.statusText}`);
1330
+ throw new Error(await getGitHubStatusGuidance(response, "start browser login"));
1205
1331
  }
1206
1332
  const payload = (await response.json());
1207
1333
  if (!payload.device_code || !payload.user_code || !payload.verification_uri) {
@@ -1365,7 +1491,7 @@ export async function createPullRequest(options = {}) {
1365
1491
  }
1366
1492
  await runGitStrict(workspacePath, ["add", "-A"]);
1367
1493
  await runGitStrict(workspacePath, ["commit", "-m", commitMessage]);
1368
- await runGitStrict(workspacePath, ["push", "-u", "origin", branch]);
1494
+ await pushPullRequestBranch(workspacePath, branch);
1369
1495
  const storedGitHubToken = github.tokenSource === "openpome" ? await readStoredGitHubOAuth() : undefined;
1370
1496
  const prProvider = storedGitHubToken?.accessToken ? "github-api" : "github-cli";
1371
1497
  const prUrl = storedGitHubToken?.accessToken
@@ -1756,15 +1882,15 @@ async function isGitHubCliAuthenticated() {
1756
1882
  }
1757
1883
  }
1758
1884
  async function fetchGitHubAuthenticatedUser(accessToken) {
1759
- const response = await fetch("https://api.github.com/user", {
1885
+ const response = await fetchGitHub("https://api.github.com/user", {
1760
1886
  headers: {
1761
1887
  Accept: "application/vnd.github+json",
1762
1888
  Authorization: `Bearer ${accessToken}`,
1763
1889
  "X-GitHub-Api-Version": "2022-11-28"
1764
1890
  }
1765
- });
1891
+ }, "verify authenticated user");
1766
1892
  if (!response.ok) {
1767
- throw new Error(`GitHub user lookup failed: ${response.status} ${response.statusText}`);
1893
+ throw new Error(await getGitHubStatusGuidance(response, "verify authenticated user"));
1768
1894
  }
1769
1895
  const payload = (await response.json());
1770
1896
  if (!payload.login) {
@@ -1777,7 +1903,7 @@ async function fetchGitHubAuthenticatedUser(accessToken) {
1777
1903
  };
1778
1904
  }
1779
1905
  async function pollGitHubDeviceAccessToken(clientId, deviceCode) {
1780
- const response = await fetch("https://github.com/login/oauth/access_token", {
1906
+ const response = await fetchGitHub("https://github.com/login/oauth/access_token", {
1781
1907
  method: "POST",
1782
1908
  headers: {
1783
1909
  Accept: "application/json",
@@ -1788,11 +1914,11 @@ async function pollGitHubDeviceAccessToken(clientId, deviceCode) {
1788
1914
  device_code: deviceCode,
1789
1915
  grant_type: "urn:ietf:params:oauth:grant-type:device_code"
1790
1916
  })
1791
- });
1917
+ }, "complete browser login");
1792
1918
  if (!response.ok) {
1793
1919
  return {
1794
1920
  status: "error",
1795
- detail: `GitHub device token polling failed: ${response.status} ${response.statusText}`
1921
+ detail: await getGitHubStatusGuidance(response, "complete browser login")
1796
1922
  };
1797
1923
  }
1798
1924
  const payload = (await response.json());
@@ -1830,6 +1956,48 @@ function parseGitHubScopes(scope) {
1830
1956
  .map((value) => value.trim())
1831
1957
  .filter((value) => value.length > 0);
1832
1958
  }
1959
+ async function fetchGitHub(input, init, action) {
1960
+ try {
1961
+ return await fetch(input, init);
1962
+ }
1963
+ catch (error) {
1964
+ throw new Error(getGitHubNetworkGuidance(action, error));
1965
+ }
1966
+ }
1967
+ async function getGitHubStatusGuidance(response, action) {
1968
+ const body = await safeResponseText(response);
1969
+ const detail = body ? ` Detail: ${summarizeProviderBody(body)}` : "";
1970
+ if (response.status === 401) {
1971
+ return `GitHub ${action} was unauthorized (401). Run \`pome auth github login\` again, or \`gh auth login\` if you use the GitHub CLI fallback.${detail}`;
1972
+ }
1973
+ if (response.status === 403) {
1974
+ return `GitHub ${action} was forbidden (403). Check repository permission, organization SSO, token scopes, branch protection, and whether the token has \`repo\` access.${detail}`;
1975
+ }
1976
+ if (response.status === 404) {
1977
+ return `GitHub ${action} could not find the repository or resource (404). Check the git remote, repository visibility, GitHub Enterprise host, and account access.${detail}`;
1978
+ }
1979
+ if (response.status === 422) {
1980
+ 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}`;
1981
+ }
1982
+ if (response.status === 429 || response.headers.get("x-ratelimit-remaining") === "0") {
1983
+ const reset = response.headers.get("x-ratelimit-reset");
1984
+ const resetDetail = reset ? ` Rate limit resets at ${new Date(Number(reset) * 1000).toISOString()}.` : "";
1985
+ return `GitHub rate limit reached while trying to ${action}.${resetDetail} Wait and retry, or use a token with the right organization access.${detail}`;
1986
+ }
1987
+ if (response.status >= 500) {
1988
+ return `GitHub ${action} failed with ${response.status} ${response.statusText}. GitHub may be unavailable, blocked by a proxy, or unreachable from this network.${detail}`;
1989
+ }
1990
+ return `GitHub ${action} failed: ${response.status} ${response.statusText}.${detail}`;
1991
+ }
1992
+ function getGitHubNetworkGuidance(action, error) {
1993
+ const detail = error instanceof Error ? error.message : String(error);
1994
+ return [
1995
+ `GitHub ${action} could not reach GitHub.`,
1996
+ "Check internet access, VPN split tunneling, proxy/firewall rules, corporate certificate trust, and GitHub Enterprise host configuration.",
1997
+ "Run `pome auth github status` after fixing network access.",
1998
+ `Detail: ${detail}`
1999
+ ].join(" ");
2000
+ }
1833
2001
  function summarizeUnknownError(error) {
1834
2002
  return error instanceof Error ? error.message : String(error);
1835
2003
  }
@@ -1878,7 +2046,11 @@ function getOpenPomePaths() {
1878
2046
  async function readConfigIfPresent(configFile) {
1879
2047
  try {
1880
2048
  const content = await readFile(configFile, "utf8");
1881
- return JSON.parse(content);
2049
+ return {
2050
+ ...defaultConfig,
2051
+ ...JSON.parse(content),
2052
+ telemetryEnabled: false
2053
+ };
1882
2054
  }
1883
2055
  catch (error) {
1884
2056
  if (error instanceof Error && "code" in error && error.code === "ENOENT") {
@@ -2461,22 +2633,40 @@ function selectArchivedTaskSession(sessions, sessionId) {
2461
2633
  }
2462
2634
  function buildPlanningContext(session) {
2463
2635
  const workspace = session.workspaceCandidate?.workspace;
2636
+ const missingRequirementSignals = detectMissingRequirementSignals(session.workItem);
2464
2637
  const context = [
2465
2638
  `Work item type: ${session.workItem.type}`,
2466
2639
  `Status: ${session.workItem.status}`,
2467
2640
  session.workItem.priority ? `Priority: ${session.workItem.priority}` : undefined,
2641
+ session.workItem.description ? `Description length: ${session.workItem.description.length} characters` : "Description: not provided",
2642
+ hasExplicitAcceptanceCriteria(session.workItem)
2643
+ ? "Acceptance criteria: detected in work item text"
2644
+ : "Acceptance criteria: not explicit; identify missing acceptance criteria before implementation",
2645
+ missingRequirementSignals.length ? `Missing requirement signals: ${missingRequirementSignals.join("; ")}` : undefined,
2468
2646
  session.workItem.labels?.length ? `Labels: ${session.workItem.labels.join(", ")}` : undefined,
2469
2647
  session.workItem.components?.length ? `Components: ${session.workItem.components.join(", ")}` : undefined,
2648
+ session.workItem.links?.length ? `Linked references: ${session.workItem.links.map((link) => `${link.kind}:${link.title ?? link.url}`).join("; ")}` : undefined,
2649
+ session.workItem.subtasks?.length ? `Subtasks: ${session.workItem.subtasks.map((subtask) => `${subtask.key} ${subtask.status} ${subtask.title}`).join("; ")}` : undefined,
2470
2650
  workspace ? `Workspace: ${workspace.name}` : "Workspace: unresolved",
2471
2651
  workspace?.path ? `Workspace path: ${workspace.path}` : undefined,
2472
2652
  session.workspaceCandidate ? `Workspace confidence: ${Math.round(session.workspaceCandidate.confidence * 100)}%` : undefined,
2473
- session.workspaceCandidate?.reasons.length ? `Workspace reasons: ${session.workspaceCandidate.reasons.join("; ")}` : undefined
2653
+ session.workspaceCandidate?.reasons.length ? `Workspace reasons: ${session.workspaceCandidate.reasons.join("; ")}` : undefined,
2654
+ workspace?.packageNames?.length ? `Workspace packages: ${workspace.packageNames.slice(0, 8).join(", ")}` : undefined,
2655
+ workspace?.readmeKeywords?.length ? `README signals: ${workspace.readmeKeywords.slice(0, 12).join(", ")}` : undefined,
2656
+ workspace?.codeownersKeywords?.length ? `Ownership signals: ${workspace.codeownersKeywords.slice(0, 12).join(", ")}` : undefined,
2657
+ workspace?.recentBranches?.length ? `Recent branches: ${workspace.recentBranches.slice(0, 8).join(", ")}` : undefined,
2658
+ workspace?.recentCommitRefs?.length ? `Recent work refs: ${workspace.recentCommitRefs.slice(0, 12).join(", ")}` : undefined
2474
2659
  ];
2475
2660
  return context.filter((item) => Boolean(item));
2476
2661
  }
2477
2662
  function buildInitialImplementationPlan(workItem, workspaceCandidate) {
2478
2663
  const workspace = workspaceCandidate?.workspace;
2479
2664
  const hasWorkspace = Boolean(workspace?.path);
2665
+ const missingRequirementSignals = detectMissingRequirementSignals(workItem);
2666
+ const missingInfo = [
2667
+ hasWorkspace ? undefined : "No workspace candidate is selected yet.",
2668
+ ...missingRequirementSignals
2669
+ ].filter((item) => Boolean(item));
2480
2670
  return {
2481
2671
  summary: `Prepare implementation for ${workItem.key}: ${workItem.title}`,
2482
2672
  assumptions: [
@@ -2516,9 +2706,42 @@ function buildInitialImplementationPlan(workItem, workspaceCandidate) {
2516
2706
  "Workspace resolution may be incomplete until real GitHub and historical session signals are added.",
2517
2707
  "Manual-copy mode uses deterministic planning; connect OpenAI or Claude for AI-assisted planning and patch proposals."
2518
2708
  ],
2519
- missingInfo: hasWorkspace ? [] : ["No workspace candidate is selected yet."]
2709
+ missingInfo
2520
2710
  };
2521
2711
  }
2712
+ function hasExplicitAcceptanceCriteria(workItem) {
2713
+ const text = [workItem.title, workItem.description].filter(Boolean).join("\n").toLowerCase();
2714
+ 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);
2715
+ }
2716
+ function detectMissingRequirementSignals(workItem) {
2717
+ const text = [workItem.title, workItem.description].filter(Boolean).join("\n").trim();
2718
+ const lower = text.toLowerCase();
2719
+ const signals = [];
2720
+ if (!workItem.description || workItem.description.trim().length < 40) {
2721
+ signals.push("Work item description is short; confirm exact scope before broad edits.");
2722
+ }
2723
+ if (!hasExplicitAcceptanceCriteria(workItem)) {
2724
+ signals.push("Acceptance criteria are not explicit in the work item.");
2725
+ }
2726
+ if (workItem.type === "bug") {
2727
+ const hasExpected = /\b(expected|should happen|desired behavior|correct behavior)\b/u.test(lower);
2728
+ const hasActual = /\b(actual|currently|observed|happens now|error|failure|failed)\b/u.test(lower);
2729
+ const hasRepro = /\b(steps to reproduce|repro|reproduce|given\b.*\bwhen\b.*\bthen)\b/su.test(lower);
2730
+ if (!hasExpected || !hasActual) {
2731
+ signals.push("Bug report is missing clear expected vs actual behavior.");
2732
+ }
2733
+ if (!hasRepro) {
2734
+ signals.push("Bug report has no clear reproduction steps.");
2735
+ }
2736
+ }
2737
+ if (!workItem.labels?.length && !workItem.components?.length) {
2738
+ signals.push("No labels or components are available to narrow the code area.");
2739
+ }
2740
+ if (!workItem.links?.some((link) => link.kind === "code" || link.kind === "pull_request" || link.kind === "document")) {
2741
+ signals.push("No linked code, pull request, or reference document is attached.");
2742
+ }
2743
+ return Array.from(new Set(signals)).slice(0, 6);
2744
+ }
2522
2745
  async function buildImplementationPlan(persisted, prompt) {
2523
2746
  const config = await readConfigIfPresent(getOpenPomePaths().configFile);
2524
2747
  const provider = normalizeModelProviderId(config?.activeModelProvider ?? defaultConfig.activeModelProvider);
@@ -2544,7 +2767,7 @@ async function completeModelText(provider, prompt) {
2544
2767
  return completeClaudeCliText(prompt);
2545
2768
  }
2546
2769
  async function completeOpenAIText(prompt, apiKey) {
2547
- const response = await fetch("https://api.openai.com/v1/responses", {
2770
+ const response = await fetchModelProvider("OpenAI", "https://api.openai.com/v1/responses", {
2548
2771
  method: "POST",
2549
2772
  headers: {
2550
2773
  "authorization": `Bearer ${apiKey}`,
@@ -2556,7 +2779,7 @@ async function completeOpenAIText(prompt, apiKey) {
2556
2779
  })
2557
2780
  });
2558
2781
  if (!response.ok) {
2559
- throw new Error(`OpenAI request failed: ${response.status} ${response.statusText}`);
2782
+ throw new Error(await getModelProviderStatusGuidance("OpenAI", response, "generate a plan or patch"));
2560
2783
  }
2561
2784
  const body = await response.json();
2562
2785
  if (typeof body.output_text === "string") {
@@ -2570,7 +2793,7 @@ async function completeOpenAIText(prompt, apiKey) {
2570
2793
  .join("\n");
2571
2794
  }
2572
2795
  async function completeAnthropicText(prompt, apiKey) {
2573
- const response = await fetch("https://api.anthropic.com/v1/messages", {
2796
+ const response = await fetchModelProvider("Claude", "https://api.anthropic.com/v1/messages", {
2574
2797
  method: "POST",
2575
2798
  headers: {
2576
2799
  "x-api-key": apiKey,
@@ -2589,7 +2812,7 @@ async function completeAnthropicText(prompt, apiKey) {
2589
2812
  })
2590
2813
  });
2591
2814
  if (!response.ok) {
2592
- throw new Error(`Claude request failed: ${response.status} ${response.statusText}`);
2815
+ throw new Error(await getModelProviderStatusGuidance("Claude", response, "generate a plan or patch"));
2593
2816
  }
2594
2817
  const body = await response.json();
2595
2818
  const content = Array.isArray(body.content) ? body.content : [];
@@ -2631,12 +2854,50 @@ async function completeClaudeCliText(prompt) {
2631
2854
  throw new Error(`Claude CLI request failed: ${summarizeExecError(error) || String(error)}`);
2632
2855
  }
2633
2856
  }
2857
+ async function fetchModelProvider(provider, input, init) {
2858
+ try {
2859
+ return await fetch(input, init);
2860
+ }
2861
+ catch (error) {
2862
+ const detail = error instanceof Error ? error.message : String(error);
2863
+ 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}`);
2864
+ }
2865
+ }
2866
+ async function getModelProviderStatusGuidance(provider, response, action) {
2867
+ const body = await safeResponseText(response);
2868
+ const detail = body ? ` Detail: ${summarizeProviderBody(body)}` : "";
2869
+ const authCommand = provider === "OpenAI" ? "pome auth ai openai" : "pome auth ai claude";
2870
+ if (response.status === 401) {
2871
+ return `${provider} ${action} was unauthorized (401). Reconnect with \`${authCommand}\` or verify the provider API key in your OS credential store/environment.${detail}`;
2872
+ }
2873
+ if (response.status === 403) {
2874
+ return `${provider} ${action} was forbidden (403). Check organization policy, model access, provider project permissions, and corporate egress rules.${detail}`;
2875
+ }
2876
+ if (response.status === 404) {
2877
+ return `${provider} ${action} could not find the configured model or endpoint (404). Check OPENPOME_${provider === "OpenAI" ? "OPENAI_MODEL" : "ANTHROPIC_MODEL"} and provider access.${detail}`;
2878
+ }
2879
+ if (response.status === 408 || response.status === 409 || response.status === 429) {
2880
+ return `${provider} is busy or rate limited (${response.status}). Wait and retry, or choose a smaller model/context. OpenPome has not written files.${detail}`;
2881
+ }
2882
+ if (response.status >= 500) {
2883
+ return `${provider} failed with ${response.status} ${response.statusText}. Provider service may be unavailable or blocked by your network/proxy. Retry later.${detail}`;
2884
+ }
2885
+ return `${provider} ${action} failed: ${response.status} ${response.statusText}.${detail}`;
2886
+ }
2634
2887
  function buildStructuredPlanPrompt(prompt) {
2635
2888
  return [
2636
2889
  "You are OpenPome's planning engine.",
2890
+ "Plan like a senior developer assistant working from a live corporate work item.",
2891
+ "Prefer the smallest repo-aware change that satisfies the work item. Call out unclear scope instead of inventing requirements.",
2892
+ "Use workspace metadata, labels, linked references, ownership signals, and recent branch/commit refs to rank likely files.",
2893
+ "Suggest targeted validation commands before broad commands when the work item points to a specific component.",
2637
2894
  "Return only compact JSON with this exact shape:",
2638
2895
  "{\"summary\":\"...\",\"assumptions\":[\"...\"],\"steps\":[{\"id\":\"1\",\"title\":\"...\",\"detail\":\"...\"}],\"filesLikelyChanged\":[\"...\"],\"commandsToRun\":[\"...\"],\"risks\":[\"...\"],\"missingInfo\":[\"...\"]}",
2639
- "Do not include source code, full diffs, secrets, or markdown fences.",
2896
+ "Rules:",
2897
+ "- Do not include source code, full diffs, secrets, or markdown fences.",
2898
+ "- Put missing acceptance criteria, missing repro steps, unclear expected behavior, and missing code links in missingInfo.",
2899
+ "- Keep filesLikelyChanged to relative paths or package/module hints when exact files are unknown.",
2900
+ "- Keep commandsToRun executable from the selected workspace.",
2640
2901
  "",
2641
2902
  prompt
2642
2903
  ].join("\n");
@@ -2695,39 +2956,79 @@ const modelProviderTimeoutMs = 120_000;
2695
2956
  const modelProviderMaxBufferBytes = 2 * 1024 * 1024;
2696
2957
  const sensitivePathFragments = [
2697
2958
  ".env",
2959
+ ".env.local",
2960
+ ".env.production",
2961
+ ".env.development",
2698
2962
  ".npmrc",
2963
+ ".yarnrc",
2964
+ ".pnpmrc",
2699
2965
  ".pypirc",
2700
2966
  ".netrc",
2701
2967
  ".ssh",
2702
2968
  ".aws",
2703
2969
  ".gcp",
2704
2970
  ".azure",
2971
+ ".kube",
2972
+ ".docker",
2973
+ "credentials",
2974
+ "credential",
2975
+ "secrets",
2976
+ "secret",
2977
+ "private-key",
2978
+ "private_key",
2705
2979
  "id_rsa",
2706
2980
  "id_dsa",
2707
- "id_ed25519"
2981
+ "id_ed25519",
2982
+ ".pem",
2983
+ ".key",
2984
+ ".crt",
2985
+ ".p12",
2986
+ ".pfx"
2987
+ ];
2988
+ const sensitiveContentPatterns = [
2989
+ /-----BEGIN (?:RSA |DSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/u,
2990
+ /\b(?:OPENAI_API_KEY|ANTHROPIC_API_KEY|OPENPOME_JIRA_API_TOKEN|OPENPOME_GITHUB_TOKEN|GITHUB_TOKEN|NPM_TOKEN)\s*=\s*['"]?[^'"\s]+/iu,
2991
+ /\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|private[_-]?key)\s*[:=]\s*['"][^'"]{12,}['"]/iu,
2992
+ /\b(?:ghp|github_pat|npm|sk|sk-ant|xox[baprs])-?[A-Za-z0-9_=-]{20,}\b/u
2708
2993
  ];
2709
2994
  async function collectPatchContextFiles(workspacePath, session) {
2710
2995
  const candidates = [];
2711
2996
  for (const filePath of session.plan?.filesLikelyChanged ?? []) {
2712
2997
  const normalized = normalizeWorkspaceRelativePath(workspacePath, filePath, "skip");
2713
2998
  if (normalized && normalized !== ".") {
2714
- candidates.push(normalized);
2999
+ candidates.push({
3000
+ filePath: normalized,
3001
+ score: 80,
3002
+ reason: "AI plan marked this file as likely impacted."
3003
+ });
2715
3004
  }
2716
3005
  }
2717
- candidates.push("package.json", "README.md", "AGENTS.md");
3006
+ 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." });
2718
3007
  const trackedFiles = await listTrackedWorkspaceFiles(workspacePath);
2719
3008
  const tokens = tokenizePatchSearchText([
2720
3009
  session.workItem.key,
2721
3010
  session.workItem.title,
2722
3011
  session.workItem.description,
3012
+ session.plan?.summary,
3013
+ ...(session.plan?.steps.map((step) => `${step.title} ${step.detail ?? ""}`) ?? []),
2723
3014
  ...(session.workItem.labels ?? []),
2724
- ...(session.workItem.components ?? [])
3015
+ ...(session.workItem.components ?? []),
3016
+ ...(session.workspaceCandidate?.workspace.packageNames ?? []),
3017
+ ...(session.workspaceCandidate?.workspace.readmeKeywords ?? [])
2725
3018
  ].filter((value) => Boolean(value)).join(" "));
2726
- for (const filePath of trackedFiles) {
2727
- const lower = filePath.toLowerCase();
2728
- if (tokens.some((token) => lower.includes(token))) {
2729
- candidates.push(filePath);
2730
- }
3019
+ const planHints = new Set((session.plan?.filesLikelyChanged ?? [])
3020
+ .map((filePath) => normalizeWorkspaceRelativePath(workspacePath, filePath, "skip"))
3021
+ .filter((filePath) => Boolean(filePath)));
3022
+ const rankedTrackedFiles = trackedFiles
3023
+ .map((filePath) => ({
3024
+ filePath,
3025
+ score: scorePatchContextFile(filePath, tokens, planHints),
3026
+ reason: describePatchContextReason(filePath, tokens, planHints)
3027
+ }))
3028
+ .filter((candidate) => candidate.score > 0)
3029
+ .sort((left, right) => right.score - left.score || left.filePath.localeCompare(right.filePath));
3030
+ for (const candidate of rankedTrackedFiles.slice(0, 40)) {
3031
+ candidates.push(candidate);
2731
3032
  }
2732
3033
  const selected = [];
2733
3034
  const seen = new Set();
@@ -2736,7 +3037,7 @@ async function collectPatchContextFiles(workspacePath, session) {
2736
3037
  if (selected.length >= maxPatchContextFiles || totalBytes >= maxPatchContextTotalBytes) {
2737
3038
  break;
2738
3039
  }
2739
- const relativePath = normalizeWorkspaceRelativePath(workspacePath, candidate, "skip");
3040
+ const relativePath = normalizeWorkspaceRelativePath(workspacePath, candidate.filePath, "skip");
2740
3041
  if (!relativePath || seen.has(relativePath) || isSensitiveWorkspacePath(relativePath)) {
2741
3042
  continue;
2742
3043
  }
@@ -2744,7 +3045,7 @@ async function collectPatchContextFiles(workspacePath, session) {
2744
3045
  const absolutePath = resolve(workspacePath, relativePath);
2745
3046
  try {
2746
3047
  const content = await readFile(absolutePath, "utf8");
2747
- if (content.includes("\u0000")) {
3048
+ if (content.includes("\u0000") || containsSensitiveContent(content)) {
2748
3049
  continue;
2749
3050
  }
2750
3051
  const remainingBytes = maxPatchContextTotalBytes - totalBytes;
@@ -2754,7 +3055,9 @@ async function collectPatchContextFiles(workspacePath, session) {
2754
3055
  selected.push({
2755
3056
  path: relativePath,
2756
3057
  content: sliced,
2757
- truncated: Buffer.byteLength(content, "utf8") > Buffer.byteLength(sliced, "utf8")
3058
+ truncated: Buffer.byteLength(content, "utf8") > Buffer.byteLength(sliced, "utf8"),
3059
+ score: candidate.score,
3060
+ reason: candidate.reason
2758
3061
  });
2759
3062
  }
2760
3063
  catch {
@@ -2765,31 +3068,121 @@ async function collectPatchContextFiles(workspacePath, session) {
2765
3068
  }
2766
3069
  async function listTrackedWorkspaceFiles(workspacePath) {
2767
3070
  const output = await runGit(workspacePath, ["ls-files"]);
2768
- return output
3071
+ const trackedFiles = output
2769
3072
  .split(/\r?\n/u)
2770
3073
  .map((line) => line.trim())
2771
3074
  .filter(Boolean)
2772
3075
  .filter((filePath) => !isSensitiveWorkspacePath(filePath))
2773
3076
  .slice(0, 1000);
3077
+ return trackedFiles.length > 0 ? trackedFiles : listWorkspaceFilesFallback(workspacePath);
3078
+ }
3079
+ async function listWorkspaceFilesFallback(workspacePath) {
3080
+ const collected = [];
3081
+ const queue = ["."];
3082
+ const ignoredDirectories = new Set([".git", "node_modules", "dist", "build", "coverage", ".next", ".turbo", ".cache", "vendor"]);
3083
+ while (queue.length > 0 && collected.length < 1000) {
3084
+ const current = queue.shift() ?? ".";
3085
+ const absoluteCurrent = resolve(workspacePath, current);
3086
+ let directory;
3087
+ try {
3088
+ directory = await opendir(absoluteCurrent);
3089
+ }
3090
+ catch {
3091
+ continue;
3092
+ }
3093
+ for await (const entry of directory) {
3094
+ const relativePath = current === "." ? entry.name : `${current}/${entry.name}`;
3095
+ if (entry.isDirectory()) {
3096
+ if (!ignoredDirectories.has(entry.name) && !isSensitiveWorkspacePath(relativePath)) {
3097
+ queue.push(relativePath);
3098
+ }
3099
+ continue;
3100
+ }
3101
+ if (entry.isFile() && !isSensitiveWorkspacePath(relativePath)) {
3102
+ collected.push(relativePath);
3103
+ if (collected.length >= 1000) {
3104
+ break;
3105
+ }
3106
+ }
3107
+ }
3108
+ }
3109
+ return collected;
2774
3110
  }
2775
3111
  function tokenizePatchSearchText(value) {
2776
3112
  return Array.from(new Set(value.toLowerCase().split(/[^a-z0-9]+/u).filter((token) => token.length >= 3))).slice(0, 20);
2777
3113
  }
3114
+ function scorePatchContextFile(filePath, tokens, planHints) {
3115
+ const lower = filePath.toLowerCase();
3116
+ let score = 0;
3117
+ if (planHints.has(filePath)) {
3118
+ score += 40;
3119
+ }
3120
+ for (const token of tokens) {
3121
+ if (lower.includes(token)) {
3122
+ score += 5;
3123
+ }
3124
+ }
3125
+ if (/\.(ts|tsx|js|jsx|mjs|cjs|py|java|kt|go|rs|rb|php|cs|swift)$/u.test(lower)) {
3126
+ score += 4;
3127
+ }
3128
+ if (/(src|app|lib|packages|services|connectors|components|routes|api)\//u.test(lower)) {
3129
+ score += 4;
3130
+ }
3131
+ if (/(test|spec|__tests__|tests)\b/u.test(lower)) {
3132
+ score += 5;
3133
+ }
3134
+ if (/(readme|package\.json|codeowners|agents\.md|tsconfig|vite|jest|vitest|pytest|gradle|pom\.xml|go\.mod|cargo\.toml)/u.test(lower)) {
3135
+ score += 2;
3136
+ }
3137
+ if (/(dist|build|coverage|node_modules|vendor|generated|\.lock$|lockfile|\.min\.)/u.test(lower)) {
3138
+ score -= 16;
3139
+ }
3140
+ if (/(snapshot|snapshots|fixtures|fixture|mock|mocks)\//u.test(lower)) {
3141
+ score -= 2;
3142
+ }
3143
+ return score;
3144
+ }
3145
+ function describePatchContextReason(filePath, tokens, planHints) {
3146
+ const lower = filePath.toLowerCase();
3147
+ const reasons = [
3148
+ planHints.has(filePath) ? "named by the approved plan" : undefined,
3149
+ tokens.filter((token) => lower.includes(token)).slice(0, 4).length
3150
+ ? `matches task token(s): ${tokens.filter((token) => lower.includes(token)).slice(0, 4).join(", ")}`
3151
+ : undefined,
3152
+ /(test|spec|__tests__|tests)\b/u.test(lower) ? "is a related validation file" : undefined,
3153
+ /(package\.json|tsconfig|vite|jest|vitest|pytest|gradle|pom\.xml|go\.mod|cargo\.toml)/u.test(lower) ? "contains project or test configuration" : undefined,
3154
+ /(readme|codeowners|agents\.md)/u.test(lower) ? "contains repository guidance" : undefined,
3155
+ /\.(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
3156
+ ].filter((reason) => Boolean(reason));
3157
+ return reasons.length ? reasons.join("; ") : "ranked from repository metadata and work item text";
3158
+ }
2778
3159
  function buildStructuredPatchPrompt(session, workspacePath, contextFiles) {
2779
3160
  const plan = session.plan;
2780
3161
  const context = contextFiles.map((file) => [
2781
3162
  `FILE: ${file.path}${file.truncated ? " (truncated)" : ""}`,
3163
+ `RANK: ${file.score}`,
3164
+ `WHY_INCLUDED: ${file.reason}`,
2782
3165
  "```",
2783
3166
  file.content,
2784
3167
  "```"
2785
3168
  ].join("\n")).join("\n\n");
2786
3169
  const failedTestContext = getFailedTestContextAfterLatestPatch(session);
3170
+ const missingRequirementSignals = Array.from(new Set([
3171
+ ...detectMissingRequirementSignals(session.workItem),
3172
+ ...(plan?.missingInfo ?? [])
3173
+ ])).slice(0, 8);
3174
+ const workspace = session.workspaceCandidate?.workspace;
2787
3175
  return [
2788
3176
  "You are OpenPome's implementation engine.",
3177
+ failedTestContext.length
3178
+ ? "This is a retry after approved validation failed. Repair only the failure using the evidence below."
3179
+ : "This is the first implementation patch for the approved plan.",
2789
3180
  "Return only compact JSON. Do not include markdown fences outside JSON.",
2790
3181
  "Only propose a minimal safe file patch for the approved work item.",
2791
3182
  "Do not include secrets, credentials, generated dependency folders, lockfile rewrites, or unrelated refactors.",
2792
3183
  "Use full replacement file content for each proposed file.",
3184
+ "If requirements are unclear, prefer a small diagnostic or guardrail change over a speculative broad rewrite.",
3185
+ "Keep existing style, imports, formatting, and public contracts unless the work item clearly requires a change.",
2793
3186
  "Allowed JSON shape:",
2794
3187
  "{\"summary\":\"...\",\"files\":[{\"path\":\"relative/path\",\"action\":\"create|update\",\"content\":\"full file content\"}],\"risks\":[\"...\"]}",
2795
3188
  "",
@@ -2802,7 +3195,11 @@ function buildStructuredPatchPrompt(session, workspacePath, contextFiles) {
2802
3195
  session.workItem.priority ? `- Priority: ${session.workItem.priority}` : undefined,
2803
3196
  session.workItem.labels?.length ? `- Labels: ${session.workItem.labels.join(", ")}` : undefined,
2804
3197
  session.workItem.components?.length ? `- Components: ${session.workItem.components.join(", ")}` : undefined,
3198
+ session.workItem.links?.length ? `- Links: ${session.workItem.links.map((link) => `${link.kind}:${link.title ?? link.url}`).join("; ")}` : undefined,
2805
3199
  "",
3200
+ missingRequirementSignals.length ? "Known missing or unclear requirements:" : undefined,
3201
+ ...missingRequirementSignals.map((signal) => `- ${signal}`),
3202
+ missingRequirementSignals.length ? "" : undefined,
2806
3203
  "Approved plan:",
2807
3204
  plan?.summary ? `- Summary: ${plan.summary}` : "- Summary: unavailable",
2808
3205
  ...(plan?.steps.map((step) => `- ${step.title}${step.detail ? `: ${step.detail}` : ""}`) ?? []),
@@ -2813,7 +3210,13 @@ function buildStructuredPatchPrompt(session, workspacePath, contextFiles) {
2813
3210
  failedTestContext.length ? "" : undefined,
2814
3211
  "Workspace:",
2815
3212
  `- Path: ${workspacePath}`,
2816
- session.workspaceCandidate?.workspace.name ? `- Name: ${session.workspaceCandidate.workspace.name}` : undefined,
3213
+ workspace?.name ? `- Name: ${workspace.name}` : undefined,
3214
+ workspace?.currentBranch ? `- Current branch: ${workspace.currentBranch}` : undefined,
3215
+ workspace?.packageNames?.length ? `- Packages: ${workspace.packageNames.slice(0, 8).join(", ")}` : undefined,
3216
+ workspace?.readmeKeywords?.length ? `- README signals: ${workspace.readmeKeywords.slice(0, 12).join(", ")}` : undefined,
3217
+ workspace?.codeownersKeywords?.length ? `- Ownership signals: ${workspace.codeownersKeywords.slice(0, 12).join(", ")}` : undefined,
3218
+ workspace?.recentBranches?.length ? `- Recent branches: ${workspace.recentBranches.slice(0, 8).join(", ")}` : undefined,
3219
+ workspace?.recentCommitRefs?.length ? `- Recent work refs: ${workspace.recentCommitRefs.slice(0, 12).join(", ")}` : undefined,
2817
3220
  "",
2818
3221
  "Readable context files:",
2819
3222
  context || "No source files were safely included. You may propose small new files only if the task clearly asks for them."
@@ -2860,7 +3263,7 @@ function parseAIPatchProposal(value, session, provider, workspacePath, createdAt
2860
3263
  const relativePath = normalizeWorkspaceRelativePath(workspacePath, maybe.path, "throw");
2861
3264
  const action = maybe.action === "create" ? "create" : "update";
2862
3265
  const content = maybe.content;
2863
- if (!relativePath || isSensitiveWorkspacePath(relativePath) || content.includes("\u0000")) {
3266
+ if (!relativePath || isSensitiveWorkspacePath(relativePath) || content.includes("\u0000") || containsSensitiveContent(content)) {
2864
3267
  return undefined;
2865
3268
  }
2866
3269
  if (Buffer.byteLength(content, "utf8") > maxPatchProposalBytesPerFile) {
@@ -2908,6 +3311,9 @@ function isSensitiveWorkspacePath(filePath) {
2908
3311
  }
2909
3312
  return sensitivePathFragments.some((fragment) => lower === fragment || lower.includes(`/${fragment}`) || lower.endsWith(fragment));
2910
3313
  }
3314
+ function containsSensitiveContent(content) {
3315
+ return sensitiveContentPatterns.some((pattern) => pattern.test(content));
3316
+ }
2911
3317
  async function applyPatchFiles(workspacePath, files) {
2912
3318
  for (const file of files) {
2913
3319
  const relativePath = normalizeWorkspaceRelativePath(workspacePath, file.path, "throw");
@@ -2919,7 +3325,7 @@ async function applyPatchFiles(workspacePath, files) {
2919
3325
  await writeFile(absolutePath, file.content, "utf8");
2920
3326
  }
2921
3327
  }
2922
- async function discoverTestCommandCandidates(workspacePath) {
3328
+ async function discoverTestCommandCandidates(workspacePath, session) {
2923
3329
  const scripts = await readPackageScripts(join(workspacePath, "package.json"));
2924
3330
  const packageManager = detectPackageManager(workspacePath);
2925
3331
  const candidates = [];
@@ -2935,6 +3341,17 @@ async function discoverTestCommandCandidates(workspacePath) {
2935
3341
  cwd: workspacePath
2936
3342
  });
2937
3343
  }
3344
+ const relatedTestFiles = session ? await findRelatedTestFiles(workspacePath, session) : [];
3345
+ const testScript = scripts["test"] ? buildPackageScriptCommand(packageManager, "test") : undefined;
3346
+ for (const testFile of relatedTestFiles.slice(0, 5)) {
3347
+ candidates.push({
3348
+ id: `related_${createHash("sha256").update(testFile).digest("hex").slice(0, 8)}`,
3349
+ command: testScript ? `${testScript} -- ${quoteShellArg(testFile)}` : buildLanguageSpecificTestCommand(packageManager, testFile),
3350
+ source: "related_file",
3351
+ reason: `Related test file matched likely impacted work: ${testFile}.`,
3352
+ cwd: workspacePath
3353
+ });
3354
+ }
2938
3355
  if (candidates.length > 0) {
2939
3356
  return candidates;
2940
3357
  }
@@ -2951,6 +3368,74 @@ async function discoverTestCommandCandidates(workspacePath) {
2951
3368
  }
2952
3369
  return getFallbackTestCommandCandidates(workspacePath);
2953
3370
  }
3371
+ async function findRelatedTestFiles(workspacePath, session) {
3372
+ let trackedFiles = [];
3373
+ try {
3374
+ trackedFiles = await listTrackedWorkspaceFiles(workspacePath);
3375
+ }
3376
+ catch {
3377
+ return [];
3378
+ }
3379
+ const impactHints = new Set([
3380
+ ...(session.plan?.filesLikelyChanged ?? [])
3381
+ .map((filePath) => normalizeWorkspaceRelativePath(workspacePath, filePath, "skip"))
3382
+ .filter((filePath) => Boolean(filePath))
3383
+ .flatMap((filePath) => [filePath, basename(filePath).replace(/\.[^.]+$/u, "")]),
3384
+ ...tokenizePatchSearchText([
3385
+ session.workItem.key,
3386
+ session.workItem.title,
3387
+ session.workItem.description,
3388
+ ...(session.workItem.labels ?? []),
3389
+ ...(session.workItem.components ?? []),
3390
+ session.plan?.summary
3391
+ ].filter((value) => Boolean(value)).join(" "))
3392
+ ]);
3393
+ return trackedFiles
3394
+ .filter((filePath) => isTestLikeFile(filePath))
3395
+ .map((filePath) => ({
3396
+ filePath,
3397
+ score: scoreRelatedTestFile(filePath, impactHints)
3398
+ }))
3399
+ .filter((candidate) => candidate.score > 0)
3400
+ .sort((left, right) => right.score - left.score || left.filePath.localeCompare(right.filePath))
3401
+ .map((candidate) => candidate.filePath);
3402
+ }
3403
+ function isTestLikeFile(filePath) {
3404
+ return /(^|\/)(__tests__|tests?|specs?)\//u.test(filePath.toLowerCase())
3405
+ || /\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs|py|java|kt|go|rs|rb|php|cs)$/u.test(filePath.toLowerCase());
3406
+ }
3407
+ function scoreRelatedTestFile(filePath, impactHints) {
3408
+ const lower = filePath.toLowerCase();
3409
+ let score = 0;
3410
+ for (const hint of impactHints) {
3411
+ const normalized = hint.toLowerCase();
3412
+ if (normalized.length >= 3 && lower.includes(normalized)) {
3413
+ score += normalized.includes("/") ? 10 : 4;
3414
+ }
3415
+ }
3416
+ if (score === 0) {
3417
+ return 0;
3418
+ }
3419
+ if (/\.(test|spec)\./u.test(lower)) {
3420
+ score += 3;
3421
+ }
3422
+ if (/(__tests__|tests?)\//u.test(lower)) {
3423
+ score += 2;
3424
+ }
3425
+ if (/(snapshot|fixtures|mocks)\//u.test(lower)) {
3426
+ score -= 2;
3427
+ }
3428
+ return score;
3429
+ }
3430
+ function buildLanguageSpecificTestCommand(packageManager, testFile) {
3431
+ if (/\.(py)$/u.test(testFile)) {
3432
+ return `python -m pytest ${quoteShellArg(testFile)}`;
3433
+ }
3434
+ if (/\.(go)$/u.test(testFile)) {
3435
+ return "go test ./...";
3436
+ }
3437
+ return `${buildPackageScriptCommand(packageManager, "test")} -- ${quoteShellArg(testFile)}`;
3438
+ }
2954
3439
  function detectPackageManager(workspacePath) {
2955
3440
  if (existsSync(join(workspacePath, "pnpm-lock.yaml"))) {
2956
3441
  return "pnpm";
@@ -2975,6 +3460,9 @@ function buildPackageScriptCommand(packageManager, scriptName) {
2975
3460
  }
2976
3461
  return `pnpm ${scriptName}`;
2977
3462
  }
3463
+ function quoteShellArg(value) {
3464
+ return `'${value.replace(/'/gu, "'\\''")}'`;
3465
+ }
2978
3466
  function getFallbackTestCommandCandidates(cwd) {
2979
3467
  return [
2980
3468
  {
@@ -3075,6 +3563,14 @@ async function ensurePullRequestBranch(workspacePath, branch) {
3075
3563
  }
3076
3564
  return branch;
3077
3565
  }
3566
+ async function pushPullRequestBranch(workspacePath, branch) {
3567
+ try {
3568
+ await runGitStrict(workspacePath, ["push", "-u", "origin", branch]);
3569
+ }
3570
+ catch (error) {
3571
+ throw new Error(getGitHubCliGuidance("push pull request branch", error));
3572
+ }
3573
+ }
3078
3574
  async function createGitHubPullRequestWithCli(workspacePath, draft, branch, draftPr) {
3079
3575
  const ghArgs = [
3080
3576
  "pr",
@@ -3091,14 +3587,19 @@ async function createGitHubPullRequestWithCli(workspacePath, draft, branch, draf
3091
3587
  if (draftPr) {
3092
3588
  ghArgs.push("--draft");
3093
3589
  }
3094
- return (await execFileStrict("gh", ghArgs, workspacePath)).trim();
3590
+ try {
3591
+ return (await execFileStrict("gh", ghArgs, workspacePath)).trim();
3592
+ }
3593
+ catch (error) {
3594
+ throw new Error(getGitHubCliGuidance("create pull request", error));
3595
+ }
3095
3596
  }
3096
3597
  async function createGitHubPullRequestWithApi(accessToken, draft, branch, draftPr) {
3097
3598
  const repository = parseGitHubRepositoryCoordinates(draft.remoteUrl);
3098
3599
  if (!repository) {
3099
3600
  throw new Error("Unable to determine GitHub owner/repo from the workspace remote URL.");
3100
3601
  }
3101
- const response = await fetch(`${getGitHubApiBaseUrl()}/repos/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}/pulls`, {
3602
+ const response = await fetchGitHub(`${getGitHubApiBaseUrl()}/repos/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}/pulls`, {
3102
3603
  method: "POST",
3103
3604
  headers: {
3104
3605
  Accept: "application/vnd.github+json",
@@ -3113,14 +3614,22 @@ async function createGitHubPullRequestWithApi(accessToken, draft, branch, draftP
3113
3614
  base: draft.baseBranch,
3114
3615
  draft: draftPr
3115
3616
  })
3116
- });
3617
+ }, "create pull request");
3117
3618
  if (!response.ok) {
3118
- const body = await safeResponseText(response);
3119
- throw new Error(`GitHub PR creation failed: ${response.status} ${response.statusText}${body ? `: ${body}` : ""}`);
3619
+ throw new Error(await getGitHubStatusGuidance(response, "create pull request"));
3120
3620
  }
3121
3621
  const payload = (await response.json());
3122
3622
  return payload.html_url ?? `https://github.com/${repository.owner}/${repository.repo}/pull/${payload.number ?? ""}`;
3123
3623
  }
3624
+ function getGitHubCliGuidance(action, error) {
3625
+ const detail = summarizeExecError(error) ?? String(error);
3626
+ return [
3627
+ `GitHub ${action} failed.`,
3628
+ "Check repository write permission, organization SSO authorization, branch protection, remote URL, and whether your token/SSH key can push to origin.",
3629
+ "Run `pome auth github status` and `git remote -v` to verify the account and repository.",
3630
+ `Detail: ${detail}`
3631
+ ].join(" ");
3632
+ }
3124
3633
  function parseGitHubRepositoryCoordinates(remoteUrl) {
3125
3634
  if (!remoteUrl) {
3126
3635
  return undefined;
@@ -3169,6 +3678,33 @@ async function safeResponseText(response) {
3169
3678
  return "";
3170
3679
  }
3171
3680
  }
3681
+ function summarizeProviderBody(value) {
3682
+ const trimmed = value.trim();
3683
+ if (!trimmed) {
3684
+ return "";
3685
+ }
3686
+ try {
3687
+ const parsed = JSON.parse(trimmed);
3688
+ const errorObject = typeof parsed.error === "object" && parsed.error
3689
+ ? parsed.error
3690
+ : undefined;
3691
+ const messages = [
3692
+ typeof parsed.message === "string" ? parsed.message : undefined,
3693
+ typeof parsed.error === "string" ? parsed.error : undefined,
3694
+ typeof errorObject?.message === "string" ? errorObject.message : undefined,
3695
+ typeof errorObject?.type === "string" ? `type=${errorObject.type}` : undefined,
3696
+ typeof errorObject?.code === "string" ? `code=${errorObject.code}` : undefined,
3697
+ typeof parsed.documentation_url === "string" ? parsed.documentation_url : undefined
3698
+ ].filter((item) => Boolean(item));
3699
+ if (messages.length) {
3700
+ return messages.join("; ").slice(0, 500);
3701
+ }
3702
+ }
3703
+ catch {
3704
+ // Fall through to plain-text summary.
3705
+ }
3706
+ return trimmed.replace(/\s+/gu, " ").slice(0, 500);
3707
+ }
3172
3708
  async function hasWorkspaceChanges(workspacePath) {
3173
3709
  const output = await runGit(workspacePath, ["status", "--porcelain"]);
3174
3710
  return output.trim().length > 0;