@bridge_gpt/mcp-server 0.2.16 → 0.2.19

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/CONDUCTOR.md +75 -0
  2. package/README.md +2 -2
  3. package/build/agent-capabilities/probe-context.js +13 -3
  4. package/build/agent-capabilities/probes.js +262 -11
  5. package/build/agent-capabilities/reporter.js +1 -0
  6. package/build/agents.generated.js +3 -3
  7. package/build/backend-warnings.js +44 -0
  8. package/build/claude-settings.js +129 -0
  9. package/build/commands.generated.js +7 -6
  10. package/build/conductor/bridge-api-client.js +198 -18
  11. package/build/conductor/claude-hook.js +22 -4
  12. package/build/conductor/cli.js +76 -25
  13. package/build/conductor/deny-enforcement-preflight.js +96 -0
  14. package/build/conductor/doctor.js +183 -2
  15. package/build/conductor/done-gate.js +5 -0
  16. package/build/conductor/epic-reconcile.js +71 -14
  17. package/build/conductor/epic-runtime.js +839 -67
  18. package/build/conductor/epic-state.js +524 -63
  19. package/build/conductor/errors.js +156 -3
  20. package/build/conductor/event-accessors.js +252 -0
  21. package/build/conductor/file-scope-guard.js +201 -0
  22. package/build/conductor/github-mergeability.js +85 -0
  23. package/build/conductor/local-merge.js +47 -1
  24. package/build/conductor/merge-identity.js +41 -0
  25. package/build/conductor/merge-ledger.js +19 -72
  26. package/build/conductor/plan.js +12 -2
  27. package/build/conductor/pr-ci-producer.js +17 -2
  28. package/build/conductor/pr-discovery.js +11 -1
  29. package/build/conductor/producer-ledger.js +1 -1
  30. package/build/conductor/store.js +161 -18
  31. package/build/conductor/supervisor-config.js +4 -39
  32. package/build/conductor/supervisor-escalation.js +10 -26
  33. package/build/conductor/supervisor-ledger.js +5 -12
  34. package/build/conductor/supervisor-merge.js +32 -5
  35. package/build/conductor/supervisor-message-relay.js +2 -5
  36. package/build/conductor/supervisor-notification.js +1 -1
  37. package/build/conductor/supervisor-runtime.js +12 -54
  38. package/build/conductor/supervisor-state.js +4 -18
  39. package/build/conductor/supervisor-types.js +2 -2
  40. package/build/conductor/taxonomy.js +12 -0
  41. package/build/conductor/tools.js +28 -6
  42. package/build/conductor/worker-ledger-cli.js +244 -0
  43. package/build/conductor-bin.js +1800 -5166
  44. package/build/conductor-claude-hook-bin.js +4 -2
  45. package/build/doctor.js +40 -0
  46. package/build/executor/cli.js +229 -0
  47. package/build/executor/credentials.js +65 -0
  48. package/build/executor/deps.js +117 -0
  49. package/build/executor/env.js +79 -0
  50. package/build/executor/heartbeat.js +59 -0
  51. package/build/executor/http-client.js +131 -0
  52. package/build/executor/index.js +10 -0
  53. package/build/executor/job-errors.js +55 -0
  54. package/build/executor/job-log-registry.js +110 -0
  55. package/build/executor/job-runner.js +688 -0
  56. package/build/executor/job-types.js +60 -0
  57. package/build/executor/merge-job.js +155 -0
  58. package/build/executor/observation.js +123 -0
  59. package/build/executor/permissions.js +79 -0
  60. package/build/executor/preflight.js +144 -0
  61. package/build/executor/process.js +81 -0
  62. package/build/executor/prompt-spec.js +235 -0
  63. package/build/executor/results.js +134 -0
  64. package/build/executor/resume-pre-spawn.js +179 -0
  65. package/build/executor/runner.js +98 -0
  66. package/build/executor/terminal-mutation.js +34 -0
  67. package/build/executor/test-clock.js +109 -0
  68. package/build/executor/types.js +18 -0
  69. package/build/executor/verdict-artifact.js +53 -0
  70. package/build/executor/viewer-tabs.js +78 -0
  71. package/build/executor/watch-cli.js +113 -0
  72. package/build/executor/worker-command.js +106 -0
  73. package/build/executor/worker-finalization.js +97 -0
  74. package/build/executor/worker-log.js +92 -0
  75. package/build/executor/worktree-gc.js +134 -0
  76. package/build/executor/worktree-inspection.js +86 -0
  77. package/build/executor/worktree.js +103 -0
  78. package/build/index.js +13950 -9669
  79. package/build/install-bridge.js +25 -8
  80. package/build/install-doctor.js +387 -0
  81. package/build/mcp-invoke.js +19 -3
  82. package/build/mcp-provisioning.js +31 -25
  83. package/build/mcp-registration-doctor.js +27 -7
  84. package/build/mcp-server-invocation.js +152 -0
  85. package/build/pipelines.generated.js +31 -6
  86. package/build/readme.generated.js +1 -1
  87. package/build/regression-check.js +53 -1
  88. package/build/review-tickets.js +175 -21
  89. package/build/sfcc/reads-site-preference.js +52 -19
  90. package/build/start-tickets-conductor.js +47 -99
  91. package/build/start-tickets-prereqs.js +185 -4
  92. package/build/start-tickets.js +218 -180
  93. package/build/version.generated.js +1 -1
  94. package/build/visual-diff-worker.js +313 -0
  95. package/build/visual-diff.js +632 -0
  96. package/build/worktree-core.js +202 -0
  97. package/package.json +10 -6
  98. package/pipelines/review-ticket.json +24 -2
  99. package/public/css/main.min.css +3311 -1
  100. package/public/css/main.min.css.map +1 -1
  101. package/public/js/main.min.js +7924 -1
  102. package/public/js/main.min.js.map +1 -1
  103. package/smoke-test/SMOKE-TEST.md +5 -2
