@bridge_gpt/mcp-server 0.2.39 → 0.2.42

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 (74) hide show
  1. package/README.md +10 -10
  2. package/build/agent-capabilities/cli.js +2 -1
  3. package/build/agent-launchers/claude-executor-adapter.js +17 -4
  4. package/build/claude-user-config-doctor.js +42 -11
  5. package/build/cli-release.js +2 -1
  6. package/build/commands.generated.js +4 -4
  7. package/build/conduct-epic/bridge-client.js +354 -113
  8. package/build/conduct-epic/checkpoint-store.js +75 -2
  9. package/build/conduct-epic/cli.js +795 -109
  10. package/build/conduct-epic/cut-protocol.js +327 -0
  11. package/build/conduct-epic/pr-state.js +113 -24
  12. package/build/conduct-epic/spawn.js +14 -2
  13. package/build/conductor/bridge-api-client.js +27 -1
  14. package/build/conductor/cli.js +46 -1
  15. package/build/conductor/doctor.js +101 -16
  16. package/build/conductor/epic-reconcile.js +72 -19
  17. package/build/conductor/epic-runtime.js +15 -3
  18. package/build/conductor/errors.js +47 -0
  19. package/build/conductor/git-hooks.js +205 -11
  20. package/build/conductor/install-doctor.js +230 -1
  21. package/build/conductor/local-merge.js +130 -28
  22. package/build/conductor/tools.js +32 -3
  23. package/build/conductor/worker-ledger-cli.js +27 -1
  24. package/build/conductor-bin.js +15 -15
  25. package/build/credentials-cli.js +3 -2
  26. package/build/doctor.js +107 -41
  27. package/build/executor/cli.js +48 -1
  28. package/build/executor/env.js +21 -0
  29. package/build/executor/index-scope.js +39 -0
  30. package/build/executor/job-log-registry.js +69 -0
  31. package/build/executor/job-runner.js +148 -26
  32. package/build/executor/live-worker-registry.js +83 -0
  33. package/build/executor/observation.js +167 -6
  34. package/build/executor/platform.js +147 -3
  35. package/build/executor/process.js +58 -14
  36. package/build/executor/runner.js +235 -48
  37. package/build/executor/test-clock.js +3 -2
  38. package/build/index-scope-contract.js +96 -0
  39. package/build/index.js +153 -204
  40. package/build/init.js +83 -22
  41. package/build/install-bridge-conductor.js +323 -14
  42. package/build/install-bridge.js +202 -38
  43. package/build/install-doctor.js +23 -9
  44. package/build/install-reexec.js +2 -1
  45. package/build/launcher-config-inspection.js +83 -22
  46. package/build/mcp-host-config.js +331 -67
  47. package/build/mcp-host-targets.js +45 -21
  48. package/build/mcp-identity.js +92 -0
  49. package/build/mcp-install-state.js +94 -1
  50. package/build/mcp-invoke.js +2 -1
  51. package/build/mcp-provisioning.js +45 -12
  52. package/build/mcp-registration-doctor.js +35 -13
  53. package/build/mcp-server-invocation.js +4 -2
  54. package/build/merge-pull-request.js +208 -9
  55. package/build/pipelines.generated.js +3 -3
  56. package/build/plane/defaults.js +4 -1
  57. package/build/plane/preflight.js +81 -10
  58. package/build/plane/test-fakes.js +9 -1
  59. package/build/readme.generated.js +1 -1
  60. package/build/regression-check.js +3 -2
  61. package/build/review-tickets.js +8 -7
  62. package/build/run-unit-tests-launcher.js +74 -1
  63. package/build/schedule-run.js +3 -2
  64. package/build/setup-epic.js +453 -78
  65. package/build/sfcc/tool-wrapper.js +15 -0
  66. package/build/start-tickets-prereqs.js +11 -6
  67. package/build/start-tickets.js +91 -85
  68. package/build/update-check.js +3 -2
  69. package/build/upgrade-advice.js +2 -1
  70. package/build/upgrade-cli.js +50 -18
  71. package/build/version.generated.js +1 -1
  72. package/docs/CONDUCTOR.md +22 -0
  73. package/docs/install/mcp-tool-integrations.md +19 -3
  74. package/package.json +2 -2
