@bridge_gpt/mcp-server 0.2.69 → 0.2.70

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 (151) hide show
  1. package/README.md +140 -292
  2. package/build/agent-launchers/claude-executor-adapter.js +17 -4
  3. package/build/agents.generated.js +4 -4
  4. package/build/artifact-diff.js +110 -0
  5. package/build/bin-entrypoint.js +36 -0
  6. package/build/bridge-permissions.js +23 -4
  7. package/build/claude-review-workflow-drift.js +3 -3
  8. package/build/codex-bridge-profile.js +1 -1
  9. package/build/commands.generated.js +6 -6
  10. package/build/conductor/bridge-api-client.js +598 -19
  11. package/build/conductor/bring-up-facts.js +9 -9
  12. package/build/conductor/doctor.js +466 -18
  13. package/build/conductor/epic-child-continuation.js +54 -3
  14. package/build/conductor/epic-implementer-cli.js +132 -34
  15. package/build/conductor/epic-runtime.js +2 -2
  16. package/build/conductor/epic-state.js +1 -1
  17. package/build/conductor/hook-runtime-qualification.js +1 -1
  18. package/build/conductor/install-doctor.js +4 -2
  19. package/build/conductor/launch-need-probes.js +337 -0
  20. package/build/conductor/local-merge.js +241 -56
  21. package/build/conductor/merge-credential.js +172 -0
  22. package/build/conductor/merge-execution-eligibility.js +1 -1
  23. package/build/conductor/merged-child-check.js +95 -0
  24. package/build/conductor/plan-trim.js +30 -0
  25. package/build/conductor/pr-ci-producer.js +1 -1
  26. package/build/conductor/readiness-cli.js +11 -6
  27. package/build/conductor/readiness-prerequisites.js +41 -0
  28. package/build/conductor/readiness-sections.js +345 -55
  29. package/build/conductor/readiness.js +99 -87
  30. package/build/conductor/recovery-cli.js +606 -21
  31. package/build/conductor/recovery-operations.js +306 -7
  32. package/build/conductor/repair-contract.js +47 -2
  33. package/build/conductor/repository-workflow-remedy.js +8 -6
  34. package/build/conductor/stored-merge-credential.js +76 -0
  35. package/build/conductor/stored-merge-token-probe.js +28 -0
  36. package/build/conductor/temporary-plan.js +50 -0
  37. package/build/conductor/wait-run-cli.js +759 -0
  38. package/build/conductor/worker-database-doctor-inspector.js +49 -0
  39. package/build/conductor-bin.js +2 -2
  40. package/build/conductor-claude-hook-bin.js +2 -2
  41. package/build/conductor-claude-hook-removed-stub-bin.js +2 -5
  42. package/build/conductor-removed-stub-bin.js +2 -5
  43. package/build/credential-store.js +356 -13
  44. package/build/docs.generated.js +26 -13
  45. package/build/doctor.js +94 -32
  46. package/build/drive-epic-help.js +212 -0
  47. package/build/drive-epic.js +1840 -373
  48. package/build/epic-ci-workflow-drift.js +77 -0
  49. package/build/epic-ci-workflow-install.js +183 -0
  50. package/build/epic-ci-workflow.js +157 -0
  51. package/build/epic-contract-context.js +326 -0
  52. package/build/epic-implementer-bin.js +57 -41
  53. package/build/epic-implementer-claude-hook-bin.js +2 -2
  54. package/build/escalation-reachability.js +134 -0
  55. package/build/executor/cleanup-report.js +99 -0
  56. package/build/executor/cli.js +12 -1
  57. package/build/executor/completion-contract.js +66 -11
  58. package/build/executor/completion-supervision.js +99 -0
  59. package/build/executor/delivery-outcome.js +179 -0
  60. package/build/executor/deps.js +3 -0
  61. package/build/executor/env.js +71 -0
  62. package/build/executor/failure-reason-codes.js +126 -0
  63. package/build/executor/heartbeat.js +38 -0
  64. package/build/executor/http-client.js +72 -0
  65. package/build/executor/integration-job.js +298 -5
  66. package/build/executor/job-errors.js +36 -6
  67. package/build/executor/job-runner.js +720 -36
  68. package/build/executor/likely-tests.js +36 -2
  69. package/build/executor/merge-job.js +145 -2
  70. package/build/executor/observation.js +73 -4
  71. package/build/executor/permissions.js +193 -97
  72. package/build/executor/preflight.js +14 -3
  73. package/build/executor/process-containment.js +28 -2
  74. package/build/executor/prompt-spec.js +50 -5
  75. package/build/executor/rebase-finalization.js +38 -3
  76. package/build/executor/reconcile-overlap-job.js +35 -7
  77. package/build/executor/required-work-ledger.js +236 -81
  78. package/build/executor/resume-pre-spawn.js +38 -1
  79. package/build/executor/runner.js +91 -3
  80. package/build/executor/test-clock.js +8 -0
  81. package/build/executor/types.js +37 -2
  82. package/build/executor/worker-cli-launcher.js +259 -0
  83. package/build/executor/worker-command.js +41 -4
  84. package/build/executor/worker-database-target.js +91 -0
  85. package/build/executor/worker-database.js +908 -0
  86. package/build/executor/worker-finalization.js +106 -0
  87. package/build/executor/worker-guard-hook.js +203 -0
  88. package/build/executor/worker-resources.js +47 -0
  89. package/build/executor/worker-verification-guidance.js +15 -2
  90. package/build/executor/worktree.js +303 -15
  91. package/build/executor-launch.js +11 -1
  92. package/build/generated-assets-doctor.js +9 -1
  93. package/build/generated-assets-health.js +13 -4
  94. package/build/generated-assets-legacy.js +39 -0
  95. package/build/generated-assets-paths.js +12 -1
  96. package/build/generated-assets-repair.js +4 -1
  97. package/build/generated-assets-test-support.js +12 -0
  98. package/build/github-remote.js +33 -0
  99. package/build/github-sign-in.js +62 -0
  100. package/build/index.js +1067 -536
  101. package/build/init.js +39 -1
  102. package/build/install-bridge-conductor-gate.js +78 -0
  103. package/build/install-bridge-conductor-setup.js +413 -0
  104. package/build/install-bridge-conductor.js +1226 -267
  105. package/build/install-bridge.js +376 -60
  106. package/build/mcp-install-state.js +8 -2
  107. package/build/merge-pull-request.js +6 -0
  108. package/build/merge-workflow-install.js +763 -345
  109. package/build/phase-result-artifacts.js +75 -20
  110. package/build/pipeline-orchestrator.js +247 -4
  111. package/build/pipelines.generated.js +35 -35
  112. package/build/plan-discovery.js +15 -2
  113. package/build/plan-execution-ledger.js +306 -17
  114. package/build/plan-phase-routing.js +48 -13
  115. package/build/plane/cli.js +105 -51
  116. package/build/plane/defaults.js +3 -0
  117. package/build/plane/plane-freshness.js +294 -0
  118. package/build/plane/schema-compatibility.js +137 -0
  119. package/build/plane/shutdown-deps.js +38 -0
  120. package/build/plane/shutdown.js +83 -8
  121. package/build/plane/terminal-run-cleanup.js +303 -0
  122. package/build/printed-remedy-commands.js +26 -4
  123. package/build/readiness-check.js +6 -2
  124. package/build/readiness-prerequisites.generated.js +532 -0
  125. package/build/readme.generated.js +1 -1
  126. package/build/setup-epic.js +400 -30
  127. package/build/shared-resources.generated.js +5 -0
  128. package/build/start-tickets.js +28 -15
  129. package/build/test-command-detection.js +247 -0
  130. package/build/test-command-validation.js +73 -0
  131. package/build/testing-config-client.js +150 -0
  132. package/build/ticket-delivery-artifacts.js +258 -0
  133. package/build/ticket-delivery-enrollment.js +306 -0
  134. package/build/ticket-delivery.js +205 -0
  135. package/build/ticket-review-summary.js +14 -0
  136. package/build/upgrade-cli.js +13 -0
  137. package/build/v2-readiness-requirements.js +552 -0
  138. package/build/version.generated.js +3 -3
  139. package/build/worker-guard-hook-bin.js +2 -2
  140. package/build/worktree-core.js +22 -7
  141. package/docs/BRIDGE-DIRECTORY-README.md +48 -0
  142. package/docs/EPIC-IMPLEMENTER-INSTALL.md +12 -867
  143. package/docs/EPIC-IMPLEMENTER.md +104 -63
  144. package/docs/epic-implementer/getting-started.md +131 -0
  145. package/docs/epic-implementer/self-hosting.md +133 -0
  146. package/package.json +3 -3
  147. package/pipelines/idea-to-ticket.json +1 -1
  148. package/pipelines/implement-ticket.json +8 -8
  149. package/pipelines/review-ticket.json +2 -2
  150. package/design-assets/favicon/favicon-32x32.png +0 -0
  151. package/design-assets/just-logo-rough-draft.png +0 -0
