@bridge_gpt/mcp-server 0.2.38 → 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.
- package/README.md +189 -14
- package/build/agent-capabilities/probe-context.js +2 -1
- package/build/agent-launchers/claude-executor-adapter.js +392 -0
- package/build/agent-launchers/executor-adapter-inspection.js +163 -0
- package/build/agent-launchers/executor-adapter-registry.js +90 -0
- package/build/agent-launchers/executor-adapter.js +136 -0
- package/build/agent-registry.js +28 -0
- package/build/agents.generated.js +1 -1
- package/build/claude-login.js +85 -0
- package/build/claude-user-config-doctor.js +59 -33
- package/build/commands.generated.js +12 -11
- package/build/conduct-epic/bridge-client.js +345 -0
- package/build/conduct-epic/checkpoint-store.js +423 -0
- package/build/conduct-epic/cli.js +1732 -0
- package/build/conduct-epic/lock.js +302 -0
- package/build/conduct-epic/pr-state.js +197 -0
- package/build/conduct-epic/spawn.js +101 -0
- package/build/conductor/bridge-api-client.js +37 -2
- package/build/conductor/doctor.js +11 -1
- package/build/conductor/install-doctor.js +184 -10
- package/build/conductor-bin.js +7 -7
- package/build/credential-store.js +10 -4
- package/build/credentials-cli.js +34 -19
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +579 -88
- package/build/executor/agent-identity.js +32 -0
- package/build/executor/cli.js +50 -39
- package/build/executor/deps.js +15 -1
- package/build/executor/env.js +56 -45
- package/build/executor/index.js +9 -1
- package/build/executor/install-preflight.js +138 -0
- package/build/executor/job-errors.js +200 -0
- package/build/executor/job-runner.js +619 -268
- package/build/executor/observation.js +165 -0
- package/build/executor/permissions.js +163 -36
- package/build/executor/platform.js +54 -0
- package/build/executor/preflight.js +175 -67
- package/build/executor/process.js +39 -7
- package/build/executor/runner.js +19 -0
- package/build/executor/service-lifecycle.js +269 -0
- package/build/executor/service-unit.js +121 -12
- package/build/executor/stale-artifacts.js +70 -0
- package/build/executor/test-clock.js +188 -24
- package/build/executor/worker-command.js +22 -58
- package/build/executor/worker-log.js +82 -0
- package/build/executor/worktree-lock.js +264 -0
- package/build/index.js +527 -357
- package/build/install-bridge-conductor.js +376 -38
- package/build/install-bridge.js +414 -114
- package/build/install-doctor.js +13 -0
- package/build/install-reexec.js +5 -3
- package/build/mcp-install-state.js +130 -0
- package/build/mcp-profile.js +11 -2
- package/build/mcp-provisioning.js +15 -0
- package/build/merge-pull-request.js +562 -0
- package/build/phase-result-artifacts.js +450 -0
- package/build/pipeline-orchestrator.js +4 -0
- package/build/pipeline-utils.js +16 -0
- package/build/pipelines.generated.js +7 -7
- package/build/plane/preflight.js +18 -14
- package/build/plane/supervisor.js +8 -1
- package/build/project-root.js +34 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +36 -9
- package/build/setup-epic.js +57 -4
- package/build/sfcc/permissions.js +25 -6
- package/build/sfcc/reads-site-preference.js +6 -0
- package/build/sfcc/register.js +61 -23
- package/build/sfcc/registration-inventory.js +89 -0
- package/build/sfcc/setup-status.js +18 -34
- package/build/sfcc/tool-wrapper.js +294 -17
- package/build/sfcc/write-grants.js +33 -1
- package/build/sfcc/write-guard.js +41 -12
- package/build/sfcc/writes-custom-object-def.js +6 -2
- package/build/sfcc/writes-site-preference.js +6 -1
- package/build/sfcc/writes-system-object.js +11 -2
- package/build/sfcc/writes.js +13 -8
- package/build/start-tickets-prereqs.js +25 -15
- package/build/start-tickets.js +123 -21
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +9 -3
- package/docs/install/mcp-tool-integrations.md +54 -9
- package/docs/install/sfcc-integration.md +71 -24
- package/package.json +3 -3
- 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.
|
|
1586
|
-
*
|
|
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`, {
|