@bridge_gpt/mcp-server 0.2.34 → 0.2.36

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 (49) hide show
  1. package/README.md +456 -370
  2. package/build/agent-capabilities/probe-context.js +8 -1
  3. package/build/agent-capabilities/probes.js +7 -1
  4. package/build/agents.generated.js +1 -1
  5. package/build/claude-review-workflow.js +264 -0
  6. package/build/cli-release.js +53 -0
  7. package/build/commands.generated.js +4 -4
  8. package/build/conductor/bridge-api-client.js +215 -0
  9. package/build/conductor/deny-enforcement-preflight.js +1 -0
  10. package/build/conductor/done-gate.js +44 -5
  11. package/build/conductor/epic-reconcile.js +6 -0
  12. package/build/conductor/install-doctor.js +462 -0
  13. package/build/conductor-bin.js +3 -3
  14. package/build/conductor-bundle-artifacts.js +30 -9
  15. package/build/doctor.js +234 -1
  16. package/build/executor/cli.js +32 -5
  17. package/build/executor/credentials.js +45 -11
  18. package/build/executor/deps.js +14 -0
  19. package/build/executor/env.js +23 -6
  20. package/build/executor/index.js +4 -0
  21. package/build/executor/job-runner.js +119 -9
  22. package/build/executor/permissions.js +12 -2
  23. package/build/executor/preflight.js +95 -8
  24. package/build/executor/prompt-spec.js +51 -0
  25. package/build/executor/runner.js +15 -2
  26. package/build/executor/service-unit.js +876 -0
  27. package/build/executor/test-clock.js +8 -0
  28. package/build/executor/types.js +0 -17
  29. package/build/executor/worker-command.js +62 -9
  30. package/build/index.js +575 -143
  31. package/build/init.js +153 -51
  32. package/build/install-bridge-conductor.js +491 -0
  33. package/build/install-bridge.js +628 -175
  34. package/build/install-reexec.js +233 -0
  35. package/build/mcp-host-config.js +11 -1
  36. package/build/mcp-install-state.js +32 -0
  37. package/build/mcp-provisioning.js +22 -6
  38. package/build/pipelines.generated.js +14 -8
  39. package/build/readme.generated.js +1 -1
  40. package/build/run-unit-tests-launcher.js +257 -0
  41. package/build/setup-epic.js +117 -8
  42. package/build/upgrade-cli.js +1 -15
  43. package/build/version.generated.js +1 -1
  44. package/docs/CONDUCTOR.md +115 -4
  45. package/docs/install/mcp-tool-integrations.md +29 -21
  46. package/package.json +8 -5
  47. package/pipelines/implement-ticket.json +6 -1
  48. package/build/conductor/supervisor-judgment-python.js +0 -141
  49. package/build/conductor/supervisor-judgment.js +0 -215
@@ -20,6 +20,7 @@ import { runHeartbeatLoop } from "./heartbeat.js";
20
20
  import { isExecutorNamedError, toExecutorFailure, secretFreeErrorMessage, MissingVerdictArtifact, } from "./job-errors.js";
21
21
  import { buildConductorMergeAccessForExecutorJob, buildDefaultMergeLocalDeps, runExecutorMergeJob, MERGE_RETRYABLE, MERGE_FAILED, } from "./merge-job.js";
22
22
  import { createObservationState } from "./observation.js";
23
+ import { provisionMcpRegistrationForWorktree } from "../mcp-provisioning.js";
23
24
  import { provisionExecutorDenyLayer } from "./permissions.js";
24
25
  import { runProcessWithTimeout } from "./process.js";
25
26
  import { prepareResumeSpawn } from "./resume-pre-spawn.js";
@@ -35,7 +36,7 @@ import { resolveExecutorJobBaseBranch } from "./base-branch.js";
35
36
  import { validateWorkerFinalization } from "./worker-finalization.js";
36
37
  import { ensureExecutorWorktree } from "./worktree.js";
