@bridge_gpt/mcp-server 0.2.16 → 0.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/CONDUCTOR.md +75 -0
  2. package/README.md +2 -2
  3. package/build/agent-capabilities/probe-context.js +13 -3
  4. package/build/agent-capabilities/probes.js +262 -11
  5. package/build/agent-capabilities/reporter.js +1 -0
  6. package/build/agents.generated.js +3 -3
  7. package/build/backend-warnings.js +44 -0
  8. package/build/claude-settings.js +129 -0
  9. package/build/commands.generated.js +7 -6
  10. package/build/conductor/bridge-api-client.js +198 -18
  11. package/build/conductor/claude-hook.js +22 -4
  12. package/build/conductor/cli.js +76 -25
  13. package/build/conductor/deny-enforcement-preflight.js +96 -0
  14. package/build/conductor/doctor.js +183 -2
  15. package/build/conductor/done-gate.js +5 -0
  16. package/build/conductor/epic-reconcile.js +71 -14
  17. package/build/conductor/epic-runtime.js +839 -67
  18. package/build/conductor/epic-state.js +524 -63
  19. package/build/conductor/errors.js +156 -3
  20. package/build/conductor/event-accessors.js +252 -0
  21. package/build/conductor/file-scope-guard.js +201 -0
  22. package/build/conductor/github-mergeability.js +85 -0
  23. package/build/conductor/local-merge.js +47 -1
  24. package/build/conductor/merge-identity.js +41 -0
  25. package/build/conductor/merge-ledger.js +19 -72
  26. package/build/conductor/plan.js +12 -2
  27. package/build/conductor/pr-ci-producer.js +17 -2
  28. package/build/conductor/pr-discovery.js +11 -1
  29. package/build/conductor/producer-ledger.js +1 -1
  30. package/build/conductor/store.js +161 -18
  31. package/build/conductor/supervisor-config.js +4 -39
  32. package/build/conductor/supervisor-escalation.js +10 -26
  33. package/build/conductor/supervisor-ledger.js +5 -12
  34. package/build/conductor/supervisor-merge.js +32 -5
  35. package/build/conductor/supervisor-message-relay.js +2 -5
  36. package/build/conductor/supervisor-notification.js +1 -1
  37. package/build/conductor/supervisor-runtime.js +12 -54
  38. package/build/conductor/supervisor-state.js +4 -18
  39. package/build/conductor/supervisor-types.js +2 -2
  40. package/build/conductor/taxonomy.js +12 -0
  41. package/build/conductor/tools.js +28 -6
  42. package/build/conductor/worker-ledger-cli.js +244 -0
  43. package/build/conductor-bin.js +1800 -5166
  44. package/build/conductor-claude-hook-bin.js +4 -2
  45. package/build/doctor.js +40 -0
  46. package/build/executor/cli.js +229 -0
  47. package/build/executor/credentials.js +65 -0
  48. package/build/executor/deps.js +117 -0
  49. package/build/executor/env.js +79 -0
  50. package/build/executor/heartbeat.js +59 -0
  51. package/build/executor/http-client.js +131 -0
  52. package/build/executor/index.js +10 -0
  53. package/build/executor/job-errors.js +55 -0
  54. package/build/executor/job-log-registry.js +110 -0
  55. package/build/executor/job-runner.js +688 -0
  56. package/build/executor/job-types.js +60 -0
  57. package/build/executor/merge-job.js +155 -0
  58. package/build/executor/observation.js +123 -0
  59. package/build/executor/permissions.js +79 -0
  60. package/build/executor/preflight.js +144 -0
  61. package/build/executor/process.js +81 -0
  62. package/build/executor/prompt-spec.js +235 -0
  63. package/build/executor/results.js +134 -0
  64. package/build/executor/resume-pre-spawn.js +179 -0
  65. package/build/executor/runner.js +98 -0
  66. package/build/executor/terminal-mutation.js +34 -0
  67. package/build/executor/test-clock.js +109 -0
  68. package/build/executor/types.js +18 -0
  69. package/build/executor/verdict-artifact.js +53 -0
  70. package/build/executor/viewer-tabs.js +78 -0
  71. package/build/executor/watch-cli.js +113 -0
  72. package/build/executor/worker-command.js +106 -0
  73. package/build/executor/worker-finalization.js +97 -0
  74. package/build/executor/worker-log.js +92 -0
  75. package/build/executor/worktree-gc.js +134 -0
  76. package/build/executor/worktree-inspection.js +86 -0
  77. package/build/executor/worktree.js +103 -0
  78. package/build/index.js +13950 -9669
  79. package/build/install-bridge.js +25 -8
  80. package/build/install-doctor.js +387 -0
  81. package/build/mcp-invoke.js +19 -3
  82. package/build/mcp-provisioning.js +31 -25
  83. package/build/mcp-registration-doctor.js +27 -7
  84. package/build/mcp-server-invocation.js +152 -0
  85. package/build/pipelines.generated.js +31 -6
  86. package/build/readme.generated.js +1 -1
  87. package/build/regression-check.js +53 -1
  88. package/build/review-tickets.js +175 -21
  89. package/build/sfcc/reads-site-preference.js +52 -19
  90. package/build/start-tickets-conductor.js +47 -99
  91. package/build/start-tickets-prereqs.js +185 -4
  92. package/build/start-tickets.js +218 -180
  93. package/build/version.generated.js +1 -1
  94. package/build/visual-diff-worker.js +313 -0
  95. package/build/visual-diff.js +632 -0
  96. package/build/worktree-core.js +202 -0
  97. package/package.json +10 -6
  98. package/pipelines/review-ticket.json +24 -2
  99. package/public/css/main.min.css +3311 -1
  100. package/public/css/main.min.css.map +1 -1
  101. package/public/js/main.min.js +7924 -1
  102. package/public/js/main.min.js.map +1 -1
  103. package/smoke-test/SMOKE-TEST.md +5 -2
