@bridge_gpt/mcp-server 0.2.38 → 0.2.41

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 (85) hide show
  1. package/README.md +189 -14
  2. package/build/agent-capabilities/probe-context.js +2 -1
  3. package/build/agent-launchers/claude-executor-adapter.js +392 -0
  4. package/build/agent-launchers/executor-adapter-inspection.js +163 -0
  5. package/build/agent-launchers/executor-adapter-registry.js +90 -0
  6. package/build/agent-launchers/executor-adapter.js +136 -0
  7. package/build/agent-registry.js +28 -0
  8. package/build/agents.generated.js +1 -1
  9. package/build/claude-login.js +85 -0
  10. package/build/claude-user-config-doctor.js +59 -33
  11. package/build/commands.generated.js +12 -11
  12. package/build/conduct-epic/bridge-client.js +345 -0
  13. package/build/conduct-epic/checkpoint-store.js +479 -0
  14. package/build/conduct-epic/cli.js +1765 -0
  15. package/build/conduct-epic/lock.js +302 -0
  16. package/build/conduct-epic/pr-state.js +286 -0
  17. package/build/conduct-epic/spawn.js +101 -0
  18. package/build/conductor/bridge-api-client.js +37 -2
  19. package/build/conductor/doctor.js +11 -1
  20. package/build/conductor/install-doctor.js +184 -10
  21. package/build/conductor-bin.js +7 -7
  22. package/build/credential-store.js +10 -4
  23. package/build/credentials-cli.js +34 -19
  24. package/build/docs.generated.js +1 -1
  25. package/build/doctor.js +579 -88
  26. package/build/executor/agent-identity.js +32 -0
  27. package/build/executor/cli.js +50 -39
  28. package/build/executor/deps.js +15 -1
  29. package/build/executor/env.js +56 -45
  30. package/build/executor/index.js +9 -1
  31. package/build/executor/install-preflight.js +138 -0
  32. package/build/executor/job-errors.js +200 -0
  33. package/build/executor/job-runner.js +619 -268
  34. package/build/executor/observation.js +165 -0
  35. package/build/executor/permissions.js +163 -36
  36. package/build/executor/platform.js +54 -0
  37. package/build/executor/preflight.js +175 -67
  38. package/build/executor/process.js +39 -7
  39. package/build/executor/runner.js +19 -0
  40. package/build/executor/service-lifecycle.js +269 -0
  41. package/build/executor/service-unit.js +121 -12
  42. package/build/executor/stale-artifacts.js +70 -0
  43. package/build/executor/test-clock.js +188 -24
  44. package/build/executor/worker-command.js +22 -58
  45. package/build/executor/worker-log.js +82 -0
  46. package/build/executor/worktree-lock.js +264 -0
  47. package/build/index.js +527 -357
  48. package/build/install-bridge-conductor.js +376 -38
  49. package/build/install-bridge.js +414 -114
  50. package/build/install-doctor.js +13 -0
  51. package/build/install-reexec.js +5 -3
  52. package/build/mcp-install-state.js +130 -0
  53. package/build/mcp-profile.js +11 -2
  54. package/build/mcp-provisioning.js +15 -0
  55. package/build/merge-pull-request.js +562 -0
  56. package/build/phase-result-artifacts.js +450 -0
  57. package/build/pipeline-orchestrator.js +4 -0
  58. package/build/pipeline-utils.js +16 -0
  59. package/build/pipelines.generated.js +7 -7
  60. package/build/plane/preflight.js +18 -14
  61. package/build/plane/supervisor.js +8 -1
  62. package/build/project-root.js +34 -0
  63. package/build/readme.generated.js +1 -1
  64. package/build/run-unit-tests-launcher.js +36 -9
  65. package/build/setup-epic.js +57 -4
  66. package/build/sfcc/permissions.js +25 -6
  67. package/build/sfcc/reads-site-preference.js +6 -0
  68. package/build/sfcc/register.js +61 -23
  69. package/build/sfcc/registration-inventory.js +89 -0
  70. package/build/sfcc/setup-status.js +18 -34
  71. package/build/sfcc/tool-wrapper.js +294 -17
  72. package/build/sfcc/write-grants.js +33 -1
  73. package/build/sfcc/write-guard.js +41 -12
  74. package/build/sfcc/writes-custom-object-def.js +6 -2
  75. package/build/sfcc/writes-site-preference.js +6 -1
  76. package/build/sfcc/writes-system-object.js +11 -2
  77. package/build/sfcc/writes.js +13 -8
  78. package/build/start-tickets-prereqs.js +25 -15
  79. package/build/start-tickets.js +123 -21
  80. package/build/version.generated.js +1 -1
  81. package/build/worktree-core.js +9 -3
  82. package/docs/install/mcp-tool-integrations.md +54 -9
  83. package/docs/install/sfcc-integration.md +71 -24
  84. package/package.json +3 -3
  85. package/build/executor/worker-config-isolation.js +0 -287
