dorfl 0.14.2 → 0.15.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 +17 -16
- package/dist/advance-ci-template.d.ts.map +1 -1
- package/dist/advance-ci-template.js +42 -53
- package/dist/advance-ci-template.js.map +1 -1
- package/dist/advance-drivers.d.ts +8 -0
- package/dist/advance-drivers.d.ts.map +1 -1
- package/dist/advance-drivers.js +28 -4
- package/dist/advance-drivers.js.map +1 -1
- package/dist/advance-isolated.d.ts.map +1 -1
- package/dist/advance-isolated.js +3 -2
- package/dist/advance-isolated.js.map +1 -1
- package/dist/advance-lifecycle-template.d.ts +41 -40
- package/dist/advance-lifecycle-template.d.ts.map +1 -1
- package/dist/advance-lifecycle-template.js +309 -489
- package/dist/advance-lifecycle-template.js.map +1 -1
- package/dist/advance-loop-driver.d.ts.map +1 -1
- package/dist/advance-loop-driver.js +3 -2
- package/dist/advance-loop-driver.js.map +1 -1
- package/dist/advance-treeless-publish.d.ts +31 -0
- package/dist/advance-treeless-publish.d.ts.map +1 -1
- package/dist/advance-treeless-publish.js +40 -0
- package/dist/advance-treeless-publish.js.map +1 -1
- package/dist/advance.d.ts +63 -3
- package/dist/advance.d.ts.map +1 -1
- package/dist/advance.js +80 -12
- package/dist/advance.js.map +1 -1
- package/dist/advancing-lock.d.ts +9 -0
- package/dist/advancing-lock.d.ts.map +1 -1
- package/dist/advancing-lock.js +21 -1
- package/dist/advancing-lock.js.map +1 -1
- package/dist/agent-env.d.ts +61 -0
- package/dist/agent-env.d.ts.map +1 -0
- package/dist/agent-env.js +144 -0
- package/dist/agent-env.js.map +1 -0
- package/dist/agent-stop.d.ts +35 -0
- package/dist/agent-stop.d.ts.map +1 -1
- package/dist/agent-stop.js +62 -0
- package/dist/agent-stop.js.map +1 -1
- package/dist/apply-merge-action.d.ts +76 -34
- package/dist/apply-merge-action.d.ts.map +1 -1
- package/dist/apply-merge-action.js +339 -129
- package/dist/apply-merge-action.js.map +1 -1
- package/dist/apply-stuck-action.js +2 -2
- package/dist/apply-stuck-action.js.map +1 -1
- package/dist/ci-agent-result.d.ts +151 -0
- package/dist/ci-agent-result.d.ts.map +1 -0
- package/dist/ci-agent-result.js +314 -0
- package/dist/ci-agent-result.js.map +1 -0
- package/dist/ci-handoff-apply.d.ts +162 -0
- package/dist/ci-handoff-apply.d.ts.map +1 -0
- package/dist/ci-handoff-apply.js +497 -0
- package/dist/ci-handoff-apply.js.map +1 -0
- package/dist/ci-handoff-format.d.ts +369 -0
- package/dist/ci-handoff-format.d.ts.map +1 -0
- package/dist/ci-handoff-format.js +646 -0
- package/dist/ci-handoff-format.js.map +1 -0
- package/dist/ci-handoff-lfs.d.ts +146 -0
- package/dist/ci-handoff-lfs.d.ts.map +1 -0
- package/dist/ci-handoff-lfs.js +397 -0
- package/dist/ci-handoff-lfs.js.map +1 -0
- package/dist/ci-handoff.d.ts +99 -0
- package/dist/ci-handoff.d.ts.map +1 -0
- package/dist/ci-handoff.js +277 -0
- package/dist/ci-handoff.js.map +1 -0
- package/dist/ci-lock-outputs.d.ts +93 -0
- package/dist/ci-lock-outputs.d.ts.map +1 -0
- package/dist/ci-lock-outputs.js +223 -0
- package/dist/ci-lock-outputs.js.map +1 -0
- package/dist/ci-phase-build.d.ts +178 -0
- package/dist/ci-phase-build.d.ts.map +1 -0
- package/dist/ci-phase-build.js +989 -0
- package/dist/ci-phase-build.js.map +1 -0
- package/dist/ci-phase-driver.d.ts +247 -0
- package/dist/ci-phase-driver.d.ts.map +1 -0
- package/dist/ci-phase-driver.js +370 -0
- package/dist/ci-phase-driver.js.map +1 -0
- package/dist/ci-phase-intake.d.ts +172 -0
- package/dist/ci-phase-intake.d.ts.map +1 -0
- package/dist/ci-phase-intake.js +555 -0
- package/dist/ci-phase-intake.js.map +1 -0
- package/dist/ci-phase-tasking.d.ts +205 -0
- package/dist/ci-phase-tasking.d.ts.map +1 -0
- package/dist/ci-phase-tasking.js +906 -0
- package/dist/ci-phase-tasking.js.map +1 -0
- package/dist/ci-phase-treeless.d.ts +224 -0
- package/dist/ci-phase-treeless.d.ts.map +1 -0
- package/dist/ci-phase-treeless.js +1482 -0
- package/dist/ci-phase-treeless.js.map +1 -0
- package/dist/ci-read-token.d.ts +47 -0
- package/dist/ci-read-token.d.ts.map +1 -0
- package/dist/ci-read-token.js +80 -0
- package/dist/ci-read-token.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +447 -22
- package/dist/cli.js.map +1 -1
- package/dist/close-job-template.d.ts +10 -1
- package/dist/close-job-template.d.ts.map +1 -1
- package/dist/close-job-template.js +46 -14
- package/dist/close-job-template.js.map +1 -1
- package/dist/close-job.d.ts +20 -5
- package/dist/close-job.d.ts.map +1 -1
- package/dist/close-job.js +65 -12
- package/dist/close-job.js.map +1 -1
- package/dist/complete.d.ts.map +1 -1
- package/dist/complete.js +20 -1
- package/dist/complete.js.map +1 -1
- package/dist/config.d.ts +41 -44
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -4
- package/dist/config.js.map +1 -1
- package/dist/continue-branch.d.ts +63 -15
- package/dist/continue-branch.d.ts.map +1 -1
- package/dist/continue-branch.js +75 -8
- package/dist/continue-branch.js.map +1 -1
- package/dist/crashed-run-save.d.ts +73 -0
- package/dist/crashed-run-save.d.ts.map +1 -0
- package/dist/crashed-run-save.js +86 -0
- package/dist/crashed-run-save.js.map +1 -0
- package/dist/cwd-section.d.ts.map +1 -1
- package/dist/cwd-section.js +10 -2
- package/dist/cwd-section.js.map +1 -1
- package/dist/decision-engine.d.ts +10 -0
- package/dist/decision-engine.d.ts.map +1 -1
- package/dist/decision-engine.js.map +1 -1
- package/dist/do-config.d.ts +2 -24
- package/dist/do-config.d.ts.map +1 -1
- package/dist/do-config.js +1 -33
- package/dist/do-config.js.map +1 -1
- package/dist/do.d.ts +149 -1
- package/dist/do.d.ts.map +1 -1
- package/dist/do.js +115 -25
- package/dist/do.js.map +1 -1
- package/dist/dorfl-item-template.d.ts +98 -0
- package/dist/dorfl-item-template.d.ts.map +1 -0
- package/dist/dorfl-item-template.js +504 -0
- package/dist/dorfl-item-template.js.map +1 -0
- package/dist/env-config.d.ts.map +1 -1
- package/dist/env-config.js +7 -5
- package/dist/env-config.js.map +1 -1
- package/dist/frontmatter.d.ts +52 -0
- package/dist/frontmatter.d.ts.map +1 -1
- package/dist/frontmatter.js +136 -2
- package/dist/frontmatter.js.map +1 -1
- package/dist/gc.d.ts +13 -0
- package/dist/gc.d.ts.map +1 -1
- package/dist/gc.js +16 -2
- package/dist/gc.js.map +1 -1
- package/dist/git.d.ts +18 -1
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +70 -5
- package/dist/git.js.map +1 -1
- package/dist/harness.d.ts +44 -5
- package/dist/harness.d.ts.map +1 -1
- package/dist/harness.js +24 -1
- package/dist/harness.js.map +1 -1
- package/dist/index.d.ts +11 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/install-ci-action-pins.d.ts +123 -0
- package/dist/install-ci-action-pins.d.ts.map +1 -0
- package/dist/install-ci-action-pins.js +190 -0
- package/dist/install-ci-action-pins.js.map +1 -0
- package/dist/install-ci-actions-pr-creation.d.ts +70 -0
- package/dist/install-ci-actions-pr-creation.d.ts.map +1 -0
- package/dist/install-ci-actions-pr-creation.js +106 -0
- package/dist/install-ci-actions-pr-creation.js.map +1 -0
- package/dist/install-ci-capabilities/dorfl-item.d.ts +12 -0
- package/dist/install-ci-capabilities/dorfl-item.d.ts.map +1 -0
- package/dist/install-ci-capabilities/dorfl-item.js +26 -0
- package/dist/install-ci-capabilities/dorfl-item.js.map +1 -0
- package/dist/install-ci-core.d.ts +155 -37
- package/dist/install-ci-core.d.ts.map +1 -1
- package/dist/install-ci-core.js +304 -200
- package/dist/install-ci-core.js.map +1 -1
- package/dist/install-ci-github.d.ts +47 -2
- package/dist/install-ci-github.d.ts.map +1 -1
- package/dist/install-ci-github.js +113 -1
- package/dist/install-ci-github.js.map +1 -1
- package/dist/install-ci.d.ts +25 -6
- package/dist/install-ci.d.ts.map +1 -1
- package/dist/install-ci.js +72 -36
- package/dist/install-ci.js.map +1 -1
- package/dist/intake-marker.d.ts +6 -1
- package/dist/intake-marker.d.ts.map +1 -1
- package/dist/intake-marker.js.map +1 -1
- package/dist/intake-triage.d.ts.map +1 -1
- package/dist/intake-triage.js +6 -2
- package/dist/intake-triage.js.map +1 -1
- package/dist/intake-trigger-template.d.ts +31 -25
- package/dist/intake-trigger-template.d.ts.map +1 -1
- package/dist/intake-trigger-template.js +107 -292
- package/dist/intake-trigger-template.js.map +1 -1
- package/dist/intake.d.ts +186 -4
- package/dist/intake.d.ts.map +1 -1
- package/dist/intake.js +533 -416
- package/dist/intake.js.map +1 -1
- package/dist/integration-core.d.ts +154 -1
- package/dist/integration-core.d.ts.map +1 -1
- package/dist/integration-core.js +759 -486
- package/dist/integration-core.js.map +1 -1
- package/dist/integrator.d.ts +12 -0
- package/dist/integrator.d.ts.map +1 -1
- package/dist/integrator.js +21 -0
- package/dist/integrator.js.map +1 -1
- package/dist/isolation.d.ts +12 -2
- package/dist/isolation.d.ts.map +1 -1
- package/dist/isolation.js +19 -8
- package/dist/isolation.js.map +1 -1
- package/dist/item-lock.d.ts +93 -11
- package/dist/item-lock.d.ts.map +1 -1
- package/dist/item-lock.js +233 -65
- package/dist/item-lock.js.map +1 -1
- package/dist/land-question-residue.d.ts +58 -0
- package/dist/land-question-residue.d.ts.map +1 -0
- package/dist/land-question-residue.js +95 -0
- package/dist/land-question-residue.js.map +1 -0
- package/dist/lifecycle-gather.d.ts +16 -0
- package/dist/lifecycle-gather.d.ts.map +1 -1
- package/dist/lifecycle-gather.js +54 -6
- package/dist/lifecycle-gather.js.map +1 -1
- package/dist/merge-answer.d.ts +41 -0
- package/dist/merge-answer.d.ts.map +1 -0
- package/dist/merge-answer.js +60 -0
- package/dist/merge-answer.js.map +1 -0
- package/dist/merge-question-surfacer.d.ts +105 -30
- package/dist/merge-question-surfacer.d.ts.map +1 -1
- package/dist/merge-question-surfacer.js +202 -91
- package/dist/merge-question-surfacer.js.map +1 -1
- package/dist/merge-question-tick.d.ts +64 -0
- package/dist/merge-question-tick.d.ts.map +1 -0
- package/dist/merge-question-tick.js +96 -0
- package/dist/merge-question-tick.js.map +1 -0
- package/dist/mint-adr.d.ts.map +1 -1
- package/dist/mint-adr.js +20 -2
- package/dist/mint-adr.js.map +1 -1
- package/dist/needs-attention.d.ts +72 -2
- package/dist/needs-attention.d.ts.map +1 -1
- package/dist/needs-attention.js +275 -38
- package/dist/needs-attention.js.map +1 -1
- package/dist/phase-recorder.d.ts +168 -0
- package/dist/phase-recorder.d.ts.map +1 -0
- package/dist/phase-recorder.js +218 -0
- package/dist/phase-recorder.js.map +1 -0
- package/dist/phase.d.ts +94 -0
- package/dist/phase.d.ts.map +1 -0
- package/dist/phase.js +148 -0
- package/dist/phase.js.map +1 -0
- package/dist/pi-harness.d.ts.map +1 -1
- package/dist/pi-harness.js +19 -6
- package/dist/pi-harness.js.map +1 -1
- package/dist/prepare.d.ts.map +1 -1
- package/dist/prepare.js +4 -0
- package/dist/prepare.js.map +1 -1
- package/dist/prompt.d.ts +4 -0
- package/dist/prompt.d.ts.map +1 -1
- package/dist/prompt.js +48 -1
- package/dist/prompt.js.map +1 -1
- package/dist/protocol/CLAIM-PROTOCOL.md +3 -1
- package/dist/protocol/WORK-CONTRACT.md +1 -1
- package/dist/reap-branches.d.ts +48 -0
- package/dist/reap-branches.d.ts.map +1 -1
- package/dist/reap-branches.js +64 -0
- package/dist/reap-branches.js.map +1 -1
- package/dist/ref-write.d.ts +198 -0
- package/dist/ref-write.d.ts.map +1 -0
- package/dist/ref-write.js +79 -0
- package/dist/ref-write.js.map +1 -0
- package/dist/repo-config.js +8 -8
- package/dist/repo-config.js.map +1 -1
- package/dist/repo-mirror.d.ts +36 -0
- package/dist/repo-mirror.d.ts.map +1 -1
- package/dist/repo-mirror.js +91 -1
- package/dist/repo-mirror.js.map +1 -1
- package/dist/review-verdict.d.ts +1 -1
- package/dist/review-verdict.js +1 -1
- package/dist/run.d.ts +7 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +28 -4
- package/dist/run.js.map +1 -1
- package/dist/scan.d.ts +19 -1
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +21 -8
- package/dist/scan.js.map +1 -1
- package/dist/sidecar.d.ts +14 -0
- package/dist/sidecar.d.ts.map +1 -1
- package/dist/sidecar.js +21 -0
- package/dist/sidecar.js.map +1 -1
- package/dist/single-job-warning.d.ts +63 -0
- package/dist/single-job-warning.d.ts.map +1 -0
- package/dist/single-job-warning.js +110 -0
- package/dist/single-job-warning.js.map +1 -0
- package/dist/skills/setup/protocol/CLAIM-PROTOCOL.md +3 -1
- package/dist/skills/setup/protocol/WORK-CONTRACT.md +1 -1
- package/dist/spec-complete.d.ts +13 -6
- package/dist/spec-complete.d.ts.map +1 -1
- package/dist/spec-complete.js +23 -11
- package/dist/spec-complete.js.map +1 -1
- package/dist/start.d.ts.map +1 -1
- package/dist/start.js +47 -14
- package/dist/start.js.map +1 -1
- package/dist/status.d.ts +12 -0
- package/dist/status.d.ts.map +1 -1
- package/dist/status.js +11 -3
- package/dist/status.js.map +1 -1
- package/dist/tasking.d.ts +246 -1
- package/dist/tasking.d.ts.map +1 -1
- package/dist/tasking.js +452 -358
- package/dist/tasking.js.map +1 -1
- package/dist/triage-persist.d.ts.map +1 -1
- package/dist/triage-persist.js +21 -2
- package/dist/triage-persist.js.map +1 -1
- package/dist/verify-workflow-template.d.ts.map +1 -1
- package/dist/verify-workflow-template.js +8 -4
- package/dist/verify-workflow-template.js.map +1 -1
- package/dist/verify.d.ts.map +1 -1
- package/dist/verify.js +4 -0
- package/dist/verify.js.map +1 -1
- package/dist/watch-session.d.ts +42 -13
- package/dist/watch-session.d.ts.map +1 -1
- package/dist/watch-session.js +52 -23
- package/dist/watch-session.js.map +1 -1
- package/dist/work-layout.d.ts +10 -4
- package/dist/work-layout.d.ts.map +1 -1
- package/dist/work-layout.js +11 -3
- package/dist/work-layout.js.map +1 -1
- package/dist/workspace.d.ts +65 -4
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +159 -11
- package/dist/workspace.js.map +1 -1
- package/package.json +1 -1
- package/src/advance-ci-template.ts +80 -81
- package/src/advance-drivers.ts +45 -6
- package/src/advance-isolated.ts +3 -5
- package/src/advance-lifecycle-template.ts +375 -538
- package/src/advance-loop-driver.ts +3 -5
- package/src/advance-treeless-publish.ts +61 -0
- package/src/advance.ts +145 -15
- package/src/advancing-lock.ts +36 -1
- package/src/agent-env.ts +174 -0
- package/src/agent-stop.ts +81 -0
- package/src/apply-merge-action.ts +401 -154
- package/src/apply-stuck-action.ts +2 -2
- package/src/ci-agent-result.ts +421 -0
- package/src/ci-handoff-apply.ts +737 -0
- package/src/ci-handoff-format.ts +926 -0
- package/src/ci-handoff-lfs.ts +519 -0
- package/src/ci-handoff.ts +392 -0
- package/src/ci-lock-outputs.ts +273 -0
- package/src/ci-phase-build.ts +1328 -0
- package/src/ci-phase-driver.ts +548 -0
- package/src/ci-phase-intake.ts +861 -0
- package/src/ci-phase-tasking.ts +1255 -0
- package/src/ci-phase-treeless.ts +2028 -0
- package/src/ci-read-token.ts +90 -0
- package/src/cli.ts +591 -29
- package/src/close-job-template.ts +52 -15
- package/src/close-job.ts +77 -12
- package/src/complete.ts +19 -1
- package/src/config.ts +43 -48
- package/src/continue-branch.ts +121 -16
- package/src/crashed-run-save.ts +162 -0
- package/src/cwd-section.ts +10 -0
- package/src/decision-engine.ts +10 -0
- package/src/do-config.ts +2 -57
- package/src/do.ts +170 -28
- package/src/dorfl-item-template.ts +556 -0
- package/src/env-config.ts +7 -5
- package/src/frontmatter.ts +155 -2
- package/src/gc.ts +27 -2
- package/src/git.ts +83 -5
- package/src/harness.ts +58 -6
- package/src/index.ts +50 -1
- package/src/install-ci-action-pins.ts +209 -0
- package/src/install-ci-actions-pr-creation.ts +186 -0
- package/src/install-ci-capabilities/dorfl-item.ts +33 -0
- package/src/install-ci-core.ts +388 -233
- package/src/install-ci-github.ts +149 -1
- package/src/install-ci.ts +83 -37
- package/src/intake-marker.ts +6 -1
- package/src/intake-triage.ts +7 -3
- package/src/intake-trigger-template.ts +126 -327
- package/src/intake.ts +725 -544
- package/src/integration-core.ts +975 -523
- package/src/integrator.ts +28 -0
- package/src/isolation.ts +29 -10
- package/src/item-lock.ts +308 -92
- package/src/land-question-residue.ts +122 -0
- package/src/lifecycle-gather.ts +82 -10
- package/src/merge-answer.ts +82 -0
- package/src/merge-question-surfacer.ts +320 -112
- package/src/merge-question-tick.ts +182 -0
- package/src/mint-adr.ts +28 -2
- package/src/needs-attention.ts +350 -42
- package/src/phase-recorder.ts +350 -0
- package/src/phase.ts +177 -0
- package/src/pi-harness.ts +25 -5
- package/src/prepare.ts +4 -0
- package/src/prompt.ts +68 -1
- package/src/reap-branches.ts +107 -0
- package/src/ref-write.ts +324 -0
- package/src/repo-config.ts +8 -8
- package/src/repo-mirror.ts +107 -1
- package/src/review-verdict.ts +1 -1
- package/src/run.ts +47 -6
- package/src/scan.ts +37 -7
- package/src/sidecar.ts +36 -0
- package/src/single-job-warning.ts +135 -0
- package/src/spec-complete.ts +36 -14
- package/src/start.ts +55 -16
- package/src/status.ts +29 -3
- package/src/tasking.ts +593 -378
- package/src/triage-persist.ts +24 -2
- package/src/verify-workflow-template.ts +8 -4
- package/src/verify.ts +4 -0
- package/src/watch-session.ts +86 -28
- package/src/work-layout.ts +11 -3
- package/src/workspace.ts +210 -14
|
@@ -0,0 +1,1255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **The tasking path split into the three CI phases** (spec
|
|
3
|
+
* `ci-agent-job-without-write-token` §3 tasking row, ADR
|
|
4
|
+
* `ci-agent-job-holds-no-write-token`, task `ci-split-tasking`).
|
|
5
|
+
*
|
|
6
|
+
* `do spec:<slug>` / `advance spec:<slug>` with `--phase` run ONE of three
|
|
7
|
+
* halves of the one tasking pipeline (`tasking.ts`: {@link runTaskingAgentHalf}
|
|
8
|
+
* is the agent half, {@link integrateTaskingCandidates} /
|
|
9
|
+
* {@link surfaceTaskingBlock} / {@link persistTaskingCandidates} the write half),
|
|
10
|
+
* never a second implementation:
|
|
11
|
+
*
|
|
12
|
+
* - **lock** ({@link performTaskingLockPhase}): fetch `<arbiter>/main` (its tip
|
|
13
|
+
* is `baseSha`); in a throwaway worktree of it, check the spec rests in
|
|
14
|
+
* `work/specs/ready/` and passes the agent tasking gate (and, for `advance`,
|
|
15
|
+
* classifies on the `task-spec` rung); take the tasking lock
|
|
16
|
+
* (`acquireTaskingLock`, the unified `spec:<slug>` lock, `action: task`);
|
|
17
|
+
* publish `acquired`, `needsAgent`, `rung`, `baseSha`, `lockSha`,
|
|
18
|
+
* `handoffName` and `agentTimeoutMinutes`. No agent, no repository code.
|
|
19
|
+
* - **agent** ({@link performTaskingAgentPhase}): check, read-only, that the
|
|
20
|
+
* lock ref still equals `lockSha`; on a `work/spec-<slug>` branch cut from
|
|
21
|
+
* `baseSha`, run the tasker, its review rounds, and the one-round task-set
|
|
22
|
+
* review (which the laptop path runs inside `performIntegration`; it is an
|
|
23
|
+
* agent, so it runs HERE), then hand over `tasking-land` (the candidates, the
|
|
24
|
+
* PR body, the review verdict and prose, the trimmed spec body) or
|
|
25
|
+
* `tasking-surface` (the candidates to save, the reason). Writes nothing to
|
|
26
|
+
* the arbiter: every write seam records into a recorder that knows no write.
|
|
27
|
+
* A tasker failure writes no handoff and fails the job.
|
|
28
|
+
* - **apply** ({@link performTaskingApplyPhase}): refuse to write unless the
|
|
29
|
+
* lock ref still equals `lockSha`; act on the agent job's result first
|
|
30
|
+
* (`failure` / timeout surfaces the spec, a real cancel only releases the
|
|
31
|
+
* lock); read the handoff as hostile and never take a bundle: it re-commits
|
|
32
|
+
* only VALIDATED candidate files ({@link checkTaskingCandidate}) and the
|
|
33
|
+
* validated trimmed spec body ({@link checkTrimmedSpecBody}) on a fresh
|
|
34
|
+
* `work/spec-<slug>` branch cut from `baseSha`, then integrates with the spec
|
|
35
|
+
* move and the review OFF (it already ran), or saves the candidates on the
|
|
36
|
+
* work branch, closes a stale PR in propose mode and surfaces. Every lock
|
|
37
|
+
* release is leased on `lockSha`.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs';
|
|
41
|
+
import {basename, dirname, join} from 'node:path';
|
|
42
|
+
import {classifyTick} from './advance-classify.js';
|
|
43
|
+
import {readItemSignals} from './advance.js';
|
|
44
|
+
import {
|
|
45
|
+
HANDOFF_LIMITS,
|
|
46
|
+
HandoffRejected,
|
|
47
|
+
handoffName as deriveHandoffName,
|
|
48
|
+
type HandoffRecord,
|
|
49
|
+
type TaskingLandProducts,
|
|
50
|
+
type TaskingSurfaceProducts,
|
|
51
|
+
} from './ci-handoff-format.js';
|
|
52
|
+
import {readHandoff, writeHandoff} from './ci-handoff.js';
|
|
53
|
+
import type {LockOutputs} from './ci-lock-outputs.js';
|
|
54
|
+
import type {AgentJobResult, GithubApiGet} from './ci-agent-result.js';
|
|
55
|
+
import {
|
|
56
|
+
PhaseDriverError,
|
|
57
|
+
agentTimeoutMinutesAt,
|
|
58
|
+
arbiterLockSha,
|
|
59
|
+
boundHandoffText,
|
|
60
|
+
acquireNotingOnce,
|
|
61
|
+
checkLockOwnership,
|
|
62
|
+
emitLockOutputs,
|
|
63
|
+
fetchArbiterMain,
|
|
64
|
+
noSmudgeEnv,
|
|
65
|
+
releaseLockLeased,
|
|
66
|
+
requireHeldLock,
|
|
67
|
+
resolveAgentResult,
|
|
68
|
+
runnerTempFrom,
|
|
69
|
+
withBaseWorktree,
|
|
70
|
+
type HeldLock,
|
|
71
|
+
} from './ci-phase-driver.js';
|
|
72
|
+
import type {DoOptions} from './do.js';
|
|
73
|
+
import {
|
|
74
|
+
FrontmatterRenderError,
|
|
75
|
+
assertFrontmatterFields,
|
|
76
|
+
parseFrontmatter,
|
|
77
|
+
unquoteYamlScalar,
|
|
78
|
+
type Frontmatter,
|
|
79
|
+
} from './frontmatter.js';
|
|
80
|
+
import {runAsync} from './git.js';
|
|
81
|
+
import {identityEnv} from './identity.js';
|
|
82
|
+
import {runReviewRounds} from './integration-core.js';
|
|
83
|
+
import {leaseLockReleases} from './item-lock.js';
|
|
84
|
+
import {formatBlockReason} from './review-gate.js';
|
|
85
|
+
import {ledgerRead} from './ledger-read.js';
|
|
86
|
+
import type {Phase} from './phase.js';
|
|
87
|
+
import {createPhaseRecorder, runAgentPhase} from './phase-recorder.js';
|
|
88
|
+
import {isSafeSlug} from './slug-safety.js';
|
|
89
|
+
import {
|
|
90
|
+
SlugResolutionError,
|
|
91
|
+
resolveAdvanceArg,
|
|
92
|
+
resolveSlug,
|
|
93
|
+
} from './slug-namespace.js';
|
|
94
|
+
import {
|
|
95
|
+
STAGED_TASKS_DIR,
|
|
96
|
+
collectEmittedTasks,
|
|
97
|
+
composeTaskingProposeBody,
|
|
98
|
+
decompositionUnclearReason,
|
|
99
|
+
gateRefusalReason,
|
|
100
|
+
integrateTaskingCandidates,
|
|
101
|
+
newOrChangedStagedTasks,
|
|
102
|
+
persistTaskingCandidates,
|
|
103
|
+
resolveAgentGate,
|
|
104
|
+
reviewFailureReason,
|
|
105
|
+
runTaskingAgentHalf,
|
|
106
|
+
snapshotPool,
|
|
107
|
+
snapshotStagedTasks,
|
|
108
|
+
surfaceTaskingBlock,
|
|
109
|
+
switchToWorkBranch,
|
|
110
|
+
taskGateBlockedReason,
|
|
111
|
+
taskGateUnparseableReason,
|
|
112
|
+
taskPlacementDir,
|
|
113
|
+
type PerformTaskOptions,
|
|
114
|
+
type TaskResult,
|
|
115
|
+
} from './tasking.js';
|
|
116
|
+
import {acquireTaskingLock, releaseTaskingLock} from './tasking-lock.js';
|
|
117
|
+
import {workFolderRel, workItemPath, workItemRel} from './work-layout.js';
|
|
118
|
+
|
|
119
|
+
const DEFAULT_ARBITER = 'origin';
|
|
120
|
+
|
|
121
|
+
/** The verb a tasking phase runs under (they classify the argument differently). */
|
|
122
|
+
export type TaskingPhaseVerb = 'do' | 'advance';
|
|
123
|
+
|
|
124
|
+
/** Options of a tasking phase run: the `do` pipeline's options plus the phase inputs. */
|
|
125
|
+
export interface TaskingPhaseOptions extends DoOptions {
|
|
126
|
+
/** Which of the three jobs this process is. */
|
|
127
|
+
phase: Phase;
|
|
128
|
+
/** `do spec:<slug>` or `advance spec:<slug>`. */
|
|
129
|
+
verb: TaskingPhaseVerb;
|
|
130
|
+
/** agent: write the handoff here (empty or absent); apply: read it (under {@link runnerTemp}). */
|
|
131
|
+
handoffDir?: string;
|
|
132
|
+
/** `$RUNNER_TEMP` (the apply phase); defaults to the env's. */
|
|
133
|
+
runnerTemp?: string;
|
|
134
|
+
/** The lock job's outputs (agent / apply); defaults to `DORFL_LOCK_OUTPUTS`. */
|
|
135
|
+
lockOutputs?: LockOutputs;
|
|
136
|
+
/** `github.run_attempt` (the lock phase names the artifact with it); default the env's, else 1. */
|
|
137
|
+
runAttempt?: string;
|
|
138
|
+
/** The `$GITHUB_OUTPUT` file the lock phase appends to; default the env's. */
|
|
139
|
+
githubOutput?: string;
|
|
140
|
+
/** The merge-mode CAS-loop jitter (tests pass 0). */
|
|
141
|
+
mergeJitterMs?: number;
|
|
142
|
+
/** apply: `needs.agent.result` (`--agent-result`), REQUIRED. Only `success` reads the handoff. */
|
|
143
|
+
agentResult?: AgentJobResult;
|
|
144
|
+
/** apply: the agent job's timeout (`--agent-timeout-minutes`); default the lock outputs'. */
|
|
145
|
+
agentTimeoutMinutes?: number;
|
|
146
|
+
/** apply: the Actions API reader (tests stub it); default `fetch` with `GITHUB_TOKEN`. */
|
|
147
|
+
actionsApi?: GithubApiGet;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** How a tasking phase run ended. */
|
|
151
|
+
export type TaskingPhaseOutcome =
|
|
152
|
+
/** lock: the spec is locked; the agent phase runs next. */
|
|
153
|
+
| 'locked'
|
|
154
|
+
/** lock: nothing to task at the arbiter's `main`. */
|
|
155
|
+
| 'no-op'
|
|
156
|
+
/** lock: the agent tasking gate refused the spec (nothing was written). */
|
|
157
|
+
| 'gate-refused'
|
|
158
|
+
/** lock: another run holds the spec's lock. */
|
|
159
|
+
| 'lost'
|
|
160
|
+
/** agent / apply: the lock ref no longer equals `lockSha`; nothing was done. */
|
|
161
|
+
| 'stale-lock'
|
|
162
|
+
/** agent: the handoff was written (see `intent`). */
|
|
163
|
+
| 'handed-over'
|
|
164
|
+
/** agent: the tasker (or the phase) failed; no handoff (the job fails). */
|
|
165
|
+
| 'agent-failed'
|
|
166
|
+
/** apply: the tasks and the spec move landed on `main`; the lock was released. */
|
|
167
|
+
| 'landed'
|
|
168
|
+
/** apply: the work branch was pushed (and a PR requested); the lock stays held. */
|
|
169
|
+
| 'proposed'
|
|
170
|
+
/** apply: the spec was surfaced to needs-attention and its lock released. */
|
|
171
|
+
| 'surfaced'
|
|
172
|
+
/** apply: surfacing the spec did not land on the arbiter. */
|
|
173
|
+
| 'surface-unmoved'
|
|
174
|
+
/** apply: the spec on `main` changed since the lock job; nothing was written. */
|
|
175
|
+
| 'stale'
|
|
176
|
+
/** apply: the agent job was cancelled (not timed out); the lock was only released. */
|
|
177
|
+
| 'released'
|
|
178
|
+
/** apply: the cancelled agent job's leased lock release was refused. */
|
|
179
|
+
| 'release-refused'
|
|
180
|
+
/** apply: the handoff broke a rule; the spec was surfaced, nothing from it landed. */
|
|
181
|
+
| 'rejected'
|
|
182
|
+
/** A usage or environment problem. */
|
|
183
|
+
| 'usage-error';
|
|
184
|
+
|
|
185
|
+
/** The result of one tasking phase run. */
|
|
186
|
+
export interface TaskingPhaseResult {
|
|
187
|
+
/** See "A handled outcome is green" in `ci-phase-driver.ts`. */
|
|
188
|
+
exitCode: 0 | 1 | 2 | 3 | 4;
|
|
189
|
+
outcome: TaskingPhaseOutcome;
|
|
190
|
+
/**
|
|
191
|
+
* The result line: the CLI prints it (`>> ` or `error: `), so the phase does
|
|
192
|
+
* NOT also `note` it (each line appears once in the job log).
|
|
193
|
+
*/
|
|
194
|
+
message: string;
|
|
195
|
+
slug?: string;
|
|
196
|
+
/** lock: the facts published. */
|
|
197
|
+
lockOutputs?: LockOutputs;
|
|
198
|
+
/** agent: the handed-over intent kind. */
|
|
199
|
+
intent?: HandoffRecord['intent']['kind'];
|
|
200
|
+
/** apply: the repository paths of the tasks the runner committed. */
|
|
201
|
+
emitted?: string[];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Run one phase of the tasking path. The CLI calls this for `do spec:<slug>
|
|
206
|
+
* --phase` and `advance spec:<slug> --phase`.
|
|
207
|
+
*/
|
|
208
|
+
export async function performTaskingPhase(
|
|
209
|
+
options: TaskingPhaseOptions,
|
|
210
|
+
): Promise<TaskingPhaseResult> {
|
|
211
|
+
try {
|
|
212
|
+
switch (options.phase) {
|
|
213
|
+
case 'lock':
|
|
214
|
+
return await performTaskingLockPhase(options);
|
|
215
|
+
case 'agent':
|
|
216
|
+
return await performTaskingAgentPhase(options);
|
|
217
|
+
case 'apply':
|
|
218
|
+
return await performTaskingApplyPhase(options);
|
|
219
|
+
}
|
|
220
|
+
} catch (err) {
|
|
221
|
+
if (err instanceof PhaseDriverError || err instanceof SlugResolutionError) {
|
|
222
|
+
return {exitCode: 1, outcome: 'usage-error', message: err.message};
|
|
223
|
+
}
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The spec slug a tasking phase acts on, from the (trusted) workflow argument. */
|
|
229
|
+
function taskingSlug(options: TaskingPhaseOptions, repoPath: string): string {
|
|
230
|
+
const read = options.read ?? ledgerRead;
|
|
231
|
+
const resolved =
|
|
232
|
+
options.verb === 'advance'
|
|
233
|
+
? resolveAdvanceArg({arg: options.arg, repoPath, read})
|
|
234
|
+
: resolveSlug({arg: options.arg, repoPath, read});
|
|
235
|
+
if (resolved.namespace !== 'spec') {
|
|
236
|
+
throw new PhaseDriverError(
|
|
237
|
+
`the tasking phases take a spec; ${options.arg} is not one`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
return resolved.slug;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The `performTask` options the tasking halves read, mapped from the `do`
|
|
245
|
+
* options exactly as `performDo` maps them for `do spec:<slug>`.
|
|
246
|
+
*/
|
|
247
|
+
function taskOptionsFor(
|
|
248
|
+
options: TaskingPhaseOptions,
|
|
249
|
+
slug: string,
|
|
250
|
+
cwd: string,
|
|
251
|
+
env: NodeJS.ProcessEnv,
|
|
252
|
+
agentEnv: NodeJS.ProcessEnv,
|
|
253
|
+
): PerformTaskOptions {
|
|
254
|
+
return {
|
|
255
|
+
slug,
|
|
256
|
+
cwd,
|
|
257
|
+
arbiter: options.arbiter ?? DEFAULT_ARBITER,
|
|
258
|
+
doer: 'agent',
|
|
259
|
+
autoTask: options.autoTask,
|
|
260
|
+
explicit: true,
|
|
261
|
+
dorfl: options.dorfl,
|
|
262
|
+
harness: options.harness,
|
|
263
|
+
agentCmd: options.agentCmd,
|
|
264
|
+
model: options.model,
|
|
265
|
+
sessionsDir: options.sessionsDir,
|
|
266
|
+
integration: options.taskingIntegration ?? options.integration,
|
|
267
|
+
mergeRetries: options.mergeRetries,
|
|
268
|
+
tasksLandIn: options.tasksLandIn,
|
|
269
|
+
untrustedTasksLandIn: options.untrustedTasksLandIn,
|
|
270
|
+
explicitTasksLandIn: options.explicitTasksLandIn,
|
|
271
|
+
noPR: options.noPR,
|
|
272
|
+
providerInstance: options.providerInstance,
|
|
273
|
+
reviewLoop: options.reviewLoop,
|
|
274
|
+
taskerLoopMax: options.taskerLoopMax,
|
|
275
|
+
reviewExecutions: options.reviewExecutions,
|
|
276
|
+
taskerLoopModel: options.taskerLoopModel,
|
|
277
|
+
review: options.review,
|
|
278
|
+
reviewGate: options.taskReviewGate,
|
|
279
|
+
acceptanceReviewModel: options.reviewModel,
|
|
280
|
+
env,
|
|
281
|
+
agentEnv,
|
|
282
|
+
note: options.note,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// lock
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The lock phase: check the spec at the arbiter's `main`, take the tasking lock
|
|
292
|
+
* and publish the trusted facts. Runs no agent and no repository code. A spec
|
|
293
|
+
* that is not taskable at `baseSha` is refused BEFORE any write.
|
|
294
|
+
*/
|
|
295
|
+
export async function performTaskingLockPhase(
|
|
296
|
+
options: TaskingPhaseOptions,
|
|
297
|
+
): Promise<TaskingPhaseResult> {
|
|
298
|
+
const note = options.note ?? (() => {});
|
|
299
|
+
const arbiter = options.arbiter ?? DEFAULT_ARBITER;
|
|
300
|
+
const cwd = options.cwd;
|
|
301
|
+
const env = identityEnv(options.identity, options.env ?? process.env);
|
|
302
|
+
const githubOutput = options.githubOutput ?? env.GITHUB_OUTPUT;
|
|
303
|
+
const publish = (facts: LockOutputs): LockOutputs => {
|
|
304
|
+
emitLockOutputs(facts, {githubOutput, note});
|
|
305
|
+
return facts;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const baseSha = await fetchArbiterMain({cwd, arbiter, env});
|
|
309
|
+
const verdict = await withBaseWorktree(
|
|
310
|
+
{cwd, baseSha, env},
|
|
311
|
+
async (base): Promise<{slug: string; skip?: string; refused?: string}> => {
|
|
312
|
+
const slug = taskingSlug(options, base);
|
|
313
|
+
const item = `spec:${slug}`;
|
|
314
|
+
if (options.verb === 'advance') {
|
|
315
|
+
const signals = readItemSignals({
|
|
316
|
+
repoPath: base,
|
|
317
|
+
type: 'spec',
|
|
318
|
+
slug,
|
|
319
|
+
item,
|
|
320
|
+
});
|
|
321
|
+
const kind = classifyTick({type: 'spec', ...signals}).kind;
|
|
322
|
+
if (kind !== 'task-spec') {
|
|
323
|
+
if (kind === 'no-op') {
|
|
324
|
+
return {slug, skip: `no-op for ${item} at ${baseSha}`};
|
|
325
|
+
}
|
|
326
|
+
throw new PhaseDriverError(
|
|
327
|
+
`--phase splits only the build and tasking paths so far; ${item} ` +
|
|
328
|
+
`classifies as ${kind} at ${baseSha}`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const specPath = workItemPath(base, 'specs-ready', slug);
|
|
333
|
+
if (!existsSync(specPath)) {
|
|
334
|
+
return {
|
|
335
|
+
slug,
|
|
336
|
+
skip:
|
|
337
|
+
`'${slug}' is not in ${workFolderRel('specs-ready')}/ at ` +
|
|
338
|
+
`${arbiter}/main (${baseSha}): it was already tasked, or removed`,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
const specFm = parseFrontmatter(readFileSync(specPath, 'utf8'));
|
|
342
|
+
const eligibility = resolveAgentGate(
|
|
343
|
+
base,
|
|
344
|
+
slug,
|
|
345
|
+
specFm,
|
|
346
|
+
options.autoTask,
|
|
347
|
+
true,
|
|
348
|
+
);
|
|
349
|
+
if (!eligibility.taskable) {
|
|
350
|
+
return {
|
|
351
|
+
slug,
|
|
352
|
+
refused: gateRefusalReason(slug, specFm, eligibility, {
|
|
353
|
+
slug,
|
|
354
|
+
cwd: base,
|
|
355
|
+
autoTask: options.autoTask,
|
|
356
|
+
explicit: true,
|
|
357
|
+
}),
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
return {slug};
|
|
361
|
+
},
|
|
362
|
+
);
|
|
363
|
+
const slug = verdict.slug;
|
|
364
|
+
const item = `spec:${slug}`;
|
|
365
|
+
if (verdict.skip !== undefined) {
|
|
366
|
+
return {
|
|
367
|
+
exitCode: 0,
|
|
368
|
+
outcome: 'no-op',
|
|
369
|
+
slug,
|
|
370
|
+
message: verdict.skip,
|
|
371
|
+
lockOutputs: publish({acquired: false, baseSha}),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
if (verdict.refused !== undefined) {
|
|
375
|
+
return {
|
|
376
|
+
exitCode: 1,
|
|
377
|
+
outcome: 'gate-refused',
|
|
378
|
+
slug,
|
|
379
|
+
message: verdict.refused,
|
|
380
|
+
lockOutputs: publish({acquired: false, rung: 'task-spec', baseSha}),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
if ((await arbiterLockSha({cwd, arbiter, item, env})) !== undefined) {
|
|
384
|
+
const message = `'${slug}' is already locked on ${arbiter}; backing off.`;
|
|
385
|
+
return {
|
|
386
|
+
exitCode: 0,
|
|
387
|
+
outcome: 'lost',
|
|
388
|
+
slug,
|
|
389
|
+
message,
|
|
390
|
+
lockOutputs: publish({acquired: false, rung: 'task-spec', baseSha}),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const acquired = await acquireNotingOnce(note, (acquireNote) =>
|
|
395
|
+
acquireTaskingLock({slug, cwd, arbiter, env, note: acquireNote}),
|
|
396
|
+
);
|
|
397
|
+
if (acquired.exitCode !== 0) {
|
|
398
|
+
const outcome =
|
|
399
|
+
acquired.outcome === 'lost' || acquired.outcome === 'contended'
|
|
400
|
+
? 'lost'
|
|
401
|
+
: 'usage-error';
|
|
402
|
+
return {
|
|
403
|
+
// A lock lost to another run is handled, so green ("A handled
|
|
404
|
+
// outcome is green", `ci-phase-driver.ts`).
|
|
405
|
+
exitCode: outcome === 'lost' ? 0 : acquired.exitCode,
|
|
406
|
+
outcome,
|
|
407
|
+
slug,
|
|
408
|
+
message: acquired.message,
|
|
409
|
+
lockOutputs: publish({acquired: false, rung: 'task-spec', baseSha}),
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
const lockSha = await arbiterLockSha({cwd, arbiter, item, env});
|
|
413
|
+
if (lockSha === undefined) {
|
|
414
|
+
throw new Error(`the lock of ${item} vanished right after the acquire`);
|
|
415
|
+
}
|
|
416
|
+
const facts = publish({
|
|
417
|
+
acquired: true,
|
|
418
|
+
needsAgent: true,
|
|
419
|
+
rung: 'task-spec',
|
|
420
|
+
baseSha,
|
|
421
|
+
lockSha,
|
|
422
|
+
handoffName: deriveHandoffName(
|
|
423
|
+
item,
|
|
424
|
+
options.runAttempt ?? env.GITHUB_RUN_ATTEMPT ?? '1',
|
|
425
|
+
),
|
|
426
|
+
agentTimeoutMinutes: agentTimeoutMinutesAt(cwd, baseSha, env),
|
|
427
|
+
});
|
|
428
|
+
return {
|
|
429
|
+
exitCode: 0,
|
|
430
|
+
outcome: 'locked',
|
|
431
|
+
slug,
|
|
432
|
+
message: `locked ${item} at ${baseSha} (lock ${lockSha})`,
|
|
433
|
+
lockOutputs: facts,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
// agent
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
/** The candidate task files keyed by slug (their file name without `.md`). */
|
|
442
|
+
function candidatesBySlug(
|
|
443
|
+
emitTasks: Record<string, string>,
|
|
444
|
+
): Record<string, string> {
|
|
445
|
+
const out: Record<string, string> = {};
|
|
446
|
+
for (const [rel, content] of Object.entries(emitTasks)) {
|
|
447
|
+
out[basename(rel).replace(/\.md$/i, '')] = content;
|
|
448
|
+
}
|
|
449
|
+
return out;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** A bounded reason for the handoff record. */
|
|
453
|
+
function reasonText(text: string): string {
|
|
454
|
+
return boundHandoffText(text, HANDOFF_LIMITS.reasonChars);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* The agent half's work under the recorder: the tasker, the loop and the
|
|
459
|
+
* task-set review, mapped to the handoff record. Throws on a tasker failure.
|
|
460
|
+
*/
|
|
461
|
+
async function taskingHandover(params: {
|
|
462
|
+
options: TaskingPhaseOptions;
|
|
463
|
+
slug: string;
|
|
464
|
+
item: string;
|
|
465
|
+
cwd: string;
|
|
466
|
+
env: NodeJS.ProcessEnv;
|
|
467
|
+
note: (message: string) => void;
|
|
468
|
+
}): Promise<HandoffRecord> {
|
|
469
|
+
const {options, slug, item, cwd, env, note} = params;
|
|
470
|
+
const specPath = workItemPath(cwd, 'specs-ready', slug);
|
|
471
|
+
if (!existsSync(specPath)) {
|
|
472
|
+
throw new Error(
|
|
473
|
+
`the spec ${workItemRel('specs-ready', `${slug}.md`)} is missing at the base`,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
const specFm = parseFrontmatter(readFileSync(specPath, 'utf8'));
|
|
477
|
+
const taskOptions = taskOptionsFor(options, slug, cwd, env, env);
|
|
478
|
+
const half = await runTaskingAgentHalf(taskOptions, {
|
|
479
|
+
cwd,
|
|
480
|
+
slug,
|
|
481
|
+
specFm,
|
|
482
|
+
doer: 'agent',
|
|
483
|
+
note,
|
|
484
|
+
});
|
|
485
|
+
const surface = (
|
|
486
|
+
reason: string,
|
|
487
|
+
candidates: Record<string, string>,
|
|
488
|
+
): HandoffRecord => ({
|
|
489
|
+
schema: 1,
|
|
490
|
+
item,
|
|
491
|
+
intent: {kind: 'tasking-surface'},
|
|
492
|
+
products: {
|
|
493
|
+
candidates,
|
|
494
|
+
reason: reasonText(reason),
|
|
495
|
+
} satisfies TaskingSurfaceProducts,
|
|
496
|
+
});
|
|
497
|
+
switch (half.kind) {
|
|
498
|
+
case 'agent-failed':
|
|
499
|
+
throw new Error(`Agent failed tasking '${slug}' (${half.detail}).`);
|
|
500
|
+
case 'review-leg-failed': {
|
|
501
|
+
const paths = newOrChangedStagedTasks(cwd, half.before);
|
|
502
|
+
return surface(
|
|
503
|
+
reviewFailureReason(slug, half.error, paths),
|
|
504
|
+
candidatesBySlug(collectEmittedTasks(cwd, paths)),
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
case 'decomposition-unclear':
|
|
508
|
+
return surface(decompositionUnclearReason(slug, half.questions), {});
|
|
509
|
+
case 'candidates':
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
const emitTasks = collectEmittedTasks(
|
|
513
|
+
cwd,
|
|
514
|
+
newOrChangedStagedTasks(cwd, half.before),
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
// THE TASK-SET ACCEPTANCE GATE, one round (the laptop path runs it inside
|
|
518
|
+
// `performIntegration` with `reviewMaxRounds: 1`). It launches an agent, so
|
|
519
|
+
// it runs here; the apply phase integrates with the review OFF.
|
|
520
|
+
let reviewVerdict: TaskingLandProducts['reviewVerdict'];
|
|
521
|
+
let reviewProse: string | undefined;
|
|
522
|
+
if (taskOptions.review === true) {
|
|
523
|
+
if (taskOptions.reviewGate === undefined) {
|
|
524
|
+
throw new Error(
|
|
525
|
+
`review is on but no task-set review gate is configured for '${slug}' ` +
|
|
526
|
+
'(a wiring bug; the gate must not be skipped)',
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
note('Running the task-set acceptance review…');
|
|
530
|
+
const rounds = await runReviewRounds({
|
|
531
|
+
reviewGate: taskOptions.reviewGate,
|
|
532
|
+
slug,
|
|
533
|
+
reviewCwd: cwd,
|
|
534
|
+
maxRounds: 1,
|
|
535
|
+
reviewModel: taskOptions.acceptanceReviewModel,
|
|
536
|
+
sessionsDir: taskOptions.sessionsDir,
|
|
537
|
+
env,
|
|
538
|
+
});
|
|
539
|
+
if (rounds.kind === 'unparseable') {
|
|
540
|
+
return surface(taskGateUnparseableReason(slug, rounds.reason), {});
|
|
541
|
+
}
|
|
542
|
+
if (rounds.kind === 'blocked') {
|
|
543
|
+
const findings =
|
|
544
|
+
rounds.verdict === undefined ? '' : formatBlockReason(rounds.verdict);
|
|
545
|
+
return surface(
|
|
546
|
+
taskGateBlockedReason(slug, findings === '' ? undefined : findings),
|
|
547
|
+
{},
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
reviewVerdict = 'approve';
|
|
551
|
+
reviewProse = rounds.verdict?.review;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return {
|
|
555
|
+
schema: 1,
|
|
556
|
+
item,
|
|
557
|
+
intent: {kind: 'tasking-land'},
|
|
558
|
+
products: {
|
|
559
|
+
candidates: candidatesBySlug(emitTasks),
|
|
560
|
+
prBody: boundHandoffText(
|
|
561
|
+
composeTaskingProposeBody(slug, emitTasks) ?? '',
|
|
562
|
+
HANDOFF_LIMITS.commentChars,
|
|
563
|
+
),
|
|
564
|
+
...(reviewVerdict === undefined ? {} : {reviewVerdict}),
|
|
565
|
+
...(reviewProse === undefined
|
|
566
|
+
? {}
|
|
567
|
+
: {
|
|
568
|
+
reviewProse: boundHandoffText(
|
|
569
|
+
reviewProse,
|
|
570
|
+
HANDOFF_LIMITS.commentChars,
|
|
571
|
+
),
|
|
572
|
+
}),
|
|
573
|
+
specBody: readFileSync(specPath, 'utf8'),
|
|
574
|
+
} satisfies TaskingLandProducts,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* The agent phase: after a read-only lock-ownership check, run the tasker, its
|
|
580
|
+
* review rounds and the task-set review on a `work/spec-<slug>` branch cut from
|
|
581
|
+
* `baseSha`, and write the handoff. Holds a read-only token: every write seam
|
|
582
|
+
* records into a recorder that knows no write, so a write attempt fails the
|
|
583
|
+
* job. Any failure writes no handoff and fails the job (the apply job then
|
|
584
|
+
* surfaces the spec).
|
|
585
|
+
*/
|
|
586
|
+
export async function performTaskingAgentPhase(
|
|
587
|
+
options: TaskingPhaseOptions,
|
|
588
|
+
): Promise<TaskingPhaseResult> {
|
|
589
|
+
const note = options.note ?? (() => {});
|
|
590
|
+
const arbiter = options.arbiter ?? DEFAULT_ARBITER;
|
|
591
|
+
const cwd = options.cwd;
|
|
592
|
+
const env = options.env ?? process.env;
|
|
593
|
+
const held = requireHeldLock({
|
|
594
|
+
lockOutputs: options.lockOutputs,
|
|
595
|
+
env,
|
|
596
|
+
rung: 'task-spec',
|
|
597
|
+
});
|
|
598
|
+
if (options.handoffDir === undefined) {
|
|
599
|
+
throw new PhaseDriverError('the agent phase needs a handoff directory');
|
|
600
|
+
}
|
|
601
|
+
const slug = taskingSlug(options, options.repoPath ?? cwd);
|
|
602
|
+
const item = `spec:${slug}`;
|
|
603
|
+
|
|
604
|
+
const ownership = await checkLockOwnership({
|
|
605
|
+
cwd,
|
|
606
|
+
arbiter,
|
|
607
|
+
item,
|
|
608
|
+
lockSha: held.lockSha,
|
|
609
|
+
env,
|
|
610
|
+
});
|
|
611
|
+
if (!ownership.owned) {
|
|
612
|
+
return {
|
|
613
|
+
exitCode: 0,
|
|
614
|
+
outcome: 'stale-lock',
|
|
615
|
+
slug,
|
|
616
|
+
message: ownership.message,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
let record: HandoffRecord;
|
|
621
|
+
try {
|
|
622
|
+
await switchToWorkBranch(cwd, arbiter, slug, env, held.baseSha);
|
|
623
|
+
const outcome = await runAgentPhase(createPhaseRecorder({record: []}), () =>
|
|
624
|
+
taskingHandover({options, slug, item, cwd, env, note}),
|
|
625
|
+
);
|
|
626
|
+
if (outcome.halted) {
|
|
627
|
+
throw new Error(
|
|
628
|
+
`the tasking reached a write (${outcome.intent.seam}.${outcome.intent.method})`,
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
record = outcome.result;
|
|
632
|
+
writeHandoff({dir: options.handoffDir, rung: 'task-spec', record});
|
|
633
|
+
} catch (err) {
|
|
634
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
635
|
+
return {exitCode: 1, outcome: 'agent-failed', slug, message};
|
|
636
|
+
}
|
|
637
|
+
const kind = record.intent.kind;
|
|
638
|
+
const message = `handed over ${kind} for ${item}`;
|
|
639
|
+
return {exitCode: 0, outcome: 'handed-over', slug, message, intent: kind};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ---------------------------------------------------------------------------
|
|
643
|
+
// apply: the hostile-handoff checks
|
|
644
|
+
// ---------------------------------------------------------------------------
|
|
645
|
+
|
|
646
|
+
/** The spec's origin stamp at `baseSha`: the one every candidate carries. */
|
|
647
|
+
export type OriginStamp = Pick<Frontmatter, 'origin' | 'originTrust'>;
|
|
648
|
+
|
|
649
|
+
/** A top-level `key: value` frontmatter line (the grammar `parseFrontmatter` reads). */
|
|
650
|
+
const TOP_LEVEL_KEY_RE = /^([A-Za-z0-9_.]+)\s*:\s*(.*)$/;
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* The frontmatter of `content` split into its block lines and everything from
|
|
654
|
+
* the closing fence on, or `undefined` when it does not parse: the document must
|
|
655
|
+
* open with a `---` line and close the fence (the rule `parseFrontmatter` uses;
|
|
656
|
+
* an unclosed fence reads as NO frontmatter, so every key in it is silently
|
|
657
|
+
* ignored). A leading BOM and CRLF line ends are normalised away.
|
|
658
|
+
*/
|
|
659
|
+
function splitFrontmatter(
|
|
660
|
+
content: string,
|
|
661
|
+
): {block: string[]; rest: string[]} | undefined {
|
|
662
|
+
const normalized = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
|
|
663
|
+
if (!normalized.startsWith('---\n')) return undefined;
|
|
664
|
+
const lines = normalized.split('\n');
|
|
665
|
+
const closing = lines.indexOf('---', 1);
|
|
666
|
+
if (closing === -1) return undefined;
|
|
667
|
+
return {block: lines.slice(1, closing), rest: lines.slice(closing)};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** One top-level key of a frontmatter block, with its indented continuation lines. */
|
|
671
|
+
interface KeyEntry {
|
|
672
|
+
key: string;
|
|
673
|
+
/** The raw value, then each continuation line (a block-list item). */
|
|
674
|
+
raw: string[];
|
|
675
|
+
/** The block line indexes the entry spans, `[start, end)`. */
|
|
676
|
+
start: number;
|
|
677
|
+
end: number;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** Every top-level key of a frontmatter block, in order (duplicates kept). */
|
|
681
|
+
function keyEntries(block: string[]): KeyEntry[] {
|
|
682
|
+
const out: KeyEntry[] = [];
|
|
683
|
+
for (let i = 0; i < block.length; i++) {
|
|
684
|
+
const line = block[i];
|
|
685
|
+
if (line.trim() === '' || line.trimStart().startsWith('#')) continue;
|
|
686
|
+
const m = TOP_LEVEL_KEY_RE.exec(line);
|
|
687
|
+
if (m === null) continue;
|
|
688
|
+
let end = i + 1;
|
|
689
|
+
while (end < block.length && /^\s+\S/.test(block[end])) end++;
|
|
690
|
+
out.push({
|
|
691
|
+
key: m[1],
|
|
692
|
+
raw: [m[2].trim(), ...block.slice(i + 1, end).map((l) => l.trim())],
|
|
693
|
+
start: i,
|
|
694
|
+
end,
|
|
695
|
+
});
|
|
696
|
+
i = end - 1;
|
|
697
|
+
}
|
|
698
|
+
return out;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** The gate booleans a candidate may set; the apply job checks only that they parse. */
|
|
702
|
+
const CANDIDATE_BOOLEAN_KEYS = ['humanOnly', 'needsAnswers'] as const;
|
|
703
|
+
/** The origin stamp keys the apply job overwrites on every candidate. */
|
|
704
|
+
const STAMP_KEYS = ['origin', 'originTrust'] as const;
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Check ONE candidate task file of a hostile handoff and return the content the
|
|
708
|
+
* apply job commits (spec §3 "Structured products", task `ci-split-tasking`).
|
|
709
|
+
*
|
|
710
|
+
* - The path is derived, never taken: `work/tasks/backlog/<slug>.md`, and the
|
|
711
|
+
* slug must be safe (`slug-safety.ts`; the record validation already refused
|
|
712
|
+
* any other key, so `..` or a folder can never reach here).
|
|
713
|
+
* - It must be NEW relative to `baseSha`: a candidate that names a staged task
|
|
714
|
+
* already on the base is an edit to a task this run did not produce.
|
|
715
|
+
* - Its frontmatter must parse (an opening AND a closing `---`): an unclosed
|
|
716
|
+
* fence reads as no frontmatter, which would silently drop the stamp.
|
|
717
|
+
* - `humanOnly` / `needsAnswers` must each appear at most once and read as a
|
|
718
|
+
* boolean. Their VALUES are not judged: the review loop that sets them ran in
|
|
719
|
+
* the hostile job, and every candidate lands staged for a human to promote.
|
|
720
|
+
* - `origin` / `originTrust` are OVERWRITTEN with the spec's stamp at `baseSha`:
|
|
721
|
+
* every such line is dropped (with its continuation lines) and exactly one of
|
|
722
|
+
* each is written, so a candidate that repeats `originTrust: trusted` cannot
|
|
723
|
+
* keep one past the stamp (observation
|
|
724
|
+
* `tasker-drafted-duplicate-origintrust-launders-propagated-stamp`); then the
|
|
725
|
+
* result is re-parsed with `assertFrontmatterFields`, and any duplicate or
|
|
726
|
+
* mismatch rejects it.
|
|
727
|
+
*
|
|
728
|
+
* Throws {@link HandoffRejected} on any breach.
|
|
729
|
+
*/
|
|
730
|
+
export function checkTaskingCandidate(params: {
|
|
731
|
+
slug: string;
|
|
732
|
+
content: string;
|
|
733
|
+
stamp: OriginStamp;
|
|
734
|
+
existsAtBase: boolean;
|
|
735
|
+
}): string {
|
|
736
|
+
const {slug, content, stamp} = params;
|
|
737
|
+
const path = `${STAGED_TASKS_DIR}/${slug}.md`;
|
|
738
|
+
if (!isSafeSlug(slug)) {
|
|
739
|
+
throw new HandoffRejected(
|
|
740
|
+
'field',
|
|
741
|
+
`candidate '${slug}' is not a safe slug`,
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
if (params.existsAtBase) {
|
|
745
|
+
throw new HandoffRejected(
|
|
746
|
+
'ledger',
|
|
747
|
+
`candidate ${path} already exists at the base: an edit to a ` +
|
|
748
|
+
'pre-existing staged task this run did not produce',
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
const fm = splitFrontmatter(content);
|
|
752
|
+
if (fm === undefined) {
|
|
753
|
+
throw new HandoffRejected(
|
|
754
|
+
'field',
|
|
755
|
+
`candidate ${path}: its frontmatter does not parse (it must open and ` +
|
|
756
|
+
'close a --- fence at the top)',
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
const entries = keyEntries(fm.block);
|
|
760
|
+
for (const key of CANDIDATE_BOOLEAN_KEYS) {
|
|
761
|
+
const found = entries.filter((e) => e.key === key);
|
|
762
|
+
if (found.length > 1) {
|
|
763
|
+
throw new HandoffRejected(
|
|
764
|
+
'field',
|
|
765
|
+
`candidate ${path}: '${key}' appears ${found.length} times`,
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
const value = found[0];
|
|
769
|
+
if (value === undefined) continue;
|
|
770
|
+
const v = unquoteYamlScalar(value.raw[0]).toLowerCase();
|
|
771
|
+
if (value.raw.length > 1 || (v !== 'true' && v !== 'false')) {
|
|
772
|
+
throw new HandoffRejected(
|
|
773
|
+
'field',
|
|
774
|
+
`candidate ${path}: '${key}' does not read as a boolean`,
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
const drop = new Set<number>();
|
|
779
|
+
for (const e of entries) {
|
|
780
|
+
if ((STAMP_KEYS as readonly string[]).includes(e.key)) {
|
|
781
|
+
for (let i = e.start; i < e.end; i++) drop.add(i);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
const block = fm.block.filter((_, i) => !drop.has(i));
|
|
785
|
+
if (stamp.origin !== undefined) block.push(`origin: ${stamp.origin}`);
|
|
786
|
+
if (stamp.originTrust !== undefined) {
|
|
787
|
+
block.push(`originTrust: ${stamp.originTrust}`);
|
|
788
|
+
}
|
|
789
|
+
const stamped = ['---', ...block, ...fm.rest].join('\n');
|
|
790
|
+
try {
|
|
791
|
+
assertFrontmatterFields(stamped, {
|
|
792
|
+
origin: stamp.origin,
|
|
793
|
+
originTrust: stamp.originTrust,
|
|
794
|
+
});
|
|
795
|
+
} catch (err) {
|
|
796
|
+
if (!(err instanceof FrontmatterRenderError)) throw err;
|
|
797
|
+
throw new HandoffRejected('field', `candidate ${path}: ${err.message}`);
|
|
798
|
+
}
|
|
799
|
+
return stamped;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* The spec frontmatter keys a trimmed body must keep EXACTLY as they are at
|
|
804
|
+
* `baseSha`: the gate keys (`humanOnly`, `needsAnswers`, `taskedAfter`), the
|
|
805
|
+
* issue link and the origin stamp, plus `slug` (the tasked-spec identity
|
|
806
|
+
* `taskedAfter` of other specs resolves against).
|
|
807
|
+
*/
|
|
808
|
+
export const SPEC_PINNED_KEYS = [
|
|
809
|
+
'humanOnly',
|
|
810
|
+
'needsAnswers',
|
|
811
|
+
'taskedAfter',
|
|
812
|
+
'issue',
|
|
813
|
+
'origin',
|
|
814
|
+
'originTrust',
|
|
815
|
+
'slug',
|
|
816
|
+
] as const;
|
|
817
|
+
|
|
818
|
+
/** The headings a trimmed spec keeps (its durable framing, TASKING-PROTOCOL §6). */
|
|
819
|
+
export const SPEC_REQUIRED_HEADINGS = [
|
|
820
|
+
'Problem Statement',
|
|
821
|
+
'Solution',
|
|
822
|
+
'User Stories',
|
|
823
|
+
] as const;
|
|
824
|
+
|
|
825
|
+
function hasHeading(content: string, heading: string): boolean {
|
|
826
|
+
return content
|
|
827
|
+
.replace(/\r\n/g, '\n')
|
|
828
|
+
.split('\n')
|
|
829
|
+
.some((line) => {
|
|
830
|
+
const m = /^#{1,6}[ \t]+(.*?)[ \t#]*$/.exec(line);
|
|
831
|
+
return m !== null && m[1] === heading;
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Check the trimmed spec body of a hostile `tasking-land` against the spec at
|
|
837
|
+
* `baseSha`, and return the body the apply job commits as the tasked spec. Its
|
|
838
|
+
* frontmatter must parse; every {@link SPEC_PINNED_KEYS} key must read EXACTLY
|
|
839
|
+
* as at `baseSha` (the same top-level lines, in the same order, with the same
|
|
840
|
+
* continuation lines, so neither a first-wins nor a last-wins reader can see
|
|
841
|
+
* another value); and every {@link SPEC_REQUIRED_HEADINGS} heading the base
|
|
842
|
+
* spec has must still be there. Throws {@link HandoffRejected} otherwise.
|
|
843
|
+
*/
|
|
844
|
+
export function checkTrimmedSpecBody(params: {
|
|
845
|
+
slug: string;
|
|
846
|
+
body: string;
|
|
847
|
+
baseSpec: string;
|
|
848
|
+
}): string {
|
|
849
|
+
const {slug, body, baseSpec} = params;
|
|
850
|
+
const what = `the trimmed spec body of '${slug}'`;
|
|
851
|
+
const fm = splitFrontmatter(body);
|
|
852
|
+
if (fm === undefined) {
|
|
853
|
+
throw new HandoffRejected(
|
|
854
|
+
'field',
|
|
855
|
+
`${what}: its frontmatter does not parse (it must open and close a --- fence at the top)`,
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
const baseEntries = keyEntries(splitFrontmatter(baseSpec)?.block ?? []);
|
|
859
|
+
const bodyEntries = keyEntries(fm.block);
|
|
860
|
+
for (const key of SPEC_PINNED_KEYS) {
|
|
861
|
+
const want = baseEntries.filter((e) => e.key === key).map((e) => e.raw);
|
|
862
|
+
const got = bodyEntries.filter((e) => e.key === key).map((e) => e.raw);
|
|
863
|
+
if (JSON.stringify(want) !== JSON.stringify(got)) {
|
|
864
|
+
throw new HandoffRejected(
|
|
865
|
+
'field',
|
|
866
|
+
`${what} changes the gate key '${key}' (it must keep its value at the base)`,
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
for (const heading of SPEC_REQUIRED_HEADINGS) {
|
|
871
|
+
if (hasHeading(baseSpec, heading) && !hasHeading(body, heading)) {
|
|
872
|
+
throw new HandoffRejected(
|
|
873
|
+
'field',
|
|
874
|
+
`${what} drops the required '## ${heading}' heading`,
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return body;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// ---------------------------------------------------------------------------
|
|
882
|
+
// apply
|
|
883
|
+
// ---------------------------------------------------------------------------
|
|
884
|
+
|
|
885
|
+
/** What the apply phase works with past the ownership check. */
|
|
886
|
+
interface ApplyContext {
|
|
887
|
+
options: TaskingPhaseOptions;
|
|
888
|
+
held: HeldLock;
|
|
889
|
+
slug: string;
|
|
890
|
+
item: string;
|
|
891
|
+
arbiter: string;
|
|
892
|
+
cwd: string;
|
|
893
|
+
env: NodeJS.ProcessEnv;
|
|
894
|
+
note: (message: string) => void;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* The apply phase: check the lock is still this run's, act on the agent job's
|
|
899
|
+
* result, validate the handoff as hostile, then resume at the tasking write
|
|
900
|
+
* half. Runs no agent and no repository code.
|
|
901
|
+
*/
|
|
902
|
+
export async function performTaskingApplyPhase(
|
|
903
|
+
options: TaskingPhaseOptions,
|
|
904
|
+
): Promise<TaskingPhaseResult> {
|
|
905
|
+
const note = options.note ?? (() => {});
|
|
906
|
+
const arbiter = options.arbiter ?? DEFAULT_ARBITER;
|
|
907
|
+
const cwd = options.cwd;
|
|
908
|
+
const env = noSmudgeEnv(
|
|
909
|
+
identityEnv(options.identity, options.env ?? process.env),
|
|
910
|
+
);
|
|
911
|
+
const held = requireHeldLock({
|
|
912
|
+
lockOutputs: options.lockOutputs,
|
|
913
|
+
env,
|
|
914
|
+
rung: 'task-spec',
|
|
915
|
+
});
|
|
916
|
+
if (options.agentResult === undefined) {
|
|
917
|
+
throw new PhaseDriverError(
|
|
918
|
+
'the apply phase needs the agent job result (--agent-result, ' +
|
|
919
|
+
'needs.agent.result): only success reads the handoff',
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
const agentResult = options.agentResult;
|
|
923
|
+
const slug = taskingSlug(options, options.repoPath ?? cwd);
|
|
924
|
+
const item = `spec:${slug}`;
|
|
925
|
+
|
|
926
|
+
// Lock ownership BEFORE the first write (spec §8): a lock that was released,
|
|
927
|
+
// reaped or re-taken since the lock job ran means this run writes nothing.
|
|
928
|
+
const ownership = await checkLockOwnership({
|
|
929
|
+
cwd,
|
|
930
|
+
arbiter,
|
|
931
|
+
item,
|
|
932
|
+
lockSha: held.lockSha,
|
|
933
|
+
env,
|
|
934
|
+
});
|
|
935
|
+
if (!ownership.owned) {
|
|
936
|
+
return {
|
|
937
|
+
exitCode: 0,
|
|
938
|
+
outcome: 'stale-lock',
|
|
939
|
+
slug,
|
|
940
|
+
message: ownership.message,
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Every lock release from here on (the merge land's, the surface's) is
|
|
945
|
+
// leased on the sha this run owns.
|
|
946
|
+
const restoreLease = leaseLockReleases(item, held.lockSha);
|
|
947
|
+
try {
|
|
948
|
+
const ctx: ApplyContext = {
|
|
949
|
+
options,
|
|
950
|
+
held,
|
|
951
|
+
slug,
|
|
952
|
+
item,
|
|
953
|
+
arbiter,
|
|
954
|
+
cwd,
|
|
955
|
+
env,
|
|
956
|
+
note,
|
|
957
|
+
};
|
|
958
|
+
const decision = await resolveAgentResult({
|
|
959
|
+
result: agentResult,
|
|
960
|
+
held,
|
|
961
|
+
agentTimeoutMinutes: options.agentTimeoutMinutes,
|
|
962
|
+
api: options.actionsApi,
|
|
963
|
+
env,
|
|
964
|
+
});
|
|
965
|
+
switch (decision.action) {
|
|
966
|
+
case 'read-handoff':
|
|
967
|
+
return await applyOwned(ctx);
|
|
968
|
+
case 'deterministic':
|
|
969
|
+
return await surfaceSpec(
|
|
970
|
+
ctx,
|
|
971
|
+
'the agent job was skipped (needsAgent: false), but the tasking ' +
|
|
972
|
+
'path has no rung that runs without an agent; the handoff was not read',
|
|
973
|
+
);
|
|
974
|
+
case 'surface':
|
|
975
|
+
return await surfaceSpec(ctx, decision.reason);
|
|
976
|
+
case 'release': {
|
|
977
|
+
const released = await releaseLockLeased({
|
|
978
|
+
cwd,
|
|
979
|
+
arbiter,
|
|
980
|
+
item,
|
|
981
|
+
expectedSha: held.lockSha,
|
|
982
|
+
env,
|
|
983
|
+
});
|
|
984
|
+
note(released.message);
|
|
985
|
+
const reason = decision.reason;
|
|
986
|
+
return released.released
|
|
987
|
+
? {
|
|
988
|
+
exitCode: 0,
|
|
989
|
+
outcome: 'released',
|
|
990
|
+
slug,
|
|
991
|
+
message: `Released '${slug}': ${reason}.`,
|
|
992
|
+
}
|
|
993
|
+
: {
|
|
994
|
+
exitCode: 1,
|
|
995
|
+
outcome: 'release-refused',
|
|
996
|
+
slug,
|
|
997
|
+
message: `Could not release '${slug}' (${released.message}): ${reason}.`,
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
} finally {
|
|
1002
|
+
restoreLease();
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* Surface the spec to needs-attention because the agent job did not succeed
|
|
1008
|
+
* (decision 5): the question sidecar and `needsAnswers: true` on `main`, the lock
|
|
1009
|
+
* released (leased). Nothing from the agent job is read or pushed.
|
|
1010
|
+
*/
|
|
1011
|
+
async function surfaceSpec(
|
|
1012
|
+
ctx: ApplyContext,
|
|
1013
|
+
reason: string,
|
|
1014
|
+
): Promise<TaskingPhaseResult> {
|
|
1015
|
+
const {slug, cwd, arbiter, env, note} = ctx;
|
|
1016
|
+
const routed = await releaseTaskingLock({
|
|
1017
|
+
slug,
|
|
1018
|
+
cwd,
|
|
1019
|
+
arbiter,
|
|
1020
|
+
routeToNeedsAttention: {reason},
|
|
1021
|
+
env,
|
|
1022
|
+
note,
|
|
1023
|
+
});
|
|
1024
|
+
const moved = routed.outcome === 'released';
|
|
1025
|
+
const message = moved
|
|
1026
|
+
? `Surfaced '${slug}' to needs-attention: ${reason}`
|
|
1027
|
+
: `Could not surface '${slug}' (${routed.message}): ${reason}`;
|
|
1028
|
+
return {
|
|
1029
|
+
exitCode: moved ? 0 : 1,
|
|
1030
|
+
outcome: moved ? 'surfaced' : 'surface-unmoved',
|
|
1031
|
+
slug,
|
|
1032
|
+
message,
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/** The spec at `baseSha` (trusted), or a usage error when it is not there. */
|
|
1037
|
+
async function specAtBase(ctx: ApplyContext): Promise<{
|
|
1038
|
+
content: string;
|
|
1039
|
+
blob: string;
|
|
1040
|
+
}> {
|
|
1041
|
+
const rel = workItemRel('specs-ready', `${ctx.slug}.md`);
|
|
1042
|
+
const show = await runAsync(
|
|
1043
|
+
'git',
|
|
1044
|
+
['cat-file', 'blob', `${ctx.held.baseSha}:${rel}`],
|
|
1045
|
+
ctx.cwd,
|
|
1046
|
+
{env: ctx.env},
|
|
1047
|
+
);
|
|
1048
|
+
const blob = await runAsync(
|
|
1049
|
+
'git',
|
|
1050
|
+
['rev-parse', `${ctx.held.baseSha}:${rel}`],
|
|
1051
|
+
ctx.cwd,
|
|
1052
|
+
{env: ctx.env},
|
|
1053
|
+
);
|
|
1054
|
+
if (show.status !== 0 || blob.status !== 0) {
|
|
1055
|
+
throw new PhaseDriverError(
|
|
1056
|
+
`the spec ${rel} is not at the lock job's base ${ctx.held.baseSha}`,
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
return {content: show.stdout, blob: blob.stdout.trim()};
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/** Whether `work/tasks/backlog/<slug>.md` exists at `baseSha`. */
|
|
1063
|
+
async function stagedTaskAtBase(
|
|
1064
|
+
ctx: ApplyContext,
|
|
1065
|
+
slug: string,
|
|
1066
|
+
): Promise<boolean> {
|
|
1067
|
+
const r = await runAsync(
|
|
1068
|
+
'git',
|
|
1069
|
+
['cat-file', '-e', `${ctx.held.baseSha}:${STAGED_TASKS_DIR}/${slug}.md`],
|
|
1070
|
+
ctx.cwd,
|
|
1071
|
+
{env: ctx.env},
|
|
1072
|
+
);
|
|
1073
|
+
return r.status === 0;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/** Validate every candidate of the record (throws {@link HandoffRejected}). */
|
|
1077
|
+
async function checkCandidates(
|
|
1078
|
+
ctx: ApplyContext,
|
|
1079
|
+
candidates: Record<string, string>,
|
|
1080
|
+
stamp: OriginStamp,
|
|
1081
|
+
): Promise<Record<string, string>> {
|
|
1082
|
+
const out: Record<string, string> = {};
|
|
1083
|
+
for (const [slug, content] of Object.entries(candidates)) {
|
|
1084
|
+
out[slug] = checkTaskingCandidate({
|
|
1085
|
+
slug,
|
|
1086
|
+
content,
|
|
1087
|
+
stamp,
|
|
1088
|
+
existsAtBase: await stagedTaskAtBase(ctx, slug),
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
return out;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/** Write the checked candidates to `work/tasks/backlog/<slug>.md`. */
|
|
1095
|
+
function writeCandidates(cwd: string, candidates: Record<string, string>) {
|
|
1096
|
+
for (const [slug, content] of Object.entries(candidates)) {
|
|
1097
|
+
const abs = join(cwd, STAGED_TASKS_DIR, `${slug}.md`);
|
|
1098
|
+
mkdirSync(dirname(abs), {recursive: true});
|
|
1099
|
+
writeFileSync(abs, content);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* The apply phase once the lock is known to be this run's: read the handoff,
|
|
1105
|
+
* check it, rebuild the commit on a fresh `work/spec-<slug>` branch cut from
|
|
1106
|
+
* `baseSha`, and resume at the write half.
|
|
1107
|
+
*/
|
|
1108
|
+
async function applyOwned(ctx: ApplyContext): Promise<TaskingPhaseResult> {
|
|
1109
|
+
const {options, held, slug, item, arbiter, cwd, env, note} = ctx;
|
|
1110
|
+
const mode = options.taskingIntegration ?? options.integration ?? 'propose';
|
|
1111
|
+
const rejected = async (reason: string): Promise<TaskingPhaseResult> => {
|
|
1112
|
+
note(reason);
|
|
1113
|
+
const r = await surfaceTaskingBlock({
|
|
1114
|
+
slug,
|
|
1115
|
+
cwd,
|
|
1116
|
+
arbiter,
|
|
1117
|
+
reason,
|
|
1118
|
+
message: `the handoff was rejected; parked '${slug}' for your attention`,
|
|
1119
|
+
lockedBlob: undefined,
|
|
1120
|
+
release: releaseTaskingLock,
|
|
1121
|
+
mode,
|
|
1122
|
+
provider: options.providerInstance,
|
|
1123
|
+
env,
|
|
1124
|
+
note,
|
|
1125
|
+
});
|
|
1126
|
+
// A rejected handoff whose surface landed is handled, so green ("A
|
|
1127
|
+
// handled outcome is green", `ci-phase-driver.ts`).
|
|
1128
|
+
const surfaced = r.outcome === 'needs-attention';
|
|
1129
|
+
return {
|
|
1130
|
+
exitCode: surfaced ? 0 : 1,
|
|
1131
|
+
outcome: surfaced ? 'rejected' : 'surface-unmoved',
|
|
1132
|
+
slug,
|
|
1133
|
+
message: `${reason}; ${r.message}`,
|
|
1134
|
+
};
|
|
1135
|
+
};
|
|
1136
|
+
|
|
1137
|
+
const base = await specAtBase(ctx);
|
|
1138
|
+
const baseFm = parseFrontmatter(base.content);
|
|
1139
|
+
const stamp: OriginStamp = {
|
|
1140
|
+
origin: baseFm.origin,
|
|
1141
|
+
originTrust: baseFm.originTrust,
|
|
1142
|
+
};
|
|
1143
|
+
|
|
1144
|
+
let record: HandoffRecord;
|
|
1145
|
+
let candidates: Record<string, string>;
|
|
1146
|
+
let specBody: string | undefined;
|
|
1147
|
+
try {
|
|
1148
|
+
if (options.handoffDir === undefined) {
|
|
1149
|
+
throw new HandoffRejected('layout', 'no handoff directory was given');
|
|
1150
|
+
}
|
|
1151
|
+
record = readHandoff({
|
|
1152
|
+
dir: options.handoffDir,
|
|
1153
|
+
runnerTemp: runnerTempFrom(options.runnerTemp, env),
|
|
1154
|
+
trust: {item, rung: 'task-spec'},
|
|
1155
|
+
}).record;
|
|
1156
|
+
const products = record.products as {candidates: Record<string, string>};
|
|
1157
|
+
candidates = await checkCandidates(ctx, products.candidates, stamp);
|
|
1158
|
+
if (record.intent.kind === 'tasking-land') {
|
|
1159
|
+
const land = record.products as TaskingLandProducts;
|
|
1160
|
+
if (
|
|
1161
|
+
land.reviewVerdict !== undefined &&
|
|
1162
|
+
land.reviewVerdict !== 'approve'
|
|
1163
|
+
) {
|
|
1164
|
+
throw new HandoffRejected(
|
|
1165
|
+
'field',
|
|
1166
|
+
`products.reviewVerdict is ${land.reviewVerdict}: a blocked task-set ` +
|
|
1167
|
+
'review is a tasking-surface, never a tasking-land',
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
specBody = checkTrimmedSpecBody({
|
|
1171
|
+
slug,
|
|
1172
|
+
body: land.specBody,
|
|
1173
|
+
baseSpec: base.content,
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
} catch (err) {
|
|
1177
|
+
if (!(err instanceof HandoffRejected)) throw err;
|
|
1178
|
+
return rejected(err.message);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// A FRESH work branch cut from `baseSha`: nothing from the agent job but the
|
|
1182
|
+
// checked files above reaches it.
|
|
1183
|
+
await switchToWorkBranch(cwd, arbiter, slug, env, held.baseSha);
|
|
1184
|
+
const before = snapshotStagedTasks(cwd);
|
|
1185
|
+
const poolBefore = snapshotPool(cwd);
|
|
1186
|
+
writeCandidates(cwd, candidates);
|
|
1187
|
+
|
|
1188
|
+
if (record.intent.kind === 'tasking-surface') {
|
|
1189
|
+
const surfaceProducts = record.products as TaskingSurfaceProducts;
|
|
1190
|
+
await persistTaskingCandidates(cwd, slug, before, arbiter, env, note);
|
|
1191
|
+
const r = await surfaceTaskingBlock({
|
|
1192
|
+
slug,
|
|
1193
|
+
cwd,
|
|
1194
|
+
arbiter,
|
|
1195
|
+
reason: surfaceProducts.reason,
|
|
1196
|
+
message: `parked '${slug}' for your attention (no tasks landed)`,
|
|
1197
|
+
lockedBlob: base.blob,
|
|
1198
|
+
release: releaseTaskingLock,
|
|
1199
|
+
mode,
|
|
1200
|
+
provider: options.providerInstance,
|
|
1201
|
+
env,
|
|
1202
|
+
note,
|
|
1203
|
+
});
|
|
1204
|
+
return fromTaskResult(r, mode);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
const land = record.products as TaskingLandProducts;
|
|
1208
|
+
writeFileSync(workItemPath(cwd, 'specs-ready', slug), specBody as string);
|
|
1209
|
+
const stagedEmitted = newOrChangedStagedTasks(cwd, before);
|
|
1210
|
+
const r = await integrateTaskingCandidates({
|
|
1211
|
+
slug,
|
|
1212
|
+
cwd,
|
|
1213
|
+
arbiter,
|
|
1214
|
+
stagedEmitted,
|
|
1215
|
+
emitTasks: collectEmittedTasks(cwd, stagedEmitted),
|
|
1216
|
+
poolBefore,
|
|
1217
|
+
placementDir: taskPlacementDir(options, baseFm),
|
|
1218
|
+
loopTag: undefined,
|
|
1219
|
+
lockedBlob: base.blob,
|
|
1220
|
+
release: releaseTaskingLock,
|
|
1221
|
+
mode,
|
|
1222
|
+
// The task-set review already ran in the agent phase.
|
|
1223
|
+
review: false,
|
|
1224
|
+
approvedReviewProse: land.reviewProse,
|
|
1225
|
+
mergeRetries: options.mergeRetries,
|
|
1226
|
+
mergeJitterMs: options.mergeJitterMs,
|
|
1227
|
+
noPR: options.noPR,
|
|
1228
|
+
providerInstance: options.providerInstance,
|
|
1229
|
+
env,
|
|
1230
|
+
agentEnv: env,
|
|
1231
|
+
note,
|
|
1232
|
+
});
|
|
1233
|
+
return fromTaskResult(r, mode);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
/** Map the tasking write half's {@link TaskResult} to the apply phase's. */
|
|
1237
|
+
function fromTaskResult(r: TaskResult, mode: string): TaskingPhaseResult {
|
|
1238
|
+
const outcome: TaskingPhaseOutcome =
|
|
1239
|
+
r.outcome === 'tasked'
|
|
1240
|
+
? mode === 'merge'
|
|
1241
|
+
? 'landed'
|
|
1242
|
+
: 'proposed'
|
|
1243
|
+
: r.outcome === 'needs-attention'
|
|
1244
|
+
? 'surfaced'
|
|
1245
|
+
: r.outcome === 'stale'
|
|
1246
|
+
? 'stale'
|
|
1247
|
+
: 'usage-error';
|
|
1248
|
+
return {
|
|
1249
|
+
exitCode: r.exitCode,
|
|
1250
|
+
outcome,
|
|
1251
|
+
slug: r.slug,
|
|
1252
|
+
message: r.message,
|
|
1253
|
+
...(r.emitted === undefined ? {} : {emitted: r.emitted}),
|
|
1254
|
+
};
|
|
1255
|
+
}
|