@bridge_gpt/mcp-server 0.2.69 → 0.2.70

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 (151) hide show
  1. package/README.md +140 -292
  2. package/build/agent-launchers/claude-executor-adapter.js +17 -4
  3. package/build/agents.generated.js +4 -4
  4. package/build/artifact-diff.js +110 -0
  5. package/build/bin-entrypoint.js +36 -0
  6. package/build/bridge-permissions.js +23 -4
  7. package/build/claude-review-workflow-drift.js +3 -3
  8. package/build/codex-bridge-profile.js +1 -1
  9. package/build/commands.generated.js +6 -6
  10. package/build/conductor/bridge-api-client.js +598 -19
  11. package/build/conductor/bring-up-facts.js +9 -9
  12. package/build/conductor/doctor.js +466 -18
  13. package/build/conductor/epic-child-continuation.js +54 -3
  14. package/build/conductor/epic-implementer-cli.js +132 -34
  15. package/build/conductor/epic-runtime.js +2 -2
  16. package/build/conductor/epic-state.js +1 -1
  17. package/build/conductor/hook-runtime-qualification.js +1 -1
  18. package/build/conductor/install-doctor.js +4 -2
  19. package/build/conductor/launch-need-probes.js +337 -0
  20. package/build/conductor/local-merge.js +241 -56
  21. package/build/conductor/merge-credential.js +172 -0
  22. package/build/conductor/merge-execution-eligibility.js +1 -1
  23. package/build/conductor/merged-child-check.js +95 -0
  24. package/build/conductor/plan-trim.js +30 -0
  25. package/build/conductor/pr-ci-producer.js +1 -1
  26. package/build/conductor/readiness-cli.js +11 -6
  27. package/build/conductor/readiness-prerequisites.js +41 -0
  28. package/build/conductor/readiness-sections.js +345 -55
  29. package/build/conductor/readiness.js +99 -87
  30. package/build/conductor/recovery-cli.js +606 -21
  31. package/build/conductor/recovery-operations.js +306 -7
  32. package/build/conductor/repair-contract.js +47 -2
  33. package/build/conductor/repository-workflow-remedy.js +8 -6
  34. package/build/conductor/stored-merge-credential.js +76 -0
  35. package/build/conductor/stored-merge-token-probe.js +28 -0
  36. package/build/conductor/temporary-plan.js +50 -0
  37. package/build/conductor/wait-run-cli.js +759 -0
  38. package/build/conductor/worker-database-doctor-inspector.js +49 -0
  39. package/build/conductor-bin.js +2 -2
  40. package/build/conductor-claude-hook-bin.js +2 -2
  41. package/build/conductor-claude-hook-removed-stub-bin.js +2 -5
  42. package/build/conductor-removed-stub-bin.js +2 -5
  43. package/build/credential-store.js +356 -13
  44. package/build/docs.generated.js +26 -13
  45. package/build/doctor.js +94 -32
  46. package/build/drive-epic-help.js +212 -0
  47. package/build/drive-epic.js +1840 -373
  48. package/build/epic-ci-workflow-drift.js +77 -0
  49. package/build/epic-ci-workflow-install.js +183 -0
  50. package/build/epic-ci-workflow.js +157 -0
  51. package/build/epic-contract-context.js +326 -0
  52. package/build/epic-implementer-bin.js +57 -41
  53. package/build/epic-implementer-claude-hook-bin.js +2 -2
  54. package/build/escalation-reachability.js +134 -0
  55. package/build/executor/cleanup-report.js +99 -0
  56. package/build/executor/cli.js +12 -1
  57. package/build/executor/completion-contract.js +66 -11
  58. package/build/executor/completion-supervision.js +99 -0
  59. package/build/executor/delivery-outcome.js +179 -0
  60. package/build/executor/deps.js +3 -0
  61. package/build/executor/env.js +71 -0
  62. package/build/executor/failure-reason-codes.js +126 -0
  63. package/build/executor/heartbeat.js +38 -0
  64. package/build/executor/http-client.js +72 -0
  65. package/build/executor/integration-job.js +298 -5
  66. package/build/executor/job-errors.js +36 -6
  67. package/build/executor/job-runner.js +720 -36
  68. package/build/executor/likely-tests.js +36 -2
  69. package/build/executor/merge-job.js +145 -2
  70. package/build/executor/observation.js +73 -4
  71. package/build/executor/permissions.js +193 -97
  72. package/build/executor/preflight.js +14 -3
  73. package/build/executor/process-containment.js +28 -2
  74. package/build/executor/prompt-spec.js +50 -5
  75. package/build/executor/rebase-finalization.js +38 -3
  76. package/build/executor/reconcile-overlap-job.js +35 -7
  77. package/build/executor/required-work-ledger.js +236 -81
  78. package/build/executor/resume-pre-spawn.js +38 -1
  79. package/build/executor/runner.js +91 -3
  80. package/build/executor/test-clock.js +8 -0
  81. package/build/executor/types.js +37 -2
  82. package/build/executor/worker-cli-launcher.js +259 -0
  83. package/build/executor/worker-command.js +41 -4
  84. package/build/executor/worker-database-target.js +91 -0
  85. package/build/executor/worker-database.js +908 -0
  86. package/build/executor/worker-finalization.js +106 -0
  87. package/build/executor/worker-guard-hook.js +203 -0
  88. package/build/executor/worker-resources.js +47 -0
  89. package/build/executor/worker-verification-guidance.js +15 -2
  90. package/build/executor/worktree.js +303 -15
  91. package/build/executor-launch.js +11 -1
  92. package/build/generated-assets-doctor.js +9 -1
  93. package/build/generated-assets-health.js +13 -4
  94. package/build/generated-assets-legacy.js +39 -0
  95. package/build/generated-assets-paths.js +12 -1
  96. package/build/generated-assets-repair.js +4 -1
  97. package/build/generated-assets-test-support.js +12 -0
  98. package/build/github-remote.js +33 -0
  99. package/build/github-sign-in.js +62 -0
  100. package/build/index.js +1067 -536
  101. package/build/init.js +39 -1
  102. package/build/install-bridge-conductor-gate.js +78 -0
  103. package/build/install-bridge-conductor-setup.js +413 -0
  104. package/build/install-bridge-conductor.js +1226 -267
  105. package/build/install-bridge.js +376 -60
  106. package/build/mcp-install-state.js +8 -2
  107. package/build/merge-pull-request.js +6 -0
  108. package/build/merge-workflow-install.js +763 -345
  109. package/build/phase-result-artifacts.js +75 -20
  110. package/build/pipeline-orchestrator.js +247 -4
  111. package/build/pipelines.generated.js +35 -35
  112. package/build/plan-discovery.js +15 -2
  113. package/build/plan-execution-ledger.js +306 -17
  114. package/build/plan-phase-routing.js +48 -13
  115. package/build/plane/cli.js +105 -51
  116. package/build/plane/defaults.js +3 -0
  117. package/build/plane/plane-freshness.js +294 -0
  118. package/build/plane/schema-compatibility.js +137 -0
  119. package/build/plane/shutdown-deps.js +38 -0
  120. package/build/plane/shutdown.js +83 -8
  121. package/build/plane/terminal-run-cleanup.js +303 -0
  122. package/build/printed-remedy-commands.js +26 -4
  123. package/build/readiness-check.js +6 -2
  124. package/build/readiness-prerequisites.generated.js +532 -0
  125. package/build/readme.generated.js +1 -1
  126. package/build/setup-epic.js +400 -30
  127. package/build/shared-resources.generated.js +5 -0
  128. package/build/start-tickets.js +28 -15
  129. package/build/test-command-detection.js +247 -0
  130. package/build/test-command-validation.js +73 -0
  131. package/build/testing-config-client.js +150 -0
  132. package/build/ticket-delivery-artifacts.js +258 -0
  133. package/build/ticket-delivery-enrollment.js +306 -0
  134. package/build/ticket-delivery.js +205 -0
  135. package/build/ticket-review-summary.js +14 -0
  136. package/build/upgrade-cli.js +13 -0
  137. package/build/v2-readiness-requirements.js +552 -0
  138. package/build/version.generated.js +3 -3
  139. package/build/worker-guard-hook-bin.js +2 -2
  140. package/build/worktree-core.js +22 -7
  141. package/docs/BRIDGE-DIRECTORY-README.md +48 -0
  142. package/docs/EPIC-IMPLEMENTER-INSTALL.md +12 -867
  143. package/docs/EPIC-IMPLEMENTER.md +104 -63
  144. package/docs/epic-implementer/getting-started.md +131 -0
  145. package/docs/epic-implementer/self-hosting.md +133 -0
  146. package/package.json +3 -3
  147. package/pipelines/idea-to-ticket.json +1 -1
  148. package/pipelines/implement-ticket.json +8 -8
  149. package/pipelines/review-ticket.json +2 -2
  150. package/design-assets/favicon/favicon-32x32.png +0 -0
  151. package/design-assets/just-logo-rough-draft.png +0 -0