@@ -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,155 @@
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
+ import { makeLocalMergeExecutor, resolveLocalMergeMethod, } from "../conductor/local-merge.js";
15
+ import { resolveConductorBridgeApiAccess, } from "../conductor/bridge-api-client.js";
16
+ import { secretFreeErrorMessage } from "./job-errors.js";
17
+ function positiveIntOrNull(value) {
18
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
19
+ }
20
+ function asString(value) {
21
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
22
+ }
23
+ /**
24
+ * Extract and validate merge fields. Prefers the claimed-job `expected_head_sha`
25
+ * over any payload copy, validates `pr_number` as a positive integer, and fails
26
+ * (before any subprocess) when the PR number or expected head SHA is absent.
27
+ */
28
+ export function readMergeJobPayloadFields(job) {
29
+ const payload = job.payload && typeof job.payload === "object" ? job.payload : {};
30
+ const prNumber = positiveIntOrNull(payload.pr_number);
31
+ if (prNumber === null) {
32
+ return { ok: false, error: "merge job requires a positive integer pr_number" };
33
+ }
34
+ const expectedHeadSha = asString(job.expected_head_sha) ?? asString(payload.expected_head_sha);
35
+ if (!expectedHeadSha) {
36
+ return { ok: false, error: "merge job requires an expected_head_sha (top-level or payload)" };
37
+ }
38
+ const method = resolveLocalMergeMethod(payload.method);
39
+ const requiredChecks = Array.isArray(payload.required_checks)
40
+ ? payload.required_checks.filter((c) => typeof c === "string")
41
+ : [];
42
+ const actionKey = asString(payload.action_key) ?? `merge:pr-${prNumber}:${expectedHeadSha}`;
43
+ return {
44
+ ok: true,
45
+ fields: { prNumber, expectedHeadSha, method, requiredChecks, actionKey },
46
+ };
47
+ }
48
+ /** Construct the {@link ConductorMergeRequest} for the local merge executor. */
49
+ export function buildConductorMergeRequestForExecutorJob(fields, repoName) {
50
+ return {
51
+ repo_name: repoName,
52
+ pr_number: fields.prNumber,
53
+ expected_head_sha: fields.expectedHeadSha,
54
+ gate: { name: "merge", required_checks: fields.requiredChecks },
55
+ action_key: fields.actionKey,
56
+ };
57
+ }
58
+ /**
59
+ * Resolve {@link ConductorBridgeApiAccess} for the merge via the SHARED conductor
60
+ * resolver (repo name + credential store + base URL). The API key never reaches
61
+ * `gh` argv/env/logs — it is used only for the executor's own read-only CI
62
+ * re-poll. Returns a structured failure rather than throwing.
63
+ */
64
+ export async function buildConductorMergeAccessForExecutorJob(deps) {
65
+ const result = await resolveConductorBridgeApiAccess(deps);
66
+ if (result.ok)
67
+ return { ok: true, access: result.access };
68
+ return { ok: false, error: result.error };
69
+ }
70
+ /** Pull the best-effort merge commit SHA from a succeeded merge response. */
71
+ function extractMergeCommitSha(response) {
72
+ for (const ev of response.ledger_events) {
73
+ if (ev.type === "merge.succeeded") {
74
+ const sha = ev.details?.merge_commit_sha;
75
+ if (typeof sha === "string" && sha.length > 0)
76
+ return sha;
77
+ }
78
+ }
79
+ return undefined;
80
+ }
81
+ /**
82
+ * Map a succeeded local-merge response into the backend `merge` result schema
83
+ * (`MinimalJobResult`, `extra="allow"`): the provider action was accepted, plus
84
+ * the best-effort merge commit SHA. NO gate-advancement or observer-only fields.
85
+ */
86
+ export function buildMergeJobResult(response, fields) {
87
+ const mergeCommitSha = extractMergeCommitSha(response);
88
+ const result = {
89
+ accepted: true,
90
+ pr_number: fields.prNumber,
91
+ merge_method: fields.method,
92
+ summary: `merged PR #${fields.prNumber} via ${fields.method}`,
93
+ };
94
+ if (mergeCommitSha) {
95
+ result.commit_sha = mergeCommitSha;
96
+ result.head_sha = mergeCommitSha;
97
+ }
98
+ return result;
99
+ }
100
+ /** Convert a non-succeeded local-merge response into a bounded executor failure. */
101
+ export function buildMergeJobFailure(response) {
102
+ const reason = asString(response.reason ?? undefined) ?? response.status;
103
+ return {
104
+ error_kind: "MergeFailed",
105
+ error_message: secretFreeErrorMessage(new Error(`local merge ${response.status}: ${reason}`)),
106
+ classification: "crashed",
107
+ };
108
+ }
109
+ /**
110
+ * Run the deterministic merge. NEVER ensures/recreates a worktree and NEVER
111
+ * spawns a worker. Returns a completion result on a succeeded merge, else a
112
+ * bounded, secret-free executor failure.
113
+ */
114
+ export async function runExecutorMergeJob(job, seams) {
115
+ const resolution = readMergeJobPayloadFields(job);
116
+ if (!resolution.ok) {
117
+ return {
118
+ ok: false,
119
+ failure: {
120
+ error_kind: "ContractError.MergePayload",
121
+ error_message: resolution.error,
122
+ classification: "crashed",
123
+ },
124
+ };
125
+ }
126
+ const fields = resolution.fields;
127
+ const request = buildConductorMergeRequestForExecutorJob(fields, seams.access.repoName);
128
+ const make = seams.makeExecutor ?? makeLocalMergeExecutor;
129
+ const executor = make({ method: fields.method }, seams.localMergeDeps);
130
+ let response;
131
+ try {
132
+ response = await executor(seams.access, request);
133
+ }
134
+ catch (err) {
135
+ return {
136
+ ok: false,
137
+ failure: {
138
+ error_kind: "MergeFailed",
139
+ error_message: secretFreeErrorMessage(err),
140
+ classification: "crashed",
141
+ },
142
+ };
143
+ }
144
+ if (response.status === "succeeded") {
145
+ return { ok: true, result: buildMergeJobResult(response, fields) };
146
+ }
147
+ return { ok: false, failure: buildMergeJobFailure(response) };
148
+ }
149
+ /** Build the default local-merge deps for production wiring (no real I/O here). */
150
+ export function buildDefaultMergeLocalDeps(deps) {
151
+ // Local `gh` auth lives in the ambient environment; makeLocalMergeExecutor
152
+ // overlays it and pins the no-noise/non-interactive flags. The App token is
153
+ // never placed here.
154
+ return { env: deps.env };
155
+ }
@@ -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
+ }
@@ -0,0 +1,81 @@
1
+ /** Default retained stdout/stderr excerpt bound (bytes). */
2
+ export const DEFAULT_EXCERPT_BYTES = 8_000;
3
+ /** Default SIGTERM→SIGKILL grace period (ms). */
4
+ export const DEFAULT_TERM_GRACE_MS = 10_000;
5
+ function byteLength(value) {
6
+ return Buffer.byteLength(value, "utf8");
7
+ }
8
+ function appendBounded(existing, chunk, limitBytes) {
9
+ if (byteLength(existing) >= limitBytes)
10
+ return existing;
11
+ const combined = existing + chunk;
12
+ if (byteLength(combined) <= limitBytes)
13
+ return combined;
14
+ return Buffer.from(combined, "utf8").subarray(0, limitBytes).toString("utf8");
15
+ }
16
+ async function pump(iterable, onChunk) {
17
+ if (!iterable)
18
+ return;
19
+ try {
20
+ for await (const chunk of iterable) {
21
+ onChunk(typeof chunk === "string" ? chunk : String(chunk));
22
+ }
23
+ }
24
+ catch {
25
+ /* stream errors degrade observability, never correctness */
26
+ }
27
+ }
28
+ /**
29
+ * Run an owned child process to completion, enforcing `timeoutSeconds` locally.
30
+ * On timeout: SIGTERM, then SIGKILL after the grace period if still alive;
31
+ * classification `timeout`. On normal exit: `clean_exit` (code 0) or `crashed`
32
+ * (non-zero). Retained stdout/stderr excerpts are bounded.
33
+ */
34
+ export async function runProcessWithTimeout(proc, timeoutSeconds, deps, options = {}) {
35
+ const termGraceMs = options.termGraceMs ?? DEFAULT_TERM_GRACE_MS;
36
+ const limit = options.excerptLimitBytes ?? DEFAULT_EXCERPT_BYTES;
37
+ let stdoutExcerpt = "";
38
+ let stderrExcerpt = "";
39
+ const pumpStdout = pump(proc.stdout, (chunk) => {
40
+ options.onStdout?.(chunk);
41
+ stdoutExcerpt = appendBounded(stdoutExcerpt, chunk, limit);
42
+ });
43
+ const pumpStderr = pump(proc.stderr, (chunk) => {
44
+ stderrExcerpt = appendBounded(stderrExcerpt, chunk, limit);
45
+ });
46
+ let timedOut = false;
47
+ let graceTimer;
48
+ const timeoutTimer = deps.setTimer(() => {
49
+ timedOut = true;
50
+ proc.kill("SIGTERM");
51
+ graceTimer = deps.setTimer(() => {
52
+ // Still alive after the grace period — escalate.
53
+ proc.kill("SIGKILL");
54
+ }, termGraceMs);
55
+ }, timeoutSeconds * 1000);
56
+ const { exitCode, signal } = await proc.wait();
57
+ deps.clearTimer(timeoutTimer);
58
+ if (graceTimer !== undefined)
59
+ deps.clearTimer(graceTimer);
60
+ await pumpStdout;
61
+ await pumpStderr;
62
+ let classification;
63
+ if (timedOut) {
64
+ classification = "timeout";
65
+ }
66
+ else if (exitCode === 0) {
67
+ classification = "clean_exit";
68
+ }
69
+ else {
70
+ classification = "crashed";
71
+ }
72
+ return { classification, exitCode, signal, stdoutExcerpt, stderrExcerpt };
73
+ }
74
+ /**
75
+ * Immediately SIGKILL an owned process for a stale-claim or dead-man abandonment.
76
+ * Returns a `killed` classification carrying the abandonment reason.
77
+ */
78
+ export function killOwnedProcess(proc, reason) {
79
+ proc.kill("SIGKILL");
80
+ return { classification: "killed", reason };
81
+ }