@bridge_gpt/mcp-server 0.2.49 → 0.2.50

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 (47) hide show
  1. package/README.md +24 -7
  2. package/build/base-ref.js +28 -3
  3. package/build/claude-review-workflow-drift-probe.js +130 -0
  4. package/build/claude-review-workflow-drift.js +173 -0
  5. package/build/claude-review-workflow.js +81 -16
  6. package/build/commands.generated.js +5 -5
  7. package/build/conductor/done-gate.js +25 -3
  8. package/build/conductor/install-doctor.js +65 -5
  9. package/build/conductor/latest-check-selector.js +170 -0
  10. package/build/conductor/local-merge.js +8 -6
  11. package/build/conductor-bin.js +1 -1
  12. package/build/{brainstorm-files.js → council-files.js} +15 -15
  13. package/build/decision-page-schema.js +1 -1
  14. package/build/docs.generated.js +1 -1
  15. package/build/doctor.js +162 -4
  16. package/build/executor/worktree.js +46 -1
  17. package/build/index.js +92 -51
  18. package/build/init.js +9 -2
  19. package/build/install-bridge.js +60 -2
  20. package/build/install-reexec.js +47 -9
  21. package/build/pipelines.generated.js +1 -1
  22. package/build/plane/cli.js +12 -2
  23. package/build/plane/manifest.js +25 -1
  24. package/build/plane/member-roster.js +61 -7
  25. package/build/plane/preflight.js +24 -9
  26. package/build/plane/supervisor.js +77 -5
  27. package/build/plane/types.js +23 -3
  28. package/build/readme.generated.js +1 -1
  29. package/build/run-unit-tests-launcher.js +2 -1
  30. package/build/stale-worktree-doctor.js +120 -0
  31. package/build/start-tickets-prereqs.js +70 -0
  32. package/build/start-tickets.js +91 -3
  33. package/build/version.generated.js +3 -2
  34. package/package.json +4 -2
  35. package/build/chain-orchestrator.js +0 -1457
  36. package/build/chain-utils.js +0 -68
  37. package/build/command-catalog.js +0 -376
  38. package/build/schedule-run.js +0 -1300
  39. package/build/schedule-store.js +0 -172
  40. package/build/scheduled-prompt.js +0 -115
  41. package/build/scheduler-backends/at-fallback.js +0 -139
  42. package/build/scheduler-backends/escaping.js +0 -143
  43. package/build/scheduler-backends/index.js +0 -72
  44. package/build/scheduler-backends/launchd.js +0 -225
  45. package/build/scheduler-backends/systemd-user.js +0 -250
  46. package/build/scheduler-backends/task-scheduler.js +0 -214
  47. package/build/scheduler-backends/types.js +0 -23
package/build/init.js CHANGED
@@ -15,7 +15,7 @@ import os from "os";
15
15
  import { COMMANDS } from "./commands.generated.js";
16
16
  import { AGENTS } from "./agents.generated.js";
17
17
  import { DOCS } from "./docs.generated.js";
18
- import { VERSION } from "./version.generated.js";
18
+ import { VERSION, LAUNCHER_ARGS } from "./version.generated.js";
19
19
  import { reconstructAgentMarkdown, translateAgentToCopilot } from "./agent-utils.js";
20
20
  import { validateRepoName, resolveRepoNameForProjectRoot } from "./bridge-config.js";
21
21
  import { ensureGitignored as ensureGitignoredShared } from "./git-ignore-utils.js";
