@bridge_gpt/mcp-server 0.2.37 → 0.2.39

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 (91) hide show
  1. package/README.md +193 -16
  2. package/build/agent-capabilities/probe-context.js +2 -1
  3. package/build/agent-launchers/claude-executor-adapter.js +392 -0
  4. package/build/agent-launchers/executor-adapter-inspection.js +163 -0
  5. package/build/agent-launchers/executor-adapter-registry.js +90 -0
  6. package/build/agent-launchers/executor-adapter.js +136 -0
  7. package/build/agent-registry.js +28 -0
  8. package/build/agents.generated.js +1 -1
  9. package/build/claude-login.js +85 -0
  10. package/build/claude-user-config-doctor.js +59 -33
  11. package/build/commands.generated.js +12 -11
  12. package/build/conduct-epic/bridge-client.js +345 -0
  13. package/build/conduct-epic/checkpoint-store.js +423 -0
  14. package/build/conduct-epic/cli.js +1732 -0
  15. package/build/conduct-epic/lock.js +302 -0
  16. package/build/conduct-epic/pr-state.js +197 -0
  17. package/build/conduct-epic/spawn.js +101 -0
  18. package/build/conductor/bridge-api-client.js +37 -2
  19. package/build/conductor/doctor.js +11 -1
  20. package/build/conductor/install-doctor.js +184 -10
  21. package/build/conductor-bin.js +7 -7
  22. package/build/credential-store.js +10 -4
  23. package/build/credentials-cli.js +34 -19
  24. package/build/docs.generated.js +1 -1
  25. package/build/doctor.js +579 -88
  26. package/build/executor/agent-identity.js +32 -0
  27. package/build/executor/cli.js +50 -39
  28. package/build/executor/deps.js +15 -1
  29. package/build/executor/env.js +56 -45
  30. package/build/executor/index.js +9 -1
  31. package/build/executor/install-preflight.js +138 -0
  32. package/build/executor/job-errors.js +200 -0
  33. package/build/executor/job-runner.js +619 -268
  34. package/build/executor/observation.js +165 -0
  35. package/build/executor/permissions.js +163 -36
  36. package/build/executor/platform.js +54 -0
  37. package/build/executor/preflight.js +175 -67
  38. package/build/executor/process.js +39 -7
  39. package/build/executor/runner.js +19 -0
  40. package/build/executor/service-lifecycle.js +269 -0
  41. package/build/executor/service-unit.js +121 -12
  42. package/build/executor/stale-artifacts.js +70 -0
  43. package/build/executor/test-clock.js +188 -24
  44. package/build/executor/worker-command.js +22 -58
  45. package/build/executor/worker-log.js +82 -0
  46. package/build/executor/worktree-lock.js +264 -0
  47. package/build/index.js +527 -357
  48. package/build/install-bridge-conductor.js +376 -38
  49. package/build/install-bridge.js +414 -114
  50. package/build/install-doctor.js +13 -0
  51. package/build/install-reexec.js +5 -3
  52. package/build/mcp-install-state.js +130 -0
  53. package/build/mcp-profile.js +11 -2
  54. package/build/mcp-provisioning.js +15 -0
  55. package/build/merge-pull-request.js +562 -0
  56. package/build/phase-result-artifacts.js +450 -0
  57. package/build/pipeline-orchestrator.js +4 -0
  58. package/build/pipeline-utils.js +16 -0
  59. package/build/pipelines.generated.js +7 -7
  60. package/build/plane/preflight.js +18 -14
  61. package/build/plane/supervisor.js +8 -1
  62. package/build/project-root.js +34 -0
  63. package/build/readme.generated.js +1 -1
  64. package/build/run-unit-tests-launcher.js +36 -9
  65. package/build/setup-epic.js +57 -4
  66. package/build/sfcc/ocapi-shape.js +23 -4
  67. package/build/sfcc/permissions.js +25 -6
  68. package/build/sfcc/read-body.js +92 -0
  69. package/build/sfcc/read-projection.js +6 -2
  70. package/build/sfcc/reads-custom-object-def.js +33 -21
  71. package/build/sfcc/reads-site-preference.js +20 -7
  72. package/build/sfcc/reads-system-object.js +11 -5
  73. package/build/sfcc/register.js +61 -23
  74. package/build/sfcc/registration-inventory.js +89 -0
  75. package/build/sfcc/setup-status.js +18 -34
  76. package/build/sfcc/tool-wrapper.js +294 -17
  77. package/build/sfcc/write-grants.js +33 -1
  78. package/build/sfcc/write-guard.js +41 -12
  79. package/build/sfcc/write-result.js +16 -7
  80. package/build/sfcc/writes-custom-object-def.js +12 -4
  81. package/build/sfcc/writes-site-preference.js +6 -1
  82. package/build/sfcc/writes-system-object.js +11 -2
  83. package/build/sfcc/writes.js +13 -8
  84. package/build/start-tickets-prereqs.js +25 -15
  85. package/build/start-tickets.js +123 -21
  86. package/build/version.generated.js +1 -1
  87. package/build/worktree-core.js +9 -3
  88. package/docs/install/mcp-tool-integrations.md +54 -9
  89. package/docs/install/sfcc-integration.md +71 -24
  90. package/package.json +3 -3
  91. package/build/executor/worker-config-isolation.js +0 -287
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Consented executor service lifecycle (BAPI-779, ratified decision R-1).
3
+ *
4
+ * WHY THIS MODULE EXISTS SEPARATELY. `service-unit.ts` carries an invariant
5
+ * titled "Never manages service lifecycle": it renders and writes a unit file
6
+ * and runs no `launchctl` / `systemctl`. R-1 (2026-08-14) deliberately amends
7
+ * the *product* rule — `install conductor` may now start the service the
8
+ * generator just wrote — while keeping the *module* rule intact. So lifecycle
9
+ * lives here, behind its own entry points, and the generator stays lifecycle
10
+ * free. A caller that only wants a unit written physically cannot start a
11
+ * persistent daemon by accident, because it never imports this module.
12
+ *
13
+ * Four rules shape everything below.
14
+ *
15
+ * 1. **One attempt, never a retry loop.** Each planned command runs at most
16
+ * once, and the first failure stops the sequence. A service manager refusing
17
+ * to bootstrap a unit is a state to report, not a race to win — retrying
18
+ * turns a clear degradation into a hang, and the operator has the exact
19
+ * manual command either way.
20
+ * 2. **Shell-free by construction.** Every command is an
21
+ * {@link ExecutorServiceCommand} — an executable plus an argument ARRAY.
22
+ * There is no code path that concatenates a command string and hands it to a
23
+ * shell, so a unit path or unit name containing shell metacharacters cannot
24
+ * become syntax.
25
+ * 3. **Sanitized results only.** A failure result carries the stage label, the
26
+ * exit code, and the manual command. It never carries runner stdout, stderr,
27
+ * thrown exception text, or environment values — the command's output is
28
+ * untrusted for reporting purposes even though the command itself is ours.
29
+ * 4. **Local success is NOT readiness.** `startExecutorService` resolving `ok`
30
+ * means the service manager accepted the unit on THIS host. It says nothing
31
+ * about whether the Bridge server has observed an executor claim, which is
32
+ * the only thing that may produce `executor_ready`. The two are reported as
33
+ * separate facts everywhere, and nothing in this module may be used to
34
+ * derive readiness.
35
+ *
36
+ * The command forms follow the conventions the scheduler backends already use
37
+ * (`scheduler-backends/launchd.ts`, `scheduler-backends/systemd-user.ts`), so
38
+ * the executor and the scheduler address user-domain services the same way.
39
+ */
40
+ import { posixShellQuote } from "../scheduler-backends/escaping.js";
41
+ /**
42
+ * Characters that need no shell quoting. Deliberately conservative — anything
43
+ * outside this set is quoted rather than reasoned about.
44
+ */
45
+ const SHELL_SAFE_TOKEN_RE = /^[A-Za-z0-9_./:@%+=-]+$/;
46
+ /**
47
+ * Quote a token only when it needs it.
48
+ *
49
+ * The output is copy-pasted by a human, so `launchctl bootstrap gui/501 /path`
50
+ * beats `'launchctl' 'bootstrap' 'gui/501' '/path'` for legibility. Safety is
51
+ * unchanged: anything containing a shell metacharacter — which a unit name or
52
+ * an executor id legitimately can, since darwin allows those characters in
53
+ * filenames — falls through to `posixShellQuote`.
54
+ */
55
+ function shellToken(value) {
56
+ return SHELL_SAFE_TOKEN_RE.test(value) ? value : posixShellQuote(value);
57
+ }
58
+ /** Render one descriptor as a shell-safe command line. */
59
+ function renderCommand(command) {
60
+ return [command.executable, ...command.args].map(shellToken).join(" ");
61
+ }
62
+ /**
63
+ * Build the platform-specific start sequence.
64
+ *
65
+ * darwin: `launchctl bootstrap gui/<uid> <plist-path>`
66
+ * linux: `systemctl --user daemon-reload`, then
67
+ * `systemctl --user enable --now <unit-name>`
68
+ *
69
+ * PURE — it consults no runner, no environment, and no filesystem, so an
70
+ * unsupported or incompletely described target is refused before anything could
71
+ * be executed.
72
+ */
73
+ export function buildExecutorServiceStartPlan(target) {
74
+ if (target.platform !== "darwin" && target.platform !== "linux") {
75
+ return {
76
+ ok: false,
77
+ error: `platform '${String(target.platform)}' has no supported executor service start ` +
78
+ "sequence (consented start is supported on darwin and linux only).",
79
+ };
80
+ }
81
+ if (typeof target.serviceIdentifier !== "string" || target.serviceIdentifier.length === 0) {
82
+ return { ok: false, error: "the service identifier is missing, so no unit can be addressed." };
83
+ }
84
+ if (target.platform === "darwin") {
85
+ if (typeof target.unitPath !== "string" || target.unitPath.length === 0) {
86
+ return { ok: false, error: "the unit path is missing, so launchd has no unit to bootstrap." };
87
+ }
88
+ if (typeof target.uid !== "number" || !Number.isInteger(target.uid) || target.uid < 0) {
89
+ return {
90
+ ok: false,
91
+ error: "a numeric uid is required for the launchd domain target `gui/<uid>`; " +
92
+ "none was supplied.",
93
+ };
94
+ }
95
+ const commands = [
96
+ {
97
+ stage: "launchctl bootstrap",
98
+ executable: "launchctl",
99
+ args: ["bootstrap", `gui/${target.uid}`, target.unitPath],
100
+ },
101
+ ];
102
+ return {
103
+ ok: true,
104
+ plan: {
105
+ platform: "darwin",
106
+ serviceManager: "launchd",
107
+ commands,
108
+ manualCommand: commands.map(renderCommand).join(" && "),
109
+ },
110
+ };
111
+ }
112
+ const commands = [
113
+ {
114
+ stage: "systemctl --user daemon-reload",
115
+ executable: "systemctl",
116
+ args: ["--user", "daemon-reload"],
117
+ },
118
+ {
119
+ stage: "systemctl --user enable --now",
120
+ executable: "systemctl",
121
+ args: ["--user", "enable", "--now", target.serviceIdentifier],
122
+ },
123
+ ];
124
+ return {
125
+ ok: true,
126
+ plan: {
127
+ platform: "linux",
128
+ serviceManager: "systemd-user",
129
+ commands,
130
+ // The two rendered commands joined by ` && ` — the same descriptors the
131
+ // runner receives, so the printed and the invoked forms cannot diverge.
132
+ manualCommand: commands.map(renderCommand).join(" && "),
133
+ },
134
+ };
135
+ }
136
+ /**
137
+ * Run the planned start sequence: each command at most once, in order, stopping
138
+ * at the first non-zero exit or runner refusal.
139
+ *
140
+ * A runner that throws is ONE attempted failure, not a signal to retry — a
141
+ * refusal (`ENOENT` on `systemctl`, a sandbox denial, a spawn error) is a
142
+ * durable property of the host, so re-running it would only spend time.
143
+ * `exitCode` is `null` in that case because no exit code exists.
144
+ */
145
+ export async function startExecutorService(plan, run) {
146
+ let commandsRun = 0;
147
+ for (const command of plan.commands) {
148
+ let exitCode;
149
+ commandsRun++;
150
+ try {
151
+ const result = await run(command.executable, command.args);
152
+ exitCode = typeof result?.exitCode === "number" ? result.exitCode : 1;
153
+ }
154
+ catch {
155
+ // The thrown value is deliberately discarded: exception text can carry a
156
+ // path, an environment value, or a spawn detail that has no place in
157
+ // operator output.
158
+ return {
159
+ ok: false,
160
+ manualCommand: plan.manualCommand,
161
+ failedStage: command.stage,
162
+ exitCode: null,
163
+ reason: "runner-refused",
164
+ commandsRun,
165
+ };
166
+ }
167
+ if (exitCode !== 0) {
168
+ return {
169
+ ok: false,
170
+ manualCommand: plan.manualCommand,
171
+ failedStage: command.stage,
172
+ exitCode,
173
+ reason: "nonzero-exit",
174
+ commandsRun,
175
+ };
176
+ }
177
+ }
178
+ return { ok: true, manualCommand: plan.manualCommand, commandsRun };
179
+ }
180
+ /**
181
+ * Render a start failure as one secret-free operator line.
182
+ *
183
+ * Exported so the installer and any future diagnostic print byte-identical
184
+ * text; two copies of this sentence would drift.
185
+ */
186
+ export function formatExecutorServiceStartFailure(result) {
187
+ const detail = result.reason === "runner-refused"
188
+ ? "the command could not be run on this host"
189
+ : `exit code ${result.exitCode}`;
190
+ return `service start did not complete: '${result.failedStage}' failed (${detail}).`;
191
+ }
192
+ /**
193
+ * Build the strictly read-only state probe for a target.
194
+ *
195
+ * darwin: `launchctl print gui/<uid>/<label>` — the same query
196
+ * `scheduler-backends/launchd.ts` uses for status.
197
+ * linux: `systemctl --user is-active <unit>` — a non-mutating query whose
198
+ * stdout is a single state token, which is what makes a deterministic mapping
199
+ * possible (`systemctl --user status` would require parsing a paragraph).
200
+ */
201
+ export function buildExecutorServiceStateProbe(target) {
202
+ if (typeof target.serviceIdentifier !== "string" || target.serviceIdentifier.length === 0) {
203
+ return null;
204
+ }
205
+ if (target.platform === "darwin") {
206
+ if (typeof target.uid !== "number" || !Number.isInteger(target.uid) || target.uid < 0) {
207
+ return null;
208
+ }
209
+ return {
210
+ stage: "launchctl print",
211
+ executable: "launchctl",
212
+ args: ["print", `gui/${target.uid}/${target.serviceIdentifier}`],
213
+ };
214
+ }
215
+ if (target.platform === "linux") {
216
+ return {
217
+ stage: "systemctl --user is-active",
218
+ executable: "systemctl",
219
+ args: ["--user", "is-active", target.serviceIdentifier],
220
+ };
221
+ }
222
+ return null;
223
+ }
224
+ /** The systemd state tokens this module maps; anything else is `unknown`. */
225
+ const SYSTEMD_INACTIVE_TOKENS = new Set([
226
+ "inactive",
227
+ "failed",
228
+ "activating",
229
+ "deactivating",
230
+ "reloading",
231
+ ]);
232
+ /**
233
+ * Inspect local service state. STRICTLY read-only: it issues exactly one probe
234
+ * and never starts, enables, reloads, bootstraps, or writes anything.
235
+ *
236
+ * Returns `unknown` rather than guessing whenever the probe cannot be built,
237
+ * the runner refuses, or the output is not a token this module recognizes — a
238
+ * diagnostic that invents a state is worse than one that admits ignorance.
239
+ */
240
+ export async function inspectExecutorServiceState(target, run) {
241
+ const probe = buildExecutorServiceStateProbe(target);
242
+ if (!probe)
243
+ return { state: "unknown", probe: null };
244
+ let exitCode;
245
+ let stdout;
246
+ try {
247
+ const result = await run(probe.executable, probe.args);
248
+ exitCode = typeof result?.exitCode === "number" ? result.exitCode : 1;
249
+ stdout = typeof result?.stdout === "string" ? result.stdout : "";
250
+ }
251
+ catch {
252
+ return { state: "unknown", probe };
253
+ }
254
+ if (target.platform === "darwin") {
255
+ // `launchctl print` succeeds only for a job the domain actually holds, so a
256
+ // zero exit is the loaded/active signal and a non-zero exit is "no such
257
+ // job". launchd exposes no cheap loaded-but-stopped distinction here, so
258
+ // this deliberately does not claim `inactive`.
259
+ return { state: exitCode === 0 ? "active" : "not-found", probe };
260
+ }
261
+ const token = stdout.trim().split(/\s+/)[0]?.toLowerCase() ?? "";
262
+ if (token === "active")
263
+ return { state: "active", probe };
264
+ if (token === "unknown")
265
+ return { state: "not-found", probe };
266
+ if (SYSTEMD_INACTIVE_TOKENS.has(token))
267
+ return { state: "inactive", probe };
268
+ return { state: "unknown", probe };
269
+ }
@@ -10,9 +10,16 @@
10
10
  *