@@ -74,16 +74,174 @@ export function buildConductorJiraUrl(baseUrl, apiPath, params = {}) {
74
74
  }
75
75
  return url.toString();
76
76
  }
77
- /** Sanitized HTTP error for conductor Bridge API calls — never leaks secrets. */
77
+ const CONDUCTOR_BRIDGE_API_ERROR_KINDS = [
78
+ "invalid-input",
79
+ "network",
80
+ "timeout",
81
+ "unauthorized",
82
+ "server",
83
+ "http",
84
+ ];
85
+ /** Max length of the sanitized body preview embedded in error diagnostics. */
86
+ const CONDUCTOR_ERROR_PREVIEW_MAX = 200;
87
+ /**
88
+ * Redact obvious secret-shaped tokens from a diagnostic preview string so a
89
+ * backend message that happens to echo a token/header never reaches a log line.
90
+ */
91
+ function redactErrorPreview(text) {
92
+ return text
93
+ .replace(/sk-[A-Za-z0-9_-]{8,}/g, "[REDACTED]")
94
+ .replace(/(Bearer|X-API-Key|api[_-]?key)\b\s*[:=]?\s*\S+/gi, "$1 [REDACTED]");
95
+ }
96
+ /** Collapse whitespace, redact secrets, and bound a string to the preview cap. */
97
+ function boundedErrorPreview(text) {
98
+ const redacted = redactErrorPreview(text).replace(/\s+/g, " ").trim();
99
+ return redacted.length > CONDUCTOR_ERROR_PREVIEW_MAX
100
+ ? `${redacted.slice(0, CONDUCTOR_ERROR_PREVIEW_MAX)}…`
101
+ : redacted;
102
+ }
103
+ /**
104
+ * Extract sanitized diagnostics from a parsed backend error body. Recognizes the
105
+ * FastAPI/backend shapes `{ detail: { error_code, message } }`,
106
+ * `{ detail: "..." }`, and `{ error_code, message }`; any other shape yields a
107
+ * bounded stringified preview. Never throws and never returns raw/unbounded text.
108
+ */
109
+ export function extractSanitizedErrorDiagnostics(body) {
110
+ if (typeof body === "string") {
111
+ const trimmed = body.trim();
112
+ return trimmed ? { bodyPreview: boundedErrorPreview(trimmed) } : {};
113
+ }
114
+ if (!body || typeof body !== "object") {
115
+ return {};
116
+ }
117
+ const record = body;
118
+ const detail = record["detail"];
119
+ let errorCode;
120
+ let message;
121
+ if (detail && typeof detail === "object") {
122
+ const d = detail;
123
+ if (typeof d["error_code"] === "string")
124
+ errorCode = d["error_code"];
125
+ if (typeof d["message"] === "string")
126
+ message = d["message"];
127
+ }
128
+ else if (typeof detail === "string") {
129
+ message = detail;
130
+ }
131
+ if (!errorCode && typeof record["error_code"] === "string") {
132
+ errorCode = record["error_code"];
133
+ }
134
+ if (!message && typeof record["message"] === "string") {
135
+ message = record["message"];
136
+ }
137
+ const diagnostics = {};
138
+ if (errorCode)
139
+ diagnostics.errorCode = boundedErrorPreview(errorCode);
140
+ if (message)
141
+ diagnostics.bodyPreview = boundedErrorPreview(message);
142
+ return diagnostics;
143
+ }
144
+ /** Redact exact secret substrings (e.g. the API key from the request headers). */
145
+ function redactDiagnosticValues(diagnostics, secrets) {
146
+ const scrub = (text) => {
147
+ let out = text;
148
+ for (const secret of secrets) {
149
+ if (secret && secret.length >= 4)
150
+ out = out.split(secret).join("[REDACTED]");
151
+ }
152
+ return out;
153
+ };
154
+ const out = {};
155
+ if (diagnostics.errorCode)
156
+ out.errorCode = scrub(diagnostics.errorCode);
157
+ if (diagnostics.bodyPreview)
158
+ out.bodyPreview = scrub(diagnostics.bodyPreview);
159
+ return out;
160
+ }
161
+ /**
162
+ * Best-effort read of a non-2xx response body into sanitized diagnostics. Never
163
+ * throws — a missing/invalid/non-JSON body simply yields `{}`. Any auth-header
164
+ * values in `headers` (e.g. `X-API-Key`) are additionally redacted from the
165
+ * preview so a body that echoes the credential can never surface it.
166
+ */
167
+ async function readSanitizedErrorDiagnostics(resp, headers = {}) {
168
+ try {
169
+ const diagnostics = extractSanitizedErrorDiagnostics(await resp.json());
170
+ const secrets = Object.entries(headers)
171
+ .filter(([k]) => /key|authorization|token/i.test(k))
172
+ .map(([, v]) => v);
173
+ return redactDiagnosticValues(diagnostics, secrets);
174
+ }
175
+ catch {
176
+ return {};
177
+ }
178
+ }
179
+ /**
180
+ * Sanitized HTTP error for conductor Bridge API calls — never leaks secrets.
181
+ *
182
+ * Construction is backward-compatible in two forms:
183
+ * - `new ConductorBridgeApiError(kind, status?, diagnostics?)` — the canonical
184
+ * form; builds a message from the coarse kind plus optional HTTP status,
185
+ * backend `error_code`, and a bounded secret-redacted message preview.
186
+ * - `new ConductorBridgeApiError("some legacy message")` — a bare message
187
+ * string (not one of the known kinds) is preserved verbatim as the error
188
+ * message, with `kind` defaulting to `"http"` and no diagnostic fields set.
189
+ */
78
190
  export class ConductorBridgeApiError extends Error {
79
191
  kind;
80
192
  status;
81
- constructor(kind, status) {
82
- super(`Conductor Bridge API request failed (${kind}${typeof status === "number" ? `, status ${status}` : ""})`);
193
+ errorCode;
194
+ bodyPreview;
195
+ constructor(kindOrMessage, status, diagnostics) {
196
+ const isKnownKind = CONDUCTOR_BRIDGE_API_ERROR_KINDS.includes(kindOrMessage);
197
+ const errorCode = diagnostics?.errorCode;
198
+ const bodyPreview = diagnostics?.bodyPreview;
199
+ if (isKnownKind) {
200
+ const parts = [
201
+ `Conductor Bridge API request failed (${kindOrMessage}${typeof status === "number" ? `, status ${status}` : ""})`,
202
+ ];
203
+ if (errorCode)
204
+ parts.push(`code=${errorCode}`);
205
+ if (bodyPreview)
206
+ parts.push(bodyPreview);
207
+ super(parts.join(": "));
208
+ }
209
+ else {
210
+ // Legacy/backward-compatible bare-message construction.
211
+ super(kindOrMessage);
212
+ }
83
213
  this.name = "ConductorBridgeApiError";
84
- this.kind = kind;
85
- this.status = status;
214
+ this.kind = (isKnownKind ? kindOrMessage : "http");
215
+ if (typeof status === "number")
216
+ this.status = status;
217
+ if (errorCode)
218
+ this.errorCode = errorCode;
219
+ if (bodyPreview)
220
+ this.bodyPreview = bodyPreview;
221
+ }
222
+ }
223
+ /**
224
+ * Build a bounded, secret-free diagnostic string from an unknown thrown error
225
+ * for tick warnings/logs. For a {@link ConductorBridgeApiError} it surfaces the
226
+ * coarse kind, HTTP status, backend error code, and sanitized message preview
227
+ * (never a stack, raw body, or secret). For any other Error it returns the
228
+ * constructor name; non-Errors fall back to `fallback`.
229
+ */
230
+ export function safeDiagnosticMessage(err, fallback) {
231
+ if (err instanceof ConductorBridgeApiError) {
232
+ const parts = [`kind=${err.kind}`];
233
+ if (typeof err.status === "number")
234
+ parts.push(`status=${err.status}`);
235
+ if (err.errorCode)
236
+ parts.push(`code=${err.errorCode}`);
237
+ if (err.bodyPreview)
238
+ parts.push(err.bodyPreview);
239
+ return parts.join(" ");
240
+ }
241
+ if (err instanceof Error) {
242
+ return err.constructor.name;
86
243
  }
244
+ return fallback;
87
245
  }
88
246
  /** GET auth headers. The API key travels ONLY in a header, never in the URL. */
89
247
  function conductorGetHeaders(access) {
@@ -108,13 +266,14 @@ export async function fetchConductorJsonWithTimeout(url, headers, timeoutMs, fet
108
266
  throw new ConductorBridgeApiError(controller.signal.aborted ? "timeout" : "network");
109
267
  }
110
268
  if (!resp.ok) {
269
+ const diagnostics = await readSanitizedErrorDiagnostics(resp, headers);
111
270
  if (resp.status === 401 || resp.status === 403) {
112
- throw new ConductorBridgeApiError("unauthorized", resp.status);
271
+ throw new ConductorBridgeApiError("unauthorized", resp.status, diagnostics);
113
272
  }
114
273
  if (resp.status >= 500) {
115
- throw new ConductorBridgeApiError("server", resp.status);
274
+ throw new ConductorBridgeApiError("server", resp.status, diagnostics);
116
275
  }
117
- throw new ConductorBridgeApiError("http", resp.status);
276
+ throw new ConductorBridgeApiError("http", resp.status, diagnostics);
118
277
  }
119
278
  try {
120
279
  return await resp.json();
@@ -252,13 +411,14 @@ async function fetchConductorJsonWithMethodAndTimeout(method, url, headers, body
252
411
  throw new ConductorBridgeApiError(controller.signal.aborted ? "timeout" : "network");
253
412
  }
254
413
  if (!resp.ok) {
414
+ const diagnostics = await readSanitizedErrorDiagnostics(resp, headers);
255
415
  if (resp.status === 401 || resp.status === 403) {
256
- throw new ConductorBridgeApiError("unauthorized", resp.status);
416
+ throw new ConductorBridgeApiError("unauthorized", resp.status, diagnostics);
257
417
  }
258
418
  if (resp.status >= 500) {
259
- throw new ConductorBridgeApiError("server", resp.status);
419
+ throw new ConductorBridgeApiError("server", resp.status, diagnostics);
260
420
  }
261
- throw new ConductorBridgeApiError("http", resp.status);
421
+ throw new ConductorBridgeApiError("http", resp.status, diagnostics);
262
422
  }
263
423
  try {
264
424
  return await resp.json();
@@ -391,6 +551,8 @@ function requireNoSlashPathSegment(value) {
391
551
  const EPIC_TICKET_STATUS_VALUES = [
392
552
  "planned", "ready", "dispatched", "running", "blocked", "abandoned", "done", "ready_for_review",
393
553
  "reviewing",
554
+ // BAPI-527: asynchronous post-merge parse state (see EpicTicketStatusValue).
555
+ "parse_pending",
394
556
  ];
395
557
  function requireEpicTicketStatusValue(value) {
396
558
  if (typeof value !== "string" || !EPIC_TICKET_STATUS_VALUES.includes(value)) {
@@ -508,6 +670,26 @@ export async function fetchActiveEpicRuns(access, fetchImpl = globalThis.fetch)
508
670
  }
509
671
  return [];
510
672
  }
673
+ /**
674
+ * PATCH `/jira/epic-runs/runs/{identifier}` to transition an epic run's
675
+ * lifecycle status. Drives the same backend `update_epic_run` CAS path the
676
+ * manual close-out used: passing `expectedStatus` makes the transition a safe
677
+ * compare-and-swap (a stale status surfaces as a sanitized
678
+ * {@link ConductorBridgeApiError} — HTTP 400 VALIDATION — rather than clobbering
679
+ * a concurrent terminal transition). Prefer passing the concrete run UUID as
680
+ * `epicKey` when the runtime has it. Returns the normalized {@link EpicRunRecord}.
681
+ */
682
+ export async function updateEpicRunStatus(access, request, fetchImpl = globalThis.fetch) {
683
+ requireNonEmptyString(request.epicKey);
684
+ const url = buildConductorJiraUrl(access.baseUrl, epicRunApiPath(request.epicKey));
685
+ const body = JSON.stringify({
686
+ repo_name: access.repoName,
687
+ status: request.status,
688
+ ...(request.expectedStatus ? { expected_status: request.expectedStatus } : {}),
689
+ });
690
+ const parsed = await fetchConductorJsonPatchWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
691
+ return parsed;
692
+ }
511
693
  // ---------------------------------------------------------------------------
512
694
  // Per-ticket CAS status advancement
513
695
  // ---------------------------------------------------------------------------
@@ -746,14 +928,12 @@ export async function getEpicPlan(access, epicKey, planVersion, fetchImpl = glob
746
928
  const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
747
929
  return parsed;
748
930
  }
749
- // ---------------------------------------------------------------------------
750
- // Parse pipeline helpers (BAPI-415)
751
- // ---------------------------------------------------------------------------
752
931
  /**
753
- * GET `/jira/parse-status?repo_name=<repo>` and return the live parse lock
754
- * status. Only two states are possible: `"in_progress"` (the async parse job
755
- * holds the lock) and `"idle"` (no lock is held). Throws a sanitized
756
- * {@link ConductorBridgeApiError} on any transport/auth/server failure.
932
+ * GET `/jira/parse-status?repo_name=<repo>` and return the durable parse-run
933
+ * status. See {@link ParseRepositoryStatusValue} for the full state set (the
934
+ * status surface distinguishes queued/running from terminal succeeded/failed).
935
+ * Throws a sanitized {@link ConductorBridgeApiError} on any transport/auth/server
936
+ * failure. URL + auth are unchanged from the pre-BAPI-527 two-state client.
757
937
  */
758
938
  export async function fetchParseStatus(access, fetchImpl = globalThis.fetch) {
759
939
  const url = buildConductorJiraUrl(access.baseUrl, "/parse-status", {
@@ -3,8 +3,11 @@
3
3
  *
4
4
  * This is a dependency-light command-hook entrypoint registered into a spawned
5
5
  * Claude worker's `.claude/settings.local.json` by `start-tickets`. Claude Code
6
- * invokes it for lifecycle events (SessionStart, Stop, SubagentStop,
7
- * Notification, optionally PreToolUse), passing the native hook JSON on stdin.
6
+ * invokes it for lifecycle events (SessionStart, SessionEnd, Notification,
7
+ * optionally PreToolUse), passing the native hook JSON on stdin. `Stop` and
8
+ * `SubagentStop` are intentionally NOT registered/emitted: they are per-turn /
9
+ * per-subagent events, not session-terminal (BAPI-507 N-1); only `SessionEnd`
10
+ * is a true session end and maps to `run.stopped`.
8
11
  * The writer:
9
12
  *
10
13
  * 1. reads + parses the native Claude hook payload from stdin,
@@ -50,15 +53,30 @@ export function resolveClaudeHookEventName(payload) {
50
53
  /**
51
54
  * Map a Claude lifecycle event name to a canonical conductor semantic type.
52
55
  * Unknown events map to `null` (the caller then skips emission).
56
+ *
57
+ * BAPI-507 (N-1): `Stop` and `SubagentStop` are intentionally ignored (→ null).
58
+ * Claude Code fires `Stop` at the end of EACH assistant turn and `SubagentStop`
59
+ * at each Task-subagent completion — NOT only at session termination. Mapping
60
+ * them to `run.stopped` folded a ticket to `ready_for_review` from minutes into
61
+ * the session, while the worker was still implementing with no branch pushed and
62
+ * no PR. The only true session-terminal event is `SessionEnd`, which maps to
63
+ * `run.stopped`. (Verified against the Claude Code lifecycle-hook contract:
64
+ * `SessionEnd` fires once when the session ends; `Stop`/`SubagentStop` are
65
+ * per-turn/per-subagent.)
53
66
  */
54
67
  export function mapClaudeHookEventToSemanticType(eventName) {
55
68
  switch (eventName) {
56
69
  case "SessionStart":
57
70
  return "run.started";
58
- case "Stop":
71
+ case "SessionEnd":
72
+ // The one true session-terminal event: the worker's session has ended.
59
73
  return "run.stopped";
74
+ case "Stop":
75
+ // Per-turn boundary, NOT session end — deliberately ignored (BAPI-507 N-1).
76
+ return null;
60
77
  case "SubagentStop":
61
- return "run.stopped";
78
+ // Per-subagent completion, NOT session end — deliberately ignored.
79
+ return null;
62
80
  case "Notification":
63
81
  return "agent.notification";
64
82
  case "PreToolUse":
@@ -14,10 +14,12 @@
14
14
  * `process.exit` itself (the bin wrapper owns that).
15
15
  */
16
16
  import { readFileSync, unlinkSync } from "node:fs";
17
- import { ConductorValidationError, toConductorErrorEnvelope } from "./errors.js";
17
+ import { ConductorValidationError, ConductorEpicTickV1FrozenError, toConductorErrorEnvelope, } from "./errors.js";
18
18
  import { emitConductorEvent, purgeConductorLedger, sendWorkerMessage, checkWorkerMessages, } from "./store.js";
19
+ import { isDuplicateConstraintError } from "./producer-ledger.js";
19
20
  import { SEMANTIC_EVENT_TYPES } from "./taxonomy.js";
20
21
  import { installConductorGitHooks } from "./git-hooks.js";
22
+ import { runFileScopeGuardCli } from "./file-scope-guard.js";
21
23
  import { runPostCommitHookProducer, runReferenceTransactionHookProducer } from "./git-producer.js";
22
24
  import { buildConductorDoctorReport, formatConductorDoctorReport } from "./doctor.js";
23
25
  import { resolveSupervisorConfig } from "./supervisor-config.js";
@@ -25,6 +27,21 @@ import { resolveSupervisorConfig } from "./supervisor-config.js";
25
27
  // pulls in the SQLite store, and a static import here would force every conductor
26
28
  // CLI invocation (and every test that mocks ./store.js for the other commands) to
27
29
  // resolve the full supervisor/store graph eagerly.
30
+ /**
31
+ * Human-readable error line for a sanitized conductor error envelope. For the
32
+ * BAPI-526 LEDGER_NATIVE_MODULE_LOAD_FAILED envelope ONLY, append a compact,
33
+ * allowlisted `details:` suffix (module / node_version / node_modules_abi) so a
34
+ * CLI operator can see the ABI skew. Never prints arbitrary detail keys, paths,
35
+ * stacks, argv, or raw caught messages.
36
+ */
37
+ export function formatConductorErrorLine(envelope) {
38
+ if (envelope.error === "LEDGER_NATIVE_MODULE_LOAD_FAILED" && envelope.details) {
39
+ const d = envelope.details;
40
+ return (`${envelope.message} ` +
41
+ `details: module=${d.module} node_version=${d.node_version} node_modules_abi=${d.node_modules_abi}`);
42
+ }
43
+ return envelope.message;
44
+ }
28
45
  /** Human-readable usage text for the conductor CLI. */
29
46
  export function getConductorUsage() {
30
47
  return [
@@ -46,14 +63,14 @@ export function getConductorUsage() {
46
63
  " git-hook post-commit Run the post-commit producer (invoked by the installed hook)",
47
64
  " git-hook reference-transaction --phase <p> --stdin-file <f>",
48
65
  " Run the reference-transaction producer (invoked by the hook)",
66
+ " file-scope-guard Warn-only: compare the branch diff against the declared",
67
+ " touched-file set (always exits 0; never blocks a PR)",
49
68
  "",
50
69
  "supervise options:",
51
70
  " --run-id <id> Run/session identifier to supervise (required)",
52
71
  " --wake-interval-ms <n> Deterministic event-poll cadence (clamped 30000..60000)",
53
72
  " --global-timeout-ms <n> Total wall-clock ceiling for the run",
54
- " --llm-budget-calls <n> Max LLM judgment calls per run before degraded-only mode",
55
73
  " --escalation-cooldown-ms <n> Min gap between escalations for the same worker+reason",
56
- " --no-llm Deterministic-only mode (never call the LLM judgment boundary)",
57
74
  "",
58
75
  "install-git-hooks notes:",
59
76
  " Hooks are LOCAL, unversioned, opportunistic, and bypassable. Missing hooks are a",
@@ -102,6 +119,9 @@ export function getConductorUsage() {
102
119
  "",
103
120
  "doctor / purge options:",
104
121
  " --json Print machine-readable JSON",
122
+ " --no-deny-probe (doctor only) Skip the deny-enforcement preflight — no headless",
123
+ " agent is spawned; the deny_enforcement section reports an",
124
+ " explicit skipped state (enforcement UNVERIFIED, never enforced)",
105
125
  "",
106
126
  "Examples:",
107
127
  " conductor emit-event --type run.started --source git-hook --run-id BAPI-393 \\",
@@ -151,6 +171,7 @@ const VALID_COMMANDS = new Set([
151
171
  "purge",
152
172
  "install-git-hooks",
153
173
  "git-hook",
174
+ "file-scope-guard",
154
175
  ]);
155
176
  /**
156
177
  * Parse the top-level conductor argv into a subcommand (without a CLI
@@ -215,6 +236,7 @@ function tokenizeFlags(argv, valueFlags, boolFlags) {
215
236
  const EMIT_VALUE_FLAGS = new Set([
216
237
  "--type",
217
238
  "--source",
239
+ "--id",
218
240
  "--subject",
219
241
  "--run-id",
220
242
  "--worker-id",
@@ -313,6 +335,10 @@ export function parseEmitEventArgs(argv, deps = {}) {
313
335
  const input = {
314
336
  source,
315
337
  type: type,
338
+ // Optional caller-supplied deterministic id (BAPI-527): forwarded to the store
339
+ // so a duplicate collides on the `events.id` UNIQUE constraint, giving the
340
+ // worker gate path server-side dedup without an in-process ledger poll.
341
+ id: values.get("--id"),
316
342
  subject: values.get("--subject"),
317
343
  run_id: values.get("--run-id"),
318
344
  worker_id: values.get("--worker-id"),
@@ -342,7 +368,22 @@ export async function runEmitEventCommand(argv, deps = {}) {
342
368
  console.log(getConductorUsage());
343
369
  return 0;
344
370
  }
345
- const result = await emitConductorEvent(parsed.input);
371
+ let result;
372
+ try {
373
+ result = await emitConductorEvent(parsed.input);
374
+ }
375
+ catch (error) {
376
+ // A forwarded deterministic `--id` (BAPI-527) that collides on the events.id
377
+ // UNIQUE constraint is a server-side dedup, not a failure: report it as a
378
+ // structured duplicate so the worker gate path can treat it as a no-op emit
379
+ // without ever polling the ledger in-process.
380
+ if (isDuplicateConstraintError(error)) {
381
+ const dup = { ok: false, reason: "duplicate" };
382
+ console.log(parsed.json ? JSON.stringify(dup) : JSON.stringify(dup, null, 2));
383
+ return 0;
384
+ }
385
+ throw error;
386
+ }
346
387
  if (parsed.json) {
347
388
  console.log(JSON.stringify(result));
348
389
  }
@@ -514,21 +555,25 @@ export async function runCheckMessagesCommand(argv) {
514
555
  return 0;
515
556
  }
516
557
  const DIAGNOSTIC_BOOL_FLAGS = new Set(["--json", "--help"]);
558
+ const DOCTOR_BOOL_FLAGS = new Set([...DIAGNOSTIC_BOOL_FLAGS, "--no-deny-probe"]);
517
559
  /**
518
560
  * Run the strictly read-only `doctor` command. Combines ledger health, git hook
519
561
  * health, and epic-tick schedule enablement status. `--json` emits the full
520
562
  * report with `epic_tick` alongside `git_hooks` at the top level.
563
+ * `--no-deny-probe` skips the deny-enforcement preflight (no headless agent
564
+ * spawn); the report then carries an explicit skipped state, never enforced.
521
565
  */
522
- export async function runDoctorCommand(argv) {
523
- const { bools } = tokenizeFlags(argv, new Set(), DIAGNOSTIC_BOOL_FLAGS);
566
+ export async function runDoctorCommand(argv, deps = {}) {
567
+ const { bools } = tokenizeFlags(argv, new Set(), DOCTOR_BOOL_FLAGS);
524
568
  if (bools.has("--help")) {
525
569
  console.log(getConductorUsage());
526
570
  return 0;
527
571
  }
528
572
  // scheduleDeps omitted: buildConductorDoctorReport lazily loads schedule-run.
529
- const report = await buildConductorDoctorReport({});
573
+ // A caller may inject deps (e.g. a fake deny inspector in tests) to stay hermetic.
574
+ const report = await buildConductorDoctorReport(bools.has("--no-deny-probe") ? { ...deps, skipDenyProbe: true } : deps);
530
575
  if (bools.has("--json")) {
531
- console.log(JSON.stringify({ ...report.ledger, git_hooks: report.git_hooks, epic_tick: report.epic_tick, mcp_profile: report.mcp_profile }));
576
+ console.log(JSON.stringify({ ...report.ledger, git_hooks: report.git_hooks, epic_tick: report.epic_tick, mcp_profile: report.mcp_profile, native_ledger: report.native_ledger, deny_enforcement: report.deny_enforcement }));
532
577
  return 0;
533
578
  }
534
579
  console.log(formatConductorDoctorReport(report));
@@ -685,7 +730,17 @@ export function applyEpicTickPreToolUseDefault(env = process.env) {
685
730
  * `conductor doctor` / `emit-event` invocation never eagerly resolves the
686
731
  * epic/store graph.
687
732
  */
688
- export async function runEpicTickCommand(argv) {
733
+ export async function runEpicTickCommand(_argv) {
734
+ // BAPI-534 (Epic Conductor v2 slice 0): the v1 epic-tick path is FROZEN. This
735
+ // fail-loud guard is the FIRST executable statement — before argument parsing,
736
+ // `applyEpicTickPreToolUseDefault()`, and any lazy `import("./epic-runtime.js")`
737
+ // — so no v1 tick (or its store/supervisor graph) can ever run. Interactive
738
+ // `/start-tickets` is unaffected. `runConductorCli` catches this named error
739
+ // and formats it consistently (EPIC_TICK_V1_FROZEN → exit 1).
740
+ throw new ConductorEpicTickV1FrozenError();
741
+ }
742
+ /** @deprecated v1 epic-tick body — retained unreachable behind the freeze guard. */
743
+ export async function runEpicTickCommandLegacy(argv) {
689
744
  const parsed = parseEpicTickArgs(argv);
690
745
  if (parsed.help) {
691
746
  console.log(getConductorUsage());
@@ -761,7 +816,7 @@ export async function runApprovePlanCommand(argv) {
761
816
  }
762
817
  catch (error) {
763
818
  const envelope = toConductorErrorEnvelope(error);
764
- console.error(`Error: ${envelope.message}`);
819
+ console.error(`Error: ${formatConductorErrorLine(envelope)}`);
765
820
  return 1;
766
821
  }
767
822
  if (parsed.help) {
@@ -809,7 +864,7 @@ export async function runApprovePlanCommand(argv) {
809
864
  console.log(JSON.stringify(envelope));
810
865
  }
811
866
  else {
812
- console.error(`Error: ${envelope.message}`);
867
+ console.error(`Error: ${formatConductorErrorLine(envelope)}`);
813
868
  }
814
869
  return envelope.status >= 500 ? 2 : 1;
815
870
  }
@@ -847,7 +902,7 @@ export async function runEpicStatusCommand(argv) {
847
902
  }
848
903
  catch (error) {
849
904
  const envelope = toConductorErrorEnvelope(error);
850
- console.error(`Error: ${envelope.message}`);
905
+ console.error(`Error: ${formatConductorErrorLine(envelope)}`);
851
906
  return 1;
852
907
  }
853
908
  if (parsed.help) {
@@ -913,7 +968,7 @@ export async function runEpicStatusCommand(argv) {
913
968
  console.log(JSON.stringify(envelope));
914
969
  }
915
970
  else {
916
- console.error(`Error: ${envelope.message}`);
971
+ console.error(`Error: ${formatConductorErrorLine(envelope)}`);
917
972
  }
918
973
  return envelope.status >= 500 ? 2 : 1;
919
974
  }
@@ -925,10 +980,9 @@ const SUPERVISE_VALUE_FLAGS = new Set([
925
980
  "--run-id",
926
981
  "--wake-interval-ms",
927
982
  "--global-timeout-ms",
928
- "--llm-budget-calls",
929
983
  "--escalation-cooldown-ms",
930
984
  ]);
931
- const SUPERVISE_BOOL_FLAGS = new Set(["--no-llm", "--help"]);
985
+ const SUPERVISE_BOOL_FLAGS = new Set(["--help"]);
932
986
  /** Parse a required positive-integer flag, raising a sanitized error otherwise. */
933
987
  function parsePositiveIntFlag(values, flag) {
934
988
  const raw = values.get(flag);
@@ -946,9 +1000,8 @@ function parsePositiveIntFlag(values, flag) {
946
1000
  /**
947
1001
  * Parse `supervise` flags. `--run-id` is required and must be non-empty /
948
1002
  * non-whitespace. Numeric overrides are validated as non-negative integers (the
949
- * config resolver clamps them to safe bounds); `--no-llm` selects
950
- * deterministic-only mode. Malformed input raises a sanitized
951
- * {@link ConductorValidationError}.
1003
+ * config resolver clamps them to safe bounds). Malformed input raises a
1004
+ * sanitized {@link ConductorValidationError}.
952
1005
  */
953
1006
  export function parseSuperviseArgs(argv) {
954
1007
  const { values, bools } = tokenizeFlags(argv, SUPERVISE_VALUE_FLAGS, SUPERVISE_BOOL_FLAGS);
@@ -966,14 +1019,9 @@ export function parseSuperviseArgs(argv) {
966
1019
  const globalTimeout = parsePositiveIntFlag(values, "--global-timeout-ms");
967
1020
  if (globalTimeout !== undefined)
968
1021
  overrides.global_timeout_ms = globalTimeout;
969
- const llmCalls = parsePositiveIntFlag(values, "--llm-budget-calls");
970
- if (llmCalls !== undefined)
971
- overrides.llm_max_calls = llmCalls;
972
1022
  const cooldown = parsePositiveIntFlag(values, "--escalation-cooldown-ms");
973
1023
  if (cooldown !== undefined)
974
1024
  overrides.escalation_cooldown_ms = cooldown;
975
- if (bools.has("--no-llm"))
976
- overrides.llm_enabled = false;
977
1025
  return { runId: runIdRaw.trim(), overrides, help: false };
978
1026
  }
979
1027
  /**
@@ -990,7 +1038,7 @@ export async function runSuperviseCommand(argv) {
990
1038
  }
991
1039
  const config = resolveSupervisorConfig(parsed.overrides);
992
1040
  console.log(`[supervisor] starting run=${parsed.runId} wake=${config.wake_interval_ms}ms ` +
993
- `global_timeout=${config.global_timeout_ms}ms llm=${config.llm_enabled ? `on(${config.llm_max_calls})` : "off"}`);
1041
+ `global_timeout=${config.global_timeout_ms}ms`);
994
1042
  const { runSupervisor } = await import("./supervisor-runtime.js");
995
1043
  const result = await runSupervisor({ run_id: parsed.runId, config });
996
1044
  return result.exit_code;
@@ -1056,6 +1104,9 @@ export async function runConductorCli(argv) {
1056
1104
  return runInstallGitHooksCommand(parsed.argv);
1057
1105
  case "git-hook":
1058
1106
  return await runGitHookCommand(parsed.argv);
1107
+ case "file-scope-guard":
1108
+ // BAPI-507 (N-2): warn-only worker file-scope guard. Always exits 0.
1109
+ return runFileScopeGuardCli();
1059
1110
  default:
1060
1111
  console.error('Error: Unknown command. Run "conductor --help" for usage.');
1061
1112
  return 1;
@@ -1063,7 +1114,7 @@ export async function runConductorCli(argv) {
1063
1114
  }
1064
1115
  catch (error) {
1065
1116
  const envelope = toConductorErrorEnvelope(error);
1066
- console.error(`Error: ${envelope.message}`);
1117
+ console.error(`Error: ${formatConductorErrorLine(envelope)}`);
1067
1118
  return envelope.status >= 500 ? 2 : 1;
1068
1119
  }
1069
1120
  }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Executor deny-enforcement preflight (TDD §7 / §11, R8).
3
+ *
4
+ * v2's permission model is "skip-permissions + a deterministic deny-layer": workers
5
+ * run `claude -p … --dangerously-skip-permissions` with a small stable deny set. But
6
+ * whether `permissions.deny` is actually enforced under `--dangerously-skip-permissions`
7
+ * is version-specific and must be PROBED, never assumed. This module exports the single
8
+ * reusable predicate the T3a executor's claim loop calls at startup/preflight before
9
+ * claiming any job: on a failed deny probe with no working fallback it returns
10
+ * `enforced: false`, and the executor must refuse to claim jobs (a fatal finding).
11
+ *
12
+ * This ticket (T9) defines the INTERFACE only; the claim loop that consumes it lands in
13
+ * T3a. The predicate is a thin wrapper over the shared `runDenyEnforcementCheck` — it
14
+ * never re-derives probe logic, never touches the wider probe registry, and never runs
15
+ * the advisory output-format / stream-json probes. It is exception-safe: any unexpected
16
+ * failure resolves to a fatal `enforced: false` result rather than throwing.
17
+ */
18
+ import { resolveAgentSpec } from "../agent-registry.js";
19
+ import { createProbeContext } from "../agent-capabilities/probe-context.js";
20
+ import { createDefaultAgentCapabilitiesDeps } from "../agent-capabilities/default-deps.js";
21
+ import { runDenyEnforcementCheck } from "../agent-capabilities/probes.js";
22
+ /** Canonical refuse-to-claim directive included in every fatal/degraded-fatal result. */
23
+ const REFUSE_TO_CLAIM_WARNING = "The executor claim loop MUST refuse to claim jobs until settings permissions.deny " +
24
+ "or the PreToolUse fallback enforces the deny layer.";
25
+ /**
26
+ * Run the deny-enforcement preflight and map the shared deny-check outcome onto the
27
+ * standard inspection shape. Never throws — unexpected exceptions become a fatal
28
+ * `enforced: false` result. Always cleans up the probe context's temp dirs.
29
+ */
30
+ export async function runDenyEnforcementPreflight(opts = {}) {
31
+ try {
32
+ const agent = resolveAgentSpec("claude");
33
+ if (!agent) {
34
+ return {
35
+ enforced: false,
36
+ layer: "none",
37
+ degraded: true,
38
+ warnings: [
39
+ "Could not resolve the 'claude' agent spec for the deny-enforcement preflight.",
40
+ REFUSE_TO_CLAIM_WARNING,
41
+ ],
42
+ };
43
+ }
44
+ const deps = opts.capabilityDeps ?? createDefaultAgentCapabilitiesDeps();
45
+ const { ctx, cleanup } = await createProbeContext(deps, agent, opts.timeoutMs);
46
+ try {
47
+ const { result, layer } = await runDenyEnforcementCheck(ctx, {
48
+ model: opts.model,
49
+ timeoutMs: opts.timeoutMs,
50
+ });
51
+ if (result.status === "pass" && layer === "settings-deny") {
52
+ return {
53
+ enforced: true,
54
+ layer: "settings-deny",
55
+ degraded: false,
56
+ warnings: [],
57
+ status: result.status,
58
+ detail: result.detail,
59
+ };
60
+ }
61
+ if (result.status === "pass" && layer === "pretooluse-hook") {
62
+ return {
63
+ enforced: true,
64
+ layer: "pretooluse-hook",
65
+ degraded: true,
66
+ warnings: [
67
+ "settings permissions.deny was not enforced under --dangerously-skip-permissions; relying on PreToolUse fallback.",
68
+ ],
69
+ status: result.status,
70
+ detail: result.detail,
71
+ };
72
+ }
73
+ // Any other outcome (fail / hang / spawn-error) — deny is not enforced.
74
+ return {
75
+ enforced: false,
76
+ layer: "none",
77
+ degraded: true,
78
+ warnings: [`Deny-layer enforcement probe did not pass: ${result.detail}`, REFUSE_TO_CLAIM_WARNING],
79
+ status: result.status,
80
+ detail: result.detail,
81
+ };
82
+ }
83
+ finally {
84
+ await cleanup();
85
+ }
86
+ }
87
+ catch (err) {
88
+ const msg = err instanceof Error ? err.message : String(err);
89
+ return {
90
+ enforced: false,
91
+ layer: "none",
92
+ degraded: true,
93
+ warnings: [`Deny-layer enforcement preflight failed unexpectedly: ${msg}`, REFUSE_TO_CLAIM_WARNING],
94
+ };
95
+ }
96
+ }