@@ -0,0 +1,450 @@
1
+ /**
2
+ * BAPI-823 — the durable execution ledger for INLINE recipe execution.
3
+ *
4
+ * {@link ./plan-execution-ledger.js} holds the domain rules and
5
+ * {@link ./plan-phase-routing.js} connects them to the server-side orchestrator.
6
+ * Neither was reachable from `get_pipeline_recipe`, the path every recipe-driven
7
+ * slash command actually takes: `injectPhaseRoutingContext` and
8
+ * `settlePhaseResult` are called only from `pipeline-orchestrator.ts`, so on the
9
+ * `/implement-ticket` path there was no routed context, no ledger, and nothing
10
+ * that parsed the `bapi-phase-result` envelope a phase emitted. The instructions
11
+ * asserted all three anyway, and a named downstream consumer plus a terminal
12
+ * *text* deliverable is what ended the agent's turn at every phase boundary.
13
+ *
14
+ * This module is the missing consumer. It gives the inline path the same
15
+ * guarantees the orchestrator provides, backed by files under the configured
16
+ * docs directory rather than by pipeline state:
17
+ *
18
+ * - **The envelope becomes a tool call.** A phase records its result by calling a
19
+ * tool that lands here, so the step's last act is a tool call and the turn does
20
+ * not terminate on it.
21
+ * - **The ledger is real.** {@link loadInlinePhaseContext} merges every prior
22
+ * phase artifact through the same {@link mergePlanStepRecords}, so a re-entering
23
+ * phase reads what earlier phases actually settled.
24
+ * - **Validation is real.** {@link writeInlinePhaseResult} runs the envelope
25
+ * through {@link validatePhaseResult} before anything is written, so "a step you
26
+ * owned but did not report will be rejected" is a fact rather than a claim.
27
+ *
28
+ * Two properties are load-bearing and easy to lose in a later edit:
29
+ *
30
+ * - **Fail closed on corrupt state.** An absent artifact is an empty ledger; a
31
+ * malformed, mis-versioned, or plan-mismatched artifact is an error. Treating
32
+ * corruption as absence would re-run work already recorded as done, which is
33
+ * how a "safe" fallback becomes a double-apply.
34
+ * - **Plan-bound records.** Each artifact carries a fingerprint of the plan
35
+ * metadata it was settled against. A regenerated plan renumbers steps, so
36
+ * reusing terminal records across plans would mark the wrong work complete.
37
+ *
38
+ * Filesystem only: no MCP transport, no network, no git, no subprocesses, and no
39
+ * process-level state. The caller supplies the trusted docs directory and the
40
+ * already-fetched plan metadata.
41
+ */
42
+ import { createHash } from "crypto";
43
+ import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
44
+ import path from "path";
45
+ import { PLAN_PHASES, PLAN_STEP_DISPOSITIONS, PlanLedgerError, currentLedgerState, resolveOwnedParts, resolveOwnedSteps, sanitizePlanStepRecords, unresolvedLedgerRecords, validatePhaseResult, validatePlanMetadata, } from "./plan-execution-ledger.js";
46
+ import { assertPlanIsRoutable } from "./plan-phase-routing.js";
47
+ // ---------------------------------------------------------------------------
48
+ // Layout
49
+ // ---------------------------------------------------------------------------
50
+ /** Directory, relative to the configured docs dir, holding every phase artifact. */
51
+ export const PHASE_RESULT_SUBDIR = "phase-results";
52
+ /** Artifact schema version. Bumping it invalidates on-disk artifacts by design. */
53
+ export const PHASE_RESULT_ARTIFACT_VERSION = 1;
54
+ /**
55
+ * A ticket key that is safe to embed in a filename.
56
+ *
57
+ * Deliberately narrower than "a Jira key": the value becomes a path segment, so
58
+ * anything that could traverse (`/`, `\`, `.`, `..`) or expand is rejected before
59
+ * it reaches the filesystem, rather than being sanitized into something that
60
+ * silently addresses a different ticket's artifact.
61
+ */
62
+ const TICKET_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
63
+ /** Terminal dispositions that mean "this step is done; do not redo it". */
64
+ const REENTRY_TERMINAL_DISPOSITIONS = new Set([
65
+ "executed",
66
+ "adapted",
67
+ ]);
68
+ /**
69
+ * How inline routing was derived, announced by the phase that used it.
70
+ *
71
+ * The BAPI-815 run reported owning 21 of 31 steps without saying where that came
72
+ * from, which made a correct answer indistinguishable from the prose-guessing the
73
+ * instructions forbid. Naming the source is what makes the claim auditable.
74
+ */
75
+ export const INLINE_ROUTING_SOURCE = "inline:get_plan_metadata+phase_result_artifacts";
76
+ // ---------------------------------------------------------------------------
77
+ // Path safety
78
+ // ---------------------------------------------------------------------------
79
+ function assertValidTicketKey(ticketKey) {
80
+ if (typeof ticketKey !== "string" || !TICKET_KEY_PATTERN.test(ticketKey.trim())) {
81
+ throw new PlanLedgerError("ticket key must match PROJ-123; it becomes a filename segment and is never sanitized into a different one");
82
+ }
83
+ return ticketKey.trim().toUpperCase();
84
+ }
85
+ function assertValidPhase(phase) {
86
+ if (typeof phase !== "string" || !PLAN_PHASES.includes(phase)) {
87
+ throw new PlanLedgerError(`unknown phase '${String(phase)}'; expected one of ${PLAN_PHASES.join(", ")}`);
88
+ }
89
+ return phase;
90
+ }
91
+ function assertDocsDir(docsDir) {
92
+ if (typeof docsDir !== "string" || docsDir.trim() === "") {
93
+ throw new PlanLedgerError("docs directory is required and must be a non-empty path");
94
+ }
95
+ if (!path.isAbsolute(docsDir)) {
96
+ throw new PlanLedgerError("docs directory must be an absolute, server-resolved path");
97
+ }
98
+ return path.resolve(docsDir);
99
+ }
100
+ /**
101
+ * Resolve `relative` under `root` and prove the result stays inside it.
102
+ *
103
+ * Checked before any `mkdir`, read, temporary write, or rename — a containment
104
+ * check that runs after the directory exists has already had its side effect.
105
+ */
106
+ function resolveWithinRoot(root, ...segments) {
107
+ const resolved = path.resolve(root, ...segments);
108
+ const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
109
+ if (resolved !== root && !resolved.startsWith(rootWithSep)) {
110
+ throw new PlanLedgerError("refusing to read or write a phase artifact outside the configured docs directory");
111
+ }
112
+ return resolved;
113
+ }
114
+ /** `phase-results/<TICKET>-<phase>.json`, relative to the docs dir. */
115
+ export function phaseResultRelativePath(ticketKey, phase) {
116
+ return `${PHASE_RESULT_SUBDIR}/${assertValidTicketKey(ticketKey)}-${assertValidPhase(phase)}.json`;
117
+ }
118
+ /** `phase-results/<TICKET>-checkpoint.json`, relative to the docs dir. */
119
+ export function checkpointRelativePath(ticketKey) {
120
+ return `${PHASE_RESULT_SUBDIR}/${assertValidTicketKey(ticketKey)}-checkpoint.json`;
121
+ }
122
+ // ---------------------------------------------------------------------------
123
+ // Plan binding
124
+ // ---------------------------------------------------------------------------
125
+ /**
126
+ * A stable fingerprint of the routing-relevant shape of a plan.
127
+ *
128
+ * Only the fields routing depends on are hashed — part ids, classes, step ranges,
129
+ * advisory flags, and the total — so cosmetic prose changes do not invalidate a
130
+ * ledger while a renumbered plan does.
131
+ */
132
+ export function planFingerprint(metadata) {
133
+ const canonical = JSON.stringify({
134
+ version: metadata.version,
135
+ total_steps: metadata.total_steps,
136
+ parts: metadata.parts.map((part) => [
137
+ part.part_id,
138
+ part.provenance_class,
139
+ part.start_step,
140
+ part.end_step,
141
+ part.declared_advisory,
142
+ ]),
143
+ });
144
+ return createHash("sha256").update(canonical).digest("hex").slice(0, 32);
145
+ }
146
+ // ---------------------------------------------------------------------------
147
+ // Reading
148
+ // ---------------------------------------------------------------------------
149
+ function isMissingFileError(err) {
150
+ return (typeof err === "object" &&
151
+ err !== null &&
152
+ err.code === "ENOENT");
153
+ }
154
+ async function readJsonArtifact(absolutePath, relativePath) {
155
+ let raw;
156
+ try {
157
+ raw = await readFile(absolutePath, "utf8");
158
+ }
159
+ catch (err) {
160
+ if (isMissingFileError(err))
161
+ return undefined;
162
+ throw new PlanLedgerError(`could not read ${relativePath}`);
163
+ }
164
+ try {
165
+ return JSON.parse(raw);
166
+ }
167
+ catch {
168
+ // Fail closed. An unparseable artifact is not an empty one: treating it as
169
+ // empty would silently re-run work a previous phase already settled.
170
+ throw new PlanLedgerError(`${relativePath} is not valid JSON; refusing to treat a corrupt ledger as an empty one`);
171
+ }
172
+ }
173
+ function validateArtifactEnvelope(parsed, relativePath, ticketKey, phase, fingerprint) {
174
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
175
+ throw new PlanLedgerError(`${relativePath} is not a phase-result artifact object`);
176
+ }
177
+ const artifact = parsed;
178
+ if (artifact.artifact_version !== PHASE_RESULT_ARTIFACT_VERSION) {
179
+ throw new PlanLedgerError(`${relativePath} has unsupported artifact_version ${String(artifact.artifact_version)}; expected ${PHASE_RESULT_ARTIFACT_VERSION}`);
180
+ }
181
+ if (artifact.ticket_key !== ticketKey) {
182
+ throw new PlanLedgerError(`${relativePath} records ticket '${String(artifact.ticket_key)}' but was read for '${ticketKey}'`);
183
+ }
184
+ if (artifact.phase !== phase) {
185
+ throw new PlanLedgerError(`${relativePath} declares phase '${String(artifact.phase)}' but was read as '${phase}'`);
186
+ }
187
+ if (artifact.plan_fingerprint !== fingerprint) {
188
+ throw new PlanLedgerError(`${relativePath} was settled against a different plan; a regenerated plan renumbers steps, so its terminal records must not be reused`);
189
+ }
190
+ if (!Array.isArray(artifact.records)) {
191
+ throw new PlanLedgerError(`${relativePath} has no records array`);
192
+ }
193
+ return artifact;
194
+ }
195
+ /**
196
+ * Check stored records against the plan in play, without the once-only rule.
197
+ *
198
+ * {@link validatePhaseResult} is the right gate for an incoming envelope, which
199
+ * must settle every owned step exactly once. It is the wrong gate for reading a
200
+ * stored artifact back: the artifact is a chronological log, so a re-entry
201
+ * legitimately holds more than one record per step and `validatePhaseResult`
202
+ * would reject its own accepted output. Ownership and the closed disposition
203
+ * vocabulary are still enforced — a record naming a step this phase does not own,
204
+ * or a disposition outside the set, means the artifact does not describe this
205
+ * plan and is refused rather than partially trusted.
206
+ */
207
+ function assertStoredRecordsRoutable(records, metadata, phase, relativePath) {
208
+ const owned = new Set(resolveOwnedSteps(metadata, phase));
209
+ for (const record of records) {
210
+ if (typeof record !== "object" || record === null) {
211
+ throw new PlanLedgerError(`${relativePath} holds a record that is not an object`);
212
+ }
213
+ if (!owned.has(record.stepId)) {
214
+ throw new PlanLedgerError(`${relativePath} records step ${String(record.stepId)}, which phase '${phase}' does not own under the current plan`);
215
+ }
216
+ if (!PLAN_STEP_DISPOSITIONS.includes(record.disposition)) {
217
+ throw new PlanLedgerError(`${relativePath} records step ${record.stepId} with unsupported disposition '${String(record.disposition)}'`);
218
+ }
219
+ }
220
+ return records.map((record) => ({ ...record, phase }));
221
+ }
222
+ /**
223
+ * Append `incoming` to `existing`, refusing to walk a settled step backwards.
224
+ *
225
+ * This is deliberately more permissive than {@link mergePlanStepRecords}, and the
226
+ * difference is the re-entry case. That function treats any re-report of a
227
+ * settled step as ambiguous, which is right for the orchestrated ledger where a
228
+ * phase reports once per run. Inline, a recovery run re-enters the same phase and
229
+ * {@link validatePhaseResult} requires it to settle *every* owned step — including
230
+ * the ones it correctly skipped because they were already terminal. Rejecting
231
+ * that honest re-affirmation would make re-entry impossible.
232
+ *
233
+ * What is still refused is the dangerous direction: a step recorded as `executed`
234
+ * or `adapted` coming back as `escalated` or `unrun-advisory` with no statement of
235
+ * what invalidated the earlier evidence. That is a completion claim being quietly
236
+ * withdrawn.
237
+ */
238
+ function appendInlineRecords(existing, incoming) {
239
+ const latest = currentLedgerState(existing);
240
+ for (const record of incoming) {
241
+ const prior = latest.get(record.stepId);
242
+ if (!prior)
243
+ continue;
244
+ const priorTerminal = REENTRY_TERMINAL_DISPOSITIONS.has(prior.disposition);
245
+ const incomingTerminal = REENTRY_TERMINAL_DISPOSITIONS.has(record.disposition);
246
+ if (priorTerminal && !incomingTerminal && !record.invalidatedBy) {
247
+ throw new PlanLedgerError(`step ${record.stepId} is already recorded as '${prior.disposition}'; re-recording it as '${record.disposition}' requires an explicit invalidatedBy note stating what invalidated the prior evidence`);
248
+ }
249
+ }
250
+ return [...existing, ...incoming.map((record) => ({ ...record }))];
251
+ }
252
+ /**
253
+ * Read every phase artifact for a ticket and derive this phase's routing.
254
+ *
255
+ * Ownership comes from {@link resolveOwnedSteps} — the same function the
256
+ * orchestrator uses — never from matching plan prose. Reading creates nothing:
257
+ * a run that only inspects its context leaves no artifact behind.
258
+ */
259
+ export async function loadInlinePhaseContext(options) {
260
+ const docsDir = assertDocsDir(options.docsDir);
261
+ const ticketKey = assertValidTicketKey(options.ticketKey);
262
+ const phase = assertValidPhase(options.phase);
263
+ const metadata = validatePlanMetadata(options.planMetadata);
264
+ assertPlanIsRoutable(metadata);
265
+ const fingerprint = planFingerprint(metadata);
266
+ let ledger = [];
267
+ const artifactPaths = [];
268
+ // Canonical phase order, not directory order: the ledger is chronological and
269
+ // production always precedes the phases that verify it.
270
+ for (const priorPhase of PLAN_PHASES) {
271
+ const relativePath = phaseResultRelativePath(ticketKey, priorPhase);
272
+ const absolutePath = resolveWithinRoot(docsDir, relativePath);
273
+ const parsed = await readJsonArtifact(absolutePath, relativePath);
274
+ if (parsed === undefined)
275
+ continue;
276
+ const artifact = validateArtifactEnvelope(parsed, relativePath, ticketKey, priorPhase, fingerprint);
277
+ // Re-check against the CURRENT metadata: an artifact that was valid when
278
+ // written must still describe steps this phase owns under the plan in play now.
279
+ const routable = assertStoredRecordsRoutable(artifact.records, metadata, priorPhase, relativePath);
280
+ ledger = [...ledger, ...sanitizePlanStepRecords(routable)];
281
+ artifactPaths.push(relativePath);
282
+ }
283
+ const checkpoint = await readInlineCheckpoint(docsDir, ticketKey);
284
+ if (checkpoint)
285
+ artifactPaths.push(checkpointRelativePath(ticketKey));
286
+ const current = currentLedgerState(ledger);
287
+ const currentRecordsByStep = {};
288
+ const terminalStepIds = [];
289
+ for (const [stepId, record] of current) {
290
+ currentRecordsByStep[stepId] = record;
291
+ if (REENTRY_TERMINAL_DISPOSITIONS.has(record.disposition))
292
+ terminalStepIds.push(stepId);
293
+ }
294
+ terminalStepIds.sort((a, b) => a - b);
295
+ return {
296
+ phase,
297
+ routingSource: INLINE_ROUTING_SOURCE,
298
+ ownedSteps: resolveOwnedSteps(metadata, phase),
299
+ ownedRanges: resolveOwnedParts(metadata, phase).map((part) => ({
300
+ partId: part.part_id,
301
+ provenanceClass: part.provenance_class,
302
+ startStep: part.start_step,
303
+ endStep: part.end_step,
304
+ declaredAdvisory: part.declared_advisory,
305
+ })),
306
+ ledger,
307
+ currentRecordsByStep,
308
+ terminalStepIds,
309
+ unresolved: unresolvedLedgerRecords(ledger),
310
+ ...(checkpoint ? { checkpoint } : {}),
311
+ artifactPaths,
312
+ };
313
+ }
314
+ async function readInlineCheckpoint(docsDir, ticketKey) {
315
+ const relativePath = checkpointRelativePath(ticketKey);
316
+ const absolutePath = resolveWithinRoot(docsDir, relativePath);
317
+ const parsed = await readJsonArtifact(absolutePath, relativePath);
318
+ if (parsed === undefined)
319
+ return undefined;
320
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
321
+ throw new PlanLedgerError(`${relativePath} is not a checkpoint artifact object`);
322
+ }
323
+ const artifact = parsed;
324
+ if (artifact.artifact_version !== PHASE_RESULT_ARTIFACT_VERSION) {
325
+ throw new PlanLedgerError(`${relativePath} has unsupported artifact_version ${String(artifact.artifact_version)}`);
326
+ }
327
+ if (artifact.ticket_key !== ticketKey) {
328
+ throw new PlanLedgerError(`${relativePath} records ticket '${String(artifact.ticket_key)}' but was read for '${ticketKey}'`);
329
+ }
330
+ if (typeof artifact.branch !== "string" ||
331
+ artifact.branch.trim() === "" ||
332
+ typeof artifact.sha !== "string" ||
333
+ artifact.sha.trim() === "") {
334
+ throw new PlanLedgerError(`${relativePath} is missing its branch or sha`);
335
+ }
336
+ return { branch: artifact.branch, sha: artifact.sha };
337
+ }
338
+ // ---------------------------------------------------------------------------
339
+ // Writing
340
+ // ---------------------------------------------------------------------------
341
+ /**
342
+ * Write `payload` to `absolutePath` via an adjacent temp file and a rename.
343
+ *
344
+ * The rename is what makes replacement atomic: a reader never observes a
345
+ * half-written ledger. The temp file is removed on failure so a rejected write
346
+ * leaves nothing behind to be mistaken for state.
347
+ */
348
+ async function writeAtomically(absolutePath, relativePath, payload) {
349
+ const tempPath = `${absolutePath}.tmp`;
350
+ await mkdir(path.dirname(absolutePath), { recursive: true });
351
+ try {
352
+ await writeFile(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
353
+ await rename(tempPath, absolutePath);
354
+ }
355
+ catch {
356
+ await rm(tempPath, { force: true }).catch(() => undefined);
357
+ throw new PlanLedgerError(`could not durably write ${relativePath}`);
358
+ }
359
+ }
360
+ /**
361
+ * Validate a phase's envelope and persist it as this phase's durable record.
362
+ *
363
+ * Everything is checked before the artifact is touched: the metadata, the
364
+ * envelope against the steps the phase owns, and the monotonic merge against what
365
+ * is already recorded. A rejected write leaves any existing valid artifact
366
+ * exactly as it was — the phase made a claim that does not hold, and overwriting
367
+ * good state with it would record that claim as the durable truth.
368
+ */
369
+ export async function writeInlinePhaseResult(options) {
370
+ const docsDir = assertDocsDir(options.docsDir);
371
+ const ticketKey = assertValidTicketKey(options.ticketKey);
372
+ const metadata = validatePlanMetadata(options.planMetadata);
373
+ assertPlanIsRoutable(metadata);
374
+ const fingerprint = planFingerprint(metadata);
375
+ const envelope = options.phaseResult;
376
+ if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope)) {
377
+ throw new PlanLedgerError("phase result must be an object");
378
+ }
379
+ const phase = assertValidPhase(envelope.phase);
380
+ const validated = validatePhaseResult(envelope, metadata, phase);
381
+ const sanitized = sanitizePlanStepRecords(validated);
382
+ const relativePath = phaseResultRelativePath(ticketKey, phase);
383
+ const absolutePath = resolveWithinRoot(docsDir, relativePath);
384
+ // Merge against what this phase already recorded, refusing to walk a settled
385
+ // step backwards. This runs before the artifact is touched, so a rejected
386
+ // second pass leaves the first pass's record exactly as it was.
387
+ const existing = await readJsonArtifact(absolutePath, relativePath);
388
+ let priorRecords = [];
389
+ if (existing !== undefined) {
390
+ priorRecords = assertStoredRecordsRoutable(validateArtifactEnvelope(existing, relativePath, ticketKey, phase, fingerprint).records, metadata, phase, relativePath);
391
+ }
392
+ const merged = appendInlineRecords(priorRecords, sanitized);
393
+ const artifact = {
394
+ artifact_version: PHASE_RESULT_ARTIFACT_VERSION,
395
+ ticket_key: ticketKey,
396
+ phase,
397
+ plan_fingerprint: fingerprint,
398
+ records: merged,
399
+ };
400
+ await writeAtomically(absolutePath, relativePath, artifact);
401
+ const terminalStepIds = [...currentLedgerState(merged).values()]
402
+ .filter((record) => REENTRY_TERMINAL_DISPOSITIONS.has(record.disposition))
403
+ .map((record) => record.stepId)
404
+ .sort((a, b) => a - b);
405
+ return {
406
+ phase,
407
+ routingSource: INLINE_ROUTING_SOURCE,
408
+ recordCount: merged.length,
409
+ artifactPath: relativePath,
410
+ terminalStepIds,
411
+ };
412
+ }
413
+ /**
414
+ * Persist the checkpoint the produce phase's work was pushed at.
415
+ *
416
+ * Only a checkpoint that reports its work durable on origin is accepted, matching
417
+ * {@link ../plan-phase-routing.js `parseCheckpointResult`}: a block claiming
418
+ * `pushed: false` or `remoteMatchesHead: false` describes a checkpoint that did
419
+ * not achieve the one thing it exists for.
420
+ */
421
+ export async function writeInlineCheckpoint(options) {
422
+ const docsDir = assertDocsDir(options.docsDir);
423
+ const ticketKey = assertValidTicketKey(options.ticketKey);
424
+ const value = options.checkpoint;
425
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
426
+ throw new PlanLedgerError("checkpoint must be an object");
427
+ }
428
+ const checkpoint = value;
429
+ const branch = typeof checkpoint.branch === "string" ? checkpoint.branch.trim() : "";
430
+ const sha = typeof checkpoint.sha === "string" ? checkpoint.sha.trim() : "";
431
+ if (branch === "")
432
+ throw new PlanLedgerError("checkpoint branch must be a non-empty string");
433
+ if (sha === "")
434
+ throw new PlanLedgerError("checkpoint sha must be a non-empty string");
435
+ if (checkpoint.pushed !== true || checkpoint.remoteMatchesHead !== true) {
436
+ throw new PlanLedgerError("checkpoint must report pushed and remoteMatchesHead true; a checkpoint that is not on origin is not a checkpoint");
437
+ }
438
+ const relativePath = checkpointRelativePath(ticketKey);
439
+ const absolutePath = resolveWithinRoot(docsDir, relativePath);
440
+ const artifact = {
441
+ artifact_version: PHASE_RESULT_ARTIFACT_VERSION,
442
+ ticket_key: ticketKey,
443
+ branch,
444
+ sha,
445
+ pushed: true,
446
+ remote_matches_head: true,
447
+ };
448
+ await writeAtomically(absolutePath, relativePath, artifact);
449
+ return { branch, sha, artifactPath: relativePath };
450
+ }
@@ -227,6 +227,10 @@ export async function runPipeline(deps, input) {
227
227
  try {
228
228
  recipe = resolveRecipe(pipelineDef, deps.instructions, mergedVariables, undefined, autoApprove, {
229
229
  includeUpgradeAdviceSurfacing: deps.includeUpgradeAdviceSurfacing !== false,
230
+ // BAPI-823: this IS the orchestrator. The routed phase context below and
231
+ // `settlePhaseResult` / `parseCheckpointResult` are reachable only from
232
+ // here, so only this call site may claim the orchestrated mode.
233
+ executionMode: "orchestrated",
230
234
  });
231
235
  }
232
236
  catch (err) {
@@ -5,6 +5,15 @@
5
5
  */
6
6
  import { readdir, readFile } from "fs/promises";
7
7
  import path from "path";
8
+ /**
9
+ * The mode substitution token instruction files branch on.
10
+ *
11
+ * Reserved: {@link resolveRecipe} always overwrites any caller-supplied value, so
12
+ * a recipe variable can never claim a mode the resolver did not choose. A phase
13
+ * that believed a spoofed `"orchestrated"` would emit a text envelope into the
14
+ * void and skip the durable write — the halt this ticket removes.
15
+ */
16
+ export const EXECUTION_MODE_VARIABLE = "execution_mode";
8
17
  // ---------------------------------------------------------------------------
9
18
  // Schema Validation
10
19
  // ---------------------------------------------------------------------------
@@ -175,6 +184,12 @@ export function resolveRecipe(pipeline, instructions, variables, skipSteps, auto
175
184
  `Pipeline "${pipeline.name}" declares: [${declared.join(", ")}].`);
176
185
  }
177
186
  const skip = new Set(skipSteps ?? []);
187
+ // BAPI-823: the mode is resolver-owned, never caller-owned. Overwriting rather
188
+ // than defaulting is the point — a variables map carrying its own
189
+ // `execution_mode` must not be able to make a phase believe an orchestrator is
190
+ // watching when none is.
191
+ const executionMode = options?.executionMode ?? "inline";
192
+ variables = { ...variables, [EXECUTION_MODE_VARIABLE]: executionMode };
178
193
  // Filter and resolve steps
179
194
  const resolvedSteps = [];
180
195
  let stepIndex = 1;
@@ -254,6 +269,7 @@ export function resolveRecipe(pipeline, instructions, variables, skipSteps, auto
254
269
  total_steps: resolvedSteps.length,
255
270
  agent_instructions: baseInstructions + upgradeAdviceConvention + autoApproveSuffix,
256
271
  auto_approve: !!autoApprove,
272
+ execution_mode: executionMode,
257
273
  steps: resolvedSteps,
258
274
  };
259
275
  }