@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,177 @@
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 an authoritative
11
+ * local `git ls-remote` check (no Bridge API credentials, no conductor identity)
12
+ * that reads the real origin tip — never a stale local `origin/<branch>` tracking
13
+ * ref. That lookup is optionally repeated a few times with a short bounded delay
14
+ * before reporting `WorkerFinalizationMissingRemoteBranchAndPr`, so a recovery
15
+ * job whose push has landed but whose remote visibility settles just after the
16
+ * worker exits is not spuriously failed (BAPI-566 Bug A).
17
+ *
18
+ * For `resume`/`remediate`/`ci_fix`/`rebase`, the branch is fetched from origin
19
+ * BEFORE the worker starts (worktree.ts's recovery reuse path, resume-pre-spawn's
20
+ * "recreate ONLY from the pushed branch" path) — so the ref existing on origin is
21
+ * not proof THIS session's commit landed; a stale pre-existing branch would pass
22
+ * an existence-only check. The check instead compares the authoritative origin
23
+ * branch tip SHA against the worker's own HEAD commit (`headSha`, from git
24
+ * telemetry) whenever that comparison is available.
25
+ */
26
+ import { secretFreeErrorMessage, WorkerFinalizationMissingRemoteBranchAndPr } from "./job-errors.js";
27
+ import { isImplementationStyleJobType } from "./job-types.js";
28
+ /** Bounded settling re-check defaults for the authoritative origin-tip lookup. */
29
+ const DEFAULT_ORIGIN_FINALIZATION_ATTEMPTS = 3;
30
+ const DEFAULT_ORIGIN_FINALIZATION_RETRY_DELAY_MS = 500;
31
+ /** Real-timer sleep; overridable via the `sleep` seam for deterministic tests. */
32
+ function sleepMs(ms) {
33
+ return new Promise((resolve) => setTimeout(resolve, ms));
34
+ }
35
+ function normalizeAttempts(value) {
36
+ return typeof value === "number" && Number.isInteger(value) && value > 0
37
+ ? value
38
+ : DEFAULT_ORIGIN_FINALIZATION_ATTEMPTS;
39
+ }
40
+ function normalizeRetryDelay(value) {
41
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
42
+ ? Math.floor(value)
43
+ : DEFAULT_ORIGIN_FINALIZATION_RETRY_DELAY_MS;
44
+ }
45
+ function extractPrUrl(result) {
46
+ const raw = result.pr_url;
47
+ return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : undefined;
48
+ }
49
+ /** Normalize to exactly one `refs/heads/` prefix — never `refs/heads/refs/heads/...`. */
50
+ function normalizeBranchRef(branch) {
51
+ return branch.startsWith("refs/heads/") ? branch : `refs/heads/${branch}`;
52
+ }
53
+ /**
54
+ * Scan `git ls-remote` stdout and return the SHA for EXACTLY `expectedRef`.
55
+ * `ls-remote` lines are `<sha>\t<ref>`; only an exact ref match counts, so a
56
+ * prefix/suffix/child ref (`…-old`, `…/retry`) is never accepted. The returned
57
+ * SHA is trimmed and lower-cased so callers compare against a normalized worker
58
+ * HEAD. Raw stderr and credentials are never touched here.
59
+ */
60
+ function parseLsRemoteHeadSha(stdout, expectedRef) {
61
+ for (const line of stdout.split("\n")) {
62
+ const trimmed = line.trim();
63
+ if (trimmed.length === 0)
64
+ continue;
65
+ const parts = trimmed.split(/\s+/);
66
+ if (parts.length < 2)
67
+ continue;
68
+ const [sha, ref] = parts;
69
+ if (ref === expectedRef) {
70
+ const normalized = sha.trim().toLowerCase();
71
+ return normalized.length > 0 ? normalized : null;
72
+ }
73
+ }
74
+ return null;
75
+ }
76
+ /**
77
+ * Resolve the origin branch's authoritative current tip SHA (normalized), or
78
+ * `null` if the ref is absent. Uses `git ls-remote` against the exact normalized
79
+ * ref — never a local `origin/<branch>` remote-tracking ref, which can be stale
80
+ * on a recovery worktree. A non-zero exit is treated as "branch not found"
81
+ * without copying raw stderr into diagnostics.
82
+ */
83
+ export async function resolveOriginBranchSha(runCommand, worktreePath, branch) {
84
+ const normalizedRef = normalizeBranchRef(branch);
85
+ const result = await runCommand("git", ["ls-remote", "--exit-code", "--heads", "origin", normalizedRef], { cwd: worktreePath });
86
+ if (result.exitCode !== 0)
87
+ return null;
88
+ return parseLsRemoteHeadSha(result.stdout, normalizedRef);
89
+ }
90
+ /**
91
+ * Repeatedly resolve the authoritative origin tip until it either satisfies
92
+ * finalization or the bounded retry budget is exhausted. Stops early (no further
93
+ * lookups) when the origin tip matches the worker HEAD, or when the branch exists
94
+ * and the worker HEAD is unavailable (degraded telemetry, existence-only). Retries
95
+ * when the tip is `null` (branch not yet visible) or, with a known HEAD, does not
96
+ * yet match. Returns the LAST authoritative observation — the caller decides
97
+ * loud-failure from that final value, so true negatives are preserved.
98
+ */
99
+ async function resolveOriginBranchShaForFinalization(runCommand, worktreePath, branch, trimmedHeadSha, attempts, retryDelayMs, sleep) {
100
+ const total = Math.max(1, attempts);
101
+ let remoteSha = null;
102
+ for (let attempt = 0; attempt < total; attempt++) {
103
+ remoteSha = await resolveOriginBranchSha(runCommand, worktreePath, branch);
104
+ if (remoteSha !== null) {
105
+ // Existence-only success when HEAD is unknown; exact-match success otherwise.
106
+ if (!trimmedHeadSha || remoteSha === trimmedHeadSha)
107
+ return remoteSha;
108
+ }
109
+ if (attempt < total - 1)
110
+ await sleep(retryDelayMs);
111
+ }
112
+ return remoteSha;
113
+ }
114
+ function missingBranchAndPrFailure(job, detail) {
115
+ const label = job.ticket_key ? `${job.ticket_key} (job ${job.id})` : `job ${job.id}`;
116
+ return {
117
+ error_kind: WorkerFinalizationMissingRemoteBranchAndPr,
118
+ error_message: `${label} exited cleanly but ${detail}`,
119
+ classification: "crashed",
120
+ };
121
+ }
122
+ /**
123
+ * Validate that an implementation-style job's clean exit actually produced a PR
124
+ * or a pushed origin branch. Non-implementation-style jobs (verdict jobs) and
125
+ * any result carrying a non-empty `pr_url` bypass the git check entirely.
126
+ */
127
+ export async function validateWorkerFinalization(input) {
128
+ const { job, branch, worktreePath, result, runCommand, headSha } = input;
129
+ if (!isImplementationStyleJobType(job.job_type)) {
130
+ return { ok: true };
131
+ }
132
+ if (extractPrUrl(result)) {
133
+ return { ok: true };
134
+ }
135
+ const trimmedBranch = typeof branch === "string" ? branch.trim() : "";
136
+ if (!trimmedBranch) {
137
+ return {
138
+ ok: false,
139
+ failure: missingBranchAndPrFailure(job, "produced no PR URL and no expected branch to verify on origin"),
140
+ };
141
+ }
142
+ // Recovery/resume jobs fetch `origin/<branch>` before the worker even starts,
143
+ // so the ref merely existing is not proof this session's commit landed. When
144
+ // the worker's own HEAD is known, require the authoritative origin tip to match
145
+ // it exactly — a stale pre-existing branch (this session's push never happened)
146
+ // fails loud instead of passing. When HEAD is unresolved (degraded telemetry),
147
+ // fall back to the existence-only check rather than inventing a new failure
148
+ // mode. The authoritative lookup is retried a few times so a push that settles
149
+ // just after the worker exits still finalizes (BAPI-566 Bug A).
150
+ const trimmedHeadSha = typeof headSha === "string" ? headSha.trim().toLowerCase() : "";
151
+ const attempts = normalizeAttempts(input.originResolveAttempts);
152
+ const retryDelayMs = normalizeRetryDelay(input.originResolveRetryDelayMs);
153
+ const sleep = input.sleep ?? sleepMs;
154
+ let remoteSha;
155
+ try {
156
+ remoteSha = await resolveOriginBranchShaForFinalization(runCommand, worktreePath, trimmedBranch, trimmedHeadSha, attempts, retryDelayMs, sleep);
157
+ }
158
+ catch (err) {
159
+ return {
160
+ ok: false,
161
+ failure: missingBranchAndPrFailure(job, `produced no PR URL and branch '${trimmedBranch}' could not be verified on origin: ${secretFreeErrorMessage(err)}`),
162
+ };
163
+ }
164
+ if (remoteSha === null) {
165
+ return {
166
+ ok: false,
167
+ failure: missingBranchAndPrFailure(job, `produced no PR URL and branch '${trimmedBranch}' is not on origin`),
168
+ };
169
+ }
170
+ if (trimmedHeadSha && remoteSha !== trimmedHeadSha) {
171
+ return {
172
+ ok: false,
173
+ 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`),
174
+ };
175
+ }
176
+ return { ok: true };
177
+ }
@@ -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
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Conservative executor worktree GC sweep (BAPI-535, TDD §7 teardown).
3
+ *
4
+ * Removes ONLY executor/conductor-managed worktrees whose branch is merged into
5
+ * the base branch (`git merge-base --is-ancestor <branch> origin/<base>`) and
6
+ * idle for more than 24h. CONSERVATIVE by construction: a worktree that is the
7
+ * base branch, unmanaged, dirty, recently active, on an unknown/detached branch,
8
+ * unmerged, or whose merged/idle status cannot be PROVEN is always retained. Any
9
+ * inability to prove eligibility retains the worktree; per-worktree failures
10
+ * never abort the whole sweep. All I/O is injected for offline unit testing.
11
+ */
12
+ import { listGitWorktrees, pathApiForExecutorPlatform, } from "./worktree-inspection.js";
13
+ /** Idle cutoff: a worktree must be idle strictly longer than 24h to be eligible. */
14
+ export const EXECUTOR_WORKTREE_GC_IDLE_MS = 24 * 60 * 60 * 1000;
15
+ function conductorMarkerPaths(worktreePath, platform) {
16
+ const pathApi = pathApiForExecutorPlatform(platform);
17
+ const dir = pathApi.join(worktreePath, ".conductor");
18
+ return {
19
+ dir,
20
+ workerLog: pathApi.join(dir, "worker.log"),
21
+ resultJson: pathApi.join(dir, "result.json"),
22
+ };
23
+ }
24
+ /**
25
+ * Conservatively identify an executor/conductor-managed worktree by the presence
26
+ * of a `.conductor` directory, `.conductor/worker.log`, or `.conductor/result.json`.
27
+ */
28
+ export async function isExecutorManagedWorktree(worktreePath, deps) {
29
+ const markers = conductorMarkerPaths(worktreePath, deps.platform ?? process.platform);
30
+ for (const marker of [markers.workerLog, markers.resultJson, markers.dir]) {
31
+ if ((await deps.statMtimeMs(marker)) !== null)
32
+ return true;
33
+ }
34
+ return false;
35
+ }
36
+ /**
37
+ * Compute last activity as the NEWEST mtime among `.conductor/worker.log`,
38
+ * `.conductor/result.json`, and the worktree directory itself. Returns `null`
39
+ * when none can be stat'd (activity cannot be proven → the caller retains).
40
+ */
41
+ export async function getWorktreeLastActivityMs(worktreePath, deps) {
42
+ const markers = conductorMarkerPaths(worktreePath, deps.platform ?? process.platform);
43
+ const candidates = [markers.workerLog, markers.resultJson, worktreePath];
44
+ let newest = null;
45
+ for (const candidate of candidates) {
46
+ const mtime = await deps.statMtimeMs(candidate);
47
+ if (mtime !== null && (newest === null || mtime > newest))
48
+ newest = mtime;
49
+ }
50
+ return newest;
51
+ }
52
+ /** Idle when the last activity is STRICTLY more than 24h before `now`. */
53
+ export function isWorktreeIdle(lastActivityMs, nowMs) {
54
+ return nowMs - lastActivityMs > EXECUTOR_WORKTREE_GC_IDLE_MS;
55
+ }
56
+ /** True only when `git merge-base --is-ancestor <branch> origin/<base>` exits 0. */
57
+ export async function isBranchMergedIntoBase(runCommand, cwd, branch, baseBranch) {
58
+ const result = await runCommand("git", ["merge-base", "--is-ancestor", branch, `origin/${baseBranch}`], { cwd });
59
+ return result.exitCode === 0;
60
+ }
61
+ /** True when `git status --porcelain` inside the worktree is non-empty. */
62
+ export async function isWorktreeDirty(runCommand, worktreePath) {
63
+ const result = await runCommand("git", ["status", "--porcelain"], { cwd: worktreePath });
64
+ return result.stdout.trim().length > 0;
65
+ }
66
+ /**
67
+ * Sweep worktrees, removing ONLY managed, clean, idle, non-base, merged
68
+ * worktrees. Everything else — including any worktree whose eligibility cannot be
69
+ * proven — is retained with a structured reason. Per-worktree failures are
70
+ * recorded and the sweep continues; the sweep never throws globally.
71
+ */
72
+ export async function sweepExecutorWorktrees(deps) {
73
+ const summary = { removed: [], retained: [], errors: [] };
74
+ const listWorktrees = deps.listWorktrees ?? listGitWorktrees;
75
+ const nowMs = deps.now();
76
+ let entries;
77
+ try {
78
+ entries = await listWorktrees(deps.runCommand, deps.cwd);
79
+ }
80
+ catch (err) {
81
+ summary.errors.push({ path: deps.cwd, error: err instanceof Error ? err.message : String(err) });
82
+ return summary;
83
+ }
84
+ for (const entry of entries) {
85
+ try {
86
+ if (!entry.branch) {
87
+ summary.retained.push({ path: entry.path, reason: "detached/unknown branch" });
88
+ continue;
89
+ }
90
+ if (entry.branch === deps.baseBranch) {
91
+ summary.retained.push({ path: entry.path, reason: "base branch" });
92
+ continue;
93
+ }
94
+ if (!(await isExecutorManagedWorktree(entry.path, deps))) {
95
+ summary.retained.push({ path: entry.path, reason: "unmanaged (no .conductor marker)" });
96
+ continue;
97
+ }
98
+ if (await isWorktreeDirty(deps.runCommand, entry.path)) {
99
+ summary.retained.push({ path: entry.path, reason: "dirty" });
100
+ continue;
101
+ }
102
+ const lastActivity = await getWorktreeLastActivityMs(entry.path, deps);
103
+ if (lastActivity === null) {
104
+ summary.retained.push({ path: entry.path, reason: "activity unprovable" });
105
+ continue;
106
+ }
107
+ if (!isWorktreeIdle(lastActivity, nowMs)) {
108
+ summary.retained.push({ path: entry.path, reason: "recent activity" });
109
+ continue;
110
+ }
111
+ if (!(await isBranchMergedIntoBase(deps.runCommand, deps.cwd, entry.branch, deps.baseBranch))) {
112
+ summary.retained.push({ path: entry.path, reason: "not merged into base" });
113
+ continue;
114
+ }
115
+ const removeResult = await deps.runCommand("git", ["worktree", "remove", entry.path], {
116
+ cwd: deps.cwd,
117
+ });
118
+ if (removeResult.exitCode === 0) {
119
+ summary.removed.push(entry.path);
120
+ }
121
+ else {
122
+ summary.errors.push({
123
+ path: entry.path,
124
+ error: (removeResult.stderr || removeResult.stdout || "git worktree remove failed").trim(),
125
+ });
126
+ }
127
+ }
128
+ catch (err) {
129
+ // Any failure to prove eligibility retains the worktree.
130
+ summary.errors.push({ path: entry.path, error: err instanceof Error ? err.message : String(err) });
131
+ }
132
+ }
133
+ return summary;
134
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Local worktree inspection utilities for executor job behaviors (BAPI-535).
3
+ *
4
+ * Safe, read-only Git worktree lookup, branch verification, and remote-branch
5
+ * probes used by the `resume` pre-spawn protocol and the worktree GC sweep. All
6
+ * subprocess access is behind the injected `RunCommand` boundary so every helper
7
+ * is unit-testable on Linux CI with no real `git`. NONE of these helpers ever
8
+ * mutates repository state (no reset/checkout/clean/branch writes).
9
+ */
10
+ import path from "node:path";
11
+ import { BranchMismatch, ExecutorNamedError } from "./job-errors.js";
12
+ /** Return the Node `path` API matching a platform (`win32` vs POSIX). */
13
+ export function pathApiForExecutorPlatform(platform) {
14
+ return platform === "win32" ? path.win32 : path.posix;
15
+ }
16
+ /**
17
+ * Parse `git worktree list --porcelain` output into entries. Each record is a
18
+ * blank-line-separated block whose first line is `worktree <path>`; a `branch
19
+ * refs/heads/<name>` line carries the local branch, which is normalized to the
20
+ * short branch name. Detached/no-branch worktrees are represented without a
21
+ * `branch` (never an invented one).
22
+ */
23
+ export function parseGitWorktreePorcelain(porcelain) {
24
+ const entries = [];
25
+ let current = null;
26
+ for (const rawLine of porcelain.split("\n")) {
27
+ const line = rawLine.replace(/\r$/, "");
28
+ if (line.startsWith("worktree ")) {
29
+ if (current)
30
+ entries.push(current);
31
+ current = { path: line.slice("worktree ".length).trim() };
32
+ }
33
+ else if (line.startsWith("branch ") && current) {
34
+ const ref = line.slice("branch ".length).trim();
35
+ current.branch = ref.startsWith("refs/heads/")
36
+ ? ref.slice("refs/heads/".length)
37
+ : ref;
38
+ }
39
+ // `detached`, `bare`, `HEAD <sha>`, and blank lines carry no branch.
40
+ }
41
+ if (current)
42
+ entries.push(current);
43
+ return entries;
44
+ }
45
+ /** Run `git worktree list --porcelain` from the repo root and parse the result. */
46
+ export async function listGitWorktrees(runCommand, cwd) {
47
+ const result = await runCommand("git", ["worktree", "list", "--porcelain"], { cwd });
48
+ if (result.exitCode !== 0)
49
+ return [];
50
+ return parseGitWorktreePorcelain(result.stdout);
51
+ }
52
+ /**
53
+ * Return the worktree path whose local branch EXACTLY equals `branch` (not a
54
+ * substring / prefix match), or `undefined` when none matches.
55
+ */
56
+ export function findWorktreeByBranch(entries, branch) {
57
+ const match = entries.find((e) => e.branch === branch);
58
+ return match?.path;
59
+ }
60
+ /** Run `git rev-parse --abbrev-ref HEAD` inside a worktree and return the branch. */
61
+ export async function getCurrentBranch(runCommand, worktreePath) {
62
+ const result = await runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
63
+ cwd: worktreePath,
64
+ });
65
+ return result.stdout.trim();
66
+ }
67
+ /**
68
+ * Verify a worktree is on the expected branch; throw {@link ExecutorNamedError}
69
+ * with kind {@link BranchMismatch} otherwise. Working on the wrong branch would
70
+ * corrupt an unrelated branch, so this aborts rather than proceeding (TDD §7).
71
+ */
72
+ export async function assertWorktreeOnBranch(runCommand, worktreePath, expectedBranch) {
73
+ const actual = await getCurrentBranch(runCommand, worktreePath);
74
+ if (actual !== expectedBranch) {
75
+ throw new ExecutorNamedError(BranchMismatch, `worktree '${worktreePath}' is on '${actual}', expected '${expectedBranch}'`);
76
+ }
77
+ }
78
+ /**
79
+ * Non-mutating probe for whether `origin/<branch>` exists. Uses
80
+ * `git rev-parse --verify --quiet origin/<branch>` (exit 0 ⇒ exists). Never
81
+ * fetches, never writes a ref.
82
+ */
83
+ export async function remoteBranchExists(runCommand, cwd, branch) {
84
+ const result = await runCommand("git", ["rev-parse", "--verify", "--quiet", `origin/${branch}`], { cwd });
85
+ return result.exitCode === 0;
86
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Executor worktree ensure (BAPI-534, TDD §7, §12).
3
+ *
4
+ * Real worker jobs run in a Worktrunk-created worktree via the SHARED primitive
5
+ * (`worktree-core.createWorktreeForTicket`) — no duplicated `wt switch` argument
6
+ * construction or Worktrunk JSON path parsing here. Per-job create failures are
7
+ * returned as structured job failures (→ `/fail`), never thrown.
8
+ */
9
+ import { commandSucceeded } from "../start-tickets-prereqs.js";
10
+ import { createWorktreeForTicket } from "../worktree-core.js";
11
+ /**
12
+ * Resolve the branch for a real spawn job: `expected_branch` wins; else
13
+ * `feature/<ticket_key>`; else a structured contract failure.
14
+ */
15
+ export function resolveExecutorBranch(job) {
16
+ const expected = typeof job.expected_branch === "string" && job.expected_branch.trim().length > 0
17
+ ? job.expected_branch.trim()
18
+ : null;
19
+ if (expected)
20
+ return { ok: true, branch: expected };
21
+ const ticketKey = typeof job.ticket_key === "string" && job.ticket_key.trim().length > 0
22
+ ? job.ticket_key.trim()
23
+ : null;
24
+ if (ticketKey)
25
+ return { ok: true, branch: `feature/${ticketKey}` };
26
+ return { ok: false, error: "no branch could be resolved (no expected_branch and no ticket_key)" };
27
+ }
28
+ /** Build the lean shared-worktree deps from the executor deps. */
29
+ function toWorktreeCoreDeps(deps) {
30
+ return {
31
+ runCommand: deps.runCommand,
32
+ platform: deps.platform,
33
+ env: deps.env,
34
+ cwd: deps.cwd,
35
+ };
36
+ }
37
+ /**
38
+ * Ensure the worktree for a job exists, delegating to the shared Worktrunk
39
+ * primitive. A create failure is returned as a structured failure that the job
40
+ * runner can send to `/fail`.
41
+ *
42
+ * `policy.reuseExistingBranch` selects between the two supported modes (see
43
+ * {@link EnsureExecutorWorktreePolicy}); it defaults to the fresh-off-base,
44
+ * F7-guarded behavior so callers that omit it are unchanged.
45
+ */
46
+ export async function ensureExecutorWorktree(job, options, deps, policy = {}) {
47
+ const branchResult = resolveExecutorBranch(job);
48
+ if (!branchResult.ok)
49
+ return { ok: false, error: branchResult.error };
50
+ const branch = branchResult.branch;
51
+ // The shared primitive resolves the branch from `key` via a per-key override,
52
+ // so we pin the exact resolved branch through the override map.
53
+ const key = typeof job.ticket_key === "string" && job.ticket_key.trim().length > 0
54
+ ? job.ticket_key.trim()
55
+ : branch;
56
+ const reuseExistingBranch = policy.reuseExistingBranch === true;
57
+ // The F7 guard applies to FRESH implementation-style jobs — refuse to reuse a
58
+ // stale pre-existing branch carrying commits not on base (mirrors the v1
59
+ // conductor's `!isResume` guard). RECOVERY jobs intentionally continue from the
60
+ // ticket's bound branch (BAPI-542): they cut from `origin/<branch>` — the pushed
61
+ // PR branch, so a missing local branch is recreated from it rather than from
62
+ // base — with the guard OFF, because that branch legitimately carries the
63
+ // implement commit and later correction rounds.
64
+ const baseStartPoint = reuseExistingBranch ? `origin/${branch}` : options.baseBranch;
65
+ const guardStaleWorktree = !reuseExistingBranch;
66
+ // BAPI-528: recovery correction is ALWAYS post-push (the PR exists), so the
67
+ // pushed head is the source of truth. Two gaps this closes:
68
+ // 1. A multi-consumer executor that only cloned/fetched main has NEITHER the
69
+ // local branch NOR the `origin/<branch>` tracking ref, so cutting from
70
+ // `origin/<branch>` cannot resolve its start point → WorktreeError → the
71
+ // run strands at code_review. Fetch the pushed head first so it can.
72
+ // 2. When a local branch already exists but has DIVERGED from origin, reusing
73
+ // it as-is would address review comments on stale code and force-push over
74
+ // newer origin commits. Freshen it onto `origin/<branch>` (below).
75
+ // A failed fetch surfaces as a structured job failure — never a silent stale
76
+ // reuse. (§16's "no fetch && reset --hard" rule is scoped to the RESUME path,
77
+ // which must preserve unpushed local work; it does not apply here.)
78
+ if (reuseExistingBranch) {
79
+ const originRef = `origin/${branch}`;
80
+ const fetch = await deps.runCommand("git", ["fetch", "origin", branch], { cwd: deps.cwd });
81
+ if (!commandSucceeded(fetch)) {
82
+ const reason = (fetch.stderr || fetch.stdout || "").trim();
83
+ return {
84
+ ok: false,
85
+ error: `git fetch origin ${branch} failed${reason ? `: ${reason}` : ""}`,
86
+ };
87
+ }
88
+ const row = await createWorktreeForTicket(toWorktreeCoreDeps(deps), key, { [key]: branch }, options.worktrunkBinary, baseStartPoint, guardStaleWorktree,
89
+ // Hard-reset a reused existing local branch onto the freshly fetched
90
+ // pushed head; a missing local branch is instead cut from `baseStartPoint`
91
+ // (= `origin/<branch>`) inside the primitive, so both land at origin.
92
+ { freshenFromOrigin: originRef });
93
+ if (row.status === "created" && typeof row.path === "string") {
94
+ return { ok: true, worktreePath: row.path, branch };
95
+ }
96
+ return { ok: false, error: row.error ?? `worktree creation failed for branch '${branch}'` };
97
+ }
98
+ const row = await createWorktreeForTicket(toWorktreeCoreDeps(deps), key, { [key]: branch }, options.worktrunkBinary, baseStartPoint, guardStaleWorktree);
99
+ if (row.status === "created" && typeof row.path === "string") {
100
+ return { ok: true, worktreePath: row.path, branch };
101
+ }
102
+ return { ok: false, error: row.error ?? `worktree creation failed for branch '${branch}'` };
103
+ }