@bridge_gpt/mcp-server 0.2.53 → 0.2.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +116 -56
  2. package/build/agent-guidance.generated.js +3 -0
  3. package/build/agent-launchers/claude-executor-adapter.js +3 -0
  4. package/build/agent-launchers/claude.js +3 -3
  5. package/build/agent-launchers/prompt.js +8 -11
  6. package/build/agent-notes.js +178 -0
  7. package/build/agent-registry.js +5 -2
  8. package/build/agent-utils.js +58 -0
  9. package/build/agents.generated.js +1 -1
  10. package/build/base-ref.js +33 -9
  11. package/build/bounded-wait.js +174 -0
  12. package/build/codex-skill-adapter.js +55 -0
  13. package/build/commands.generated.js +6 -5
  14. package/build/conductor/bridge-api-client.js +235 -14
  15. package/build/conductor/bring-up-facts.js +187 -0
  16. package/build/conductor/claude-hook.js +7 -5
  17. package/build/conductor/cli.js +28 -0
  18. package/build/conductor/doctor.js +80 -9
  19. package/build/conductor/epic-implementer-cli.js +1298 -0
  20. package/build/conductor/epic-runtime.js +134 -98
  21. package/build/conductor/errors.js +2 -2
  22. package/build/conductor/git-ci-types.js +1 -1
  23. package/build/conductor/git-hooks.js +28 -14
  24. package/build/conductor/install-doctor.js +11 -5
  25. package/build/conductor/readiness-cli.js +10 -10
  26. package/build/conductor/readiness-sections.js +58 -9
  27. package/build/conductor/readiness.js +120 -4
  28. package/build/conductor/recovery-cli.js +289 -10
  29. package/build/conductor/recovery-operations.js +125 -2
  30. package/build/conductor/repair-contract.js +58 -0
  31. package/build/conductor/run-branch.js +137 -0
  32. package/build/conductor/store.js +2 -2
  33. package/build/conductor/supervisor-runtime.js +1 -1
  34. package/build/conductor/test-run-branch-vectors.js +165 -0
  35. package/build/conductor-bin.js +2 -139
  36. package/build/conductor-claude-hook-bin.js +2 -2
  37. package/build/conductor-claude-hook-removed-stub-bin.js +31 -0
  38. package/build/conductor-removed-stub-bin.js +30 -0
  39. package/build/docs.generated.js +1 -1
  40. package/build/doctor.js +145 -18
  41. package/build/drive-epic.js +752 -90
  42. package/build/epic-implementer-bin.js +145 -0
  43. package/build/epic-implementer-bundle-cli.js +264 -0
  44. package/build/epic-implementer-claude-hook-bin.js +3 -0
  45. package/build/epic-integration-pr.js +5 -3
  46. package/build/executor/claim-scope.js +104 -0
  47. package/build/executor/cli.js +14 -25
  48. package/build/executor/env-file-guard.js +82 -3
  49. package/build/executor/env.js +6 -0
  50. package/build/executor/interrupted-worktree.js +60 -0
  51. package/build/executor/job-errors.js +45 -0
  52. package/build/executor/job-runner.js +334 -9
  53. package/build/executor/job-types.js +25 -9
  54. package/build/executor/merge-tree-classifier.js +171 -0
  55. package/build/executor/reconcile-overlap-governance.js +129 -0
  56. package/build/executor/reconcile-overlap-job.js +989 -0
  57. package/build/executor/reconcile-overlap-types.js +14 -0
  58. package/build/executor/spawn-job-driver.js +1 -0
  59. package/build/executor/types.js +2 -0
  60. package/build/executor/worker-finalization.js +25 -2
  61. package/build/executor/worker-guard-hook.js +15 -7
  62. package/build/implement-epic/bridge-client.js +773 -0
  63. package/build/implement-epic/checkpoint-store.js +542 -0
  64. package/build/implement-epic/cli.js +3158 -0
  65. package/build/implement-epic/cut-protocol.js +392 -0
  66. package/build/implement-epic/lock.js +302 -0
  67. package/build/implement-epic/pr-state.js +286 -0
  68. package/build/implement-epic/spawn.js +113 -0
  69. package/build/index.js +657 -481
  70. package/build/init.js +72 -8
  71. package/build/install-bridge-conductor.js +5 -5
  72. package/build/install-bridge.js +403 -70
  73. package/build/local-artifact-storage.js +130 -0
  74. package/build/mcp-host-config.js +22 -60
  75. package/build/mcp-host-entry-adapter.js +18 -0
  76. package/build/mcp-host-targets.js +1 -21
  77. package/build/merge-pull-request.js +1 -1
  78. package/build/pipelines.generated.js +23 -16
  79. package/build/plan-epic-conductor-eligibility.js +1 -1
  80. package/build/plane/cli.js +321 -41
  81. package/build/plane/manifest.js +209 -1
  82. package/build/plane/member-roster.js +70 -0
  83. package/build/plane/preflight.js +128 -12
  84. package/build/plane/shutdown.js +18 -5
  85. package/build/plane/status.js +35 -1
  86. package/build/plane/supervisor.js +546 -164
  87. package/build/plane/types.js +25 -2
  88. package/build/polling-policy.js +72 -0
  89. package/build/readiness-check.js +3 -3
  90. package/build/readme.generated.js +1 -1
  91. package/build/review-generation.js +219 -0
  92. package/build/run-unit-tests-launcher.js +6 -1
  93. package/build/setup-epic.js +567 -38
  94. package/build/start-tickets-conductor.js +8 -7
  95. package/build/ticket-key-utils.js +4 -3
  96. package/build/ticket-review-artifact-gate.js +461 -0
  97. package/build/upgrade-cli.js +5 -26
  98. package/build/version.generated.js +3 -3
  99. package/build/worker-guard-hook-bin.js +1 -1
  100. package/docs/CONDUCTOR.md +8 -6
  101. package/docs/install/mcp-tool-integrations.md +23 -1
  102. package/package.json +5 -3
  103. package/pipelines/review-ticket.json +17 -4
@@ -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
+ }