@bridge_gpt/mcp-server 0.2.53 → 0.2.55

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 (103) hide show
  1. package/README.md +116 -56
  2. package/build/agent-guidance.generated.js +3 -0
  3. package/build/agent-launchers/claude-executor-adapter.js +3 -0
  4. package/build/agent-launchers/claude.js +3 -3
  5. package/build/agent-launchers/prompt.js +8 -11
  6. package/build/agent-notes.js +178 -0
  7. package/build/agent-registry.js +5 -2
  8. package/build/agent-utils.js +58 -0
  9. package/build/agents.generated.js +1 -1
  10. package/build/base-ref.js +33 -9
  11. package/build/bounded-wait.js +174 -0
  12. package/build/codex-skill-adapter.js +55 -0
  13. package/build/commands.generated.js +6 -5
  14. package/build/conductor/bridge-api-client.js +235 -14
  15. package/build/conductor/bring-up-facts.js +187 -0
  16. package/build/conductor/claude-hook.js +7 -5
  17. package/build/conductor/cli.js +28 -0
  18. package/build/conductor/doctor.js +80 -9
  19. package/build/conductor/epic-implementer-cli.js +1298 -0
  20. package/build/conductor/epic-runtime.js +134 -98
  21. package/build/conductor/errors.js +2 -2
  22. package/build/conductor/git-ci-types.js +1 -1
  23. package/build/conductor/git-hooks.js +28 -14
  24. package/build/conductor/install-doctor.js +11 -5
  25. package/build/conductor/readiness-cli.js +10 -10
  26. package/build/conductor/readiness-sections.js +58 -9
  27. package/build/conductor/readiness.js +120 -4
  28. package/build/conductor/recovery-cli.js +289 -10
  29. package/build/conductor/recovery-operations.js +125 -2
  30. package/build/conductor/repair-contract.js +58 -0
  31. package/build/conductor/run-branch.js +137 -0
  32. package/build/conductor/store.js +2 -2
  33. package/build/conductor/supervisor-runtime.js +1 -1
  34. package/build/conductor/test-run-branch-vectors.js +165 -0
  35. package/build/conductor-bin.js +2 -139
  36. package/build/conductor-claude-hook-bin.js +2 -2
  37. package/build/conductor-claude-hook-removed-stub-bin.js +31 -0
  38. package/build/conductor-removed-stub-bin.js +30 -0
  39. package/build/docs.generated.js +1 -1
  40. package/build/doctor.js +145 -18
  41. package/build/drive-epic.js +752 -90
  42. package/build/epic-implementer-bin.js +145 -0
  43. package/build/epic-implementer-bundle-cli.js +264 -0
  44. package/build/epic-implementer-claude-hook-bin.js +3 -0
  45. package/build/epic-integration-pr.js +5 -3
  46. package/build/executor/claim-scope.js +104 -0
  47. package/build/executor/cli.js +14 -25
  48. package/build/executor/env-file-guard.js +82 -3
  49. package/build/executor/env.js +6 -0
  50. package/build/executor/interrupted-worktree.js +60 -0
  51. package/build/executor/job-errors.js +45 -0
  52. package/build/executor/job-runner.js +334 -9
  53. package/build/executor/job-types.js +25 -9
  54. package/build/executor/merge-tree-classifier.js +171 -0
  55. package/build/executor/reconcile-overlap-governance.js +129 -0
  56. package/build/executor/reconcile-overlap-job.js +989 -0
  57. package/build/executor/reconcile-overlap-types.js +14 -0
  58. package/build/executor/spawn-job-driver.js +1 -0
  59. package/build/executor/types.js +2 -0
  60. package/build/executor/worker-finalization.js +25 -2
  61. package/build/executor/worker-guard-hook.js +15 -7
  62. package/build/implement-epic/bridge-client.js +773 -0
  63. package/build/implement-epic/checkpoint-store.js +542 -0
  64. package/build/implement-epic/cli.js +3158 -0
  65. package/build/implement-epic/cut-protocol.js +392 -0
  66. package/build/implement-epic/lock.js +302 -0
  67. package/build/implement-epic/pr-state.js +286 -0
  68. package/build/implement-epic/spawn.js +113 -0
  69. package/build/index.js +657 -481
  70. package/build/init.js +72 -8
  71. package/build/install-bridge-conductor.js +5 -5
  72. package/build/install-bridge.js +403 -70
  73. package/build/local-artifact-storage.js +130 -0
  74. package/build/mcp-host-config.js +22 -60
  75. package/build/mcp-host-entry-adapter.js +18 -0
  76. package/build/mcp-host-targets.js +1 -21
  77. package/build/merge-pull-request.js +1 -1
  78. package/build/pipelines.generated.js +23 -16
  79. package/build/plan-epic-conductor-eligibility.js +1 -1
  80. package/build/plane/cli.js +321 -41
  81. package/build/plane/manifest.js +209 -1
  82. package/build/plane/member-roster.js +70 -0
  83. package/build/plane/preflight.js +128 -12
  84. package/build/plane/shutdown.js +18 -5
  85. package/build/plane/status.js +35 -1
  86. package/build/plane/supervisor.js +546 -164
  87. package/build/plane/types.js +25 -2
  88. package/build/polling-policy.js +72 -0
  89. package/build/readiness-check.js +3 -3
  90. package/build/readme.generated.js +1 -1
  91. package/build/review-generation.js +219 -0
  92. package/build/run-unit-tests-launcher.js +6 -1
  93. package/build/setup-epic.js +567 -38
  94. package/build/start-tickets-conductor.js +8 -7
  95. package/build/ticket-key-utils.js +4 -3
  96. package/build/ticket-review-artifact-gate.js +461 -0
  97. package/build/upgrade-cli.js +5 -26
  98. package/build/version.generated.js +3 -3
  99. package/build/worker-guard-hook-bin.js +1 -1
  100. package/docs/CONDUCTOR.md +8 -6
  101. package/docs/install/mcp-tool-integrations.md +23 -1
  102. package/package.json +5 -3
  103. package/pipelines/review-ticket.json +17 -4