11
11
  * 1. **Never embeds credentials.** A generated unit carries only the validated
12
12
  * base URL, the generating shell's `PATH`, the repository list, the executor
13
- * id, and the packaged invocation. `BAPI_API_KEY` (and every
14
- * credential-store value) resolves at *launch* time through the executor's
15
- * existing credential seam, so an artifact left on disk is not a secret.
13
+ * id, and the packaged invocation. `BAPI_API_KEY` resolves at *launch* time
14
+ * through the executor's existing credential-store seam, so an artifact left
15
+ * on disk is not a secret. This holds BY CONSTRUCTION rather than by
16
+ * scrubbing: {@link ExecutorServiceRenderInput} has no field capable of
17
+ * carrying a credential, and no renderer reads ambient environment state.
18
+ * `CLAUDE_CODE_OAUTH_TOKEN`, when the operator has exported one into the
19
+ * service's own launch-time environment, forwards unchanged into the direct
20
+ * `claude` worker environment (BAPI-791) — Bridge never stores, resolves, or
21
+ * diagnoses it, and there is no `anthropic:oauth` bundle any more.
22
+ * `ANTHROPIC_API_KEY` is never forwarded to a worker under any circumstance.
16
23
  * `PATH` is baked deliberately (the scheduler backends set the precedent —
17
24
  * see `BAKED_ENV_VAR_NAMES` in `../scheduler-backends/escaping.js`,
18
25
  * BAPI-327): under launchd / `systemd --user` the environment PATH is the
