@bridge_gpt/mcp-server 0.2.62 → 0.2.65

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 (80) hide show
  1. package/README.md +47 -8
  2. package/build/agent-launchers/claude-executor-adapter.js +134 -3
  3. package/build/agent-launchers/executor-adapter.js +54 -2
  4. package/build/agents.generated.js +10 -1
  5. package/build/bounded-wait.js +32 -1
  6. package/build/commands.generated.js +6 -5
  7. package/build/conductor/bridge-api-client.js +335 -22
  8. package/build/conductor/bring-up-facts.js +3 -1
  9. package/build/conductor/doctor.js +23 -14
  10. package/build/conductor/epic-child-continuation.js +324 -0
  11. package/build/conductor/epic-implementer-cli.js +142 -17
  12. package/build/conductor/git-hooks.js +529 -118
  13. package/build/conductor/hook-runtime-qualification.js +340 -0
  14. package/build/conductor/readiness-sections.js +5 -4
  15. package/build/conductor/readiness.js +22 -11
  16. package/build/conductor/recovery-cli.js +354 -13
  17. package/build/conductor/recovery-operations.js +251 -5
  18. package/build/conductor/repair-contract.js +8 -0
  19. package/build/conductor-bundle-artifacts.js +1 -0
  20. package/build/credential-store.js +50 -12
  21. package/build/docs.generated.js +2 -1
  22. package/build/doctor.js +77 -11
  23. package/build/drive-epic-key.js +12 -0
  24. package/build/drive-epic.js +1211 -116
  25. package/build/epic-coherence-input-cli.js +137 -0
  26. package/build/epic-coherence-input.js +920 -0
  27. package/build/epic-coherence-input.test-fixtures.js +175 -0
  28. package/build/epic-implementer-bin.js +35 -29
  29. package/build/epic-implementer-bundle-cli.js +14 -1
  30. package/build/epic-launch-profile.js +14 -5
  31. package/build/epic-run-status.js +5 -0
  32. package/build/epic-shared/bridge-client.js +46 -4
  33. package/build/executor/cli.js +39 -0
  34. package/build/executor/completion-contract.js +423 -0
  35. package/build/executor/completion-supervision.js +286 -0
  36. package/build/executor/credentials.js +3 -1
  37. package/build/executor/deps.js +65 -1
  38. package/build/executor/env.js +6 -0
  39. package/build/executor/heartbeat.js +13 -3
  40. package/build/executor/http-client.js +76 -4
  41. package/build/executor/job-errors.js +48 -0
  42. package/build/executor/job-runner.js +1294 -26
  43. package/build/executor/job-types.js +10 -2
  44. package/build/executor/preflight.js +99 -0
  45. package/build/executor/process-containment.js +756 -0
  46. package/build/executor/process.js +235 -5
  47. package/build/executor/prompt-spec.js +262 -1
  48. package/build/executor/provider-checks.js +69 -0
  49. package/build/executor/rebase-finalization.js +351 -0
  50. package/build/executor/reconcile-overlap-job.js +77 -84
  51. package/build/executor/required-verification.js +313 -0
  52. package/build/executor/required-work-ledger.js +1121 -0
  53. package/build/executor/results.js +142 -0
  54. package/build/executor/resume-pre-spawn.js +87 -4
  55. package/build/executor/runner.js +145 -0
  56. package/build/executor/test-declaration.js +20 -0
  57. package/build/executor/types.js +12 -0
  58. package/build/executor/worker-command.js +49 -13
  59. package/build/executor/worker-finalization.js +88 -11
  60. package/build/executor/worker-guard-hook.js +29 -4
  61. package/build/executor/worktree.js +13 -0
  62. package/build/executor-launch.js +658 -0
  63. package/build/index.js +537 -943
  64. package/build/install-bridge-conductor.js +231 -41
  65. package/build/merge-pull-request.js +1 -1
  66. package/build/pipelines.generated.js +8 -7
  67. package/build/plan-discovery.js +137 -0
  68. package/build/plan-epic-implementer-eligibility.js +2 -2
  69. package/build/plane/cli.js +37 -3
  70. package/build/plane/member-logs.js +3 -0
  71. package/build/plane/member-roster.js +7 -1
  72. package/build/plane/shutdown.js +59 -1
  73. package/build/plane/supervisor.js +142 -0
  74. package/build/readme.generated.js +1 -1
  75. package/build/scripts/executor-protocol-contract-driver.js +13 -3
  76. package/build/setup-epic.js +28 -17
  77. package/build/version.generated.js +3 -3
  78. package/build/worker-guard-hook-bin.js +2 -2
  79. package/docs/CONDUCTOR.md +116 -2
  80. package/package.json +2 -1