@@ -5,14 +5,22 @@
5
5
  * Optional .conductor/recently-failed-tests.json is
6
6
  * {version:1,tests:["most-recent-first reference"],truncated:false}.
7
7
  * Child 3 writes recent failures under the worker worktree before each spawn.
8
- * References are data only, never commands. Priority: recent failures, ordered
9
- * pins, sorted inferred tracked tests; exact deduplication precedes the limit.
8
+ * Optional declared touched files (raw JSON, supplied by the caller) admit the
9
+ * declared files that are tests and exist in the worktree.
10
+ * References are data only, never commands. Priority: recent failures, sorted
11
+ * declared tests, ordered pins, sorted inferred tracked tests; exact
12
+ * deduplication precedes the limit.
10
13
  * Missing/malformed/unreadable/truncated sources degrade advisory coverage.
14
+ * Changed files come from the committed `<base>..HEAD` diff only; workers commit
15
+ * before discovery. BAPI-1479 confirmed this was not why workers saw no output
16
+ * (see `runLikelyTestsCommand` for the confirmed cause), so uncommitted changes
17
+ * stay out of scope.
11
18
  */
12
19
  import path from "node:path";
13
20
  import { constants } from "node:fs";
14
21
  import { lstat, open } from "node:fs/promises";
15
22
  import { execFile } from "node:child_process";