37
38
  import { buildPrBaseContractLaunchInstruction } from "../pr-base-contract.js";
38
- import { buildClaudeExecutorArgv, CLAUDE_EXECUTABLE, resolveExecutorModelAlias, resolveExecutorPrompt, } from "./worker-command.js";
39
+ import { buildClaudeExecutorArgv, CLAUDE_EXECUTABLE, resolveExecutorModelAlias, resolveExecutorPrompt, resolveWorkerPermissionPosture, } from "./worker-command.js";
39
40
  import { collectGitTelemetry } from "./observation.js";
40
41
  /** Default runtime for the no-op smoke process (ms). */
41
42
  const DEFAULT_SMOKE_DURATION_MS = 100;
@@ -91,6 +92,57 @@ function buildJobLogRegistryDeps(deps) {
91
92
  platform: deps.platform,
92
93
  };
93
94
  }
95
+ /** Map executor deps into the shared MCP-provisioning filesystem boundary. */
96
+ function buildExecutorMcpProvisioningDeps(deps) {
97
+ return {
98
+ readFile: deps.readFile,
99
+ writeFile: deps.writeFile,
100
+ mkdir: deps.mkdir,
101
+ platform: deps.platform,
102
+ cwd: deps.cwd,
103
+ mcpServerInvocation: deps.mcpServerInvocation,
104
+ };
105
+ }
106
+ /**
107
+ * Provision secret-free Bridge MCP registration + Claude trust pre-approval
108
+ * (BAPI-724) into a prepared spawn worktree, for every job type that reaches
109
+ * the shared spawn path. Reuses the exact `start-tickets` provisioning
110
+ * machinery by constructing a synthetic `created` {@link SummaryRow} for this
111
+ * job/worktree — no registration serialization or second trust write happens
112
+ * here.
113
+ *
114
+ * Fail-open by construction: a provisioner warning or a `spawn-failed` result
115
+ * is logged via `deps.errorLog` and this function still returns normally, and
116
+ * an unexpected thrown exception is caught and reduced to a single fixed,
117
+ * secret-free diagnostic. Either way `runSpawnJob` proceeds to spawn the
118
+ * worker — a job that later needs an MCP tool it cannot reach fails loudly
119
+ * through its own process/gate/timeout/budget path, never here.
120
+ */
121
+ async function provisionMcpForPreparedSpawn(job, branch, worktreePath, deps, seams) {
122
+ const provision = seams.provisionMcp ?? provisionMcpRegistrationForWorktree;
123
+ const row = {
124
+ key: job.ticket_key ?? String(job.id),
125
+ branch,
126
+ status: "created",
127
+ path: worktreePath,
128
+ };
129
+ try {
130
+ const result = await provision(row, buildExecutorMcpProvisioningDeps(deps));
131
+ for (const warning of result.warnings ?? []) {
132
+ deps.errorLog(`MCP provisioning warning: ${warning}`);
133
+ }
134
+ if (result.status === "spawn-failed") {
135
+ deps.errorLog(`MCP provisioning did not complete (${result.error ?? "unknown reason"}); continuing fail-open`);
136
+ }
137
+ }
138
+ catch {
139
+ // Fail-open, secret-free: a fixed diagnostic only. Never interpolate the
140
+ // thrown exception, the worktree path, a command/argument, or credential
141
+ // material — this branch exists precisely so an unexpected provisioning
142
+ // failure can never leak anything into the executor's own logs.
143
+ deps.errorLog("MCP provisioning did not complete; continuing fail-open");
144
+ }
145
+ }
94
146
  /**
95
147
  * Default merge dispatch: resolve Bridge API access, then run the deterministic
96
148
  * merge. Exported for unit tests (access-resolution failure → retryable). The
@@ -401,6 +453,19 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
401
453
  });
402
454
  return terminalToRunResult(terminal, "failed");
403
455
  }
456
+ /**
457
+ * BAPI-724 design note (deliberate, not an accidental AC-5 gap): `smoke` stays
458
+ * on this no-worktree, no-real-worker-LLM `createNoopProcess` path and does
459
+ * NOT receive MCP registration. The ratified requirement anchors MCP
460
+ * provisioning to "the executor's worktree-preparation path
461
+ * (`job-runner.ts`), alongside the existing `provisionExecutorDenyLayer` call"
462
+ * — that call, like the new MCP provisioning call, exists ONLY inside
463
+ * `runSpawnJob`, which `smoke` never reaches (see `isSpawnJobType` /
464
+ * `job-types.ts`, and this module's own "no real worker LLM" framing of the
465
+ * acceptance spine). Converting smoke into a worktree-having, worker-spawning
466
+ * job to give it something to register MCP into would be a materially larger,
467
+ * unratified change to that architectural boundary, not a wiring task.
468
+ */
404
469
  async function runSmokeJob(job, httpClient, options, deps, ownership, observation) {
405
470
  const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
406
471
  if (!timeout.ok) {
@@ -488,10 +553,18 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
488
553
  }
489
554
  }