@@ -83,9 +83,10 @@ export const DEFAULT_CONDUCTOR_GATE_NAME = "implement-ticket";
83
83
  * Exported since BAPI-1020 so the executor resolves the worker-guard bin through
84
84
  * the SAME resolver rather than growing a second one. Correct in both build
85
85
  * shapes because this module compiles to the build ROOT: the esbuild bundle puts
86
- * `import.meta.url` at `build/index.js` / `build/conductor-bin.js`, and `tsc` puts
87
- * it at `build/start-tickets-conductor.js`. A resolver living in a subdirectory
88
- * module would resolve to `build/<subdir>/<filename>` under `tsc` and be wrong.
86
+ * `import.meta.url` at `build/index.js` / `build/epic-implementer-bin.js`, and
87
+ * `tsc` puts it at `build/start-tickets-conductor.js`. A resolver living in a
88
+ * subdirectory module would resolve to `build/<subdir>/<filename>` under `tsc`
89
+ * and be wrong.
89
90
  */
90
91
  export function defaultResolveBinPath(filename) {
91
92
  return fileURLToPath(new URL(`./${filename}`, import.meta.url));
@@ -124,12 +125,12 @@ export async function createStartTicketsConductorContext(options, agent, deps) {
124
125
  gateName,
125
126
  supervisorMode,
126
127
  agentName: agent.name,
127
- cliFile: resolveBinPath("conductor-bin.js"),
128
+ cliFile: resolveBinPath("epic-implementer-bin.js"),
128
129
  // Capture the conductor process's Node executable so dispatched workers run
129
- // conductor-bin.js under THIS runtime (which owns the ledger), never the
130
- // worker's own Node (BAPI-527).
130
+ // epic-implementer-bin.js under THIS runtime (which owns the ledger), never
131
+ // the worker's own Node (BAPI-527).
131
132
  conductorNodePath: deps.execPath ?? process.execPath,
132
- hookBinPath: resolveBinPath("conductor-claude-hook-bin.js"),
133
+ hookBinPath: resolveBinPath("epic-implementer-claude-hook-bin.js"),
133
134
  };
134
135
  if (options.epic) {
135
136
  context.epic = options.epic;
@@ -10,9 +10,10 @@
10
10
  * | `start-tickets.ts` | `^[A-Z]+-[0-9]+$` | no |
11
11
  *
12
12
  * A repository configured with `jira_ticket_key = "MY_PROJ"` therefore minted
13
- * keys the server considered valid and the clients rejected — `/full-automation`
14
- * silently dropped them. Jira project keys may contain underscores too, so this
15
- * was never a local-mode-only defect.
13
+ * keys the server considered valid and the clients rejected — the pre-rename
14
+ * `/full-automation` command (renamed to `/idea-to-pr` in BAPI-1058) silently
15
+ * dropped them. Jira project keys may contain underscores too, so this was never
16
+ * a local-mode-only defect.
16
17
  *
17
18
  * This module mirrors the public Python contract
18
19
  * (`api.library.config.field_validators.TICKET_KEY_PREFIX_PATTERN_SOURCE` plus a
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Generation-aware persistence gate for ticket review (BAPI-1121, BAPI-1129).
3
+ *
4
+ * WHO CALLS THIS. Both calling conventions, and nothing else:
5
+ * `wait_for_ticket_review` (the non-blocking submit-then-wait pattern the
6
+ * review-ticket recipe uses) and `request_ticket_review` with
7
+ * `wait_for_result: true` (the older blocking convention, routed here by
8
+ * BAPI-1129). This module is therefore the SOLE IMPLEMENTATION of review
9
+ * classification and persistence — not the wait, which is merely one of its
10
+ * two callers. A second copy of the advancement rules in either handler is
11
+ * the defect this arrangement exists to prevent.
12
+ *
13
+ * THE PROBLEM THIS CLOSES. BAPI-1104 made `wait_for_ticket_review` refuse a
14
+ * `200` whose generation had not advanced past the pre-submission baseline —
15
+ * but the review-ticket RECIPE still retrieved and saved BOTH legs
16
+ * unconditionally after a ready wait (`get_clarifying_questions` +
17
+ * `get_ticket_critique`), regardless of what the wait actually proved. A
18
+ * fresh clarify beside a stale, un-advanced critique attachment was still
19
+ * written to the critique's canonical path — the exact stale-attachment
20
+ * failure the generation baseline was built to catch, reintroduced one layer
21
+ * up by an unconditional write.
22
+ *
23
+ * THE FIX. Persistence authority moves INTO the wait. This module classifies
24
+ * the observed combined envelope against the submission baseline — using
25
+ * `parseReviewGeneration` / `generationAdvanced`, never file presence — and
26
+ * writes ONLY the legs proven to be this submission's own output. A leg that
27
+ * is delivered but not proven (stale, or carrying no readable identity) is
28
+ * WITHHELD: never written, and any pre-existing canonical file for it is
29
+ * removed so a later file-presence read cannot resurrect it. The returned
30
+ * manifest (`accepted_legs`, `saved_legs`, `withheld_legs`, `absent_legs`,
31
+ * `freshness_basis`) becomes the authoritative source set for every
32
+ * downstream consumer (see `evaluate-and-recommend.md` /
33
+ * `capture-review-decisions.md`).
34
+ *
35
+ * ACCEPTED VERSUS SAVED (BAPI-1129). `accepted_legs` is the classification
36
+ * verdict — which legs are this review's own output — and `saved_legs` is the
37
+ * durable-write record. They coincide on a persistence-enabled call and
38
+ * deliberately diverge under `persistenceEnabled: false`, the
39
+ * classification-only mode `request_ticket_review`'s `save_locally: false`
40
+ * convention needs: every leg is classified so withheld content is never
41
+ * handed back as current-review evidence, and no file is written, removed,
42
+ * reconciled, or recorded. A consumer choosing which document bodies to
43
+ * present reads `accepted_legs`; one reporting what landed on disk reads
44
+ * `saved_legs`.
45
+ *
46
+ * NO-BASELINE FALLBACK. A resumed session (or an out-of-band submission) has
47
+ * no baseline to prove freshness against. Terminality alone decides, exactly
48
+ * as the two `get_*` tools behaved before this ticket — but the manifest
49
+ * reports `freshness_basis: "terminality_only"` and an empty `advanced_legs`
50
+ * so nothing here is silently upgraded to a proven claim.
51
+ */
52
+ import path from "path";
53
+ import { createHash } from "node:crypto";
54
+ import { parseReviewGeneration, generationAdvanced } from "./review-generation.js";
55
+ import { writeCanonicalArtifact, removeCanonicalArtifact } from "./local-artifact-storage.js";
56
+ // Documented leg ordering (BAPI-1121): critique before clarify, matching the
57
+ // pre-existing wait_for_ticket_review response's delivered_legs order so this
58
+ // ticket does not silently reorder an established response field.
59
+ const LEG_NAMES = ["critique", "clarify"];
60
+ function legContent(envelope, leg) {
61
+ const raw = envelope[leg]?.content;
62
+ return typeof raw === "string" ? raw : "";
63
+ }
64
+ /**
65
+ * Whether the resolved submission policy expected `leg`, from
66
+ * `expectedLegCount`. Every valid resolved policy configures at least one
67
+ * clarify round (`_validate_bucket_policy` in `review_ticket_policy.py`
68
+ * rejects a zero-clarify policy), so `expectedLegCount` counting 1 always
69
+ * means "clarify only" and 2 always means "both" — clarify is therefore
70
+ * always expected whenever a count was supplied at all, and critique is
71
+ * expected only when the count is exactly 2.
72
+ */
73
+ function expectedByPolicy(leg, expectedLegCount) {
74
+ if (expectedLegCount === undefined)
75
+ return undefined;
76
+ return leg === "clarify" ? expectedLegCount >= 1 : expectedLegCount >= 2;
77
+ }
78
+ /** Same order-of-magnitude lifetime as `ReviewGenerationRegistry`'s baseline TTL. */
79
+ const COMPLETED_RESULT_TTL_MS = 2 * 60 * 60 * 1000;
80
+ const MAX_COMPLETED_RESULT_KEYS = 64;
81
+ const COMPLETED_RESULTS = new Map();
82
+ const KEY_SEPARATOR = "\u0000";
83
+ function completedResultKey(repoName, ticketNumber) {
84
+ return `${repoName.trim().toLowerCase()}${KEY_SEPARATOR}${ticketNumber.trim().toUpperCase()}`;
85
+ }
86
+ function evictExpiredCompletedResults() {
87
+ const cutoff = Date.now() - COMPLETED_RESULT_TTL_MS;
88
+ for (const [key, bySignature] of COMPLETED_RESULTS) {
89
+ for (const [signature, entry] of bySignature) {
90
+ if (entry.recordedAtMs <= cutoff)
91
+ bySignature.delete(signature);
92
+ }
93
+ if (bySignature.size === 0)
94
+ COMPLETED_RESULTS.delete(key);
95
+ }
96
+ }
97
+ /**
98
+ * A stable signature for one delivered leg: its readable generation when one
99
+ * exists, or a content digest when it does not. The digest keeps two distinct
100
+ * footerless documents from colliding onto the same signature (a bare
101
+ * generation-less key would otherwise treat every unstamped document as
102
+ * identical).
103
+ */
104
+ function legSignaturePart(content) {
105
+ const trimmed = content.trim();
106
+ if (trimmed.length === 0)
107
+ return "absent";
108
+ const generation = parseReviewGeneration(trimmed);
109
+ if (generation !== null)
110
+ return `g${generation}`;
111
+ return `d${createHash("sha256").update(trimmed).digest("hex").slice(0, 16)}`;
112
+ }
113
+ function envelopeSignature(envelope) {
114
+ return LEG_NAMES.map((leg) => `${leg}:${legSignaturePart(legContent(envelope, leg))}`).join("|");
115
+ }
116
+ function recordCompletedResult(repoName, ticketNumber, signature, manifest) {
117
+ evictExpiredCompletedResults();
118
+ const key = completedResultKey(repoName, ticketNumber);
119
+ let bySignature = COMPLETED_RESULTS.get(key);
120
+ if (!bySignature) {
121
+ bySignature = new Map();
122
+ COMPLETED_RESULTS.set(key, bySignature);
123
+ }
124
+ // Delete-then-set so Map iteration order tracks recency, matching the
125
+ // baseline registry's own eviction technique.
126
+ bySignature.delete(signature);
127
+ bySignature.set(signature, { manifest, recordedAtMs: Date.now() });
128
+ while (COMPLETED_RESULTS.size > MAX_COMPLETED_RESULT_KEYS) {
129
+ const oldest = COMPLETED_RESULTS.keys().next();
130
+ if (oldest.done)
131
+ break;
132
+ COMPLETED_RESULTS.delete(oldest.value);
133
+ }
134
+ }
135
+ function getCompletedResult(repoName, ticketNumber, signature) {
136
+ evictExpiredCompletedResults();
137
+ return COMPLETED_RESULTS.get(completedResultKey(repoName, ticketNumber))?.get(signature)?.manifest ?? null;
138
+ }
139
+ /**
140
+ * Invalidate every completed-result entry for this repo/ticket. Called when a
141
+ * new submission attempt begins (`recordReviewGenerationBaseline`) so a prior
142
+ * completion can never satisfy a later, not-yet-advanced submission.
143
+ */
144
+ export function invalidateCompletedReviewResult(repoName, ticketNumber) {
145
+ COMPLETED_RESULTS.delete(completedResultKey(repoName, ticketNumber));
146
+ }
147
+ // ---------------------------------------------------------------------------
148
+ // Classification + persistence
149
+ // ---------------------------------------------------------------------------
150
+ /**
151
+ * The single wording authority for every classification outcome (BAPI-1129).
152
+ *
153
+ * Both calling conventions render this verbatim — `wait_for_ticket_review` in
154
+ * its JSON `message` field, blocking `request_ticket_review` appended after
155
+ * the accepted document bodies. Neither re-derives freshness, withholding,
156
+ * absence, suppression or zero-advance prose of its own; a second wording
157
+ * would be a second contract, and the two would drift.
158
+ *
159
+ * `acceptedLegs` (not `savedLegs`) drives the proceed/nothing-vouched-for
160
+ * branch, because a classification-only call accepts legs while saving none.
161
+ */
162
+ function buildMessage(freshnessBasis, acceptedLegs, savedLegs, withheldLegs, absentLegs, persistenceEnabled) {
163
+ // Rendered after the outcome sentence on every branch that could otherwise
164
+ // read as "these files are on disk now".
165
+ const persistenceNote = persistenceEnabled
166
+ ? ""
167
+ : ` PERSISTENCE DISABLED (save_locally: false) — this was a classification-only call: ` +
168
+ `saved leg(s): none, and no file was written, removed, or reconciled.`;
169
+ if (freshnessBasis === "terminality_only") {
170
+ return (`⚠ DEGRADED FRESHNESS — no submission baseline was available, so acceptance is ` +
171
+ `terminality-only: delivery is proven, generation advancement is NOT. Accepted leg(s): ` +
172
+ `${acceptedLegs.length > 0 ? acceptedLegs.join(", ") : "none"}. Saved leg(s): ` +
173
+ `${savedLegs.length > 0 ? savedLegs.join(", ") : "none"}. You may proceed on these ` +
174
+ `documents, but they may be indistinguishable from a prior attachment; the downstream ` +
175
+ `identity-footer agreement check remains the protective backstop. If that check ` +
176
+ `disagrees, STOP and escalate to an operator rather than proceeding. To prove freshness ` +
177
+ `instead, submit the review with request_ticket_review and wait within the same MCP ` +
178
+ `session.` + persistenceNote + describeAbsent(absentLegs));
179
+ }
180
+ if (acceptedLegs.length === 0) {
181
+ return (`No current-review leg could be vouched for against the submission baseline yet — every ` +
182
+ `delivered leg is either at the baseline generation or carries no readable identity. ` +
183
+ `This is a pending-compatible outcome, not a failure; no file was written or removed. ` +
184
+ `The submission baseline is retained: call wait_for_ticket_review to keep waiting for ` +
185
+ `this submission's own output.` +
186
+ describeWithheld(withheldLegs, false) +
187
+ describeAbsent(absentLegs));
188
+ }
189
+ let message = `The review can proceed. Accepted leg(s): ${acceptedLegs.join(", ")}.`;
190
+ message += persistenceEnabled
191
+ ? ` Saved leg(s): ${savedLegs.length > 0 ? savedLegs.join(", ") : "none"}.`
192
+ : persistenceNote;
193
+ message += describeWithheld(withheldLegs, persistenceEnabled);
194
+ message += describeAbsent(absentLegs);
195
+ return message;
196
+ }
197
+ /**
198
+ * Name every withheld leg with exactly one closed-vocabulary reason. The
199
+ * quarantine sentence is stated only when persistence actually ran — a
200
+ * classification-only call removed nothing and must not claim otherwise.
201
+ */
202
+ function describeWithheld(withheldLegs, persistenceEnabled) {
203
+ if (withheldLegs.length === 0)
204
+ return "";
205
+ const summary = withheldLegs.map((leg) => `${leg.leg} (${leg.reason})`).join(", ");
206
+ return (` WARNING — withheld as NOT current-review evidence: ${summary}. These were not saved` +
207
+ (persistenceEnabled
208
+ ? `, and any pre-existing local file for them has been removed so it cannot be mistaken ` +
209
+ `for this submission's output.`
210
+ : `, and their content is not this review's output; no local file was removed because ` +
211
+ `persistence was disabled.`));
212
+ }
213
+ /**
214
+ * Name every absent leg with `not_delivered`, separating a leg the resolved
215
+ * policy EXPECTED (prominent — something the submission configured never
216
+ * arrived) from ordinary policy-permitted absence (a one-leg policy simply
217
+ * did not configure it, which is not a problem at all).
218
+ */
219
+ function describeAbsent(absentLegs) {
220
+ if (absentLegs.length === 0)
221
+ return "";
222
+ const expected = absentLegs.filter((leg) => leg.expectedByPolicy === true);
223
+ const permitted = absentLegs.filter((leg) => leg.expectedByPolicy !== true);
224
+ let text = "";
225
+ if (expected.length > 0) {
226
+ text +=
227
+ ` WARNING — the resolved policy EXPECTED ${expected.map((l) => l.leg).join(", ")} but it ` +
228
+ `was not delivered (not_delivered) — see absent_legs.`;
229
+ }
230
+ if (permitted.length > 0) {
231
+ text +=
232
+ ` NOTE: ${permitted.map((l) => l.leg).join(", ")} was not delivered (not_delivered); the ` +
233
+ `resolved policy did not require it, so this is ordinary policy-permitted absence.`;
234
+ }
235
+ return text;
236
+ }
237
+ /**
238
+ * Classify one delivered leg's advancement against `baseline`.
239
+ *
240
+ * `baselineAvailable=false` means terminality alone decides (no proof of
241
+ * freshness is possible): every delivered leg is accepted, `advanced` stays
242
+ * false, and `generation` is still reported for operator visibility.
243
+ */
244
+ function classifyDelivery(content, legBaseline, baselineAvailable) {
245
+ if (content.trim().length === 0) {
246
+ return { delivered: false, generation: null, advanced: false, reason: "not_delivered" };
247
+ }
248
+ const generation = parseReviewGeneration(content);
249
+ if (!baselineAvailable) {
250
+ return { delivered: true, generation, advanced: false };
251
+ }
252
+ if (generation === null) {
253
+ return { delivered: true, generation: null, advanced: false, reason: "identity_unreadable" };
254
+ }
255
+ if (generationAdvanced(legBaseline, generation)) {
256
+ return { delivered: true, generation, advanced: true };
257
+ }
258
+ return { delivered: true, generation, advanced: false, reason: "generation_not_advanced" };
259
+ }
260
+ /**
261
+ * Classify the observed combined envelope against the submission baseline,
262
+ * persist exactly the accepted legs, remove any stale withheld canonical
263
+ * file, and record a completed-result entry on full success. Returns a
264
+ * structured failure — never throws — on any write or removal failure.
265
+ *
266
+ * Idempotent: a repeated call with the SAME observed envelope for the same
267
+ * repo/ticket replays the previously completed result (reconciling canonical
268
+ * files from the current envelope) rather than degrading to a fresh
269
+ * terminality-only classification after the live baseline has been cleared.
270
+ *
271
+ * BAPI-1129: `ctx.persistenceEnabled === false` makes the whole call
272
+ * classification-only — the same rules produce the same `accepted_legs`, and
273
+ * nothing is written, removed, reconciled, or recorded.
274
+ */
275
+ export async function classifyAndPersistReviewEnvelope(ctx) {
276
+ const signature = envelopeSignature(ctx.envelope);
277
+ // Default true: an omitted flag keeps the pre-BAPI-1129 durable-write
278
+ // behavior, so persistence can only ever be given up deliberately.
279
+ const persistenceEnabled = ctx.persistenceEnabled !== false;
280
+ const replayed = getCompletedResult(ctx.repoName, ctx.ticketNumber, signature);
281
+ if (replayed && !persistenceEnabled) {
282
+ // Reuse the recorded CLASSIFICATION — it was produced by these same rules
283
+ // against the same envelope, and is strictly better evidence than a fresh
284
+ // terminality-only read once the live baseline has been cleared — but
285
+ // perform none of the replay's reconciliation, and do not mutate the
286
+ // cached entry. A dry run observes; it never repairs.
287
+ return {
288
+ ...replayed,
289
+ saved_legs: [],
290
+ legs: replayed.legs.map((leg) => ({ ...leg, saved: false })),
291
+ withheld_legs: replayed.withheld_legs.map((leg) => ({ ...leg, saved: false })),
292
+ absent_legs: replayed.absent_legs.map((leg) => ({ ...leg, saved: false })),
293
+ replay: true,
294
+ message: `REPLAY (classification only) — reproducing the previously completed classification for ` +
295
+ `this submission; no write, removal, or reconciliation was performed. ` +
296
+ buildMessage(replayed.freshness_basis, replayed.accepted_legs, [], replayed.withheld_legs, replayed.absent_legs, false),
297
+ };
298
+ }
299
+ if (replayed) {
300
+ for (const leg of replayed.saved_legs) {
301
+ const content = legContent(ctx.envelope, leg);
302
+ const dir = path.dirname(ctx.canonicalPaths[leg]);
303
+ const filename = path.basename(ctx.canonicalPaths[leg]);
304
+ const outcome = await writeCanonicalArtifact(dir, filename, content);
305
+ if (!outcome.ok) {
306
+ // BAPI-1121 (Step 7): a reconciliation failure does NOT drop the
307
+ // completed entry — a subsequent retry must still be able to repair
308
+ // the canonical state from the same recorded manifest.
309
+ return {
310
+ ok: false,
311
+ error: "ARTIFACT_PERSISTENCE_FAILED",
312
+ status: 500,
313
+ message: `Failed to reconcile ${leg} artifact during replay: ${outcome.message}`,
314
+ saved_legs: [],
315
+ };
316
+ }
317
+ }
318
+ // BAPI-1121 (code review finding): also re-enforce the withheld side of
319
+ // the manifest. Nothing in this process recreates a withheld leg's
320
+ // canonical file between two waits, but the manifest-is-authoritative
321
+ // contract must hold even if something outside this process did — a
322
+ // replay that only reconciled saved_legs could let a withheld file
323
+ // resurface and be read by a downstream step that trusts file presence.
324
+ for (const legStatus of replayed.withheld_legs) {
325
+ const outcome = await removeCanonicalArtifact(ctx.canonicalPaths[legStatus.leg]);
326
+ if (!outcome.ok) {
327
+ return {
328
+ ok: false,
329
+ error: "ARTIFACT_PERSISTENCE_FAILED",
330
+ status: 500,
331
+ message: `Failed to re-quarantine withheld ${legStatus.leg} artifact during replay: ${outcome.message}`,
332
+ saved_legs: replayed.saved_legs,
333
+ };
334
+ }
335
+ }
336
+ return {
337
+ ...replayed,
338
+ replay: true,
339
+ message: `REPLAY — reproducing the previously completed result for this submission (no reclassification performed). ${replayed.message}`,
340
+ };
341
+ }
342
+ const baselineAvailable = ctx.baseline !== null;
343
+ const legs = LEG_NAMES.map((leg) => {
344
+ const content = legContent(ctx.envelope, leg);
345
+ const legBaseline = leg === "clarify" ? ctx.baseline?.clarify ?? null : ctx.baseline?.critique ?? null;
346
+ const classification = classifyDelivery(content, legBaseline, baselineAvailable);
347
+ return {
348
+ leg,
349
+ delivered: classification.delivered,
350
+ generation: classification.generation,
351
+ advanced: classification.advanced,
352
+ saved: false,
353
+ canonicalPath: ctx.canonicalPaths[leg],
354
+ expectedByPolicy: expectedByPolicy(leg, ctx.expectedLegCount),
355
+ reason: classification.reason,
356
+ };
357
+ });
358
+ const freshnessBasis = baselineAvailable ? "generation" : "terminality_only";
359
+ // The shared ACCEPTANCE rule, and the only place it is expressed: every
360
+ // delivered leg under terminality-only fallback (no proof is possible, so
361
+ // terminal delivery alone is accepted — today's pre-BAPI-1121 behavior);
362
+ // only ADVANCED legs under a generation baseline. BAPI-1129 separated this
363
+ // from "which legs get written": under `persistenceEnabled: false` the same
364
+ // legs are accepted and none is written.
365
+ const acceptedLegs = legs.filter((leg) => (baselineAvailable ? leg.advanced : leg.delivered));
366
+ const legsToSave = persistenceEnabled ? acceptedLegs : [];
367
+ // Withheld = delivered but not accepted, ONLY under a generation baseline —
368
+ // terminality-only has no concept of withholding. Under suppression these
369
+ // are still classified and reported; they are simply not quarantined.
370
+ const withheldCandidates = baselineAvailable
371
+ ? legs.filter((leg) => leg.delivered && !leg.advanced)
372
+ : [];
373
+ // Nothing proven yet: do not write, do not remove, do not record
374
+ // completion. `wait_for_ticket_review` never reaches this branch — its own
375
+ // bounded-wait readiness gate already required at least one advanced leg
376
+ // before calling here. Blocking `request_ticket_review` DOES (BAPI-1129):
377
+ // `pollForResult` treats only 404/202 as pending, so a review that outran
378
+ // the backend's `_GENERATING_TTL_SECONDS` terminates the poll on a `200`
379
+ // serving both legs' prior attachments. This is that call's honest
380
+ // negative — and because it writes nothing, the submission baseline stays
381
+ // live for a follow-up `wait_for_ticket_review`.
382
+ if (baselineAvailable && acceptedLegs.length === 0) {
383
+ const withheldOnly = legs.map((leg) => ({ ...leg, reason: leg.reason ?? "not_delivered" }));
384
+ return {
385
+ ok: true,
386
+ delivered_legs: legs.filter((l) => l.delivered).map((l) => l.leg),
387
+ advanced_legs: [],
388
+ accepted_legs: [],
389
+ saved_legs: [],
390
+ withheld_legs: withheldOnly.filter((l) => l.delivered),
391
+ absent_legs: withheldOnly.filter((l) => !l.delivered),
392
+ freshness_basis: freshnessBasis,
393
+ generation_baseline_available: baselineAvailable,
394
+ legs: withheldOnly,
395
+ message: buildMessage(freshnessBasis, [], [], withheldOnly.filter((l) => l.delivered), withheldOnly.filter((l) => !l.delivered), persistenceEnabled),
396
+ };
397
+ }
398
+ const savedLegNames = [];
399
+ for (const leg of legsToSave) {
400
+ const content = legContent(ctx.envelope, leg.leg);
401
+ const dir = path.dirname(ctx.canonicalPaths[leg.leg]);
402
+ const filename = path.basename(ctx.canonicalPaths[leg.leg]);
403
+ const outcome = await writeCanonicalArtifact(dir, filename, content);
404
+ if (!outcome.ok) {
405
+ return {
406
+ ok: false,
407
+ error: "ARTIFACT_PERSISTENCE_FAILED",
408
+ status: 500,
409
+ message: `Failed to save ${leg.leg} artifact: ${outcome.message}. Sibling artifacts already saved were preserved.`,
410
+ saved_legs: savedLegNames,
411
+ };
412
+ }
413
+ leg.saved = true;
414
+ savedLegNames.push(leg.leg);
415
+ }
416
+ // Stale-file cleanup: remove the exact canonical target for every withheld
417
+ // leg so a downstream file-presence read can never reintroduce it. Only
418
+ // performed once at least one leg was genuinely accepted this call — see
419
+ // the early return above for the zero-advance case — and only when
420
+ // persistence is enabled: a classification-only call touches no file at
421
+ // all, in either direction (BAPI-1129).
422
+ for (const leg of persistenceEnabled ? withheldCandidates : []) {
423
+ const outcome = await removeCanonicalArtifact(leg.canonicalPath);
424
+ if (!outcome.ok) {
425
+ return {
426
+ ok: false,
427
+ error: "ARTIFACT_PERSISTENCE_FAILED",
428
+ status: 500,
429
+ message: `Failed to remove withheld ${leg.leg} artifact: ${outcome.message}.`,
430
+ saved_legs: savedLegNames,
431
+ };
432
+ }
433
+ }
434
+ const deliveredLegs = legs.filter((l) => l.delivered).map((l) => l.leg);
435
+ const advancedLegs = legs.filter((l) => l.advanced).map((l) => l.leg);
436
+ const withheldStatuses = legs.filter((l) => withheldCandidates.some((w) => w.leg === l.leg));
437
+ const absentStatuses = legs.filter((l) => !l.delivered);
438
+ const manifest = {
439
+ ok: true,
440
+ delivered_legs: deliveredLegs,
441
+ advanced_legs: advancedLegs,
442
+ accepted_legs: acceptedLegs.map((l) => l.leg),
443
+ saved_legs: savedLegNames,
444
+ withheld_legs: withheldStatuses,
445
+ absent_legs: absentStatuses,
446
+ freshness_basis: freshnessBasis,
447
+ generation_baseline_available: baselineAvailable,
448
+ legs,
449
+ message: buildMessage(freshnessBasis, acceptedLegs.map((l) => l.leg), savedLegNames, withheldStatuses, absentStatuses, persistenceEnabled),
450
+ };
451
+ // Recorded only after a persistence-enabled call actually landed something.
452
+ // `savedLegNames` is empty for every suppressed call, so a dry run seeds
453
+ // nothing and a later persistence-enabled wait still does the real
454
+ // reconciliation. The `persistenceEnabled` conjunct is redundant today and
455
+ // deliberately explicit: it keeps the invariant true if the saved-leg
456
+ // accounting ever changes.
457
+ if (persistenceEnabled && savedLegNames.length > 0) {
458
+ recordCompletedResult(ctx.repoName, ctx.ticketNumber, signature, manifest);
459
+ }
460
+ return manifest;
461
+ }
@@ -41,8 +41,6 @@ import { isNewerVersion } from "./update-check.js";
41
41
  import { fetchLatestVersion } from "./cli-release.js";
42
42
  import { CANONICAL_UPGRADE_COMMAND } from "./upgrade-advice.js";
43
43
  import { describeLauncherReason, inspectLauncherConfigs, isApplicableStandardLauncher, isPrerequisiteFailure, LAUNCHER_CONFIG_TARGETS, } from "./launcher-config-inspection.js";
44
- import { buildGenericAgentShellCommand, detectTerminal, getDefaultSpawnTerminalTabForPlatform, createDefaultStartTicketsDeps, } from "./start-tickets.js";
45
- import { AGENT_REGISTRY } from "./agent-registry.js";
46
44
  import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
47
45
  /** The sentinel this CLI passes to the child it re-execs from `@latest`. */
48
46
  const INTERNAL_REEXEC_FLAG = "--internal-reexec";
@@ -90,21 +88,6 @@ function defaultRemoveCompetingLocalInstall(cwd) {
90
88
  child.on("error", () => resolve(false));
91
89
  });
92
90
  }
93
- async function defaultSpawnReconnect(cwd, command) {
94
- try {
95
- const terminal = detectTerminal(undefined, process.env);
96
- const deps = createDefaultStartTicketsDeps();
97
- const spawnTerminalTab = getDefaultSpawnTerminalTabForPlatform(process.platform);
98
- const result = await spawnTerminalTab(deps, terminal, command, {
99
- key: "reconnect",
100
- worktreePath: cwd,
101
- });
102
- return result.ok;
103
- }
104
- catch {
105
- return false;
106
- }
107
- }
108
91
  /** Build the production dependency set. */
109
92
  export function createDefaultUpgradeCliDeps(cwd) {
110
93
  return {
@@ -115,7 +98,6 @@ export function createDefaultUpgradeCliDeps(cwd) {
115
98
  removeCompetingLocalInstall: defaultRemoveCompetingLocalInstall,
116
99
  runInit,
117
100
  reexecFromLatest: defaultReexec,
118
- spawnReconnect: defaultSpawnReconnect,
119
101
  stdout: (m) => console.log(m),
120
102
  stderr: (m) => console.error(m),
121
103
  env: process.env,
@@ -308,7 +290,7 @@ export async function runUpgradeCli(argv, injected) {
308
290
  stdout(`- Would print: Already up-to-date.`);
309
291
  }
310
292
  stdout(`- Would refresh scaffolded artifacts (commands, agents, pipelines).`);
311
- stdout(`- Would open a fresh reconnect session.`);
293
+ stdout(`- Would print a reminder to reload/restart your MCP.`);
312
294
  return 0;
313
295
  }
314
296
  // ---- Phase 4: competing local install — a reconciliation, not a transition ----
@@ -440,13 +422,10 @@ export async function runUpgradeCli(argv, injected) {
440
422
  }
441
423
  else {
442
424
  stdout(`\nUpgrade complete: launcher pins are at ${target.version}.`);
443
- stdout("Please reconnect your MCP now.");
444
- }
445
- // ---- Phase 8: reconnect convenience only, never changes the verdict ----
446
- const spawnCommand = buildGenericAgentShellCommand(AGENT_REGISTRY.claude, "Bridge API MCP server has been upgraded. Please reload/reconnect the MCP server in your environment.", deps.cwd, process.platform);
447
- const spawned = await deps.spawnReconnect(deps.cwd, spawnCommand);
448
- if (!spawned) {
449
- stdout(`\nTo continue, manually run:\n${spawnCommand}\n`);
425
+ // The running session still holds the OLD MCP connection — MCP config is only
426
+ // read at session startup, so a launcher repin does not hot-reload it. Tell the
427
+ // user the concrete action instead of spawning a fresh session for them.
428
+ stdout("Reload your MCP now (run /mcp) or restart your session to pick up the upgraded server.");
450
429
  }
451
430
  return 0;
452
431
  }
@@ -1,4 +1,4 @@
1
1
  // AUTO-GENERATED — do not edit manually. Regenerate with: npm run build
2
- export const VERSION = "0.2.53";
3
- export const BUILD_COMMIT = "99d75063b906";
4
- export const LAUNCHER_ARGS = ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.53", "serve"];
2
+ export const VERSION = "0.2.55";
3
+ export const BUILD_COMMIT = "b96c1451f24e";
4
+ export const LAUNCHER_ARGS = ["-y", "--prefer-offline", "@bridge_gpt/mcp-server@0.2.55", "serve"];