@@ -27,6 +34,24 @@
27
34
  * not run `launchctl`, `systemctl`, `loginctl`, or `schtasks` — starting,
28
35
  * stopping, and enabling the unit stay operator-owned.
29
36
  *
37
+ * R-1 AMENDMENT (BAPI-779, ratified 2026-08-14). This invariant continues to
38
+ * hold for *this module* — the generator and its write primitives still run
39
+ * no lifecycle command, and `runExecutorInstallServiceCli` still only writes
40
+ * a file. What the amendment changes is that lifecycle management is no
41
+ * longer forbidden to Bridge outright: consented, one-shot start and
42
+ * read-only state inspection now live in a SEPARATE module,
43
+ * {@link ../executor/service-lifecycle.js `executor/service-lifecycle.ts`},
44
+ * which `install conductor` drives behind its own independent consent. The
45
+ * split is the point — a caller that wants a unit written cannot
46
+ * accidentally start a persistent daemon, because the two capabilities are
47
+ * in different modules with different entry points.
48
+ *
49
+ * The programmatic seam that split makes possible is
50
+ * {@link planExecutorServiceInstall} + {@link writeExecutorServicePlan}:
51
+ * pure plan construction separated from the filesystem write, with consent
52
+ * left entirely to the caller so the standalone CLI and the conductor can
53
+ * apply their own independent consent policies over the same primitives.
54
+ *
30
55
  * The artifact type is deliberately executor-local rather than the scheduler's
