@bridge_gpt/mcp-server 0.2.18 → 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 (81) 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 +1 -1
  7. package/build/backend-warnings.js +44 -0
  8. package/build/claude-settings.js +129 -0
  9. package/build/commands.generated.js +1 -0
  10. package/build/conductor/bridge-api-client.js +7 -7
  11. package/build/conductor/cli.js +65 -12
  12. package/build/conductor/deny-enforcement-preflight.js +96 -0
  13. package/build/conductor/doctor.js +183 -2
  14. package/build/conductor/epic-reconcile.js +9 -1
  15. package/build/conductor/epic-runtime.js +403 -43
  16. package/build/conductor/epic-state.js +7 -0
  17. package/build/conductor/errors.js +115 -3
  18. package/build/conductor/event-accessors.js +28 -10
  19. package/build/conductor/merge-ledger.js +6 -4
  20. package/build/conductor/pr-ci-producer.js +17 -2
  21. package/build/conductor/producer-ledger.js +1 -1
  22. package/build/conductor/store.js +161 -18
  23. package/build/conductor/supervisor-merge.js +32 -5
  24. package/build/conductor/taxonomy.js +8 -0
  25. package/build/conductor/tools.js +28 -6
  26. package/build/conductor/worker-ledger-cli.js +244 -0
  27. package/build/conductor-bin.js +1884 -6917
  28. package/build/doctor.js +8 -0
  29. package/build/executor/cli.js +229 -0
  30. package/build/executor/credentials.js +65 -0
  31. package/build/executor/deps.js +117 -0
  32. package/build/executor/env.js +79 -0
  33. package/build/executor/heartbeat.js +59 -0
  34. package/build/executor/http-client.js +131 -0
  35. package/build/executor/index.js +10 -0
  36. package/build/executor/job-errors.js +55 -0
  37. package/build/executor/job-log-registry.js +110 -0
  38. package/build/executor/job-runner.js +688 -0
  39. package/build/executor/job-types.js +60 -0
  40. package/build/executor/merge-job.js +155 -0
  41. package/build/executor/observation.js +123 -0
  42. package/build/executor/permissions.js +79 -0
  43. package/build/executor/preflight.js +144 -0
  44. package/build/executor/process.js +81 -0
  45. package/build/executor/prompt-spec.js +235 -0
  46. package/build/executor/results.js +134 -0
  47. package/build/executor/resume-pre-spawn.js +179 -0
  48. package/build/executor/runner.js +98 -0
  49. package/build/executor/terminal-mutation.js +34 -0
  50. package/build/executor/test-clock.js +109 -0
  51. package/build/executor/types.js +18 -0
  52. package/build/executor/verdict-artifact.js +53 -0
  53. package/build/executor/viewer-tabs.js +78 -0
  54. package/build/executor/watch-cli.js +113 -0
  55. package/build/executor/worker-command.js +106 -0
  56. package/build/executor/worker-finalization.js +97 -0
  57. package/build/executor/worker-log.js +92 -0
  58. package/build/executor/worktree-gc.js +134 -0
  59. package/build/executor/worktree-inspection.js +86 -0
  60. package/build/executor/worktree.js +103 -0
  61. package/build/index.js +11222 -8544
  62. package/build/mcp-invoke.js +19 -3
  63. package/build/mcp-provisioning.js +31 -25
  64. package/build/mcp-registration-doctor.js +27 -7
  65. package/build/mcp-server-invocation.js +152 -0
  66. package/build/pipelines.generated.js +1 -1
  67. package/build/readme.generated.js +1 -1
  68. package/build/sfcc/reads-site-preference.js +52 -19
  69. package/build/start-tickets-conductor.js +25 -93
  70. package/build/start-tickets-prereqs.js +152 -1
  71. package/build/start-tickets.js +96 -158
  72. package/build/version.generated.js +1 -1
  73. package/build/visual-diff-worker.js +313 -0
  74. package/build/visual-diff.js +632 -0
  75. package/build/worktree-core.js +202 -0
  76. package/package.json +8 -4
  77. package/public/css/main.min.css +39 -0
  78. package/public/css/main.min.css.map +1 -1
  79. package/public/js/main.min.js +7924 -1
  80. package/public/js/main.min.js.map +1 -1
  81. package/smoke-test/SMOKE-TEST.md +2 -1
