@bridge_gpt/mcp-server 0.2.58 → 0.2.60

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 (127) hide show
  1. package/README.md +231 -113
  2. package/build/agent-guidance.generated.js +1 -1
  3. package/build/agent-notes.js +7 -1
  4. package/build/agent-registry.js +15 -0
  5. package/build/agent-utils.js +16 -8
  6. package/build/claude-user-config-doctor.js +1 -35
  7. package/build/codex-bridge-profile.js +1 -1
  8. package/build/commands.generated.js +7 -8
  9. package/build/conductor/bridge-api-client.js +202 -2
  10. package/build/conductor/bring-up-facts.js +1 -1
  11. package/build/conductor/claude-hook.js +16 -16
  12. package/build/conductor/doctor.js +279 -72
  13. package/build/conductor/epic-implementer-cli.js +38 -5
  14. package/build/conductor/epic-reconcile.js +1 -1
  15. package/build/conductor/epic-runtime.js +21 -12
  16. package/build/conductor/errors.js +3 -3
  17. package/build/conductor/file-scope-guard.js +4 -4
  18. package/build/conductor/git-ci-types.js +1 -1
  19. package/build/conductor/git-inspection.js +2 -2
  20. package/build/conductor/install-doctor.js +28 -6
  21. package/build/conductor/local-merge.js +319 -26
  22. package/build/conductor/merge-execution-contract.js +61 -0
  23. package/build/conductor/merge-execution-eligibility.js +129 -0
  24. package/build/conductor/merge-ledger.js +2 -2
  25. package/build/conductor/pr-ci-producer.js +6 -6
  26. package/build/conductor/pr-review-producer.js +2 -2
  27. package/build/conductor/producer-ledger.js +2 -0
  28. package/build/conductor/readiness-sections.js +166 -9
  29. package/build/conductor/readiness.js +97 -16
  30. package/build/conductor/recovery-cli.js +243 -12
  31. package/build/conductor/recovery-operations.js +64 -6
  32. package/build/conductor/repair-contract.js +38 -4
  33. package/build/conductor/spec-review-producer.js +6 -6
  34. package/build/conductor/store.js +10 -10
  35. package/build/conductor/supervisor-config.js +14 -14
  36. package/build/conductor/supervisor-ledger.js +2 -2
  37. package/build/conductor/supervisor-message-relay.js +2 -2
  38. package/build/conductor/worker-ledger-cli.js +19 -19
  39. package/build/conductor-bundle-artifacts.js +2 -2
  40. package/build/connect-bitbucket-api.js +6 -1
  41. package/build/connect-bitbucket.js +398 -88
  42. package/build/credential-store.js +16 -0
  43. package/build/docs.generated.js +2 -2
  44. package/build/doctor.js +199 -303
  45. package/build/drive-epic.js +88 -71
  46. package/build/epic-implementer-bin.js +22 -17
  47. package/build/epic-implementer-claude-hook-bin.js +4 -2
  48. package/build/epic-implementer-env.js +184 -0
  49. package/build/epic-integration-pr.js +2 -2
  50. package/build/executor/ci-rerun-job.js +269 -0
  51. package/build/executor/cli.js +31 -0
  52. package/build/executor/credentials.js +11 -0
  53. package/build/executor/env.js +79 -8
  54. package/build/executor/heartbeat.js +1 -1
  55. package/build/executor/index.js +21 -4
  56. package/build/executor/job-errors.js +3 -3
  57. package/build/executor/job-runner.js +127 -1
  58. package/build/executor/job-types.js +12 -2
  59. package/build/executor/merge-job.js +46 -5
  60. package/build/executor/observation.js +1 -1
  61. package/build/executor/preflight.js +1 -1
  62. package/build/executor/prompt-spec.js +1 -1
  63. package/build/executor/reconcile-overlap-job.js +10 -130
  64. package/build/executor/reconcile-overlap-types.js +1 -1
  65. package/build/executor/runner.js +1 -1
  66. package/build/executor/service-lifecycle.js +136 -0
  67. package/build/executor/service-unit.js +427 -9
  68. package/build/executor/types.js +5 -0
  69. package/build/index.js +320 -342
  70. package/build/init.js +20 -28
  71. package/build/install-bridge-conductor.js +208 -15
  72. package/build/install-bridge.js +555 -37
  73. package/build/mcp-host-entry-adapter.js +6 -0
  74. package/build/mcp-host-targets.js +2 -0
  75. package/build/mcp-install-state.js +42 -4
  76. package/build/mcp-invoke.js +5 -5
  77. package/build/mcp-profile.js +2 -2
  78. package/build/mcp-provisioning.js +2 -2
  79. package/build/mcp-server-invocation.js +3 -3
  80. package/build/merge-pull-request.js +118 -10
  81. package/build/merge-workflow-codeowners.js +224 -0
  82. package/build/merge-workflow-config.js +133 -0
  83. package/build/merge-workflow-drift.js +66 -0
  84. package/build/merge-workflow-install.js +638 -0
  85. package/build/merge-workflow.js +572 -0
  86. package/build/model-routing-host.js +25 -0
  87. package/build/model-routing-preferences.js +99 -0
  88. package/build/pipelines.generated.js +10 -10
  89. package/build/{plan-epic-conductor-eligibility.js → plan-epic-implementer-eligibility.js} +18 -16
  90. package/build/plan-execution-ledger.js +8 -0
  91. package/build/plane/cli.js +24 -2
  92. package/build/plane/member-roster.js +5 -5
  93. package/build/plane/preflight.js +68 -0
  94. package/build/plane/supervisor.js +1 -1
  95. package/build/plane/types.js +5 -5
  96. package/build/readiness-check.js +1 -1
  97. package/build/readme.generated.js +1 -1
  98. package/build/review-tickets.js +4 -4
  99. package/build/run-unit-tests-launcher.js +5 -0
  100. package/build/setup-epic.js +171 -17
  101. package/build/start-tickets-conductor.js +32 -32
  102. package/build/start-tickets-prereqs.js +4 -9
  103. package/build/start-tickets.js +475 -91
  104. package/build/version.generated.js +3 -3
  105. package/docs/CONDUCTOR.md +69 -18
  106. package/docs/install/mcp-tool-integrations.md +2 -1
  107. package/package.json +4 -4
  108. package/pipelines/implement-ticket.json +1 -1
  109. package/pipelines/plan-epic.json +2 -2
  110. package/build/conduct-epic/bridge-client.js +0 -773
  111. package/build/conduct-epic/checkpoint-store.js +0 -542
  112. package/build/conduct-epic/cli.js +0 -3145
  113. package/build/conduct-epic/cut-protocol.js +0 -392
  114. package/build/conduct-epic/lock.js +0 -302
  115. package/build/conduct-epic/pr-state.js +0 -286
  116. package/build/conduct-epic/spawn.js +0 -113
  117. package/build/conductor/cli.js +0 -1278
  118. package/build/conductor-bundle-cli.js +0 -256
  119. package/build/direct-ticket-tools.js +0 -70
  120. package/build/executor/reconcile-overlap-governance.js +0 -129
  121. package/build/implement-epic/checkpoint-store.js +0 -542
  122. package/build/implement-epic/cli.js +0 -3158
  123. package/build/implement-epic/lock.js +0 -302
  124. package/build/implement-epic/pr-state.js +0 -286
  125. package/build/implement-epic/spawn.js +0 -113
  126. /package/build/{implement-epic → epic-shared}/bridge-client.js +0 -0
  127. /package/build/{implement-epic → epic-shared}/cut-protocol.js +0 -0