31
56
  * {@link ../scheduler-backends/types.js#GeneratedArtifact}: that shape carries a
32
57
  * loose `kind: string`, whereas the executor wants a *discriminating* literal
@@ -555,9 +580,11 @@ export function getExecutorInstallServiceUsage() {
555
580
  "The generated unit contains only the base URL, the generating shell's PATH, the",
556
581
  "repositories, the executor id, and the packaged invocation. PATH is baked because",
557
582
  "the service manager's minimal default cannot resolve the `claude` worker command.",
558
- "BAPI_API_KEY and every credential-store value are deliberately omitted ",
559
- "credentials resolve at launch time from the environment or the user-scoped",
560
- "bapi:<repo> credential store.",
583
+ "BAPI_API_KEY is deliberately omitted it resolves at launch time from the",
584
+ "environment or the user-scoped bapi:<repo> credential store. A worker",
585
+ "authenticates through this host's own interactive `claude login`, or, headlessly,",
586
+ "through a CLAUDE_CODE_OAUTH_TOKEN you export into the service's own environment",
587
+ "yourself; Bridge only forwards that value unchanged and never stores it.",
561
588
  "",
562
589
  "This command writes a file and nothing else. Starting, stopping, enabling, and",
563
590
  "removing the service stay operator-managed via launchctl / systemctl --user /",
@@ -636,6 +663,79 @@ export async function writeExecutorServiceArtifact(artifact, deps, knownExists)
636
663
  await deps.writeFile(artifact.path, artifact.content);
637
664
  return { replaced };
638
665
  }
666
+ /** Reject anything that is not an absolute http(s) URL. Never echoes the value. */
667
+ function assertAbsoluteHttpBaseUrl(baseUrl) {
668
+ let parsed;
669
+ try {
670
+ parsed = new URL(baseUrl);
671
+ }
672
+ catch {
673
+ throw new ExecutorServiceValidationError(`${EXECUTOR_SERVICE_BASE_URL_ENV} must be an absolute http(s) URL; ` +
674
+ "the supplied value is not a valid URL");
675
+ }
676
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
677
+ throw new ExecutorServiceValidationError(`${EXECUTOR_SERVICE_BASE_URL_ENV} must use http: or https: (got '${parsed.protocol}')`);
678
+ }
679
+ }
680
+ /**
681
+ * Build the install plan for one executor id. PURE: validates the render input
682
+ * for the target platform and renders the artifact, touching no filesystem, no
683
+ * environment, and no service manager.
684
+ *
685
+ * Throws {@link ExecutorServiceValidationError} for an unsafe executor id, an
686
+ * invalid base URL, an empty repository list, or an unsupported platform —
687
+ * before any caller could reach a write.
688
+ */
689
+ export function planExecutorServiceInstall(input, platform) {
690
+ // The full URL grammar check lives HERE rather than in
691
+ // `validateExecutorServiceRenderInput`, which deliberately only bars
692
+ // whitespace and control characters. `runExecutorInstallServiceCli` already
693
+ // performs this exact check before it reaches a plan, so its behavior is
694
+ // unchanged; a programmatic caller gets the same guarantee without having to
695
+ // remember to re-implement it, and a malformed endpoint can never be baked
696
+ // into a durable unit.
697
+ assertAbsoluteHttpBaseUrl(input.baseUrl);
698
+ const artifact = buildExecutorServiceArtifact(input, platform);
699
+ return {
700
+ platform,
701
+ executorId: input.executorId,
702
+ unitPath: artifact.path,
703
+ serviceManager: platform === "darwin" ? "launchd" : "systemd-user",
704
+ serviceIdentifier: platform === "darwin"
705
+ ? executorLaunchdLabelForId(input.executorId)
706
+ : executorSystemdUnitNameForId(input.executorId),
707
+ artifact,
708
+ };
709
+ }
710
+ /**
711
+ * Write one planned unit. Creates ONLY the unit's parent directory and ONLY the
712
+ * unit file — no lifecycle command, no process spawn, no install-state write.
713
+ *
714
+ * `knownExists` lets a caller that already probed the target (the standalone
715
+ * CLI, which stats it to word its consent prompt) avoid a second `stat`.
716
+ */
717
+ export async function writeExecutorServicePlan(plan, deps, knownExists) {
718
+ const exists = knownExists ?? (await targetExists(deps.stat, plan.unitPath));
719
+ if (exists && deps.readFile) {
720
+ let current = null;
721
+ try {
722
+ current = await deps.readFile(plan.unitPath);
723
+ }
724
+ catch {
725
+ // An unreadable existing file is not an error here — it simply means the
726
+ // content comparison is unavailable, so the write proceeds and reports
727
+ // `replaced` rather than fabricating `unchanged`.
728
+ current = null;
729
+ }
730
+ if (current === plan.artifact.content) {
731
+ return { outcome: "unchanged", unitPath: plan.unitPath, plan };
732
+ }
733
+ }
734
+ const pathApi = pathApiForPlatform(plan.platform);
735
+ await deps.mkdir(pathApi.dirname(plan.unitPath), { recursive: true });
736
+ await deps.writeFile(plan.unitPath, plan.artifact.content);
737
+ return { outcome: exists ? "replaced" : "written", unitPath: plan.unitPath, plan };
738
+ }
639
739
  /** ENOENT means absent; any other rejection is a real failure and propagates. */
