@bridge_gpt/mcp-server 0.2.68 → 0.2.70

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.
Files changed (166) hide show
  1. package/README.md +226 -320
  2. package/build/agent-launchers/claude-executor-adapter.js +17 -4
  3. package/build/agents.generated.js +4 -4
  4. package/build/artifact-diff.js +110 -0
  5. package/build/bin-entrypoint.js +36 -0
  6. package/build/bridge-permissions.js +650 -98
  7. package/build/check-update-cli.js +74 -0
  8. package/build/claude-review-workflow-drift.js +3 -3
  9. package/build/claude-settings.js +84 -2
  10. package/build/cli-help.js +7 -1
  11. package/build/codex-bridge-profile.js +1 -1
  12. package/build/codex-home.js +9 -1
  13. package/build/codex-launcher-pin.js +598 -0
  14. package/build/commands.generated.js +8 -8
  15. package/build/conductor/bridge-api-client.js +598 -19
  16. package/build/conductor/bring-up-facts.js +9 -9
  17. package/build/conductor/doctor.js +466 -18
  18. package/build/conductor/epic-child-continuation.js +54 -3
  19. package/build/conductor/epic-implementer-cli.js +132 -34
  20. package/build/conductor/epic-runtime.js +2 -2
  21. package/build/conductor/epic-state.js +1 -1
  22. package/build/conductor/hook-runtime-qualification.js +1 -1
  23. package/build/conductor/install-doctor.js +4 -2
  24. package/build/conductor/launch-need-probes.js +337 -0
  25. package/build/conductor/local-merge.js +241 -56
  26. package/build/conductor/merge-credential.js +172 -0
  27. package/build/conductor/merge-execution-eligibility.js +1 -1
  28. package/build/conductor/merged-child-check.js +95 -0
  29. package/build/conductor/plan-trim.js +30 -0
  30. package/build/conductor/pr-ci-producer.js +1 -1
  31. package/build/conductor/readiness-cli.js +11 -6
  32. package/build/conductor/readiness-prerequisites.js +41 -0
  33. package/build/conductor/readiness-sections.js +345 -55
  34. package/build/conductor/readiness.js +99 -87
  35. package/build/conductor/recovery-cli.js +606 -21
  36. package/build/conductor/recovery-operations.js +306 -7
  37. package/build/conductor/repair-contract.js +47 -2
  38. package/build/conductor/repository-workflow-remedy.js +8 -6
  39. package/build/conductor/stored-merge-credential.js +76 -0
  40. package/build/conductor/stored-merge-token-probe.js +28 -0
  41. package/build/conductor/temporary-plan.js +50 -0
  42. package/build/conductor/wait-run-cli.js +759 -0
  43. package/build/conductor/worker-database-doctor-inspector.js +49 -0
  44. package/build/conductor-bin.js +2 -2
  45. package/build/conductor-claude-hook-bin.js +2 -2
  46. package/build/conductor-claude-hook-removed-stub-bin.js +2 -5
  47. package/build/conductor-removed-stub-bin.js +2 -5
  48. package/build/connect-github-api.js +33 -14
  49. package/build/credential-store.js +356 -13
  50. package/build/docs.generated.js +27 -14
  51. package/build/doctor.js +203 -38
  52. package/build/drive-epic-help.js +212 -0
  53. package/build/drive-epic.js +1840 -373
  54. package/build/epic-ci-workflow-drift.js +77 -0
  55. package/build/epic-ci-workflow-install.js +183 -0
  56. package/build/epic-ci-workflow.js +157 -0
  57. package/build/epic-contract-context.js +326 -0
  58. package/build/epic-implementer-bin.js +131 -120
  59. package/build/epic-implementer-claude-hook-bin.js +2 -2
  60. package/build/escalation-reachability.js +134 -0
  61. package/build/executor/cleanup-report.js +99 -0
  62. package/build/executor/cli.js +12 -1
  63. package/build/executor/completion-contract.js +68 -11
  64. package/build/executor/completion-supervision.js +99 -0
  65. package/build/executor/delivery-outcome.js +179 -0
  66. package/build/executor/deps.js +3 -0
  67. package/build/executor/env.js +71 -0
  68. package/build/executor/failure-reason-codes.js +126 -0
  69. package/build/executor/heartbeat.js +38 -0
  70. package/build/executor/http-client.js +115 -4
  71. package/build/executor/integration-job.js +298 -5
  72. package/build/executor/job-errors.js +36 -6
  73. package/build/executor/job-runner.js +720 -36
  74. package/build/executor/likely-tests.js +36 -2
  75. package/build/executor/merge-job.js +145 -2
  76. package/build/executor/observation.js +73 -4
  77. package/build/executor/permissions.js +193 -97
  78. package/build/executor/preflight.js +14 -3
  79. package/build/executor/process-containment.js +28 -2
  80. package/build/executor/prompt-spec.js +50 -5
  81. package/build/executor/rebase-finalization.js +38 -3
  82. package/build/executor/reconcile-overlap-job.js +35 -7
  83. package/build/executor/required-work-ledger.js +236 -81
  84. package/build/executor/resume-pre-spawn.js +38 -1
  85. package/build/executor/runner.js +91 -3
  86. package/build/executor/test-clock.js +8 -0
  87. package/build/executor/types.js +37 -2
  88. package/build/executor/worker-cli-launcher.js +259 -0
  89. package/build/executor/worker-command.js +41 -4
  90. package/build/executor/worker-database-target.js +91 -0
  91. package/build/executor/worker-database.js +908 -0
  92. package/build/executor/worker-finalization.js +106 -0
  93. package/build/executor/worker-guard-hook.js +203 -0
  94. package/build/executor/worker-resources.js +47 -0
  95. package/build/executor/worker-verification-guidance.js +15 -2
  96. package/build/executor/worktree.js +303 -15
  97. package/build/executor-launch.js +11 -1
  98. package/build/generated-assets-doctor.js +9 -1
  99. package/build/generated-assets-health.js +13 -4
  100. package/build/generated-assets-legacy.js +39 -0
  101. package/build/generated-assets-paths.js +12 -1
  102. package/build/generated-assets-repair.js +4 -1
  103. package/build/generated-assets-test-support.js +12 -0
  104. package/build/github-remote.js +33 -0
  105. package/build/github-sign-in.js +62 -0
  106. package/build/index.js +1214 -465
  107. package/build/init.js +39 -1
  108. package/build/install-bridge-conductor-gate.js +78 -0
  109. package/build/install-bridge-conductor-setup.js +413 -0
  110. package/build/install-bridge-conductor.js +1226 -267
  111. package/build/install-bridge.js +629 -350
  112. package/build/launcher-config-inspection.js +184 -19
  113. package/build/mcp-host-targets.js +4 -0
  114. package/build/mcp-identity.js +7 -0
  115. package/build/mcp-install-state.js +8 -2
  116. package/build/merge-pull-request.js +6 -0
  117. package/build/merge-workflow-install.js +763 -345
  118. package/build/phase-result-artifacts.js +75 -20
  119. package/build/pipeline-orchestrator.js +247 -4
  120. package/build/pipelines.generated.js +35 -35
  121. package/build/plan-discovery.js +15 -2
  122. package/build/plan-execution-ledger.js +306 -17
  123. package/build/plan-phase-routing.js +48 -13
  124. package/build/plane/cli.js +105 -51
  125. package/build/plane/defaults.js +3 -0
  126. package/build/plane/plane-freshness.js +294 -0
  127. package/build/plane/schema-compatibility.js +137 -0
  128. package/build/plane/shutdown-deps.js +38 -0
  129. package/build/plane/shutdown.js +83 -8
  130. package/build/plane/terminal-run-cleanup.js +303 -0
  131. package/build/printed-remedy-commands.js +26 -4
  132. package/build/readiness-check.js +6 -2
  133. package/build/readiness-prerequisites.generated.js +532 -0
  134. package/build/readme.generated.js +1 -1
  135. package/build/session-upgrade-notice.js +86 -0
  136. package/build/setup-epic.js +400 -30
  137. package/build/shared-resources.generated.js +5 -0
  138. package/build/start-tickets.js +43 -15
  139. package/build/test-command-detection.js +247 -0
  140. package/build/test-command-validation.js +73 -0
  141. package/build/testing-config-client.js +150 -0
  142. package/build/ticket-delivery-artifacts.js +258 -0
  143. package/build/ticket-delivery-enrollment.js +306 -0
  144. package/build/ticket-delivery.js +205 -0
  145. package/build/ticket-review-summary.js +14 -0
  146. package/build/tool-call-advisory.js +162 -0
  147. package/build/tool-surface-gating.js +28 -89
  148. package/build/update-check.js +97 -16
  149. package/build/update-status.js +61 -71
  150. package/build/upgrade-advice.js +15 -11
  151. package/build/upgrade-cli.js +360 -20
  152. package/build/v2-readiness-requirements.js +552 -0
  153. package/build/version.generated.js +3 -3
  154. package/build/worker-guard-hook-bin.js +2 -2
  155. package/build/worktree-core.js +22 -7
  156. package/docs/BRIDGE-DIRECTORY-README.md +48 -0
  157. package/docs/EPIC-IMPLEMENTER-INSTALL.md +12 -867
  158. package/docs/EPIC-IMPLEMENTER.md +104 -63
  159. package/docs/epic-implementer/getting-started.md +131 -0
  160. package/docs/epic-implementer/self-hosting.md +133 -0
  161. package/package.json +3 -3
  162. package/pipelines/idea-to-ticket.json +1 -1
  163. package/pipelines/implement-ticket.json +8 -8
  164. package/pipelines/review-ticket.json +2 -2
  165. package/design-assets/favicon/favicon-32x32.png +0 -0
  166. package/design-assets/just-logo-rough-draft.png +0 -0
