dorfl 0.0.0 → 0.1.0
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.
- package/dist/advance-ci-template.d.ts +73 -0
- package/dist/advance-ci-template.d.ts.map +1 -0
- package/dist/advance-ci-template.js +104 -0
- package/dist/advance-ci-template.js.map +1 -0
- package/dist/advance-classify.d.ts +132 -0
- package/dist/advance-classify.d.ts.map +1 -0
- package/dist/advance-classify.js +120 -0
- package/dist/advance-classify.js.map +1 -0
- package/dist/advance-drivers.d.ts +182 -0
- package/dist/advance-drivers.d.ts.map +1 -0
- package/dist/advance-drivers.js +231 -0
- package/dist/advance-drivers.js.map +1 -0
- package/dist/advance-isolated.d.ts +156 -0
- package/dist/advance-isolated.d.ts.map +1 -0
- package/dist/advance-isolated.js +256 -0
- package/dist/advance-isolated.js.map +1 -0
- package/dist/advance-lifecycle-template.d.ts +107 -0
- package/dist/advance-lifecycle-template.d.ts.map +1 -0
- package/dist/advance-lifecycle-template.js +668 -0
- package/dist/advance-lifecycle-template.js.map +1 -0
- package/dist/advance-loop-driver.d.ts +325 -0
- package/dist/advance-loop-driver.d.ts.map +1 -0
- package/dist/advance-loop-driver.js +437 -0
- package/dist/advance-loop-driver.js.map +1 -0
- package/dist/advance-treeless-publish.d.ts +108 -0
- package/dist/advance-treeless-publish.d.ts.map +1 -0
- package/dist/advance-treeless-publish.js +71 -0
- package/dist/advance-treeless-publish.js.map +1 -0
- package/dist/advance.d.ts +340 -0
- package/dist/advance.d.ts.map +1 -0
- package/dist/advance.js +1122 -0
- package/dist/advance.js.map +1 -0
- package/dist/advancing-lock.d.ts +294 -0
- package/dist/advancing-lock.d.ts.map +1 -0
- package/dist/advancing-lock.js +594 -0
- package/dist/advancing-lock.js.map +1 -0
- package/dist/agent-launch.d.ts +79 -0
- package/dist/agent-launch.d.ts.map +1 -0
- package/dist/agent-launch.js +61 -0
- package/dist/agent-launch.js.map +1 -0
- package/dist/agent-stop.d.ts +149 -0
- package/dist/agent-stop.d.ts.map +1 -0
- package/dist/agent-stop.js +307 -0
- package/dist/agent-stop.js.map +1 -0
- package/dist/apply-decide.d.ts +127 -0
- package/dist/apply-decide.d.ts.map +1 -0
- package/dist/apply-decide.js +176 -0
- package/dist/apply-decide.js.map +1 -0
- package/dist/apply-merge-action.d.ts +206 -0
- package/dist/apply-merge-action.d.ts.map +1 -0
- package/dist/apply-merge-action.js +307 -0
- package/dist/apply-merge-action.js.map +1 -0
- package/dist/apply-persist.d.ts +174 -0
- package/dist/apply-persist.d.ts.map +1 -0
- package/dist/apply-persist.js +359 -0
- package/dist/apply-persist.js.map +1 -0
- package/dist/arbiter.d.ts +120 -0
- package/dist/arbiter.d.ts.map +1 -0
- package/dist/arbiter.js +255 -0
- package/dist/arbiter.js.map +1 -0
- package/dist/brand.d.ts +70 -0
- package/dist/brand.d.ts.map +1 -0
- package/dist/brand.js +84 -0
- package/dist/brand.js.map +1 -0
- package/dist/buildable-body.d.ts +132 -0
- package/dist/buildable-body.d.ts.map +1 -0
- package/dist/buildable-body.js +131 -0
- package/dist/buildable-body.js.map +1 -0
- package/dist/categorise.d.ts +66 -0
- package/dist/categorise.d.ts.map +1 -0
- package/dist/categorise.js +106 -0
- package/dist/categorise.js.map +1 -0
- package/dist/claim-cas.d.ts +117 -0
- package/dist/claim-cas.d.ts.map +1 -0
- package/dist/claim-cas.js +312 -0
- package/dist/claim-cas.js.map +1 -0
- package/dist/cli-spinner.d.ts +112 -0
- package/dist/cli-spinner.d.ts.map +1 -0
- package/dist/cli-spinner.js +157 -0
- package/dist/cli-spinner.js.map +1 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +3094 -0
- package/dist/cli.js.map +1 -0
- package/dist/close-job-template.d.ts +70 -0
- package/dist/close-job-template.d.ts.map +1 -0
- package/dist/close-job-template.js +180 -0
- package/dist/close-job-template.js.map +1 -0
- package/dist/close-job.d.ts +95 -0
- package/dist/close-job.d.ts.map +1 -0
- package/dist/close-job.js +226 -0
- package/dist/close-job.js.map +1 -0
- package/dist/complete.d.ts +361 -0
- package/dist/complete.d.ts.map +1 -0
- package/dist/complete.js +885 -0
- package/dist/complete.js.map +1 -0
- package/dist/concurrency.d.ts +68 -0
- package/dist/concurrency.d.ts.map +1 -0
- package/dist/concurrency.js +112 -0
- package/dist/concurrency.js.map +1 -0
- package/dist/config-override.d.ts +76 -0
- package/dist/config-override.d.ts.map +1 -0
- package/dist/config-override.js +50 -0
- package/dist/config-override.js.map +1 -0
- package/dist/config.d.ts +668 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +241 -0
- package/dist/config.js.map +1 -0
- package/dist/continue-branch.d.ts +249 -0
- package/dist/continue-branch.d.ts.map +1 -0
- package/dist/continue-branch.js +389 -0
- package/dist/continue-branch.js.map +1 -0
- package/dist/cwd-section.d.ts +186 -0
- package/dist/cwd-section.d.ts.map +1 -0
- package/dist/cwd-section.js +209 -0
- package/dist/cwd-section.js.map +1 -0
- package/dist/decision-engine.d.ts +170 -0
- package/dist/decision-engine.d.ts.map +1 -0
- package/dist/decision-engine.js +136 -0
- package/dist/decision-engine.js.map +1 -0
- package/dist/detect.d.ts +17 -0
- package/dist/detect.d.ts.map +1 -0
- package/dist/detect.js +118 -0
- package/dist/detect.js.map +1 -0
- package/dist/do-autopick.d.ts +85 -0
- package/dist/do-autopick.d.ts.map +1 -0
- package/dist/do-autopick.js +112 -0
- package/dist/do-autopick.js.map +1 -0
- package/dist/do-config.d.ts +312 -0
- package/dist/do-config.d.ts.map +1 -0
- package/dist/do-config.js +358 -0
- package/dist/do-config.js.map +1 -0
- package/dist/do-remote-auto.d.ts +75 -0
- package/dist/do-remote-auto.d.ts.map +1 -0
- package/dist/do-remote-auto.js +111 -0
- package/dist/do-remote-auto.js.map +1 -0
- package/dist/do.d.ts +621 -0
- package/dist/do.d.ts.map +1 -0
- package/dist/do.js +1882 -0
- package/dist/do.js.map +1 -0
- package/dist/drop-source.d.ts +96 -0
- package/dist/drop-source.d.ts.map +1 -0
- package/dist/drop-source.js +91 -0
- package/dist/drop-source.js.map +1 -0
- package/dist/eligibility.d.ts +46 -0
- package/dist/eligibility.d.ts.map +1 -0
- package/dist/eligibility.js +34 -0
- package/dist/eligibility.js.map +1 -0
- package/dist/env-config.d.ts +51 -0
- package/dist/env-config.d.ts.map +1 -0
- package/dist/env-config.js +272 -0
- package/dist/env-config.js.map +1 -0
- package/dist/failure-cause.d.ts +70 -0
- package/dist/failure-cause.d.ts.map +1 -0
- package/dist/failure-cause.js +126 -0
- package/dist/failure-cause.js.map +1 -0
- package/dist/format.d.ts +43 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +256 -0
- package/dist/format.js.map +1 -0
- package/dist/frontmatter.d.ts +215 -0
- package/dist/frontmatter.d.ts.map +1 -0
- package/dist/frontmatter.js +345 -0
- package/dist/frontmatter.js.map +1 -0
- package/dist/gate-readiness.d.ts +84 -0
- package/dist/gate-readiness.d.ts.map +1 -0
- package/dist/gate-readiness.js +103 -0
- package/dist/gate-readiness.js.map +1 -0
- package/dist/gc.d.ts +165 -0
- package/dist/gc.d.ts.map +1 -0
- package/dist/gc.js +313 -0
- package/dist/gc.js.map +1 -0
- package/dist/gh-failure.d.ts +42 -0
- package/dist/gh-failure.d.ts.map +1 -0
- package/dist/gh-failure.js +49 -0
- package/dist/gh-failure.js.map +1 -0
- package/dist/git.d.ts +75 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +130 -0
- package/dist/git.js.map +1 -0
- package/dist/github.d.ts +187 -0
- package/dist/github.d.ts.map +1 -0
- package/dist/github.js +343 -0
- package/dist/github.js.map +1 -0
- package/dist/harness.d.ts +242 -0
- package/dist/harness.d.ts.map +1 -0
- package/dist/harness.js +157 -0
- package/dist/harness.js.map +1 -0
- package/dist/identity.d.ts +167 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +231 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +147 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +76 -0
- package/dist/index.js.map +1 -0
- package/dist/install-ci-branch-protection.d.ts +147 -0
- package/dist/install-ci-branch-protection.d.ts.map +1 -0
- package/dist/install-ci-branch-protection.js +166 -0
- package/dist/install-ci-branch-protection.js.map +1 -0
- package/dist/install-ci-capabilities/advance-lifecycle.d.ts +15 -0
- package/dist/install-ci-capabilities/advance-lifecycle.d.ts.map +1 -0
- package/dist/install-ci-capabilities/advance-lifecycle.js +28 -0
- package/dist/install-ci-capabilities/advance-lifecycle.js.map +1 -0
- package/dist/install-ci-capabilities/close-job.d.ts +13 -0
- package/dist/install-ci-capabilities/close-job.d.ts.map +1 -0
- package/dist/install-ci-capabilities/close-job.js +26 -0
- package/dist/install-ci-capabilities/close-job.js.map +1 -0
- package/dist/install-ci-capabilities/example-noop.d.ts +16 -0
- package/dist/install-ci-capabilities/example-noop.d.ts.map +1 -0
- package/dist/install-ci-capabilities/example-noop.js +23 -0
- package/dist/install-ci-capabilities/example-noop.js.map +1 -0
- package/dist/install-ci-capabilities/intake.d.ts +15 -0
- package/dist/install-ci-capabilities/intake.d.ts.map +1 -0
- package/dist/install-ci-capabilities/intake.js +28 -0
- package/dist/install-ci-capabilities/intake.js.map +1 -0
- package/dist/install-ci-capabilities/verify.d.ts +14 -0
- package/dist/install-ci-capabilities/verify.d.ts.map +1 -0
- package/dist/install-ci-capabilities/verify.js +27 -0
- package/dist/install-ci-capabilities/verify.js.map +1 -0
- package/dist/install-ci-core.d.ts +446 -0
- package/dist/install-ci-core.d.ts.map +1 -0
- package/dist/install-ci-core.js +760 -0
- package/dist/install-ci-core.js.map +1 -0
- package/dist/install-ci-github.d.ts +167 -0
- package/dist/install-ci-github.d.ts.map +1 -0
- package/dist/install-ci-github.js +315 -0
- package/dist/install-ci-github.js.map +1 -0
- package/dist/install-ci.d.ts +105 -0
- package/dist/install-ci.d.ts.map +1 -0
- package/dist/install-ci.js +363 -0
- package/dist/install-ci.js.map +1 -0
- package/dist/intake-event.d.ts +88 -0
- package/dist/intake-event.d.ts.map +1 -0
- package/dist/intake-event.js +66 -0
- package/dist/intake-event.js.map +1 -0
- package/dist/intake-marker.d.ts +95 -0
- package/dist/intake-marker.d.ts.map +1 -0
- package/dist/intake-marker.js +127 -0
- package/dist/intake-marker.js.map +1 -0
- package/dist/intake-triage.d.ts +48 -0
- package/dist/intake-triage.d.ts.map +1 -0
- package/dist/intake-triage.js +95 -0
- package/dist/intake-triage.js.map +1 -0
- package/dist/intake-trigger-template.d.ts +185 -0
- package/dist/intake-trigger-template.d.ts.map +1 -0
- package/dist/intake-trigger-template.js +449 -0
- package/dist/intake-trigger-template.js.map +1 -0
- package/dist/intake.d.ts +569 -0
- package/dist/intake.d.ts.map +1 -0
- package/dist/intake.js +1628 -0
- package/dist/intake.js.map +1 -0
- package/dist/integration-core.d.ts +539 -0
- package/dist/integration-core.d.ts.map +1 -0
- package/dist/integration-core.js +2195 -0
- package/dist/integration-core.js.map +1 -0
- package/dist/integrator.d.ts +343 -0
- package/dist/integrator.d.ts.map +1 -0
- package/dist/integrator.js +400 -0
- package/dist/integrator.js.map +1 -0
- package/dist/isolation.d.ts +219 -0
- package/dist/isolation.d.ts.map +1 -0
- package/dist/isolation.js +261 -0
- package/dist/isolation.js.map +1 -0
- package/dist/issue-provider.d.ts +349 -0
- package/dist/issue-provider.d.ts.map +1 -0
- package/dist/issue-provider.js +360 -0
- package/dist/issue-provider.js.map +1 -0
- package/dist/item-lock.d.ts +626 -0
- package/dist/item-lock.d.ts.map +1 -0
- package/dist/item-lock.js +1381 -0
- package/dist/item-lock.js.map +1 -0
- package/dist/item-path.d.ts +49 -0
- package/dist/item-path.d.ts.map +1 -0
- package/dist/item-path.js +66 -0
- package/dist/item-path.js.map +1 -0
- package/dist/ledger-lint.d.ts +129 -0
- package/dist/ledger-lint.d.ts.map +1 -0
- package/dist/ledger-lint.js +249 -0
- package/dist/ledger-lint.js.map +1 -0
- package/dist/ledger-read.d.ts +357 -0
- package/dist/ledger-read.d.ts.map +1 -0
- package/dist/ledger-read.js +442 -0
- package/dist/ledger-read.js.map +1 -0
- package/dist/ledger-write.d.ts +330 -0
- package/dist/ledger-write.d.ts.map +1 -0
- package/dist/ledger-write.js +411 -0
- package/dist/ledger-write.js.map +1 -0
- package/dist/lifecycle-gather.d.ts +30 -0
- package/dist/lifecycle-gather.d.ts.map +1 -0
- package/dist/lifecycle-gather.js +205 -0
- package/dist/lifecycle-gather.js.map +1 -0
- package/dist/lifecycle-pools.d.ts +180 -0
- package/dist/lifecycle-pools.d.ts.map +1 -0
- package/dist/lifecycle-pools.js +78 -0
- package/dist/lifecycle-pools.js.map +1 -0
- package/dist/merge-question-surfacer.d.ts +166 -0
- package/dist/merge-question-surfacer.d.ts.map +1 -0
- package/dist/merge-question-surfacer.js +297 -0
- package/dist/merge-question-surfacer.js.map +1 -0
- package/dist/mint-adr.d.ts +126 -0
- package/dist/mint-adr.d.ts.map +1 -0
- package/dist/mint-adr.js +257 -0
- package/dist/mint-adr.js.map +1 -0
- package/dist/mirror-pool-scan.d.ts +125 -0
- package/dist/mirror-pool-scan.d.ts.map +1 -0
- package/dist/mirror-pool-scan.js +104 -0
- package/dist/mirror-pool-scan.js.map +1 -0
- package/dist/needs-attention.d.ts +341 -0
- package/dist/needs-attention.d.ts.map +1 -0
- package/dist/needs-attention.js +900 -0
- package/dist/needs-attention.js.map +1 -0
- package/dist/orphan-sidecar.d.ts +79 -0
- package/dist/orphan-sidecar.d.ts.map +1 -0
- package/dist/orphan-sidecar.js +71 -0
- package/dist/orphan-sidecar.js.map +1 -0
- package/dist/output.d.ts +48 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +66 -0
- package/dist/output.js.map +1 -0
- package/dist/pi-harness.d.ts +179 -0
- package/dist/pi-harness.d.ts.map +1 -0
- package/dist/pi-harness.js +342 -0
- package/dist/pi-harness.js.map +1 -0
- package/dist/placement.d.ts +99 -0
- package/dist/placement.d.ts.map +1 -0
- package/dist/placement.js +67 -0
- package/dist/placement.js.map +1 -0
- package/dist/prd-to-spec.d.ts +315 -0
- package/dist/prd-to-spec.d.ts.map +1 -0
- package/dist/prd-to-spec.js +684 -0
- package/dist/prd-to-spec.js.map +1 -0
- package/dist/prepare.d.ts +121 -0
- package/dist/prepare.d.ts.map +1 -0
- package/dist/prepare.js +140 -0
- package/dist/prepare.js.map +1 -0
- package/dist/prompt.d.ts +360 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +499 -0
- package/dist/prompt.js.map +1 -0
- package/dist/protocol/ADR-FORMAT.md +47 -0
- package/dist/protocol/CLAIM-PROTOCOL.md +217 -0
- package/dist/protocol/REVIEW-PROTOCOL.md +119 -0
- package/dist/protocol/SURFACE-PROTOCOL.md +121 -0
- package/dist/protocol/TASKING-PROTOCOL.md +122 -0
- package/dist/protocol/WORK-CONTRACT.md +276 -0
- package/dist/protocol/spec-template.md +71 -0
- package/dist/protocol/task-template.md +65 -0
- package/dist/readiness.d.ts +66 -0
- package/dist/readiness.d.ts.map +1 -0
- package/dist/readiness.js +36 -0
- package/dist/readiness.js.map +1 -0
- package/dist/reap-branches.d.ts +102 -0
- package/dist/reap-branches.d.ts.map +1 -0
- package/dist/reap-branches.js +149 -0
- package/dist/reap-branches.js.map +1 -0
- package/dist/recover-isolated.d.ts +72 -0
- package/dist/recover-isolated.d.ts.map +1 -0
- package/dist/recover-isolated.js +188 -0
- package/dist/recover-isolated.js.map +1 -0
- package/dist/registry.d.ts +172 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +296 -0
- package/dist/registry.js.map +1 -0
- package/dist/repo-config.d.ts +201 -0
- package/dist/repo-config.d.ts.map +1 -0
- package/dist/repo-config.js +414 -0
- package/dist/repo-config.js.map +1 -0
- package/dist/repo-key.d.ts +20 -0
- package/dist/repo-key.d.ts.map +1 -0
- package/dist/repo-key.js +68 -0
- package/dist/repo-key.js.map +1 -0
- package/dist/repo-mirror.d.ts +177 -0
- package/dist/repo-mirror.d.ts.map +1 -0
- package/dist/repo-mirror.js +271 -0
- package/dist/repo-mirror.js.map +1 -0
- package/dist/retry-backoff.d.ts +90 -0
- package/dist/retry-backoff.d.ts.map +1 -0
- package/dist/retry-backoff.js +98 -0
- package/dist/retry-backoff.js.map +1 -0
- package/dist/review-gate.d.ts +173 -0
- package/dist/review-gate.d.ts.map +1 -0
- package/dist/review-gate.js +261 -0
- package/dist/review-gate.js.map +1 -0
- package/dist/review-verdict.d.ts +149 -0
- package/dist/review-verdict.d.ts.map +1 -0
- package/dist/review-verdict.js +332 -0
- package/dist/review-verdict.js.map +1 -0
- package/dist/run.d.ts +221 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +963 -0
- package/dist/run.js.map +1 -0
- package/dist/scan.d.ts +308 -0
- package/dist/scan.d.ts.map +1 -0
- package/dist/scan.js +374 -0
- package/dist/scan.js.map +1 -0
- package/dist/select-order.d.ts +75 -0
- package/dist/select-order.d.ts.map +1 -0
- package/dist/select-order.js +108 -0
- package/dist/select-order.js.map +1 -0
- package/dist/select-priority.d.ts +188 -0
- package/dist/select-priority.d.ts.map +1 -0
- package/dist/select-priority.js +80 -0
- package/dist/select-priority.js.map +1 -0
- package/dist/select.d.ts +25 -0
- package/dist/select.d.ts.map +1 -0
- package/dist/select.js +43 -0
- package/dist/select.js.map +1 -0
- package/dist/session-path.d.ts +36 -0
- package/dist/session-path.d.ts.map +1 -0
- package/dist/session-path.js +129 -0
- package/dist/session-path.js.map +1 -0
- package/dist/sidecar-apply.d.ts +83 -0
- package/dist/sidecar-apply.d.ts.map +1 -0
- package/dist/sidecar-apply.js +111 -0
- package/dist/sidecar-apply.js.map +1 -0
- package/dist/sidecar.d.ts +245 -0
- package/dist/sidecar.d.ts.map +1 -0
- package/dist/sidecar.js +481 -0
- package/dist/sidecar.js.map +1 -0
- package/dist/slug-namespace.d.ts +204 -0
- package/dist/slug-namespace.d.ts.map +1 -0
- package/dist/slug-namespace.js +229 -0
- package/dist/slug-namespace.js.map +1 -0
- package/dist/spec-complete.d.ts +44 -0
- package/dist/spec-complete.d.ts.map +1 -0
- package/dist/spec-complete.js +69 -0
- package/dist/spec-complete.js.map +1 -0
- package/dist/start.d.ts +97 -0
- package/dist/start.d.ts.map +1 -0
- package/dist/start.js +633 -0
- package/dist/start.js.map +1 -0
- package/dist/status.d.ts +199 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +228 -0
- package/dist/status.js.map +1 -0
- package/dist/surface-gate.d.ts +162 -0
- package/dist/surface-gate.d.ts.map +1 -0
- package/dist/surface-gate.js +206 -0
- package/dist/surface-gate.js.map +1 -0
- package/dist/surface-persist.d.ts +86 -0
- package/dist/surface-persist.d.ts.map +1 -0
- package/dist/surface-persist.js +129 -0
- package/dist/surface-persist.js.map +1 -0
- package/dist/tasker-review-loop.d.ts +249 -0
- package/dist/tasker-review-loop.d.ts.map +1 -0
- package/dist/tasker-review-loop.js +369 -0
- package/dist/tasker-review-loop.js.map +1 -0
- package/dist/tasking-eligibility.d.ts +74 -0
- package/dist/tasking-eligibility.d.ts.map +1 -0
- package/dist/tasking-eligibility.js +52 -0
- package/dist/tasking-eligibility.js.map +1 -0
- package/dist/tasking-lock.d.ts +111 -0
- package/dist/tasking-lock.d.ts.map +1 -0
- package/dist/tasking-lock.js +256 -0
- package/dist/tasking-lock.js.map +1 -0
- package/dist/tasking.d.ts +275 -0
- package/dist/tasking.d.ts.map +1 -0
- package/dist/tasking.js +952 -0
- package/dist/tasking.js.map +1 -0
- package/dist/triage-gate.d.ts +127 -0
- package/dist/triage-gate.d.ts.map +1 -0
- package/dist/triage-gate.js +139 -0
- package/dist/triage-gate.js.map +1 -0
- package/dist/triage-persist.d.ts +163 -0
- package/dist/triage-persist.d.ts.map +1 -0
- package/dist/triage-persist.js +387 -0
- package/dist/triage-persist.js.map +1 -0
- package/dist/verdict-json.d.ts +32 -0
- package/dist/verdict-json.d.ts.map +1 -0
- package/dist/verdict-json.js +74 -0
- package/dist/verdict-json.js.map +1 -0
- package/dist/verify-workflow-template.d.ts +60 -0
- package/dist/verify-workflow-template.d.ts.map +1 -0
- package/dist/verify-workflow-template.js +126 -0
- package/dist/verify-workflow-template.js.map +1 -0
- package/dist/verify.d.ts +60 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +62 -0
- package/dist/verify.js.map +1 -0
- package/dist/watch-session.d.ts +112 -0
- package/dist/watch-session.d.ts.map +1 -0
- package/dist/watch-session.js +347 -0
- package/dist/watch-session.js.map +1 -0
- package/dist/work-layout.d.ts +198 -0
- package/dist/work-layout.d.ts.map +1 -0
- package/dist/work-layout.js +217 -0
- package/dist/work-layout.js.map +1 -0
- package/dist/work-on.d.ts +154 -0
- package/dist/work-on.d.ts.map +1 -0
- package/dist/work-on.js +387 -0
- package/dist/work-on.js.map +1 -0
- package/dist/workspace.d.ts +224 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +325 -0
- package/dist/workspace.js.map +1 -0
- package/package.json +46 -2
- package/src/advance-ci-template.ts +203 -0
- package/src/advance-classify.ts +197 -0
- package/src/advance-drivers.ts +414 -0
- package/src/advance-isolated.ts +432 -0
- package/src/advance-lifecycle-template.ts +791 -0
- package/src/advance-loop-driver.ts +745 -0
- package/src/advance-treeless-publish.ts +177 -0
- package/src/advance.ts +1564 -0
- package/src/advancing-lock.ts +988 -0
- package/src/agent-launch.ts +137 -0
- package/src/agent-stop.ts +361 -0
- package/src/apply-decide.ts +242 -0
- package/src/apply-merge-action.ts +502 -0
- package/src/apply-persist.ts +518 -0
- package/src/arbiter.ts +372 -0
- package/src/brand.ts +111 -0
- package/src/buildable-body.ts +196 -0
- package/src/categorise.ts +158 -0
- package/src/claim-cas.ts +513 -0
- package/src/cli-spinner.ts +225 -0
- package/src/cli.ts +4369 -0
- package/src/close-job-template.ts +236 -0
- package/src/close-job.ts +319 -0
- package/src/complete.ts +1379 -0
- package/src/concurrency.ts +151 -0
- package/src/config-override.ts +116 -0
- package/src/config.ts +883 -0
- package/src/continue-branch.ts +542 -0
- package/src/cwd-section.ts +392 -0
- package/src/decision-engine.ts +272 -0
- package/src/detect.ts +124 -0
- package/src/do-autopick.ts +223 -0
- package/src/do-config.ts +589 -0
- package/src/do-remote-auto.ts +197 -0
- package/src/do.ts +2623 -0
- package/src/drop-source.ts +194 -0
- package/src/eligibility.ts +79 -0
- package/src/env-config.ts +305 -0
- package/src/failure-cause.ts +142 -0
- package/src/format.ts +313 -0
- package/src/frontmatter.ts +485 -0
- package/src/gate-readiness.ts +147 -0
- package/src/gc.ts +510 -0
- package/src/gh-failure.ts +53 -0
- package/src/git.ts +186 -0
- package/src/github.ts +468 -0
- package/src/harness.ts +355 -0
- package/src/identity.ts +322 -0
- package/src/index.ts +785 -0
- package/src/install-ci-branch-protection.ts +255 -0
- package/src/install-ci-capabilities/advance-lifecycle.ts +34 -0
- package/src/install-ci-capabilities/close-job.ts +32 -0
- package/src/install-ci-capabilities/example-noop.ts +24 -0
- package/src/install-ci-capabilities/intake.ts +34 -0
- package/src/install-ci-capabilities/verify.ts +33 -0
- package/src/install-ci-core.ts +1088 -0
- package/src/install-ci-github.ts +376 -0
- package/src/install-ci.ts +552 -0
- package/src/intake-event.ts +102 -0
- package/src/intake-marker.ts +195 -0
- package/src/intake-triage.ts +138 -0
- package/src/intake-trigger-template.ts +591 -0
- package/src/intake.ts +2445 -0
- package/src/integration-core.ts +3065 -0
- package/src/integrator.ts +771 -0
- package/src/isolation.ts +484 -0
- package/src/issue-provider.ts +733 -0
- package/src/item-lock.ts +1858 -0
- package/src/item-path.ts +75 -0
- package/src/ledger-lint.ts +332 -0
- package/src/ledger-read.ts +924 -0
- package/src/ledger-write.ts +865 -0
- package/src/lifecycle-gather.ts +298 -0
- package/src/lifecycle-pools.ts +250 -0
- package/src/merge-question-surfacer.ts +496 -0
- package/src/mint-adr.ts +362 -0
- package/src/mirror-pool-scan.ts +240 -0
- package/src/needs-attention.ts +1506 -0
- package/src/orphan-sidecar.ts +150 -0
- package/src/output.ts +89 -0
- package/src/pi-harness.ts +403 -0
- package/src/placement.ts +131 -0
- package/src/prd-to-spec.ts +1023 -0
- package/src/prepare.ts +230 -0
- package/src/prompt.ts +760 -0
- package/src/readiness.ts +98 -0
- package/src/reap-branches.ts +278 -0
- package/src/recover-isolated.ts +276 -0
- package/src/registry.ts +475 -0
- package/src/repo-config.ts +550 -0
- package/src/repo-key.ts +74 -0
- package/src/repo-mirror.ts +367 -0
- package/src/retry-backoff.ts +130 -0
- package/src/review-gate.ts +389 -0
- package/src/review-verdict.ts +422 -0
- package/src/run.ts +1430 -0
- package/src/scan.ts +611 -0
- package/src/select-order.ts +143 -0
- package/src/select-priority.ts +266 -0
- package/src/select.ts +62 -0
- package/src/session-path.ts +153 -0
- package/src/sidecar-apply.ts +216 -0
- package/src/sidecar.ts +700 -0
- package/src/slug-namespace.ts +367 -0
- package/src/spec-complete.ts +118 -0
- package/src/start.ts +974 -0
- package/src/status.ts +441 -0
- package/src/surface-gate.ts +337 -0
- package/src/surface-persist.ts +241 -0
- package/src/tasker-review-loop.ts +671 -0
- package/src/tasking-eligibility.ts +114 -0
- package/src/tasking-lock.ts +416 -0
- package/src/tasking.ts +1438 -0
- package/src/triage-gate.ts +248 -0
- package/src/triage-persist.ts +570 -0
- package/src/verdict-json.ts +73 -0
- package/src/verify-workflow-template.ts +159 -0
- package/src/verify.ts +123 -0
- package/src/watch-session.ts +397 -0
- package/src/work-layout.ts +262 -0
- package/src/work-on.ts +660 -0
- package/src/workspace.ts +502 -0
|
@@ -0,0 +1,3065 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs';
|
|
9
|
+
import {tmpdir} from 'node:os';
|
|
10
|
+
import {join} from 'node:path';
|
|
11
|
+
import {
|
|
12
|
+
LEDGER_STATUS_FOLDERS,
|
|
13
|
+
type LedgerStatusFolder,
|
|
14
|
+
type WorkFolderKey,
|
|
15
|
+
workItemPath,
|
|
16
|
+
workItemRel,
|
|
17
|
+
workFolderPath,
|
|
18
|
+
workFolderPrefix,
|
|
19
|
+
} from './work-layout.js';
|
|
20
|
+
import {runVerify, type VerifyConfig} from './verify.js';
|
|
21
|
+
import {ensurePrepared} from './prepare.js';
|
|
22
|
+
import {
|
|
23
|
+
type ReviewGate,
|
|
24
|
+
type ReviewFinding,
|
|
25
|
+
type ReviewVerdict,
|
|
26
|
+
ReviewParseError,
|
|
27
|
+
formatBlockReason,
|
|
28
|
+
reviewRoundsExhaustedReason,
|
|
29
|
+
} from './review-gate.js';
|
|
30
|
+
import {type IntegrateResult, type ReviewProvider} from './integrator.js';
|
|
31
|
+
import {ledgerWrite} from './ledger-write.js';
|
|
32
|
+
import {selectProvider} from './github.js';
|
|
33
|
+
import type {IntegrationMode} from './config.js';
|
|
34
|
+
import {parseFrontmatter} from './frontmatter.js';
|
|
35
|
+
import {git, run, runAsync, type RunResult} from './git.js';
|
|
36
|
+
import {realSleep, type Sleep} from './retry-backoff.js';
|
|
37
|
+
import {workBranchRef} from './slug-namespace.js';
|
|
38
|
+
import {isAncestor} from './gc.js';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* **The shared gate→integrate BACK-HALF** of the per-item pipeline, extracted out
|
|
42
|
+
* of `performComplete` (`complete.ts`) so BOTH the human `do`/`complete` path and
|
|
43
|
+
* (a later task) the autonomous `run` path share ONE implementation of the
|
|
44
|
+
* gate→integrate band. The exact ORDER depends on the fresh-worktree gate
|
|
45
|
+
* (`freshWorktreeGate`, task `gate-on-rebased-tip-fresh-worktree`):
|
|
46
|
+
*
|
|
47
|
+
* - fresh gate OFF (the pre-rebase gate, today byte-for-byte):
|
|
48
|
+
* verify (cwd) → review (cwd) → effective-mode decision → done-move →
|
|
49
|
+
* atomic commit → rebase-onto-arbiter → integrate.
|
|
50
|
+
* - fresh gate ON (the default; gate the tree that MERGES):
|
|
51
|
+
* done-move → atomic commit → rebase-onto-arbiter → verify (rebased tip) →
|
|
52
|
+
* review (rebased tip) → effective-mode decision → integrate.
|
|
53
|
+
*
|
|
54
|
+
* Either way `verify` is the deterministic FLOOR and runs FIRST, with the Gate-2
|
|
55
|
+
* REVIEW (judgement) layered ON TOP and only on `verify`'s green — and (ON path,
|
|
56
|
+
* MAINTAINER DECISION 2) BOTH run on the SAME rebased tip (the tree that actually
|
|
57
|
+
* integrates), so verify-then-review holds on the merged tree, never split across
|
|
58
|
+
* two trees. Any failure routes to needs-attention.
|
|
59
|
+
*
|
|
60
|
+
* It is the CORE in the head / core / tail decomposition (prd
|
|
61
|
+
* `work/prds/tasked/run-do-integrate-convergence.md`): the caller-specific HEAD
|
|
62
|
+
* (repo/arbiter/branch checks, source resolution, the `recovering` flag) and TAIL
|
|
63
|
+
* (switch-to-main / `syncLocalMain` / delete-local-branch / `--no-switch` / the
|
|
64
|
+
* propose next-step block, or — for `run` — the job-record + worktree reap) stay
|
|
65
|
+
* in their respective callers; this is the band they share.
|
|
66
|
+
*
|
|
67
|
+
* It returns DATA ({@link IntegrationCoreResult}) and performs the needs-attention
|
|
68
|
+
* ROUTING and the effective-mode DECISION — but NO caller-specific side effects
|
|
69
|
+
* (no `git switch main`, no branch delete, no job record, no propose next-step
|
|
70
|
+
* block). The human-vs-autonomous difference rides entirely on the {@link
|
|
71
|
+
* IntegrationCoreInput.surfaceArbiter} field (human = unset ⇒ local-only routing;
|
|
72
|
+
* autonomous = set ⇒ surface-on-main + push-branch routing) — DATA, never a
|
|
73
|
+
* caller-identity flag.
|
|
74
|
+
*
|
|
75
|
+
* The band below was extracted (a pure refactor) from `performComplete`. A
|
|
76
|
+
* resolved `merge` ALWAYS lands on a green gate (and, with review on, an
|
|
77
|
+
* `approve`); `propose` always leaves the merge to a human. There is no
|
|
78
|
+
* `merge`→`propose` downgrade — `merge` IS the auto-land mode. The effective
|
|
79
|
+
* mode the core resolved is carried in {@link IntegrationCoreResult.integration}'s
|
|
80
|
+
* `mode`; the tail reads it from there, NEVER from the requested mode.
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The outcome the core resolved. These already match `complete`'s
|
|
85
|
+
* `CompleteOutcome` subset the core can produce — the tail maps them 1:1 (it adds
|
|
86
|
+
* only `refused`/`usage-error`, which are HEAD concerns the core never reaches).
|
|
87
|
+
*/
|
|
88
|
+
export type IntegrationCoreOutcome =
|
|
89
|
+
| 'completed' // gated, reviewed, moved, committed, rebased, integrated
|
|
90
|
+
| 'prepare-failed' // the env-prep step (prepare) was red — env not ready, verify untrusted
|
|
91
|
+
| 'gate-failed' // the acceptance gate (verify) was red (and not skipped)
|
|
92
|
+
| 'review-blocked' // Gate 2 (PR/code review) returned `block` (or exhausted rounds)
|
|
93
|
+
| 'review-unparseable' // Gate 2 ran but its verdict JSON could not be parsed (malformed output) — work-preserving route, transient-infra cause (NOT a reviewer block)
|
|
94
|
+
| 'rebase-conflict' // rebase onto arbiter/main conflicted (aborted; human resolves)
|
|
95
|
+
| 'invariant-violation' // one-slug-one-folder would break (slug in two folders on the arbiter)
|
|
96
|
+
| 'already-integrated'; // committed-recovery: the kept tip is already on <arbiter>/main (clean no-op)
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The CORE's input — everything the band needs, nothing caller-shaped. Every
|
|
100
|
+
* divergence between the human `complete` path and the autonomous `do`/`run` path
|
|
101
|
+
* maps to a FIELD VALUE here, not an `if (caller === …)` branch (prd: "zero
|
|
102
|
+
* caller-identity leakage"). In-place vs worktree = `cwd`; arbiter name =
|
|
103
|
+
* `arbiter`; human vs autonomous surfacing = `surfaceArbiter`; do's recovery =
|
|
104
|
+
* `source` + `recovering`; per-repo/lang gate = `verify`.
|
|
105
|
+
*/
|
|
106
|
+
/**
|
|
107
|
+
* A NON-TASK lifecycle the integrate band threads in place of its default,
|
|
108
|
+
* task-shaped done-move + title source. The shared band
|
|
109
|
+
* (verify→review→commit→rebase→integrate→propose-PR-with-title/body) is
|
|
110
|
+
* IDENTICAL; only the "which item move + which file to read the title from" step
|
|
111
|
+
* is caller-supplied. This is the seam the `do prd:<slug>` TASKING transition
|
|
112
|
+
* rides (task `slice-output-through-integration`): its "item move" is the prd
|
|
113
|
+
* LIFECYCLE move (`work/prds/ready/<slug>.md → work/prds/tasked/<slug>.md`, residence =
|
|
114
|
+
* tasked-ness) plus its EMITTED backlog files — NOT a task done-move. Supplying it
|
|
115
|
+
* makes every integrate-time arg (`--propose`/`--merge`, provider, title/body)
|
|
116
|
+
* apply to tasking BY CONSTRUCTION, because they resolve ONCE here.
|
|
117
|
+
*
|
|
118
|
+
* When set, the band: (1) reads the PR title / default commit summary from
|
|
119
|
+
* {@link titlePath} instead of `work/<source>/<slug>.md`; (2) calls {@link stage}
|
|
120
|
+
* (runner-owned, on the work branch) instead of the task `git mv → work/done/`;
|
|
121
|
+
* and (3) uses the PLAIN rebase (a tasking transition never `recover`s a
|
|
122
|
+
* surfaced needs-attention move). Everything else — the `git add -A` that sweeps
|
|
123
|
+
* the agent's uncommitted work + the staged lifecycle move, the atomic commit,
|
|
124
|
+
* the rebase, the integrate, and the propose-mode PR title/body — is shared.
|
|
125
|
+
*/
|
|
126
|
+
export interface IntegrationLifecycle {
|
|
127
|
+
/**
|
|
128
|
+
* Absolute path to the item file whose `title:` frontmatter seeds the default
|
|
129
|
+
* commit summary AND the synthesised propose-mode PR title. For the tasking
|
|
130
|
+
* transition this is the held prd (`work/prds/ready/<slug>.md`) — read BEFORE
|
|
131
|
+
* {@link stage} moves it. IGNORED when {@link title} is supplied (the explicit
|
|
132
|
+
* title wins — no file read).
|
|
133
|
+
*/
|
|
134
|
+
titlePath: string;
|
|
135
|
+
/**
|
|
136
|
+
* The drafted item TITLE, supplied EXPLICITLY (no file read). When set, the band
|
|
137
|
+
* uses THIS as the title source for the default commit summary AND the synthesised
|
|
138
|
+
* propose-mode PR title, INSTEAD of reading {@link titlePath}. This is the seam for
|
|
139
|
+
* a lifecycle whose output file does NOT yet exist at title-read time — the intake
|
|
140
|
+
* lone-task / prd path WRITES `work/backlog/<slug>.md` / `work/prds/ready/<slug>.md` in
|
|
141
|
+
* {@link stage}, which runs AFTER the title read, so a `titlePath` read would race
|
|
142
|
+
* the write and fall back to a generic subject. The `do prd:` tasking transition
|
|
143
|
+
* leaves this unset (its `titlePath` is an already-existing held prd, read fine).
|
|
144
|
+
*/
|
|
145
|
+
title?: string;
|
|
146
|
+
/**
|
|
147
|
+
* STAGE the lifecycle move + emitted files into the index on the current work
|
|
148
|
+
* branch (runner-owned git; the agent never does git). For the tasking
|
|
149
|
+
* transition: `git mv work/prds/ready/<slug>.md → work/prds/tasked/<slug>.md`
|
|
150
|
+
* (residence = tasked-ness; no marker), and write+`git add` the produced
|
|
151
|
+
* `work/backlog/*.md` files. The band's subsequent `git add -A` + atomic commit
|
|
152
|
+
* folds this staging
|
|
153
|
+
* AND the agent's uncommitted backlog writes into ONE runner-owned commit. May
|
|
154
|
+
* be async (it shells git).
|
|
155
|
+
*/
|
|
156
|
+
stage(): Promise<void> | void;
|
|
157
|
+
/**
|
|
158
|
+
* The trailing transition tag on the runner-owned commit subject
|
|
159
|
+
* (`<type>(<slug>): <summary>; <commitTag>`). Defaults to `done` (the build
|
|
160
|
+
* lifecycle); the tasking transition supplies `tasked`. Cosmetic — it names
|
|
161
|
+
* WHICH lifecycle landed; it gates nothing.
|
|
162
|
+
*/
|
|
163
|
+
commitTag?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The default LIVENESS CEILING for the merge-mode `${branch}:main` push (the
|
|
168
|
+
* durable promotions `tasks/ready → tasks/done` and, via `lifecycle`, `prds/ready
|
|
169
|
+
* → prds/tasked`). Task `c2-rebase-until-real-on-durable-main-promotions` turned
|
|
170
|
+
* the previous SMALL FIXED CAP (was 5, the `run-fleet-claim-integrate-and-sibling-
|
|
171
|
+
* rebase-concurrency-safe` Race-1 budget) into rebase-until-real-conflict: a CLEAN
|
|
172
|
+
* re-rebase no longer counts against a tiny give-up budget — only a GENUINE
|
|
173
|
+
* conflict surfaced by {@link rebaseOntoMainWithReconcile} stops the loop (the
|
|
174
|
+
* step-4 rebase IS the source-folder precondition recheck for the slug-relocation
|
|
175
|
+
* family: if the slug is GONE from its expected source folder on the new `main`,
|
|
176
|
+
* the `git mv` replay fails and `rebaseConflictRoute` routes definitively — never a
|
|
177
|
+
* silent re-push that would clobber a concurrent legitimate same-item winner).
|
|
178
|
+
*
|
|
179
|
+
* The cap survives only as a LARGE liveness ceiling that bounds the pathological
|
|
180
|
+
* livelock tail (a sustained-parallel-load hot ref where the loser's round-trip is
|
|
181
|
+
* always beaten by another winner — classic CAS livelock). Combined with the
|
|
182
|
+
* modest jitter on the refetch below it desynchronises the herd, so a route-to-
|
|
183
|
+
* needs-attention from this loop becomes a RARE livelock signal rather than the
|
|
184
|
+
* ROUTINE false-contention signal it was at the old cap of 5.
|
|
185
|
+
*
|
|
186
|
+
* SCOPE (`work/notes/ideas/ledger-lock-evolution-per-item-ref-vs-rebase-until-real-
|
|
187
|
+
* conflict.md` `### C2` SCOPE box, repeated here because over-applying C2 is the
|
|
188
|
+
* one near-fatal mistake): the durable promotions are slug RELOCATIONS, not the
|
|
189
|
+
* same-path / append family. They MUST keep their source-folder precondition
|
|
190
|
+
* recheck — reused here verbatim as `rebaseOntoMainWithReconcile`'s rebase replay
|
|
191
|
+
* + arbiter ledger-placement read. Do NOT add a new conflict-detection path; the
|
|
192
|
+
* existing one IS the genuine-conflict terminator.
|
|
193
|
+
*
|
|
194
|
+
* Tests inject a small `mergeRetries` (or `0`) to exercise the old un-retried /
|
|
195
|
+
* cap-exhausted route deterministically — that test seam is preserved.
|
|
196
|
+
*/
|
|
197
|
+
const DEFAULT_MERGE_RETRIES = 1000;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Default modest jitter (ms) on the refetch between merge-push retries — load-
|
|
201
|
+
* bearing under sustained parallel load: an instant lockstep refetch→re-push loop
|
|
202
|
+
* maximises mutual rejection (a thundering herd), fattening the livelock tail.
|
|
203
|
+
* Modest randomisation desynchronises the herd. Each attempt sleeps a UNIFORMLY-
|
|
204
|
+
* random integer in `[0, DEFAULT_MERGE_JITTER_MS]` ms. Small enough (≤25ms) that
|
|
205
|
+
* the additional latency under realistic contention is negligible vs the
|
|
206
|
+
* round-trip cost of a rebase+push, and small enough that the in-tree concurrency
|
|
207
|
+
* tests are not slowed measurably. Tests override via `mergeJitterMs: 0`.
|
|
208
|
+
*/
|
|
209
|
+
const DEFAULT_MERGE_JITTER_MS = 25;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Promise-based sleep, used for the {@link DEFAULT_MERGE_JITTER_MS} merge-push
|
|
213
|
+
* retry jitter (C2). Internal — the legacy non-seamed sleep this module uses
|
|
214
|
+
* for the C2 jitter (kept for byte-for-byte compatibility with existing tests).
|
|
215
|
+
* The recovery-rebase loop uses the INJECTABLE {@link Sleep} seam from
|
|
216
|
+
* `retry-backoff.ts` instead, so its timeline is test-driveable.
|
|
217
|
+
*/
|
|
218
|
+
function sleepMs(ms: number): Promise<void> {
|
|
219
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* **Default cap on RE-FETCH+RE-REBASE attempts** in the committed-recovery tail
|
|
224
|
+
* (`recoverAlreadyCommitted`, task `recovery-rebase-retry-against-moving-arbiter
|
|
225
|
+
* -main`). The recovery's single fetch-then-rebase is a CONTENTION race against a
|
|
226
|
+
* concurrently-MOVING `<arbiter>/main` (a sibling `advance` run lands a burst of
|
|
227
|
+
* `advance: surface observation:…` commits); a one-shot rebase against a stale
|
|
228
|
+
* fetched base can conflict against a main that already moved AGAIN, surfacing a
|
|
229
|
+
* purely transient race as `rebase-conflict`. So the rebase is wrapped in a small
|
|
230
|
+
* bounded CONTENTION loop: re-fetch `<arbiter>/main` (it may have advanced) +
|
|
231
|
+
* re-rebase; on clean → integrate; on conflict → `--abort`, a small jitter sleep,
|
|
232
|
+
* try again. The cap stops the loop ONLY when every fresh-fetched attempt still
|
|
233
|
+
* conflicts (a genuinely persistent conflict ⇒ route to needs-attention exactly
|
|
234
|
+
* as today). Small on purpose — a few attempts ride out a `advance` burst (each
|
|
235
|
+
* burst is tens of commits over a few seconds); a real conflict surfaces fast.
|
|
236
|
+
*
|
|
237
|
+
* This is the CONTENTION model (instant re-fetch+rebuild, like `claim-cas.ts`
|
|
238
|
+
* and the Race-1 merge loop above), NOT the OUTAGE model in
|
|
239
|
+
* {@link file://./retry-backoff.ts} (exponential temporal backoff, the remote
|
|
240
|
+
* may come back). The two failure classes are deliberately kept SEPARATE; do not
|
|
241
|
+
* substitute `retryWithBackoff` here.
|
|
242
|
+
*
|
|
243
|
+
* Tests inject `recoveryRebaseRetries: 0` (no retry — the legacy one-shot shape)
|
|
244
|
+
* or a small explicit cap (assert the cap exhausts deterministically).
|
|
245
|
+
*/
|
|
246
|
+
const DEFAULT_RECOVERY_REBASE_RETRIES = 4;
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* **Default max jitter (ms)** between recovery-rebase attempts — a SMALL
|
|
250
|
+
* livelock-breaking SPREAD between concurrent runners (NOT exponential outage
|
|
251
|
+
* backoff). Pure instant retry has a real hazard: two runners that begin
|
|
252
|
+
* retrying at the same instant re-fetch and re-rebase in LOCKSTEP, each moving
|
|
253
|
+
* the base the other just rebased onto, and can livelock. A uniformly-random
|
|
254
|
+
* `[0, mergeJitterMs]` ms sleep before each re-attempt de-correlates the two
|
|
255
|
+
* racers. Bounded and tiny — a contention nudge, not an outage wait. Tests pass
|
|
256
|
+
* `recoveryRebaseJitterMs: 0` for a deterministic latency-free loop, OR inject
|
|
257
|
+
* the `sleep`/`random` seams to drive the timeline reproducibly with a seeded RNG.
|
|
258
|
+
*/
|
|
259
|
+
const DEFAULT_RECOVERY_REBASE_JITTER_MS = 100;
|
|
260
|
+
|
|
261
|
+
export interface IntegrationCoreInput {
|
|
262
|
+
/** The working clone/checkout (in-place) OR worktree dir the work branch lives in. */
|
|
263
|
+
cwd: string;
|
|
264
|
+
/** Name of the arbiter git remote (valid in `cwd`). */
|
|
265
|
+
arbiter: string;
|
|
266
|
+
/** The slug being integrated (its work branch is `work/<type>-<slug>`). */
|
|
267
|
+
slug: string;
|
|
268
|
+
/**
|
|
269
|
+
* The work BRANCH being integrated. The caller is ALWAYS on it (the agent
|
|
270
|
+
* built there / the lifecycle stage wrote there), so it carries the namespaced
|
|
271
|
+
* `work/<type>-<slug>` identity. When omitted, the core derives it from the
|
|
272
|
+
* branch HEAD is on (the robust default — the type is encoded IN the name). A
|
|
273
|
+
* caller that knows the type explicitly may pass it (e.g. the tasking path
|
|
274
|
+
* passes its `work/prds/ready-<slug>`).
|
|
275
|
+
*/
|
|
276
|
+
branch?: string;
|
|
277
|
+
/**
|
|
278
|
+
* Which folder the item is being completed FROM: `tasks-ready` (the normal,
|
|
279
|
+
* freshly-built path — since claim no longer moves the body, a freshly-built
|
|
280
|
+
* task RESTS in the pool on `main`, task
|
|
281
|
+
* `cutover-claim-body-stays-and-complete-sources-from-backlog`),
|
|
282
|
+
* `tasks-backlog` (the `--allow-backlog` staged-drive path, prd
|
|
283
|
+
* `do-allow-backlog-drive-staged-tasks-without-promotion`: a human drove a
|
|
284
|
+
* STAGED task in place, so the done-move goes `tasks/backlog/ → tasks/done/`
|
|
285
|
+
* DIRECTLY — the explicit drive IS the promotion, never bouncing through the
|
|
286
|
+
* pool), `needs-attention` (the runner-owned recovery path), or `done` (the
|
|
287
|
+
* CONTINUE-BUILD path, task `complete-builds-on-already-done-moved-continue`).
|
|
288
|
+
* The HEAD resolved this; the core uses it for the done-move source folder and
|
|
289
|
+
* the recovery rebase. IGNORED when {@link lifecycle} is set (a non-task move).
|
|
290
|
+
* (`in-progress` is RETAINED in the union for the bounce/recovery surfaces that
|
|
291
|
+
* may still source from `in-progress/` until its folder removal, 9c.)
|
|
292
|
+
*
|
|
293
|
+
* `done` — the continue-build lifecycle state: a CONTINUE on a kept work
|
|
294
|
+
* branch whose `<slug>.md` is ALREADY in `work/done/` (a prior attempt moved
|
|
295
|
+
* it there), and THIS run produced NEW uncommitted source edits. The slug is
|
|
296
|
+
* already in `done/` ⇒ the step-2 `git mv` is SKIPPED (there is nothing to
|
|
297
|
+
* move), and the originTrust read + the divergent-done-move reconciliation
|
|
298
|
+
* are EXEMPTED (there is no first-time move on this commit — the prior
|
|
299
|
+
* attempt already went through the checkpoint, and the local + arbiter both
|
|
300
|
+
* already hold the slug in `done/` so there is nothing to reconcile). The
|
|
301
|
+
* build path still runs prepare → gate → `git add -A` → commit → rebase →
|
|
302
|
+
* integrate on the NEW work so it lands as a continuation commit on top of
|
|
303
|
+
* the kept (already-done-moved) tip. Mutually exclusive with
|
|
304
|
+
* {@link committedRecovery} (the clean-strand fast-path — `complete.ts`
|
|
305
|
+
* resolves `done` only when the working tree is DIRTY, and
|
|
306
|
+
* {@link committedRecovery} only when it is CLEAN; they cannot both be set
|
|
307
|
+
* for the same call). See `docs/adr/continue-build-already-done-moved.md`.
|
|
308
|
+
*/
|
|
309
|
+
// `needs-attention` was a recovery source in the legacy folder model; it is
|
|
310
|
+
// gone (task `finish-needs-attention-folder-cutover-remove-legacy-recovery-readers`).
|
|
311
|
+
// The retired folder is no longer written, so a recovery `complete` sources
|
|
312
|
+
// from `tasks-ready` (the pool position, where claim now leaves the body)
|
|
313
|
+
// like a normal build.
|
|
314
|
+
source: 'tasks-ready' | 'tasks-backlog' | 'in-progress' | 'done';
|
|
315
|
+
/**
|
|
316
|
+
* Vestigial: was `true` when completing FROM `work/needs-attention/` (a
|
|
317
|
+
* recovery finish under the legacy folder model). The per-item-lock cutover
|
|
318
|
+
* (`cutover-needs-attention-becomes-lock-stuck-recovery-surface`) retired
|
|
319
|
+
* that folder — a stuck item is now the lock `state: stuck` and the body
|
|
320
|
+
* stays in `tasks/ready/`, so a recovery `complete` is structurally a normal
|
|
321
|
+
* build. EVERY caller now passes `false`; the field is preserved on the input
|
|
322
|
+
* type only so callers compile unchanged — every branch that read it has been
|
|
323
|
+
* deleted (the `if (recovering)` re-gate paths). A future cross-caller change
|
|
324
|
+
* can remove the field; out of this task's scope.
|
|
325
|
+
*/
|
|
326
|
+
recovering: boolean;
|
|
327
|
+
/**
|
|
328
|
+
* **RECOVER an already-committed, already-done-moved STRANDED branch** (prd
|
|
329
|
+
* `ledger-integrity` story 6, the `finish-already-committed-branch` task). When
|
|
330
|
+
* `true`, the work is ALREADY committed on the work branch with the task already
|
|
331
|
+
* `git mv`'d into `work/done/` (a terminal push failed AFTER steps 2–3, leaving
|
|
332
|
+
* the green work stranded). So the core SKIPS steps 0–3 (prepare / gate / review /
|
|
333
|
+
* done-move / commit — they already ran) and runs ONLY the rebase→integrate TAIL
|
|
334
|
+
* (steps 4–5) from the kept commit, reusing the SAME
|
|
335
|
+
* `ledgerWrite.applyCompleteTransition` integrate primitive — no rebuild, no
|
|
336
|
+
* orphan branch.
|
|
337
|
+
*
|
|
338
|
+
* The detection is UNSPOOFABLE: before acting the core verifies the work-branch
|
|
339
|
+
* tip is genuinely AHEAD of `<arbiter>/main` (`isAncestor`, the SAME reachability
|
|
340
|
+
* predicate `gc.ts` uses). A tip ALREADY reachable on `<arbiter>/main` is
|
|
341
|
+
* already-integrated → a clean `already-integrated` no-op, NEVER a re-push /
|
|
342
|
+
* double-integrate. Mutually exclusive with `recovering` and `lifecycle` (both
|
|
343
|
+
* presuppose an un-committed source state this path has moved past); when set
|
|
344
|
+
* they are ignored.
|
|
345
|
+
*/
|
|
346
|
+
committedRecovery?: boolean;
|
|
347
|
+
/**
|
|
348
|
+
* The declared per-repo ENV-PREP step (string | list), run ONCE before the
|
|
349
|
+
* FIRST `verify` on a fresh worktree to make the env ready (install deps,
|
|
350
|
+
* submodules, codegen). Unset ⇒ a no-op (NO default install — the deliberate
|
|
351
|
+
* difference from `verify`). Sequenced BEFORE `verify`; a failing prepare
|
|
352
|
+
* surfaces as `prepare-failed` and NEVER proceeds to `verify`/integrate. Skip
|
|
353
|
+
* is gated by a NON-COMMITTED prepared-ness marker in the worktree's git
|
|
354
|
+
* control area (`prepare.ts`), so it does not re-install per gate within one
|
|
355
|
+
* worktree. Honoured on the build paths (`do`/`run` fresh worktrees) +
|
|
356
|
+
* `complete`; the standalone `dorfl verify` CLI does NOT run it (the
|
|
357
|
+
* pure gate — a human prepares their own checkout).
|
|
358
|
+
*/
|
|
359
|
+
prepare?: VerifyConfig;
|
|
360
|
+
/** The declared per-repo gate (string | list). Unset ⇒ the default command. */
|
|
361
|
+
verify?: VerifyConfig;
|
|
362
|
+
/**
|
|
363
|
+
* **The fresh-worktree gate toggle** (config `freshWorktreeGate`, ON by
|
|
364
|
+
* default). When `true`, the acceptance gate (`prepare` then `verify`) runs in
|
|
365
|
+
* a CLEAN throwaway worktree cut from the work branch REBASED onto the latest
|
|
366
|
+
* `<arbiter>/main` (the would-be-integrated tip) — so a green gate provably
|
|
367
|
+
* describes the MERGED artifact: gitignored/uncommitted state in this `cwd`
|
|
368
|
+
* cannot leak in (the worktree is cut from the committed, rebased tip), and a
|
|
369
|
+
* change the integration rebase introduces IS gated. The band then does the
|
|
370
|
+
* done-move + commit, rebases, runs the gate in the fresh worktree, reaps it,
|
|
371
|
+
* and only on green integrates. When `false` (or unset ⇒ treated as the
|
|
372
|
+
* caller's default; the CLI resolves the default to `true`), `prepare`+`verify`
|
|
373
|
+
* run in THIS `cwd` BEFORE the done-move exactly as before (the pre-rebase
|
|
374
|
+
* gate), byte-for-byte. The band simply HONOURS this boolean (caller-agnostic);
|
|
375
|
+
* the `run`-fleet downgrade at `perRepoMax > 1` lives in the `run` caller, NOT
|
|
376
|
+
* here. A `--skip-verify` skips the gate ENTIRELY regardless of this flag.
|
|
377
|
+
*/
|
|
378
|
+
freshWorktreeGate?: boolean;
|
|
379
|
+
/** Skip the acceptance gate (human-only escape hatch; never used unattended). */
|
|
380
|
+
skipVerify?: boolean;
|
|
381
|
+
/** Run Gate 2 (the PR/code review gate) after the green `verify`. Default OFF. */
|
|
382
|
+
review?: boolean;
|
|
383
|
+
/** The review-gate SEAM (injectable). Required when `review` is on. */
|
|
384
|
+
reviewGate?: ReviewGate;
|
|
385
|
+
/** The model the REVIEW agent runs on (de-correlated from the builder). */
|
|
386
|
+
reviewModel?: string;
|
|
387
|
+
/** Bound the revise↔review loop (Gate 2). Defaults to 2. */
|
|
388
|
+
reviewMaxRounds?: number;
|
|
389
|
+
/** Integration mode the caller REQUESTED (`propose` default, or `merge`). */
|
|
390
|
+
mode: IntegrationMode;
|
|
391
|
+
/**
|
|
392
|
+
* **The UNTRUSTED-ORIGIN build-propose rule** (task
|
|
393
|
+
* `untrusted-origin-forces-build-propose`). When `true`, the operator EXPLICITLY
|
|
394
|
+
* passed `--merge` on this invocation — which OVERRIDES the untrusted-origin
|
|
395
|
+
* `propose` default (the operator is present; CLI always wins, no special
|
|
396
|
+
* force-key). The autonomous/CI build path passes no flag ⇒ leaves this unset ⇒
|
|
397
|
+
* an untrusted-origin task reliably forces `propose`. Resolved entirely from the
|
|
398
|
+
* task's stamped `originTrust:` frontmatter (read HERE from the build source
|
|
399
|
+
* file); a `trusted`/unset task is config-as-is (ZERO behaviour change). This
|
|
400
|
+
* rule touches the task BUILD transition ONLY: it never fires when
|
|
401
|
+
* {@link lifecycle} is set (the tasking/intake-emit transitions — a file landing
|
|
402
|
+
* on main is inert) nor on {@link committedRecovery} (already gated + moved).
|
|
403
|
+
*/
|
|
404
|
+
explicitMerge?: boolean;
|
|
405
|
+
/**
|
|
406
|
+
* **The PR-INTENT axis** (config `noPR`, ADR §6). When `true` on the `propose`
|
|
407
|
+
* path, the branch is pushed (the safety-bearing step) but NO review request is
|
|
408
|
+
* opened (the explicit suppress-PR intent that re-homes the old `provider: none`
|
|
409
|
+
* use) — no warning, the no-PR outcome is intended. It does NOT pick a provider
|
|
410
|
+
* (`selectProvider` stays purely arbiter-derived); it is an intent LAYERED on
|
|
411
|
+
* top. Ignored in `merge` mode (it never opens a PR) and when `providerInstance`
|
|
412
|
+
* is injected (the test/embedding seam decides its own provider). Unset/false ⇒
|
|
413
|
+
* propose opens the PR via the arbiter-derived provider as normal.
|
|
414
|
+
*/
|
|
415
|
+
noPR?: boolean;
|
|
416
|
+
/**
|
|
417
|
+
* Optional FULLY-FORMED review provider to use VERBATIM (highest precedence,
|
|
418
|
+
* above `openPr` and the arbiter-derived selection). The `run` path injects a
|
|
419
|
+
* stubbed `GitHubProvider` here in tests (a custom `gh` binary path) to drive
|
|
420
|
+
* the full propose pipeline incl. title/body/url — which the lossy `openPr`
|
|
421
|
+
* bridge cannot carry. Unset by `do`/`complete` (they use the arbiter-derived
|
|
422
|
+
* selection / `openPr`), so their behaviour is unchanged.
|
|
423
|
+
*/
|
|
424
|
+
providerInstance?: ReviewProvider;
|
|
425
|
+
/** Optional injectable PR opener (legacy bridge); used in `propose` mode. */
|
|
426
|
+
openPr?: (opts: {
|
|
427
|
+
cwd: string;
|
|
428
|
+
branch: string;
|
|
429
|
+
env?: NodeJS.ProcessEnv;
|
|
430
|
+
}) => void;
|
|
431
|
+
/**
|
|
432
|
+
* Optional review-request BODY (propose mode) — advisory prose. The runner
|
|
433
|
+
* scaffolds a deterministic header (a pointer to `work/done/<slug>.md`) above
|
|
434
|
+
* it. Absent ⇒ today's `gh pr create --fill` (no regression).
|
|
435
|
+
*/
|
|
436
|
+
body?: string;
|
|
437
|
+
/** Conventional-commit type for the completion commit. Defaults to `feat`. */
|
|
438
|
+
type?: string;
|
|
439
|
+
/** Commit summary. Defaults to the task `title` minus a leading `slug — `. */
|
|
440
|
+
message?: string;
|
|
441
|
+
/**
|
|
442
|
+
* Surface a needs-attention bounce ON THE ARBITER (the AUTONOMOUS variant). When
|
|
443
|
+
* set, the failure routings (`gate-failed` / `review-blocked` / `rebase-conflict`)
|
|
444
|
+
* pass this arbiter remote into the ledger write seam's
|
|
445
|
+
* `applyNeedsAttentionTransition` (which cherry-picks the move onto `main` +
|
|
446
|
+
* pushes the branch — observable + recoverable cross-machine). Unset ⇒ the human
|
|
447
|
+
* `complete` behaviour: route LOCALLY only.
|
|
448
|
+
*/
|
|
449
|
+
surfaceArbiter?: string;
|
|
450
|
+
/** `--watch`: tail the Gate-2 review agent's session live. Observability only. */
|
|
451
|
+
watch?: boolean;
|
|
452
|
+
/** Where the tailed review lines are written (defaults to stderr). */
|
|
453
|
+
watchSink?: (line: string) => void;
|
|
454
|
+
/** Emit ANSI colour in the tailed review lines (the caller's TTY decision). */
|
|
455
|
+
color?: boolean;
|
|
456
|
+
/** The HOST-ONLY sessions root the review session FILE is generated under. */
|
|
457
|
+
sessionsDir?: string;
|
|
458
|
+
/** Environment for child GIT/provider processes (the identity-scoped env). */
|
|
459
|
+
env?: NodeJS.ProcessEnv;
|
|
460
|
+
/**
|
|
461
|
+
* Optional per-repo INTEGRATE serialiser (the `run` concurrency seam, sibling
|
|
462
|
+
* of the claim lock). When set, ONLY the rebase-to-integrate TAIL (step 4
|
|
463
|
+
* fetch+rebase through step 5 integrate) runs inside `integrateLock(key, fn)`,
|
|
464
|
+
* so two concurrent SAME-repo merge jobs land on `main` one-at-a-time: the
|
|
465
|
+
* loser re-fetches + rebases onto the winner's now-advanced `<arbiter>/main`
|
|
466
|
+
* INSIDE the lock, making its `${branch}:main` a clean fast-forward (a genuine
|
|
467
|
+
* code conflict then routes ONE to needs-attention, never both-land by timing).
|
|
468
|
+
* The front-of-band `prepare`+`verify` gate and the Gate-2 review agent run
|
|
469
|
+
* OUTSIDE the lock, so same-repo jobs still gate + review CONCURRENTLY (run's
|
|
470
|
+
* parallelism is preserved). Single-job callers (`do`/`--isolated`/`--remote`/
|
|
471
|
+
* `complete`) leave it unset ⇒ the tail runs directly, byte-for-byte unchanged.
|
|
472
|
+
* Keyed per repo (see {@link integrateLockKey}), so cross-repo integration
|
|
473
|
+
* stays fully concurrent.
|
|
474
|
+
*/
|
|
475
|
+
integrateLock?: <T>(key: string, fn: () => Promise<T>) => Promise<T>;
|
|
476
|
+
/**
|
|
477
|
+
* The key {@link integrateLock} serialises on — the repo path (the SAME key the
|
|
478
|
+
* claim lock uses), so same-repo integrations serialise while distinct repos
|
|
479
|
+
* integrate concurrently. Ignored when {@link integrateLock} is unset.
|
|
480
|
+
*/
|
|
481
|
+
integrateLockKey?: string;
|
|
482
|
+
/**
|
|
483
|
+
* **Race-1 (claim-vs-integrate) bounded re-rebase-and-retry cap** for the
|
|
484
|
+
* merge-mode `${branch}:main` push (task
|
|
485
|
+
* `run-fleet-claim-integrate-and-sibling-rebase-concurrency-safe`). On a
|
|
486
|
+
* non-fast-forward rejection (a sibling same-repo CLAIM — under the SEPARATE
|
|
487
|
+
* claim lock — or integrate advanced `<arbiter>/main` during this job's push
|
|
488
|
+
* window) the step-4 tail RE-RUNS its rebase (reconciling sibling-ledger
|
|
489
|
+
* divergence) and RETRIES the push up to this cap. The `integrateLock` only
|
|
490
|
+
* serialises sibling INTEGRATES; a sibling CLAIM is on a DIFFERENT lock, so this
|
|
491
|
+
* retry (not the lock) is what makes claim-vs-integrate deterministic. Absent ⇒
|
|
492
|
+
* {@link DEFAULT_MERGE_RETRIES}. Tests inject a small cap (or `0` to assert the
|
|
493
|
+
* un-retried non-fast-forward route).
|
|
494
|
+
*
|
|
495
|
+
* **C2 rebase-until-real-conflict (task `c2-rebase-until-real-on-durable-main-
|
|
496
|
+
* promotions`):** the SEMANTICS changed — a CLEAN re-rebase no longer counts
|
|
497
|
+
* against a give-up budget; only a GENUINE conflict surfaced by the rebase
|
|
498
|
+
* (`rebaseConflictRoute` / `invariant-violation`) stops the loop. This value
|
|
499
|
+
* now serves as a LARGE liveness ceiling (default {@link DEFAULT_MERGE_RETRIES}
|
|
500
|
+
* = 1000) that bounds the pathological livelock tail, not as a small Race-1
|
|
501
|
+
* contention budget. The test seam is preserved: a small explicit cap (or `0`)
|
|
502
|
+
* still forces the un-retried / cap-exhausted route.
|
|
503
|
+
*/
|
|
504
|
+
mergeRetries?: number;
|
|
505
|
+
/**
|
|
506
|
+
* Modest jitter (ms) on the refetch between merge-push retries — load-bearing
|
|
507
|
+
* under sustained parallel load (desynchronises a thundering-herd lockstep,
|
|
508
|
+
* task `c2-rebase-until-real-on-durable-main-promotions`). Each attempt sleeps
|
|
509
|
+
* a uniformly-random integer in `[0, mergeJitterMs]` ms. Defaults to
|
|
510
|
+
* {@link DEFAULT_MERGE_JITTER_MS} (25ms); tests can pass `0` for deterministic,
|
|
511
|
+
* latency-free retries.
|
|
512
|
+
*/
|
|
513
|
+
mergeJitterMs?: number;
|
|
514
|
+
/**
|
|
515
|
+
* **Committed-recovery rebase RE-FETCH+RE-REBASE cap** (task `recovery-rebase-
|
|
516
|
+
* retry-against-moving-arbiter-main`). The recovery tail
|
|
517
|
+
* ({@link recoverAlreadyCommitted}) wraps its rebase onto `<arbiter>/main` in a
|
|
518
|
+
* bounded CONTENTION loop: on a conflicting rebase it `--abort`s, re-fetches
|
|
519
|
+
* `<arbiter>/main` (it may have advanced — `advance` runs land bursts of
|
|
520
|
+
* observation commits on main), and re-rebases, up to this cap of ADDITIONAL
|
|
521
|
+
* attempts after the first one. Only after the cap is exhausted does it return
|
|
522
|
+
* `rebase-conflict` exactly as today. Absent ⇒ {@link DEFAULT_RECOVERY_REBASE_
|
|
523
|
+
* RETRIES}. Tests inject `0` for the legacy one-shot shape, or a small explicit
|
|
524
|
+
* cap to assert the cap-exhausted route.
|
|
525
|
+
*/
|
|
526
|
+
recoveryRebaseRetries?: number;
|
|
527
|
+
/**
|
|
528
|
+
* **Committed-recovery rebase JITTER (max ms)** between attempts — a SMALL
|
|
529
|
+
* livelock-breaking SPREAD, NOT exponential outage backoff. Each post-conflict
|
|
530
|
+
* sleep is a uniformly-random integer in `[0, recoveryRebaseJitterMs]` ms
|
|
531
|
+
* (drawn via the injectable {@link recoveryRebaseRandom}). Defaults to
|
|
532
|
+
* {@link DEFAULT_RECOVERY_REBASE_JITTER_MS}; tests pass `0` for a deterministic
|
|
533
|
+
* zero-delay schedule.
|
|
534
|
+
*/
|
|
535
|
+
recoveryRebaseJitterMs?: number;
|
|
536
|
+
/**
|
|
537
|
+
* **Injectable sleep seam** for the committed-recovery rebase retry loop —
|
|
538
|
+
* reuses the {@link Sleep} type from `retry-backoff.ts` (the same seam
|
|
539
|
+
* `run.ts` / `needs-attention.ts` use). Defaults to {@link realSleep}; tests
|
|
540
|
+
* inject a capturing zero-sleep to assert the per-attempt delay schedule and to
|
|
541
|
+
* drive a moving-base scenario between attempts.
|
|
542
|
+
*/
|
|
543
|
+
recoveryRebaseSleep?: Sleep;
|
|
544
|
+
/**
|
|
545
|
+
* **Injectable RNG** for the recovery-rebase jitter — `() => number` returning
|
|
546
|
+
* `[0, 1)` (same shape as `Math.random`). Defaults to `Math.random`; tests
|
|
547
|
+
* inject a seeded RNG (or a fixed constant) so the captured jitter timeline is
|
|
548
|
+
* reproducible.
|
|
549
|
+
*/
|
|
550
|
+
recoveryRebaseRandom?: () => number;
|
|
551
|
+
/**
|
|
552
|
+
* Environment for the REVIEW-AGENT launch (Gate 2). Distinct from {@link env}
|
|
553
|
+
* because the review agent is an AGENT — it must NOT carry the runner identity
|
|
554
|
+
* (the agent must not act/commit as the bot; only the runner's own git
|
|
555
|
+
* transitions do). The caller passes the plain AMBIENT env here when an identity
|
|
556
|
+
* is configured. Unset ⇒ falls back to {@link env} (no identity ⇒ they are the
|
|
557
|
+
* same env, so this is byte-for-byte unchanged for every non-identity caller).
|
|
558
|
+
*/
|
|
559
|
+
agentEnv?: NodeJS.ProcessEnv;
|
|
560
|
+
/** Sink for human-readable progress notes. */
|
|
561
|
+
note?: (message: string) => void;
|
|
562
|
+
/**
|
|
563
|
+
* A NON-TASK lifecycle move + title source (task
|
|
564
|
+
* `slice-output-through-integration`). When set, the band reads the title from
|
|
565
|
+
* its {@link IntegrationLifecycle.titlePath}, calls its
|
|
566
|
+
* {@link IntegrationLifecycle.stage} INSTEAD of the task `git mv → work/done/`,
|
|
567
|
+
* and uses the plain rebase ({@link recovering} is irrelevant). The `do prd:`
|
|
568
|
+
* TASKING transition supplies it; `do`/`complete`/`run` leave it unset (the
|
|
569
|
+
* task done-move is unchanged).
|
|
570
|
+
*/
|
|
571
|
+
lifecycle?: IntegrationLifecycle;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* The CORE's output — pure DATA. The core performed the gate/review/move/commit/
|
|
576
|
+
* rebase/integrate AND the failure routing, but NOT the caller-specific tail.
|
|
577
|
+
*/
|
|
578
|
+
export interface IntegrationCoreResult {
|
|
579
|
+
/** What the core resolved (the tail maps this 1:1 onto its own outcome). */
|
|
580
|
+
outcome: IntegrationCoreOutcome;
|
|
581
|
+
/**
|
|
582
|
+
* True iff a FAILURE outcome was routed to `work/needs-attention/` via the
|
|
583
|
+
* shared mechanism. False on the success path and when a failure was NOT moved
|
|
584
|
+
* (e.g. a red re-gate that stays put in recovery).
|
|
585
|
+
*/
|
|
586
|
+
routedToNeedsAttention: boolean;
|
|
587
|
+
/** The work branch that was processed (`work/<slug>`). */
|
|
588
|
+
branch: string;
|
|
589
|
+
/**
|
|
590
|
+
* A human-readable summary of a FAILURE terminal condition, ready for the tail
|
|
591
|
+
* to surface VERBATIM. Absent on the success path (the tail composes the
|
|
592
|
+
* success message from the integration result + its own switch/delete state).
|
|
593
|
+
*/
|
|
594
|
+
reason?: string;
|
|
595
|
+
/** The completion commit message that was authored, on success. */
|
|
596
|
+
commitMessage?: string;
|
|
597
|
+
/**
|
|
598
|
+
* The integration result — carries the resolved mode + the PR url. Present on
|
|
599
|
+
* the success path; the tail reads `integration.mode` to decide its switch/ff
|
|
600
|
+
* behaviour, NEVER the requested mode.
|
|
601
|
+
*/
|
|
602
|
+
integration?: IntegrateResult;
|
|
603
|
+
/**
|
|
604
|
+
* On a `review-blocked` outcome, the review gate's STRUCTURED block reason (the
|
|
605
|
+
* `formatBlockReason` of the blocking findings) — so a caller doing its OWN
|
|
606
|
+
* needs-attention routing can record the findings as the item-body prose. The
|
|
607
|
+
* tasking path (task `slice-acceptance-gate`) reads this: the core's build
|
|
608
|
+
* `applyNeedsAttentionTransition` is keyed on a TASK lock, so the tasking path
|
|
609
|
+
* routes the prd itself via the lock release's needs-attention redirect (amend the
|
|
610
|
+
* `prd:<slug>` unified lock `active -> stuck`, no folder move), using THIS
|
|
611
|
+
* findings text as the body. Absent on every non-`review-blocked` outcome. (The
|
|
612
|
+
* build path ignores it — its routing already records the findings in-body.)
|
|
613
|
+
*/
|
|
614
|
+
reviewBlockReason?: string;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const DEFAULT_TYPE = 'feat';
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Run the shared gate→integrate band for a prepared (claimed, built, on-its-work-
|
|
621
|
+
* branch) item. The HEAD has already resolved `cwd`/`arbiter`/`slug`/`source`/
|
|
622
|
+
* `recovering`; this runs the gate, the review gate, the effective-mode decision,
|
|
623
|
+
* the done-move + atomic commit + rebase + integrate, and routes ANY failure to
|
|
624
|
+
* needs-attention — returning pure DATA for the caller's tail.
|
|
625
|
+
*
|
|
626
|
+
* It never throws for the expected gate-failed / review-blocked / rebase-conflict
|
|
627
|
+
* cases (those are returned with the corresponding outcome). A genuinely
|
|
628
|
+
* unexpected git plumbing failure (e.g. the done-move itself, or nothing staged)
|
|
629
|
+
* throws — the caller's existing try/catch maps it.
|
|
630
|
+
*/
|
|
631
|
+
export async function performIntegration(
|
|
632
|
+
input: IntegrationCoreInput,
|
|
633
|
+
): Promise<IntegrationCoreResult> {
|
|
634
|
+
const cwd = input.cwd;
|
|
635
|
+
const env = input.env;
|
|
636
|
+
const arbiter = input.arbiter;
|
|
637
|
+
const slug = input.slug;
|
|
638
|
+
const source = input.source;
|
|
639
|
+
const recovering = input.recovering;
|
|
640
|
+
const note = input.note ?? (() => {});
|
|
641
|
+
// The work branch: the caller is on it (it carries the namespaced identity).
|
|
642
|
+
// Prefer the explicit `branch`; else read the branch HEAD is on; only fall
|
|
643
|
+
// back to a synthesised `work/task-<slug>` if HEAD is detached (a degenerate
|
|
644
|
+
// case that the on-branch invariant should preclude).
|
|
645
|
+
const branch = input.branch ?? resolveWorkBranch(cwd, slug, env);
|
|
646
|
+
// Captured from the Gate-2 review (when it runs + approves) so that AFTER the
|
|
647
|
+
// propose integrate — where the opened PR url is finally in scope — we can post
|
|
648
|
+
// the agent's deliberately-authored `review` prose as a PR comment (task
|
|
649
|
+
// `review-comment-prose-field`). Stays undefined when review is off / the agent
|
|
650
|
+
// emitted no `review` field, so the post is skipped (no-op). The verdict/routing
|
|
651
|
+
// decision uses neither.
|
|
652
|
+
let approvedVerdict: ReviewVerdict | undefined;
|
|
653
|
+
// The resolved integration mode. `merge` lands automatically on a green gate
|
|
654
|
+
// (and an `approve` when review is on); `propose` leaves the merge to a human.
|
|
655
|
+
// MUTABLE because the untrusted-origin build-propose rule below may force it to
|
|
656
|
+
// `propose` for a task BUILD (the build transition only) — see the rule after
|
|
657
|
+
// `sourcePath` is resolved.
|
|
658
|
+
let mode = input.mode;
|
|
659
|
+
// The fresh-worktree gate (task `gate-on-rebased-tip-fresh-worktree`): when ON
|
|
660
|
+
// the deterministic acceptance gate (`prepare`+`verify`) does NOT run here on
|
|
661
|
+
// the agent's PRE-rebase `cwd`; instead it runs LATER, in a clean throwaway
|
|
662
|
+
// worktree cut from the work branch REBASED onto `<arbiter>/main` (the
|
|
663
|
+
// would-be-integrated tip), inside the rebase-to-integrate tail. So a green gate
|
|
664
|
+
// provably describes the MERGED artifact. When OFF the front prepare+verify runs
|
|
665
|
+
// here exactly as before (byte-for-byte). The band HONOURS the boolean it is
|
|
666
|
+
// handed (caller-agnostic); the `run`-fleet `perRepoMax === 1` downgrade lives
|
|
667
|
+
// in the `run` caller, not here.
|
|
668
|
+
const freshWorktreeGate = input.freshWorktreeGate === true;
|
|
669
|
+
|
|
670
|
+
// RECOVER an already-committed, already-done-moved STRANDED branch (prd
|
|
671
|
+
// `ledger-integrity` story 6). The work + the done-move are ALREADY committed on
|
|
672
|
+
// the work branch (a terminal push failed AFTER steps 2–3); SKIP steps 0–3
|
|
673
|
+
// (prepare / gate / review / done-move / commit) and run ONLY the
|
|
674
|
+
// rebase→integrate TAIL from the kept commit, reusing the SAME integrate
|
|
675
|
+
// primitive. Returns BEFORE any of the build-path steps run.
|
|
676
|
+
if (input.committedRecovery) {
|
|
677
|
+
return await recoverAlreadyCommitted({
|
|
678
|
+
cwd,
|
|
679
|
+
arbiter,
|
|
680
|
+
slug,
|
|
681
|
+
branch,
|
|
682
|
+
mode,
|
|
683
|
+
noPR: input.noPR,
|
|
684
|
+
providerInstance: input.providerInstance,
|
|
685
|
+
openPr: input.openPr,
|
|
686
|
+
recoveryRebaseRetries: input.recoveryRebaseRetries,
|
|
687
|
+
recoveryRebaseJitterMs: input.recoveryRebaseJitterMs,
|
|
688
|
+
recoveryRebaseSleep: input.recoveryRebaseSleep,
|
|
689
|
+
recoveryRebaseRandom: input.recoveryRebaseRandom,
|
|
690
|
+
// Answered-merge land (task `committed-recovery-honours-fresh-worktree-gate`,
|
|
691
|
+
// prd `land-time-reverify-and-parallel-merge-ceiling`): the apply-rung
|
|
692
|
+
// dispatches an answered `merge` through this committed-recovery tail (the
|
|
693
|
+
// branch already carries its done-move commit, so the build path's
|
|
694
|
+
// `git mv`+`add -A`+commit would raise `IntegrationNothingStaged`). UNLIKE
|
|
695
|
+
// the original stranded-recovery caller (whose pre-strand build already
|
|
696
|
+
// gated), `<arbiter>/main` may have MOVED since this branch's last build,
|
|
697
|
+
// so the rebased tip MUST be re-verified before it lands or the load-bearing
|
|
698
|
+
// invariant ("main never receives a tree that fails verify") cannot hold on
|
|
699
|
+
// the merge path. Thread the gate inputs through; recovery runs the EXISTING
|
|
700
|
+
// `runFreshWorktreeGate` on the rebased tip when `freshWorktreeGate` is set
|
|
701
|
+
// (and not `--skip-verify`), routing a RED gate to needs-attention via the
|
|
702
|
+
// SAME seam the build path uses. With `freshWorktreeGate` unset (the
|
|
703
|
+
// stranded-recovery caller), recovery is byte-identical to before — no
|
|
704
|
+
// extra gate, no extra fetch.
|
|
705
|
+
freshWorktreeGate,
|
|
706
|
+
skipVerify: input.skipVerify,
|
|
707
|
+
prepare: input.prepare,
|
|
708
|
+
verify: input.verify,
|
|
709
|
+
surfaceArbiter: input.surfaceArbiter,
|
|
710
|
+
env,
|
|
711
|
+
note,
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// The file whose `title:` seeds the commit summary + PR title. For a TASKING
|
|
716
|
+
// transition (a non-task `lifecycle`) this is the held prd it supplies; for a
|
|
717
|
+
// build it is the task in its source folder. Read BEFORE any move.
|
|
718
|
+
const lifecycle = input.lifecycle;
|
|
719
|
+
// CONTINUE-BUILD (`source: 'done'`, task
|
|
720
|
+
// `complete-builds-on-already-done-moved-continue`): on a dirty continue whose
|
|
721
|
+
// kept branch already holds the slug in `work/done/`, the file we read for the
|
|
722
|
+
// title + (otherwise) untrusted-origin lives there too — not in
|
|
723
|
+
// `in-progress/`/`needs-attention/`. The step-2 `git mv` is later SKIPPED for
|
|
724
|
+
// this source (the slug is already in done/), and the originTrust read +
|
|
725
|
+
// arbiter ledger placement/divergent-done-move reconcile are EXEMPTED below.
|
|
726
|
+
const sourcePath = lifecycle
|
|
727
|
+
? lifecycle.titlePath
|
|
728
|
+
: source === 'done'
|
|
729
|
+
? workItemPath(cwd, 'done', slug)
|
|
730
|
+
: workItemPath(cwd, source, slug);
|
|
731
|
+
|
|
732
|
+
// UNTRUSTED-ORIGIN BUILD-PROPOSE RULE (task `untrusted-origin-forces-build-propose`).
|
|
733
|
+
// A task born from an UNTRUSTED issue carries `originTrust: untrusted` (stamped
|
|
734
|
+
// at intake, propagated by the tasker). Its risk is the BUILD (it becomes code),
|
|
735
|
+
// so the build transition resolves to `propose` even when the requested mode is
|
|
736
|
+
// `merge` — moving the human checkpoint onto the becomes-code build. Precedence:
|
|
737
|
+
// explicit --merge > untrusted-origin ⇒ propose > config mode > default.
|
|
738
|
+
// An explicit `--merge` (input.explicitMerge) OVERRIDES the rule (the operator
|
|
739
|
+
// is present; CLI always wins, no special force-key). The autonomous/CI path
|
|
740
|
+
// passes no flag, so there an untrusted-origin task RELIABLY forces propose.
|
|
741
|
+
//
|
|
742
|
+
// SCOPE: the task BUILD transition ONLY. It NEVER fires for a `lifecycle`
|
|
743
|
+
// transition (tasking / intake-emit — a prd/task FILE landing on main is inert;
|
|
744
|
+
// intake's OWN per-emit resolver already decided that mode), and the source file
|
|
745
|
+
// here is the task being built. A `trusted`/unset task ⇒ untouched (zero
|
|
746
|
+
// behaviour change for the normal human path).
|
|
747
|
+
// CONTINUE-BUILD EXEMPTION (task
|
|
748
|
+
// `complete-builds-on-already-done-moved-continue`): the prior attempt that
|
|
749
|
+
// done-moved this task already went through the originTrust checkpoint (an
|
|
750
|
+
// untrusted task on a `merge` config proposed on that first attempt). The
|
|
751
|
+
// continue-build commit is layered on top of that kept tip, so re-evaluating
|
|
752
|
+
// the rule here would either double-checkpoint or be a no-op against the same
|
|
753
|
+
// frontmatter — exempt this state (the task file lives in `done/` and is now
|
|
754
|
+
// effectively an on-`main` artifact). The other states are unchanged.
|
|
755
|
+
if (
|
|
756
|
+
!lifecycle &&
|
|
757
|
+
source !== 'done' &&
|
|
758
|
+
mode === 'merge' &&
|
|
759
|
+
input.explicitMerge !== true &&
|
|
760
|
+
existsSync(sourcePath) &&
|
|
761
|
+
parseFrontmatter(readFileSync(sourcePath, 'utf8')).originTrust ===
|
|
762
|
+
'untrusted'
|
|
763
|
+
) {
|
|
764
|
+
mode = 'propose';
|
|
765
|
+
note(
|
|
766
|
+
`Untrusted-origin task '${slug}': forcing the BUILD transition to ` +
|
|
767
|
+
'propose (a human reviews the becomes-code change before it merges). ' +
|
|
768
|
+
'Pass --merge to override.',
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// 0. Prepare: make the worktree's ENV READY before the gate (install deps,
|
|
773
|
+
// submodules, codegen). A fresh job worktree off the hub mirror has no
|
|
774
|
+
// `node_modules`, so `verify` would fail for lack of deps unless prepare
|
|
775
|
+
// runs FIRST. `prepare` is the SIBLING of `verify`, NOT baked into it: it
|
|
776
|
+
// runs ONCE before the first verify, gated by a NON-COMMITTED prepared-ness
|
|
777
|
+
// marker in the worktree's git control area (so it does not re-install per
|
|
778
|
+
// gate within one persistent worktree). Unset ⇒ a no-op (no default install).
|
|
779
|
+
// A FAILING prepare is a HARD STOP distinct from a red gate (`prepare-failed`):
|
|
780
|
+
// the env could not be made ready, so `verify` cannot be trusted — it NEVER
|
|
781
|
+
// proceeds to verify/integrate, and routes the item the SAME way a red gate
|
|
782
|
+
// does. (`--skip-verify` skips only the gate, not env-prep: a verify-skipped
|
|
783
|
+
// finish still needs a ready env; the marker keeps an already-prepared tree
|
|
784
|
+
// a no-op.)
|
|
785
|
+
//
|
|
786
|
+
// FRESH-WORKTREE GATE: when ON, this front prepare+verify is SKIPPED here and
|
|
787
|
+
// runs LATER on the rebased-tip throwaway worktree (see the tail). The OFF
|
|
788
|
+
// path below is byte-for-byte today's pre-rebase gate.
|
|
789
|
+
if (!freshWorktreeGate) {
|
|
790
|
+
const prep = await ensurePrepared({cwd, prepare: input.prepare, env});
|
|
791
|
+
if (!prep.noop && !prep.skipped) {
|
|
792
|
+
note('Running the env-prep step (prepare)…');
|
|
793
|
+
}
|
|
794
|
+
if (!prep.passed) {
|
|
795
|
+
const reason = `prepare (env-prep) failed (exit ${prep.exitCode})`;
|
|
796
|
+
// Bounce from in-progress/ straight to needs-attention/ (the SAME seam a red
|
|
797
|
+
// gate uses) — recording the prepare-failed reason + committing the move
|
|
798
|
+
// (with the agent's uncommitted work) as ONE atomic transition. We NEVER
|
|
799
|
+
// run `verify` on an env that could not be made ready.
|
|
800
|
+
const routed = await ledgerWrite.applyNeedsAttentionTransition({
|
|
801
|
+
cwd,
|
|
802
|
+
slug,
|
|
803
|
+
reason,
|
|
804
|
+
arbiter: input.surfaceArbiter,
|
|
805
|
+
env,
|
|
806
|
+
note,
|
|
807
|
+
});
|
|
808
|
+
return {
|
|
809
|
+
outcome: 'prepare-failed',
|
|
810
|
+
routedToNeedsAttention: routed.moved,
|
|
811
|
+
branch,
|
|
812
|
+
reason: routed.moved
|
|
813
|
+
? `Env-prep (prepare) failed (exit ${prep.exitCode}); routed '${slug}' ` +
|
|
814
|
+
'to work/needs-attention/ (the environment could not be made ready, ' +
|
|
815
|
+
'so the acceptance gate was NOT run). Fix the prepare command, then ' +
|
|
816
|
+
'return it to backlog/.'
|
|
817
|
+
: `Env-prep (prepare) failed (exit ${prep.exitCode}); not completing ` +
|
|
818
|
+
`'${slug}' (the environment could not be made ready, so the ` +
|
|
819
|
+
'acceptance gate was NOT run). Fix the prepare command, then retry.',
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// 1. Gate: bad work never proceeds to done. Default-on; --skip-verify is a
|
|
825
|
+
// human-only escape hatch (the autonomous runner never skips — ADR §8).
|
|
826
|
+
// FRESH-WORKTREE GATE: when ON this front gate is SKIPPED and runs LATER on
|
|
827
|
+
// the rebased-tip throwaway worktree (see the tail); the OFF branch below is
|
|
828
|
+
// byte-for-byte today's pre-rebase gate.
|
|
829
|
+
if (input.skipVerify) {
|
|
830
|
+
note('Skipping the acceptance gate (--skip-verify).');
|
|
831
|
+
} else if (!freshWorktreeGate) {
|
|
832
|
+
note('Running the acceptance gate (verify)…');
|
|
833
|
+
const gate = await runVerify({cwd, verify: input.verify, env});
|
|
834
|
+
if (!gate.passed) {
|
|
835
|
+
// Don't leave the item dangling in in-progress/: route it to
|
|
836
|
+
// needs-attention/ with the reason (ADR §12) THROUGH the ledger write
|
|
837
|
+
// seam's needs-attention transition. The item has NOT been committed/moved
|
|
838
|
+
// yet, so the move bounces it straight from in-progress/ — recording the
|
|
839
|
+
// reason + committing the move (with the agent's uncommitted work) as ONE
|
|
840
|
+
// atomic transition. No partial state.
|
|
841
|
+
const reason = `acceptance gate failed (exit ${gate.exitCode})`;
|
|
842
|
+
const routed = await ledgerWrite.applyNeedsAttentionTransition({
|
|
843
|
+
cwd,
|
|
844
|
+
slug,
|
|
845
|
+
reason,
|
|
846
|
+
// Autonomous caller (`do`) passes the arbiter so the seam both SURFACES
|
|
847
|
+
// the stuck state on `main` (OBSERVABLE, cross-machine visible) AND
|
|
848
|
+
// pushes the `work/<slug>` branch (RECOVERABLE — a requeue-continue on
|
|
849
|
+
// another machine, reading <arbiter>/work/<slug>, lands on the saved
|
|
850
|
+
// wip). The human `complete` leaves it unset → no surface, no push,
|
|
851
|
+
// local-only (a human is right there). One operation in one place: the
|
|
852
|
+
// push lives in the seam (it is HEAD's branch — `work/<slug>` — here),
|
|
853
|
+
// no bolted-on push to forget.
|
|
854
|
+
arbiter: input.surfaceArbiter,
|
|
855
|
+
env,
|
|
856
|
+
note,
|
|
857
|
+
});
|
|
858
|
+
return {
|
|
859
|
+
outcome: 'gate-failed',
|
|
860
|
+
routedToNeedsAttention: routed.moved,
|
|
861
|
+
branch,
|
|
862
|
+
reason: routed.moved
|
|
863
|
+
? `Acceptance gate failed (exit ${gate.exitCode}); routed '${slug}' ` +
|
|
864
|
+
'to work/needs-attention/ (surfaced by status; return to backlog/ ' +
|
|
865
|
+
'once resolved). Fix the work, or use --skip-verify to override.'
|
|
866
|
+
: `Acceptance gate failed (exit ${gate.exitCode}); not completing ` +
|
|
867
|
+
`'${slug}'. Fix the work, or use --skip-verify to override.`,
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// 1b. Gate 2 — the PR/code REVIEW gate (GATES prd `work/prds/tasked/review.md`). It is a
|
|
873
|
+
// JUDGEMENT gate layered ON TOP of the deterministic `verify` floor (ADR §8)
|
|
874
|
+
// — NEVER a replacement, and ALWAYS verify-THEN-review on the SAME tree.
|
|
875
|
+
//
|
|
876
|
+
// WHERE it runs depends on the fresh-worktree gate (MAINTAINER DECISION 2):
|
|
877
|
+
// - OFF: `verify` ran HERE on the pre-rebase `cwd`, so the review runs HERE
|
|
878
|
+
// too, on `cwd`, BEFORE the done-move — byte-for-byte today's order.
|
|
879
|
+
// - ON: the front `verify` was SKIPPED here and runs LATER on the rebased
|
|
880
|
+
// tip (step 4c). So the review is RELOCATED to run there too, AFTER the
|
|
881
|
+
// rebased-tip `verify` passes, inside the same fresh gate worktree — so
|
|
882
|
+
// verify-then-review holds on the SAME merged tree (the tree that lands),
|
|
883
|
+
// not split across two trees. (Letting verify move to the rebased tip
|
|
884
|
+
// while review stayed on the pre-rebase `cwd` would deliver the
|
|
885
|
+
// gate-the-merged-tree guarantee for verify but BREAK it for review,
|
|
886
|
+
// incoherent with this task's own goal.)
|
|
887
|
+
//
|
|
888
|
+
// Either way the verdict routes IDENTICALLY:
|
|
889
|
+
// approve → fall through to the done-move/commit/integrate unchanged;
|
|
890
|
+
// block → route to needs-attention via the SAME machinery the red gate
|
|
891
|
+
// uses (`applyNeedsAttentionTransition`, surfaced on
|
|
892
|
+
// `surfaceArbiter` for the autonomous `do` path), with the
|
|
893
|
+
// blocking findings recorded as the reason, no integrate.
|
|
894
|
+
if (input.review && !freshWorktreeGate) {
|
|
895
|
+
const reviewOutcome = await runGate2Review({
|
|
896
|
+
reviewCwd: cwd,
|
|
897
|
+
input,
|
|
898
|
+
slug,
|
|
899
|
+
branch,
|
|
900
|
+
cwd,
|
|
901
|
+
env,
|
|
902
|
+
note,
|
|
903
|
+
});
|
|
904
|
+
if (reviewOutcome.kind === 'blocked') {
|
|
905
|
+
return reviewOutcome.result;
|
|
906
|
+
}
|
|
907
|
+
// approve: carry the verdict (post-integrate PR comment) and write the per-run
|
|
908
|
+
// non-blocking-nits observation INTO `cwd` BEFORE the done-move + atomic commit
|
|
909
|
+
// (so it is swept into that SAME done-commit). A resolved `merge` then lands
|
|
910
|
+
// automatically (`merge` IS the auto-land mode); `propose` leaves it to a human.
|
|
911
|
+
approvedVerdict = reviewOutcome.verdict;
|
|
912
|
+
writeReviewNitsObservation({
|
|
913
|
+
cwd,
|
|
914
|
+
slug,
|
|
915
|
+
findings: reviewOutcome.verdict?.findings ?? [],
|
|
916
|
+
note,
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Read the title now, BEFORE the move, for the default commit summary AND the
|
|
921
|
+
// synthesised propose-mode PR TITLE (the source file is about to be git-mv'd
|
|
922
|
+
// away). The PR title is a SINGLE, capped line built runner-side from the
|
|
923
|
+
// task's `title:` frontmatter + the slug (`<type>(<slug>): <title>`) so it can
|
|
924
|
+
// never be the multi-line commit-subject run-on `--fill` would derive.
|
|
925
|
+
//
|
|
926
|
+
// When the lifecycle supplies an EXPLICIT `title`, use it DIRECTLY (no file read):
|
|
927
|
+
// the intake lone-task / prd path writes its output file in `stage()`, which runs
|
|
928
|
+
// AFTER this point, so a `titlePath` read would race the write and degrade the
|
|
929
|
+
// subject/PR title to the generic fallback. The `do prd:` tasking path leaves
|
|
930
|
+
// `title` unset and keeps reading its already-existing held prd (unchanged).
|
|
931
|
+
const explicitTitle = lifecycle?.title;
|
|
932
|
+
const taskTitle =
|
|
933
|
+
explicitTitle !== undefined ? explicitTitle : readTaskTitle(sourcePath);
|
|
934
|
+
const defaultMessage =
|
|
935
|
+
explicitTitle !== undefined
|
|
936
|
+
? summaryFromTitle(explicitTitle, slug)
|
|
937
|
+
: defaultSummary(sourcePath, slug);
|
|
938
|
+
const prTitle = synthesiseProposeTitle({
|
|
939
|
+
type: (input.type ?? DEFAULT_TYPE).trim() || DEFAULT_TYPE,
|
|
940
|
+
slug,
|
|
941
|
+
title: taskTitle,
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
// 2. STAGE the item move into the index. For a build that is the task done-move
|
|
945
|
+
// (`work/<source>/<slug>.md → work/done/<slug>.md`); for a TASKING
|
|
946
|
+
// transition (a non-task `lifecycle`) it is the caller-supplied prd
|
|
947
|
+
// lifecycle move + emitted backlog files (the runner stages them, the agent
|
|
948
|
+
// never does git). Either way the subsequent `git add -A` folds the agent's
|
|
949
|
+
// uncommitted work + this staging into ONE atomic commit.
|
|
950
|
+
//
|
|
951
|
+
// The task done-move is ATOMIC AGAINST THE ARBITER (ledger-integrity
|
|
952
|
+
// defect 1 + its root defect 2). The LOCAL move here is the legacy clean
|
|
953
|
+
// `git mv work/<source>/<slug>.md → work/done/<slug>.md` (one folder, no
|
|
954
|
+
// fetch — every existing path is byte-for-byte unchanged); the ARBITER
|
|
955
|
+
// resolution + the one-slug-one-folder enforcement happen AFTER the rebase
|
|
956
|
+
// (step 4b, `reconcileDoneMoveAgainstArbiter`), against the freshly-fetched
|
|
957
|
+
// `<arbiter>/main`. That ordering is deliberate: a NEW fetch in THIS step
|
|
958
|
+
// would race a sibling job's integration on the SHARED bare-mirror refs (the
|
|
959
|
+
// very regression that orphaned this work, ADR §2), so we reuse the existing
|
|
960
|
+
// step-4 fetch's result instead. The reconciliation REMOVES any divergent
|
|
961
|
+
// `in-progress/`/`needs-attention/` ghost the merge would otherwise leave (so
|
|
962
|
+
// the move is a MOVE, not a COPY) and FAILS LOUD if the arbiter holds the slug
|
|
963
|
+
// in two folders with differing content.
|
|
964
|
+
if (lifecycle) {
|
|
965
|
+
await lifecycle.stage();
|
|
966
|
+
} else if (source === 'done') {
|
|
967
|
+
// CONTINUE-BUILD (`source: 'done'`, task
|
|
968
|
+
// `complete-builds-on-already-done-moved-continue`): the slug is ALREADY in
|
|
969
|
+
// `work/done/` on this kept branch (a prior attempt moved it there), so there
|
|
970
|
+
// is NOTHING to move — skip the step-2 `git mv`. The subsequent `git add -A`
|
|
971
|
+
// folds the agent's NEW uncommitted source edits into the atomic commit on
|
|
972
|
+
// top of the kept (already-done-moved) tip; no second move, no copy.
|
|
973
|
+
} else {
|
|
974
|
+
mkdirSync(workFolderPath(cwd, 'done'), {recursive: true});
|
|
975
|
+
await gitHard(
|
|
976
|
+
[
|
|
977
|
+
'mv',
|
|
978
|
+
workItemRel(source, `${slug}.md`),
|
|
979
|
+
workItemRel('done', `${slug}.md`),
|
|
980
|
+
],
|
|
981
|
+
cwd,
|
|
982
|
+
env,
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// 3. Commit: git add -A (the agent's uncommitted work + the move) into ONE
|
|
987
|
+
// atomic commit. Nothing to commit is FATAL (no-op-is-fatal, like claim.sh).
|
|
988
|
+
await gitHard(['add', '-A'], cwd, env);
|
|
989
|
+
if (await nothingStaged(cwd, env)) {
|
|
990
|
+
throw new IntegrationNothingStaged(
|
|
991
|
+
`nothing to commit for '${slug}' — no work and no move staged. ` +
|
|
992
|
+
'(Did the agent produce changes? Is the task already done?)',
|
|
993
|
+
slug,
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
// SCOOP + REPORT agent-authored CAPTURED NOTES (task `runner-scoops-captured-notes`,
|
|
997
|
+
// advance-loop's reporting-channel fold-in). A rung's agent may write capture-bucket
|
|
998
|
+
// files (`work/notes/observations/*`, `work/findings/*`) during its run — its `capture-signal`
|
|
999
|
+
// reflex — but it does NO git (Rule A). The `git add -A` above already SWEEPS them into
|
|
1000
|
+
// THIS one runner-owned commit (the same way the review-nits observation rides it), so
|
|
1001
|
+
// they are TRACKED, not dropped/untracked. Rule B is extended HERE: the runner REPORTS
|
|
1002
|
+
// exactly which note files landed (honest reporting — what actually reached the commit,
|
|
1003
|
+
// read from the staged set, not assumed). This is the ONE shared place — BOTH the build
|
|
1004
|
+
// path (`do <task>`/`run`/`complete`) and the tasking path (`do prd:`, via the
|
|
1005
|
+
// `lifecycle` seam) route through it, so the channel is NOT forked. Zero notes ⇒ no
|
|
1006
|
+
// report (the no-note case is byte-for-byte unchanged).
|
|
1007
|
+
await reportScoopedNotes(cwd, env, note);
|
|
1008
|
+
const summary = input.message ?? defaultMessage;
|
|
1009
|
+
const type = (input.type ?? DEFAULT_TYPE).trim() || DEFAULT_TYPE;
|
|
1010
|
+
// The trailing transition tag: a build is `; done`, a TASKING transition is
|
|
1011
|
+
// `; tasked` (the lifecycle supplies it). Keeps the runner-owned commit subject
|
|
1012
|
+
// honest about WHICH lifecycle landed.
|
|
1013
|
+
const commitMessage = `${type}(${slug}): ${summary}; ${lifecycle?.commitTag ?? 'done'}`;
|
|
1014
|
+
await gitHard(['commit', '-q', '-m', commitMessage], cwd, env);
|
|
1015
|
+
note(`Committed: ${commitMessage}`);
|
|
1016
|
+
|
|
1017
|
+
// The rebase-to-integrate TAIL (step 4 fetch+rebase → step 5 integrate) is the
|
|
1018
|
+
// ONLY region serialised per repo under the `run` concurrency seam: it is the
|
|
1019
|
+
// land-on-`main` band where two concurrent SAME-repo merge jobs would otherwise
|
|
1020
|
+
// race (the loser pushing a non-fast-forward `${branch}:main`). Wrapping ONLY
|
|
1021
|
+
// this tail keeps the front-of-band gate (`prepare`+`verify`) and the Gate-2
|
|
1022
|
+
// review agent CONCURRENT across same-repo jobs (run's parallelism); inside the
|
|
1023
|
+
// lock the loser re-fetches + rebases onto the winner's now-advanced main, so
|
|
1024
|
+
// its push is a clean fast-forward (a genuine conflict routes ONE to
|
|
1025
|
+
// needs-attention). Single-job callers pass no lock ⇒ the tail runs directly,
|
|
1026
|
+
// byte-for-byte unchanged. The lock is keyed per repo, so cross-repo
|
|
1027
|
+
// integration stays fully concurrent.
|
|
1028
|
+
const runRebaseToIntegrateTail = async (): Promise<IntegrationCoreResult> => {
|
|
1029
|
+
// The step-4 rebase-onto-`<arbiter>/main` (with BOTH reconciliation arms: the
|
|
1030
|
+
// sibling-slug ledger arm and the divergent-done-move recovery), factored so it
|
|
1031
|
+
// can run ONCE before the gate AND be RE-RUN in the Race-1 merge-push retry loop
|
|
1032
|
+
// (a sibling advancing main mid-push needs the SAME reconcile, not a bare
|
|
1033
|
+
// rebase). Returns `{}` on a clean rebase (fall through to gate/integrate) or
|
|
1034
|
+
// `{route}` when a genuine conflict / invariant violation must stop the tail.
|
|
1035
|
+
const rebaseOntoMainWithReconcile = async (): Promise<{
|
|
1036
|
+
route?: IntegrationCoreResult;
|
|
1037
|
+
}> => {
|
|
1038
|
+
// 4. Rebase-before-integrate (ADR §10): rebase the work branch onto the
|
|
1039
|
+
// latest <arbiter>/main. Clean → continue. Conflict → abort + stop.
|
|
1040
|
+
//
|
|
1041
|
+
// RECOVERY reconciliation: when completing FROM needs-attention/, the work
|
|
1042
|
+
// branch's history still carries the original `in-progress → needs-attention`
|
|
1043
|
+
// MOVE-ONLY commit, and `<arbiter>/main` was SURFACED with that same move
|
|
1044
|
+
// (the item is in needs-attention/ on main). Replaying that historical move
|
|
1045
|
+
// onto main conflicts (main has no in-progress/<slug>.md) — exactly the
|
|
1046
|
+
// rebase conflict the human hit doing this by hand. So we DROP that move-only
|
|
1047
|
+
// commit during the rebase: the replay becomes `wip + (needs-attention →
|
|
1048
|
+
// done)`, which applies cleanly onto the surfaced main (it HAS the item in
|
|
1049
|
+
// needs-attention/). The done-move thus SUPERSEDES the surfaced state — no
|
|
1050
|
+
// leftover/conflicting on-`main` surface for the human to resolve.
|
|
1051
|
+
// Fetch the arbiter's `main` into the `<arbiter>/main` remote-tracking ref
|
|
1052
|
+
// EXPLICITLY. A `run` JOB WORKTREE is cut from a bare hub mirror whose remote
|
|
1053
|
+
// has no fetch refspec (so `<arbiter>/main` would not otherwise resolve / would
|
|
1054
|
+
// be stale, causing a spurious rebase conflict); a regular clone (`do`/
|
|
1055
|
+
// `complete`) already has it, where the explicit refspec is harmless (the same
|
|
1056
|
+
// refspec `rebaseOntoArbiterMain` used before the convergence).
|
|
1057
|
+
await gitHard(
|
|
1058
|
+
[
|
|
1059
|
+
'fetch',
|
|
1060
|
+
'--quiet',
|
|
1061
|
+
arbiter,
|
|
1062
|
+
`+refs/heads/main:refs/remotes/${arbiter}/main`,
|
|
1063
|
+
],
|
|
1064
|
+
cwd,
|
|
1065
|
+
env,
|
|
1066
|
+
);
|
|
1067
|
+
// ONE-SLUG-ONE-FOLDER guard + divergent-base PRE-CHECK, read from the
|
|
1068
|
+
// freshly-fetched `<arbiter>/main` (a READ of the tracking ref the fetch above
|
|
1069
|
+
// just populated — NO new fetch, so no shared-mirror race). It (a) FAILS LOUD if
|
|
1070
|
+
// the arbiter already holds the slug in >1 status folder with differing content
|
|
1071
|
+
// (a corrupt ledger; never publish over it), and (b) detects the DIVERGENT base
|
|
1072
|
+
// — the arbiter holds the slug's source in a DIFFERENT folder than our local
|
|
1073
|
+
// done-move removed — which is the case that turns the rebased "move" into a
|
|
1074
|
+
// "copy" (PR #86). The tasking lifecycle is exempt (its move is not a task
|
|
1075
|
+
// done-move).
|
|
1076
|
+
// CONTINUE-BUILD EXEMPTION (task
|
|
1077
|
+
// `complete-builds-on-already-done-moved-continue`): on `source: 'done'`
|
|
1078
|
+
// there was no first-time move on this commit (the slug was already in
|
|
1079
|
+
// `done/` on the kept branch + on the arbiter), so the divergent-done-move
|
|
1080
|
+
// reconcile reasoning does not apply. Skip the arbiter ledger placement
|
|
1081
|
+
// pre-check too: it is structurally an instrument FOR that reconcile, and a
|
|
1082
|
+
// continue-build is by construction already in `done/` on both sides.
|
|
1083
|
+
if (!lifecycle && source !== 'done') {
|
|
1084
|
+
const arbiterPlacement = readArbiterLedgerPlacement(
|
|
1085
|
+
cwd,
|
|
1086
|
+
arbiter,
|
|
1087
|
+
slug,
|
|
1088
|
+
env,
|
|
1089
|
+
);
|
|
1090
|
+
if (arbiterPlacement.error) {
|
|
1091
|
+
note(arbiterPlacement.error);
|
|
1092
|
+
return {
|
|
1093
|
+
route: {
|
|
1094
|
+
outcome: 'invariant-violation',
|
|
1095
|
+
routedToNeedsAttention: false,
|
|
1096
|
+
branch,
|
|
1097
|
+
reason: arbiterPlacement.error,
|
|
1098
|
+
},
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
// PLAIN rebase. After the per-item-lock cut-over (prd
|
|
1103
|
+
// `ledger-status-per-item-lock-refs`, tasks 9a–9d) no transient status
|
|
1104
|
+
// lands on a work branch: needs-attention is the lock `state: stuck` (not a
|
|
1105
|
+
// `git mv` to `needs-attention/`), the body rests in `backlog/` while
|
|
1106
|
+
// claimed, and the tasking/advancing markers are gone. So a recovery
|
|
1107
|
+
// complete's kept branch carries NO historical route-to-needs-attention
|
|
1108
|
+
// move-only commit to drop — the old `rebaseDroppingNeedsAttentionSurface`
|
|
1109
|
+
// (drop-bookkeeping-rebase) is deleted and BOTH recovering and lifecycle
|
|
1110
|
+
// rebases are the same plain replay onto `<arbiter>/main`.
|
|
1111
|
+
void recovering;
|
|
1112
|
+
void lifecycle;
|
|
1113
|
+
// RENAME-DETECTION-OFF (task
|
|
1114
|
+
// `disable-rename-detection-on-continue-rebase`): scope
|
|
1115
|
+
// `-c merge.directoryRenames=false` to THIS rebase invocation, so a single
|
|
1116
|
+
// durable folder-transition `git mv` out of a SPARSE work/<from>/ folder is
|
|
1117
|
+
// NOT misread by git's directory-rename heuristic as a whole-DIRECTORY
|
|
1118
|
+
// rename `work/<from>/ → work/<to>/` (which would spuriously flag every
|
|
1119
|
+
// sibling file `<arbiter>/main` added into that folder as `CONFLICT (file
|
|
1120
|
+
// location)` and force a FALSE needs-attention). Content-rename detection
|
|
1121
|
+
// (`-Xno-renames`/`merge.renames`/`diff.renames`) is the wrong knob and
|
|
1122
|
+
// does NOT suppress this directory-rename conflict; only
|
|
1123
|
+
// `merge.directoryRenames=false` does. NEVER a persistent `git config`
|
|
1124
|
+
// write — the repo's config stays clean so a user's interactive
|
|
1125
|
+
// `git rebase` is unaffected. A GENUINE content conflict still surfaces
|
|
1126
|
+
// and still routes via `rebaseConflictRoute()` below.
|
|
1127
|
+
const rebase = await gitSoft(
|
|
1128
|
+
['-c', 'merge.directoryRenames=false', 'rebase', `${arbiter}/main`],
|
|
1129
|
+
cwd,
|
|
1130
|
+
env,
|
|
1131
|
+
);
|
|
1132
|
+
if (rebase.status !== 0) {
|
|
1133
|
+
// NEVER auto-resolve a genuine CODE conflict. But FIRST, a SIBLING-SLUG
|
|
1134
|
+
// LEDGER conflict (the replay conflicts ONLY on OTHER slugs'
|
|
1135
|
+
// `work/<status>/<otherslug>.md` ledger files — a sibling job landed its own
|
|
1136
|
+
// status-folder move on `<arbiter>/main` between our base and this rebase) is
|
|
1137
|
+
// a benign ledger-only divergence with NO semantic judgement: the reconcile
|
|
1138
|
+
// ABORTS the rebase, then redoes OUR work as one clean commit on top of
|
|
1139
|
+
// `<arbiter>/main` (taking the arbiter's version of every sibling ledger file
|
|
1140
|
+
// automatically). It is scoped STRICTLY to other slugs' ledger files — a
|
|
1141
|
+
// conflict touching ANY code file, or THIS slug's own ledger, returns `false`
|
|
1142
|
+
// (and leaves the rebase in progress), so the divergent-done-move recovery /
|
|
1143
|
+
// needs-attention route below handles it; it NEVER widens to code. The tasking
|
|
1144
|
+
// lifecycle is exempt (its move is not a task done-move).
|
|
1145
|
+
const siblingReconciled = lifecycle
|
|
1146
|
+
? false
|
|
1147
|
+
: await reconcileSiblingLedgerConflict({
|
|
1148
|
+
cwd,
|
|
1149
|
+
arbiter,
|
|
1150
|
+
slug,
|
|
1151
|
+
env,
|
|
1152
|
+
note,
|
|
1153
|
+
});
|
|
1154
|
+
if (siblingReconciled) {
|
|
1155
|
+
// The branch is now cleanly on top of `<arbiter>/main` with OUR work + the
|
|
1156
|
+
// arbiter's sibling-ledger files — fall through to the fresh-gate + integrate
|
|
1157
|
+
// band. THIS slug's own move is untouched.
|
|
1158
|
+
note(
|
|
1159
|
+
`Reconciled a sibling-slug ledger conflict during the rebase onto ` +
|
|
1160
|
+
`${arbiter}/main (took the arbiter's version of the other slugs' ` +
|
|
1161
|
+
`work/<status>/<slug>.md ledger files; no code file was touched).`,
|
|
1162
|
+
);
|
|
1163
|
+
} else if (!lifecycle && source !== 'done') {
|
|
1164
|
+
// NEVER auto-resolve a genuine CODE conflict: abort the rebase. But FIRST, a
|
|
1165
|
+
// DIVERGENT-LEDGER conflict (the arbiter holds the slug's source in a folder
|
|
1166
|
+
// our local done-move did not remove — PR #86) is auto-RECONCILABLE without
|
|
1167
|
+
// any semantic judgement: redo the done-move arbiter-resolved (remove the
|
|
1168
|
+
// arbiter's actual source folder, add `done/`) on top of `<arbiter>/main`. We
|
|
1169
|
+
// only do this when the post-abort tree's ONLY divergence is the slug's ledger
|
|
1170
|
+
// file; a real code conflict still routes to needs-attention untouched.
|
|
1171
|
+
await gitSoft(['rebase', '--abort'], cwd, env);
|
|
1172
|
+
const recovered = await reconcileDivergentDoneMove({
|
|
1173
|
+
cwd,
|
|
1174
|
+
arbiter,
|
|
1175
|
+
slug,
|
|
1176
|
+
branch,
|
|
1177
|
+
localSource: source,
|
|
1178
|
+
env,
|
|
1179
|
+
note,
|
|
1180
|
+
});
|
|
1181
|
+
if (recovered) {
|
|
1182
|
+
// The branch is now cleanly on top of `<arbiter>/main` with the slug in
|
|
1183
|
+
// `done/` ONLY — fall through to integrate (skip the needs-attention
|
|
1184
|
+
// route below).
|
|
1185
|
+
note(
|
|
1186
|
+
`Reconciled the done-move against ${arbiter}/main: '${slug}' is in ` +
|
|
1187
|
+
'work/done/ ONLY (the divergent source folder was removed; the move ' +
|
|
1188
|
+
'is a move, not a copy).',
|
|
1189
|
+
);
|
|
1190
|
+
} else {
|
|
1191
|
+
return {route: await rebaseConflictRoute()};
|
|
1192
|
+
}
|
|
1193
|
+
} else {
|
|
1194
|
+
await gitSoft(['rebase', '--abort'], cwd, env);
|
|
1195
|
+
return {route: await rebaseConflictRoute()};
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
// Clean rebase (or a reconciled one): fall through to the gate + integrate.
|
|
1199
|
+
return {};
|
|
1200
|
+
};
|
|
1201
|
+
|
|
1202
|
+
// Run the step-4 rebase ONCE up front (before the slow fresh gate).
|
|
1203
|
+
const firstRebase = await rebaseOntoMainWithReconcile();
|
|
1204
|
+
if (firstRebase.route) {
|
|
1205
|
+
return firstRebase.route;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// The rebase-conflict needs-attention route, factored so the divergent-ledger
|
|
1209
|
+
// recovery above can fall through to integrate while a genuine code conflict
|
|
1210
|
+
// still routes here.
|
|
1211
|
+
async function rebaseConflictRoute(): Promise<IntegrationCoreResult> {
|
|
1212
|
+
// Then route the item to needs-attention/ with the conflict reason (ADR
|
|
1213
|
+
// §12) THROUGH the ledger write seam's needs-attention transition, rather
|
|
1214
|
+
// than leaving it dangling in done/. The done-move was already committed
|
|
1215
|
+
// above, so the item sits in work/done/; the move bounces it from there and
|
|
1216
|
+
// commits the in-progress→needs-attention move (here done→needs-attention)
|
|
1217
|
+
// as ONE transition. No partial state.
|
|
1218
|
+
const reason = `rebase onto ${arbiter}/main conflicted (aborted, never auto-resolved)`;
|
|
1219
|
+
const routed = await ledgerWrite.applyNeedsAttentionTransition({
|
|
1220
|
+
cwd,
|
|
1221
|
+
slug,
|
|
1222
|
+
reason,
|
|
1223
|
+
// Autonomous caller (`do`) passes the arbiter so the seam both surfaces the
|
|
1224
|
+
// conflict on `main` (OBSERVABLE) AND pushes the `work/<slug>` branch
|
|
1225
|
+
// (RECOVERABLE, cross-machine). The human `complete` leaves it unset →
|
|
1226
|
+
// no surface, no push, local-only. The push lives in the seam (HEAD's
|
|
1227
|
+
// branch is `work/<slug>` here) — no bolted-on push.
|
|
1228
|
+
arbiter: input.surfaceArbiter,
|
|
1229
|
+
env,
|
|
1230
|
+
note,
|
|
1231
|
+
});
|
|
1232
|
+
return {
|
|
1233
|
+
outcome: 'rebase-conflict',
|
|
1234
|
+
routedToNeedsAttention: routed.moved,
|
|
1235
|
+
branch,
|
|
1236
|
+
commitMessage,
|
|
1237
|
+
reason: routed.moved
|
|
1238
|
+
? `Rebasing ${branch} onto ${arbiter}/main conflicted; the rebase was ` +
|
|
1239
|
+
`aborted (never auto-resolved) and '${slug}' was routed to ` +
|
|
1240
|
+
'work/needs-attention/ (surfaced by status). Resolve against the ' +
|
|
1241
|
+
'latest main, then return it to backlog/ and re-run.'
|
|
1242
|
+
: `Rebasing ${branch} onto ${arbiter}/main conflicted; the rebase was ` +
|
|
1243
|
+
'aborted (never auto-resolved). Resolve against the latest main, ' +
|
|
1244
|
+
'then re-run complete.',
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// 4c. FRESH-WORKTREE GATE (task `gate-on-rebased-tip-fresh-worktree`): when ON,
|
|
1249
|
+
// the acceptance gate (`prepare` then `verify`) runs HERE — on the work
|
|
1250
|
+
// branch tip the rebase above just produced (the would-be-integrated tip) —
|
|
1251
|
+
// rather than on the agent's pre-rebase `cwd`. We cut a CLEAN throwaway
|
|
1252
|
+
// worktree from `HEAD` (the rebased committed tip), `prepare` then `verify`
|
|
1253
|
+
// in it, REAP it (pass or fail), and only on GREEN fall through to integrate.
|
|
1254
|
+
// A gitignored/uncommitted file in `cwd` cannot leak into this gate (the
|
|
1255
|
+
// worktree is cut from the committed, rebased tip), and a change the
|
|
1256
|
+
// integration rebase introduced IS gated. A red gate routes the item the SAME
|
|
1257
|
+
// way the front gate did — EXCEPT the done-move already happened (steps 2–3),
|
|
1258
|
+
// so the bounce is from `work/done/` (the seam finds the slug wherever it
|
|
1259
|
+
// rests) instead of `work/in-progress/`. A `--skip-verify` skipped the gate
|
|
1260
|
+
// entirely at the front, so it never reaches here. The tasking `lifecycle`
|
|
1261
|
+
// path is exempt (its quality engine is the tasker loop, not this gate).
|
|
1262
|
+
if (freshWorktreeGate && !input.skipVerify && !lifecycle) {
|
|
1263
|
+
const tip = (
|
|
1264
|
+
await gitSoft(['rev-parse', '--verify', '--quiet', 'HEAD'], cwd, env)
|
|
1265
|
+
).stdout.trim();
|
|
1266
|
+
const gated = await runFreshWorktreeGate({
|
|
1267
|
+
cwd,
|
|
1268
|
+
commit: tip,
|
|
1269
|
+
prepare: input.prepare,
|
|
1270
|
+
verify: input.verify,
|
|
1271
|
+
env,
|
|
1272
|
+
note,
|
|
1273
|
+
// GATE-2 REVIEW relocation (MAINTAINER DECISION 2): when `review` is ON,
|
|
1274
|
+
// the fresh gate runs it AFTER the rebased-tip verify, against the rebased
|
|
1275
|
+
// tip (the gate worktree) — so verify-THEN-review holds on the SAME merged
|
|
1276
|
+
// tree. The needs-attention ROUTING still targets `cwd` (the work branch +
|
|
1277
|
+
// ledger), so the verdict handling is identical to the OFF path.
|
|
1278
|
+
review: input.review
|
|
1279
|
+
? (reviewCwd) =>
|
|
1280
|
+
runGate2Review({
|
|
1281
|
+
reviewCwd,
|
|
1282
|
+
input,
|
|
1283
|
+
slug,
|
|
1284
|
+
branch,
|
|
1285
|
+
cwd,
|
|
1286
|
+
env,
|
|
1287
|
+
note,
|
|
1288
|
+
})
|
|
1289
|
+
: undefined,
|
|
1290
|
+
});
|
|
1291
|
+
if (!gated.passed) {
|
|
1292
|
+
// prepare-failed or gate-failed on the rebased tip: route the item to
|
|
1293
|
+
// needs-attention through the SAME seam the front gate / rebase-conflict use.
|
|
1294
|
+
// The done-move was already committed (steps 2–3), so the slug sits in
|
|
1295
|
+
// work/done/; the seam bounces it from there (done → needs-attention). The
|
|
1296
|
+
// recovery path keeps it where it is (no re-route) exactly like the front gate.
|
|
1297
|
+
const outcome: IntegrationCoreOutcome =
|
|
1298
|
+
gated.kind === 'prepare' ? 'prepare-failed' : 'gate-failed';
|
|
1299
|
+
const what =
|
|
1300
|
+
gated.kind === 'prepare'
|
|
1301
|
+
? `Env-prep (prepare) failed (exit ${gated.exitCode})`
|
|
1302
|
+
: `Acceptance gate failed (exit ${gated.exitCode})`;
|
|
1303
|
+
const reason =
|
|
1304
|
+
gated.kind === 'prepare'
|
|
1305
|
+
? `prepare (env-prep) failed (exit ${gated.exitCode}) on the rebased tip`
|
|
1306
|
+
: `acceptance gate failed (exit ${gated.exitCode}) on the rebased tip`;
|
|
1307
|
+
const routed = await ledgerWrite.applyNeedsAttentionTransition({
|
|
1308
|
+
cwd,
|
|
1309
|
+
slug,
|
|
1310
|
+
reason,
|
|
1311
|
+
arbiter: input.surfaceArbiter,
|
|
1312
|
+
env,
|
|
1313
|
+
note,
|
|
1314
|
+
});
|
|
1315
|
+
return {
|
|
1316
|
+
outcome,
|
|
1317
|
+
routedToNeedsAttention: routed.moved,
|
|
1318
|
+
branch,
|
|
1319
|
+
commitMessage,
|
|
1320
|
+
reason: routed.moved
|
|
1321
|
+
? `${what} on the rebased tip; routed '${slug}' to ` +
|
|
1322
|
+
'work/needs-attention/ (surfaced by status; return to backlog/ ' +
|
|
1323
|
+
'once resolved). Fix the work, or use --skip-verify to override.'
|
|
1324
|
+
: `${what} on the rebased tip; not completing '${slug}'. Fix the ` +
|
|
1325
|
+
'work, or use --skip-verify to override.',
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
// GATE-2 REVIEW outcome on the rebased tip (MAINTAINER DECISION 2): the
|
|
1329
|
+
// rebased-tip verify PASSED, so the review ran AFTER it (verify-then-review on
|
|
1330
|
+
// the merged tree). Route a BLOCK exactly as the OFF-path front review does
|
|
1331
|
+
// (only the reviewed tree moved, not the routing). On APPROVE: carry the
|
|
1332
|
+
// verdict (post-integrate PR comment), write the per-run non-blocking-nits
|
|
1333
|
+
// observation. A resolved `merge` then lands automatically; `propose` leaves
|
|
1334
|
+
// the merge to a human.
|
|
1335
|
+
if (gated.review) {
|
|
1336
|
+
if (gated.review.kind === 'blocked') {
|
|
1337
|
+
// The done-move was already committed (steps 2–3), so the slug sits in
|
|
1338
|
+
// work/done/; the routing bounced it from there to needs-attention/.
|
|
1339
|
+
return gated.review.result;
|
|
1340
|
+
}
|
|
1341
|
+
approvedVerdict = gated.review.verdict;
|
|
1342
|
+
// The done-move + atomic commit already happened (steps 2–3, before this
|
|
1343
|
+
// rebased-tip gate), so — unlike the OFF path where the nits write rides the
|
|
1344
|
+
// upcoming commit — we write the observation into `cwd` and FOLD it into the
|
|
1345
|
+
// existing done-commit via `commit --amend` (the branch is not yet
|
|
1346
|
+
// integrated). The observation still lands in the SAME done-commit that
|
|
1347
|
+
// integrates, preserving the no-separate-commit/surface model.
|
|
1348
|
+
const nitsBefore = await stagedCaptureNotes(cwd, env);
|
|
1349
|
+
writeReviewNitsObservation({
|
|
1350
|
+
cwd,
|
|
1351
|
+
slug,
|
|
1352
|
+
findings: gated.review.verdict?.findings ?? [],
|
|
1353
|
+
note,
|
|
1354
|
+
});
|
|
1355
|
+
// Only amend when the write actually produced a new staged file (zero
|
|
1356
|
+
// non-blocking findings ⇒ no write ⇒ no amend, the done-commit unchanged).
|
|
1357
|
+
await gitHard(['add', '-A'], cwd, env);
|
|
1358
|
+
if (!(await nothingStaged(cwd, env))) {
|
|
1359
|
+
const nitsAfter = await stagedCaptureNotes(cwd, env);
|
|
1360
|
+
if (nitsAfter.length > nitsBefore.length) {
|
|
1361
|
+
await gitHard(['commit', '-q', '--amend', '--no-edit'], cwd, env);
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// 5. Integrate per mode through the ledger write seam's COMPLETE transition
|
|
1368
|
+
// (ADR §6 + `docs/adr/claim-ledger-vs-protected-main.md`). The rebase above
|
|
1369
|
+
// already brought the branch up to date, so the seam's sole strategy uses
|
|
1370
|
+
// `integrate` (not `integrateWithRebase`) and never --forces. Provider
|
|
1371
|
+
// selection: an injected `openPr` wins (legacy bridge); otherwise pick by
|
|
1372
|
+
// the arbiter's remote URL (a GitHub remote ⇒ `gh pr create`, else push-only
|
|
1373
|
+
// `none`) — PURELY arbiter-derived, no override axis. A missing/unauthenticated
|
|
1374
|
+
// `gh` degrades to push-only at runtime — never a hard failure (and the
|
|
1375
|
+
// start-of-run unauthed case is caught UP FRONT by the pre-flight `gh` probe).
|
|
1376
|
+
// The seam is storage-agnostic: we hand it the work branch, the integration
|
|
1377
|
+
// mode, the provider, and the PR-INTENT (`noPR`) — `main` lives only in the
|
|
1378
|
+
// strategy.
|
|
1379
|
+
// Provider precedence: an injected fully-formed provider wins (the `run`
|
|
1380
|
+
// stubbed-provider seam, carrying title/body/url); else the legacy `openPr`
|
|
1381
|
+
// bridge; else select PURELY from the arbiter URL (no override). The orthogonal
|
|
1382
|
+
// `noPR` INTENT (suppress the PR) is threaded SEPARATELY — it does NOT pick a
|
|
1383
|
+
// provider, the integrator simply skips `openRequest` when it is set.
|
|
1384
|
+
const provider =
|
|
1385
|
+
input.providerInstance ??
|
|
1386
|
+
(input.openPr
|
|
1387
|
+
? bridgeProvider(input.openPr)
|
|
1388
|
+
: selectProvider({
|
|
1389
|
+
arbiterUrl: await arbiterUrl(cwd, arbiter, env),
|
|
1390
|
+
}));
|
|
1391
|
+
// Race-1 (claim-vs-integrate, task
|
|
1392
|
+
// `run-fleet-claim-integrate-and-sibling-rebase-concurrency-safe`): integrate,
|
|
1393
|
+
// and on a non-fast-forward `${branch}:main` push (a SIBLING same-repo CLAIM —
|
|
1394
|
+
// under the SEPARATE claim lock — or a sibling integrate advanced
|
|
1395
|
+
// `<arbiter>/main` during our push window) RE-RUN the step-4 rebase (which
|
|
1396
|
+
// carries the sibling-ledger + divergent-done-move reconcile arms — a bare
|
|
1397
|
+
// re-rebase would MISS them) and RETRY the push, up to a small cap. INSTANT
|
|
1398
|
+
// retry (contention, not an outage; see `claim-cas.ts`). We NEVER `--force`
|
|
1399
|
+
// main: each retry re-rebases to a clean fast-forward. A genuine code conflict
|
|
1400
|
+
// on a re-rebase routes to needs-attention via the SAME `route` the up-front
|
|
1401
|
+
// rebase uses. A persistent non-fast-forward past the cap also routes (never a
|
|
1402
|
+
// silent drop). `input.mergeRetries` overrides the cap (tests; `0` ⇒ no retry).
|
|
1403
|
+
//
|
|
1404
|
+
// **C2 rebase-until-real-conflict (task `c2-rebase-until-real-on-durable-main-
|
|
1405
|
+
// promotions`):** the loop's TERMINATION CHANGED. A CLEAN re-rebase no longer
|
|
1406
|
+
// counts against a tiny give-up budget — only a GENUINE conflict surfaced by
|
|
1407
|
+
// `rebaseOntoMainWithReconcile` (a `route` ⇒ `rebase-conflict` or
|
|
1408
|
+
// `invariant-violation`) stops the loop. The step-4 rebase IS the source-folder
|
|
1409
|
+
// precondition recheck reused verbatim: if the slug is GONE from its expected
|
|
1410
|
+
// source folder on the new `main` (a concurrent legitimate same-item winner
|
|
1411
|
+
// already moved it), the `git mv` replay fails and `rebaseConflictRoute` routes
|
|
1412
|
+
// definitively — never a silent re-push that would clobber the winner. The
|
|
1413
|
+
// `maxMergeRetries` cap survives ONLY as a large liveness ceiling on the
|
|
1414
|
+
// pathological livelock tail (default {@link DEFAULT_MERGE_RETRIES} = 1000);
|
|
1415
|
+
// modest jitter on the refetch desynchronises a herd so the tail is not
|
|
1416
|
+
// reached under sustained parallel load.
|
|
1417
|
+
const maxMergeRetries = input.mergeRetries ?? DEFAULT_MERGE_RETRIES;
|
|
1418
|
+
const mergeJitterMs = input.mergeJitterMs ?? DEFAULT_MERGE_JITTER_MS;
|
|
1419
|
+
let integration!: IntegrateResult;
|
|
1420
|
+
for (let mergeAttempt = 0; ; mergeAttempt++) {
|
|
1421
|
+
integration = await ledgerWrite.applyCompleteTransition({
|
|
1422
|
+
arbiter,
|
|
1423
|
+
branch,
|
|
1424
|
+
mode,
|
|
1425
|
+
provider,
|
|
1426
|
+
// PR-INTENT: when set (propose mode), push the branch but skip the PR.
|
|
1427
|
+
noPR: input.noPR,
|
|
1428
|
+
// Half A: an explicit single-line PR title (propose mode), so `gh` no longer
|
|
1429
|
+
// derives a run-on title from the commit subject via `--fill`.
|
|
1430
|
+
title: prTitle,
|
|
1431
|
+
// Half B: the propose-mode PR body — the agent's summary under a deterministic
|
|
1432
|
+
// runner header (task pointer). Undefined when no body was supplied (the
|
|
1433
|
+
// header is only scaffolded when there IS a body) ⇒ today's `--fill` (no
|
|
1434
|
+
// regression). Ignored in merge mode by the provider/integrator.
|
|
1435
|
+
body: composeProposeBody({slug, body: input.body}),
|
|
1436
|
+
// Part (b) of the merged-branch hygiene task: when WE perform the merge
|
|
1437
|
+
// (this resolved `merge` mode), reap the remote `work/<slug>` HEAD branch
|
|
1438
|
+
// INLINE right after the merge lands — the commits are now on `main`, so the
|
|
1439
|
+
// head is provably merged and safe to delete (ancestor-guarded inside the
|
|
1440
|
+
// integrator). Idempotent no-op when no remote head exists (the plain
|
|
1441
|
+
// `${branch}:main` push opened none); ignored in `propose` mode (its branch is
|
|
1442
|
+
// the review surface, reaped later by `gc --remote-branches`). NEVER `--force`.
|
|
1443
|
+
deleteMergedHead: true,
|
|
1444
|
+
cwd,
|
|
1445
|
+
env,
|
|
1446
|
+
});
|
|
1447
|
+
// Only the merge push can be non-fast-forward (propose pushes its own ref).
|
|
1448
|
+
if (integration.mergeNonFastForward !== true) {
|
|
1449
|
+
break;
|
|
1450
|
+
}
|
|
1451
|
+
if (mergeAttempt >= maxMergeRetries) {
|
|
1452
|
+
// LIVENESS CEILING hit (default 1000; previously the small Race-1 cap of 5,
|
|
1453
|
+
// now reinterpreted by C2 as the pathological-livelock-tail bound, NOT a
|
|
1454
|
+
// false-contention budget). Route to needs-attention rather than looping
|
|
1455
|
+
// forever or force-pushing main. A RARE outcome under realistic load thanks
|
|
1456
|
+
// to the jitter below + the rebase-until-real-conflict semantics; tests
|
|
1457
|
+
// reach it deterministically by injecting a small `mergeRetries`.
|
|
1458
|
+
return await mergeNonFastForwardRoute(
|
|
1459
|
+
`integrating ${branch} onto ${arbiter}/main kept hitting a ` +
|
|
1460
|
+
`non-fast-forward push (a sibling advanced main ${mergeAttempt + 1} ` +
|
|
1461
|
+
`times); gave up cleanly without --force`,
|
|
1462
|
+
);
|
|
1463
|
+
}
|
|
1464
|
+
// Modest jitter on the refetch (C2): an instant lockstep refetch→re-push loop
|
|
1465
|
+
// maximises mutual rejection under sustained parallel load (thundering herd).
|
|
1466
|
+
// A uniformly-random `[0, mergeJitterMs]` ms sleep desynchronises the herd.
|
|
1467
|
+
// Skipped when `mergeJitterMs === 0` (the test seam).
|
|
1468
|
+
if (mergeJitterMs > 0) {
|
|
1469
|
+
await sleepMs(Math.floor(Math.random() * (mergeJitterMs + 1)));
|
|
1470
|
+
}
|
|
1471
|
+
// A sibling advanced main: re-run the step-4 rebase (with the reconcile arms)
|
|
1472
|
+
// before retrying the push. A genuine conflict on the re-rebase routes via
|
|
1473
|
+
// `rebaseOntoMainWithReconcile`'s `route` (the existing source-folder /
|
|
1474
|
+
// one-slug placement recheck IS the genuine-conflict terminator — see the
|
|
1475
|
+
// `DEFAULT_MERGE_RETRIES` docstring for the C2 SCOPE box). A clean re-rebase
|
|
1476
|
+
// (no route) loops without counting against a small budget.
|
|
1477
|
+
const reRebase = await rebaseOntoMainWithReconcile();
|
|
1478
|
+
if (reRebase.route) {
|
|
1479
|
+
return reRebase.route;
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
// The Race-1 needs-attention route for a merge that could not land (a genuine
|
|
1484
|
+
// re-rebase conflict is handled by `rebaseOntoMainWithReconcile`'s `route`; this
|
|
1485
|
+
// covers the cap-exhausted persistent-contention case). Mirrors
|
|
1486
|
+
// `rebaseConflictRoute`: the done-move was already committed (steps 2–3), so the
|
|
1487
|
+
// slug sits in work/done/ and the seam bounces it from there.
|
|
1488
|
+
async function mergeNonFastForwardRoute(
|
|
1489
|
+
reason: string,
|
|
1490
|
+
): Promise<IntegrationCoreResult> {
|
|
1491
|
+
const routed = await ledgerWrite.applyNeedsAttentionTransition({
|
|
1492
|
+
cwd,
|
|
1493
|
+
slug,
|
|
1494
|
+
reason,
|
|
1495
|
+
arbiter: input.surfaceArbiter,
|
|
1496
|
+
env,
|
|
1497
|
+
note,
|
|
1498
|
+
});
|
|
1499
|
+
return {
|
|
1500
|
+
outcome: 'rebase-conflict',
|
|
1501
|
+
routedToNeedsAttention: routed.moved,
|
|
1502
|
+
branch,
|
|
1503
|
+
commitMessage,
|
|
1504
|
+
reason: routed.moved
|
|
1505
|
+
? `Integrating ${branch} onto ${arbiter}/main kept hitting a ` +
|
|
1506
|
+
`non-fast-forward push (a sibling advanced main); '${slug}' was routed ` +
|
|
1507
|
+
`to work/needs-attention/ (surfaced by status). Resolve against the ` +
|
|
1508
|
+
`latest main, then return it to backlog/ and re-run.`
|
|
1509
|
+
: `Integrating ${branch} onto ${arbiter}/main kept hitting a ` +
|
|
1510
|
+
`non-fast-forward push (a sibling advanced main). Resolve against the ` +
|
|
1511
|
+
`latest main, then re-run complete.`,
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// 6. Make the Gate-2 review VISIBLE on the PR (task `review-comment-prose-field`,
|
|
1516
|
+
// refining `review-gate-pr-comment`): AFTER the propose integrate, where the
|
|
1517
|
+
// approved verdict (with its deliberately-authored `review` prose), the
|
|
1518
|
+
// resolved `provider`, AND the opened PR url (`integration.url`) are ALL in
|
|
1519
|
+
// scope, post `verdict.review` as a comment on that PR — INCLUDING on approve
|
|
1520
|
+
// (the audit trail; decided 2026-06-06). The `review` field is a first-class
|
|
1521
|
+
// AUTHORED review (the prompt requires it), NOT the residue around the JSON
|
|
1522
|
+
// — posting the residue was the bug
|
|
1523
|
+
// (`work/findings/review-comment-posts-agent-thinking-not-a-review.md`). It
|
|
1524
|
+
// reuses the SAME `provider` the integrate used (the core never imports `gh`).
|
|
1525
|
+
// The comment is ADVISORY: it changes no gate/verdict/merge/integration logic
|
|
1526
|
+
// — by here the verdict has ALREADY routed (block never reaches this point; it
|
|
1527
|
+
// routed to needs-attention above) and the integrate has ALREADY happened.
|
|
1528
|
+
// The PR identity is resolved in PRECEDENCE: a parsed `integration.url` wins
|
|
1529
|
+
// (the normal path — post on it directly); else, when a PR WAS opened but its
|
|
1530
|
+
// url was unparseable (`integration.requestOpened` true, `url` undefined — the
|
|
1531
|
+
// `gh pr create` exit-0-but-unparseable-stdout degradation), FALL BACK to the
|
|
1532
|
+
// BRANCH-resolved comment (task `review-comment-fallback-on-unparsed-pr-url`):
|
|
1533
|
+
// the provider resolves the branch's open PR and comments on it, instead of
|
|
1534
|
+
// silently dropping a review on a PR that genuinely exists. Only when NO PR was
|
|
1535
|
+
// opened at all (merge mode, or a degraded/push-only propose ⇒ `requestOpened`
|
|
1536
|
+
// false) is it the honest clean no-op — and the branch-resolved fallback's own
|
|
1537
|
+
// "no PR resolvable" path is a clean no-op too (it tries first). Either way
|
|
1538
|
+
// `postPRComment*` never throws; the review stays in the run output. Because
|
|
1539
|
+
// this lives in the shared core, BOTH `do`/`complete` AND `run` post the
|
|
1540
|
+
// comment — no per-caller wiring.
|
|
1541
|
+
if (approvedVerdict?.review !== undefined) {
|
|
1542
|
+
if (integration.url !== undefined) {
|
|
1543
|
+
const posted = provider.postPRComment({
|
|
1544
|
+
cwd,
|
|
1545
|
+
url: integration.url,
|
|
1546
|
+
body: approvedVerdict.review,
|
|
1547
|
+
env,
|
|
1548
|
+
});
|
|
1549
|
+
note(posted.instruction);
|
|
1550
|
+
} else if (integration.requestOpened) {
|
|
1551
|
+
// A PR opened but its url was unparseable — resolve it from the branch
|
|
1552
|
+
// rather than dropping the review (the audit-trail fallback).
|
|
1553
|
+
const posted = provider.postPRCommentOnBranch({
|
|
1554
|
+
cwd,
|
|
1555
|
+
branch,
|
|
1556
|
+
body: approvedVerdict.review,
|
|
1557
|
+
env,
|
|
1558
|
+
});
|
|
1559
|
+
note(posted.instruction);
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
return {
|
|
1564
|
+
outcome: 'completed',
|
|
1565
|
+
routedToNeedsAttention: false,
|
|
1566
|
+
branch,
|
|
1567
|
+
commitMessage,
|
|
1568
|
+
integration,
|
|
1569
|
+
};
|
|
1570
|
+
};
|
|
1571
|
+
|
|
1572
|
+
// Serialise ONLY this tail per repo when the `run` seam is wired; absent ⇒ run
|
|
1573
|
+
// it directly (an un-contended no-op, single-job behaviour unchanged).
|
|
1574
|
+
return input.integrateLock && input.integrateLockKey !== undefined
|
|
1575
|
+
? await input.integrateLock(
|
|
1576
|
+
input.integrateLockKey,
|
|
1577
|
+
runRebaseToIntegrateTail,
|
|
1578
|
+
)
|
|
1579
|
+
: await runRebaseToIntegrateTail();
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
/**
|
|
1583
|
+
* RECOVER an already-committed, already-done-moved STRANDED branch (prd
|
|
1584
|
+
* `ledger-integrity` story 6, the `finish-already-committed-branch` task). The
|
|
1585
|
+
* green work AND the `git mv → work/done/` are ALREADY committed on the work
|
|
1586
|
+
* branch (a terminal push failed AFTER `performIntegration`'s steps 2–3), and the
|
|
1587
|
+
* tip is NOT on the arbiter. This runs ONLY the rebase→integrate TAIL (steps 4–5)
|
|
1588
|
+
* from the kept commit — NO re-done-move, NO re-commit, NO rebuild, NO orphan
|
|
1589
|
+
* branch — reusing the SAME `ledgerWrite.applyCompleteTransition` integrate
|
|
1590
|
+
* primitive the build path uses.
|
|
1591
|
+
*
|
|
1592
|
+
* RE-GATE on the REBASED TIP (task `committed-recovery-honours-fresh-worktree-
|
|
1593
|
+
* gate`, prd `land-time-reverify-and-parallel-merge-ceiling`): when the caller
|
|
1594
|
+
* sets `freshWorktreeGate` (and not `skipVerify`), the EXISTING
|
|
1595
|
+
* `runFreshWorktreeGate` runs on the rebased tip AFTER the rebase loop and
|
|
1596
|
+
* BEFORE `applyCompleteTransition`, mirroring the build path's
|
|
1597
|
+
* `freshWorktreeGate && !skipVerify && !lifecycle` branch — a red gate routes
|
|
1598
|
+
* to needs-attention through the SAME shared seam, never integrates a clean-
|
|
1599
|
+
* rebase-but-broken merge. This is OPT-IN per caller: the original stranded-
|
|
1600
|
+
* recovery caller (`complete --integration`'s already-built strand, whose pre-
|
|
1601
|
+
* strand build already gated) leaves it UNSET and is byte-identical to before —
|
|
1602
|
+
* no extra gate, no extra fetch. The answered-merge apply-rung SETS it because
|
|
1603
|
+
* `<arbiter>/main` may have moved since the branch's last build, so the rebased
|
|
1604
|
+
* tip MUST be re-verified before it lands or the load-bearing invariant
|
|
1605
|
+
* ("main never receives a tree that fails verify") cannot hold on the merge
|
|
1606
|
+
* path.
|
|
1607
|
+
*
|
|
1608
|
+
* SAFETY — UNSPOOFABLE detection: BEFORE acting it fetches `<arbiter>/main` and
|
|
1609
|
+
* checks whether the kept tip is ALREADY reachable there (`isAncestor`, the SAME
|
|
1610
|
+
* predicate `gc.ts` uses). If so the work is already integrated → a clean
|
|
1611
|
+
* `already-integrated` no-op (never a re-push / double-integrate); a re-run after a
|
|
1612
|
+
* successful recovery hits this. Only when the tip is genuinely AHEAD does it
|
|
1613
|
+
* rebase + integrate. A rebase CONFLICT here is a genuine code conflict the human
|
|
1614
|
+
* resolves — the branch is aborted and the outcome is `rebase-conflict` (the kept
|
|
1615
|
+
* commit stays intact on the branch, recoverable), NEVER auto-resolved, NEVER
|
|
1616
|
+
* `--force` to main.
|
|
1617
|
+
*/
|
|
1618
|
+
async function recoverAlreadyCommitted(params: {
|
|
1619
|
+
cwd: string;
|
|
1620
|
+
arbiter: string;
|
|
1621
|
+
slug: string;
|
|
1622
|
+
branch: string;
|
|
1623
|
+
mode: IntegrationMode;
|
|
1624
|
+
noPR?: boolean;
|
|
1625
|
+
providerInstance?: ReviewProvider;
|
|
1626
|
+
openPr?: (opts: {
|
|
1627
|
+
cwd: string;
|
|
1628
|
+
branch: string;
|
|
1629
|
+
env?: NodeJS.ProcessEnv;
|
|
1630
|
+
}) => void;
|
|
1631
|
+
recoveryRebaseRetries?: number;
|
|
1632
|
+
recoveryRebaseJitterMs?: number;
|
|
1633
|
+
recoveryRebaseSleep?: Sleep;
|
|
1634
|
+
recoveryRebaseRandom?: () => number;
|
|
1635
|
+
/**
|
|
1636
|
+
* Run the EXISTING `runFreshWorktreeGate` (`prepare` then `verify`) on the
|
|
1637
|
+
* rebased tip AFTER the rebase loop and BEFORE `applyCompleteTransition`
|
|
1638
|
+
* (task `committed-recovery-honours-fresh-worktree-gate`, prd
|
|
1639
|
+
* `land-time-reverify-and-parallel-merge-ceiling`). The original stranded-
|
|
1640
|
+
* recovery caller (`complete --integration`'s already-built strand) leaves
|
|
1641
|
+
* this UNSET ⇒ no gate, no extra fetch — byte-identical to before. The
|
|
1642
|
+
* answered-merge apply-rung sets it ⇒ the rebased tip is re-verified before
|
|
1643
|
+
* it lands, so the load-bearing invariant ("main never receives a tree that
|
|
1644
|
+
* fails verify") holds on the merge path: a clean rebase that fails verify
|
|
1645
|
+
* routes to needs-attention via the SAME `applyNeedsAttentionTransition`
|
|
1646
|
+
* seam the build path's `freshWorktreeGate && !skipVerify && !lifecycle`
|
|
1647
|
+
* branch uses — no fork of a second gate or a second integrate primitive.
|
|
1648
|
+
*/
|
|
1649
|
+
freshWorktreeGate?: boolean;
|
|
1650
|
+
/** `--skip-verify` honoured exactly as the build path: skips the gate entirely. */
|
|
1651
|
+
skipVerify?: boolean;
|
|
1652
|
+
/** Env-prep config for the fresh gate (mirrors the build path). */
|
|
1653
|
+
prepare?: VerifyConfig;
|
|
1654
|
+
/** Acceptance gate config for the fresh gate (mirrors the build path). */
|
|
1655
|
+
verify?: VerifyConfig;
|
|
1656
|
+
/**
|
|
1657
|
+
* Autonomous needs-attention surface (mirrors the build path): when set, a
|
|
1658
|
+
* red rebased-tip gate cherry-picks the bounce onto `main` + pushes the
|
|
1659
|
+
* work branch (observable + cross-machine). Unset ⇒ local-only routing.
|
|
1660
|
+
*/
|
|
1661
|
+
surfaceArbiter?: string;
|
|
1662
|
+
env: NodeJS.ProcessEnv | undefined;
|
|
1663
|
+
note: (message: string) => void;
|
|
1664
|
+
}): Promise<IntegrationCoreResult> {
|
|
1665
|
+
const {cwd, arbiter, slug, branch, mode, env, note} = params;
|
|
1666
|
+
const retries =
|
|
1667
|
+
params.recoveryRebaseRetries ?? DEFAULT_RECOVERY_REBASE_RETRIES;
|
|
1668
|
+
const jitterMs =
|
|
1669
|
+
params.recoveryRebaseJitterMs ?? DEFAULT_RECOVERY_REBASE_JITTER_MS;
|
|
1670
|
+
const sleep = params.recoveryRebaseSleep ?? realSleep;
|
|
1671
|
+
const random = params.recoveryRebaseRandom ?? Math.random;
|
|
1672
|
+
|
|
1673
|
+
// Helper: the explicit-refspec fetch (the build path's step-4 fetch shape — a
|
|
1674
|
+
// bare-mirror worktree's remote has no fetch refspec, so `<arbiter>/main` would
|
|
1675
|
+
// not otherwise resolve / would be stale). REUSED on EACH attempt (the root cause
|
|
1676
|
+
// of the moving-base race is a stale SINGLE fetch — see the loop below).
|
|
1677
|
+
const refetchMain = async (): Promise<void> => {
|
|
1678
|
+
await gitHard(
|
|
1679
|
+
[
|
|
1680
|
+
'fetch',
|
|
1681
|
+
'--quiet',
|
|
1682
|
+
arbiter,
|
|
1683
|
+
`+refs/heads/main:refs/remotes/${arbiter}/main`,
|
|
1684
|
+
],
|
|
1685
|
+
cwd,
|
|
1686
|
+
env,
|
|
1687
|
+
);
|
|
1688
|
+
};
|
|
1689
|
+
|
|
1690
|
+
await refetchMain();
|
|
1691
|
+
|
|
1692
|
+
const tip = (
|
|
1693
|
+
await gitSoft(['rev-parse', '--verify', '--quiet', 'HEAD'], cwd, env)
|
|
1694
|
+
).stdout.trim();
|
|
1695
|
+
if (tip === '') {
|
|
1696
|
+
throw new Error(
|
|
1697
|
+
`cannot recover '${slug}': HEAD does not resolve (no committed work on ` +
|
|
1698
|
+
`${branch}?).`,
|
|
1699
|
+
);
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
// UNSPOOFABLE detection: the kept tip ALREADY reachable on `<arbiter>/main`
|
|
1703
|
+
// means the work is already integrated — a clean no-op, NEVER a re-integration.
|
|
1704
|
+
// (`isAncestor` is the SAME reachability predicate `gc.ts` uses; do not fork it.)
|
|
1705
|
+
// KEPT before the retry loop: a no-op MUST short-circuit before we burn any
|
|
1706
|
+
// re-fetch/re-rebase budget.
|
|
1707
|
+
if (isAncestor(cwd, tip, `refs/remotes/${arbiter}/main`, env)) {
|
|
1708
|
+
const message =
|
|
1709
|
+
`Nothing to recover for '${slug}': its work branch tip is already on ` +
|
|
1710
|
+
`${arbiter}/main (already integrated). No re-push, no double-integrate.`;
|
|
1711
|
+
note(message);
|
|
1712
|
+
return {
|
|
1713
|
+
outcome: 'already-integrated',
|
|
1714
|
+
routedToNeedsAttention: false,
|
|
1715
|
+
branch,
|
|
1716
|
+
reason: message,
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
// The tip is genuinely AHEAD — rebase the kept commit onto the latest
|
|
1721
|
+
// `<arbiter>/main`. A clean rebase continues; a CONFLICT is wrapped in a
|
|
1722
|
+
// bounded CONTENTION loop (task `recovery-rebase-retry-against-moving-arbiter-
|
|
1723
|
+
// main`): on each conflict `--abort`, sleep a small jitter, RE-FETCH
|
|
1724
|
+
// `<arbiter>/main` (it may have advanced — `advance` runs land bursts of
|
|
1725
|
+
// `advance: surface observation:…` commits on main, so a one-shot rebase against
|
|
1726
|
+
// a stale fetched base can conflict against a main that already moved AGAIN),
|
|
1727
|
+
// then re-rebase. Only after the cap exhausts (a freshly-fetched main STILL
|
|
1728
|
+
// conflicts on every attempt) do we surface `rebase-conflict` (never auto-
|
|
1729
|
+
// resolved, NEVER `--force` to main — the kept commit stays on the branch,
|
|
1730
|
+
// recoverable; the human resolves and re-runs).
|
|
1731
|
+
//
|
|
1732
|
+
// This is the CONTENTION model (instant re-fetch+rebuild against the new base,
|
|
1733
|
+
// like `claim-cas.ts` / the Race-1 merge loop above), NOT the OUTAGE model in
|
|
1734
|
+
// `retry-backoff.ts` (exponential temporal backoff for an unreachable remote).
|
|
1735
|
+
// The jitter is a SMALL livelock-breaking SPREAD (two runners that begin
|
|
1736
|
+
// retrying at the same instant must NOT re-fetch/re-rebase in lockstep) — NOT
|
|
1737
|
+
// exponential outage backoff. The `--abort` is unconditional on conflict (never
|
|
1738
|
+
// leave the worktree mid-rebase between attempts).
|
|
1739
|
+
//
|
|
1740
|
+
// RECONCILE ARMS DECISION (this task): the recovery rebase is deliberately
|
|
1741
|
+
// BARE — it does NOT layer the sibling-ledger / divergent-done-move arms the
|
|
1742
|
+
// build path's `rebaseOntoMainWithReconcile()` carries. Reasoning: this tail
|
|
1743
|
+
// integrates a branch whose done-move was ALREADY committed in a prior run, so
|
|
1744
|
+
// there is no first-time slug relocation on THIS commit for the divergent-
|
|
1745
|
+
// done-move reconcile to act on, and a sibling-slug ledger conflict on the
|
|
1746
|
+
// re-fetched main is the same shape it would have hit on the original run (the
|
|
1747
|
+
// recovery is not the place to grow new reconcile semantics).
|
|
1748
|
+
//
|
|
1749
|
+
// RENAME-DETECTION composition (task
|
|
1750
|
+
// `disable-rename-detection-on-continue-rebase`): the rebase carries
|
|
1751
|
+
// `-c merge.directoryRenames=false` SCOPED to the invocation — written as a
|
|
1752
|
+
// small args array so every retry of THIS loop carries it too — so a single
|
|
1753
|
+
// durable folder-transition `git mv` out of a SPARSE source folder is NOT
|
|
1754
|
+
// misread as a whole-DIRECTORY rename and the post-rename heuristic does NOT
|
|
1755
|
+
// flag sibling files `<arbiter>/main` added into that folder as `CONFLICT
|
|
1756
|
+
// (file location)`. Content-rename detection (`-Xno-renames`/`merge.renames`/
|
|
1757
|
+
// `diff.renames`) is the WRONG knob and was verified ineffective for this
|
|
1758
|
+
// directory-rename conflict; only `merge.directoryRenames=false` suppresses
|
|
1759
|
+
// it. NEVER a persistent `git config` write — the repo's config stays clean.
|
|
1760
|
+
// A GENUINE same-path content conflict still surfaces and still routes to
|
|
1761
|
+
// `rebase-conflict` (the user's interactive `git rebase` is unaffected).
|
|
1762
|
+
note(
|
|
1763
|
+
`Recovering '${slug}': rebasing the kept ${branch} onto ${arbiter}/main…`,
|
|
1764
|
+
);
|
|
1765
|
+
const rebaseArgs = (): string[] => [
|
|
1766
|
+
'-c',
|
|
1767
|
+
'merge.directoryRenames=false',
|
|
1768
|
+
'rebase',
|
|
1769
|
+
`${arbiter}/main`,
|
|
1770
|
+
];
|
|
1771
|
+
let attempt = 0;
|
|
1772
|
+
for (;;) {
|
|
1773
|
+
const rebase = await gitSoft(rebaseArgs(), cwd, env);
|
|
1774
|
+
if (rebase.status === 0) {
|
|
1775
|
+
break; // clean rebase ⇒ fall through to integrate
|
|
1776
|
+
}
|
|
1777
|
+
// ALWAYS abort on conflict — never leave mid-rebase between attempts.
|
|
1778
|
+
await gitSoft(['rebase', '--abort'], cwd, env);
|
|
1779
|
+
if (attempt >= retries) {
|
|
1780
|
+
const message =
|
|
1781
|
+
`Recovering '${slug}': rebasing the kept ${branch} onto ${arbiter}/main ` +
|
|
1782
|
+
`conflicted on every attempt (${attempt + 1} total, against a freshly-` +
|
|
1783
|
+
`fetched ${arbiter}/main each time); the rebase was aborted (never auto-` +
|
|
1784
|
+
'resolved). The committed work is intact on the branch (recoverable). ' +
|
|
1785
|
+
'Resolve against the latest main, then re-run.';
|
|
1786
|
+
note(message);
|
|
1787
|
+
return {
|
|
1788
|
+
outcome: 'rebase-conflict',
|
|
1789
|
+
routedToNeedsAttention: false,
|
|
1790
|
+
branch,
|
|
1791
|
+
reason: message,
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1794
|
+
// Small livelock-breaking jitter (contention spread, NOT outage backoff).
|
|
1795
|
+
// Sleep happens BEFORE the re-fetch so a sleep-injection in tests can also
|
|
1796
|
+
// drive the timeline (e.g. advance the arbiter between attempts).
|
|
1797
|
+
const delay = jitterMs > 0 ? Math.floor(random() * (jitterMs + 1)) : 0;
|
|
1798
|
+
await sleep(delay);
|
|
1799
|
+
await refetchMain();
|
|
1800
|
+
attempt++;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
// FRESH-WORKTREE GATE on the REBASED TIP (task `committed-recovery-honours-
|
|
1804
|
+
// fresh-worktree-gate`, prd `land-time-reverify-and-parallel-merge-ceiling`):
|
|
1805
|
+
// when `freshWorktreeGate` is set (the answered-merge land caller) and not
|
|
1806
|
+
// `--skip-verify`, re-run the acceptance gate on the rebased tip BEFORE we
|
|
1807
|
+
// integrate, mirroring the build path's `freshWorktreeGate && !skipVerify &&
|
|
1808
|
+
// !lifecycle` branch (recovery never carries a lifecycle, so no lifecycle
|
|
1809
|
+
// guard is needed). A green gate ⇒ fall through to integrate exactly as today;
|
|
1810
|
+
// a red gate routes to needs-attention through the SAME shared seam
|
|
1811
|
+
// (`applyNeedsAttentionTransition`) the build path uses — NEVER integrates a
|
|
1812
|
+
// clean-rebase-but-broken merge. With `freshWorktreeGate` UNSET (the original
|
|
1813
|
+
// stranded-recovery caller, whose pre-strand build already gated) this whole
|
|
1814
|
+
// block is skipped and behaviour is byte-identical to before.
|
|
1815
|
+
if (params.freshWorktreeGate && !params.skipVerify) {
|
|
1816
|
+
const tip = (
|
|
1817
|
+
await gitSoft(['rev-parse', '--verify', '--quiet', 'HEAD'], cwd, env)
|
|
1818
|
+
).stdout.trim();
|
|
1819
|
+
// No `review:` callback here: the recovery tail re-verifies an already-
|
|
1820
|
+
// reviewed, already-committed result, so Gate-2 review semantics do not
|
|
1821
|
+
// apply on this path.
|
|
1822
|
+
const gated = await runFreshWorktreeGate({
|
|
1823
|
+
cwd,
|
|
1824
|
+
commit: tip,
|
|
1825
|
+
prepare: params.prepare,
|
|
1826
|
+
verify: params.verify,
|
|
1827
|
+
env,
|
|
1828
|
+
note,
|
|
1829
|
+
});
|
|
1830
|
+
if (!gated.passed) {
|
|
1831
|
+
const outcome: IntegrationCoreOutcome =
|
|
1832
|
+
gated.kind === 'prepare' ? 'prepare-failed' : 'gate-failed';
|
|
1833
|
+
const what =
|
|
1834
|
+
gated.kind === 'prepare'
|
|
1835
|
+
? `Env-prep (prepare) failed (exit ${gated.exitCode})`
|
|
1836
|
+
: `Acceptance gate failed (exit ${gated.exitCode})`;
|
|
1837
|
+
const reason =
|
|
1838
|
+
gated.kind === 'prepare'
|
|
1839
|
+
? `prepare (env-prep) failed (exit ${gated.exitCode}) on the rebased tip`
|
|
1840
|
+
: `acceptance gate failed (exit ${gated.exitCode}) on the rebased tip`;
|
|
1841
|
+
const routed = await ledgerWrite.applyNeedsAttentionTransition({
|
|
1842
|
+
cwd,
|
|
1843
|
+
slug,
|
|
1844
|
+
reason,
|
|
1845
|
+
arbiter: params.surfaceArbiter,
|
|
1846
|
+
env,
|
|
1847
|
+
note,
|
|
1848
|
+
});
|
|
1849
|
+
return {
|
|
1850
|
+
outcome,
|
|
1851
|
+
routedToNeedsAttention: routed.moved,
|
|
1852
|
+
branch,
|
|
1853
|
+
reason: routed.moved
|
|
1854
|
+
? `${what} on the rebased tip during committed-recovery; routed ` +
|
|
1855
|
+
`'${slug}' to work/needs-attention/ (surfaced by status; return ` +
|
|
1856
|
+
'to backlog/ once resolved). Fix the work, or use --skip-verify ' +
|
|
1857
|
+
'to override.'
|
|
1858
|
+
: `${what} on the rebased tip during committed-recovery; not ` +
|
|
1859
|
+
`completing '${slug}'. Fix the work, or use --skip-verify to ` +
|
|
1860
|
+
'override.',
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// Integrate the rebased kept commit through the SAME complete-transition
|
|
1866
|
+
// primitive the build path uses (no duplication of the integrate mechanism; the
|
|
1867
|
+
// branch is already rebased so it is the non-rebasing `integrate`, never
|
|
1868
|
+
// `--force`). Provider precedence matches the build path: injected instance >
|
|
1869
|
+
// legacy `openPr` bridge > arbiter-derived selection.
|
|
1870
|
+
const provider =
|
|
1871
|
+
params.providerInstance ??
|
|
1872
|
+
(params.openPr
|
|
1873
|
+
? bridgeProvider(params.openPr)
|
|
1874
|
+
: selectProvider({arbiterUrl: await arbiterUrl(cwd, arbiter, env)}));
|
|
1875
|
+
const integration = await ledgerWrite.applyCompleteTransition({
|
|
1876
|
+
arbiter,
|
|
1877
|
+
branch,
|
|
1878
|
+
mode,
|
|
1879
|
+
provider,
|
|
1880
|
+
noPR: params.noPR,
|
|
1881
|
+
deleteMergedHead: true,
|
|
1882
|
+
cwd,
|
|
1883
|
+
env,
|
|
1884
|
+
});
|
|
1885
|
+
note(
|
|
1886
|
+
attempt === 0
|
|
1887
|
+
? `Recovered '${slug}': integrated the kept commit from ${branch}.`
|
|
1888
|
+
: `Recovered '${slug}': integrated the kept commit from ${branch} ` +
|
|
1889
|
+
`(absorbed a moving ${arbiter}/main across ${attempt} re-fetch+re-` +
|
|
1890
|
+
`rebase attempt${attempt === 1 ? '' : 's'}).`,
|
|
1891
|
+
);
|
|
1892
|
+
return {
|
|
1893
|
+
outcome: 'completed',
|
|
1894
|
+
routedToNeedsAttention: false,
|
|
1895
|
+
branch,
|
|
1896
|
+
integration,
|
|
1897
|
+
};
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
/**
|
|
1901
|
+
* Resolve the work branch the integration runs on: the branch HEAD is currently
|
|
1902
|
+
* on (the caller is ALWAYS on the work branch — the agent built there / the
|
|
1903
|
+
* lifecycle stage wrote there), which carries the namespaced `work/<type>-<slug>`
|
|
1904
|
+
* identity. Falls back to a synthesised `work/task-<slug>` ONLY for a detached
|
|
1905
|
+
* HEAD (a degenerate case the on-branch invariant precludes), so the push target
|
|
1906
|
+
* is always defined.
|
|
1907
|
+
*/
|
|
1908
|
+
function resolveWorkBranch(
|
|
1909
|
+
cwd: string,
|
|
1910
|
+
slug: string,
|
|
1911
|
+
env: NodeJS.ProcessEnv | undefined,
|
|
1912
|
+
): string {
|
|
1913
|
+
try {
|
|
1914
|
+
const head = git(['symbolic-ref', '--quiet', '--short', 'HEAD'], cwd, {
|
|
1915
|
+
env,
|
|
1916
|
+
}).trim();
|
|
1917
|
+
if (head.startsWith('work/')) {
|
|
1918
|
+
return head;
|
|
1919
|
+
}
|
|
1920
|
+
} catch {
|
|
1921
|
+
// detached HEAD or plumbing failure — fall through to the synthesised default
|
|
1922
|
+
}
|
|
1923
|
+
return workBranchRef('task', slug);
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
/**
|
|
1927
|
+
* Raised when the atomic completion commit has NOTHING staged (no agent work and
|
|
1928
|
+
* no move) — a deliberate REFUSAL, mapped by `complete`'s try/catch to its
|
|
1929
|
+
* `refused` outcome (preserving its existing message verbatim). Exported so the
|
|
1930
|
+
* caller can `instanceof`-route it.
|
|
1931
|
+
*
|
|
1932
|
+
* Carries the {@link slug} so the autonomous-strand surface in `complete.ts`
|
|
1933
|
+
* (which catches this error in `performComplete`'s outer try/catch, OUTSIDE the
|
|
1934
|
+
* `runComplete` slug scope) can publish the `in-progress/ → needs-attention/`
|
|
1935
|
+
* tree-less move without re-deriving the slug from the error message.
|
|
1936
|
+
*/
|
|
1937
|
+
export class IntegrationNothingStaged extends Error {
|
|
1938
|
+
constructor(
|
|
1939
|
+
message: string,
|
|
1940
|
+
readonly slug: string,
|
|
1941
|
+
) {
|
|
1942
|
+
super(message);
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
/**
|
|
1947
|
+
* The arbiter's remote URL for `arbiter` in `cwd` (for provider auto-detection),
|
|
1948
|
+
* or `undefined` when it cannot be resolved. Read-only; soft (never throws).
|
|
1949
|
+
* Exported so the PR-INTENT pre-flight guard (`do.ts`) can resolve the arbiter
|
|
1950
|
+
* URL up front to decide whether a GitHub PR is even possible for this run.
|
|
1951
|
+
*/
|
|
1952
|
+
export async function arbiterUrl(
|
|
1953
|
+
cwd: string,
|
|
1954
|
+
arbiter: string,
|
|
1955
|
+
env: NodeJS.ProcessEnv | undefined,
|
|
1956
|
+
): Promise<string | undefined> {
|
|
1957
|
+
const res = await gitSoft(['remote', 'get-url', arbiter], cwd, env);
|
|
1958
|
+
if (res.status !== 0) {
|
|
1959
|
+
return undefined;
|
|
1960
|
+
}
|
|
1961
|
+
const url = res.stdout.trim();
|
|
1962
|
+
return url === '' ? undefined : url;
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
/** Adapt the legacy `openPr` callback into the new ReviewProvider seam. */
|
|
1966
|
+
function bridgeProvider(
|
|
1967
|
+
openPr: (opts: {
|
|
1968
|
+
cwd: string;
|
|
1969
|
+
branch: string;
|
|
1970
|
+
env?: NodeJS.ProcessEnv;
|
|
1971
|
+
}) => void,
|
|
1972
|
+
): ReviewProvider {
|
|
1973
|
+
return {
|
|
1974
|
+
name: 'none',
|
|
1975
|
+
async openRequest(req) {
|
|
1976
|
+
openPr({cwd: req.cwd, branch: req.branch, env: req.env});
|
|
1977
|
+
return {
|
|
1978
|
+
opened: true,
|
|
1979
|
+
instruction: `Opened a review for ${req.branch}.`,
|
|
1980
|
+
};
|
|
1981
|
+
},
|
|
1982
|
+
// The legacy `openPr` bridge has no comment channel (it returns no PR url),
|
|
1983
|
+
// so postPRComment degrades: it never opens a PR url to comment on, so the
|
|
1984
|
+
// in-core poster no-ops anyway. Implemented for the seam, surfacing the text.
|
|
1985
|
+
postPRComment(req) {
|
|
1986
|
+
return {
|
|
1987
|
+
posted: false,
|
|
1988
|
+
instruction:
|
|
1989
|
+
'The legacy review bridge cannot post a comment; the review:\n' +
|
|
1990
|
+
req.body,
|
|
1991
|
+
};
|
|
1992
|
+
},
|
|
1993
|
+
// Likewise the bridge cannot resolve a PR from a branch — a clean no-op.
|
|
1994
|
+
postPRCommentOnBranch(req) {
|
|
1995
|
+
return {
|
|
1996
|
+
posted: false,
|
|
1997
|
+
instruction:
|
|
1998
|
+
'The legacy review bridge cannot post a comment; the review:\n' +
|
|
1999
|
+
req.body,
|
|
2000
|
+
};
|
|
2001
|
+
},
|
|
2002
|
+
};
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
/**
|
|
2006
|
+
* Default commit summary: the task's `title` frontmatter with any leading
|
|
2007
|
+
* `slug — ` (or `slug -`) prefix stripped, so a task titled
|
|
2008
|
+
* "complete — gate, mark done, …" yields "gate, mark done, …". Falls back to a
|
|
2009
|
+
* generic summary when the title is missing/unreadable.
|
|
2010
|
+
*/
|
|
2011
|
+
function defaultSummary(inProgressPath: string, slug: string): string {
|
|
2012
|
+
let title: string | undefined;
|
|
2013
|
+
try {
|
|
2014
|
+
title = readTitle(readFileSync(inProgressPath, 'utf8'));
|
|
2015
|
+
} catch {
|
|
2016
|
+
title = undefined;
|
|
2017
|
+
}
|
|
2018
|
+
return summaryFromTitle(title, slug);
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
/**
|
|
2022
|
+
* The PURE commit-summary derivation from a (possibly absent) item title — the
|
|
2023
|
+
* shared core of {@link defaultSummary} (file-read path) and the lifecycle's
|
|
2024
|
+
* EXPLICIT-title path (intake, whose output file is not written until {@link
|
|
2025
|
+
* IntegrationLifecycle.stage}, AFTER the title read). Strips a leading `slug — `
|
|
2026
|
+
* prefix; falls back to the generic summary when the title is missing/empty.
|
|
2027
|
+
*/
|
|
2028
|
+
function summaryFromTitle(title: string | undefined, slug: string): string {
|
|
2029
|
+
if (!title) {
|
|
2030
|
+
return 'complete work task';
|
|
2031
|
+
}
|
|
2032
|
+
// Strip a leading "slug" followed by an em-dash / en-dash / hyphen separator.
|
|
2033
|
+
const prefix = new RegExp(`^${escapeRegExp(slug)}\\s*[—–-]\\s*`, 'i');
|
|
2034
|
+
return title.replace(prefix, '').trim() || title;
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
/**
|
|
2038
|
+
* The sane single-line cap for a synthesised PR title (Half A). GitHub itself
|
|
2039
|
+
* accepts long titles, but a PR list/notification truncates ugly past ~72 chars;
|
|
2040
|
+
* we cap to keep the title scannable and guarantee it is never a run-on. Beyond
|
|
2041
|
+
* the cap we truncate and append an ellipsis (counted within the cap).
|
|
2042
|
+
*/
|
|
2043
|
+
export const PR_TITLE_MAX = 72;
|
|
2044
|
+
|
|
2045
|
+
/**
|
|
2046
|
+
* The task's raw `title:` frontmatter (NOT the commit-summary-stripped form),
|
|
2047
|
+
* or undefined when missing/unreadable. Used as the human-authored source for
|
|
2048
|
+
* the synthesised PR title.
|
|
2049
|
+
*/
|
|
2050
|
+
function readTaskTitle(taskPath: string): string | undefined {
|
|
2051
|
+
try {
|
|
2052
|
+
return readTitle(readFileSync(taskPath, 'utf8'));
|
|
2053
|
+
} catch {
|
|
2054
|
+
return undefined;
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
/**
|
|
2059
|
+
* Synthesise the propose-mode PR TITLE runner-side (Half A) from data the runner
|
|
2060
|
+
* already has — NO agent text: `<type>(<slug>): <title>`, reusing the `--type`
|
|
2061
|
+
* convention (default `feat`). It is FORCED to a single line (newlines → spaces,
|
|
2062
|
+
* runs of whitespace collapsed) and CAPPED to {@link PR_TITLE_MAX} (truncating
|
|
2063
|
+
* with a trailing `…`), so it can NEVER be the multi-line run-on `gh ... --fill`
|
|
2064
|
+
* derives from the commit subject. When the task `title:` is missing it falls
|
|
2065
|
+
* back to the slug alone (`<type>(<slug>)`). Exported for unit tests of the
|
|
2066
|
+
* single-line + cap guarantee.
|
|
2067
|
+
*/
|
|
2068
|
+
export function synthesiseProposeTitle(input: {
|
|
2069
|
+
type: string;
|
|
2070
|
+
slug: string;
|
|
2071
|
+
title?: string;
|
|
2072
|
+
}): string {
|
|
2073
|
+
const type = input.type.trim() || DEFAULT_TYPE;
|
|
2074
|
+
// Strip a leading `slug — ` / `slug -` prefix (some task titles repeat the
|
|
2075
|
+
// slug; the `<slug>` scope already carries it) and flatten to one line.
|
|
2076
|
+
const prefix = new RegExp(`^${escapeRegExp(input.slug)}\\s*[—–-]\\s*`, 'i');
|
|
2077
|
+
const cleanTitle = (input.title ?? '')
|
|
2078
|
+
.replace(prefix, '')
|
|
2079
|
+
.replace(/\s+/g, ' ')
|
|
2080
|
+
.trim();
|
|
2081
|
+
const composed =
|
|
2082
|
+
cleanTitle === ''
|
|
2083
|
+
? `${type}(${input.slug})`
|
|
2084
|
+
: `${type}(${input.slug}): ${cleanTitle}`;
|
|
2085
|
+
if (composed.length <= PR_TITLE_MAX) {
|
|
2086
|
+
return composed;
|
|
2087
|
+
}
|
|
2088
|
+
// Cap, reserving one char for the ellipsis (counted within the cap).
|
|
2089
|
+
return composed.slice(0, PR_TITLE_MAX - 1).trimEnd() + '…';
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
/**
|
|
2093
|
+
* Compose the propose-mode PR BODY (Half B): the supplied advisory prose (the
|
|
2094
|
+
* build agent's final summary, or a human `--body`) UNDER a deterministic runner
|
|
2095
|
+
* header that points a reviewer back to the task file. Returns `undefined` when
|
|
2096
|
+
* no body was supplied — so the provider degrades to today's `gh ... --fill` (no
|
|
2097
|
+
* regression); the header is ONLY scaffolded when there IS prose to carry.
|
|
2098
|
+
* Exported for unit tests of the header + pointer.
|
|
2099
|
+
*/
|
|
2100
|
+
export function composeProposeBody(input: {
|
|
2101
|
+
slug: string;
|
|
2102
|
+
body?: string;
|
|
2103
|
+
}): string | undefined {
|
|
2104
|
+
const prose = input.body?.trim();
|
|
2105
|
+
if (!prose) {
|
|
2106
|
+
return undefined;
|
|
2107
|
+
}
|
|
2108
|
+
const header = `Task: \`${workItemRel('done', `${input.slug}.md`)}\``;
|
|
2109
|
+
return `${header}\n\n${prose}`;
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
/**
|
|
2113
|
+
* On a review APPROVE that carries ≥1 NON-BLOCKING finding, write ONE per-run
|
|
2114
|
+
* observation `work/notes/observations/review-nits-<slug>-<YYYY-MM-DD>.md` capturing all
|
|
2115
|
+
* of this run's non-blocking nits, so they get a durable, contract-native home
|
|
2116
|
+
* instead of evaporating (the block path already routes BLOCKING findings to
|
|
2117
|
+
* needs-attention/; the approve path dropped non-blocking ones — see
|
|
2118
|
+
* `work/findings/review-nonblocking-findings-disposition.md`).
|
|
2119
|
+
*
|
|
2120
|
+
* The RUNNER writes it (the review agent stays write-free). It is a PLAIN
|
|
2121
|
+
* pre-commit disk write — NOT the heavier `applyNeedsAttentionTransition`
|
|
2122
|
+
* move/commit/surface — so `performIntegration`'s subsequent done-move + atomic
|
|
2123
|
+
* `git add -A` commit sweeps it into the SAME done-commit on every path
|
|
2124
|
+
* (merge / propose / CI, `do` AND `run`); it is never left dangling/uncommitted.
|
|
2125
|
+
*
|
|
2126
|
+
* ZERO non-blocking findings ⇒ writes NOTHING (no empty-file spam). The file is
|
|
2127
|
+
* ONE-per-RUN (a content-derived, dated name), never an append to a shared ledger
|
|
2128
|
+
* — the dated `<slug>-<date>` name makes a later-abandoned run's nit-observation
|
|
2129
|
+
* trivially findable + deletable (lifecycle hygiene). Frontmatter mirrors the
|
|
2130
|
+
* `work/notes/observations/*.md` convention (`title` / `date` / `status: open`) plus a
|
|
2131
|
+
* `reviewOf:` back-pointer to the slug it came from, so it gets triaged like any
|
|
2132
|
+
* observation. (Identity stays the FILENAME — no `slug:` frontmatter — so the
|
|
2133
|
+
* lifecycle enumerate→resolve round-trip is total; see task
|
|
2134
|
+
* `observation-identity-is-its-filename-not-a-foreign-slug`.)
|
|
2135
|
+
*/
|
|
2136
|
+
function writeReviewNitsObservation(params: {
|
|
2137
|
+
cwd: string;
|
|
2138
|
+
slug: string;
|
|
2139
|
+
findings: ReviewFinding[];
|
|
2140
|
+
note: (message: string) => void;
|
|
2141
|
+
}): void {
|
|
2142
|
+
const nits = params.findings.filter((f) => f.severity === 'non-blocking');
|
|
2143
|
+
// No empty observations: an approve with zero non-blocking findings writes none.
|
|
2144
|
+
if (nits.length === 0) {
|
|
2145
|
+
return;
|
|
2146
|
+
}
|
|
2147
|
+
const date = observationDate();
|
|
2148
|
+
const obsDir = workFolderPath(params.cwd, 'observations');
|
|
2149
|
+
mkdirSync(obsDir, {recursive: true});
|
|
2150
|
+
const filename = `review-nits-${params.slug}-${date}.md`;
|
|
2151
|
+
writeFileSync(
|
|
2152
|
+
join(obsDir, filename),
|
|
2153
|
+
renderReviewNitsObservation({slug: params.slug, date, nits}),
|
|
2154
|
+
);
|
|
2155
|
+
params.note(
|
|
2156
|
+
`Recorded ${nits.length} non-blocking review nit(s) for '${params.slug}' ` +
|
|
2157
|
+
`in ${workItemRel('observations', filename)}.`,
|
|
2158
|
+
);
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
/** Today's date as `YYYY-MM-DD` (UTC), for the dated observation filename. */
|
|
2162
|
+
function observationDate(): string {
|
|
2163
|
+
return new Date().toISOString().slice(0, 10);
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
/**
|
|
2167
|
+
* Render the per-run review-nits observation file body — `observations/`-convention
|
|
2168
|
+
* frontmatter (`title` / `date` / `status: open`) plus a `reviewOf:` back-pointer
|
|
2169
|
+
* naming the TASK the run reviewed, then each non-blocking finding (its
|
|
2170
|
+
* `question` + optional `context`), and a one-line note that these are review-gate
|
|
2171
|
+
* nits for triage (promote-to-task / keep / delete). Exported-free pure string
|
|
2172
|
+
* builder.
|
|
2173
|
+
*
|
|
2174
|
+
* Identity rule (task `observation-identity-is-its-filename-not-a-foreign-slug`):
|
|
2175
|
+
* the observation's IDENTITY is its FILENAME (`review-nits-<slug>-<date>.md`).
|
|
2176
|
+
* The frontmatter therefore does NOT emit `slug:` — emitting the reviewed task's
|
|
2177
|
+
* slug there collided with the (now-done) reviewed task AND broke the
|
|
2178
|
+
* enumerate→resolve round-trip (the lifecycle pool keyed off `fm.slug`, which
|
|
2179
|
+
* differed from the filename). The back-pointer lives in `reviewOf:` instead, a
|
|
2180
|
+
* clearly-different field whose name cannot be mistaken for identity.
|
|
2181
|
+
*/
|
|
2182
|
+
function renderReviewNitsObservation(input: {
|
|
2183
|
+
slug: string;
|
|
2184
|
+
date: string;
|
|
2185
|
+
nits: ReviewFinding[];
|
|
2186
|
+
}): string {
|
|
2187
|
+
const findingBlocks = input.nits.map((f) => {
|
|
2188
|
+
const ctx = f.context ? `\n (${f.context})` : '';
|
|
2189
|
+
return `- ${f.question}${ctx}`;
|
|
2190
|
+
});
|
|
2191
|
+
return [
|
|
2192
|
+
'---',
|
|
2193
|
+
`title: review-gate non-blocking nits for '${input.slug}' (Gate 2 approve)`,
|
|
2194
|
+
`date: ${input.date}`,
|
|
2195
|
+
'status: open',
|
|
2196
|
+
`reviewOf: ${input.slug}`,
|
|
2197
|
+
'---',
|
|
2198
|
+
'',
|
|
2199
|
+
'## Non-blocking review findings',
|
|
2200
|
+
'',
|
|
2201
|
+
`The PR/code review gate (Gate 2) APPROVED '${input.slug}' but raised the`,
|
|
2202
|
+
'following non-blocking findings (nits). They do not block integration; this',
|
|
2203
|
+
'is their durable home for triage — promote-to-task / keep / delete.',
|
|
2204
|
+
'',
|
|
2205
|
+
...findingBlocks,
|
|
2206
|
+
'',
|
|
2207
|
+
].join('\n');
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
/** Read the `title:` scalar from a task's frontmatter block, or undefined. */
|
|
2211
|
+
function readTitle(content: string): string | undefined {
|
|
2212
|
+
const normalized = content.replace(/\r\n/g, '\n').replace(/^\uFEFF/, '');
|
|
2213
|
+
if (!normalized.startsWith('---\n')) {
|
|
2214
|
+
return undefined;
|
|
2215
|
+
}
|
|
2216
|
+
const lines = normalized.split('\n');
|
|
2217
|
+
const closing = lines.indexOf('---', 1);
|
|
2218
|
+
const block = closing === -1 ? lines.slice(1) : lines.slice(1, closing);
|
|
2219
|
+
for (const line of block) {
|
|
2220
|
+
const match = /^title\s*:\s*(.*)$/.exec(line);
|
|
2221
|
+
if (match) {
|
|
2222
|
+
const value = match[1].trim();
|
|
2223
|
+
return value === '' ? undefined : unquote(value);
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
return undefined;
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
function unquote(value: string): string {
|
|
2230
|
+
if (value.length >= 2) {
|
|
2231
|
+
const first = value[0];
|
|
2232
|
+
const last = value[value.length - 1];
|
|
2233
|
+
if ((first === '"' || first === "'") && last === first) {
|
|
2234
|
+
return value.slice(1, -1);
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
return value;
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
function escapeRegExp(value: string): string {
|
|
2241
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
/** True when the index has no staged changes against HEAD (nothing to commit). */
|
|
2245
|
+
async function nothingStaged(
|
|
2246
|
+
cwd: string,
|
|
2247
|
+
env: NodeJS.ProcessEnv | undefined,
|
|
2248
|
+
): Promise<boolean> {
|
|
2249
|
+
// `diff --cached --quiet` exits 0 when there is NOTHING staged, 1 when there is.
|
|
2250
|
+
const res = await gitSoft(['diff', '--cached', '--quiet'], cwd, env);
|
|
2251
|
+
return res.status === 0;
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
/**
|
|
2255
|
+
* The capture-bucket folders a rung's agent may write notes into (its
|
|
2256
|
+
* `capture-signal` reflex). REPORTED by {@link reportScoopedNotes} when the
|
|
2257
|
+
* runner's atomic commit scoops them. Deliberately NARROW (only the two capture
|
|
2258
|
+
* buckets) so accidental scratch files the agent left elsewhere are not announced
|
|
2259
|
+
* as captured signals — matching the observation's recommended scope.
|
|
2260
|
+
*/
|
|
2261
|
+
const CAPTURE_NOTE_DIRS = [
|
|
2262
|
+
workFolderPrefix('observations'),
|
|
2263
|
+
workFolderPrefix('findings'),
|
|
2264
|
+
] as const;
|
|
2265
|
+
|
|
2266
|
+
/**
|
|
2267
|
+
* SCOOP + REPORT the agent-authored CAPTURED NOTES this run's atomic commit is
|
|
2268
|
+
* landing (task `runner-scoops-captured-notes`). A rung's agent writes
|
|
2269
|
+
* capture-bucket files (`work/notes/observations/*`, `work/findings/*`) but does NO git
|
|
2270
|
+
* (Rule A); the caller's `git add -A` already STAGED them into THIS commit, so
|
|
2271
|
+
* they are tracked, not dropped. This extends Rule B: the runner REPORTS exactly
|
|
2272
|
+
* which note files landed — read from the STAGED set (`git diff --cached`), so it
|
|
2273
|
+
* reports what ACTUALLY reached the commit, never an assumption.
|
|
2274
|
+
*
|
|
2275
|
+
* It is honest reporting, the same model as the review-nits observation report:
|
|
2276
|
+
* a PLAIN read + `note(...)`, no extra git (the commit owns the files). Zero
|
|
2277
|
+
* captured notes ⇒ NOTHING is reported (the no-note case is byte-for-byte
|
|
2278
|
+
* unchanged). Read-only / best-effort: a failed status read reports nothing
|
|
2279
|
+
* rather than crashing the integrate. Because it lives in the shared core, BOTH
|
|
2280
|
+
* the build path AND the tasking path report identically — the channel is not
|
|
2281
|
+
* forked.
|
|
2282
|
+
*/
|
|
2283
|
+
async function reportScoopedNotes(
|
|
2284
|
+
cwd: string,
|
|
2285
|
+
env: NodeJS.ProcessEnv | undefined,
|
|
2286
|
+
note: (message: string) => void,
|
|
2287
|
+
): Promise<void> {
|
|
2288
|
+
const notes = await stagedCaptureNotes(cwd, env);
|
|
2289
|
+
if (notes.length === 0) {
|
|
2290
|
+
return;
|
|
2291
|
+
}
|
|
2292
|
+
note(
|
|
2293
|
+
`Scooped ${notes.length} agent-authored captured note` +
|
|
2294
|
+
`${notes.length === 1 ? '' : 's'} into this commit: ${notes.join(', ')}.`,
|
|
2295
|
+
);
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
/**
|
|
2299
|
+
* The repo-relative paths of the capture-bucket files (`work/notes/observations/*`,
|
|
2300
|
+
* `work/findings/*`) STAGED for THIS commit — i.e. new-or-changed vs HEAD, exactly
|
|
2301
|
+
* what the runner is about to land. Read via `git diff --cached --name-only` so it
|
|
2302
|
+
* reflects the real staged set (`git add -A` already ran), filtered to the capture
|
|
2303
|
+
* buckets and sorted for a deterministic report. Best-effort: a non-zero status
|
|
2304
|
+
* reads as no notes (never crashes the integrate). Exported-free; pure read.
|
|
2305
|
+
*/
|
|
2306
|
+
async function stagedCaptureNotes(
|
|
2307
|
+
cwd: string,
|
|
2308
|
+
env: NodeJS.ProcessEnv | undefined,
|
|
2309
|
+
): Promise<string[]> {
|
|
2310
|
+
const res = await gitSoft(['diff', '--cached', '--name-only'], cwd, env);
|
|
2311
|
+
if (res.status !== 0) {
|
|
2312
|
+
return [];
|
|
2313
|
+
}
|
|
2314
|
+
return res.stdout
|
|
2315
|
+
.split('\n')
|
|
2316
|
+
.map((line) => line.trim())
|
|
2317
|
+
.filter((path) => CAPTURE_NOTE_DIRS.some((dir) => path.startsWith(dir)))
|
|
2318
|
+
.sort();
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
/**
|
|
2322
|
+
* The DURABLE `work/` status folders a slug's ledger file can resting-live in (the
|
|
2323
|
+
* one-slug-one-folder set the invariant is asserted over). After the capstone
|
|
2324
|
+
* cut-over (task `cutover-retire-slicing-advancing-markers-and-trim-folder-sets`,
|
|
2325
|
+
* prd `ledger-status-per-item-lock-refs`) the ONLY `work/` moves on `main` are the
|
|
2326
|
+
* durable resting transitions, so the source a build completes FROM is `backlog/`
|
|
2327
|
+
* (claim no longer moves the body, task
|
|
2328
|
+
* `cutover-claim-body-stays-and-complete-sources-from-backlog`) and the canonical
|
|
2329
|
+
* done-move destination is `done/`. The task regime's won't-proceed terminal
|
|
2330
|
+
* `tasks/cancelled/` is also a durable resting folder, so the one-slug-one-folder
|
|
2331
|
+
* guard covers it (a slug in `tasks/cancelled/` AND another durable folder is a
|
|
2332
|
+
* corrupt ledger to refuse). The
|
|
2333
|
+
* transient `in-progress`/`needs-attention` are GONE from `main`'s tree (they are
|
|
2334
|
+
* per-item lock-ref state now).
|
|
2335
|
+
*/
|
|
2336
|
+
/**
|
|
2337
|
+
* The folders {@link readArbiterLedgerPlacement} scans for a slug's source on the
|
|
2338
|
+
* arbiter: the durable `LEDGER_STATUS_FOLDERS` (`tasks-ready`/`done`/`cancelled`)
|
|
2339
|
+
* PLUS `tasks-backlog` (staging). Staging is included ONLY for the arbiter-side
|
|
2340
|
+
* source RESOLUTION of a `--allow-backlog` done-move (prd
|
|
2341
|
+
* `do-allow-backlog-drive-staged-tasks-without-promotion`) and the one-slug-one-
|
|
2342
|
+
* folder guard over the malformed "same slug in `tasks/ready/` AND `tasks/backlog/`"
|
|
2343
|
+
* state; it is DELIBERATELY NOT added to the shared `LEDGER_STATUS_FOLDERS` (which
|
|
2344
|
+
* ledger-lint's duplicate detection + the sibling-ledger reconcile reuse and which
|
|
2345
|
+
* deliberately omits the non-resting staging folder).
|
|
2346
|
+
*/
|
|
2347
|
+
const ARBITER_PLACEMENT_FOLDERS = [
|
|
2348
|
+
...LEDGER_STATUS_FOLDERS,
|
|
2349
|
+
'tasks-backlog',
|
|
2350
|
+
] as const satisfies readonly WorkFolderKey[];
|
|
2351
|
+
|
|
2352
|
+
/** One of the folders the arbiter placement read scans. */
|
|
2353
|
+
type ArbiterPlacementFolder = (typeof ARBITER_PLACEMENT_FOLDERS)[number];
|
|
2354
|
+
|
|
2355
|
+
/** The result of {@link readArbiterLedgerPlacement}. */
|
|
2356
|
+
interface ArbiterLedgerPlacement {
|
|
2357
|
+
/**
|
|
2358
|
+
* Set when the ONE-SLUG-ONE-FOLDER guard FAILED LOUD: the arbiter already holds
|
|
2359
|
+
* the slug in >1 status folder with DIFFERING content (a corrupt ledger). The
|
|
2360
|
+
* caller maps it to the `invariant-violation` outcome and refuses — nothing is
|
|
2361
|
+
* published over the corruption.
|
|
2362
|
+
*/
|
|
2363
|
+
error?: string;
|
|
2364
|
+
/**
|
|
2365
|
+
* The NON-`done` status folders the ARBITER currently holds the slug in (e.g.
|
|
2366
|
+
* `['in-progress']` or `['needs-attention']`). Empty when the arbiter holds it
|
|
2367
|
+
* only in `done/`, holds it nowhere, or the tracking ref could not be read.
|
|
2368
|
+
*/
|
|
2369
|
+
sourceFolders: string[];
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
/**
|
|
2373
|
+
* Read WHICH `work/<folder>/<slug>.md` the ARBITER currently holds the slug in,
|
|
2374
|
+
* from the `<arbiter>/main` TRACKING REF (the source of truth). It is a pure READ
|
|
2375
|
+
* of the ref the caller has ALREADY fetched (the step-4 rebase fetch) — it does
|
|
2376
|
+
* NOT fetch, so it never races a sibling job's integration on the shared
|
|
2377
|
+
* bare-mirror refs (ADR §2; a new fetch here was the regression that orphaned
|
|
2378
|
+
* this very work).
|
|
2379
|
+
*
|
|
2380
|
+
* It ENFORCES the one-slug-one-folder invariant: if the arbiter already holds the
|
|
2381
|
+
* slug in MORE THAN ONE status folder it is a pre-existing corrupt ledger — it
|
|
2382
|
+
* FAILS LOUD (returns an `error`) rather than silently pick one, UNLESS it is
|
|
2383
|
+
* PROVABLY SAFE (every copy is byte-identical, so the canonical `done/`
|
|
2384
|
+
* destination is unambiguous), mirroring the manual `279b542` cleanup.
|
|
2385
|
+
*
|
|
2386
|
+
* It scans {@link ARBITER_PLACEMENT_FOLDERS} — the durable `LEDGER_STATUS_FOLDERS`
|
|
2387
|
+
* PLUS `tasks-backlog`, so a `--allow-backlog` staged drive (prd
|
|
2388
|
+
* `do-allow-backlog-drive-staged-tasks-without-promotion`) whose done-move sources
|
|
2389
|
+
* from `tasks/backlog/` is DISCOVERED here too (the arbiter is the authority for
|
|
2390
|
+
* the actual source folder; the local `source` is the fallback). Including staging
|
|
2391
|
+
* also makes the one-slug-one-folder guard cover the malformed "same slug in both
|
|
2392
|
+
* `tasks/ready/` and `tasks/backlog/`" state (the prd's decision 5): it FAILS LOUD
|
|
2393
|
+
* rather than the resolver silently arbitrating a collision the contract forbids.
|
|
2394
|
+
*/
|
|
2395
|
+
function readArbiterLedgerPlacement(
|
|
2396
|
+
cwd: string,
|
|
2397
|
+
arbiter: string,
|
|
2398
|
+
slug: string,
|
|
2399
|
+
env: NodeJS.ProcessEnv | undefined,
|
|
2400
|
+
): ArbiterLedgerPlacement {
|
|
2401
|
+
const arbiterRef = `${arbiter}/main`;
|
|
2402
|
+
const placements: {folder: ArbiterPlacementFolder; blob: string}[] = [];
|
|
2403
|
+
for (const folder of ARBITER_PLACEMENT_FOLDERS) {
|
|
2404
|
+
const path = workItemRel(folder, `${slug}.md`);
|
|
2405
|
+
const ls = run('git', ['ls-tree', arbiterRef, path], cwd, {env});
|
|
2406
|
+
const line = ls.stdout.trim();
|
|
2407
|
+
if (ls.status !== 0 || line === '') {
|
|
2408
|
+
continue;
|
|
2409
|
+
}
|
|
2410
|
+
const match = /^\d+ blob ([0-9a-f]+)\t/.exec(line);
|
|
2411
|
+
if (match) {
|
|
2412
|
+
placements.push({folder, blob: match[1]});
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
const sourceFolders = placements
|
|
2416
|
+
.filter((p) => p.folder !== 'done')
|
|
2417
|
+
.map((p) => p.folder);
|
|
2418
|
+
|
|
2419
|
+
if (placements.length > 1) {
|
|
2420
|
+
const uniqueBlobs = new Set(placements.map((p) => p.blob));
|
|
2421
|
+
const folders = placements
|
|
2422
|
+
.map((p) => workFolderPrefix(p.folder))
|
|
2423
|
+
.join(', ');
|
|
2424
|
+
if (uniqueBlobs.size !== 1) {
|
|
2425
|
+
return {
|
|
2426
|
+
error:
|
|
2427
|
+
`one-slug-one-folder invariant violated: '${slug}' is present in more ` +
|
|
2428
|
+
`than one status folder on ${arbiterRef} (${folders}) with DIFFERING ` +
|
|
2429
|
+
`content — refusing to publish a corrupt ledger. Resolve the duplicate ` +
|
|
2430
|
+
`(keep the correct copy, delete the stale one) and re-run; ` +
|
|
2431
|
+
`'dorfl scan'/'gc' surfaces such duplicates.`,
|
|
2432
|
+
sourceFolders,
|
|
2433
|
+
};
|
|
2434
|
+
}
|
|
2435
|
+
// Provably safe (byte-identical copies): the duplicate is auto-cleaned by the
|
|
2436
|
+
// divergent-done-move reconciliation, which moves the slug to `done/` ONLY.
|
|
2437
|
+
}
|
|
2438
|
+
return {sourceFolders};
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
/** The `work/<status>/` prefixes a ledger file can live under (no trailing `/`). */
|
|
2442
|
+
const LEDGER_FOLDER_PREFIXES = LEDGER_STATUS_FOLDERS.map((folder) =>
|
|
2443
|
+
workFolderPrefix(folder),
|
|
2444
|
+
);
|
|
2445
|
+
|
|
2446
|
+
/**
|
|
2447
|
+
* Classify a rebase-conflicted path: is it a SIBLING-slug ledger file (a
|
|
2448
|
+
* `work/<status>/<otherslug>.md` for some slug OTHER than `ourSlug`)? Returns
|
|
2449
|
+
* `false` for any code file AND for THIS slug's own ledger file (both must keep
|
|
2450
|
+
* routing to needs-attention — the sibling arm NEVER widens to code or own-ledger).
|
|
2451
|
+
*/
|
|
2452
|
+
function isSiblingLedgerPath(path: string, ourSlug: string): boolean {
|
|
2453
|
+
const prefix = LEDGER_FOLDER_PREFIXES.find((p) => path.startsWith(p));
|
|
2454
|
+
if (prefix === undefined) {
|
|
2455
|
+
return false; // not a ledger file at all — a code file (or non-ledger work/ file).
|
|
2456
|
+
}
|
|
2457
|
+
const rest = path.slice(prefix.length);
|
|
2458
|
+
if (!rest.endsWith('.md') || rest.includes('/')) {
|
|
2459
|
+
return false; // not a `<slug>.md` directly under the status folder.
|
|
2460
|
+
}
|
|
2461
|
+
const otherSlug = rest.slice(0, -'.md'.length);
|
|
2462
|
+
return otherSlug !== ourSlug; // OUR own ledger is NOT a sibling — it routes as today.
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
/**
|
|
2466
|
+
* Reconcile a SIBLING-SLUG ledger conflict during the step-4 rebase WITHOUT
|
|
2467
|
+
* aborting it (Race 2 of `run-fleet-claim-integrate-and-sibling-rebase-concurrency-safe`).
|
|
2468
|
+
* Called WHILE the rebase is still in progress (right after `git rebase` returned
|
|
2469
|
+
* non-zero): a sibling same-repo job landed its OWN `work/<status>/<otherslug>.md`
|
|
2470
|
+
* move on `<arbiter>/main` between our base and this rebase, so replaying our
|
|
2471
|
+
* commit conflicts on that OTHER slug's ledger file — a benign ledger-only
|
|
2472
|
+
* divergence, NOT a real code conflict.
|
|
2473
|
+
*
|
|
2474
|
+
* STRICT SCOPE (the safety fence): it reconciles ONLY when EVERY conflicted path
|
|
2475
|
+
* is a SIBLING slug's ledger file ({@link isSiblingLedgerPath}). If ANY conflicted
|
|
2476
|
+
* path is a CODE file, a non-ledger `work/` file, or THIS slug's OWN ledger file,
|
|
2477
|
+
* it does NOTHING (returns `false`) so the caller aborts + routes to
|
|
2478
|
+
* needs-attention exactly as today — it NEVER widens to code or own-ledger.
|
|
2479
|
+
*
|
|
2480
|
+
* The resolution takes the ARBITER's (rebased-onto) version of each sibling
|
|
2481
|
+
* ledger file (`git checkout --ours` — during a rebase `--ours` is the base we are
|
|
2482
|
+
* replaying ONTO, i.e. `<arbiter>/main`), stages it, and `git rebase --continue`s,
|
|
2483
|
+
* looping until the rebase completes (a later replayed commit could re-conflict on
|
|
2484
|
+
* a sibling ledger). Returns `true` once the rebase finished cleanly (the caller
|
|
2485
|
+
* falls through to integrate); returns `false` — leaving the rebase in progress —
|
|
2486
|
+
* when the conflict is out of scope (the caller aborts + routes).
|
|
2487
|
+
*/
|
|
2488
|
+
async function reconcileSiblingLedgerConflict(params: {
|
|
2489
|
+
cwd: string;
|
|
2490
|
+
arbiter: string;
|
|
2491
|
+
slug: string;
|
|
2492
|
+
env: NodeJS.ProcessEnv | undefined;
|
|
2493
|
+
note: (message: string) => void;
|
|
2494
|
+
}): Promise<boolean> {
|
|
2495
|
+
const {cwd, arbiter, slug, env} = params;
|
|
2496
|
+
const arbiterRef = `${arbiter}/main`;
|
|
2497
|
+
|
|
2498
|
+
// The conflicted (unmerged) paths of the failed rebase step. Read them WHILE the
|
|
2499
|
+
// rebase is still in progress (the caller invokes us right after the non-zero
|
|
2500
|
+
// rebase), BEFORE we abort — so we know what conflicted.
|
|
2501
|
+
const conflicted = (
|
|
2502
|
+
await gitSoft(['diff', '--name-only', '--diff-filter=U'], cwd, env)
|
|
2503
|
+
).stdout
|
|
2504
|
+
.split('\n')
|
|
2505
|
+
.map((line) => line.trim())
|
|
2506
|
+
.filter((line) => line !== '');
|
|
2507
|
+
if (conflicted.length === 0) {
|
|
2508
|
+
return false; // not a conflict we can reason about here — defer to the caller.
|
|
2509
|
+
}
|
|
2510
|
+
// SCOPE GATE: every conflicted path MUST be a SIBLING slug's ledger file. Any
|
|
2511
|
+
// code file / own-ledger / non-ledger work file disqualifies the WHOLE
|
|
2512
|
+
// reconciliation (never widen to code) — the caller aborts + routes.
|
|
2513
|
+
if (!conflicted.every((path) => isSiblingLedgerPath(path, slug))) {
|
|
2514
|
+
return false;
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
// Benign sibling-ledger divergence. Rather than the fragile in-progress
|
|
2518
|
+
// `git rebase --continue` (which mutates the shared-worktree branch ref mid-
|
|
2519
|
+
// rebase and flakes under same-repo fleet ref contention), ABORT and REDO our
|
|
2520
|
+
// own work as ONE clean commit on top of `<arbiter>/main` — the SAME safe
|
|
2521
|
+
// reset-and-redo pattern `reconcileDivergentDoneMove` uses. This automatically
|
|
2522
|
+
// takes the arbiter's version of EVERY sibling ledger file (they live in the
|
|
2523
|
+
// reset base, untouched) while preserving OUR agent edits + OUR done-move (kept
|
|
2524
|
+
// in the working tree by the mixed reset). NO semantic judgement, NO `--ours`/
|
|
2525
|
+
// `--theirs` heuristic on any code file.
|
|
2526
|
+
await gitSoft(['rebase', '--abort'], cwd, env);
|
|
2527
|
+
|
|
2528
|
+
// Re-point the branch onto `<arbiter>/main`, KEEPING the working tree (our edits
|
|
2529
|
+
// + our done-move): a mixed reset moves HEAD + index to the arbiter base but
|
|
2530
|
+
// leaves the working tree intact. Our own changes stay in the tree. (`HEAD` was
|
|
2531
|
+
// restored to our work-branch tip by the abort above.)
|
|
2532
|
+
const reset = await gitSoft(
|
|
2533
|
+
['reset', '--mixed', '--quiet', arbiterRef],
|
|
2534
|
+
cwd,
|
|
2535
|
+
env,
|
|
2536
|
+
);
|
|
2537
|
+
if (reset.status !== 0) {
|
|
2538
|
+
return false;
|
|
2539
|
+
}
|
|
2540
|
+
// Take the ARBITER's placement of every SIBLING slug whose ledger conflicted: the
|
|
2541
|
+
// conflict means we touched a sibling's ledger file that the arbiter moved, so our
|
|
2542
|
+
// working-tree copy is STALE. Hard-restore EVERY ledger folder for each affected
|
|
2543
|
+
// sibling slug from the arbiter (index + working tree) and drop any stray copy our
|
|
2544
|
+
// tree still holds, so the sibling's OWN status-folder move (e.g. its done-move) is
|
|
2545
|
+
// honoured verbatim — never clobbered by our stale touch, never duplicated.
|
|
2546
|
+
const siblingSlugs = new Set<string>();
|
|
2547
|
+
for (const path of conflicted) {
|
|
2548
|
+
const prefix = LEDGER_FOLDER_PREFIXES.find((p) => path.startsWith(p));
|
|
2549
|
+
if (prefix !== undefined) {
|
|
2550
|
+
siblingSlugs.add(path.slice(prefix.length, -'.md'.length));
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
for (const otherSlug of siblingSlugs) {
|
|
2554
|
+
for (const folder of LEDGER_STATUS_FOLDERS) {
|
|
2555
|
+
const ledgerPath = workItemRel(folder, `${otherSlug}.md`);
|
|
2556
|
+
const onArbiter =
|
|
2557
|
+
(
|
|
2558
|
+
await gitSoft(
|
|
2559
|
+
['cat-file', '-e', `${arbiterRef}:${ledgerPath}`],
|
|
2560
|
+
cwd,
|
|
2561
|
+
env,
|
|
2562
|
+
)
|
|
2563
|
+
).status === 0;
|
|
2564
|
+
if (onArbiter) {
|
|
2565
|
+
// The arbiter holds the sibling here — take its exact copy.
|
|
2566
|
+
await gitSoft(['checkout', arbiterRef, '--', ledgerPath], cwd, env);
|
|
2567
|
+
} else {
|
|
2568
|
+
// The arbiter does NOT hold the sibling here — drop any stale copy ours has.
|
|
2569
|
+
const abs = join(cwd, ledgerPath);
|
|
2570
|
+
if (existsSync(abs)) {
|
|
2571
|
+
rmSync(abs, {force: true});
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
// Stage everything (our agent edits + our arbiter-aligned ledger move; the
|
|
2577
|
+
// sibling ledgers are already at the arbiter version) and commit ONE clean
|
|
2578
|
+
// commit on top of `<arbiter>/main`. Nothing staged ⇒ the work is already on the
|
|
2579
|
+
// arbiter (an already-integrated no-op) — treat as cleanly reconciled.
|
|
2580
|
+
await gitSoft(['add', '-A'], cwd, env);
|
|
2581
|
+
if ((await gitSoft(['diff', '--cached', '--quiet'], cwd, env)).status === 0) {
|
|
2582
|
+
return true;
|
|
2583
|
+
}
|
|
2584
|
+
await gitHard(
|
|
2585
|
+
[
|
|
2586
|
+
'commit',
|
|
2587
|
+
'-q',
|
|
2588
|
+
'-m',
|
|
2589
|
+
`feat(${slug}): reconcile sibling-ledger rebase; done`,
|
|
2590
|
+
],
|
|
2591
|
+
cwd,
|
|
2592
|
+
env,
|
|
2593
|
+
);
|
|
2594
|
+
return true;
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
/**
|
|
2598
|
+
* Recover a DIVERGENT-BASE done-move whose plain rebase CONFLICTED (ledger-
|
|
2599
|
+
* integrity defect 1, the PR #86 ghost). The arbiter holds the slug's source in a
|
|
2600
|
+
* DIFFERENT folder than our local done-move removed, so replaying the local
|
|
2601
|
+
* `-work/<localsrc>/<slug>.md +work/done/<slug>.md` patch onto `<arbiter>/main`
|
|
2602
|
+
* (which lacks `<localsrc>`) conflicts on the ledger file.
|
|
2603
|
+
*
|
|
2604
|
+
* It reconciles WITHOUT any semantic judgement (so it is safe automatically,
|
|
2605
|
+
* unlike a real code conflict): RESET the work branch onto `<arbiter>/main` (the
|
|
2606
|
+
* working tree kept), then redo the done-move ARBITER-RESOLVED — remove the slug
|
|
2607
|
+
* from EVERY non-`done` folder the arbiter holds it in, write `work/done/<slug>.md`
|
|
2608
|
+
* — and commit ONE done commit on top of `<arbiter>/main`. The branch ends cleanly
|
|
2609
|
+
* on the arbiter with the slug in `done/` ONLY (the move is a MOVE, not a copy);
|
|
2610
|
+
* no further rebase is needed.
|
|
2611
|
+
*
|
|
2612
|
+
* Returns `true` on success (the caller falls through to integrate). Returns
|
|
2613
|
+
* `false` when this is NOT the divergent-ledger case (the arbiter holds the slug
|
|
2614
|
+
* only in `done/`/nowhere, or the placement read failed) so the caller routes the
|
|
2615
|
+
* genuine conflict to needs-attention unchanged.
|
|
2616
|
+
*/
|
|
2617
|
+
async function reconcileDivergentDoneMove(params: {
|
|
2618
|
+
cwd: string;
|
|
2619
|
+
arbiter: string;
|
|
2620
|
+
slug: string;
|
|
2621
|
+
branch: string;
|
|
2622
|
+
/** The folder the LOCAL done-move removed the slug from (its `git mv` source). */
|
|
2623
|
+
localSource: 'tasks-ready' | 'tasks-backlog' | 'in-progress';
|
|
2624
|
+
env: NodeJS.ProcessEnv | undefined;
|
|
2625
|
+
note: (message: string) => void;
|
|
2626
|
+
}): Promise<boolean> {
|
|
2627
|
+
const {cwd, arbiter, slug, localSource, env} = params;
|
|
2628
|
+
const arbiterRef = `${arbiter}/main`;
|
|
2629
|
+
|
|
2630
|
+
// The arbiter's current placement (read from the already-fetched ref — no
|
|
2631
|
+
// fetch). This recovery applies ONLY to the divergent-LEDGER conflict: the
|
|
2632
|
+
// arbiter holds the slug's source in a DIFFERENT folder than our local done-move
|
|
2633
|
+
// removed it from. When the arbiter's source folder MATCHES our local source (or
|
|
2634
|
+
// the arbiter holds it only in `done/`/nowhere), the rebase conflict is a
|
|
2635
|
+
// genuine CODE conflict (e.g. the agent's edits vs an advanced main) — NEVER
|
|
2636
|
+
// auto-resolved; defer to the needs-attention route.
|
|
2637
|
+
const placement = readArbiterLedgerPlacement(cwd, arbiter, slug, env);
|
|
2638
|
+
if (placement.error || placement.sourceFolders.length === 0) {
|
|
2639
|
+
return false;
|
|
2640
|
+
}
|
|
2641
|
+
if (placement.sourceFolders.includes(localSource)) {
|
|
2642
|
+
// The arbiter still holds the slug in the SAME source folder we moved from —
|
|
2643
|
+
// the ledger placement agrees, so the conflict is NOT a divergent-ledger one.
|
|
2644
|
+
return false;
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
// Capture the slug's ledger content (our tip's done/ copy, or any source copy)
|
|
2648
|
+
// BEFORE we reset — it is what lands in `done/`.
|
|
2649
|
+
let ledgerContent: string | undefined;
|
|
2650
|
+
// Scan the ARBITER-placement set (durable folders PLUS `tasks-backlog`) so a
|
|
2651
|
+
// `--allow-backlog` staged-drive's source copy is captured too. `done` first so
|
|
2652
|
+
// our tip's already-moved copy wins.
|
|
2653
|
+
const captureOrder: readonly ArbiterPlacementFolder[] = [
|
|
2654
|
+
'done',
|
|
2655
|
+
...ARBITER_PLACEMENT_FOLDERS,
|
|
2656
|
+
];
|
|
2657
|
+
for (const folder of captureOrder) {
|
|
2658
|
+
const abs = workItemPath(cwd, folder, slug);
|
|
2659
|
+
if (existsSync(abs)) {
|
|
2660
|
+
ledgerContent = readFileSync(abs, 'utf8');
|
|
2661
|
+
break;
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
// Re-point the branch onto `<arbiter>/main`, KEEPING the working tree (the
|
|
2666
|
+
// agent's edits + our done/ file): a mixed reset moves HEAD + the index to the
|
|
2667
|
+
// arbiter base but leaves the working tree intact. We then fix only the LEDGER
|
|
2668
|
+
// placement against the arbiter and commit one clean done commit.
|
|
2669
|
+
const reset = await gitSoft(
|
|
2670
|
+
['reset', '--mixed', '--quiet', arbiterRef],
|
|
2671
|
+
cwd,
|
|
2672
|
+
env,
|
|
2673
|
+
);
|
|
2674
|
+
if (reset.status !== 0) {
|
|
2675
|
+
return false;
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
// Arbiter-resolved ledger placement: write `done/` from the captured content,
|
|
2679
|
+
// and remove every non-`done` copy (the arbiter's source folder is now checked
|
|
2680
|
+
// out by the reset; any stale local source is swept too).
|
|
2681
|
+
mkdirSync(workFolderPath(cwd, 'done'), {recursive: true});
|
|
2682
|
+
const donePath = workItemPath(cwd, 'done', slug);
|
|
2683
|
+
if (ledgerContent !== undefined) {
|
|
2684
|
+
writeFileSync(donePath, ledgerContent);
|
|
2685
|
+
}
|
|
2686
|
+
// Sweep every non-`done` copy the arbiter could hold the slug in, INCLUDING
|
|
2687
|
+
// `tasks-backlog` (a `--allow-backlog` staged drive), so the move is a MOVE not
|
|
2688
|
+
// a copy.
|
|
2689
|
+
for (const folder of ARBITER_PLACEMENT_FOLDERS) {
|
|
2690
|
+
if (folder === 'done') {
|
|
2691
|
+
continue;
|
|
2692
|
+
}
|
|
2693
|
+
const abs = workItemPath(cwd, folder, slug);
|
|
2694
|
+
if (existsSync(abs)) {
|
|
2695
|
+
rmSync(abs, {force: true});
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
// Stage everything (agent work + the arbiter-aligned ledger move) and commit a
|
|
2700
|
+
// single done commit on top of `<arbiter>/main`. Nothing staged ⇒ the slug is
|
|
2701
|
+
// already done on the arbiter (an already-integrated no-op) — treat as cleanly
|
|
2702
|
+
// reconciled.
|
|
2703
|
+
await gitSoft(['add', '-A'], cwd, env);
|
|
2704
|
+
if ((await gitSoft(['diff', '--cached', '--quiet'], cwd, env)).status === 0) {
|
|
2705
|
+
return true;
|
|
2706
|
+
}
|
|
2707
|
+
await gitHard(
|
|
2708
|
+
['commit', '-q', '-m', `feat(${slug}): reconcile done-move; done`],
|
|
2709
|
+
cwd,
|
|
2710
|
+
env,
|
|
2711
|
+
);
|
|
2712
|
+
return true;
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
/**
|
|
2716
|
+
* The outcome of the Gate-2 review run ({@link runGate2Review}): either it BLOCKED
|
|
2717
|
+
* (a ready-to-return {@link IntegrationCoreResult}) or it APPROVED (the verdict to
|
|
2718
|
+
* carry).
|
|
2719
|
+
*/
|
|
2720
|
+
type Gate2ReviewOutcome =
|
|
2721
|
+
| {kind: 'blocked'; result: IntegrationCoreResult}
|
|
2722
|
+
| {
|
|
2723
|
+
kind: 'approved';
|
|
2724
|
+
verdict: ReviewVerdict | undefined;
|
|
2725
|
+
};
|
|
2726
|
+
|
|
2727
|
+
/**
|
|
2728
|
+
* Run the Gate-2 PR/code REVIEW gate against a given tree ({@param reviewCwd}) and
|
|
2729
|
+
* route a BLOCK to needs-attention, returning DATA the caller acts on. Factored out
|
|
2730
|
+
* of {@link performIntegration} so the SAME gate can run in TWO places without
|
|
2731
|
+
* forking its logic (MAINTAINER DECISION 2, task `gate-on-rebased-tip-fresh-worktree`):
|
|
2732
|
+
*
|
|
2733
|
+
* - fresh-worktree gate OFF: the caller invokes it at the FRONT on the pre-rebase
|
|
2734
|
+
* `cwd`, right after the front `verify` (today's order, byte-for-byte);
|
|
2735
|
+
* - fresh-worktree gate ON: the caller invokes it on the REBASED TIP (the fresh
|
|
2736
|
+
* gate worktree), right AFTER the rebased-tip `verify` passes — so
|
|
2737
|
+
* verify-THEN-review holds on the SAME merged tree.
|
|
2738
|
+
*
|
|
2739
|
+
* The review AGENT inspects {@param reviewCwd} (the tree under review); the
|
|
2740
|
+
* needs-attention ROUTING always targets `params.cwd` (where the work branch +
|
|
2741
|
+
* ledger live), so the routing is identical whichever tree was reviewed — ONLY the
|
|
2742
|
+
* source of the reviewed tree moved, not the verdict handling.
|
|
2743
|
+
*
|
|
2744
|
+
* It NEVER mutates `mode` or writes the nits observation itself (those are the
|
|
2745
|
+
* caller's concern, because WHEN they happen differs by path — pre-commit on OFF,
|
|
2746
|
+
* post-commit-amend on ON); it returns the verdict and lets the caller place those
|
|
2747
|
+
* effects correctly for its band position.
|
|
2748
|
+
*/
|
|
2749
|
+
async function runGate2Review(params: {
|
|
2750
|
+
/** The tree the review AGENT inspects (pre-rebase `cwd` OFF; rebased tip ON). */
|
|
2751
|
+
reviewCwd: string;
|
|
2752
|
+
input: IntegrationCoreInput;
|
|
2753
|
+
slug: string;
|
|
2754
|
+
branch: string;
|
|
2755
|
+
/** Where the work branch + ledger live (the needs-attention routing target). */
|
|
2756
|
+
cwd: string;
|
|
2757
|
+
env: NodeJS.ProcessEnv | undefined;
|
|
2758
|
+
note: (message: string) => void;
|
|
2759
|
+
}): Promise<Gate2ReviewOutcome> {
|
|
2760
|
+
const {reviewCwd, input, slug, branch, cwd, env, note} = params;
|
|
2761
|
+
const reviewGate = input.reviewGate;
|
|
2762
|
+
if (!reviewGate) {
|
|
2763
|
+
// `review` on with no gate wired is a usage error — the floor must never be
|
|
2764
|
+
// silently skipped. (Production always wires `harnessReviewGate`.) The caller's
|
|
2765
|
+
// try/catch maps a thrown error to its usage-error outcome.
|
|
2766
|
+
throw new Error(
|
|
2767
|
+
`review is on but no review gate is configured — cannot run Gate 2 ` +
|
|
2768
|
+
`for '${slug}' (this is a wiring bug; the gate must not be skipped).`,
|
|
2769
|
+
);
|
|
2770
|
+
}
|
|
2771
|
+
const maxRounds = Math.max(1, input.reviewMaxRounds ?? 2);
|
|
2772
|
+
note('Running the PR/code review gate (Gate 2)…');
|
|
2773
|
+
// CORROBORATED-APPROVAL semantics (NOT retry-until-pass): the gate runs the
|
|
2774
|
+
// reviewer up to `reviewMaxRounds` times on the SAME tip and approves ONLY if
|
|
2775
|
+
// EVERY round approves. A `block` is TERMINAL — it short-circuits the loop and
|
|
2776
|
+
// is never re-rolled, because the reviewer is stochastic and re-reviewing an
|
|
2777
|
+
// UNCHANGED tip after a block would just be a dice re-roll that could launder a
|
|
2778
|
+
// real reject into a pass. The extra rounds therefore exist to make a FALSE
|
|
2779
|
+
// APPROVE harder to slip through (a second reviewer gets a veto), never to give
|
|
2780
|
+
// blocked work a second chance. (A future builder-REVISE step that mutates the
|
|
2781
|
+
// tree between rounds is the ONLY thing that should make a block retryable; it
|
|
2782
|
+
// would change the artifact under review and is not implemented here.)
|
|
2783
|
+
let approved = false;
|
|
2784
|
+
let lastVerdict: ReviewVerdict | undefined;
|
|
2785
|
+
for (let round = 1; round <= maxRounds; round++) {
|
|
2786
|
+
let verdict: ReviewVerdict;
|
|
2787
|
+
try {
|
|
2788
|
+
verdict = await reviewGate({
|
|
2789
|
+
slug,
|
|
2790
|
+
cwd: reviewCwd,
|
|
2791
|
+
reviewModel: input.reviewModel,
|
|
2792
|
+
round,
|
|
2793
|
+
// `--watch` threading (task `watch-review-session`): when on, the production
|
|
2794
|
+
// gate tails the review session live. OFF ⇒ the plain sync launch, unchanged.
|
|
2795
|
+
watch: input.watch,
|
|
2796
|
+
watchSink: input.watchSink,
|
|
2797
|
+
color: input.color,
|
|
2798
|
+
sessionsDir: input.sessionsDir,
|
|
2799
|
+
// The review AGENT launches with the AMBIENT env, never the identity-scoped
|
|
2800
|
+
// `env` (an agent must not act as the bot). Falls back to `env` when no
|
|
2801
|
+
// identity is configured (unchanged for non-identity callers).
|
|
2802
|
+
env: input.agentEnv ?? env,
|
|
2803
|
+
});
|
|
2804
|
+
} catch (err) {
|
|
2805
|
+
if (!(err instanceof ReviewParseError)) {
|
|
2806
|
+
// Anything else (a harness/connection throw, a programmer bug) is NOT this
|
|
2807
|
+
// gate's concern — re-throw so the existing catch sites classify it.
|
|
2808
|
+
throw err;
|
|
2809
|
+
}
|
|
2810
|
+
// THE GATE RAN BUT ITS VERDICT WAS UNREADABLE (direction 1, the safety net):
|
|
2811
|
+
// the reviewer did NOT block — the gate's OUTPUT could not be parsed (a
|
|
2812
|
+
// malformed JSON verdict, common on large diffs + weaker models, AFTER the
|
|
2813
|
+
// direction-2 repair pass could not salvage it). WITHOUT this catch the throw
|
|
2814
|
+
// escapes the core and `performComplete` maps it to the generic `usage-error`
|
|
2815
|
+
// (verbatim, no push, no surface) AFTER the green build but BEFORE the
|
|
2816
|
+
// done-move/push — STRANDING the lock + work branch with no PR.
|
|
2817
|
+
//
|
|
2818
|
+
// A parse failure in ANY round is TERMINAL: route IMMEDIATELY, never re-roll
|
|
2819
|
+
// the remaining rounds (mirroring the block-is-terminal rule — re-reviewing
|
|
2820
|
+
// the same tip would just be the dice re-roll the corroboration loop forbids).
|
|
2821
|
+
// We route through the SAME work-preserving `applyNeedsAttentionTransition`
|
|
2822
|
+
// seam the block path uses (it PUSHES the work branch + surfaces the item on
|
|
2823
|
+
// `surfaceArbiter` for the autonomous path), targeting `cwd` (the work branch +
|
|
2824
|
+
// ledger), NOT the throwaway `reviewCwd` — so BOTH the direct `!freshWorktreeGate`
|
|
2825
|
+
// path AND the fresh-worktree `review:` callback are covered by this ONE catch.
|
|
2826
|
+
// The recorded reason carries the parse-failure phrase the `failure-cause.ts`
|
|
2827
|
+
// signature matches → the `do`/`run` tail classifies it `transient-infra`
|
|
2828
|
+
// (retry the SAME work: the gate output is STOCHASTIC, so a re-run CAN differ,
|
|
2829
|
+
// and the direction-2 repair makes a re-run far more likely to parse). NEVER a
|
|
2830
|
+
// silent approve.
|
|
2831
|
+
const reason =
|
|
2832
|
+
`PR/code review (Gate 2) ran but its verdict could not be parsed: ` +
|
|
2833
|
+
`${err.message}`;
|
|
2834
|
+
const routed = await ledgerWrite.applyNeedsAttentionTransition({
|
|
2835
|
+
cwd,
|
|
2836
|
+
slug,
|
|
2837
|
+
reason,
|
|
2838
|
+
arbiter: input.surfaceArbiter,
|
|
2839
|
+
env,
|
|
2840
|
+
note,
|
|
2841
|
+
});
|
|
2842
|
+
const message = routed.moved
|
|
2843
|
+
? `PR/code review (Gate 2) produced an UNPARSEABLE verdict for '${slug}'; ` +
|
|
2844
|
+
'routed it to work/needs-attention/ (work branch pushed + surfaced; ' +
|
|
2845
|
+
'transient-infra — re-run). NOT integrated.'
|
|
2846
|
+
: `PR/code review (Gate 2) produced an UNPARSEABLE verdict for '${slug}'; ` +
|
|
2847
|
+
'NOT integrating.';
|
|
2848
|
+
note(message);
|
|
2849
|
+
return {
|
|
2850
|
+
kind: 'blocked',
|
|
2851
|
+
result: {
|
|
2852
|
+
outcome: 'review-unparseable',
|
|
2853
|
+
routedToNeedsAttention: routed.moved,
|
|
2854
|
+
branch,
|
|
2855
|
+
reason,
|
|
2856
|
+
},
|
|
2857
|
+
};
|
|
2858
|
+
}
|
|
2859
|
+
lastVerdict = verdict;
|
|
2860
|
+
if (verdict.verdict !== 'approve') {
|
|
2861
|
+
// A `block` is TERMINAL: stop now (never re-roll an unchanged tip) and route
|
|
2862
|
+
// the blocking findings to needs-attention below. `approved` stays false.
|
|
2863
|
+
approved = false;
|
|
2864
|
+
break;
|
|
2865
|
+
}
|
|
2866
|
+
// An `approve`: provisionally approved, but keep going — every remaining round
|
|
2867
|
+
// must ALSO approve for the gate to pass (corroboration, not first-approve-wins).
|
|
2868
|
+
approved = true;
|
|
2869
|
+
}
|
|
2870
|
+
if (!approved) {
|
|
2871
|
+
// NON-approve verdict: route to needs-attention via the SAME seam the red gate
|
|
2872
|
+
// uses, NEVER integrate. We reach here EITHER because a round returned a
|
|
2873
|
+
// (terminal) block, OR because not every round corroborated the approve. The
|
|
2874
|
+
// reason records the last verdict's blocking findings (the proximate cause) plus
|
|
2875
|
+
// the `reviewMaxRounds` note (so a single-round block also reads correctly).
|
|
2876
|
+
const findingsReason = lastVerdict ? formatBlockReason(lastVerdict) : '';
|
|
2877
|
+
const reason =
|
|
2878
|
+
(findingsReason ? findingsReason + '\n' : '') +
|
|
2879
|
+
reviewRoundsExhaustedReason(maxRounds);
|
|
2880
|
+
const routed = await ledgerWrite.applyNeedsAttentionTransition({
|
|
2881
|
+
cwd,
|
|
2882
|
+
slug,
|
|
2883
|
+
reason,
|
|
2884
|
+
// Same autonomous-vs-human gate as the red-gate path: `do` passes the arbiter
|
|
2885
|
+
// (surface on main + push the branch), the human `complete` leaves it unset.
|
|
2886
|
+
arbiter: input.surfaceArbiter,
|
|
2887
|
+
env,
|
|
2888
|
+
note,
|
|
2889
|
+
});
|
|
2890
|
+
const message = routed.moved
|
|
2891
|
+
? `PR/code review (Gate 2) blocked '${slug}'; routed it to ` +
|
|
2892
|
+
'work/needs-attention/ (surfaced by status; the blocking findings are ' +
|
|
2893
|
+
'recorded in the item body). NOT integrated.'
|
|
2894
|
+
: `PR/code review (Gate 2) blocked '${slug}'; NOT integrating.`;
|
|
2895
|
+
note(message);
|
|
2896
|
+
return {
|
|
2897
|
+
kind: 'blocked',
|
|
2898
|
+
result: {
|
|
2899
|
+
outcome: 'review-blocked',
|
|
2900
|
+
routedToNeedsAttention: routed.moved,
|
|
2901
|
+
branch,
|
|
2902
|
+
reason: message,
|
|
2903
|
+
// The structured block reason (the blocking findings ONLY) for a caller
|
|
2904
|
+
// doing its OWN routing (the tasking path); the build path ignores it.
|
|
2905
|
+
reviewBlockReason: findingsReason || message,
|
|
2906
|
+
},
|
|
2907
|
+
};
|
|
2908
|
+
}
|
|
2909
|
+
note(`PR/code review (Gate 2) approved '${slug}'.`);
|
|
2910
|
+
return {
|
|
2911
|
+
kind: 'approved',
|
|
2912
|
+
verdict: lastVerdict,
|
|
2913
|
+
};
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
/** The result of {@link runFreshWorktreeGate}. */
|
|
2917
|
+
interface FreshGateResult {
|
|
2918
|
+
/** True iff BOTH `prepare` and `verify` passed on the rebased-tip worktree. */
|
|
2919
|
+
passed: boolean;
|
|
2920
|
+
/** Which step failed (when `!passed`): the env-prep step or the acceptance gate. */
|
|
2921
|
+
kind?: 'prepare' | 'verify';
|
|
2922
|
+
/** The non-zero exit code of the failing step (when `!passed`). */
|
|
2923
|
+
exitCode?: number;
|
|
2924
|
+
/**
|
|
2925
|
+
* The Gate-2 REVIEW outcome, present ONLY when a review gate was supplied to the
|
|
2926
|
+
* fresh gate AND `verify` passed (so the review ran AFTER it on the rebased tip).
|
|
2927
|
+
* The caller routes a `blocked` and acts on an `approved` (carry the verdict,
|
|
2928
|
+
* write the nits observation). Absent when no review was requested or `verify`
|
|
2929
|
+
* failed (the review never ran).
|
|
2930
|
+
*/
|
|
2931
|
+
review?: Gate2ReviewOutcome;
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
/**
|
|
2935
|
+
* Run the acceptance gate (`prepare` then `verify`) in a CLEAN THROWAWAY worktree
|
|
2936
|
+
* cut from `commit` (the work branch tip AFTER it was rebased onto `<arbiter>/main`
|
|
2937
|
+
* — the would-be-integrated tip), then REAP the worktree (pass OR fail). This is
|
|
2938
|
+
* the fresh-worktree gate (task `gate-on-rebased-tip-fresh-worktree`): a green
|
|
2939
|
+
* gate provably describes the MERGED artifact, because the worktree is cut from the
|
|
2940
|
+
* COMMITTED, rebased tip — gitignored/uncommitted state in the agent's `cwd` cannot
|
|
2941
|
+
* leak in, and a change the integration rebase introduced IS gated.
|
|
2942
|
+
*
|
|
2943
|
+
* The worktree is registered on `cwd`'s git common dir (`git worktree add --detach`
|
|
2944
|
+
* run IN `cwd`), so it works for BOTH isolation strategies: an in-place clone
|
|
2945
|
+
* (`<cwd>/.git`) and a job worktree cut from a bare hub mirror (the mirror's git
|
|
2946
|
+
* dir). It is a TRANSIENT gate sandbox — distinct from the agent's job worktree — and
|
|
2947
|
+
* is ALWAYS removed afterwards (`git worktree remove --force` + a dir cleanup
|
|
2948
|
+
* fallback), never leaked (cross-ref the worktree-hygiene/reap discipline in
|
|
2949
|
+
* `gc.ts`). The throwaway worktree is fresh (no deps), so `prepare` runs in it
|
|
2950
|
+
* before `verify` (forced — `useMarker: false` — since it is per-gate); a failing
|
|
2951
|
+
* `prepare` short-circuits and never runs `verify` (the env could not be made
|
|
2952
|
+
* ready), surfaced distinctly as `kind: 'prepare'`.
|
|
2953
|
+
*
|
|
2954
|
+
* GATE-2 REVIEW (MAINTAINER DECISION 2): when a `review` callback is supplied (the
|
|
2955
|
+
* caller resolved `review` ON), the review runs HERE — AFTER the rebased-tip
|
|
2956
|
+
* `verify` passes, against THIS fresh gate worktree (the rebased tip) — so
|
|
2957
|
+
* verify-THEN-review holds on the SAME merged tree. The review runs INSIDE the
|
|
2958
|
+
* worktree's lifetime (before it is reaped), so the review agent inspects the
|
|
2959
|
+
* rebased tip. Its outcome is returned in {@link FreshGateResult.review} for the
|
|
2960
|
+
* caller to route/act on; the worktree is reaped regardless of the verdict.
|
|
2961
|
+
*/
|
|
2962
|
+
async function runFreshWorktreeGate(params: {
|
|
2963
|
+
cwd: string;
|
|
2964
|
+
commit: string;
|
|
2965
|
+
prepare?: VerifyConfig;
|
|
2966
|
+
verify?: VerifyConfig;
|
|
2967
|
+
env: NodeJS.ProcessEnv | undefined;
|
|
2968
|
+
note: (message: string) => void;
|
|
2969
|
+
/**
|
|
2970
|
+
* Run the Gate-2 review on the rebased-tip worktree AFTER `verify` passes
|
|
2971
|
+
* (verify-then-review on the merged tree). Supplied ONLY when `review` resolved
|
|
2972
|
+
* ON; absent ⇒ the fresh gate runs prepare+verify only (no review). Receives the
|
|
2973
|
+
* gate worktree dir (the tree to review) and returns the routed outcome.
|
|
2974
|
+
*/
|
|
2975
|
+
review?: (reviewCwd: string) => Promise<Gate2ReviewOutcome>;
|
|
2976
|
+
}): Promise<FreshGateResult> {
|
|
2977
|
+
const {cwd, commit, env, note} = params;
|
|
2978
|
+
// A throwaway gate-sandbox dir OUTSIDE any tracked tree (the OS temp area), so it
|
|
2979
|
+
// can never be swept into a commit and is naturally disposable.
|
|
2980
|
+
const gateDir = mkdtempSync(join(tmpdir(), 'dorfl-fresh-gate-'));
|
|
2981
|
+
// `git worktree add` will refuse to add into a non-empty existing dir, so add a
|
|
2982
|
+
// child path under the (empty) mkdtemp dir.
|
|
2983
|
+
const worktreeDir = join(gateDir, 'tip');
|
|
2984
|
+
try {
|
|
2985
|
+
// Cut a CLEAN DETACHED worktree from the rebased tip. Detached (no branch) so it
|
|
2986
|
+
// never collides with the work branch already checked out in `cwd`.
|
|
2987
|
+
await gitHard(
|
|
2988
|
+
['worktree', 'add', '--quiet', '--detach', worktreeDir, commit],
|
|
2989
|
+
cwd,
|
|
2990
|
+
env,
|
|
2991
|
+
);
|
|
2992
|
+
note(
|
|
2993
|
+
'Running the acceptance gate (prepare then verify) on the rebased tip in ' +
|
|
2994
|
+
'a clean throwaway worktree…',
|
|
2995
|
+
);
|
|
2996
|
+
// prepare: a fresh worktree has no deps, so install BEFORE verify. Forced per
|
|
2997
|
+
// gate (`useMarker: false`) — this worktree is throwaway. Unset ⇒ a no-op.
|
|
2998
|
+
const prep = await ensurePrepared({
|
|
2999
|
+
cwd: worktreeDir,
|
|
3000
|
+
prepare: params.prepare,
|
|
3001
|
+
env,
|
|
3002
|
+
useMarker: false,
|
|
3003
|
+
});
|
|
3004
|
+
if (!prep.passed) {
|
|
3005
|
+
return {passed: false, kind: 'prepare', exitCode: prep.exitCode};
|
|
3006
|
+
}
|
|
3007
|
+
const gate = await runVerify({
|
|
3008
|
+
cwd: worktreeDir,
|
|
3009
|
+
verify: params.verify,
|
|
3010
|
+
env,
|
|
3011
|
+
});
|
|
3012
|
+
if (!gate.passed) {
|
|
3013
|
+
return {passed: false, kind: 'verify', exitCode: gate.exitCode};
|
|
3014
|
+
}
|
|
3015
|
+
// GATE-2 REVIEW on the rebased tip, AFTER the green verify (verify-then-review
|
|
3016
|
+
// on the SAME merged tree). Runs while the worktree is still live (the review
|
|
3017
|
+
// agent inspects the rebased tip). The outcome is returned for the caller to
|
|
3018
|
+
// route/act on.
|
|
3019
|
+
const review = params.review ? await params.review(worktreeDir) : undefined;
|
|
3020
|
+
return {passed: true, review};
|
|
3021
|
+
} finally {
|
|
3022
|
+
// REAP the throwaway fresh-gate worktree (pass or fail) — never leak it. Remove the
|
|
3023
|
+
// git-registered worktree first (so the common dir has no dangling
|
|
3024
|
+
// registration), then best-effort drop the temp dir + prune.
|
|
3025
|
+
try {
|
|
3026
|
+
await gitSoft(['worktree', 'remove', '--force', worktreeDir], cwd, env);
|
|
3027
|
+
} catch {
|
|
3028
|
+
// best-effort
|
|
3029
|
+
}
|
|
3030
|
+
try {
|
|
3031
|
+
await gitSoft(['worktree', 'prune'], cwd, env);
|
|
3032
|
+
} catch {
|
|
3033
|
+
// best-effort
|
|
3034
|
+
}
|
|
3035
|
+
try {
|
|
3036
|
+
rmSync(gateDir, {recursive: true, force: true});
|
|
3037
|
+
} catch {
|
|
3038
|
+
// best-effort
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
|
|
3043
|
+
/** Run git, returning the raw result (no throw) — for soft checks. */
|
|
3044
|
+
function gitSoft(
|
|
3045
|
+
args: string[],
|
|
3046
|
+
cwd: string,
|
|
3047
|
+
env: NodeJS.ProcessEnv | undefined,
|
|
3048
|
+
): Promise<RunResult> {
|
|
3049
|
+
return runAsync('git', args, cwd, {env});
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
/** Run git; throw on non-zero (genuinely unexpected plumbing failures). */
|
|
3053
|
+
async function gitHard(
|
|
3054
|
+
args: string[],
|
|
3055
|
+
cwd: string,
|
|
3056
|
+
env: NodeJS.ProcessEnv | undefined,
|
|
3057
|
+
): Promise<RunResult> {
|
|
3058
|
+
const result = await runAsync('git', args, cwd, {env});
|
|
3059
|
+
if (result.status !== 0) {
|
|
3060
|
+
throw new Error(
|
|
3061
|
+
`git ${args.join(' ')} failed (exit ${result.status}): ${result.stderr.trim()}`,
|
|
3062
|
+
);
|
|
3063
|
+
}
|
|
3064
|
+
return result;
|
|
3065
|
+
}
|