@@ -1,49 +1,65 @@
1
1
  /**
2
- * `install-bridge conductor --merge-workflow` — the generate-and-PR installer
3
- * (BAPI-1136).
2
+ * The generate-and-PR artifact installer (BAPI-1136, generalized by BAPI-1460).
4
3
  *
5
- * One command generates the Epic Implementer merge workflow plus its two review
6
- * artifacts, commits them on a managed branch, pushes with the CUSTOMER's own
7
- * git identity, and opens a pull request with the customer's own `gh`. It never
4
+ * One engine installs a named ARTIFACT SET: it generates the set's managed
5
+ * files, commits them on a managed branch, pushes with the CUSTOMER's own git
6
+ * identity, and opens a pull request with the customer's own `gh`. It never
8
7
  * merges that pull request, and Bridge never writes to the repository at all.
9
- * The customer's review IS the security control: this workflow is the one file
10
- * that can perform a privileged merge, so a human deciding to merge it is the
11
- * whole point rather than an inconvenience on the way to automation.
8
+ * Two sets exist today: the Epic Implementer merge workflow with its two review
9
+ * artifacts (`install-bridge conductor --merge-workflow`, below), and the
10
+ * Epic Implementer CI workflow (`epic-ci-workflow-install.ts`).
12
11
  *
13
12
  * ## Domain logic lives here, not in the CLI
14
13
  *
15
14
  * `install-bridge.ts` parses arguments and builds seams. Everything below — the
16
- * preflight, the four-way classification, the consent summary, the branch /
17
- * commit / push / PR sequence — is here so it is unit-testable with in-memory
18
- * fakes and so the CLI layer stays thin.
15
+ * preflight, the classification, the consent summary, the worktree / commit /
16
+ * push / PR sequence — is here so it is unit-testable with in-memory fakes and
17
+ * so the CLI layer stays thin.
19
18
  *
20
19
  * ## Read-only until it is not
21
20
  *
22
- * The ENTIRE preflight completes before the first branch, file write,
23
- * install-state write, commit, push, or pull request. That ordering is the
24
- * design: a failure discovered halfway through leaves a branch, or a file, or a
25
- * pushed ref in a customer's repository that they now have to understand and
26
- * clean up. Nothing here can partially install.
21
+ * The ENTIRE preflight — local checks, the remote default-branch head, every
22
+ * managed file's content on that head, the open managed pull request, and the
23
+ * managed remote branch — completes before the first fetch, file write, commit,
24
+ * push, or pull request. Nothing here can partially install.
27
25
  *
28
- * ## Four content states, and why `identical` is not the only no-op
26
+ * ## The person's checkout is never touched
29
27
  *
30
- * A file at the managed path is one of four things: absent, byte-identical to
31
- * what this build renders, a complete older version Bridge itself published, or
32
- * something else. Only the first and third are installable. "Something else"
33
- * includes a hand-edit, a truncation, a newer version from a future build, and a
34
- * file that merely claims a lower version in its marker — all of them get the
35
- * same fail-closed treatment, because this installer cannot tell a benign edit
36
- * from a hostile one and must not guess about a file that can merge code.
28
+ * All branch and file work happens in a temporary, DETACHED `git worktree`
29
+ * created from the verified default-branch head, outside the person's checkout,
30
+ * and removed on every exit path. The person's HEAD, index, and working files
31
+ * are unchanged at the end: no checkout, switch, reset, or stash ever runs
32
+ * against them. Content is classified from the default branch, not from
33
+ * whatever the person has checked out.
34
+ *
35
+ * ## Content states, and why `identical` is not the only no-op
36
+ *
37
+ * A file at a managed path is absent, byte-identical to the proposal, a complete
38
+ * older version Bridge itself published, or something else. Only the first and
39
+ * third are installable. "Something else" — a hand edit, a truncation, a newer
40
+ * version, a file that merely claims a version — is never overwritten: the
41
+ * installer prints a unified diff of what it would have written against what is
42
+ * there and reports a named refusal.
43
+ *
44
+ * ## Re-runs find their own work
45
+ *
46
+ * A re-run that finds an open managed pull request whose contents match the
47
+ * proposal reports it and writes nothing. One whose contents differ is reported
48
+ * for manual reconciliation. A pushed managed branch with no open pull request
49
+ * (left by an earlier `pr_open_failed`) is reported with a fixed next step; it
50
+ * is never reused, overwritten, or deleted automatically.
37
51
  *
38
52
  * ## Secret discipline
39
53
  *
40
- * No path here accepts, prompts for, reads, writes, or prints a credential. The
41
- * Bridge API key is referenced by NAME only. Raw `git` and `gh` output never
42
- * reaches the operator: every failure is a stable named reason plus one
43
- * remediation sentence, because subprocess text is unbounded and can carry a
44
- * remote URL with an embedded token.
54
+ * No path here accepts, prompts for, reads, writes, or prints a credential. Raw
55
+ * `git` and `gh` output never reaches the operator: every failure is a stable
56
+ * named reason plus one remediation sentence, because subprocess text is
57
+ * unbounded and can carry a remote URL with an embedded token.
45
58
  */
46
- import { resolve } from "node:path";
59
+ import { lstat as fsLstat, mkdtemp, rm } from "node:fs/promises";
60
+ import { tmpdir } from "node:os";
61
+ import { join, resolve } from "node:path";
62
+ import { renderUnifiedDiff } from "./artifact-diff.js";
47
63
  import { MERGE_WORKFLOW_EXPECTED_VERSION, MERGE_WORKFLOW_PATH, } from "./conductor/merge-execution-contract.js";
48
64
  import { MERGE_WORKFLOW_API_KEY_SECRET_NAME, } from "./merge-workflow-config.js";
49
65
  import { MERGE_WORKFLOW_CLASSIFICATION_TEXT, classifyMergeWorkflowContent, } from "./merge-workflow-drift.js";