640
740
  async function targetExists(stat, filePath) {
641
741
  try {
@@ -757,14 +857,18 @@ export async function runExecutorInstallServiceCli(argv, overrides) {
757
857
  deps.log(formatWindowsGuidance(renderInput, args));
758
858
  return 0;
759
859
  }
760
- let artifact;
860
+ // Delegates to the shared programmatic API (BAPI-779) so the CLI and the
861
+ // conductor render through ONE code path. Consent stays here, in the CLI,
862
+ // exactly as before — the plan/write primitives ask for none.
863
+ let plan;
761
864
  try {
762
- artifact = buildExecutorServiceArtifact(renderInput, deps.platform);
865
+ plan = planExecutorServiceInstall(renderInput, deps.platform);
763
866
  }
764
867
  catch (error) {
765
868
  deps.errorLog(`Error: ${sanitizeError(error)}`);
766
869
  return 1;
767
870
  }
871
+ const artifact = plan.artifact;
768
872
  // --- Dry run: a complete preview, before consent and before any fs call. ---
769
873
  if (flags.dryRun) {
770
874
  deps.log(`Target: ${artifact.path}`);
@@ -803,15 +907,18 @@ export async function runExecutorInstallServiceCli(argv, overrides) {
803
907
  }
804
908
  }
805
909
  // --- Write phase: parent directory + the single unit, nothing else. ---
806
- let replaced = false;
910
+ // The CLI deps carry no `readFile` seam, so the write can only report
911
+ // `written` or `replaced` here — the existing Created/Replaced output
912
+ // contract is preserved exactly.
913
+ let outcome;
807
914
  try {
808
- ({ replaced } = await writeExecutorServiceArtifact(artifact, deps, exists));
915
+ ({ outcome } = await writeExecutorServicePlan(plan, deps, exists));
809
916
  }
810
917
  catch (error) {
811
918
  deps.errorLog(`Error: could not write ${artifact.path}: ${sanitizeError(error)}`);
812
919
  return 1;
813
920
  }
814
- deps.log(`${replaced ? "Replaced" : "Created"} ${artifact.path}`);
921
+ deps.log(`${outcome === "replaced" ? "Replaced" : "Created"} ${artifact.path}`);
815
922
  deps.log("");
816
923
  deps.log(formatPostWriteGuidance(artifact, options.executorId));
817
924
  return 0;
@@ -849,7 +956,9 @@ function formatWindowsGuidance(input, args) {
849
956
  "and configure the task to run at logon and restart on failure.",
850
957
  "",
851
958
  "The command line above carries no credential. Provide the Bridge API key at run time via",
852
- "the BAPI_API_KEY environment variable or the user-scoped bapi:<repo> credential store.",
959
+ "the BAPI_API_KEY environment variable or the user-scoped bapi:<repo> credential store. The",
960
+ "worker authenticates via this host's interactive `claude login`, or, headlessly, via a",
961
+ "CLAUDE_CODE_OAUTH_TOKEN you export into the task's own environment yourself.",
853
962
  ].join("\n");
854
963
  }
855
964
  /** Post-write operator guidance — naming the manual lifecycle step, not running it. */
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Pre-spawn removal of a previous attempt's conductor artifacts (BAPI-814/D1).
3
+ *
4
+ * `.conductor/` is an IGNORED path in this repository, and worktree preparation's
5
+ * `git clean -fd` deliberately omits `-x`, so ignored paths survive it. That
6
+ * turned a stale `result.json` from a housekeeping nuisance into a safety
7
+ * problem: a `spec_review` retry whose worker exits `0` without writing a
8
+ * verdict would have the PREVIOUS attempt's verdict read back and posted
9
+ * verbatim as this attempt's answer. Explicit removal by the attempt owner,
10
+ * holding the worktree lock, is what makes "no verdict written" observable as
11
+ * absence instead.
12
+ *
13
+ * WHY THIS IS ITS OWN MODULE: `verdict-artifact.ts` is pinned by
14
+ * `tests/pytest/mcp/test_executor_job_behaviors_static.py` to contain no `.md`
15
+ * or `markdown` token in its executable code — the "the executor performs NO
16
+ * markdown parsing, NO verdict judgment" invariant. Naming `critique.md` as a
17
+ * deletion target there would trip that guard. Deleting an artifact and
18
+ * interpreting one are genuinely different concerns, so the deletion half lives
19
+ * here and the guard on the reading half stays exactly as strict as it was.
20
+ */
21
+ import { CONDUCTOR_RESULT_DIR, CONDUCTOR_RESULT_FILE } from "./verdict-artifact.js";
22
+ import { pathApiForExecutorPlatform } from "./worktree-inspection.js";
23
+ /** The critique artifact a reviewer worker writes alongside its verdict. */
24
+ export const CONDUCTOR_CRITIQUE_FILE = "critique.md";
25
+ /**
26
+ * The files a prior attempt may leave behind, and the ONLY ones removed here.
27
+ *
28
+ * `worker.lock` and `worker.log` are deliberately absent: the lock is live
29
+ * coordination state, and the log is forensic evidence truncated by its own
30
+ * owner (`worker-log.ts`). The `.conductor/` directory itself and any unrelated
31
+ * file inside it are left untouched.
32
+ */
33
+ export const STALE_ATTEMPT_ARTIFACT_FILES = [
34
+ CONDUCTOR_RESULT_FILE,
35
+ CONDUCTOR_CRITIQUE_FILE,
36
+ ];
37
+ /**
38
+ * Delete a previous attempt's verdict and critique artifacts.
39
+ *
40
+ * A missing file is a successful no-op — that is the normal first-attempt case.
41
+ * Any other filesystem failure (a permission problem, an unexpected errno) is
42
+ * returned as a bounded failure so the caller can stop the attempt *before*
43
+ * spawning: proceeding past a cleanup that did not happen is exactly the state
44
+ * this function exists to prevent. The returned reason names the file and the
45
+ * error code only — never the exception text, the absolute path, or file
46
+ * contents.
47
+ */
48
+ export async function removeStaleVerdictArtifacts(worktreePath, deps) {
49
+ const pathApi = pathApiForExecutorPlatform(deps.platform ?? process.platform);
50
+ for (const file of STALE_ATTEMPT_ARTIFACT_FILES) {
51
+ const target = pathApi.join(worktreePath, CONDUCTOR_RESULT_DIR, file);
52
+ try {
53
+ await deps.removeFile(target);
54
+ }
55
+ catch (err) {
56
+ const raw = err && typeof err === "object" ? err.code : undefined;
57
+ // Absence is the expected first-attempt state, not a failure.
58
+ if (raw === "ENOENT")
59
+ continue;
60
+ // A blank code is as uninformative as a missing one; both must render the
61
+ // same words rather than an empty pair of parentheses.
62
+ const code = typeof raw === "string" && raw.trim().length > 0 ? raw : "unknown error";
63
+ return {
64
+ ok: false,
65
+ reason: `stale ${CONDUCTOR_RESULT_DIR}/${file} could not be removed (${code})`,
66
+ };
67
+ }
68
+ }
69
+ return { ok: true };
70
+ }