@@ -27,7 +27,10 @@ const USAGE = [
27
27
  " validate Validate identities, the node->ticket mapping, manifest agreement,",
28
28
  " and path containment. Writes nothing.",
29
29
  " finalize Validate, then finalize epic-plan.dag.json with real keys and",
30
- " per-node touched_files, and write the sibling-ticket manifest.",
30
+ " per-node touched_files, write the sibling-ticket manifest, and",
31
+ " write epic-plan.meta.json ({\"epic_key\": ...}) beside the DAG so",
32
+ " drive-epic can find the plan from the epic key. Each file is a",
33
+ " separate atomic write, not one transaction across all three.",
31
34
  "",
32
35
  "Options:",
33
36
  " --input <file> JSON document with epic_key, epic_slug, mappings,",
@@ -235,6 +238,15 @@ export async function runEpicImplementerBundleCli(argv, overrides = {}) {
235
238
  const manifestWrite = await writeJsonAtomically(validation.value.manifest_path, manifest, deps.fs);
236
239
  if (!manifestWrite.ok)
237
240
  return emitFailure(manifestWrite.error);
241
+ // BAPI-1285: the discovery metadata is written LAST, and separately from the
242
+ // DAG on purpose — the sidecar goes to the server, whose epic-run models
243
+ // forbid extra fields. A failure here must never read as success, but the
244
+ // earlier artifacts are already final, so the message says so.
245
+ const metaWrite = await writeJsonAtomically(validation.value.meta_path, { epic_key: validation.value.epic_key }, deps.fs);
246
+ if (!metaWrite.ok) {
247
+ return emitFailure(`Could not write meta_path ${validation.value.meta_path}. The sidecar and manifest may ` +
248
+ "already be finalized. Re-run finalize, or check write permissions on the docs directory.");
249
+ }
238
250
  const result = {
239
251
  ok: true,
240
252
  mode: "finalize",
@@ -242,6 +254,7 @@ export async function runEpicImplementerBundleCli(argv, overrides = {}) {
242
254
  plan_version: validation.value.plan_version,
243
255
  sidecar_path: validation.value.sidecar_path,
244
256
  manifest_path: validation.value.manifest_path,
257
+ meta_path: validation.value.meta_path,
245
258
  ticket_keys: validation.value.mappings.map((m) => m.ticket_key),
246
259
  };
247
260
  if (opts.json)
@@ -317,8 +317,11 @@ export async function writeEpicLaunchProfile(profile, deps) {
317
317
  * `BAPI_BASE_URL` that names a DIFFERENT endpoint is a conflict — an override
318
318
  * alone never rebinds a profile; guided setup does that.
319
319
  * - Without one, `--base-url` and `BAPI_BASE_URL` must agree when both are set.
320
- * - Nothing set is `target-missing`. There is no production default here:
321
- * production is supported, but only as a deliberate selection.
320
+ * - Nothing set uses the caller's `defaultBaseUrl` (source `"default"`), which
321
+ * is validated only on that branch, so it can never change an explicit
322
+ * outcome or mask a conflict. `drive-epic` passes production. A caller that
323
+ * passes no default — guided setup, which writes a binding and a profile and
324
+ * so needs a deliberate target — gets `target-missing`.
322
325
  *
323
326
  * Messages name the source and the rule only — never a URL, which can carry
324
327
  * userinfo.
@@ -377,12 +380,18 @@ export function resolveEpicLaunchTarget(inputs) {
377
380
  return { ok: true, baseUrl: flag.value, source: "flag" };
378
381
  if (env.value !== null)
379
382
  return { ok: true, baseUrl: env.value, source: "env" };
383
+ if (inputs.defaultBaseUrl !== undefined) {
384
+ const fallback = normalize(inputs.defaultBaseUrl, "default Bridge API target");
385
+ if (!fallback.ok)
386
+ return { ok: false, kind: "target-invalid", message: fallback.message };
387
+ if (fallback.value !== null)
388
+ return { ok: true, baseUrl: fallback.value, source: "default" };
389
+ }
380
390
  return {
381
391
  ok: false,
382
392
  kind: "target-missing",
383
- message: "No Bridge API target is selected for this epic launch. Pass --launch-profile <name> (created by " +
384
- `\`npx -y ${MCP_PACKAGE_NAME} install conductor --launch-profile <name> --base-url <url>\`), or ` +
385
- "--base-url <url>. Nothing defaults to production.",
393
+ message: "No Bridge API target is selected. Pass --base-url <url> or set BAPI_BASE_URL to the server " +
394
+ "this setup should approve.",
386
395
  };
387
396
  }
388
397
  // ---------------------------------------------------------------------------
@@ -0,0 +1,5 @@
1
+ export const TERMINAL_EPIC_RUN_STATUSES = ["abandoned", "done"];
2
+ /** True when `status` names a finished run. Unknown strings are NOT terminal. */
3
+ export function isTerminalEpicRunStatus(status) {
4
+ return TERMINAL_EPIC_RUN_STATUSES.includes(status);
5
+ }
@@ -657,6 +657,36 @@ export async function reclaimIndexScope(access, request, fetchImpl = globalThis.
657
657
  },
658
658
  };
659
659
  }
660
+ const AUTOMATION_QUEUE_VERDICTS = [
661
+ "ok",
662
+ "working_backlog",
663
+ "undrained",
664
+ "unknown",
665
+ ];
666
+ /**
667
+ * Narrow the optional `automation_queue` block. Anything not shaped like the
668
+ * BAPI-1299 contract is `null`, so a consumer reports it as unavailable.
669
+ */
670
+ export function parseAutomationQueueHealth(raw) {
671
+ if (!isRecord(raw))
672
+ return null;
673
+ const status = raw["status"];
674
+ const verdict = raw["verdict"];
675
+ if (status !== "readable" && status !== "unreadable")
676
+ return null;
677
+ if (typeof verdict !== "string" || !AUTOMATION_QUEUE_VERDICTS.includes(verdict))
678
+ return null;
679
+ const count = raw["queued_count"];
680
+ const age = raw["oldest_queued_age_seconds"];
681
+ const evidence = raw["has_fresh_drainer_evidence"];
682
+ return {
683
+ status,
684
+ verdict: verdict,
685
+ queuedCount: typeof count === "number" && Number.isFinite(count) ? count : null,
686
+ oldestQueuedAgeSeconds: typeof age === "number" && Number.isFinite(age) ? age : null,
687
+ hasFreshDrainerEvidence: typeof evidence === "boolean" ? evidence : null,
688
+ };
689
+ }
660
690
  /**
661
691
  * Epic-run statuses that mean a v2 conductor may still be driving the run.
662
692
  *
@@ -681,9 +711,10 @@ const ACTIVE_EPIC_RUN_STATUSES = [
681
711
  /**
682
712
  * `GET /automation/health?repo_name=` — read-only parse-dispatcher liveness.
683
713
  *
684
- * Root-mounted, NOT under `/jira`. The response is narrowed to two safe fields;
685
- * signals, stale-ticket samples, queue contents, and every timestamp are
686
- * deliberately dropped — an advisory line needs a verdict, not a report.
714
+ * Root-mounted, NOT under `/jira`. The response is narrowed to two safe fields,
715
+ * plus (BAPI-1299) the automation-queue verdict and its three counts; signals,
716
+ * stale-ticket samples, queue contents, and every timestamp are deliberately
717
+ * dropped — an advisory line needs a verdict, not a report.
687
718
  *
688
719
  * `absent` is claimed ONLY on positive evidence: a `stale` or `never_seen`
689
720
  * heartbeat together with a responding process that is not itself sweeping.
@@ -704,6 +735,7 @@ export async function getParseDispatcherHealth(access, fetchImpl = globalThis.fe
704
735
  value: { observation: "unavailable", heartbeatState: null, respondingSchedulerRunning: false },
705
736
  };
706
737
  }
738
+ const automationQueue = parseAutomationQueueHealth(result.value["automation_queue"]);
707
739
  const reconciler = isRecord(result.value["reconciler"]) ? result.value["reconciler"] : null;
708
740
  const scheduler = isRecord(result.value["scheduler"]) ? result.value["scheduler"] : null;
709
741
  const rawState = reconciler === null ? undefined : reconciler["state"];
@@ -722,7 +754,17 @@ export async function getParseDispatcherHealth(access, fetchImpl = globalThis.fe
722
754
  else {
723
755
  observation = "unavailable";
724
756
  }
725
- return { ok: true, value: { observation, heartbeatState, respondingSchedulerRunning } };
757
+ // BAPI-1299: the queue block is added only when the server sent a
758
+ // well-formed one, so an older server's result keeps its exact prior shape.
759
+ return {
760
+ ok: true,
761
+ value: {
762
+ observation,
763
+ heartbeatState,
764
+ respondingSchedulerRunning,
765
+ ...(automationQueue !== null ? { automationQueue } : {}),
766
+ },
767
+ };
726
768
  }
727
769
  /**
728
770
  * `GET /jira/epic-runs/runs?repo_name=` — live v2 conductor runs on THIS repo.
@@ -6,6 +6,7 @@
6
6
  * the MCP server; all user-facing diagnostics go to stderr; secrets are never
7
7
  * printed. Returns numeric exit codes rather than throwing at the top level.
8
8
  */
9
+ import { unlink } from "node:fs/promises";
9
10
  import os from "node:os";
10
11
  import { VERSION } from "../version.generated.js";
11
12
  import { resolveWorktrunkBinary } from "../start-tickets-prereqs.js";
@@ -20,6 +21,7 @@ import { createIntegrationActionHttpClient, } from "./integration-job.js";
20
21
  import { startExecutorSleepAssertion } from "./platform.js";
21
22
  import { runExecutor } from "./runner.js";
22
23
  import { runExecutorWatchCli } from "./watch-cli.js";
24
+ import { removeRunExecutorRecordIfOwned } from "../executor-launch.js";
23
25
  /** Fixed executor timing/behavior defaults. */
24
26
  const DEFAULT_POLL_INTERVAL_MS = 15_000;
25
27
  const DEFAULT_HEARTBEAT_INTERVAL_MS = 60_000;
@@ -48,6 +50,9 @@ export function getExecutorUsage() {
48
50
  " Exactly one of --epic-run-id or --repo-wide is",
49
51
  " REQUIRED; omitting both is a startup error, not",
50
52
  " repository-wide claiming (BAPI-1026 / R54).",
53
+ " --exit-when-run-terminal Exit 0 once the ONE --epic-run-id run is done or",
54
+ " abandoned and no job is held (BAPI-1279). Valid",
55
+ " only with exactly one --epic-run-id.",
51
56
  " --base-url <url> Bridge API endpoint (overrides BAPI_BASE_URL).",
52
57
  " --executor-id <id> Stable executor id (default: <hostname>-<pid>).",
53
58
  " --max-concurrent <n> Max concurrent jobs (>= 1, default 1).",
@@ -80,6 +85,7 @@ export function parseExecutorArgs(argv, context) {
80
85
  const repos = [];
81
86
  const epicRunIds = [];
82
87
  let repoWide = false;
88
+ let exitWhenRunTerminal = false;
83
89
  let scrubInheritedGithubCredentials = false;
84
90
  let executorId;
85
91
  let maxConcurrent = DEFAULT_MAX_CONCURRENT;
@@ -126,6 +132,9 @@ export function parseExecutorArgs(argv, context) {
126
132
  else if (arg === "--repo-wide") {
127
133
  repoWide = true;
128
134
  }
135
+ else if (arg === "--exit-when-run-terminal") {
136
+ exitWhenRunTerminal = true;
137
+ }
129
138
  else if (arg === "--scrub-inherited-github-credentials") {
130
139
  scrubInheritedGithubCredentials = true;
131
140
  }
@@ -208,6 +217,15 @@ export function parseExecutorArgs(argv, context) {
208
217
  if (!scope.ok) {
209
218
  return { kind: "error", message: scope.message };
210
219
  }
220
+ // BAPI-1279 — "exit when THE run is finished" needs exactly one run to watch.
221
+ // With several, or with --repo-wide, there is no single run whose end means
222
+ // this process is done, so the combination is refused before startup.
223
+ if (exitWhenRunTerminal && epicRunIds.length !== 1) {
224
+ return {
225
+ kind: "error",
226
+ message: "--exit-when-run-terminal requires exactly one --epic-run-id (and no --repo-wide)",
227
+ };
228
+ }
211
229
  const options = {
212
230
  executorId: executorIdFinal,
213
231
  repos,
@@ -237,6 +255,8 @@ export function parseExecutorArgs(argv, context) {
237
255
  // BAPI-1026 (R54) — same "truly absent, never a falsy placeholder"
238
256
  // discipline: `repoWide` is either `true` or the key is missing.
239
257
  ...(repoWide ? { repoWide: true } : {}),
258
+ // BAPI-1279 — same discipline: `true` or absent.
259
+ ...(exitWhenRunTerminal ? { exitWhenRunTerminal: true } : {}),
240
260
  // BAPI-1137: absent unless explicitly requested, so an ordinary executor's
241
261
  // options object is byte-for-byte what it was before this slice.
242
262
  ...(scrubInheritedGithubCredentials ? { scrubInheritedGithubCredentials: true } : {}),
@@ -401,6 +421,25 @@ export async function runExecutorCli(argv, overrides = {}) {
401
421
  const createIntegrationClient = overrides.createIntegrationClient ?? createIntegrationActionHttpClient;
402
422
  const runnerSeams = {
403
423
  onSignal: overrides.onSignal ?? defaultSignalRegistrar,
424
+ // BAPI-1279 — on a terminal exit, delete the launcher record drive-epic
425
+ // wrote for this run, but only if it still names THIS instance (its id, and
426
+ // this process or the relay that started it). Advisory: a failure here
427
+ // never changes the exit code.
428
+ ...(options.exitWhenRunTerminal === true
429
+ ? {
430
+ onRunTerminal: async (epicRunId) => {
431
+ await removeRunExecutorRecordIfOwned({
432
+ repoRoot: deps.cwd,
433
+ epicRunId,
434
+ executorId: options.executorId,
435
+ pids: [context.pid, process.ppid],
436
+ }, {
437
+ readFile: deps.readFile,
438
+ unlink: (filePath) => unlink(filePath),
439
+ });
440
+ },
441
+ }
442
+ : {}),
404
443
  integrationActions: createIntegrationClient({
405
444
  baseUrl,
406
445
  apiKey: apiKeyByRepo[options.repoName],
@@ -0,0 +1,423 @@
1
+ /**
2
+ * The required-work completion-evidence contract (BAPI-1301, D4a) — executor
3
+ * mirror of `src/python/llms/executor_completion_contract.py`.
4
+ *
5
+ * A covered child attempt (`implement`, `resume`, `remediate`, `ci_fix`) that
6
+ * was claimed under the `completion_evidence.v1` requirement reports its
7
+ * terminal outcome with one bounded, versioned envelope built from facts the
8
+ * EXECUTOR observed: the stream-json lifecycle it read to EOF, the processes it
9
+ * swept, and the finalization facts it verified. The server re-validates the
10
+ * envelope against the claimed row and refuses success unless the envelope
11
+ * proves the attempt finished its required work and cleaned up after itself.
12
+ *
13
+ * What the envelope proves, and what it does not:
14
+ * - A task that ended `succeeded` proves its LIFECYCLE ended. It does not prove
15
+ * the check it ran is correct or that an effect it attempted was authorized.
16
+ * Declared checks are run by the executor itself (D4c) and reported through
17
+ * `verification`.
18
+ * - `cleanup.verdict: "confirmed"` covers processes that kept the inherited
19
+ * environment. Every covered verdict names `residual_not_covered`: a daemon
20
+ * that detaches AND clears its environment carries no tag and is invisible to
21
+ * the sweep on macOS. The controlled workload excludes it.
22
+ *
23
+ * Nothing in the envelope is a prompt, a raw tool argument, a provider payload,
24
+ * a process argument, an environment value, a path, or a URL. Inputs and targets
25
+ * cross the wire only as SHA-256 digests.
26
+ */
27
+ import { createHash } from "node:crypto";
28
+ /** The capability string declared on `POST /claim` and required by covered jobs. */
29
+ export const COMPLETION_EVIDENCE_CAPABILITY = "completion_evidence.v1";
30
+ export const COMPLETION_CONTRACT_VERSION = 1;
31
+ /**
32
+ * BAPI-1302 (D4c) — the envelope version that carries executor-run check facts
33
+ * in `verification_facts`. Sent for every job whose payload carries a
34
+ * required-verification declaration; version 1 otherwise.
35
+ */
36
+ export const COMPLETION_CONTRACT_VERSION_V2 = 2;
37
+ /**
38
+ * BAPI-1302 (D4c) — declared on `POST /claim` when this executor runs declared
39
+ * checks itself. A job carrying a declaration requires it, so an executor
40
+ * without the runner never claims one.
41
+ */
42
+ export const REQUIRED_VERIFICATION_CAPABILITY = "required_verification.v1";
43
+ export const COMPLETION_CONTRACT_MARKER_VALUES = ["completion_evidence.v1", "legacy"];
44
+ export const COVERED_JOB_TYPES = ["implement", "resume", "remediate", "ci_fix"];
45
+ /**
46
+ * The measured runtime identity (BAPI-1292 probe): Claude Code 2.1.282 on macOS
47
+ * arm64. A measured baseline, not a certification — any other CLI version or
48
+ * platform is unsupported until measured.
49
+ */
50
+ export const SUPPORTED_COMPLETION_RUNTIME = {
51
+ adapter: "claude",
52
+ cli_version: "2.1.282",
53
+ platform: "darwin-arm64",
54
+ event_schema: "claude-stream-json.lifecycle.v1",
55
+ };
56
+ export const EVIDENCE_VALUES = ["sufficient", "insufficient"];
57
+ export const INSUFFICIENT_REASON_VALUES = [
58
+ "no_init_event",
59
+ "no_result_turn",
60
+ "active_turn_at_close",
61
+ "live_required_work",
62
+ "stale_task_snapshot",
63
+ "late_activity_after_close",
64
+ "stdout_not_read_to_eof",
65
+ "stream_parse_error",
66
+ "stream_lost",
67
+ "stream_oversize",
68
+ "stream_truncated",
69
+ "event_conflict",
70
+ "event_unknown",
71
+ "ledger_capacity_exhausted",
72
+ "denial_capacity_exhausted",
73
+ "forced_kill_no_cli_teardown",
74
+ "no_process_exit",
75
+ "transport_failed_before_work",
76
+ "transport_failed_after_progress",
77
+ "allowance_exhausted",
78
+ "unsupported_runtime",
79
+ "binding_mismatch",
80
+ // Failed before any worker was launched: nothing to be incomplete, nothing to
81
+ // clean up. Alone, it is not a completion cause (see isNotLaunchedEnvelope).
82
+ "worker_not_launched",
83
+ // BAPI-1302: the job's required-verification declaration was unusable, so no
84
+ // worker was launched against a partial set of checks.
85
+ "declaration_refused",
86
+ ];
87
+ export const EXECUTION_VALUES = ["complete", "incomplete", "unknown"];
88
+ /**
89
+ * `not_declared` is "no declared local verification" — NOT a pass. `unavailable`
90
+ * means checks were declared but no runner is installed (D4c absent): a park.
91
+ */
92
+ export const VERIFICATION_VALUES = [
93
+ "not_declared",
94
+ "passed",
95
+ "failed",
96
+ "unavailable",
97
+ "incomplete",
98
+ ];
99
+ export const FINALIZATION_VALUES = ["ok", "unmet", "not_required", "unknown"];
100
+ export const CLEANUP_VERDICT_VALUES = ["confirmed", "failed", "unconfirmed"];
101
+ export const CLEANUP_RESIDUAL_ENV_SCRUBBING_DAEMON = "environment_scrubbing_daemon";
102
+ /**
103
+ * The second accepted residual (BAPI-1301). macOS hides a platform (Apple-signed)
104
+ * binary's environment from `ps -E`, so `/bin/bash`, `/bin/zsh` or `/bin/sleep`
105
+ * shows no tag. Tracked descendants cover the common case; a platform-binary
106
+ * process that detaches from the tracked tree between samples (reparented to
107
+ * launchd) is not attributable. Named on every verdict; the server still
108
+ * requires only the scrubbing-daemon token.
109
+ */
110
+ export const CLEANUP_RESIDUAL_PLATFORM_BINARY_ENV_HIDDEN = "platform_binary_environment_hidden";
111
+ export const CLEANUP_REASON_VALUES = [
112
+ "survivors_detected",
113
+ "enumeration_failed",
114
+ "ownership_unverifiable",
115
+ "executor_interrupted",
116
+ "sweep_deadline_exhausted",
117
+ "not_run",
118
+ ];
119
+ export const TASK_KIND_VALUES = [
120
+ "delegate",
121
+ "delegate_bg",
122
+ "bash_bg",
123
+ "monitor",
124
+ "bash_task",
125
+ "subagent_bash",
126
+ "task",
127
+ ];
128
+ export const TASK_STATE_VALUES = [
129
+ "pending",
130
+ "succeeded",
131
+ "failed",
132
+ "stopped",
133
+ "withdrawn",
134
+ "replaced",
135
+ "unknown",
136
+ "conflict",
137
+ ];
138
+ /** The only states that resolve a REQUIRED task. */
139
+ export const TASK_RESOLVING_STATES = new Set([
140
+ "succeeded",
141
+ "replaced",
142
+ ]);
143
+ export const DENIAL_OPERATION_VALUES = [
144
+ "push",
145
+ "pr_create",
146
+ "external_mutation",
147
+ "delegate",
148
+ "other",
149
+ "unknown",
150
+ ];
151
+ export const DENIAL_STATE_VALUES = ["blocking", "historical", "unlinked", "ambiguous"];
152
+ export const MISSING_CAPABILITY_VALUES = [
153
+ "declared_check_runner",
154
+ "permission_enforcement",
155
+ "focused_ci_repair",
156
+ "repaired_head_convergence",
157
+ "continuation",
158
+ ];
159
+ /** BAPI-1302 — where a job's declaration came from. Neither `absent` nor `declared_empty` is a pass. */
160
+ export const DECLARATION_PROVENANCE_VALUES = ["declared", "declared_empty", "absent", "refused"];
161
+ /** BAPI-1302 — how one declared command ended, as the executor observed it. */
162
+ export const CHECK_OUTCOME_VALUES = [
163
+ "exited_zero",
164
+ "exited_nonzero",
165
+ "timed_out",
166
+ "launch_error",
167
+ "cancelled",
168
+ "not_run",
169
+ ];
170
+ /** BAPI-1302 — bounded reason for a non-zero outcome. Never exit text or output. */
171
+ export const CHECK_REASON_VALUES = [
172
+ "declared_timeout",
173
+ "reservation_exhausted",
174
+ "stopped_after_interruption",
175
+ "ownership_lost",
176
+ "executor_cancelled",
177
+ "spawn_failed",
178
+ ];
179
+ /**
180
+ * BAPI-1302 — what the executor saw of the final local artifact (full HEAD plus
181
+ * the TRACKED tree and index) around the checks. Only `stable` lets a result
182
+ * count. Untracked files are outside the guarantee.
183
+ */
184
+ export const ARTIFACT_VALUES = [
185
+ "stable",
186
+ "not_observed",
187
+ "head_unreadable",
188
+ "dirty_before",
189
+ "head_moved",
190
+ "tracked_changed",
191
+ "unobservable_after",
192
+ ];
193
+ export const MAX_CHECKS = 16;
194
+ export const MAX_CHECK_DURATION_MS = 2 * 60 * 60 * 1000;
195
+ export const COMPLETION_CAUSE_VALUES = [
196
+ "required_work_incomplete",
197
+ "required_check_failed",
198
+ "permission_stop",
199
+ "unsupported_completion_evidence",
200
+ "cleanup_unconfirmed",
201
+ "verification_runner_unavailable",
202
+ ];
203
+ export const MAX_TASKS = 64;
204
+ export const MAX_DENIALS = 32;
205
+ export const MAX_IDENTIFIER_CHARS = 128;
206
+ export const MAX_SERIALIZED_BYTES = 32 * 1024;
207
+ /** One stream-json event larger than this is `stream_oversize`, never parsed. */
208
+ export const MAX_EVENT_BYTES = 1024 * 1024;
209
+ const IDENTIFIER_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]*$/;
210
+ export function sha256Hex(value) {
211
+ return createHash("sha256").update(value, "utf8").digest("hex");
212
+ }
213
+ /** A stable digest of a structured value (sorted keys). Never the value itself. */
214
+ export function digestStructured(value) {
215
+ return sha256Hex(stableStringify(value));
216
+ }
217
+ function stableStringify(value) {
218
+ if (value === null || typeof value !== "object")
219
+ return JSON.stringify(value ?? null);
220
+ if (Array.isArray(value))
221
+ return `[${value.map(stableStringify).join(",")}]`;
222
+ const entries = Object.entries(value)
223
+ .filter(([, v]) => v !== undefined)
224
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
225
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
226
+ }
227
+ /** An opaque identifier, or null when the value cannot be carried safely. */
228
+ export function boundedIdentifier(value) {
229
+ if (typeof value !== "string")
230
+ return null;
231
+ if (value.length === 0 || value.length > MAX_IDENTIFIER_CHARS)
232
+ return null;
233
+ return IDENTIFIER_RE.test(value) ? value : null;
234
+ }
235
+ export function isCoveredJobType(jobType) {
236
+ return COVERED_JOB_TYPES.includes(jobType);
237
+ }
238
+ /** Whether a claimed job carries the covered completion contract. */
239
+ export function claimRequiresCompletionEvidence(requiredCapabilities) {
240
+ return Array.isArray(requiredCapabilities) && requiredCapabilities.includes(COMPLETION_EVIDENCE_CAPABILITY);
241
+ }
242
+ export function runtimeMatchesSupported(runtime) {
243
+ if (!runtime)
244
+ return false;
245
+ return (runtime.adapter === SUPPORTED_COMPLETION_RUNTIME.adapter &&
246
+ runtime.cli_version === SUPPORTED_COMPLETION_RUNTIME.cli_version &&
247
+ runtime.platform === SUPPORTED_COMPLETION_RUNTIME.platform &&
248
+ runtime.event_schema === SUPPORTED_COMPLETION_RUNTIME.event_schema);
249
+ }
250
+ export function unresolvedRequiredTasks(envelope) {
251
+ return envelope.tasks.filter((t) => t.required && !TASK_RESOLVING_STATES.has(t.state));
252
+ }
253
+ const FULL_SHA_RE = /^[0-9a-f]{40}$/;
254
+ /**
255
+ * The verification verdict version-2 facts support. Mirrors the server's
256
+ * `verification_verdict_from_facts` clause for clause; the envelope's
257
+ * `verification` token must equal it or the server refuses the envelope.
258
+ *
259
+ * `declaredCount` is the number of commands the job's declaration names; a
260
+ * declared command with no fact is not-run, so the verdict is `incomplete`.
261
+ */
262
+ export function verificationVerdictFromFacts(facts, declaredCount) {
263
+ if (facts.provenance === "absent" || facts.provenance === "declared_empty")
264
+ return "not_declared";
265
+ if (facts.provenance !== "declared")
266
+ return "incomplete";
267
+ const expected = declaredCount ?? facts.commands.length;
268
+ if (expected === 0 || facts.commands.length !== expected)
269
+ return "incomplete";
270
+ if (facts.artifact !== "stable" || facts.tested_head_sha === null || !FULL_SHA_RE.test(facts.tested_head_sha)) {
271
+ return "incomplete";
272
+ }
273
+ if (facts.commands.some((c) => c.outcome !== "exited_zero" && c.outcome !== "exited_nonzero"))
274
+ return "incomplete";
275
+ if (facts.commands.some((c) => c.outcome === "exited_nonzero"))
276
+ return "failed";
277
+ return "passed";
278
+ }
279
+ /**
280
+ * Closed reason the envelope cannot support a covered success, or null.
281
+ * Mirrors the server's `success_refusal_reason` clause for clause, so the
282
+ * executor never sends `/complete` for an envelope the server would refuse.
283
+ */
284
+ export function successRefusalReason(envelope) {
285
+ if (envelope.evidence !== "sufficient")
286
+ return "evidence_insufficient";
287
+ if (envelope.execution !== "complete")
288
+ return "execution_not_complete";
289
+ if (unresolvedRequiredTasks(envelope).length > 0)
290
+ return "required_task_unresolved";
291
+ if (envelope.verification !== "passed" && envelope.verification !== "not_declared") {
292
+ return "verification_not_acceptable";
293
+ }
294
+ if (envelope.finalization !== "ok" && envelope.finalization !== "not_required") {
295
+ return "finalization_not_met";
296
+ }
297
+ if (envelope.cleanup.verdict !== "confirmed" || envelope.cleanup.survivors !== 0) {
298
+ return "cleanup_not_confirmed";
299
+ }
300
+ if (envelope.denials.some((d) => d.state === "blocking"))
301
+ return "blocking_denial";
302
+ if (envelope.cause !== null)
303
+ return "cause_present";
304
+ return null;
305
+ }
306
+ /**
307
+ * The executor-proposed cause for an envelope that cannot be a success. Same
308
+ * order as the server's `derive_park_cause`; the server re-derives it and never
309
+ * lets this value widen what the rest of the envelope proves.
310
+ */
311
+ export function deriveCompletionCause(envelope) {
312
+ const reasons = new Set(envelope.insufficient_reasons);
313
+ if (reasons.has("unsupported_runtime"))
314
+ return "unsupported_completion_evidence";
315
+ if (isNotLaunchedEnvelope(envelope))
316
+ return null;
317
+ const blocking = envelope.denials.filter((d) => d.state === "blocking");
318
+ if (blocking.length > 0 && envelope.evidence === "sufficient" && blocking.some((d) => d.result_after_denial)) {
319
+ return "permission_stop";
320
+ }
321
+ if (envelope.cleanup.verdict !== "confirmed" || envelope.cleanup.survivors !== 0)
322
+ return "cleanup_unconfirmed";
323
+ const unresolved = envelope.tasks.some((t) => t.required && !TASK_RESOLVING_STATES.has(t.state));
324
+ if (blocking.length > 0 ||
325
+ envelope.evidence !== "sufficient" ||
326
+ envelope.execution !== "complete" ||
327
+ unresolved ||
328
+ envelope.verification === "incomplete") {
329
+ return "required_work_incomplete";
330
+ }
331
+ if (envelope.verification === "unavailable")
332
+ return "verification_runner_unavailable";
333
+ if (envelope.verification === "failed")
334
+ return "required_check_failed";
335
+ // Nothing completion-specific is wrong: an unmet finalization (or success)
336
+ // keeps its existing executor/server classification.
337
+ return null;
338
+ }
339
+ /**
340
+ * Whether an envelope reports a failure before any worker was launched: exactly
341
+ * the `worker_not_launched` reason, no task/denial/result, confirmed cleanup with
342
+ * zero survivors. Mirrors the server's `is_not_launched_envelope`.
343
+ */
344
+ export function isNotLaunchedEnvelope(envelope) {
345
+ return (envelope.insufficient_reasons.length === 1 &&
346
+ envelope.insufficient_reasons[0] === "worker_not_launched" &&
347
+ envelope.tasks.length === 0 &&
348
+ envelope.denials.length === 0 &&
349
+ envelope.results_seen === 0 &&
350
+ envelope.cleanup.verdict === "confirmed" &&
351
+ envelope.cleanup.survivors === 0);
352
+ }
353
+ /**
354
+ * The envelope for a covered attempt that failed before any worker existed.
355
+ *
356
+ * Every covered terminal report must carry an envelope; without one the server
357
+ * refuses the report and the row eventually dead-letters as cleanup
358
+ * unconfirmed — parking a transient pre-spawn problem for a human with a
359
+ * misleading cause. Nothing was spawned, so cleanup is truthfully confirmed with
360
+ * zero survivors, and the report's own error kind keeps deciding.
361
+ */
362
+ export function buildNotLaunchedEnvelope(input) {
363
+ return {
364
+ evidence_version: input.verificationFacts ? COMPLETION_CONTRACT_VERSION_V2 : COMPLETION_CONTRACT_VERSION,
365
+ runtime: { ...input.runtime },
366
+ binding: { ...input.binding },
367
+ session_id: null,
368
+ evidence: "insufficient",
369
+ insufficient_reasons: ["worker_not_launched"],
370
+ execution: "unknown",
371
+ verification: input.verification,
372
+ finalization: "unknown",
373
+ cleanup: {
374
+ verdict: "confirmed",
375
+ method: "env_tag_sweep",
376
+ survivors: 0,
377
+ reasons: [],
378
+ residual_not_covered: [...input.residuals],
379
+ },
380
+ tasks: [],
381
+ denials: [],
382
+ missing_capabilities: [],
383
+ results_seen: 0,
384
+ cause: null,
385
+ ...(input.verificationFacts ? { verification_facts: input.verificationFacts } : {}),
386
+ };
387
+ }
388
+ /**
389
+ * Enforce the serialized bound. An envelope that would exceed it drops its
390
+ * lowest-value detail (resolved tasks, then historical/unlinked denials) and
391
+ * becomes `insufficient` — it never silently loses an unresolved obligation.
392
+ */
393
+ export function boundEnvelope(envelope) {
394
+ const size = (e) => Buffer.byteLength(JSON.stringify(e), "utf8");
395
+ if (size(envelope) <= MAX_SERIALIZED_BYTES)
396
+ return envelope;
397
+ const markOversize = (e) => {
398
+ const reasons = new Set(e.insufficient_reasons);
399
+ reasons.add("ledger_capacity_exhausted");
400
+ const next = {
401
+ ...e,
402
+ evidence: "insufficient",
403
+ insufficient_reasons: [...reasons],
404
+ execution: e.execution === "complete" ? "unknown" : e.execution,
405
+ cause: null,
406
+ };
407
+ return { ...next, cause: deriveCompletionCause(next) };
408
+ };
409
+ let next = markOversize({
410
+ ...envelope,
411
+ tasks: envelope.tasks.filter((t) => t.required && !TASK_RESOLVING_STATES.has(t.state)),
412
+ });
413
+ if (size(next) <= MAX_SERIALIZED_BYTES)
414
+ return next;
415
+ next = markOversize({
416
+ ...next,
417
+ denials: next.denials.filter((d) => d.state === "blocking" || d.state === "ambiguous"),
418
+ });
419
+ if (size(next) <= MAX_SERIALIZED_BYTES)
420
+ return next;
421
+ // Last resort: identities cannot fit. Keep the count-free verdict only.
422
+ return markOversize({ ...next, tasks: next.tasks.slice(0, 8), denials: next.denials.slice(0, 8) });
423
+ }