23
+ import { normalizeDeclaredTouchedFiles } from "../conductor/file-scope-guard.js";
16
24
  export const RECENTLY_FAILED_TESTS_PATH = ".conductor/recently-failed-tests.json";
17
25
  export const RECENTLY_FAILED_TESTS_MAX_COUNT = 20;
18
26
  export const TEST_REFERENCE_MAX_LENGTH = 512;
@@ -213,6 +221,32 @@ export async function resolveLikelyTests(options) {
213
221
  if (error.code !== "ENOENT")
214
222
  diagnostics.add("data_unavailable");
215
223
  }
224
+ // Declared test files sit right after recent failures and BEFORE any git diff
225
+ // or scan, so a later early return cannot bypass them.
226
+ if (options.declaredTouchedFilesJson !== undefined) {
227
+ let declared;
228
+ try {
229
+ const parsed = JSON.parse(options.declaredTouchedFilesJson);
230
+ if (Array.isArray(parsed))
231
+ declared = normalizeDeclaredTouchedFiles(parsed);
232
+ }
233
+ catch { /* malformed: reported below */ }
234
+ if (declared === undefined)
235
+ diagnostics.add("data_unavailable");
236
+ else {
237
+ const admitted = [];
238
+ for (const file of declared.filter(testCandidate)) {
239
+ try {
240
+ await read(root, file, MAX_SCAN_FILE_BYTES);
241
+ admitted.push(file);
242
+ }
243
+ catch {
244
+ diagnostics.add("skipped_entries");
245
+ }
246
+ }
247
+ add(admitted);
248
+ }
249
+ }
216
250
  let hints;