@@ -58,9 +58,16 @@ export function buildBridgeApiEntry(cwd, metadata) {
58
58
  // BAPI-728: `repoName`/`baseUrl` are non-secret and always supplied by the
59
59
  // caller when it has already resolved them. `BAPI_API_KEY` remains absent in
60
60
  // EVERY branch — the server self-resolves it at runtime.
61
+ //
62
+ // BAPI-930: `LAUNCHER_ARGS` (generated by scripts/launcher-args.js ->
63
+ // scripts/bundle-version.js -> version.generated.ts) is the canonical launcher
64
+ // token sequence; `mcp_server/README.md`'s hand-configuration examples are
65
+ // rendered from the same generated constant by
66
+ // scripts/sync-readme-host-examples.js, so the two cannot drift. Spread into a
67
+ // fresh array so each registration gets its own independently mutable `args`.
61
68
  return {
62
69
  command: "npx",
63
- args: ["-y", "--prefer-offline", `${MCP_PACKAGE_NAME}@${VERSION}`, "serve"],
70
+ args: [...LAUNCHER_ARGS],
64
71
  env: {
65
72
  BAPI_BASE_URL: metadata?.baseUrl ?? DEFAULT_BRIDGE_BASE_URL,
66
73
  BAPI_REPO_NAME: metadata?.repoName ?? PLACEHOLDER_REPO_NAME,
@@ -165,6 +165,9 @@ import { runInstallBridgeConductorCli, } from "./install-bridge-conductor.js";
165
165
  import { runConductorInstallDoctor, CONDUCTOR_PROFILE_TOKEN, } from "./conductor/install-doctor.js";
166
166
  import { resolveConductorBridgeApiAccess, } from "./conductor/bridge-api-client.js";
167
167
  import { claudeReviewWorkflowPath, writeClaudeReviewWorkflow, } from "./claude-review-workflow.js";
168
+ // BAPI-941: read-only workflow-lineage probe, wired into the conductor install
169
+ // doctor's optional drift seam below.
170
+ import { probeClaudeReviewWorkflowDrift, resolveCurrentBranch, resolveRepositoryDefaultBranch, } from "./claude-review-workflow-drift-probe.js";
168
171
  import { runSetupEpicCli } from "./setup-epic.js";
169
172
  import { ensureGitignored as ensureGitignoredShared, } from "./git-ignore-utils.js";
170
173
  import { resolveStartTicketsRepoName } from "./start-tickets-repo.js";
@@ -927,6 +930,10 @@ export function sanitizePrewarmEnv(env) {
927
930
  delete sanitized.BAPI_API_KEY;
928
931
  delete sanitized.BAPI_INVITE;
929
932
  delete sanitized.BAPI_SIGNUP_EMAIL;
933
+ // BAPI-931: the provenance marker is a label, not a secret, but it describes a
934
+ // credential this probe deliberately no longer carries. Stripping it keeps the
935
+ // prewarm environment internally consistent rather than leaving an orphan.
936
+ delete sanitized[INSTALL_REEXEC_KEY_SOURCE_ENV];
930
937
  return sanitized;
931
938
  }
932
939
  function spawnPrewarmDefault(command, args, env) {
@@ -959,6 +966,10 @@ function spawnPrewarmDefault(command, args, env) {
959
966
  }
960
967
  /** Build default deps from the live process. */
961
968
  export function createDefaultInstallBridgeDeps() {
969
+ // BAPI-941: hoisted so the conductor install doctor's read-only
970
+ // workflow-lineage seam below can reuse the SAME command runner the rest of
971
+ // installation uses, rather than constructing a second one.
972
+ const startTicketsDeps = createDefaultStartTicketsDeps();
962
973
  const isTTY = Boolean(process.stdin.isTTY);
963
974
  // One production fetch, reused for both the `fetch` seam and the default
964
975
  // resolver, so a direct caller of this factory gets a resolver bound to the
@@ -1005,7 +1016,7 @@ export function createDefaultInstallBridgeDeps() {
1005
1016
  discardBootstrapPending: discardBootstrapPendingCredential,
1006
1017
  buildShellCommand: buildGenericAgentShellCommand,
1007
1018
  spawnTerminalTab: getDefaultSpawnTerminalTabForPlatform(process.platform),
1008
- startTicketsDeps: createDefaultStartTicketsDeps(),
1019
+ startTicketsDeps,
1009
1020
  log: (m) => console.log(m),
1010
1021
  errorLog: (m) => console.error(m),
1011
1022
  // Debug-only sink (BAPI-666): gated on BAPI_INSTALL_DEBUG so it is silent on a
@@ -1022,6 +1033,24 @@ export function createDefaultInstallBridgeDeps() {
1022
1033
  fetch: params.fetch,
1023
1034
  reviewPolicySource: params.reviewPolicySource,
1024
1035
  readWorkflowFile: () => params.readFile(claudeReviewWorkflowPath(params.cwd)),
1036
+ // BAPI-941: read-only workflow-lineage classification. Runs only after
1037
+ // the file is confirmed present, uses the same command runner the rest
1038
+ // of installation already has, and touches the network never — two
1039
+ // `git show` reads against the local object database. Any failure
1040
+ // resolves to `unverified` inside the probe, which leaves the doctor's
1041
+ // presence state exactly as it was.
1042
+ classifyWorkflowDrift: async () => {
1043
+ const probeDeps = {
1044
+ runCommand: startTicketsDeps.runCommand,
1045
+ cwd: params.cwd,
1046
+ };
1047
+ const baseRef = await resolveCurrentBranch(probeDeps);
1048
+ const defaultRef = await resolveRepositoryDefaultBranch(probeDeps);
1049
+ return probeClaudeReviewWorkflowDrift(probeDeps, {
1050
+ baseRef: baseRef ?? "",
1051
+ defaultRef,
1052
+ });
1053
+ },
1025
1054
  // BAPI-775: the SAME project root the rest of installation resolves
1026
1055
  // against, and the SAME read-only host-config inspector the
1027
1056
  // tool-visibility phase reports from — so the doctor's profile-token
@@ -1242,6 +1271,28 @@ function buildDoctorServiceStateInspector(deps) {
1242
1271
  return inspection.state;
1243
1272
  };
1244
1273
  }
1274
+ /**
1275
+ * Internal marker carrying credential PROVENANCE — never a credential VALUE —
1276
+ * across the `install` self-re-exec (BAPI-931).
1277
+ *
1278
+ * `prepareInstallReexecArguments` lifts `--api-key <value>` out of child argv into
1279
+ * `BAPI_API_KEY` so the secret stays out of `ps`. Without this marker the child
1280
+ * then resolves from its environment and attributes a 401 to "the BAPI_API_KEY
1281
+ * environment variable" — naming a variable the operator never set, at the exact
1282
+ * moment their install is failing. That regressed BAPI-668 R11 for every user
1283
+ * whose installed copy is behind `@latest`, which is the common case for the
1284
+ * documented unpinned `npx … install`.
1285
+ *
1286
+ * NOT a public interface. It is undocumented, set only by this package's own
1287
+ * re-exec alongside `BAPI_API_KEY`, read only by {@link resolveApiKey}, and
1288
+ * stripped by {@link sanitizePrewarmEnv}. Its vocabulary is CLOSED: only the
1289
+ * exact string `"flag"` relabels; anything else — absent, blank, misspelled, or
1290
+ * hand-set — falls through to the ordinary `env` attribution, so a malformed or
1291
+ * forged value can never produce a source that is not one of the three real ones.
1292
+ */
1293
+ export const INSTALL_REEXEC_KEY_SOURCE_ENV = "BAPI_INTERNAL_KEY_SOURCE";
1294
+ /** The only value {@link INSTALL_REEXEC_KEY_SOURCE_ENV} may carry. */
1295
+ export const INSTALL_REEXEC_KEY_SOURCE_FLAG = "flag";
1245
1296
  /**
1246
1297
  * Resolve the ordinary credential entry: `--api-key` → `BAPI_API_KEY` env →
1247
1298
  * interactive no-echo prompt. Fails (secret-free) when none is available and
@@ -1264,7 +1315,14 @@ export async function resolveApiKey(options, deps) {
1264
1315
  }
1265
1316
  const fromEnv = deps.env.BAPI_API_KEY;
1266
1317
  if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
1267
- return { ok: true, value: fromEnv.trim(), source: "env" };
1318
+ // BAPI-931: in a re-exec'd child the flag was lifted into this env var to keep
1319
+ // it out of `ps`, so "env" would name a variable the operator never set. The
1320
+ // marker restores the truth. Strict equality against the closed vocabulary —
1321
+ // any other value keeps today's `env` attribution byte-for-byte.
1322
+ const source = deps.env[INSTALL_REEXEC_KEY_SOURCE_ENV] === INSTALL_REEXEC_KEY_SOURCE_FLAG
1323
+ ? "flag"
1324
+ : "env";
1325
+ return { ok: true, value: fromEnv.trim(), source };
1268
1326
  }
1269
1327
  if (deps.isTTY && deps.promptSecret) {
1270
1328
  // BAPI-708 (A-3): the prompt no longer asks the user to pre-classify their own
@@ -34,12 +34,20 @@
34
34
  * the child down the bare-onboarding branch instead. The failure diagnostic is a
35
35
  * fixed string — it interpolates no argv, no environment value, and no exception
36
36
  * text.
37
+ *
38
+ * PROVENANCE SURVIVES THE HAND-OFF (BAPI-931). Lifting `--api-key` into the
39
+ * environment destroys the evidence of which input the operator actually used, so
40
+ * the child reported a 401 as coming from "the BAPI_API_KEY environment variable"
41
+ * — naming a variable nobody set. A separate `provenanceEnv` overlay carries the
42
+ * LABEL `flag` alongside the secret so the child can attribute the failure
43
+ * truthfully. It is a label, never a value, and it is kept out of `secretEnv`
44
+ * precisely so that overlay's "never logged" contract stays true of every member.
37
45
  */
38
46
  import { spawn } from "child_process";
39
47
  import { VERSION } from "./version.generated.js";
40
48
  import { isNewerVersion } from "./update-check.js";
41
49
  import { fetchLatestVersion } from "./cli-release.js";
42
- import { runInstallBridgeCli } from "./install-bridge.js";
50
+ import { runInstallBridgeCli, INSTALL_REEXEC_KEY_SOURCE_ENV, INSTALL_REEXEC_KEY_SOURCE_FLAG, } from "./install-bridge.js";
43
51
  import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
44
52
  /** The sentinel that marks an already-re-exec'd child. Matches `upgrade-cli.ts`. */
45
53
  export const INSTALL_REEXEC_SENTINEL = "--internal-reexec";
@@ -91,6 +99,7 @@ export function stripInternalReexecSentinels(argv) {
91
99
  export function prepareInstallReexecArguments(argv) {
92
100
  const forwardedArgs = [];
93
101
  const secretEnv = {};
102
+ const provenanceEnv = {};
94
103
  for (let i = 0; i < argv.length; i++) {
95
104
  const arg = argv[i];
96
105
  if (arg === "--api-key" || arg.startsWith("--api-key=")) {
@@ -109,6 +118,11 @@ export function prepareInstallReexecArguments(argv) {
109
118
  continue;
110
119
  }
111
120
  secretEnv.BAPI_API_KEY = value;
121
+ // BAPI-931: record that the FLAG was the real source. Only set here,
122
+ // beside the lift that destroys the argv evidence — never for --invite
123
+ // (no 401 attribution on that path) and never for a malformed --api-key
124
+ // (handed to the installer's parser above, so nothing was lifted).
125
+ provenanceEnv[INSTALL_REEXEC_KEY_SOURCE_ENV] = INSTALL_REEXEC_KEY_SOURCE_FLAG;
112
126
  if (consumedNext)
113
127
  i += 1;
114
128
  continue;
@@ -137,7 +151,7 @@ export function prepareInstallReexecArguments(argv) {
137
151
  }
138
152
  forwardedArgs.push(arg);
139
153
  }
140
- return { forwardedArgs, secretEnv };
154
+ return { forwardedArgs, secretEnv, provenanceEnv };
141
155
  }
142
156
  /**
143
157
  * The public `install` / legacy `install-bridge` entry point.
@@ -164,11 +178,33 @@ export async function runInstallBridgeWithLatestCli(argv, deps = {}) {
164
178
  const errorLog = deps.errorLog ?? ((message) => console.error(message));
165
179
  const localVersion = deps.localVersion ?? VERSION;
166
180
  const { args: cleanedArgs, sentinelPresent } = stripInternalReexecSentinels(argv);
167
- // Terminating condition, the nested-conductor bypass, and `--dry-run`
168
- // (BAPI-818, R2) all skip the registry entirely. A dry run's documented
169
- // contract is "no network" — a version-freshness lookup is still a network
170
- // call, so it cannot run first even though it is fail-open and unauthenticated.
171
- if (sentinelPresent || cleanedArgs[0] === "conductor" || cleanedArgs.includes("--dry-run")) {
181
+ // Terminating condition, the nested-conductor bypass, `--dry-run`
182
+ // (BAPI-818, R2), and help (BAPI-952) all skip the registry entirely. A dry
183
+ // run's documented contract is "no network" — a version-freshness lookup is
184
+ // still a network call, so it cannot run first even though it is fail-open
185
+ // and unauthenticated.
186
+ //
187
+ // HELP IS NOT AN INSTALL (BAPI-952). `-h`/`--help` short-circuits inside
188
+ // `runInstallBridgeCli` (`install-bridge.ts`, `parsed.status === "help"`)
189
+ // having initialized nothing, so a registry lookup on that path buys nothing
190
+ // and costs two live resources: an undici socket from `fetchLatestVersion`'s
191
+ // `fetch` and the timer behind its `AbortSignal.timeout`. `dispatchCliSubcommand`
192
+ // returns straight into `process.exit(cliExitCode)` in `index.ts`, so both were
193
+ // still open at teardown. On `windows-latest` that killed the process with
194
+ // `3221226505` (`0xC0000409`, STATUS_STACK_BUFFER_OVERRUN) AFTER the complete,
195
+ // correct help text had already been printed — the reported symptom exactly.
196
+ // It also made `install --help` need the network to answer, which no help
197
+ // screen should.
198
+ //
199
+ // The predicate MUST stay identical to `parseInstallBridgeArgs`'s own help
200
+ // detection (`argv.includes("-h") || argv.includes("--help")`). If this test
201
+ // were narrower the two would disagree and a genuine install could skip the
202
+ // freshness check; if it were wider, help would still pay for the lookup.
203
+ const isHelpInvocation = cleanedArgs.includes("-h") || cleanedArgs.includes("--help");
204
+ if (sentinelPresent ||
205
+ cleanedArgs[0] === "conductor" ||
206
+ cleanedArgs.includes("--dry-run") ||
207
+ isHelpInvocation) {
172
208
  return runLocal(cleanedArgs);
173
209
  }
174
210
  // Default the comparison target to the local version: an unusable registry
@@ -186,7 +222,7 @@ export async function runInstallBridgeWithLatestCli(argv, deps = {}) {
186
222
  if (!isNewerVersion(localVersion, latestVersion)) {
187
223
  return runLocal(cleanedArgs);
188
224
  }
189
- const { forwardedArgs, secretEnv } = prepareInstallReexecArguments(cleanedArgs);
225
+ const { forwardedArgs, secretEnv, provenanceEnv } = prepareInstallReexecArguments(cleanedArgs);
190
226
  const npxCmd = platform === "win32" ? "npx.cmd" : "npx";
191
227
  const childArgs = [
192
228
  "-y",
@@ -214,7 +250,9 @@ export async function runInstallBridgeWithLatestCli(argv, deps = {}) {
214
250
  stdio: "inherit",
215
251
  cwd,
216
252
  // Explicit CLI values win over an inherited value of the same key.
217
- env: { ...env, ...secretEnv },
253
+ // The provenance label rides alongside so the child can attribute a 401
254
+ // to the flag the operator actually used (BAPI-931).
255
+ env: { ...env, ...secretEnv, ...provenanceEnv },
218
256
  });
219
257
  }
220
258
  catch {
@@ -893,7 +893,7 @@ export const INSTRUCTIONS = {
893
893
  "learn-style-correctness.md": "## Objective\n\nExplore the codebase to identify correctness standards for style files, then draft the corresponding correctness standards document.\n\n## Target Type\n\n- **Type**: `style_correctness`\n- **Field name**: `style_correctness_standards`\n- **Scope**: Style files: CSS, SCSS, SASS, LESS, Styled Components, Tailwind configs.\n\n## Instructions\n\n### Phase 1 — Explore Correctness Patterns\n\nFocus on implementation correctness: how to write code that is correct, idiomatic, and robust within this project's conventions.\n\n1. **File Type Detection**: Search by filename pattern for files matching `**/*.css`, `**/*.scss`, `**/*.sass`, `**/*.less` (excluding `node_modules/`). If very few or no files exist, note this and draft minimal instructions.\n\n2. **Convention Analysis**: Read 3-5 representative style files to identify:\n - Structure patterns (imports, exports, class structure, function ordering)\n - Naming conventions (variables, functions, classes, files)\n - Framework conventions and idioms\n - Best practices followed\n - Issues and inconsistencies\n\n### Phase 2 — Draft\n\nDraft correctness standards as clear, actionable instructions for an AI code generation agent. Cover:\n- Code structure and organization requirements\n- Naming conventions to follow\n- Framework-specific patterns and idioms\n- Security requirements relevant to this code type\n- Performance considerations\n- Common mistakes to avoid\n- Guards against common AI weaknesses: duplicative code, verbose implementations, security vulnerabilities\n\nWrite the draft to `{docs_dir}/standards/style_correctness_standards.md`.\n\n## Length Budget\n\nThe platform ceiling for a configuration field is **40,000 characters**, and the server enforces it\nat upload time: a draft over the limit is rejected outright and ``style_correctness_standards`` is simply not learned. Bound\nthe draft here, at write time, rather than discovering the limit through a failed upload.\n\n1. When the draft is complete, measure its length in **characters** — not tokens, not words, not\n bytes.\n2. If it exceeds 40,000 characters, condense it: remove redundancy, collapse repetitive passages, and\n shorten or drop verbose code examples.\n3. Keep every required section listed above. Never meet the budget by deleting a required section,\n and never truncate the draft mid-sentence.\n4. Re-measure after condensing and repeat until the draft is at most 40,000 characters.\n\n## Return\n\nReturn a brief summary of what was learned about style-file correctness conventions (structure, naming, methodology), citing the key files inspected, and confirm the draft was written to `{docs_dir}/standards/style_correctness_standards.md`.\n\nAlso report the draft's final length in characters, the applicable maximum (40,000 characters),\nwhether the draft was condensed to meet that maximum (`condensed`: true/false), and — when it was\ncondensed — the reason it needed condensing.\n",
894
894
  "learn-template-correctness.md": "## Objective\n\nExplore the codebase to identify correctness standards for template files, then draft the corresponding correctness standards document.\n\n## Target Type\n\n- **Type**: `template_correctness`\n- **Field name**: `template_correctness_standards`\n- **Scope**: Template files: HTML, Jinja2, Handlebars, EJS, ERB, Blade, Pug, Twig.\n\n## Instructions\n\n### Phase 1 — Explore Correctness Patterns\n\nFocus on implementation correctness: how to write code that is correct, idiomatic, and robust within this project's conventions.\n\n1. **File Type Detection**: Search by filename pattern for files matching `**/*.html`, `**/*.jinja2`, `**/*.j2` in `templates/` and similar directories (excluding `node_modules/`). If very few or no files exist, note this and draft minimal instructions.\n\n2. **Convention Analysis**: Read 3-5 representative template files to identify:\n - Structure patterns (imports, exports, class structure, function ordering)\n - Naming conventions (variables, functions, classes, files)\n - Framework conventions and idioms\n - Best practices followed\n - Issues and inconsistencies\n\n### Phase 2 — Draft\n\nDraft correctness standards as clear, actionable instructions for an AI code generation agent. Cover:\n- Code structure and organization requirements\n- Naming conventions to follow\n- Framework-specific patterns and idioms\n- Security requirements relevant to this code type\n- Performance considerations\n- Common mistakes to avoid\n- Guards against common AI weaknesses: duplicative code, verbose implementations, security vulnerabilities\n\nWrite the draft to `{docs_dir}/standards/template_correctness_standards.md`.\n\n## Length Budget\n\nThe platform ceiling for a configuration field is **40,000 characters**, and the server enforces it\nat upload time: a draft over the limit is rejected outright and ``template_correctness_standards`` is simply not learned. Bound\nthe draft here, at write time, rather than discovering the limit through a failed upload.\n\n1. When the draft is complete, measure its length in **characters** — not tokens, not words, not\n bytes.\n2. If it exceeds 40,000 characters, condense it: remove redundancy, collapse repetitive passages, and\n shorten or drop verbose code examples.\n3. Keep every required section listed above. Never meet the budget by deleting a required section,\n and never truncate the draft mid-sentence.\n4. Re-measure after condensing and repeat until the draft is at most 40,000 characters.\n\n## Return\n\nReturn a brief summary of what was learned about template-file correctness conventions (structure, naming, framework idioms), citing the key files inspected, and confirm the draft was written to `{docs_dir}/standards/template_correctness_standards.md`.\n\nAlso report the draft's final length in characters, the applicable maximum (40,000 characters),\nwhether the draft was condensed to meet that maximum (`condensed`: true/false), and — when it was\ncondensed — the reason it needed condensing.\n",
895
895
  "learn-unit-testing.md": "## Objective\n\nExplore the codebase to identify the test runner, assertion library, mocking framework, and testing patterns, then draft `unit_testing_instructions` for the project config.\n\n## Instructions\n\n### Phase 1 — Explore Testing Infrastructure\n\n1. **Test Runner and Framework Detection**: Search for test runner configs (`pytest.ini`, `pyproject.toml` `[tool.pytest]` section, `jest.config.*`) and read `package.json` test scripts. Read the `tests/` directory structure.\n\n2. **Testing Patterns**: Read 3-5 representative test files in `tests/pytest/` to identify:\n - Assertion library and style (`assert`, `expect`, custom matchers)\n - Mocking framework (`unittest.mock`, `jest.mock`, `sinon`, etc.)\n - Fixture patterns (setup/teardown)\n - Test organization (by module, feature, layer)\n - Exemplary tests vs. weak tests\n\n3. **How to Run Tests**: Read `pyproject.toml`, `package.json`, and `Makefile` (if present) to determine exact commands for: full suite, single file, by name pattern, with verbose output.\n\n4. **Mocking vs. Fidelity**: Read test helper files in `tests/pytest/helpers/` to document how external APIs are mocked, whether integration tests exist alongside unit tests, and patterns for avoiding third-party calls in tests.\n\n### Phase 2 — Draft\n\nIf there is nothing repository-specific to cite, say that no repository evidence was found and write a short conservative standard; do not invent files, commands, or conventions.\n\nDraft `unit_testing_instructions` as clear, actionable instructions for an AI agent writing unit tests. Cover:\n- How to run tests (exact commands)\n- Which test framework and assertion library to use\n- How to mock external dependencies without calling third parties\n- How to structure test files and test functions\n- What constitutes a thorough test (not just happy path)\n- How to avoid shallow tests that pass but don't verify meaningful behavior\n- Guards against common AI weaknesses: tests that mock the thing being tested, trivially passing assertions, overly complex setup\n\nWrite the draft to `{docs_dir}/standards/unit_testing_instructions.md`.\n\n## Length Budget\n\nThe platform ceiling for a configuration field is **40,000 characters**, and the server enforces it\nat upload time: a draft over the limit is rejected outright and ``unit_testing_instructions`` is simply not learned. Bound\nthe draft here, at write time, rather than discovering the limit through a failed upload.\n\n1. When the draft is complete, measure its length in **characters** — not tokens, not words, not\n bytes.\n2. If it exceeds 40,000 characters, condense it: remove redundancy, collapse repetitive passages, and\n shorten or drop verbose code examples.\n3. Keep every required section listed above. Never meet the budget by deleting a required section,\n and never truncate the draft mid-sentence.\n4. Re-measure after condensing and repeat until the draft is at most 40,000 characters.\n\n## Return\n\nReturn a brief summary of what was learned about the project's unit testing setup (test runner, assertion library, mocking framework, run commands), citing the key files inspected, and confirm the draft was written to `{docs_dir}/standards/unit_testing_instructions.md`.\n\nAlso report the draft's final length in characters, the applicable maximum (40,000 characters),\nwhether the draft was condensed to meet that maximum (`condensed`: true/false), and — when it was\ncondensed — the reason it needed condensing.\n",
896
- "monitor-ci-checks.md": "Monitor CI checks for the most recent commit. The behavior is dispatched on the repo-specific `ci_followup_config` JSON value: `poll_only`, `fix_and_iterate`, or `custom`. Read this entire file once before doing anything, then follow only the matching branch.\n\n\n**Required-check source**: Both the `poll_only` (Step 5) and `fix_and_iterate` (Step 6) branches gate progression on the *required* check subset, not the aggregate `all_passed` flag. Each check returned by `resolve_ci_checks`/`poll_ci_checks` carries a `required` field (from GitHub Branch Protection, or an LLM classification fallback) — treat `required: false` as non-required (e.g. `pip-audit`) and a missing field or `required: true` as required. This is the tool-provided proxy for the Conductor done-gate's authoritative required-checks set (`mcp_server/src/conductor/done-gate.ts`); do not re-derive required/non-required status in prose.\n\n## Entry state and ownership boundary\n\n**Re-resolve the git context before anything else.** Run `git branch --show-current`\nand `git rev-parse HEAD` when this step begins. In the implement pipeline the pull\nrequest is opened *before* the bounded post-finalization verification phase, and\nthat phase may have pushed a correction on top of the commit the PR was originally\nopened at. The head you monitor must be the branch's current pushed head, not a SHA\ncarried over from PR creation.\n\n**Ownership is split, and the split matters.** The bounded verification phase\n(`verify-plan.md`) owns findings produced by its own local, touched-area commands.\nThis step owns everything subsequently reported by the authoritative `ci` and\n`code_review` gates — failing required checks and requested review changes. Do not\nre-adjudicate the other phase's findings, and do not assume a finding it reported\nhas been fixed unless a pushed commit shows it.\n\n**Do not substitute a broad local run for the authoritative checks.** Running the\nfull local suite here does not establish that CI passed; it duplicates the work the\n`conductor-ci` gate already performs on the pull request, and it is exactly the\nbudget sink this protocol was reordered to avoid. Use the structured CI failure\ndetail from `poll_ci_checks` to target a fix, and scope any local reproduction to\nthe failing area.\n\n**Keep every correction on the existing pull request branch**, and commit *and push*\neach correction before polling resumes. Polling always restarts against the new\npushed head — an unpushed correction is invisible to CI, to review, and to the\nreconciler.\n\nThroughout, report the check and review states you **observed**. Observing that a\ncheck is green is not the same as issuing a verdict: the done-gate evaluation is\nserver-side and the reconciler decides. This step never emits a control signal of\nits own.\n\n## Step 3 — Parse `ci_followup_config`\n\nLook at the response from the immediately preceding `config_field` call (the pipeline step that ran right before this one). The response envelope's `value` field is itself a JSON string and must be parsed again with `JSON.parse` (i.e., the `value` is double-encoded — the outer envelope is JSON, and the inner `value` is a JSON-encoded string of the actual config object).\n\nIf ANY of the following hold, log a warning and use the defaults `{\"strategy\":\"poll_only\",\"max_iterations\":1,\"max_minutes\":10}`:\n\n- The `config_field` response is missing or unavailable (e.g., the step warned-and-continued).\n- The response `value` is `null`.\n- Parsing `value` with `JSON.parse` fails (the persisted text is not valid JSON).\n- The parsed result is not a JSON object.\n- One or more of the required keys (`strategy`, `max_iterations`, `max_minutes`, `instructions`) is missing.\n- `strategy` is not one of `poll_only`, `fix_and_iterate`, or `custom`.\n\n## Step 4 — Dispatch on `strategy`\n\nRead this whole file once and then follow only the matching branch:\n\n- `poll_only` → follow Step 5.\n- `fix_and_iterate` → follow Step 6.\n- `custom` → follow Step 7.\n\nIf `strategy` is unrecognized, log a warning and fall through to Step 5 (`poll_only`).\n\n## Step 5 — `poll_only`\n\nPreserve the baseline polling behavior. The configured `max_minutes` is IGNORED in this branch — `poll_only` always uses the existing 10-minute baseline.\n\n1. Run `git rev-parse HEAD` to get the current commit SHA.\n2. Call the `resolve_ci_checks` tool with `commit_ref` set to that SHA. This discovers and classifies the CI checks for the repository, including each check's `required` field.\n3. Poll CI status by calling `poll_ci_checks` with `commit_ref` set to the same SHA. Check the response for `all_complete`, and note each check's own `required`/green status — do not use the aggregate `all_passed` flag to decide pass/fail (see step 6 below).\n4. **Conflicting-head escalation**: if the poll shows zero check-runs for the current head after roughly two minutes of polling, call `gh pr view --json mergeable,mergeStateStatus` for the current branch's pull request. A `mergeable` value of `CONFLICTING` or a `mergeStateStatus` of `DIRTY` means GitHub will not start `pull_request` workflows for that head, so continuing to poll the same SHA is futile — merge the current base branch into your branch, resolve any conflicts, push the result, obtain the new head SHA (`git rev-parse HEAD`), and restart polling from step 1 against that new SHA. When the mergeability response is not `CONFLICTING`/`DIRTY`, zero checks alone is not a CI failure — continue with the ordinary wait behavior below.\n5. If checks are not yet complete, wait 30 seconds and poll again. Repeat until all checks are complete or 10 minutes have elapsed.\n6. Compute `required_green` = every check with `required: true` (or a missing `required` field) is complete and green. If `required_green` is `true`, report success — non-required failures (e.g. `pip-audit` with `required: false`) are reported for visibility only and never flip the Passed/Failed classification.\n7. **Review verdict gating**: if `claude-review` is one of the required checks, its GitHub check reaching a non-pending/\"success\" state means only that the review action *ran* — this is transport completion, not approval. Fetch the PR's comments and confirm the sticky comment contains `claude-review-verdict: approved` on its own line with a `Reviewed-SHA:` line matching the current commit SHA before treating the review as approved; `claude-review-verdict: changes_requested`, a missing verdict, or a stale `Reviewed-SHA:` all mean the review is not yet approved and success is not yet reached. `docs/claude/claude-review-verdict-contract.md` is the authoritative definition of this grammar, of which comment wins when several carry a verdict, and of every fail-closed outcome; consult it rather than re-deriving the rules, and do not restate a different version of them here. A verdict can also be *temporarily* absent because a running review rewrote the sticky comment in place (the vanishing window, contract §7): while a `claude-review` run is still pending, treat absence as transient and keep polling; once it has completed, absence is final and fails closed.\n8. If any required checks fail, report which required checks failed (and any non-required failures for visibility) and include any available annotations or log details from the poll response. Do NOT attempt to fix failures — just report them clearly.\n9. If CI status is unavailable (resolver/poll returns `available: false`), report unavailable status and exit; do not attempt fixes.\n10. If the 10-minute timeout is reached, report timeout and exit.\n\n### Polling Directive\n\nDuring the polling loop, execute `sleep 30` silently. Do NOT output any inline commentary, reasoning, or partial status updates between polls. Only output a status message when:\n- All checks are complete (pass or fail), OR\n- The 10-minute timeout is reached.\n\nThis minimizes context window consumption during long-running CI waits.\n\n## Step 6 — `fix_and_iterate`\n\nThis is a self-contained loop where `iteration` is the number of correction rounds already pushed and `start_time` is captured before the first iteration. `max_minutes` is the TOTAL wall-clock cap across all iterations, not an additional per-iteration budget. The 10-minute per-iteration `poll_ci_checks` cap is INSIDE that total budget.\n\nInitialize:\n\n- `iteration = 0`\n- `start_time = now()`\n\nBefore starting each iteration AND before applying corrections, check the total wall-clock budget. If `now() - start_time >= max_minutes`, warn and exit.\n\nPer iteration:\n\n1. Run `git rev-parse HEAD` to get the current commit SHA. The previous push may have changed it; always read fresh.\n2. Run `git branch --show-current` to get the current branch. Always read fresh.\n3. Call `resolve_ci_checks` with `commit_ref` set to the current SHA (once per new SHA — the server caches per project but the agent should still call it for each new SHA). Each returned check carries a `required` field — this is the tool-provided proxy for the done-gate's authoritative required-checks set.\n4. Poll `poll_ci_checks` with `commit_ref` set to the current SHA. Stop when `all_complete` is true, OR the per-iteration 10-minute timeout is reached, OR the remaining total wall-clock budget is exhausted.\n5. **Conflicting-head escalation**: if the poll shows zero check-runs for the current head after roughly two minutes of polling, call `gh pr view --json mergeable,mergeStateStatus` for the current branch's pull request. A `mergeable` value of `CONFLICTING` or a `mergeStateStatus` of `DIRTY` means GitHub will not start `pull_request` workflows for that head, so continuing to poll the same SHA is futile — merge the current base branch into your branch, resolve any conflicts, push the result, obtain the new head SHA (`git rev-parse HEAD`), and restart polling from step 1 of this per-iteration block against that new SHA. When the mergeability response is not `CONFLICTING`/`DIRTY`, zero checks alone is not a CI failure — continue with the ordinary per-iteration behavior below.\n6. If CI status is unavailable (`available: false`), warn and exit the loop — automated remediation cannot make reliable progress without CI signals.\n7. Compute `required_green` = every check with `required: true` (or a missing `required` field) is complete and green; non-required failures (e.g. `pip-audit` with `required: false`) are reported for visibility but never gate exit condition 1 below. If `claude-review` is a required check, its GitHub check reaching a non-pending/\"success\" state is transport completion only, not approval — fetch the PR's comments and confirm the sticky comment contains `claude-review-verdict: approved` on its own line with a `Reviewed-SHA:` line matching the current commit SHA before treating the review as approved; `claude-review-verdict: changes_requested`, a missing verdict, or a stale `Reviewed-SHA:` mean the review is not yet approved. `docs/claude/claude-review-verdict-contract.md` is the authoritative definition of this grammar, of which comment wins when several carry a verdict, and of every fail-closed outcome; consult it rather than re-deriving the rules, and do not restate a different version of them here. A verdict can also be *temporarily* absent because a running review rewrote the sticky comment in place (the vanishing window, contract §7): while a `claude-review` run is still pending, treat absence as transient and keep polling; once it has completed, absence is final and fails closed.\n8. Apply repo-specific `instructions` ONLY when the `instructions` field is non-empty. If the repo `instructions` reference templated placeholder tokens for the GitHub owner, repo, or PR number — e.g., the literal tokens written as a left brace, the word `owner`/`repo`/`pr`, then a right brace — resolve them from the local git/VCS context. Use `gh pr list --head <branch> --json number` to get the PR number; parse the remote URL (`git config --get remote.origin.url`) for owner/repo. If `instructions` is empty, skip repo-specific signal gathering and use only structured CI failure information.\n\n9. Evaluate exit conditions in this order:\n 1. `required_green` is true AND (if `claude-review` is required) the verdict token confirms approval for the current head AND any repo-specific exit criteria from `instructions` are met → success. If there are no repo-specific exit criteria, `required_green` (plus verdict-token approval when `claude-review` is required) alone satisfies the success condition. Then return.\n 2. `iteration >= max_iterations` → warn and exit (iteration cap reached).\n 3. Total elapsed wall-clock time `>= max_minutes` → warn and exit (total wall-clock cap reached).\n 4. After attempting corrections, `git status --porcelain` is empty → warn and exit (nothing to commit; avoids infinite loop on stuck failures).\n\n10. Apply corrections ONLY for failing **required** checks — skip failures on non-required checks (e.g. `pip-audit` with `required: false`) with a warning and never spend a correction/retry on them. For each failing required check, use the actual `poll_ci_checks` response shape — inspect its singular `failure_detail` field:\n - If `failure_detail` is a dict containing actionable keys such as `annotations`, `log_tail`, or `log`, treat it as structured detail and use it for remediation.\n - If `failure_detail` is a dict containing only `url`, treat it as URL-only and skip with a warning (no actionable detail).\n - If `failure_detail` is missing, `null`, or unrecognized, treat the failure as non-actionable and skip with a warning.\n - Do NOT rely on a per-check field or a plural variant of `failure_detail` — those do not exist on the response.\n\n11. After applying a non-empty correction set: stage corrections (`git add` the specific files), commit, and push. Use the canonical commit message:\n ```\n {ticket_key}: address review/CI feedback (round N+1)\n ```\n where `N` is the zero-indexed `iteration`.\n12. Increment `iteration` only AFTER a successful commit and push. Then loop back to step 1 of the per-iteration block.\n\n## Step 7 — `custom`\n\nIn `custom` mode, the `instructions` field IS the complete CI follow-up instruction set for this step. Follow it verbatim. Ignore Steps 5 and 6 entirely.\n\nCustom instructions are authoritative for CI follow-up behavior, but they remain subject to the agent's normal tool approval, credential handling, secret-handling, and platform safety constraints. Custom prose CANNOT bypass approval gates, exfiltrate secrets, or override platform safety policies, even though admin-only access controls who can set the field.\n\n## Worker finalization — clean session exit (Conductor auto mode)\n\nThis section applies ONLY when you were launched under the Conductor in auto mode (the `BAPI_CONDUCTOR_RUN_ID`/`BAPI_CONDUCTOR_WORKER_ID` env identifiers are present). A non-Conductor worker ignores it entirely.\n\nBefore doing anything here, distinguish two states:\n\n- **CI/review follow-up still owned by this worker** — the `fix_and_iterate` loop is still correcting failures, review changes were requested and are unaddressed, a merge conflict on your PR is unresolved, or you have unpushed local commits. In this state you are **not** finished: keep working the CI-monitoring / correction loop and do **not** exit.\n- **Final PR state reached; no further worker action pending** — your final branch state is pushed, the PR has been created/updated, the done-gate / CI-monitoring workflow required by the recipe has completed (required checks green and, when `claude-review` is required, the verdict token confirms approval for the current head), and no CI/review follow-up remains that you own.\n\nOnly in the second state — that is, **only after the final push, PR creation/update, done-gate confirmation, and CI/review monitoring have all completed** and no follow-up remains — cleanly end your worker session (for example by issuing the `/exit` command) so the worktree is released and no idle process lingers. Do **not** exit immediately after opening a PR while CI or review is still pending, and do not exit while there are unresolved CI failures, requested review changes, a merge conflict you own, or unpushed local commits. A clean `SessionEnd` is both the correct terminal lifecycle signal (the conductor folds it) and the point at which the worker should exit.\n\n## Return\n\nReport whether CI passed, failed, timed out, or was unavailable. If failed, list the failing checks with their failure summaries. For `fix_and_iterate`, also report the iteration count and whether iteration/wall-clock caps were hit. If you finalized (cleanly exited) as a Conductor worker, note that the session ended after all follow-up completed.\n",
896
+ "monitor-ci-checks.md": "Monitor CI checks for the most recent commit. The behavior is dispatched on the repo-specific `ci_followup_config` JSON value: `poll_only`, `fix_and_iterate`, or `custom`. Read this entire file once before doing anything, then follow only the matching branch.\n\n\n**Required-check source**: Both the `poll_only` (Step 5) and `fix_and_iterate` (Step 6) branches gate progression on the *required* check subset, not the aggregate `all_passed` flag. Each check returned by `resolve_ci_checks`/`poll_ci_checks` carries a `required` field (from GitHub Branch Protection, or an LLM classification fallback) — treat `required: false` as non-required (e.g. `pip-audit`) and a missing field or `required: true` as required. This is the tool-provided proxy for the Conductor done-gate's authoritative required-checks set (`mcp_server/src/conductor/done-gate.ts`); do not re-derive required/non-required status in prose.\n\n## Entry state and ownership boundary\n\n**Re-resolve the git context before anything else.** Run `git branch --show-current`\nand `git rev-parse HEAD` when this step begins. In the implement pipeline the pull\nrequest is opened *before* the bounded post-finalization verification phase, and\nthat phase may have pushed a correction on top of the commit the PR was originally\nopened at. The head you monitor must be the branch's current pushed head, not a SHA\ncarried over from PR creation.\n\n**Ownership is split, and the split matters.** The bounded verification phase\n(`verify-plan.md`) owns findings produced by its own local, touched-area commands.\nThis step owns everything subsequently reported by the authoritative `ci` and\n`code_review` gates — failing required checks and requested review changes. Do not\nre-adjudicate the other phase's findings, and do not assume a finding it reported\nhas been fixed unless a pushed commit shows it.\n\n**Do not substitute a broad local run for the authoritative checks.** Running the\nfull local suite here does not establish that CI passed; it duplicates the work the\n`conductor-ci` gate already performs on the pull request, and it is exactly the\nbudget sink this protocol was reordered to avoid. Use the structured CI failure\ndetail from `poll_ci_checks` to target a fix, and scope any local reproduction to\nthe failing area.\n\n**Keep every correction on the existing pull request branch**, and commit *and push*\neach correction before polling resumes. Polling always restarts against the new\npushed head — an unpushed correction is invisible to CI, to review, and to the\nreconciler.\n\nThroughout, report the check and review states you **observed**. Observing that a\ncheck is green is not the same as issuing a verdict: the done-gate evaluation is\nserver-side and the reconciler decides. This step never emits a control signal of\nits own.\n\n## Step 3 — Parse `ci_followup_config`\n\nLook at the response from the immediately preceding `config_field` call (the pipeline step that ran right before this one). The response envelope's `value` field is itself a JSON string and must be parsed again with `JSON.parse` (i.e., the `value` is double-encoded — the outer envelope is JSON, and the inner `value` is a JSON-encoded string of the actual config object).\n\nIf ANY of the following hold, log a warning and use the defaults `{\"strategy\":\"poll_only\",\"max_iterations\":1,\"max_minutes\":10}`:\n\n- The `config_field` response is missing or unavailable (e.g., the step warned-and-continued).\n- The response `value` is `null`.\n- Parsing `value` with `JSON.parse` fails (the persisted text is not valid JSON).\n- The parsed result is not a JSON object.\n- One or more of the required keys (`strategy`, `max_iterations`, `max_minutes`, `instructions`) is missing.\n- `strategy` is not one of `poll_only`, `fix_and_iterate`, or `custom`.\n\n## Step 4 — Dispatch on `strategy`\n\nRead this whole file once and then follow only the matching branch:\n\n- `poll_only` → follow Step 5.\n- `fix_and_iterate` → follow Step 6.\n- `custom` → follow Step 7.\n\nIf `strategy` is unrecognized, log a warning and fall through to Step 5 (`poll_only`).\n\n## Step 5 — `poll_only`\n\nPreserve the baseline polling behavior. The configured `max_minutes` is IGNORED in this branch — `poll_only` always uses the existing 10-minute baseline.\n\n1. Run `git rev-parse HEAD` to get the current commit SHA.\n2. Call the `resolve_ci_checks` tool with `commit_ref` set to that SHA. This discovers and classifies the CI checks for the repository, including each check's `required` field.\n3. Poll CI status by calling `poll_ci_checks` with `commit_ref` set to the same SHA. Check the response for `all_complete`, and note each check's own `required`/green status — do not use the aggregate `all_passed` flag to decide pass/fail (see step 6 below).\n4. **Conflicting-head escalation**: if the poll shows zero check-runs for the current head after roughly two minutes of polling, call `gh pr view --json mergeable,mergeStateStatus` for the current branch's pull request. A `mergeable` value of `CONFLICTING` or a `mergeStateStatus` of `DIRTY` means GitHub will not start `pull_request` workflows for that head, so continuing to poll the same SHA is futile — merge the current base branch into your branch, resolve any conflicts, push the result, obtain the new head SHA (`git rev-parse HEAD`), and restart polling from step 1 against that new SHA. When the mergeability response is not `CONFLICTING`/`DIRTY`, zero checks alone is not a CI failure — continue with the ordinary wait behavior below.\n5. If checks are not yet complete, wait 30 seconds and poll again. Repeat until all checks are complete or 10 minutes have elapsed.\n6. Compute `required_green` = every check with `required: true` (or a missing `required` field) is complete and green. If `required_green` is `true`, report success — non-required failures (e.g. `pip-audit` with `required: false`) are reported for visibility only and never flip the Passed/Failed classification.\n7. **Review verdict gating**: if `claude-review` is one of the required checks, its GitHub check reaching a non-pending/\"success\" state means only that the review action *ran* — this is transport completion, not approval. Fetch the PR's comments and confirm the sticky comment contains `claude-review-verdict: approved` on its own line with a `Reviewed-SHA:` line matching the current commit SHA before treating the review as approved; `claude-review-verdict: changes_requested`, a missing verdict, or a stale `Reviewed-SHA:` all mean the review is not yet approved and success is not yet reached. `docs/claude/claude-review-verdict-contract.md` is the authoritative definition of this grammar, of which comment wins when several carry a verdict, and of every fail-closed outcome; consult it rather than re-deriving the rules, and do not restate a different version of them here. A verdict can also be *temporarily* absent because a running review rewrote the sticky comment in place (the vanishing window, contract §7): while a `claude-review` run is still pending, treat absence as transient and keep polling; once it has completed, absence is final and fails closed.\n7a. **A `claude-review` failure blocked by the preflight is TERMINAL — never retry it.** When `claude-review` fails within about twelve seconds and no verdict comment was ever posted, read the run's `Preflight the review action` step. The review action refuses to run whenever this workflow differs from the repository's default-branch copy, and the preflight names which of the two causes it found:\n - `status=workflow_modified` — the pull request's own commits changed `.github/workflows/claude-review.yml`. Such a pull request must be reviewed and merged by hand; it can never be conductor-merged.\n - `status=base_workflow_stale` — the pull request changed no workflow file and **inherited** a stale copy from its base branch. The stale ref is the **base branch**, not the pull request.\n\n Both aggregate to `reason=review_action_blocked_by_workflow_modification`. In both cases the preflight is a deterministic blob-SHA comparison over unchanged inputs, so **retrying, re-running, or continuing to poll reproduces byte-identical evidence and can never succeed** — stop the retry loop and report it rather than spending the remaining budget. For the stale-base case the only remedy is to reconcile the base branch with the default branch; see `docs/claude/runbooks/claude-review-base-branch-drift.md` for detection, diagnosis, and both remediation paths.\n\n8. If any required checks fail, report which required checks failed (and any non-required failures for visibility) and include any available annotations or log details from the poll response. Do NOT attempt to fix failures — just report them clearly.\n9. If CI status is unavailable (resolver/poll returns `available: false`), report unavailable status and exit; do not attempt fixes.\n10. If the 10-minute timeout is reached, report timeout and exit.\n\n### Polling Directive\n\nDuring the polling loop, execute `sleep 30` silently. Do NOT output any inline commentary, reasoning, or partial status updates between polls. Only output a status message when:\n- All checks are complete (pass or fail), OR\n- The 10-minute timeout is reached.\n\nThis minimizes context window consumption during long-running CI waits.\n\n## Step 6 — `fix_and_iterate`\n\nThis is a self-contained loop where `iteration` is the number of correction rounds already pushed and `start_time` is captured before the first iteration. `max_minutes` is the TOTAL wall-clock cap across all iterations, not an additional per-iteration budget. The 10-minute per-iteration `poll_ci_checks` cap is INSIDE that total budget.\n\nInitialize:\n\n- `iteration = 0`\n- `start_time = now()`\n\nBefore starting each iteration AND before applying corrections, check the total wall-clock budget. If `now() - start_time >= max_minutes`, warn and exit.\n\nPer iteration:\n\n1. Run `git rev-parse HEAD` to get the current commit SHA. The previous push may have changed it; always read fresh.\n2. Run `git branch --show-current` to get the current branch. Always read fresh.\n3. Call `resolve_ci_checks` with `commit_ref` set to the current SHA (once per new SHA — the server caches per project but the agent should still call it for each new SHA). Each returned check carries a `required` field — this is the tool-provided proxy for the done-gate's authoritative required-checks set.\n4. Poll `poll_ci_checks` with `commit_ref` set to the current SHA. Stop when `all_complete` is true, OR the per-iteration 10-minute timeout is reached, OR the remaining total wall-clock budget is exhausted.\n5. **Conflicting-head escalation**: if the poll shows zero check-runs for the current head after roughly two minutes of polling, call `gh pr view --json mergeable,mergeStateStatus` for the current branch's pull request. A `mergeable` value of `CONFLICTING` or a `mergeStateStatus` of `DIRTY` means GitHub will not start `pull_request` workflows for that head, so continuing to poll the same SHA is futile — merge the current base branch into your branch, resolve any conflicts, push the result, obtain the new head SHA (`git rev-parse HEAD`), and restart polling from step 1 of this per-iteration block against that new SHA. When the mergeability response is not `CONFLICTING`/`DIRTY`, zero checks alone is not a CI failure — continue with the ordinary per-iteration behavior below.\n6. If CI status is unavailable (`available: false`), warn and exit the loop — automated remediation cannot make reliable progress without CI signals.\n7. Compute `required_green` = every check with `required: true` (or a missing `required` field) is complete and green; non-required failures (e.g. `pip-audit` with `required: false`) are reported for visibility but never gate exit condition 1 below. If `claude-review` is a required check, its GitHub check reaching a non-pending/\"success\" state is transport completion only, not approval — fetch the PR's comments and confirm the sticky comment contains `claude-review-verdict: approved` on its own line with a `Reviewed-SHA:` line matching the current commit SHA before treating the review as approved; `claude-review-verdict: changes_requested`, a missing verdict, or a stale `Reviewed-SHA:` mean the review is not yet approved. `docs/claude/claude-review-verdict-contract.md` is the authoritative definition of this grammar, of which comment wins when several carry a verdict, and of every fail-closed outcome; consult it rather than re-deriving the rules, and do not restate a different version of them here. A verdict can also be *temporarily* absent because a running review rewrote the sticky comment in place (the vanishing window, contract §7): while a `claude-review` run is still pending, treat absence as transient and keep polling; once it has completed, absence is final and fails closed.\n7a. **A `claude-review` failure blocked by the preflight is TERMINAL — never spend a correction or a retry on it.** When `claude-review` fails within about twelve seconds and no verdict comment was ever posted, read the run's `Preflight the review action` step. The review action refuses to run whenever this workflow differs from the repository's default-branch copy, and the preflight names which of the two causes it found:\n - `status=workflow_modified` — the pull request's own commits changed `.github/workflows/claude-review.yml`. It must be reviewed and merged by hand; it can never be conductor-merged.\n - `status=base_workflow_stale` — the pull request changed no workflow file and **inherited** a stale copy from its base branch. The stale ref is the **base branch**, not the pull request, so no correction you can make on this branch will clear it.\n\n Both aggregate to `reason=review_action_blocked_by_workflow_modification`, and the preflight is a deterministic blob-SHA comparison over unchanged inputs — every iteration reproduces byte-identical evidence. Treat it as non-actionable, exit the loop with that reason reported, and do not consume the remaining iteration or wall-clock budget. Remedy for the stale-base case: reconcile the base branch with the default branch. See `docs/claude/runbooks/claude-review-base-branch-drift.md`.\n\n8. Apply repo-specific `instructions` ONLY when the `instructions` field is non-empty. If the repo `instructions` reference templated placeholder tokens for the GitHub owner, repo, or PR number — e.g., the literal tokens written as a left brace, the word `owner`/`repo`/`pr`, then a right brace — resolve them from the local git/VCS context. Use `gh pr list --head <branch> --json number` to get the PR number; parse the remote URL (`git config --get remote.origin.url`) for owner/repo. If `instructions` is empty, skip repo-specific signal gathering and use only structured CI failure information.\n\n9. Evaluate exit conditions in this order:\n 1. `required_green` is true AND (if `claude-review` is required) the verdict token confirms approval for the current head AND any repo-specific exit criteria from `instructions` are met → success. If there are no repo-specific exit criteria, `required_green` (plus verdict-token approval when `claude-review` is required) alone satisfies the success condition. Then return.\n 2. `iteration >= max_iterations` → warn and exit (iteration cap reached).\n 3. Total elapsed wall-clock time `>= max_minutes` → warn and exit (total wall-clock cap reached).\n 4. After attempting corrections, `git status --porcelain` is empty → warn and exit (nothing to commit; avoids infinite loop on stuck failures).\n\n10. Apply corrections ONLY for failing **required** checks — skip failures on non-required checks (e.g. `pip-audit` with `required: false`) with a warning and never spend a correction/retry on them. For each failing required check, use the actual `poll_ci_checks` response shape — inspect its singular `failure_detail` field:\n - If `failure_detail` is a dict containing actionable keys such as `annotations`, `log_tail`, or `log`, treat it as structured detail and use it for remediation.\n - If `failure_detail` is a dict containing only `url`, treat it as URL-only and skip with a warning (no actionable detail).\n - If `failure_detail` is missing, `null`, or unrecognized, treat the failure as non-actionable and skip with a warning.\n - Do NOT rely on a per-check field or a plural variant of `failure_detail` — those do not exist on the response.\n\n11. After applying a non-empty correction set: stage corrections (`git add` the specific files), commit, and push. Use the canonical commit message:\n ```\n {ticket_key}: address review/CI feedback (round N+1)\n ```\n where `N` is the zero-indexed `iteration`.\n12. Increment `iteration` only AFTER a successful commit and push. Then loop back to step 1 of the per-iteration block.\n\n## Step 7 — `custom`\n\nIn `custom` mode, the `instructions` field IS the complete CI follow-up instruction set for this step. Follow it verbatim. Ignore Steps 5 and 6 entirely.\n\nCustom instructions are authoritative for CI follow-up behavior, but they remain subject to the agent's normal tool approval, credential handling, secret-handling, and platform safety constraints. Custom prose CANNOT bypass approval gates, exfiltrate secrets, or override platform safety policies, even though admin-only access controls who can set the field.\n\n## Worker finalization — clean session exit (Conductor auto mode)\n\nThis section applies ONLY when you were launched under the Conductor in auto mode (the `BAPI_CONDUCTOR_RUN_ID`/`BAPI_CONDUCTOR_WORKER_ID` env identifiers are present). A non-Conductor worker ignores it entirely.\n\nBefore doing anything here, distinguish two states:\n\n- **CI/review follow-up still owned by this worker** — the `fix_and_iterate` loop is still correcting failures, review changes were requested and are unaddressed, a merge conflict on your PR is unresolved, or you have unpushed local commits. In this state you are **not** finished: keep working the CI-monitoring / correction loop and do **not** exit.\n- **Final PR state reached; no further worker action pending** — your final branch state is pushed, the PR has been created/updated, the done-gate / CI-monitoring workflow required by the recipe has completed (required checks green and, when `claude-review` is required, the verdict token confirms approval for the current head), and no CI/review follow-up remains that you own.\n\nOnly in the second state — that is, **only after the final push, PR creation/update, done-gate confirmation, and CI/review monitoring have all completed** and no follow-up remains — cleanly end your worker session (for example by issuing the `/exit` command) so the worktree is released and no idle process lingers. Do **not** exit immediately after opening a PR while CI or review is still pending, and do not exit while there are unresolved CI failures, requested review changes, a merge conflict you own, or unpushed local commits. A clean `SessionEnd` is both the correct terminal lifecycle signal (the conductor folds it) and the point at which the worker should exit.\n\n## Return\n\nReport whether CI passed, failed, timed out, or was unavailable. If failed, list the failing checks with their failure summaries. For `fix_and_iterate`, also report the iteration count and whether iteration/wall-clock caps were hit. If you finalized (cleanly exited) as a Conductor worker, note that the session ended after all follow-up completed.\n",
897
897
  "preflight-and-readiness.md": "Initialize the idea-to-ticket run directory and classify the idea's readiness and scope.\n\n## Inputs\n\n- Idea: `{idea}`\n- Slug: `{slug}`\n- Run ID: `{run_id}`\n- Docs directory: `{docs_dir}`\n- Project standards: response from the immediately preceding `get_project_standards` step. If that step returned an error envelope or a 404, treat the project standards as unavailable and proceed; do not halt.\n\n## Instructions\n\n1. Create the run directory:\n ```\n mkdir -p {docs_dir}/idea-to-ticket/{slug}-{run_id}\n ```\n Every artifact produced by this pipeline run lives under this run directory. No Jira mutation may occur in any later step until `run-manifest.json` has been written to this directory.\n\n2. Classify the idea on two independent axes:\n\n **Readiness** (one of):\n - `ready_to_draft` — the idea is concrete enough that a clear ticket draft can be produced.\n - `needs_clarification` — the idea is reasonable but missing key answers; clarifying questions must be raised in `open-questions.md` later.\n - `research_first` — drafting is blocked on external/codebase research; deep or narrow research must come first.\n - `too_vague_to_ticket` — the idea is not actionable yet; do not produce a ticket.\n\n **Scope** (one of):\n - `task` — a single Jira Task (default when ambiguous).\n - `spike` — a single Jira Spike for primarily discovery/research work.\n - `epic_candidate` — the idea decomposes into a Jira Epic plus multiple child tickets.\n\n3. Halt locally if readiness is `too_vague_to_ticket`. Write the manifest anyway (see step 4) so the local artifacts record the halt; then stop without continuing the rest of the pipeline. Do not attempt any Jira mutation.\n\n4. Write `run-manifest.json` to `{docs_dir}/idea-to-ticket/{slug}-{run_id}/run-manifest.json`. Required fields:\n - `idea` — the original `{idea}` text.\n - `slug` — `{slug}`.\n - `run_id` — `{run_id}`.\n - `run_dir` — `{docs_dir}/idea-to-ticket/{slug}-{run_id}/`.\n - `readiness` — one of the four readiness values above.\n - `scope` — one of the three scope values above.\n - `project_standards_available` — `true` if `get_project_standards` returned a usable result, `false` otherwise.\n - `idempotency_label` — `bapi-idea-to-ticket-{run_id}` (per-run label; lets downstream steps resume THIS run by label).\n - `stable_label` — `bapi-idea-hash-{idea_hash}` (stable across runs of the same idea; lets the duplicate-detection step catch a PRIOR run of the same idea by label, not just fuzzy text).\n - `created_at` — ISO 8601 timestamp.\n\n5. The manifest is the resumability artifact for the whole run. Do not include secrets or raw credentials. Keep the file under a few KB.\n\n## Return\n\nConfirm the run directory and `run-manifest.json` were created, and report the classified `readiness` and `scope`. If readiness is `too_vague_to_ticket`, also report that the pipeline must stop without Jira mutation.\n",
898
898
  "render-ticket-manifest.md": "Render every entry of an approved ticket manifest into a drafted body, one `jira-ticket-writer` invocation per entry, without re-deciding the split.\n\nThis stage sits between **decomposition** (which decided the split once and froze\nit) and **creation** (`upload-epic-hierarchy.md` for an epic, ordinary\n`create_ticket` calls for siblings). It owns exactly one job: turning frozen\nmanifest entries into drafted bodies. It decides nothing.\n\n## Inputs\n\n- **The approved manifest** — the ordered plan the decomposition pass froze.\n On the `idea-to-ticket` path this is `decomposition-plan.json` in the run\n directory, produced by `decompose-epic-candidate.md`. On the `plan-epic` path\n it is the sub-task list in `epic-plan.md`, produced by `decompose-epic.md`. On\n a client-driven path (`/explore-ticket` Stage 9) it is the approved outline\n shown at the gate.\n- **The approval state** for that manifest. Whether approval is an explicit\n affirmative or a pipeline auto-approval variable is the calling surface's own\n rule; this stage only needs to know it resolved.\n- **The research and framing artifacts** each entry's writer invocation needs —\n the same set the standalone drafting path passes.\n\n## Preflight — fail closed, before any body is rendered\n\nCheck all of the following first. Each one is a **hard failure** that stops the\nflow before a single writer invocation and long before any Jira mutation. None\nof them degrades to a partial run:\n\n1. **Approval unresolved.** The manifest's approval gate has not resolved, or\n resolved as declined. Nothing renders and nothing is created.\n2. **Manifest identity changed.** The manifest presented here is not the one that\n was approved — a different entry count, different ordering, renumbered\n entries, or an altered scope boundary. A substituted split never silently\n replaces the approved one.\n3. **An entry past the size ceiling.** An entry whose stated scope runs beyond\n roughly 40 files or ~3000 LOC is over the bound wherever it sits — a\n standalone ticket and an epic child alike. Fail and name the entry; do not\n draft it, do not split it here, and do not shrink its stated scope to fit. An\n `XL` entry below that ceiling is not a violation and needs no supporting\n evidence: `XL` is the preferred shape for work that does not fit in `L`.\n4. **An entry with no writer draft after rendering.** Every entry must have\n produced a body. A missing draft is a failure, not an entry to skip.\n\nFail-open applies **only** to enrichment and context gathering — an optional\nresearch artifact that is missing may degrade the context a body is written\nfrom. Approval, manifest identity, sizing validity, and writer-output\ncompleteness all fail closed.\n\n## Instructions\n\n1. Read the approved manifest in full. Record its entry count, its order, and\n each entry's identity, so the identity check above has something to compare\n against.\n\n2. **Fan out one `jira-ticket-writer` invocation per entry**, in manifest order —\n the epic parent, then each child; or each ordinary sibling. Each invocation\n is bound to exactly one entry and receives, verbatim:\n - the entry's identity, position in the order, scope boundary, **size band**,\n parent relationship, `depends_on`, and `recommended_after`;\n - the acceptance criteria and design material for that entry's slice;\n - the shared research, materials, and framing artifacts;\n - an explicit output path for the drafted body.\n\n State in every prompt that the decomposition is **frozen**: the invocation\n renders its entry and may not add, remove, merge, reorder, renumber, or\n rescope anything, and may not write about sibling entries as though it were\n deciding them.\n\n3. **Do not batch.** One invocation per entry, never one invocation asked to emit\n every body. Two failure modes are both real and this shape avoids both: N\n fully independent decisions overlap, omit dependencies, and contradict the\n parent — which is why the decomposition is frozen upstream rather than\n re-derived per child; and one invocation emitting the whole set produces\n unreliable output as the manifest grows. Separating the decision from the\n rendering is what lets the rendering fan out safely, so a large manifest is\n handled by *more* invocations, never by collapsing back to one.\n\n4. Every entry type gets the same treatment — the same research protocol,\n materials inventory, secret-redaction rules, required sections, and Jira\n description bounds. An epic parent is not a thinner document than a\n standalone ticket, and an epic child is not a thinner document than either.\n\n5. When every entry has a draft, re-run the preflight checks against the\n rendered set and hand off to creation:\n - **Epic** — `upload-epic-hierarchy.md`, unchanged. It creates the parent with\n `issue_type = \"Epic\"`, captures the resolved epic key, then calls\n `create_ticket(parent_key=<epic_key>)` per child in manifest order with\n `track_ticket` after each, and carries idempotency labels throughout so a\n partial failure resumes rather than duplicating. Nothing new is required to\n *create* an epic — only to propose one.\n - **Siblings** — ordinary `create_ticket` calls in manifest order, unparented,\n with no epic parent synthesized.\n\n6. This stage emits **no conductor invocation**. The handoff belongs to the\n calling surface and names exactly one entry point, `drive-epic`.\n\n## Return\n\nReport the manifest's shape (`epic` or `siblings`), the entry count, each entry's\ndrafted body path in manifest order, and the preflight verdict. On a preflight\nfailure, name the violated prerequisite and the offending entry, and confirm that\nno ticket was created.\n",
899
899
  "research-decision.md": "Decide which research tools to run for this idea, biased toward cheap local research first.\n\n## Inputs\n\n- Run manifest: `{docs_dir}/idea-to-ticket/{slug}-{run_id}/run-manifest.json` (must already exist from the preflight step).\n\n## Instructions\n\n1. Read `{docs_dir}/idea-to-ticket/{slug}-{run_id}/run-manifest.json`. This is the source of truth for `idea`, `readiness`, `scope`, and `run_id`. If the file does not exist, halt locally — the preflight step did not complete.\n\n2. Decide which research tools should run for this idea, in roughly this priority order:\n - **Local codebase research first.** Inspect the working tree (search, grep, file reads) for prior art, related modules, and existing tests. Prefer this for anything that touches code you already own.\n - **Narrow web search second.** Use targeted web search for short factual lookups: a specific library API, a known external standard, a public spec.\n - **Deep research only when justified.** Deep research is expensive and slow; it must be earned by one of the rubric items below.\n\n3. Deep-research allowance rubric. Deep research is only allowed when at least one of these is true:\n - **blast radius**: the change spans many systems or has high reversibility cost (e.g., schema migrations, auth, billing, public APIs).\n - **unfamiliar external domain**: the idea depends on a third-party domain or specification the repository has no prior coverage of.\n - **compliance/security uncertainty**: there is real compliance or security uncertainty (SOC2, PII, secret handling, access control).\n - **cheaper research failed**: a cheaper round (local + narrow web search) already happened in this run and left blocking unknowns.\n - **explicit user request**: the user explicitly asked for deep research.\n\n4. Write `{docs_dir}/idea-to-ticket/{slug}-{run_id}/research-plan.json`. Required fields:\n - `selected_tools` — array of tool identifiers to run, drawn from at least `[\"codebase_search\", \"web_search\", \"deep_research\"]`. Empty array is allowed when no research is needed.\n - `rationale` — short string explaining the choice in terms of the rubric above.\n - `deep_research_query` — string. Required when `deep_research` is in `selected_tools`, otherwise empty string.\n - `web_search_topics` — array of strings; may be empty.\n - `codebase_search_topics` — array of strings; may be empty.\n - `expected_unknowns` — array of strings describing what the research is expected to resolve.\n\n5. Do not invoke any research tool from this step — that happens in `execute-research.md`. This step only writes the plan.\n\n## Return\n\nConfirm `research-plan.json` was written, list `selected_tools`, and quote the rationale.\n",
@@ -17,7 +17,7 @@ import { PLANE_RUNTIME_ENTRYPOINT_REFUSAL } from "./build-freshness.js";
17
17
  import { relativeLogPathFor } from "./manifest.js";
18
18
  import { claimPlaneManifest } from "./manifest.js";
19
19
  import { runPlanePreflight } from "./preflight.js";
20
- import { buildPlaneMemberRoster } from "./member-roster.js";
20
+ import { buildPlaneMemberRoster, resolvePlaneServerEndpoint } from "./member-roster.js";
21
21
  import { getPlaneStatus, formatPlaneStatus } from "./status.js";
22
22
  import { shutdownPlane, formatPlaneShutdown } from "./shutdown.js";
23
23
  import { launchPlaneSupervisor, runPlaneRuntime, } from "./supervisor.js";
@@ -454,7 +454,7 @@ function printStartupBanner(sinks, context, memberCount, executors, launch) {
454
454
  sinks.stdout("");
455
455
  sinks.stdout(`Plane up — ${memberCount} members (${executors} executor lane(s))`);
456
456
  sinks.stdout(` repository ${context.repoName} (${context.repoRoot})`);
457
- sinks.stdout(` server ${context.baseUrl} (no --reload)`);
457
+ sinks.stdout(` server ${context.endpoint.baseUrl} (no --reload)`);
458
458
  sinks.stdout(` supervisor pid ${launch.supervisorPid}, process group ${launch.supervisorPgid}`);
459
459
  sinks.stdout(` logs ${PLANE_RUNTIME_DIR}/`);
460
460
  sinks.stdout(" crash policy members are NOT restarted; a member exit is reported loudly.");
@@ -525,9 +525,19 @@ async function runRuntimeAction(executors, overrides) {
525
525
  });
526
526
  return result.exitCode;
527
527
  }
528
+ /**
529
+ * Bind preflight's dependencies to the real platform for one environment.
530
+ *
531
+ * This is the ONLY production path that reads the plane's server-port override
532
+ * (BAPI-950). Both `plane up` and the private `plane __runtime` action go
533
+ * through here, so the launcher and the detached runtime it spawns resolve the
534
+ * endpoint by exactly the same rule — a second lookup elsewhere is what would
535
+ * let the two disagree about which port the plane is on.
536
+ */
528
537
  function buildPreflightDeps(env) {
529
538
  return {
530
539
  env,
540
+ endpoint: resolvePlaneServerEndpoint(env),
531
541
  platform: process.platform,
532
542
  homedir: resolveHomedir,
533
543
  fs: createPlaneFsDeps(),
@@ -259,14 +259,38 @@ export function manifestHasLiveProcess(manifest, proc) {
259
259
  export function formatPlaneManifest(manifest) {
260
260
  return `${JSON.stringify(manifest, null, 2)}\n`;
261
261
  }
262
+ /**
263
+ * Monotonic per-process discriminator for temporary manifest filenames.
264
+ *
265
+ * Module-local and used for nothing else. It never appears in the published
266
+ * manifest, in a diagnostic, or in any path that outlives the rename below.
267
+ */
268
+ let manifestWriteCounter = 0;
262
269
  /**
263
270
  * Persist a state transition through a same-directory temporary file and an
264
271
  * atomic rename, so a crash mid-write can never leave a half-written manifest
265
272
  * that a subsequent `plane down` would read as a kill list.
273
+ *
274
+ * The temporary name is unique per WRITE, not per plane (BAPI-950). It used to
275
+ * be keyed only by `planeId`, which every writer of the same plane shares — so
276
+ * two overlapping writes wrote the one temp file simultaneously and the rename
277
+ * published their interleaved bytes as `plane.json`. `readPlaneManifest` then
278
+ * reported "manifest is not valid JSON" and `plane down` refused to remove a
279
+ * manifest it could not validate, leaving the plane un-windable.
280
+ *
281
+ * The runtime serializes its own writes (see `runPlaneRuntime`), which is the
282
+ * primary fix; this is defense in depth for INDEPENDENT writers — the launcher,
283
+ * the claim path, an epic-run binding — that no single in-process queue can
284
+ * order. `process.pid` separates processes, the counter separates writes within
285
+ * one process, and `planeId` is retained so a stray temp file is still
286
+ * attributable to the plane that produced it.
266
287
  */
267
288
  export async function writePlaneManifest(manifest, fs) {
268
289
  const { manifestPath } = getPlanePaths(manifest.repoRoot);
269
- const tempPath = `${manifestPath}.${manifest.planeId}.tmp`;
290
+ manifestWriteCounter += 1;
291
+ // Same directory as `plane.json`, so the rename stays a same-filesystem
292
+ // atomic replacement rather than a copy across a mount boundary.
293
+ const tempPath = `${manifestPath}.${manifest.planeId}.${process.pid}.${manifestWriteCounter}.tmp`;
270
294
  await fs.writeFile(tempPath, formatPlaneManifest(manifest));
271
295
  await fs.rename(tempPath, manifestPath);
272
296
  }
@@ -17,7 +17,7 @@
17
17
  * member name, log path, or formatted description is built from a secret.
18
18
  */
19
19
  import path from "path";
20
- import { PLANE_SERVER_HOST, PLANE_SERVER_PORT, } from "./types.js";
20
+ import { PLANE_SERVER_HOST, PLANE_SERVER_PORT, PLANE_SERVER_PORT_ENV_VAR, } from "./types.js";
21
21
  import { relativeLogPathFor } from "./manifest.js";
22
22
  /** How long a member with a readiness probe gets to start listening. */
23
23
  export const PLANE_READINESS_TIMEOUT_MS = 60_000;
@@ -35,6 +35,55 @@ export function resolveUvicornExecutable(env) {
35
35
  ? configured.trim()
36
36
  : "uvicorn";
37
37
  }
38
+ /** Highest port number a TCP endpoint can occupy. */
39
+ const MAX_TCP_PORT = 65_535;
40
+ /**
41
+ * The only diagnostic an invalid override ever produces.
42
+ *
43
+ * Fixed prose, built from the variable's own name and the accepted range and
44
+ * nothing else. The supplied value is deliberately NOT echoed: an operator who
45
+ * pasted a secret into the wrong shell export would otherwise have it printed
46
+ * to the terminal and copied into the preflight trace on disk.
47
+ */
48
+ export const PLANE_SERVER_PORT_INVALID_MESSAGE = `${PLANE_SERVER_PORT_ENV_VAR} must be a whole TCP port number in the range 1..${MAX_TCP_PORT}. ` +
49
+ "Unset it to use the default port " +
50
+ `${PLANE_SERVER_PORT}, or export a valid port and retry.`;
51
+ /** Build an endpoint from a port, deriving the base URL from the same fields. */
52
+ function endpointForPort(port) {
53
+ return { host: PLANE_SERVER_HOST, port, baseUrl: `http://${PLANE_SERVER_HOST}:${port}` };
54
+ }
55
+ /**
56
+ * Resolve the local server endpoint from the environment, exactly once.
57
+ *
58
+ * Absent or blank leaves the historical contract untouched: the default is
59
+ * `127.0.0.1:8000` and `http://127.0.0.1:8000`, identical to the constants that
60
+ * were previously read directly.
61
+ *
62
+ * Anything present is validated STRICTLY and, when invalid, *refused* rather
63
+ * than quietly falling back to 8000. A silent fallback is the failure mode this
64
+ * override exists to remove: an operator who set the variable precisely because
65
+ * 8000 was occupied would watch the plane bind 8000 anyway and see a port
66
+ * collision they had already worked around.
67
+ *
68
+ * The host is never read from the environment — see {@link PLANE_SERVER_HOST}.
69
+ */
70
+ export function resolvePlaneServerEndpoint(env) {
71
+ const configured = env[PLANE_SERVER_PORT_ENV_VAR];
72
+ if (typeof configured !== "string" || configured.trim().length === 0) {
73
+ return { ok: true, endpoint: endpointForPort(PLANE_SERVER_PORT) };
74
+ }
75
+ const raw = configured.trim();
76
+ // Digits only, deliberately: `Number()` accepts "0x1f4", "1e3", " 8000 ", and
77
+ // "+8000", and `parseInt` accepts "8000x" by stopping at the first non-digit.
78
+ // Either would let a typo resolve to a port the operator never wrote.
79
+ if (!/^[0-9]+$/.test(raw))
80
+ return { ok: false, message: PLANE_SERVER_PORT_INVALID_MESSAGE };
81
+ const port = Number(raw);
82
+ if (!Number.isInteger(port) || port < 1 || port > MAX_TCP_PORT) {
83
+ return { ok: false, message: PLANE_SERVER_PORT_INVALID_MESSAGE };
84
+ }
85
+ return { ok: true, endpoint: endpointForPort(port) };
86
+ }
38
87
  /**
39
88
  * Build a child environment: a copy of the parent plus the resolved values the
40
89
  * existing processes already expect.
@@ -51,7 +100,7 @@ export function buildPlaneChildEnv(parentEnv, context) {
51
100
  env[key] = value;
52
101
  }
53
102
  env.BAPI_REPO_NAME = context.repoName;
54
- env.BAPI_BASE_URL = context.baseUrl;
103
+ env.BAPI_BASE_URL = context.endpoint.baseUrl;
55
104
  env.BAPI_API_KEY = context.bridgeApiKey;
56
105
  delete env.CONDUCTOR_DEAD_MAN_ONLY;
57
106
  return env;
@@ -64,6 +113,11 @@ export function buildPlaneMemberRoster(params) {
64
113
  const { context, executors, parentEnv, nodeExecutable } = params;
65
114
  const env = buildPlaneChildEnv(parentEnv, context);
66
115
  const cwd = context.repoRoot;
116
+ // One resolved endpoint for the bind argv, the readiness probe, every
117
+ // executor's `--base-url`, and the child `BAPI_BASE_URL`. Reading the default
118
+ // constants here instead is what would let uvicorn bind one port while the
119
+ // probe and the executors addressed another.
120
+ const endpoint = context.endpoint;
67
121
  const server = {
68
122
  name: "server",
69
123
  command: resolveUvicornExecutable(parentEnv),
@@ -73,16 +127,16 @@ export function buildPlaneMemberRoster(params) {
73
127
  args: [
74
128
  "main:app",
75
129
  "--host",
76
- PLANE_SERVER_HOST,
130
+ endpoint.host,
77
131
  "--port",
78
- String(PLANE_SERVER_PORT),
132
+ String(endpoint.port),
79
133
  ],
80
134
  cwd,
81
135
  env,
82
136
  logPath: relativeLogPathFor("server"),
83
137
  readiness: {
84
- host: PLANE_SERVER_HOST,
85
- port: PLANE_SERVER_PORT,
138
+ host: endpoint.host,
139
+ port: endpoint.port,
86
140
  timeoutMs: PLANE_READINESS_TIMEOUT_MS,
87
141
  },
88
142
  };
@@ -111,7 +165,7 @@ export function buildPlaneMemberRoster(params) {
111
165
  "--repo",
112
166
  context.repoName,
113
167
  "--base-url",
114
- context.baseUrl,
168
+ endpoint.baseUrl,
115
169
  "--executor-id",
116
170
  buildExecutorId(context.repoName, lane),
117
171
  ],