490
555
  // Resolve an explicit `payload.prompt` or the `/implement-ticket <KEY> --auto`
491
- // synthesis (implement + recovery). `spec_review` matches neither — it is
492
- // resolved AFTER worktree preparation from its structured `payload.prompt_spec`
493
- // (rendering `{{GIT_LOG}}` needs the prepared worktree), so a non-ok result
494
- // here is not yet a failure when a `prompt_spec` is present to render.
556
+ // synthesis (implement + recovery).
557
+ //
558
+ // BAPI-725: `spec_review` now takes the explicit-prompt path like any other job
559
+ // the reconciler builds its prompt server-side and ships it in
560
+ // `payload.prompt`, so it resolves here and returns below without ever reaching
561
+ // the prompt-spec renderer. A stale `payload.prompt_spec` on an in-flight job
562
+ // enqueued before this change cannot override that: the explicit prompt wins
563
+ // unconditionally at the `explicit.ok` return.
564
+ //
565
+ // The renderer below is NOT dead. `smoke` and any future structured-prompt job
566
+ // still reach it, and rendering `{{GIT_LOG}}` is why it must run after worktree
567
+ // preparation rather than in `resolveExecutorPrompt`.
495
568
  const explicit = resolveExecutorPrompt(job);
496
569
  const payload = job.payload && typeof job.payload === "object"
497
570
  ? job.payload
@@ -529,9 +602,10 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
529
602
  return { ok: true, worktreePath: wt.worktreePath, branch: wt.branch, prompt: explicit.prompt };
530
603
  }
531
604
  // No explicit/synthesized prompt: render the structured `payload.prompt_spec`
532
- // against the prepared worktree (the `spec_review` path). A malformed spec
533
- // fails loud with `ContractError.Prompt` so we `/fail` rather than spawn a
534
- // worker with an empty prompt. Error messages are secret-free by construction.
605
+ // against the prepared worktree (`smoke`, and any future structured-prompt job;
606
+ // `spec_review` left this path in BAPI-725). A malformed spec fails loud with
607
+ // `ContractError.Prompt` so we `/fail` rather than spawn a worker with an empty
608
+ // prompt. Error messages are secret-free by construction.
535
609
  const renderPrompt = seams.renderPromptSpecPrompt ?? renderPromptSpecPrompt;
536
610
  try {
537
611
  const rendered = await renderPrompt(job, {
@@ -554,6 +628,14 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
554
628
  return { ok: false, result: { status: "failed", reason: "prompt_contract" } };
555
629
  }
556
630
  }
631
+ /**
632
+ * Final pre-spawn preparation order (BAPI-724): worktree + prompt preparation,
633
+ * timeout-contract validation, the deny-layer attempt, the MCP registration +
634
+ * trust-approval attempt, worker-log/viewer setup, then `deps.spawnProcess`.
635
+ * Failed preparation or an invalid timeout returns BEFORE either the deny
636
+ * layer or MCP provisioning runs — neither ever touches a worktree that will
637
+ * not become a runnable worker.
638
+ */
557
639
  async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams) {
558
640
  // --- Per-job base branch (BAPI-586) ----------------------------------
559
641
  // Resolve the effective logical base BEFORE any side effect: the persisted
@@ -574,6 +656,26 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
574
656
  }
