@bridge_gpt/mcp-server 0.2.18 → 0.2.20

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 (100) hide show
  1. package/CONDUCTOR.md +75 -0
  2. package/README.md +8 -5
  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 +5 -3
  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/local-merge.js +458 -95
  20. package/build/conductor/merge-ledger.js +6 -4
  21. package/build/conductor/pr-ci-producer.js +17 -2
  22. package/build/conductor/producer-ledger.js +1 -1
  23. package/build/conductor/store.js +161 -18
  24. package/build/conductor/supervisor-merge.js +32 -5
  25. package/build/conductor/taxonomy.js +8 -0
  26. package/build/conductor/tools.js +28 -6
  27. package/build/conductor/worker-ledger-cli.js +244 -0
  28. package/build/conductor-bin.js +1884 -6917
  29. package/build/doctor.js +8 -0
  30. package/build/estimate-epic.js +84 -0
  31. package/build/executor/cli.js +229 -0
  32. package/build/executor/credentials.js +65 -0
  33. package/build/executor/deps.js +117 -0
  34. package/build/executor/env.js +79 -0
  35. package/build/executor/heartbeat.js +59 -0
  36. package/build/executor/http-client.js +131 -0
  37. package/build/executor/index.js +10 -0
  38. package/build/executor/job-errors.js +55 -0
  39. package/build/executor/job-log-registry.js +110 -0
  40. package/build/executor/job-runner.js +822 -0
  41. package/build/executor/job-types.js +60 -0
  42. package/build/executor/merge-job.js +229 -0
  43. package/build/executor/observation.js +123 -0
  44. package/build/executor/permissions.js +79 -0
  45. package/build/executor/preflight.js +144 -0
  46. package/build/executor/process.js +81 -0
  47. package/build/executor/prompt-spec.js +235 -0
  48. package/build/executor/results.js +134 -0
  49. package/build/executor/resume-pre-spawn.js +179 -0
  50. package/build/executor/runner.js +98 -0
  51. package/build/executor/terminal-mutation.js +34 -0
  52. package/build/executor/test-clock.js +109 -0
  53. package/build/executor/types.js +18 -0
  54. package/build/executor/verdict-artifact.js +53 -0
  55. package/build/executor/viewer-tabs.js +78 -0
  56. package/build/executor/watch-cli.js +113 -0
  57. package/build/executor/worker-command.js +106 -0
  58. package/build/executor/worker-finalization.js +177 -0
  59. package/build/executor/worker-log.js +92 -0
  60. package/build/executor/worktree-gc.js +134 -0
  61. package/build/executor/worktree-inspection.js +86 -0
  62. package/build/executor/worktree.js +103 -0
  63. package/build/index.js +14534 -10416
  64. package/build/mcp-invoke.js +19 -3
  65. package/build/mcp-provisioning.js +31 -25
  66. package/build/mcp-registration-doctor.js +27 -7
  67. package/build/mcp-server-invocation.js +152 -0
  68. package/build/pipelines.generated.js +16 -20
  69. package/build/readme.generated.js +1 -1
  70. package/build/sfcc/client.js +192 -50
  71. package/build/sfcc/ocapi-write-faults.js +94 -0
  72. package/build/sfcc/permissions.js +7 -22
  73. package/build/sfcc/reads-site-preference.js +52 -19
  74. package/build/sfcc/register.js +9 -0
  75. package/build/sfcc/write-grants.js +80 -0
  76. package/build/sfcc/write-guard.js +39 -0
  77. package/build/sfcc/write-result.js +47 -0
  78. package/build/sfcc/write-tool-common.js +85 -0
  79. package/build/sfcc/writes-custom-object-def.js +141 -0
  80. package/build/sfcc/writes-object-attribute-payloads.js +97 -0
  81. package/build/sfcc/writes-site-preference-payloads.js +59 -0
  82. package/build/sfcc/writes-site-preference.js +96 -0
  83. package/build/sfcc/writes-system-object-payloads.js +213 -0
  84. package/build/sfcc/writes-system-object.js +348 -0
  85. package/build/sfcc/writes.js +66 -0
  86. package/build/start-tickets-conductor.js +25 -93
  87. package/build/start-tickets-prereqs.js +152 -1
  88. package/build/start-tickets.js +96 -158
  89. package/build/version.generated.js +1 -1
  90. package/build/visual-diff-worker.js +313 -0
  91. package/build/visual-diff.js +632 -0
  92. package/build/worktree-core.js +202 -0
  93. package/package.json +8 -4
  94. package/pipelines/idea-to-ticket.json +7 -0
  95. package/pipelines/review-ticket.json +5 -18
  96. package/public/css/main.min.css +1612 -107
  97. package/public/css/main.min.css.map +1 -1
  98. package/public/js/main.min.js +10267 -1
  99. package/public/js/main.min.js.map +1 -1
  100. package/smoke-test/SMOKE-TEST.md +2 -1
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Poll/claim executor runner (BAPI-534, TDD §7).
3
+ *
4
+ * Repeatedly preflights, claims capacity (only when `free_slots > 0`), and
5
+ * dispatches jobs without blocking the poll loop. Refuses to claim on any fatal
6
+ * preflight finding. `once` mode performs one preflight/claim cycle and returns
7
+ * after all active jobs finish. Depends on NO v1 conductor event/ledger module.
8
+ */
9
+ import { collectExecutorPreflight, buildClaimManifest } from "./preflight.js";
10
+ import { runClaimedJob } from "./job-runner.js";
11
+ import { sweepExecutorWorktrees } from "./worktree-gc.js";
12
+ /**
13
+ * Run the executor loop. Returns 0 on the `once` path (a single cycle drained);
14
+ * in continuous mode it does not return (the process is long-lived).
15
+ */
16
+ export async function runExecutor(options, deps, httpClient, seams = {}) {
17
+ const collectPreflight = seams.collectPreflight ?? collectExecutorPreflight;
18
+ const runJob = seams.runJob ?? runClaimedJob;
19
+ const sweepWorktrees = seams.sweepWorktrees ??
20
+ ((baseBranch) => sweepExecutorWorktrees({
21
+ runCommand: deps.runCommand,
22
+ cwd: deps.cwd,
23
+ baseBranch,
24
+ now: deps.now,
25
+ // Fall back to a no-activity probe when the deps builder omitted the
26
+ // optional mtime boundary (keeps GC conservative → retains).
27
+ statMtimeMs: deps.statMtimeMs ?? (async () => null),
28
+ platform: deps.platform,
29
+ }));
30
+ const active = new Map();
31
+ /**
32
+ * Run the conservative worktree GC sweep ONLY while the runner owns no active
33
+ * job worktree. Failures are logged to stderr and never abort claiming
34
+ * (BAPI-535, TDD §7 teardown).
35
+ */
36
+ async function maybeSweepWorktrees() {
37
+ if (active.size > 0)
38
+ return;
39
+ try {
40
+ await sweepWorktrees(options.baseBranch);
41
+ }
42
+ catch (err) {
43
+ const message = err instanceof Error ? err.message : String(err);
44
+ deps.errorLog(`executor worktree GC sweep failed (non-fatal): ${message.slice(0, 200)}`);
45
+ }
46
+ }
47
+ function dispatch(job, report) {
48
+ const promise = (async () => {
49
+ try {
50
+ await runJob(job, httpClient, options, deps, report);
51
+ }
52
+ catch (err) {
53
+ const message = err instanceof Error ? err.message : String(err);
54
+ deps.errorLog(`executor job ${job.id} crashed: ${message.slice(0, 200)}`);
55
+ }
56
+ finally {
57
+ active.delete(job.id);
58
+ }
59
+ })();
60
+ active.set(job.id, promise);
61
+ }
62
+ for (;;) {
63
+ const report = await collectPreflight(options, deps, seams.preflightSeams ?? {});
64
+ if (!report.ok) {
65
+ deps.errorLog(`executor preflight refused claiming: ${report.fatalFindings.join("; ")}`);
66
+ }
67
+ else {
68
+ let claiming = true;
69
+ while (claiming && options.maxConcurrent - active.size > 0) {
70
+ const freeSlots = options.maxConcurrent - active.size;
71
+ const manifest = buildClaimManifest(report, options, freeSlots);
72
+ const result = await httpClient.claim(manifest);
73
+ if (result.kind === "claimed") {
74
+ dispatch(result.job, report);
75
+ }
76
+ else if (result.kind === "none") {
77
+ claiming = false;
78
+ }
79
+ else {
80
+ deps.errorLog(`executor claim ${result.kind}: ${result.error}`);
81
+ claiming = false;
82
+ }
83
+ }
84
+ // Conservative worktree GC runs once per cycle, AFTER the claim attempt and
85
+ // only while idle (`active.size === 0` — no owned job worktree). This covers
86
+ // both the startup cycle and between-poll-cycle cadence without ever
87
+ // sweeping while a job is running. Kept off the pre-claim path so it never
88
+ // delays claiming a ready job.
89
+ await maybeSweepWorktrees();
90
+ }
91
+ if (options.once)
92
+ break;
93
+ await deps.sleep(options.pollIntervalMs);
94
+ }
95
+ // `once`: ensure every dispatched job promise settles before returning.
96
+ await Promise.all(active.values());
97
+ return 0;
98
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Send a terminal mutation with the fencing-aware retry policy. Skips entirely
3
+ * when ownership was already abandoned.
4
+ */
5
+ export async function sendTerminalMutationWithRetry(params) {
6
+ const { deps, options, ownership } = params;
7
+ if (ownership.abandoned)
8
+ return { outcome: "skipped" };
9
+ while (true) {
10
+ const outcome = await params.send();
11
+ if (outcome === "updated")
12
+ return { outcome: "success" };
13
+ if (outcome === "stale_claim") {
14
+ ownership.abandoned = true;
15
+ ownership.abandonReason = "stale_claim";
16
+ return { outcome: "stale_claim" };
17
+ }
18
+ if (outcome === "invalid_job_result") {
19
+ // The server already failed the job — reporting `/fail` afterward is wrong.
20
+ params.log?.(`${params.kind}: server rejected the result envelope (invalid_job_result)`);
21
+ return { outcome: "invalid_job_result" };
22
+ }
23
+ // retry_later / fatal_http_error — retryable within the dead-man window.
24
+ if (deps.now() - ownership.lastSuccessfulHeartbeatAt >= options.deadmanMs) {
25
+ ownership.abandoned = true;
26
+ ownership.abandonReason = "deadman";
27
+ return { outcome: "abandoned" };
28
+ }
29
+ params.log?.(`${params.kind}: transient mutation failure (${outcome}); retrying`);
30
+ await deps.sleep(options.heartbeatIntervalMs);
31
+ if (ownership.abandoned)
32
+ return { outcome: "abandoned" };
33
+ }
34
+ }
@@ -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";