@@ -0,0 +1,109 @@
1
+ export class VirtualClock {
2
+ t = 0;
3
+ timers = [];
4
+ id = 0;
5
+ now = () => this.t;
6
+ setTimer = (cb, ms) => {
7
+ const id = ++this.id;
8
+ this.timers.push({ at: this.t + ms, cb, id });
9
+ return id;
10
+ };
11
+ clearTimer = (h) => {
12
+ this.timers = this.timers.filter((x) => x.id !== h);
13
+ };
14
+ sleep = (ms) => new Promise((res) => {
15
+ this.setTimer(() => res(), ms);
16
+ });
17
+ /** Fire timers in time order until `predicate()` holds or timers run out. */
18
+ async tickUntil(predicate, maxSteps = 5000) {
19
+ let steps = 0;
20
+ await flushMicrotasks();
21
+ while (!predicate() && this.timers.length > 0 && steps++ < maxSteps) {
22
+ this.timers.sort((a, b) => a.at - b.at);
23
+ const next = this.timers.shift();
24
+ this.t = next.at;
25
+ next.cb();
26
+ await flushMicrotasks();
27
+ }
28
+ }
29
+ }
30
+ async function flushMicrotasks() {
31
+ for (let i = 0; i < 12; i++)
32
+ await Promise.resolve();
33
+ }
34
+ /** A controllable fake owned process for `implement` spawn tests. */
35
+ export function makeControllableProcess(stdout) {
36
+ let resolveWait;
37
+ const waitP = new Promise((r) => {
38
+ resolveWait = r;
39
+ });
40
+ const signals = [];
41
+ let done = false;
42
+ return {
43
+ proc: {
44
+ pid: 4242,
45
+ stdout: stdout ?? null,
46
+ stderr: null,
47
+ wait: () => waitP,
48
+ kill: (s) => {
49
+ signals.push(s);
50
+ if (!done) {
51
+ done = true;
52
+ resolveWait({ exitCode: null, signal: s });
53
+ }
54
+ },
55
+ },
56
+ resolve: (v) => {
57
+ done = true;
58
+ resolveWait(v);
59
+ },
60
+ signals,
61
+ };
62
+ }
63
+ /** Build a full fake `ExecutorDeps`, driven by a `VirtualClock`. */
64
+ export function makeFakeExecutorDeps(clock, overrides = {}) {
65
+ const base = {
66
+ runCommand: async () => ({ stdout: "", stderr: "", exitCode: 0 }),
67
+ spawnProcess: () => {
68
+ throw new Error("spawnProcess not configured for this test");
69
+ },
70
+ readFile: async () => {
71
+ throw new Error("ENOENT");
72
+ },
73
+ writeFile: async () => { },
74
+ mkdir: async () => undefined,
75
+ stat: async () => ({ mode: 0o644 }),
76
+ statfs: async () => ({ bavail: 1_000_000, bsize: 4096 }),
77
+ sleep: clock.sleep,
78
+ now: clock.now,
79
+ setTimer: clock.setTimer,
80
+ clearTimer: clock.clearTimer,
81
+ env: {},
82
+ cwd: "/repo",
83
+ platform: "linux",
84
+ homedir: () => "/home/tester",
85
+ fetch: async () => ({ status: 204, text: async () => "" }),
86
+ log: () => { },
87
+ errorLog: () => { },
88
+ };
89
+ return { ...base, ...overrides };
90
+ }
91
+ /** Sensible default executor options for tests. */
92
+ export function makeTestOptions(overrides = {}) {
93
+ return {
94
+ executorId: "exec-test",
95
+ repos: ["repo-a"],
96
+ repoName: "repo-a",
97
+ maxConcurrent: 2,
98
+ pollIntervalMs: 15000,
99
+ heartbeatIntervalMs: 60000,
100
+ deadmanMs: 120000,
101
+ termGraceMs: 10000,
102
+ once: true,
103
+ worktrunkBinary: "wt",
104
+ baseBranch: "main",
105
+ advisoryParserEnabled: false,
106
+ defaultJobTimeoutSeconds: 1200,
107
+ ...overrides,
108
+ };
109
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Epic Conductor v2 executor — core type/boundary definitions (BAPI-534, TDD §7).
3
+ *
4
+ * ARCHITECTURE BOUNDARY (TDD §12, Req 1): files under `mcp_server/src/executor/`
5
+ * are a dumb, pull-based local worker. They MUST NOT import from the v1
6
+ * conductor event/ledger graph — `conductor/store.ts`, `conductor/taxonomy.ts`,
7
+ * `conductor/event-accessors.ts`, `conductor/epic-runtime.ts`,
8
+ * `conductor/epic-reconcile.ts`, `conductor/epic-state.ts`, or any conductor
9
+ * producer / supervisor-runtime module. Allowed conductor reuse is limited to
10
+ * read-only / deterministic helpers — the deny-enforcement preflight
11
+ * (`conductor/deny-enforcement-preflight.ts`), and (BAPI-535 T3b) the local merge
12
+ * executor (`conductor/local-merge.ts`) plus the read-only Bridge API access +
13
+ * merge request/response types (`conductor/bridge-api-client.ts`) for the
14
+ * deterministic `merge` job.
15
+ * All subprocess / HTTP / filesystem / clock access is behind injected deps so
16
+ * every requirement is unit-testable on Linux CI with no real I/O.
17
+ */
18
+ export {};
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Fixed-path verdict-artifact reading (BAPI-535, TDD §9).
3
+ *
4
+ * For verdict job types (`spec_review`, `smoke`) the executor reads exactly
5
+ * `<worktree>/.conductor/result.json` after the worker exits and posts the parsed
6
+ * object VERBATIM at `/complete`. A missing/unreadable/invalid artifact is a
7
+ * fail-loud named failure ({@link MissingVerdictArtifact}) — "a verdict can never
8
+ * be silently absent". The executor performs NO markdown parsing, NO verdict
9
+ * judgment, and NO schema-specific interpretation; per-`job_type` schema
10
+ * validation is server-owned at `/complete` (T2/T6).
11
+ */
12
+ import { MissingVerdictArtifact, ExecutorNamedError } from "./job-errors.js";
13
+ import { pathApiForExecutorPlatform } from "./worktree-inspection.js";
14
+ /** The fixed `.conductor` result directory (relative to the worktree). */
15
+ export const CONDUCTOR_RESULT_DIR = ".conductor";
16
+ /** The fixed verdict artifact filename. */
17
+ export const CONDUCTOR_RESULT_FILE = "result.json";
18
+ /** True only for verdict job types (`spec_review`, `smoke`). */
19
+ export function isVerdictJobType(jobType) {
20
+ return jobType === "spec_review" || jobType === "smoke";
21
+ }
22
+ /** Join `<worktree>/.conductor/result.json` with platform-correct separators. */
23
+ export function buildVerdictArtifactPath(worktreePath, platform = process.platform) {
24
+ const pathApi = pathApiForExecutorPlatform(platform);
25
+ return pathApi.join(worktreePath, CONDUCTOR_RESULT_DIR, CONDUCTOR_RESULT_FILE);
26
+ }
27
+ /**
28
+ * Read and parse `<worktree>/.conductor/result.json`. Returns the parsed JSON
29
+ * OBJECT verbatim. Throws {@link ExecutorNamedError} kind
30
+ * {@link MissingVerdictArtifact} for a missing/unreadable file, invalid JSON, or
31
+ * a non-object top-level value (array, `null`, string, number, boolean).
32
+ */
33
+ export async function readVerdictArtifact(worktreePath, deps) {
34
+ const artifactPath = buildVerdictArtifactPath(worktreePath, deps.platform ?? process.platform);
35
+ let raw;
36
+ try {
37
+ raw = await deps.readFile(artifactPath);
38
+ }
39
+ catch {
40
+ throw new ExecutorNamedError(MissingVerdictArtifact, `verdict artifact missing or unreadable at ${CONDUCTOR_RESULT_DIR}/${CONDUCTOR_RESULT_FILE}`);
41
+ }
42
+ let parsed;
43
+ try {
44
+ parsed = JSON.parse(raw);
45
+ }
46
+ catch {
47
+ throw new ExecutorNamedError(MissingVerdictArtifact, `verdict artifact ${CONDUCTOR_RESULT_DIR}/${CONDUCTOR_RESULT_FILE} is not valid JSON`);
48
+ }
49
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
50
+ throw new ExecutorNamedError(MissingVerdictArtifact, `verdict artifact ${CONDUCTOR_RESULT_DIR}/${CONDUCTOR_RESULT_FILE} must be a JSON object`);
51
+ }
52
+ return parsed;
53
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Optional read-only viewer tabs (BAPI-535, TDD §8, G5).
3
+ *
4
+ * Opens a NON-OWNING terminal tab that runs `tail -f` against a job's worker log,
5
+ * visually identical to the v1 per-ticket tabs but strictly read-only: closing
6
+ * the tab kills nothing, and nothing typed into it affects the job. The tab
7
+ * command is ALWAYS `tail -f <log>` — never `claude`, Worktrunk, or any
8
+ * worker-owning process — so the viewing surface is fully decoupled from the
9
+ * execution surface. Failures are non-fatal: opening a viewer never throws,
10
+ * never kills the worker, and never changes completion/failure reporting.
11
+ */
12
+ import { getDefaultSpawnTerminalTabForPlatform, } from "../start-tickets.js";
13
+ /** Env var that force-enables/disables viewer tabs regardless of platform. */
14
+ export const EXECUTOR_VIEWER_TABS_ENV = "BAPI_EXECUTOR_VIEWER_TABS";
15
+ const TRUE_TOKENS = new Set(["1", "true", "on"]);
16
+ const FALSE_TOKENS = new Set(["0", "false", "off"]);
17
+ /**
18
+ * Whether viewer tabs are enabled: default ON for `darwin`, OFF elsewhere, with
19
+ * `BAPI_EXECUTOR_VIEWER_TABS=1|true|on` / `0|false|off` overriding either way.
20
+ */
21
+ export function executorViewerTabsEnabled(platform, env) {
22
+ const raw = env[EXECUTOR_VIEWER_TABS_ENV];
23
+ if (typeof raw === "string") {
24
+ const token = raw.trim().toLowerCase();
25
+ if (TRUE_TOKENS.has(token))
26
+ return true;
27
+ if (FALSE_TOKENS.has(token))
28
+ return false;
29
+ }
30
+ return platform === "darwin";
31
+ }
32
+ /** Single-quote a path for safe embedding in a POSIX shell command. */
33
+ function shellQuote(value) {
34
+ return `'${value.replace(/'/g, `'\\''`)}'`;
35
+ }
36
+ /**
37
+ * Build the read-only tail-follow shell command. The executable action is always
38
+ * exactly `tail -f <worker-log-path>` — it can never own the worker process.
39
+ */
40
+ export function buildTailFollowCommand(workerLogPath) {
41
+ return `tail -f ${shellQuote(workerLogPath)}`;
42
+ }
43
+ /**
44
+ * Open a read-only viewer tab tailing the worker log. Returns a `skipped` result
45
+ * when viewer tabs are disabled and a `failed` result (never a throw) when the
46
+ * spawn boundary reports/raises a failure — the worker lifecycle is untouched.
47
+ */
48
+ export async function openExecutorViewerTab(options) {
49
+ if (!executorViewerTabsEnabled(options.platform, options.env)) {
50
+ return { status: "skipped", reason: "viewer tabs disabled for this platform/config" };
51
+ }
52
+ const command = buildTailFollowCommand(options.workerLogPath);
53
+ const spawnTab = options.spawnTab ?? getDefaultSpawnTerminalTabForPlatform(options.platform);
54
+ const terminal = options.terminal ?? "terminal";
55
+ // A minimal StartTicketsDeps for the shared spawner; `spawnTerminalTab` is
56
+ // self-referential (only used by higher-level orchestration, not the leaf
57
+ // spawner) and never spawns a worker here.
58
+ const startTicketsDeps = {
59
+ runCommand: options.runCommand,
60
+ platform: options.platform,
61
+ env: options.env,
62
+ cwd: options.cwd,
63
+ spawnTerminalTab: spawnTab,
64
+ };
65
+ try {
66
+ const result = await spawnTab(startTicketsDeps, terminal, command, {
67
+ key: options.ticketKey ?? "",
68
+ worktreePath: options.worktreePath,
69
+ });
70
+ if (result.ok)
71
+ return { status: "opened" };
72
+ return { status: "failed", reason: result.error };
73
+ }
74
+ catch (err) {
75
+ const reason = err instanceof Error ? err.message : String(err);
76
+ return { status: "failed", reason: reason.slice(0, 200) };
77
+ }
78
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * `executor watch <job>` subcommand (BAPI-535, TDD §8).
3
+ *
4
+ * Ad-hoc, READ-ONLY attachment to a running (or recently-run) job's worker log:
5
+ * it resolves the local job-log registry record, verifies the log file exists,
6
+ * and `tail -f`s it with inherited stdio. It NEVER sends a signal to the worker
7
+ * process and never owns the execution lifecycle — closing the watch leaves the
8
+ * worker untouched. Missing registry records or missing log files fail loud with
9
+ * actionable stderr text and a non-zero exit code.
10
+ */
11
+ import { spawn } from "node:child_process";
12
+ import { access } from "node:fs/promises";
13
+ import os from "node:os";
14
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
15
+ import { readExecutorJobLogRecord, } from "./job-log-registry.js";
16
+ export function getExecutorWatchUsage() {
17
+ return [
18
+ "Usage: mcp-server executor watch <job-id>",
19
+ "",
20
+ "Attach read-only to a running (or recently-run) executor job's worker log",
21
+ "via `tail -f`. Closing the watch never affects the worker process.",
22
+ "",
23
+ "Arguments:",
24
+ " <job-id> Numeric executor job id to watch.",
25
+ "",
26
+ "Options:",
27
+ " -h, --help Show this help.",
28
+ ].join("\n");
29
+ }
30
+ /** Accept EXACTLY one numeric job id; reject missing/non-numeric/extra args. */
31
+ export function parseExecutorWatchArgs(argv) {
32
+ const positionals = [];
33
+ for (const arg of argv) {
34
+ if (arg === "-h" || arg === "--help")
35
+ return { kind: "help" };
36
+ if (arg.startsWith("-"))
37
+ return { kind: "error", message: `unknown argument: ${arg}` };
38
+ positionals.push(arg);
39
+ }
40
+ if (positionals.length === 0) {
41
+ return { kind: "error", message: "a numeric <job-id> is required" };
42
+ }
43
+ if (positionals.length > 1) {
44
+ return { kind: "error", message: "exactly one <job-id> is accepted" };
45
+ }
46
+ const raw = positionals[0];
47
+ if (!/^[0-9]+$/.test(raw)) {
48
+ return { kind: "error", message: `job id must be numeric: '${raw}'` };
49
+ }
50
+ return { kind: "ok", jobId: Number(raw) };
51
+ }
52
+ /**
53
+ * Spawn `tail -f <log_path>` with inherited stdio for interactive CLI attachment.
54
+ * Resolves with the tail process's numeric exit code. NEVER signals the worker.
55
+ */
56
+ export function spawnTailFollow(logPath, spawnImpl = spawn) {
57
+ return new Promise((resolve) => {
58
+ const child = spawnImpl("tail", ["-f", logPath], { stdio: "inherit" });
59
+ child.on("close", (code) => resolve(code ?? 0));
60
+ child.on("error", () => resolve(1));
61
+ });
62
+ }
63
+ /** Build the default (production) registry deps from real fs/env/os boundaries. */
64
+ export function createDefaultWatchRegistryDeps() {
65
+ return {
66
+ readFile: (p) => readFile(p, "utf-8"),
67
+ writeFile: (p, data) => writeFile(p, data, "utf-8"),
68
+ mkdir: (p, opts) => mkdir(p, opts),
69
+ env: process.env,
70
+ tmpdir: () => os.tmpdir(),
71
+ platform: process.platform,
72
+ };
73
+ }
74
+ async function defaultLogExists(logPath) {
75
+ try {
76
+ await access(logPath);
77
+ return true;
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ }
83
+ /**
84
+ * Resolve the job-log record and attach read-only via `tail -f`. Returns a
85
+ * numeric exit code: `0` on a clean tail exit, non-zero on a missing registry
86
+ * record or missing log file (with actionable stderr).
87
+ */
88
+ export async function runExecutorWatchCli(argv, overrides = {}) {
89
+ const errorLog = overrides.errorLog ?? ((m) => console.error(m));
90
+ const parsed = parseExecutorWatchArgs(argv);
91
+ if (parsed.kind === "help") {
92
+ errorLog(getExecutorWatchUsage());
93
+ return 0;
94
+ }
95
+ if (parsed.kind === "error") {
96
+ errorLog(`Error: ${parsed.message}\n\n${getExecutorWatchUsage()}`);
97
+ return 1;
98
+ }
99
+ const registryDeps = overrides.registryDeps ?? createDefaultWatchRegistryDeps();
100
+ const readRecord = overrides.readRecord ?? readExecutorJobLogRecord;
101
+ const record = await readRecord(parsed.jobId, registryDeps);
102
+ if (!record.ok) {
103
+ errorLog(`Error: no worker log registered for job ${parsed.jobId}: ${record.reason}`);
104
+ return 1;
105
+ }
106
+ const logExists = overrides.logExists ?? defaultLogExists;
107
+ if (!(await logExists(record.record.log_path))) {
108
+ errorLog(`Error: worker log for job ${parsed.jobId} is missing at ${record.record.log_path}`);
109
+ return 1;
110
+ }
111
+ const spawnTail = overrides.spawnTail ?? ((logPath) => spawnTailFollow(logPath));
112
+ return spawnTail(record.record.log_path);
113
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Headless Claude worker command construction (BAPI-534, TDD §7).
3
+ *
4
+ * Resolves the exact argv, model alias, and prompt for a spawned worker. Model
5
+ * aliases resolve through the agent registry (never a raw untrusted string); no
6
+ * MCP config is ever added. When no safe alias is available, `--model` is omitted.
7
+ */
8
+ import { resolveAgentSpec, resolveModelAlias, isModelTier, isValidModelAlias, } from "../agent-registry.js";
9
+ import { isRecoveryJobType } from "./job-types.js";
10
+ /**
11
+ * Resolve the Claude model alias from a job payload. Accepts a direct
12
+ * `model_alias` only when it passes registry alias validation AND the static
13
+ * Claude allowlist; otherwise resolves from a valid `model_tier` through the
14
+ * registry. Returns `null` when no safe alias is available (so `--model` is
15
+ * omitted) — an unsafe/invalid string is never returned.
16
+ */
17
+ export function resolveExecutorModelAlias(payload) {
18
+ const agent = resolveAgentSpec("claude");
19
+ if (!agent)
20
+ return null;
21
+ const p = payload && typeof payload === "object" ? payload : {};
22
+ const directRaw = p.model_alias;
23
+ if (typeof directRaw === "string" && directRaw.trim().length > 0) {
24
+ const direct = directRaw.trim();
25
+ const allowed = !agent.staticModelAliasAllowlist || agent.staticModelAliasAllowlist.includes(direct);
26
+ if (isValidModelAlias(direct) && allowed) {
27
+ return direct;
28
+ }
29
+ // An invalid/disallowed direct alias falls through to the tier path — never
30
+ // returned as-is.
31
+ }
32
+ const tier = isModelTier(p.model_tier) ? p.model_tier : null;
33
+ return resolveModelAlias(agent, tier, null);
34
+ }
35
+ /**
36
+ * Resolve the worker prompt for the explicit-prompt and legacy synthesis paths.
37
+ * Uses an explicit non-empty `payload.prompt` verbatim; otherwise, for
38
+ * `implement` and the recovery jobs, builds the conservative
39
+ * `/implement-ticket <ticket_key> --auto` prompt.
40
+ *
41
+ * Structured `payload.prompt_spec` rendering (the `spec_review` path) is NOT done
42
+ * here: it needs the prepared worktree/git state, so `job-runner.ts` renders it
43
+ * in the prepared-spawn path AFTER worktree preparation. `spec_review` therefore
44
+ * returns `ok:false` from this function by design — that non-ok result is the
45
+ * signal for the runner to fall through to prompt-spec rendering, not a terminal
46
+ * failure. A real spawn job with neither an explicit prompt, a synthesis path,
47
+ * nor a `prompt_spec` is the genuine contract failure.
48
+ */
49
+ /**
50
+ * True for the job types that synthesize `/implement-ticket <KEY> --auto` when no
51
+ * explicit `payload.prompt` is present: the first-pass `implement` plus the
52
+ * recovery jobs (`remediate`/`ci_fix`/`rebase`), which re-enter the SAME
53
+ * correction loop on the ticket's existing branch/PR. Recovery classification is
54
+ * shared with dispatch and worktree reuse via `isRecoveryJobType` (`job-types.ts`)
55
+ * so eligibility cannot drift. Verdict jobs (`spec_review`/`smoke`) are NOT here —
56
+ * they carry an explicit `payload.prompt` and write a fixed-path artifact (T6/T5b).
57
+ */
58
+ function synthesizesImplementPrompt(jobType) {
59
+ return jobType === "implement" || isRecoveryJobType(jobType);
60
+ }
61
+ export function resolveExecutorPrompt(job) {
62
+ const payload = job.payload && typeof job.payload === "object"
63
+ ? job.payload
64
+ : {};
65
+ // An explicit prompt wins and is returned VERBATIM (un-trimmed), but a
66
+ // whitespace-only string is not a usable worker instruction — treat it as
67
+ // absent so a recovery/implement job still falls back to the synthesized
68
+ // `/implement-ticket <KEY> --auto` correction prompt below.
69
+ const explicit = payload.prompt;
70
+ if (typeof explicit === "string" && explicit.trim().length > 0) {
71
+ return { ok: true, prompt: explicit };
72
+ }
73
+ // `/implement-ticket <KEY> --auto` is the full correction engine, not just a
74
+ // first-pass implementer: its post-PR loop owns CI-failure correction, review-
75
+ // change addressing, and merge-conflict resolution (see .claude/commands/
76
+ // implement-ticket.md "Clean session exit"). So the recovery jobs that operate
77
+ // on the ticket's existing branch/PR — `remediate`/`ci_fix`/`rebase` — resume
78
+ // through the same command, which detects the outstanding CI/review/conflict
79
+ // state and continues rather than restarting. Without this, a code_review
80
+ // `changes_requested` (near-certain in real runs) enqueues a `remediate` job
81
+ // that fails `no usable prompt` and strands the run (BAPI-528 Milestone-A gap #3).
82
+ const ticketKey = typeof job.ticket_key === "string" && job.ticket_key.trim().length > 0
83
+ ? job.ticket_key.trim()
84
+ : null;
85
+ if (synthesizesImplementPrompt(job.job_type) && ticketKey) {
86
+ return { ok: true, prompt: `/implement-ticket ${ticketKey} --auto` };
87
+ }
88
+ return {
89
+ ok: false,
90
+ error: `no usable prompt for job_type '${job.job_type}' (no payload.prompt and no ticket_key)`,
91
+ };
92
+ }
93
+ /**
94
+ * Build the exact headless Claude argv. `--model <alias>` is included only when
95
+ * an alias is provided. No MCP config arguments are ever emitted.
96
+ */
97
+ export function buildClaudeExecutorArgv(prompt, alias) {
98
+ const argv = ["-p", prompt, "--output-format", "stream-json", "--verbose"];
99
+ if (alias) {
100
+ argv.push("--model", alias);
101
+ }
102
+ argv.push("--dangerously-skip-permissions");
103
+ return argv;
104
+ }
105
+ /** The worker executable is always exactly `claude`. */
106
+ export const CLAUDE_EXECUTABLE = "claude";
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Fail-loud worker-finalization check (BAPI-551).
3
+ *
4
+ * An implementation-style worker (`implement`/`resume`/`remediate`/`ci_fix`/
5
+ * `rebase`) can exit cleanly yet strand its work if its push never landed on
6
+ * origin and no PR was opened — e.g. the worker backgrounds a slow push (the
7
+ * advisory pre-push suite) and returns before it completes. Without this check
8
+ * the executor still reports `clean_exit`/`succeeded`, and the gap is only ever
9
+ * caught by the reconciler's 3h watchdog. `validateWorkerFinalization` converts
10
+ * that condition into an explicit executor failure instead, using a local
11
+ * `git ls-remote` check (no Bridge API credentials, no conductor identity).
12
+ *
13
+ * For `resume`/`remediate`/`ci_fix`/`rebase`, the branch is fetched from origin
14
+ * BEFORE the worker starts (worktree.ts's recovery reuse path, resume-pre-spawn's
15
+ * "recreate ONLY from the pushed branch" path) — so the ref existing on origin is
16
+ * not proof THIS session's commit landed; a stale pre-existing branch would pass
17
+ * an existence-only check. The check instead compares the origin branch's tip SHA
18
+ * against the worker's own HEAD commit (`headSha`, from git telemetry) whenever
19
+ * that comparison is available.
20
+ */
21
+ import { secretFreeErrorMessage, WorkerFinalizationMissingRemoteBranchAndPr } from "./job-errors.js";
22
+ import { isImplementationStyleJobType } from "./job-types.js";
23
+ function extractPrUrl(result) {
24
+ const raw = result.pr_url;
25
+ return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : undefined;
26
+ }
27
+ /** Normalize to exactly one `refs/heads/` prefix — never `refs/heads/refs/heads/...`. */
28
+ function normalizeBranchRef(branch) {
29
+ return branch.startsWith("refs/heads/") ? branch : `refs/heads/${branch}`;
30
+ }
31
+ /** Resolve the origin branch's current tip SHA, or `null` if the ref is absent. */
32
+ async function resolveOriginBranchSha(runCommand, worktreePath, branch) {
33
+ const result = await runCommand("git", ["ls-remote", "--exit-code", "--heads", "origin", normalizeBranchRef(branch)], { cwd: worktreePath });
34
+ if (result.exitCode !== 0)
35
+ return null;
36
+ const sha = result.stdout.trim().split(/\s+/)[0];
37
+ return sha && sha.length > 0 ? sha : null;
38
+ }
39
+ function missingBranchAndPrFailure(job, detail) {
40
+ const label = job.ticket_key ? `${job.ticket_key} (job ${job.id})` : `job ${job.id}`;
41
+ return {
42
+ error_kind: WorkerFinalizationMissingRemoteBranchAndPr,
43
+ error_message: `${label} exited cleanly but ${detail}`,
44
+ classification: "crashed",
45
+ };
46
+ }
47
+ /**
48
+ * Validate that an implementation-style job's clean exit actually produced a PR
49
+ * or a pushed origin branch. Non-implementation-style jobs (verdict jobs) and
50
+ * any result carrying a non-empty `pr_url` bypass the git check entirely.
51
+ */
52
+ export async function validateWorkerFinalization(input) {
53
+ const { job, branch, worktreePath, result, runCommand, headSha } = input;
54
+ if (!isImplementationStyleJobType(job.job_type)) {
55
+ return { ok: true };
56
+ }
57
+ if (extractPrUrl(result)) {
58
+ return { ok: true };
59
+ }
60
+ const trimmedBranch = typeof branch === "string" ? branch.trim() : "";
61
+ if (!trimmedBranch) {
62
+ return {
63
+ ok: false,
64
+ failure: missingBranchAndPrFailure(job, "produced no PR URL and no expected branch to verify on origin"),
65
+ };
66
+ }
67
+ let remoteSha;
68
+ try {
69
+ remoteSha = await resolveOriginBranchSha(runCommand, worktreePath, trimmedBranch);
70
+ }
71
+ catch (err) {
72
+ return {
73
+ ok: false,
74
+ failure: missingBranchAndPrFailure(job, `produced no PR URL and branch '${trimmedBranch}' could not be verified on origin: ${secretFreeErrorMessage(err)}`),
75
+ };
76
+ }
77
+ if (remoteSha === null) {
78
+ return {
79
+ ok: false,
80
+ failure: missingBranchAndPrFailure(job, `produced no PR URL and branch '${trimmedBranch}' is not on origin`),
81
+ };
82
+ }
83
+ // Recovery/resume jobs fetch `origin/<branch>` before the worker even starts,
84
+ // so the ref merely existing is not proof this session's commit landed. When
85
+ // the worker's own HEAD is known, require the origin tip to match it exactly —
86
+ // a stale pre-existing branch (this session's push never happened) fails loud
87
+ // instead of passing. When HEAD is unresolved (degraded telemetry), fall back
88
+ // to the existence-only check rather than inventing a new failure mode.
89
+ const trimmedHeadSha = typeof headSha === "string" ? headSha.trim() : "";
90
+ if (trimmedHeadSha && remoteSha !== trimmedHeadSha) {
91
+ return {
92
+ ok: false,
93
+ failure: missingBranchAndPrFailure(job, `produced no PR URL and origin branch '${trimmedBranch}' is at ${remoteSha.slice(0, 12)}, which does not match this worker's HEAD ${trimmedHeadSha.slice(0, 12)} — the worker's commit was not pushed`),
94
+ };
95
+ }
96
+ return { ok: true };
97
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Per-job worker log tee primitives (BAPI-535, TDD §8, G5).
3
+ *
4
+ * Every worker's output is teed to the fixed path `<worktree>/.conductor/worker.log`
5
+ * in human-readable form so `tail -f` reproduces the v1 tab experience on demand.
6
+ * The tee is PURELY a recorder: it never owns, signals, or kills the worker
7
+ * process — closing/finalizing the log affects the file only. All filesystem
8
+ * access is behind injected boundaries so this is unit-testable with no real I/O.
9
+ */
10
+ import { pathApiForExecutorPlatform } from "./worktree-inspection.js";
11
+ /** The fixed `.conductor` worker-log directory (relative to the worktree). */
12
+ export const CONDUCTOR_WORKER_LOG_DIR = ".conductor";
13
+ /** The fixed worker-log filename. */
14
+ export const CONDUCTOR_WORKER_LOG_FILE = "worker.log";
15
+ /** Resolve `<worktree>/.conductor/worker.log` with platform-correct separators. */
16
+ export function buildWorkerLogPath(worktreePath, platform = process.platform) {
17
+ const pathApi = pathApiForExecutorPlatform(platform);
18
+ return pathApi.join(worktreePath, CONDUCTOR_WORKER_LOG_DIR, CONDUCTOR_WORKER_LOG_FILE);
19
+ }
20
+ /**
21
+ * Render one stdout/stderr chunk as human-readable log text. The worker's stream
22
+ * is already text; this passes it through WITHOUT JSON framing, process metadata,
23
+ * or secret-bearing envelope fields. `stream` is accepted for future labeling but
24
+ * the default rendering is a faithful passthrough so `tail -f` shows raw output.
25
+ */
26
+ export function renderWorkerLogChunk(chunk, _stream = "stdout") {
27
+ return chunk;
28
+ }
29
+ /**
30
+ * Create the `.conductor` directory, initialize an empty `worker.log`, and return
31
+ * a tee handle. When an `appendFile` boundary is supplied, chunks are appended
32
+ * incrementally; otherwise a buffered fallback re-writes the accumulated content
33
+ * via `writeFile` so all consumed chunks are preserved in order.
34
+ */
35
+ export async function createWorkerLogTee(worktreePath, deps) {
36
+ const platform = deps.platform ?? process.platform;
37
+ const pathApi = pathApiForExecutorPlatform(platform);
38
+ const dir = pathApi.join(worktreePath, CONDUCTOR_WORKER_LOG_DIR);
39
+ const logPath = pathApi.join(dir, CONDUCTOR_WORKER_LOG_FILE);
40
+ await deps.mkdir(dir, { recursive: true });
41
+ await deps.writeFile(logPath, "");
42
+ let buffer = "";
43
+ let closed = false;
44
+ return {
45
+ logPath,
46
+ async append(chunk) {
47
+ if (closed)
48
+ return;
49
+ try {
50
+ if (deps.appendFile) {
51
+ await deps.appendFile(logPath, chunk);
52
+ }
53
+ else {
54
+ buffer += chunk;
55
+ await deps.writeFile(logPath, buffer);
56
+ }
57
+ }
58
+ catch {
59
+ /* the log tee is a visibility nicety — a write failure never fails a job */
60
+ }
61
+ },
62
+ async close() {
63
+ closed = true;
64
+ },
65
+ };
66
+ }
67
+ /**
68
+ * Wrap an `AsyncIterable<string>` so every consumed chunk is rendered and written
69
+ * to the worker log before being yielded to the existing runner logic. The
70
+ * original chunks pass through UNCHANGED, so downstream observation/classification
71
+ * is unaffected.
72
+ */
73
+ export async function* teeAsyncIterable(source, tee, stream = "stdout") {
74
+ for await (const chunk of source) {
75
+ await tee.append(renderWorkerLogChunk(chunk, stream));
76
+ yield chunk;
77
+ }
78
+ }
79
+ /**
80
+ * Safely finalize a tee. Tolerates a `null`/`undefined` tee and repeated calls;
81
+ * NEVER kills or signals the worker process.
82
+ */
83
+ export async function closeWorkerLogTee(tee) {
84
+ if (!tee)
85
+ return;
86
+ try {
87
+ await tee.close();
88
+ }
89
+ catch {
90
+ /* finalization is best-effort */
91
+ }
92
+ }