@@ -51,7 +67,7 @@ import { MERGE_WORKFLOW_CODEOWNERS_RELPATH, MERGE_WORKFLOW_GUIDANCE_RELPATH, MER
51
67
  import { renderEpicImplementerMergeWorkflow } from "./merge-workflow.js";
52
68
  import { MCP_INSTALL_STATE_RELPATH } from "./mcp-install-state.js";
53
69
  // ---------------------------------------------------------------------------
54
- // Managed identity
70
+ // Managed identity (merge-workflow set)
55
71
  // ---------------------------------------------------------------------------
56
72
  /** The managed branch this installer creates, for a fresh installation. */
57
73
  export const MERGE_WORKFLOW_INSTALL_BRANCH = "bridge/merge-workflow-v" + String(MERGE_WORKFLOW_EXPECTED_VERSION);
@@ -73,7 +89,8 @@ export const MERGE_WORKFLOW_UPGRADE_PR_TITLE = MERGE_WORKFLOW_UPGRADE_COMMIT_SUB
73
89
  *
74
90
  * Closed on purpose: each value is printed verbatim as the first token of a
75
91
  * stderr line, so the vocabulary is an operator-facing contract, and the set has
76
- * to be greppable rather than assembled from subprocess text.
92
+ * to be greppable rather than assembled from subprocess text. Tokens may be
93
+ * added; none may be removed or renamed.
77
94
  */
78
95
  export const MERGE_WORKFLOW_INSTALL_FAILURE_REASONS = [
79
96
  /** `git` is not on PATH. */
@@ -106,6 +123,21 @@ export const MERGE_WORKFLOW_INSTALL_FAILURE_REASONS = [
106
123
  "pr_open_failed",
107
124
  /** A required write surface was declined, or no prompt was available. */
108
125
  "consent_declined",
126
+ // --- BAPI-1460 -----------------------------------------------------------
127
+ /** A remote read (default-branch head, managed file, PR, or branch) gave no reliable answer. */
128
+ "remote_state_unverifiable",
129
+ /** An open managed pull request carries contents that differ from the current proposal. */
130
+ "existing_pr_differs",
131
+ /** A managed branch is pushed but has no open pull request. */
132
+ "managed_branch_without_pr",
133
+ /** The default branch moved after consent and no plan could be re-approved at the new head. */
134
+ "default_branch_moved",
135
+ /** Another actor created the managed branch or pull request during this run. */
136
+ "managed_state_changed",
137
+ /** The temporary worktree could not be created. */
138
+ "worktree_failed",
139
+ /** The test command for the CI workflow is missing or invalid. */
140
+ "test_command_invalid",
109
141
  ];
110
142
  // ---------------------------------------------------------------------------
111
143
  // Path safety
@@ -113,10 +145,10 @@ export const MERGE_WORKFLOW_INSTALL_FAILURE_REASONS = [
113
145
  /**
114
146
  * Reject any managed path that is not a plain, repository-relative POSIX path.
115
147
  *
116
- * The three managed paths are constants, so this can never fire in production —
117
- * which is exactly why it is here. It is the assertion that keeps a future edit
118
- * from introducing an absolute path, a `..` escape, or a Windows separator into
119
- * the one code path that writes files and stages them for commit.
148
+ * The managed paths are constants, so this can never fire in production — which
149
+ * is exactly why it is here. It keeps a future edit from introducing an
150
+ * absolute path, a `..` escape, or a Windows separator into the one code path
151
+ * that writes files and stages them for commit.
120
152
  */
121
153
  export function isSafeManagedRelPath(relPath) {
122
154
  if (typeof relPath !== "string" || relPath.length === 0)
@@ -137,49 +169,19 @@ function joinCwd(cwd, rel) {
137
169
  return `${base}/${rel}`;
138
170
  }
139
171
  function isEnoent(err) {
140
- return typeof err === "object" && err !== null && err.code === "ENOENT";
141
- }
142
- /** Read a file, mapping "absent" to `null` and any other error to a throw. */
143
- async function readOrNull(deps, absPath) {
144
- try {
145
- return await deps.readFile(absPath);
146
- }
147
- catch (err) {
148
- if (isEnoent(err))
149
- return null;
150
- throw err;
151
- }
172
+ const code = typeof err === "object" && err !== null ? err.code : undefined;
173
+ return code === "ENOENT" || code === "ENOTDIR";
152
174
  }
153
175
  // ---------------------------------------------------------------------------
154
176
  // Remote identity
155
177
  // ---------------------------------------------------------------------------
156
- /**
157
- * Parse `owner/name` out of a git remote URL, accepting ONLY canonical
158
- * github.com forms.
159
- *
160
- * GitHub Enterprise and every non-GitHub host return `null`. This feature is
161
- * GitHub.com-only: the workflow it installs uses `${{ github.token }}` and the
162
- * GitHub REST surface, and quietly "succeeding" against a host where the result
163
- * cannot work would open a pull request that can never actuate anything.
164
- */
165
- export function parseGithubRemote(remoteUrl) {
166
- if (typeof remoteUrl !== "string")
167
- return null;
168
- const trimmed = remoteUrl.trim();
169
- if (trimmed.length === 0)
170
- return null;
171
- const patterns = [
172
- /^https:\/\/github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/,
173
- /^ssh:\/\/git@github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/,
174
- /^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/,
175
- ];
176
- for (const pattern of patterns) {
177
- const m = pattern.exec(trimmed);
178
- if (m)
179
- return `${m[1]}/${m[2]}`;
180
- }
181
- return null;
182
- }
178
+ // BAPI-1461: moved to a dependency-free module so credential resolution can use
179
+ // it without loading this installer's graph; re-exported so this surface is unchanged.
180
+ import { parseGithubRemote } from "./github-remote.js";
181
+ export { parseGithubRemote };
182
+ // ---------------------------------------------------------------------------
183
+ // Remediation
184
+ // ---------------------------------------------------------------------------
183
185
  /** One remediation sentence per named failure. Printed after the reason token. */
184
186
  const REMEDIATION = {
185
187
  git_unavailable: "Install git and re-run; this command creates the branch with your own git.",
@@ -192,15 +194,29 @@ const REMEDIATION = {
192
194
  codeowner_invalid: "Pass exactly one real reviewer as `--codeowner @org/team` or `--codeowner @user`.",
193
195
  workflow_modified_or_unrecognized: "Review the existing file by hand; this command never overwrites a workflow Bridge did not write.",
194
196
  companion_artifact_conflict: "Review the differing file by hand and remove or reconcile it, then re-run.",
195
- existing_pr_unverifiable: "Inspect the open Bridge merge-workflow pull request by hand and close it if it is not the one you want.",
197
+ existing_pr_unverifiable: "Inspect the open Bridge pull request by hand and close it if it is not the one you want.",
196
198
  write_failed: "Check directory permissions for the repository and re-run.",
197
- git_failed: "Check the reported branch with git by hand; nothing already created has been removed.",
198
- pr_open_failed: "The branch is pushed; open the pull request by hand, or re-run this command to reuse it.",
199
+ git_failed: "Check the reported branch with git by hand; nothing already pushed has been removed.",
200
+ pr_open_failed: "The branch is pushed; open a pull request from it by hand, or delete that remote branch, then re-run this command.",
199
201
  consent_declined: "Re-run in an interactive terminal and accept the write to continue.",
202
+ remote_state_unverifiable: "Check that `gh` is authenticated and can read this repository, then re-run; nothing was written.",
203
+ existing_pr_differs: "An open Bridge pull request differs from what this build proposes; review it and close it, then re-run.",
204
+ managed_branch_without_pr: "Open a pull request from the managed branch, or delete that remote branch, then re-run this command.",
205
+ default_branch_moved: "The default branch changed while this command ran; re-run it to review the new proposal.",
206
+ managed_state_changed: "Another managed branch or pull request appeared while this command ran; review it, then re-run.",
207
+ worktree_failed: "A temporary git worktree could not be created; check free disk space and `git worktree list`, then re-run.",
208
+ test_command_invalid: "Save a valid test command through Bridge setup, then re-run.",
200
209
  };
201
- function fail(reason) {
202
- return { outcome: "failed", reason, remediation: REMEDIATION[reason] };
210
+ /** The remediation sentence for a reason. */
211
+ export function artifactSetRemediation(reason) {
212
+ return REMEDIATION[reason];
203
213
  }
214
+ function fail(reason, extra = {}) {
215
+ return { outcome: "failed", reason, remediation: REMEDIATION[reason], warnings: [], ...extra };
216
+ }
217
+ // ---------------------------------------------------------------------------
218
+ // Subprocess helpers
219
+ // ---------------------------------------------------------------------------
204
220
  async function commandSucceeds(run, args) {
205
221
  try {
206
222
  const result = await run(args);
@@ -211,31 +227,242 @@ async function commandSucceeds(run, args) {
211
227
  }
212
228
  }
213
229
  async function captureStdout(run, args) {
230
+ const raw = await captureRawStdout(run, args);
231
+ return raw === null ? null : raw.trim();
232
+ }
233
+ /** Successful stdout, UNtrimmed — a leading status column is significant. */
234
+ async function captureRawStdout(run, args) {
214
235
  try {
215
236
  const result = await run(args);
216
237
  if (result.status !== 0)
217
238
  return null;
218
- return result.stdout.trim();
239
+ return result.stdout;
219
240
  }
220
241
  catch {
221
242
  return null;
222
243
  }
223
244
  }
245
+ /** Whether a porcelain-v1 status line names exactly the install manifest. */
246
+ function isInstallStateLine(line) {
247
+ // `XY <path>`: two status columns, a space, then the path.
248
+ return line.length > 3 && line.slice(3) === MCP_INSTALL_STATE_RELPATH;
249
+ }
250
+ /** Split `gh api --include` output into its status code and RAW body. */
251
+ function parseIncludedResponse(stdout) {
252
+ const status = /^HTTP\/[0-9.]+ (\d{3})\b/.exec(stdout);
253
+ if (!status)
254
+ return null;
255
+ const crlf = stdout.indexOf("\r\n\r\n");
256
+ const lf = stdout.indexOf("\n\n");
257
+ let cut;
258
+ if (crlf >= 0 && (lf < 0 || crlf < lf))
259
+ cut = crlf + 4;
260
+ else if (lf >= 0)
261
+ cut = lf + 2;
262
+ else
263
+ return null;
264
+ return { status: Number(status[1]), body: stdout.slice(cut) };
265
+ }
224
266
  /**
225
- * Run the complete read-only preflight.
226
- *
227
- * Every check here is a read. Nothing it calls creates a branch, writes a file,
228
- * touches install state, commits, pushes, or opens a pull request — which is
229
- * what lets the caller treat a failure as "nothing happened".
267
+ * Read one file's RAW bytes from the remote at an immutable commit, through
268
+ * `gh api`. A confirmed 404 is `absent`; anything else that is not a clean 200
269
+ * is `unverifiable` — an uncertain read must never authorize a replacement.
270
+ */
271
+ async function readRemoteFile(deps, repository, sha, relPath) {
272
+ const encodedPath = relPath.split("/").map(encodeURIComponent).join("/");
273
+ let result;
274
+ try {
275
+ result = await deps.runGh([
276
+ "api",
277
+ "--include",
278
+ "-H",
279
+ "Accept: application/vnd.github.raw",
280
+ `repos/${repository}/contents/${encodedPath}?ref=${encodeURIComponent(sha)}`,
281
+ ]);
282
+ }
283
+ catch {
284
+ return { kind: "unverifiable" };
285
+ }
286
+ const parsed = parseIncludedResponse(result.stdout ?? "");
287
+ if (!parsed)
288
+ return { kind: "unverifiable" };
289
+ if (parsed.status === 200 && result.status === 0)
290
+ return { kind: "present", content: parsed.body };
291
+ if (parsed.status === 404)
292
+ return { kind: "absent" };
293
+ return { kind: "unverifiable" };
294
+ }
295
+ /** The SHA a remote branch points at; `null` when absent; `undefined` when unknown. */
296
+ async function readRemoteBranchSha(deps, remoteName, branch) {
297
+ const ref = `refs/heads/${branch}`;
298
+ const raw = await captureRawStdout(deps.runGit, ["ls-remote", remoteName, ref]);
299
+ if (raw === null)
300
+ return undefined;
301
+ const lines = raw.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
302
+ const matches = lines
303
+ .map((line) => line.split(/\s+/))
304
+ .filter((parts) => parts.length === 2 && parts[1] === ref);
305
+ if (matches.length === 0)
306
+ return lines.length === 0 ? null : undefined;
307
+ if (matches.length > 1 || !/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(matches[0][0]))
308
+ return undefined;
309
+ return matches[0][0];
310
+ }
311
+ /**
312
+ * Find an open pull request already carrying this installation, by the
313
+ * deterministic managed BRANCH identity (a title is prose anyone can change).
314
+ * A lookup failure is distinct from a reliable "no open PR".
315
+ */
316
+ async function findExistingManagedPr(deps, repository, branch) {
317
+ const raw = await captureStdout(deps.runGh, [
318
+ "pr",
319
+ "list",
320
+ "--repo",
321
+ repository,
322
+ "--state",
323
+ "open",
324
+ "--head",
325
+ branch,
326
+ "--json",
327
+ "url,headRefName,headRefOid,baseRefName,isCrossRepository,files",
328
+ "--limit",
329
+ "2",
330
+ ]);
331
+ if (raw === null)
332
+ return { kind: "lookup_failed" };
333
+ let parsed;
334
+ try {
335
+ parsed = JSON.parse(raw);
336
+ }
337
+ catch {
338
+ return { kind: "lookup_failed" };
339
+ }
340
+ if (!Array.isArray(parsed))
341
+ return { kind: "lookup_failed" };
342
+ if (parsed.length === 0)
343
+ return { kind: "none" };
344
+ if (parsed.length > 1) {
345
+ // Two open PRs from one managed branch name cannot both be ours.
346
+ return { kind: "found", pr: { url: "", headRefName: "", headRefOid: "", baseRefName: "", isCrossRepository: null, files: [] } };
347
+ }
348
+ const first = parsed[0];
349
+ if (typeof first.url !== "string")
350
+ return { kind: "lookup_failed" };
351
+ const files = Array.isArray(first.files)
352
+ ? first.files
353
+ .map((f) => (f && typeof f === "object" ? f.path : undefined))
354
+ .filter((p) => typeof p === "string")
355
+ : [];
356
+ return {
357
+ kind: "found",
358
+ pr: {
359
+ url: first.url,
360
+ headRefName: typeof first.headRefName === "string" ? first.headRefName : "",
361
+ headRefOid: typeof first.headRefOid === "string" ? first.headRefOid : "",
362
+ baseRefName: typeof first.baseRefName === "string" ? first.baseRefName : "",
363
+ isCrossRepository: typeof first.isCrossRepository === "boolean" ? first.isCrossRepository : null,
364
+ files,
365
+ },
366
+ };
367
+ }
368
+ /**
369
+ * Confirm a detected pull request has the shape of this installation's review
370
+ * surface: the managed head branch in THIS repository, the default branch as
371
+ * base, and only managed paths changed.
372
+ */
373
+ function existingPrIsExpected(pr, branch, defaultBranch, managedPaths) {
374
+ if (pr.headRefName !== branch)
375
+ return false;
376
+ if (pr.baseRefName !== defaultBranch)
377
+ return false;
378
+ if (pr.isCrossRepository !== false)
379
+ return false;
380
+ if (!/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(pr.headRefOid))
381
+ return false;
382
+ if (pr.files.length === 0)
383
+ return false;
384
+ const managed = new Set(managedPaths);
385
+ return pr.files.every((f) => managed.has(f));
386
+ }
387
+ /** Whether every managed file at the PR head equals the proposal. `null` if unreadable. */
388
+ async function prContentMatchesProposal(deps, repository, pr, proposals) {
389
+ for (const [relPath, proposal] of proposals) {
390
+ const file = await readRemoteFile(deps, repository, pr.headRefOid, relPath);
391
+ if (file.kind === "unverifiable")
392
+ return null;
393
+ if (file.kind === "absent" || file.content !== proposal)
394
+ return false;
395
+ }
396
+ return true;
397
+ }
398
+ function compareUrl(repository, defaultBranch, branch) {
399
+ return `https://github.com/${repository}/compare/${encodeURI(defaultBranch)}...${encodeURI(branch)}`;
400
+ }
401
+ /**
402
+ * Classify every managed artifact against the default branch at `sha`.
403
+ * Read-only. Prints each refused artifact's diff.
404
+ */
405
+ async function classifyAtRemote(deps, descriptor, repository, sha, quiet) {
406
+ const proposals = new Map();
407
+ let allIdentical = true;
408
+ let upgrade = false;
409
+ const refusals = [];
410
+ for (const artifact of descriptor.artifacts) {
411
+ const proposal = artifact.render();
412
+ proposals.set(artifact.relPath, proposal);
413
+ const remote = await readRemoteFile(deps, repository, sha, artifact.relPath);
414
+ if (remote.kind === "unverifiable")
415
+ return { ok: false, result: fail("remote_state_unverifiable") };
416
+ const existing = remote.kind === "present" ? remote.content : null;
417
+ const classification = artifact.classify(existing);
418
+ if (artifact.progressLabel && classification.text && !quiet) {
419
+ deps.log(` ${artifact.progressLabel}: ${classification.text}`);
420
+ }
421
+ if (classification.state === "conflict") {
422
+ refusals.push({
423
+ reason: classification.reason,
424
+ diff: { relPath: artifact.relPath, diff: renderUnifiedDiff(artifact.relPath, existing ?? "", proposal) },
425
+ });
426
+ continue;
427
+ }
428
+ if (classification.state !== "identical")
429
+ allIdentical = false;
430
+ if (classification.state === "managed_version_older")
431
+ upgrade = true;
432
+ }
433
+ if (refusals.length > 0) {
434
+ if (!quiet) {
435
+ for (const { diff } of refusals) {
436
+ deps.log("");
437
+ deps.log(`Not overwriting ${diff.relPath}. What this command would have written, against what is there:`);
438
+ for (const line of diff.diff.replace(/\n$/, "").split("\n"))
439
+ deps.log(line);
440
+ }
441
+ }
442
+ return {
443
+ ok: false,
444
+ result: fail(refusals[0].reason, { diffs: refusals.map((r) => r.diff) }),
445
+ };
446
+ }
447
+ return {
448
+ ok: true,
449
+ classification: allIdentical ? "identical" : upgrade ? "managed_version_older" : "not_installed",
450
+ proposals,
451
+ };
452
+ }
453
+ /**
454
+ * Run the complete read-only preflight for an artifact set.
230
455
  *
231
- * The progress lines are emitted in a fixed order — repository/worktree, default
232
- * branch, remote, git, gh, code owner — so two runs on different machines
233
- * produce comparable output.
456
+ * Every check here is a read: executables, repository root, clean worktree,
457
+ * default branch, remote and `gh` identity, set-specific options, the remote
458
+ * default-branch head (via `ls-remote`, no fetch), and each managed file's raw
459
+ * bytes at that head (via `gh api`). Nothing it calls creates a branch, writes a
460
+ * file, touches install state, fetches, commits, pushes, or opens a pull request.
234
461
  */
235
- export async function runMergeWorkflowPreflight(deps, options) {
462
+ export async function runArtifactSetPreflight(descriptor, deps) {
236
463
  // Managed paths are constants; this guards a future edit, not today's input.
237
- for (const relPath of MERGE_WORKFLOW_MANAGED_RELPATHS) {
238
- if (!isSafeManagedRelPath(relPath))
464
+ for (const artifact of descriptor.artifacts) {
465
+ if (!isSafeManagedRelPath(artifact.relPath))
239
466
  return { ok: false, result: fail("write_failed") };
240
467
  }
241
468
  if (!(await commandSucceeds(deps.runGit, ["--version"]))) {
@@ -253,26 +480,21 @@ export async function runMergeWorkflowPreflight(deps, options) {
253
480
  return { ok: false, result: fail("not_repository_root") };
254
481
  }
255
482
  deps.log(` repository root: ${normalizedRoot}`);
256
- // A clean worktree is required BEFORE a branch exists, so an unrelated
257
- // customer change can never be swept into the managed commit below.
483
+ // A clean worktree is still required, so the person is not surprised by a
484
+ // proposal built while their own changes are in flight.
258
485
  //
259
486
  // `--untracked-files=all` rather than the default: git otherwise COLLAPSES an
260
- // untracked directory to a single `?? .bridge/` entry, and the one path that
261
- // must be tolerated below could not then be distinguished from its neighbours.
262
- const status = await captureStdout(deps.runGit, ["status", "--porcelain", "--untracked-files=all"]);
487
+ // untracked directory to a single `?? .bridge/` entry. The output is read RAW
488
+ // (untrimmed) because the first status column is significant, and exactly one
489
+ // path — the install manifest, by full name — is tolerated.
490
+ const status = await captureRawStdout(deps.runGit, ["status", "--porcelain", "--untracked-files=all"]);
263
491
  if (status === null)
264
492
  return { ok: false, result: fail("git_failed") };
265
493
  const dirty = status
266
494
  .split("\n")
267
- .map((line) => line.trim())
268
- .filter((line) => line.length > 0)
269
- // The install manifest is Bridge's OWN record, written by this command and
270
- // deliberately never staged. Counting it as a customer change made a
271
- // successful install poison the very re-run the ticket requires to be a
272
- // clean no-op: the first run left the file behind, and the second refused
273
- // with `worktree_not_clean`. Exactly one path is tolerated, by full name, so
274
- // any other file under `.bridge/` still makes the worktree dirty.
275
- .filter((line) => !line.endsWith(MCP_INSTALL_STATE_RELPATH));
495
+ .map((line) => line.replace(/\r$/, ""))
496
+ .filter((line) => line.trim().length > 0)
497
+ .filter((line) => !isInstallStateLine(line));
276
498
  if (dirty.length > 0)
277
499
  return { ok: false, result: fail("worktree_not_clean") };
278
500
  deps.log(" worktree: clean");
@@ -309,135 +531,75 @@ export async function runMergeWorkflowPreflight(deps, options) {
309
531
  return { ok: false, result: fail("repository_identity_mismatch") };
310
532
  }
311
533
  deps.log(` gh: authenticated for ${ghRepository}`);
312
- let codeOwner;
313
- try {
314
- codeOwner = validateMergeWorkflowCodeOwner(options.codeOwner);
315
- }
316
- catch (err) {
317
- if (err instanceof MergeWorkflowCodeOwnerError) {
318
- return { ok: false, result: fail("codeowner_invalid") };
319
- }
320
- throw err;
534
+ if (descriptor.validateOptions) {
535
+ const checked = descriptor.validateOptions();
536
+ if (!checked.ok)
537
+ return { ok: false, result: fail(checked.reason) };
538
+ for (const line of checked.progress)
539
+ deps.log(line);
321
540
  }
322
- deps.log(` code owner: ${codeOwner}`);
323
- // --- content classification -------------------------------------------
324
- let existingWorkflow;
325
- try {
326
- existingWorkflow = await readOrNull(deps, joinCwd(normalizedRoot, MERGE_WORKFLOW_PATH));
327
- }
328
- catch {
329
- return { ok: false, result: fail("write_failed") };
330
- }
331
- const classification = classifyMergeWorkflowContent(existingWorkflow);
332
- deps.log(` workflow: ${MERGE_WORKFLOW_CLASSIFICATION_TEXT[classification]}`);
333
- if (classification === "modified_or_unrecognized") {
334
- return { ok: false, result: fail("workflow_modified_or_unrecognized") };
335
- }
336
- // Companion artifacts are inspected BEFORE any write. An existing file whose
337
- // bytes differ from what this owner would produce is a conflict, not a target:
338
- // it may be a previous install for a DIFFERENT code owner, or a customer edit.
339
- const companions = [
340
- [MERGE_WORKFLOW_CODEOWNERS_RELPATH, renderMergeWorkflowCodeownersFragment(codeOwner)],
341
- [MERGE_WORKFLOW_GUIDANCE_RELPATH, renderMergeWorkflowGuidance(codeOwner)],
342
- ];
343
- let allCompanionsIdentical = true;
344
- for (const [relPath, expected] of companions) {
345
- let existing;
346
- try {
347
- existing = await readOrNull(deps, joinCwd(normalizedRoot, relPath));
348
- }
349
- catch {
350
- return { ok: false, result: fail("write_failed") };
351
- }
352
- if (existing === null) {
353
- allCompanionsIdentical = false;
354
- continue;
355
- }
356
- if (existing !== expected) {
357
- return { ok: false, result: fail("companion_artifact_conflict") };
358
- }
541
+ // The default branch head, read from the remote. A managed branch is never
542
+ // the default branch; that is enforced again at push time.
543
+ if (descriptor.installBranch === ghDefaultBranch || descriptor.upgradeBranch === ghDefaultBranch) {
544
+ return { ok: false, result: fail("default_branch_unresolved") };
359
545
  }
360
- // The aggregate no-op requires EVERY managed artifact to match. A workflow that
361
- // is already identical while its guidance is missing is still an installation.
362
- const effective = classification === "identical" && !allCompanionsIdentical ? "not_installed" : classification;
546
+ const defaultBranchSha = await readRemoteBranchSha(deps, remoteName, ghDefaultBranch);
547
+ if (defaultBranchSha === undefined)
548
+ return { ok: false, result: fail("remote_state_unverifiable") };
549
+ if (defaultBranchSha === null)
550
+ return { ok: false, result: fail("default_branch_unresolved") };
551
+ const classified = await classifyAtRemote(deps, descriptor, ghRepository, defaultBranchSha, false);
552
+ if (!classified.ok)
553
+ return { ok: false, result: classified.result };
363
554
  return {
364
555
  ok: true,
365
556
  preflight: {
366
557
  repositoryRoot: normalizedRoot,
367
558
  defaultBranch: ghDefaultBranch,
559
+ defaultBranchSha,
368
560
  remoteName,
369
561
  repository: ghRepository,
370
- codeOwner,
371
- classification: effective,
562
+ classification: classified.classification,
563
+ proposals: classified.proposals,
372
564
  },
373
565
  };
374
566
  }
375
567
  /**
376
- * Find an open pull request already carrying this installation.
377
- *
378
- * Looked up by the deterministic managed BRANCH identity rather than by title:
379
- * a title is prose anyone can change, while the branch is the thing this command
380
- * would otherwise try to create.
568
+ * Inspect the managed pull request and branch for `branch`. Read-only.
569
+ * Returns `null` when the way is clear, or the outcome to report.
381
570
  */
382
- async function findExistingManagedPr(deps, branch) {
383
- const raw = await captureStdout(deps.runGh, [
384
- "pr",
385
- "list",
386
- "--state",
387
- "open",
388
- "--head",
389
- branch,
390
- "--json",
391
- "url,headRefName,baseRefName,files",
392
- "--limit",
393
- "1",
394
- ]);
395
- if (!raw)
396
- return null;
397
- try {
398
- const parsed = JSON.parse(raw);
399
- if (!Array.isArray(parsed) || parsed.length === 0)
400
- return null;
401
- const first = parsed[0];
402
- const files = Array.isArray(first.files)
403
- ? first.files
404
- .map((f) => (f && typeof f === "object" ? f.path : undefined))
405
- .filter((p) => typeof p === "string")
406
- : [];
407
- if (typeof first.url !== "string")
408
- return null;
409
- return {
410
- url: first.url,
411
- headRefName: typeof first.headRefName === "string" ? first.headRefName : "",
412
- baseRefName: typeof first.baseRefName === "string" ? first.baseRefName : "",
413
- files,
414
- };
571
+ async function inspectManagedState(deps, descriptor, preflight, branch, upgrade) {
572
+ const managedPaths = descriptor.artifacts.map((a) => a.relPath);
573
+ const lookup = await findExistingManagedPr(deps, preflight.repository, branch);
574
+ if (lookup.kind === "lookup_failed")
575
+ return fail("remote_state_unverifiable", { branch });
576
+ if (lookup.kind === "found") {
577
+ if (!existingPrIsExpected(lookup.pr, branch, preflight.defaultBranch, managedPaths)) {
578
+ return fail("existing_pr_unverifiable", { branch });
579
+ }
580
+ const matches = await prContentMatchesProposal(deps, preflight.repository, lookup.pr, preflight.proposals);
581
+ if (matches === null)
582
+ return fail("existing_pr_unverifiable", { branch, prUrl: lookup.pr.url });
583
+ if (!matches)
584
+ return fail("existing_pr_differs", { branch, prUrl: lookup.pr.url });
585
+ return { outcome: "existing-pr", branch, prUrl: lookup.pr.url, upgrade, warnings: [] };
415
586
  }
416
- catch {
417
- return null;
587
+ // Independently of PR discovery: a pushed managed branch with no open PR.
588
+ const branchSha = await readRemoteBranchSha(deps, preflight.remoteName, branch);
589
+ if (branchSha === undefined)
590
+ return fail("remote_state_unverifiable", { branch });
591
+ if (branchSha !== null) {
592
+ return fail("managed_branch_without_pr", {
593
+ branch,
594
+ compareUrl: compareUrl(preflight.repository, preflight.defaultBranch, branch),
595
+ });
418
596
  }
419
- }
420
- /**
421
- * Confirm a detected pull request really is this installation's review surface.
422
- *
423
- * Reporting an unverified pull request as "already open" would tell an operator
424
- * the review exists when it might touch different files, target a different
425
- * base, or have been opened for something else entirely.
426
- */
427
- function existingPrIsExpected(pr, branch, defaultBranch) {
428
- if (pr.headRefName !== branch)
429
- return false;
430
- if (pr.baseRefName !== defaultBranch)
431
- return false;
432
- if (pr.files.length === 0)
433
- return false;
434
- const managed = new Set(MERGE_WORKFLOW_MANAGED_RELPATHS);
435
- return pr.files.every((f) => managed.has(f));
597
+ return null;
436
598
  }
437
599
  // ---------------------------------------------------------------------------
438
- // Pull request body
600
+ // Merge-workflow descriptor
439
601
  // ---------------------------------------------------------------------------
440
- /** Render the pull request body. Deterministic, and free of any secret value. */
602
+ /** Render the merge-workflow pull request body. Deterministic, and free of any secret value. */
441
603
  export function renderMergeWorkflowPrBody(codeOwner, upgrade) {
442
604
  const owner = validateMergeWorkflowCodeOwner(codeOwner);
443
605
  return [
@@ -479,159 +641,415 @@ export function renderMergeWorkflowPrBody(codeOwner, upgrade) {
479
641
  "",
480
642
  ].join("\n");
481
643
  }
644
+ /** A companion: absent or byte-identical is fine; anything else is a conflict. */
645
+ function companionArtifact(relPath, render) {
646
+ return {
647
+ relPath,
648
+ render,
649
+ classify: (existing) => {
650
+ if (existing === null)
651
+ return { state: "not_installed" };
652
+ if (existing === render())
653
+ return { state: "identical" };
654
+ return { state: "conflict", reason: "companion_artifact_conflict" };
655
+ },
656
+ };
657
+ }
658
+ /** The merge-workflow artifact set for one code owner. */
659
+ export function buildMergeWorkflowArtifactSet(codeOwner) {
660
+ let owner = codeOwner;
661
+ return {
662
+ setName: "merge-workflow",
663
+ installBranch: MERGE_WORKFLOW_INSTALL_BRANCH,
664
+ upgradeBranch: MERGE_WORKFLOW_UPGRADE_BRANCH,
665
+ installCommitSubject: MERGE_WORKFLOW_INSTALL_COMMIT_SUBJECT,
666
+ upgradeCommitSubject: MERGE_WORKFLOW_UPGRADE_COMMIT_SUBJECT,
667
+ installPrTitle: MERGE_WORKFLOW_INSTALL_PR_TITLE,
668
+ upgradePrTitle: MERGE_WORKFLOW_UPGRADE_PR_TITLE,
669
+ renderPrBody: (upgrade) => renderMergeWorkflowPrBody(owner, upgrade),
670
+ validateOptions: () => {
671
+ try {
672
+ owner = validateMergeWorkflowCodeOwner(codeOwner);
673
+ }
674
+ catch (err) {
675
+ if (err instanceof MergeWorkflowCodeOwnerError)
676
+ return { ok: false, reason: "codeowner_invalid" };
677
+ throw err;
678
+ }
679
+ return { ok: true, progress: [` code owner: ${owner}`] };
680
+ },
681
+ consentPrompt: (plan) => `Create branch ${plan.branch}, write ${plan.artifacts.length} files, commit, push, and open a pull request? [y/N] `,
682
+ artifacts: [
683
+ {
684
+ relPath: MERGE_WORKFLOW_PATH,
685
+ render: () => renderEpicImplementerMergeWorkflow(),
686
+ progressLabel: "workflow",
687
+ classify: (existing) => {
688
+ const classification = classifyMergeWorkflowContent(existing);
689
+ const text = MERGE_WORKFLOW_CLASSIFICATION_TEXT[classification];
690
+ if (classification === "modified_or_unrecognized") {
691
+ return { state: "conflict", reason: "workflow_modified_or_unrecognized", text };
692
+ }
693
+ return { state: classification, text };
694
+ },
695
+ },
696
+ companionArtifact(MERGE_WORKFLOW_CODEOWNERS_RELPATH, () => renderMergeWorkflowCodeownersFragment(owner)),
697
+ companionArtifact(MERGE_WORKFLOW_GUIDANCE_RELPATH, () => renderMergeWorkflowGuidance(owner)),
698
+ ],
699
+ };
700
+ }
701
+ /**
702
+ * Run the complete read-only merge-workflow preflight. Compatibility entry point
703
+ * around {@link runArtifactSetPreflight}.
704
+ */
705
+ export async function runMergeWorkflowPreflight(deps, options) {
706
+ const pre = await runArtifactSetPreflight(buildMergeWorkflowArtifactSet(options.codeOwner), deps);
707
+ if (!pre.ok)
708
+ return pre;
709
+ return {
710
+ ok: true,
711
+ preflight: { ...pre.preflight, codeOwner: validateMergeWorkflowCodeOwner(options.codeOwner) },
712
+ };
713
+ }
482
714
  // ---------------------------------------------------------------------------
483
715
  // Orchestration
484
716
  // ---------------------------------------------------------------------------
717
+ /** How many times consent is asked before a still-moving default branch stops the run. */
718
+ const MAX_CONSENT_ROUNDS = 3;
719
+ function defaultConsentPrompt(plan) {
720
+ return (`Create branch ${plan.branch} from ${plan.defaultBranch} in a temporary worktree, write ` +
721
+ `${plan.artifacts.join(", ")}, commit "${plan.commitSubject}", push to ` +
722
+ `${plan.remoteName}/${plan.branch}, and open a pull request into ${plan.defaultBranch}? [y/N] `);
723
+ }
485
724
  async function consent(deps, promptText) {
486
725
  if (!deps.confirmWrite)
487
726
  return false;
488
727
  return await deps.confirmWrite(promptText);
489
728
  }
729
+ function samePlan(a, b) {
730
+ if (a.classification !== b.classification)
731
+ return false;
732
+ if (a.proposals.size !== b.proposals.size)
733
+ return false;
734
+ for (const [path, content] of a.proposals)
735
+ if (b.proposals.get(path) !== content)
736
+ return false;
737
+ return true;
738
+ }
739
+ /** Refuse to write through a symlink anywhere on the path inside the worktree. */
740
+ async function pathIsSafeInWorktree(deps, worktree, relPath) {
741
+ const target = resolve(worktree, relPath);
742
+ if (!target.startsWith(resolve(worktree) + "/"))
743
+ return false;
744
+ const lstat = deps.lstat ?? fsLstat;
745
+ const segments = relPath.split("/");
746
+ let current = resolve(worktree);
747
+ for (const segment of segments) {
748
+ current = join(current, segment);
749
+ try {
750
+ if ((await lstat(current)).isSymbolicLink())
751
+ return false;
752
+ }
753
+ catch (err) {
754
+ if (isEnoent(err))
755
+ return true;
756
+ return false;
757
+ }
758
+ }
759
+ return true;
760
+ }
490
761
  /**
491
- * Generate the managed artifacts and open the customer's review pull request.
762
+ * Install one artifact set: read-only preflight, consent, then all writes in a
763
+ * temporary worktree, push to the managed branch, and open the pull request.
492
764
  *
493
- * Returns a process exit code: `0` for every success shape (new pull request,
494
- * upgrade pull request, verified existing pull request, byte-identical no-op,
495
- * dry run) and `1` for every failure. A failure prints the stable reason token
496
- * first and one remediation sentence second, both on stderr.
497
- *
498
- * Nothing already created is undone on a later failure. A branch that exists, a
499
- * file that was written, or a ref that was pushed is left in place with a
500
- * bounded recovery instruction: deleting a customer's branch to tidy up after
501
- * ourselves is a destructive act taken on incomplete information.
765
+ * Progress lines go to `deps.log`; the caller reports the returned outcome and
766
+ * prints any `warnings` (cleanup problems) after it, on stderr.
767
+ * Nothing already pushed is undone on a later failure: deleting a customer's
768
+ * branch to tidy up is a destructive act taken on incomplete information.
502
769
  */
503
- export async function runMergeWorkflowInstall(cwd, options, deps) {
504
- const report = (result) => {
505
- if (result.outcome === "failed") {
506
- deps.errorLog(`${result.reason}`);
507
- deps.errorLog(result.remediation);
508
- return 1;
509
- }
510
- return 0;
511
- };
512
- deps.log("Bridge merge-workflow installer");
513
- deps.log(" preflight:");
514
- const pre = await runMergeWorkflowPreflight({ ...deps, cwd }, options);
770
+ export async function installArtifactSet(descriptor, options, deps) {
771
+ const pre = await runArtifactSetPreflight(descriptor, deps);
515
772
  if (!pre.ok)
516
- return report(pre.result);
517
- const preflight = pre.preflight;
773
+ return pre.result;
774
+ let preflight = pre.preflight;
518
775
  if (preflight.classification === "identical") {
519
- deps.log("");
520
- deps.log("Clean no-op: every managed artifact already matches this build.");
521
- deps.log(` ${MERGE_WORKFLOW_PATH} (contract version ${MERGE_WORKFLOW_EXPECTED_VERSION})`);
522
- deps.log("Nothing was written and no pull request was opened.");
523
- return 0;
776
+ return { outcome: "identical", artifacts: [...preflight.proposals.keys()], warnings: [] };
524
777
  }
525
- const upgrade = preflight.classification === "managed_version_older";
526
- const branch = upgrade ? MERGE_WORKFLOW_UPGRADE_BRANCH : MERGE_WORKFLOW_INSTALL_BRANCH;
527
- // Asked BEFORE a local branch exists, so a verified open pull request is
528
- // reported instead of duplicated rather than after a duplicate branch is made.
529
- const existing = await findExistingManagedPr(deps, branch);
530
- if (existing) {
531
- if (!existingPrIsExpected(existing, branch, preflight.defaultBranch)) {
532
- return report(fail("existing_pr_unverifiable"));
533
- }
778
+ const managedPaths = descriptor.artifacts.map((a) => a.relPath);
779
+ let upgrade = false;
780
+ let branch = "";
781
+ let commitSubject = "";
782
+ // Consent covers one plan at one default-branch head. When the default branch
783
+ // moves after consent and the plan changes, the new plan is shown and consent
784
+ // is asked again; a branch that keeps moving stops with default_branch_moved.
785
+ for (let round = 0;; round += 1) {
786
+ upgrade = preflight.classification === "managed_version_older";
787
+ branch = upgrade ? descriptor.upgradeBranch : descriptor.installBranch;
788
+ commitSubject = upgrade ? descriptor.upgradeCommitSubject : descriptor.installCommitSubject;
789
+ const managed = await inspectManagedState(deps, descriptor, preflight, branch, upgrade);
790
+ if (managed)
791
+ return managed;
792
+ const plan = {
793
+ branch,
794
+ upgrade,
795
+ artifacts: managedPaths,
796
+ commitSubject,
797
+ remoteName: preflight.remoteName,
798
+ defaultBranch: preflight.defaultBranch,
799
+ };
534
800
  deps.log("");
535
- deps.log(`${upgrade ? "Upgrade" : "Installation"} pull request is already open.`);
801
+ if (round > 0)
802
+ deps.log(`${preflight.defaultBranch} moved since you approved, and the plan changed.`);
803
+ deps.log(upgrade ? "Planned upgrade:" : "Planned installation:");
536
804
  deps.log(` branch: ${branch}`);
537
- deps.log(` pull request: ${existing.url}`);
538
- deps.log("Review and merge it; re-running this command opens nothing new.");
539
- return 0;
805
+ for (const relPath of managedPaths)
806
+ deps.log(` file: ${relPath}`);
807
+ deps.log(` commit: ${commitSubject}`);
808
+ deps.log(` push: ${preflight.remoteName}/${branch} (your git identity)`);
809
+ deps.log(` pull request: into ${preflight.defaultBranch} (your gh identity)`);
810
+ if (options.dryRun) {
811
+ deps.log("");
812
+ deps.log("--dry-run: nothing was written, committed, pushed, or opened.");
813
+ return { outcome: "dry-run", branch, artifacts: managedPaths, upgrade, warnings: [] };
814
+ }
815
+ const prompt = (descriptor.consentPrompt ?? defaultConsentPrompt)(plan);
816
+ if (!(await consent(deps, prompt)))
817
+ return fail("consent_declined", { branch });
818
+ // --- first mutation: fetch the default branch into the remote-tracking ref --
819
+ const trackingRef = `refs/remotes/${preflight.remoteName}/${preflight.defaultBranch}`;
820
+ if (!(await commandSucceeds(deps.runGit, [
821
+ "fetch",
822
+ "--no-tags",
823
+ preflight.remoteName,
824
+ `+refs/heads/${preflight.defaultBranch}:${trackingRef}`,
825
+ ]))) {
826
+ return fail("git_failed", { branch });
827
+ }
828
+ const fetchedSha = await captureStdout(deps.runGit, ["rev-parse", "--verify", "--quiet", `${trackingRef}^{commit}`]);
829
+ if (!fetchedSha)
830
+ return fail("git_failed", { branch });
831
+ if (fetchedSha === preflight.defaultBranchSha)
832
+ break;
833
+ // The default branch moved: re-inspect at the fetched head. An unchanged
834
+ // plan proceeds from the new head under the consent already given.
835
+ const again = await classifyAtRemote(deps, descriptor, preflight.repository, fetchedSha, true);
836
+ if (!again.ok && again.result.reason === "remote_state_unverifiable")
837
+ return fail("remote_state_unverifiable", { branch });
838
+ if (!again.ok)
839
+ return fail("default_branch_moved", { branch });
840
+ const unchanged = samePlan(again, preflight);
841
+ preflight = { ...preflight, defaultBranchSha: fetchedSha, classification: again.classification, proposals: again.proposals };
842
+ if (unchanged)
843
+ break;
844
+ if (again.classification === "identical") {
845
+ return { outcome: "identical", artifacts: [...again.proposals.keys()], warnings: [] };
846
+ }
847
+ if (round + 1 >= MAX_CONSENT_ROUNDS)
848
+ return fail("default_branch_moved", { branch });
540
849
  }
541
- deps.log("");
542
- deps.log(upgrade ? "Planned upgrade:" : "Planned installation:");
543
- deps.log(` branch: ${branch}`);
544
- for (const relPath of MERGE_WORKFLOW_MANAGED_RELPATHS)
545
- deps.log(` file: ${relPath}`);
546
- deps.log(` commit: ${upgrade ? MERGE_WORKFLOW_UPGRADE_COMMIT_SUBJECT : MERGE_WORKFLOW_INSTALL_COMMIT_SUBJECT}`);
547
- deps.log(` push: ${preflight.remoteName}/${branch} (your git identity)`);
548
- deps.log(` pull request: into ${preflight.defaultBranch} (your gh identity)`);
549
- if (options.dryRun) {
550
- deps.log("");
551
- deps.log("--dry-run: nothing was written, committed, pushed, or opened.");
552
- return 0;
850
+ const makeTempDir = deps.makeTempDir ?? (() => mkdtemp(join(tmpdir(), `bridge-${descriptor.setName}-`)));
851
+ const removeDir = deps.removeDir ?? ((dir) => rm(dir, { recursive: true, force: true }));
852
+ let tempRoot;
853
+ try {
854
+ tempRoot = await makeTempDir();
553
855
  }
554
- if (!(await consent(deps, `Create branch ${branch}, write ${MERGE_WORKFLOW_MANAGED_RELPATHS.length} files, commit, push, and open a pull request? [y/N] `))) {
555
- return report(fail("consent_declined"));
856
+ catch {
857
+ return fail("worktree_failed", { branch });
556
858
  }
557
- // --- first mutation: the branch ----------------------------------------
558
- if (!(await commandSucceeds(deps.runGit, ["checkout", "-b", branch]))) {
559
- return report(fail("git_failed"));
859
+ const worktree = join(tempRoot, "worktree");
860
+ const warnings = [];
861
+ let worktreeAdded = false;
862
+ const run = async () => {
863
+ if (!(await commandSucceeds(deps.runGit, [
864
+ "worktree",
865
+ "add",
866
+ "--detach",
867
+ worktree,
868
+ preflight.defaultBranchSha,
869
+ ]))) {
870
+ return fail("worktree_failed", { branch });
871
+ }
872
+ worktreeAdded = true;
873
+ for (const artifact of descriptor.artifacts) {
874
+ const content = preflight.proposals.get(artifact.relPath);
875
+ if (!(await pathIsSafeInWorktree(deps, worktree, artifact.relPath)))
876
+ return fail("write_failed", { branch });
877
+ const absPath = joinCwd(worktree, artifact.relPath);
878
+ const parent = absPath.slice(0, absPath.lastIndexOf("/"));
879
+ try {
880
+ // Read-compare-write: an identical target is never rewritten.
881
+ let existing;
882
+ try {
883
+ existing = await deps.readFile(absPath);
884
+ }
885
+ catch (err) {
886
+ if (!isEnoent(err))
887
+ throw err;
888
+ existing = null;
889
+ }
890
+ if (existing !== content) {
891
+ await deps.mkdir(parent, { recursive: true });
892
+ await deps.writeFile(absPath, content, { encoding: "utf-8" });
893
+ }
894
+ }
895
+ catch {
896
+ return fail("write_failed", { branch });
897
+ }
898
+ try {
899
+ // The manifest lands in the temporary worktree and is never staged.
900
+ await deps.recordArtifact(worktree, artifact.relPath);
901
+ }
902
+ catch {
903
+ // Recording is best effort and never blocks the install.
904
+ }
905
+ }
906
+ // Only the explicit managed paths are staged, after `--`.
907
+ if (!(await commandSucceeds(deps.runGit, ["-C", worktree, "add", "--", ...managedPaths]))) {
908
+ return fail("git_failed", { branch });
909
+ }
910
+ if (!(await commandSucceeds(deps.runGit, ["-C", worktree, "commit", "-m", commitSubject]))) {
911
+ return fail("git_failed", { branch });
912
+ }
913
+ // Immediately before push: nobody else created the managed branch or PR.
914
+ const raced = await inspectManagedState(deps, descriptor, preflight, branch, upgrade);
915
+ if (raced) {
916
+ if (raced.outcome === "failed" && raced.reason === "remote_state_unverifiable")
917
+ return raced;
918
+ return fail("managed_state_changed", { branch });
919
+ }
920
+ if (branch === preflight.defaultBranch)
921
+ return fail("git_failed", { branch });
922
+ if (!(await commandSucceeds(deps.runGit, [
923
+ "-C",
924
+ worktree,
925
+ "push",
926
+ preflight.remoteName,
927
+ `HEAD:refs/heads/${branch}`,
928
+ ]))) {
929
+ return fail("git_failed", { branch });
930
+ }
931
+ const prUrl = await captureStdout(deps.runGh, [
932
+ "pr",
933
+ "create",
934
+ "--repo",
935
+ preflight.repository,
936
+ "--base",
937
+ preflight.defaultBranch,
938
+ "--head",
939
+ branch,
940
+ "--title",
941
+ upgrade ? descriptor.upgradePrTitle : descriptor.installPrTitle,
942
+ "--body",
943
+ descriptor.renderPrBody(upgrade),
944
+ ]);
945
+ if (!prUrl) {
946
+ return fail("pr_open_failed", {
947
+ branch,
948
+ compareUrl: compareUrl(preflight.repository, preflight.defaultBranch, branch),
949
+ });
950
+ }
951
+ return {
952
+ outcome: upgrade ? "upgrade-pr-opened" : "installed-pr-opened",
953
+ branch,
954
+ prUrl,
955
+ artifacts: managedPaths,
956
+ warnings: [],
957
+ };
958
+ };
959
+ let result;
960
+ try {
961
+ result = await run();
560
962
  }
561
- const artifacts = [
562
- [MERGE_WORKFLOW_PATH, renderEpicImplementerMergeWorkflow()],
563
- [MERGE_WORKFLOW_CODEOWNERS_RELPATH, renderMergeWorkflowCodeownersFragment(preflight.codeOwner)],
564
- [MERGE_WORKFLOW_GUIDANCE_RELPATH, renderMergeWorkflowGuidance(preflight.codeOwner)],
565
- ];
566
- const written = [];
567
- for (const [relPath, content] of artifacts) {
568
- const absPath = joinCwd(preflight.repositoryRoot, relPath);
569
- const parent = absPath.slice(0, absPath.lastIndexOf("/"));
963
+ finally {
964
+ let cleaned = true;
965
+ if (worktreeAdded && !(await commandSucceeds(deps.runGit, ["worktree", "remove", "--force", worktree]))) {
966
+ cleaned = false;
967
+ }
570
968
  try {
571
- // Read-compare-write: an identical target is never rewritten, so a partial
572
- // re-run does not churn file mtimes or produce an empty diff entry.
573
- const existingContent = await readOrNull(deps, absPath);
574
- if (existingContent !== content) {
575
- await deps.mkdir(parent, { recursive: true });
576
- await deps.writeFile(absPath, content, { encoding: "utf-8" });
577
- }
578
- written.push(relPath);
969
+ await removeDir(tempRoot);
579
970
  }
580
971
  catch {
581
- return report(fail("write_failed"));
972
+ cleaned = false;
973
+ }
974
+ if (!cleaned) {
975
+ const warning = `cleanup_incomplete: the temporary worktree at ${worktree} was not fully removed. ` +
976
+ `Remove it with \`git worktree remove --force ${worktree}\` and delete ${tempRoot}.`;
977
+ // Returned, not printed: the caller prints it AFTER its own outcome, so a
978
+ // failure's stable reason token stays the first stderr line.
979
+ warnings.push(warning);
582
980
  }
583
- // Recorded per artifact, with the POSIX repository-relative path only.
584
- await deps.recordArtifact(preflight.repositoryRoot, relPath);
585
- }
586
- // Only the explicit managed paths are staged, passed after `--` so a path can
587
- // never be read as a git option.
588
- if (!(await commandSucceeds(deps.runGit, ["add", "--", ...MERGE_WORKFLOW_MANAGED_RELPATHS]))) {
589
- return report(fail("git_failed"));
590
- }
591
- const commitSubject = upgrade
592
- ? MERGE_WORKFLOW_UPGRADE_COMMIT_SUBJECT
593
- : MERGE_WORKFLOW_INSTALL_COMMIT_SUBJECT;
594
- if (!(await commandSucceeds(deps.runGit, ["commit", "-m", commitSubject]))) {
595
- return report(fail("git_failed"));
596
- }
597
- if (!(await commandSucceeds(deps.runGit, [
598
- "push",
599
- "--set-upstream",
600
- preflight.remoteName,
601
- branch,
602
- ]))) {
603
- return report(fail("git_failed"));
604
981
  }
605
- const prTitle = upgrade ? MERGE_WORKFLOW_UPGRADE_PR_TITLE : MERGE_WORKFLOW_INSTALL_PR_TITLE;
606
- const prUrl = await captureStdout(deps.runGh, [
607
- "pr",
608
- "create",
609
- "--base",
610
- preflight.defaultBranch,
611
- "--head",
612
- branch,
613
- "--title",
614
- prTitle,
615
- "--body",
616
- renderMergeWorkflowPrBody(preflight.codeOwner, upgrade),
617
- ]);
618
- if (!prUrl) {
619
- deps.errorLog("pr_open_failed");
620
- deps.errorLog(`${REMEDIATION.pr_open_failed} The branch ${branch} is pushed to ${preflight.remoteName} and was left in place.`);
621
- return 1;
982
+ return { ...result, warnings: [...result.warnings, ...warnings] };
983
+ }
984
+ /**
985
+ * Generate the merge-workflow artifacts and open the customer's review PR.
986
+ *
987
+ * Compatibility wrapper for `install-bridge conductor --merge-workflow` around
988
+ * {@link installArtifactSet}. Returns a process exit code: `0` for every
989
+ * success shape (new pull request, upgrade pull request, verified existing pull
990
+ * request, byte-identical no-op, dry run) and `1` for every failure. A failure
991
+ * prints the stable reason token first and one remediation sentence second,
992
+ * both on stderr.
993
+ */
994
+ export async function runMergeWorkflowInstall(cwd, options, deps) {
995
+ deps.log("Bridge merge-workflow installer");
996
+ deps.log(" preflight:");
997
+ const descriptor = buildMergeWorkflowArtifactSet(options.codeOwner);
998
+ const result = await installArtifactSet(descriptor, { dryRun: options.dryRun }, { ...deps, cwd });
999
+ const exitCode = reportMergeWorkflowResult(result, deps);
1000
+ for (const warning of result.warnings)
1001
+ deps.errorLog(warning);
1002
+ return exitCode;
1003
+ }
1004
+ function reportMergeWorkflowResult(result, deps) {
1005
+ switch (result.outcome) {
1006
+ case "failed":
1007
+ deps.errorLog(`${result.reason}`);
1008
+ if (result.reason === "pr_open_failed" && result.branch) {
1009
+ deps.errorLog(`${result.remediation} The branch ${result.branch} is pushed to origin and was left in place.`);
1010
+ }
1011
+ else if (result.reason === "managed_branch_without_pr" && result.branch) {
1012
+ deps.errorLog(`${result.remediation} Managed branch: ${result.branch}.`);
1013
+ if (result.compareUrl)
1014
+ deps.errorLog(`Open a pull request from it: ${result.compareUrl}`);
1015
+ }
1016
+ else if (result.reason === "existing_pr_differs" && result.prUrl) {
1017
+ deps.errorLog(`${result.remediation} Pull request: ${result.prUrl}`);
1018
+ }
1019
+ else {
1020
+ deps.errorLog(result.remediation);
1021
+ }
1022
+ return 1;
1023
+ case "identical":
1024
+ deps.log("");
1025
+ deps.log("Clean no-op: every managed artifact already matches this build.");
1026
+ deps.log(` ${MERGE_WORKFLOW_PATH} (contract version ${MERGE_WORKFLOW_EXPECTED_VERSION})`);
1027
+ deps.log("Nothing was written and no pull request was opened.");
1028
+ return 0;
1029
+ case "existing-pr":
1030
+ deps.log("");
1031
+ deps.log(`${result.upgrade ? "Upgrade" : "Installation"} pull request is already open.`);
1032
+ deps.log(` branch: ${result.branch}`);
1033
+ deps.log(` pull request: ${result.prUrl}`);
1034
+ deps.log("Review and merge it; re-running this command opens nothing new.");
1035
+ return 0;
1036
+ case "dry-run":
1037
+ return 0;
1038
+ case "installed-pr-opened":
1039
+ case "upgrade-pr-opened":
1040
+ deps.log("");
1041
+ deps.log(result.outcome === "upgrade-pr-opened" ? "Upgrade pull request opened." : "Installation pull request opened.");
1042
+ deps.log(` branch: ${result.branch}`);
1043
+ for (const relPath of result.artifacts)
1044
+ deps.log(` file: ${relPath}`);
1045
+ deps.log(` pull request: ${result.prUrl}`);
1046
+ deps.log("");
1047
+ deps.log("Next, in this order:");
1048
+ deps.log(" 1. Review the pull request — the workflow is what performs the privileged merge.");
1049
+ deps.log(` 2. Create the repository Actions secret ${MERGE_WORKFLOW_API_KEY_SECRET_NAME} through GitHub. Never pass its value to this command.`);
1050
+ deps.log(` 3. Merge the rule in ${MERGE_WORKFLOW_CODEOWNERS_RELPATH} into your active CODEOWNERS file.`);
1051
+ deps.log(" 4. Add a default-branch rule requiring a pull request and code-owner review.");
1052
+ deps.log(" 5. Merge the pull request yourself; Bridge never merges it.");
1053
+ return 0;
622
1054
  }
623
- deps.log("");
624
- deps.log(upgrade ? "Upgrade pull request opened." : "Installation pull request opened.");
625
- deps.log(` branch: ${branch}`);
626
- for (const relPath of written)
627
- deps.log(` file: ${relPath}`);
628
- deps.log(` pull request: ${prUrl}`);
629
- deps.log("");
630
- deps.log("Next, in this order:");
631
- deps.log(" 1. Review the pull request — the workflow is what performs the privileged merge.");
632
- deps.log(` 2. Create the repository Actions secret ${MERGE_WORKFLOW_API_KEY_SECRET_NAME} through GitHub. Never pass its value to this command.`);
633
- deps.log(` 3. Merge the rule in ${MERGE_WORKFLOW_CODEOWNERS_RELPATH} into your active CODEOWNERS file.`);
634
- deps.log(" 4. Add a default-branch rule requiring a pull request and code-owner review.");
635
- deps.log(" 5. Merge the pull request yourself; Bridge never merges it.");
636
- return 0;
637
1055
  }