@@ -0,0 +1,5 @@
1
+ // AUTO-GENERATED — do not edit manually. Regenerate with: npm run build
2
+ // This file is produced by scripts/bundle-pipelines.js
3
+ export const SHARED_RESOURCES = {
4
+ "epic-contract-resolution-duty.md": "# Epic-contract resolution duty (version 1)\n\nThis ticket is a confirmed child of an epic. Resolve every review finding — Confirmed Improvements, Needs Scrutiny, Open Questions, and every clarifying answer — against the epic's current contracts before you finalize it. This duty adds evidence to your decision. It does not replace the review's other rules, and it does not tell you to accept or reject critique by default.\n\n## 1. What counts as an obligation\n\n- The **current text** of the parent epic and of each sibling ticket is the requirement source. A normative statement there (\"must\", \"never\", \"owns\", \"exposes\", \"no migration\", an acceptance criterion) is a mandatory obligation.\n- **Inspected code** at `fresh_base_root` is feasibility evidence: it shows what exists and what a change would touch. Code does not create an obligation, and a changed file is not a changed scope.\n- A stale attachment, an old review document, or an earlier plan never overrides current ticket text. If you already met such historical material and it disagrees with current text, say so in one line, decide from current text, and name the ticket that owns the disputed requirement. A discrepancy alone is never a blocker.\n\n## 2. Choose relevant sources, not every source\n\n- Start from the parent requirements and the sibling inventory summaries in the context response.\n- Request a sibling's text (`sibling_keys`) only when a finding, the parent, a summary, an explicit reference, or an inspected interface ties it to the change: it produces something this ticket consumes, consumes something this ticket produces, or owns a behavior the finding would change.\n- Leave irrelevant siblings unhydrated. Never claim anything about a sibling whose text you did not receive.\n- A completed sibling's contract still binds. Its work has shipped, so the obligation is now also code.\n\n## 3. Resolve each finding\n\nFor each finding, check the proposed change's effect on every relevant producer or consumer obligation. Then choose one:\n\n- **Accept** it when it is a beneficial, in-scope fix that keeps every cited obligation. A finding is not suspect because a critic raised it.\n- **Adapt** it when its goal is sound but its form breaks an obligation, and a compatible form exists. Record the adapted direction.\n- **Reject** it when it would break an obligation and no compatible form serves its goal.\n- **Select a supported alternative** when the ticket, a sibling, or the code already offers a compatible way to meet the goal.\n\nRecord for each decision: the contract source (ticket key and `content_sha256`), the obligation in a short quote or paraphrase, the effect of the change on it, and the inspected code location (`file:symbol` at `base_sha`).\n\n## 4. Report coverage truthfully\n\n- Cite only sources whose `outcome` is `fetched` or `partial`. For `partial`, cite only the returned range.\n- When the response lists gaps — `missing`, `failed`, `unhydrated`, an incomplete inventory, a reached bound — disclose them in one plain line and keep resolving with the evidence you have. Missing context is a limitation, not a conflict and not a reason to stop.\n- Never write that a contract was checked when its text was not returned.\n\n## 5. Before final capture, revalidate what you relied on\n\nPass each cited source's `ticket_key` and `content_sha256` in `revalidate`. Reassess only the decisions whose sources came back `changed`. If revalidation is `unavailable`, disclose that and keep the earlier evidence; do not claim the source is unchanged.\n\n## 6. The only stop: mandatory incompatibility\n\nStop automatic finalization **only** when all of these hold:\n\n1. Two or more cited, current, mandatory obligations cannot all hold at once.\n2. You tried a supported interpretation of each, rejecting or adapting the suggestion, and every compatible alternative you could find.\n3. No decision within this ticket's scope can satisfy them all without abandoning an explicit obligation.\n\nAmbiguity, disagreement between reviewers, several valid options, optional improvements, unavailable context, and imperfect documentation never meet this threshold. Resolve those automatically and record your choice.\n\nWhen the threshold is met, set the finding's status to `mandatory_incompatibility` and record: the exact conflicting obligations with their sources, every alternative you attempted and why it failed, and the one decision you recommend (which obligation should change, and who owns it). Do not record that finding as accepted or dismissed, do not write a binding instruction that claims every obligation holds, and do not report the review as finalized. Waiving another ticket's obligation is that ticket owner's decision; route it to them.\n"
5
+ };
@@ -63,6 +63,8 @@ import path from "path";
63
63
  import { fileURLToPath } from "node:url";
