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,926 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **The CI handoff record: `handoff.json` schema v1 and its intent table**
|
|
3
|
+
* (spec `ci-agent-job-without-write-token`, ADR
|
|
4
|
+
* `ci-agent-job-holds-no-write-token`).
|
|
5
|
+
*
|
|
6
|
+
* In CI every item runs as three jobs: **lock** (write token, no agent),
|
|
7
|
+
* **agent** (read-only token) and **apply** (write token, no agent). The agent
|
|
8
|
+
* job hands its results to the apply job through ONE artifact per item run (the
|
|
9
|
+
* file I/O is `ci-handoff.ts`). Named "CI handoff" to keep it apart from the
|
|
10
|
+
* requeue "handoff note" (`prompt.ts`), an unrelated human-written body section.
|
|
11
|
+
*
|
|
12
|
+
* `handoff.json` is `{schema: 1, item, intent: {kind}, products}`. `intent` is
|
|
13
|
+
* the closed union of the 15 kinds, one per boundary; `products` holds the
|
|
14
|
+
* bounded fields the agent phase produced (texts, verdict enums, booleans).
|
|
15
|
+
*
|
|
16
|
+
* **The security contract is {@link HANDOFF_INTENT_TABLE}.** For every intent
|
|
17
|
+
* it lists what the apply job recomputes from trusted inputs, the ONLY fields
|
|
18
|
+
* it takes from the record, whether a bundle comes with it, and the tail
|
|
19
|
+
* function the apply job resumes at. A field outside a row's `record` column is
|
|
20
|
+
* REJECTED, never ignored, so a record cannot smuggle a target, a mode or a path
|
|
21
|
+
* the apply job must derive itself. Which intents a run may hand over is bounded
|
|
22
|
+
* by its TRUSTED rung (a lock-job output): a surface run cannot hand over
|
|
23
|
+
* `integrate`. Every limit is a constant here, never read from the artifact.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import {PR_TITLE_MAX} from './integration-core.js';
|
|
27
|
+
import {isSafeSlug} from './slug-safety.js';
|
|
28
|
+
import {parseSlugArg} from './slug-namespace.js';
|
|
29
|
+
|
|
30
|
+
/** The only `schema` value this dorfl reads or writes. */
|
|
31
|
+
export const HANDOFF_SCHEMA = 1;
|
|
32
|
+
|
|
33
|
+
const MB = 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The size limits (spec, "Size limits"), set in code and never read from the
|
|
37
|
+
* artifact. Byte limits are on file sizes; character limits are on JavaScript
|
|
38
|
+
* string length (UTF-16 code units, never fewer than the characters GitHub
|
|
39
|
+
* counts).
|
|
40
|
+
*/
|
|
41
|
+
export const HANDOFF_LIMITS = {
|
|
42
|
+
/** Every file of the extracted artifact together. */
|
|
43
|
+
artifactBytes: 200 * MB,
|
|
44
|
+
/** `work.bundle`. */
|
|
45
|
+
bundleBytes: 100 * MB,
|
|
46
|
+
/** One blob inside the bundle (enforced by the bundle content validation). */
|
|
47
|
+
blobBytes: 20 * MB,
|
|
48
|
+
/** Every file under `lfs/` together. */
|
|
49
|
+
lfsBytes: 500 * MB,
|
|
50
|
+
/** `handoff.json`. */
|
|
51
|
+
handoffJsonBytes: 2 * MB,
|
|
52
|
+
/** A PR title (the existing {@link PR_TITLE_MAX}). */
|
|
53
|
+
prTitleChars: PR_TITLE_MAX,
|
|
54
|
+
/** A PR body, and every text posted as one comment (under GitHub's 65,536). */
|
|
55
|
+
commentChars: 60_000,
|
|
56
|
+
/** Each reason or question (and a surfaced question's context and default). */
|
|
57
|
+
reasonChars: 10_000,
|
|
58
|
+
/**
|
|
59
|
+
* A drafted document title (task, spec or ADR `title:`). Not in the spec's
|
|
60
|
+
* list: a single line that becomes frontmatter, so it gets a tight bound.
|
|
61
|
+
*/
|
|
62
|
+
documentTitleChars: 200,
|
|
63
|
+
/**
|
|
64
|
+
* A whole document (a candidate task file, a drafted task/spec/ADR body, the
|
|
65
|
+
* trimmed spec body). Not in the spec's list: the largest spec in this repo
|
|
66
|
+
* is about 52,000 characters, so the comment limit would be too tight. All
|
|
67
|
+
* documents together are still bounded by `handoffJsonBytes`.
|
|
68
|
+
*/
|
|
69
|
+
documentChars: 200_000,
|
|
70
|
+
} as const;
|
|
71
|
+
|
|
72
|
+
/** The byte limits of {@link HANDOFF_LIMITS} (the ones a test may lower). */
|
|
73
|
+
export type HandoffByteLimits = {
|
|
74
|
+
[K in
|
|
75
|
+
| 'artifactBytes'
|
|
76
|
+
| 'bundleBytes'
|
|
77
|
+
| 'blobBytes'
|
|
78
|
+
| 'lfsBytes'
|
|
79
|
+
| 'handoffJsonBytes']: number;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
let byteLimitsOverride: Partial<HandoffByteLimits> | undefined;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The byte limits in force: {@link HANDOFF_LIMITS}, unless a test lowered some
|
|
86
|
+
* of them with {@link setHandoffByteLimitsForTest} (so a size-limit test does
|
|
87
|
+
* not need a 200 MB fixture). Nothing read from an artifact reaches this.
|
|
88
|
+
*/
|
|
89
|
+
export function handoffByteLimits(): HandoffByteLimits {
|
|
90
|
+
return {
|
|
91
|
+
artifactBytes: HANDOFF_LIMITS.artifactBytes,
|
|
92
|
+
bundleBytes: HANDOFF_LIMITS.bundleBytes,
|
|
93
|
+
blobBytes: HANDOFF_LIMITS.blobBytes,
|
|
94
|
+
lfsBytes: HANDOFF_LIMITS.lfsBytes,
|
|
95
|
+
handoffJsonBytes: HANDOFF_LIMITS.handoffJsonBytes,
|
|
96
|
+
...byteLimitsOverride,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Lower some byte limits (tests only); `undefined` restores the production values. */
|
|
101
|
+
export function setHandoffByteLimitsForTest(
|
|
102
|
+
override: Partial<HandoffByteLimits> | undefined,
|
|
103
|
+
): void {
|
|
104
|
+
byteLimitsOverride = override;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Rejection
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
/** Which rule a rejected handoff broke (machine-stable, for tests and reports). */
|
|
112
|
+
export type HandoffRule =
|
|
113
|
+
| 'location'
|
|
114
|
+
| 'layout'
|
|
115
|
+
| 'symlink'
|
|
116
|
+
| 'size'
|
|
117
|
+
| 'json'
|
|
118
|
+
| 'schema'
|
|
119
|
+
| 'shape'
|
|
120
|
+
| 'item'
|
|
121
|
+
| 'kind'
|
|
122
|
+
| 'kind-for-rung'
|
|
123
|
+
| 'field'
|
|
124
|
+
| 'limit'
|
|
125
|
+
| 'bundle-presence'
|
|
126
|
+
| 'bundle-format'
|
|
127
|
+
| 'bundle-refs'
|
|
128
|
+
| 'name'
|
|
129
|
+
// The bundle's content (`ci-handoff-apply.ts`):
|
|
130
|
+
| 'history'
|
|
131
|
+
| 'merge-commit'
|
|
132
|
+
| 'commit-count'
|
|
133
|
+
| 'protected-path'
|
|
134
|
+
| 'symlink-target'
|
|
135
|
+
| 'gitlink'
|
|
136
|
+
| 'ledger'
|
|
137
|
+
// The LFS objects (`ci-handoff-lfs.ts`):
|
|
138
|
+
| 'lfs-pointer'
|
|
139
|
+
| 'lfs-missing'
|
|
140
|
+
| 'lfs-mismatch'
|
|
141
|
+
| 'lfs-extra';
|
|
142
|
+
|
|
143
|
+
/** A handoff (or one being written) that breaks one of the rules. */
|
|
144
|
+
export class HandoffRejected extends Error {
|
|
145
|
+
constructor(
|
|
146
|
+
readonly rule: HandoffRule,
|
|
147
|
+
message: string,
|
|
148
|
+
) {
|
|
149
|
+
super(`handoff rejected (${rule}): ${message}`);
|
|
150
|
+
this.name = 'HandoffRejected';
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Throw a {@link HandoffRejected}. */
|
|
155
|
+
export function rejectHandoff(rule: HandoffRule, message: string): never {
|
|
156
|
+
throw new HandoffRejected(rule, message);
|
|
157
|
+
}
|
|
158
|
+
const reject: (rule: HandoffRule, message: string) => never = rejectHandoff;
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Rungs and intent kinds
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The trusted rung the lock job classified (its `rung` output): the agent
|
|
166
|
+
* rungs of `advance-classify.ts` (`TickRungKind`) plus `intake`. It bounds
|
|
167
|
+
* which intents the run may hand over.
|
|
168
|
+
*/
|
|
169
|
+
export const HANDOFF_RUNGS = [
|
|
170
|
+
'build-task',
|
|
171
|
+
'task-spec',
|
|
172
|
+
'surface',
|
|
173
|
+
'triage-observation',
|
|
174
|
+
'apply',
|
|
175
|
+
'intake',
|
|
176
|
+
] as const;
|
|
177
|
+
export type HandoffRung = (typeof HANDOFF_RUNGS)[number];
|
|
178
|
+
|
|
179
|
+
/** The 15 intent kinds (spec, "The intent kinds"). */
|
|
180
|
+
export const HANDOFF_INTENT_KINDS = [
|
|
181
|
+
'integrate',
|
|
182
|
+
'merge-restale',
|
|
183
|
+
'needs-attention',
|
|
184
|
+
'deadline-checkpoint',
|
|
185
|
+
'stop',
|
|
186
|
+
'agent-failed',
|
|
187
|
+
'tasking-land',
|
|
188
|
+
'tasking-surface',
|
|
189
|
+
'surface',
|
|
190
|
+
'triage',
|
|
191
|
+
'apply-decision',
|
|
192
|
+
'intake-ask',
|
|
193
|
+
'intake-bounce',
|
|
194
|
+
'intake-task',
|
|
195
|
+
'intake-spec',
|
|
196
|
+
] as const;
|
|
197
|
+
export type HandoffIntentKind = (typeof HANDOFF_INTENT_KINDS)[number];
|
|
198
|
+
|
|
199
|
+
/** The agent-stop kinds a `stop` carries (`agent-stop.ts` `AgentStopKind`). */
|
|
200
|
+
export const STOP_KINDS = ['sentinel', 'empty-diff'] as const;
|
|
201
|
+
/** The triage gate's dispositions (`keep` is the fall-through to surface). */
|
|
202
|
+
export const TRIAGE_DISPOSITIONS = ['keep', 'duplicate', 'map'] as const;
|
|
203
|
+
/** The agentic apply decision's outcomes (`APPLY_ALLOWED_OUTCOMES`). */
|
|
204
|
+
export const APPLY_DECISION_OUTCOMES = [
|
|
205
|
+
'task',
|
|
206
|
+
'spec',
|
|
207
|
+
'adr',
|
|
208
|
+
'dispose',
|
|
209
|
+
'resolve',
|
|
210
|
+
'ask',
|
|
211
|
+
] as const;
|
|
212
|
+
/** The task-set review verdict of `tasking-land`. */
|
|
213
|
+
export const REVIEW_VERDICTS = ['approve', 'block'] as const;
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Record types (what `products` holds per intent)
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
/** `integrate` on the build rung. */
|
|
220
|
+
export interface IntegrateBuildProducts {
|
|
221
|
+
prTitle: string;
|
|
222
|
+
prBody: string;
|
|
223
|
+
reviewProse?: string;
|
|
224
|
+
}
|
|
225
|
+
/** Answered-merge `integrate`, `merge-restale`, `deadline-checkpoint`: nothing. */
|
|
226
|
+
export type NoProducts = Record<string, never>;
|
|
227
|
+
export interface NeedsAttentionProducts {
|
|
228
|
+
reason: string;
|
|
229
|
+
questions?: string[];
|
|
230
|
+
}
|
|
231
|
+
export interface StopProducts {
|
|
232
|
+
reason: string;
|
|
233
|
+
stopKind: (typeof STOP_KINDS)[number];
|
|
234
|
+
}
|
|
235
|
+
export interface AgentFailedProducts {
|
|
236
|
+
failureDetail: string;
|
|
237
|
+
}
|
|
238
|
+
export interface TaskingLandProducts {
|
|
239
|
+
/** Candidate task file contents keyed by safe slug. */
|
|
240
|
+
candidates: Record<string, string>;
|
|
241
|
+
prBody: string;
|
|
242
|
+
reviewVerdict?: (typeof REVIEW_VERDICTS)[number];
|
|
243
|
+
reviewProse?: string;
|
|
244
|
+
/** The trimmed spec body (task `ci-split-tasking`). */
|
|
245
|
+
specBody: string;
|
|
246
|
+
}
|
|
247
|
+
export interface TaskingSurfaceProducts {
|
|
248
|
+
candidates: Record<string, string>;
|
|
249
|
+
reason: string;
|
|
250
|
+
questions?: string[];
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* A surfaced question with the optional context and suggested default the
|
|
254
|
+
* `surface-questions` agent gave it (the sidecar authoring fields, see
|
|
255
|
+
* `SurfaceQuestion` in `surface-gate.ts`). A closed shape: no other key is
|
|
256
|
+
* accepted, and each text is bounded by `reasonChars`.
|
|
257
|
+
*/
|
|
258
|
+
export interface HandoffQuestion {
|
|
259
|
+
question: string;
|
|
260
|
+
context?: string;
|
|
261
|
+
default?: string;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* One entry of a surfaced-question list: a {@link HandoffQuestion}, or a bare
|
|
265
|
+
* string for a question with neither context nor default (the plain form every
|
|
266
|
+
* schema-1 writer before the object form wrote, still read).
|
|
267
|
+
*/
|
|
268
|
+
export type HandoffQuestionEntry = string | HandoffQuestion;
|
|
269
|
+
export interface SurfaceProducts {
|
|
270
|
+
questions: HandoffQuestionEntry[];
|
|
271
|
+
}
|
|
272
|
+
export interface TriageProducts {
|
|
273
|
+
disposition: (typeof TRIAGE_DISPOSITIONS)[number];
|
|
274
|
+
/** The existing item (`<task|spec|observation>:<slug>`) for `duplicate` / `map`. */
|
|
275
|
+
target?: string;
|
|
276
|
+
reason?: string;
|
|
277
|
+
/** The surface agent's questions when the rung fell through (`keep`). */
|
|
278
|
+
questions?: HandoffQuestionEntry[];
|
|
279
|
+
}
|
|
280
|
+
export interface ApplyDecisionProducts {
|
|
281
|
+
outcome: (typeof APPLY_DECISION_OUTCOMES)[number];
|
|
282
|
+
/** For `task` / `spec` / `adr`: the minted title, body and slug. */
|
|
283
|
+
title?: string;
|
|
284
|
+
body?: string;
|
|
285
|
+
slug?: string;
|
|
286
|
+
reason?: string;
|
|
287
|
+
/** For `ask`: the follow-up questions, one entry per question (never joined). */
|
|
288
|
+
questions?: string[];
|
|
289
|
+
}
|
|
290
|
+
export interface IntakeAskProducts {
|
|
291
|
+
question: string;
|
|
292
|
+
}
|
|
293
|
+
export interface IntakeBounceProducts {
|
|
294
|
+
bounceText: string;
|
|
295
|
+
}
|
|
296
|
+
export interface IntakeTaskProducts {
|
|
297
|
+
title: string;
|
|
298
|
+
body: string;
|
|
299
|
+
}
|
|
300
|
+
export interface IntakeSpecProducts {
|
|
301
|
+
title: string;
|
|
302
|
+
body: string;
|
|
303
|
+
humanOnly?: boolean;
|
|
304
|
+
needsAnswers?: boolean;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** One `{intent, products}` pair; `products` narrows with `intent.kind`. */
|
|
308
|
+
export type HandoffPayload =
|
|
309
|
+
| {
|
|
310
|
+
intent: {kind: 'integrate'};
|
|
311
|
+
products: IntegrateBuildProducts | NoProducts;
|
|
312
|
+
}
|
|
313
|
+
| {intent: {kind: 'merge-restale'}; products: NoProducts}
|
|
314
|
+
| {intent: {kind: 'needs-attention'}; products: NeedsAttentionProducts}
|
|
315
|
+
| {intent: {kind: 'deadline-checkpoint'}; products: NoProducts}
|
|
316
|
+
| {intent: {kind: 'stop'}; products: StopProducts}
|
|
317
|
+
| {intent: {kind: 'agent-failed'}; products: AgentFailedProducts}
|
|
318
|
+
| {intent: {kind: 'tasking-land'}; products: TaskingLandProducts}
|
|
319
|
+
| {intent: {kind: 'tasking-surface'}; products: TaskingSurfaceProducts}
|
|
320
|
+
| {intent: {kind: 'surface'}; products: SurfaceProducts}
|
|
321
|
+
| {intent: {kind: 'triage'}; products: TriageProducts}
|
|
322
|
+
| {intent: {kind: 'apply-decision'}; products: ApplyDecisionProducts}
|
|
323
|
+
| {intent: {kind: 'intake-ask'}; products: IntakeAskProducts}
|
|
324
|
+
| {intent: {kind: 'intake-bounce'}; products: IntakeBounceProducts}
|
|
325
|
+
| {intent: {kind: 'intake-task'}; products: IntakeTaskProducts}
|
|
326
|
+
| {intent: {kind: 'intake-spec'}; products: IntakeSpecProducts};
|
|
327
|
+
|
|
328
|
+
/** The whole `handoff.json`. */
|
|
329
|
+
export type HandoffRecord = {
|
|
330
|
+
schema: typeof HANDOFF_SCHEMA;
|
|
331
|
+
/** The canonical item id ({@link canonicalHandoffItem}). */
|
|
332
|
+
item: string;
|
|
333
|
+
} & HandoffPayload;
|
|
334
|
+
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// Field specs
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
/** How one field of a row's `record` column is validated. */
|
|
340
|
+
export type FieldSpec = {required: boolean} & (
|
|
341
|
+
| {type: 'line'; maxChars: number}
|
|
342
|
+
| {type: 'text'; maxChars: number}
|
|
343
|
+
| {type: 'text-list'; maxChars: number}
|
|
344
|
+
| {type: 'question-list'; maxChars: number}
|
|
345
|
+
| {type: 'documents'; maxChars: number}
|
|
346
|
+
| {type: 'enum'; values: readonly string[]}
|
|
347
|
+
| {type: 'boolean'}
|
|
348
|
+
| {type: 'slug'}
|
|
349
|
+
| {type: 'item-ref'}
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
const L = HANDOFF_LIMITS;
|
|
353
|
+
const line = (maxChars: number, required = true): FieldSpec => ({
|
|
354
|
+
type: 'line',
|
|
355
|
+
maxChars,
|
|
356
|
+
required,
|
|
357
|
+
});
|
|
358
|
+
const text = (maxChars: number, required = true): FieldSpec => ({
|
|
359
|
+
type: 'text',
|
|
360
|
+
maxChars,
|
|
361
|
+
required,
|
|
362
|
+
});
|
|
363
|
+
const textList = (maxChars: number, required = true): FieldSpec => ({
|
|
364
|
+
type: 'text-list',
|
|
365
|
+
maxChars,
|
|
366
|
+
required,
|
|
367
|
+
});
|
|
368
|
+
const questionList = (maxChars: number, required = true): FieldSpec => ({
|
|
369
|
+
type: 'question-list',
|
|
370
|
+
maxChars,
|
|
371
|
+
required,
|
|
372
|
+
});
|
|
373
|
+
const oneOf = (values: readonly string[], required = true): FieldSpec => ({
|
|
374
|
+
type: 'enum',
|
|
375
|
+
values,
|
|
376
|
+
required,
|
|
377
|
+
});
|
|
378
|
+
const docs = (): FieldSpec => ({
|
|
379
|
+
type: 'documents',
|
|
380
|
+
maxChars: L.documentChars,
|
|
381
|
+
required: true,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
// The intent table (the security contract)
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
/** Whether a bundle must, may or must not come with an intent. */
|
|
389
|
+
export type BundleRule = 'required' | 'optional' | 'none';
|
|
390
|
+
|
|
391
|
+
/** One row of the intent table: its four columns plus the rungs that produce it. */
|
|
392
|
+
export interface IntentRow {
|
|
393
|
+
/** A stable row name (`integrate` has two rows). */
|
|
394
|
+
row: string;
|
|
395
|
+
kind: HandoffIntentKind;
|
|
396
|
+
/** The trusted rungs that may hand this intent over. */
|
|
397
|
+
rungs: readonly HandoffRung[];
|
|
398
|
+
/** Column 1: what the apply job recomputes from trusted inputs. */
|
|
399
|
+
recomputed: readonly string[];
|
|
400
|
+
/** Column 2: the ONLY fields taken from the record (`products`); `{}` = none. */
|
|
401
|
+
record: Readonly<Record<string, FieldSpec>>;
|
|
402
|
+
/** Cross-field rules of the record, run after every field passed. */
|
|
403
|
+
check?: (products: Record<string, unknown>) => void;
|
|
404
|
+
/** Column 3: what comes from the bundle, and whether one must come. */
|
|
405
|
+
bundle: {rule: BundleRule; carries: string};
|
|
406
|
+
/** Column 4: the tail function the apply job resumes at. */
|
|
407
|
+
resumesAt: string;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const NO_BUNDLE = {rule: 'none', carries: 'none'} as const;
|
|
411
|
+
|
|
412
|
+
const INTAKE_DOCUMENT_RECOMPUTED = [
|
|
413
|
+
'issue number',
|
|
414
|
+
'placement',
|
|
415
|
+
'origin-trust stamp',
|
|
416
|
+
'document mode',
|
|
417
|
+
'seen comment ids',
|
|
418
|
+
'the slug (derived from the verdict, then checked by slug-safety.ts)',
|
|
419
|
+
];
|
|
420
|
+
const INTAKE_DOCUMENT_RESUME =
|
|
421
|
+
'render the document, switchToWorkBranch, performIntegration, completion comment, label removal';
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* The intent table, carried from the spec row by row. `integrate` has two rows
|
|
425
|
+
* (build on the `build-task` rung, answered merge on the `apply` rung); the
|
|
426
|
+
* spec's shared `intake-task` / `intake-spec` row is split in two here because
|
|
427
|
+
* only the spec variant carries the two gate booleans.
|
|
428
|
+
*/
|
|
429
|
+
export const HANDOFF_INTENT_TABLE: readonly IntentRow[] = [
|
|
430
|
+
{
|
|
431
|
+
row: 'integrate-build',
|
|
432
|
+
kind: 'integrate',
|
|
433
|
+
rungs: ['build-task'],
|
|
434
|
+
recomputed: [
|
|
435
|
+
'item',
|
|
436
|
+
'work branch name',
|
|
437
|
+
'arbiter',
|
|
438
|
+
'integration mode (workflow flag, then the untrusted-origin rule on the task at baseSha)',
|
|
439
|
+
'deleteMergedHead',
|
|
440
|
+
],
|
|
441
|
+
record: {
|
|
442
|
+
prTitle: line(L.prTitleChars),
|
|
443
|
+
prBody: text(L.commentChars),
|
|
444
|
+
reviewProse: text(L.commentChars, false),
|
|
445
|
+
},
|
|
446
|
+
bundle: {rule: 'required', carries: 'work branch tip (done-move included)'},
|
|
447
|
+
resumesAt:
|
|
448
|
+
'the land half of runRebaseToIntegrateTail (the applyCompleteTransition loop), then the review comment, then the lock release',
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
row: 'integrate-answered-merge',
|
|
452
|
+
kind: 'integrate',
|
|
453
|
+
rungs: ['apply'],
|
|
454
|
+
recomputed: [
|
|
455
|
+
'item',
|
|
456
|
+
'branch',
|
|
457
|
+
'mode merge',
|
|
458
|
+
'the answered kind: merge entry read from main',
|
|
459
|
+
],
|
|
460
|
+
record: {},
|
|
461
|
+
bundle: {rule: 'required', carries: 'rebased tip of work/task-<slug>'},
|
|
462
|
+
resumesAt:
|
|
463
|
+
'the same land loop, then applyAnsweredQuestions, the tree-less publish and the release',
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
row: 'merge-restale',
|
|
467
|
+
kind: 'merge-restale',
|
|
468
|
+
rungs: ['apply'],
|
|
469
|
+
recomputed: ['item', 'the answered entry'],
|
|
470
|
+
record: {},
|
|
471
|
+
bundle: NO_BUNDLE,
|
|
472
|
+
resumesAt:
|
|
473
|
+
'the strictMergeApproval follow-up question and re-pause, publish, release',
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
row: 'needs-attention',
|
|
477
|
+
kind: 'needs-attention',
|
|
478
|
+
rungs: ['build-task', 'apply'],
|
|
479
|
+
recomputed: ['item', 'branch', 'sidecar path'],
|
|
480
|
+
record: {
|
|
481
|
+
reason: text(L.reasonChars),
|
|
482
|
+
questions: textList(L.reasonChars, false),
|
|
483
|
+
},
|
|
484
|
+
bundle: {rule: 'optional', carries: 'WIP tip, if any'},
|
|
485
|
+
resumesAt:
|
|
486
|
+
'the write half of routeToNeedsAttention (branch push) and the surface (sidecar, needsAnswers: true), then the release',
|
|
487
|
+
},
|
|
488
|
+
{
|
|
489
|
+
row: 'deadline-checkpoint',
|
|
490
|
+
kind: 'deadline-checkpoint',
|
|
491
|
+
rungs: ['build-task'],
|
|
492
|
+
recomputed: ['item', 'branch', 'maxAutoCheckpoints from config at baseSha'],
|
|
493
|
+
record: {},
|
|
494
|
+
bundle: {
|
|
495
|
+
rule: 'required',
|
|
496
|
+
carries:
|
|
497
|
+
'WIP tip; the checkpoint count is recounted from the new commits',
|
|
498
|
+
},
|
|
499
|
+
resumesAt:
|
|
500
|
+
'the write half of routeDeadlineCheckpoint: branch push, then auto-continue release or surface',
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
row: 'stop',
|
|
504
|
+
kind: 'stop',
|
|
505
|
+
rungs: ['build-task'],
|
|
506
|
+
recomputed: ['item', 'branch'],
|
|
507
|
+
record: {reason: text(L.reasonChars), stopKind: oneOf(STOP_KINDS)},
|
|
508
|
+
bundle: {rule: 'optional', carries: 'WIP tip, if any'},
|
|
509
|
+
resumesAt: 'the write half of saveAgentStop',
|
|
510
|
+
},
|
|
511
|
+
{
|
|
512
|
+
row: 'agent-failed',
|
|
513
|
+
kind: 'agent-failed',
|
|
514
|
+
rungs: ['build-task'],
|
|
515
|
+
recomputed: ['item', 'branch'],
|
|
516
|
+
record: {failureDetail: text(L.reasonChars)},
|
|
517
|
+
bundle: {rule: 'optional', carries: 'WIP tip, if any'},
|
|
518
|
+
resumesAt: 'the write half of saveAgentFailure',
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
row: 'tasking-land',
|
|
522
|
+
kind: 'tasking-land',
|
|
523
|
+
rungs: ['task-spec'],
|
|
524
|
+
recomputed: [
|
|
525
|
+
'spec slug',
|
|
526
|
+
'the spec move to specs/tasked/',
|
|
527
|
+
'candidate folder (work/tasks/backlog/)',
|
|
528
|
+
'mode',
|
|
529
|
+
'the origin stamp and gate keys of the spec at baseSha',
|
|
530
|
+
],
|
|
531
|
+
record: {
|
|
532
|
+
candidates: docs(),
|
|
533
|
+
prBody: text(L.commentChars),
|
|
534
|
+
reviewVerdict: oneOf(REVIEW_VERDICTS, false),
|
|
535
|
+
reviewProse: text(L.commentChars, false),
|
|
536
|
+
specBody: text(L.documentChars),
|
|
537
|
+
},
|
|
538
|
+
bundle: NO_BUNDLE,
|
|
539
|
+
resumesAt:
|
|
540
|
+
'the tasking integrate with the review OFF (it ran in the agent phase), then the release',
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
row: 'tasking-surface',
|
|
544
|
+
kind: 'tasking-surface',
|
|
545
|
+
rungs: ['task-spec'],
|
|
546
|
+
recomputed: ['spec slug', 'branch work/spec-<slug>', 'mode'],
|
|
547
|
+
record: {
|
|
548
|
+
candidates: docs(),
|
|
549
|
+
reason: text(L.reasonChars),
|
|
550
|
+
questions: textList(L.reasonChars, false),
|
|
551
|
+
},
|
|
552
|
+
bundle: NO_BUNDLE,
|
|
553
|
+
resumesAt:
|
|
554
|
+
'persistTaskingCandidates (commit rebuilt in the apply job), closeRequestOnBranch in propose mode, surfaceTaskingBlock',
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
row: 'surface',
|
|
558
|
+
kind: 'surface',
|
|
559
|
+
rungs: ['surface'],
|
|
560
|
+
recomputed: [
|
|
561
|
+
'item',
|
|
562
|
+
'item path at baseSha',
|
|
563
|
+
'sidecar path',
|
|
564
|
+
'engine-built base questions',
|
|
565
|
+
],
|
|
566
|
+
record: {questions: questionList(L.reasonChars)},
|
|
567
|
+
bundle: NO_BUNDLE,
|
|
568
|
+
resumesAt: 'persistSurfacedQuestions, publish, release',
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
row: 'triage',
|
|
572
|
+
kind: 'triage',
|
|
573
|
+
rungs: ['triage-observation'],
|
|
574
|
+
recomputed: [
|
|
575
|
+
'item',
|
|
576
|
+
'the resolved observationTriage gate',
|
|
577
|
+
'the engine-built triage question',
|
|
578
|
+
'the note at baseSha',
|
|
579
|
+
],
|
|
580
|
+
record: {
|
|
581
|
+
disposition: oneOf(TRIAGE_DISPOSITIONS),
|
|
582
|
+
target: {type: 'item-ref', required: false},
|
|
583
|
+
reason: text(L.reasonChars, false),
|
|
584
|
+
questions: questionList(L.reasonChars, false),
|
|
585
|
+
},
|
|
586
|
+
check: (p) => {
|
|
587
|
+
const auto = p.disposition !== 'keep';
|
|
588
|
+
if (auto && p.target === undefined) {
|
|
589
|
+
reject('field', `products.target is required for ${p.disposition}`);
|
|
590
|
+
}
|
|
591
|
+
if (!auto && p.target !== undefined) {
|
|
592
|
+
reject('field', 'products.target is only for duplicate or map');
|
|
593
|
+
}
|
|
594
|
+
if (auto && p.questions !== undefined) {
|
|
595
|
+
reject('field', 'products.questions are only for keep (fall-through)');
|
|
596
|
+
}
|
|
597
|
+
},
|
|
598
|
+
bundle: NO_BUNDLE,
|
|
599
|
+
resumesAt:
|
|
600
|
+
'autoDisposition or persistSurfacedQuestions, publish, release (the marker back-fill needs no agent and runs in apply directly)',
|
|
601
|
+
},
|
|
602
|
+
{
|
|
603
|
+
row: 'apply-decision',
|
|
604
|
+
kind: 'apply-decision',
|
|
605
|
+
rungs: ['apply'],
|
|
606
|
+
recomputed: ['item', 'the answered sidecar read from main'],
|
|
607
|
+
record: {
|
|
608
|
+
outcome: oneOf(APPLY_DECISION_OUTCOMES),
|
|
609
|
+
title: line(L.documentTitleChars, false),
|
|
610
|
+
body: text(L.documentChars, false),
|
|
611
|
+
slug: {type: 'slug', required: false},
|
|
612
|
+
reason: text(L.reasonChars, false),
|
|
613
|
+
questions: textList(L.reasonChars, false),
|
|
614
|
+
},
|
|
615
|
+
check: (p) => {
|
|
616
|
+
const mints = ['task', 'spec', 'adr'].includes(p.outcome as string);
|
|
617
|
+
for (const f of ['title', 'body', 'slug']) {
|
|
618
|
+
if (mints && p[f] === undefined) {
|
|
619
|
+
reject('field', `products.${f} is required for ${p.outcome}`);
|
|
620
|
+
}
|
|
621
|
+
if (!mints && p[f] !== undefined) {
|
|
622
|
+
reject('field', `products.${f} is only for task, spec or adr`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
const ask = p.outcome === 'ask';
|
|
626
|
+
if (ask && p.questions === undefined) {
|
|
627
|
+
reject('field', 'products.questions is required for ask');
|
|
628
|
+
}
|
|
629
|
+
if (!ask && p.questions !== undefined) {
|
|
630
|
+
reject('field', 'products.questions is only for ask');
|
|
631
|
+
}
|
|
632
|
+
},
|
|
633
|
+
bundle: NO_BUNDLE,
|
|
634
|
+
resumesAt:
|
|
635
|
+
'the verdict router (promoteObservation via createItemThroughCas, mintAdr, delete, settle and keep, or append and re-pause), publish, release',
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
row: 'intake-ask',
|
|
639
|
+
kind: 'intake-ask',
|
|
640
|
+
rungs: ['intake'],
|
|
641
|
+
recomputed: ['issue number', 'marker', 'seen comment ids (lock output)'],
|
|
642
|
+
record: {question: text(L.reasonChars)},
|
|
643
|
+
bundle: NO_BUNDLE,
|
|
644
|
+
resumesAt: 'dispatchComment, label removal',
|
|
645
|
+
},
|
|
646
|
+
{
|
|
647
|
+
row: 'intake-bounce',
|
|
648
|
+
kind: 'intake-bounce',
|
|
649
|
+
rungs: ['intake'],
|
|
650
|
+
recomputed: ['issue number', 'marker', 'seen comment ids'],
|
|
651
|
+
record: {bounceText: text(L.commentChars)},
|
|
652
|
+
bundle: NO_BUNDLE,
|
|
653
|
+
resumesAt: 'closeIssue with the comment, label removal',
|
|
654
|
+
},
|
|
655
|
+
{
|
|
656
|
+
row: 'intake-task',
|
|
657
|
+
kind: 'intake-task',
|
|
658
|
+
rungs: ['intake'],
|
|
659
|
+
recomputed: INTAKE_DOCUMENT_RECOMPUTED,
|
|
660
|
+
record: {
|
|
661
|
+
title: line(L.documentTitleChars),
|
|
662
|
+
body: text(L.documentChars),
|
|
663
|
+
},
|
|
664
|
+
bundle: NO_BUNDLE,
|
|
665
|
+
resumesAt: INTAKE_DOCUMENT_RESUME,
|
|
666
|
+
},
|
|
667
|
+
{
|
|
668
|
+
row: 'intake-spec',
|
|
669
|
+
kind: 'intake-spec',
|
|
670
|
+
rungs: ['intake'],
|
|
671
|
+
recomputed: INTAKE_DOCUMENT_RECOMPUTED,
|
|
672
|
+
record: {
|
|
673
|
+
title: line(L.documentTitleChars),
|
|
674
|
+
body: text(L.documentChars),
|
|
675
|
+
humanOnly: {type: 'boolean', required: false},
|
|
676
|
+
needsAnswers: {type: 'boolean', required: false},
|
|
677
|
+
},
|
|
678
|
+
bundle: NO_BUNDLE,
|
|
679
|
+
resumesAt: INTAKE_DOCUMENT_RESUME,
|
|
680
|
+
},
|
|
681
|
+
];
|
|
682
|
+
|
|
683
|
+
/** The row for an intent handed over by a run on `rung`, or `undefined`. */
|
|
684
|
+
export function intentRowFor(
|
|
685
|
+
rung: HandoffRung,
|
|
686
|
+
kind: HandoffIntentKind,
|
|
687
|
+
): IntentRow | undefined {
|
|
688
|
+
return HANDOFF_INTENT_TABLE.find(
|
|
689
|
+
(r) => r.kind === kind && r.rungs.includes(rung),
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// ---------------------------------------------------------------------------
|
|
694
|
+
// Item ids and the artifact name
|
|
695
|
+
// ---------------------------------------------------------------------------
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* The canonical form of a legal item id: `issue:<N>`, or `task:` / `spec:` /
|
|
699
|
+
* `observation:` plus a safe slug. A bare slug is a task and `obs:` an
|
|
700
|
+
* observation, as everywhere else in dorfl (`resolveSidecarIdentity`). Anything
|
|
701
|
+
* else (an issue title, say) throws {@link HandoffRejected} (`item`).
|
|
702
|
+
*/
|
|
703
|
+
export function canonicalHandoffItem(item: string): string {
|
|
704
|
+
if (typeof item !== 'string') reject('item', 'the item is not a string');
|
|
705
|
+
const issue = /^issue:([1-9][0-9]{0,11})$/.exec(item);
|
|
706
|
+
if (issue) return `issue:${issue[1]}`;
|
|
707
|
+
if (item.startsWith('issue:')) reject('item', 'not a legal issue item id');
|
|
708
|
+
const parsed = parseSlugArg(item);
|
|
709
|
+
if (!isSafeSlug(parsed.slug)) reject('item', 'not a legal item id');
|
|
710
|
+
return `${parsed.explicit ?? 'task'}:${parsed.slug}`;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** The artifact-name shape {@link handoffName} produces (and only that). */
|
|
714
|
+
export const HANDOFF_NAME_RE =
|
|
715
|
+
/^dorfl-handoff-(?:task|spec|observation|issue)-[A-Za-z0-9._-]{1,120}-attempt-[1-9][0-9]{0,5}$/;
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* The artifact name of an item run: `dorfl-handoff-<type>-<slug>-attempt-<N>`,
|
|
719
|
+
* derived by the lock job from the item and `github.run_attempt`. The item id's
|
|
720
|
+
* `:` becomes `-` (artifact names refuse `:`), so the name holds only
|
|
721
|
+
* `[A-Za-z0-9._-]`, which every artifact-name rule accepts. The canonical item
|
|
722
|
+
* type is a fixed word without `-`, so two different items never share a name;
|
|
723
|
+
* the attempt keeps "Re-run all jobs" from colliding with attempt 1's artifact
|
|
724
|
+
* (artifacts belong to the run, not the attempt).
|
|
725
|
+
*/
|
|
726
|
+
export function handoffName(item: string, runAttempt: number | string): string {
|
|
727
|
+
const canonical = canonicalHandoffItem(item);
|
|
728
|
+
const attempt =
|
|
729
|
+
typeof runAttempt === 'number' ? String(runAttempt) : runAttempt;
|
|
730
|
+
if (!/^[1-9][0-9]{0,5}$/.test(attempt)) {
|
|
731
|
+
reject('name', 'the run attempt is not a positive integer');
|
|
732
|
+
}
|
|
733
|
+
const name = `dorfl-handoff-${canonical.replace(':', '-')}-attempt-${attempt}`;
|
|
734
|
+
if (!HANDOFF_NAME_RE.test(name)) reject('name', 'not a legal artifact name');
|
|
735
|
+
return name;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// ---------------------------------------------------------------------------
|
|
739
|
+
// Validation
|
|
740
|
+
// ---------------------------------------------------------------------------
|
|
741
|
+
|
|
742
|
+
/** Control characters other than tab, line feed and carriage return. */
|
|
743
|
+
// eslint-disable-next-line no-control-regex
|
|
744
|
+
const TEXT_CONTROL_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;
|
|
745
|
+
/** Any control character or line separator (a single-line field). */
|
|
746
|
+
// eslint-disable-next-line no-control-regex
|
|
747
|
+
const LINE_CONTROL_RE = /[\u0000-\u001f\u007f\u2028\u2029]/;
|
|
748
|
+
|
|
749
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
750
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
751
|
+
return false;
|
|
752
|
+
}
|
|
753
|
+
const proto = Object.getPrototypeOf(value);
|
|
754
|
+
return proto === Object.prototype || proto === null;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function checkText(
|
|
758
|
+
path: string,
|
|
759
|
+
value: unknown,
|
|
760
|
+
maxChars: number,
|
|
761
|
+
singleLine: boolean,
|
|
762
|
+
): void {
|
|
763
|
+
if (typeof value !== 'string') reject('field', `${path} is not a string`);
|
|
764
|
+
if (value.length > maxChars) {
|
|
765
|
+
reject(
|
|
766
|
+
'limit',
|
|
767
|
+
`${path} is ${value.length} characters, over the limit of ${maxChars}`,
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
if (singleLine && LINE_CONTROL_RE.test(value)) {
|
|
771
|
+
reject('field', `${path} must be one line without control characters`);
|
|
772
|
+
}
|
|
773
|
+
if (!singleLine && TEXT_CONTROL_RE.test(value)) {
|
|
774
|
+
reject('field', `${path} holds a control character`);
|
|
775
|
+
}
|
|
776
|
+
if (singleLine && value.trim() === '') reject('field', `${path} is blank`);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** The keys a {@link HandoffQuestion} may carry (a closed shape). */
|
|
780
|
+
const QUESTION_KEYS = ['question', 'context', 'default'] as const;
|
|
781
|
+
|
|
782
|
+
/** One {@link HandoffQuestionEntry}: a bare question text, or the closed object. */
|
|
783
|
+
function checkQuestion(path: string, value: unknown, maxChars: number): void {
|
|
784
|
+
if (typeof value === 'string') {
|
|
785
|
+
return checkText(path, value, maxChars, false);
|
|
786
|
+
}
|
|
787
|
+
if (!isPlainObject(value)) {
|
|
788
|
+
reject('field', `${path} is neither a question text nor a question object`);
|
|
789
|
+
}
|
|
790
|
+
checkKeys(`${path}.`, value, QUESTION_KEYS);
|
|
791
|
+
if (value.question === undefined) {
|
|
792
|
+
reject('field', `${path}.question is required`);
|
|
793
|
+
}
|
|
794
|
+
for (const key of QUESTION_KEYS) {
|
|
795
|
+
if (value[key] !== undefined) {
|
|
796
|
+
checkText(`${path}.${key}`, value[key], maxChars, false);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function checkField(path: string, spec: FieldSpec, value: unknown): void {
|
|
802
|
+
switch (spec.type) {
|
|
803
|
+
case 'line':
|
|
804
|
+
case 'text':
|
|
805
|
+
return checkText(path, value, spec.maxChars, spec.type === 'line');
|
|
806
|
+
case 'text-list':
|
|
807
|
+
if (!Array.isArray(value)) reject('field', `${path} is not a list`);
|
|
808
|
+
value.forEach((v, i) =>
|
|
809
|
+
checkText(`${path}[${i}]`, v, spec.maxChars, false),
|
|
810
|
+
);
|
|
811
|
+
return;
|
|
812
|
+
case 'question-list':
|
|
813
|
+
if (!Array.isArray(value)) reject('field', `${path} is not a list`);
|
|
814
|
+
value.forEach((v, i) => checkQuestion(`${path}[${i}]`, v, spec.maxChars));
|
|
815
|
+
return;
|
|
816
|
+
case 'documents':
|
|
817
|
+
if (!isPlainObject(value)) reject('field', `${path} is not an object`);
|
|
818
|
+
for (const [slug, doc] of Object.entries(value)) {
|
|
819
|
+
if (!isSafeSlug(slug))
|
|
820
|
+
reject('field', `a ${path} key is not a safe slug`);
|
|
821
|
+
checkText(`${path}.${slug}`, doc, spec.maxChars, false);
|
|
822
|
+
}
|
|
823
|
+
return;
|
|
824
|
+
case 'enum':
|
|
825
|
+
if (typeof value !== 'string' || !spec.values.includes(value)) {
|
|
826
|
+
reject('field', `${path} is not one of ${spec.values.join(', ')}`);
|
|
827
|
+
}
|
|
828
|
+
return;
|
|
829
|
+
case 'boolean':
|
|
830
|
+
if (typeof value !== 'boolean') {
|
|
831
|
+
reject('field', `${path} is not a boolean`);
|
|
832
|
+
}
|
|
833
|
+
return;
|
|
834
|
+
case 'slug':
|
|
835
|
+
if (typeof value !== 'string' || !isSafeSlug(value)) {
|
|
836
|
+
reject('field', `${path} is not a safe slug`);
|
|
837
|
+
}
|
|
838
|
+
return;
|
|
839
|
+
case 'item-ref': {
|
|
840
|
+
const m =
|
|
841
|
+
typeof value === 'string'
|
|
842
|
+
? /^(?:task|spec|observation):(.*)$/.exec(value)
|
|
843
|
+
: null;
|
|
844
|
+
if (m === null || !isSafeSlug(m[1])) {
|
|
845
|
+
reject('field', `${path} is not a <task|spec|observation>:<slug> item`);
|
|
846
|
+
}
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function checkKeys(
|
|
853
|
+
path: string,
|
|
854
|
+
obj: Record<string, unknown>,
|
|
855
|
+
allowed: readonly string[],
|
|
856
|
+
): void {
|
|
857
|
+
for (const key of Object.keys(obj)) {
|
|
858
|
+
if (!allowed.includes(key)) {
|
|
859
|
+
reject(
|
|
860
|
+
'field',
|
|
861
|
+
`unknown field ${path}${JSON.stringify(key).slice(0, 80)}`,
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** The trusted facts a handoff is judged against (lock-job outputs). */
|
|
868
|
+
export interface HandoffTrust {
|
|
869
|
+
/** The item this run carries. */
|
|
870
|
+
item: string;
|
|
871
|
+
/** The rung the lock job classified. */
|
|
872
|
+
rung: HandoffRung;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Validate a parsed `handoff.json` against the trusted item and rung, and return
|
|
877
|
+
* it typed together with its table row. Rejects an unknown schema, a record for
|
|
878
|
+
* another item, an unknown kind, a kind the rung cannot produce, any field
|
|
879
|
+
* outside the row's `record` column (at every level), a missing required field,
|
|
880
|
+
* a malformed value and any over-limit text.
|
|
881
|
+
*/
|
|
882
|
+
export function validateHandoffRecord(
|
|
883
|
+
value: unknown,
|
|
884
|
+
trust: HandoffTrust,
|
|
885
|
+
): {record: HandoffRecord; row: IntentRow} {
|
|
886
|
+
if (!isPlainObject(value)) reject('shape', 'handoff.json is not an object');
|
|
887
|
+
if (value.schema !== HANDOFF_SCHEMA) {
|
|
888
|
+
reject('schema', `unknown schema ${JSON.stringify(value.schema)}`);
|
|
889
|
+
}
|
|
890
|
+
checkKeys('', value, ['schema', 'item', 'intent', 'products']);
|
|
891
|
+
const trustedItem = canonicalHandoffItem(trust.item);
|
|
892
|
+
if (value.item !== trustedItem) {
|
|
893
|
+
reject('item', `the record does not name this run's item ${trustedItem}`);
|
|
894
|
+
}
|
|
895
|
+
if (!isPlainObject(value.intent)) reject('shape', 'intent is not an object');
|
|
896
|
+
checkKeys('intent.', value.intent, ['kind']);
|
|
897
|
+
const kind = value.intent.kind;
|
|
898
|
+
if (
|
|
899
|
+
typeof kind !== 'string' ||
|
|
900
|
+
!(HANDOFF_INTENT_KINDS as readonly string[]).includes(kind)
|
|
901
|
+
) {
|
|
902
|
+
reject('kind', `unknown intent kind ${JSON.stringify(kind).slice(0, 80)}`);
|
|
903
|
+
}
|
|
904
|
+
if (!(HANDOFF_RUNGS as readonly string[]).includes(trust.rung)) {
|
|
905
|
+
reject('kind-for-rung', `unknown trusted rung ${String(trust.rung)}`);
|
|
906
|
+
}
|
|
907
|
+
const row = intentRowFor(trust.rung, kind as HandoffIntentKind);
|
|
908
|
+
if (row === undefined) {
|
|
909
|
+
reject('kind-for-rung', `a ${trust.rung} run cannot hand over ${kind}`);
|
|
910
|
+
}
|
|
911
|
+
if (!isPlainObject(value.products)) {
|
|
912
|
+
reject('shape', 'products is not an object');
|
|
913
|
+
}
|
|
914
|
+
const products = value.products;
|
|
915
|
+
checkKeys('products.', products, Object.keys(row.record));
|
|
916
|
+
for (const [name, spec] of Object.entries(row.record)) {
|
|
917
|
+
const v = products[name];
|
|
918
|
+
if (v === undefined) {
|
|
919
|
+
if (spec.required) reject('field', `products.${name} is required`);
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
checkField(`products.${name}`, spec, v);
|
|
923
|
+
}
|
|
924
|
+
row.check?.(products);
|
|
925
|
+
return {record: value as unknown as HandoffRecord, row};
|
|
926
|
+
}
|