@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.
- package/README.md +116 -56
- package/build/agent-guidance.generated.js +3 -0
- package/build/agent-launchers/claude-executor-adapter.js +3 -0
- package/build/agent-launchers/claude.js +3 -3
- package/build/agent-launchers/prompt.js +8 -11
- package/build/agent-notes.js +178 -0
- package/build/agent-registry.js +5 -2
- package/build/agent-utils.js +58 -0
- package/build/agents.generated.js +1 -1
- package/build/base-ref.js +33 -9
- package/build/bounded-wait.js +174 -0
- package/build/codex-skill-adapter.js +55 -0
- package/build/commands.generated.js +6 -5
- package/build/conductor/bridge-api-client.js +235 -14
- package/build/conductor/bring-up-facts.js +187 -0
- package/build/conductor/claude-hook.js +7 -5
- package/build/conductor/cli.js +28 -0
- package/build/conductor/doctor.js +80 -9
- package/build/conductor/epic-implementer-cli.js +1298 -0
- package/build/conductor/epic-runtime.js +134 -98
- package/build/conductor/errors.js +2 -2
- package/build/conductor/git-ci-types.js +1 -1
- package/build/conductor/git-hooks.js +28 -14
- package/build/conductor/install-doctor.js +11 -5
- package/build/conductor/readiness-cli.js +10 -10
- package/build/conductor/readiness-sections.js +58 -9
- package/build/conductor/readiness.js +120 -4
- package/build/conductor/recovery-cli.js +289 -10
- package/build/conductor/recovery-operations.js +125 -2
- package/build/conductor/repair-contract.js +58 -0
- package/build/conductor/run-branch.js +137 -0
- package/build/conductor/store.js +2 -2
- package/build/conductor/supervisor-runtime.js +1 -1
- package/build/conductor/test-run-branch-vectors.js +165 -0
- package/build/conductor-bin.js +2 -139
- package/build/conductor-claude-hook-bin.js +2 -2
- package/build/conductor-claude-hook-removed-stub-bin.js +31 -0
- package/build/conductor-removed-stub-bin.js +30 -0
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +145 -18
- package/build/drive-epic.js +752 -90
- package/build/epic-implementer-bin.js +145 -0
- package/build/epic-implementer-bundle-cli.js +264 -0
- package/build/epic-implementer-claude-hook-bin.js +3 -0
- package/build/epic-integration-pr.js +5 -3
- package/build/executor/claim-scope.js +104 -0
- package/build/executor/cli.js +14 -25
- package/build/executor/env-file-guard.js +82 -3
- package/build/executor/env.js +6 -0
- package/build/executor/interrupted-worktree.js +60 -0
- package/build/executor/job-errors.js +45 -0
- package/build/executor/job-runner.js +334 -9
- package/build/executor/job-types.js +25 -9
- package/build/executor/merge-tree-classifier.js +171 -0
- package/build/executor/reconcile-overlap-governance.js +129 -0
- package/build/executor/reconcile-overlap-job.js +989 -0
- package/build/executor/reconcile-overlap-types.js +14 -0
- package/build/executor/spawn-job-driver.js +1 -0
- package/build/executor/types.js +2 -0
- package/build/executor/worker-finalization.js +25 -2
- package/build/executor/worker-guard-hook.js +15 -7
- package/build/implement-epic/bridge-client.js +773 -0
- package/build/implement-epic/checkpoint-store.js +542 -0
- package/build/implement-epic/cli.js +3158 -0
- package/build/implement-epic/cut-protocol.js +392 -0
- package/build/implement-epic/lock.js +302 -0
- package/build/implement-epic/pr-state.js +286 -0
- package/build/implement-epic/spawn.js +113 -0
- package/build/index.js +657 -481
- package/build/init.js +72 -8
- package/build/install-bridge-conductor.js +5 -5
- package/build/install-bridge.js +403 -70
- package/build/local-artifact-storage.js +130 -0
- package/build/mcp-host-config.js +22 -60
- package/build/mcp-host-entry-adapter.js +18 -0
- package/build/mcp-host-targets.js +1 -21
- package/build/merge-pull-request.js +1 -1
- package/build/pipelines.generated.js +23 -16
- package/build/plan-epic-conductor-eligibility.js +1 -1
- package/build/plane/cli.js +321 -41
- package/build/plane/manifest.js +209 -1
- package/build/plane/member-roster.js +70 -0
- package/build/plane/preflight.js +128 -12
- package/build/plane/shutdown.js +18 -5
- package/build/plane/status.js +35 -1
- package/build/plane/supervisor.js +546 -164
- package/build/plane/types.js +25 -2
- package/build/polling-policy.js +72 -0
- package/build/readiness-check.js +3 -3
- package/build/readme.generated.js +1 -1
- package/build/review-generation.js +219 -0
- package/build/run-unit-tests-launcher.js +6 -1
- package/build/setup-epic.js +567 -38
- package/build/start-tickets-conductor.js +8 -7
- package/build/ticket-key-utils.js +4 -3
- package/build/ticket-review-artifact-gate.js +461 -0
- package/build/upgrade-cli.js +5 -26
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +1 -1
- package/docs/CONDUCTOR.md +8 -6
- package/docs/install/mcp-tool-integrations.md +23 -1
- package/package.json +5 -3
- package/pipelines/review-ticket.json +17 -4
package/build/setup-epic.js
CHANGED
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
* association is immutable server-side and can never be attached later.
|
|
48
48
|
* c. Cut or validate `origin/<feature>` at EXACTLY the canonical indexed SHA
|
|
49
49
|
* with the operator's LOCAL `git`, through the shared cut protocol
|
|
50
|
-
* `
|
|
50
|
+
* `implement-epic init` also drives, with the run id so the scope is minted
|
|
51
51
|
* owned by the run. An existing branch at a different commit fails closed.
|
|
52
52
|
* The GitHub App never pushes — it is `contents: read`.
|
|
53
53
|
* d. Store and approve (unchanged requests). The server now READ-validates the
|
|
@@ -78,10 +78,10 @@
|
|
|
78
78
|
import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
|
|
79
79
|
import os from "node:os";
|
|
80
80
|
import { createProductionEpicIntegrationGhRunner, ensureEpicIntegrationPullRequest, formatEpicIntegrationPullRequestOutcome, } from "./epic-integration-pr.js";
|
|
81
|
-
import { approveEpicPlan, createEpicRunWithDisposition, fetchEpicRunState, replaceEpicRunPolicy, resolveConductorBridgeApiAccess, storeEpicPlan, validateEpicPlan, ConductorBridgeApiError, } from "./conductor/bridge-api-client.js";
|
|
81
|
+
import { approveEpicPlan, createEpicRunWithDisposition, fetchConductorReadiness, fetchEpicRunState, replaceEpicRunPolicy, resolveConductorBridgeApiAccess, storeEpicPlan, validateEpicPlan, ConductorBridgeApiError, } from "./conductor/bridge-api-client.js";
|
|
82
82
|
import { resolveTrackedPaths } from "./git-reachability.js";
|
|
83
83
|
import { validateBranchName } from "./base-ref.js";
|
|
84
|
-
// Reused from the
|
|
84
|
+
// Reused from the implement-epic client on purpose: adding a second wrapper under
|
|
85
85
|
// `conductor/` would give the v2 path its own copy to drift from.
|
|
86
86
|
//
|
|
87
87
|
// BAPI-850 extends that reuse to the canonical parse read, the repository base
|
|
@@ -89,15 +89,45 @@ import { validateBranchName } from "./base-ref.js";
|
|
|
89
89
|
// typed clients the pilot already has are the v2 path's clients too, so neither
|
|
90
90
|
// conductor can drift on a field name or an endpoint shape. No MCP tool, no
|
|
91
91
|
// input-schema field, and no description changes.
|
|
92
|
-
import { getConfigFieldBaseBranch, getIndexScopeLifecycle, getParseStatus, } from "./
|
|
93
|
-
import { boundScopeLifecycleLabel, createExecFileRunCommand, ensureCommitResolvableLocally, normalizeCommitSha, performExactIndexScopeCut, pollIndexScopeLifecycle, readRemoteBranchHead, runGit, SCOPE_BOOTSTRAP_POLL_INTERVAL_MS, SCOPE_LIFECYCLE_LABELS, } from "./
|
|
92
|
+
import { getConfigFieldBaseBranch, getIndexScopeLifecycle, getParseStatus, } from "./implement-epic/bridge-client.js";
|
|
93
|
+
import { boundScopeLifecycleLabel, createExecFileRunCommand, ensureCommitResolvableLocally, normalizeCommitSha, performExactIndexScopeCut, pollIndexScopeLifecycle, readRemoteBranchHead, runGit, SCOPE_BOOTSTRAP_POLL_INTERVAL_MS, SCOPE_LIFECYCLE_LABELS, } from "./implement-epic/cut-protocol.js";
|
|
94
94
|
import { hashPlan } from "./conductor/plan.js";
|
|
95
|
+
import { BRING_UP_REFUSAL_MESSAGES, firstBringUpRefusal, } from "./conductor/bring-up-facts.js";
|
|
95
96
|
import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
|
|
96
97
|
// BAPI-872: local plane-manifest binding, so `plane down` can later stop the
|
|
97
98
|
// run this setup resolved WITHOUT guessing from repository-wide active-run
|
|
98
99
|
// state. See `planeBinding` on `SetupEpicDeps` below.
|
|
99
100
|
import { createPlaneFsDeps, createPlaneProcessDeps } from "./plane/defaults.js";
|
|
100
101
|
import { bindPlaneManifestEpicRun, manifestHasLiveProcess, readPlaneManifest, } from "./plane/manifest.js";
|
|
102
|
+
/**
|
|
103
|
+
* The one `policy_json.posture` value that means "unattended" (BAPI-1102).
|
|
104
|
+
*
|
|
105
|
+
* Mirrors the server's `api.models.epic_run.RUN_POSTURE_UNATTENDED`, which is a
|
|
106
|
+
* closed one-member vocabulary: the model refuses any other value and treats an
|
|
107
|
+
* omitted key as attended. Written out here rather than derived, because the
|
|
108
|
+
* client cannot import a Python constant; a unit test pins the two together.
|
|
109
|
+
*/
|
|
110
|
+
export const SETUP_EPIC_UNATTENDED_POSTURE = "unattended";
|
|
111
|
+
/**
|
|
112
|
+
* The server's stable error code for an unattended-posture refusal (BAPI-734).
|
|
113
|
+
*
|
|
114
|
+
* Mirrors `api.library.epic_conductor.unattended_posture.UNATTENDED_POSTURE_REFUSED_CODE`.
|
|
115
|
+
* Matched by EQUALITY on the structured `error_code`, never by searching the
|
|
116
|
+
* message text: the message is prose that may be rewritten, the code is the
|
|
117
|
+
* contract.
|
|
118
|
+
*/
|
|
119
|
+
export const SETUP_EPIC_UNATTENDED_POSTURE_REFUSED_CODE = "UNATTENDED_POSTURE_REFUSED";
|
|
120
|
+
/**
|
|
121
|
+
* The `policy_json` key authorizing automatic merge dispatch (BAPI-722).
|
|
122
|
+
*
|
|
123
|
+
* Composed into the unattended default alongside the posture. The two are not
|
|
124
|
+
* redundant: `posture` selects the unattended predicate set and the escalation
|
|
125
|
+
* discipline, `v2_auto_merge_enabled` is what actually authorizes the reconciler
|
|
126
|
+
* to dispatch a merge. A run with the posture and without the flag is a valid,
|
|
127
|
+
* fully supported configuration — it simply parks at every merge for a human,
|
|
128
|
+
* which is what the observed BAPI-1085 run did.
|
|
129
|
+
*/
|
|
130
|
+
export const SETUP_EPIC_AUTO_MERGE_POLICY_KEY = "v2_auto_merge_enabled";
|
|
101
131
|
/** Accepted `policy_json.review_policy.source` values (the `ReviewPolicy` surface). */
|
|
102
132
|
export const SETUP_EPIC_REVIEW_POLICY_SOURCES = [
|
|
103
133
|
"verdict_protocol",
|
|
@@ -138,6 +168,19 @@ export function getSetupEpicUsage() {
|
|
|
138
168
|
"integration PR targets the repository base branch. A single-node plan keeps the",
|
|
139
169
|
"repository base branch, matching plain start-tickets semantics.",
|
|
140
170
|
"",
|
|
171
|
+
"Posture (default): a run that selects an epic branch is created UNATTENDED —",
|
|
172
|
+
"posture: unattended and v2_auto_merge_enabled: true — and the server stamps its",
|
|
173
|
+
"CI gate at first approval from the repository default, or records an explicit",
|
|
174
|
+
"no-CI-gate mode when the repository declares none. Child PRs merge into the epic",
|
|
175
|
+
"branch; the INTEGRATION PR into the repository base branch stays human-gated.",
|
|
176
|
+
"Pass --attended to opt out. A run that selects no epic branch (--into-base, or a",
|
|
177
|
+
"single-node plan) is unchanged and composes no posture at all.",
|
|
178
|
+
"",
|
|
179
|
+
"Unattended runs have TWO one-time repository prerequisites, refused by name",
|
|
180
|
+
"before any run is created: repository consent (unattended_epic_implementer_allowed) and",
|
|
181
|
+
"a verified notify webhook default. `drive-epic` readiness lists both with their",
|
|
182
|
+
"current state.",
|
|
183
|
+
"",
|
|
141
184
|
"Required:",
|
|
142
185
|
" --epic-key <KEY> Jira epic key (e.g. BAPI-405)",
|
|
143
186
|
" --plan-file <path> Path to epic-plan.dag.json (from decompose-epic)",
|
|
@@ -165,6 +208,13 @@ export function getSetupEpicUsage() {
|
|
|
165
208
|
" (the pre-BAPI-1009 behavior). Use this if automation",
|
|
166
209
|
" depended on \"no flag = direct to base\". Contradictory",
|
|
167
210
|
" with --feature-branch; passing both is a parse error.",
|
|
211
|
+
" --attended OPT OUT of the unattended default: compose only",
|
|
212
|
+
" base_branch (plus --review-policy when supplied), with",
|
|
213
|
+
" no posture and no auto-merge authorization, and skip the",
|
|
214
|
+
" unattended repository prerequisites entirely. The create",
|
|
215
|
+
" request is byte-for-byte the pre-BAPI-1102 one.",
|
|
216
|
+
" Contradictory with a --policy-file that declares",
|
|
217
|
+
" posture: unattended; that pair is refused by name.",
|
|
168
218
|
" --review-policy <src> PER-RUN review policy source, one of:",
|
|
169
219
|
` ${SETUP_EPIC_REVIEW_POLICY_SOURCES.join(", ")}.`,
|
|
170
220
|
" Composed into policy_json.review_policy on create.",
|
|
@@ -269,6 +319,7 @@ export function parseSetupEpicArgs(argv) {
|
|
|
269
319
|
// fire on the INTENT, not on whether a name survived normalization.
|
|
270
320
|
let featureBranchSupplied = false;
|
|
271
321
|
let intoBase = false;
|
|
322
|
+
let attended = false;
|
|
272
323
|
let reviewPolicy;
|
|
273
324
|
let policyFile;
|
|
274
325
|
let replacePolicy = false;
|
|
@@ -379,6 +430,14 @@ export function parseSetupEpicArgs(argv) {
|
|
|
379
430
|
case "--into-base":
|
|
380
431
|
intoBase = true;
|
|
381
432
|
break;
|
|
433
|
+
// BAPI-1102. Valueless, like `--into-base` and `--replace-policy`. It is
|
|
434
|
+
// deliberately NOT contradictory with `--into-base`: a direct-to-main run
|
|
435
|
+
// composes no policy at all, so `--attended` is redundant there rather than
|
|
436
|
+
// conflicting, and refusing a redundant flag would be a worse experience
|
|
437
|
+
// than accepting it.
|
|
438
|
+
case "--attended":
|
|
439
|
+
attended = true;
|
|
440
|
+
break;
|
|
382
441
|
case "--replace-policy":
|
|
383
442
|
replacePolicy = true;
|
|
384
443
|
break;
|
|
@@ -442,6 +501,7 @@ export function parseSetupEpicArgs(argv) {
|
|
|
442
501
|
planVersion,
|
|
443
502
|
featureBranch,
|
|
444
503
|
intoBase,
|
|
504
|
+
attended,
|
|
445
505
|
reviewPolicy,
|
|
446
506
|
policyFile,
|
|
447
507
|
replacePolicy,
|
|
@@ -523,6 +583,15 @@ export function composeSetupEpicPolicy(filePolicy, selections) {
|
|
|
523
583
|
// contradiction purposes so a conflicting value is reported rather than
|
|
524
584
|
// silently ignored, but never invent it and never rewrite it into snake_case:
|
|
525
585
|
// the server's strict boundary should be the one to reject it.
|
|
586
|
+
//
|
|
587
|
+
// BAPI-1127: this paired read is CONTRADICTION DETECTION over a policy file
|
|
588
|
+
// the operator just handed in, at SETUP time, before any run exists — it is
|
|
589
|
+
// not run-branch resolution and must never become one. It asks "did the file
|
|
590
|
+
// name a branch under either spelling that disagrees with --feature-branch?",
|
|
591
|
+
// so it reads both keys for PRESENCE and deliberately picks no winner between
|
|
592
|
+
// them. `conductor/run-branch.ts#resolveDeclaredRunBaseBranch` is the single
|
|
593
|
+
// authority for resolving a PERSISTED run's declared branch; this is a
|
|
594
|
+
// documented exception to that, not a second implementation of it.
|
|
526
595
|
const declaredBranches = [];
|
|
527
596
|
for (const key of ["base_branch", "baseBranch"]) {
|
|
528
597
|
if (key in composed && composed[key] !== undefined && composed[key] !== null) {
|
|
@@ -611,7 +680,30 @@ export function preflightSetupEpicPolicy(args) {
|
|
|
611
680
|
`branch declaration from the policy file, or drop --into-base.`,
|
|
612
681
|
};
|
|
613
682
|
}
|
|
614
|
-
// 2.
|
|
683
|
+
// 2. Posture contradiction (BAPI-1102). Placed SECOND — after the branch
|
|
684
|
+
// strategy, before the composer's field-level conflicts — so one invocation
|
|
685
|
+
// still yields exactly one named reason, deterministically. It belongs above
|
|
686
|
+
// the composer because it is a whole-INTENT contradiction: an operator who
|
|
687
|
+
// said "attended" and supplied a file that says "unattended" has not made a
|
|
688
|
+
// field mistake, and reporting a downstream field conflict first would send
|
|
689
|
+
// them to the wrong line of the file.
|
|
690
|
+
//
|
|
691
|
+
// READ-ONLY, and the read is deliberately the narrowest possible: only
|
|
692
|
+
// whether the file explicitly declares `posture: "unattended"`. Nothing is
|
|
693
|
+
// normalized, no other key is inspected, and — like every message here — the
|
|
694
|
+
// refusal interpolates the file PATH and fixed prose only, never a value
|
|
695
|
+
// read out of the file.
|
|
696
|
+
if (args.attended === true && policyFileDeclaresUnattendedPosture(args.filePolicy)) {
|
|
697
|
+
return {
|
|
698
|
+
ok: false,
|
|
699
|
+
reason: "attended_with_policy_file_posture",
|
|
700
|
+
message: `--attended opts out of the unattended default, but --policy-file ` +
|
|
701
|
+
`'${args.policyFile}' declares posture: "unattended". setup-epic will not ` +
|
|
702
|
+
`pick a winner between them: drop --attended, or remove the posture ` +
|
|
703
|
+
`declaration from the policy file.`,
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
// 3. Policy-source contradictions between the file and the CLI selections.
|
|
615
707
|
// Delegated to the composer, which owns the "no silent winner" rule; the
|
|
616
708
|
// gate only gives the outcome a stable name.
|
|
617
709
|
if (args.filePolicy === undefined)
|
|
@@ -625,6 +717,24 @@ export function preflightSetupEpicPolicy(args) {
|
|
|
625
717
|
}
|
|
626
718
|
return { ok: true, policy: composed.policy };
|
|
627
719
|
}
|
|
720
|
+
/**
|
|
721
|
+
* Does this policy file explicitly declare the UNATTENDED posture (BAPI-1102)?
|
|
722
|
+
*
|
|
723
|
+
* Strict by construction, and narrow on purpose. `true` only for a plain object
|
|
724
|
+
* whose `posture` is exactly the string `"unattended"` — the one spelling the
|
|
725
|
+
* server's `RUN_POSTURE_UNATTENDED` accepts. It never reads another key, never
|
|
726
|
+
* normalizes, and never returns the value it read, so the caller cannot leak a
|
|
727
|
+
* policy value into a refusal message even by accident.
|
|
728
|
+
*
|
|
729
|
+
* A file that declares no posture is not a contradiction: `--attended` is the
|
|
730
|
+
* opt-out from a DEFAULT, and a file that stays silent about posture leaves that
|
|
731
|
+
* default in play for `--attended` to decline.
|
|
732
|
+
*/
|
|
733
|
+
export function policyFileDeclaresUnattendedPosture(filePolicy) {
|
|
734
|
+
if (filePolicy === undefined || filePolicy === null)
|
|
735
|
+
return false;
|
|
736
|
+
return filePolicy.posture === SETUP_EPIC_UNATTENDED_POSTURE;
|
|
737
|
+
}
|
|
628
738
|
/**
|
|
629
739
|
* Does this policy object explicitly supply the `notify.webhook_url` leaf?
|
|
630
740
|
*
|
|
@@ -1005,6 +1115,258 @@ export function resolveFeatureBranchSelection(opts, planNodeCount, policyDeclare
|
|
|
1005
1115
|
return undefined;
|
|
1006
1116
|
return proposeFeatureBranchName(opts.epicKey);
|
|
1007
1117
|
}
|
|
1118
|
+
/**
|
|
1119
|
+
* Evaluate the unattended prerequisites against a readiness report.
|
|
1120
|
+
*
|
|
1121
|
+
* Pure and total: it takes an already-read report (or `null` for a failed read)
|
|
1122
|
+
* and the COMPOSED policy the create request will carry, and returns at most ONE
|
|
1123
|
+
* refusal, in a fixed order that matches the server's own cheapest-first
|
|
1124
|
+
* sequence, so readiness, this gate, and the approval refusal all name the same
|
|
1125
|
+
* first blocker.
|
|
1126
|
+
*
|
|
1127
|
+
* The policy is a parameter rather than something re-derived here because one
|
|
1128
|
+
* prerequisite — the escalation channel — is satisfiable per RUN as well as per
|
|
1129
|
+
* repository. A version of this gate that read only the repository report
|
|
1130
|
+
* refused every unattended run, its own named remedy included.
|
|
1131
|
+
*
|
|
1132
|
+
* The reason CODES are the server's bounded vocabulary, spelled exactly as
|
|
1133
|
+
* `api/library/epic_conductor/unattended_posture.py` spells them. The client adds
|
|
1134
|
+
* remediation TEXT around them and invents no aliases — an alias would drift from
|
|
1135
|
+
* the server's refusal the first time either side was edited, and an operator
|
|
1136
|
+
* comparing a CLI message with an API response would have no way to tell they
|
|
1137
|
+
* were the same refusal.
|
|
1138
|
+
*
|
|
1139
|
+
* `readiness_unreadable` is the one code with no server twin, and it is
|
|
1140
|
+
* deliberately generic and secret-free: an unreadable or older-server report has
|
|
1141
|
+
* PROVEN nothing, and fail-closed is the only safe direction for a check that
|
|
1142
|
+
* authorizes unattended merging.
|
|
1143
|
+
*/
|
|
1144
|
+
/**
|
|
1145
|
+
* Does this policy declare a notify webhook the server would probe?
|
|
1146
|
+
*
|
|
1147
|
+
* BAPI-1102. The shape check mirrors `_extract_webhook_url` in
|
|
1148
|
+
* `api/library/epic_conductor/webhook_verification.py` exactly as far as
|
|
1149
|
+
* PRESENCE goes — a `notify` object carrying a non-blank string `webhook_url` —
|
|
1150
|
+
* and stops there on purpose. Scheme, host, and reachability are the server's to
|
|
1151
|
+
* judge at activation, where the probe actually happens; re-deciding them here
|
|
1152
|
+
* would mean a client that refused a URL the server accepts, or accepted one it
|
|
1153
|
+
* refuses, the first time either side's rules were edited.
|
|
1154
|
+
*
|
|
1155
|
+
* `notify.local_sink` deliberately does NOT satisfy this, matching the server: a
|
|
1156
|
+
* local sink has no destination to prove reachable, so it is not an escalation
|
|
1157
|
+
* channel for a run nobody is watching.
|
|
1158
|
+
*/
|
|
1159
|
+
export function policyDeclaresNotifyWebhook(policy) {
|
|
1160
|
+
if (policy === undefined || policy === null)
|
|
1161
|
+
return false;
|
|
1162
|
+
const notify = policy.notify;
|
|
1163
|
+
if (typeof notify !== "object" || notify === null || Array.isArray(notify)) {
|
|
1164
|
+
return false;
|
|
1165
|
+
}
|
|
1166
|
+
const url = notify.webhook_url;
|
|
1167
|
+
return typeof url === "string" && url.trim() !== "";
|
|
1168
|
+
}
|
|
1169
|
+
export function evaluateSetupEpicUnattendedPrerequisites(readiness, requestedPolicy) {
|
|
1170
|
+
const unattended = readiness?.unattended ?? null;
|
|
1171
|
+
if (unattended === null) {
|
|
1172
|
+
return {
|
|
1173
|
+
reason: "readiness_unreadable",
|
|
1174
|
+
message: "the Bridge API did not report this repository's unattended prerequisites, so " +
|
|
1175
|
+
"they could not be confirmed. Retry once the server is reachable and running a " +
|
|
1176
|
+
"build that reports them, or pass --attended to create an attended run. No run " +
|
|
1177
|
+
"was created.",
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
if (!unattended.conductor_allowed) {
|
|
1181
|
+
return {
|
|
1182
|
+
reason: "target_not_allowed",
|
|
1183
|
+
message: "this repository has not consented to unattended conductor runs. Set " +
|
|
1184
|
+
"`unattended_epic_implementer_allowed` for it (see " +
|
|
1185
|
+
"docs/claude/account-settings-operator-runbook.md), or pass --attended to " +
|
|
1186
|
+
"create an attended run. No run was created.",
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
// Two independent sources satisfy the escalation channel, and the check has to
|
|
1190
|
+
// consult BOTH or it refuses its own documented remedy. No column supplies a
|
|
1191
|
+
// repository-level notify default today, so `notify_webhook_default_declared`
|
|
1192
|
+
// is `false` for every repository; reading only that field made this branch
|
|
1193
|
+
// refuse EVERY unattended run — including the `--policy-file` supplying
|
|
1194
|
+
// `notify.webhook_url` that its own message names as the fix — and left
|
|
1195
|
+
// `--attended` as the only reachable path. The run-level declaration is the
|
|
1196
|
+
// supported form, so it is the one that has to be honoured here.
|
|
1197
|
+
//
|
|
1198
|
+
// Presence, not verification: the server probes the URL at activation and is
|
|
1199
|
+
// the authority on whether it is reachable. This gate exists to spare the
|
|
1200
|
+
// operator a created-and-refused run, not to duplicate the probe.
|
|
1201
|
+
if (!unattended.notify_webhook_default_declared &&
|
|
1202
|
+
!policyDeclaresNotifyWebhook(requestedPolicy)) {
|
|
1203
|
+
return {
|
|
1204
|
+
reason: "webhook_unverified",
|
|
1205
|
+
message: "an unattended run must have a verified escalation channel, and neither this " +
|
|
1206
|
+
"repository nor this run declares one. Supply a --policy-file declaring " +
|
|
1207
|
+
"notify.webhook_url (notify.local_sink does not satisfy this), or pass " +
|
|
1208
|
+
"--attended to create an attended run. No run was created.",
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
if (!unattended.repository_readiness_confirmed) {
|
|
1212
|
+
return {
|
|
1213
|
+
reason: "repository_readiness_unconfirmed",
|
|
1214
|
+
message: "the unattended default authorizes automatic merge, which requires the review and " +
|
|
1215
|
+
"conductor-CI workflows to be CONFIRMED on the default branch, and they are not. " +
|
|
1216
|
+
"Run `install-bridge conductor` to install them, or pass --attended to create an " +
|
|
1217
|
+
"attended run. No run was created.",
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
return null;
|
|
1221
|
+
}
|
|
1222
|
+
/** The closing sentence every pre-create refusal ends with. */
|
|
1223
|
+
const NO_RUN_WAS_CREATED = "No run was created.";
|
|
1224
|
+
/**
|
|
1225
|
+
* Evaluate the bring-up facts against a readiness report.
|
|
1226
|
+
*
|
|
1227
|
+
* Pure and total: `null` is a readiness read that failed, which has proven
|
|
1228
|
+
* nothing and refuses as `readiness_unreadable`. Otherwise at most ONE refusal,
|
|
1229
|
+
* in the fixed {@link BRING_UP_REFUSAL_REASONS} order — each fact's
|
|
1230
|
+
* "unreadable" before its "bad", so an operator is never told to repair a
|
|
1231
|
+
* condition that could not be read.
|
|
1232
|
+
*/
|
|
1233
|
+
export function evaluateSetupEpicBringUpPrerequisites(readiness) {
|
|
1234
|
+
if (readiness === null) {
|
|
1235
|
+
return {
|
|
1236
|
+
reason: "readiness_unreadable",
|
|
1237
|
+
message: "the Bridge API conductor readiness report could not be read, so the database " +
|
|
1238
|
+
"migration, worker presence, and single-reconciler facts could not be confirmed. " +
|
|
1239
|
+
`Retry once the server is reachable. ${NO_RUN_WAS_CREATED}`,
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
const reason = firstBringUpRefusal(readiness.bring_up ?? null);
|
|
1243
|
+
if (reason === null)
|
|
1244
|
+
return null;
|
|
1245
|
+
return { reason, message: `${BRING_UP_REFUSAL_MESSAGES[reason]} ${NO_RUN_WAS_CREATED}` };
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Does the policy this invocation will actually send request unattended operation?
|
|
1249
|
+
*
|
|
1250
|
+
* Read off the COMPOSED policy — the same object the create request carries —
|
|
1251
|
+
* rather than off the flags, so a `--policy-file` that declares
|
|
1252
|
+
* `posture: "unattended"` is gated exactly as the default profile is. Reading the
|
|
1253
|
+
* flags instead would let the file route around the prerequisites, which is the
|
|
1254
|
+
* one thing this gate exists to prevent.
|
|
1255
|
+
*
|
|
1256
|
+
* `--attended` short-circuits before the read: it is the explicit opt-out, and
|
|
1257
|
+
* the preflight gate above has already refused the one case where it contradicts
|
|
1258
|
+
* a policy file.
|
|
1259
|
+
*/
|
|
1260
|
+
export function requestedPolicyIsUnattended(policy, attended) {
|
|
1261
|
+
if (attended)
|
|
1262
|
+
return false;
|
|
1263
|
+
if (policy === undefined)
|
|
1264
|
+
return false;
|
|
1265
|
+
return policy.posture === SETUP_EPIC_UNATTENDED_POSTURE;
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* Compose the create-time `policy_json` for a run with NO `--policy-file`.
|
|
1269
|
+
*
|
|
1270
|
+
* BAPI-1102. Extracted from the create branch so the default profile has one
|
|
1271
|
+
* definition and one place to read it, rather than an inline object literal that
|
|
1272
|
+
* a later edit could silently widen.
|
|
1273
|
+
*
|
|
1274
|
+
* Three shapes, and the boundaries between them are the contract:
|
|
1275
|
+
*
|
|
1276
|
+
* * **No feature branch selected** → `{}`. `--into-base`, a single-node plan,
|
|
1277
|
+
* and the branch-silent path each keep the EXACT legacy create request shape:
|
|
1278
|
+
* the caller sends no `policy_json` key at all, byte-for-byte what it sent
|
|
1279
|
+
* before BAPI-1009 (AC-9). Adding a posture, an auto-merge authorization, an
|
|
1280
|
+
* empty policy object, or anything else here would break all three at once —
|
|
1281
|
+
* and, worse, would ask the server to admit an ungated auto-merge on a run
|
|
1282
|
+
* whose children merge straight into the repository base branch.
|
|
1283
|
+
* * **Feature branch, `--attended`** → `base_branch` plus an explicitly
|
|
1284
|
+
* requested `review_policy`, and nothing else. Byte-identical to the
|
|
1285
|
+
* pre-BAPI-1102 composition, which is precisely what the opt-out promises.
|
|
1286
|
+
* * **Feature branch, default** → adds `posture: "unattended"` and
|
|
1287
|
+
* `v2_auto_merge_enabled: true`.
|
|
1288
|
+
*
|
|
1289
|
+
* Deliberately absent from every shape:
|
|
1290
|
+
*
|
|
1291
|
+
* * **`caps`.** Absent and JSON `null` both mean "the system default applies"
|
|
1292
|
+
* (`unattended_posture.py`), so composing a caps object here would replace a
|
|
1293
|
+
* server-owned ceiling table with a client-authored one.
|
|
1294
|
+
* * **`required_checks` / `no_ci_gate`.** The client has no business encoding a
|
|
1295
|
+
* repository's check names, and a repository default may change between run
|
|
1296
|
+
* creation and approval. First approval resolves the CI gate server-side from
|
|
1297
|
+
* the repository default, or records the explicit no-gate mode — and the stamp
|
|
1298
|
+
* is fill-if-ABSENT, so anything composed here would suppress it.
|
|
1299
|
+
* * **`notify`.** The escalation channel is a repository prerequisite, refused by
|
|
1300
|
+
* name before creation; inventing one here would fake a verified channel.
|
|
1301
|
+
*/
|
|
1302
|
+
export function composeDefaultSetupEpicPolicy(args) {
|
|
1303
|
+
const policy = {};
|
|
1304
|
+
if (args.featureBranch !== undefined) {
|
|
1305
|
+
policy.base_branch = args.featureBranch;
|
|
1306
|
+
if (!args.attended) {
|
|
1307
|
+
policy.posture = SETUP_EPIC_UNATTENDED_POSTURE;
|
|
1308
|
+
policy[SETUP_EPIC_AUTO_MERGE_POLICY_KEY] = true;
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
if (args.reviewPolicy !== undefined) {
|
|
1312
|
+
policy.review_policy = { source: args.reviewPolicy };
|
|
1313
|
+
}
|
|
1314
|
+
return policy;
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Apply the unattended default to a policy a `--policy-file` supplied (BAPI-1102).
|
|
1318
|
+
*
|
|
1319
|
+
* `composeDefaultSetupEpicPolicy` covers the NO-FILE path. This covers the other
|
|
1320
|
+
* one, and without it the default was unreachable by any documented invocation:
|
|
1321
|
+
* a file bypasses that composer entirely, so a file declaring only
|
|
1322
|
+
* `notify.webhook_url` — the exact remedy the `webhook_unverified` refusal, the
|
|
1323
|
+
* readiness remediation, and the record all name — produced a policy with no
|
|
1324
|
+
* `posture`, was never gated, and created an ATTENDED run with no auto-merge
|
|
1325
|
+
* that then parked `v2_merge_approval_required` at every merge. Silently. The
|
|
1326
|
+
* only working unattended invocation was a hand-written file carrying `posture`,
|
|
1327
|
+
* `v2_auto_merge_enabled` and the webhook together, which nothing documents.
|
|
1328
|
+
*
|
|
1329
|
+
* `policyFileDeclaresUnattendedPosture` already states the intended contract in
|
|
1330
|
+
* its own docstring — "a file that stays silent about posture leaves that
|
|
1331
|
+
* default in play for `--attended` to decline" — and this is the code that makes
|
|
1332
|
+
* that true.
|
|
1333
|
+
*
|
|
1334
|
+
* FILL-IF-ABSENT, on the strictest reading of absent, and the boundaries are the
|
|
1335
|
+
* contract:
|
|
1336
|
+
*
|
|
1337
|
+
* * **The file declared a `posture`** → untouched, whatever it says. A file that
|
|
1338
|
+
* answered the question owns the answer; that is what `--policy-file` means,
|
|
1339
|
+
* and the one case where it contradicts `--attended` is already a named
|
|
1340
|
+
* refusal upstream (`attended_with_policy_file_posture`).
|
|
1341
|
+
* * **`--attended`** → untouched. It is the explicit opt-out from a default, and
|
|
1342
|
+
* this is the default it opts out of.
|
|
1343
|
+
* * **No feature branch** → untouched. Identical to the no-file composer's own
|
|
1344
|
+
* boundary: a run whose children merge straight into the repository base
|
|
1345
|
+
* branch must never be handed a posture and an auto-merge authorization.
|
|
1346
|
+
* * **`v2_auto_merge_enabled` already present** → left exactly as the file set
|
|
1347
|
+
* it, including `false`. Filling the posture does not overwrite an explicit
|
|
1348
|
+
* merge decision.
|
|
1349
|
+
*
|
|
1350
|
+
* Pure: it returns a new object and never mutates the input. It is applied on the
|
|
1351
|
+
* CREATE path only. A reused live run reconciles the file against its STORED
|
|
1352
|
+
* policy, and adding a posture there would let `--replace-policy` newly authorize
|
|
1353
|
+
* auto-merge on a run that never had it — the AC-3 guarantee this ticket's gate
|
|
1354
|
+
* placement already rests on.
|
|
1355
|
+
*/
|
|
1356
|
+
export function applySetupEpicUnattendedDefault(policy, args) {
|
|
1357
|
+
if (args.attended)
|
|
1358
|
+
return policy;
|
|
1359
|
+
if (args.featureBranch === undefined)
|
|
1360
|
+
return policy;
|
|
1361
|
+
if ("posture" in policy)
|
|
1362
|
+
return policy;
|
|
1363
|
+
const composed = { ...policy };
|
|
1364
|
+
composed.posture = SETUP_EPIC_UNATTENDED_POSTURE;
|
|
1365
|
+
if (!(SETUP_EPIC_AUTO_MERGE_POLICY_KEY in composed)) {
|
|
1366
|
+
composed[SETUP_EPIC_AUTO_MERGE_POLICY_KEY] = true;
|
|
1367
|
+
}
|
|
1368
|
+
return composed;
|
|
1369
|
+
}
|
|
1008
1370
|
/**
|
|
1009
1371
|
* One line naming the selected branch strategy AND why it was selected.
|
|
1010
1372
|
*
|
|
@@ -1013,14 +1375,30 @@ export function resolveFeatureBranchSelection(opts, planNodeCount, policyDeclare
|
|
|
1013
1375
|
* their policy file asked for it, or the multi-node default derived it — those
|
|
1014
1376
|
* three have different recovery steps when the name is not the one they wanted.
|
|
1015
1377
|
*/
|
|
1016
|
-
function describeBranchStrategy(opts, featureBranch, policyDeclaresBranch
|
|
1378
|
+
function describeBranchStrategy(opts, featureBranch, policyDeclaresBranch,
|
|
1379
|
+
/**
|
|
1380
|
+
* BAPI-1102 — `false` when a `--policy-file` owns the whole policy. The posture
|
|
1381
|
+
* suffix describes what THIS COMMAND composes, so claiming a posture over a
|
|
1382
|
+
* policy the file supplied would be a statement setup-epic cannot make: the
|
|
1383
|
+
* file may declare any posture, or none, and this function never reads it.
|
|
1384
|
+
*/
|
|
1385
|
+
composesDefaultPolicy = true) {
|
|
1017
1386
|
const cutNote = "cut on origin with local git at the canonical indexed commit; " +
|
|
1018
1387
|
"validated server-side at approval";
|
|
1388
|
+
// BAPI-1102: the posture is stated ALONGSIDE the branch, on the same line, and
|
|
1389
|
+
// only for the paths where this command actually chooses it. An operator
|
|
1390
|
+
// reading `epic/BAPI-1102` needs to know whether the run will drive itself.
|
|
1391
|
+
const posture = !composesDefaultPolicy
|
|
1392
|
+
? ""
|
|
1393
|
+
: opts.attended
|
|
1394
|
+
? "; ATTENDED posture (--attended): no auto-merge authorization"
|
|
1395
|
+
: "; UNATTENDED posture with auto-merge into the epic branch " +
|
|
1396
|
+
"(the integration PR stays human-gated) — pass --attended to opt out";
|
|
1019
1397
|
if (featureBranch !== undefined) {
|
|
1020
1398
|
if (opts.featureBranch !== undefined) {
|
|
1021
|
-
return `${featureBranch} (explicit --feature-branch override; ${cutNote})`;
|
|
1399
|
+
return `${featureBranch} (explicit --feature-branch override; ${cutNote}${posture})`;
|
|
1022
1400
|
}
|
|
1023
|
-
return `${featureBranch} (derived epic branch for this multi-ticket plan; ${cutNote})`;
|
|
1401
|
+
return `${featureBranch} (derived epic branch for this multi-ticket plan; ${cutNote}${posture})`;
|
|
1024
1402
|
}
|
|
1025
1403
|
if (opts.intoBase) {
|
|
1026
1404
|
return "repository base branch — epic branch disabled by --into-base; every child PR targets base";
|
|
@@ -1152,6 +1530,17 @@ export const SETUP_EPIC_PARSE_FIRST_MESSAGE = "parse the repository first";
|
|
|
1152
1530
|
* `baseBranch` slip) is deliberately NOT interpreted here: the server's strict
|
|
1153
1531
|
* `RunPolicy` validation owns rejecting it, so this returns `undefined` and the
|
|
1154
1532
|
* request proceeds to that 422 unchanged.
|
|
1533
|
+
*
|
|
1534
|
+
* BAPI-1127: SNAKE-CASE ONLY, on purpose, and a documented exception to the
|
|
1535
|
+
* "one persisted-run resolver" rule rather than a violation of it. The input
|
|
1536
|
+
* here is a REQUESTED policy document the operator is about to submit — not a
|
|
1537
|
+
* persisted run — so the right answer to the camelCase slip is to leave it
|
|
1538
|
+
* alone and let the server's strict boundary refuse it with a 422 the operator
|
|
1539
|
+
* can act on. Honoring the alias here would quietly repair a file the server is
|
|
1540
|
+
* about to reject, and the operator would never learn their file was wrong.
|
|
1541
|
+
* The canonical precedence for a PERSISTED run's declared branch lives in
|
|
1542
|
+
* `conductor/run-branch.ts#resolveDeclaredRunBaseBranch`; this function is not
|
|
1543
|
+
* it and must not grow into it.
|
|
1155
1544
|
*/
|
|
1156
1545
|
export function resolveEffectiveFeatureBranch(featureBranch, requestedPolicy) {
|
|
1157
1546
|
if (featureBranch !== undefined)
|
|
@@ -1256,13 +1645,13 @@ function scopeFailureGuidance(reason, featureBranch, epicRunId) {
|
|
|
1256
1645
|
if (reason === "canonical_index_advanced") {
|
|
1257
1646
|
return (`${SETUP_EPIC_SCOPE_REDRIVE_RECOVERY} The canonical index advanced before the ` +
|
|
1258
1647
|
`scope could be seeded: delete origin/${featureBranch}, abandon this run ` +
|
|
1259
|
-
`(
|
|
1648
|
+
`(epic-implementer abandon-run --epic-run-id ${epicRunId}), and re-run setup-epic.`);
|
|
1260
1649
|
}
|
|
1261
1650
|
if (reason === "head_mismatch") {
|
|
1262
1651
|
return (`${SETUP_EPIC_SCOPE_REDRIVE_RECOVERY} origin/${featureBranch} does not sit at the ` +
|
|
1263
1652
|
`commit this scope was cut at, and setup-epic never force-updates an existing ` +
|
|
1264
1653
|
`ref: delete origin/${featureBranch}, abandon this run ` +
|
|
1265
|
-
`(
|
|
1654
|
+
`(epic-implementer abandon-run --epic-run-id ${epicRunId}), and re-run setup-epic.`);
|
|
1266
1655
|
}
|
|
1267
1656
|
return `Recovery: ${SETUP_EPIC_SCOPE_RECOVERY_COMMAND}.`;
|
|
1268
1657
|
}
|
|
@@ -1292,7 +1681,29 @@ function describeScopeTransition(state, status) {
|
|
|
1292
1681
|
}
|
|
1293
1682
|
return `Scope: ${label}…`;
|
|
1294
1683
|
}
|
|
1684
|
+
/**
|
|
1685
|
+
* Run the whole `setup-epic` workflow and return its authoritative result.
|
|
1686
|
+
*
|
|
1687
|
+
* The internal entry point composed callers use. Behaviorally identical to
|
|
1688
|
+
* {@link runSetupEpicCli} in every observable way — same output, same refusals,
|
|
1689
|
+
* same exit codes — and differs only in what it hands back.
|
|
1690
|
+
*/
|
|
1691
|
+
export async function runSetupEpicWorkflow(argv, overrides = {}) {
|
|
1692
|
+
const sink = {};
|
|
1693
|
+
const exitCode = await executeSetupEpic(argv, overrides, sink);
|
|
1694
|
+
return {
|
|
1695
|
+
exitCode,
|
|
1696
|
+
...(sink.epicRunId !== undefined ? { epicRunId: sink.epicRunId } : {}),
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
/**
|
|
1700
|
+
* The PUBLIC `setup-epic` entry point. Returns the process exit code and nothing
|
|
1701
|
+
* else, exactly as it always has.
|
|
1702
|
+
*/
|
|
1295
1703
|
export async function runSetupEpicCli(argv, overrides = {}) {
|
|
1704
|
+
return (await runSetupEpicWorkflow(argv, overrides)).exitCode;
|
|
1705
|
+
}
|
|
1706
|
+
async function executeSetupEpic(argv, overrides, runIdSink) {
|
|
1296
1707
|
const deps = { ...createDefaultSetupEpicDeps(), ...overrides };
|
|
1297
1708
|
const parsed = parseSetupEpicArgs(argv);
|
|
1298
1709
|
if (parsed.status === "help") {
|
|
@@ -1361,7 +1772,12 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
1361
1772
|
// network and asks no question, so running it here is what makes `--local-only`
|
|
1362
1773
|
// and an ordinary invocation report the same strategy for the same plan.
|
|
1363
1774
|
const featureBranch = resolveFeatureBranchSelection(opts, plan.nodes.length, policyDeclaresBranch);
|
|
1364
|
-
const branchStrategyLine = describeBranchStrategy(opts, featureBranch, policyDeclaresBranch
|
|
1775
|
+
const branchStrategyLine = describeBranchStrategy(opts, featureBranch, policyDeclaresBranch,
|
|
1776
|
+
// `filePolicy` is set exactly when `--policy-file` was supplied, and a
|
|
1777
|
+
// supplied file supersedes composition entirely — so this is the same
|
|
1778
|
+
// predicate as "this command composes the policy", available here, several
|
|
1779
|
+
// statements before the gate resolves `requestedPolicy`.
|
|
1780
|
+
filePolicy === undefined);
|
|
1365
1781
|
// --- Consolidated policy preflight (BAPI-1054) ---------------------------
|
|
1366
1782
|
// ONE gate, run BEFORE credential resolution and before any request, over the
|
|
1367
1783
|
// whole flag-backed choice-set. It sits above the `--local-only` return
|
|
@@ -1374,6 +1790,7 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
1374
1790
|
// still the better error for everything inside the policy file.
|
|
1375
1791
|
const policyGate = preflightSetupEpicPolicy({
|
|
1376
1792
|
intoBase: opts.intoBase,
|
|
1793
|
+
attended: opts.attended,
|
|
1377
1794
|
policyFile: opts.policyFile,
|
|
1378
1795
|
filePolicy,
|
|
1379
1796
|
policyDeclaresBranch,
|
|
@@ -1451,14 +1868,42 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
1451
1868
|
// that passed the gate — not a second composition — so the payload the create
|
|
1452
1869
|
// request carries is exactly what was validated.
|
|
1453
1870
|
const requestedPolicy = policyGate.policy;
|
|
1454
|
-
if (requestedPolicy !== undefined) {
|
|
1455
|
-
say(`Policy: ${opts.policyFile} (complete run policy, applied at creation)`);
|
|
1456
|
-
}
|
|
1457
1871
|
// --- Effective feature branch (BAPI-850) ----------------------------------
|
|
1458
1872
|
// Derived AFTER policy composition so a branch the policy file declares under
|
|
1459
1873
|
// `base_branch` is honored exactly as `--feature-branch` would be. A malformed
|
|
1460
1874
|
// or unknown policy field is left for the server's strict validation.
|
|
1461
1875
|
const effectiveFeatureBranch = resolveEffectiveFeatureBranch(featureBranch, requestedPolicy);
|
|
1876
|
+
// --- The policy the CREATE path will send (BAPI-1102) ---------------------
|
|
1877
|
+
// Composed HERE, as soon as both of its inputs exist, so the one object that
|
|
1878
|
+
// gets reported, gated, and sent is the same object throughout. It is consumed
|
|
1879
|
+
// only on the create branch below; the reuse branch reconciles the RAW file
|
|
1880
|
+
// against the run's stored policy, because adding a posture there would let
|
|
1881
|
+
// `--replace-policy` newly authorize auto-merge on a run that never had it.
|
|
1882
|
+
const composedForCreate = requestedPolicy !== undefined
|
|
1883
|
+
? applySetupEpicUnattendedDefault(requestedPolicy, {
|
|
1884
|
+
featureBranch: effectiveFeatureBranch,
|
|
1885
|
+
attended: opts.attended,
|
|
1886
|
+
})
|
|
1887
|
+
: composeDefaultSetupEpicPolicy({
|
|
1888
|
+
featureBranch,
|
|
1889
|
+
attended: opts.attended,
|
|
1890
|
+
reviewPolicy: opts.reviewPolicy,
|
|
1891
|
+
});
|
|
1892
|
+
if (requestedPolicy !== undefined) {
|
|
1893
|
+
// The posture is stated for the FILE path too, and read off the composed
|
|
1894
|
+
// object rather than re-derived. The `--policy-file` invocation is the one
|
|
1895
|
+
// the `webhook_unverified` remedy steers operators toward, so it was the one
|
|
1896
|
+
// path that could create an auto-merging run without ever printing that it
|
|
1897
|
+
// had — the exact thing this line exists to prevent. "Complete run policy"
|
|
1898
|
+
// is likewise no longer said when this command added the posture to it.
|
|
1899
|
+
const inherited = composedForCreate !== requestedPolicy
|
|
1900
|
+
? "; UNATTENDED posture with auto-merge into the epic branch composed by " +
|
|
1901
|
+
"default over a posture-silent file (the integration PR stays " +
|
|
1902
|
+
"human-gated) — pass --attended to opt out"
|
|
1903
|
+
: "";
|
|
1904
|
+
const shape = inherited === "" ? "complete run policy" : "run policy";
|
|
1905
|
+
say(`Policy: ${opts.policyFile} (${shape}, applied at creation${inherited})`);
|
|
1906
|
+
}
|
|
1462
1907
|
if (effectiveFeatureBranch !== undefined && featureBranch === undefined) {
|
|
1463
1908
|
say(`Feature: ${effectiveFeatureBranch} (from the policy file; cut on origin with local ` +
|
|
1464
1909
|
`git at the canonical indexed commit; validated server-side at approval)`);
|
|
@@ -1505,7 +1950,7 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
1505
1950
|
else if (err instanceof ConductorBridgeApiError && err.status === 409) {
|
|
1506
1951
|
deps.errorLog(`Epic ${opts.epicKey} has MULTIPLE active runs — it is wedged, and every ` +
|
|
1507
1952
|
`plan call will keep failing. Abandon the duplicate before retrying:\n` +
|
|
1508
|
-
`
|
|
1953
|
+
` epic-implementer abandon-run --epic-run-id <duplicate_epic_run_id>\n` +
|
|
1509
1954
|
`Detail: ${errorDetail(err)}`);
|
|
1510
1955
|
return 1;
|
|
1511
1956
|
}
|
|
@@ -1563,7 +2008,7 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
1563
2008
|
`'${existingBaseBranch}', not the repository base '${repositoryBase}', and ` +
|
|
1564
2009
|
`--into-base asks for the repository base branch. setup-epic will not retarget ` +
|
|
1565
2010
|
`an existing run. Re-run without --into-base to reuse it unchanged, or abandon ` +
|
|
1566
|
-
`the run (
|
|
2011
|
+
`the run (epic-implementer abandon-run --epic-run-id ${existingRunId}) and recreate ` +
|
|
1567
2012
|
`it on base.`);
|
|
1568
2013
|
return 1;
|
|
1569
2014
|
}
|
|
@@ -1693,7 +2138,7 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
1693
2138
|
`keep failing. This is TERMINAL: it will not clear on retry, and it is ` +
|
|
1694
2139
|
`NOT a problem with your plan. Adopt the run you want to keep, or ` +
|
|
1695
2140
|
`abandon the duplicate:\n` +
|
|
1696
|
-
`
|
|
2141
|
+
` epic-implementer abandon-run --epic-run-id <duplicate_epic_run_id>\n` +
|
|
1697
2142
|
`No run was created and no automation-start charge occurred.\n` +
|
|
1698
2143
|
`Detail: ${errorDetail(err)}`);
|
|
1699
2144
|
return 1;
|
|
@@ -1767,7 +2212,7 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
1767
2212
|
// used for status instead). A no-feature run performs none of this.
|
|
1768
2213
|
//
|
|
1769
2214
|
// The scope's BASE is the repository's configured base branch (the same
|
|
1770
|
-
// resolution `
|
|
2215
|
+
// resolution `implement-epic init` uses), NOT `policy_json.base_branch` — that
|
|
1771
2216
|
// key names the FEATURE branch. A feature branch equal to the configured base
|
|
1772
2217
|
// needs no scope (dispatch already cuts from it) and matches the server's
|
|
1773
2218
|
// long-standing no-op for that case.
|
|
@@ -1922,6 +2367,9 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
1922
2367
|
result.feature_branch = effectiveFeatureBranch;
|
|
1923
2368
|
// --- Step 1: create (only when there is no live run) ---------------------
|
|
1924
2369
|
if (existingRunId) {
|
|
2370
|
+
// BAPI-1102 — the reuse path's authoritative id, taken from the pre-check's
|
|
2371
|
+
// server response rather than from anything local.
|
|
2372
|
+
runIdSink.epicRunId = existingRunId;
|
|
1925
2373
|
say(`Run: reusing ${existingRunId} (status: ${existingStatus})`);
|
|
1926
2374
|
// A pre-check that already found a live run means create would answer 200
|
|
1927
2375
|
// anyway; reconcile the file against that run's stored policy directly.
|
|
@@ -1941,6 +2389,58 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
1941
2389
|
}
|
|
1942
2390
|
}
|
|
1943
2391
|
else {
|
|
2392
|
+
// --- BAPI-1102: the pre-create unattended prerequisite gate -------------
|
|
2393
|
+
//
|
|
2394
|
+
// Applied ONLY on the create path, and that placement is the AC-3 guarantee
|
|
2395
|
+
// in code: a reused live run keeps its stored policy and its dispatch
|
|
2396
|
+
// behavior untouched, so no deploy of this ticket can refuse or re-gate a
|
|
2397
|
+
// run that is already going.
|
|
2398
|
+
//
|
|
2399
|
+
// Evaluated against the COMPOSED policy, so it covers the default profile and
|
|
2400
|
+
// a `--policy-file` alike; the UNATTENDED evaluation is skipped for
|
|
2401
|
+
// `--attended` and for any policy that is not unattended. (The readiness
|
|
2402
|
+
// read itself is no longer skipped — see BAPI-1153 below.)
|
|
2403
|
+
//
|
|
2404
|
+
// The file branch runs through `applySetupEpicUnattendedDefault` rather than
|
|
2405
|
+
// taking the file verbatim: a file bypasses the no-file composer entirely, so
|
|
2406
|
+
// a posture-SILENT file used to skip this gate and create an attended run —
|
|
2407
|
+
// which made the default profile unreachable by any documented invocation.
|
|
2408
|
+
// The composed object is what gets gated AND what gets sent, so the create
|
|
2409
|
+
// request carries exactly what was validated.
|
|
2410
|
+
//
|
|
2411
|
+
// BAPI-1153: ONE readiness read now serves both gates, and it happens for
|
|
2412
|
+
// EVERY create — attended included — because the bring-up gate below applies
|
|
2413
|
+
// to every run. Before any create request, manifest binding, or lane.
|
|
2414
|
+
{
|
|
2415
|
+
let readiness = null;
|
|
2416
|
+
try {
|
|
2417
|
+
readiness = await fetchConductorReadiness(access, deps.fetch);
|
|
2418
|
+
}
|
|
2419
|
+
catch {
|
|
2420
|
+
// Fail CLOSED, and silently as to WHY: the thrown value can carry a URL
|
|
2421
|
+
// or a response excerpt, and this refusal is rendered to a terminal. The
|
|
2422
|
+
// generic `readiness_unreadable` message says what to do about it.
|
|
2423
|
+
readiness = null;
|
|
2424
|
+
}
|
|
2425
|
+
// Bring-up first: a behind database or a missing worker makes the
|
|
2426
|
+
// unattended questions moot, and refusing on the cheaper, universal fact
|
|
2427
|
+
// keeps the first-named blocker the same for attended and unattended runs.
|
|
2428
|
+
const bringUpRefusal = evaluateSetupEpicBringUpPrerequisites(readiness);
|
|
2429
|
+
if (bringUpRefusal !== null) {
|
|
2430
|
+
deps.errorLog(`setup-epic REFUSED (${bringUpRefusal.reason}): ${bringUpRefusal.message}`);
|
|
2431
|
+
return 1;
|
|
2432
|
+
}
|
|
2433
|
+
if (requestedPolicyIsUnattended(composedForCreate, opts.attended)) {
|
|
2434
|
+
// The COMPOSED policy, not the flags: a --policy-file declaring
|
|
2435
|
+
// notify.webhook_url satisfies the escalation-channel prerequisite, and
|
|
2436
|
+
// it is the same object the create request carries.
|
|
2437
|
+
const refusal = evaluateSetupEpicUnattendedPrerequisites(readiness, composedForCreate);
|
|
2438
|
+
if (refusal !== null) {
|
|
2439
|
+
deps.errorLog(`setup-epic REFUSED (${refusal.reason}): ${refusal.message}`);
|
|
2440
|
+
return 1;
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
1944
2444
|
try {
|
|
1945
2445
|
// Persist the confirmed feature branch as policy_json.base_branch ONLY when
|
|
1946
2446
|
// one was selected; otherwise keep the exact legacy create request shape.
|
|
@@ -1951,23 +2451,27 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
1951
2451
|
// BAPI-754: a --policy-file supersedes that ad-hoc composition entirely —
|
|
1952
2452
|
// `requestedPolicy` IS the composed result, flags included — so the whole
|
|
1953
2453
|
// policy reaches the server in the create that mints the run.
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
2454
|
+
//
|
|
2455
|
+
// BAPI-1102: a run that SELECTS A FEATURE BRANCH is composed unattended by
|
|
2456
|
+
// default, whether or not a policy file supplied the rest of the policy.
|
|
2457
|
+
// The gating on a feature branch being selected is the whole safety of it,
|
|
2458
|
+
// not an optimization — see `composeDefaultSetupEpicPolicy` and
|
|
2459
|
+
// `applySetupEpicUnattendedDefault`.
|
|
2460
|
+
//
|
|
2461
|
+
// This is the OBJECT THE GATE ABOVE JUST VALIDATED, not a second
|
|
2462
|
+
// composition of the same inputs. Recomposing here is how the two could
|
|
2463
|
+
// drift: the gate would pass on one policy and the server would receive
|
|
2464
|
+
// another.
|
|
2465
|
+
const policyJson = composedForCreate;
|
|
1966
2466
|
const createRequest = Object.keys(policyJson).length > 0 || requestedPolicy !== undefined
|
|
1967
2467
|
? { epicKey: opts.epicKey, policyJson }
|
|
1968
2468
|
: { epicKey: opts.epicKey };
|
|
1969
2469
|
const { run, created } = await createEpicRunWithDisposition(access, createRequest, deps.fetch);
|
|
1970
2470
|
result.epic_run_id = run.epic_run_id;
|
|
2471
|
+
// BAPI-1102 — the create path's authoritative id. `createEpicRunWithDisposition`
|
|
2472
|
+
// returns the server's run for a fresh creation AND for a create-race
|
|
2473
|
+
// adoption (`created: false`), so both are captured by this one assignment.
|
|
2474
|
+
runIdSink.epicRunId = run.epic_run_id;
|
|
1971
2475
|
result.status = run.status;
|
|
1972
2476
|
result.run_created = created;
|
|
1973
2477
|
if (created) {
|
|
@@ -1990,7 +2494,7 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
1990
2494
|
say(`Run: adopting existing active run ${run.epic_run_id} (status: ${run.status})`);
|
|
1991
2495
|
say(` No second run was created — one epic has at most one active ` +
|
|
1992
2496
|
`run. Continue on this one, or abandon it first with ` +
|
|
1993
|
-
`\`
|
|
2497
|
+
`\`epic-implementer abandon-run --epic-run-id ${run.epic_run_id}\` to start over.`);
|
|
1994
2498
|
if (requestedPolicy !== undefined) {
|
|
1995
2499
|
const outcome = await applyReusedRunPolicy({
|
|
1996
2500
|
access,
|
|
@@ -2056,7 +2560,7 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
2056
2560
|
else {
|
|
2057
2561
|
const msg = `Could not bind the local plane to run ${result.epic_run_id} (${bound.message}). ` +
|
|
2058
2562
|
"A later `plane down` cannot be guaranteed to stop it automatically — if you need " +
|
|
2059
|
-
`to stop this run, run \`
|
|
2563
|
+
`to stop this run, run \`epic-implementer stop-run --epic-run-id ${result.epic_run_id}\`.`;
|
|
2060
2564
|
warnings.push(msg);
|
|
2061
2565
|
say(`Plane: [warn] ${msg}`);
|
|
2062
2566
|
}
|
|
@@ -2076,7 +2580,7 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
2076
2580
|
const msg = `A local plane manifest exists but could not be validated (${manifestRead.error}), so ` +
|
|
2077
2581
|
`run ${result.epic_run_id} was NOT bound to it. If a plane is running, \`plane down\` ` +
|
|
2078
2582
|
"will not stop this run automatically — use " +
|
|
2079
|
-
`\`
|
|
2583
|
+
`\`epic-implementer stop-run --epic-run-id ${result.epic_run_id}\`. A manifest written by a ` +
|
|
2080
2584
|
"NEWER build must be wound down with that build's `plane down`.";
|
|
2081
2585
|
warnings.push(msg);
|
|
2082
2586
|
say(`Plane: [warn] ${msg}`);
|
|
@@ -2087,7 +2591,7 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
2087
2591
|
// an unexpected error here is reported as a warning, never thrown.
|
|
2088
2592
|
const msg = `Could not check for a local plane to bind run ${result.epic_run_id} to (${errorDetail(err)}). ` +
|
|
2089
2593
|
"A later `plane down` cannot be guaranteed to stop it automatically — if you need to stop " +
|
|
2090
|
-
`this run, run \`
|
|
2594
|
+
`this run, run \`epic-implementer stop-run --epic-run-id ${result.epic_run_id}\`.`;
|
|
2091
2595
|
warnings.push(msg);
|
|
2092
2596
|
say(`Plane: [warn] ${msg}`);
|
|
2093
2597
|
}
|
|
@@ -2096,7 +2600,7 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
2096
2600
|
// AFTER the run exists and BEFORE the plan is stored/approved: the scope's run
|
|
2097
2601
|
// association is immutable server-side, so the cut is driven with this run's
|
|
2098
2602
|
// id and the scope is minted owned by it. Everything from `cut/begin` to the
|
|
2099
|
-
// hold's release is the SHARED protocol `
|
|
2603
|
+
// hold's release is the SHARED protocol `implement-epic init` drives — local git
|
|
2100
2604
|
// only, an exact-SHA create-only push, never the GitHub App, never a force
|
|
2101
2605
|
// update. A failure leaves the run non-active (never discoverable by the
|
|
2102
2606
|
// reconciler) and re-running setup-epic reuses it and re-drives the cut.
|
|
@@ -2203,6 +2707,25 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
2203
2707
|
`dispatch has NOT started. Correct repository access or the branch ` +
|
|
2204
2708
|
`configuration, then re-run setup-epic.\nDetail: ${errorDetail(err)}`);
|
|
2205
2709
|
}
|
|
2710
|
+
else if (err instanceof ConductorBridgeApiError &&
|
|
2711
|
+
err.errorCode === SETUP_EPIC_UNATTENDED_POSTURE_REFUSED_CODE) {
|
|
2712
|
+
// BAPI-1102 — the one approval refusal with a canonical, non-obvious
|
|
2713
|
+
// remedy. Consent is read at BOTH boundaries, so a repository whose consent
|
|
2714
|
+
// was revoked between creation and approval refuses HERE, with the run left
|
|
2715
|
+
// exactly as it was: created, planning, unapproved, no activation, no
|
|
2716
|
+
// executor dispatch, and reusable once consent is restored.
|
|
2717
|
+
//
|
|
2718
|
+
// Named separately from the generic approval failure because the generic
|
|
2719
|
+
// message ("Failed to approve the plan: …") tells an operator to look at the
|
|
2720
|
+
// plan, which is the wrong place entirely.
|
|
2721
|
+
deps.errorLog(`The plan was NOT approved: this repository is no longer authorized for ` +
|
|
2722
|
+
`unattended runs. The run WAS created and is left in 'planning' with no ` +
|
|
2723
|
+
`activation and no executor dispatch — nothing was half-applied.\n` +
|
|
2724
|
+
`Restore \`unattended_epic_implementer_allowed\` for this repository (see ` +
|
|
2725
|
+
`docs/claude/account-settings-operator-runbook.md) and re-run setup-epic, ` +
|
|
2726
|
+
`or deliberately convert this run to an attended posture through the ` +
|
|
2727
|
+
`supported operator flow.\nDetail: ${errorDetail(err)}`);
|
|
2728
|
+
}
|
|
2206
2729
|
else {
|
|
2207
2730
|
deps.errorLog(`Failed to approve the plan: ${errorDetail(err)}`);
|
|
2208
2731
|
}
|
|
@@ -2369,8 +2892,14 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
2369
2892
|
else {
|
|
2370
2893
|
say("The server-side reconciler will pick it up within ~30s.");
|
|
2371
2894
|
}
|
|
2372
|
-
|
|
2373
|
-
|
|
2895
|
+
// BAPI-1102 — the executor command is printed WITH a claim scope. Since
|
|
2896
|
+
// BAPI-1026 an executor started with no scope REFUSES TO START, so the
|
|
2897
|
+
// unscoped form printed here was guidance that could not work: an operator
|
|
2898
|
+
// who copied it got a startup error, and the natural reading of that error is
|
|
2899
|
+
// that the run is broken rather than that the instruction was.
|
|
2900
|
+
say("To execute this run's claimed jobs on this machine, run:");
|
|
2901
|
+
say(` npx -y ${MCP_PACKAGE_NAME} executor --repo ${access.repoName} ` +
|
|
2902
|
+
`--epic-run-id ${result.epic_run_id}`);
|
|
2374
2903
|
}
|
|
2375
2904
|
return exitCode;
|
|
2376
2905
|
}
|