64
64
  import { validateHttpBaseUrl } from "./base-url.js";
65
65
  import { VERSION } from "./version.generated.js";
66
+ import { getNoticeDecision } from "./session-upgrade-notice.js";
67
+ import { formatUpdateAdvice } from "./upgrade-advice.js";
66
68
  import { resolveBapiCredentials, getPrimaryCredentialStorePath, } from "./credential-store.js";
67
69
  import { LEGACY_SERVER_NAMES, MCP_PACKAGE_NAME, MCP_SERVER_NAME } from "./mcp-identity.js";
68
70
  // BAPI-941: the shared drift diagnostic formatter. The leaf drift module imports
@@ -199,6 +201,7 @@ export function parseStartTicketsArgs(argv) {
199
201
  let hostPlatform;
200
202
  let baseBranch = "main";
201
203
  let conductorEnabled = false;
204
+ let verificationScope;
202
205
  // BAPI-948: `--guard-stale-branch` (and `--conductor`, which implies it) opt the
203
206
  // packaged CLI into the F7 stale-branch guard. Tracked separately from
204
207
  // `conductorEnabled` so the pilot can take the safety check WITHOUT taking the
@@ -221,6 +224,13 @@ export function parseStartTicketsArgs(argv) {
221
224
  const keys = [];
222
225
  for (let i = 0; i < argv.length; i++) {
223
226
  const arg = argv[i];
227
+ if (arg === "--verification-scope" || arg.startsWith("--verification-scope=")) {
228
+ const value = arg.startsWith("--verification-scope=") ? arg.slice("--verification-scope=".length) : argv[++i];
229
+ if (value !== "full" && value !== "targeted")
230
+ return { status: "error", message: "--verification-scope requires full or targeted" };
231
+ verificationScope = value;
232
+ continue;
233
+ }
224
234
  const takeValue = () => {
225
235
  // The `--flag=value` form is handled inline by callers; this reads the
226
236
  // separate next token for the `--flag value` form.
@@ -552,6 +562,7 @@ export function parseStartTicketsArgs(argv) {
552
562
  agentName: resolvedAgentName,
553
563
  baseBranch,
554
564
  conductorEnabled,
565
+ ...(verificationScope ? { verificationScope } : {}),
555
566
  workflow,
556
567
  reviewRounds,
557
568
  // BAPI-948: include `guardStaleWorktree` ONLY when the run asked for the F7
@@ -622,7 +633,7 @@ resumeMode = false, workflow = "implement", reviewRounds,
622
633
  // command.
623
634
  baseBranch, bridgeApiBaseUrlOverride,
624
635
  // BAPI-1365: a recognized operator `--tier`, forwarded to the review prompt.
625
- manualTier) {
636
+ manualTier, verificationScope) {
626
637
  if (!isSupportedStartTicketsPlatform(deps.platform)) {
627
638
  return { ok: false, error: unsupportedPlatformMessage(deps.platform) };
628
639
  }
@@ -643,7 +654,7 @@ manualTier) {
643
654
  // falls back to the basename-derived repo name (the 403 root cause), and
644
655
  // (BAPI-586/BAPI-801) the run base so the spawned worker opens its PR
645
656
  // against it. The base assignment stays outermost.
646
- buildAgentShellCommand: (key, worktreePath, modelAlias, launchArgs) => prependBaseBranchEnvAssignment(prependRepoNameEnvAssignment(buildAgentShellCommand(agent, key, worktreePath, platform, autoApprove, modelAlias, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, launchArgs, bridgeApiBaseUrl, manualTier), repoName, platform), prBaseBranch, platform),
657
+ buildAgentShellCommand: (key, worktreePath, modelAlias, launchArgs) => prependBaseBranchEnvAssignment(prependRepoNameEnvAssignment(buildAgentShellCommand(agent, key, worktreePath, platform, autoApprove, modelAlias, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, launchArgs, bridgeApiBaseUrl, manualTier, verificationScope), repoName, platform), prBaseBranch, platform),
647
658
  spawnTerminalTab: deps.spawnTerminalTab,
648
659
  },
649
660
  };
@@ -1384,7 +1395,9 @@ export async function resumeWorktrees(deps, options) {
1384
1395
  * Single line (no newlines, no `;`, no quotes/apostrophes) so it stays safe when
1385
1396
  * single-quoted into a shell command and embedded in terminal-launcher args.
1386
1397
  */
1387
- export function buildResumeModeRemediationFinalizeInstruction() {
1398
+ export function buildResumeModeRemediationFinalizeInstruction(scope = "full") {
1399
+ if (scope === "targeted")
1400
+ return "Explicit epic targeted verification scope: run focused repair checks and mandatory local obligations once at the final artifact. Defer only server-approved CI coverage joins to named required checks. Missing coverage stays local and actual failures remain blockers. Do one renewal pass and one confirming readiness read. Never treat a focused subset as suite proof.";
1388
1401
  return ("Resume-mode remediation finalize: you were re-dispatched to fix a blocked ticket " +
1389
1402
  "(a merge conflict, a CI failure, or requested review changes). First rebase against " +
1390
1403
  "the current base branch and resolve the merge conflicts. A clean textual merge can " +
@@ -1446,7 +1459,9 @@ export function buildAgentPrompt(key, opts = {}) {
1446
1459
  // read $BAPI_BASE_BRANCH for them.
1447
1460
  parts.push(buildPrBaseContractLaunchInstruction());
1448
1461
  }
1449
- if (opts.resumeMode)
1462
+ if (opts.verificationScope === "targeted")
1463
+ parts.push(buildResumeModeRemediationFinalizeInstruction("targeted"));
1464
+ else if (opts.resumeMode)
1450
1465
  parts.push(buildResumeModeRemediationFinalizeInstruction());
1451
1466
  return parts.join(" ");
1452
1467
  }
@@ -1498,13 +1513,13 @@ export function buildAgentInvocation(agent, prompt, quote, modelAlias, launchArg
1498
1513
  }
1499
1514
  }
1500
1515
  /** POSIX agent shell command: `cd '<path>' && <agent> [--model '<alias>'] '<prompt>'`. */
1501
- export function buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false, workflow = "implement", reviewRounds, baseBranch, launchArgs = [], bridgeApiBaseUrl, manualTier) {
1502
- const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, agentName: agent.name, manualTier }), (p) => `'${shSquoteInner(p)}'`, modelAlias, agent.name === "cursor-agent" && autoApprove ? worktreePath : launchArgs);
1516
+ export function buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false, workflow = "implement", reviewRounds, baseBranch, launchArgs = [], bridgeApiBaseUrl, manualTier, verificationScope) {
1517
+ const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, agentName: agent.name, manualTier, verificationScope }), (p) => `'${shSquoteInner(p)}'`, modelAlias, agent.name === "cursor-agent" && autoApprove ? worktreePath : launchArgs);
1503
1518
  return `cd '${shSquoteInner(worktreePath)}' && ${prependBridgeApiBaseUrlEnvAssignment(invocation, bridgeApiBaseUrl, "darwin")}`;