217
251
  try {
218
252
  hints = parseHints(JSON.parse(await read(root, ".bridge/verification-hints.json", MAX_OPTIONAL_JSON_BYTES)));
@@ -21,11 +21,23 @@
21
21
  * {@link MERGE_FAILED} `error_kind` (via {@link classifyMergeFailureErrorKind})
22
22
  * so the companion re-fire lane can route retry-vs-rebase without string parsing.
23
23
  */
24
- import { makeLocalMergeExecutor, resolveLocalMergeCredential, resolveLocalMergeMethod, } from "../conductor/local-merge.js";
25
- import { resolveConductorBridgeApiAccess, } from "../conductor/bridge-api-client.js";
24
+ import { classifyLocalGhPreflight, makeLocalMergeExecutor, probeLocalMergeRepositoryAccess, resolveLocalMergeCredential, resolveLocalMergeMethod, } from "../conductor/local-merge.js";
25
+ import { isProviderRepositoryIdentity, resolveConductorBridgeApiAccess, } from "../conductor/bridge-api-client.js";
26
26
  import { createProductionEpicIntegrationGhRunner, ensureEpicIntegrationPullRequest, formatEpicIntegrationPullRequestOutcome, EPIC_BRANCH_PREFIX, } from "../epic-integration-pr.js";
27
+ import { resolveMergeCredentialAtAction } from "../conductor/stored-merge-credential.js";
27
28
  import { secretFreeErrorMessage } from "./job-errors.js";
28
29
  import { resolveExecutorJobBaseBranch } from "./base-branch.js";
30
+ import { GH_REPO_ACCESS_DENIED, failureReasonCode, isFailureReasonCode, } from "./failure-reason-codes.js";
31
+ /** Read the optional `payload.repository` against the provider-repository format. */
32
+ export function resolveMergeJobRepository(job) {
33
+ const payload = job.payload && typeof job.payload === "object" ? job.payload : {};
34
+ const value = payload.repository;
35
+ if (value === undefined || value === null)
36
+ return { kind: "absent" };
37
+ if (isProviderRepositoryIdentity(value))
38
+ return { kind: "valid", repository: value };
39
+ return { kind: "malformed" };
40
+ }
29
41
  function positiveIntOrNull(value) {
30
42
  return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
31
43
  }
@@ -80,6 +92,7 @@ export function readMergeJobPayloadFields(job, repositoryBaseBranch) {
80
92
  const actionKey = asString(payload.action_key) ?? `merge:pr-${prNumber}:${expectedHeadSha}`;
81
93
  const ciWaitTimeoutMs = nonNegativeIntOrUndefined(payload.ci_wait_timeout_ms);
82
94
  const ciWaitPollIntervalMs = nonNegativeIntOrUndefined(payload.ci_wait_poll_interval_ms);
95
+ const repository = resolveMergeJobRepository(job);
83
96
  return {
84
97
  ok: true,
85
98
  fields: {
@@ -92,6 +105,7 @@ export function readMergeJobPayloadFields(job, repositoryBaseBranch) {
92
105
  ciWaitPollIntervalMs,
93
106
  expectedBaseBranch,
94
107
  expectedHeadBranch,
108
+ ...(repository.kind === "valid" ? { repository: repository.repository } : {}),
95
109
  },
96
110
  };
97
111
  }
@@ -166,6 +180,16 @@ export const MERGE_RETRYABLE = "MergeRetryable";
166
180
  export const MERGE_CONFLICT = "MergeConflict";
167
181
  export const MERGE_FAILED = "MergeFailed";
168
182
  export const MERGE_REF_MISMATCH = "MergeRefMismatch";
183
+ /**
184
+ * BAPI-1453 — a DELIBERATE, terminal refusal: after `gh pr view` failed, the
185
+ * permission probe PROVED the merge credential cannot access the repository (a
186
+ * 404, or a 200 without push). Emitted only with the `gh_repo_access_denied`
187
+ * reason code. Retrying with the same credential reproduces the identical
188
+ * failure, so — like {@link MERGE_REF_MISMATCH} — it is never retryable, never a
189
+ * rebase target, and reported as `clean_exit`. The server parks it as
190
+ * `merge_credential_no_access`.
191
+ */
192
+ export const MERGE_CREDENTIAL_NO_ACCESS = "MergeCredentialNoAccess";
169
193
  /**
170
194
  * BAPI-1234 — the server's migration-graph merge guard refused the merge. Two
171
195
  * kinds, kept distinct because they call for different human responses:
@@ -187,6 +211,7 @@ const MIGRATION_GRAPH_ERROR_KINDS = new Map([
187
211
  /** Error kinds that are deliberate refusals rather than crashes. */
188
212
  const DELIBERATE_REFUSAL_ERROR_KINDS = new Set([
189
213
  MERGE_REF_MISMATCH,
214
+ MERGE_CREDENTIAL_NO_ACCESS,
190
215
  MERGE_MIGRATION_GRAPH_INVALID,
191
216
  MERGE_MIGRATION_GRAPH_UNREADABLE,
192
217
  ]);
@@ -222,10 +247,16 @@ const RETRYABLE_MERGE_REASONS = new Set([
222
247
  * {@link RETRYABLE_MERGE_REASONS} — it is a terminal safety refusal, not a
223
248
  * transient or conflict-shaped failure, and must not be re-firable by any
224
249
  * future classification change.
250
+ *
251
+ * BAPI-1453: `gh_repo_access_denied` maps to {@link MERGE_CREDENTIAL_NO_ACCESS}
252
+ * in the same early position and for the same reason. Every other reason keeps
253
+ * its existing mapping.
225
254
  */
226
255
  export function classifyMergeFailureErrorKind(response) {
227
256
  const hasConflictEvent = response.ledger_events.some((ev) => ev.type === "merge.conflict");
228
257
  const reason = asString(response.reason ?? undefined);
258
+ if (reason === GH_REPO_ACCESS_DENIED)
259
+ return MERGE_CREDENTIAL_NO_ACCESS;
229
260
  if (reason === "ref_mismatch")
230
261
  return MERGE_REF_MISMATCH;
231
262
  const migrationGraphKind = reason ? MIGRATION_GRAPH_ERROR_KINDS.get(reason) : undefined;
@@ -251,6 +282,11 @@ export function classifyMergeFailureErrorKind(response) {
251
282
  * refusal kinds to that set: a `crashed` classification would make the
252
283
  * backend's merge re-fire lane retry a merge the server will refuse identically.
253
284
  * Every other merge outcome keeps its existing `"crashed"` classification.
285
+ *
286
+ * BAPI-1453: also sets `telemetry.failure_reason_code` — the response's reason
287
+ * when it is in the closed vocabulary, else the fixed
288
+ * `merge_failure_unclassified`, so an arbitrary response string never reaches
289
+ * telemetry. The code is display-only; `error_kind` stays the routing signal.
254
290
  */
255
291
  export function buildMergeJobFailure(response) {
256
292
  const reason = asString(response.reason ?? undefined) ?? response.status;
@@ -259,8 +295,21 @@ export function buildMergeJobFailure(response) {
259
295
  error_kind: errorKind,
260
296
  error_message: secretFreeErrorMessage(new Error(`local merge ${response.status}: ${reason}`)),
261
297
  classification: DELIBERATE_REFUSAL_ERROR_KINDS.has(errorKind) ? "clean_exit" : "crashed",
298
+ telemetry: { failure_reason_code: mergeResponseReasonCode(response) },
262
299
  };
263
300
  }
301
+ /** The response's reason as a vocabulary code, or the fixed unclassified code. */
302
+ function mergeResponseReasonCode(response) {
303
+ const reason = asString(response.reason ?? undefined);
304
+ return isFailureReasonCode(reason) ? reason : MERGE_FAILURE_UNCLASSIFIED;
305
+ }
306
+ /** BAPI-1453 — fixed codes for failures the executor's merge flow builds itself. */
307
+ export const MERGE_FAILURE_UNCLASSIFIED = failureReasonCode("merge_failure_unclassified");
308
+ export const MERGE_PAYLOAD_INVALID = failureReasonCode("merge_payload_invalid");
309
+ export const MERGE_ACCESS_UNAVAILABLE = failureReasonCode("merge_access_unavailable");
310
+ export const MERGE_FLOW_TIMEOUT = failureReasonCode("merge_flow_timeout");
311
+ export const MERGE_FLOW_ERROR = failureReasonCode("merge_flow_error");
312
+ export const MERGE_NO_OUTCOME = failureReasonCode("merge_no_outcome");
264
313
  /**
265
314
  * Post-merge draft epic-integration-PR retry (BAPI-951). Invoked ONLY after a
266
315
  * successful ticket-bound merge whose child PR based on `epic/<EPIC>` — never
@@ -306,6 +355,7 @@ export async function runExecutorMergeJob(job, seams) {
306
355
  error_kind: "ContractError.MergePayload",
307
356
  error_message: resolution.error,
308
357
  classification: "crashed",
358
+ telemetry: { failure_reason_code: MERGE_PAYLOAD_INVALID },
309
359
  },
310
360
  };
311
361
  }
@@ -333,6 +383,7 @@ export async function runExecutorMergeJob(job, seams) {
333
383
  error_kind: "MergeFailed",
334
384
  error_message: secretFreeErrorMessage(err),
335
385
  classification: "crashed",
386
+ telemetry: { failure_reason_code: MERGE_FLOW_ERROR },
336
387
  },
337
388
  };
338
389
  }
@@ -343,8 +394,77 @@ export async function runExecutorMergeJob(job, seams) {
343
394
  await tryEnsurePostMergeEpicIntegrationPr(seams, fields.expectedBaseBranch);
344
395
  return { ok: true, result };
345
396
  }
397
+ if (asString(response.reason ?? undefined) === "gh_pr_view_failed") {
398
+ response = await reclassifyPrViewFailure(job, response, seams);
399
+ }
346
400
  return { ok: false, failure: buildMergeJobFailure(response) };
347
401
  }
402
+ /** Emit one fixed probe diagnostic: the validated job id and a closed category. */
403
+ function logProbeDiagnostic(job, seams, category) {
404
+ const jobId = positiveIntOrNull(job.id);
405
+ try {
406
+ seams.diagnosticLog?.(`[executor] merge job ${jobId ?? "unknown"}: gh_pr_view_failed kept retryable (${category})`);
407
+ }
408
+ catch {
409
+ // A diagnostic sink can never change the merge outcome.
410
+ }
411
+ }
412
+ /**
413
+ * BAPI-1453 — after a `gh_pr_view_failed`, run Child 2's permission probe ONCE
414
+ * against the server-carried repository, with the credential, runner, and abort
415
+ * signal the merge itself used.
416
+ *
417
+ * Only a proven no-access verdict (a 404, or a 200 without push) turns the
418
+ * failure into `gh_repo_access_denied`. Every other outcome — access confirmed,
419
+ * probe unavailable (401, 403, 429, 5xx, transport, timeout, malformed), a
420
+ * thrown probe, a cancelled flow — returns the response UNCHANGED, so the
421
+ * failure stays a bare, retryable `gh_pr_view_failed`. HTTP statuses are never
422
+ * re-derived here; the probe's own verdict is the whole answer.
423
+ *
424
+ * With no valid `payload.repository` no probe runs at all: there is no
425
+ * `gh repo view` fallback on this path.
426
+ */
427
+ async function reclassifyPrViewFailure(job, response, seams) {
428
+ const repository = resolveMergeJobRepository(job);
429
+ if (repository.kind !== "valid") {
430
+ logProbeDiagnostic(job, seams, repository.kind === "absent" ? "repository_absent" : "repository_malformed");
431
+ return response;
432
+ }
433
+ // An aborted flow (overall timeout or ownership loss) owns the outcome; a late
434
+ // probe must never be started for it.
435
+ if (seams.localMergeDeps.signal?.aborted) {
436
+ logProbeDiagnostic(job, seams, "probe_cancelled");
437
+ return response;
438
+ }
439
+ const probe = seams.probeRepositoryAccess ??
440
+ ((repo) => probeLocalMergeRepositoryAccess(repo, seams.localMergeDeps));
441
+ let result;
442
+ try {
443
+ result = await probe(repository.repository);
444
+ }
445
+ catch {
446
+ // Exception text is never logged: it can carry subprocess detail.
447
+ logProbeDiagnostic(job, seams, "probe_failed");
448
+ return response;
449
+ }
450
+ if (seams.localMergeDeps.signal?.aborted) {
451
+ logProbeDiagnostic(job, seams, "probe_cancelled");
452
+ return response;
453
+ }
454
+ const verdict = classifyLocalGhPreflight(result);
455
+ if (verdict === "no_access") {
456
+ return { ...response, reason: GH_REPO_ACCESS_DENIED };
457
+ }
458
+ if (verdict === "passed")
459
+ return response;
460
+ if (verdict === "probe_unavailable") {
461
+ const detail = result.ok ? undefined : result.probe_unavailable_reason;
462
+ logProbeDiagnostic(job, seams, detail ?? "probe_response_malformed");
463
+ return response;
464
+ }
465
+ logProbeDiagnostic(job, seams, verdict);
466
+ return response;
467
+ }
348
468
  /**
349
469
  * Build the default local-merge deps for production wiring (no real I/O here).
350
470
  * An optional {@link AbortSignal} is threaded through so the job-runner's overall
@@ -364,3 +484,26 @@ export function buildDefaultMergeLocalDeps(deps, signal) {
364
484
  signal,
365
485
  };
366
486
  }
487
+ /**
488
+ * BAPI-1461 — the merge-job deps with the stored-entry fallback, resolved when
489
+ * the merge job RUNS. The store is read afresh each time, so an executor that
490
+ * started before the token was stored can still merge.
491
+ *
492
+ * `allowStoredEntry` comes from the executor's own mode declaration: it is
493
+ * `false` for a durable-host service executor (started with
494
+ * `--scrub-inherited-github-credentials`), which never reads a stored GitHub
495
+ * credential. The entry key comes from the job's server-carried repository.
496
+ */
497
+ export async function buildMergeLocalDepsForJob(job, deps, options, signal) {
498
+ const repository = resolveMergeJobRepository(job);
499
+ const resolution = await resolveMergeCredentialAtAction(deps.env, "local", {
500
+ allowStoredEntry: options.allowStoredEntry,
501
+ providerRepository: repository.kind === "valid" ? repository.repository : null,
502
+ storeDeps: { env: deps.env, homedir: deps.homedir, readFile: deps.readFile },
503
+ });
504
+ return {
505
+ env: deps.env,
506
+ credential: resolution.status === "resolved" ? resolution.credential : null,
507
+ signal,
508
+ };
509
+ }
@@ -1,10 +1,16 @@
1
+ import { CONSUMER_GUIDE_POINTER } from "../printed-remedy-commands.js";
2
+ import { redactSecrets } from "../secret-redaction.js";
1
3
  import { createWorkerDiagnosticsState } from "./worker-diagnostics.js";
4
+ /** BAPI-1453 — at most this many dirty paths are retained as evidence. */
5
+ export const MAX_DIRTY_PATHS = 20;
6
+ /** BAPI-1453 — a retained dirty path is at most this many characters. */
7
+ export const MAX_DIRTY_PATH_CHARS = 256;
2
8
  /**
3
9
  * Collect load-bearing git telemetry from a worktree. Individual git failures
4
10
  * degrade the telemetry (fields omitted) rather than throwing — a degraded
5
11
  * signal must never surface as a worker correctness failure.
6
12
  */
7
- export async function collectGitTelemetry(deps, worktreePath, baseBranch) {
13
+ export async function collectGitTelemetry(deps, worktreePath, baseBranch, options = {}) {
8
14
  const telemetry = {};
9
15
  try {
10
16
  const revList = await deps.runCommand("git", [
@@ -39,18 +45,27 @@ export async function collectGitTelemetry(deps, worktreePath, baseBranch) {
39
45
  // `.git/index.lock`. This runs on every heartbeat against the LIVE worker's
40
46
  // worktree, so without it a worker's own `git add`/`commit` can fail with
41
47
  // "index.lock: File exists".
48
+ // BAPI-1453: `-z` only when paths are wanted. NUL-delimited records are the
49
+ // only form that survives a filename containing a newline or a quote.
42
50
  const status = await deps.runCommand("git", [
43
51
  "-C",
44
52
  worktreePath,
45
53
  "--no-optional-locks",
46
54
  "status",
47
55
  "--porcelain",
56
+ ...(options.includeDirtyPaths === true ? ["-z"] : []),
48
57
  ]);
49
58
  if (status.exitCode === 0) {
50
- const dirty = status.stdout.trim().length > 0;
59
+ // The verdict never depends on path parsing: any output at all is dirty.
60
+ const dirty = status.stdout.replace(/\0/g, "").trim().length > 0;
51
61
  telemetry.dirty = dirty;
52
62
  if (dirty) {
53
63
  telemetry.last_activity_at = new Date(deps.now()).toISOString();
64
+ if (options.includeDirtyPaths === true) {
65
+ const evidence = parseDirtyPathEvidence(status.stdout);
66
+ telemetry.dirty_paths = evidence.paths;
67
+ telemetry.dirty_paths_overflow_count = evidence.overflow;
68
+ }
54
69
  }
55
70
  }
56
71
  }
@@ -59,6 +74,60 @@ export async function collectGitTelemetry(deps, worktreePath, baseBranch) {
59
74
  }
60
75
  return telemetry;
61
76
  }
77
+ /** True for a path that is absolute, drive/UNC-rooted, or escapes the worktree. */
78
+ function isUnsafeRelativePath(path) {
79
+ if (path.startsWith("/") || path.startsWith("\\"))
80
+ return true;
81
+ if (/^[A-Za-z]:/.test(path))
82
+ return true;
83
+ return path.split(/[\\/]/).some((segment) => segment === "..");
84
+ }
85
+ /** Escape every control character so a filename can never forge a log line. */
86
+ function escapeControlCharacters(path) {
87
+ return path.replace(/[\x00-\x1f\x7f-\x9f]/g, (ch) => `\\x${ch.charCodeAt(0).toString(16).padStart(2, "0")}`);
88
+ }
89
+ /**
90
+ * Reduce `git status --porcelain -z` output to at most {@link MAX_DIRTY_PATHS}
91
+ * repository-relative paths plus a count of everything omitted (BAPI-1453).
92
+ *
93
+ * Each record is `XY <path>`; a rename or copy (`R` or `C` in either column) is
94
+ * followed by one more NUL-terminated record holding the source path, which is
95
+ * consumed and not retained. An entry is omitted — and counted — when it is
96
+ * malformed, absolute, escapes the worktree, longer than
97
+ * {@link MAX_DIRTY_PATH_CHARS} once escaped, needs secret redaction, or arrives
98
+ * after the cap. Never throws; never retains file content.
99
+ */
100
+ export function parseDirtyPathEvidence(stdout) {
101
+ const paths = [];
102
+ let overflow = 0;
103
+ const records = stdout.split("\0");
104
+ for (let i = 0; i < records.length; i++) {
105
+ const record = records[i];
106
+ if (record.length === 0)
107
+ continue;
108
+ const status = record.slice(0, 2);
109
+ if (/[RC]/.test(status))
110
+ i += 1; // the source path record of a rename/copy
111
+ if (record.length < 4 || record[2] !== " ") {
112
+ overflow += 1;
113
+ continue;
114
+ }
115
+ const path = record.slice(3);
116
+ if (paths.length >= MAX_DIRTY_PATHS) {
117
+ overflow += 1;
118
+ continue;
119
+ }
120
+ const escaped = escapeControlCharacters(path);
121
+ if (isUnsafeRelativePath(path) ||
122
+ escaped.length > MAX_DIRTY_PATH_CHARS ||
123
+ redactSecrets(escaped) !== escaped) {
124
+ overflow += 1;
125
+ continue;
126
+ }
127
+ paths.push(escaped);
128
+ }
129
+ return { paths, overflow };
130
+ }
62
131
  /** A full 40-character hex object name. */
63
132
  const FULL_SHA_PATTERN = /^[0-9a-f]{40}$/i;
64
133
  /** Normalize a candidate SHA, or `null` when it is absent/malformed. */
@@ -298,8 +367,8 @@ const RATE_LIMIT_CONSEQUENCE_TEXT = "This warning is advisory only and triggers
298
367
  "waiting happens only when the worker then fails with the supported terminal " +
299
368
  "rejection and the server advertised capacity recovery for this job; otherwise " +
300
369
  "the failure follows ordinary failure handling.";
301
- /** Fixed runbook reference appended to every rate-limit diagnostic (BAPI-898). */
302
- const RATE_LIMIT_RUNBOOK_REFERENCE = "See docs/epic-implementer/operator-runbook.md §8.";
370
+ /** Fixed guide reference appended to every rate-limit diagnostic. */
371
+ const RATE_LIMIT_RUNBOOK_REFERENCE = `See ${CONSUMER_GUIDE_POINTER}.`;
303
372
  /**
304
373
  * Render the operator-facing rate-limit line (BAPI-828, BAPI-898).
305
374
  *