@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,60 @@
1
+ /**
2
+ * Shared executor job-type policy (BAPI-542, TDD §9).
3
+ *
4
+ * Single source of truth for which claimed job types the executor spawns and
5
+ * which of those are RECOVERY jobs. Recovery jobs (`remediate`/`ci_fix`/`rebase`)
6
+ * resume the ticket's existing branch/PR through the `/implement-ticket <KEY>
7
+ * --auto` correction loop rather than cutting a fresh branch off base. Dispatch
8
+ * (`job-runner.ts` `runClaimedJob`), prompt synthesis (`worker-command.ts`
9
+ * `resolveExecutorPrompt`), and worktree reuse (`job-runner.ts` `prepareSpawn` →
10
+ * `worktree.ts` `ensureExecutorWorktree`) all read from here so the recovery /
11
+ * spawn classification cannot drift between them.
12
+ *
13
+ * ARCHITECTURE BOUNDARY (TDD §12): this module is executor-local. It imports
14
+ * NOTHING from the v1 conductor runtime, ledger, event, taxonomy, supervisor, or
15
+ * server-side reconciler graph — only bare string sets and predicates.
16
+ */
17
+ /**
18
+ * Recovery job types: enqueued by the server-side reconciler when a gate is
19
+ * ACTIONABLE (review changes requested → `remediate`, CI failed → `ci_fix`,
20
+ * merge conflict → `rebase`). They re-enter the correction loop on the ticket's
21
+ * EXISTING branch/PR, so the executor reuses that branch instead of cutting a
22
+ * fresh one off base.
23
+ */
24
+ export const RECOVERY_JOB_TYPES = new Set([
25
+ "remediate",
26
+ "ci_fix",
27
+ "rebase",
28
+ ]);
29
+ /**
30
+ * Real headless-spawn job types (worktree + worker LLM): the first-pass
31
+ * `implement`, the `resume` continuation (own pre-spawn protocol), the
32
+ * `spec_review` verdict job, and the three recovery jobs. `merge` (deterministic,
33
+ * no worktree/worker) and `smoke` (no-op acceptance) are intentionally EXCLUDED —
34
+ * they have dedicated dispatch branches BEFORE the generic spawn path.
35
+ */
36
+ export const SPAWN_JOB_TYPES = new Set([
37
+ "implement",
38
+ "resume",
39
+ "spec_review",
40
+ "remediate",
41
+ "ci_fix",
42
+ "rebase",
43
+ ]);
44
+ /** True only for the exact lowercase recovery job types. */
45
+ export function isRecoveryJobType(jobType) {
46
+ return RECOVERY_JOB_TYPES.has(jobType);
47
+ }
48
+ /** True only for the exact lowercase spawn job types. */
49
+ export function isSpawnJobType(jobType) {
50
+ return SPAWN_JOB_TYPES.has(jobType);
51
+ }
52
+ /**
53
+ * Implementation-style spawn jobs (BAPI-551): every spawn job type expected to
54
+ * end with a pushed origin branch or an opened PR — `implement`, `resume`, and
55
+ * the recovery jobs. Excludes `spec_review`, the one spawn type that is a
56
+ * verdict job (it produces `.conductor/result.json` instead of a branch/PR).
57
+ */
58
+ export function isImplementationStyleJobType(jobType) {
59
+ return isSpawnJobType(jobType) && jobType !== "spec_review";
60
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Deterministic `merge` job body (BAPI-535, TDD §5, §9).
3
+ *
4
+ * `merge` is a DETERMINISTIC executor action, distinct from every spawn-a-worker
5
+ * job type: NO worktree is ensured and NO `claude` worker is spawned. It runs
6
+ * `gh pr merge <pr_number> --<method> --match-head-commit <expected_head_sha>`
7
+ * under the machine's LOCAL `gh` auth by reusing {@link makeLocalMergeExecutor}
8
+ * (the App token is `contents: read` and cannot merge — the run-7
9
+ * `provider_unauthorized` failure). `--match-head-commit` rejects head drift at
10
+ * GitHub, so no action-key idempotency protocol is needed. On success the merge
11
+ * outcome (accepted / merge commit) is posted at `/complete`; gate advancement is
12
+ * left entirely to the merge OBSERVER (T4/T5) — this module never advances a gate.
13
+ *
14
+ * BAPI-572: the local `gh` auth model is unchanged (no App credentials — locked
15
+ * decision). Two behaviors were hardened here:
16
+ * - Ambiguous merge failures are resolved by re-reading the merged state inside
17
+ * {@link makeLocalMergeExecutor}, so an already-merged PR at the expected head
18
+ * maps to a SUCCESS result (with the merge commit SHA), not a failure.
19
+ * - Merge-domain failures no longer collapse into one `error_kind` — they emit a
20
+ * machine-readable {@link MERGE_RETRYABLE}, {@link MERGE_CONFLICT}, or
21
+ * {@link MERGE_FAILED} `error_kind` (via {@link classifyMergeFailureErrorKind})
22
+ * so the companion re-fire lane can route retry-vs-rebase without string parsing.
23
+ */
24
+ import { makeLocalMergeExecutor, resolveLocalMergeMethod, } from "../conductor/local-merge.js";
25
+ import { resolveConductorBridgeApiAccess, } from "../conductor/bridge-api-client.js";
26
+ import { secretFreeErrorMessage } from "./job-errors.js";
27
+ function positiveIntOrNull(value) {
28
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
29
+ }
30
+ /** Read an optional non-negative integer (preserving 0), else `undefined`. */
31
+ function nonNegativeIntOrUndefined(value) {
32
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined;
33
+ }
34
+ function asString(value) {
35
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
36
+ }
37
+ /**
38
+ * Extract and validate merge fields. Prefers the claimed-job `expected_head_sha`
39
+ * over any payload copy, validates `pr_number` as a positive integer, and fails
40
+ * (before any subprocess) when the PR number or expected head SHA is absent.
41
+ */
42
+ export function readMergeJobPayloadFields(job) {
43
+ const payload = job.payload && typeof job.payload === "object" ? job.payload : {};
44
+ const prNumber = positiveIntOrNull(payload.pr_number);
45
+ if (prNumber === null) {
46
+ return { ok: false, error: "merge job requires a positive integer pr_number" };
47
+ }
48
+ const expectedHeadSha = asString(job.expected_head_sha) ?? asString(payload.expected_head_sha);
49
+ if (!expectedHeadSha) {
50
+ return { ok: false, error: "merge job requires an expected_head_sha (top-level or payload)" };
51
+ }
52
+ const method = resolveLocalMergeMethod(payload.method);
53
+ const requiredChecks = Array.isArray(payload.required_checks)
54
+ ? payload.required_checks.filter((c) => typeof c === "string")
55
+ : [];
56
+ const actionKey = asString(payload.action_key) ?? `merge:pr-${prNumber}:${expectedHeadSha}`;
57
+ const ciWaitTimeoutMs = nonNegativeIntOrUndefined(payload.ci_wait_timeout_ms);
58
+ const ciWaitPollIntervalMs = nonNegativeIntOrUndefined(payload.ci_wait_poll_interval_ms);
59
+ return {
60
+ ok: true,
61
+ fields: { prNumber, expectedHeadSha, method, requiredChecks, actionKey, ciWaitTimeoutMs, ciWaitPollIntervalMs },
62
+ };
63
+ }
64
+ /** Construct the {@link ConductorMergeRequest} for the local merge executor. */
65
+ export function buildConductorMergeRequestForExecutorJob(fields, repoName) {
66
+ return {
67
+ repo_name: repoName,
68
+ pr_number: fields.prNumber,
69
+ expected_head_sha: fields.expectedHeadSha,
70
+ gate: { name: "merge", required_checks: fields.requiredChecks },
71
+ action_key: fields.actionKey,
72
+ };
73
+ }
74
+ /**
75
+ * Resolve {@link ConductorBridgeApiAccess} for the merge via the SHARED conductor
76
+ * resolver (repo name + credential store + base URL). The API key never reaches
77
+ * `gh` argv/env/logs — it is used only for the executor's own read-only CI
78
+ * re-poll. Returns a structured failure rather than throwing.
79
+ */
80
+ export async function buildConductorMergeAccessForExecutorJob(deps) {
81
+ const result = await resolveConductorBridgeApiAccess(deps);
82
+ if (result.ok)
83
+ return { ok: true, access: result.access };
84
+ return { ok: false, error: result.error };
85
+ }
86
+ /** Pull the best-effort merge commit SHA from a succeeded merge response. */
87
+ function extractMergeCommitSha(response) {
88
+ for (const ev of response.ledger_events) {
89
+ if (ev.type === "merge.succeeded") {
90
+ const sha = ev.details?.merge_commit_sha;
91
+ if (typeof sha === "string" && sha.length > 0)
92
+ return sha;
93
+ }
94
+ }
95
+ return undefined;
96
+ }
97
+ /**
98
+ * Map a succeeded local-merge response into the backend `merge` result schema
99
+ * (`MinimalJobResult`, `extra="allow"`): the provider action was accepted, plus
100
+ * the best-effort merge commit SHA. NO gate-advancement or observer-only fields.
101
+ */
102
+ export function buildMergeJobResult(response, fields) {
103
+ const mergeCommitSha = extractMergeCommitSha(response);
104
+ const result = {
105
+ accepted: true,
106
+ pr_number: fields.prNumber,
107
+ merge_method: fields.method,
108
+ summary: `merged PR #${fields.prNumber} via ${fields.method}`,
109
+ };
110
+ if (mergeCommitSha) {
111
+ result.commit_sha = mergeCommitSha;
112
+ result.head_sha = mergeCommitSha;
113
+ }
114
+ return result;
115
+ }
116
+ /**
117
+ * Machine-readable merge-domain error kinds (BAPI-572). The companion re-fire
118
+ * lane routes retry-vs-rebase on these values, NOT on free text:
119
+ * - {@link MERGE_RETRYABLE}: transient gh/network/auth-refresh, `ci_not_green` —
120
+ * safe to re-fire the same merge later.
121
+ * - {@link MERGE_CONFLICT}: needs a rebase before it can merge.
122
+ * - {@link MERGE_FAILED}: deterministic other failure (bad state, missing input).
123
+ */
124
+ export const MERGE_RETRYABLE = "MergeRetryable";
125
+ export const MERGE_CONFLICT = "MergeConflict";
126
+ export const MERGE_FAILED = "MergeFailed";
127
+ /** Reasons whose failure is transient/infrastructure and safe to retry. */
128
+ const RETRYABLE_MERGE_REASONS = new Set([
129
+ "gh_pr_view_timeout",
130
+ "gh_pr_view_failed",
131
+ "gh_pr_view_unparseable",
132
+ "ci_poll_failed",
133
+ "ci_not_green",
134
+ "gh_merge_timeout",
135
+ "gh_merge_failed",
136
+ "merge_aborted",
137
+ ]);
138
+ /**
139
+ * Classify a non-succeeded merge response into a machine-readable `error_kind`.
140
+ * A `merge.conflict` ledger event (or a `gh_merge_conflict` reason) is a conflict;
141
+ * transient/infrastructure reasons are retryable; everything else — including a
142
+ * missing/malformed reason — is a deterministic {@link MERGE_FAILED}.
143
+ *
144
+ * BAPI-577 Part A: the CI re-poll now emits distinct `ci_poll_*` reasons
145
+ * (`ci_poll_timeout`, `ci_poll_unauthorized`, `ci_poll_http_<status>`, …) instead of a
146
+ * single `ci_poll_failed`. All of them are transient infrastructure reads, so any
147
+ * `ci_poll_`-prefixed reason is retryable — a prefix match keeps unbounded HTTP-status
148
+ * variants covered so a distinct reason never accidentally downgrades to
149
+ * {@link MERGE_FAILED}. Conflict classification is checked first so it is never masked.
150
+ */
151
+ export function classifyMergeFailureErrorKind(response) {
152
+ const hasConflictEvent = response.ledger_events.some((ev) => ev.type === "merge.conflict");
153
+ const reason = asString(response.reason ?? undefined);
154
+ if (hasConflictEvent || reason === "gh_merge_conflict")
155
+ return MERGE_CONFLICT;
156
+ if (reason && (reason.startsWith("ci_poll_") || RETRYABLE_MERGE_REASONS.has(reason))) {
157
+ return MERGE_RETRYABLE;
158
+ }
159
+ return MERGE_FAILED;
160
+ }
161
+ /**
162
+ * Convert a non-succeeded local-merge response into a bounded, secret-free
163
+ * executor failure. `error_kind` is machine-readable (retryable/conflict/failed);
164
+ * `error_message` includes only the sanitized merge status and reason (never raw
165
+ * `gh` output). `classification` stays within the executor's vocabulary; the
166
+ * `error_kind` is the routing signal.
167
+ */
168
+ export function buildMergeJobFailure(response) {
169
+ const reason = asString(response.reason ?? undefined) ?? response.status;
170
+ return {
171
+ error_kind: classifyMergeFailureErrorKind(response),
172
+ error_message: secretFreeErrorMessage(new Error(`local merge ${response.status}: ${reason}`)),
173
+ classification: "crashed",
174
+ };
175
+ }
176
+ /**
177
+ * Run the deterministic merge. NEVER ensures/recreates a worktree and NEVER
178
+ * spawns a worker. Returns a completion result on a succeeded merge, else a
179
+ * bounded, secret-free executor failure.
180
+ */
181
+ export async function runExecutorMergeJob(job, seams) {
182
+ const resolution = readMergeJobPayloadFields(job);
183
+ if (!resolution.ok) {
184
+ return {
185
+ ok: false,
186
+ failure: {
187
+ error_kind: "ContractError.MergePayload",
188
+ error_message: resolution.error,
189
+ classification: "crashed",
190
+ },
191
+ };
192
+ }
193
+ const fields = resolution.fields;
194
+ const request = buildConductorMergeRequestForExecutorJob(fields, seams.access.repoName);
195
+ const make = seams.makeExecutor ?? makeLocalMergeExecutor;
196
+ const executor = make({
197
+ method: fields.method,
198
+ ciWaitTimeoutMs: fields.ciWaitTimeoutMs,
199
+ ciWaitPollIntervalMs: fields.ciWaitPollIntervalMs,
200
+ }, seams.localMergeDeps);
201
+ let response;
202
+ try {
203
+ response = await executor(seams.access, request);
204
+ }
205
+ catch (err) {
206
+ return {
207
+ ok: false,
208
+ failure: {
209
+ error_kind: "MergeFailed",
210
+ error_message: secretFreeErrorMessage(err),
211
+ classification: "crashed",
212
+ },
213
+ };
214
+ }
215
+ if (response.status === "succeeded") {
216
+ return { ok: true, result: buildMergeJobResult(response, fields) };
217
+ }
218
+ return { ok: false, failure: buildMergeJobFailure(response) };
219
+ }
220
+ /**
221
+ * Build the default local-merge deps for production wiring (no real I/O here).
222
+ * An optional {@link AbortSignal} is threaded through so the job-runner's overall
223
+ * merge timeout can cancel in-flight local `gh` subprocess work. The signal is
224
+ * NOT a credential and never reaches `gh` argv/env — the local `gh` auth model is
225
+ * unchanged (no App token is ever placed here).
226
+ */
227
+ export function buildDefaultMergeLocalDeps(deps, signal) {
228
+ return { env: deps.env, signal };
229
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Collect load-bearing git telemetry from a worktree. Individual git failures
3
+ * degrade the telemetry (fields omitted) rather than throwing — a degraded
4
+ * signal must never surface as a worker correctness failure.
5
+ */
6
+ export async function collectGitTelemetry(deps, worktreePath, baseBranch) {
7
+ const telemetry = {};
8
+ try {
9
+ const revList = await deps.runCommand("git", [
10
+ "-C",
11
+ worktreePath,
12
+ "rev-list",
13
+ "--count",
14
+ `origin/${baseBranch}..HEAD`,
15
+ ]);
16
+ if (revList.exitCode === 0) {
17
+ const count = Number.parseInt(revList.stdout.trim(), 10);
18
+ if (Number.isFinite(count))
19
+ telemetry.local_commit_count = count;
20
+ }
21
+ }
22
+ catch {
23
+ /* degraded: omit local_commit_count */
24
+ }
25
+ try {
26
+ const revParse = await deps.runCommand("git", ["-C", worktreePath, "rev-parse", "HEAD"]);
27
+ if (revParse.exitCode === 0) {
28
+ const sha = revParse.stdout.trim();
29
+ if (sha.length > 0)
30
+ telemetry.last_commit_sha = sha;
31
+ }
32
+ }
33
+ catch {
34
+ /* degraded: omit last_commit_sha */
35
+ }
36
+ try {
37
+ const status = await deps.runCommand("git", [
38
+ "-C",
39
+ worktreePath,
40
+ "status",
41
+ "--porcelain",
42
+ ]);
43
+ if (status.exitCode === 0) {
44
+ const dirty = status.stdout.trim().length > 0;
45
+ telemetry.dirty = dirty;
46
+ if (dirty) {
47
+ telemetry.last_activity_at = new Date(deps.now()).toISOString();
48
+ }
49
+ }
50
+ }
51
+ catch {
52
+ /* degraded: omit dirty/last_activity_at */
53
+ }
54
+ return telemetry;
55
+ }
56
+ /**
57
+ * Tolerantly parse one Claude stream-json line for an advisory `phase_hint`.
58
+ * Catches ALL parse/schema errors and never throws; returns `{}` when nothing
59
+ * usable is found.
60
+ */
61
+ export function parseClaudeStreamJsonLine(line) {
62
+ try {
63
+ const trimmed = typeof line === "string" ? line.trim() : "";
64
+ if (trimmed.length === 0)
65
+ return {};
66
+ const parsed = JSON.parse(trimmed);
67
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
68
+ return {};
69
+ const obj = parsed;
70
+ // Advisory only: prefer an explicit phase/subtype, else the stream event type.
71
+ const hint = pickString(obj.phase) ??
72
+ pickString(obj.subtype) ??
73
+ pickString(obj.type);
74
+ return hint ? { phase_hint: hint } : {};
75
+ }
76
+ catch {
77
+ return {};
78
+ }
79
+ }
80
+ function pickString(value) {
81
+ return typeof value === "string" && value.length > 0 ? value : undefined;
82
+ }
83
+ /**
84
+ * Create the observation state. When `advisoryParserEnabled` is false, stdout is
85
+ * still timestamped (`last_stdout_at`) but never parsed — proving the advisory
86
+ * parser is not load-bearing.
87
+ */
88
+ export function createObservationState(deps, options) {
89
+ let git = {};
90
+ const advisory = {};
91
+ let exitCode;
92
+ return {
93
+ recordStdout(chunk) {
94
+ advisory.last_stdout_at = new Date(deps.now()).toISOString();
95
+ if (!options.advisoryParserEnabled)
96
+ return;
97
+ for (const line of chunk.split("\n")) {
98
+ const hint = parseClaudeStreamJsonLine(line);
99
+ if (hint.phase_hint)
100
+ advisory.phase_hint = hint.phase_hint;
101
+ }
102
+ },
103
+ setGitTelemetry(telemetry) {
104
+ git = telemetry;
105
+ },
106
+ setExitCode(code) {
107
+ exitCode = code;
108
+ },
109
+ git() {
110
+ return git;
111
+ },
112
+ snapshot() {
113
+ const residue = { ...git };
114
+ if (advisory.phase_hint)
115
+ residue.phase_hint = advisory.phase_hint;
116
+ if (advisory.last_stdout_at)
117
+ residue.last_stdout_at = advisory.last_stdout_at;
118
+ if (exitCode !== undefined)
119
+ residue.exit_code = exitCode;
120
+ return residue;
121
+ },
122
+ };
123
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Executor deny-layer provisioning (BAPI-534, TDD §7 permission model, R8).
3
+ *
4
+ * Provisions a small, stable deny set into each worktree's
5
+ * `.claude/settings.local.json` via the shared malformed-safe merge machinery.
6
+ * A denied call returns an error to the model (which adapts) — no stall. The
7
+ * deny layer FAILS OPEN: if it drifts or is missing, safety margin shrinks but
8
+ * liveness never does. (Refusing to claim on `enforced:false` is a SEPARATE,
9
+ * fatal preflight concern — see `preflight.ts`.)
10
+ *
11
+ * The Wave-1 probe on the target machine reports enforcement layer `settings-deny`
12
+ * (claude 2.1.201), so plain `permissions.deny` is provisioned; the PreToolUse
13
+ * fallback is available for CLIs where settings-deny is not enforced.
14
+ */
15
+ import { mergeClaudeSettingsWithCommandHook, provisionClaudeSettingsForWorktree, DEFAULT_PRE_TOOL_USE_MATCHER, } from "../claude-settings.js";
16
+ /**
17
+ * The stable executor deny rules. Grammar mirrors the deny-enforcement probe's
18
+ * `Bash(<cmd>:<args-glob>)` / `Read(<path-glob>)` form. Covers: reads under
19
+ * ~/.ssh, ~/.aws, and keychain paths; force-push to the base branch; and
20
+ * destructive `rm -rf` outside the worktree.
21
+ */
22
+ export function executorDenyRules(inputs) {
23
+ return [
24
+ "Read(~/.ssh/**)",
25
+ "Read(~/.aws/**)",
26
+ "Read(~/Library/Keychains/**)",
27
+ "Read(/etc/shadow)",
28
+ `Bash(git push:*--force*${inputs.baseBranch}*)`,
29
+ "Bash(git push:*--force*)",
30
+ "Bash(rm:*-rf /*)",
31
+ "Bash(rm:*-rf ~*)",
32
+ ];
33
+ }
34
+ function asObject(value) {
35
+ return value !== null && typeof value === "object" && !Array.isArray(value)
36
+ ? value
37
+ : {};
38
+ }
39
+ /**
40
+ * Merge the executor deny set into a settings object under `permissions.deny`,
41
+ * preserving all existing settings and appending only rules not already present
42
+ * (idempotent).
43
+ */
44
+ export function mergeExecutorDenySettings(existing, inputs) {
45
+ const permissions = { ...asObject(existing.permissions) };
46
+ const existingDeny = Array.isArray(permissions.deny) ? permissions.deny : [];
47
+ const merged = [...existingDeny];
48
+ for (const rule of executorDenyRules(inputs)) {
49
+ if (!merged.includes(rule))
50
+ merged.push(rule);
51
+ }
52
+ permissions.deny = merged;
53
+ return { ...existing, permissions };
54
+ }
55
+ /**
56
+ * Provision the executor deny layer into `<worktree>/.claude/settings.local.json`.
57
+ * Fails open: a malformed existing file is preserved (not clobbered) and I/O
58
+ * errors return a warning — the caller still spawns the worker.
59
+ */
60
+ export async function provisionExecutorDenyLayer(worktreePath, options, deps) {
61
+ const inputs = {
62
+ baseBranch: options.baseBranch,
63
+ worktreePath,
64
+ homedir: deps.homedir(),
65
+ };
66
+ const result = await provisionClaudeSettingsForWorktree(worktreePath, (existing) => {
67
+ let merged = mergeExecutorDenySettings(existing, inputs);
68
+ if (options.preToolUseFallbackCommand) {
69
+ merged = mergeClaudeSettingsWithCommandHook(merged, options.preToolUseFallbackCommand, [], { enablePreToolUse: true, preToolUseMatcher: DEFAULT_PRE_TOOL_USE_MATCHER });
70
+ }
71
+ return merged;
72
+ }, deps);
73
+ if (result.ok)
74
+ return { ok: true };
75
+ return {
76
+ ok: false,
77
+ warning: `deny-layer provisioning did not complete (${result.reason}); continuing fail-open`,
78
+ };
79
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Executor preflight + capability manifest construction (BAPI-534, TDD §11, R8).
3
+ *
4
+ * Before claiming, verify local tooling (wt/git/claude), credentials, disk, and
5
+ * — the binding post-Wave-2 contract — deny-layer enforcement. REFUSE TO CLAIM
6
+ * on any fatal finding. The capability manifest (exactly the 8 T2-accepted keys)
7
+ * is built from these inspections. Local `gh` auth is captured in the internal
8
+ * report (for T5's fatal-before-merge preflight) but is NOT added to the T2
9
+ * claim manifest, which does not accept it in this slice.
10
+ *
11
+ * The deny-enforcement probe is injected (`seams.runDenyPreflight`) so unit tests
12
+ * never spawn a real headless `claude`; the default is the real
13
+ * `runDenyEnforcementPreflight` from the conductor deny-enforcement module (an
14
+ * allowed reuse surface — it does not pull the v1 event/ledger graph).
15
+ */
16
+ import { runDenyEnforcementPreflight } from "../conductor/deny-enforcement-preflight.js";
17
+ import { resolveAllExecutorApiAccess } from "./credentials.js";
18
+ const VERSION_DETAIL_MAX = 200;
19
+ function normalizeVersion(stdout, stderr) {
20
+ return (stdout || stderr || "").trim().slice(0, VERSION_DETAIL_MAX);
21
+ }
22
+ /**
23
+ * Collect the executor preflight report. Populates the manifest fields and
24
+ * accumulates fatal findings — the runner refuses to claim when any exist.
25
+ */
26
+ export async function collectExecutorPreflight(options, deps, seams = {}) {
27
+ const fatalFindings = [];
28
+ const warnings = [];
29
+ // --- Tooling probes ---------------------------------------------------
30
+ let wtVersion = "";
31
+ try {
32
+ const wt = await deps.runCommand(options.worktrunkBinary, ["--version"]);
33
+ if (wt.exitCode === 0) {
34
+ wtVersion = normalizeVersion(wt.stdout, wt.stderr);
35
+ }
36
+ else {
37
+ fatalFindings.push(`Worktrunk binary '${options.worktrunkBinary}' is not available`);
38
+ }
39
+ }
40
+ catch {
41
+ fatalFindings.push(`Worktrunk binary '${options.worktrunkBinary}' could not be probed`);
42
+ }
43
+ try {
44
+ const git = await deps.runCommand("git", ["--version"]);
45
+ if (git.exitCode !== 0)
46
+ fatalFindings.push("git is not available on PATH");
47
+ }
48
+ catch {
49
+ fatalFindings.push("git could not be probed");
50
+ }
51
+ let agentVersion = "";
52
+ try {
53
+ const claude = await deps.runCommand("claude", ["--version"]);
54
+ if (claude.exitCode === 0) {
55
+ agentVersion = normalizeVersion(claude.stdout, claude.stderr);
56
+ }
57
+ else {
58
+ fatalFindings.push("claude (headless agent) is not available on PATH");
59
+ }
60
+ }
61
+ catch {
62
+ fatalFindings.push("claude could not be probed");
63
+ }
64
+ // --- Credentials for every configured repo ----------------------------
65
+ const access = await resolveAllExecutorApiAccess(options.repos, deps);
66
+ for (const a of access) {
67
+ if (!a.ok)
68
+ fatalFindings.push(`credential resolution failed for repo '${a.repoName}'`);
69
+ }
70
+ // --- Disk space (fatal when it cannot be verified) --------------------
71
+ let diskFreeGb = null;
72
+ if (!deps.statfs) {
73
+ fatalFindings.push("disk space could not be verified (statfs unavailable)");
74
+ }
75
+ else {
76
+ try {
77
+ const fs = await deps.statfs(deps.homedir());
78
+ diskFreeGb = (fs.bavail * fs.bsize) / 1024 ** 3;
79
+ }
80
+ catch {
81
+ fatalFindings.push("disk space could not be verified (statfs failed)");
82
+ }
83
+ }
84
+ // --- Local gh merge capability (internal-only manifest residue) --------
85
+ let ghAuthed = false;
86
+ try {
87
+ const ghVersion = await deps.runCommand("gh", ["--version"]);
88
+ if (ghVersion.exitCode === 0) {
89
+ const ghAuth = await deps.runCommand("gh", ["auth", "status"]);
90
+ ghAuthed = ghAuth.exitCode === 0;
91
+ }
92
+ }
93
+ catch {
94
+ ghAuthed = false;
95
+ }
96
+ if (!ghAuthed) {
97
+ warnings.push("local `gh` is unavailable/unauthenticated; local merges would be degraded");
98
+ }
99
+ // --- Deny-layer enforcement (binding fatal contract) ------------------
100
+ let denyEnforced = false;
101
+ try {
102
+ const denyProbe = seams.runDenyPreflight ?? runDenyEnforcementPreflight;
103
+ const deny = await denyProbe();
104
+ denyEnforced = deny.enforced === true;
105
+ if (!deny.enforced) {
106
+ fatalFindings.push(`deny-layer enforcement not verified (enforced=false, layer=${deny.layer}); refusing to claim`);
107
+ }
108
+ else if (deny.layer === "none") {
109
+ // Defensive: an enforced=true with layer "none" is contradictory — treat
110
+ // as unverifiable, hence fatal.
111
+ fatalFindings.push("deny-layer enforcement layer is 'none'; cannot verify — refusing to claim");
112
+ }
113
+ }
114
+ catch {
115
+ fatalFindings.push("deny-layer enforcement probe failed; refusing to claim");
116
+ }
117
+ return {
118
+ ok: fatalFindings.length === 0,
119
+ fatalFindings,
120
+ warnings,
121
+ agentVersion,
122
+ wtVersion,
123
+ diskFreeGb,
124
+ ghAuthed,
125
+ denyEnforced,
126
+ };
127
+ }
128
+ /**
129
+ * Build the HTTP claim manifest — EXACTLY the eight T2-accepted keys. `free_slots`
130
+ * is supplied by the runner (dynamic). The internal-only `gh` auth capability is
131
+ * intentionally NOT included (not accepted by the T2 request schema in this slice).
132
+ */
133
+ export function buildClaimManifest(report, options, freeSlots) {
134
+ return {
135
+ repo_name: options.repoName,
136
+ executor_id: options.executorId,
137
+ repos: options.repos,
138
+ agent_version: report.agentVersion,
139
+ wt_version: report.wtVersion,
140
+ max_concurrent: options.maxConcurrent,
141
+ free_slots: freeSlots,
142
+ disk_free_gb: report.diskFreeGb ?? 0,
143
+ };
144
+ }