@@ -17,8 +17,6 @@
17
17
  * worker-completed ONLY when the merge conflicted or the checks were red:
18
18
  * exactly one worker, holding both tickets' intents, edits
19
19
  * the working tree — it never commits and never pushes;
20
- * governance-scanned the candidate is compared against the governance regions
21
- * the server read at the INPUT epic head;
22
20
  * pushed one fast-forward push, leased on the recorded child head;
23
21
  * cleaned every exit that does not push resets the worktree to the
24
22
  * recorded child head.
@@ -32,7 +30,7 @@
32
30
  * The job returns FACTS, never a verdict (R14 rule 3): the input heads, the
33
31
  * classification, whether a worker ran, every edited path and why, the local
34
32
  * checks observed, and either the pushed head or the reason nothing was pushed —
35
- * a governance fact or an observed moved head. The server verifies all of it
33
+ * an observed moved head. The server verifies all of it
36
34
  * against the provider (`reconcile_overlap_completion_contract.py`).
37
35
  *
38
36
  * Runtime prerequisite: the push runs in the executor's OWN process under the
@@ -47,7 +45,6 @@ import { allRequiredChecksGreen } from "../conductor/local-merge.js";
47
45
  import { buildExecutorBaseWorkerEnv } from "./env.js";
48
46
  import { ReconcileOverlapBranchMismatch, ReconcileOverlapConflictsUnresolved, ReconcileOverlapMergeFailed, ReconcileOverlapNothingToMerge, ReconcileOverlapPayload as ReconcileOverlapPayloadInvalid, ReconcileOverlapPushRejected, ReconcileOverlapPushUnauthorized, ReconcileOverlapTopologyRefused, ReconcileOverlapVerificationRed, ReconcileOverlapWorkerRewroteHistory, } from "./job-errors.js";