575
657
  const effectiveBaseBranch = baseResolution.baseBranch;
576
658
  const jobOptions = { ...options, baseBranch: effectiveBaseBranch };
659
+ // --- Permission posture (BAPI-725) -----------------------------------
660
+ // Resolved alongside the base branch, BEFORE any side effect, for the same
661
+ // reason: an unsupported posture is a contract failure, and failing it here
662
+ // means no worktree is cut, no deny layer is written, and no process is
663
+ // spawned for a job that can never run correctly. The error text names only
664
+ // the field — the supplied value never reaches `/fail`.
665
+ let permissionPosture;
666
+ try {
667
+ permissionPosture = resolveWorkerPermissionPosture(job.payload);
668
+ }
669
+ catch (err) {
670
+ await httpClient.fail(job, isExecutorNamedError(err)
671
+ ? toExecutorFailure(err)
672
+ : {
673
+ error_kind: "ContractError.PermissionPosture",
674
+ error_message: secretFreeErrorMessage(err),
675
+ classification: "crashed",
676
+ });
677
+ return { status: "failed", reason: "permission_posture_contract" };
678
+ }
577
679
  // --- Worktree + prompt (resume pre-spawn protocol or standard) -------
578
680
  const prep = await prepareSpawn(job, httpClient, jobOptions, deps, seams);
579
681
  if (!prep.ok)
@@ -605,6 +707,11 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
605
707
  });
606
708
  if (!deny.ok)
607
709
  deps.errorLog(deny.warning);
710
+ // --- MCP registration + trust pre-approval (fail-open, BAPI-724) -----
711
+ // Unconditional for every job type that reaches this shared spawn path
712
+ // (implement/resume/spec_review/remediate/ci_fix/rebase) — no job-type
713
+ // branching, so a future spawn job type inherits the same default access.
714
+ await provisionMcpForPreparedSpawn(job, branch, worktreePath, deps, seams);
608
715
  // --- Worker log tee + watch registry + viewer tab (BAPI-535 §8) ------
609
716
  const registryDeps = buildJobLogRegistryDeps(deps);
610
717
  const markFinished = seams.markExecutorJobLogFinished ?? markExecutorJobLogFinished;
@@ -670,7 +777,10 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
670
777
  };
671
778
  // --- Spawn -----------------------------------------------------------
672
779
  const alias = resolveExecutorModelAlias(job.payload);
673
- const argv = buildClaudeExecutorArgv(prompt, alias);
780
+ // BAPI-725: the posture resolved at the top of this function — after the deny
781
+ // layer has already been provisioned above, which is what keeps `acceptEdits`
782
+ // from being a relaxation of what the worker may do.
783
+ const argv = buildClaudeExecutorArgv(prompt, alias, permissionPosture);
674
784
  // BAPI-586: inject the validated run base as BAPI_BASE_BRANCH so the worker
675
785
  // can target it via `gh pr create --base "$BAPI_BASE_BRANCH"`.
676
786
  const env = buildExecutorWorkerEnv(deps.env, effectiveBaseBranch);
@@ -8,6 +8,13 @@
8
8
  * liveness never does. (Refusing to claim on `enforced:false` is a SEPARATE,
9
9
  * fatal preflight concern — see `preflight.ts`.)
10
10
  *
11
+ * BAPI-724: the worker model is now provisioned with default Bridge MCP access
12
+ * (see `../mcp-provisioning.js` wiring in `job-runner.ts`), so this module also
13
+ * denies direct reads of the user-scoped Bridge credential store
14
+ * (`~/.config/bridge/**`). MCP authentication stays available to the worker
15
+ * through the separately launched `mcp-invoke` shim process, which resolves the
16
+ * credential itself — the model process never reads it directly.
17
+ *
11
18
  * The Wave-1 probe on the target machine reports enforcement layer `settings-deny`
