@bridge_gpt/mcp-server 0.2.38 → 0.2.41

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 (85) hide show
  1. package/README.md +189 -14
  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 +479 -0
  14. package/build/conduct-epic/cli.js +1765 -0
  15. package/build/conduct-epic/lock.js +302 -0
  16. package/build/conduct-epic/pr-state.js +286 -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/permissions.js +25 -6
  67. package/build/sfcc/reads-site-preference.js +6 -0
  68. package/build/sfcc/register.js +61 -23
  69. package/build/sfcc/registration-inventory.js +89 -0
  70. package/build/sfcc/setup-status.js +18 -34
  71. package/build/sfcc/tool-wrapper.js +294 -17
  72. package/build/sfcc/write-grants.js +33 -1
  73. package/build/sfcc/write-guard.js +41 -12
  74. package/build/sfcc/writes-custom-object-def.js +6 -2
  75. package/build/sfcc/writes-site-preference.js +6 -1
  76. package/build/sfcc/writes-system-object.js +11 -2
  77. package/build/sfcc/writes.js +13 -8
  78. package/build/start-tickets-prereqs.js +25 -15
  79. package/build/start-tickets.js +123 -21
  80. package/build/version.generated.js +1 -1
  81. package/build/worktree-core.js +9 -3
  82. package/docs/install/mcp-tool-integrations.md +54 -9
  83. package/docs/install/sfcc-integration.md +71 -24
  84. package/package.json +3 -3
  85. 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,286 @@
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 `gh` PR records with a different, terminal-state-preserving
12
+ * contract, while REUSING the shared mergeability normalizer so a conflict is
13
+ * classified identically everywhere.
14
+ *
15
+ * The probe reads `gh pr list --head <branch>`, whose output is an ARRAY, and
16
+ * selects one record from it. `gh pr view` was replaced because it cannot
17
+ * express "no pull request" as anything but a non-zero exit (BAPI-825/B1).
18
+ *
19
+ * **Worktree discovery.** `/review-and-start` names branches
20
+ * `feature/<KEY>-<slug>` and the loop cannot know the slug. When
21
+ * `ticket.branch` is null, `status` and `spawn` scan `git worktree list
22
+ * --porcelain` and match by precedence: the stored branch, then exactly
23
+ * `feature/<KEY>`, then a branch beginning `feature/<KEY>-`. The `-` is required
24
+ * so `feature/BAPI-8010` never matches ticket `BAPI-801`.
25
+ *
26
+ * Every probe is NON-THROWING and returns a sanitized reason. Raw `gh`/`git`
27
+ * stdout and stderr never reach a result, a log line, or an error message.
28
+ */
29
+ import { isPrMergeConflict, parseGhPrMergeabilityFields } from "../conductor/github-mergeability.js";
30
+ import { runGhCommand } from "../conductor/pr-discovery.js";
31
+ /**
32
+ * The `--json` field list the status contract fixes.
33
+ *
34
+ * Passed to `gh pr list` (BAPI-825/B1), which returns an ARRAY of records with
35
+ * exactly these keys. The name is retained because it is the published contract
36
+ * identifier and is asserted by name in the tests; only the subcommand changed.
37
+ */
38
+ export const CONDUCT_EPIC_GH_PR_VIEW_FIELDS = "number,state,headRefOid,mergeable,mergeStateStatus,baseRefName,updatedAt";
39
+ /**
40
+ * How many candidate pull requests the probe asks `gh` for.
41
+ *
42
+ * More than one, deliberately: a branch routinely carries a closed pull request
43
+ * plus a reopened one, and `--limit 1` would hand back whichever `gh` ordered
44
+ * first rather than letting {@link selectConductEpicPrState} prefer the OPEN
45
+ * one. Bounded so a branch with a long pull-request history cannot turn one
46
+ * probe into an unbounded read.
47
+ */
48
+ export const CONDUCT_EPIC_GH_PR_LIST_LIMIT = 20;
49
+ /** The accepted GitHub PR states. Terminal states are RETAINED, not filtered. */
50
+ export const CONDUCT_EPIC_PR_STATES = ["OPEN", "MERGED", "CLOSED"];
51
+ function isRecord(value) {
52
+ return typeof value === "object" && value !== null && !Array.isArray(value);
53
+ }
54
+ /** A trimmed non-empty string, or `null` for every other shape. */
55
+ function text(value) {
56
+ if (typeof value !== "string")
57
+ return null;
58
+ const trimmed = value.trim();
59
+ return trimmed.length === 0 ? null : trimmed;
60
+ }
61
+ /**
62
+ * Normalize an already-parsed `gh pr view` record.
63
+ *
64
+ * Never throws and never partially fails: an unknown state, a non-numeric
65
+ * number, or a missing SHA each become `null` on their own field while the rest
66
+ * of the record survives. A PR whose `state` we cannot read is still worth
67
+ * reporting — its head SHA and mergeability are what the loop acts on.
68
+ */
69
+ export function parseConductEpicPrState(value) {
70
+ if (!isRecord(value))
71
+ return null;
72
+ const rawNumber = value.number;
73
+ const number = typeof rawNumber === "number" && Number.isInteger(rawNumber) && rawNumber > 0 ? rawNumber : null;
74
+ const rawState = text(value.state);
75
+ const upper = rawState === null ? null : rawState.toUpperCase();
76
+ const state = CONDUCT_EPIC_PR_STATES.includes(upper ?? "")
77
+ ? upper
78
+ : null;
79
+ const mergeability = parseGhPrMergeabilityFields(value);
80
+ return {
81
+ number,
82
+ state,
83
+ head_sha: text(value.headRefOid),
84
+ base: text(value.baseRefName),
85
+ mergeable: mergeability.mergeable,
86
+ merge_state: mergeability.mergeStateStatus,
87
+ updated_at: text(value.updatedAt),
88
+ };
89
+ }
90
+ /**
91
+ * True for the two authoritative GitHub conflict signals. Delegates to the
92
+ * shared predicate so PR discovery, the done gate, and this loop agree.
93
+ */
94
+ export function conductEpicPrHasConflict(pr) {
95
+ return isPrMergeConflict({ mergeable: pr.mergeable, mergeStateStatus: pr.merge_state });
96
+ }
97
+ /**
98
+ * Choose the one pull request the loop acts on from `gh`'s candidate list.
99
+ *
100
+ * An OPEN pull request always wins: it is the only one any action row can
101
+ * advance, and a stale closed sibling must never displace it. Among terminal
102
+ * candidates the most recently updated wins, because that is the one whose
103
+ * MERGED or CLOSED state describes the branch's current reality.
104
+ *
105
+ * Deterministic when timestamps tie or are unavailable: a valid `updated_at`
106
+ * outranks an absent one, and source order breaks any remaining tie. A probe
107
+ * that returned a different pull request run-to-run for identical input would
108
+ * make every downstream row unreproducible.
109
+ */
110
+ export function selectConductEpicPrState(candidates) {
111
+ if (candidates.length === 0)
112
+ return null;
113
+ const open = candidates.find((pr) => pr.state === "OPEN");
114
+ if (open)
115
+ return open;
116
+ let best = candidates[0];
117
+ for (const candidate of candidates.slice(1)) {
118
+ const bestAt = Date.parse(best.updated_at ?? "");
119
+ const candidateAt = Date.parse(candidate.updated_at ?? "");
120
+ const bestUsable = Number.isFinite(bestAt);
121
+ const candidateUsable = Number.isFinite(candidateAt);
122
+ // Strictly greater, so an equal timestamp keeps the earlier record and
123
+ // source order remains the final tie-breaker.
124
+ if (candidateUsable && (!bestUsable || candidateAt > bestAt))
125
+ best = candidate;
126
+ }
127
+ return best;
128
+ }
129
+ /**
130
+ * Run `gh pr list --head <branch> --state all --json …` and normalize the result.
131
+ *
132
+ * Nothing throws. The three outcomes are kept strictly apart:
133
+ *
134
+ * - **`ok`** — `gh` succeeded and returned at least one parseable PR record.
135
+ * - **`none`** — `gh` SUCCEEDED and returned an empty array. Confirmed absence.
136
+ * - **`error`** — `gh` could not be run, exited non-zero, or returned output
137
+ * that is not a valid PR array. Unavailable evidence, not absence.
138
+ *
139
+ * **`gh pr list`, not `gh pr view` (BAPI-825/B1).** `gh pr view <branch>` prints
140
+ * `no pull requests found for branch "…"` to stderr and **exits 1** when the
141
+ * branch has no pull request, and `runGhCommand` discards stderr, so every
142
+ * pre-PR tick produced `{ok: false, stdout: ""}`. Under BAPI-814/B2's correct
143
+ * rule that a non-zero exit is a failed probe, `none` became unreachable in
144
+ * production: the loop saw `pr: null` PLUS a `probe_errors` entry on every tick
145
+ * before a worker opened its pull request, so the "no PR yet" rows could never
146
+ * fire and a worker that died early parked at the three-hour hard deadline
147
+ * instead of getting its one-hour respawn. `gh pr list` exits 0 with `[]`,
148
+ * which makes absence expressible without weakening the failure rule.
149
+ *
150
+ * Every `error` reason is a fixed bounded string chosen here. The raw output is
151
+ * never included — a `gh` failure message can echo a URL, a token hint, or the
152
+ * repository layout, none of which belongs in a status object the loop prints.
153
+ */
154
+ export async function discoverConductEpicPrState(branch, options = {}) {
155
+ const runGh = options.runGh ?? runGhCommand;
156
+ let result;
157
+ try {
158
+ result = await runGh([
159
+ "pr",
160
+ "list",
161
+ "--head",
162
+ branch,
163
+ "--state",
164
+ "all",
165
+ "--json",
166
+ CONDUCT_EPIC_GH_PR_VIEW_FIELDS,
167
+ "--limit",
168
+ String(CONDUCT_EPIC_GH_PR_LIST_LIMIT),
169
+ ], { cwd: options.cwd });
170
+ }
171
+ catch {
172
+ return { kind: "error", reason: "the gh command could not be run" };
173
+ }
174
+ // A non-zero `gh` exit is a FAILED PROBE, never "no PR" (BAPI-814/B2).
175
+ //
176
+ // `gh pr list` exits non-zero only when it could not answer — unauthenticated,
177
+ // rate-limited, offline, not a repository. Absence is now expressed as a
178
+ // successful `[]`, so nothing legitimate is lost by keeping this strict: the
179
+ // status contract turns an error into `pr: null` PLUS a `probe_errors` entry,
180
+ // which the command treats as unavailable evidence rather than a negative
181
+ // result.
182
+ //
183
+ // The reason is a FIXED string. `gh`'s own output can echo a URL, a token
184
+ // hint, or the repository layout, so none of it is interpolated here.
185
+ if (!result.ok)
186
+ return { kind: "error", reason: "the gh command failed" };
187
+ // Empty stdout from a SUCCESSFUL `--json` run is malformed, not absence: the
188
+ // documented answer for "no pull requests" is the two bytes `[]`. Silence is
189
+ // a shape this command should never produce, and reading it as a confirmed
190
+ // negative is precisely the inference this module refuses to make.
191
+ const raw = typeof result.stdout === "string" ? result.stdout.trim() : "";
192
+ if (raw.length === 0) {
193
+ return { kind: "error", reason: "gh returned no output for a --json query" };
194
+ }
195
+ let parsed;
196
+ try {
197
+ parsed = JSON.parse(raw);
198
+ }
199
+ catch {
200
+ return { kind: "error", reason: "gh returned output that is not valid JSON" };
201
+ }
202
+ if (!Array.isArray(parsed)) {
203
+ return { kind: "error", reason: "gh returned a PR list in an unexpected shape" };
204
+ }
205
+ // A successful, well-formed empty list is the ONLY confirmed absence.
206
+ if (parsed.length === 0)
207
+ return { kind: "none" };
208
+ const records = [];
209
+ for (const entry of parsed) {
210
+ const pr = parseConductEpicPrState(entry);
211
+ // An unusable record is a shape error, never silently dropped: discarding
212
+ // it could empty the list and turn a real pull request into "no PR".
213
+ if (pr === null) {
214
+ return { kind: "error", reason: "gh returned a PR record in an unexpected shape" };
215
+ }
216
+ records.push(pr);
217
+ }
218
+ const pr = selectConductEpicPrState(records);
219
+ if (pr === null)
220
+ return { kind: "error", reason: "gh returned a PR record in an unexpected shape" };
221
+ return { kind: "ok", pr };
222
+ }
223
+ /**
224
+ * Parse `git worktree list --porcelain` into branch-attached entries.
225
+ *
226
+ * Detached worktrees and records without a resolvable `refs/heads/<name>` are
227
+ * dropped: a worktree with no branch cannot be the ticket's worktree, and
228
+ * carrying it forward would only make the precedence rules below ambiguous.
229
+ */
230
+ export function parseGitWorktreePorcelain(output) {
231
+ const entries = [];
232
+ let currentPath = null;
233
+ let currentBranch = null;
234
+ const flush = () => {
235
+ if (currentPath !== null && currentBranch !== null) {
236
+ entries.push({ path: currentPath, branch: currentBranch });
237
+ }
238
+ currentPath = null;
239
+ currentBranch = null;
240
+ };
241
+ for (const rawLine of String(output ?? "").split("\n")) {
242
+ const line = rawLine.replace(/\r$/, "");
243
+ if (line.startsWith("worktree ")) {
244
+ flush();
245
+ const value = line.slice("worktree ".length).trim();
246
+ currentPath = value.length > 0 ? value : null;
247
+ }
248
+ else if (line.startsWith("branch ") && currentPath !== null) {
249
+ const ref = line.slice("branch ".length).trim();
250
+ if (ref.startsWith("refs/heads/")) {
251
+ const name = ref.slice("refs/heads/".length);
252
+ currentBranch = name.length > 0 ? name : null;
253
+ }
254
+ }
255
+ }
256
+ flush();
257
+ return entries;
258
+ }
259
+ /**
260
+ * Resolve the worktree for `ticketKey`, in strict precedence order:
261
+ *
262
+ * 1. the branch already stored on the ticket;
263
+ * 2. exactly `feature/<KEY>`;
264
+ * 3. the first branch beginning `feature/<KEY>-` in porcelain order.
265
+ *
266
+ * The later rules are a FALLBACK, not an alternative: a stored branch whose
267
+ * worktree was removed still resolves through the naming convention, which is
268
+ * what lets the loop survive an operator pruning worktrees mid-run.
269
+ *
270
+ * Returns `null` rather than guessing. Persisting a discovered branch is the
271
+ * CALLER's decision — `status` may write it, and a pure resolver that also wrote
272
+ * to the checkpoint would make the read-only surface a mutating one.
273
+ */
274
+ export function discoverTicketWorktree(entries, ticketKey, storedBranch) {
275
+ if (storedBranch !== null && storedBranch.length > 0) {
276
+ const stored = entries.find((entry) => entry.branch === storedBranch);
277
+ if (stored)
278
+ return { branch: stored.branch, path: stored.path };
279
+ }
280
+ const canonical = `feature/${ticketKey}`;
281
+ const exact = entries.find((entry) => entry.branch === canonical);
282
+ if (exact)
283
+ return { branch: exact.branch, path: exact.path };
284
+ const prefixed = entries.find((entry) => entry.branch.startsWith(`${canonical}-`));
285
+ return prefixed ? { branch: prefixed.branch, path: prefixed.path } : null;
286
+ }
@@ -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
+ }