@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.
- package/README.md +140 -292
- package/build/agent-launchers/claude-executor-adapter.js +17 -4
- package/build/agents.generated.js +4 -4
- package/build/artifact-diff.js +110 -0
- package/build/bin-entrypoint.js +36 -0
- package/build/bridge-permissions.js +23 -4
- package/build/claude-review-workflow-drift.js +3 -3
- package/build/codex-bridge-profile.js +1 -1
- package/build/commands.generated.js +6 -6
- package/build/conductor/bridge-api-client.js +598 -19
- package/build/conductor/bring-up-facts.js +9 -9
- package/build/conductor/doctor.js +466 -18
- package/build/conductor/epic-child-continuation.js +54 -3
- package/build/conductor/epic-implementer-cli.js +132 -34
- package/build/conductor/epic-runtime.js +2 -2
- package/build/conductor/epic-state.js +1 -1
- package/build/conductor/hook-runtime-qualification.js +1 -1
- package/build/conductor/install-doctor.js +4 -2
- package/build/conductor/launch-need-probes.js +337 -0
- package/build/conductor/local-merge.js +241 -56
- package/build/conductor/merge-credential.js +172 -0
- package/build/conductor/merge-execution-eligibility.js +1 -1
- package/build/conductor/merged-child-check.js +95 -0
- package/build/conductor/plan-trim.js +30 -0
- package/build/conductor/pr-ci-producer.js +1 -1
- package/build/conductor/readiness-cli.js +11 -6
- package/build/conductor/readiness-prerequisites.js +41 -0
- package/build/conductor/readiness-sections.js +345 -55
- package/build/conductor/readiness.js +99 -87
- package/build/conductor/recovery-cli.js +606 -21
- package/build/conductor/recovery-operations.js +306 -7
- package/build/conductor/repair-contract.js +47 -2
- package/build/conductor/repository-workflow-remedy.js +8 -6
- package/build/conductor/stored-merge-credential.js +76 -0
- package/build/conductor/stored-merge-token-probe.js +28 -0
- package/build/conductor/temporary-plan.js +50 -0
- package/build/conductor/wait-run-cli.js +759 -0
- package/build/conductor/worker-database-doctor-inspector.js +49 -0
- package/build/conductor-bin.js +2 -2
- package/build/conductor-claude-hook-bin.js +2 -2
- package/build/conductor-claude-hook-removed-stub-bin.js +2 -5
- package/build/conductor-removed-stub-bin.js +2 -5
- package/build/credential-store.js +356 -13
- package/build/docs.generated.js +26 -13
- package/build/doctor.js +94 -32
- package/build/drive-epic-help.js +212 -0
- package/build/drive-epic.js +1840 -373
- package/build/epic-ci-workflow-drift.js +77 -0
- package/build/epic-ci-workflow-install.js +183 -0
- package/build/epic-ci-workflow.js +157 -0
- package/build/epic-contract-context.js +326 -0
- package/build/epic-implementer-bin.js +57 -41
- package/build/epic-implementer-claude-hook-bin.js +2 -2
- package/build/escalation-reachability.js +134 -0
- package/build/executor/cleanup-report.js +99 -0
- package/build/executor/cli.js +12 -1
- package/build/executor/completion-contract.js +66 -11
- package/build/executor/completion-supervision.js +99 -0
- package/build/executor/delivery-outcome.js +179 -0
- package/build/executor/deps.js +3 -0
- package/build/executor/env.js +71 -0
- package/build/executor/failure-reason-codes.js +126 -0
- package/build/executor/heartbeat.js +38 -0
- package/build/executor/http-client.js +72 -0
- package/build/executor/integration-job.js +298 -5
- package/build/executor/job-errors.js +36 -6
- package/build/executor/job-runner.js +720 -36
- package/build/executor/likely-tests.js +36 -2
- package/build/executor/merge-job.js +145 -2
- package/build/executor/observation.js +73 -4
- package/build/executor/permissions.js +193 -97
- package/build/executor/preflight.js +14 -3
- package/build/executor/process-containment.js +28 -2
- package/build/executor/prompt-spec.js +50 -5
- package/build/executor/rebase-finalization.js +38 -3
- package/build/executor/reconcile-overlap-job.js +35 -7
- package/build/executor/required-work-ledger.js +236 -81
- package/build/executor/resume-pre-spawn.js +38 -1
- package/build/executor/runner.js +91 -3
- package/build/executor/test-clock.js +8 -0
- package/build/executor/types.js +37 -2
- package/build/executor/worker-cli-launcher.js +259 -0
- package/build/executor/worker-command.js +41 -4
- package/build/executor/worker-database-target.js +91 -0
- package/build/executor/worker-database.js +908 -0
- package/build/executor/worker-finalization.js +106 -0
- package/build/executor/worker-guard-hook.js +203 -0
- package/build/executor/worker-resources.js +47 -0
- package/build/executor/worker-verification-guidance.js +15 -2
- package/build/executor/worktree.js +303 -15
- package/build/executor-launch.js +11 -1
- package/build/generated-assets-doctor.js +9 -1
- package/build/generated-assets-health.js +13 -4
- package/build/generated-assets-legacy.js +39 -0
- package/build/generated-assets-paths.js +12 -1
- package/build/generated-assets-repair.js +4 -1
- package/build/generated-assets-test-support.js +12 -0
- package/build/github-remote.js +33 -0
- package/build/github-sign-in.js +62 -0
- package/build/index.js +1067 -536
- package/build/init.js +39 -1
- package/build/install-bridge-conductor-gate.js +78 -0
- package/build/install-bridge-conductor-setup.js +413 -0
- package/build/install-bridge-conductor.js +1226 -267
- package/build/install-bridge.js +376 -60
- package/build/mcp-install-state.js +8 -2
- package/build/merge-pull-request.js +6 -0
- package/build/merge-workflow-install.js +763 -345
- package/build/phase-result-artifacts.js +75 -20
- package/build/pipeline-orchestrator.js +247 -4
- package/build/pipelines.generated.js +35 -35
- package/build/plan-discovery.js +15 -2
- package/build/plan-execution-ledger.js +306 -17
- package/build/plan-phase-routing.js +48 -13
- package/build/plane/cli.js +105 -51
- package/build/plane/defaults.js +3 -0
- package/build/plane/plane-freshness.js +294 -0
- package/build/plane/schema-compatibility.js +137 -0
- package/build/plane/shutdown-deps.js +38 -0
- package/build/plane/shutdown.js +83 -8
- package/build/plane/terminal-run-cleanup.js +303 -0
- package/build/printed-remedy-commands.js +26 -4
- package/build/readiness-check.js +6 -2
- package/build/readiness-prerequisites.generated.js +532 -0
- package/build/readme.generated.js +1 -1
- package/build/setup-epic.js +400 -30
- package/build/shared-resources.generated.js +5 -0
- package/build/start-tickets.js +28 -15
- package/build/test-command-detection.js +247 -0
- package/build/test-command-validation.js +73 -0
- package/build/testing-config-client.js +150 -0
- package/build/ticket-delivery-artifacts.js +258 -0
- package/build/ticket-delivery-enrollment.js +306 -0
- package/build/ticket-delivery.js +205 -0
- package/build/ticket-review-summary.js +14 -0
- package/build/upgrade-cli.js +13 -0
- package/build/v2-readiness-requirements.js +552 -0
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +2 -2
- package/build/worktree-core.js +22 -7
- package/docs/BRIDGE-DIRECTORY-README.md +48 -0
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +12 -867
- package/docs/EPIC-IMPLEMENTER.md +104 -63
- package/docs/epic-implementer/getting-started.md +131 -0
- package/docs/epic-implementer/self-hosting.md +133 -0
- package/package.json +3 -3
- package/pipelines/idea-to-ticket.json +1 -1
- package/pipelines/implement-ticket.json +8 -8
- package/pipelines/review-ticket.json +2 -2
- package/design-assets/favicon/favicon-32x32.png +0 -0
- package/design-assets/just-logo-rough-draft.png +0 -0
package/build/setup-epic.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { ticketReviewSummary } from "./ticket-review-summary.js";
|
|
2
|
+
import { previewLaunchReadiness } from "./conductor/bridge-api-client.js";
|
|
1
3
|
/**
|
|
2
4
|
* `setup-epic` — one-command bootstrap for an Epic Implementer run.
|
|
3
5
|
*
|
|
@@ -82,7 +84,7 @@
|
|
|
82
84
|
import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
|
|
83
85
|
import os from "node:os";
|
|
84
86
|
import { createProductionEpicIntegrationGhRunner, ensureEpicIntegrationPullRequest, formatEpicIntegrationPullRequestOutcome, } from "./epic-integration-pr.js";
|
|
85
|
-
import { approveEpicPlan, buildConductorJiraUrl, conductorGetHeaders, createEpicRunWithDisposition, decodeCanonicalProvenance, fetchConductorReadiness, fetchEpicRunState, replaceEpicRunPolicy, resolveConductorBridgeApiAccess, resolveEpicLaunchBridgeApiAccess, safeDiagnosticMessage, storeEpicPlan, validateEpicPlan, ConductorBridgeApiError, } from "./conductor/bridge-api-client.js";
|
|
87
|
+
import { approveEpicPlan, buildConductorJiraUrl, conductorGetHeaders, createEpicRunWithDisposition, decodeCanonicalProvenance, fetchConductorReadiness, formatSchemaIncompatibleRefusal, isSchemaIncompatibleError, fetchEpicRunState, replaceEpicRunPolicy, resolveConductorBridgeApiAccess, resolveEpicLaunchBridgeApiAccess, safeDiagnosticMessage, storeEpicPlan, validateEpicPlan, ConductorBridgeApiError, } from "./conductor/bridge-api-client.js";
|
|
86
88
|
import { formatEpicLaunchChannelIdentity, getPinnedEpicLaunchPolicy, readEpicLaunchProfile, } from "./epic-launch-profile.js";
|
|
87
89
|
// BAPI-1133 (A2) — the single legacy-to-typed merge-execution-mode mapping and
|
|
88
90
|
// its total legacy resolver live in `merge-pull-request.ts` (no import cycle:
|
|
@@ -94,6 +96,7 @@ import { classifyMergeExecutionEligibility } from "./conductor/merge-execution-e
|
|
|
94
96
|
import { repositoryWorkflowRemedy } from "./conductor/repository-workflow-remedy.js";
|
|
95
97
|
import { resolveTrackedPaths } from "./git-reachability.js";
|
|
96
98
|
import { validateBranchName } from "./base-ref.js";
|
|
99
|
+
import { TICKET_KEY_PATTERN } from "./ticket-key-utils.js";
|
|
97
100
|
// Reused from the implement-epic client on purpose: adding a second wrapper under
|
|
98
101
|
// `conductor/` would give the v2 path its own copy to drift from.
|
|
99
102
|
//
|
|
@@ -113,6 +116,134 @@ import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
|
|
|
113
116
|
// state. See `planeBinding` on `SetupEpicDeps` below.
|
|
114
117
|
import { createPlaneFsDeps, createPlaneProcessDeps } from "./plane/defaults.js";
|
|
115
118
|
import { bindPlaneManifestEpicRun, manifestHasLiveProcess, readPlaneManifest, } from "./plane/manifest.js";
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Verification hand-off choice (BAPI-1496)
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
/** The policy keys child 1 (BAPI-1492) declares on the server's `RunPolicy`. */
|
|
123
|
+
export const SETUP_EPIC_VERIFICATION_HANDOFF_ENABLED_KEY = "verification_handoff_enabled";
|
|
124
|
+
export const SETUP_EPIC_VERIFICATION_HANDOFF_EXCEPTIONS_KEY = "verification_handoff_exceptions";
|
|
125
|
+
/**
|
|
126
|
+
* The closed step-class vocabulary. Mirrors the server's
|
|
127
|
+
* `VERIFICATION_HANDOFF_STEP_CLASSES` in `api/models/epic_run.py`; a client
|
|
128
|
+
* cannot import a Python constant, so a test pins the two together.
|
|
129
|
+
*/
|
|
130
|
+
export const SETUP_EPIC_VERIFICATION_HANDOFF_STEP_CLASSES = ["e2e_tests", "rendered_ui_review"];
|
|
131
|
+
/** Mirrors the server's `MAX_VERIFICATION_HANDOFF_EXCEPTIONS`. */
|
|
132
|
+
export const SETUP_EPIC_MAX_VERIFICATION_HANDOFF_EXCEPTIONS = 200;
|
|
133
|
+
/**
|
|
134
|
+
* Deduplicate and order exception pairs deterministically, by ticket key and
|
|
135
|
+
* then class, so the same set always composes the same bytes.
|
|
136
|
+
*/
|
|
137
|
+
export function normalizeVerificationHandoffExceptions(entries) {
|
|
138
|
+
const seen = new Map();
|
|
139
|
+
for (const entry of entries) {
|
|
140
|
+
seen.set(`${entry.ticket_key}\u0000${entry.step_class}`, {
|
|
141
|
+
ticket_key: entry.ticket_key,
|
|
142
|
+
step_class: entry.step_class,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return [...seen.values()].sort((a, b) => a.ticket_key === b.ticket_key
|
|
146
|
+
? a.step_class.localeCompare(b.step_class)
|
|
147
|
+
: a.ticket_key.localeCompare(b.ticket_key));
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Validate the raw flag values. Every refusal names the offending flag and
|
|
151
|
+
* repeats only CLI-supplied text, before any file read or request.
|
|
152
|
+
*/
|
|
153
|
+
export function parseVerificationHandoffFlags(args) {
|
|
154
|
+
if (args.choice === undefined && args.exceptions.length === 0)
|
|
155
|
+
return { ok: true, value: undefined };
|
|
156
|
+
if (args.choice === undefined) {
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
error: "--verification-handoff-exception requires --verification-handoff block. An exception " +
|
|
160
|
+
"only narrows a hand-off choice, so without one there is nothing to narrow.",
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
if (args.choice !== "proceed" && args.choice !== "block") {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
error: `--verification-handoff must be one of: proceed, block. Got '${args.choice}'.`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
if (args.attended || args.intoBase) {
|
|
170
|
+
const flag = args.attended ? "--attended" : "--into-base";
|
|
171
|
+
return {
|
|
172
|
+
ok: false,
|
|
173
|
+
error: `--verification-handoff cannot be combined with ${flag}: a verification hand-off ` +
|
|
174
|
+
"needs an unattended epic-branch run, and this launch has no such authority. " +
|
|
175
|
+
"Drop --verification-handoff; verification stays a blocker.",
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const parsed = [];
|
|
179
|
+
for (const raw of args.exceptions) {
|
|
180
|
+
const separator = raw.lastIndexOf(":");
|
|
181
|
+
const ticketKey = separator > 0 ? raw.slice(0, separator) : "";
|
|
182
|
+
const stepClass = separator > 0 ? raw.slice(separator + 1) : "";
|
|
183
|
+
if (!TICKET_KEY_PATTERN.test(ticketKey)) {
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
error: `--verification-handoff-exception '${raw}' must be <TICKET>:<class> with a ticket key such as PROJ-12.`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (!SETUP_EPIC_VERIFICATION_HANDOFF_STEP_CLASSES.includes(stepClass)) {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
error: `--verification-handoff-exception '${raw}' names an unsupported class. Use one of: ` +
|
|
193
|
+
`${SETUP_EPIC_VERIFICATION_HANDOFF_STEP_CLASSES.join(", ")}.`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
parsed.push({ ticket_key: ticketKey, step_class: stepClass });
|
|
197
|
+
}
|
|
198
|
+
if (args.choice === "proceed" && parsed.length > 0) {
|
|
199
|
+
return {
|
|
200
|
+
ok: false,
|
|
201
|
+
error: "--verification-handoff-exception cannot be combined with --verification-handoff proceed. " +
|
|
202
|
+
"Use --verification-handoff block to keep verification a blocker for named work.",
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
if (args.choice === "block" && parsed.length === 0) {
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
error: "--verification-handoff block needs at least one --verification-handoff-exception " +
|
|
209
|
+
"<TICKET>:<class> naming the work whose verification stays a blocker.",
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
const exceptions = normalizeVerificationHandoffExceptions(parsed);
|
|
213
|
+
if (exceptions.length > SETUP_EPIC_MAX_VERIFICATION_HANDOFF_EXCEPTIONS) {
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
error: `--verification-handoff-exception names ${exceptions.length} distinct entries; the run ` +
|
|
217
|
+
`policy holds at most ${SETUP_EPIC_MAX_VERIFICATION_HANDOFF_EXCEPTIONS}. Nothing was created.`,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
return { ok: true, value: { choice: args.choice, exceptions } };
|
|
221
|
+
}
|
|
222
|
+
/** The two policy keys a validated choice composes. */
|
|
223
|
+
export function verificationHandoffPolicyKeys(choice) {
|
|
224
|
+
return {
|
|
225
|
+
[SETUP_EPIC_VERIFICATION_HANDOFF_ENABLED_KEY]: true,
|
|
226
|
+
[SETUP_EPIC_VERIFICATION_HANDOFF_EXCEPTIONS_KEY]: choice.exceptions.map((entry) => ({ ...entry })),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Read a policy file's declared exception list as a normalized pair set, or
|
|
231
|
+
* `null` when it is not a well-shaped list. Never returns or echoes a value.
|
|
232
|
+
*/
|
|
233
|
+
function readDeclaredHandoffExceptions(value) {
|
|
234
|
+
if (!Array.isArray(value))
|
|
235
|
+
return null;
|
|
236
|
+
const entries = [];
|
|
237
|
+
for (const entry of value) {
|
|
238
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
239
|
+
return null;
|
|
240
|
+
const { ticket_key: ticketKey, step_class: stepClass } = entry;
|
|
241
|
+
if (typeof ticketKey !== "string" || typeof stepClass !== "string")
|
|
242
|
+
return null;
|
|
243
|
+
entries.push({ ticket_key: ticketKey, step_class: stepClass });
|
|
244
|
+
}
|
|
245
|
+
return normalizeVerificationHandoffExceptions(entries);
|
|
246
|
+
}
|
|
116
247
|
/**
|
|
117
248
|
* The one `policy_json.posture` value that means "unattended" (BAPI-1102).
|
|
118
249
|
*
|
|
@@ -225,7 +356,7 @@ export function getSetupEpicUsage() {
|
|
|
225
356
|
"default branch. `drive-epic` readiness lists all three with their current state.",
|
|
226
357
|
"",
|
|
227
358
|
"Required:",
|
|
228
|
-
" --epic-key <KEY>
|
|
359
|
+
" --epic-key <KEY> Epic key (e.g. PROJ-123)",
|
|
229
360
|
" --plan-file <path> Path to epic-plan.dag.json (from decompose-epic)",
|
|
230
361
|
"",
|
|
231
362
|
"Options:",
|
|
@@ -248,14 +379,13 @@ export function getSetupEpicUsage() {
|
|
|
248
379
|
" the default would leave on base.",
|
|
249
380
|
" --into-base OPT OUT of the epic-branch default: cut no branch and",
|
|
250
381
|
" target every child PR at the repository base branch",
|
|
251
|
-
" (the
|
|
382
|
+
" (children target the base branch). Use this if automation",
|
|
252
383
|
" depended on \"no flag = direct to base\". Contradictory",
|
|
253
384
|
" with --feature-branch; passing both is a parse error.",
|
|
254
385
|
" --attended OPT OUT of the unattended default: compose only",
|
|
255
386
|
" base_branch (plus --review-policy when supplied), with",
|
|
256
387
|
" no posture and no auto-merge authorization, and skip the",
|
|
257
|
-
" unattended repository prerequisites entirely.
|
|
258
|
-
" request is byte-for-byte the pre-BAPI-1102 one.",
|
|
388
|
+
" unattended repository prerequisites entirely.",
|
|
259
389
|
" Contradictory with a --policy-file that declares",
|
|
260
390
|
" posture: unattended; that pair is refused by name.",
|
|
261
391
|
" --review-policy <src> PER-RUN review policy source, one of:",
|
|
@@ -272,12 +402,22 @@ export function getSetupEpicUsage() {
|
|
|
272
402
|
" default is stamped into a new run only when the run",
|
|
273
403
|
" sets no review policy of its own, so a value supplied",
|
|
274
404
|
" here always wins.",
|
|
405
|
+
" --verification-handoff proceed|block",
|
|
406
|
+
" The owner's answer to the setup gap question,",
|
|
407
|
+
" for a NEW unattended epic-branch run. proceed composes",
|
|
408
|
+
" verification_handoff_enabled: true with no exceptions;",
|
|
409
|
+
" block composes it with the named exceptions. Refused with",
|
|
410
|
+
" --attended, --into-base, a launch with no epic branch, or",
|
|
411
|
+
" a live run (use patch-policy). Omitted, no key is sent.",
|
|
412
|
+
" --verification-handoff-exception <TICKET>:<class>",
|
|
413
|
+
` Repeatable; class is one of ${SETUP_EPIC_VERIFICATION_HANDOFF_STEP_CLASSES.join(", ")}.`,
|
|
414
|
+
" Required with block, refused with proceed. At most",
|
|
415
|
+
` ${SETUP_EPIC_MAX_VERIFICATION_HANDOFF_EXCEPTIONS} distinct entries.`,
|
|
275
416
|
" --json Emit a single JSON result object on stdout",
|
|
276
|
-
" --launch-profile <name>
|
|
277
|
-
" credential from
|
|
278
|
-
"
|
|
279
|
-
"
|
|
280
|
-
" --base-url <url> (BAPI-1260) Explicit target; must agree with the profile.",
|
|
417
|
+
" --launch-profile <name> Passed by drive-epic: resolve the target, repository, and",
|
|
418
|
+
" credential from a host-local launch profile (see",
|
|
419
|
+
" `drive-epic --help-advanced`).",
|
|
420
|
+
" --base-url <url> Explicit target; must agree with the profile.",
|
|
281
421
|
" -h, --help Show this help",
|
|
282
422
|
"",
|
|
283
423
|
"Policy controls:",
|
|
@@ -290,8 +430,7 @@ export function getSetupEpicUsage() {
|
|
|
290
430
|
" type's gate ceiling (implement / spec_review / resume",
|
|
291
431
|
" 10799; remediate / ci_fix 3599; rebase / smoke 2699;",
|
|
292
432
|
" merge 1799); the server rejects a higher value with a",
|
|
293
|
-
" 422 naming the offending field.
|
|
294
|
-
" docs/epic-implementer/operator-runbook.md §4.",
|
|
433
|
+
" 422 naming the offending field.",
|
|
295
434
|
" --replace-policy Authorize replacing a LIVE run's stored policy with the",
|
|
296
435
|
" policy file, as a complete replacement. Without it, a",
|
|
297
436
|
" divergent policy on a reused run is refused with a",
|
|
@@ -380,8 +519,26 @@ export function parseSetupEpicArgs(argv) {
|
|
|
380
519
|
let json = false;
|
|
381
520
|
let launchProfile;
|
|
382
521
|
let baseUrl;
|
|
522
|
+
// BAPI-1496 — raw hand-off values; validated together after the loop.
|
|
523
|
+
let verificationHandoffChoice;
|
|
524
|
+
const verificationHandoffExceptions = [];
|
|
383
525
|
for (let i = 0; i < argv.length; i++) {
|
|
384
526
|
const arg = argv[i];
|
|
527
|
+
// BAPI-1496 — inline forms of the hand-off flags.
|
|
528
|
+
if (arg.startsWith("--verification-handoff=")) {
|
|
529
|
+
verificationHandoffChoice = arg.slice("--verification-handoff=".length).trim();
|
|
530
|
+
if (verificationHandoffChoice === "") {
|
|
531
|
+
return { status: "error", message: "--verification-handoff requires a value." };
|
|
532
|
+
}
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (arg.startsWith("--verification-handoff-exception=")) {
|
|
536
|
+
const raw = arg.slice("--verification-handoff-exception=".length).trim();
|
|
537
|
+
if (raw === "")
|
|
538
|
+
return { status: "error", message: "--verification-handoff-exception requires a value." };
|
|
539
|
+
verificationHandoffExceptions.push(raw);
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
385
542
|
// `--policy-file=<path>` inline form (BAPI-754).
|
|
386
543
|
if (arg.startsWith("--policy-file=")) {
|
|
387
544
|
const raw = arg.slice("--policy-file=".length).trim();
|
|
@@ -494,6 +651,18 @@ export function parseSetupEpicArgs(argv) {
|
|
|
494
651
|
i++;
|
|
495
652
|
break;
|
|
496
653
|
}
|
|
654
|
+
case "--verification-handoff":
|
|
655
|
+
case "--verification-handoff-exception": {
|
|
656
|
+
const v = takeValue(argv, i, arg);
|
|
657
|
+
if (v === null || v.trim() === "")
|
|
658
|
+
return { status: "error", message: `${arg} requires a value.` };
|
|
659
|
+
if (arg === "--verification-handoff")
|
|
660
|
+
verificationHandoffChoice = v.trim();
|
|
661
|
+
else
|
|
662
|
+
verificationHandoffExceptions.push(v.trim());
|
|
663
|
+
i++;
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
497
666
|
case "--into-base":
|
|
498
667
|
intoBase = true;
|
|
499
668
|
break;
|
|
@@ -551,6 +720,16 @@ export function parseSetupEpicArgs(argv) {
|
|
|
551
720
|
"may be selected — remove whichever one you did not mean.",
|
|
552
721
|
};
|
|
553
722
|
}
|
|
723
|
+
// BAPI-1496 — the hand-off choice, its pairing rules, and its topology rule,
|
|
724
|
+
// refused here before any file read, credential resolution, or request.
|
|
725
|
+
const handoff = parseVerificationHandoffFlags({
|
|
726
|
+
choice: verificationHandoffChoice,
|
|
727
|
+
exceptions: verificationHandoffExceptions,
|
|
728
|
+
attended,
|
|
729
|
+
intoBase,
|
|
730
|
+
});
|
|
731
|
+
if (!handoff.ok)
|
|
732
|
+
return { status: "error", message: handoff.error };
|
|
554
733
|
if (replacePolicy && policyFile === undefined) {
|
|
555
734
|
return {
|
|
556
735
|
status: "error",
|
|
@@ -578,6 +757,8 @@ export function parseSetupEpicArgs(argv) {
|
|
|
578
757
|
// BAPI-1260 — only when supplied, so a legacy options object is unchanged.
|
|
579
758
|
...(launchProfile ? { launchProfile } : {}),
|
|
580
759
|
...(baseUrl ? { baseUrl } : {}),
|
|
760
|
+
// BAPI-1496 — only when supplied, so a legacy options object is unchanged.
|
|
761
|
+
...(handoff.value !== undefined ? { verificationHandoff: handoff.value } : {}),
|
|
581
762
|
},
|
|
582
763
|
};
|
|
583
764
|
}
|
|
@@ -715,6 +896,39 @@ export function composeSetupEpicPolicy(filePolicy, selections) {
|
|
|
715
896
|
: { source: selections.reviewPolicy };
|
|
716
897
|
}
|
|
717
898
|
}
|
|
899
|
+
// BAPI-1496 — the hand-off keys under the same "no silent winner" rule. No
|
|
900
|
+
// choice leaves the file's values exactly as written; a choice fills only an
|
|
901
|
+
// absent key and refuses any declared disagreement. Exception lists compare as
|
|
902
|
+
// (ticket_key, step_class) sets. Neither value is echoed.
|
|
903
|
+
if (selections.verificationHandoff !== undefined) {
|
|
904
|
+
const wanted = verificationHandoffPolicyKeys(selections.verificationHandoff);
|
|
905
|
+
const enabledKey = SETUP_EPIC_VERIFICATION_HANDOFF_ENABLED_KEY;
|
|
906
|
+
const exceptionsKey = SETUP_EPIC_VERIFICATION_HANDOFF_EXCEPTIONS_KEY;
|
|
907
|
+
const flag = `--verification-handoff ${selections.verificationHandoff.choice}`;
|
|
908
|
+
if (enabledKey in composed && composed[enabledKey] !== wanted[enabledKey]) {
|
|
909
|
+
return {
|
|
910
|
+
ok: false,
|
|
911
|
+
error: `${flag} turns the verification hand-off on, but --policy-file declares a different ` +
|
|
912
|
+
`${enabledKey}. setup-epic will not pick a winner between them: drop the flag, or fix ` +
|
|
913
|
+
`the policy file so both agree.`,
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
if (exceptionsKey in composed) {
|
|
917
|
+
const declared = readDeclaredHandoffExceptions(composed[exceptionsKey]);
|
|
918
|
+
if (declared === null || JSON.stringify(declared) !== JSON.stringify(wanted[exceptionsKey])) {
|
|
919
|
+
return {
|
|
920
|
+
ok: false,
|
|
921
|
+
error: `${flag} selects a set of verification exceptions, but --policy-file declares a ` +
|
|
922
|
+
`different ${exceptionsKey}. setup-epic will not pick a winner between them: drop ` +
|
|
923
|
+
`the flag, or fix the policy file so both agree.`,
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
for (const key of [enabledKey, exceptionsKey]) {
|
|
928
|
+
if (!(key in composed))
|
|
929
|
+
composed[key] = wanted[key];
|
|
930
|
+
}
|
|
931
|
+
}
|
|
718
932
|
return { ok: true, policy: composed };
|
|
719
933
|
}
|
|
720
934
|
/**
|
|
@@ -773,7 +987,27 @@ export function preflightSetupEpicPolicy(args) {
|
|
|
773
987
|
`declaration from the policy file.`,
|
|
774
988
|
};
|
|
775
989
|
}
|
|
776
|
-
// 3.
|
|
990
|
+
// 3. BAPI-1496 — a hand-off choice needs an unattended epic-branch launch.
|
|
991
|
+
// `--attended` and `--into-base` are already parse errors; this covers the
|
|
992
|
+
// topology resolved from the plan and the file: no epic branch at all, or a
|
|
993
|
+
// file whose declared posture is not the unattended one.
|
|
994
|
+
if (args.verificationHandoff !== undefined) {
|
|
995
|
+
const declaresOtherPosture = args.filePolicy !== undefined &&
|
|
996
|
+
"posture" in args.filePolicy &&
|
|
997
|
+
args.filePolicy.posture !== SETUP_EPIC_UNATTENDED_POSTURE;
|
|
998
|
+
if ((args.featureBranch === undefined && !args.policyDeclaresBranch) || declaresOtherPosture) {
|
|
999
|
+
return {
|
|
1000
|
+
ok: false,
|
|
1001
|
+
reason: "verification_handoff_without_authority",
|
|
1002
|
+
message: "--verification-handoff needs an unattended run on an epic branch, and this launch " +
|
|
1003
|
+
(declaresOtherPosture
|
|
1004
|
+
? `is not unattended: --policy-file '${args.policyFile}' declares another posture. `
|
|
1005
|
+
: "selects no epic branch. ") +
|
|
1006
|
+
"Drop --verification-handoff; verification stays a blocker.",
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
// 4. Policy-source contradictions between the file and the CLI selections.
|
|
777
1011
|
// Delegated to the composer, which owns the "no silent winner" rule; the
|
|
778
1012
|
// gate only gives the outcome a stable name.
|
|
779
1013
|
if (args.filePolicy === undefined)
|
|
@@ -781,6 +1015,7 @@ export function preflightSetupEpicPolicy(args) {
|
|
|
781
1015
|
const composed = composeSetupEpicPolicy(args.filePolicy, {
|
|
782
1016
|
featureBranch: args.featureBranch,
|
|
783
1017
|
reviewPolicy: args.reviewPolicy,
|
|
1018
|
+
...(args.verificationHandoff !== undefined ? { verificationHandoff: args.verificationHandoff } : {}),
|
|
784
1019
|
});
|
|
785
1020
|
if (!composed.ok) {
|
|
786
1021
|
return { ok: false, reason: "policy_source_contradiction", message: composed.error };
|
|
@@ -1144,7 +1379,48 @@ function findCycle(keys, adjacency) {
|
|
|
1144
1379
|
}
|
|
1145
1380
|
return null;
|
|
1146
1381
|
}
|
|
1147
|
-
|
|
1382
|
+
/**
|
|
1383
|
+
* Count the distinct ordering relationships a validated plan declares, through
|
|
1384
|
+
* either supported representation: top-level `edges` and per-node `depends_on`.
|
|
1385
|
+
* One pair is counted once however many places declare it. Empty IDs and
|
|
1386
|
+
* self-loops are ignored; node existence is validated elsewhere, not here.
|
|
1387
|
+
*/
|
|
1388
|
+
function countDeclaredOrderingPairs(plan) {
|
|
1389
|
+
const pairs = new Set();
|
|
1390
|
+
const add = (from, to) => {
|
|
1391
|
+
if (typeof from !== "string" || typeof to !== "string")
|
|
1392
|
+
return;
|
|
1393
|
+
if (from === "" || to === "" || from === to)
|
|
1394
|
+
return;
|
|
1395
|
+
pairs.add(`${from}->${to}`);
|
|
1396
|
+
};
|
|
1397
|
+
for (const edge of Array.isArray(plan.edges) ? plan.edges : [])
|
|
1398
|
+
add(edge?.from, edge?.to);
|
|
1399
|
+
for (const node of Array.isArray(plan.nodes) ? plan.nodes : []) {
|
|
1400
|
+
for (const dependency of Array.isArray(node?.depends_on) ? node.depends_on : []) {
|
|
1401
|
+
add(dependency, node.ticket_key);
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
return pairs.size;
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* The channel a STORED policy resolves to, from the engine's own
|
|
1408
|
+
* `notification_sink` facts. The stored policy is server-redacted, so its
|
|
1409
|
+
* `notify` block cannot answer this. Strict `=== true` comparisons: a truthy
|
|
1410
|
+
* non-boolean is unavailable, never a guess.
|
|
1411
|
+
*/
|
|
1412
|
+
function storedEscalationChannel(notificationSink) {
|
|
1413
|
+
if (!notificationSink || typeof notificationSink !== "object" || Array.isArray(notificationSink)) {
|
|
1414
|
+
return "unavailable";
|
|
1415
|
+
}
|
|
1416
|
+
const sink = notificationSink;
|
|
1417
|
+
if (sink.webhook_configured === true)
|
|
1418
|
+
return "configured webhook";
|
|
1419
|
+
if (sink.email_channel_selected === true)
|
|
1420
|
+
return "account email";
|
|
1421
|
+
return "unavailable";
|
|
1422
|
+
}
|
|
1423
|
+
export function effectivePolicyFacts(policy, source, notificationSink) {
|
|
1148
1424
|
const object = policy && typeof policy === "object" && !Array.isArray(policy)
|
|
1149
1425
|
? policy : {};
|
|
1150
1426
|
const numbers = (value) => {
|
|
@@ -1159,7 +1435,9 @@ export function effectivePolicyFacts(policy, source) {
|
|
|
1159
1435
|
posture: object.posture === "unattended" ? "unattended" : "attended",
|
|
1160
1436
|
caps: numbers(object.caps),
|
|
1161
1437
|
job_timeouts: numbers(object.job_timeouts),
|
|
1162
|
-
escalation_channel: formatEpicLaunchChannelIdentity(source === "stored"
|
|
1438
|
+
escalation_channel: formatEpicLaunchChannelIdentity(source === "stored"
|
|
1439
|
+
? storedEscalationChannel(notificationSink)
|
|
1440
|
+
: policyDeclaresNotifyWebhook(object) ? "configured webhook" : "account email"),
|
|
1163
1441
|
};
|
|
1164
1442
|
}
|
|
1165
1443
|
/** Propose a conductor epic feature branch name for an epic key (BAPI-655). */
|
|
@@ -1351,7 +1629,7 @@ export function evaluateSetupEpicUnattendedPrerequisites(readiness, requestedPol
|
|
|
1351
1629
|
message: "unattended runs are off for this repository. Turn on 'Allow unattended " +
|
|
1352
1630
|
"Epic Implementer runs' on the repository's Setup page in the Bridge admin " +
|
|
1353
1631
|
"panel, or pass --attended to create an attended run. If it is already on, " +
|
|
1354
|
-
"
|
|
1632
|
+
"email info@bridgegpt.ai. No run was created.",
|
|
1355
1633
|
};
|
|
1356
1634
|
}
|
|
1357
1635
|
// Two independent sources satisfy the escalation channel, and the check has to
|
|
@@ -1411,7 +1689,7 @@ export function evaluateSetupEpicBringUpPrerequisites(readiness) {
|
|
|
1411
1689
|
if (readiness === null) {
|
|
1412
1690
|
return {
|
|
1413
1691
|
reason: "readiness_unreadable",
|
|
1414
|
-
message: "the
|
|
1692
|
+
message: "the Epic Implementer readiness report could not be read, so the database " +
|
|
1415
1693
|
"migration, worker presence, and single-reconciler facts could not be confirmed. " +
|
|
1416
1694
|
`Retry once the server is reachable. ${NO_RUN_WAS_CREATED}`,
|
|
1417
1695
|
};
|
|
@@ -1462,8 +1740,8 @@ export function evaluateSetupEpicMergeExecutionPrerequisites(readiness, requeste
|
|
|
1462
1740
|
if (readiness === null) {
|
|
1463
1741
|
return {
|
|
1464
1742
|
reason: MERGE_EXECUTION_INELIGIBLE,
|
|
1465
|
-
message: "this run declares merge_execution_mode: github_actions, and the
|
|
1466
|
-
"
|
|
1743
|
+
message: "this run declares merge_execution_mode: github_actions, and the Epic " +
|
|
1744
|
+
"Implementer readiness report could not be read, so the repository's merge " +
|
|
1467
1745
|
"capability could not be confirmed. Retry once the server is reachable. If " +
|
|
1468
1746
|
"readiness then reports the merge workflow missing or out of date, resolve it " +
|
|
1469
1747
|
`with \`${MERGE_WORKFLOW_REMEDY_COMMAND}\`. ${MERGE_WORKFLOW_CODEOWNER_INSTRUCTION} ` +
|
|
@@ -1555,6 +1833,9 @@ export function composeDefaultSetupEpicPolicy(args) {
|
|
|
1555
1833
|
policy[SETUP_EPIC_INTEGRATION_PREPARATION_POLICY_KEY] = {
|
|
1556
1834
|
...SETUP_EPIC_INTEGRATION_PREPARATION_DEFAULT,
|
|
1557
1835
|
};
|
|
1836
|
+
if (args.verificationHandoff !== undefined) {
|
|
1837
|
+
Object.assign(policy, verificationHandoffPolicyKeys(args.verificationHandoff));
|
|
1838
|
+
}
|
|
1558
1839
|
}
|
|
1559
1840
|
}
|
|
1560
1841
|
if (args.reviewPolicy !== undefined) {
|
|
@@ -1698,13 +1979,13 @@ async function applyReusedRunPolicy(args) {
|
|
|
1698
1979
|
});
|
|
1699
1980
|
if (outcome.kind === "noop") {
|
|
1700
1981
|
args.result.policy_applied = "noop";
|
|
1701
|
-
args.result.effective_policy = effectivePolicyFacts(args.storedPolicy, "stored");
|
|
1982
|
+
args.result.effective_policy = effectivePolicyFacts(args.storedPolicy, "stored", args.notificationSink);
|
|
1702
1983
|
args.say("Policy: policy unchanged");
|
|
1703
1984
|
return true;
|
|
1704
1985
|
}
|
|
1705
1986
|
if (outcome.kind === "refused_divergent") {
|
|
1706
1987
|
args.result.policy_applied = "refused_divergent";
|
|
1707
|
-
args.result.effective_policy = effectivePolicyFacts(args.storedPolicy, "stored");
|
|
1988
|
+
args.result.effective_policy = effectivePolicyFacts(args.storedPolicy, "stored", args.notificationSink);
|
|
1708
1989
|
args.deps.errorLog(`Epic run ${args.epicRunId} is live and its stored policy differs from the ` +
|
|
1709
1990
|
`policy file. setup-epic will not silently change a running epic's policy.\n` +
|
|
1710
1991
|
`${outcome.diffLines.join("\n")}\n` +
|
|
@@ -1717,7 +1998,7 @@ async function applyReusedRunPolicy(args) {
|
|
|
1717
1998
|
}
|
|
1718
1999
|
catch (err) {
|
|
1719
2000
|
args.result.policy_applied = "refused_divergent";
|
|
1720
|
-
args.result.effective_policy = effectivePolicyFacts(args.storedPolicy, "stored");
|
|
2001
|
+
args.result.effective_policy = effectivePolicyFacts(args.storedPolicy, "stored", args.notificationSink);
|
|
1721
2002
|
args.deps.errorLog(`Failed to replace the run policy: ${errorDetail(err)}`);
|
|
1722
2003
|
return false;
|
|
1723
2004
|
}
|
|
@@ -1981,6 +2262,9 @@ export async function runSetupEpicWorkflow(argv, overrides = {}, handoff) {
|
|
|
1981
2262
|
exitCode,
|
|
1982
2263
|
...(sink.epicRunId !== undefined ? { epicRunId: sink.epicRunId } : {}),
|
|
1983
2264
|
...(sink.epicRunId !== undefined ? { runExists: true } : {}),
|
|
2265
|
+
...(sink.runCreated !== undefined ? { runCreated: sink.runCreated } : {}),
|
|
2266
|
+
...(sink.planStored !== undefined ? { planStored: sink.planStored } : {}),
|
|
2267
|
+
...(sink.reusedExistingRun !== undefined ? { reusedExistingRun: sink.reusedExistingRun } : {}),
|
|
1984
2268
|
};
|
|
1985
2269
|
}
|
|
1986
2270
|
/**
|
|
@@ -2081,12 +2365,16 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
|
|
|
2081
2365
|
}
|
|
2082
2366
|
}
|
|
2083
2367
|
let existingRunId = null;
|
|
2368
|
+
let existingTicketReview;
|
|
2084
2369
|
let existingStatus = null;
|
|
2085
2370
|
let existingBaseBranch = null;
|
|
2086
2371
|
let existingReviewPolicy = null;
|
|
2087
2372
|
// BAPI-754: the live run's SERVER-REDACTED stored policy, kept for the reuse
|
|
2088
2373
|
// comparison. Redacted is the only form a client can ever see.
|
|
2089
2374
|
let existingPolicyJson = null;
|
|
2375
|
+
// The same response's engine-reported notification facts (never the redacted
|
|
2376
|
+
// policy's `notify`), for the stored-policy channel label.
|
|
2377
|
+
let existingNotificationSink = null;
|
|
2090
2378
|
if (!opts.localOnly) {
|
|
2091
2379
|
// --- Resolve access ------------------------------------------------------
|
|
2092
2380
|
// BAPI-1260: a caller that selected the epic-launch contract (drive-epic
|
|
@@ -2116,7 +2404,7 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
|
|
|
2116
2404
|
access = accessResult.access;
|
|
2117
2405
|
say(`Epic: ${opts.epicKey}`);
|
|
2118
2406
|
say(`Repo: ${access.repoName}`);
|
|
2119
|
-
say(`Plan: v${plan.plan_version}, ${plan.nodes.length} node(s), ${plan
|
|
2407
|
+
say(`Plan: v${plan.plan_version}, ${plan.nodes.length} node(s), ${countDeclaredOrderingPairs(plan)} edge(s)`);
|
|
2120
2408
|
say(`Local hash: ${localHash}`);
|
|
2121
2409
|
for (const w of warnings)
|
|
2122
2410
|
say(` [warn] ${w}`);
|
|
@@ -2128,9 +2416,11 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
|
|
|
2128
2416
|
try {
|
|
2129
2417
|
const state = await fetchEpicRunState(access, opts.epicKey, deps.fetch);
|
|
2130
2418
|
existingRunId = state.epic_run?.epic_run_id ?? null;
|
|
2419
|
+
existingTicketReview = state.epic_run?.ticket_review;
|
|
2131
2420
|
existingStatus = state.epic_run?.status ?? null;
|
|
2132
2421
|
const existingPolicy = state.epic_run?.policy_json;
|
|
2133
2422
|
existingPolicyJson = existingPolicy ?? null;
|
|
2423
|
+
existingNotificationSink = state.notification_sink ?? null;
|
|
2134
2424
|
const existingBase = existingPolicy && typeof existingPolicy === "object"
|
|
2135
2425
|
? existingPolicy.base_branch
|
|
2136
2426
|
: undefined;
|
|
@@ -2170,6 +2460,22 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
|
|
|
2170
2460
|
}
|
|
2171
2461
|
}
|
|
2172
2462
|
}
|
|
2463
|
+
// BAPI-1510 — attach-only reuse. A caller that trimmed the plan must never apply
|
|
2464
|
+
// it to a run that already exists, so a pre-check that found a live run returns
|
|
2465
|
+
// here, before the policy file, any cut, plan store, approval, or other mutation.
|
|
2466
|
+
// A dry run keeps its ordinary reporting. Direct setup-epic never sets the option.
|
|
2467
|
+
//
|
|
2468
|
+
// Only an ACTIVATED run is attach-only. A run still in `planning` or
|
|
2469
|
+
// `pending_approval` was created by an earlier setup that failed a later step
|
|
2470
|
+
// (cut, plan store, approval); re-running setup is how it gets finished, so it
|
|
2471
|
+
// takes the ordinary reuse path with the trimmed plan, guarded by the pinned cut.
|
|
2472
|
+
const reusedRunIsActivated = existingStatus !== null && existingStatus !== "planning" && existingStatus !== "pending_approval";
|
|
2473
|
+
if (existingRunId && reusedRunIsActivated && handoff?.attachOnlyOnReuse === true && !opts.dryRun) {
|
|
2474
|
+
runIdSink.epicRunId = existingRunId;
|
|
2475
|
+
runIdSink.reusedExistingRun = true;
|
|
2476
|
+
say(`Run: reusing ${existingRunId} (status: ${existingStatus}); existing runs are unchanged`);
|
|
2477
|
+
return 0;
|
|
2478
|
+
}
|
|
2173
2479
|
// --- Read the policy file (still no network) ------------------------------
|
|
2174
2480
|
// BAPI-754: parsed here so a malformed file, or a contradiction between the
|
|
2175
2481
|
// file and the CLI flags, fails before credentials are resolved or any request
|
|
@@ -2240,6 +2546,7 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
|
|
|
2240
2546
|
policyDeclaresBranch,
|
|
2241
2547
|
featureBranch,
|
|
2242
2548
|
reviewPolicy: opts.reviewPolicy,
|
|
2549
|
+
...(opts.verificationHandoff !== undefined ? { verificationHandoff: opts.verificationHandoff } : {}),
|
|
2243
2550
|
});
|
|
2244
2551
|
if (!policyGate.ok) {
|
|
2245
2552
|
deps.errorLog(policyGate.message);
|
|
@@ -2252,7 +2559,7 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
|
|
|
2252
2559
|
// The policy gate above already composed and validated; nothing is repeated
|
|
2253
2560
|
// here, so the offline path and the real path refuse the same set.
|
|
2254
2561
|
say(`Epic: ${opts.epicKey}`);
|
|
2255
|
-
say(`Plan: v${plan.plan_version}, ${plan.nodes.length} node(s), ${plan
|
|
2562
|
+
say(`Plan: v${plan.plan_version}, ${plan.nodes.length} node(s), ${countDeclaredOrderingPairs(plan)} edge(s)`);
|
|
2256
2563
|
// BAPI-1009: the same node-count-derived strategy an ordinary run would take,
|
|
2257
2564
|
// stated without cutting a branch, reading the network, or touching git.
|
|
2258
2565
|
say(`Branch: ${branchStrategyLine}`);
|
|
@@ -2336,8 +2643,21 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
|
|
|
2336
2643
|
attended: opts.attended,
|
|
2337
2644
|
reviewPolicy: opts.reviewPolicy,
|
|
2338
2645
|
mergeExecutionMode: declaredMergeExecutionMode,
|
|
2646
|
+
...(opts.verificationHandoff !== undefined ? { verificationHandoff: opts.verificationHandoff } : {}),
|
|
2339
2647
|
});
|
|
2340
|
-
|
|
2648
|
+
// Reuse the actual composition result; drive-epic never interprets policy files.
|
|
2649
|
+
let launchReadiness = null;
|
|
2650
|
+
if (!opts.localOnly) {
|
|
2651
|
+
try {
|
|
2652
|
+
launchReadiness = await previewLaunchReadiness(access, existingRunId !== null ? existingPolicyJson ?? {} : composedForCreate, deps.fetch);
|
|
2653
|
+
}
|
|
2654
|
+
catch {
|
|
2655
|
+
// An unavailable preview is not a proven launch deficiency. Create is authoritative.
|
|
2656
|
+
launchReadiness = null;
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
const selectedTicketReview = ticketReviewSummary(existingTicketReview, existingRunId !== null ? existingPolicyJson ?? {} : composedForCreate);
|
|
2660
|
+
const selectedEffectivePolicyFacts = effectivePolicyFacts(existingRunId !== null ? existingPolicyJson : composedForCreate, selectedPolicySource, existingRunId !== null ? existingNotificationSink : undefined);
|
|
2341
2661
|
say(`Policy: effective ${selectedEffectivePolicyFacts.source}; serial execution; ` +
|
|
2342
2662
|
`caps ${selectedEffectivePolicyFacts.caps ? JSON.stringify(selectedEffectivePolicyFacts.caps) : "unavailable"}; ` +
|
|
2343
2663
|
`timeouts ${selectedEffectivePolicyFacts.job_timeouts ? JSON.stringify(selectedEffectivePolicyFacts.job_timeouts) : "unavailable"}; ` +
|
|
@@ -2393,6 +2713,15 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
|
|
|
2393
2713
|
// dropping `--feature-branch` reuses the run only when the flag is what asked
|
|
2394
2714
|
// for the branch; when the DEFAULT asked, `--into-base` is the way to say
|
|
2395
2715
|
// "keep this run on base".
|
|
2716
|
+
// BAPI-1496 — the hand-off flags compose a NEW run's policy only. A live run's
|
|
2717
|
+
// policy changes through `patch-policy`; reusing it here would either drop the
|
|
2718
|
+
// owner's answer silently or patch a live run behind the operator's back.
|
|
2719
|
+
if (existingRunId && opts.verificationHandoff !== undefined) {
|
|
2720
|
+
deps.errorLog(`Epic ${opts.epicKey} already has a live run (${existingRunId}). --verification-handoff ` +
|
|
2721
|
+
"sets the policy of a NEW run only. Change a live run's verification hand-off with " +
|
|
2722
|
+
"`epic-implementer patch-policy`, or re-run without --verification-handoff to reuse it.");
|
|
2723
|
+
return 1;
|
|
2724
|
+
}
|
|
2396
2725
|
if (existingRunId && featureBranch !== undefined && existingBaseBranch !== featureBranch) {
|
|
2397
2726
|
const reuseAdvice = opts.featureBranch !== undefined
|
|
2398
2727
|
? "Re-run without --feature-branch to reuse it unchanged"
|
|
@@ -2449,7 +2778,7 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
|
|
|
2449
2778
|
return 1;
|
|
2450
2779
|
}
|
|
2451
2780
|
if (opts.reviewPolicy !== undefined) {
|
|
2452
|
-
say(`Review: ${opts.reviewPolicy} (per-run; repository
|
|
2781
|
+
say(`Review: ${opts.reviewPolicy} (per-run only; no repository default is stored)`);
|
|
2453
2782
|
}
|
|
2454
2783
|
// --- Authoritative server validation (BAPI-754) --------------------------
|
|
2455
2784
|
// Runs BEFORE any create. A plan the server would refuse must not cost a run
|
|
@@ -2607,7 +2936,9 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
|
|
|
2607
2936
|
say(" serialization disabled — no ordering was derived from file overlap");
|
|
2608
2937
|
}
|
|
2609
2938
|
else if (validated.insertedEdges === 0) {
|
|
2610
|
-
say(
|
|
2939
|
+
say(validated.overlappingPairsFound > 0
|
|
2940
|
+
? ` serialization enabled — ${validated.overlappingPairsFound} overlapping pair(s) already ordered by declared dependencies`
|
|
2941
|
+
: " serialization enabled — no declared file overlaps produced edges");
|
|
2611
2942
|
}
|
|
2612
2943
|
else {
|
|
2613
2944
|
say(` serialization enabled — ${validated.insertedEdges} edge(s) inserted from ` +
|
|
@@ -2658,6 +2989,23 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
|
|
|
2658
2989
|
cutCommitSha = truth.cutCommitSha;
|
|
2659
2990
|
say(`Cut: ${cutCommitSha} (canonical indexed commit; origin/${effectiveFeatureBranch} will be cut here)`);
|
|
2660
2991
|
}
|
|
2992
|
+
// BAPI-1510 — pinned cut. A trimmed launch classified children against ONE
|
|
2993
|
+
// indexed commit; refuse unless setup's own canonical read names that same commit.
|
|
2994
|
+
// This runs even when trimming left a base-branch or single-node launch that
|
|
2995
|
+
// would not otherwise read canonical parse truth, and before any create-side
|
|
2996
|
+
// effect (including on a dry run).
|
|
2997
|
+
if (handoff?.expectedCutCommitSha !== undefined) {
|
|
2998
|
+
let observedCut = cutCommitSha;
|
|
2999
|
+
if (observedCut === null) {
|
|
3000
|
+
const pinTruth = await readCanonicalParseTruth(access, deps.fetch);
|
|
3001
|
+
observedCut = pinTruth.ok ? pinTruth.cutCommitSha : null;
|
|
3002
|
+
}
|
|
3003
|
+
if (observedCut === null || observedCut !== handoff.expectedCutCommitSha) {
|
|
3004
|
+
deps.errorLog("The indexed commit could not be confirmed or changed since the already-merged check. " +
|
|
3005
|
+
"Re-run drive-epic. No run was created and no branch was changed.");
|
|
3006
|
+
return 1;
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
2661
3009
|
if (opts.dryRun) {
|
|
2662
3010
|
say("");
|
|
2663
3011
|
say(validated
|
|
@@ -2675,7 +3023,7 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
|
|
|
2675
3023
|
: ` - feature branch: ${effectiveFeatureBranch} (validated server-side at approval; no request made in dry-run)`);
|
|
2676
3024
|
if (needsCut && cutCommitSha !== null && scopeBaseBranch !== null) {
|
|
2677
3025
|
say(` - would open a draft pull request ${effectiveFeatureBranch} → ${scopeBaseBranch} ` +
|
|
2678
|
-
"for epic-implementer-ci / gate (
|
|
3026
|
+
"for epic-implementer-ci / gate (nothing opened in dry-run)");
|
|
2679
3027
|
}
|
|
2680
3028
|
}
|
|
2681
3029
|
if (opts.reviewPolicy !== undefined) {
|
|
@@ -2698,6 +3046,11 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
|
|
|
2698
3046
|
// BAPI-850: the exact commit the cut WOULD use, reported without
|
|
2699
3047
|
// cutting. Present only when the dry run validated canonical parse truth.
|
|
2700
3048
|
...(cutCommitSha !== null ? { cut_commit_sha: cutCommitSha } : {}),
|
|
3049
|
+
// BAPI-1506: the repository base branch the epic branch is cut from
|
|
3050
|
+
// (the configured base, else "main"). Only present for a
|
|
3051
|
+
// feature-branch run; the launch preview compares the epic branch
|
|
3052
|
+
// against it, information only.
|
|
3053
|
+
...(scopeBaseBranch !== null ? { scope_base_branch: scopeBaseBranch } : {}),
|
|
2701
3054
|
// Only present when a policy was explicitly selected — the
|
|
2702
3055
|
// no-policy JSON shape is unchanged.
|
|
2703
3056
|
...(opts.reviewPolicy !== undefined
|
|
@@ -2708,6 +3061,8 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
|
|
|
2708
3061
|
server_validated: validated !== null,
|
|
2709
3062
|
policy_applied: "absent",
|
|
2710
3063
|
effective_policy: selectedEffectivePolicyFacts,
|
|
3064
|
+
launch_readiness: launchReadiness,
|
|
3065
|
+
ticket_review: selectedTicketReview,
|
|
2711
3066
|
...(validated
|
|
2712
3067
|
? {
|
|
2713
3068
|
server_plan_hash: validated.planHash,
|
|
@@ -2791,6 +3146,7 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
|
|
|
2791
3146
|
// BAPI-1102 — the reuse path's authoritative id, taken from the pre-check's
|
|
2792
3147
|
// server response rather than from anything local.
|
|
2793
3148
|
runIdSink.epicRunId = existingRunId;
|
|
3149
|
+
runIdSink.reusedExistingRun = true;
|
|
2794
3150
|
say(`Run: reusing ${existingRunId} (status: ${existingStatus})`);
|
|
2795
3151
|
// A pre-check that already found a live run means create would answer 200
|
|
2796
3152
|
// anyway; reconcile the file against that run's stored policy directly.
|
|
@@ -2802,6 +3158,7 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
|
|
|
2802
3158
|
result,
|
|
2803
3159
|
epicRunId: existingRunId,
|
|
2804
3160
|
storedPolicy: existingPolicyJson,
|
|
3161
|
+
notificationSink: existingNotificationSink,
|
|
2805
3162
|
requestedPolicy,
|
|
2806
3163
|
replacePolicy: opts.replacePolicy,
|
|
2807
3164
|
});
|
|
@@ -2837,7 +3194,13 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
|
|
|
2837
3194
|
try {
|
|
2838
3195
|
readiness = await fetchConductorReadiness(access, deps.fetch);
|
|
2839
3196
|
}
|
|
2840
|
-
catch {
|
|
3197
|
+
catch (err) {
|
|
3198
|
+
// BAPI-1455 — the one readable refusal: a proven-incompatible local
|
|
3199
|
+
// schema. Its explanation is server-authored from a maintained list.
|
|
3200
|
+
if (isSchemaIncompatibleError(err)) {
|
|
3201
|
+
deps.errorLog(formatSchemaIncompatibleRefusal("setup-epic", err).join("\n"));
|
|
3202
|
+
return 1;
|
|
3203
|
+
}
|
|
2841
3204
|
// Fail CLOSED, and silently as to WHY: the thrown value can carry a URL
|
|
2842
3205
|
// or a response excerpt, and this refusal is rendered to a terminal. The
|
|
2843
3206
|
// generic `readiness_unreadable` message says what to do about it.
|
|
@@ -2908,6 +3271,7 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
|
|
|
2908
3271
|
// returns the server's run for a fresh creation AND for a create-race
|
|
2909
3272
|
// adoption (`created: false`), so both are captured by this one assignment.
|
|
2910
3273
|
runIdSink.epicRunId = run.epic_run_id;
|
|
3274
|
+
runIdSink.runCreated = created;
|
|
2911
3275
|
result.status = run.status;
|
|
2912
3276
|
result.run_created = created;
|
|
2913
3277
|
if (created) {
|
|
@@ -2927,10 +3291,15 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
|
|
|
2927
3291
|
// index is the race backstop that keeps it so. Naming the adopted run and
|
|
2928
3292
|
// its status, rather than reporting a bare reuse, is what lets an operator
|
|
2929
3293
|
// decide between continuing on it and closing it.
|
|
3294
|
+
runIdSink.reusedExistingRun = true;
|
|
2930
3295
|
say(`Run: adopting existing active run ${run.epic_run_id} (status: ${run.status})`);
|
|
2931
3296
|
say(` No second run was created — one epic has at most one active ` +
|
|
2932
3297
|
`run. Continue on this one, or abandon it first with ` +
|
|
2933
3298
|
`\`epic-implementer abandon-run --epic-run-id ${run.epic_run_id}\` to start over.`);
|
|
3299
|
+
// BAPI-1510 — attach-only: a race-adopted run is never touched by a
|
|
3300
|
+
// trimmed launch (no policy replacement, cut, plan store, or approval).
|
|
3301
|
+
if (handoff?.attachOnlyOnReuse === true)
|
|
3302
|
+
return 0;
|
|
2934
3303
|
if (requestedPolicy !== undefined) {
|
|
2935
3304
|
const outcome = await applyReusedRunPolicy({
|
|
2936
3305
|
access,
|
|
@@ -3108,6 +3477,7 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
|
|
|
3108
3477
|
...(trackedPathEvidence !== undefined ? { trackedPaths: trackedPathEvidence } : {}),
|
|
3109
3478
|
}, deps.fetch);
|
|
3110
3479
|
result.plan_stored = true;
|
|
3480
|
+
runIdSink.planStored = true;
|
|
3111
3481
|
const serverHash = stored?.plan_hash;
|
|
3112
3482
|
if (typeof serverHash === "string")
|
|
3113
3483
|
result.plan_hash = serverHash;
|
|
@@ -3161,7 +3531,7 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
|
|
|
3161
3531
|
`Turn 'Allow unattended Epic Implementer runs' back on in the repository's ` +
|
|
3162
3532
|
`Setup page in the Bridge admin panel and re-run setup-epic, or ` +
|
|
3163
3533
|
`deliberately convert this run to an attended posture through the ` +
|
|
3164
|
-
`supported operator flow. If it is already on,
|
|
3534
|
+
`supported operator flow. If it is already on, email info@bridgegpt.ai.` +
|
|
3165
3535
|
`\nDetail: ${errorDetail(err)}`);
|
|
3166
3536
|
}
|
|
3167
3537
|
else {
|