12
19
  * (claude 2.1.201), so plain `permissions.deny` is provisioned; the PreToolUse
13
20
  * fallback is available for CLIs where settings-deny is not enforced.
@@ -16,8 +23,10 @@ import { mergeClaudeSettingsWithCommandHook, provisionClaudeSettingsForWorktree,
16
23
  /**
17
24
  * The stable executor deny rules. Grammar mirrors the deny-enforcement probe's
18
25
  * `Bash(<cmd>:<args-glob>)` / `Read(<path-glob>)` form. Covers: reads under
19
- * ~/.ssh, ~/.aws, and keychain paths; force-push to the base branch; and
20
- * destructive `rm -rf` outside the worktree.
26
+ * ~/.ssh, ~/.aws, and keychain paths; the user-scoped Bridge credential store
27
+ * (BAPI-724 the model is denied direct reads, while the separately launched
28
+ * `mcp-invoke` shim process still resolves it); force-push to the base branch;
29
+ * and destructive `rm -rf` outside the worktree.
21
30
  */
22
31
  export function executorDenyRules(inputs) {
23
32
  return [
@@ -25,6 +34,7 @@ export function executorDenyRules(inputs) {
25
34
  "Read(~/.aws/**)",
26
35
  "Read(~/Library/Keychains/**)",
27
36
  "Read(/etc/shadow)",
37
+ "Read(~/.config/bridge/**)",
28
38
  `Bash(git push:*--force*${inputs.baseBranch}*)`,
29
39
  "Bash(git push:*--force*)",
30
40
  "Bash(rm:*-rf /*)",
@@ -14,8 +14,70 @@
14
14
  * allowed reuse surface — it does not pull the v1 event/ledger graph).
15
15
  */
16
16
  import { runDenyEnforcementPreflight } from "../conductor/deny-enforcement-preflight.js";
17
- import { resolveAllExecutorApiAccess } from "./credentials.js";
17
+ import { DEFAULT_PROBE_TIMEOUT_MS } from "../agent-capabilities/types.js";
18
+ import { resolveAllExecutorApiAccess, resolveBaseUrl, EXECUTOR_BASE_URL_REQUIRED_MESSAGE, } from "./credentials.js";
18
19
  const VERSION_DETAIL_MAX = 200;
20
+ /**
21
+ * Environment override for the deny probe's headless timeout (BAPI-722).
22
+ *
23
+ * EXECUTOR-PROCESS CONFIGURATION ONLY. It is deliberately excluded from spawned
24
+ * worker environments by `isExecutorEnvKeyAllowed` — a worker has no deny probe to
25
+ * time out, and letting a job influence the executor's own safety-probe budget
26
+ * would be a privilege inversion.
27
+ */
28
+ export const DENY_PROBE_TIMEOUT_ENV_KEY = "BAPI_CONDUCTOR_DENY_PROBE_TIMEOUT_MS";
29
+ /**
30
+ * Accepted bounds for the resolved probe timeout. The lower bound keeps a
31
+ * fat-fingered `1` from turning every probe into an instant HANG (which is FATAL —
32
+ * the executor would refuse to claim forever); the upper bound keeps a typo from
33
+ * wedging the claim loop for hours. Anything outside them falls back to the
34
+ * established capability-probe default rather than being clamped, so a
35
+ * misconfiguration produces the known-good value instead of a silently different
36
+ * one the operator never chose.
37
+ */
38
+ const DENY_PROBE_TIMEOUT_MIN_MS = 1_000;
39
+ const DENY_PROBE_TIMEOUT_MAX_MS = 600_000;
40
+ /**
41
+ * Resolve the deny probe's timeout from the environment, falling back to the
42
+ * established capability-probe default (`DEFAULT_PROBE_TIMEOUT_MS`).
43
+ *
44
+ * Strict: blank, non-numeric, non-integer, zero, negative, and out-of-range values
45
+ * all resolve to the default. The result is ALWAYS a finite positive number — never
46
+ * `undefined`, never `0`, never unbounded — because an unbounded deny probe is
47
+ * exactly the hang this timeout exists to prevent.
48
+ *
49
+ * The value itself is never logged alongside its surrounding environment.
50
+ */
51
+ export function resolveDenyProbeTimeoutMs(env = {}) {
52
+ const raw = env[DENY_PROBE_TIMEOUT_ENV_KEY];
53
+ if (typeof raw !== "string" || raw.trim().length === 0)
54
+ return DEFAULT_PROBE_TIMEOUT_MS;
55
+ const parsed = Number(raw.trim());
56
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed))
57
+ return DEFAULT_PROBE_TIMEOUT_MS;
58
+ if (parsed < DENY_PROBE_TIMEOUT_MIN_MS || parsed > DENY_PROBE_TIMEOUT_MAX_MS) {
59
+ return DEFAULT_PROBE_TIMEOUT_MS;
60
+ }
61
+ return parsed;
62
+ }
63
+ /** True only for a result safe to reuse: genuinely enforced by a real layer. */
64
+ function isCacheableDenyResult(result) {
65
+ return result.enforced === true && result.layer !== "none";
66
+ }
67
+ /** Create a fresh, empty executor-scoped deny-probe cache. */
68
+ export function createDenyProbeCache() {
69
+ let cached;
70
+ return {
71
+ get: () => cached,
72
+ set: (result) => {
73
+ if (isCacheableDenyResult(result))
74
+ cached = result;
75
+ },
76
+ reset: () => {
77
+ cached = undefined;
78
+ },
79
+ };
80
+ }
19
81
  function normalizeVersion(stdout, stderr) {
20
82
  return (stdout || stderr || "").trim().slice(0, VERSION_DETAIL_MAX);
21
83
  }
@@ -61,11 +123,20 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
61
123
  catch {
62
124
  fatalFindings.push("claude could not be probed");
63
125
  }
64
- // --- Credentials for every configured repo ----------------------------
65
- const access = await resolveAllExecutorApiAccess(options.repos, deps);
66
- for (const a of access) {
67
- if (!a.ok)
68
- fatalFindings.push(`credential resolution failed for repo '${a.repoName}'`);
126
+ // --- Explicit base URL (fatal; never defaults to production, BAPI-676) ---
127
+ // Resolved before per-repo credentials so a missing URL reports ONCE rather
128
+ // than once per repo. The remaining probes still run and accumulate findings.
129
+ const baseUrlResult = resolveBaseUrl(deps.env, options.baseUrl);
130
+ if (!baseUrlResult.ok) {
131
+ fatalFindings.push(EXECUTOR_BASE_URL_REQUIRED_MESSAGE);
132
+ }
133
+ else {
134
+ // --- Credentials for every configured repo --------------------------
135
+ const access = await resolveAllExecutorApiAccess(options.repos, deps, baseUrlResult.baseUrl);
136
+ for (const a of access) {
137
+ if (!a.ok)
138
+ fatalFindings.push(`credential resolution failed for repo '${a.repoName}'`);
139
+ }
69
140
  }
70
141
  // --- Disk space (fatal when it cannot be verified) --------------------
71
142
  let diskFreeGb = null;
@@ -97,10 +168,26 @@ export async function collectExecutorPreflight(options, deps, seams = {}) {
97
168
  warnings.push("local `gh` is unavailable/unauthenticated; local merges would be degraded");
98
169
  }
99
170
  // --- Deny-layer enforcement (binding fatal contract) ------------------
171
+ // BAPI-722: only THIS probe is cached across claim cycles. Every probe above —
172
+ // tooling, credentials, disk, gh — still runs on each collection, because each of
173
+ // them can genuinely change between polls (a binary removed, a credential
174
+ // rotated, a disk filled). Deny-layer enforcement is a property of the machine's
175
+ // static settings; once verified it does not spontaneously unverify.
100
176
  let denyEnforced = false;
101
177
  try {
102
- const denyProbe = seams.runDenyPreflight ?? runDenyEnforcementPreflight;
103
- const deny = await denyProbe();
178
+ const cache = seams.bypassDenyProbeCache === true ? undefined : seams.denyProbeCache;
179
+ const cachedDeny = cache?.get();
180
+ let deny;
181
+ if (cachedDeny !== undefined) {
182
+ deny = cachedDeny;
183
+ }
184
+ else {
185
+ const denyProbe = seams.runDenyPreflight ?? runDenyEnforcementPreflight;
186
+ deny = await denyProbe({ timeoutMs: resolveDenyProbeTimeoutMs(deps.env) });
187
+ // `set` itself refuses anything that is not a genuine success, so a failure
188
+ // cannot become sticky even if this call site were later moved.
189
+ seams.denyProbeCache?.set(deny);
190
+ }
104
191
  denyEnforced = deny.enforced === true;
105
192
  if (!deny.enforced) {
106
193
  fatalFindings.push(`deny-layer enforcement not verified (enforced=false, layer=${deny.layer}); refusing to claim`);
@@ -26,6 +26,20 @@ const SUPPORTED_PROMPT_SPEC_VERSION = 1;
26
26
  const GIT_LOG_MAX_CHARS = 8_000;
27
27
  /** Explicit "no prior revise reasons" value — never an empty string. */
28
28
  const REVISE_REASONS_NONE = "(none — this is the first review of the ticket spec)";
29
+ /**
30
+ * Explicit "no related-ticket context" value (BAPI-699) — never an empty string.
31
+ *
32
+ * The server normally renders this section itself (availability header + one
33
+ * record block per related ticket), so this fallback only fires when a payload
34
+ * reaches a placeholder-aware executor without a `related_context` field at all
35
+ * — e.g. a job enqueued by an older server revision. Substituting an empty
36
+ * string there would hand the reviewer a blank section that reads exactly like
37
+ * "this ticket has no neighbors", so the fallback states the blindness instead.
38
+ */
39
+ const RELATED_CONTEXT_NONE = "(no related-ticket context was supplied with this job. This review is " +
40
+ "PARTIALLY BLIND to the surrounding tickets: do NOT conclude that the ticket " +
41
+ "has no related tickets, no dependencies, and no already-merged neighbors — " +
42
+ "that information was simply not provided.)";
29
43
  /** The `git log` argv used to resolve `{{GIT_LOG}}` (list-based, never shelled). */
30
44
  const GIT_LOG_ARGS = ["log", "--oneline", "--decorate", "--max-count=50"];
31
45
  /**
@@ -39,7 +53,21 @@ const SUPPORTED_PLACEHOLDERS = new Set([
39
53
  "WORK_BRANCH",
40
54
  "GIT_LOG",
41
55
  "REVISE_REASONS",
56
+ "RELATED_CONTEXT",
57
+ "SPEC_CITATION_FRESHNESS_WARNING",
42
58
  ]);
59
+ /**
60
+ * Explicit "no stale-citation advisory" value (BAPI-722) — never an empty string.
61
+ *
62
+ * Unlike `RELATED_CONTEXT`, absence here is the COMMON and entirely healthy case:
63
+ * most tickets have no predecessor that merged after their spec was written, so the
64
+ * server omits the field. The substituted text therefore states that plainly rather
65
+ * than warning about blindness — a reviewer must not read "no advisory" as "the
66
+ * server could not tell", which would push them toward re-verifying every citation
67
+ * on every review.
68
+ */
69
+ const SPEC_CITATION_FRESHNESS_NONE = "(none — no dependency of this ticket merged after this ticket's " +
70
+ "specification snapshot was taken.)";
43
71
  function promptContractError(message) {
44
72
  return new ExecutorNamedError(PROMPT_CONTRACT_ERROR, message);
45
73
  }
@@ -190,6 +218,29 @@ export async function buildPromptSpecPlaceholderValues(job, spec, context) {
190
218
  values.REVISE_REASONS = formatReviseReasons(payload.reasons);
191
219
  break;
192
220
  }
221
+ case "RELATED_CONTEXT": {
222
+ // The server renders this section (availability header, per-entry
223
+ // record blocks, its own bounded trim marker) and records the SHA-256
224
+ // of EXACTLY the text it rendered. So the value is substituted VERBATIM
225
+ // — no `.trim()`, no re-wrapping, no re-bounding — otherwise the
226
+ // recorded display hash would stop describing what the reviewer read.
227
+ values.RELATED_CONTEXT = isNonBlankString(payload.related_context)
228
+ ? payload.related_context
229
+ : RELATED_CONTEXT_NONE;
230
+ break;
231
+ }
232
+ case "SPEC_CITATION_FRESHNESS_WARNING": {
233
+ // OPTIONAL by contract (BAPI-722): an absent field is the normal case and
234
+ // resolves to the explicit "none" text, NEVER a thrown contract error and
235
+ // never an empty substitution. This deliberately does not weaken
236
+ // {{TICKET_SPEC}}, which stays required — a review with no spec is not a
237
+ // review, whereas a review with no advisory is simply the usual review.
238
+ // Substituted verbatim: the server composed this text and bounded it.
239
+ values.SPEC_CITATION_FRESHNESS_WARNING = isNonBlankString(payload.spec_citation_freshness_warning)
240
+ ? payload.spec_citation_freshness_warning
241
+ : SPEC_CITATION_FRESHNESS_NONE;
242
+ break;
243
+ }
193
244
  }
194
245
  }
195
246
  return values;
@@ -6,7 +6,7 @@
6
6
  * preflight finding. `once` mode performs one preflight/claim cycle and returns
7
7
  * after all active jobs finish. Depends on NO v1 conductor event/ledger module.
8
8
  */
9
- import { collectExecutorPreflight, buildClaimManifest } from "./preflight.js";
9
+ import { collectExecutorPreflight, buildClaimManifest, createDenyProbeCache, } from "./preflight.js";
10
10
  import { runClaimedJob } from "./job-runner.js";
11
11
  import { sweepExecutorWorktrees } from "./worktree-gc.js";
12
12
  /**
@@ -28,6 +28,19 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
28
28
  platform: deps.platform,
29
29
  }));
30
30
  const active = new Map();
31
+ // BAPI-722: ONE deny-probe cache per `runExecutor` invocation, created OUTSIDE
32
+ // the claim loop below — that scope is the whole feature. A cache created inside
33
+ // the loop would be discarded every cycle and re-probe exactly as before; a
34
+ // module-level cache would leak across invocations and survive a deliberate
35
+ // restart, which is precisely the escape hatch an operator fixing a broken deny
36
+ // layer needs. Process lifetime is the correct boundary.
37
+ //
38
+ // An injected `preflightSeams.denyProbeCache` (or `bypassDenyProbeCache`) wins,
39
+ // so a test can force deterministic fresh probes.
40
+ const preflightSeams = {
41
+ denyProbeCache: createDenyProbeCache(),
42
+ ...(seams.preflightSeams ?? {}),
43
+ };
31
44
  /**
32
45
  * Run the conservative worktree GC sweep ONLY while the runner owns no active
33
46
  * job worktree. Failures are logged to stderr and never abort claiming
@@ -60,7 +73,7 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
60
73
  active.set(job.id, promise);
61
74
  }
62
75
  for (;;) {
63
- const report = await collectPreflight(options, deps, seams.preflightSeams ?? {});
76
+ const report = await collectPreflight(options, deps, preflightSeams);
64
77
  if (!report.ok) {
65
78
  deps.errorLog(`executor preflight refused claiming: ${report.fatalFindings.join("; ")}`);
66
79
  }