49
47
  import { classifyMergeTree } from "./merge-tree-classifier.js";
50
- import { scanGovernanceRegions } from "./reconcile-overlap-governance.js";
51
48
  import { RECONCILE_OVERLAP_PAYLOAD_SCHEMA_VERSION, } from "./reconcile-overlap-types.js";
52
49
  import { validateWorkerFinalization } from "./worker-finalization.js";
53
50
  export const RECONCILE_OVERLAP_JOB_TYPE = "reconcile_overlap";
@@ -65,7 +62,6 @@ const MAX_ARGV = 32;
65
62
  const MAX_ARG_CHARS = 1024;
66
63
  const MAX_COMMAND_TIMEOUT_SECONDS = 30 * 60;
67
64
  const MAX_CHANGED_FILES = 1000;
68
- const MAX_GOVERNANCE_REGIONS = 64;
69
65
  const REF_NAME_MAX_CHARS = 255;
70
66
  const CONFLICT_REPORT_DIR = ".conductor/";
71
67
  /** The executor's per-worktree artifact directory, never part of a candidate. */
@@ -92,8 +88,6 @@ const WRAPPER_STAGE_EXCLUDES = [
92
88
  ":(exclude).claude/settings.local.json",
93
89
  ];
94
90
  const SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
95
- const TAG_RE = /^[A-Z][A-Z0-9_]{0,31}$/;
96
- const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
97
91
  const FAILED_CHECK_CONCLUSIONS = new Set([
98
92
  "failure",
99
93
  "cancelled",
@@ -194,54 +188,6 @@ function ticketContext(value, field) {
194
188
  verificationCommands: verificationCommands(entry.verification_commands, `${field}.verification_commands`),
195
189
  };
196
190
  }
197
- function governanceRegions(value) {
198
- if (!Array.isArray(value))
199
- throw new PayloadError("governance.regions must be a list");
200
- if (value.length > MAX_GOVERNANCE_REGIONS)
201
- throw new PayloadError("governance.regions exceeds its bound");
202
- return value.map((raw, index) => {
203
- const entry = asRecord(raw, `governance.regions[${index}]`);
204
- const tag = typeof entry.tag === "string" ? entry.tag : "";
205
- const regionPath = normalizeRepoRelativePath(entry.path);
206
- const begin = entry.begin_marker;
207
- const end = entry.end_marker;
208
- const sha = entry.content_sha256;
209
- if (!TAG_RE.test(tag) ||
210
- regionPath === null ||
211
- typeof begin !== "string" ||
212
- typeof end !== "string" ||
213
- !begin.includes(tag) ||
214
- !end.includes(tag) ||
215
- typeof sha !== "string" ||
216
- !SHA256_HEX_RE.test(sha) ||
217
- !Number.isInteger(entry.begin_line) ||
218
- !Number.isInteger(entry.end_line)) {
219
- throw new PayloadError(`governance.regions[${index}] is malformed`);
220
- }
221
- return {
222
- tag,
223
- path: regionPath,
224
- beginLine: entry.begin_line,
225
- endLine: entry.end_line,
226
- beginMarker: begin,
227
- endMarker: end,
228
- contentSha256: sha,
229
- };
230
- });
231
- }
232
- function governanceFindings(value) {
233
- if (value === undefined || value === null)
234
- return [];
235
- if (!Array.isArray(value))
236
- throw new PayloadError("governance.findings must be a list");
237
- return value.map((raw, index) => {
238
- const entry = asRecord(raw, `governance.findings[${index}]`);
239
- const findingPath = normalizeRepoRelativePath(entry.path);
240
- if (findingPath === null)
241
- throw new PayloadError(`governance.findings[${index}].path is unsafe`);
242
- return { path: findingPath, reason: typeof entry.reason === "string" ? entry.reason.slice(0, 300) : "" };
243
- });
244
- }
245
191
  /**
246
192
  * Validate the claimed job's payload. Pure — no subprocess, no filesystem. A
247
193
  * payload missing either input SHA fails here, so the job never infers a head.
@@ -270,7 +216,9 @@ export function validateReconcileOverlapPayload(job) {
270
216
  .map((c) => c.trim());
271
217
  const child = ticketContext(payload.child, "payload.child");
272
218
  const peer = payload.peer === null || payload.peer === undefined ? null : ticketContext(payload.peer, "payload.peer");
273
- const governanceRaw = asRecord(payload.governance, "payload.governance");
219
+ // BAPI-1215 — `payload.governance` is retired. A server that predates the
220
+ // retirement still sends it: it is accepted when present and never read, so
221
+ // this executor consumes both payload shapes at schema version 1.
274
222
  const conflictReportPath = normalizeRepoRelativePath(payload.conflict_report_path);
275
223
  if (conflictReportPath === null || !conflictReportPath.startsWith(CONFLICT_REPORT_DIR)) {
276
224
  throw new PayloadError("payload.conflict_report_path must name a file under .conductor/");
@@ -295,11 +243,6 @@ export function validateReconcileOverlapPayload(job) {
295
243
  requiredChecks,
296
244
  child,
297
245
  peer,
298
- governance: {
299
- regions: governanceRegions(governanceRaw.regions ?? []),
300
- findings: governanceFindings(governanceRaw.findings),
301
- scannedPaths: pathList(governanceRaw.scanned_paths ?? [], "governance.scanned_paths", MAX_CHANGED_FILES * 2),
302
- },
303
246
  declaredTouchedFilesUnion: pathList(payload.declared_touched_files_union ?? [], "payload.declared_touched_files_union", MAX_CHANGED_FILES * 4),
304
247
  conflictReportPath,
305
248
  prompt,
@@ -566,55 +509,6 @@ export function createReconcileOverlapDriver(job, payload, seams) {
566
509
  }
567
510
  return runs;
568
511
  };
569
- /** Paths the candidate changes relative to the input epic head. */
570
- const changedPathsAgainstEpic = async (ctx, candidate) => {
571
- const r = await git(ctx, ["diff", "--name-only", "-z", "--no-renames", payload.requiredEpicSha, candidate]);
572
- if (r.exitCode !== 0)
573
- return null;
574
- return r.stdout
575
- .split("\0")
576
- .map((p) => normalizeRepoRelativePath(p))
577
- .filter((p) => p !== null);
578
- };
579
- const readTreeFile = async (ctx, treeish, filePath) => {
580
- const r = await git(ctx, ["cat-file", "blob", `${treeish}:${filePath}`]);
581
- if (r.exitCode !== 0) {
582
- const exists = await git(ctx, ["cat-file", "-e", `${treeish}:${filePath}`]);
583
- return exists.exitCode === 0 ? { kind: "unreadable" } : { kind: "absent" };
584
- }
585
- return r.stdout.includes("\0") ? { kind: "binary" } : { kind: "text", text: r.stdout };
586
- };
587
- /** Governance scan of a candidate tree or commit against the input epic head. */
588
- const scanCandidate = async (ctx, candidate) => {
589
- const changed = await changedPathsAgainstEpic(ctx, candidate);
590
- if (changed === null)
591
- return "unprovable";
592
- return scanGovernanceRegions({
593
- snapshot: payload.governance,
594
- changedPaths: changed,
595
- readCandidate: (p) => readTreeFile(ctx, candidate, p),
596
- readInput: (p) => readTreeFile(ctx, payload.requiredEpicSha, p),
597
- });
598
- };
599
- const unprovableFact = () => ({
600
- tag: "GOVERNANCE_MARKERS_UNPROVABLE",
601
- paths: [],
602
- findings: [{ tag: "GOVERNANCE_MARKERS_UNPROVABLE", paths: [] }],
603
- truncated: false,
604
- });
605
- /** `git write-tree` of the index: the candidate BEFORE a commit exists. */
606
- const indexTree = async (ctx) => {
607
- const r = await git(ctx, ["write-tree"]);
608
- const oid = r.stdout.trim().toLowerCase();
609
- return r.exitCode === 0 && SHA_RE.test(oid) ? oid : null;
610
- };
611
- const governanceGate = async (ctx, candidate) => {
612
- const scanned = candidate === null ? "unprovable" : await scanCandidate(ctx, candidate);
613
- if (scanned === null)
614
- return null;
615
- await resetToChildHead(ctx);
616
- return complete({ governance_guard_change_required: scanned === "unprovable" ? unprovableFact() : scanned });
617
- };
618
512
  const commitStaged = async (ctx, message) => {
619
513
  const r = await git(ctx, ["commit", "--no-verify", "--no-edit", "-m", message]);
620
514
  if (r.exitCode !== 0)
@@ -678,12 +572,12 @@ export function createReconcileOverlapDriver(job, payload, seams) {
678
572
  await seams.sleep(Math.min(CI_WAIT_POLL_INTERVAL_MS, Math.max(0, ctx.remainingMs() - CI_WAIT_MARGIN_MS)));
679
573
  }
680
574
  };
681
- /** Drift check, final governance scan, guarded push, finalization, CI wait. */
575
+ /** Drift check, guarded push, finalization, CI wait. */
682
576
  const pushFlow = async (ctx) => {
683
577
  const candidate = await revParse(ctx, "HEAD");
684
578
  if (candidate === null) {
685
579
  await resetToChildHead(ctx);
686
- return fail(ReconcileOverlapTopologyRefused, "governance-scanned", "the candidate head could not be read");
580
+ return fail(ReconcileOverlapTopologyRefused, "pushed", "the candidate head could not be read");
687
581
  }
688
582
  // (1) Freshness: both remote heads must still be the dispatched ones. A moved
689
583
  // head is an OBSERVATION the server re-dispatches on; it is not a failure.
@@ -708,11 +602,7 @@ export function createReconcileOverlapDriver(job, payload, seams) {
708
602
  }
709
603
  return complete({ observed_epic_head: heads.epic });
710
604
  }
711
- // (2) Final governance scan of the exact tree about to be pushed.
712
- const gated = await governanceGate(ctx, candidate);
713
- if (gated !== null)
714
- return gated;
715
- // (3) The ONE push: a fast-forward of the child branch, leased on the
605
+ // (2) The ONE push: a fast-forward of the child branch, leased on the
716
606
  // recorded child head. Never plain --force, never a lease-less lease.
717
607
  const destination = `refs/heads/${payload.expectedBranch}`;
718
608
  const push = await git(ctx, [
@@ -741,7 +631,7 @@ export function createReconcileOverlapDriver(job, payload, seams) {
741
631
  }
742
632
  return fail(ReconcileOverlapPushRejected, "pushed", "the remote refused the guarded push");
743
633
  }
744
- // (4) Finalization runs ONCE, after the wrapper's push, against the pushed
634
+ // (3) Finalization runs ONCE, after the wrapper's push, against the pushed
745
635
  // head: origin must carry it and the PR must be attached at it.
746
636
  const finalize = seams.finalize ?? validateWorkerFinalization;
747
637
  const finalization = await finalize({
@@ -758,7 +648,7 @@ export function createReconcileOverlapDriver(job, payload, seams) {
758
648
  });
759
649
  if (!finalization.ok)
760
650
  return { kind: "fail", failure: finalization.failure };
761
- // (5) Wait for provider checks at the pushed head, bounded by the job budget.
651
+ // (4) Wait for provider checks at the pushed head, bounded by the job budget.
762
652
  await waitForProviderChecks(ctx, candidate);
763
653
  return complete({ resulting_head: candidate }, candidate);
764
654
  };
@@ -835,10 +725,6 @@ export function createReconcileOverlapDriver(job, payload, seams) {
835
725
  declaredTouchedFiles: payload.declaredTouchedFilesUnion,
836
726
  };
837
727
  }
838
- // Clean: scan the merged index BEFORE the wrapper creates the merge commit.
839
- const gated = await governanceGate(ctx, await indexTree(ctx));
840
- if (gated !== null)
841
- return finish(gated);
842
728
  const mergeCommit = await commitStaged(ctx, `Reconcile overlap: merge ${payload.requiredEpicSha.slice(0, 12)} into ${payload.expectedBranch}`);
843
729
  if (mergeCommit === null) {
844
730
  await resetToChildHead(ctx);
@@ -900,9 +786,6 @@ export function createReconcileOverlapDriver(job, payload, seams) {
900
786
  await resetToChildHead(ctx);
901
787
  return fail(ReconcileOverlapConflictsUnresolved, "worker-completed", "unmerged index entries remain");
902
788
  }
903
- const gated = await governanceGate(ctx, await indexTree(ctx));
904
- if (gated !== null)
905
- return gated;
906
789
  const mergeCommit = await commitStaged(ctx, `Reconcile overlap: merge ${payload.requiredEpicSha.slice(0, 12)} into ${payload.expectedBranch}`);
907
790
  if (mergeCommit === null) {
908
791
  await resetToChildHead(ctx);
@@ -913,9 +796,6 @@ export function createReconcileOverlapDriver(job, payload, seams) {
913
796
  // Any remaining worker repair: one ordinary commit ON TOP of the merge.
914
797
  await git(ctx, ["add", "-A", "--", ".", ...WRAPPER_STAGE_EXCLUDES]);
915
798
  if (await stagedChanges(ctx)) {
916
- const gated = await governanceGate(ctx, await indexTree(ctx));
917
- if (gated !== null)
918
- return gated;
919
799
  const repairPaths = await git(ctx, ["diff", "--cached", "--name-only", "-z"]);
920
800
  const repairReason = facts.failingCommandNames.length > 0
921
801
  ? `repair for failing check(s): ${facts.failingCommandNames.join(", ")}`
@@ -953,7 +833,7 @@ export function createReconcileOverlapDriver(job, payload, seams) {
953
833
  !(await isAncestor(ctx, payload.childHead, candidate)) ||
954
834
  !(await isAncestor(ctx, payload.requiredEpicSha, candidate))) {
955
835
  await resetToChildHead(ctx);
956
- return fail(ReconcileOverlapTopologyRefused, "governance-scanned", "the candidate is not a merge of the dispatched heads");
836
+ return fail(ReconcileOverlapTopologyRefused, "checked", "the candidate is not a merge of the dispatched heads");
957
837
  }
958
838
  return pushFlow(ctx);
959
839
  },
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Types for the `reconcile_overlap` executor job (BAPI-1152).
3
3
  *
4
- * The server authors the payload (`api/library/epic_conductor/reconcile_overlap_payload.py`)
4
+ * The server authors the payload (`api/library/epic_implementer/reconcile_overlap_payload.py`)
5
5
  * and verifies the result (`reconcile_overlap_completion_contract.py`); these
6
6
  * interfaces are the executor's view of both. They are deliberately plain data —
7
7
  * validation lives in `reconcile-overlap-job.ts`, which refuses anything that does
@@ -64,7 +64,7 @@ export function formatDispatcherUnavailableDiagnostic(liveness) {
64
64
  return (`executor: no fresh reconciler/dispatcher heartbeat is available ` +
65
65
  `(reconciler_liveness=${liveness}); the dispatcher may not be running. ` +
66
66
  "Check DISABLE_SCHEDULER and GET /automation/health; see " +
67
- "docs/claude/epic-conductor-v2-operator-runbook.md. Continuing to poll.");
67
+ "docs/claude/epic-implementer-v2-operator-runbook.md. Continuing to poll.");
68
68
  }
69
69
  /** The recovery diagnostic, emitted once when an absent dispatcher comes back. */
70
70
  export function formatDispatcherRecoveredDiagnostic() {
@@ -36,6 +36,24 @@
36
36
  * The command forms address user-domain (per-user) services directly:
37
37
  * `launchctl` against `gui/<uid>/<label>` on darwin, and `systemctl --user`
38
38
  * on linux.
39
+ *
40
+ * FOUR INDEPENDENT FACTS (BAPI-1137). A durable host is described by four
41
+ * separate observations, and no one of them implies another:
42
+ *
43
+ * 1. **Unit-content compliance** — what the generated/on-disk unit DECLARES
44
+ * (`service-unit.ts`). A perfect unit file can be sitting unloaded.
45
+ * 2. **Service-manager state** — what the manager is running RIGHT NOW
46
+ * ({@link inspectExecutorServiceState}). The manager may still be running a
47
+ * previously loaded definition rather than the current bytes on disk.
48
+ * 3. **Session persistence** — whether the host keeps running it once nobody is
49
+ * logged in ({@link inspectExecutorServiceDurability}). An `active` service
50
+ * with lingering off is active exactly until the operator logs out.
51
+ * 4. **Server-observed readiness** — whether BRIDGE has seen an executor claim.
52
+ * Only this one can produce `executor_ready`, and rule 4 above is why:
53
+ * nothing local, however green, is evidence for it.
54
+ *
55
+ * They are reported separately everywhere, and collapsing any pair of them would
56
+ * reintroduce exactly the dishonest-green this module is built to refuse.
39
57
  */
40
58
  import { posixShellQuote } from "../platform-escaping.js";
41
59
  /**
@@ -62,6 +80,10 @@ function renderCommand(command) {
62
80
  /**
63
81
  * Build the platform-specific start sequence.
64
82
  *
83
+ * UNCHANGED by BAPI-1137: start remains separately consented, one-shot, and
84
+ * non-retrying. The durability inspection added lower in this file is read-only
85
+ * and has no path to this function or to {@link startExecutorService}.
86
+ *
65
87
  * darwin: `launchctl bootstrap gui/<uid> <plist-path>`
66
88
  * linux: `systemctl --user daemon-reload`, then
67
89
  * `systemctl --user enable --now <unit-name>`
@@ -267,3 +289,117 @@ export async function inspectExecutorServiceState(target, run) {
267
289
  return { state: "inactive", probe };
268
290
  return { state: "unknown", probe };
269
291
  }
292
+ /**
293
+ * The Linux remediation, verbatim and in one place so the lifecycle module, the
294
+ * installer, and doctor cannot print three subtly different commands.
295
+ */
296
+ export const EXECUTOR_LINGER_REMEDIATION = "run `loginctl enable-linger $USER` yourself to keep this user's services running " +
297
+ "without an active login session (Bridge never runs it for you)";
298
+ /**
299
+ * The macOS prerequisite. A LaunchAgent bootstrapped into `gui/<uid>` lives in a
300
+ * user-login domain, which `launchctl(1)` documents as created when the user logs
301
+ * in at the GUI — so it needs that login to exist, and after a reboot it needs
302
+ * the user to log in again (automatic login, or a human at the keyboard).
303
+ */
304
+ export const EXECUTOR_MACOS_SESSION_REMEDIATION = "keep this user logged in at the GUI — a launchd LaunchAgent runs in the gui/<uid> " +
305
+ "login domain, so after a reboot the user must log in again (e.g. enable automatic " +
306
+ "login) for the executor to come back";
307
+ /**
308
+ * Build the strictly read-only durability prerequisite probe for a target.
309
+ *
310
+ * linux: `loginctl show-user <uid> --property=Linger` — a QUERY. `show-user`
311
+ * reads properties; it cannot enable or disable anything, which is what makes it
312
+ * safe to run unprompted where `enable-linger` would not be.
313
+ * darwin: `launchctl print gui/<uid>` — the login domain itself, not a service
314
+ * inside it. A zero exit means the GUI login domain exists for this uid.
315
+ *
316
+ * PURE: consults no runner, no environment, and no filesystem, so an
317
+ * incompletely described target is refused before anything could be executed.
318
+ */
319
+ export function buildExecutorServiceDurabilityProbe(target) {
320
+ if (typeof target.uid !== "number" || !Number.isInteger(target.uid) || target.uid < 0) {
321
+ return null;
322
+ }
323
+ if (target.platform === "linux") {
324
+ return {
325
+ stage: "loginctl show-user --property=Linger",
326
+ executable: "loginctl",
327
+ args: ["show-user", String(target.uid), "--property=Linger"],
328
+ };
329
+ }
330
+ if (target.platform === "darwin") {
331
+ return {
332
+ stage: "launchctl print gui",
333
+ executable: "launchctl",
334
+ args: ["print", `gui/${target.uid}`],
335
+ };
336
+ }
337
+ return null;
338
+ }
339
+ /**
340
+ * Inspect the local session/linger durability prerequisite. STRICTLY read-only:
341
+ * it issues exactly one query and never enables lingering, starts a service,
342
+ * reloads a manager, or writes anything.
343
+ *
344
+ * Everything the runner returns beyond a recognized state token is DISCARDED —
345
+ * stdout past the token, stderr entirely, and any thrown value — because a
346
+ * service manager's output can name paths, units, and environment values that
347
+ * have no place in operator-facing text.
348
+ */
349
+ export async function inspectExecutorServiceDurability(target, run) {
350
+ if (target.platform !== "darwin" && target.platform !== "linux") {
351
+ return { prerequisite: "not-applicable", probe: null, remediation: null };
352
+ }
353
+ const probe = buildExecutorServiceDurabilityProbe(target);
354
+ if (!probe)
355
+ return { prerequisite: "unverifiable", probe: null, remediation: null };
356
+ let exitCode;
357
+ let stdout;
358
+ try {
359
+ const result = await run(probe.executable, probe.args);
360
+ exitCode = typeof result?.exitCode === "number" ? result.exitCode : 1;
361
+ stdout = typeof result?.stdout === "string" ? result.stdout : "";
362
+ }
363
+ catch {
364
+ return { prerequisite: "unverifiable", probe, remediation: null };
365
+ }
366
+ if (target.platform === "darwin") {
367
+ // The login domain either exists for this uid or it does not; launchctl
368
+ // exposes no third answer here, and a non-zero exit is reported as an
369
+ // unavailable session rather than dressed up as a confirmed absence of
370
+ // durability the probe did not establish.
371
+ //
372
+ // A ZERO exit is NOT `configured`. It says the gui/<uid> domain exists at
373
+ // this instant — which it does whenever an operator runs `install conductor`
374
+ // or `doctor` from their own logged-in session — and a LaunchAgent in that
375
+ // domain is tied to the login, not to the machine being powered on. Calling
376
+ // it `configured` would let a renderer that means "survives logout" assert
377
+ // the opposite of what launchd guarantees, so darwin gets its own state and
378
+ // carries the session prerequisite as the thing to keep true.
379
+ if (exitCode === 0) {
380
+ return {
381
+ prerequisite: "session-bound",
382
+ probe,
383
+ remediation: EXECUTOR_MACOS_SESSION_REMEDIATION,
384
+ };
385
+ }
386
+ return {
387
+ prerequisite: "not-configured",
388
+ probe,
389
+ remediation: EXECUTOR_MACOS_SESSION_REMEDIATION,
390
+ };
391
+ }
392
+ if (exitCode !== 0)
393
+ return { prerequisite: "unverifiable", probe, remediation: null };
394
+ // `loginctl show-user --property=Linger` prints exactly `Linger=yes|no`. Only
395
+ // those two tokens are mapped; anything else is unverifiable rather than a
396
+ // guess derived from a format this module does not recognize.
397
+ const match = stdout.match(/^\s*Linger=(\S+)\s*$/m);
398
+ const value = match?.[1]?.toLowerCase();
399
+ if (value === "yes")
400
+ return { prerequisite: "configured", probe, remediation: null };
401
+ if (value === "no") {
402
+ return { prerequisite: "not-configured", probe, remediation: EXECUTOR_LINGER_REMEDIATION };
403
+ }
404
+ return { prerequisite: "unverifiable", probe, remediation: null };
405
+ }