@@ -91,119 +91,6 @@ function nullableString(value) {
91
91
  return value;
92
92
  return undefined;
93
93
  }
94
- /** Parse the `override` member, or `undefined` when it is malformed. */
95
- function parseOverride(value) {
96
- if (value === null || value === undefined)
97
- return null;
98
- if (!isRecord(value))
99
- return undefined;
100
- const original = nullableString(value["original_base_branch"]);
101
- if (typeof value["repo_name"] !== "string" ||
102
- original === undefined ||
103
- typeof value["override_branch"] !== "string" ||
104
- typeof value["created_at"] !== "string") {
105
- return undefined;
106
- }
107
- return {
108
- repo_name: value["repo_name"],
109
- original_base_branch: original,
110
- override_branch: value["override_branch"],
111
- created_at: value["created_at"],
112
- };
113
- }
114
- /**
115
- * Validate a 200 body against {@link IndexBranchStatus}, returning `null` when it
116
- * does not match.
117
- *
118
- * A body that parsed as JSON but is not this shape is treated as a failure
119
- * rather than cast through: `changed` and `current_base_branch` drive
120
- * destructive branch decisions in the caller, and a missing `changed` read as
121
- * `undefined` would silently mean "nothing happened".
122
- */
123
- function parseStatus(body) {
124
- if (!isRecord(body))
125
- return null;
126
- const current = nullableString(body["current_base_branch"]);
127
- const override = parseOverride(body["override"]);
128
- if (typeof body["repo_name"] !== "string" ||
129
- current === undefined ||
130
- override === undefined ||
131
- typeof body["changed"] !== "boolean") {
132
- return null;
133
- }
134
- return {
135
- repo_name: body["repo_name"],
136
- current_base_branch: current,
137
- override,
138
- changed: body["changed"],
139
- };
140
- }
141
- /** Wrap a parsed body into a success result, or a generic failure if malformed. */
142
- function toStatusResult(body) {
143
- const status = parseStatus(body);
144
- if (status === null) {
145
- return { ok: false, status: null, error: GENERIC_ERROR };
146
- }
147
- return { ok: true, value: status };
148
- }
149
- /**
150
- * `POST /jira/index-branch/repoint` — point the indexed base branch at `branch`.
151
- *
152
- * A repeat call with the same branch resolves `{ ok: true }` with
153
- * `value.changed === false`. A call for a *different* branch while an override is
154
- * active resolves `{ ok: false, status: 409 }` with an error naming the override
155
- * the server preserved — it is not an exception, because a conflict is an
156
- * expected outcome the CLI must report and continue from.
157
- */
158
- export async function repointIndexBranch(access, input, fetchImpl = globalThis.fetch) {
159
- try {
160
- // `buildConductorJiraUrl` already appends the `/jira` segment.
161
- const url = buildConductorJiraUrl(access.baseUrl, "/index-branch/repoint");
162
- const body = await fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({ repo_name: access.repoName, branch: input.branch }), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
163
- return toStatusResult(body);
164
- }
165
- catch (err) {
166
- return toFailure(err, access);
167
- }
168
- }
169
- /**
170
- * `POST /jira/index-branch/restore` — write the stored original branch back and
171
- * drop the override.
172
- *
173
- * Idempotent: with no active override the call resolves `{ ok: true }` with
174
- * `value.changed === false` and `value.override === null`. That matters because
175
- * restore is the recovery path — it must be safe to run after a crash whose
176
- * position is unknown.
177
- */
178
- export async function restoreIndexBranch(access, fetchImpl = globalThis.fetch) {
179
- try {
180
- const url = buildConductorJiraUrl(access.baseUrl, "/index-branch/restore");
181
- const body = await fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({ repo_name: access.repoName }), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
182
- return toStatusResult(body);
183
- }
184
- catch (err) {
185
- return toFailure(err, access);
186
- }
187
- }
188
- /**
189
- * `GET /jira/index-branch?repo_name=` — report the current indexed branch and any
190
- * active override.
191
- *
192
- * This is how a stale override left behind by a crashed loop becomes visible;
193
- * `value.changed` is always `false` because a read changes nothing.
194
- */
195
- export async function getIndexBranch(access, fetchImpl = globalThis.fetch) {
196
- try {
197
- const url = buildConductorJiraUrl(access.baseUrl, "/index-branch", {
198
- repo_name: access.repoName,
199
- });
200
- const body = await fetchConductorJsonWithTimeout(url, getHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
201
- return toStatusResult(body);
202
- }
203
- catch (err) {
204
- return toFailure(err, access);
205
- }
206
- }
207
94
  /**
208
95
  * Run `operation` and convert any thrown value into a sanitized failure result.
209
96
  *
@@ -343,3 +230,357 @@ export async function getConfigFieldBaseBranch(access, fetchImpl = globalThis.fe
343
230
  const trimmed = raw.trim();
344
231
  return { ok: true, value: { base_branch: trimmed.length === 0 ? null : trimmed } };
345
232
  }
233
+ const INDEX_SCOPE_FRESHNESS_VALUES = new Set([
234
+ "fresh",
235
+ "pending",
236
+ "blocked",
237
+ "failed",
238
+ "unavailable",
239
+ ]);
240
+ /**
241
+ * Read the server's freshness verdict, failing CLOSED.
242
+ *
243
+ * An absent or unrecognized value becomes `null` rather than a guess, and every
244
+ * caller treats `null` as "not fresh". A malformed control-plane response must
245
+ * never be the thing that lets a conductor proceed against a stale index.
246
+ */
247
+ function parseFreshnessStatus(value) {
248
+ return typeof value === "string" && INDEX_SCOPE_FRESHNESS_VALUES.has(value)
249
+ ? value
250
+ : null;
251
+ }
252
+ /**
253
+ * Read a soft envelope's refusal, or `null` when the body reports success.
254
+ *
255
+ * A body that is not an object at all is a refusal too: silently treating an
256
+ * unparseable response as success is how a cut gets recorded against a commit
257
+ * nobody verified.
258
+ */
259
+ function softEnvelopeFailure(body) {
260
+ if (!isRecord(body))
261
+ return { ok: false, status: null, error: GENERIC_ERROR };
262
+ if (body["ok"] === true)
263
+ return null;
264
+ const error = typeof body["error"] === "string" && body["error"].length > 0 ? body["error"] : GENERIC_ERROR;
265
+ const message = typeof body["message"] === "string" && body["message"].length > 0 ? body["message"] : null;
266
+ return { ok: false, status: null, error: message ? `${error}: ${message}` : error };
267
+ }
268
+ /** Read a required string field, or `null` when it is absent or empty. */
269
+ function requiredString(body, key) {
270
+ const value = body[key];
271
+ return typeof value === "string" && value.length > 0 ? value : null;
272
+ }
273
+ /**
274
+ * `POST /jira/index-scope/cut/begin` — lease a cut hold at the indexed commit.
275
+ *
276
+ * `candidateCommitSha` is the CLI's own preflight observation, sent as a
277
+ * cross-check. The server re-reads the canonical snapshot under the parse lock
278
+ * and refuses on disagreement, which is the "the index moved while I was
279
+ * checking" case the protocol re-drives rather than papers over.
280
+ */
281
+ export async function beginIndexScopeCut(access, request, fetchImpl = globalThis.fetch) {
282
+ const result = await wrap(access, () => {
283
+ const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/cut/begin");
284
+ return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({
285
+ repo_name: access.repoName,
286
+ feature_branch: request.featureBranch,
287
+ base_branch: request.baseBranch,
288
+ candidate_commit_sha: request.candidateCommitSha ?? null,
289
+ epic_run_id: request.epicRunId ?? null,
290
+ }), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
291
+ });
292
+ if (!result.ok)
293
+ return result;
294
+ const refusal = softEnvelopeFailure(result.value);
295
+ if (refusal)
296
+ return refusal;
297
+ const body = result.value;
298
+ const cutHoldId = requiredString(body, "cut_hold_id");
299
+ const scopeId = requiredString(body, "scope_id");
300
+ const shadowRepoName = requiredString(body, "shadow_repo_name");
301
+ const cutCommitSha = requiredString(body, "cut_commit_sha");
302
+ if (!cutHoldId || !scopeId || !shadowRepoName || !cutCommitSha) {
303
+ // An `ok: true` body missing any of these is not a usable lease, and acting
304
+ // on a partial one would push a ref at `undefined`.
305
+ return { ok: false, status: null, error: GENERIC_ERROR };
306
+ }
307
+ return { ok: true, value: { cut_hold_id: cutHoldId, scope_id: scopeId, shadow_repo_name: shadowRepoName, cut_commit_sha: cutCommitSha } };
308
+ }
309
+ /**
310
+ * `POST /jira/index-scope/cut/commit` — record the immutable cut.
311
+ *
312
+ * `epicRefCommitSha` is what the CLI read back from `origin` AFTER creating the
313
+ * ref, not what it intended to create. The server proves the two agree.
314
+ */
315
+ export async function commitIndexScopeCut(access, request, fetchImpl = globalThis.fetch) {
316
+ const result = await wrap(access, () => {
317
+ const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/cut/commit");
318
+ return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({
319
+ repo_name: access.repoName,
320
+ scope_id: request.scopeId,
321
+ cut_hold_id: request.cutHoldId,
322
+ epic_ref_commit_sha: request.epicRefCommitSha,
323
+ }), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
324
+ });
325
+ if (!result.ok)
326
+ return result;
327
+ const refusal = softEnvelopeFailure(result.value);
328
+ if (refusal)
329
+ return refusal;
330
+ const body = result.value;
331
+ const scopeId = requiredString(body, "scope_id");
332
+ const shadowRepoName = requiredString(body, "shadow_repo_name");
333
+ const cutCommitSha = requiredString(body, "cut_commit_sha");
334
+ if (!scopeId || !shadowRepoName || !cutCommitSha) {
335
+ return { ok: false, status: null, error: GENERIC_ERROR };
336
+ }
337
+ return {
338
+ ok: true,
339
+ value: {
340
+ scope_id: scopeId,
341
+ shadow_repo_name: shadowRepoName,
342
+ cut_commit_sha: cutCommitSha,
343
+ outcome: requiredString(body, "outcome"),
344
+ },
345
+ };
346
+ }
347
+ /**
348
+ * `POST /jira/index-scope/cut/abandon` — release this CLI's own cut hold.
349
+ *
350
+ * Called from `init`'s `finally` on every pre-seed outcome. Idempotent: a hold
351
+ * already released (or already reclaimed) still resolves successfully, so the
352
+ * cleanup path can never turn a primary failure into a second one.
353
+ */
354
+ export async function abandonIndexScopeCut(access, request, fetchImpl = globalThis.fetch) {
355
+ const result = await wrap(access, () => {
356
+ const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/cut/abandon");
357
+ return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({
358
+ repo_name: access.repoName,
359
+ scope_id: request.scopeId,
360
+ cut_hold_id: request.cutHoldId,
361
+ }), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
362
+ });
363
+ if (!result.ok)
364
+ return result;
365
+ const refusal = softEnvelopeFailure(result.value);
366
+ if (refusal)
367
+ return refusal;
368
+ return { ok: true, value: true };
369
+ }
370
+ /**
371
+ * `POST /jira/index-scope/bootstrap` — seed the scope and drive its verifying
372
+ * parse.
373
+ *
374
+ * Accepted means SCHEDULED, never ready. Readiness is observed through
375
+ * {@link getIndexScopeStatus}, because the bootstrap copies a repository's whole
376
+ * parse cache and then runs a parse.
377
+ */
378
+ export async function bootstrapIndexScope(access, request, fetchImpl = globalThis.fetch) {
379
+ const result = await wrap(access, () => {
380
+ const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/bootstrap");
381
+ return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({ repo_name: access.repoName, scope_id: request.scopeId }), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
382
+ });
383
+ if (!result.ok)
384
+ return result;
385
+ const refusal = softEnvelopeFailure(result.value);
386
+ if (refusal)
387
+ return refusal;
388
+ return { ok: true, value: true };
389
+ }
390
+ /** `GET /jira/index-scope/status?repo_name=&scope_id=` — the scope's own state. */
391
+ export async function getIndexScopeStatus(access, scopeId, fetchImpl = globalThis.fetch) {
392
+ const result = await wrap(access, () => {
393
+ const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/status", {
394
+ repo_name: access.repoName,
395
+ scope_id: scopeId,
396
+ });
397
+ return fetchConductorJsonWithTimeout(url, getHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
398
+ });
399
+ if (!result.ok)
400
+ return result;
401
+ const refusal = softEnvelopeFailure(result.value);
402
+ if (refusal)
403
+ return refusal;
404
+ const body = result.value;
405
+ const resolvedScopeId = requiredString(body, "scope_id");
406
+ const shadowRepoName = requiredString(body, "shadow_repo_name");
407
+ const lifecycleState = requiredString(body, "lifecycle_state");
408
+ if (!resolvedScopeId || !shadowRepoName || !lifecycleState) {
409
+ return { ok: false, status: null, error: GENERIC_ERROR };
410
+ }
411
+ return {
412
+ ok: true,
413
+ value: {
414
+ scope_id: resolvedScopeId,
415
+ shadow_repo_name: shadowRepoName,
416
+ lifecycle_state: lifecycleState,
417
+ cut_commit_sha: nullableString(body["cut_commit_sha"]) ?? null,
418
+ required_commit_sha: nullableString(body["required_commit_sha"]) ?? null,
419
+ indexed_commit_sha: nullableString(body["indexed_commit_sha"]) ?? null,
420
+ seed_source_commit_sha: nullableString(body["seed_source_commit_sha"]) ?? null,
421
+ last_error: nullableString(body["last_error"]) ?? null,
422
+ freshness_status: parseFreshnessStatus(body["freshness_status"]),
423
+ blocked_reason: nullableString(body["blocked_reason"]) ?? null,
424
+ },
425
+ };
426
+ }
427
+ function nullableNumber(value) {
428
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
429
+ }
430
+ /**
431
+ * Read a boolean field, defaulting to the SAFE value rather than to `false`.
432
+ *
433
+ * The safe default differs per field and each caller passes it, because guessing
434
+ * uniformly would be wrong in both directions: an unreadable `lease_valid` must
435
+ * read as `true` (assume it is live, do not offer to reclaim it) while an
436
+ * unreadable `retention_elapsed` must read as `false` (assume the window has not
437
+ * passed).
438
+ */
439
+ function booleanOr(value, fallback) {
440
+ return typeof value === "boolean" ? value : fallback;
441
+ }
442
+ function parseLifecycleEntry(value) {
443
+ if (!isRecord(value))
444
+ return null;
445
+ const scopeId = requiredString(value, "scope_id");
446
+ if (!scopeId)
447
+ return null;
448
+ const blockers = Array.isArray(value["blockers"])
449
+ ? value["blockers"].filter((entry) => typeof entry === "string")
450
+ : [];
451
+ return {
452
+ scope_id: scopeId,
453
+ shadow_repo_name: nullableString(value["shadow_repo_name"]) ?? null,
454
+ feature_branch: nullableString(value["feature_branch"]) ?? null,
455
+ epic_run_id: nullableString(value["epic_run_id"]) ?? null,
456
+ lifecycle_state: nullableString(value["lifecycle_state"]) ?? null,
457
+ lease_epoch: nullableNumber(value["lease_epoch"]),
458
+ lease_expires_at: nullableString(value["lease_expires_at"]) ?? null,
459
+ // Fail closed on each: an unreadable field must never make a scope look more
460
+ // reclaimable than it is.
461
+ lease_valid: booleanOr(value["lease_valid"], true),
462
+ retention_deadline: nullableString(value["retention_deadline"]) ?? null,
463
+ retention_elapsed: booleanOr(value["retention_elapsed"], false),
464
+ recoverable: booleanOr(value["recoverable"], false),
465
+ active_parse_run: booleanOr(value["active_parse_run"], true),
466
+ parse_lock_held: booleanOr(value["parse_lock_held"], true),
467
+ active_automation_run: booleanOr(value["active_automation_run"], true),
468
+ active_epic_run: booleanOr(value["active_epic_run"], true),
469
+ identity_valid: booleanOr(value["identity_valid"], false),
470
+ blockers,
471
+ };
472
+ }
473
+ /** `GET /jira/index-scope/lifecycle?repo_name=` — every scope this repo owns. */
474
+ export async function getIndexScopeLifecycle(access, fetchImpl = globalThis.fetch) {
475
+ const result = await wrap(access, () => {
476
+ const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/lifecycle", {
477
+ repo_name: access.repoName,
478
+ });
479
+ return fetchConductorJsonWithTimeout(url, getHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
480
+ });
481
+ if (!result.ok)
482
+ return result;
483
+ const refusal = softEnvelopeFailure(result.value);
484
+ if (refusal)
485
+ return refusal;
486
+ const body = result.value;
487
+ const rawScopes = Array.isArray(body["scopes"]) ? body["scopes"] : [];
488
+ const scopes = [];
489
+ for (const raw of rawScopes) {
490
+ const parsed = parseLifecycleEntry(raw);
491
+ // A malformed entry is DROPPED rather than failing the whole listing: the
492
+ // listing is a diagnostic, and showing an operator four readable scopes plus
493
+ // nothing about a fifth beats showing them nothing at all.
494
+ if (parsed !== null)
495
+ scopes.push(parsed);
496
+ }
497
+ return {
498
+ ok: true,
499
+ value: { retention_seconds: nullableNumber(body["retention_seconds"]), scopes },
500
+ };
501
+ }
502
+ /** Shared parser for the three lease verbs, whose response shape is identical. */
503
+ function toLeaseResult(body) {
504
+ const refusal = softEnvelopeFailure(body);
505
+ if (refusal)
506
+ return refusal;
507
+ const record = body;
508
+ const scopeId = requiredString(record, "scope_id");
509
+ if (!scopeId)
510
+ return { ok: false, status: null, error: GENERIC_ERROR };
511
+ return {
512
+ ok: true,
513
+ value: {
514
+ scope_id: scopeId,
515
+ lifecycle_state: nullableString(record["lifecycle_state"]) ?? null,
516
+ lease_epoch: nullableNumber(record["lease_epoch"]),
517
+ lease_expires_at: nullableString(record["lease_expires_at"]) ?? null,
518
+ already_retired: booleanOr(record["already_retired"], false),
519
+ },
520
+ };
521
+ }
522
+ async function postScopeLifecycle(access, path, body, fetchImpl) {
523
+ const result = await wrap(access, () => {
524
+ const url = buildConductorJiraUrl(access.baseUrl, path);
525
+ return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({ repo_name: access.repoName, ...body }), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
526
+ });
527
+ if (!result.ok)
528
+ return result;
529
+ return toLeaseResult(result.value);
530
+ }
531
+ /** `POST /jira/index-scope/heartbeat` — renew the lease under `leaseEpoch`. */
532
+ export async function heartbeatIndexScope(access, request, fetchImpl = globalThis.fetch) {
533
+ return postScopeLifecycle(access, "/index-scope/heartbeat", { scope_id: request.scopeId, lease_epoch: request.leaseEpoch }, fetchImpl);
534
+ }
535
+ /**
536
+ * `POST /jira/index-scope/recover` — take a new ownership generation.
537
+ *
538
+ * Deliberately sends NO epoch: recovery exists for the case where the caller has
539
+ * no valid one, and the server's increment is what fences the previous owner.
540
+ */
541
+ export async function recoverIndexScope(access, request, fetchImpl = globalThis.fetch) {
542
+ return postScopeLifecycle(access, "/index-scope/recover", { scope_id: request.scopeId }, fetchImpl);
543
+ }
544
+ /** `POST /jira/index-scope/retire` — start retention; delete nothing. */
545
+ export async function retireIndexScope(access, request, fetchImpl = globalThis.fetch) {
546
+ return postScopeLifecycle(access, "/index-scope/retire", { scope_id: request.scopeId, lease_epoch: request.leaseEpoch }, fetchImpl);
547
+ }
548
+ /**
549
+ * `POST /jira/index-scope/reclaim` — schedule the server-side teardown.
550
+ *
551
+ * There is deliberately no raw-deletion mode and no field that could name a
552
+ * namespace, table, or repository: the only knob is `overrideRetention`, which
553
+ * waives the two TIME blockers and nothing else. Everything actually deleted is
554
+ * derived server-side from the scope id.
555
+ *
556
+ * A refusal carries the server's ordered blocker tokens so the CLI can print WHY
557
+ * rather than a generic failure.
558
+ */
559
+ export async function reclaimIndexScope(access, request, fetchImpl = globalThis.fetch) {
560
+ const result = await wrap(access, () => {
561
+ const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/reclaim");
562
+ return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({
563
+ repo_name: access.repoName,
564
+ scope_id: request.scopeId,
565
+ override_retention: request.overrideRetention === true,
566
+ }), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
567
+ });
568
+ if (!result.ok)
569
+ return result;
570
+ const refusal = softEnvelopeFailure(result.value);
571
+ if (refusal) {
572
+ const body = isRecord(result.value) ? result.value : {};
573
+ const blockers = Array.isArray(body["blockers"])
574
+ ? body["blockers"].filter((entry) => typeof entry === "string")
575
+ : undefined;
576
+ return blockers && blockers.length > 0 ? { ...refusal, blockers } : refusal;
577
+ }
578
+ const body = result.value;
579
+ return {
580
+ ok: true,
581
+ value: {
582
+ scope_id: nullableString(body["scope_id"]) ?? null,
583
+ scheduled: booleanOr(body["scheduled"], false),
584
+ },
585
+ };
586
+ }
@@ -87,6 +87,45 @@ export function resolveConductEpicPromptsDirectory(repoName, epicKey, deps) {
87
87
  function isRecord(value) {
88
88
  return typeof value === "object" && value !== null && !Array.isArray(value);
89
89
  }
90
+ /**
91
+ * Supply `null` for parse-request fields a pre-BAPI-825 version-1 ticket does
92
+ * not carry, returning a NEW document rather than mutating the input.
93
+ *
94
+ * The two fields were added without incrementing
95
+ * {@link CONDUCT_EPIC_CHECKPOINT_VERSION}, because bumping the version would
96
+ * make every checkpoint written by an in-flight pilot run instantly
97
+ * `unsupported-version` — a hard error whose documented recovery is a human
98
+ * editing the file. Normalizing an absent field to `null` is the compatible
99
+ * alternative: an old document stays valid, and every downstream TypeScript
100
+ * reader sees a real `null` instead of `undefined`.
101
+ *
102
+ * Only ABSENT keys are filled. A key that is present but malformed is left
103
+ * exactly as it is, so validation still rejects it rather than having it
104
+ * quietly repaired into a legal value.
105
+ */
106
+ export function normalizeConductEpicCheckpoint(value) {
107
+ if (!isRecord(value) || !Array.isArray(value.tickets))
108
+ return value;
109
+ return {
110
+ ...value,
111
+ // BAPI-844: a checkpoint written before the scope field existed reads as an
112
+ // unscoped epic rather than as invalid state.
113
+ index_scope_id: "index_scope_id" in value ? value.index_scope_id : null,
114
+ // BAPI-846: a checkpoint written before the lease field existed reads as
115
+ // "no lease held", not as invalid state.
116
+ index_scope_lease_epoch: "index_scope_lease_epoch" in value ? value.index_scope_lease_epoch : null,
117
+ tickets: value.tickets.map((ticket) => {
118
+ if (!isRecord(ticket))
119
+ return ticket;
120
+ const normalized = { ...ticket };
121
+ if (!("parse_requested_at" in normalized))
122
+ normalized.parse_requested_at = null;
123
+ if (!("parse_requested_for_sha" in normalized))
124
+ normalized.parse_requested_for_sha = null;
125
+ return normalized;
126
+ }),
127
+ };
128
+ }
90
129
  /** A non-empty string. Blank-only values are rejected everywhere. */
91
130
  function isText(value) {
92
131
  return typeof value === "string" && value.trim().length > 0;
@@ -147,6 +186,14 @@ function validateTicket(value, index) {
147
186
  const counters = validateCounters(value.counters, where);
148
187
  if (!counters.ok)
149
188
  return counters;
189
+ // Present-but-malformed is rejected; ABSENT is impossible here, because
190
+ // `normalizeConductEpicCheckpoint` runs before every validation and fills a
191
+ // missing parse-request field with `null` (BAPI-825/A2).
192
+ for (const key of ["parse_requested_at", "parse_requested_for_sha"]) {
193
+ if (!isNullableText(value[key])) {
194
+ return fail(`${where}.${key} must be a non-empty string or null`);
195
+ }
196
+ }
150
197
  if (!Array.isArray(value.journal) || value.journal.some((line) => typeof line !== "string")) {
151
198
  return fail(`${where}.journal must be an array of strings`);
152
199
  }
@@ -203,8 +250,14 @@ function validateLock(value) {
203
250
  * this guards against is a partially-hand-edited checkpoint (an operator
204
251
  * unparking a run edits this file by hand) being accepted because its top-level
205
252
  * keys still exist, and then driving the loop from a nonsense value.
253
+ *
254
+ * The candidate is passed through {@link normalizeConductEpicCheckpoint} first,
255
+ * so a version-1 document written before the parse-request fields existed
256
+ * validates unchanged. Normalization only fills ABSENT keys, so it can never
257
+ * launder a malformed present value past the rules below.
206
258
  */
207
- export function validateConductEpicCheckpoint(value) {
259
+ export function validateConductEpicCheckpoint(candidate) {
260
+ const value = normalizeConductEpicCheckpoint(candidate);
208
261
  if (!isRecord(value))
209
262
  return fail("checkpoint must be a JSON object");
210
263
  if (value.version !== CONDUCT_EPIC_CHECKPOINT_VERSION) {
@@ -218,6 +271,15 @@ export function validateConductEpicCheckpoint(value) {
218
271
  if (!isText(value[key]))
219
272
  return fail(`${key} must be a timestamp string`);
220
273
  }
274
+ if (!isNullableText(value.index_scope_id)) {
275
+ return fail("index_scope_id must be a non-empty string or null");
276
+ }
277
+ if (value.index_scope_lease_epoch !== null &&
278
+ (typeof value.index_scope_lease_epoch !== "number" ||
279
+ !Number.isInteger(value.index_scope_lease_epoch) ||
280
+ value.index_scope_lease_epoch < 0)) {
281
+ return fail("index_scope_lease_epoch must be a non-negative integer or null");
282
+ }
221
283
  const deadlines = value.deadlines;
222
284
  if (!isRecord(deadlines))
223
285
  return fail("deadlines must be an object");
@@ -301,7 +363,14 @@ export async function readConductEpicCheckpoint(checkpointPath, fs) {
301
363
  if (!validation.ok) {
302
364
  return { kind: "invalid", error: `checkpoint at ${checkpointPath} is invalid: ${validation.error}` };
303
365
  }
304
- return { kind: "ok", checkpoint: parsed };
366
+ // Return the NORMALIZED document, not the parsed one. Validation normalizes a
367
+ // private copy, so handing back `parsed` would give every caller `undefined`
368
+ // where the interface promises `string | null` — and `undefined` is exactly
369
+ // the value a `!== null` causal check reads as "a request was recorded".
370
+ return {
371
+ kind: "ok",
372
+ checkpoint: normalizeConductEpicCheckpoint(parsed),
373
+ };
305
374
  }
306
375
  /** Build the initial document: every ticket `pending`, every counter zero. */
307
376
  export function createInitialConductEpicCheckpoint(input) {
@@ -311,6 +380,8 @@ export function createInitialConductEpicCheckpoint(input) {
311
380
  repo_name: input.repoName,
312
381
  epic_branch: input.epicBranch,
313
382
  base_branch_original: input.baseBranchOriginal,
383
+ index_scope_id: input.indexScopeId ?? null,
384
+ index_scope_lease_epoch: input.indexScopeLeaseEpoch ?? null,
314
385
  created_at: input.now,
315
386
  updated_at: input.now,
316
387
  deadlines: input.deadlines ?? {
@@ -328,6 +399,8 @@ export function createInitialConductEpicCheckpoint(input) {
328
399
  respawns: 0,
329
400
  conflict_attempts: 0,
330
401
  counters: { sessions_spawned: 0, plan_generations_observed: 0, merge_attempts: 0 },
402
+ parse_requested_at: null,
403
+ parse_requested_for_sha: null,
331
404
  journal: [],
332
405
  })),
333
406
  counters: { iterations: 0, merges: 0 },