1504
1519
  }
1505
1520
  /** PowerShell agent shell command: `Set-Location -LiteralPath '<path>'; <agent> [--model '<alias>'] '<prompt>'`. */
1506
- export function buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false, workflow = "implement", reviewRounds, baseBranch, launchArgs = [], bridgeApiBaseUrl, manualTier) {
1507
- const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, agentName: agent.name, manualTier }), powershellSquote, modelAlias, agent.name === "cursor-agent" && autoApprove ? worktreePath : launchArgs);
1521
+ export function buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false, workflow = "implement", reviewRounds, baseBranch, launchArgs = [], bridgeApiBaseUrl, manualTier, verificationScope) {
1522
+ const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, agentName: agent.name, manualTier, verificationScope }), powershellSquote, modelAlias, agent.name === "cursor-agent" && autoApprove ? worktreePath : launchArgs);
1508
1523
  return `Set-Location -LiteralPath ${powershellSquote(worktreePath)}; ${prependBridgeApiBaseUrlEnvAssignment(invocation, bridgeApiBaseUrl, "win32")}`;
1509
1524
  }
1510
1525
  /**
@@ -1515,10 +1530,10 @@ export function buildPowerShellAgentShellCommand(agent, key, worktreePath, autoA
1515
1530
  * injected as `--model` at the spawn boundary. `conductorEnabled` appends the
1516
1531
  * BAPI-586 PR-base contract instruction to the prompt (opt-in via `--conductor`).
1517
1532
  */
1518
- export function buildAgentShellCommand(agent, key, worktreePath, platform = "darwin", autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false, workflow = "implement", reviewRounds, baseBranch, launchArgs = [], bridgeApiBaseUrl, manualTier) {
1533
+ export function buildAgentShellCommand(agent, key, worktreePath, platform = "darwin", autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false, workflow = "implement", reviewRounds, baseBranch, launchArgs = [], bridgeApiBaseUrl, manualTier, verificationScope) {
1519
1534
  if (platform === "win32")
1520
- return buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, launchArgs, bridgeApiBaseUrl, manualTier);
1521
- return buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, launchArgs, bridgeApiBaseUrl, manualTier);
1535
+ return buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, launchArgs, bridgeApiBaseUrl, manualTier, verificationScope);
1536
+ return buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, launchArgs, bridgeApiBaseUrl, manualTier, verificationScope);
1522
1537
  }
1523
1538
  /**
1524
1539
  * Build the shell command run inside a spawned tab/session for an ARBITRARY
@@ -2146,7 +2161,7 @@ export function buildDryRunResults(keys, overrides) {
2146
2161
  * PowerShell on Windows, `wt` + POSIX on macOS/Linux, and a non-throwing `wt` +
2147
2162
  * POSIX fallback for unsupported platforms.
2148
2163
  */
