@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,302 @@
1
+ /**
2
+ * Per-epic mutual exclusion for the mutating `conduct-epic` verbs (BAPI-803).
3
+ *
4
+ * `checkpoint set`, `spawn`, and `finish` all read-modify-write one JSON
5
+ * document. Two overlapping invocations — an operator running `checkpoint set`
6
+ * by hand while the loop ticks, a second `/conduct-epic` session started in
7
+ * another terminal — would otherwise interleave and lose one side's write. This
8
+ * file is the local backstop for that: one lock file beside the checkpoint,
9
+ * created with an exclusive `open(..., "wx")`, carrying the owner's identity.
10
+ *
11
+ * The design is COPIED from `executor/worktree-lock.ts` rather than imported.
12
+ * That module's ownership identity is a server-minted claim token for an
13
+ * `executor_jobs` row, which has no meaning here; conduct-epic's owner is a
14
+ * `(pid, host, acquired_at)` triple. Sharing the file would mean generalizing a
15
+ * lock that is load-bearing for a different subsystem.
16
+ *
17
+ * Four properties, each with a failure it exists to prevent:
18
+ *
19
+ * - ATOMIC ACQUISITION. `open(..., "wx")` creates or fails `EEXIST` with no
20
+ * observable window. An exists-then-write pair would reintroduce the race.
21
+ * - FAIL CLOSED. A remote-host owner, a malformed payload, an unreadable file,
22
+ * a foreign schema version, or an indeterminate liveness probe all REFUSE.
23
+ * Refusing costs one invocation; guessing costs a lost checkpoint write.
24
+ * - RECOVERY IS PROVEN, NOT ASSUMED. Only `ESRCH` on the current host proves a
25
+ * dead owner, and even then the bytes are re-read and compared before removal
26
+ * so a successor's lock cannot be deleted in the gap.
27
+ * - RELEASE IS OWNERSHIP-CHECKED. `release()` removes the file only while the
28
+ * persisted pid/host/acquired_at still match the handle it was granted.
29
+ *
30
+ * No credential, checkpoint content, or file payload is ever logged or returned.
31
+ */
32
+ /** Lock metadata schema version; a foreign version fails closed. */
33
+ export const CONDUCT_EPIC_LOCK_VERSION = 1;
34
+ /**
35
+ * Hard bound on a lock file we are willing to parse. A lock is ~150 bytes;
36
+ * anything larger is not our metadata, and parsing it would let an unrelated
37
+ * file at that path drive this module's behavior.
38
+ */
39
+ export const CONDUCT_EPIC_LOCK_MAX_CHARS = 4_096;
40
+ /** How many times acquisition may recover a dead owner's lock and retry. */
41
+ export const CONDUCT_EPIC_LOCK_MAX_ATTEMPTS = 3;
42
+ function errorCode(err) {
43
+ const code = err?.code;
44
+ return typeof code === "string" ? code : undefined;
45
+ }
46
+ /**
47
+ * Parse lock metadata, or `null` when it is not a record this version
48
+ * understands. Bounded BEFORE parsing, and every field type-checked: a
49
+ * partially-written or foreign file must never be mistaken for a releasable lock.
50
+ */
51
+ export function parseConductEpicLock(raw) {
52
+ if (typeof raw !== "string" || raw.length === 0 || raw.length > CONDUCT_EPIC_LOCK_MAX_CHARS) {
53
+ return null;
54
+ }
55
+ let value;
56
+ try {
57
+ value = JSON.parse(raw);
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ if (typeof value !== "object" || value === null || Array.isArray(value))
63
+ return null;
64
+ const record = value;
65
+ const { version, owner_pid: ownerPid, host, acquired_at: acquiredAt } = record;
66
+ if (version !== CONDUCT_EPIC_LOCK_VERSION)
67
+ return null;
68
+ if (typeof ownerPid !== "number" || !Number.isInteger(ownerPid) || ownerPid <= 0)
69
+ return null;
70
+ if (typeof host !== "string" || host.length === 0)
71
+ return null;
72
+ if (typeof acquiredAt !== "string" || acquiredAt.length === 0)
73
+ return null;
74
+ return { version, owner_pid: ownerPid, host, acquired_at: acquiredAt };
75
+ }
76
+ /**
77
+ * Real liveness probe. `process.kill(pid, 0)` signals nothing and only reports
78
+ * reachability. Only an explicit `ESRCH` proves the owner is gone: `EPERM` means
79
+ * the process exists under another user, and an unrecognized failure means we do
80
+ * not know — both are ALIVE, so an uncertain probe can never authorize stealing
81
+ * a lock.
82
+ */
83
+ export function isConductEpicLockOwnerAlive(pid) {
84
+ if (!Number.isInteger(pid) || pid <= 0)
85
+ return true;
86
+ try {
87
+ process.kill(pid, 0);
88
+ return true;
89
+ }
90
+ catch (err) {
91
+ return errorCode(err) !== "ESRCH";
92
+ }
93
+ }
94
+ /** Serialize ownership metadata. Only the four approved fields are written. */
95
+ function serializeLock(request) {
96
+ const owner = {
97
+ version: CONDUCT_EPIC_LOCK_VERSION,
98
+ owner_pid: request.ownerPid,
99
+ host: request.host,
100
+ acquired_at: request.acquiredAt,
101
+ };
102
+ return JSON.stringify(owner);
103
+ }
104
+ /**
105
+ * Classify the lock at `lockPath` WITHOUT mutating it.
106
+ *
107
+ * `status` and `doctor` are read-only surfaces and use only this; the mutating
108
+ * verbs call it first so they can report a precise refusal reason before
109
+ * attempting acquisition.
110
+ */
111
+ export async function inspectConductEpicLock(lockPath, request, seams = {}) {
112
+ const read = seams.readFile ?? defaultReadFile;
113
+ const isAlive = seams.isProcessAlive ?? isConductEpicLockOwnerAlive;
114
+ let raw;
115
+ try {
116
+ raw = await read(lockPath);
117
+ }
118
+ catch (err) {
119
+ if (errorCode(err) === "ENOENT")
120
+ return { kind: "missing" };
121
+ return { kind: "unknown", reason: "the lock file exists but could not be read" };
122
+ }
123
+ const owner = parseConductEpicLock(raw);
124
+ if (owner === null) {
125
+ return {
126
+ kind: "unknown",
127
+ reason: "the lock file is malformed, oversized, or written by an unsupported version",
128
+ };
129
+ }
130
+ if (owner.host !== request.host)
131
+ return { kind: "remote-host", owner };
132
+ if (owner.owner_pid === request.ownerPid)
133
+ return { kind: "owned", owner };
134
+ if (isAlive(owner.owner_pid))
135
+ return { kind: "live-foreign", owner };
136
+ return { kind: "dead-local", owner };
137
+ }
138
+ /* Real filesystem defaults, imported lazily so a test that injects every seam
139
+ * never loads `node:fs/promises` behavior it does not use. */
140
+ async function defaultReadFile(filePath) {
141
+ const { readFile } = await import("node:fs/promises");
142
+ return readFile(filePath, "utf-8");
143
+ }
144
+ async function defaultWriteFileExclusive(filePath, data) {
145
+ const { open } = await import("node:fs/promises");
146
+ const handle = await open(filePath, "wx", 0o600);
147
+ try {
148
+ await handle.writeFile(data, "utf-8");
149
+ }
150
+ finally {
151
+ await handle.close();
152
+ }
153
+ }
154
+ async function defaultRemoveFile(filePath) {
155
+ const { rm } = await import("node:fs/promises");
156
+ await rm(filePath, { force: true });
157
+ }
158
+ async function defaultMkdir(dirPath, options) {
159
+ const { mkdir } = await import("node:fs/promises");
160
+ return mkdir(dirPath, options);
161
+ }
162
+ /**
163
+ * Acquire the per-epic lock at `lockPath`.
164
+ *
165
+ * Returns an acquired handle, or a refusal naming the sanitized reason. A lock
166
+ * whose owning pid is provably gone ON THIS HOST is recovered — but only through
167
+ * a re-read-then-compare sequence, so recovery cannot delete a lock a successor
168
+ * wrote between our read and our unlink. Recovery and exclusive-create
169
+ * contention are together bounded to {@link CONDUCT_EPIC_LOCK_MAX_ATTEMPTS}.
170
+ */
171
+ export async function acquireConductEpicLock(lockPath, request, seams = {}) {
172
+ const writeExclusive = seams.writeFileExclusive ?? defaultWriteFileExclusive;
173
+ const read = seams.readFile ?? defaultReadFile;
174
+ const remove = seams.removeFile ?? defaultRemoveFile;
175
+ const makeDir = seams.mkdir ?? defaultMkdir;
176
+ const isAlive = seams.isProcessAlive ?? isConductEpicLockOwnerAlive;
177
+ const refused = (reason, owner) => ({
178
+ acquired: false,
179
+ lockPath,
180
+ reason,
181
+ owner,
182
+ });
183
+ const { dirname } = await import("node:path");
184
+ try {
185
+ await makeDir(dirname(lockPath), { recursive: true });
186
+ }
187
+ catch {
188
+ // An existing directory is the normal case and `recursive: true` makes it a
189
+ // no-op. Any other failure surfaces below as a failed exclusive create,
190
+ // which refuses. Never proceed as though the lock were held by us.
191
+ }
192
+ const payload = serializeLock(request);
193
+ for (let attempt = 0; attempt < CONDUCT_EPIC_LOCK_MAX_ATTEMPTS; attempt += 1) {
194
+ try {
195
+ await writeExclusive(lockPath, payload);
196
+ const owner = parseConductEpicLock(payload);
197
+ // Unreachable in practice — we just serialized it — but typing the handle
198
+ // on a parsed record keeps the file the single source of truth for what a
199
+ // lock IS.
200
+ if (owner === null)
201
+ return refused("the lock payload could not be re-parsed", null);
202
+ return {
203
+ acquired: true,
204
+ lockPath,
205
+ owner,
206
+ release: () => releaseConductEpicLock(lockPath, owner, { readFile: read, removeFile: remove }),
207
+ };
208
+ }
209
+ catch (err) {
210
+ if (errorCode(err) !== "EEXIST") {
211
+ // Not contention: a permission problem, a read-only directory, a
212
+ // vanished parent. We do not hold the lock, so we do not proceed.
213
+ return refused("the lock file could not be created", null);
214
+ }
215
+ }
216
+ let raw;
217
+ try {
218
+ raw = await read(lockPath);
219
+ }
220
+ catch (err) {
221
+ // Released between our failed create and this read: retry the exclusive
222
+ // create. Anything else is unreadable state — fail closed.
223
+ if (errorCode(err) === "ENOENT")
224
+ continue;
225
+ return refused("the lock file exists but could not be read", null);
226
+ }
227
+ const owner = parseConductEpicLock(raw);
228
+ if (owner === null) {
229
+ return refused("the lock file is malformed, oversized, or written by an unsupported version", null);
230
+ }
231
+ if (owner.host !== request.host) {
232
+ return refused(`the lock is held by host ${owner.host}; liveness cannot be checked from here`, owner);
233
+ }
234
+ if (owner.owner_pid === request.ownerPid) {
235
+ // Our own lock from earlier in this same process. Adopt it rather than
236
+ // deadlocking against ourselves.
237
+ return {
238
+ acquired: true,
239
+ lockPath,
240
+ owner,
241
+ release: () => releaseConductEpicLock(lockPath, owner, { readFile: read, removeFile: remove }),
242
+ };
243
+ }
244
+ if (isAlive(owner.owner_pid)) {
245
+ return refused(`the lock is held by live process ${owner.owner_pid} on ${owner.host}`, owner);
246
+ }
247
+ // Dead owner. Recover only if the bytes we are about to delete are still the
248
+ // exact bytes we read and judged stale.
249
+ let confirmation;
250
+ try {
251
+ confirmation = await read(lockPath);
252
+ }
253
+ catch (err) {
254
+ if (errorCode(err) === "ENOENT")
255
+ continue;
256
+ return refused("the lock file exists but could not be re-read", null);
257
+ }
258
+ if (confirmation !== raw)
259
+ continue;
260
+ try {
261
+ await remove(lockPath);
262
+ }
263
+ catch {
264
+ return refused("a stale lock could not be removed", owner);
265
+ }
266
+ }
267
+ return refused(`the lock could not be acquired after ${CONDUCT_EPIC_LOCK_MAX_ATTEMPTS} attempts`, null);
268
+ }
269
+ /**
270
+ * Remove the lock only while `owner` is still exactly what the file records.
271
+ *
272
+ * Best-effort by design: a failed release leaves a lock whose pid is this
273
+ * (exiting) process, which the next acquisition recovers as stale. Throwing here
274
+ * would turn a cleanup hiccup into a command failure after the work succeeded.
275
+ */
276
+ export async function releaseConductEpicLock(lockPath, owner, seams = {}) {
277
+ const read = seams.readFile ?? defaultReadFile;
278
+ const remove = seams.removeFile ?? defaultRemoveFile;
279
+ let raw;
280
+ try {
281
+ raw = await read(lockPath);
282
+ }
283
+ catch {
284
+ return;
285
+ }
286
+ const current = parseConductEpicLock(raw);
287
+ if (current === null)
288
+ return;
289
+ if (current.owner_pid !== owner.owner_pid ||
290
+ current.host !== owner.host ||
291
+ current.acquired_at !== owner.acquired_at) {
292
+ // A successor replaced our lock. Removing it here is exactly the bug the
293
+ // ownership check exists to prevent.
294
+ return;
295
+ }
296
+ try {
297
+ await remove(lockPath);
298
+ }
299
+ catch {
300
+ /* best-effort */
301
+ }
302
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * `gh`-backed PR state parsing and ticket branch/worktree discovery (BAPI-803).
3
+ *
4
+ * Two jobs, both deliberately kept out of the frozen v1 runtime:
5
+ *
6
+ * **PR state.** The conduct-epic loop must SEE a `MERGED` or `CLOSED` PR — that
7
+ * is how it detects an externally merged ticket and stops re-driving it.
8
+ * `conductor/epic-runtime.ts#parsePrBindingFromGhJson` fails closed on any
9
+ * non-`OPEN` state, which is correct for binding a merge action and exactly
10
+ * wrong here, and importing it would pull the whole v1 runtime along. So this
11
+ * module parses the same `gh pr view` payload with a different, terminal-state-
12
+ * preserving contract, while REUSING the shared mergeability normalizer so a
13
+ * conflict is classified identically everywhere.
14
+ *
15
+ * **Worktree discovery.** `/review-and-start` names branches
16
+ * `feature/<KEY>-<slug>` and the loop cannot know the slug. When
17
+ * `ticket.branch` is null, `status` and `spawn` scan `git worktree list
18
+ * --porcelain` and match by precedence: the stored branch, then exactly
19
+ * `feature/<KEY>`, then a branch beginning `feature/<KEY>-`. The `-` is required
20
+ * so `feature/BAPI-8010` never matches ticket `BAPI-801`.
21
+ *
22
+ * Every probe is NON-THROWING and returns a sanitized reason. Raw `gh`/`git`
23
+ * stdout and stderr never reach a result, a log line, or an error message.
24
+ */
25
+ import { isPrMergeConflict, parseGhPrMergeabilityFields } from "../conductor/github-mergeability.js";
26
+ import { runGhCommand } from "../conductor/pr-discovery.js";
27
+ /** The `gh pr view --json` field list the status contract fixes. */
28
+ export const CONDUCT_EPIC_GH_PR_VIEW_FIELDS = "number,state,headRefOid,mergeable,mergeStateStatus,baseRefName,updatedAt";
29
+ /** The accepted GitHub PR states. Terminal states are RETAINED, not filtered. */
30
+ export const CONDUCT_EPIC_PR_STATES = ["OPEN", "MERGED", "CLOSED"];
31
+ function isRecord(value) {
32
+ return typeof value === "object" && value !== null && !Array.isArray(value);
33
+ }
34
+ /** A trimmed non-empty string, or `null` for every other shape. */
35
+ function text(value) {
36
+ if (typeof value !== "string")
37
+ return null;
38
+ const trimmed = value.trim();
39
+ return trimmed.length === 0 ? null : trimmed;
40
+ }
41
+ /**
42
+ * Normalize an already-parsed `gh pr view` record.
43
+ *
44
+ * Never throws and never partially fails: an unknown state, a non-numeric
45
+ * number, or a missing SHA each become `null` on their own field while the rest
46
+ * of the record survives. A PR whose `state` we cannot read is still worth
47
+ * reporting — its head SHA and mergeability are what the loop acts on.
48
+ */
49
+ export function parseConductEpicPrState(value) {
50
+ if (!isRecord(value))
51
+ return null;
52
+ const rawNumber = value.number;
53
+ const number = typeof rawNumber === "number" && Number.isInteger(rawNumber) && rawNumber > 0 ? rawNumber : null;
54
+ const rawState = text(value.state);
55
+ const upper = rawState === null ? null : rawState.toUpperCase();
56
+ const state = CONDUCT_EPIC_PR_STATES.includes(upper ?? "")
57
+ ? upper
58
+ : null;
59
+ const mergeability = parseGhPrMergeabilityFields(value);
60
+ return {
61
+ number,
62
+ state,
63
+ head_sha: text(value.headRefOid),
64
+ base: text(value.baseRefName),
65
+ mergeable: mergeability.mergeable,
66
+ merge_state: mergeability.mergeStateStatus,
67
+ updated_at: text(value.updatedAt),
68
+ };
69
+ }
70
+ /**
71
+ * True for the two authoritative GitHub conflict signals. Delegates to the
72
+ * shared predicate so PR discovery, the done gate, and this loop agree.
73
+ */
74
+ export function conductEpicPrHasConflict(pr) {
75
+ return isPrMergeConflict({ mergeable: pr.mergeable, mergeStateStatus: pr.merge_state });
76
+ }
77
+ /**
78
+ * Run `gh pr view <branch> --json …` and normalize the result.
79
+ *
80
+ * Nothing throws. The three outcomes are kept strictly apart:
81
+ *
82
+ * - **`ok`** — `gh` succeeded and returned a parseable PR record.
83
+ * - **`none`** — `gh` SUCCEEDED and returned nothing. Confirmed absence.
84
+ * - **`error`** — `gh` could not be run, exited non-zero, or returned output
85
+ * that is not a valid PR record. Unavailable evidence, not absence.
86
+ *
87
+ * Every `error` reason is a fixed bounded string chosen here. The raw output is
88
+ * never included — a `gh` failure message can echo a URL, a token hint, or the
89
+ * repository layout, none of which belongs in a status object the loop prints.
90
+ */
91
+ export async function discoverConductEpicPrState(branch, options = {}) {
92
+ const runGh = options.runGh ?? runGhCommand;
93
+ let result;
94
+ try {
95
+ result = await runGh(["pr", "view", branch, "--json", CONDUCT_EPIC_GH_PR_VIEW_FIELDS], {
96
+ cwd: options.cwd,
97
+ });
98
+ }
99
+ catch {
100
+ return { kind: "error", reason: "the gh command could not be run" };
101
+ }
102
+ // A non-zero `gh` exit is a FAILED PROBE, never "no PR" (BAPI-814/B2).
103
+ //
104
+ // `gh` exits non-zero both when the branch genuinely has no pull request and
105
+ // when it is unauthenticated, rate-limited, or offline, and this module
106
+ // deliberately does not parse its stderr to tell those apart. Reporting the
107
+ // ambiguity as absence is the dangerous half of that choice: the loop reads a
108
+ // GitHub outage as "no PR yet" and keeps waiting on a pull request it can no
109
+ // longer see. Reporting it as a failed probe is the safe half — the status
110
+ // contract turns that into `pr: null` PLUS a `probe_errors` entry, which the
111
+ // command treats as unavailable evidence rather than a negative result.
112
+ //
113
+ // The reason is a FIXED string. `gh`'s own output can echo a URL, a token
114
+ // hint, or the repository layout, so none of it is interpolated here.
115
+ if (!result.ok)
116
+ return { kind: "error", reason: "the gh command failed" };
117
+ // A SUCCESSFUL command with empty output is genuine absence: `gh` ran, looked,
118
+ // and found nothing. That stays distinct from the failure above.
119
+ const raw = typeof result.stdout === "string" ? result.stdout.trim() : "";
120
+ if (raw.length === 0)
121
+ return { kind: "none" };
122
+ let parsed;
123
+ try {
124
+ parsed = JSON.parse(raw);
125
+ }
126
+ catch {
127
+ return { kind: "error", reason: "gh returned output that is not valid JSON" };
128
+ }
129
+ const pr = parseConductEpicPrState(parsed);
130
+ if (pr === null)
131
+ return { kind: "error", reason: "gh returned a PR record in an unexpected shape" };
132
+ return { kind: "ok", pr };
133
+ }
134
+ /**
135
+ * Parse `git worktree list --porcelain` into branch-attached entries.
136
+ *
137
+ * Detached worktrees and records without a resolvable `refs/heads/<name>` are
138
+ * dropped: a worktree with no branch cannot be the ticket's worktree, and
139
+ * carrying it forward would only make the precedence rules below ambiguous.
140
+ */
141
+ export function parseGitWorktreePorcelain(output) {
142
+ const entries = [];
143
+ let currentPath = null;
144
+ let currentBranch = null;
145
+ const flush = () => {
146
+ if (currentPath !== null && currentBranch !== null) {
147
+ entries.push({ path: currentPath, branch: currentBranch });
148
+ }
149
+ currentPath = null;
150
+ currentBranch = null;
151
+ };
152
+ for (const rawLine of String(output ?? "").split("\n")) {
153
+ const line = rawLine.replace(/\r$/, "");
154
+ if (line.startsWith("worktree ")) {
155
+ flush();
156
+ const value = line.slice("worktree ".length).trim();
157
+ currentPath = value.length > 0 ? value : null;
158
+ }
159
+ else if (line.startsWith("branch ") && currentPath !== null) {
160
+ const ref = line.slice("branch ".length).trim();
161
+ if (ref.startsWith("refs/heads/")) {
162
+ const name = ref.slice("refs/heads/".length);
163
+ currentBranch = name.length > 0 ? name : null;
164
+ }
165
+ }
166
+ }
167
+ flush();
168
+ return entries;
169
+ }
170
+ /**
171
+ * Resolve the worktree for `ticketKey`, in strict precedence order:
172
+ *
173
+ * 1. the branch already stored on the ticket;
174
+ * 2. exactly `feature/<KEY>`;
175
+ * 3. the first branch beginning `feature/<KEY>-` in porcelain order.
176
+ *
177
+ * The later rules are a FALLBACK, not an alternative: a stored branch whose
178
+ * worktree was removed still resolves through the naming convention, which is
179
+ * what lets the loop survive an operator pruning worktrees mid-run.
180
+ *
181
+ * Returns `null` rather than guessing. Persisting a discovered branch is the
182
+ * CALLER's decision — `status` may write it, and a pure resolver that also wrote
183
+ * to the checkpoint would make the read-only surface a mutating one.
184
+ */
185
+ export function discoverTicketWorktree(entries, ticketKey, storedBranch) {
186
+ if (storedBranch !== null && storedBranch.length > 0) {
187
+ const stored = entries.find((entry) => entry.branch === storedBranch);
188
+ if (stored)
189
+ return { branch: stored.branch, path: stored.path };
190
+ }
191
+ const canonical = `feature/${ticketKey}`;
192
+ const exact = entries.find((entry) => entry.branch === canonical);
193
+ if (exact)
194
+ return { branch: exact.branch, path: exact.path };
195
+ const prefixed = entries.find((entry) => entry.branch.startsWith(`${canonical}-`));
196
+ return prefixed ? { branch: prefixed.branch, path: prefixed.path } : null;
197
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Secret-free agent-tab spawning for `conduct-epic spawn` (BAPI-803).
3
+ *
4
+ * `spawn` opens exactly ONE terminal tab in a ticket's worktree running the
5
+ * agent against a prompt the CALLER wrote to a file. The prompt is arbitrary
6
+ * operator/loop-authored text — it can contain apostrophes, `$(…)`, backticks,
7
+ * and newlines — so it is placed inside a single-quoted shell literal using the
8
+ * repository's existing quoting helpers rather than any local escaping. Reusing
9
+ * `shSquoteInner` / `powershellSquote` is the point: a second escaping
10
+ * implementation is a second place for a quoting bug to produce shell execution.
11
+ *
12
+ * Two invariants this module is responsible for:
13
+ *
14
+ * - **Exactly one tab, and only after every check passes.** The spawn seam is
15
+ * invoked once, at the end, after the agent is validated and the command is
16
+ * built. A partial failure must leave no tab, because the caller increments
17
+ * `sessions_spawned` on success and a phantom tab would desynchronize it.
18
+ * - **No credential reaches the command.** Nothing here resolves, receives, or
19
+ * formats Bridge access data. The spawned agent resolves its own credentials
20
+ * from the user-scoped store, exactly as `start-tickets` workers do; a key in
21
+ * argv would be visible to every process on the machine via `ps`.
22
+ */
23
+ import { powershellSquote, shSquoteInner, } from "../start-tickets.js";
24
+ /** The agents `conduct-epic spawn` may launch. */
25
+ export const CONDUCT_EPIC_AGENTS = ["claude", "cursor-agent"];
26
+ /** The repository's established default agent. */
27
+ export const CONDUCT_EPIC_DEFAULT_AGENT = "claude";
28
+ /**
29
+ * Validate an agent value, defaulting to {@link CONDUCT_EPIC_DEFAULT_AGENT}.
30
+ *
31
+ * Rejects before any command is built. The agent name becomes the executable in
32
+ * a shell command, so an unvalidated value is a command-injection surface even
33
+ * though the surrounding operands are quoted.
34
+ */
35
+ export function resolveConductEpicAgent(agent) {
36
+ if (agent === undefined || agent.trim().length === 0) {
37
+ return { ok: true, agent: CONDUCT_EPIC_DEFAULT_AGENT };
38
+ }
39
+ const trimmed = agent.trim();
40
+ if (!CONDUCT_EPIC_AGENTS.includes(trimmed)) {
41
+ return {
42
+ ok: false,
43
+ error: `Unsupported agent '${trimmed}'. Expected one of: ${CONDUCT_EPIC_AGENTS.join(", ")}`,
44
+ };
45
+ }
46
+ return { ok: true, agent: trimmed };
47
+ }
48
+ /**
49
+ * Build `cd '<worktree>' && <agent> '<prompt>'` (POSIX) or the PowerShell
50
+ * equivalent, with both operands quoted by the shared helpers.
51
+ *
52
+ * Returns the command as a string rather than an argv array because that is what
53
+ * every terminal spawner in this repository consumes — a tab is opened by handing
54
+ * a shell a command line, not by `exec`ing a process.
55
+ */
56
+ export function buildConductEpicAgentCommand(input) {
57
+ const resolved = resolveConductEpicAgent(input.agent);
58
+ if (!resolved.ok)
59
+ return resolved;
60
+ if (input.worktreePath.trim().length === 0) {
61
+ return { ok: false, error: "A worktree path is required to build the agent command." };
62
+ }
63
+ if (input.platform === "win32") {
64
+ return {
65
+ ok: true,
66
+ command: `Set-Location -LiteralPath ${powershellSquote(input.worktreePath)}; ` +
67
+ `${resolved.agent} ${powershellSquote(input.prompt)}`,
68
+ };
69
+ }
70
+ return {
71
+ ok: true,
72
+ command: `cd '${shSquoteInner(input.worktreePath)}' && ` +
73
+ `${resolved.agent} '${shSquoteInner(input.prompt)}'`,
74
+ };
75
+ }
76
+ /**
77
+ * Build the command and open exactly one tab through `spawnTab`.
78
+ *
79
+ * The seam is invoked once and only after construction succeeds. A spawner
80
+ * failure is returned as `{ ok: false }` with the spawner's own sanitized
81
+ * message — never the command, which embeds the full prompt text.
82
+ */
83
+ export async function spawnConductEpicAgentTab(input, spawnTab) {
84
+ const built = buildConductEpicAgentCommand(input);
85
+ if (!built.ok)
86
+ return built;
87
+ let result;
88
+ try {
89
+ result = await spawnTab(built.command, {
90
+ key: input.ticketKey,
91
+ worktreePath: input.worktreePath,
92
+ title: `conduct-epic ${input.ticketKey}`,
93
+ });
94
+ }
95
+ catch {
96
+ return { ok: false, error: "The terminal tab could not be opened." };
97
+ }
98
+ if (!result.ok)
99
+ return { ok: false, error: result.error };
100
+ return { ok: true, command: built.command };
101
+ }
@@ -1418,6 +1418,12 @@ export async function transitionJiraStatus(access, ticketNumber, targetStatus =
1418
1418
  throw err;
1419
1419
  }
1420
1420
  }
1421
+ export const CONDUCTOR_REVIEW_ALIGNMENT_STATUSES = new Set([
1422
+ "aligned",
1423
+ "divergent",
1424
+ "not_configured",
1425
+ "invalid",
1426
+ ]);
1421
1427
  const READINESS_SOURCES = new Set(["epic", "project_default", "none"]);
1422
1428
  const ACTIONS_LEVELS = new Set(["write", "read", "none", "unknown"]);
1423
1429
  function readinessShapeError() {
@@ -1469,6 +1475,31 @@ function requireNullableBool(o, key) {
1469
1475
  throw readinessShapeError();
1470
1476
  return v;
1471
1477
  }
1478
+ /**
1479
+ * Parse the optional alignment block (BAPI-694).
1480
+ *
1481
+ * ABSENT — the key is missing or null — yields `null`, which is how a CLI newer
1482
+ * than the server degrades: alignment is advisory, and hard-failing a readiness
1483
+ * read over a field the server has never heard of would break every operator on
1484
+ * an older deploy. PRESENT-but-malformed still throws, because a body that
1485
+ * claims to carry an alignment verdict and cannot be read is exactly the case
1486
+ * the fail-closed parser exists for.
1487
+ */
1488
+ function parseReviewPolicyAlignment(o) {
1489
+ const raw = o.review_policy_alignment;
1490
+ if (raw === undefined || raw === null)
1491
+ return null;
1492
+ const a = requireObject(raw);
1493
+ const explanation = a.explanation;
1494
+ if (typeof explanation !== "string")
1495
+ throw readinessShapeError();
1496
+ return {
1497
+ status: requireEnum(a, "status", CONDUCTOR_REVIEW_ALIGNMENT_STATUSES),
1498
+ repo_review_signal: requireNullableString(a, "repo_review_signal"),
1499
+ done_gate_review_signal: requireNullableString(a, "done_gate_review_signal"),
1500
+ explanation,
1501
+ };
1502
+ }
1472
1503
  function requireEnum(o, key, allowed) {
1473
1504
  const v = o[key];
1474
1505
  if (typeof v !== "string" || !allowed.has(v))
@@ -1509,6 +1540,9 @@ export function parseConductorReadinessResponse(body) {
1509
1540
  required_checks_empty: requireBool(sup, "required_checks_empty"),
1510
1541
  auto_merge_enabled: requireBool(sup, "auto_merge_enabled"),
1511
1542
  merge_approval_required_set: requireBool(sup, "merge_approval_required_set"),
1543
+ review_policy_alignment: parseReviewPolicyAlignment(sup),
1544
+ // Absent on a pre-BAPI-694 server, where no repository default can exist.
1545
+ review_policy_present: sup.review_policy_present === true,
1512
1546
  },
1513
1547
  github: {
1514
1548
  credentials_readable: requireBool(gh, "credentials_readable"),
@@ -1582,8 +1616,9 @@ export function parseConductorSupervisorBootstrapResponse(body) {
1582
1616
  /**
1583
1617
  * POST `/jira/epic-runs/supervisor-bootstrap?repo_name=<repo>`.
1584
1618
  *
1585
- * The ONLY write this client performs. Deliberately carries no `review_policy`
1586
- * field: repository-default review policy is owned by BAPI-694.
1619
+ * The ONLY write this client performs. Since BAPI-694 it may also carry the
1620
+ * optional repository-default `review_policy`; omitting that field stores no
1621
+ * default rather than a neutral one.
1587
1622
  */
1588
1623
  export async function bootstrapConductorSupervisorDefaults(access, request, fetchImpl) {
1589
1624
  const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/supervisor-bootstrap`, {