@iowarp/clio-coder 0.5.0 → 0.5.2
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/.claude-plugin/marketplace.json +4 -4
- package/CHANGELOG.md +35 -0
- package/CONTRIBUTING.md +9 -0
- package/README.md +12 -9
- package/ROADMAP.md +30 -5
- package/dist/{acp-KS7ARGV6.js → acp-F6PGOFZ4.js} +2 -2
- package/dist/{agents-YOGJ2YEJ.js → agents-ZHP4R6EX.js} +22 -21
- package/dist/assets/codewiki.json +1 -1
- package/dist/{auth-CEDCO6O6.js → auth-N75U2H3Z.js} +4 -4
- package/dist/{chunk-D3KWFOUT.js → chunk-23UIXH6Z.js} +3 -3
- package/dist/{chunk-ZR5YA6ZF.js → chunk-36XVK5P5.js} +5 -5
- package/dist/{chunk-AVSR2HKI.js → chunk-3DZ4LPLS.js} +6 -6
- package/dist/{chunk-LYJJ6546.js → chunk-3GSPCWFX.js} +12 -12
- package/dist/{chunk-CRHPCPZP.js → chunk-4KC4CHKJ.js} +2 -2
- package/dist/{chunk-4VDHLCF5.js → chunk-4WJBWKMI.js} +2 -2
- package/dist/{chunk-KBUTSJSR.js → chunk-4XBYHXGK.js} +2 -2
- package/dist/{chunk-6W75D37F.js → chunk-53P775GE.js} +6 -3
- package/dist/{chunk-XZWSP67B.js → chunk-5APYAXCB.js} +2 -2
- package/dist/{chunk-D65E25JG.js → chunk-67HPYNTJ.js} +2 -2
- package/dist/{chunk-5JQR5B4D.js → chunk-6KV4KCQZ.js} +4 -4
- package/dist/{chunk-V4XJWDHR.js → chunk-6UKFRTXI.js} +2 -1
- package/dist/{chunk-NDPF2N2L.js → chunk-7DW7CJW2.js} +2 -2
- package/dist/{chunk-5KQEUIWM.js → chunk-7LUEQODV.js} +4 -4
- package/dist/{chunk-LNOXCGN2.js → chunk-7UFDIOBT.js} +3 -3
- package/dist/{chunk-DXSETN72.js → chunk-7WUGNN43.js} +2 -2
- package/dist/{chunk-V35KZDUZ.js → chunk-AA3GRZWS.js} +2 -2
- package/dist/{chunk-X4CEFOSG.js → chunk-ADO3OQIC.js} +4 -4
- package/dist/{chunk-UX2L5CUD.js → chunk-AI36V3ON.js} +3 -3
- package/dist/{chunk-B7GY6QER.js → chunk-B26OUMJW.js} +2 -2
- package/dist/{chunk-LAR7DX5E.js → chunk-B5TASTCK.js} +153 -62
- package/dist/{chunk-TGWZXJ7L.js → chunk-BEMQK5Q7.js} +92 -84
- package/dist/{chunk-RNDCROSG.js → chunk-CNXU7TR2.js} +3 -3
- package/dist/{chunk-3JLTRPN7.js → chunk-CVMUXT64.js} +35 -66
- package/dist/{chunk-733ZBT5P.js → chunk-E74MJF6G.js} +4 -4
- package/dist/{chunk-T2MDZES3.js → chunk-EBMTBE76.js} +3 -3
- package/dist/{chunk-OZ76TLGU.js → chunk-EEITU7S6.js} +2 -2
- package/dist/{chunk-GRHOXDKB.js → chunk-ELDJWJKM.js} +97 -14
- package/dist/{chunk-GCQFN7YI.js → chunk-EMYEQLWO.js} +2 -2
- package/dist/{chunk-LTWH2ACR.js → chunk-EQPFEDFV.js} +222 -127
- package/dist/{chunk-TDGYTWM6.js → chunk-F4I3H56H.js} +2 -2
- package/dist/chunk-F6KCJO3U.js +523 -0
- package/dist/{chunk-FGSQHUCK.js → chunk-F6VKWTTY.js} +5 -5
- package/dist/{chunk-UJD5MPP6.js → chunk-FLNXQQ5B.js} +2 -2
- package/dist/{chunk-EX57VVWW.js → chunk-FOSRJ2VZ.js} +4 -4
- package/dist/{chunk-ZARZ4POR.js → chunk-G5ZDQQE3.js} +3 -3
- package/dist/{chunk-HQVN7G4F.js → chunk-G62JBPXN.js} +7 -7
- package/dist/{chunk-ZJPORZTC.js → chunk-GIAI7A2K.js} +2 -2
- package/dist/chunk-GPTR6OTU.js +2551 -0
- package/dist/{chunk-EZD2BXGP.js → chunk-GUFQPTQK.js} +2 -2
- package/dist/{chunk-OPD7GL6F.js → chunk-H6FQVNZC.js} +8 -7
- package/dist/chunk-H7REJ7L5.js +80 -0
- package/dist/{chunk-EYTLV3W3.js → chunk-HTWTORF7.js} +2 -2
- package/dist/{chunk-DF6PI6GN.js → chunk-I6UKQPGS.js} +3 -3
- package/dist/{chunk-ISHUS7HC.js → chunk-JRW236JT.js} +3 -3
- package/dist/{chunk-DE2LU267.js → chunk-KKAJGT3B.js} +3 -2
- package/dist/{chunk-OV62D6KO.js → chunk-KQMYL5GR.js} +3 -3
- package/dist/{chunk-QDUUOZ3S.js → chunk-KWMFC7FN.js} +3 -3
- package/dist/{chunk-R7FGRZNF.js → chunk-LXYHCCIZ.js} +9 -9
- package/dist/{chunk-NFSK2VNU.js → chunk-LZMTVVN4.js} +2 -2
- package/dist/{chunk-62UK7AGW.js → chunk-M5PMRZSR.js} +397 -44
- package/dist/chunk-MMZMM6SW.js +131 -0
- package/dist/{chunk-FFYTC2KQ.js → chunk-MNABNEBX.js} +2 -2
- package/dist/{chunk-XMDBTYCH.js → chunk-N3CWOGVF.js} +3 -3
- package/dist/{chunk-VYRQHORQ.js → chunk-NMQSO6Z6.js} +4 -3
- package/dist/{chunk-R2UUNSNQ.js → chunk-O3ZAF7UY.js} +18 -5
- package/dist/{chunk-4SYHGVDE.js → chunk-OQNCLZI4.js} +4 -4
- package/dist/{chunk-EG4ARXPC.js → chunk-OX6QTA4N.js} +3 -3
- package/dist/{chunk-P4GNZPXO.js → chunk-OYUS3UZP.js} +2 -2
- package/dist/{chunk-OQ2EOAHA.js → chunk-PF36KOGI.js} +291 -112
- package/dist/{chunk-55RMDDFM.js → chunk-PGQG7ZMW.js} +2 -2
- package/dist/{chunk-CBAQPTDU.js → chunk-PIU6BXXW.js} +2 -2
- package/dist/{chunk-EKV4UBCM.js → chunk-PZBQUJ2F.js} +2 -2
- package/dist/{chunk-7RET77VB.js → chunk-Q2HKY32Y.js} +2 -2
- package/dist/{chunk-U6YTUVOX.js → chunk-Q4RMNWMZ.js} +4 -4
- package/dist/chunk-Q6FRR3AQ.js +48 -0
- package/dist/{chunk-6RJMYYIE.js → chunk-QJSIAJBY.js} +3 -3
- package/dist/{chunk-DOUISWEE.js → chunk-QOJ4ERMU.js} +5 -5
- package/dist/{chunk-IFPDAD7R.js → chunk-RWSRKXLT.js} +3 -3
- package/dist/{chunk-T33IYTZM.js → chunk-SAASVMIY.js} +3 -3
- package/dist/{chunk-2DTIWSAW.js → chunk-SEQDH6JC.js} +3 -3
- package/dist/{chunk-BRCOSE7O.js → chunk-SZUO5BSE.js} +2 -2
- package/dist/{chunk-HOEDA42N.js → chunk-T7HZ7KJF.js} +2 -2
- package/dist/{chunk-OJXOA6YU.js → chunk-TL6LNERH.js} +2 -2
- package/dist/{chunk-UCINIRHW.js → chunk-TPCZXWLT.js} +3 -3
- package/dist/{chunk-ZUKUCZYZ.js → chunk-TQ2KTH4A.js} +2 -2
- package/dist/{chunk-4WOOTKFD.js → chunk-U3FETNQB.js} +341 -20
- package/dist/{chunk-5B3RTYAK.js → chunk-UGDSB4AN.js} +2 -2
- package/dist/{chunk-VKHLUZNO.js → chunk-UZ7YBL43.js} +3 -3
- package/dist/{chunk-QNUQ7K7D.js → chunk-VKMSEO7Y.js} +4 -4
- package/dist/{chunk-IN7DGBVS.js → chunk-VLX5VZ35.js} +5 -5
- package/dist/{chunk-SJGS3GDI.js → chunk-WAGBMMNX.js} +5 -5
- package/dist/{chunk-BFOSV5EZ.js → chunk-WX2YCH7F.js} +2 -2
- package/dist/{chunk-3LT34CAM.js → chunk-XBIGUILU.js} +32 -17
- package/dist/{chunk-XDHUDE5K.js → chunk-XKYBFRWR.js} +5 -5
- package/dist/{chunk-U5QU5ZOD.js → chunk-XYPWFSU5.js} +3 -3
- package/dist/{chunk-YBUECLAF.js → chunk-YEJQDODA.js} +2 -2
- package/dist/{chunk-YQ6XEFVK.js → chunk-Z74OGONW.js} +39 -7
- package/dist/{chunk-I4ELN5BX.js → chunk-ZTNOEJRI.js} +2 -2
- package/dist/cli/index.js +26 -26
- package/dist/{clio-K2PBVCEM.js → clio-4IKFE25F.js} +2 -2
- package/dist/{clio-context-tools-GR4OHFSO.js → clio-context-tools-OHJXYX4R.js} +25 -23
- package/dist/{code-nav-4X3OI6DK.js → code-nav-XPM3MY5I.js} +7 -7
- package/dist/{config-Q5J7UBAB.js → config-343YS25T.js} +43 -41
- package/dist/{config-graph-P3555574.js → config-graph-WI263KDX.js} +43 -41
- package/dist/{configure-KIJ32ZO6.js → configure-GZDG4HER.js} +21 -21
- package/dist/{context-LFBQOSXT.js → context-4OFO7N7Y.js} +12 -12
- package/dist/{context-WBH3KSVR.js → context-JLK2RLOG.js} +42 -40
- package/dist/{context-NNLBES2Y.js → context-O6DIWCF6.js} +23 -21
- package/dist/{context-clear-YOHMQ6SQ.js → context-clear-MR7KOGQI.js} +42 -40
- package/dist/{context-working-set-MRAEVDNV.js → context-working-set-YZAD3KWO.js} +14 -13
- package/dist/{data-tool-JJPBGMQJ.js → data-tool-EKD7DFHG.js} +7 -7
- package/dist/{detail-RHJOTGN4.js → detail-YZPHFEVJ.js} +43 -41
- package/dist/{dispatch-runner-NC7PYNIO.js → dispatch-runner-VKVXVTWJ.js} +44 -42
- package/dist/{doctor-S5KZ2GTN.js → doctor-53AVQXAO.js} +17 -16
- package/dist/{doctor-deep-2EJQSHIZ.js → doctor-deep-BLRB2NCG.js} +5 -5
- package/dist/{eval-3D6M36N7.js → eval-P3BQSLQE.js} +21 -20
- package/dist/{evidence-XNVPPLVK.js → evidence-6LLI6SZH.js} +44 -42
- package/dist/{evidence-6IYN52ET.js → evidence-HTN3JLUO.js} +42 -40
- package/dist/{evidence-JVECPI3F.js → evidence-NJRE3R5H.js} +42 -40
- package/dist/{evolve-TLPUDJDM.js → evolve-WWZHJVJP.js} +42 -40
- package/dist/{fleet-WKVBF5SZ.js → fleet-GGZ4BVJE.js} +64 -62
- package/dist/{fleet-6RJWIOEO.js → fleet-QG3IIWWM.js} +44 -42
- package/dist/{fleet-commands-L6WFT7KZ.js → fleet-commands-J2ZIKISQ.js} +7 -7
- package/dist/{fleet-decisions-BTQMWU5A.js → fleet-decisions-Y2Q7375Q.js} +8 -8
- package/dist/{fleet-graph-553DZ67Q.js → fleet-graph-TCBCCCBP.js} +11 -11
- package/dist/{fleet-inspect-236FZPRB.js → fleet-inspect-KOLKBC56.js} +44 -42
- package/dist/{fleet-preflight-NZQHABWO.js → fleet-preflight-44UNCC4Y.js} +27 -25
- package/dist/{fleet-validate-7NLAE26R.js → fleet-validate-K2FXRZVA.js} +13 -13
- package/dist/{fleet-verify-E3RO6A5W.js → fleet-verify-AS444WDW.js} +42 -40
- package/dist/{fleet-view-KMZQY7YJ.js → fleet-view-X4JU4J6O.js} +43 -41
- package/dist/gui/ops-worker.js +9 -9
- package/dist/gui/reads-worker.js +9 -8
- package/dist/{init-DV5B6HQG.js → init-VSOWFHVL.js} +54 -52
- package/dist/{interactive-OXGYZYAS.js → interactive-JRCBENLZ.js} +1733 -577
- package/dist/{interop-J7UOBOSF.js → interop-JR6BCY7R.js} +10 -10
- package/dist/{inventory-OEGIL6PY.js → inventory-3JUGLVJI.js} +43 -41
- package/dist/{library-ROZAMUIL.js → library-7BOH3Y7N.js} +15 -15
- package/dist/{library-B3UPGY4V.js → library-7PFONXUI.js} +9 -9
- package/dist/{library-MXQA5DLT.js → library-TUYZMFIA.js} +7 -7
- package/dist/{library-import-2LTWOVBD.js → library-import-TJVXKLDK.js} +10 -10
- package/dist/{library-inventory-LJ3XHLWO.js → library-inventory-MD3AYTP4.js} +9 -9
- package/dist/{library-validation-DARHHUNW.js → library-validation-ZDLITEA3.js} +7 -7
- package/dist/{mcp-2GSPYQ54.js → mcp-EFCWUPUR.js} +3 -3
- package/dist/{memory-J3STUUIK.js → memory-Q2YVOKK5.js} +42 -40
- package/dist/{models-Y3GMFJ2V.js → models-M7BOCW7F.js} +17 -16
- package/dist/{monitor-LHNH577J.js → monitor-2HD3MC67.js} +47 -45
- package/dist/{orchestrator-DTAPXULA.js → orchestrator-OUW3ZN6Y.js} +1894 -226
- package/dist/{panes-A743ONZ5.js → panes-TTPPSYCL.js} +3 -3
- package/dist/{preload-OJBMAIXF.js → preload-A47C2NUF.js} +42 -40
- package/dist/{providers-CMETPZNS.js → providers-T43W7EL4.js} +4 -4
- package/dist/{resources-FY6XYFN2.js → resources-TP6X2V6W.js} +20 -11
- package/dist/{run-H64MEVTA.js → run-LDSBPMCS.js} +61 -59
- package/dist/{share-CAFMMQLM.js → share-URHKKMHR.js} +9 -9
- package/dist/{skills-ERKQUC7L.js → skills-E477MOOO.js} +13 -11
- package/dist/{skills-eval-CQ23MYQK.js → skills-eval-AIBR2L7H.js} +583 -93
- package/dist/{skills-inventory-TVUYJDVN.js → skills-inventory-HLMKY3R5.js} +13 -11
- package/dist/{slash-commands-ROG3KIFJ.js → slash-commands-LIIZKA5Y.js} +28 -26
- package/dist/{startup-background-KDWCQMWT.js → startup-background-JR2KDK6R.js} +43 -41
- package/dist/{steer-KKYXJSH4.js → steer-D7RTAPCM.js} +3 -3
- package/dist/{system-ZDW44ZGX.js → system-X2P5N4IY.js} +14 -13
- package/dist/{targets-AJWA6E7Z.js → targets-VFFAKUFM.js} +25 -24
- package/dist/{tasks-6L2JJRC5.js → tasks-ERAWZA22.js} +6 -6
- package/dist/{terminal-lease-YFUSDPAD.js → terminal-lease-V2N2BIKQ.js} +2 -2
- package/dist/{trace-LA5MVR4Y.js → trace-MA5BT7XY.js} +3 -3
- package/dist/{usage-XSILDHKF.js → usage-WYFY2L6M.js} +50 -48
- package/dist/{verifiers-ECA5GIHL.js → verifiers-TYIW2XKF.js} +7 -7
- package/dist/{verify-R6QQCCEB.js → verify-242UFYTP.js} +6 -6
- package/dist/{web-fetch-PVZNMBOB.js → web-fetch-LRAGYU6Y.js} +3 -3
- package/dist/{wiki-generate-RP22WP5K.js → wiki-generate-OFKOX2T6.js} +53 -51
- package/dist/worker/entry.js +31 -29
- package/docs/architecture/architecture.md +1 -0
- package/docs/architecture/context-engine.md +4 -2
- package/docs/architecture/observability.md +6 -4
- package/docs/architecture/prompt-envelope-and-tools.md +1 -1
- package/docs/architecture/session-lifecycle.md +1 -1
- package/docs/architecture/tui-design.md +12 -9
- package/docs/gui/parity/02-slash-and-surfaces.md +1 -1
- package/docs/guide/commands-and-modes.md +91 -5
- package/docs/guide/configuration-and-targets.md +2 -2
- package/docs/guide/context-continuity.md +49 -0
- package/docs/guide/proactive-memory.md +6 -3
- package/library/registry.yaml +292 -32
- package/library/skills/README.md +27 -2
- package/library/skills/context/context-handoff/SKILL.md +10 -4
- package/library/skills/meta/clio-coder-dev/SKILL.md +92 -88
- package/library/skills/meta/clio-coder-dev/evals.md +51 -46
- package/library/skills/meta/clio-coder-dev/plugin.json +2 -2
- package/library/skills/meta/clio-coder-dev/references/change-map.md +49 -0
- package/library/skills/meta/clio-coder-test/SKILL.md +89 -142
- package/library/skills/meta/clio-coder-test/evals.md +58 -62
- package/library/skills/meta/clio-coder-test/plugin.json +2 -2
- package/library/skills/meta/clio-coder-test/references/harness.md +3 -3
- package/library/skills/meta/clio-coder-test/references/lifecycle-validation.md +30 -0
- package/library/skills/meta/clio-coder-test/references/test-map.md +60 -89
- package/library/skills/registry.yaml +5 -5
- package/library/skills/skill-marketplace.json +12 -14
- package/package.json +1 -1
- package/src/cli/skills-eval.ts +1064 -54
- package/src/cli/usage.ts +2 -2
- package/src/core/clio-repo.ts +3 -0
- package/src/core/tool-names.ts +1 -0
- package/src/domains/context/budget/inspection.ts +9 -0
- package/src/domains/context/budget/live-view.ts +418 -0
- package/src/domains/context/budget/pressure.ts +393 -0
- package/src/domains/context/budget/request-fit.ts +15 -0
- package/src/domains/evidence/build.ts +24 -0
- package/src/domains/gateway/mcp/client.ts +1 -1
- package/src/domains/memory/commit-state.ts +160 -0
- package/src/domains/memory/operations.ts +18 -10
- package/src/domains/memory/prompt-cache.ts +95 -0
- package/src/domains/memory/prompt-section.ts +50 -7
- package/src/domains/memory/relevance.ts +93 -0
- package/src/domains/memory/restoration.ts +78 -0
- package/src/domains/memory/store.ts +39 -1
- package/src/domains/middleware/memory-intervention.ts +116 -24
- package/src/domains/observability/background-memory-usage.ts +1 -1
- package/src/domains/observability/cost.ts +3 -3
- package/src/domains/observability/extension.ts +1 -1
- package/src/domains/observability/metrics.ts +1 -1
- package/src/domains/prompts/compiler.ts +1 -1
- package/src/domains/prompts/extension.ts +38 -1
- package/src/domains/quota/anthropic-max-provider.ts +134 -0
- package/src/domains/quota/anthropic-usage.ts +220 -0
- package/src/domains/quota/antigravity-provider.ts +339 -0
- package/src/domains/quota/cache.ts +87 -0
- package/src/domains/quota/claude-code-provider.ts +169 -0
- package/src/domains/quota/codex-provider.ts +237 -0
- package/src/domains/quota/presentation.ts +240 -0
- package/src/domains/quota/registry.ts +23 -0
- package/src/domains/quota/service.ts +93 -0
- package/src/domains/quota/summary-feed.ts +86 -0
- package/src/domains/quota/types.ts +84 -0
- package/src/domains/resources/index.ts +11 -0
- package/src/domains/resources/skills/catalog-view.ts +571 -0
- package/src/domains/resources/skills/lexical-match.ts +136 -0
- package/src/domains/resources/skills/loader.ts +38 -0
- package/src/domains/resources/skills/promotion.ts +1 -55
- package/src/domains/resources/skills/provenance-pin.ts +50 -20
- package/src/domains/safety/action-classifier.ts +1 -0
- package/src/domains/session/compaction/branch-summary.ts +4 -1
- package/src/domains/session/compaction/compact.ts +18 -2
- package/src/domains/session/compaction/cut-point.ts +20 -1
- package/src/domains/session/compaction/tokens.ts +48 -3
- package/src/domains/session/context-accounting.ts +8 -1
- package/src/domains/session/continuity/carry.ts +59 -0
- package/src/domains/session/continuity/contract.ts +592 -0
- package/src/domains/session/continuity/evidence.ts +290 -0
- package/src/domains/session/continuity/fold.ts +1075 -0
- package/src/domains/session/continuity/note.ts +79 -0
- package/src/domains/session/continuity/operator-request.ts +104 -0
- package/src/domains/session/continuity/persistence.ts +408 -0
- package/src/domains/session/continuity/ports.ts +231 -0
- package/src/domains/session/continuity/projection.ts +538 -0
- package/src/domains/session/continuity/validate.ts +352 -0
- package/src/domains/session/entries.ts +50 -3
- package/src/domains/session/index.ts +48 -0
- package/src/domains/session/migrations/index.ts +7 -3
- package/src/domains/session/tree/fork.ts +15 -1
- package/src/domains/session/usage.ts +2 -2
- package/src/engine/acp/commands.ts +1 -1
- package/src/engine/agent.ts +133 -9
- package/src/engine/session.ts +13 -6
- package/src/entry/orchestrator.ts +116 -33
- package/src/interactive/chat-loop-messages.ts +2 -2
- package/src/interactive/chat-loop.ts +219 -63
- package/src/interactive/chat-renderer.ts +99 -8
- package/src/interactive/context-overlay.ts +24 -3
- package/src/interactive/continuity-controller.ts +526 -0
- package/src/interactive/dispatch-board.ts +21 -3
- package/src/interactive/footer/dashboard.ts +25 -1
- package/src/interactive/footer/key-hints.ts +2 -2
- package/src/interactive/footer/pages.ts +70 -27
- package/src/interactive/footer/widgets.ts +32 -7
- package/src/interactive/footer-panel.ts +1 -1
- package/src/interactive/interactive-application.ts +7 -2
- package/src/interactive/interactive-input-runtime.ts +2 -2
- package/src/interactive/interactive-presentation.ts +15 -0
- package/src/interactive/interactive-slash-runtime.ts +45 -8
- package/src/interactive/interactive-tickers.ts +7 -1
- package/src/interactive/model-session-replay.ts +130 -3
- package/src/interactive/output-reserve.ts +35 -0
- package/src/interactive/overlay-general-openers.ts +12 -8
- package/src/interactive/overlay-key-routing.ts +6 -9
- package/src/interactive/overlay-lifecycle.ts +10 -6
- package/src/interactive/overlay-session-lifecycle.ts +21 -14
- package/src/interactive/quota-view.ts +229 -0
- package/src/interactive/session-last-turn.ts +1 -1
- package/src/interactive/session-transcript.ts +2 -1
- package/src/interactive/session-usage-reseed.ts +2 -2
- package/src/interactive/side-question.ts +2 -2
- package/src/interactive/slash-commands.ts +28 -9
- package/src/interactive/turn-context.ts +686 -63
- package/src/interactive/turn-middleware.ts +39 -6
- package/src/interactive/turn-persistence.ts +15 -2
- package/src/interactive/turn-prewarm.ts +1 -1
- package/src/interactive/turn-runtime.ts +48 -5
- package/src/interactive/{cost-overlay.ts → usage-overlay.ts} +121 -33
- package/src/interactive/welcome-dashboard.ts +26 -3
- package/src/tools/agent-tools.ts +3 -1
- package/src/tools/bootstrap.ts +6 -0
- package/src/tools/builtin-tool-catalog.ts +9 -0
- package/src/tools/context/index.ts +148 -113
- package/src/tools/context/surface.ts +11 -7
- package/src/tools/core-bootstrap.ts +4 -0
- package/src/tools/observation.ts +8 -2
- package/src/tools/policy.ts +3 -0
- package/src/tools/self-compact.ts +31 -0
- package/src/tools/surface.ts +1 -0
- package/src/tools/tasks.ts +1 -1
- package/dist/chunk-CTFPFW3H.js +0 -44
- package/dist/chunk-EOJPDNUP.js +0 -1325
- package/dist/chunk-I4Y4WKZR.js +0 -386
|
@@ -0,0 +1,1075 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure validating continuity fold (CONTRACTS.md §3, §7, §8).
|
|
3
|
+
*
|
|
4
|
+
* Given one ordered applicable projection of the ledger, this returns the state
|
|
5
|
+
* of the selected transaction, the recallable note, the anomalies it found, and
|
|
6
|
+
* a **proposed** recovery action. It reads no clock, mints no id, touches no
|
|
7
|
+
* disk, calls no model, and never performs the action it proposes.
|
|
8
|
+
*
|
|
9
|
+
* The rules that decide what may extend a chain:
|
|
10
|
+
*
|
|
11
|
+
* - A real record extends the chain only as a validated successor:
|
|
12
|
+
* predecessor equals the head, sequence is head + 1, attempt moves only for
|
|
13
|
+
* `reducing`, and the phase is legal from the head's phase. A higher
|
|
14
|
+
* sequence number is never on its own a reason to advance, for a commit any
|
|
15
|
+
* more than for an event.
|
|
16
|
+
* - A summary carry is a projection of a chain whose middle was cut out of
|
|
17
|
+
* this input, so it may be ahead of the observed head. It may never
|
|
18
|
+
* contradict what was observed: it cannot reopen a terminal state, walk the
|
|
19
|
+
* lifecycle backwards, leave a pause without a resume, or disagree with the
|
|
20
|
+
* observed identity, note, policy or commit.
|
|
21
|
+
* - An exact original commit appended after a validated later carry is the one
|
|
22
|
+
* legitimate out-of-order case. It fills the commit's presence and does not
|
|
23
|
+
* move the head, so recovery can write the record it owes without regressing
|
|
24
|
+
* an acknowledgement or a pause on the next reload.
|
|
25
|
+
* - Missing-commit classification waits for the whole input, because an
|
|
26
|
+
* initial summary legitimately precedes its own commit, and is abandoned
|
|
27
|
+
* entirely when unreadable records could still hide it.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
type AcceptedNote,
|
|
32
|
+
type ContinuityAnomaly,
|
|
33
|
+
type ContinuityAuthority,
|
|
34
|
+
type ContinuityCarriedState,
|
|
35
|
+
type ContinuityCheckpointPayload,
|
|
36
|
+
type ContinuityCommitData,
|
|
37
|
+
type ContinuityCommitEntry,
|
|
38
|
+
type ContinuityFoldInput,
|
|
39
|
+
type ContinuityFoldResult,
|
|
40
|
+
type ContinuityOutcomeEvidence,
|
|
41
|
+
type ContinuityPhase,
|
|
42
|
+
type ContinuityPriorHandoff,
|
|
43
|
+
type ContinuityRecoveryAction,
|
|
44
|
+
HANDOFF_MAX_WINDOW_MS,
|
|
45
|
+
HANDOFF_SCHEMA_VERSION,
|
|
46
|
+
type HandoffEvent,
|
|
47
|
+
type HandoffEventPhase,
|
|
48
|
+
type HandoffIdentity,
|
|
49
|
+
type HandoffPolicy,
|
|
50
|
+
type HandoffResumeAuthority,
|
|
51
|
+
type HandoffTransactionEntry,
|
|
52
|
+
type MissingCommitReconstruction,
|
|
53
|
+
type OperatorResumeAuthorityEvidence,
|
|
54
|
+
type TerminalResponseEvidence,
|
|
55
|
+
} from "./contract.js";
|
|
56
|
+
import { verifyAcceptedNote } from "./note.js";
|
|
57
|
+
import {
|
|
58
|
+
canonicalJson,
|
|
59
|
+
isContinuityCarryingSummary,
|
|
60
|
+
isContinuityCheckpointPayload,
|
|
61
|
+
isContinuityCommitEntry,
|
|
62
|
+
isHandoffTransactionEntry,
|
|
63
|
+
} from "./validate.js";
|
|
64
|
+
|
|
65
|
+
/** Phases a real successor may legally follow. */
|
|
66
|
+
const LEGAL_PREDECESSORS: Readonly<Record<HandoffEventPhase | "ready", ReadonlySet<ContinuityPhase>>> = Object.freeze({
|
|
67
|
+
prepared: new Set<ContinuityPhase>(["absent"]),
|
|
68
|
+
reducing: new Set<ContinuityPhase>(["prepared", "failed", "resumed"]),
|
|
69
|
+
ready: new Set<ContinuityPhase>(["reducing"]),
|
|
70
|
+
delivered: new Set<ContinuityPhase>(["ready", "resumed"]),
|
|
71
|
+
acknowledged: new Set<ContinuityPhase>(["delivered"]),
|
|
72
|
+
paused: new Set<ContinuityPhase>(["prepared", "reducing", "ready", "delivered", "resumed"]),
|
|
73
|
+
failed: new Set<ContinuityPhase>(["prepared", "reducing", "resumed"]),
|
|
74
|
+
resumed: new Set<ContinuityPhase>(["paused", "failed"]),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/** Position in the linear lifecycle, for carry comparison. Side states are -1. */
|
|
78
|
+
const LIFECYCLE_RANK: Readonly<Record<ContinuityPhase, number>> = Object.freeze({
|
|
79
|
+
absent: 0,
|
|
80
|
+
prepared: 1,
|
|
81
|
+
reducing: 2,
|
|
82
|
+
ready: 3,
|
|
83
|
+
delivered: 4,
|
|
84
|
+
acknowledged: 5,
|
|
85
|
+
paused: -1,
|
|
86
|
+
failed: -1,
|
|
87
|
+
resumed: -1,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/** A usable durable timestamp: finite, non-negative, and an exact integer. */
|
|
91
|
+
function isUsableTimestamp(value: number): boolean {
|
|
92
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface ChainState {
|
|
96
|
+
handoffId: string;
|
|
97
|
+
rootPosition: number;
|
|
98
|
+
identity: HandoffIdentity | null;
|
|
99
|
+
accepted: AcceptedNote | null;
|
|
100
|
+
policy: HandoffPolicy | null;
|
|
101
|
+
commit: ContinuityCommitData | null;
|
|
102
|
+
head: { transition: ContinuityCarriedState["transition"]; event: HandoffEvent | { phase: "ready" } } | null;
|
|
103
|
+
activeResume: { entryId: string; authority: HandoffResumeAuthority } | null;
|
|
104
|
+
/** The evidence row that authorized `activeResume`, for the renewal base. */
|
|
105
|
+
resumeEvidence: OperatorResumeAuthorityEvidence | null;
|
|
106
|
+
delivery: { deliveryId: string; continuationTurnId: string } | null;
|
|
107
|
+
/** Ledger position of the delivery intent, when its record is retained. */
|
|
108
|
+
deliveryPosition: number | null;
|
|
109
|
+
carry: ContinuityCheckpointPayload | null;
|
|
110
|
+
commitPresent: boolean;
|
|
111
|
+
rootSeen: boolean;
|
|
112
|
+
seen: Map<string, string>;
|
|
113
|
+
anomalies: ContinuityAnomaly[];
|
|
114
|
+
poisoned: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function newChain(handoffId: string, position: number): ChainState {
|
|
118
|
+
return {
|
|
119
|
+
handoffId,
|
|
120
|
+
rootPosition: position,
|
|
121
|
+
identity: null,
|
|
122
|
+
accepted: null,
|
|
123
|
+
policy: null,
|
|
124
|
+
commit: null,
|
|
125
|
+
head: null,
|
|
126
|
+
activeResume: null,
|
|
127
|
+
resumeEvidence: null,
|
|
128
|
+
delivery: null,
|
|
129
|
+
deliveryPosition: null,
|
|
130
|
+
carry: null,
|
|
131
|
+
commitPresent: false,
|
|
132
|
+
rootSeen: false,
|
|
133
|
+
seen: new Map(),
|
|
134
|
+
anomalies: [],
|
|
135
|
+
poisoned: false,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function fail(chain: ChainState, kind: ContinuityAnomaly["kind"], entryId: string | null, detail: string): void {
|
|
140
|
+
chain.anomalies.push({ kind, entryId, detail });
|
|
141
|
+
chain.poisoned = true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function headPhase(chain: ChainState): ContinuityPhase {
|
|
145
|
+
return chain.head ? (chain.head.event.phase as ContinuityPhase) : "absent";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function duplicateVerdict(chain: ChainState, entryId: string, payload: string): "new" | "identical" | "conflict" {
|
|
149
|
+
const previous = chain.seen.get(entryId);
|
|
150
|
+
if (previous === undefined) {
|
|
151
|
+
chain.seen.set(entryId, payload);
|
|
152
|
+
return "new";
|
|
153
|
+
}
|
|
154
|
+
return previous === payload ? "identical" : "conflict";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Immutable facts every copy of one transaction must agree on. */
|
|
158
|
+
function adoptImmutable(
|
|
159
|
+
chain: ChainState,
|
|
160
|
+
entryId: string,
|
|
161
|
+
identity: HandoffIdentity,
|
|
162
|
+
accepted: AcceptedNote,
|
|
163
|
+
policy: HandoffPolicy,
|
|
164
|
+
): boolean {
|
|
165
|
+
if (chain.identity === null) {
|
|
166
|
+
chain.identity = identity;
|
|
167
|
+
} else if (canonicalJson(chain.identity) !== canonicalJson(identity)) {
|
|
168
|
+
fail(chain, "duplicate_conflict", entryId, "identity differs across copies of one handoff");
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
// Every initial authority source is verified, not only the second one: a
|
|
172
|
+
// carry-only note whose stored hash disagrees with its text must not become
|
|
173
|
+
// the chain's accepted note.
|
|
174
|
+
const verified = verifyAcceptedNote(accepted, chain.accepted ?? undefined);
|
|
175
|
+
if (!verified.ok) {
|
|
176
|
+
fail(chain, chain.accepted ? "conflicting_carry" : "unverified_note", entryId, `note rejected: ${verified.reason}`);
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
chain.accepted ??= accepted;
|
|
180
|
+
if (chain.policy === null) {
|
|
181
|
+
chain.policy = policy;
|
|
182
|
+
} else if (canonicalJson(chain.policy) !== canonicalJson(policy)) {
|
|
183
|
+
fail(chain, "policy_conflict", entryId, "policy differs across copies of one handoff");
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function adoptCommit(chain: ChainState, entryId: string, commit: ContinuityCommitData): boolean {
|
|
190
|
+
if (chain.commit === null) {
|
|
191
|
+
chain.commit = commit;
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
if (canonicalJson(chain.commit) !== canonicalJson(commit)) {
|
|
195
|
+
fail(chain, "conflicting_carry", entryId, "commit data differs across copies of one handoff");
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Validate a real successor against the observed head: legal phase, exact
|
|
203
|
+
* predecessor, sequence + 1, and the attempt rule. Used for events and for a
|
|
204
|
+
* commit alike, because "a larger sequence number is not proof of a valid
|
|
205
|
+
* chain" applies to both.
|
|
206
|
+
*/
|
|
207
|
+
function validSuccessor(
|
|
208
|
+
chain: ChainState,
|
|
209
|
+
entryId: string,
|
|
210
|
+
phase: ContinuityPhase,
|
|
211
|
+
transition: ContinuityCarriedState["transition"],
|
|
212
|
+
): boolean {
|
|
213
|
+
const from = headPhase(chain);
|
|
214
|
+
if (!LEGAL_PREDECESSORS[phase as HandoffEventPhase | "ready"].has(from)) {
|
|
215
|
+
fail(chain, "illegal_transition", entryId, `${from} -> ${phase} is not a legal transition`);
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
if (phase === "prepared") return true;
|
|
219
|
+
const head = chain.head;
|
|
220
|
+
if (!head) {
|
|
221
|
+
fail(chain, "broken_chain", entryId, `${phase} has no predecessor in this projection`);
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
if (transition.prevEntryId !== head.transition.entryId) {
|
|
225
|
+
fail(chain, "broken_chain", entryId, `predecessor ${transition.prevEntryId} is not the head`);
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
if (transition.sequence !== head.transition.sequence + 1) {
|
|
229
|
+
fail(chain, "broken_chain", entryId, `sequence ${transition.sequence} does not follow the head`);
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
const expectedAttempt = phase === "reducing" ? head.transition.attempt + 1 : head.transition.attempt;
|
|
233
|
+
if (transition.attempt !== expectedAttempt) {
|
|
234
|
+
fail(chain, "invalid_attempt", entryId, `attempt ${transition.attempt} should be ${expectedAttempt}`);
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
if (phase === "reducing" && chain.policy && transition.attempt > chain.policy.maxAttempts) {
|
|
238
|
+
fail(chain, "invalid_attempt", entryId, `attempt ${transition.attempt} exceeds the policy maximum`);
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A successor of a resume must cite it and match the action it was granted. The
|
|
246
|
+
* same rule governs a real event and a one-step carried successor, because a
|
|
247
|
+
* carry that names the adjacent link is making the same claim a record would.
|
|
248
|
+
*/
|
|
249
|
+
function resumeRefValid(chain: ChainState, entryId: string, event: HandoffEvent | { phase: "ready" }): boolean {
|
|
250
|
+
if (event.phase !== "reducing" && event.phase !== "delivered") return true;
|
|
251
|
+
const ref = event.resumeRef;
|
|
252
|
+
if (headPhase(chain) !== "resumed") {
|
|
253
|
+
if (ref === undefined) return true;
|
|
254
|
+
fail(chain, "missing_resume_ref", entryId, "resumeRef names a resume this event does not follow");
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
const resume = chain.activeResume;
|
|
258
|
+
if (!resume || ref !== resume.entryId) {
|
|
259
|
+
fail(chain, "missing_resume_ref", entryId, "event after a resume must name that resume");
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
const wanted = event.phase === "reducing" ? "reduce" : "deliver";
|
|
263
|
+
if (resume.authority.action !== wanted) {
|
|
264
|
+
fail(chain, "illegal_transition", entryId, `resume granted ${resume.authority.action}, not ${wanted}`);
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
interface ApplyContext {
|
|
271
|
+
positions: ReadonlyMap<string, number>;
|
|
272
|
+
/** The actual transaction records, by entry id, from this same input. */
|
|
273
|
+
records: ReadonlyMap<string, HandoffTransactionEntry>;
|
|
274
|
+
authorities: ReadonlyArray<OperatorResumeAuthorityEvidence>;
|
|
275
|
+
sessionId: string;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Bind a resume to a durable new operator control request. Every part of the
|
|
280
|
+
* binding is required, and the request must sit strictly between the head it
|
|
281
|
+
* answers and the resume that cites it: a request recorded before the pause
|
|
282
|
+
* cannot have answered it, and one recorded after the resume cannot have
|
|
283
|
+
* authorized it.
|
|
284
|
+
*/
|
|
285
|
+
function authorizeResume(
|
|
286
|
+
chain: ChainState,
|
|
287
|
+
entry: HandoffTransactionEntry,
|
|
288
|
+
authority: HandoffResumeAuthority,
|
|
289
|
+
position: number,
|
|
290
|
+
context: ApplyContext,
|
|
291
|
+
): OperatorResumeAuthorityEvidence | null {
|
|
292
|
+
// The answered head must be the resume's actual predecessor, so a request
|
|
293
|
+
// answering an older pause cannot authorize a later one.
|
|
294
|
+
if (entry.transition.prevEntryId !== authority.pausedOrFailedEntryId) {
|
|
295
|
+
fail(chain, "unmatched_resume", entry.turnId, "resume authority does not name its own predecessor");
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
if (authority.action === "deliver" && chain.commit === null) {
|
|
299
|
+
fail(chain, "illegal_transition", entry.turnId, "resume to deliver requires a valid commit");
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
// Once the reduction has committed there is nothing left to reduce; the
|
|
303
|
+
// recovery path past a commit is delivery.
|
|
304
|
+
if (authority.action === "reduce" && chain.commit !== null) {
|
|
305
|
+
fail(chain, "illegal_transition", entry.turnId, "resume to reduce is not legal once a commit exists");
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
const answered = context.positions.get(authority.pausedOrFailedEntryId);
|
|
309
|
+
if (answered === undefined) {
|
|
310
|
+
fail(chain, "unmatched_resume", entry.turnId, "the paused or failed head this resume answers is not in the input");
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
const matched = context.authorities.find(
|
|
314
|
+
(candidate) =>
|
|
315
|
+
candidate.operatorRequestEntryId === authority.operatorRequestEntryId &&
|
|
316
|
+
candidate.handoffId === chain.handoffId &&
|
|
317
|
+
candidate.action === authority.action &&
|
|
318
|
+
candidate.pausedOrFailedEntryId === authority.pausedOrFailedEntryId &&
|
|
319
|
+
candidate.originSessionId === context.sessionId &&
|
|
320
|
+
candidate.position > answered &&
|
|
321
|
+
candidate.position < position,
|
|
322
|
+
);
|
|
323
|
+
if (!matched) {
|
|
324
|
+
fail(
|
|
325
|
+
chain,
|
|
326
|
+
"unvalidated_resume_authority",
|
|
327
|
+
entry.turnId,
|
|
328
|
+
"no durable operator control request binds this handoff, action, head and position",
|
|
329
|
+
);
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
if (!isUsableTimestamp(matched.acceptedAtMs)) {
|
|
333
|
+
fail(chain, "unvalidated_resume_authority", entry.turnId, "the operator request has no usable accepted time");
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
return matched;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Validate a resume that arrived through a summary carry.
|
|
341
|
+
*
|
|
342
|
+
* The references it names must resolve to real records in this input: the
|
|
343
|
+
* answered paused/failed head and the resume itself, with the operator request
|
|
344
|
+
* sitting strictly between them.
|
|
345
|
+
*
|
|
346
|
+
* The summary that carries the payload is **never** a stand-in for the resume's
|
|
347
|
+
* own position. A request recorded before that summary is not thereby before the
|
|
348
|
+
* resume the summary describes, and a carried payload is not evidence of when
|
|
349
|
+
* the events it describes were written. A phase name is not authority either:
|
|
350
|
+
* when a required reference cannot be resolved the transaction stays
|
|
351
|
+
* recall-only and publishes no carry or reconstruction.
|
|
352
|
+
*/
|
|
353
|
+
function carriedResumeAuthorized(
|
|
354
|
+
chain: ChainState,
|
|
355
|
+
entryId: string,
|
|
356
|
+
resume: { entryId: string; authority: HandoffResumeAuthority },
|
|
357
|
+
context: ApplyContext,
|
|
358
|
+
carriedPrevEntryId: string | null,
|
|
359
|
+
): OperatorResumeAuthorityEvidence | null {
|
|
360
|
+
const refuse = (detail: string): null => {
|
|
361
|
+
fail(chain, "unvalidated_resume_authority", entryId, detail);
|
|
362
|
+
return null;
|
|
363
|
+
};
|
|
364
|
+
// When the carried head is the resume itself, its link must answer the head
|
|
365
|
+
// its authority names; a request for an older pause authorizes nothing here.
|
|
366
|
+
if (carriedPrevEntryId !== null) {
|
|
367
|
+
if (carriedPrevEntryId !== resume.authority.pausedOrFailedEntryId) {
|
|
368
|
+
return refuse("the carried resume does not follow the head its authority answers");
|
|
369
|
+
}
|
|
370
|
+
// A carried resumed head is a live request, so it obeys the same action
|
|
371
|
+
// rule a real one does. A historical reduce resume that happened before
|
|
372
|
+
// the commit and is merely retained under a later head is not this case.
|
|
373
|
+
if (resume.authority.action === "reduce" && chain.commit !== null) {
|
|
374
|
+
return refuse("a carried resume to reduce is not legal once a commit exists");
|
|
375
|
+
}
|
|
376
|
+
if (resume.authority.action === "deliver" && chain.commit === null) {
|
|
377
|
+
return refuse("a carried resume to deliver requires a valid commit");
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// The referenced records are looked up and compared, not merely located by
|
|
381
|
+
// id. A carry that reuses a real resume's id while rewriting its authority to
|
|
382
|
+
// another genuine request names an event that never happened.
|
|
383
|
+
const resumeRecord = context.records.get(resume.entryId);
|
|
384
|
+
if (!resumeRecord || resumeRecord.event.phase !== "resumed") {
|
|
385
|
+
return refuse("the carried resume names no actual resumed record in this input");
|
|
386
|
+
}
|
|
387
|
+
if (resumeRecord.identity.handoffId !== chain.handoffId) {
|
|
388
|
+
return refuse("the referenced resumed record belongs to another handoff");
|
|
389
|
+
}
|
|
390
|
+
if (
|
|
391
|
+
canonicalJson(resumeRecord.event.authority) !== canonicalJson(resume.authority) ||
|
|
392
|
+
resumeRecord.transition.prevEntryId !== resume.authority.pausedOrFailedEntryId
|
|
393
|
+
) {
|
|
394
|
+
return refuse("the carried resume disagrees with the actual resumed record it names");
|
|
395
|
+
}
|
|
396
|
+
const answeredRecord = context.records.get(resume.authority.pausedOrFailedEntryId);
|
|
397
|
+
if (!answeredRecord || (answeredRecord.event.phase !== "paused" && answeredRecord.event.phase !== "failed")) {
|
|
398
|
+
return refuse("the head this carried resume answers is not an actual paused or failed record");
|
|
399
|
+
}
|
|
400
|
+
if (answeredRecord.identity.handoffId !== chain.handoffId) {
|
|
401
|
+
return refuse("the answered paused or failed record belongs to another handoff");
|
|
402
|
+
}
|
|
403
|
+
const answered = context.positions.get(resume.authority.pausedOrFailedEntryId);
|
|
404
|
+
const upper = context.positions.get(resume.entryId);
|
|
405
|
+
if (answered === undefined || upper === undefined || upper <= answered) {
|
|
406
|
+
return refuse("the carried resume does not sit after the head it answers");
|
|
407
|
+
}
|
|
408
|
+
const matched = context.authorities.find(
|
|
409
|
+
(candidate) =>
|
|
410
|
+
candidate.operatorRequestEntryId === resume.authority.operatorRequestEntryId &&
|
|
411
|
+
candidate.handoffId === chain.handoffId &&
|
|
412
|
+
candidate.action === resume.authority.action &&
|
|
413
|
+
candidate.pausedOrFailedEntryId === resume.authority.pausedOrFailedEntryId &&
|
|
414
|
+
candidate.originSessionId === context.sessionId &&
|
|
415
|
+
isUsableTimestamp(candidate.acceptedAtMs) &&
|
|
416
|
+
candidate.position > answered &&
|
|
417
|
+
candidate.position < upper,
|
|
418
|
+
);
|
|
419
|
+
if (!matched) {
|
|
420
|
+
return refuse("no durable operator control request binds this carried resume's handoff, action, head and order");
|
|
421
|
+
}
|
|
422
|
+
return matched;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function applyTransactionEvent(
|
|
426
|
+
chain: ChainState,
|
|
427
|
+
entry: HandoffTransactionEntry,
|
|
428
|
+
position: number,
|
|
429
|
+
context: ApplyContext,
|
|
430
|
+
): void {
|
|
431
|
+
const verdict = duplicateVerdict(chain, entry.turnId, canonicalJson(entry));
|
|
432
|
+
if (verdict === "identical") return;
|
|
433
|
+
if (verdict === "conflict") {
|
|
434
|
+
fail(chain, "duplicate_conflict", entry.turnId, `two different payloads share entry id ${entry.turnId}`);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (entry.event.phase === "prepared") {
|
|
439
|
+
if (!validSuccessor(chain, entry.turnId, "prepared", entry.transition)) return;
|
|
440
|
+
if (!adoptImmutable(chain, entry.turnId, entry.identity, entry.event.accepted, entry.event.policy)) return;
|
|
441
|
+
chain.rootSeen = true;
|
|
442
|
+
chain.rootPosition = position;
|
|
443
|
+
} else {
|
|
444
|
+
if (chain.identity === null) {
|
|
445
|
+
chain.identity = entry.identity;
|
|
446
|
+
} else if (canonicalJson(chain.identity) !== canonicalJson(entry.identity)) {
|
|
447
|
+
fail(chain, "duplicate_conflict", entry.turnId, "identity differs across records of one handoff");
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (!validSuccessor(chain, entry.turnId, entry.event.phase, entry.transition)) return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// A failure only reopens reduction for a provider failure, and only within
|
|
454
|
+
// the attempt budget. Every other failure reason is terminal for automatic
|
|
455
|
+
// work and needs an explicit resume.
|
|
456
|
+
if (entry.event.phase === "reducing" && headPhase(chain) === "failed") {
|
|
457
|
+
const failure = chain.head?.event as Extract<HandoffEvent, { phase: "failed" }>;
|
|
458
|
+
if (failure.reason !== "provider_failed") {
|
|
459
|
+
fail(chain, "illegal_transition", entry.turnId, `failed(${failure.reason}) may not resume reduction`);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (!resumeRefValid(chain, entry.turnId, entry.event)) return;
|
|
465
|
+
|
|
466
|
+
if (entry.event.phase === "resumed") {
|
|
467
|
+
const evidence = authorizeResume(chain, entry, entry.event.authority, position, context);
|
|
468
|
+
if (!evidence) return;
|
|
469
|
+
chain.activeResume = { entryId: entry.turnId, authority: entry.event.authority };
|
|
470
|
+
chain.resumeEvidence = evidence;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
chain.head = { transition: entry.transition, event: entry.event };
|
|
474
|
+
if (entry.event.phase === "delivered") {
|
|
475
|
+
chain.delivery = { deliveryId: entry.event.deliveryId, continuationTurnId: entry.event.continuationTurnId };
|
|
476
|
+
chain.deliveryPosition = position;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Apply a commit record. It is a validated successor of `reducing` like any
|
|
482
|
+
* other event, with one deliberate exception: a commit that exactly matches a
|
|
483
|
+
* commit a validated carry already described is the original being restored, so
|
|
484
|
+
* it records presence and leaves the head alone.
|
|
485
|
+
*/
|
|
486
|
+
function applyCommit(chain: ChainState, entry: ContinuityCommitEntry): void {
|
|
487
|
+
const payload = entry.continuity;
|
|
488
|
+
const verdict = duplicateVerdict(chain, entry.turnId, canonicalJson(entry));
|
|
489
|
+
if (verdict === "identical") {
|
|
490
|
+
chain.commitPresent = true;
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (verdict === "conflict") {
|
|
494
|
+
fail(chain, "duplicate_conflict", entry.turnId, `two different commit payloads share entry id ${entry.turnId}`);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (!adoptImmutable(chain, entry.turnId, payload.identity, payload.accepted, payload.policy)) return;
|
|
498
|
+
|
|
499
|
+
const known = chain.commit;
|
|
500
|
+
const restoresKnownCommit = known !== null && canonicalJson(known) === canonicalJson(payload.commit);
|
|
501
|
+
if (!adoptCommit(chain, entry.turnId, payload.commit)) return;
|
|
502
|
+
|
|
503
|
+
if (restoresKnownCommit) {
|
|
504
|
+
// The carry already proved this exact commit. Recording its presence is
|
|
505
|
+
// the whole effect; moving the head would regress the carried state.
|
|
506
|
+
chain.commitPresent = true;
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (!validSuccessor(chain, entry.turnId, "ready", payload.commit.transition)) return;
|
|
510
|
+
chain.commitPresent = true;
|
|
511
|
+
chain.head = { transition: payload.commit.transition, event: { phase: "ready" } };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Whether a carried head may advance past an observed one.
|
|
516
|
+
*
|
|
517
|
+
* The intervening records were cut, so adjacency cannot be checked; what can be
|
|
518
|
+
* checked is that the carry does not claim something the observed head rules
|
|
519
|
+
* out. Linear phases must move strictly forward, because there is no legal
|
|
520
|
+
* `ready -> ready` or `delivered -> delivered`; a terminal state is not
|
|
521
|
+
* reopened; a pause is left only through a resume; and a failure reopens
|
|
522
|
+
* reduction only when it was a provider failure.
|
|
523
|
+
*/
|
|
524
|
+
function carryMayAdvance(chain: ChainState, to: ContinuityPhase): boolean {
|
|
525
|
+
const from = headPhase(chain);
|
|
526
|
+
if (from === "absent") return true;
|
|
527
|
+
if (from === "acknowledged") return false;
|
|
528
|
+
if (from === "paused") return to === "resumed";
|
|
529
|
+
if (from === "failed") {
|
|
530
|
+
if (to === "resumed") return true;
|
|
531
|
+
if (to !== "reducing") return false;
|
|
532
|
+
const failure = chain.head?.event as Extract<HandoffEvent, { phase: "failed" }>;
|
|
533
|
+
return failure.reason === "provider_failed";
|
|
534
|
+
}
|
|
535
|
+
if (to === "paused" || to === "failed" || to === "resumed") return true;
|
|
536
|
+
return LIFECYCLE_RANK[to] > LIFECYCLE_RANK[from];
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Apply a summary-carried payload. The carry describes a chain whose middle was
|
|
541
|
+
* cut out of this projection, so it may be ahead of the observed head; it may
|
|
542
|
+
* never contradict what was observed, and at the same sequence it must be the
|
|
543
|
+
* same state.
|
|
544
|
+
*/
|
|
545
|
+
function applyCarry(
|
|
546
|
+
chain: ChainState,
|
|
547
|
+
payload: ContinuityCheckpointPayload,
|
|
548
|
+
entryId: string,
|
|
549
|
+
context: ApplyContext,
|
|
550
|
+
): void {
|
|
551
|
+
const verdict = duplicateVerdict(chain, entryId, canonicalJson(payload));
|
|
552
|
+
if (verdict === "identical") return;
|
|
553
|
+
if (verdict === "conflict") {
|
|
554
|
+
fail(chain, "conflicting_carry", entryId, `two different carried payloads share entry id ${entryId}`);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (!adoptImmutable(chain, entryId, payload.identity, payload.accepted, payload.policy)) return;
|
|
558
|
+
if (!adoptCommit(chain, entryId, payload.commit)) return;
|
|
559
|
+
|
|
560
|
+
if (chain.carry && payload.state.transition.sequence === chain.carry.state.transition.sequence) {
|
|
561
|
+
if (canonicalJson(chain.carry.state) !== canonicalJson(payload.state)) {
|
|
562
|
+
fail(chain, "conflicting_carry", entryId, "two carries disagree about the state at the same sequence");
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (chain.carry === null || payload.state.transition.sequence > chain.carry.state.transition.sequence) {
|
|
567
|
+
chain.carry = payload;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const head = chain.head;
|
|
571
|
+
const carriedPhase = payload.state.event.phase as ContinuityPhase;
|
|
572
|
+
let adjacent = false;
|
|
573
|
+
if (head) {
|
|
574
|
+
if (payload.state.transition.sequence === head.transition.sequence) {
|
|
575
|
+
// At the same position the two must be the same state: link, event and
|
|
576
|
+
// every retained field. A matching event under a different delivery or
|
|
577
|
+
// an invented resume is a conflicting copy, not an idempotent repeat.
|
|
578
|
+
const observed = {
|
|
579
|
+
transition: head.transition,
|
|
580
|
+
event: head.event,
|
|
581
|
+
delivery: chain.delivery,
|
|
582
|
+
activeResume: chain.activeResume,
|
|
583
|
+
};
|
|
584
|
+
const carried = {
|
|
585
|
+
transition: payload.state.transition,
|
|
586
|
+
event: payload.state.event,
|
|
587
|
+
delivery: payload.state.delivery,
|
|
588
|
+
activeResume: payload.state.activeResume,
|
|
589
|
+
};
|
|
590
|
+
if (canonicalJson(observed) !== canonicalJson(carried)) {
|
|
591
|
+
fail(chain, "conflicting_carry", entryId, "the carry contradicts the observed head at the same sequence");
|
|
592
|
+
}
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if (payload.state.transition.sequence < head.transition.sequence) return;
|
|
596
|
+
|
|
597
|
+
adjacent = payload.state.transition.sequence === head.transition.sequence + 1;
|
|
598
|
+
if (adjacent) {
|
|
599
|
+
// One step is one step whether a record or a carry describes it, so the
|
|
600
|
+
// real successor rules apply in full: exact predecessor, the attempt
|
|
601
|
+
// rule, and the legal phase.
|
|
602
|
+
if (!validSuccessor(chain, entryId, carriedPhase, payload.state.transition)) return;
|
|
603
|
+
} else {
|
|
604
|
+
if (!carryMayAdvance(chain, carriedPhase)) {
|
|
605
|
+
fail(chain, "fabricated_carry_extension", entryId, `a carry cannot move ${headPhase(chain)} to ${carriedPhase}`);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
// Attempts are journaled and never refunded, so a carry that advances
|
|
609
|
+
// the head while lowering the attempt count is another history.
|
|
610
|
+
if (payload.state.transition.attempt < head.transition.attempt) {
|
|
611
|
+
fail(chain, "invalid_attempt", entryId, "a carry cannot advance the head while refunding a spent attempt");
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// A carried resume is held to the same durable operator authority a real one
|
|
618
|
+
// is. Adopting the head first and checking later would let a phase name grant
|
|
619
|
+
// execution for a request that never existed.
|
|
620
|
+
const carriedResume = payload.state.activeResume;
|
|
621
|
+
if (carriedResume) {
|
|
622
|
+
const carriedPrev = carriedPhase === "resumed" ? payload.state.transition.prevEntryId : null;
|
|
623
|
+
const evidence = carriedResumeAuthorized(chain, entryId, carriedResume, context, carriedPrev);
|
|
624
|
+
if (!evidence) return;
|
|
625
|
+
chain.activeResume = carriedResume;
|
|
626
|
+
chain.resumeEvidence = evidence;
|
|
627
|
+
} else if (carriedPhase === "resumed") {
|
|
628
|
+
fail(chain, "unmatched_resume", entryId, "a carried resumed head retains no resume to validate");
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// The resume reference is checked against the head the carry follows, which
|
|
633
|
+
// is why it runs after the carried resume is in place and before the head
|
|
634
|
+
// moves on from it.
|
|
635
|
+
if (adjacent && !resumeRefValid(chain, entryId, payload.state.event)) return;
|
|
636
|
+
|
|
637
|
+
chain.head = { transition: payload.state.transition, event: payload.state.event };
|
|
638
|
+
if (payload.state.delivery) chain.delivery = payload.state.delivery;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function outcomeRefMatches(
|
|
642
|
+
commit: ContinuityCommitData,
|
|
643
|
+
identity: HandoffIdentity,
|
|
644
|
+
refs: ReadonlyArray<ContinuityOutcomeEvidence>,
|
|
645
|
+
): boolean {
|
|
646
|
+
if (commit.outcome === "continuity_only") return true;
|
|
647
|
+
const expectedId = commit.outcome === "summarized" ? commit.summaryRef : commit.evictionRef;
|
|
648
|
+
if (expectedId === undefined) return false;
|
|
649
|
+
return refs.some(
|
|
650
|
+
(ref) =>
|
|
651
|
+
ref.entryId === expectedId &&
|
|
652
|
+
ref.outcome === commit.outcome &&
|
|
653
|
+
ref.handoffId === identity.handoffId &&
|
|
654
|
+
ref.commitId === identity.commitId,
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Acknowledgement needs a terminal success that is durable **before** the
|
|
660
|
+
* acknowledgement and **after** the delivery it answers. Matching ids alone
|
|
661
|
+
* would accept a response recorded after the acknowledgement that cites it, or
|
|
662
|
+
* an older success for a newer delivery intent.
|
|
663
|
+
*
|
|
664
|
+
* Both bounds are required and both name real records in this input: the
|
|
665
|
+
* delivery intent's own position and the acknowledgement's own position. The
|
|
666
|
+
* summary that carried the state is never a stand-in for either, because a
|
|
667
|
+
* carried payload is not evidence of when those events were written. Without
|
|
668
|
+
* both bounds the order cannot be proven and the transaction stays recall-only.
|
|
669
|
+
*/
|
|
670
|
+
function acknowledgementSupported(
|
|
671
|
+
event: Extract<HandoffEvent, { phase: "acknowledged" }>,
|
|
672
|
+
delivery: { deliveryId: string; continuationTurnId: string } | null,
|
|
673
|
+
responses: ReadonlyArray<TerminalResponseEvidence>,
|
|
674
|
+
bounds: { deliveredAfter: number | null; acknowledgedBefore: number | null },
|
|
675
|
+
): boolean {
|
|
676
|
+
if (!delivery || delivery.deliveryId !== event.deliveryId) return false;
|
|
677
|
+
const { deliveredAfter, acknowledgedBefore } = bounds;
|
|
678
|
+
if (deliveredAfter === null || acknowledgedBefore === null) return false;
|
|
679
|
+
return responses.some(
|
|
680
|
+
(response) =>
|
|
681
|
+
response.entryId === event.terminalResponseEntryId &&
|
|
682
|
+
response.continuationTurnId === delivery.continuationTurnId &&
|
|
683
|
+
response.status === "success" &&
|
|
684
|
+
Number.isFinite(response.position) &&
|
|
685
|
+
response.position > deliveredAfter &&
|
|
686
|
+
response.position < acknowledgedBefore,
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* The window in force, clamped to §7's ceiling. A renewal is measured from when
|
|
692
|
+
* the operator control request was actually accepted, never from the deadline
|
|
693
|
+
* the record asks for: deriving the base backwards would let a far-future
|
|
694
|
+
* request renew itself without bound.
|
|
695
|
+
*/
|
|
696
|
+
function effectiveDeadline(
|
|
697
|
+
policy: HandoffPolicy,
|
|
698
|
+
resume: HandoffResumeAuthority | null,
|
|
699
|
+
evidence: OperatorResumeAuthorityEvidence | null,
|
|
700
|
+
): number {
|
|
701
|
+
if (resume && evidence) {
|
|
702
|
+
return Math.min(resume.automaticDeadlineAtMs, evidence.acceptedAtMs + HANDOFF_MAX_WINDOW_MS);
|
|
703
|
+
}
|
|
704
|
+
return Math.min(policy.automaticDeadlineAtMs, policy.preparedAtMs + HANDOFF_MAX_WINDOW_MS);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function priorProjection(chain: ChainState): ContinuityPriorHandoff {
|
|
708
|
+
return {
|
|
709
|
+
handoffId: chain.handoffId,
|
|
710
|
+
commitId: chain.identity?.commitId ?? "",
|
|
711
|
+
phase: headPhase(chain),
|
|
712
|
+
accepted: chain.accepted,
|
|
713
|
+
outcome: chain.commit?.outcome ?? null,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function applicable(identity: HandoffIdentity, input: ContinuityFoldInput, path: ReadonlySet<string>): boolean {
|
|
718
|
+
if (identity.originSessionId !== input.selection.sessionId) return false;
|
|
719
|
+
if (!path.has(identity.initiatingTurnId)) return false;
|
|
720
|
+
return identity.branchAnchorTurnId === null || path.has(identity.branchAnchorTurnId);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** A record that claims to be continuity but does not validate. */
|
|
724
|
+
function looksLikeContinuity(value: unknown): boolean {
|
|
725
|
+
if (typeof value !== "object" || value === null) return false;
|
|
726
|
+
const record = value as Record<string, unknown>;
|
|
727
|
+
if (record.kind === "handoffTransaction" || record.kind === "continuityCommit") return true;
|
|
728
|
+
return record.kind === "compactionSummary" && record.continuity !== undefined;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
export function foldContinuity(input: ContinuityFoldInput): ContinuityFoldResult {
|
|
732
|
+
const path = new Set(input.selection.pathTurnIds);
|
|
733
|
+
const chains = new Map<string, ChainState>();
|
|
734
|
+
const positions = new Map<string, number>();
|
|
735
|
+
const ownerByEntryId = new Map<string, string>();
|
|
736
|
+
/** Entry ids seen as real records here, so a duplicate cannot reorder them. */
|
|
737
|
+
const observed = new Set<string>();
|
|
738
|
+
const malformed: ContinuityAnomaly[] = [];
|
|
739
|
+
let inapplicable = 0;
|
|
740
|
+
|
|
741
|
+
const records = new Map<string, HandoffTransactionEntry>();
|
|
742
|
+
const context: ApplyContext = {
|
|
743
|
+
positions,
|
|
744
|
+
records,
|
|
745
|
+
authorities: input.evidence.resumeAuthorities,
|
|
746
|
+
sessionId: input.selection.sessionId,
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
const chainFor = (handoffId: string, position: number): ChainState => {
|
|
750
|
+
const existing = chains.get(handoffId);
|
|
751
|
+
if (existing) return existing;
|
|
752
|
+
const created = newChain(handoffId, position);
|
|
753
|
+
chains.set(handoffId, created);
|
|
754
|
+
return created;
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
// Entry ids are indexed first so a resume can be checked against the position
|
|
758
|
+
// of the head it answers regardless of scan order. The input is the full
|
|
759
|
+
// applicable ledger before the replay cut, so a record a reference names is
|
|
760
|
+
// present here or nowhere; there is no second coordinate system to reconcile.
|
|
761
|
+
for (let position = 0; position < input.entries.length; position += 1) {
|
|
762
|
+
const raw = input.entries[position];
|
|
763
|
+
if (isHandoffTransactionEntry(raw) || isContinuityCommitEntry(raw)) {
|
|
764
|
+
// First occurrence wins. An identical duplicate appended later must not
|
|
765
|
+
// reorder history and revoke authority that was already valid against
|
|
766
|
+
// the original position.
|
|
767
|
+
const entryId = (raw as { turnId: string }).turnId;
|
|
768
|
+
if (!observed.has(entryId)) {
|
|
769
|
+
observed.add(entryId);
|
|
770
|
+
positions.set(entryId, position);
|
|
771
|
+
if (isHandoffTransactionEntry(raw)) records.set(entryId, raw);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
for (let position = 0; position < input.entries.length; position += 1) {
|
|
777
|
+
const raw = input.entries[position];
|
|
778
|
+
const transaction = isHandoffTransactionEntry(raw) ? raw : null;
|
|
779
|
+
const commit = !transaction && isContinuityCommitEntry(raw) ? raw : null;
|
|
780
|
+
const carrying = !transaction && !commit && isContinuityCarryingSummary(raw) ? raw : null;
|
|
781
|
+
|
|
782
|
+
if (!transaction && !commit && !carrying) {
|
|
783
|
+
// An unrelated kind is inert. A record that claims to be continuity and
|
|
784
|
+
// does not validate is not: ignoring it silently would let malformed
|
|
785
|
+
// evidence read as missing evidence.
|
|
786
|
+
if (looksLikeContinuity(raw)) {
|
|
787
|
+
malformed.push({
|
|
788
|
+
kind: "malformed_continuity_record",
|
|
789
|
+
entryId: typeof (raw as { turnId?: unknown }).turnId === "string" ? (raw as { turnId: string }).turnId : null,
|
|
790
|
+
detail: "a record claiming a continuity kind failed structural validation",
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const identity = transaction?.identity ?? commit?.continuity.identity ?? carrying?.continuity.identity;
|
|
797
|
+
if (!identity || !applicable(identity, input, path)) {
|
|
798
|
+
inapplicable += 1;
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
const entryId = (transaction ?? commit ?? carrying)?.turnId as string;
|
|
802
|
+
const owner = ownerByEntryId.get(entryId);
|
|
803
|
+
if (owner !== undefined && owner !== identity.handoffId) {
|
|
804
|
+
const chain = chainFor(identity.handoffId, position);
|
|
805
|
+
fail(chain, "entry_id_reused", entryId, `entry id is also used by handoff ${owner}`);
|
|
806
|
+
const other = chains.get(owner);
|
|
807
|
+
if (other) fail(other, "entry_id_reused", entryId, `entry id is also used by handoff ${identity.handoffId}`);
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
ownerByEntryId.set(entryId, identity.handoffId);
|
|
811
|
+
|
|
812
|
+
const chain = chainFor(identity.handoffId, position);
|
|
813
|
+
if (transaction) applyTransactionEvent(chain, transaction, position, context);
|
|
814
|
+
else if (commit) applyCommit(chain, commit);
|
|
815
|
+
else if (carrying) applyCarry(chain, carrying.continuity, carrying.turnId, context);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const base: Pick<ContinuityFoldResult, "skipped"> = {
|
|
819
|
+
skipped: { inapplicable, unreadable: input.evidence.unreadableRecords },
|
|
820
|
+
};
|
|
821
|
+
if (chains.size === 0) {
|
|
822
|
+
return {
|
|
823
|
+
phase: "absent",
|
|
824
|
+
authority: "none",
|
|
825
|
+
identity: null,
|
|
826
|
+
accepted: null,
|
|
827
|
+
policy: null,
|
|
828
|
+
commit: null,
|
|
829
|
+
state: null,
|
|
830
|
+
attemptsSpent: 0,
|
|
831
|
+
effectiveDeadlineAtMs: null,
|
|
832
|
+
missingCommit: null,
|
|
833
|
+
validated: malformed.length === 0,
|
|
834
|
+
anomalies: malformed,
|
|
835
|
+
action: { kind: "none" },
|
|
836
|
+
priorHandoffs: [],
|
|
837
|
+
...base,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// Successive compaction cycles leave several transactions behind. The
|
|
842
|
+
// newest chain root is the selected transaction; the rest are history whose
|
|
843
|
+
// notes stay recallable and which authorize nothing here. A duplicate of an
|
|
844
|
+
// older prepared record never moves its root, so it cannot re-elect itself.
|
|
845
|
+
const ordered = [...chains.values()].sort((a, b) => a.rootPosition - b.rootPosition);
|
|
846
|
+
const selected = ordered[ordered.length - 1] as ChainState;
|
|
847
|
+
const priorHandoffs = ordered.slice(0, -1).map(priorProjection);
|
|
848
|
+
|
|
849
|
+
return finalize(selected, input, positions, records, malformed, priorHandoffs, inapplicable);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function finalize(
|
|
853
|
+
chain: ChainState,
|
|
854
|
+
input: ContinuityFoldInput,
|
|
855
|
+
positions: ReadonlyMap<string, number>,
|
|
856
|
+
records: ReadonlyMap<string, HandoffTransactionEntry>,
|
|
857
|
+
malformed: ReadonlyArray<ContinuityAnomaly>,
|
|
858
|
+
priorHandoffs: ReadonlyArray<ContinuityPriorHandoff>,
|
|
859
|
+
inapplicable: number,
|
|
860
|
+
): ContinuityFoldResult {
|
|
861
|
+
const anomalies: ContinuityAnomaly[] = [...malformed, ...chain.anomalies];
|
|
862
|
+
const unreadable = input.evidence.unreadableRecords;
|
|
863
|
+
const identity = chain.identity;
|
|
864
|
+
const policy = chain.policy;
|
|
865
|
+
let poisoned = chain.poisoned || malformed.length > 0;
|
|
866
|
+
|
|
867
|
+
// Unreadable records fail closed whether or not a commit is present: a line
|
|
868
|
+
// this projection could not parse can hide a pause, a conflicting duplicate,
|
|
869
|
+
// or the very evidence a reference needs. The note stays recallable; nothing
|
|
870
|
+
// authoritative is published from unresolved input.
|
|
871
|
+
if (unreadable > 0 && identity !== null) {
|
|
872
|
+
anomalies.push({
|
|
873
|
+
kind: "malformed_record",
|
|
874
|
+
entryId: null,
|
|
875
|
+
detail: `${unreadable} unreadable records leave this transaction's evidence unresolved`,
|
|
876
|
+
});
|
|
877
|
+
poisoned = true;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
if (chain.commit && identity && !outcomeRefMatches(chain.commit, identity, input.evidence.outcomeRefs)) {
|
|
881
|
+
anomalies.push({
|
|
882
|
+
kind: unreadable > 0 ? "missing_commit_ref" : "unbound_outcome_ref",
|
|
883
|
+
entryId: chain.commit.entry.turnId,
|
|
884
|
+
detail: `no ${chain.commit.outcome} record is bound to commit ${identity.commitId}`,
|
|
885
|
+
});
|
|
886
|
+
poisoned = true;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const phase = headPhase(chain);
|
|
890
|
+
if (phase === "acknowledged") {
|
|
891
|
+
const event = chain.head?.event as Extract<HandoffEvent, { phase: "acknowledged" }>;
|
|
892
|
+
// Both bounds come from actual records whose payloads are compared, not
|
|
893
|
+
// from ids that merely resolve. When the head arrived through a carry, the
|
|
894
|
+
// delivery intent is the acknowledgement's own predecessor link.
|
|
895
|
+
const ackEntryId = chain.head?.transition.entryId ?? "";
|
|
896
|
+
const deliveryEntryId = chain.head?.transition.prevEntryId ?? "";
|
|
897
|
+
const ackRecord = records.get(ackEntryId);
|
|
898
|
+
const deliveryRecord = records.get(deliveryEntryId);
|
|
899
|
+
const ackMatches =
|
|
900
|
+
ackRecord?.event.phase === "acknowledged" &&
|
|
901
|
+
ackRecord.identity.handoffId === chain.handoffId &&
|
|
902
|
+
ackRecord.event.deliveryId === event.deliveryId &&
|
|
903
|
+
ackRecord.event.terminalResponseEntryId === event.terminalResponseEntryId;
|
|
904
|
+
const deliveryMatches =
|
|
905
|
+
deliveryRecord?.event.phase === "delivered" &&
|
|
906
|
+
deliveryRecord.identity.handoffId === chain.handoffId &&
|
|
907
|
+
deliveryRecord.event.deliveryId === (chain.delivery?.deliveryId ?? null) &&
|
|
908
|
+
deliveryRecord.event.continuationTurnId === (chain.delivery?.continuationTurnId ?? null);
|
|
909
|
+
const ackBounds = {
|
|
910
|
+
deliveredAfter: deliveryMatches ? (chain.deliveryPosition ?? positions.get(deliveryEntryId) ?? null) : null,
|
|
911
|
+
acknowledgedBefore: ackMatches ? (positions.get(ackEntryId) ?? null) : null,
|
|
912
|
+
};
|
|
913
|
+
if (!acknowledgementSupported(event, chain.delivery, input.evidence.terminalResponses, ackBounds)) {
|
|
914
|
+
anomalies.push({
|
|
915
|
+
kind: "acknowledgement_unsupported",
|
|
916
|
+
entryId: chain.head?.transition.entryId ?? null,
|
|
917
|
+
detail: "no terminal successful response matches this delivery",
|
|
918
|
+
});
|
|
919
|
+
poisoned = true;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const state: ContinuityCarriedState | null = chain.head
|
|
924
|
+
? {
|
|
925
|
+
transition: chain.head.transition,
|
|
926
|
+
event: chain.head.event,
|
|
927
|
+
activeResume: chain.activeResume,
|
|
928
|
+
delivery: chain.delivery,
|
|
929
|
+
}
|
|
930
|
+
: null;
|
|
931
|
+
|
|
932
|
+
// Absence is concluded only from a complete, validated projection.
|
|
933
|
+
let missingCommit: MissingCommitReconstruction | null = null;
|
|
934
|
+
if (!chain.commitPresent && chain.carry && identity && state && !poisoned) {
|
|
935
|
+
{
|
|
936
|
+
const original = chain.carry.commit;
|
|
937
|
+
const rebuilt: ContinuityCommitEntry = {
|
|
938
|
+
kind: "continuityCommit",
|
|
939
|
+
turnId: original.entry.turnId,
|
|
940
|
+
parentTurnId: original.entry.parentTurnId,
|
|
941
|
+
timestamp: original.entry.timestamp,
|
|
942
|
+
continuity: {
|
|
943
|
+
schemaVersion: HANDOFF_SCHEMA_VERSION,
|
|
944
|
+
identity,
|
|
945
|
+
accepted: chain.carry.accepted,
|
|
946
|
+
policy: chain.carry.policy,
|
|
947
|
+
commit: original,
|
|
948
|
+
state: { transition: original.transition, event: { phase: "ready" }, activeResume: null, delivery: null },
|
|
949
|
+
},
|
|
950
|
+
};
|
|
951
|
+
// Never publish a reconstruction this module would refuse to read.
|
|
952
|
+
if (isContinuityCheckpointPayload(rebuilt.continuity)) {
|
|
953
|
+
missingCommit = { entry: rebuilt, carriedState: state };
|
|
954
|
+
} else {
|
|
955
|
+
anomalies.push({
|
|
956
|
+
kind: "conflicting_carry",
|
|
957
|
+
entryId: original.entry.turnId,
|
|
958
|
+
detail: "the reconstructed commit does not validate",
|
|
959
|
+
});
|
|
960
|
+
poisoned = true;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const attemptsSpent = chain.head?.transition.attempt ?? 0;
|
|
966
|
+
// A clock that sits before preparation, or before the operator request a
|
|
967
|
+
// renewal is measured from, cannot be used to decide a window. Either way
|
|
968
|
+
// the operator recovers; the fold never silently grants time.
|
|
969
|
+
const renewalBase = chain.resumeEvidence?.acceptedAtMs ?? null;
|
|
970
|
+
const clockAmbiguous =
|
|
971
|
+
!Number.isFinite(input.nowMs) ||
|
|
972
|
+
(policy !== null && input.nowMs < policy.preparedAtMs) ||
|
|
973
|
+
(renewalBase !== null && input.nowMs < renewalBase);
|
|
974
|
+
const deadline = policy
|
|
975
|
+
? effectiveDeadline(policy, chain.activeResume?.authority ?? null, chain.resumeEvidence)
|
|
976
|
+
: null;
|
|
977
|
+
const expired = deadline !== null && !clockAmbiguous && input.nowMs >= deadline;
|
|
978
|
+
|
|
979
|
+
const action = resolveAction({
|
|
980
|
+
historical: input.selection.historical,
|
|
981
|
+
poisoned,
|
|
982
|
+
phase,
|
|
983
|
+
clockAmbiguous,
|
|
984
|
+
expired,
|
|
985
|
+
missingCommit,
|
|
986
|
+
attemptsSpent,
|
|
987
|
+
policy,
|
|
988
|
+
delivery: chain.delivery,
|
|
989
|
+
deliveryPosition: chain.deliveryPosition,
|
|
990
|
+
responses: input.evidence.terminalResponses,
|
|
991
|
+
committed: chain.commit !== null,
|
|
992
|
+
resumeAction: chain.activeResume?.authority.action ?? null,
|
|
993
|
+
});
|
|
994
|
+
// Authority follows the action, so the two can never disagree about whether
|
|
995
|
+
// automatic work may proceed. A transaction whose records were all rejected
|
|
996
|
+
// still has an identity and a note, so it is recall-only rather than absent.
|
|
997
|
+
const authority: ContinuityAuthority =
|
|
998
|
+
identity === null
|
|
999
|
+
? "none"
|
|
1000
|
+
: action.kind === "none" && phase !== "absent" && phase !== "acknowledged"
|
|
1001
|
+
? "execution"
|
|
1002
|
+
: "recall_only";
|
|
1003
|
+
|
|
1004
|
+
return {
|
|
1005
|
+
phase,
|
|
1006
|
+
authority,
|
|
1007
|
+
identity,
|
|
1008
|
+
accepted: chain.accepted,
|
|
1009
|
+
policy,
|
|
1010
|
+
commit: chain.commit,
|
|
1011
|
+
state,
|
|
1012
|
+
attemptsSpent,
|
|
1013
|
+
effectiveDeadlineAtMs: deadline,
|
|
1014
|
+
missingCommit,
|
|
1015
|
+
validated: !poisoned,
|
|
1016
|
+
anomalies,
|
|
1017
|
+
action,
|
|
1018
|
+
priorHandoffs,
|
|
1019
|
+
skipped: { inapplicable, unreadable },
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function resolveAction(facts: {
|
|
1024
|
+
historical: boolean;
|
|
1025
|
+
poisoned: boolean;
|
|
1026
|
+
phase: ContinuityPhase;
|
|
1027
|
+
clockAmbiguous: boolean;
|
|
1028
|
+
expired: boolean;
|
|
1029
|
+
missingCommit: MissingCommitReconstruction | null;
|
|
1030
|
+
attemptsSpent: number;
|
|
1031
|
+
policy: HandoffPolicy | null;
|
|
1032
|
+
delivery: { deliveryId: string; continuationTurnId: string } | null;
|
|
1033
|
+
deliveryPosition: number | null;
|
|
1034
|
+
responses: ReadonlyArray<TerminalResponseEvidence>;
|
|
1035
|
+
committed: boolean;
|
|
1036
|
+
resumeAction: "reduce" | "deliver" | null;
|
|
1037
|
+
}): ContinuityRecoveryAction {
|
|
1038
|
+
// Disputed evidence is reported before anything else, including an empty
|
|
1039
|
+
// head: a chain whose only records were rejected has evidence to explain,
|
|
1040
|
+
// not nothing to say.
|
|
1041
|
+
if (facts.poisoned) return { kind: "recall_only", reason: "conflicting_evidence" };
|
|
1042
|
+
if (facts.phase === "absent") return { kind: "none" };
|
|
1043
|
+
if (facts.phase === "acknowledged") return { kind: "none" };
|
|
1044
|
+
// An inherited note is recallable in any phase; a fresh explicit request is
|
|
1045
|
+
// what establishes ownership in the new session.
|
|
1046
|
+
if (facts.historical) return { kind: "recall_only", reason: "historical_fork" };
|
|
1047
|
+
if (facts.phase === "paused") return { kind: "recall_only", reason: "paused" };
|
|
1048
|
+
if (facts.phase === "failed") return { kind: "recall_only", reason: "failed" };
|
|
1049
|
+
if (facts.clockAmbiguous) return { kind: "recall_only", reason: "clock_ambiguous" };
|
|
1050
|
+
if (facts.expired) return { kind: "fail", reason: "deadline_exceeded" };
|
|
1051
|
+
|
|
1052
|
+
if (facts.phase === "delivered") {
|
|
1053
|
+
// An older success for an earlier delivery does not resolve this one, and
|
|
1054
|
+
// an intent whose own position was cut cannot be ordered against anything.
|
|
1055
|
+
const supported =
|
|
1056
|
+
facts.deliveryPosition !== null &&
|
|
1057
|
+
facts.responses.some(
|
|
1058
|
+
(response) =>
|
|
1059
|
+
response.continuationTurnId === facts.delivery?.continuationTurnId &&
|
|
1060
|
+
response.status === "success" &&
|
|
1061
|
+
Number.isFinite(response.position) &&
|
|
1062
|
+
response.position > (facts.deliveryPosition as number),
|
|
1063
|
+
);
|
|
1064
|
+
return supported ? { kind: "none" } : { kind: "pause", reason: "delivery_uncertain" };
|
|
1065
|
+
}
|
|
1066
|
+
if (facts.missingCommit) return { kind: "reconstruct_commit", reconstruction: facts.missingCommit };
|
|
1067
|
+
// The attempt budget bounds reductions, not deliveries. Delivering a commit
|
|
1068
|
+
// the final attempt already produced, under an explicit operator request,
|
|
1069
|
+
// spends no attempt and is not a third reduction.
|
|
1070
|
+
const wouldReduce = !(facts.committed && facts.phase === "resumed" && facts.resumeAction === "deliver");
|
|
1071
|
+
if (facts.policy && facts.phase !== "ready" && wouldReduce && facts.attemptsSpent >= facts.policy.maxAttempts) {
|
|
1072
|
+
return { kind: "recall_only", reason: "attempts_exhausted" };
|
|
1073
|
+
}
|
|
1074
|
+
return { kind: "none" };
|
|
1075
|
+
}
|