@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
|
@@ -0,0 +1,1298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-only epic-implementer CLI.
|
|
3
|
+
*
|
|
4
|
+
* Provides a dependency-light command skeleton for fast git/agent hooks and
|
|
5
|
+
* operator diagnostics that must NOT depend on the Bridge API backend:
|
|
6
|
+
*
|
|
7
|
+
* epic-implementer emit-event --type <t> --source <s> [...]
|
|
8
|
+
* epic-implementer doctor [--json]
|
|
9
|
+
* epic-implementer purge [--json]
|
|
10
|
+
*
|
|
11
|
+
* Every command talks only to the local SQLite ledger. Errors are surfaced as
|
|
12
|
+
* sanitized messages (no stack traces, no secrets, no raw payloads) and
|
|
13
|
+
* {@link runEpicImplementerCli} returns a process exit code rather than calling
|
|
14
|
+
* `process.exit` itself (the bin wrapper owns that).
|
|
15
|
+
*
|
|
16
|
+
* S4 (BAPI-1080) seam: this file is the renamed public entry point
|
|
17
|
+
* (`conductor/cli.ts` -> `conductor/epic-implementer-cli.ts`,
|
|
18
|
+
* `runConductorCli` -> `runEpicImplementerCli`). The enclosing `conductor/`
|
|
19
|
+
* directory, its ledger schema, event kinds, and MCP profile group remain
|
|
20
|
+
* unchanged until S5.
|
|
21
|
+
*/
|
|
22
|
+
import { readFileSync, unlinkSync } from "node:fs";
|
|
23
|
+
import { ConductorValidationError, ConductorEpicTickV1FrozenError, toConductorErrorEnvelope, } from "./errors.js";
|
|
24
|
+
import { emitConductorEvent, purgeConductorLedger, sendWorkerMessage, checkWorkerMessages, } from "./store.js";
|
|
25
|
+
import { isDuplicateConstraintError } from "./producer-ledger.js";
|
|
26
|
+
import { SEMANTIC_EVENT_TYPES } from "./taxonomy.js";
|
|
27
|
+
import { DRIVER_CONTEXT_MAX_BYTES } from "./repair-contract.js";
|
|
28
|
+
import { installConductorGitHooks, resolveConductorHookBin } from "./git-hooks.js";
|
|
29
|
+
import { runFileScopeGuardCli } from "./file-scope-guard.js";
|
|
30
|
+
import { runPostCommitHookProducer, runReferenceTransactionHookProducer } from "./git-producer.js";
|
|
31
|
+
import { buildConductorDoctorReport, formatConductorDoctorReport } from "./doctor.js";
|
|
32
|
+
import { resolveSupervisorConfig } from "./supervisor-config.js";
|
|
33
|
+
// `runSupervisor` is imported LAZILY inside runSuperviseCommand: it transitively
|
|
34
|
+
// pulls in the SQLite store, and a static import here would force every
|
|
35
|
+
// epic-implementer CLI invocation (and every test that mocks ./store.js for the
|
|
36
|
+
// other commands) to resolve the full supervisor/store graph eagerly.
|
|
37
|
+
/**
|
|
38
|
+
* Human-readable error line for a sanitized conductor error envelope. For the
|
|
39
|
+
* BAPI-526 LEDGER_NATIVE_MODULE_LOAD_FAILED envelope ONLY, append a compact,
|
|
40
|
+
* allowlisted `details:` suffix (module / node_version / node_modules_abi) so a
|
|
41
|
+
* CLI operator can see the ABI skew. Never prints arbitrary detail keys, paths,
|
|
42
|
+
* stacks, argv, or raw caught messages.
|
|
43
|
+
*/
|
|
44
|
+
export function formatConductorErrorLine(envelope) {
|
|
45
|
+
if (envelope.error === "LEDGER_NATIVE_MODULE_LOAD_FAILED" && envelope.details) {
|
|
46
|
+
const d = envelope.details;
|
|
47
|
+
return (`${envelope.message} ` +
|
|
48
|
+
`details: module=${d.module} node_version=${d.node_version} node_modules_abi=${d.node_modules_abi}`);
|
|
49
|
+
}
|
|
50
|
+
return envelope.message;
|
|
51
|
+
}
|
|
52
|
+
/** Human-readable usage text for the epic-implementer CLI. */
|
|
53
|
+
export function getConductorUsage() {
|
|
54
|
+
return [
|
|
55
|
+
"Usage: epic-implementer <command> [options]",
|
|
56
|
+
"",
|
|
57
|
+
"Local append-only event ledger for multi-agent coordination.",
|
|
58
|
+
"Talks ONLY to the local SQLite store (~/.config/bridge/events.db); no Bridge API calls.",
|
|
59
|
+
"",
|
|
60
|
+
"Commands:",
|
|
61
|
+
" emit-event Append one semantic event to the ledger",
|
|
62
|
+
" supervise --run-id <id> Run the foreground, run-scoped supervisor loop",
|
|
63
|
+
" epic-tick Run one stateless reconciliation pass for an Epic",
|
|
64
|
+
" epic-status Print read-only health summary of an Epic Run",
|
|
65
|
+
" send-message Enqueue ONE typed supervisor->worker relay message (idempotent)",
|
|
66
|
+
" check-messages Read + ACK pending relay messages for a worker (no redelivery)",
|
|
67
|
+
" doctor Read-only health/diagnostics report (ledger + git hooks)",
|
|
68
|
+
" readiness Read-only ADVISORY report of every conductor prerequisite",
|
|
69
|
+
" (install + epic-implementer doctor + plane preflight + server),",
|
|
70
|
+
" each with pass/warn/fail/skip and one named remediation.",
|
|
71
|
+
" Remediations are NOT run automatically; always exits 0.",
|
|
72
|
+
" purge Delete ALL ledger rows (events, messages, supervisor_projection)",
|
|
73
|
+
" install-git-hooks Install local, opportunistic, non-blocking git hooks",
|
|
74
|
+
" git-hook post-commit Run the post-commit producer (invoked by the installed hook)",
|
|
75
|
+
" git-hook reference-transaction --phase <p> --stdin-file <f>",
|
|
76
|
+
" Run the reference-transaction producer (invoked by the hook)",
|
|
77
|
+
" file-scope-guard Warn-only: compare the branch diff against the declared",
|
|
78
|
+
" touched-file set (always exits 0; never blocks a PR)",
|
|
79
|
+
" stop-run Stop an epic run: block dispatch and cancel queued work",
|
|
80
|
+
" (the run record is preserved). Idempotent.",
|
|
81
|
+
" abandon-run Abandon an epic run — TERMINAL and IRREVERSIBLE.",
|
|
82
|
+
" Idempotent on an already-abandoned run.",
|
|
83
|
+
" unpark Move a parked (needs_human) ticket back into its gate",
|
|
84
|
+
" machine. Retries internally on a concurrent change.",
|
|
85
|
+
" adopt-current-head-and-unpark",
|
|
86
|
+
" Recover a ticket parked by a PR-head drift: adopt the",
|
|
87
|
+
" CURRENT PR head and unpark in one step.",
|
|
88
|
+
" scope-reparse --epic-run-id <id> --scope-id <scope>",
|
|
89
|
+
" Re-drive the run's OWN index scope through retry-reparse",
|
|
90
|
+
" and schedule its parse. Refused if the scope is not the run's.",
|
|
91
|
+
" dispatch-remediation --epic-run-id <id> --ticket-key <key> --context-file <path>",
|
|
92
|
+
` Dispatch a remediation job carrying driver context (UTF-8,`,
|
|
93
|
+
` at most DRIVER_CONTEXT_MAX_BYTES = ${DRIVER_CONTEXT_MAX_BYTES} bytes; never truncated).`,
|
|
94
|
+
" merge-child --epic-run-id <id> --pr <number>",
|
|
95
|
+
" Merge the run's PR-bound child through the shared merge",
|
|
96
|
+
" service; the server composes the head and gate identity.",
|
|
97
|
+
"",
|
|
98
|
+
"supervise options:",
|
|
99
|
+
" --run-id <id> Run/session identifier to supervise (required)",
|
|
100
|
+
" --wake-interval-ms <n> Deterministic event-poll cadence (clamped 30000..60000)",
|
|
101
|
+
" --global-timeout-ms <n> Total wall-clock ceiling for the run",
|
|
102
|
+
" --escalation-cooldown-ms <n> Min gap between escalations for the same worker+reason",
|
|
103
|
+
"",
|
|
104
|
+
"install-git-hooks notes:",
|
|
105
|
+
" Hooks are LOCAL, unversioned, opportunistic, and bypassable. Missing hooks are a",
|
|
106
|
+
" degraded optional capability and never prevent PR/CI gate evaluation.",
|
|
107
|
+
"",
|
|
108
|
+
"emit-event options:",
|
|
109
|
+
" --type <t> Semantic event type (required). One of:",
|
|
110
|
+
` ${SEMANTIC_EVENT_TYPES.join(", ")}`,
|
|
111
|
+
" --source <s> Logical producer (required)",
|
|
112
|
+
" --subject <s> Subject the event is about (e.g. ticket key)",
|
|
113
|
+
" --run-id <s> Run/session identifier",
|
|
114
|
+
" --worker-id <s> Worker/agent identifier",
|
|
115
|
+
" --producer <s> Finer-grained producer identity",
|
|
116
|
+
" --schema-version <n> Event schema version (default 1)",
|
|
117
|
+
" --time <iso> ISO-8601 event time (default now)",
|
|
118
|
+
" --confidence <0..1> Confidence score",
|
|
119
|
+
" --observed-via <s> Channel the event was observed through",
|
|
120
|
+
" --data-json <json> Normalized data object (allowlisted top-level keys)",
|
|
121
|
+
" --data-json-stdin Read the complete normalized data object as JSON from stdin",
|
|
122
|
+
" (mutually exclusive with --data-json; keeps raw payloads and",
|
|
123
|
+
" secrets out of the process argument list)",
|
|
124
|
+
" --raw-json <json> Tool-native object; nested under data.raw",
|
|
125
|
+
" --payload-ref <ref> Reference for large external payloads (-> data.payload_ref)",
|
|
126
|
+
" --json Print compact JSON result",
|
|
127
|
+
"",
|
|
128
|
+
"send-message options:",
|
|
129
|
+
" --run-id <s> Run/session identifier (required)",
|
|
130
|
+
" --worker-id <s> Target worker identifier (required)",
|
|
131
|
+
" --type <s> Typed message kind, e.g. supervisor.worker_stalled (required)",
|
|
132
|
+
" --cause-seq <n> Idempotency cause sequence, non-negative integer (required)",
|
|
133
|
+
" --payload-json <json> Compact payload object (allowlisted top-level keys)",
|
|
134
|
+
" --payload-json-stdin Read the payload object as JSON from stdin",
|
|
135
|
+
" (mutually exclusive with --payload-json)",
|
|
136
|
+
" --available-at <iso> ISO-8601 time the message becomes available (default now)",
|
|
137
|
+
" --cooldown-ms <n> Per-call cooldown override in ms",
|
|
138
|
+
" --json Print compact JSON result",
|
|
139
|
+
" Note: a duplicate idempotency key or a same-type message inside the cooldown",
|
|
140
|
+
" window does NOT enqueue a second message.",
|
|
141
|
+
"",
|
|
142
|
+
"check-messages options:",
|
|
143
|
+
" --run-id <s> Run/session identifier (required)",
|
|
144
|
+
" --worker-id <s> Worker identifier (required)",
|
|
145
|
+
" --limit <n> Max messages to deliver/ack (default 10, max 100)",
|
|
146
|
+
" --json Print compact JSON result",
|
|
147
|
+
" Note: returned messages are ACKNOWLEDGED by this call and are not redelivered.",
|
|
148
|
+
"",
|
|
149
|
+
"readiness options:",
|
|
150
|
+
" --json Print the versioned structured report as JSON",
|
|
151
|
+
" --no-deny-probe Accepted for parity with `doctor`; inert here — `readiness`",
|
|
152
|
+
" never spawns the deny-enforcement probe, and reports that",
|
|
153
|
+
" check as an explicit skip naming `epic-implementer doctor`",
|
|
154
|
+
" --help Print the readiness usage message",
|
|
155
|
+
" Note: advisory only. It blocks nothing and changes no exit code; `drive-epic`",
|
|
156
|
+
" remains the sole route-selection gate and server-side admission the sole refusal.",
|
|
157
|
+
"",
|
|
158
|
+
"doctor / purge options:",
|
|
159
|
+
" --json Print machine-readable JSON",
|
|
160
|
+
" --no-deny-probe (doctor only) Skip the deny-enforcement preflight — no headless",
|
|
161
|
+
" agent is spawned; the deny_enforcement section reports an",
|
|
162
|
+
" explicit skipped state (enforcement UNVERIFIED, never enforced)",
|
|
163
|
+
"",
|
|
164
|
+
"Examples:",
|
|
165
|
+
" epic-implementer emit-event --type run.started --source git-hook --run-id BAPI-393 \\",
|
|
166
|
+
" --data-json '{\"summary\":\"run started\"}'",
|
|
167
|
+
" epic-implementer emit-event --type git.commit_created --source git-hook \\",
|
|
168
|
+
" --raw-json '{\"branch\":\"feature/x\",\"sha\":\"abc123\"}'",
|
|
169
|
+
" epic-implementer emit-event --type ci.failed --source ci \\",
|
|
170
|
+
" --payload-ref 'file:///tmp/ci-log.txt'",
|
|
171
|
+
" epic-implementer emit-event --type merge.succeeded --source conductor-merge \\",
|
|
172
|
+
" --worker-id w1 --data-json '{\"summary\":\"auto-merged\",\"status\":\"succeeded\"}'",
|
|
173
|
+
" epic-implementer doctor --json",
|
|
174
|
+
" epic-implementer purge",
|
|
175
|
+
"",
|
|
176
|
+
"epic-tick options:",
|
|
177
|
+
" --epic-key <KEY> Epic key to supervise (required, non-empty)",
|
|
178
|
+
" --scheduled-at <epoch> Epoch-seconds timestamp when this tick was scheduled (optional)",
|
|
179
|
+
" --lease-ttl-seconds <n> Lease TTL in seconds (optional, default 120)",
|
|
180
|
+
"",
|
|
181
|
+
"approve-plan options:",
|
|
182
|
+
" approve-plan <epic_key> --plan-version N [--json]",
|
|
183
|
+
" <epic_key> Epic key (e.g. EPIC-405) (required positional)",
|
|
184
|
+
" --plan-version <n> Strictly positive plan version to approve (required)",
|
|
185
|
+
" --json Print compact JSON result",
|
|
186
|
+
" --help Print this usage message",
|
|
187
|
+
"",
|
|
188
|
+
"epic-status options:",
|
|
189
|
+
" --epic-key <KEY> Epic key to fetch the snapshot for (required)",
|
|
190
|
+
" --json Print compact JSON result",
|
|
191
|
+
" --help Print this usage message",
|
|
192
|
+
"",
|
|
193
|
+
"Examples:",
|
|
194
|
+
" epic-implementer approve-plan EPIC-405 --plan-version 2",
|
|
195
|
+
" epic-implementer approve-plan EPIC-405 --plan-version 2 --json",
|
|
196
|
+
" epic-implementer epic-status --epic-key EPIC-405",
|
|
197
|
+
" epic-implementer epic-status --epic-key EPIC-405 --json",
|
|
198
|
+
"",
|
|
199
|
+
"stop-run / abandon-run options:",
|
|
200
|
+
" --epic-run-id <id> Epic run identifier (required)",
|
|
201
|
+
" --json Print compact JSON result",
|
|
202
|
+
" --help Print this usage message",
|
|
203
|
+
" stop-run BLOCKS new dispatch and CANCELS queued work; the run record is",
|
|
204
|
+
" preserved and the run cannot resume on its own — it is an emergency brake,",
|
|
205
|
+
" not a pause. abandon-run is the SAME plus a TERMINAL, IRREVERSIBLE close-out:",
|
|
206
|
+
" once abandoned, the run can never resume or be reused. Both are idempotent —",
|
|
207
|
+
" repeating either is a safe no-op once the run is already stopped/abandoned.",
|
|
208
|
+
"",
|
|
209
|
+
"unpark / adopt-current-head-and-unpark options:",
|
|
210
|
+
" --epic-run-id <id> Epic run identifier (required)",
|
|
211
|
+
" --ticket-key <key> Ticket key (required)",
|
|
212
|
+
" --json Print compact JSON result",
|
|
213
|
+
" --help Print this usage message",
|
|
214
|
+
" unpark resumes a ticket PARKED with needs_human, once the underlying cause",
|
|
215
|
+
" is resolved. adopt-current-head-and-unpark additionally REBINDS the ticket's",
|
|
216
|
+
" anchored PR head to whatever the PR's head currently is, for a ticket parked",
|
|
217
|
+
" by a human/external push. Both retry internally on a concurrent row change —",
|
|
218
|
+
" neither accepts, nor ever asks for, a version counter.",
|
|
219
|
+
"",
|
|
220
|
+
"Incident examples:",
|
|
221
|
+
" # A run is misbehaving and must stop immediately:",
|
|
222
|
+
" epic-implementer stop-run --epic-run-id 3f9c2b7e-...",
|
|
223
|
+
" # The run is done for good — close it out:",
|
|
224
|
+
" epic-implementer abandon-run --epic-run-id 3f9c2b7e-...",
|
|
225
|
+
" # A ticket parked itself (needs_human) and the cause is now fixed:",
|
|
226
|
+
" epic-implementer unpark --epic-run-id 3f9c2b7e-... --ticket-key BAPI-852",
|
|
227
|
+
" # A human pushed directly to the PR and the ticket parked on head drift:",
|
|
228
|
+
" epic-implementer adopt-current-head-and-unpark --epic-run-id 3f9c2b7e-... --ticket-key BAPI-852",
|
|
229
|
+
].join("\n");
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Private diagnostic action (BAPI-772): prints the `epic-implementer-bin.js`
|
|
233
|
+
* path the EXECUTING artifact resolves for hook installation. It exists so a
|
|
234
|
+
* test can run `node build/epic-implementer-bin.js __hook-bin` against the
|
|
235
|
+
* real esbuild bundle and assert the printed path exists — a tsc-emit unit
|
|
236
|
+
* test structurally cannot catch the bundled-layout bug this ticket fixes.
|
|
237
|
+
* Mirrors `plane __entrypoint`. S4 (BAPI-1080): the retained `conductor-bin.js`
|
|
238
|
+
* output name is now a fixed migration stub and must never be selected here.
|
|
239
|
+
*/
|
|
240
|
+
export const CONDUCTOR_HOOK_BIN_ACTION = "__hook-bin";
|
|
241
|
+
const VALID_COMMANDS = new Set([
|
|
242
|
+
"emit-event",
|
|
243
|
+
"supervise",
|
|
244
|
+
"epic-tick",
|
|
245
|
+
"approve-plan",
|
|
246
|
+
"epic-status",
|
|
247
|
+
"send-message",
|
|
248
|
+
"check-messages",
|
|
249
|
+
"doctor",
|
|
250
|
+
// BAPI-1055: the consolidated ADVISORY readiness gate. Read-only, always
|
|
251
|
+
// exits 0, and registers no MCP tool — see `readiness-cli.ts`.
|
|
252
|
+
"readiness",
|
|
253
|
+
"purge",
|
|
254
|
+
"install-git-hooks",
|
|
255
|
+
"git-hook",
|
|
256
|
+
"file-scope-guard",
|
|
257
|
+
// BAPI-872: CLI-only operator recovery verbs — never registered as MCP tools.
|
|
258
|
+
"stop-run",
|
|
259
|
+
"abandon-run",
|
|
260
|
+
"unpark",
|
|
261
|
+
"adopt-current-head-and-unpark",
|
|
262
|
+
// BAPI-1154: CLI-only run-addressed repair verbs — never registered as MCP tools.
|
|
263
|
+
"scope-reparse",
|
|
264
|
+
"dispatch-remediation",
|
|
265
|
+
"merge-child",
|
|
266
|
+
// Private, and deliberately ABSENT from the usage text: `__hook-bin` exists as
|
|
267
|
+
// the bundled-artifact regression guard for BAPI-772 (mirroring `plane
|
|
268
|
+
// __entrypoint`), not as a supported operator workflow.
|
|
269
|
+
CONDUCTOR_HOOK_BIN_ACTION,
|
|
270
|
+
]);
|
|
271
|
+
/**
|
|
272
|
+
* Parse the top-level conductor argv into a subcommand (without a CLI
|
|
273
|
+
* framework). `-h`/`--help` and no-args yield help; an unknown first token is a
|
|
274
|
+
* sanitized error.
|
|
275
|
+
*/
|
|
276
|
+
export function parseConductorArgs(argv) {
|
|
277
|
+
if (argv.length === 0)
|
|
278
|
+
return { kind: "help" };
|
|
279
|
+
const first = argv[0];
|
|
280
|
+
if (first === "-h" || first === "--help")
|
|
281
|
+
return { kind: "help" };
|
|
282
|
+
if (first.startsWith("-")) {
|
|
283
|
+
return { kind: "error", message: `Unknown option "${first}". Run "epic-implementer --help" for usage.` };
|
|
284
|
+
}
|
|
285
|
+
if (!VALID_COMMANDS.has(first)) {
|
|
286
|
+
return { kind: "error", message: `Unknown command "${first}". Run "epic-implementer --help" for usage.` };
|
|
287
|
+
}
|
|
288
|
+
return { kind: "command", command: first, argv: argv.slice(1) };
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Tokenize `--flag value` / `--flag=value` / boolean flags. Unknown flags and
|
|
292
|
+
* stray positionals raise a {@link ConductorValidationError} with actionable text.
|
|
293
|
+
*/
|
|
294
|
+
function tokenizeFlags(argv, valueFlags, boolFlags) {
|
|
295
|
+
const values = new Map();
|
|
296
|
+
const bools = new Set();
|
|
297
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
298
|
+
const token = argv[i];
|
|
299
|
+
if (token === "-h") {
|
|
300
|
+
bools.add("--help");
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (!token.startsWith("--")) {
|
|
304
|
+
throw new ConductorValidationError(`Unexpected argument "${token}".`);
|
|
305
|
+
}
|
|
306
|
+
const eq = token.indexOf("=");
|
|
307
|
+
const name = eq >= 0 ? token.slice(0, eq) : token;
|
|
308
|
+
if (boolFlags.has(name)) {
|
|
309
|
+
bools.add(name);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (!valueFlags.has(name)) {
|
|
313
|
+
throw new ConductorValidationError(`Unknown flag "${name}".`);
|
|
314
|
+
}
|
|
315
|
+
let value;
|
|
316
|
+
if (eq >= 0) {
|
|
317
|
+
value = token.slice(eq + 1);
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
const next = argv[i + 1];
|
|
321
|
+
if (next === undefined) {
|
|
322
|
+
throw new ConductorValidationError(`Flag "${name}" requires a value.`);
|
|
323
|
+
}
|
|
324
|
+
value = next;
|
|
325
|
+
i += 1;
|
|
326
|
+
}
|
|
327
|
+
values.set(name, value);
|
|
328
|
+
}
|
|
329
|
+
return { values, bools };
|
|
330
|
+
}
|
|
331
|
+
const EMIT_VALUE_FLAGS = new Set([
|
|
332
|
+
"--type",
|
|
333
|
+
"--source",
|
|
334
|
+
"--id",
|
|
335
|
+
"--subject",
|
|
336
|
+
"--run-id",
|
|
337
|
+
"--worker-id",
|
|
338
|
+
"--producer",
|
|
339
|
+
"--schema-version",
|
|
340
|
+
"--time",
|
|
341
|
+
"--confidence",
|
|
342
|
+
"--observed-via",
|
|
343
|
+
"--data-json",
|
|
344
|
+
"--raw-json",
|
|
345
|
+
"--payload-ref",
|
|
346
|
+
]);
|
|
347
|
+
const EMIT_BOOL_FLAGS = new Set(["--json", "--help", "--data-json-stdin"]);
|
|
348
|
+
function defaultReadStdin() {
|
|
349
|
+
return readFileSync(0, "utf-8");
|
|
350
|
+
}
|
|
351
|
+
function parseJsonFlag(raw, flag) {
|
|
352
|
+
try {
|
|
353
|
+
return JSON.parse(raw);
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
throw new ConductorValidationError(`Flag "${flag}" must be valid JSON.`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
function isPlainObject(value) {
|
|
360
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Parse `emit-event` flags into a {@link ConductorEventInput}. `--raw-json` is
|
|
364
|
+
* wrapped under `data.raw` (preserving the normalization boundary for hook
|
|
365
|
+
* producers) and `--payload-ref` is merged into `data.payload_ref`.
|
|
366
|
+
*
|
|
367
|
+
* The normalized `data` object can be supplied either inline via `--data-json`
|
|
368
|
+
* or, for callers (such as the Claude hook writer) that must keep raw payloads
|
|
369
|
+
* and secrets out of the process argument list, through stdin via the boolean
|
|
370
|
+
* `--data-json-stdin` flag. The two are mutually exclusive. `deps.readStdin`
|
|
371
|
+
* defaults to a blocking read of fd 0 and is only invoked when
|
|
372
|
+
* `--data-json-stdin` is present, so the synchronous contract is preserved.
|
|
373
|
+
*/
|
|
374
|
+
export function parseEmitEventArgs(argv, deps = {}) {
|
|
375
|
+
const { values, bools } = tokenizeFlags(argv, EMIT_VALUE_FLAGS, EMIT_BOOL_FLAGS);
|
|
376
|
+
if (bools.has("--help")) {
|
|
377
|
+
return { input: { source: "", type: "run.started" }, json: bools.has("--json"), help: true };
|
|
378
|
+
}
|
|
379
|
+
const type = values.get("--type");
|
|
380
|
+
const source = values.get("--source");
|
|
381
|
+
if (!type)
|
|
382
|
+
throw new ConductorValidationError('Flag "--type" is required for emit-event.');
|
|
383
|
+
if (!source)
|
|
384
|
+
throw new ConductorValidationError('Flag "--source" is required for emit-event.');
|
|
385
|
+
// Build the data object: start from --data-json OR --data-json-stdin (never
|
|
386
|
+
// both), then layer raw + payload-ref.
|
|
387
|
+
let data = {};
|
|
388
|
+
const dataJsonRaw = values.get("--data-json");
|
|
389
|
+
const dataJsonStdin = bools.has("--data-json-stdin");
|
|
390
|
+
// Reject the ambiguous combination BEFORE reading stdin so a misconfigured
|
|
391
|
+
// caller never blocks on an fd-0 read it did not intend.
|
|
392
|
+
if (dataJsonRaw !== undefined && dataJsonStdin) {
|
|
393
|
+
throw new ConductorValidationError('Flags "--data-json" and "--data-json-stdin" are mutually exclusive; pass the normalized data object exactly one way.');
|
|
394
|
+
}
|
|
395
|
+
if (dataJsonStdin) {
|
|
396
|
+
const readStdin = deps.readStdin ?? defaultReadStdin;
|
|
397
|
+
const stdinRaw = readStdin();
|
|
398
|
+
// Reuse the same JSON parse + object-validation path as --data-json. The
|
|
399
|
+
// flag name in any error is the flag itself, never the (possibly secret)
|
|
400
|
+
// stdin content.
|
|
401
|
+
const parsed = parseJsonFlag(stdinRaw, "--data-json-stdin");
|
|
402
|
+
if (!isPlainObject(parsed)) {
|
|
403
|
+
throw new ConductorValidationError('Flag "--data-json-stdin" must be a JSON object.');
|
|
404
|
+
}
|
|
405
|
+
data = { ...parsed };
|
|
406
|
+
}
|
|
407
|
+
else if (dataJsonRaw !== undefined) {
|
|
408
|
+
const parsed = parseJsonFlag(dataJsonRaw, "--data-json");
|
|
409
|
+
if (!isPlainObject(parsed)) {
|
|
410
|
+
throw new ConductorValidationError('Flag "--data-json" must be a JSON object.');
|
|
411
|
+
}
|
|
412
|
+
data = { ...parsed };
|
|
413
|
+
}
|
|
414
|
+
const rawJsonRaw = values.get("--raw-json");
|
|
415
|
+
if (rawJsonRaw !== undefined) {
|
|
416
|
+
const parsedRaw = parseJsonFlag(rawJsonRaw, "--raw-json");
|
|
417
|
+
if (!isPlainObject(parsedRaw)) {
|
|
418
|
+
throw new ConductorValidationError('Flag "--raw-json" must be a JSON object.');
|
|
419
|
+
}
|
|
420
|
+
// Merge with any raw already present in --data-json so neither is dropped.
|
|
421
|
+
const existingRaw = isPlainObject(data.raw) ? data.raw : {};
|
|
422
|
+
data.raw = { ...existingRaw, ...parsedRaw };
|
|
423
|
+
}
|
|
424
|
+
const payloadRef = values.get("--payload-ref");
|
|
425
|
+
if (payloadRef !== undefined) {
|
|
426
|
+
data.payload_ref = payloadRef;
|
|
427
|
+
}
|
|
428
|
+
const schemaVersionRaw = values.get("--schema-version");
|
|
429
|
+
const confidenceRaw = values.get("--confidence");
|
|
430
|
+
const input = {
|
|
431
|
+
source,
|
|
432
|
+
type: type,
|
|
433
|
+
// Optional caller-supplied deterministic id (BAPI-527): forwarded to the store
|
|
434
|
+
// so a duplicate collides on the `events.id` UNIQUE constraint, giving the
|
|
435
|
+
// worker gate path server-side dedup without an in-process ledger poll.
|
|
436
|
+
id: values.get("--id"),
|
|
437
|
+
subject: values.get("--subject"),
|
|
438
|
+
run_id: values.get("--run-id"),
|
|
439
|
+
worker_id: values.get("--worker-id"),
|
|
440
|
+
producer: values.get("--producer"),
|
|
441
|
+
time: values.get("--time"),
|
|
442
|
+
observed_via: values.get("--observed-via"),
|
|
443
|
+
data,
|
|
444
|
+
};
|
|
445
|
+
if (schemaVersionRaw !== undefined) {
|
|
446
|
+
const n = Number.parseInt(schemaVersionRaw, 10);
|
|
447
|
+
if (!Number.isFinite(n))
|
|
448
|
+
throw new ConductorValidationError('Flag "--schema-version" must be an integer.');
|
|
449
|
+
input.schema_version = n;
|
|
450
|
+
}
|
|
451
|
+
if (confidenceRaw !== undefined) {
|
|
452
|
+
const n = Number.parseFloat(confidenceRaw);
|
|
453
|
+
if (!Number.isFinite(n))
|
|
454
|
+
throw new ConductorValidationError('Flag "--confidence" must be a number.');
|
|
455
|
+
input.confidence = n;
|
|
456
|
+
}
|
|
457
|
+
return { input, json: bools.has("--json"), help: false };
|
|
458
|
+
}
|
|
459
|
+
/** Run the `emit-event` command. Prints the inserted event summary. */
|
|
460
|
+
export async function runEmitEventCommand(argv, deps = {}) {
|
|
461
|
+
const parsed = parseEmitEventArgs(argv, deps);
|
|
462
|
+
if (parsed.help) {
|
|
463
|
+
console.log(getConductorUsage());
|
|
464
|
+
return 0;
|
|
465
|
+
}
|
|
466
|
+
let result;
|
|
467
|
+
try {
|
|
468
|
+
result = await emitConductorEvent(parsed.input);
|
|
469
|
+
}
|
|
470
|
+
catch (error) {
|
|
471
|
+
// A forwarded deterministic `--id` (BAPI-527) that collides on the events.id
|
|
472
|
+
// UNIQUE constraint is a server-side dedup, not a failure: report it as a
|
|
473
|
+
// structured duplicate so the worker gate path can treat it as a no-op emit
|
|
474
|
+
// without ever polling the ledger in-process.
|
|
475
|
+
if (isDuplicateConstraintError(error)) {
|
|
476
|
+
const dup = { ok: false, reason: "duplicate" };
|
|
477
|
+
console.log(parsed.json ? JSON.stringify(dup) : JSON.stringify(dup, null, 2));
|
|
478
|
+
return 0;
|
|
479
|
+
}
|
|
480
|
+
throw error;
|
|
481
|
+
}
|
|
482
|
+
if (parsed.json) {
|
|
483
|
+
console.log(JSON.stringify(result));
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
console.log(JSON.stringify(result, null, 2));
|
|
487
|
+
}
|
|
488
|
+
return 0;
|
|
489
|
+
}
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
// send-message / check-messages (BAPI-397 cooperative message relay)
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
const SEND_MESSAGE_VALUE_FLAGS = new Set([
|
|
494
|
+
"--run-id",
|
|
495
|
+
"--worker-id",
|
|
496
|
+
"--type",
|
|
497
|
+
"--cause-seq",
|
|
498
|
+
"--payload-json",
|
|
499
|
+
"--available-at",
|
|
500
|
+
"--cooldown-ms",
|
|
501
|
+
]);
|
|
502
|
+
const SEND_MESSAGE_BOOL_FLAGS = new Set(["--payload-json-stdin", "--json", "--help"]);
|
|
503
|
+
/**
|
|
504
|
+
* Parse `send-message` flags into a {@link SendWorkerMessageInput}. The payload
|
|
505
|
+
* object can be supplied inline via `--payload-json` OR from stdin via
|
|
506
|
+
* `--payload-json-stdin` (mutually exclusive, mirroring `emit-event`). The
|
|
507
|
+
* ambiguous combination is rejected BEFORE any stdin read so a misconfigured
|
|
508
|
+
* caller never blocks on fd 0. `--cause-seq` and `--cooldown-ms` are validated as
|
|
509
|
+
* integers; deep identity/type validation is left to the store boundary.
|
|
510
|
+
*/
|
|
511
|
+
export function parseSendMessageArgs(argv, deps = {}) {
|
|
512
|
+
const { values, bools } = tokenizeFlags(argv, SEND_MESSAGE_VALUE_FLAGS, SEND_MESSAGE_BOOL_FLAGS);
|
|
513
|
+
if (bools.has("--help")) {
|
|
514
|
+
return {
|
|
515
|
+
input: { run_id: "", worker_id: "", type: "", cause_seq: 0 },
|
|
516
|
+
json: bools.has("--json"),
|
|
517
|
+
help: true,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
const runId = values.get("--run-id");
|
|
521
|
+
const workerId = values.get("--worker-id");
|
|
522
|
+
const type = values.get("--type");
|
|
523
|
+
const causeSeqRaw = values.get("--cause-seq");
|
|
524
|
+
if (!runId)
|
|
525
|
+
throw new ConductorValidationError('Flag "--run-id" is required for send-message.');
|
|
526
|
+
if (!workerId)
|
|
527
|
+
throw new ConductorValidationError('Flag "--worker-id" is required for send-message.');
|
|
528
|
+
if (!type)
|
|
529
|
+
throw new ConductorValidationError('Flag "--type" is required for send-message.');
|
|
530
|
+
if (causeSeqRaw === undefined) {
|
|
531
|
+
throw new ConductorValidationError('Flag "--cause-seq" is required for send-message.');
|
|
532
|
+
}
|
|
533
|
+
if (!/^\d+$/.test(causeSeqRaw.trim())) {
|
|
534
|
+
throw new ConductorValidationError('Flag "--cause-seq" must be a non-negative integer.');
|
|
535
|
+
}
|
|
536
|
+
const causeSeq = Number.parseInt(causeSeqRaw.trim(), 10);
|
|
537
|
+
let payload = {};
|
|
538
|
+
const payloadInline = values.get("--payload-json");
|
|
539
|
+
const payloadStdin = bools.has("--payload-json-stdin");
|
|
540
|
+
if (payloadInline !== undefined && payloadStdin) {
|
|
541
|
+
throw new ConductorValidationError('Flags "--payload-json" and "--payload-json-stdin" are mutually exclusive; pass the payload object exactly one way.');
|
|
542
|
+
}
|
|
543
|
+
if (payloadStdin) {
|
|
544
|
+
const readStdin = deps.readStdin ?? defaultReadStdin;
|
|
545
|
+
const parsed = parseJsonFlag(readStdin(), "--payload-json-stdin");
|
|
546
|
+
if (!isPlainObject(parsed)) {
|
|
547
|
+
throw new ConductorValidationError('Flag "--payload-json-stdin" must be a JSON object.');
|
|
548
|
+
}
|
|
549
|
+
payload = { ...parsed };
|
|
550
|
+
}
|
|
551
|
+
else if (payloadInline !== undefined) {
|
|
552
|
+
const parsed = parseJsonFlag(payloadInline, "--payload-json");
|
|
553
|
+
if (!isPlainObject(parsed)) {
|
|
554
|
+
throw new ConductorValidationError('Flag "--payload-json" must be a JSON object.');
|
|
555
|
+
}
|
|
556
|
+
payload = { ...parsed };
|
|
557
|
+
}
|
|
558
|
+
const input = {
|
|
559
|
+
run_id: runId,
|
|
560
|
+
worker_id: workerId,
|
|
561
|
+
type,
|
|
562
|
+
cause_seq: causeSeq,
|
|
563
|
+
payload,
|
|
564
|
+
};
|
|
565
|
+
const availableAt = values.get("--available-at");
|
|
566
|
+
if (availableAt !== undefined)
|
|
567
|
+
input.available_at = availableAt;
|
|
568
|
+
const cooldownRaw = values.get("--cooldown-ms");
|
|
569
|
+
if (cooldownRaw !== undefined) {
|
|
570
|
+
if (!/^\d+$/.test(cooldownRaw.trim())) {
|
|
571
|
+
throw new ConductorValidationError('Flag "--cooldown-ms" must be a non-negative integer.');
|
|
572
|
+
}
|
|
573
|
+
input.cooldown_ms = Number.parseInt(cooldownRaw.trim(), 10);
|
|
574
|
+
}
|
|
575
|
+
return { input, json: bools.has("--json"), help: false };
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Run `send-message`. Prints compact JSON when `--json` is set; otherwise a
|
|
579
|
+
* sanitized human summary (message id / status / type only — NEVER the payload).
|
|
580
|
+
*/
|
|
581
|
+
export async function runSendMessageCommand(argv, deps = {}) {
|
|
582
|
+
const parsed = parseSendMessageArgs(argv, deps);
|
|
583
|
+
if (parsed.help) {
|
|
584
|
+
console.log(getConductorUsage());
|
|
585
|
+
return 0;
|
|
586
|
+
}
|
|
587
|
+
const result = await sendWorkerMessage(parsed.input);
|
|
588
|
+
if (parsed.json) {
|
|
589
|
+
console.log(JSON.stringify(result));
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
console.log([
|
|
593
|
+
`Message ${result.status}.`,
|
|
594
|
+
` id: ${result.message.id}`,
|
|
595
|
+
` type: ${result.message.type}`,
|
|
596
|
+
` state: ${result.message.state}`,
|
|
597
|
+
].join("\n"));
|
|
598
|
+
}
|
|
599
|
+
return 0;
|
|
600
|
+
}
|
|
601
|
+
const CHECK_MESSAGES_VALUE_FLAGS = new Set(["--run-id", "--worker-id", "--limit"]);
|
|
602
|
+
const CHECK_MESSAGES_BOOL_FLAGS = new Set(["--json", "--help"]);
|
|
603
|
+
/**
|
|
604
|
+
* Parse `check-messages` flags into a {@link CheckWorkerMessagesInput}. CLI usage
|
|
605
|
+
* requires EXPLICIT `--run-id` and `--worker-id` — unlike the MCP tool, it never
|
|
606
|
+
* silently falls back to conductor environment identity (an operator must say
|
|
607
|
+
* exactly which worker's queue to drain).
|
|
608
|
+
*/
|
|
609
|
+
export function parseCheckMessagesArgs(argv) {
|
|
610
|
+
const { values, bools } = tokenizeFlags(argv, CHECK_MESSAGES_VALUE_FLAGS, CHECK_MESSAGES_BOOL_FLAGS);
|
|
611
|
+
if (bools.has("--help")) {
|
|
612
|
+
return { input: { run_id: "", worker_id: "" }, json: bools.has("--json"), help: true };
|
|
613
|
+
}
|
|
614
|
+
const runId = values.get("--run-id");
|
|
615
|
+
const workerId = values.get("--worker-id");
|
|
616
|
+
if (!runId)
|
|
617
|
+
throw new ConductorValidationError('Flag "--run-id" is required for check-messages.');
|
|
618
|
+
if (!workerId)
|
|
619
|
+
throw new ConductorValidationError('Flag "--worker-id" is required for check-messages.');
|
|
620
|
+
const input = { run_id: runId, worker_id: workerId };
|
|
621
|
+
const limitRaw = values.get("--limit");
|
|
622
|
+
if (limitRaw !== undefined) {
|
|
623
|
+
if (!/^\d+$/.test(limitRaw.trim())) {
|
|
624
|
+
throw new ConductorValidationError('Flag "--limit" must be a positive integer.');
|
|
625
|
+
}
|
|
626
|
+
input.limit = Number.parseInt(limitRaw.trim(), 10);
|
|
627
|
+
}
|
|
628
|
+
return { input, json: bools.has("--json"), help: false };
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Run `check-messages`. Prints compact JSON when `--json` is set; otherwise a
|
|
632
|
+
* sanitized human summary (counts + per-message id/type only — NEVER payloads).
|
|
633
|
+
*/
|
|
634
|
+
export async function runCheckMessagesCommand(argv) {
|
|
635
|
+
const parsed = parseCheckMessagesArgs(argv);
|
|
636
|
+
if (parsed.help) {
|
|
637
|
+
console.log(getConductorUsage());
|
|
638
|
+
return 0;
|
|
639
|
+
}
|
|
640
|
+
const result = await checkWorkerMessages(parsed.input);
|
|
641
|
+
if (parsed.json) {
|
|
642
|
+
console.log(JSON.stringify(result));
|
|
643
|
+
return 0;
|
|
644
|
+
}
|
|
645
|
+
const lines = [`Acknowledged ${result.acked_count} of ${result.count} message(s).`];
|
|
646
|
+
for (const message of result.messages) {
|
|
647
|
+
lines.push(` ${message.id} [${message.type}] -> ${message.state}`);
|
|
648
|
+
}
|
|
649
|
+
console.log(lines.join("\n"));
|
|
650
|
+
return 0;
|
|
651
|
+
}
|
|
652
|
+
const DIAGNOSTIC_BOOL_FLAGS = new Set(["--json", "--help"]);
|
|
653
|
+
const DOCTOR_BOOL_FLAGS = new Set([...DIAGNOSTIC_BOOL_FLAGS, "--no-deny-probe"]);
|
|
654
|
+
/**
|
|
655
|
+
* Run the strictly read-only `doctor` command. Combines ledger health and git
|
|
656
|
+
* hook health. `--json` emits the full report with `git_hooks` alongside the
|
|
657
|
+
* ledger fields at the top level. `--no-deny-probe` skips the deny-enforcement
|
|
658
|
+
* preflight (no headless agent spawn); the report then carries an explicit
|
|
659
|
+
* skipped state, never enforced.
|
|
660
|
+
*/
|
|
661
|
+
export async function runDoctorCommand(argv, deps = {}) {
|
|
662
|
+
const { bools } = tokenizeFlags(argv, new Set(), DOCTOR_BOOL_FLAGS);
|
|
663
|
+
if (bools.has("--help")) {
|
|
664
|
+
console.log(getConductorUsage());
|
|
665
|
+
return 0;
|
|
666
|
+
}
|
|
667
|
+
// A caller may inject deps (e.g. a fake deny inspector in tests) to stay hermetic.
|
|
668
|
+
const report = await buildConductorDoctorReport(bools.has("--no-deny-probe") ? { ...deps, skipDenyProbe: true } : deps);
|
|
669
|
+
if (bools.has("--json")) {
|
|
670
|
+
console.log(JSON.stringify({ ...report.ledger, git_hooks: report.git_hooks, mcp_profile: report.mcp_profile, native_ledger: report.native_ledger, deny_enforcement: report.deny_enforcement }));
|
|
671
|
+
return 0;
|
|
672
|
+
}
|
|
673
|
+
console.log(formatConductorDoctorReport(report));
|
|
674
|
+
return 0;
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Run `install-git-hooks`: install/update the local managed `post-commit` and
|
|
678
|
+
* `reference-transaction` hooks. Returns 0 even when the directory is not a git
|
|
679
|
+
* worktree (degraded optional capability) — never a fatal failure.
|
|
680
|
+
*
|
|
681
|
+
* BAPI-772: an unresolvable `conductor-bin.js` IS fatal (exit 1). Writing a hook
|
|
682
|
+
* that points at a nonexistent binary produced a hook that silently did nothing
|
|
683
|
+
* on every commit, so the installer now refuses and this command reports the
|
|
684
|
+
* refusal with the resolver's searched-layout remediation.
|
|
685
|
+
*/
|
|
686
|
+
export function runInstallGitHooksCommand(argv) {
|
|
687
|
+
const { bools } = tokenizeFlags(argv, new Set(), DIAGNOSTIC_BOOL_FLAGS);
|
|
688
|
+
if (bools.has("--help")) {
|
|
689
|
+
console.log(getConductorUsage());
|
|
690
|
+
return 0;
|
|
691
|
+
}
|
|
692
|
+
const result = installConductorGitHooks();
|
|
693
|
+
if (!result.ok) {
|
|
694
|
+
if (bools.has("--json")) {
|
|
695
|
+
console.error(JSON.stringify({ error: result.error, reason: result.reason }));
|
|
696
|
+
return 1;
|
|
697
|
+
}
|
|
698
|
+
console.error(`Error: conductor git hooks were NOT installed (${result.error}): ${result.reason}`);
|
|
699
|
+
return 1;
|
|
700
|
+
}
|
|
701
|
+
if (bools.has("--json")) {
|
|
702
|
+
console.log(JSON.stringify(result));
|
|
703
|
+
return 0;
|
|
704
|
+
}
|
|
705
|
+
const lines = [
|
|
706
|
+
"Conductor git hooks install",
|
|
707
|
+
"───────────────────────────",
|
|
708
|
+
`is git worktree: ${result.is_worktree}`,
|
|
709
|
+
`hooks dir: ${result.hooks_dir ?? "n/a"}`,
|
|
710
|
+
`conductor bin: ${result.conductor_bin}`,
|
|
711
|
+
];
|
|
712
|
+
for (const hook of result.installed) {
|
|
713
|
+
lines.push(` ${hook.name}: ${hook.action}${hook.warning ? ` (${hook.warning})` : ""}`);
|
|
714
|
+
}
|
|
715
|
+
if (result.warnings.length > 0) {
|
|
716
|
+
lines.push("warnings:");
|
|
717
|
+
for (const w of result.warnings)
|
|
718
|
+
lines.push(` - ${w}`);
|
|
719
|
+
}
|
|
720
|
+
console.log(lines.join("\n"));
|
|
721
|
+
return 0;
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Run the private `__hook-bin` diagnostic: resolve the hook binary from the
|
|
725
|
+
* executing artifact and print ONLY that canonical path to stdout, so artifact-
|
|
726
|
+
* level automation can consume it directly. A failed resolution prints the
|
|
727
|
+
* sanitized resolver reason to stderr and returns 1 — never a stack trace.
|
|
728
|
+
*/
|
|
729
|
+
export function runHookBinDiagnosticCommand() {
|
|
730
|
+
const resolution = resolveConductorHookBin();
|
|
731
|
+
if (!resolution.ok) {
|
|
732
|
+
console.error(`conductor hook bin UNRESOLVED: ${resolution.reason}`);
|
|
733
|
+
return 1;
|
|
734
|
+
}
|
|
735
|
+
console.log(resolution.path);
|
|
736
|
+
return 0;
|
|
737
|
+
}
|
|
738
|
+
const GIT_HOOK_VALUE_FLAGS = new Set(["--phase", "--stdin-file"]);
|
|
739
|
+
const GIT_HOOK_BOOL_FLAGS = new Set(["--help"]);
|
|
740
|
+
/**
|
|
741
|
+
* Parse `git-hook <subcommand> [--phase <p>] [--stdin-file <f>]`. Validates the
|
|
742
|
+
* subcommand and, for `reference-transaction`, surfaces the phase / stdin-file.
|
|
743
|
+
*/
|
|
744
|
+
export function parseGitHookArgs(argv) {
|
|
745
|
+
const subcommand = argv[0];
|
|
746
|
+
if (subcommand !== "post-commit" && subcommand !== "reference-transaction") {
|
|
747
|
+
throw new ConductorValidationError(`Unknown git-hook subcommand "${subcommand ?? ""}". Expected "post-commit" or "reference-transaction".`);
|
|
748
|
+
}
|
|
749
|
+
const { values } = tokenizeFlags(argv.slice(1), GIT_HOOK_VALUE_FLAGS, GIT_HOOK_BOOL_FLAGS);
|
|
750
|
+
return {
|
|
751
|
+
subcommand,
|
|
752
|
+
phase: values.get("--phase"),
|
|
753
|
+
stdinFile: values.get("--stdin-file"),
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Run a `git-hook` subcommand. ALWAYS returns exit code 0 (after at most a generic
|
|
758
|
+
* warning) so a conductor producer failure never blocks the git commit/ref update
|
|
759
|
+
* the hook is attached to.
|
|
760
|
+
*/
|
|
761
|
+
export async function runGitHookCommand(argv) {
|
|
762
|
+
let parsed;
|
|
763
|
+
try {
|
|
764
|
+
parsed = parseGitHookArgs(argv);
|
|
765
|
+
}
|
|
766
|
+
catch {
|
|
767
|
+
// A malformed hook invocation must not block git; warn generically and exit 0.
|
|
768
|
+
process.stderr.write("Warning: conductor git-hook invocation was invalid.\n");
|
|
769
|
+
return 0;
|
|
770
|
+
}
|
|
771
|
+
try {
|
|
772
|
+
if (parsed.subcommand === "post-commit") {
|
|
773
|
+
await runPostCommitHookProducer();
|
|
774
|
+
return 0;
|
|
775
|
+
}
|
|
776
|
+
// reference-transaction: read the captured updates from the stdin file.
|
|
777
|
+
let stdin = "";
|
|
778
|
+
if (parsed.stdinFile) {
|
|
779
|
+
try {
|
|
780
|
+
stdin = readFileSync(parsed.stdinFile, "utf-8");
|
|
781
|
+
}
|
|
782
|
+
catch {
|
|
783
|
+
stdin = "";
|
|
784
|
+
}
|
|
785
|
+
finally {
|
|
786
|
+
// The hook writes ref updates to a per-transaction mktemp file; remove it
|
|
787
|
+
// so these never accumulate in /tmp on long-lived checkouts / CI runners.
|
|
788
|
+
try {
|
|
789
|
+
unlinkSync(parsed.stdinFile);
|
|
790
|
+
}
|
|
791
|
+
catch {
|
|
792
|
+
/* best-effort cleanup */
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
await runReferenceTransactionHookProducer({ phase: parsed.phase ?? "", stdin });
|
|
797
|
+
return 0;
|
|
798
|
+
}
|
|
799
|
+
catch {
|
|
800
|
+
process.stderr.write("Warning: conductor git-hook producer failed.\n");
|
|
801
|
+
return 0;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
// ---------------------------------------------------------------------------
|
|
805
|
+
// epic-tick
|
|
806
|
+
// ---------------------------------------------------------------------------
|
|
807
|
+
const EPIC_TICK_VALUE_FLAGS = new Set([
|
|
808
|
+
"--epic-key",
|
|
809
|
+
"--scheduled-at",
|
|
810
|
+
"--lease-ttl-seconds",
|
|
811
|
+
]);
|
|
812
|
+
const EPIC_TICK_BOOL_FLAGS = new Set(["--help"]);
|
|
813
|
+
/**
|
|
814
|
+
* Parse `epic-tick` flags. `--epic-key` is required and must be non-empty /
|
|
815
|
+
* non-whitespace. Numeric overrides (`--scheduled-at`, `--lease-ttl-seconds`)
|
|
816
|
+
* are validated as non-negative integers using the shared
|
|
817
|
+
* {@link parsePositiveIntFlag} convention. Malformed input raises a sanitized
|
|
818
|
+
* {@link ConductorValidationError}.
|
|
819
|
+
*/
|
|
820
|
+
export function parseEpicTickArgs(argv) {
|
|
821
|
+
const { values, bools } = tokenizeFlags(argv, EPIC_TICK_VALUE_FLAGS, EPIC_TICK_BOOL_FLAGS);
|
|
822
|
+
if (bools.has("--help")) {
|
|
823
|
+
return { epicKey: "", help: true };
|
|
824
|
+
}
|
|
825
|
+
const epicKeyRaw = values.get("--epic-key");
|
|
826
|
+
if (epicKeyRaw === undefined || epicKeyRaw.trim().length === 0) {
|
|
827
|
+
throw new ConductorValidationError('Flag "--epic-key" is required for epic-tick and must be non-empty.');
|
|
828
|
+
}
|
|
829
|
+
const scheduledAt = parsePositiveIntFlag(values, "--scheduled-at");
|
|
830
|
+
const leaseTtlSeconds = parsePositiveIntFlag(values, "--lease-ttl-seconds");
|
|
831
|
+
return { epicKey: epicKeyRaw.trim(), scheduledAt, leaseTtlSeconds, help: false };
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* Default the PreToolUse hook ON for epic-dispatched workers so the supervisor
|
|
835
|
+
* gets worker-liveness (`tool.intent`) signals without the operator having to
|
|
836
|
+
* export `BAPI_CONDUCTOR_ENABLE_PRE_TOOL_USE=1` (BAPI-441/A1 finding).
|
|
837
|
+
*
|
|
838
|
+
* Both the child-env copy and the hook registration key off this SAME
|
|
839
|
+
* parent-process env flag, read at dispatch time, so setting it once at the
|
|
840
|
+
* epic-tick boundary covers the whole tick. Only defaults when the flag is
|
|
841
|
+
* UNSET — an explicit "0"/"" still disables it, because `isConductorFlagEnabled`
|
|
842
|
+
* treats those as false. Mutates the supplied env in place (defaults to
|
|
843
|
+
* `process.env`) and returns it.
|
|
844
|
+
*/
|
|
845
|
+
export function applyEpicTickPreToolUseDefault(env = process.env) {
|
|
846
|
+
if (env.BAPI_CONDUCTOR_ENABLE_PRE_TOOL_USE === undefined) {
|
|
847
|
+
env.BAPI_CONDUCTOR_ENABLE_PRE_TOOL_USE = "1";
|
|
848
|
+
}
|
|
849
|
+
return env;
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Run the `epic-tick` command. Lazily imports the epic runtime so a plain
|
|
853
|
+
* `epic-implementer doctor` / `emit-event` invocation never eagerly resolves
|
|
854
|
+
* the epic/store graph.
|
|
855
|
+
*/
|
|
856
|
+
export async function runEpicTickCommand(_argv) {
|
|
857
|
+
// BAPI-534 (Epic Conductor v2 slice 0): the v1 epic-tick path is FROZEN. This
|
|
858
|
+
// fail-loud guard is the FIRST executable statement — before argument parsing,
|
|
859
|
+
// `applyEpicTickPreToolUseDefault()`, and any lazy `import("./epic-runtime.js")`
|
|
860
|
+
// — so no v1 tick (or its store/supervisor graph) can ever run. Interactive
|
|
861
|
+
// `/start-tickets` is unaffected. `runEpicImplementerCli` catches this named
|
|
862
|
+
// error and formats it consistently (EPIC_TICK_V1_FROZEN → exit 1).
|
|
863
|
+
throw new ConductorEpicTickV1FrozenError();
|
|
864
|
+
}
|
|
865
|
+
/** @deprecated v1 epic-tick body — retained unreachable behind the freeze guard. */
|
|
866
|
+
export async function runEpicTickCommandLegacy(argv) {
|
|
867
|
+
const parsed = parseEpicTickArgs(argv);
|
|
868
|
+
if (parsed.help) {
|
|
869
|
+
console.log(getConductorUsage());
|
|
870
|
+
return 0;
|
|
871
|
+
}
|
|
872
|
+
// Enable supervisor worker-liveness by default for the whole tick (see the
|
|
873
|
+
// helper's doc comment); set before runEpicTick dispatches any worker.
|
|
874
|
+
applyEpicTickPreToolUseDefault();
|
|
875
|
+
// `runEpicTick` is imported LAZILY so the store/supervisor graph is not
|
|
876
|
+
// evaluated for other epic-implementer CLI commands that never need it.
|
|
877
|
+
const { runEpicTick, buildProductionEpicRuntimeDeps } = await import("./epic-runtime.js");
|
|
878
|
+
const prodDeps = await buildProductionEpicRuntimeDeps(parsed.epicKey);
|
|
879
|
+
const result = await runEpicTick({
|
|
880
|
+
epic_key: parsed.epicKey,
|
|
881
|
+
scheduled_at: parsed.scheduledAt !== undefined ? parsed.scheduledAt * 1000 : undefined,
|
|
882
|
+
lease_ttl_seconds: parsed.leaseTtlSeconds,
|
|
883
|
+
}, prodDeps);
|
|
884
|
+
return result.exit_code;
|
|
885
|
+
}
|
|
886
|
+
// ---------------------------------------------------------------------------
|
|
887
|
+
// approve-plan
|
|
888
|
+
// ---------------------------------------------------------------------------
|
|
889
|
+
const APPROVE_PLAN_VALUE_FLAGS = new Set(["--plan-version"]);
|
|
890
|
+
const APPROVE_PLAN_BOOL_FLAGS = new Set(["--json", "--help"]);
|
|
891
|
+
/**
|
|
892
|
+
* Parse `approve-plan` argv. `<epic_key>` is the required first positional
|
|
893
|
+
* argument (a Jira key, e.g. EPIC-405); `--plan-version` is a required
|
|
894
|
+
* strictly-positive integer flag.
|
|
895
|
+
* Malformed input raises a sanitized {@link ConductorValidationError}.
|
|
896
|
+
*/
|
|
897
|
+
export function parseApprovePlanArgs(argv) {
|
|
898
|
+
// Short-circuit for bare --help / -h before positional extraction.
|
|
899
|
+
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
|
|
900
|
+
return { epicKey: "", planVersion: 0, json: false, help: true };
|
|
901
|
+
}
|
|
902
|
+
// Extract the first positional argument (epic_key) before flag tokenization.
|
|
903
|
+
let epicKey;
|
|
904
|
+
let restArgv;
|
|
905
|
+
if (!argv[0].startsWith("-")) {
|
|
906
|
+
epicKey = argv[0];
|
|
907
|
+
restArgv = argv.slice(1);
|
|
908
|
+
}
|
|
909
|
+
else {
|
|
910
|
+
restArgv = argv;
|
|
911
|
+
}
|
|
912
|
+
const { values, bools } = tokenizeFlags(restArgv, APPROVE_PLAN_VALUE_FLAGS, APPROVE_PLAN_BOOL_FLAGS);
|
|
913
|
+
if (bools.has("--help")) {
|
|
914
|
+
return { epicKey: "", planVersion: 0, json: false, help: true };
|
|
915
|
+
}
|
|
916
|
+
if (!epicKey || epicKey.trim().length === 0) {
|
|
917
|
+
throw new ConductorValidationError('Argument "<epic_key>" is required for approve-plan and must be non-empty.');
|
|
918
|
+
}
|
|
919
|
+
const planVersion = parsePositiveIntFlag(values, "--plan-version");
|
|
920
|
+
if (planVersion === undefined) {
|
|
921
|
+
throw new ConductorValidationError('Flag "--plan-version" is required for approve-plan and must be a strictly positive integer.');
|
|
922
|
+
}
|
|
923
|
+
if (planVersion === 0) {
|
|
924
|
+
throw new ConductorValidationError('Flag "--plan-version" must be a strictly positive integer (≥ 1).');
|
|
925
|
+
}
|
|
926
|
+
return { epicKey: epicKey.trim(), planVersion, json: bools.has("--json"), help: false };
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Run the `approve-plan` command. Resolves Bridge API credentials, calls the
|
|
930
|
+
* atomic approve endpoint, and prints a human-readable or JSON summary.
|
|
931
|
+
* Returns a process exit code; never calls `process.exit` directly. All
|
|
932
|
+
* errors are wrapped in sanitized {@link toConductorErrorEnvelope} output —
|
|
933
|
+
* no stack traces, tokens, or response bodies are printed.
|
|
934
|
+
*/
|
|
935
|
+
export async function runApprovePlanCommand(argv) {
|
|
936
|
+
let parsed;
|
|
937
|
+
try {
|
|
938
|
+
parsed = parseApprovePlanArgs(argv);
|
|
939
|
+
}
|
|
940
|
+
catch (error) {
|
|
941
|
+
const envelope = toConductorErrorEnvelope(error);
|
|
942
|
+
console.error(`Error: ${formatConductorErrorLine(envelope)}`);
|
|
943
|
+
return 1;
|
|
944
|
+
}
|
|
945
|
+
if (parsed.help) {
|
|
946
|
+
console.log(getConductorUsage());
|
|
947
|
+
return 0;
|
|
948
|
+
}
|
|
949
|
+
// Lazy import keeps the credential/HTTP graph out of commands that never
|
|
950
|
+
// need Bridge API access (doctor, emit-event, etc.).
|
|
951
|
+
const { resolveConductorBridgeApiAccess, approveEpicPlan } = await import("./bridge-api-client.js");
|
|
952
|
+
const accessResult = await resolveConductorBridgeApiAccess();
|
|
953
|
+
if (!accessResult.ok) {
|
|
954
|
+
const envelope = { error: "CREDENTIALS_UNAVAILABLE", status: 401, message: accessResult.error };
|
|
955
|
+
if (parsed.json) {
|
|
956
|
+
console.log(JSON.stringify({ ok: false, kind: "unauthorized", error: envelope.message }));
|
|
957
|
+
}
|
|
958
|
+
else {
|
|
959
|
+
console.error(`Error: ${accessResult.error}`);
|
|
960
|
+
}
|
|
961
|
+
return 1;
|
|
962
|
+
}
|
|
963
|
+
try {
|
|
964
|
+
const result = await approveEpicPlan(accessResult.access, {
|
|
965
|
+
epicKey: parsed.epicKey,
|
|
966
|
+
planVersion: parsed.planVersion,
|
|
967
|
+
});
|
|
968
|
+
if (!result.ok) {
|
|
969
|
+
throw new ConductorValidationError(`approve-plan rejected: version drift or monotonic constraint violation ` +
|
|
970
|
+
`(kind: ${result.kind}). Ensure --plan-version is strictly greater than ` +
|
|
971
|
+
`the current approved version and that the blob has been stored first.`);
|
|
972
|
+
}
|
|
973
|
+
if (parsed.json) {
|
|
974
|
+
console.log(JSON.stringify({ ok: true, plan_hash: result.plan_hash }));
|
|
975
|
+
}
|
|
976
|
+
else {
|
|
977
|
+
console.log(`Plan approved atomically. ` +
|
|
978
|
+
`Epic Key: ${parsed.epicKey}, ` +
|
|
979
|
+
`Plan Version: ${parsed.planVersion}, ` +
|
|
980
|
+
`Approved Hash: ${result.plan_hash}`);
|
|
981
|
+
}
|
|
982
|
+
return 0;
|
|
983
|
+
}
|
|
984
|
+
catch (error) {
|
|
985
|
+
// S4 (BAPI-1080): standardized to a single sanitized-failure exit code (1)
|
|
986
|
+
// rather than distinguishing 5xx (2) from other failures.
|
|
987
|
+
const envelope = toConductorErrorEnvelope(error);
|
|
988
|
+
if (parsed.json) {
|
|
989
|
+
console.log(JSON.stringify(envelope));
|
|
990
|
+
}
|
|
991
|
+
else {
|
|
992
|
+
console.error(`Error: ${formatConductorErrorLine(envelope)}`);
|
|
993
|
+
}
|
|
994
|
+
return 1;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
// ---------------------------------------------------------------------------
|
|
998
|
+
// epic-status
|
|
999
|
+
// ---------------------------------------------------------------------------
|
|
1000
|
+
const EPIC_STATUS_VALUE_FLAGS = new Set(["--epic-key"]);
|
|
1001
|
+
const EPIC_STATUS_BOOL_FLAGS = new Set(["--json", "--help"]);
|
|
1002
|
+
/**
|
|
1003
|
+
* Parse `epic-status` flags. `--epic-key` is required and must be non-empty.
|
|
1004
|
+
* Malformed input raises a sanitized {@link ConductorValidationError}.
|
|
1005
|
+
*/
|
|
1006
|
+
export function parseEpicStatusArgs(argv) {
|
|
1007
|
+
const { values, bools } = tokenizeFlags(argv, EPIC_STATUS_VALUE_FLAGS, EPIC_STATUS_BOOL_FLAGS);
|
|
1008
|
+
if (bools.has("--help")) {
|
|
1009
|
+
return { epicKey: "", json: false, help: true };
|
|
1010
|
+
}
|
|
1011
|
+
const epicKeyRaw = values.get("--epic-key");
|
|
1012
|
+
if (epicKeyRaw === undefined || epicKeyRaw.trim().length === 0) {
|
|
1013
|
+
throw new ConductorValidationError('Flag "--epic-key" is required for epic-status and must be non-empty.');
|
|
1014
|
+
}
|
|
1015
|
+
return { epicKey: epicKeyRaw.trim(), json: bools.has("--json"), help: false };
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Run the `epic-status` command. Resolves Bridge API credentials, calls
|
|
1019
|
+
* `fetchEpicRunState`, and prints either compact JSON or a sanitized
|
|
1020
|
+
* human-readable summary (structured status fields only — never raw payloads).
|
|
1021
|
+
* Returns a process exit code; never calls `process.exit` directly.
|
|
1022
|
+
*/
|
|
1023
|
+
export async function runEpicStatusCommand(argv) {
|
|
1024
|
+
let parsed;
|
|
1025
|
+
try {
|
|
1026
|
+
parsed = parseEpicStatusArgs(argv);
|
|
1027
|
+
}
|
|
1028
|
+
catch (error) {
|
|
1029
|
+
const envelope = toConductorErrorEnvelope(error);
|
|
1030
|
+
console.error(`Error: ${formatConductorErrorLine(envelope)}`);
|
|
1031
|
+
return 1;
|
|
1032
|
+
}
|
|
1033
|
+
if (parsed.help) {
|
|
1034
|
+
console.log(getConductorUsage());
|
|
1035
|
+
return 0;
|
|
1036
|
+
}
|
|
1037
|
+
// Lazy import keeps the credential/HTTP graph out of commands that never
|
|
1038
|
+
// need Bridge API access (doctor, emit-event, etc.).
|
|
1039
|
+
const { resolveConductorBridgeApiAccess, fetchEpicRunState, ConductorBridgeApiError: BridgeApiError } = await import("./bridge-api-client.js");
|
|
1040
|
+
const accessResult = await resolveConductorBridgeApiAccess();
|
|
1041
|
+
if (!accessResult.ok) {
|
|
1042
|
+
if (parsed.json) {
|
|
1043
|
+
console.log(JSON.stringify({ ok: false, kind: "unauthorized", error: accessResult.error }));
|
|
1044
|
+
}
|
|
1045
|
+
else {
|
|
1046
|
+
console.error(`Error: ${accessResult.error}`);
|
|
1047
|
+
}
|
|
1048
|
+
return 1;
|
|
1049
|
+
}
|
|
1050
|
+
try {
|
|
1051
|
+
const state = await fetchEpicRunState(accessResult.access, parsed.epicKey);
|
|
1052
|
+
if (parsed.json) {
|
|
1053
|
+
console.log(JSON.stringify(state));
|
|
1054
|
+
return 0;
|
|
1055
|
+
}
|
|
1056
|
+
// Human-readable summary: structured status fields only, no raw payloads.
|
|
1057
|
+
const lines = [];
|
|
1058
|
+
const run = state.epic_run;
|
|
1059
|
+
lines.push(`Epic Run: ${run.epic_key}`);
|
|
1060
|
+
lines.push(` status: ${run.status}`);
|
|
1061
|
+
lines.push(` plan_version: ${run.current_plan_version}`);
|
|
1062
|
+
const leaseState = run.lease_owner
|
|
1063
|
+
? `${run.lease_owner} (expires ${run.lease_expires_at ?? "unknown"})`
|
|
1064
|
+
: "none";
|
|
1065
|
+
lines.push(` lease: ${leaseState}`);
|
|
1066
|
+
lines.push(` budget: ${run.budget_wall_clock_seconds ?? "unlimited"}s / ${run.budget_cost_cents ?? "unlimited"} cents`);
|
|
1067
|
+
lines.push(` consumed: ${run.consumed_wall_clock_seconds}s / ${run.consumed_cost_cents} cents`);
|
|
1068
|
+
lines.push(`\nTickets (${state.ticket_statuses.length}):`);
|
|
1069
|
+
for (const t of state.ticket_statuses) {
|
|
1070
|
+
const dispatchRef = t.dispatch_run_id ? `dispatch_run_id=${t.dispatch_run_id}` : "-";
|
|
1071
|
+
lines.push(` ${t.ticket_key} [${t.status}] ${dispatchRef} remediation=${t.remediation_attempts}/${t.remediation_no_progress_attempts}`);
|
|
1072
|
+
}
|
|
1073
|
+
lines.push(`\nDispatches (${state.dispatches.length}):`);
|
|
1074
|
+
for (const d of state.dispatches) {
|
|
1075
|
+
const runRef = d.run_id ? `run_id=${d.run_id}` : "-";
|
|
1076
|
+
lines.push(` ${d.dispatch_key} [${d.status}] ${runRef}`);
|
|
1077
|
+
}
|
|
1078
|
+
console.log(lines.join("\n"));
|
|
1079
|
+
return 0;
|
|
1080
|
+
}
|
|
1081
|
+
catch (error) {
|
|
1082
|
+
if (error instanceof BridgeApiError && error.status === 404) {
|
|
1083
|
+
if (parsed.json) {
|
|
1084
|
+
console.log(JSON.stringify({ epic_key: parsed.epicKey, status: "unknown", state: null }));
|
|
1085
|
+
}
|
|
1086
|
+
else {
|
|
1087
|
+
console.log("No such epic found.");
|
|
1088
|
+
}
|
|
1089
|
+
return 0;
|
|
1090
|
+
}
|
|
1091
|
+
// S4 (BAPI-1080): standardized to a single sanitized-failure exit code (1)
|
|
1092
|
+
// rather than distinguishing 5xx (2) from other failures.
|
|
1093
|
+
const envelope = toConductorErrorEnvelope(error);
|
|
1094
|
+
if (parsed.json) {
|
|
1095
|
+
console.log(JSON.stringify(envelope));
|
|
1096
|
+
}
|
|
1097
|
+
else {
|
|
1098
|
+
console.error(`Error: ${formatConductorErrorLine(envelope)}`);
|
|
1099
|
+
}
|
|
1100
|
+
return 1;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
// ---------------------------------------------------------------------------
|
|
1104
|
+
// supervise
|
|
1105
|
+
// ---------------------------------------------------------------------------
|
|
1106
|
+
const SUPERVISE_VALUE_FLAGS = new Set([
|
|
1107
|
+
"--run-id",
|
|
1108
|
+
"--wake-interval-ms",
|
|
1109
|
+
"--global-timeout-ms",
|
|
1110
|
+
"--escalation-cooldown-ms",
|
|
1111
|
+
]);
|
|
1112
|
+
const SUPERVISE_BOOL_FLAGS = new Set(["--help"]);
|
|
1113
|
+
/** Parse a required positive-integer flag, raising a sanitized error otherwise. */
|
|
1114
|
+
function parsePositiveIntFlag(values, flag) {
|
|
1115
|
+
const raw = values.get(flag);
|
|
1116
|
+
if (raw === undefined)
|
|
1117
|
+
return undefined;
|
|
1118
|
+
if (!/^\d+$/.test(raw.trim())) {
|
|
1119
|
+
throw new ConductorValidationError(`Flag "${flag}" must be a non-negative integer.`);
|
|
1120
|
+
}
|
|
1121
|
+
const n = Number.parseInt(raw.trim(), 10);
|
|
1122
|
+
if (!Number.isFinite(n)) {
|
|
1123
|
+
throw new ConductorValidationError(`Flag "${flag}" must be a non-negative integer.`);
|
|
1124
|
+
}
|
|
1125
|
+
return n;
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Parse `supervise` flags. `--run-id` is required and must be non-empty /
|
|
1129
|
+
* non-whitespace. Numeric overrides are validated as non-negative integers (the
|
|
1130
|
+
* config resolver clamps them to safe bounds). Malformed input raises a
|
|
1131
|
+
* sanitized {@link ConductorValidationError}.
|
|
1132
|
+
*/
|
|
1133
|
+
export function parseSuperviseArgs(argv) {
|
|
1134
|
+
const { values, bools } = tokenizeFlags(argv, SUPERVISE_VALUE_FLAGS, SUPERVISE_BOOL_FLAGS);
|
|
1135
|
+
if (bools.has("--help")) {
|
|
1136
|
+
return { runId: "", overrides: {}, help: true };
|
|
1137
|
+
}
|
|
1138
|
+
const runIdRaw = values.get("--run-id");
|
|
1139
|
+
if (runIdRaw === undefined || runIdRaw.trim().length === 0) {
|
|
1140
|
+
throw new ConductorValidationError('Flag "--run-id" is required for supervise and must be non-empty.');
|
|
1141
|
+
}
|
|
1142
|
+
const overrides = {};
|
|
1143
|
+
const wake = parsePositiveIntFlag(values, "--wake-interval-ms");
|
|
1144
|
+
if (wake !== undefined)
|
|
1145
|
+
overrides.wake_interval_ms = wake;
|
|
1146
|
+
const globalTimeout = parsePositiveIntFlag(values, "--global-timeout-ms");
|
|
1147
|
+
if (globalTimeout !== undefined)
|
|
1148
|
+
overrides.global_timeout_ms = globalTimeout;
|
|
1149
|
+
const cooldown = parsePositiveIntFlag(values, "--escalation-cooldown-ms");
|
|
1150
|
+
if (cooldown !== undefined)
|
|
1151
|
+
overrides.escalation_cooldown_ms = cooldown;
|
|
1152
|
+
return { runId: runIdRaw.trim(), overrides, help: false };
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Run the foreground `supervise` command. Resolves config (for a startup
|
|
1156
|
+
* banner), runs the supervisor loop, and returns the supervisor's process exit
|
|
1157
|
+
* code. Normal foreground output is written through the runtime's injected log
|
|
1158
|
+
* functions.
|
|
1159
|
+
*/
|
|
1160
|
+
export async function runSuperviseCommand(argv) {
|
|
1161
|
+
const parsed = parseSuperviseArgs(argv);
|
|
1162
|
+
if (parsed.help) {
|
|
1163
|
+
console.log(getConductorUsage());
|
|
1164
|
+
return 0;
|
|
1165
|
+
}
|
|
1166
|
+
const config = resolveSupervisorConfig(parsed.overrides);
|
|
1167
|
+
console.log(`[supervisor] starting run=${parsed.runId} wake=${config.wake_interval_ms}ms ` +
|
|
1168
|
+
`global_timeout=${config.global_timeout_ms}ms`);
|
|
1169
|
+
const { runSupervisor } = await import("./supervisor-runtime.js");
|
|
1170
|
+
const result = await runSupervisor({ run_id: parsed.runId, config });
|
|
1171
|
+
return result.exit_code;
|
|
1172
|
+
}
|
|
1173
|
+
/** Run the explicit `purge` command. Prints deleted row counts. */
|
|
1174
|
+
export async function runPurgeCommand(argv) {
|
|
1175
|
+
const { bools } = tokenizeFlags(argv, new Set(), DIAGNOSTIC_BOOL_FLAGS);
|
|
1176
|
+
if (bools.has("--help")) {
|
|
1177
|
+
console.log(getConductorUsage());
|
|
1178
|
+
return 0;
|
|
1179
|
+
}
|
|
1180
|
+
const result = await purgeConductorLedger();
|
|
1181
|
+
if (bools.has("--json")) {
|
|
1182
|
+
console.log(JSON.stringify(result));
|
|
1183
|
+
return 0;
|
|
1184
|
+
}
|
|
1185
|
+
console.log([
|
|
1186
|
+
`Conductor ledger purged (existed: ${result.existed}).`,
|
|
1187
|
+
` events: ${result.deleted.events}`,
|
|
1188
|
+
` messages: ${result.deleted.messages}`,
|
|
1189
|
+
` supervisor_projection: ${result.deleted.supervisor_projection}`,
|
|
1190
|
+
].join("\n"));
|
|
1191
|
+
return 0;
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* Dispatch the epic-implementer CLI. Returns (asynchronously) a process exit
|
|
1195
|
+
* code (non-zero for parser, validation, and storage failures). The dispatcher
|
|
1196
|
+
* is async so the foreground `supervise` command can be awaited; the
|
|
1197
|
+
* synchronous commands resolve immediately. Only sanitized error messages are
|
|
1198
|
+
* printed; stack traces are never shown.
|
|
1199
|
+
*
|
|
1200
|
+
* S5 seam: the internal `conductor/` ledger schema, event kinds, and MCP
|
|
1201
|
+
* profile group this dispatcher sits alongside remain unrenamed until S5; this
|
|
1202
|
+
* S4 slice renames only the public entry point and CLI-facing text.
|
|
1203
|
+
*/
|
|
1204
|
+
export async function runEpicImplementerCli(argv) {
|
|
1205
|
+
const parsed = parseConductorArgs(argv);
|
|
1206
|
+
if (parsed.kind === "help") {
|
|
1207
|
+
console.log(getConductorUsage());
|
|
1208
|
+
return 0;
|
|
1209
|
+
}
|
|
1210
|
+
if (parsed.kind === "error") {
|
|
1211
|
+
console.error(`Error: ${parsed.message}`);
|
|
1212
|
+
return 1;
|
|
1213
|
+
}
|
|
1214
|
+
try {
|
|
1215
|
+
switch (parsed.command) {
|
|
1216
|
+
case "emit-event":
|
|
1217
|
+
return await runEmitEventCommand(parsed.argv);
|
|
1218
|
+
case "supervise":
|
|
1219
|
+
return await runSuperviseCommand(parsed.argv);
|
|
1220
|
+
case "epic-tick":
|
|
1221
|
+
return await runEpicTickCommand(parsed.argv);
|
|
1222
|
+
case "approve-plan":
|
|
1223
|
+
return await runApprovePlanCommand(parsed.argv);
|
|
1224
|
+
case "epic-status":
|
|
1225
|
+
return await runEpicStatusCommand(parsed.argv);
|
|
1226
|
+
case "send-message":
|
|
1227
|
+
return await runSendMessageCommand(parsed.argv);
|
|
1228
|
+
case "check-messages":
|
|
1229
|
+
return await runCheckMessagesCommand(parsed.argv);
|
|
1230
|
+
case "doctor":
|
|
1231
|
+
return await runDoctorCommand(parsed.argv);
|
|
1232
|
+
case "readiness": {
|
|
1233
|
+
// Lazily imported, like the recovery verbs: the readiness gate pulls in
|
|
1234
|
+
// the Bridge HTTP client and the plane preflight graph, and a local-only
|
|
1235
|
+
// command (doctor, emit-event) must not pay for that at load time.
|
|
1236
|
+
const { runConductorReadinessCommand } = await import("./readiness-cli.js");
|
|
1237
|
+
return await runConductorReadinessCommand(parsed.argv);
|
|
1238
|
+
}
|
|
1239
|
+
case "purge":
|
|
1240
|
+
return await runPurgeCommand(parsed.argv);
|
|
1241
|
+
case "install-git-hooks":
|
|
1242
|
+
return runInstallGitHooksCommand(parsed.argv);
|
|
1243
|
+
case "git-hook":
|
|
1244
|
+
return await runGitHookCommand(parsed.argv);
|
|
1245
|
+
case CONDUCTOR_HOOK_BIN_ACTION:
|
|
1246
|
+
// Dispatched before any MCP server construction or store access: the
|
|
1247
|
+
// guard must be able to run against a bundle whose ledger is absent.
|
|
1248
|
+
return runHookBinDiagnosticCommand();
|
|
1249
|
+
case "file-scope-guard":
|
|
1250
|
+
// BAPI-507 (N-2): warn-only worker file-scope guard. Always exits 0.
|
|
1251
|
+
return runFileScopeGuardCli();
|
|
1252
|
+
case "stop-run": {
|
|
1253
|
+
// BAPI-872: lazily imported so the Bridge credential/HTTP graph never
|
|
1254
|
+
// loads for a local-only command (doctor, emit-event, etc.).
|
|
1255
|
+
const { runStopRunCommand } = await import("./recovery-cli.js");
|
|
1256
|
+
return await runStopRunCommand(parsed.argv);
|
|
1257
|
+
}
|
|
1258
|
+
case "abandon-run": {
|
|
1259
|
+
const { runAbandonRunCommand } = await import("./recovery-cli.js");
|
|
1260
|
+
return await runAbandonRunCommand(parsed.argv);
|
|
1261
|
+
}
|
|
1262
|
+
case "unpark": {
|
|
1263
|
+
const { runUnparkCommand } = await import("./recovery-cli.js");
|
|
1264
|
+
return await runUnparkCommand(parsed.argv);
|
|
1265
|
+
}
|
|
1266
|
+
case "adopt-current-head-and-unpark": {
|
|
1267
|
+
const { runAdoptCurrentHeadAndUnparkCommand } = await import("./recovery-cli.js");
|
|
1268
|
+
return await runAdoptCurrentHeadAndUnparkCommand(parsed.argv);
|
|
1269
|
+
}
|
|
1270
|
+
// BAPI-1154: the three run-addressed repair verbs — CLI-only, and lazily
|
|
1271
|
+
// imported exactly like the four recovery verbs above.
|
|
1272
|
+
case "scope-reparse": {
|
|
1273
|
+
const { runScopeReparseCommand } = await import("./recovery-cli.js");
|
|
1274
|
+
return await runScopeReparseCommand(parsed.argv);
|
|
1275
|
+
}
|
|
1276
|
+
case "dispatch-remediation": {
|
|
1277
|
+
const { runDispatchRemediationCommand } = await import("./recovery-cli.js");
|
|
1278
|
+
return await runDispatchRemediationCommand(parsed.argv);
|
|
1279
|
+
}
|
|
1280
|
+
case "merge-child": {
|
|
1281
|
+
const { runMergeChildCommand } = await import("./recovery-cli.js");
|
|
1282
|
+
return await runMergeChildCommand(parsed.argv);
|
|
1283
|
+
}
|
|
1284
|
+
default:
|
|
1285
|
+
console.error('Error: Unknown command. Run "epic-implementer --help" for usage.');
|
|
1286
|
+
return 1;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
catch (error) {
|
|
1290
|
+
// S4 (BAPI-1080): standardized to a single sanitized-failure exit code (1)
|
|
1291
|
+
// rather than distinguishing 5xx (2) from other failures. Deliberate
|
|
1292
|
+
// non-catch outcomes of the underlying commands (e.g. a command's own
|
|
1293
|
+
// `return 1`) are untouched by this catch.
|
|
1294
|
+
const envelope = toConductorErrorEnvelope(error);
|
|
1295
|
+
console.error(`Error: ${formatConductorErrorLine(envelope)}`);
|
|
1296
|
+
return 1;
|
|
1297
|
+
}
|
|
1298
|
+
}
|