2149
- export function getDryRunPlatformDetails(agent, platform = process.platform, env = process.env, autoApprove = false, conductorEnabled = false, repoName = null, workflow = "implement", reviewRounds, baseBranch, manualTier) {
2164
+ export function getDryRunPlatformDetails(agent, platform = process.platform, env = process.env, autoApprove = false, conductorEnabled = false, repoName = null, workflow = "implement", reviewRounds, baseBranch, manualTier, verificationScope) {
2150
2165
  return {
2151
2166
  worktrunkBinary: resolveWorktrunkBinary(platform, env),
2152
2167
  // The builder accepts an optional resolved modelAlias; the dry-run caller
@@ -2198,7 +2213,7 @@ export function buildDryRunMcpProvisioningLines(worktreePath, platform = process
2198
2213
  * the secret-free MCP provisioning preview. Pure platform formatting only — no
2199
2214
  * preflight, no routing failures.
2200
2215
  */
2201
- export function buildDryRunDetailLines(agent, key, branch, platform = process.platform, env = process.env, baseBranch = "main", autoApprove = false, modelAlias = null, conductorEnabled = false, repoName = null, mcpServerInvocation, workflow = "implement", reviewRounds, manualTier) {
2216
+ export function buildDryRunDetailLines(agent, key, branch, platform = process.platform, env = process.env, baseBranch = "main", autoApprove = false, modelAlias = null, conductorEnabled = false, repoName = null, mcpServerInvocation, workflow = "implement", reviewRounds, manualTier, verificationScope) {
2202
2217
  const { worktrunkBinary, buildAgentShellCommand: build } = getDryRunPlatformDetails(agent, platform, env, autoApprove, conductorEnabled, repoName, workflow, reviewRounds, baseBranch, manualTier);
2203
2218
  const wtArgs = buildWtSwitchArgs(branch, false, baseBranch);
2204
2219
  const agentInvocation = build(key, "<worktree-path>", modelAlias);
@@ -3922,7 +3937,7 @@ export async function orchestrateStartTickets(deps, options, overrides = {}) {
3922
3937
  // epic.base_branch override applied above) so the spawned worker gets
3923
3938
  // BAPI_BASE_BRANCH — every conductor worker, and a plain worker whose base
3924
3939
  // is non-default.
3925
- options.baseBranch, bridgeApiBaseUrl, isModelTier(options.injectedTier) ? options.injectedTier : undefined);
3940
+ options.baseBranch, bridgeApiBaseUrl, isModelTier(options.injectedTier) ? options.injectedTier : undefined, options.verificationScope);
3926
3941
  if (!platformConfig.ok)
3927
3942
  return { ok: false, error: platformConfig.error };
3928
3943
  // BAPI-527: resolve the base start point new worktrees are cut from BEFORE any
@@ -4150,6 +4165,19 @@ export async function runStartTicketsCli(argv, overrides = {}) {
4150
4165
  const options = parsed.options;
4151
4166
  const rawDeps = overrides.deps ?? createDefaultStartTicketsDeps();
4152
4167
  const orchestrate = overrides.orchestrate ?? orchestrateStartTickets;
4168
+ // BAPI-1446: one cache-only upgrade reminder per run, on the existing stderr
4169
+ // sink. The decision is evaluated over the EFFECTIVE environment, so a worker's
4170
+ // markers (set when this CLI is itself run by an unattended process) keep it
4171
+ // silent. It reads the local cache only, never the network, and any failure is
4172
+ // contained: it never changes command execution or the exit status.
4173
+ try {
4174
+ const decision = (overrides.upgradeNotice ?? ((env) => getNoticeDecision(env, VERSION)))(rawDeps.env);
4175
+ if (decision.show)
4176
+ errorLog(formatUpdateAdvice(decision.current, decision.latest));
4177
+ }
4178
+ catch {
4179
+ // Advisory only.
4180
+ }
4153
4181
  // The parser validates `--agent`, so this resolves to a real spec; the guard
4154
4182
  // is defensive only.
4155
4183
  const agent = resolveAgentSpec(options.agentName);
@@ -4192,7 +4220,7 @@ export async function runStartTicketsCli(argv, overrides = {}) {
4192
4220
  const branch = resolveBranchForTicket(key, options.branchOverrides);
4193
4221
  const routedRow = routedByKey.get(key);
4194
4222
  const modelAlias = routedRow?.modelAlias ?? null;
4195
- for (const line of buildDryRunDetailLines(agent, key, branch, deps.platform, deps.env, options.baseBranch, options.autoApprove, modelAlias, options.conductorEnabled ?? false, dryRunRepoName, dryRunMcpInvocation, options.workflow, options.reviewRounds, isModelTier(options.injectedTier) ? options.injectedTier : undefined)) {
4223
+ for (const line of buildDryRunDetailLines(agent, key, branch, deps.platform, deps.env, options.baseBranch, options.autoApprove, modelAlias, options.conductorEnabled ?? false, dryRunRepoName, dryRunMcpInvocation, options.workflow, options.reviewRounds, isModelTier(options.injectedTier) ? options.injectedTier : undefined, options.verificationScope)) {
4196
4224
  log(line);
4197
4225
  }
4198
4226
  log(`DRY-RUN: model routing: ${formatModelRoutingLine(routedRow ?? { key, branch, status: "dry-run" }, agent, { workflow: options.workflow })}`);
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Read-only test-command detection (BAPI-1460).
3
+ *
4
+ * Proposes the one runnable command a repository's CI workflow should run. It
5
+ * READS ONLY: the stored command, the learned `unit_testing_stack` prose, and a
6
+ * handful of repository files. It never runs the command, never runs a package
7
+ * manager, never imports Python, never evaluates a Make recipe, never prompts,
8
+ * and never writes. Its dependency surface has no subprocess runner, no prompt
9
+ * function, and no configuration writer, so it cannot.
10
+ *
11
+ * ## Order
12
+ *
13
+ * 1. A stored command (`GET /setup/testing-config`) wins, with no question.
14
+ * 2. Otherwise repository files are scanned, in a fixed order: JavaScript
15
+ * (`package.json` `scripts.test`, runner from the lockfile), Make (a real
16
+ * `test:` target), pytest (each listed marker), Go (`go.mod`), Rust
17
+ * (`Cargo.toml`).
18
+ * 3. When more than one stack is detected, the learned `unit_testing_stack`
19
+ * prose picks among them through a fixed vocabulary. It is a hint and is
20
+ * never interpreted as a command.
21
+ * 4. No candidate means exactly one required question, with no default.
22
+ *
23
+ * ## Caller contract (the setup command)
24
+ *
25
+ * - `question: "none"` — use `selected` as is.
26
+ * - `question: "accept_or_replace"` — ask once, offering `selected` as the
27
+ * default and the other candidates as alternatives.
28
+ * - `question: "required"` — ask once with no default. An empty answer is the
29
+ * named cancellation outcome {@link TEST_COMMAND_DETECTION_CANCELLED}; nothing
30
+ * is stored.
31
+ * - Every chosen or typed command is validated with `validateTestCommand` and
32
+ * saved through `PUT /setup/testing-config`. An invalid answer is explained
33
+ * with the validation message and is never stored.
34
+ *
35
+ * A detected command is an UNVERIFIED proposal: passing the syntax rules does
36
+ * not mean the command was run or that it works.
37
+ */
38
+ import { readStoredTestCommand, readUnitTestingStack, } from "./testing-config-client.js";
39
+ /** The stacks detection recognizes, in detection order. */
40
+ export const DETECTED_STACKS = ["javascript", "make", "python", "go", "rust"];
41
+ /** Named outcome for an empty answer to the required question. */
42
+ export const TEST_COMMAND_DETECTION_CANCELLED = "test_command_not_provided";
43
+ class DetectionReadError extends Error {
44
+ reason;
45
+ file;
46
+ constructor(reason, file) {
47
+ super(reason);
48
+ this.reason = reason;
49
+ this.file = file;
50
+ }
51
+ }
52
+ function isMissing(err) {
53
+ const code = typeof err === "object" && err !== null ? err.code : undefined;
54
+ return code === "ENOENT" || code === "ENOTDIR";
55
+ }
56
+ async function readOptional(deps, relPath) {
57
+ try {
58
+ return await deps.readFile(relPath);
59
+ }
60
+ catch (err) {
61
+ if (isMissing(err))
62
+ return null;
63
+ throw new DetectionReadError("repository_read_failed", relPath);
64
+ }
65
+ }
66
+ async function detectJavaScript(deps) {
67
+ const raw = await readOptional(deps, "package.json");
68
+ if (raw === null)
69
+ return null;
70
+ let parsed;
71
+ try {
72
+ parsed = JSON.parse(raw);
73
+ }
74
+ catch {
75
+ throw new DetectionReadError("malformed_repository_file", "package.json");
76
+ }
77
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
78
+ throw new DetectionReadError("malformed_repository_file", "package.json");
79
+ }
80
+ const scripts = parsed.scripts;
81
+ if (!scripts || typeof scripts !== "object" || Array.isArray(scripts))
82
+ return null;
83
+ const test = scripts.test;
84
+ if (typeof test !== "string" || test.trim().length === 0)
85
+ return null;
86
+ if ((await readOptional(deps, "pnpm-lock.yaml")) !== null) {
87
+ return { stack: "javascript", command: "pnpm test", provenance: "package.json + pnpm-lock.yaml", verified: false };
88
+ }
89
+ if ((await readOptional(deps, "yarn.lock")) !== null) {
90
+ return { stack: "javascript", command: "yarn test", provenance: "package.json + yarn.lock", verified: false };
91
+ }
92
+ return { stack: "javascript", command: "npm test", provenance: "package.json", verified: false };
93
+ }
94
+ /**
95
+ * Whether a Makefile declares a real `test` target: a rule line (not a recipe,
96
+ * comment, or variable assignment such as `test := x`) whose target list
97
+ * includes `test`. `.PHONY: test` alone does not count. Recipes are never read.
98
+ */
99
+ export function makefileHasTestTarget(text) {
100
+ for (const line of text.split(/\r\n|\n|\r/)) {
101
+ if (/^[\t #]/.test(line))
102
+ continue;
103
+ const rule = /^([^:=#]+?)\s*::?(?!=)/.exec(line);
104
+ if (!rule)
105
+ continue;
106
+ if (rule[1].trim().split(/\s+/).includes("test"))
107
+ return true;
108
+ }
109
+ return false;
110
+ }
111
+ async function detectMake(deps) {
112
+ // GNU make's own lookup order; only the first file present is read.
113
+ for (const name of ["GNUmakefile", "makefile", "Makefile"]) {
114
+ const raw = await readOptional(deps, name);
115
+ if (raw === null)
116
+ continue;
117
+ if (!makefileHasTestTarget(raw))
118
+ return null;
119
+ return { stack: "make", command: "make test", provenance: name, verified: false };
120
+ }
121
+ return null;
122
+ }
123
+ async function detectPython(deps) {
124
+ if ((await readOptional(deps, "pytest.ini")) !== null) {
125
+ return { stack: "python", command: "pytest", provenance: "pytest.ini", verified: false };
126
+ }
127
+ const pyproject = await readOptional(deps, "pyproject.toml");
128
+ if (pyproject !== null && /^\s*\[tool\.pytest\.ini_options\]\s*$/m.test(pyproject)) {
129
+ return { stack: "python", command: "pytest", provenance: "pyproject.toml [tool.pytest.ini_options]", verified: false };
130
+ }
131
+ const setupCfg = await readOptional(deps, "setup.cfg");
132
+ if (setupCfg !== null && /^\s*\[tool:pytest\]\s*$/m.test(setupCfg)) {
133
+ return { stack: "python", command: "pytest", provenance: "setup.cfg [tool:pytest]", verified: false };
134
+ }
135
+ if ((await readOptional(deps, "tests/conftest.py")) !== null) {
136
+ return { stack: "python", command: "pytest", provenance: "tests/conftest.py", verified: false };
137
+ }
138
+ return null;
139
+ }
140
+ async function detectGo(deps) {
141
+ if ((await readOptional(deps, "go.mod")) === null)
142
+ return null;
143
+ return { stack: "go", command: "go test ./...", provenance: "go.mod", verified: false };
144
+ }
145
+ async function detectRust(deps) {
146
+ if ((await readOptional(deps, "Cargo.toml")) === null)
147
+ return null;
148
+ return { stack: "rust", command: "cargo test", provenance: "Cargo.toml", verified: false };
149
+ }
150
+ /**
151
+ * Words in the learned `unit_testing_stack` prose that name each stack. Matched
152
+ * as whole words, case-insensitively. Order matters only for ties, which pick
153
+ * the earlier detected candidate.
154
+ */
155
+ export const STACK_HINT_VOCABULARY = {
156
+ javascript: ["javascript", "typescript", "node", "nodejs", "node.js", "jest", "vitest", "mocha", "npm", "pnpm", "yarn"],
157
+ // Bare "make" and "go" are ordinary English words, so only unambiguous forms count.
158
+ make: ["makefile", "make test", "gnu make"],
159
+ python: ["python", "pytest", "unittest"],
160
+ go: ["golang", "go test", "go.mod", "go modules"],
161
+ rust: ["rust", "cargo"],
162
+ };
163
+ function escapeRegExp(text) {
164
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
165
+ }
166
+ /** The stacks the prose names, using {@link STACK_HINT_VOCABULARY} only. */
167
+ export function stacksNamedByHint(hint) {
168
+ const named = new Set();
169
+ if (!hint)
170
+ return named;
171
+ for (const stack of DETECTED_STACKS) {
172
+ for (const word of STACK_HINT_VOCABULARY[stack]) {
173
+ const pattern = new RegExp(`(^|[^A-Za-z0-9_.])${escapeRegExp(word)}(?=$|[^A-Za-z0-9_])`, "i");
174
+ if (pattern.test(hint)) {
175
+ named.add(stack);
176
+ break;
177
+ }
178
+ }
179
+ }
180
+ return named;
181
+ }
182
+ /**
183
+ * Detect candidates from repository files, in the fixed stack order. Throws
184
+ * {@link DetectionReadError} for an unreadable or malformed file.
185
+ */
186
+ async function detectCandidates(deps) {
187
+ const found = [];
188
+ for (const detect of [detectJavaScript, detectMake, detectPython, detectGo, detectRust]) {
189
+ const candidate = await detect(deps);
190
+ if (candidate)
191
+ found.push(candidate);
192
+ }
193
+ return found;
194
+ }
195
+ /** Propose a test command. Read-only; never throws for an expected failure. */
196
+ export async function detectTestCommand(options, deps) {
197
+ if (!options.skipStoredRead) {
198
+ const stored = await readStoredTestCommand(deps.config);
199
+ if (!stored.ok)
200
+ return { status: "failed", reason: stored.reason };
201
+ if (stored.testCommand !== null) {
202
+ return {
203
+ status: "stored",
204
+ selected: stored.testCommand,
205
+ provenance: "stored configuration",
206
+ question: "none",
207
+ candidates: [],
208
+ };
209
+ }
210
+ }
211
+ let candidates;
212
+ try {
213
+ candidates = await detectCandidates(deps);
214
+ }
215
+ catch (err) {
216
+ if (err instanceof DetectionReadError)
217
+ return { status: "failed", reason: err.reason, file: err.file };
218
+ throw err;
219
+ }
220
+ if (candidates.length === 0) {
221
+ return { status: "none_detected", selected: null, question: "required", candidates };
222
+ }
223
+ if (candidates.length === 1) {
224
+ return {
225
+ status: "detected",
226
+ selected: candidates[0].command,
227
+ provenance: candidates[0].provenance,
228
+ question: "none",
229
+ selectedByStackHint: false,
230
+ candidates,
231
+ };
232
+ }
233
+ // The learned stack is advisory: an unreadable hint falls back to the first
234
+ // candidate rather than stopping detection.
235
+ const stackHint = await readUnitTestingStack(deps.config);
236
+ const named = stacksNamedByHint(stackHint.ok ? stackHint.value : null);
237
+ const hinted = candidates.find((c) => named.has(c.stack));
238
+ const chosen = hinted ?? candidates[0];
239
+ return {
240
+ status: "detected",
241
+ selected: chosen.command,
242
+ provenance: chosen.provenance,
243
+ question: "accept_or_replace",
244
+ selectedByStackHint: hinted !== undefined,
245
+ candidates,
246
+ };
247
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The stored-test-command rules, mirrored from Python (BAPI-1460).
3
+ *
4
+ * The canonical rules live in `api/library/config/test_command_validation.py`;
5
+ * the server re-checks every write. This mirror exists so detection can explain
6
+ * a bad answer before a round trip, and so the CI renderer can refuse to put a
7
+ * bad command into a workflow file at all. Keep the bound, the reason codes, and
8
+ * the blank-character set identical in both languages.
9
+ *
10
+ * Validation establishes permitted SYNTAX only — one line, no NUL, no GitHub
11
+ * Actions expression opener, a bounded length. It never establishes that the
12
+ * command runs. Error messages are fixed text and never include the command.
13
+ */
14
+ /** Upper bound on the command, counted in Unicode code points. */
15
+ export const TEST_COMMAND_MAX_CODE_POINTS = 500;
16
+ /**
17
+ * Characters that count as blank when deciding whether a command is empty.
18
+ *
19
+ * An explicit list rather than `String.prototype.trim()`, because Python's
20
+ * `str.strip()` and JavaScript's `trim()` recognize different characters.
21
+ */
22
+ export const TEST_COMMAND_BLANK_CHARACTERS = new Set(Array.from("\t\n\u000b\u000c\r \u0085\u00a0\u1680" +
23
+ "\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a" +
24
+ "\u2028\u2029\u202f\u205f\u3000\ufeff"));
25
+ /** Stable, non-revealing reason codes. Identical to the Python reason codes. */
26
+ export const TEST_COMMAND_VALIDATION_REASONS = [
27
+ "not_a_string",
28
+ "empty",
29
+ "multi_line",
30
+ "nul_byte",
31
+ "expression_opener",
32
+ "too_long",
33
+ ];
34
+ const MESSAGES = {
35
+ not_a_string: "test_command must be a string",
36
+ empty: "test_command must not be empty",
37
+ multi_line: "test_command must be a single line (no newline or carriage return)",
38
+ nul_byte: "test_command must not contain a NUL byte",
39
+ expression_opener: "test_command must not contain the GitHub Actions expression opener ${{",
40
+ too_long: `test_command must be at most ${TEST_COMMAND_MAX_CODE_POINTS} characters`,
41
+ };
42
+ /** A refused command. `reason` is a fixed code; the message never echoes input. */
43
+ export class TestCommandValidationError extends Error {
44
+ reason;
45
+ constructor(reason) {
46
+ super(MESSAGES[reason]);
47
+ this.name = "TestCommandValidationError";
48
+ this.reason = reason;
49
+ }
50
+ }
51
+ /**
52
+ * Return `command` unchanged when it is an acceptable test command; throw
53
+ * {@link TestCommandValidationError} otherwise. Checks run on the raw input.
54
+ */
55
+ export function validateTestCommand(command) {
56
+ if (typeof command !== "string")
57
+ throw new TestCommandValidationError("not_a_string");
58
+ if (command.includes("\n") || command.includes("\r")) {
59
+ throw new TestCommandValidationError("multi_line");
60
+ }
61
+ if (command.includes("\u0000"))
62
+ throw new TestCommandValidationError("nul_byte");
63
+ if (command.includes("${{"))
64
+ throw new TestCommandValidationError("expression_opener");
65
+ const codePoints = Array.from(command);
66
+ if (codePoints.length > TEST_COMMAND_MAX_CODE_POINTS) {
67
+ throw new TestCommandValidationError("too_long");
68
+ }
69
+ if (codePoints.every((ch) => TEST_COMMAND_BLANK_CHARACTERS.has(ch))) {
70
+ throw new TestCommandValidationError("empty");
71
+ }
72
+ return command;
73
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Typed client for the stored test command (BAPI-1460).
3
+ *
4
+ * Wraps `GET` / `PUT /setup/testing-config` and the existing
5
+ * `GET /jira/config-field/unit_testing_stack` reader. It reuses the shared
6
+ * {@link BridgeApiUrls} builders: `/setup/...` is a root-level mount, so it is
7
+ * built from `buildApiUrl`, while the config-field reader sits under the
8
+ * `/jira` mount and uses `buildGetUrl`.
9
+ *
10
+ * Every non-success is a typed outcome with a closed `reason`. No upstream
11
+ * error body, header, URL, API key, or command is ever placed in an outcome:
12
+ * a transport error's message can carry the request URL, and a server 422 body
13
+ * is fixed text the caller does not need verbatim.
14
+ */
15
+ import { TestCommandValidationError, validateTestCommand } from "./test-command-validation.js";
16
+ /** Root-level path of the testing-config handlers. */
17
+ export const TESTING_CONFIG_PATH = "/setup/testing-config";
18
+ /** The `/jira`-mounted config-field path for the learned unit-testing stack. */
19
+ export const UNIT_TESTING_STACK_FIELD_PATH = "/config-field/unit_testing_stack";
20
+ function failure(reason, status) {
21
+ return { ok: false, reason, status };
22
+ }
23
+ function statusFailure(status) {
24
+ if (status === 401)
25
+ return failure("unauthenticated", status);
26
+ if (status === 403)
27
+ return failure("forbidden", status);
28
+ if (status === 404)
29
+ return failure("not_found", status);
30
+ if (status === 409)
31
+ return failure("invalid_stored_command", status);
32
+ if (status === 422)
33
+ return failure("invalid_command", status);
34
+ return failure("backend_failure", status);
35
+ }
36
+ async function readJsonObject(resp) {
37
+ try {
38
+ const parsed = JSON.parse(await resp.text());
39
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
40
+ return parsed;
41
+ }
42
+ }
43
+ catch {
44
+ // fall through
45
+ }
46
+ return null;
47
+ }
48
+ /** Extract and re-validate a stored command; `undefined` means malformed. */
49
+ function storedCommand(body) {
50
+ const value = body.test_command;
51
+ if (value === null)
52
+ return null;
53
+ if (typeof value !== "string")
54
+ return undefined;
55
+ try {
56
+ return validateTestCommand(value);
57
+ }
58
+ catch (err) {
59
+ if (err instanceof TestCommandValidationError)
60
+ return undefined;
61
+ throw err;
62
+ }
63
+ }
64
+ function testingConfigUrl(deps, params) {
65
+ const url = new URL(deps.urls.buildApiUrl(TESTING_CONFIG_PATH));
66
+ for (const [key, value] of Object.entries(params ?? {}))
67
+ url.searchParams.set(key, value);
68
+ return url.toString();
69
+ }
70
+ /** `GET /setup/testing-config`. A stored value that fails validation is `invalid_stored_command`. */
71
+ export async function readStoredTestCommand(deps) {
72
+ let resp;
73
+ try {
74
+ resp = await deps.fetch(testingConfigUrl(deps, { repo_name: deps.repoName }), {
75
+ headers: await deps.getGetHeaders(),
76
+ });
77
+ }
78
+ catch {
79
+ return failure("transport_failure", 0);
80
+ }
81
+ if (!resp.ok)
82
+ return statusFailure(resp.status);
83
+ const body = await readJsonObject(resp);
84
+ if (!body)
85
+ return failure("malformed_response", resp.status);
86
+ const command = storedCommand(body);
87
+ if (command === undefined)
88
+ return failure("invalid_stored_command", resp.status);
89
+ return { ok: true, testCommand: command };
90
+ }
91
+ /**
92
+ * `PUT /setup/testing-config`. The command is validated locally first, so a
93
+ * refused value never leaves the process; the server re-validates regardless.
94
+ */
95
+ export async function writeStoredTestCommand(deps, testCommand) {
96
+ try {
97
+ validateTestCommand(testCommand);
98
+ }
99
+ catch (err) {
100
+ if (err instanceof TestCommandValidationError)
101
+ return failure("invalid_command", 0);
102
+ throw err;
103
+ }
104
+ let resp;
105
+ try {
106
+ resp = await deps.fetch(testingConfigUrl(deps), {
107
+ method: "PUT",
108
+ headers: await deps.getPostHeaders(),
109
+ body: JSON.stringify({ repo_name: deps.repoName, test_command: testCommand }),
110
+ });
111
+ }
112
+ catch {
113
+ return failure("transport_failure", 0);
114
+ }
115
+ if (!resp.ok)
116
+ return statusFailure(resp.status);
117
+ const body = await readJsonObject(resp);
118
+ if (!body || typeof body.unit_testing_instructions_updated !== "boolean") {
119
+ return failure("malformed_response", resp.status);
120
+ }
121
+ const command = storedCommand(body);
122
+ if (command === undefined)
123
+ return failure("invalid_stored_command", resp.status);
124
+ return {
125
+ ok: true,
126
+ testCommand: command,
127
+ unitTestingInstructionsUpdated: body.unit_testing_instructions_updated,
128
+ };
129
+ }
130
+ /** `GET /jira/config-field/unit_testing_stack`. Prose; a hint, never a command. */
131
+ export async function readUnitTestingStack(deps) {
132
+ let resp;
133
+ try {
134
+ resp = await deps.fetch(deps.urls.buildGetUrl(UNIT_TESTING_STACK_FIELD_PATH, { repo_name: deps.repoName }), { headers: await deps.getGetHeaders() });
135
+ }
136
+ catch {
137
+ return failure("transport_failure", 0);
138
+ }
139
+ if (!resp.ok)
140
+ return statusFailure(resp.status);
141
+ const body = await readJsonObject(resp);
142
+ if (!body)
143
+ return failure("malformed_response", resp.status);
144
+ const value = body.value;
145
+ if (value === null || value === undefined)
146
+ return { ok: true, value: null };
147
+ if (typeof value !== "string")
148
+ return failure("malformed_response", resp.status);
149
+ return { ok: true, value };
150
+ }