approval-md 0.1.0 → 0.3.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/README.md +629 -559
- package/SPEC.md +99 -24
- package/dist/src/adapters/agentmail.d.ts +426 -0
- package/dist/src/adapters/agentmail.js +2 -2
- package/dist/src/adapters/conformance.d.ts +149 -0
- package/dist/src/adapters/contract.d.ts +628 -0
- package/dist/src/adapters/contract.js +110 -16
- package/dist/src/adapters/contract.js.map +1 -1
- package/dist/src/adapters/email.d.ts +324 -0
- package/dist/src/adapters/env-passphrase.d.ts +93 -0
- package/dist/src/adapters/public.d.ts +11 -0
- package/dist/src/adapters/public.js +11 -0
- package/dist/src/adapters/public.js.map +1 -0
- package/dist/src/adapters/registry.d.ts +59 -0
- package/dist/src/adapters/registry.js +2 -1
- package/dist/src/adapters/registry.js.map +1 -1
- package/dist/src/adapters/smtp.d.ts +213 -0
- package/dist/src/adapters/vault-provider.d.ts +114 -0
- package/dist/src/adapters/vault-provider.js +3 -3
- package/dist/src/adapters/zzz.d.ts +66 -0
- package/dist/src/adapters/zzz.js +299 -0
- package/dist/src/adapters/zzz.js.map +1 -0
- package/dist/src/channels/batch.d.ts +109 -0
- package/dist/src/channels/cli.d.ts +193 -0
- package/dist/src/channels/conformance.d.ts +92 -0
- package/dist/src/channels/contract.d.ts +656 -0
- package/dist/src/channels/contract.js +200 -7
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/payload-view.d.ts +35 -0
- package/dist/src/channels/render-queue.d.ts +149 -0
- package/dist/src/channels/tagging.d.ts +196 -0
- package/dist/src/channels/telegram.d.ts +1944 -0
- package/dist/src/channels/telegram.js +218 -23
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/channels/web.d.ts +350 -0
- package/dist/src/channels/web.js +17 -0
- package/dist/src/channels/web.js.map +1 -1
- package/dist/src/cli/adapter.d.ts +90 -0
- package/dist/src/cli/adapter.js +25 -15
- package/dist/src/cli/adapter.js.map +1 -1
- package/dist/src/cli/amend.d.ts +59 -0
- package/dist/src/cli/amend.js +214 -30
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/args.d.ts +43 -0
- package/dist/src/cli/attest.d.ts +50 -0
- package/dist/src/cli/attest.js +134 -7
- package/dist/src/cli/attest.js.map +1 -1
- package/dist/src/cli/audit-card.d.ts +62 -0
- package/dist/src/cli/audit.d.ts +59 -0
- package/dist/src/cli/channel-telegram.d.ts +879 -0
- package/dist/src/cli/channel-telegram.js +311 -13
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/channel-web.d.ts +131 -0
- package/dist/src/cli/channel.d.ts +80 -0
- package/dist/src/cli/channel.js +9 -0
- package/dist/src/cli/channel.js.map +1 -1
- package/dist/src/cli/checkpoint-tap.d.ts +169 -0
- package/dist/src/cli/codex-bridge.d.ts +819 -0
- package/dist/src/cli/codex-bridge.js +1607 -0
- package/dist/src/cli/codex-bridge.js.map +1 -0
- package/dist/src/cli/codex.d.ts +2 -0
- package/dist/src/cli/codex.js +469 -0
- package/dist/src/cli/codex.js.map +1 -0
- package/dist/src/cli/coverage.d.ts +61 -0
- package/dist/src/cli/daemon.d.ts +120 -0
- package/dist/src/cli/daemon.js +4 -1
- package/dist/src/cli/daemon.js.map +1 -1
- package/dist/src/cli/doctor.d.ts +129 -0
- package/dist/src/cli/doctor.js +586 -17
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/env.d.ts +65 -0
- package/dist/src/cli/execute.d.ts +202 -0
- package/dist/src/cli/execute.js +25 -2
- package/dist/src/cli/execute.js.map +1 -1
- package/dist/src/cli/exit-codes.d.ts +73 -0
- package/dist/src/cli/feedback.d.ts +60 -0
- package/dist/src/cli/gate-window.d.ts +40 -0
- package/dist/src/cli/gate.d.ts +68 -0
- package/dist/src/cli/git-scope.d.ts +190 -0
- package/dist/src/cli/gloss-attach.d.ts +85 -0
- package/dist/src/cli/gloss-codex-child.d.ts +9 -0
- package/dist/src/cli/gloss-codex.d.ts +24 -0
- package/dist/src/cli/gloss-options.d.ts +42 -0
- package/dist/src/cli/gloss.d.ts +265 -0
- package/dist/src/cli/help.d.ts +107 -0
- package/dist/src/cli/help.js +320 -93
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook-codex.d.ts +126 -0
- package/dist/src/cli/hook-codex.js +226 -0
- package/dist/src/cli/hook-codex.js.map +1 -0
- package/dist/src/cli/hook.d.ts +787 -0
- package/dist/src/cli/hook.js +1235 -181
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/import.d.ts +35 -0
- package/dist/src/cli/import.js +1 -1
- package/dist/src/cli/import.js.map +1 -1
- package/dist/src/cli/init.d.ts +84 -0
- package/dist/src/cli/init.js +2 -2
- package/dist/src/cli/init.js.map +1 -1
- package/dist/src/cli/instructions.d.ts +23 -0
- package/dist/src/cli/journal.d.ts +41 -0
- package/dist/src/cli/log-advance.d.ts +287 -0
- package/dist/src/cli/log-advance.js +102 -11
- package/dist/src/cli/log-advance.js.map +1 -1
- package/dist/src/cli/log-anchor.d.ts +176 -0
- package/dist/src/cli/log-checkpoint.d.ts +22 -0
- package/dist/src/cli/log-sync.d.ts +243 -0
- package/dist/src/cli/log-verbs.d.ts +16 -0
- package/dist/src/cli/log-verbs.js +7 -1
- package/dist/src/cli/log-verbs.js.map +1 -1
- package/dist/src/cli/long-help.d.ts +70 -0
- package/dist/src/cli/main.d.ts +77 -0
- package/dist/src/cli/main.js +159 -7
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/mcp.d.ts +52 -0
- package/dist/src/cli/paths.d.ts +56 -0
- package/dist/src/cli/payload.d.ts +58 -0
- package/dist/src/cli/policy-apply.d.ts +195 -0
- package/dist/src/cli/policy-apply.js +573 -0
- package/dist/src/cli/policy-apply.js.map +1 -0
- package/dist/src/cli/policy.d.ts +43 -0
- package/dist/src/cli/policy.js +14 -1
- package/dist/src/cli/policy.js.map +1 -1
- package/dist/src/cli/preflight.d.ts +501 -0
- package/dist/src/cli/preflight.js +689 -45
- package/dist/src/cli/preflight.js.map +1 -1
- package/dist/src/cli/progress.d.ts +78 -0
- package/dist/src/cli/prompt.d.ts +209 -0
- package/dist/src/cli/quickstart.d.ts +46 -0
- package/dist/src/cli/quickstart.js +297 -0
- package/dist/src/cli/quickstart.js.map +1 -0
- package/dist/src/cli/records.d.ts +34 -0
- package/dist/src/cli/render.d.ts +22 -0
- package/dist/src/cli/sandbox.d.ts +51 -0
- package/dist/src/cli/sandbox.js +17 -1
- package/dist/src/cli/sandbox.js.map +1 -1
- package/dist/src/cli/scaffold.d.ts +79 -0
- package/dist/src/cli/scaffold.js +1 -1
- package/dist/src/cli/setup-adapter.d.ts +137 -0
- package/dist/src/cli/setup-adapter.js +38 -4
- package/dist/src/cli/setup-adapter.js.map +1 -1
- package/dist/src/cli/setup-channel.d.ts +126 -0
- package/dist/src/cli/setup-channel.js +28 -1
- package/dist/src/cli/setup-channel.js.map +1 -1
- package/dist/src/cli/setup-checkpoint.d.ts +57 -0
- package/dist/src/cli/setup-common.d.ts +277 -0
- package/dist/src/cli/setup-common.js +3 -2
- package/dist/src/cli/setup-common.js.map +1 -1
- package/dist/src/cli/setup-flow.d.ts +287 -0
- package/dist/src/cli/setup-service.d.ts +96 -0
- package/dist/src/cli/setup.d.ts +204 -0
- package/dist/src/cli/setup.js +94 -2
- package/dist/src/cli/setup.js.map +1 -1
- package/dist/src/cli/style.d.ts +320 -0
- package/dist/src/cli/token.d.ts +39 -0
- package/dist/src/cli/up.d.ts +155 -0
- package/dist/src/cli/up.js +119 -53
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/usage.d.ts +37 -0
- package/dist/src/cli/values.d.ts +40 -0
- package/dist/src/cli/values.js +3 -4
- package/dist/src/cli/values.js.map +1 -1
- package/dist/src/cli/vault.d.ts +59 -0
- package/dist/src/cli/vault.js +2 -2
- package/dist/src/cli/vault.js.map +1 -1
- package/dist/src/cli/verb-registry.d.ts +76 -0
- package/dist/src/cli/verb-registry.js +344 -11
- package/dist/src/cli/verb-registry.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +31 -0
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/codex/broker.d.ts +229 -0
- package/dist/src/codex/broker.js +548 -0
- package/dist/src/codex/broker.js.map +1 -0
- package/dist/src/codex/doctor.d.ts +13 -0
- package/dist/src/codex/doctor.js +41 -0
- package/dist/src/codex/doctor.js.map +1 -0
- package/dist/src/codex/manifest.d.ts +49 -0
- package/dist/src/codex/manifest.js +103 -0
- package/dist/src/codex/manifest.js.map +1 -0
- package/dist/src/codex/runner.d.ts +178 -0
- package/dist/src/codex/runner.js +231 -0
- package/dist/src/codex/runner.js.map +1 -0
- package/dist/src/codex/serve.d.ts +56 -0
- package/dist/src/codex/serve.js +98 -0
- package/dist/src/codex/serve.js.map +1 -0
- package/dist/src/codex/templates.d.ts +41 -0
- package/dist/src/codex/templates.js +319 -0
- package/dist/src/codex/templates.js.map +1 -0
- package/dist/src/codex/trust.d.ts +19 -0
- package/dist/src/codex/trust.js +183 -0
- package/dist/src/codex/trust.js.map +1 -0
- package/dist/src/codex/workspace-commit.d.ts +219 -0
- package/dist/src/codex/workspace-commit.js +549 -0
- package/dist/src/codex/workspace-commit.js.map +1 -0
- package/dist/src/codex/workspace-plan.d.ts +131 -0
- package/dist/src/codex/workspace-plan.js +561 -0
- package/dist/src/codex/workspace-plan.js.map +1 -0
- package/dist/src/core/actor.d.ts +2 -0
- package/dist/src/core/actor.js +5 -0
- package/dist/src/core/actor.js.map +1 -0
- package/dist/src/core/advance-cycle.d.ts +221 -0
- package/dist/src/core/advance-cycle.js +66 -2
- package/dist/src/core/advance-cycle.js.map +1 -1
- package/dist/src/core/agents-md.d.ts +278 -0
- package/dist/src/core/agents-md.js +33 -31
- package/dist/src/core/agents-md.js.map +1 -1
- package/dist/src/core/apply-patch.d.ts +49 -0
- package/dist/src/core/apply-patch.js +266 -0
- package/dist/src/core/apply-patch.js.map +1 -0
- package/dist/src/core/attest.d.ts +635 -0
- package/dist/src/core/attest.js +326 -4
- package/dist/src/core/attest.js.map +1 -1
- package/dist/src/core/audit.d.ts +510 -0
- package/dist/src/core/audit.js +13 -0
- package/dist/src/core/audit.js.map +1 -1
- package/dist/src/core/budgets.d.ts +238 -0
- package/dist/src/core/channel-owner.d.ts +213 -0
- package/dist/src/core/channel-owner.js +358 -0
- package/dist/src/core/channel-owner.js.map +1 -0
- package/dist/src/core/checkpoint.d.ts +500 -0
- package/dist/src/core/child-env.d.ts +88 -0
- package/dist/src/core/clock.d.ts +52 -0
- package/dist/src/core/command-class.d.ts +697 -0
- package/dist/src/core/command-class.js +713 -25
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/commit-guard.d.ts +272 -0
- package/dist/src/core/commit-guard.js +424 -0
- package/dist/src/core/commit-guard.js.map +1 -0
- package/dist/src/core/coverage-sources/adapter.d.ts +40 -0
- package/dist/src/core/coverage-sources/gh.d.ts +48 -0
- package/dist/src/core/coverage-sources/git.d.ts +101 -0
- package/dist/src/core/coverage.d.ts +217 -0
- package/dist/src/core/credential-spec.d.ts +72 -0
- package/dist/src/core/daemon-actor.d.ts +45 -0
- package/dist/src/core/daemon-actor.js +54 -0
- package/dist/src/core/daemon-actor.js.map +1 -0
- package/dist/src/core/dark-session.d.ts +432 -0
- package/dist/src/core/dark-session.js +266 -82
- package/dist/src/core/dark-session.js.map +1 -1
- package/dist/src/core/decision-refusal.d.ts +206 -0
- package/dist/src/core/decision-refusal.js +24 -2
- package/dist/src/core/decision-refusal.js.map +1 -1
- package/dist/src/core/env-file.d.ts +455 -0
- package/dist/src/core/env-file.js +60 -1
- package/dist/src/core/env-file.js.map +1 -1
- package/dist/src/core/execute.d.ts +871 -0
- package/dist/src/core/execute.js +59 -8
- package/dist/src/core/execute.js.map +1 -1
- package/dist/src/core/frontmatter.d.ts +78 -0
- package/dist/src/core/gate-window.d.ts +312 -0
- package/dist/src/core/gate.d.ts +1449 -0
- package/dist/src/core/gate.js +149 -14
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/gesture-refusal.d.ts +166 -0
- package/dist/src/core/gesture-refusal.js +188 -0
- package/dist/src/core/gesture-refusal.js.map +1 -0
- package/dist/src/core/git-run.d.ts +73 -0
- package/dist/src/core/harness-version.d.ts +157 -0
- package/dist/src/core/harness-version.js +4 -1
- package/dist/src/core/harness-version.js.map +1 -1
- package/dist/src/core/harness-wait.d.ts +55 -0
- package/dist/src/core/head-retry.d.ts +107 -0
- package/dist/src/core/instance.d.ts +310 -0
- package/dist/src/core/instance.js +113 -0
- package/dist/src/core/instance.js.map +1 -1
- package/dist/src/core/intake-limits.d.ts +247 -0
- package/dist/src/core/jcs.d.ts +52 -0
- package/dist/src/core/journal.d.ts +144 -0
- package/dist/src/core/live-draw.d.ts +436 -0
- package/dist/src/core/log-reconcile.d.ts +89 -0
- package/dist/src/core/log-subscribe.d.ts +36 -0
- package/dist/src/core/log-subscribe.js +162 -0
- package/dist/src/core/log-subscribe.js.map +1 -0
- package/dist/src/core/log.d.ts +316 -0
- package/dist/src/core/log.js.map +1 -1
- package/dist/src/core/loop.d.ts +274 -0
- package/dist/src/core/loop.js +11 -0
- package/dist/src/core/loop.js.map +1 -1
- package/dist/src/core/md-fence.d.ts +41 -0
- package/dist/src/core/money.d.ts +147 -0
- package/dist/src/core/payload-census.d.ts +74 -0
- package/dist/src/core/payload-store.d.ts +175 -0
- package/dist/src/core/payload.d.ts +71 -0
- package/dist/src/core/policy-diff.d.ts +292 -0
- package/dist/src/core/policy-diff.js +27 -4
- package/dist/src/core/policy-diff.js.map +1 -1
- package/dist/src/core/policy-expectations.d.ts +199 -0
- package/dist/src/core/policy-explain.d.ts +160 -0
- package/dist/src/core/policy-explain.js +63 -3
- package/dist/src/core/policy-explain.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +567 -0
- package/dist/src/core/policy-load.js +36 -6
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/policy-match.d.ts +324 -0
- package/dist/src/core/policy-match.js +72 -9
- package/dist/src/core/policy-match.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +317 -0
- package/dist/src/core/policy-proposal.js +102 -2
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/prompt-layout.d.ts +221 -0
- package/dist/src/core/protected-path-guard.d.ts +566 -0
- package/dist/src/core/protected-path-guard.js +848 -55
- package/dist/src/core/protected-path-guard.js.map +1 -1
- package/dist/src/core/question-preempted.d.ts +141 -0
- package/dist/src/core/question-preempted.js +152 -0
- package/dist/src/core/question-preempted.js.map +1 -0
- package/dist/src/core/read-scope.d.ts +172 -0
- package/dist/src/core/read-scope.js +252 -0
- package/dist/src/core/read-scope.js.map +1 -0
- package/dist/src/core/registration.d.ts +25 -0
- package/dist/src/core/reindex.d.ts +99 -0
- package/dist/src/core/sampler.d.ts +313 -0
- package/dist/src/core/sandbox.d.ts +371 -0
- package/dist/src/core/sandbox.js +190 -1
- package/dist/src/core/sandbox.js.map +1 -1
- package/dist/src/core/seal.d.ts +165 -0
- package/dist/src/core/sender-identity.d.ts +476 -0
- package/dist/src/core/sender-identity.js +572 -0
- package/dist/src/core/sender-identity.js.map +1 -0
- package/dist/src/core/shlex.d.ts +102 -0
- package/dist/src/core/shlex.js +159 -0
- package/dist/src/core/shlex.js.map +1 -0
- package/dist/src/core/state.d.ts +505 -0
- package/dist/src/core/task-file.d.ts +185 -0
- package/dist/src/core/telegram-config.d.ts +93 -0
- package/dist/src/core/token.d.ts +409 -0
- package/dist/src/core/token.js +21 -38
- package/dist/src/core/token.js.map +1 -1
- package/dist/src/core/validate.d.ts +138 -0
- package/dist/src/core/values.d.ts +147 -0
- package/dist/src/core/values.js +36 -1
- package/dist/src/core/values.js.map +1 -1
- package/dist/src/core/vault.d.ts +291 -0
- package/dist/src/core/verified-snapshot.d.ts +204 -0
- package/dist/src/core/verify.d.ts +336 -0
- package/dist/src/core/version.d.ts +8 -0
- package/dist/src/core/wysiwys.d.ts +370 -0
- package/dist/src/daemon/advance-child.d.ts +39 -0
- package/dist/src/daemon/advance.d.ts +476 -0
- package/dist/src/daemon/advance.js +25 -4
- package/dist/src/daemon/advance.js.map +1 -1
- package/dist/src/daemon/audit.d.ts +87 -0
- package/dist/src/daemon/daemon.d.ts +1180 -0
- package/dist/src/daemon/daemon.js +9 -0
- package/dist/src/daemon/daemon.js.map +1 -1
- package/dist/src/daemon/dark-session.d.ts +64 -0
- package/dist/src/daemon/draw-child.d.ts +36 -0
- package/dist/src/daemon/draw.d.ts +154 -0
- package/dist/src/daemon/git-evidence.d.ts +173 -0
- package/dist/src/daemon/git-evidence.js +1 -1
- package/dist/src/daemon/projection.d.ts +180 -0
- package/dist/src/daemon/prune.d.ts +207 -0
- package/dist/src/mcp/http.d.ts +113 -0
- package/dist/src/mcp/server.d.ts +265 -0
- package/dist/src/mcp/server.js +17 -1
- package/dist/src/mcp/server.js.map +1 -1
- package/docs/adapter-api.md +106 -0
- package/docs/cli-reference.md +1316 -63
- package/docs/codex-enforced-session.md +103 -0
- package/docs/codex-workspace-broker.md +118 -0
- package/package.json +14 -2
- package/schema/codex-instance.schema.json +82 -0
- package/schema/event.schema.json +539 -9
- package/schema/fixtures/codex-instance/invalid/unpinned-codex-version.json +40 -0
- package/schema/fixtures/codex-instance/valid/canonical.json +40 -0
- package/schema/fixtures/event/invalid/approval-granted-sender-hashed-false.json +20 -0
- package/schema/fixtures/event/invalid/approval-granted-sender-hashed-raw-id.json +20 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-human-actor.json +16 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-no-actor-no-sender.json +15 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-unknown-gesture.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-agent-actor.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-no-question-id.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-unknown-source.json +15 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-absolute-path.json +14 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-agent-actor.json +14 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-missing-path.json +13 -0
- package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
- package/schema/fixtures/event/valid/audit-gesture-refused-review-note.json +21 -0
- package/schema/fixtures/event/valid/audit-gesture-refused-sender-key-unavailable.json +19 -0
- package/schema/fixtures/event/valid/audit-gesture-refused.json +19 -0
- package/schema/fixtures/event/valid/audit-question-preempted-no-verdict.json +16 -0
- package/schema/fixtures/event/valid/audit-question-preempted.json +20 -0
- package/schema/fixtures/event/valid/gate-path-signed-off.json +14 -0
- package/schema/fixtures/event/valid/harness-kind-claude-code.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-codex.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-cursor.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-grok.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
- package/schema/fixtures/policy/invalid/senders-half-keyed.json +20 -0
- package/schema/fixtures/policy/valid/canonical.json +1 -1
- package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
- package/schema/fixtures/policy-md/valid/canonical.md +1 -1
- package/schema/fixtures/policy-md/valid/with-values.md +5 -7
- package/schema/fixtures/values/invalid/class-shaped.json +1 -1
- package/schema/fixtures/values/invalid/duplicate-entry.json +1 -1
- package/schema/fixtures/values/invalid/non-string-item.json +1 -1
- package/schema/fixtures/values/invalid/over-cap.json +1 -1
- package/schema/fixtures/values/invalid/unknown-key.json +1 -1
- package/schema/fixtures/values/invalid/version-float.json +1 -0
- package/schema/fixtures/values/invalid/version-integer.json +1 -0
- package/schema/fixtures/values/invalid/version-wrong-string.json +1 -0
- package/schema/fixtures/values/valid/empty-lists.json +2 -3
- package/schema/fixtures/values/valid/full.json +5 -7
- package/schema/fixtures/values/valid/minimal.json +1 -1
- package/schema/fixtures/values-md/invalid/schema-invalid.md +5 -3
- package/schema/fixtures/values-md/invalid/two-blocks.md +3 -3
- package/schema/fixtures/values-md/invalid/unterminated.md +2 -2
- package/schema/fixtures/values-md/invalid/version-1.md +69 -0
- package/schema/fixtures/values-md/invalid/version-unquoted.md +64 -0
- package/schema/fixtures/values-md/invalid/yaml-error.md +2 -2
- package/schema/fixtures/values-md/valid/absent.md +1 -1
- package/schema/fixtures/values-md/valid/with-values.md +5 -7
- package/schema/policy.schema.json +75 -3
- package/schema/values.schema.json +7 -11
- package/templates/codex/README.md +9 -0
- package/schema/fixtures/values/invalid/version-string.json +0 -1
|
@@ -15,33 +15,73 @@
|
|
|
15
15
|
*
|
|
16
16
|
* ## What it is allowed to do
|
|
17
17
|
*
|
|
18
|
-
* Read git, and at most
|
|
18
|
+
* Read git, and at most four writes: a `--ff-only` merge, `npm run build`,
|
|
19
|
+
* when the merge refused over an untracked file under `backlog/tasks/`
|
|
20
|
+
* that main already contains, clearing that file out of the way (APRV-300, and
|
|
21
|
+
* the rules it obeys are in {@link reconcileUntrackedTaskFiles}), and the one
|
|
22
|
+
* `approval log sync` performs on its behalf (APRV-346, below).
|
|
19
23
|
*
|
|
20
24
|
* It never resets, never stashes, never checks anything out, and never touches
|
|
21
|
-
* the working log. That list is not conservatism for its own sake — it is
|
|
22
|
-
* of 2026-08-20 (APRV-104's notes, and the reason `approval log sync`
|
|
23
|
-
* all): a working `events.jsonl` rewound through git underneath a live
|
|
24
|
-
* is two chains where there was one. `--ff-only` cannot rewind a file
|
|
25
|
-
* upstream did not change, and when upstream DID change it while the
|
|
26
|
-
* copy is dirty,
|
|
27
|
-
*
|
|
25
|
+
* the working log ITSELF. That list is not conservatism for its own sake — it is
|
|
26
|
+
* fork 2 of 2026-08-20 (APRV-104's notes, and the reason `approval log sync`
|
|
27
|
+
* exists at all): a working `events.jsonl` rewound through git underneath a live
|
|
28
|
+
* appender is two chains where there was one. `--ff-only` cannot rewind a file
|
|
29
|
+
* that upstream did not change, and when upstream DID change it while the
|
|
30
|
+
* working copy is dirty, the file is moved by `cli/log-sync.ts` and by nothing
|
|
31
|
+
* here.
|
|
32
|
+
*
|
|
33
|
+
* ## The routine collision, and the one that is not (APRV-346)
|
|
34
|
+
*
|
|
35
|
+
* Every records advance moves `origin/main`'s `.approval/log/events.jsonl`
|
|
36
|
+
* while the hook keeps appending locally, so "upstream changed the log and so
|
|
37
|
+
* did this working copy" is the NORMAL state of the primary checkout after a
|
|
38
|
+
* merge rather than an edge case. Refusing it sent the operator to `approval log
|
|
39
|
+
* sync` and then back to `approval up` every single time, which is a two-step
|
|
40
|
+
* ritual for a state the preflight can already tell apart from a fork.
|
|
41
|
+
*
|
|
42
|
+
* So the collision is now a question rather than a verdict: is the working log a
|
|
43
|
+
* byte-for-byte EXTENSION of the committed one (main's records 1..N unchanged,
|
|
44
|
+
* local N+1.. following) or are they two chains that share a prefix and then
|
|
45
|
+
* differ? {@link planLogSync} asks `core/log-reconcile.ts` — the same comparison
|
|
46
|
+
* `log sync` and doctor's `log-drift` ask — and answers a plan or `null`:
|
|
47
|
+
*
|
|
48
|
+
* - a plan, and {@link runPreflight} calls `logSync` itself. Not a
|
|
49
|
+
* reimplementation of it: the APRV-215 ceremony (one hold of the append lock,
|
|
50
|
+
* snapshot, baseline, fast-forward, reconcile, rebuild the projections,
|
|
51
|
+
* post-verify) stays the single implementation, and this module supplies a
|
|
52
|
+
* caller rather than a copy;
|
|
53
|
+
* - `null`, and the old `up-preflight-log-diverged` refusal stands unchanged.
|
|
54
|
+
* A fork, a log that does not verify, a log at some path other than the
|
|
55
|
+
* repository's own, or any OTHER upstream-touched path locally modified all
|
|
56
|
+
* answer `null`. Every one of those is a judgment, and this module makes none.
|
|
28
57
|
*
|
|
29
58
|
* ## Refusals, not repairs
|
|
30
59
|
*
|
|
31
|
-
*
|
|
60
|
+
* Four codes, each firing for exactly one condition. The first three are
|
|
61
|
+
* evaluated in this order, before anything is written:
|
|
32
62
|
*
|
|
33
63
|
* - `up-preflight-behind-ahead` — `origin/<branch>..HEAD` is non-empty. Local
|
|
34
64
|
* commits exist that the remote does not have. A fast-forward is not the
|
|
35
65
|
* operation for that state, and guessing which side to keep is a decision.
|
|
36
66
|
* - `up-preflight-log-diverged` — the upstream range changes the working log or
|
|
37
|
-
* the queue projection,
|
|
38
|
-
*
|
|
39
|
-
*
|
|
67
|
+
* the queue projection, the working copy has uncommitted changes to them, AND
|
|
68
|
+
* the two chains are not in a prefix relationship (or cannot be compared at
|
|
69
|
+
* all). This is the case the human could not judge by eye, and it is `approval
|
|
70
|
+
* log sync`'s whole subject. The routine case — a working log that merely
|
|
71
|
+
* extends the committed one — is reconciled rather than refused (APRV-346).
|
|
40
72
|
* - `up-preflight-dirty-protected` — some OTHER path the upstream range changes
|
|
41
73
|
* is locally modified, so `git merge --ff-only` would refuse to overwrite it.
|
|
42
74
|
* Named separately because the repair is different: look at the edit and
|
|
43
75
|
* decide, or start on the current build with `--no-preflight`.
|
|
44
76
|
*
|
|
77
|
+
* The fourth is answered after the merge has already refused, and only for the
|
|
78
|
+
* one path shape where two checkouts routinely author one file (APRV-300):
|
|
79
|
+
*
|
|
80
|
+
* - `up-preflight-task-file-conflict` — an untracked file under
|
|
81
|
+
* `backlog/tasks/` stopped the fast-forward, and it holds lines the incoming
|
|
82
|
+
* copy does not. See {@link reconcileUntrackedTaskFiles} for what happens
|
|
83
|
+
* when it holds none, and why that is a claim rather than an assumption.
|
|
84
|
+
*
|
|
45
85
|
* `git reset --hard` appears in none of them, and never will: it is the command
|
|
46
86
|
* that turns "your checkout is confusing" into "your work is gone".
|
|
47
87
|
*
|
|
@@ -67,12 +107,18 @@
|
|
|
67
107
|
* operator did not ask for, and says out loud that its answer is only as fresh
|
|
68
108
|
* as the last fetch.
|
|
69
109
|
*/
|
|
70
|
-
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
110
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, } from "node:fs";
|
|
71
111
|
import { spawn, spawnSync } from "node:child_process";
|
|
72
|
-
import { dirname, join, resolve as resolvePathSegments } from "node:path";
|
|
112
|
+
import { basename, dirname, join, resolve as resolvePathSegments } from "node:path";
|
|
73
113
|
import { fileURLToPath } from "node:url";
|
|
114
|
+
import { checkAttestation, policyBytesHash } from "../core/attest.js";
|
|
115
|
+
import { compareChains } from "../core/log-reconcile.js";
|
|
116
|
+
import { POLICY_FILENAMES } from "../core/policy-load.js";
|
|
117
|
+
import { verifyWithRecords } from "../core/verify.js";
|
|
74
118
|
import { EXIT_IO, EXIT_OK } from "./exit-codes.js";
|
|
75
|
-
import { currentBranch, failureText, fetchBase, git, repoPath, repoRoot, } from "./git-scope.js";
|
|
119
|
+
import { currentBranch, failureText, fetchBase, git, repoPath, repoRoot, showBlob, } from "./git-scope.js";
|
|
120
|
+
import { logSync } from "./log-sync.js";
|
|
121
|
+
import { DEFAULT_LOG_PATH } from "./paths.js";
|
|
76
122
|
import { runbook, style } from "./style.js";
|
|
77
123
|
// ---------------------------------------------------------------------------
|
|
78
124
|
// Build freshness (moved here from cli/doctor.ts, APRV-215)
|
|
@@ -269,6 +315,7 @@ export const PREFLIGHT_REFUSAL_CODES = [
|
|
|
269
315
|
"up-preflight-behind-ahead",
|
|
270
316
|
"up-preflight-log-diverged",
|
|
271
317
|
"up-preflight-dirty-protected",
|
|
318
|
+
"up-preflight-task-file-conflict",
|
|
272
319
|
];
|
|
273
320
|
const ZERO = {
|
|
274
321
|
behind_by: 0,
|
|
@@ -276,6 +323,7 @@ const ZERO = {
|
|
|
276
323
|
log_touched: false,
|
|
277
324
|
dist_stale: false,
|
|
278
325
|
reexec: false,
|
|
326
|
+
log_synced: false,
|
|
279
327
|
};
|
|
280
328
|
/** `git status --porcelain -uno` as a set of repo-relative paths. */
|
|
281
329
|
function dirtyPaths(root) {
|
|
@@ -323,7 +371,68 @@ function counts(root, base) {
|
|
|
323
371
|
return { behind, ahead };
|
|
324
372
|
}
|
|
325
373
|
function skipped(detail, root) {
|
|
326
|
-
return {
|
|
374
|
+
return {
|
|
375
|
+
ok: true,
|
|
376
|
+
facts: { ...ZERO, action: "skipped" },
|
|
377
|
+
detail,
|
|
378
|
+
warning: null,
|
|
379
|
+
target: null,
|
|
380
|
+
root,
|
|
381
|
+
sync: null,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Is this protected-path collision the routine one, and what would clearing it
|
|
386
|
+
* keep? `null` means "not a question this module answers", and the caller
|
|
387
|
+
* refuses (APRV-346).
|
|
388
|
+
*
|
|
389
|
+
* Four conditions, and every one of them is a reason to refuse rather than a
|
|
390
|
+
* degree of confidence:
|
|
391
|
+
*
|
|
392
|
+
* 1. **The log is the repository's own.** `approval log sync` reconciles the log
|
|
393
|
+
* at `.approval/log/events.jsonl` in the checkout it runs in, so a preflight
|
|
394
|
+
* pointed at some other file with `--log` must not hand it a reconcile of a
|
|
395
|
+
* file it was never asked about. That is a mismatch a test fixture can have
|
|
396
|
+
* and the primary checkout cannot, which is exactly when a guard is cheap.
|
|
397
|
+
* 2. **Nothing else is in the merge's way.** `logSync` ends in `git merge
|
|
398
|
+
* --ff-only`, which refuses over any dirty tracked path. A dirty unrelated
|
|
399
|
+
* file is the `up-preflight-dirty-protected` conversation and is not made
|
|
400
|
+
* better by starting a ceremony that will stop half-way.
|
|
401
|
+
* 3. **Both chains can be read.** `compareChains` verifies each side before it
|
|
402
|
+
* compares a single seq, and a side that does not verify is a refusal rather
|
|
403
|
+
* than an answer (SPEC §11.1: enforcement paths read only verified records).
|
|
404
|
+
* 4. **They are in a prefix relationship.** `ahead`, `behind` and `equal` all
|
|
405
|
+
* mean the longer chain contains the other whole, so adopting it extends and
|
|
406
|
+
* rewinds nothing. `diverged` means two appenders built different records on
|
|
407
|
+
* one predecessor, hash chains do not merge, and no verb here will pick.
|
|
408
|
+
*
|
|
409
|
+
* Read-only throughout, so doctor can ask the same question without doctor ever
|
|
410
|
+
* having done anything.
|
|
411
|
+
*/
|
|
412
|
+
function planLogSync(root, logPath, target, upstream, dirty, protectedPaths) {
|
|
413
|
+
const logRelative = repoPath(root, logPath);
|
|
414
|
+
if (logRelative !== DEFAULT_LOG_PATH)
|
|
415
|
+
return null;
|
|
416
|
+
const others = [...upstream].filter((path) => dirty.has(path) && !protectedPaths.has(path));
|
|
417
|
+
if (others.length > 0)
|
|
418
|
+
return null;
|
|
419
|
+
let working;
|
|
420
|
+
try {
|
|
421
|
+
const bytes = readIfPresent(logPath);
|
|
422
|
+
working = bytes === null ? "" : bytes.toString("utf8");
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
const committed = showBlob(root, target, logRelative);
|
|
428
|
+
if (committed === null)
|
|
429
|
+
return null;
|
|
430
|
+
const compared = compareChains({ label: `the working log ${logPath}`, text: working }, { label: `the committed log at ${target.slice(0, 12)}`, text: committed.toString("utf8") });
|
|
431
|
+
if (!compared.ok)
|
|
432
|
+
return null;
|
|
433
|
+
if (compared.drift.relation === "diverged")
|
|
434
|
+
return null;
|
|
435
|
+
return { relation: compared.drift.relation, kept: compared.drift.ahead };
|
|
327
436
|
}
|
|
328
437
|
/**
|
|
329
438
|
* The whole judgment, and not one byte of action.
|
|
@@ -360,6 +469,7 @@ export function inspectPreflight(input) {
|
|
|
360
469
|
warning: fetched.message,
|
|
361
470
|
target: null,
|
|
362
471
|
root,
|
|
472
|
+
sync: null,
|
|
363
473
|
};
|
|
364
474
|
}
|
|
365
475
|
base = fetched.sha;
|
|
@@ -391,6 +501,7 @@ export function inspectPreflight(input) {
|
|
|
391
501
|
dist_stale: stale,
|
|
392
502
|
action,
|
|
393
503
|
reexec: false,
|
|
504
|
+
log_synced: false,
|
|
394
505
|
});
|
|
395
506
|
// 1. Ahead. Nothing else is worth judging: whatever the upstream range holds,
|
|
396
507
|
// a fast-forward is not the operation for a checkout carrying commits the
|
|
@@ -436,6 +547,7 @@ export function inspectPreflight(input) {
|
|
|
436
547
|
root,
|
|
437
548
|
target: base,
|
|
438
549
|
warning,
|
|
550
|
+
sync: null,
|
|
439
551
|
facts: facts(stale ? "rebuild" : "none"),
|
|
440
552
|
detail: stale
|
|
441
553
|
? `up to date with ${remote}/${branch}, and the build is older than the sources`
|
|
@@ -449,38 +561,29 @@ export function inspectPreflight(input) {
|
|
|
449
561
|
// does this — snapshot, baseline, fast-forward, reconcile, rebuild the
|
|
450
562
|
// projections — and this module deliberately does not reimplement it.
|
|
451
563
|
const collidingProtected = [...protectedPaths].filter((path) => upstream.has(path) && dirty.has(path));
|
|
452
|
-
|
|
564
|
+
// ...unless the collision is the routine one, which is the state the primary
|
|
565
|
+
// checkout is in after every records advance (APRV-346). `planLogSync` asks
|
|
566
|
+
// the chains themselves; a plan is a reconcile `runPreflight` will delegate to
|
|
567
|
+
// `approval log sync`, and `null` is the refusal below, unchanged.
|
|
568
|
+
const plan = collidingProtected.length === 0
|
|
569
|
+
? null
|
|
570
|
+
: planLogSync(root, input.logPath, base, upstream, dirty, protectedPaths);
|
|
571
|
+
if (collidingProtected.length > 0 && plan === null) {
|
|
453
572
|
return {
|
|
454
573
|
ok: false,
|
|
455
574
|
root,
|
|
456
575
|
facts: facts("refused"),
|
|
457
|
-
refusal:
|
|
458
|
-
code: "up-preflight-log-diverged",
|
|
459
|
-
headline: `${remote}/${branch} changed ${collidingProtected.join(" and ")} and so did this working copy`,
|
|
460
|
-
state: [
|
|
461
|
-
`${plural(counted.behind, "commit")} behind ${remote}/${branch}`,
|
|
462
|
-
`changed on both sides: ${collidingProtected.join(", ")}`,
|
|
463
|
-
"the working log was not read, moved, or rewound",
|
|
464
|
-
],
|
|
465
|
-
steps: [
|
|
466
|
-
{
|
|
467
|
-
command: "approval log sync",
|
|
468
|
-
note: "snapshots the working log, fast-forwards, reconciles the chain",
|
|
469
|
-
},
|
|
470
|
-
{ command: "approval up", note: "again, once sync reports clean" },
|
|
471
|
-
],
|
|
472
|
-
footer: [
|
|
473
|
-
"a fast-forward over a log another process is appending to is how one chain becomes two",
|
|
474
|
-
"the ritual and what it refuses: docs/cli-reference.md#log-sync",
|
|
475
|
-
],
|
|
476
|
-
next: "approval log sync",
|
|
477
|
-
},
|
|
576
|
+
refusal: divergedRefusal(remote, branch, counted.behind, collidingProtected),
|
|
478
577
|
};
|
|
479
578
|
}
|
|
480
579
|
// 3. Any other local modification in the fast-forward's way. `--ff-only` would
|
|
481
580
|
// refuse rather than clobber it, so the refusal is reported here, where it
|
|
482
|
-
// can say which file and what the two ways out are.
|
|
483
|
-
|
|
581
|
+
// can say which file and what the two ways out are. The protected pair is
|
|
582
|
+
// exempt when there is a plan: `log sync` is the writer that moves those
|
|
583
|
+
// two, and it has already been asked whether it can.
|
|
584
|
+
const colliding = [...upstream]
|
|
585
|
+
.filter((path) => dirty.has(path) && !(plan !== null && protectedPaths.has(path)))
|
|
586
|
+
.sort();
|
|
484
587
|
if (colliding.length > 0) {
|
|
485
588
|
return {
|
|
486
589
|
ok: false,
|
|
@@ -514,8 +617,46 @@ export function inspectPreflight(input) {
|
|
|
514
617
|
root,
|
|
515
618
|
target: base,
|
|
516
619
|
warning,
|
|
620
|
+
sync: plan,
|
|
517
621
|
facts: facts(stale ? "fast-forward+rebuild" : "fast-forward"),
|
|
518
|
-
detail: `${plural(counted.behind, "commit")} behind ${remote}/${branch}, and the upstream range is safe to fast-forward${stale ? "; the build is older than the sources" : ""}`,
|
|
622
|
+
detail: `${plural(counted.behind, "commit")} behind ${remote}/${branch}, and the upstream range is safe to fast-forward${plan === null ? "" : `, once \`approval log sync\`'s reconcile has kept the ${plural(plan.kept, "local record")}`}${stale ? "; the build is older than the sources" : ""}`,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* The `up-preflight-log-diverged` refusal, in one place.
|
|
627
|
+
*
|
|
628
|
+
* Two callers now: the judgment that decides the chains cannot be reconciled
|
|
629
|
+
* without a human, and the one case where `logSync` — asked to reconcile a pair
|
|
630
|
+
* this module had already found reconcilable — refuses anyway. The second is a
|
|
631
|
+
* race (an appender that beat the lock, a fork that landed between the read and
|
|
632
|
+
* the ceremony) or a machine problem, and it says so in `extra` rather than in a
|
|
633
|
+
* code of its own: the repair is the same repair, and a code whose repair is
|
|
634
|
+
* another code's is not a distinct refusal, it is a synonym.
|
|
635
|
+
*/
|
|
636
|
+
function divergedRefusal(remote, branch, behind, collidingProtected, extra = []) {
|
|
637
|
+
return {
|
|
638
|
+
code: "up-preflight-log-diverged",
|
|
639
|
+
headline: `${remote}/${branch} changed ${collidingProtected.join(" and ")} and so did this working copy`,
|
|
640
|
+
state: [
|
|
641
|
+
`${plural(behind, "commit")} behind ${remote}/${branch}`,
|
|
642
|
+
`changed on both sides: ${collidingProtected.join(", ")}`,
|
|
643
|
+
...extra,
|
|
644
|
+
extra.length === 0
|
|
645
|
+
? "the working log was not read, moved, or rewound"
|
|
646
|
+
: "the reconcile restored the working log exactly as it found it, and nothing was started",
|
|
647
|
+
],
|
|
648
|
+
steps: [
|
|
649
|
+
{
|
|
650
|
+
command: "approval log sync",
|
|
651
|
+
note: "snapshots the working log, fast-forwards, reconciles the chain",
|
|
652
|
+
},
|
|
653
|
+
{ command: "approval up", note: "again, once sync reports clean" },
|
|
654
|
+
],
|
|
655
|
+
footer: [
|
|
656
|
+
"a fast-forward over a log another process is appending to is how one chain becomes two",
|
|
657
|
+
"the ritual and what it refuses: docs/cli-reference.md#log-sync",
|
|
658
|
+
],
|
|
659
|
+
next: "approval log sync",
|
|
519
660
|
};
|
|
520
661
|
}
|
|
521
662
|
function plural(count, noun) {
|
|
@@ -538,8 +679,65 @@ export function runPreflight(input, spawnBuild = npmBuild) {
|
|
|
538
679
|
if (facts.action === "skipped" || facts.action === "fetch-failed") {
|
|
539
680
|
return { ok: true, facts, detail: report.detail, warning: report.warning };
|
|
540
681
|
}
|
|
682
|
+
// What the task-file reconciliation below cleared, or `null` when it had
|
|
683
|
+
// nothing to do. It rides out on the warning line so the aside directory is
|
|
684
|
+
// printed where the operator is already looking.
|
|
685
|
+
let cleared = null;
|
|
686
|
+
// The routine protected-path collision, reconciled rather than refused
|
|
687
|
+
// (APRV-346). This runs BEFORE the fast-forward below and does that
|
|
688
|
+
// fast-forward itself: `logSync` holds the append lock for its whole ceremony
|
|
689
|
+
// — snapshot, baseline, `git merge --ff-only`, reconcile, rebuild the
|
|
690
|
+
// projections, post-verify — and this module supplies a caller for it rather
|
|
691
|
+
// than a second copy of any of that. The merge below then finds the checkout
|
|
692
|
+
// already at the target and says so.
|
|
693
|
+
let synced = null;
|
|
694
|
+
if (report.sync !== null && report.root !== null) {
|
|
695
|
+
const remote = input.remote ?? "origin";
|
|
696
|
+
const branch = input.branch ?? currentBranch(report.root) ?? "main";
|
|
697
|
+
const result = logSync({ cwd: report.root, remote, branch });
|
|
698
|
+
if (!result.ok) {
|
|
699
|
+
// Asked for a reconcile this module had already found reconcilable, and
|
|
700
|
+
// refused: an appender that took the lock first, a fork that landed in
|
|
701
|
+
// between, or git itself. Either way nothing starts, and the sync's own
|
|
702
|
+
// code and sentence go into the refusal so the operator is not sent to
|
|
703
|
+
// read a second one.
|
|
704
|
+
return {
|
|
705
|
+
ok: false,
|
|
706
|
+
facts: { ...facts, action: "refused" },
|
|
707
|
+
refusal: divergedRefusal(remote, branch, facts.behind_by, [repoPath(report.root, input.logPath)], [`approval log sync refused at its ${result.step} step (${result.code}): ${result.message}`]),
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
synced = {
|
|
711
|
+
commit: result.report.commitAfter,
|
|
712
|
+
kept: report.sync.kept,
|
|
713
|
+
relation: report.sync.relation,
|
|
714
|
+
remote,
|
|
715
|
+
branch,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
541
718
|
if (facts.behind_by > 0 && report.root !== null && report.target !== null) {
|
|
542
|
-
const
|
|
719
|
+
const root = report.root;
|
|
720
|
+
const target = report.target;
|
|
721
|
+
let merged = git(["merge", "--ff-only", target], root);
|
|
722
|
+
if (!merged.ok) {
|
|
723
|
+
const reconciled = reconcileUntrackedTaskFiles(root, target, failureText(merged));
|
|
724
|
+
if (reconciled.kind === "refused") {
|
|
725
|
+
return { ok: false, facts: { ...facts, action: "refused" }, refusal: reconciled.refusal };
|
|
726
|
+
}
|
|
727
|
+
if (reconciled.kind === "failed") {
|
|
728
|
+
return {
|
|
729
|
+
ok: false,
|
|
730
|
+
facts: { ...facts, action: "refused" },
|
|
731
|
+
failed: { step: reconciled.step, message: reconciled.message },
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
if (reconciled.kind === "cleared") {
|
|
735
|
+
cleared = reconciled.note;
|
|
736
|
+
// Once. A second failure is a different failure — the merge was asked
|
|
737
|
+
// again only because the thing that stopped it is provably gone.
|
|
738
|
+
merged = git(["merge", "--ff-only", target], root);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
543
741
|
if (!merged.ok) {
|
|
544
742
|
return {
|
|
545
743
|
ok: false,
|
|
@@ -605,12 +803,262 @@ export function runPreflight(input, spawnBuild = npmBuild) {
|
|
|
605
803
|
const plan = stale && build ? reexecPlan(input.root) : null;
|
|
606
804
|
return {
|
|
607
805
|
ok: true,
|
|
608
|
-
facts: { ...settled, reexec: plan !== null },
|
|
806
|
+
facts: { ...settled, reexec: plan !== null, log_synced: synced !== null },
|
|
609
807
|
detail: report.detail,
|
|
610
|
-
warning,
|
|
808
|
+
warning: [warning, cleared].filter((part) => part !== null).join("; ") || null,
|
|
611
809
|
...(plan === null ? {} : { reexec: plan }),
|
|
810
|
+
...(synced === null ? {} : { synced }),
|
|
612
811
|
};
|
|
613
812
|
}
|
|
813
|
+
// ---------------------------------------------------------------------------
|
|
814
|
+
// Untracked task files in the fast-forward's way (APRV-300)
|
|
815
|
+
// ---------------------------------------------------------------------------
|
|
816
|
+
/**
|
|
817
|
+
* The one directory this reconciliation is allowed to touch.
|
|
818
|
+
*
|
|
819
|
+
* Backlog.md task files are the only path in this repository where the same
|
|
820
|
+
* file is routinely authored in two checkouts at once: a lane files a task on
|
|
821
|
+
* its branch and the primary files it again with `backlog task create`, so the
|
|
822
|
+
* primary holds untracked bytes at a path the incoming commit carries. Nothing
|
|
823
|
+
* else has that shape, and widening the prefix would turn a narrow, provable
|
|
824
|
+
* case into a general licence to move an operator's files.
|
|
825
|
+
*/
|
|
826
|
+
const TASK_FILE_PREFIX = "backlog/tasks/";
|
|
827
|
+
/** Git's own sentence when a fast-forward would clobber an untracked file. */
|
|
828
|
+
const UNTRACKED_HEADLINE = "untracked working tree files would be overwritten";
|
|
829
|
+
/**
|
|
830
|
+
* The paths out of that message, or `null` when this was some other failure.
|
|
831
|
+
*
|
|
832
|
+
* {@link failureText} has already joined git's lines with ` | `, so the shape
|
|
833
|
+
* parsed here is `error: The following untracked working tree files would be
|
|
834
|
+
* overwritten by merge: | <path> | <path> | Please move or remove them…`. A
|
|
835
|
+
* path git chose to quote (`core.quotePath`, a name with a control byte or a
|
|
836
|
+
* non-ASCII byte) is answered as `null` rather than unquoted by hand: guessing
|
|
837
|
+
* the spelling of a file about to be moved is the one mistake this whole
|
|
838
|
+
* function exists to avoid, and the existing refusal is a fine answer.
|
|
839
|
+
*/
|
|
840
|
+
function untrackedCollisions(message) {
|
|
841
|
+
const parts = message.split(" | ").map((part) => part.trim());
|
|
842
|
+
const start = parts.findIndex((part) => part.includes(UNTRACKED_HEADLINE));
|
|
843
|
+
if (start < 0)
|
|
844
|
+
return null;
|
|
845
|
+
const paths = [];
|
|
846
|
+
for (const part of parts.slice(start + 1)) {
|
|
847
|
+
if (part.length === 0)
|
|
848
|
+
continue;
|
|
849
|
+
if (part.startsWith("Please ") || part === "Aborting")
|
|
850
|
+
break;
|
|
851
|
+
if (part.startsWith('"'))
|
|
852
|
+
return null;
|
|
853
|
+
paths.push(part);
|
|
854
|
+
}
|
|
855
|
+
return paths.length === 0 ? null : paths;
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Clear untracked task files out of a fast-forward's way, or refuse saying why.
|
|
859
|
+
*
|
|
860
|
+
* The incident (2026-09-07): a lane filed `backlog/tasks/aprv-299` on its
|
|
861
|
+
* branch and its pull request merged, while the primary checkout held the same
|
|
862
|
+
* path untracked from its own `backlog task create`. `git merge --ff-only`
|
|
863
|
+
* refuses to write over an untracked file, so `approval up` refused, and its
|
|
864
|
+
* next-steps text pointed at `git status`, which cannot say whether the local
|
|
865
|
+
* copy holds anything the incoming one does not. That is the question, and it
|
|
866
|
+
* is answerable, so it is answered here.
|
|
867
|
+
*
|
|
868
|
+
* Three verdicts, in this order, each with a different claim behind it:
|
|
869
|
+
*
|
|
870
|
+
* - **identical bytes** — the local file says nothing the incoming file does
|
|
871
|
+
* not say. Removing it loses nothing, so it is removed;
|
|
872
|
+
* - **every line also in the incoming copy** — the primary's copy is a subset
|
|
873
|
+
* of what main now carries (the ordinary shape: a stub filed by hand, then
|
|
874
|
+
* the lane's copy with a plan and criteria added). Nothing is lost by
|
|
875
|
+
* letting the incoming copy land, but "nothing is lost" is a judgment about
|
|
876
|
+
* an operator's file, so the bytes are moved aside rather than deleted and
|
|
877
|
+
* the destination is printed;
|
|
878
|
+
* - **anything else** — the local copy has lines main lacks. That is a
|
|
879
|
+
* question about which version is wanted, and no verb here will pick.
|
|
880
|
+
*
|
|
881
|
+
* Two passes, and the order is the safety property, exactly as APRV-225's
|
|
882
|
+
* payload reconciliation in `cli/log-sync.ts`: every file is judged before any
|
|
883
|
+
* file is touched, so a refusal over the last one cannot have already removed
|
|
884
|
+
* the first.
|
|
885
|
+
*/
|
|
886
|
+
function reconcileUntrackedTaskFiles(root, target, message) {
|
|
887
|
+
const collisions = untrackedCollisions(message);
|
|
888
|
+
if (collisions === null)
|
|
889
|
+
return { kind: "declined" };
|
|
890
|
+
// One path outside `backlog/tasks/` and the whole set is declined. A
|
|
891
|
+
// reconciliation that cleared what it understood and then refused anyway
|
|
892
|
+
// would have moved an operator's files for a merge that was never going to
|
|
893
|
+
// run (AC3).
|
|
894
|
+
if (!collisions.every((path) => path.startsWith(TASK_FILE_PREFIX)))
|
|
895
|
+
return { kind: "declined" };
|
|
896
|
+
const verdicts = [];
|
|
897
|
+
for (const relative of collisions) {
|
|
898
|
+
let local;
|
|
899
|
+
try {
|
|
900
|
+
local = readIfPresent(join(root, relative));
|
|
901
|
+
}
|
|
902
|
+
catch (cause) {
|
|
903
|
+
return {
|
|
904
|
+
kind: "failed",
|
|
905
|
+
step: "reading an untracked task file",
|
|
906
|
+
message: `${relative} stopped the fast-forward and could not be read to judge it: ${detailOf(cause)}. Nothing was moved.`,
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
// Gone between the merge's complaint and this read: nothing to clear, and
|
|
910
|
+
// nothing to weigh either. The retry will find out.
|
|
911
|
+
if (local === null)
|
|
912
|
+
continue;
|
|
913
|
+
const incoming = showBlob(root, target, relative);
|
|
914
|
+
if (incoming === null) {
|
|
915
|
+
return {
|
|
916
|
+
kind: "failed",
|
|
917
|
+
step: `git show ${target}:${relative}`,
|
|
918
|
+
message: `the fast-forward stopped on the untracked ${relative} and git could not read the incoming copy to compare it against. Nothing was moved.`,
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
if (incoming.equals(local)) {
|
|
922
|
+
verdicts.push({ kind: "remove", relative });
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
const only = linesOnlyIn(local, incoming);
|
|
926
|
+
verdicts.push(only === 0 ? { kind: "aside", relative } : { kind: "diverged", relative, only });
|
|
927
|
+
}
|
|
928
|
+
const diverged = verdicts.find((verdict) => verdict.kind === "diverged");
|
|
929
|
+
if (diverged !== undefined) {
|
|
930
|
+
const local = join(root, diverged.relative);
|
|
931
|
+
return {
|
|
932
|
+
kind: "refused",
|
|
933
|
+
refusal: {
|
|
934
|
+
code: "up-preflight-task-file-conflict",
|
|
935
|
+
headline: `the untracked ${diverged.relative} has ${plural(diverged.only, "line")} the incoming copy does not`,
|
|
936
|
+
state: [
|
|
937
|
+
`yours: ${local}`,
|
|
938
|
+
`incoming: ${target.slice(0, 12)}:${diverged.relative}`,
|
|
939
|
+
`${plural(diverged.only, "line")} only yours has`,
|
|
940
|
+
"nothing was merged, nothing was moved, and nothing was rebuilt",
|
|
941
|
+
],
|
|
942
|
+
steps: [
|
|
943
|
+
{
|
|
944
|
+
command: `git show ${target.slice(0, 12)}:${diverged.relative} | diff - ${JSON.stringify(local)}`,
|
|
945
|
+
note: "the two copies, side by side",
|
|
946
|
+
},
|
|
947
|
+
{
|
|
948
|
+
command: `mv ${JSON.stringify(local)} ${JSON.stringify(`${local}.mine`)}`,
|
|
949
|
+
note: "keep yours out of the way, then run approval up again",
|
|
950
|
+
},
|
|
951
|
+
],
|
|
952
|
+
footer: [
|
|
953
|
+
"an identical copy is removed and a copy main already contains is moved aside; this one is neither",
|
|
954
|
+
"what the preflight will and will not do: docs/cli-reference.md#up",
|
|
955
|
+
],
|
|
956
|
+
next: `git show ${target.slice(0, 12)}:${diverged.relative}`,
|
|
957
|
+
},
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
const removals = verdicts.filter((verdict) => verdict.kind === "remove");
|
|
961
|
+
const asides = verdicts.filter((verdict) => verdict.kind === "aside");
|
|
962
|
+
if (removals.length === 0 && asides.length === 0)
|
|
963
|
+
return { kind: "declined" };
|
|
964
|
+
// The moves go first and the removals second, so a failure part-way has
|
|
965
|
+
// preserved every byte it had a reason to preserve. Either way the message
|
|
966
|
+
// names what already moved: a half-finished clearing an operator cannot see
|
|
967
|
+
// is worse than the collision it was clearing.
|
|
968
|
+
const asideRoot = asidePath(root);
|
|
969
|
+
const moved = [];
|
|
970
|
+
const sofar = () => moved.length === 0 ? "" : ` Already moved aside: ${moved.join(", ")}.`;
|
|
971
|
+
for (const verdict of asides) {
|
|
972
|
+
const from = join(root, verdict.relative);
|
|
973
|
+
const to = join(asideRoot, verdict.relative);
|
|
974
|
+
try {
|
|
975
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
976
|
+
moveFile(from, to);
|
|
977
|
+
}
|
|
978
|
+
catch (cause) {
|
|
979
|
+
return {
|
|
980
|
+
kind: "failed",
|
|
981
|
+
step: "moving an untracked task file aside",
|
|
982
|
+
message: `${verdict.relative} could not be moved to ${to}: ${detailOf(cause)}. Nothing was merged.${sofar()}`,
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
moved.push(to);
|
|
986
|
+
}
|
|
987
|
+
for (const verdict of removals) {
|
|
988
|
+
try {
|
|
989
|
+
rmSync(join(root, verdict.relative), { force: true });
|
|
990
|
+
}
|
|
991
|
+
catch (cause) {
|
|
992
|
+
return {
|
|
993
|
+
kind: "failed",
|
|
994
|
+
step: "removing an untracked task file",
|
|
995
|
+
message: `${verdict.relative} is byte-identical to the incoming copy and could not be removed: ${detailOf(cause)}. Nothing was merged.${sofar()}`,
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
const said = [];
|
|
1000
|
+
if (removals.length > 0) {
|
|
1001
|
+
said.push(`${plural(removals.length, "untracked task file")} byte-identical to the incoming copy ${removals.length === 1 ? "was" : "were"} removed`);
|
|
1002
|
+
}
|
|
1003
|
+
if (moved.length > 0) {
|
|
1004
|
+
said.push(`${plural(moved.length, "untracked task file")} whose every line the incoming copy already carries ${moved.length === 1 ? "was" : "were"} moved to ${moved.join(", ")}`);
|
|
1005
|
+
}
|
|
1006
|
+
return { kind: "cleared", note: `${said.join("; ")}, and the fast-forward was retried` };
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* How many lines of `local` do not appear anywhere in `incoming`.
|
|
1010
|
+
*
|
|
1011
|
+
* A set of the incoming lines rather than a diff, and deliberately: the
|
|
1012
|
+
* question is not whether the two files line up, it is whether the local copy
|
|
1013
|
+
* holds any *content* main has not got. A task file reordered by the Backlog.md
|
|
1014
|
+
* CLI, or one whose sections were rewritten in place, is the same information
|
|
1015
|
+
* in a different arrangement, and zero here is exactly the claim that moving
|
|
1016
|
+
* the local copy aside loses nothing.
|
|
1017
|
+
*/
|
|
1018
|
+
function linesOnlyIn(local, incoming) {
|
|
1019
|
+
const carried = new Set(incoming.toString("utf8").split("\n"));
|
|
1020
|
+
return local
|
|
1021
|
+
.toString("utf8")
|
|
1022
|
+
.split("\n")
|
|
1023
|
+
.filter((line) => !carried.has(line)).length;
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Where a moved-aside file goes: a sibling of the checkout, dated.
|
|
1027
|
+
*
|
|
1028
|
+
* Outside the repository on purpose. Inside it, the file would still be
|
|
1029
|
+
* untracked, `git status` would still show it, and the next fast-forward could
|
|
1030
|
+
* collide with it all over again — which is to say the move would have solved
|
|
1031
|
+
* nothing. A sibling directory is somewhere `ls ..` finds, the date makes two
|
|
1032
|
+
* runs on two days two directories, and nothing here ever removes one: it is
|
|
1033
|
+
* the operator's copy, kept until they say otherwise.
|
|
1034
|
+
*/
|
|
1035
|
+
function asidePath(root) {
|
|
1036
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
1037
|
+
return join(dirname(root), `${basename(root)}-preflight-aside-${day}`);
|
|
1038
|
+
}
|
|
1039
|
+
/** `rename`, falling back to copy-and-unlink when the two sit on two devices. */
|
|
1040
|
+
function moveFile(from, to) {
|
|
1041
|
+
try {
|
|
1042
|
+
renameSync(from, to);
|
|
1043
|
+
}
|
|
1044
|
+
catch (cause) {
|
|
1045
|
+
if (cause.code !== "EXDEV")
|
|
1046
|
+
throw cause;
|
|
1047
|
+
copyFileSync(from, to);
|
|
1048
|
+
rmSync(from, { force: true });
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
/** The bytes at `path`, or `null` when there is no file there. */
|
|
1052
|
+
function readIfPresent(path) {
|
|
1053
|
+
try {
|
|
1054
|
+
return readFileSync(path);
|
|
1055
|
+
}
|
|
1056
|
+
catch (cause) {
|
|
1057
|
+
if (cause.code === "ENOENT")
|
|
1058
|
+
return null;
|
|
1059
|
+
throw new ScanError(`${path} could not be read: ${detailOf(cause)}`);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
614
1062
|
/**
|
|
615
1063
|
* The plan to re-exec, or `null` when there is nothing to re-exec into.
|
|
616
1064
|
*
|
|
@@ -683,6 +1131,18 @@ export function describePreflightEvent(event) {
|
|
|
683
1131
|
if (event.event === "preflight_warning") {
|
|
684
1132
|
return { text: `approval: preflight — ${event.message}`, stderr: true };
|
|
685
1133
|
}
|
|
1134
|
+
if (event.event === "preflight_policy") {
|
|
1135
|
+
return {
|
|
1136
|
+
text: `up: preflight — ${event.detail}${event.fix === null ? "" : `; ${event.fix}`}`,
|
|
1137
|
+
stderr: true,
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
if (event.event === "preflight_sync") {
|
|
1141
|
+
return {
|
|
1142
|
+
text: `up: preflight — synced: fast-forwarded to ${event.remote}/${event.branch} ${event.commit.slice(0, 12)}, kept ${String(event.kept)} local records`,
|
|
1143
|
+
stderr: false,
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
686
1146
|
const commits = `${String(event.behind_by)} commit${event.behind_by === 1 ? "" : "s"}`;
|
|
687
1147
|
const did = {
|
|
688
1148
|
none: "already at the remote tip, on a build no older than the sources",
|
|
@@ -702,6 +1162,27 @@ export function describePreflightEvent(event) {
|
|
|
702
1162
|
const handover = event.reexec ? ", in a fresh process on the new build" : "";
|
|
703
1163
|
return { text: `up: preflight — ${did[event.action]}${running}${handover}`, stderr: false };
|
|
704
1164
|
}
|
|
1165
|
+
/**
|
|
1166
|
+
* The policy file `--policy` names, or the one `--dir` (else `cwd`) holds.
|
|
1167
|
+
*
|
|
1168
|
+
* Discovery, not a load: this answers WHICH FILE, so the preflight can compare
|
|
1169
|
+
* its attested hash against the remote's copy (APRV-342) before either caller
|
|
1170
|
+
* has loaded a policy. The order is `core/policy-load.ts`'s own, so the file
|
|
1171
|
+
* named here is the file the runtime will go on to enforce. `null` when there is
|
|
1172
|
+
* no such file, which is not a finding — `approval doctor`'s `policy` rows are
|
|
1173
|
+
* where an absent policy is somebody's problem.
|
|
1174
|
+
*/
|
|
1175
|
+
export function preflightPolicyPath(policyFlag, dirFlag, cwd) {
|
|
1176
|
+
if (policyFlag !== null)
|
|
1177
|
+
return resolvePathSegments(cwd, policyFlag);
|
|
1178
|
+
const dir = dirFlag === null ? cwd : resolvePathSegments(cwd, dirFlag);
|
|
1179
|
+
for (const filename of POLICY_FILENAMES) {
|
|
1180
|
+
const candidate = join(dir, filename);
|
|
1181
|
+
if (existsSync(candidate))
|
|
1182
|
+
return candidate;
|
|
1183
|
+
}
|
|
1184
|
+
return null;
|
|
1185
|
+
}
|
|
705
1186
|
/**
|
|
706
1187
|
* Run the preflight, print what it did, and answer whether the caller may start.
|
|
707
1188
|
*
|
|
@@ -733,6 +1214,11 @@ export function startupPreflight(input) {
|
|
|
733
1214
|
if (outcome.warning !== null) {
|
|
734
1215
|
input.emit({ event: "preflight_warning", message: outcome.warning });
|
|
735
1216
|
}
|
|
1217
|
+
// Before the `preflight` line, because it happened before what that line
|
|
1218
|
+
// reports: the reconcile is what made the fast-forward possible.
|
|
1219
|
+
if (outcome.synced !== undefined) {
|
|
1220
|
+
input.emit({ event: "preflight_sync", ...outcome.synced });
|
|
1221
|
+
}
|
|
736
1222
|
// Emitted BEFORE the re-exec, and by the parent, because this is the only
|
|
737
1223
|
// place the whole story is known: the child runs with `--no-preflight` and
|
|
738
1224
|
// has nothing to say about a fast-forward it did not perform. The commit is
|
|
@@ -743,8 +1229,75 @@ export function startupPreflight(input) {
|
|
|
743
1229
|
detail: outcome.detail,
|
|
744
1230
|
...outcome.facts,
|
|
745
1231
|
});
|
|
1232
|
+
// APRV-342, and AFTER the fast-forward: a merge that just landed the
|
|
1233
|
+
// amendment is exactly the case where the answer changes, and reporting the
|
|
1234
|
+
// pre-merge one would name an interregnum this process had already left.
|
|
1235
|
+
const interregnum = attestedPolicyLine(input);
|
|
1236
|
+
if (interregnum !== null)
|
|
1237
|
+
input.emit(interregnum);
|
|
746
1238
|
return { ok: true, reexec: outcome.reexec ?? null };
|
|
747
1239
|
}
|
|
1240
|
+
/**
|
|
1241
|
+
* The `attested-policy-on-main` line, or `null` when there is nothing to say.
|
|
1242
|
+
*
|
|
1243
|
+
* `null` for a pass, a skip, and for every state the check cannot read: the
|
|
1244
|
+
* preflight's lines report what happened and what an operator has to act on,
|
|
1245
|
+
* and "your policy is where it should be" is neither. It NEVER refuses — see
|
|
1246
|
+
* {@link checkAttestedPolicyOnMain} for why a pending amendment is a normal
|
|
1247
|
+
* state of a repository rather than a fault.
|
|
1248
|
+
*
|
|
1249
|
+
* The log is re-verified here rather than passed in, because the preflight runs
|
|
1250
|
+
* before either caller has opened it. That is one whole-log read at startup, in
|
|
1251
|
+
* a process that is about to read the log on every tick.
|
|
1252
|
+
*/
|
|
1253
|
+
function attestedPolicyLine(input) {
|
|
1254
|
+
const policyPath = input.policyPath ?? null;
|
|
1255
|
+
if (policyPath === null)
|
|
1256
|
+
return null;
|
|
1257
|
+
const root = repoRoot(dirname(input.logPath));
|
|
1258
|
+
if (root === null)
|
|
1259
|
+
return null;
|
|
1260
|
+
// The cheap half first, and it answers the common case without opening the
|
|
1261
|
+
// log at all: when the remote's copy of the policy is byte-identical to the
|
|
1262
|
+
// one on disk, "is the attested policy on the remote" has the same answer as
|
|
1263
|
+
// "is the policy on disk attested" — which is doctor's `attestation` row, and
|
|
1264
|
+
// which `up` has no business duplicating on its startup line. The expensive
|
|
1265
|
+
// half below is a whole-log verify, so skipping it whenever the answer is
|
|
1266
|
+
// already settled is the difference between a read per start and a read per
|
|
1267
|
+
// start on a repository mid-amendment.
|
|
1268
|
+
const relative = repoPath(root, policyPath);
|
|
1269
|
+
if (relative.startsWith(".."))
|
|
1270
|
+
return null;
|
|
1271
|
+
const remote = input.remote ?? "origin";
|
|
1272
|
+
const branch = input.branch ?? currentBranch(root) ?? "main";
|
|
1273
|
+
const resolved = git(["rev-parse", "--verify", "--quiet", `refs/remotes/${remote}/${branch}^{commit}`], root);
|
|
1274
|
+
const tip = resolved.stdout.trim();
|
|
1275
|
+
if (!resolved.ok || tip.length === 0)
|
|
1276
|
+
return null;
|
|
1277
|
+
const blob = showBlob(root, tip, relative);
|
|
1278
|
+
let onDisk;
|
|
1279
|
+
try {
|
|
1280
|
+
onDisk = readIfPresent(policyPath);
|
|
1281
|
+
}
|
|
1282
|
+
catch {
|
|
1283
|
+
return null;
|
|
1284
|
+
}
|
|
1285
|
+
if (blob !== null && onDisk !== null && blob.equals(onDisk))
|
|
1286
|
+
return null;
|
|
1287
|
+
const verified = verifyWithRecords(input.logPath);
|
|
1288
|
+
if (verified.result.status !== "clean")
|
|
1289
|
+
return null;
|
|
1290
|
+
const row = checkAttestedPolicyOnMain({
|
|
1291
|
+
policyPath,
|
|
1292
|
+
records: verified.records,
|
|
1293
|
+
root,
|
|
1294
|
+
...(input.remote === null ? {} : { remote: input.remote }),
|
|
1295
|
+
...(input.branch === null ? {} : { branch: input.branch }),
|
|
1296
|
+
});
|
|
1297
|
+
if (row.status !== "fail")
|
|
1298
|
+
return null;
|
|
1299
|
+
return { event: "preflight_policy", detail: row.detail, fix: row.fix ?? null };
|
|
1300
|
+
}
|
|
748
1301
|
/** The short sha this checkout is on, or `null` when git will not say. */
|
|
749
1302
|
function headCommit(logPath) {
|
|
750
1303
|
const root = repoRoot(dirname(logPath));
|
|
@@ -859,6 +1412,90 @@ function npmBuild(root) {
|
|
|
859
1412
|
* never `git`. That constraint predates this row and is the right one — a repair
|
|
860
1413
|
* line telling an operator to reset a branch would be doctor making a decision.
|
|
861
1414
|
*/
|
|
1415
|
+
/**
|
|
1416
|
+
* `attested-policy-on-main`, doctor's row for the interregnum (APRV-342).
|
|
1417
|
+
*
|
|
1418
|
+
* Between a policy amendment and its pull request merging there is a window
|
|
1419
|
+
* where the attestation is in the log and the amended `APPROVAL.md` is in the
|
|
1420
|
+
* working tree, and `origin/main` carries neither. A fresh checkout of main in
|
|
1421
|
+
* that window has the OLD policy with no attestation covering it, and every
|
|
1422
|
+
* gate operation there refuses `policy-not-attested`.
|
|
1423
|
+
*
|
|
1424
|
+
* Nothing said so. On 2026-09-16 `approval up` ran its preflight in exactly that
|
|
1425
|
+
* state and reported "already at the remote tip"; doctor's `attestation` row
|
|
1426
|
+
* passed, because the LOCAL file is attested and that row asks a different
|
|
1427
|
+
* question. This row asks the missing one: is the policy the log vouches for the
|
|
1428
|
+
* policy `origin/<branch>` carries?
|
|
1429
|
+
*
|
|
1430
|
+
* Read-only, and networkless. The remote tip is read from the last fetch, like
|
|
1431
|
+
* every other answer doctor gives about a remote, and the `policy-amend-<seq>`
|
|
1432
|
+
* branch is looked for among the remote-tracking refs rather than asked of
|
|
1433
|
+
* GitHub: a report that reached the network to be more accurate would be doing
|
|
1434
|
+
* something on its own account.
|
|
1435
|
+
*/
|
|
1436
|
+
export function checkAttestedPolicyOnMain(input) {
|
|
1437
|
+
const check = "attested-policy-on-main";
|
|
1438
|
+
const root = input.root;
|
|
1439
|
+
if (root === null) {
|
|
1440
|
+
return {
|
|
1441
|
+
check,
|
|
1442
|
+
status: "skip",
|
|
1443
|
+
detail: `${input.policyPath} is not inside a git repository, so there is no remote copy of it to compare the attestation against`,
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
const status = checkAttestation([...input.records], input.policyPath);
|
|
1447
|
+
// Not applicable rather than a pass: with no attestation there is no hash to
|
|
1448
|
+
// compare, and `attestation` is the row that has something to say about that.
|
|
1449
|
+
const attested = status.status === "attested"
|
|
1450
|
+
? { sha256: status.sha256, seq: status.seq }
|
|
1451
|
+
: status.status === "hash-mismatch"
|
|
1452
|
+
? { sha256: status.attestedSha256, seq: status.seq }
|
|
1453
|
+
: null;
|
|
1454
|
+
if (attested === null) {
|
|
1455
|
+
return {
|
|
1456
|
+
check,
|
|
1457
|
+
status: "skip",
|
|
1458
|
+
detail: `${input.policyPath} carries no attestation, so there is no attested hash to look for on the remote`,
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
const remote = input.remote ?? "origin";
|
|
1462
|
+
const branch = input.branch ?? currentBranch(root) ?? "main";
|
|
1463
|
+
const ref = `refs/remotes/${remote}/${branch}`;
|
|
1464
|
+
const resolved = git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], root);
|
|
1465
|
+
const tip = resolved.stdout.trim();
|
|
1466
|
+
if (!resolved.ok || tip.length === 0) {
|
|
1467
|
+
return {
|
|
1468
|
+
check,
|
|
1469
|
+
status: "skip",
|
|
1470
|
+
detail: `this checkout has no ${remote}/${branch} remote-tracking ref, so there is no remote copy of ${input.policyPath} to compare the attestation against`,
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
const relative = repoPath(root, input.policyPath);
|
|
1474
|
+
const blob = relative.startsWith("..") ? null : showBlob(root, tip, relative);
|
|
1475
|
+
const remoteSha256 = blob === null ? null : policyBytesHash(blob);
|
|
1476
|
+
if (remoteSha256 === attested.sha256) {
|
|
1477
|
+
return {
|
|
1478
|
+
check,
|
|
1479
|
+
status: "pass",
|
|
1480
|
+
detail: `${remote}/${branch} carries the policy attested at seq ${String(attested.seq)} (sha256 ${attested.sha256.slice(0, 12)}…)`,
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
// The branch the amendment would ride, when this checkout has already seen it
|
|
1484
|
+
// on the remote. Named in the detail rather than in the fix: `approval policy
|
|
1485
|
+
// amend --pr` is the command either way, because it updates an open pull
|
|
1486
|
+
// request rather than opening a second one (APRV-341).
|
|
1487
|
+
const amendBranch = `policy-amend-${String(attested.seq)}`;
|
|
1488
|
+
const pushed = git(["rev-parse", "--verify", "--quiet", `refs/remotes/${remote}/${amendBranch}^{commit}`], root);
|
|
1489
|
+
const carried = pushed.ok && pushed.stdout.trim().length > 0;
|
|
1490
|
+
return {
|
|
1491
|
+
check,
|
|
1492
|
+
status: "fail",
|
|
1493
|
+
detail: `attested at seq ${String(attested.seq)}, not yet on main: ${remote}/${branch} carries ${remoteSha256 === null ? `no ${relative} at all` : `${relative} hashing ${remoteSha256.slice(0, 12)}…`} while the attestation covers ${attested.sha256.slice(0, 12)}…${carried
|
|
1494
|
+
? `; ${remote} already carries ${amendBranch}, so its pull request is what lands it`
|
|
1495
|
+
: ""}. A fresh checkout of ${branch} refuses every gate operation with policy-not-attested until it merges`,
|
|
1496
|
+
fix: `approval policy amend --pr — commits the policy and its attestation on ${amendBranch}, opens or updates its pull request, and arms the merge`,
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
862
1499
|
export function checkMainBehindOrigin(logPath, queuePath, root) {
|
|
863
1500
|
const report = inspectPreflight({ logPath, queuePath, root, fetch: false });
|
|
864
1501
|
if (!report.ok) {
|
|
@@ -878,10 +1515,17 @@ export function checkMainBehindOrigin(logPath, queuePath, root) {
|
|
|
878
1515
|
if (report.facts.behind_by === 0 && !report.facts.dist_stale) {
|
|
879
1516
|
return { check: "main-behind-origin", status: "pass", detail: `${report.detail}${suffix}` };
|
|
880
1517
|
}
|
|
1518
|
+
// A plan is worth naming even though it is a pass: the row is where an
|
|
1519
|
+
// operator looks to find out whether starting will be one command or two, and
|
|
1520
|
+
// "your working log extends the committed one" is the answer that used to
|
|
1521
|
+
// arrive as a refusal (APRV-346).
|
|
1522
|
+
const plan = report.sync === null
|
|
1523
|
+
? ""
|
|
1524
|
+
: `; the working log is a clean extension (${report.sync.relation}, ${plural(report.sync.kept, "local record")}), so up reconciles it rather than refusing`;
|
|
881
1525
|
return {
|
|
882
1526
|
check: "main-behind-origin",
|
|
883
1527
|
status: "pass",
|
|
884
|
-
detail: `${report.detail}; upstream ${report.facts.log_touched ? "DOES" : "does not"} touch the working log or queue${suffix}`,
|
|
1528
|
+
detail: `${report.detail}; upstream ${report.facts.log_touched ? "DOES" : "does not"} touch the working log or queue${plan}${suffix}`,
|
|
885
1529
|
fix: "approval up — fast-forwards and rebuilds when it is safe, and refuses with the next command when it is not",
|
|
886
1530
|
};
|
|
887
1531
|
}
|