@iowarp/clio-coder 0.3.2 → 0.3.4

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.
Files changed (259) hide show
  1. package/CHANGELOG.md +269 -458
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.md +3 -3
  4. package/dist/{acp-BIYHVZIM.js → acp-S5R4RR5B.js} +7 -6
  5. package/dist/{agents-YT6SSRIT.js → agents-P6DMMVZY.js} +24 -21
  6. package/dist/assets/codewiki.json +1 -1
  7. package/dist/{auth-5TWEIYDN.js → auth-2XCZLPKS.js} +12 -8
  8. package/dist/{chunk-GGXXDWE4.js → chunk-22NAGB7X.js} +2 -2
  9. package/dist/{chunk-WMSVI4G2.js → chunk-2LZI5CAG.js} +133 -13
  10. package/dist/{chunk-OAO4GE4M.js → chunk-2TZWSW76.js} +2 -2
  11. package/dist/{chunk-OOJYHWRB.js → chunk-34475P3I.js} +2 -2
  12. package/dist/{chunk-WVO7V2QY.js → chunk-35MKKU5R.js} +4 -4
  13. package/dist/{chunk-LBNRH5WM.js → chunk-3HZ5RWN2.js} +5 -5
  14. package/dist/{chunk-AGYYIBLL.js → chunk-3JLKSKD7.js} +2 -2
  15. package/dist/{chunk-MBS4V7ZP.js → chunk-4JUF2NNX.js} +7 -7
  16. package/dist/{chunk-ZDOOVTXZ.js → chunk-4OC57DA6.js} +27 -4
  17. package/dist/chunk-5M54SPOL.js +926 -0
  18. package/dist/{chunk-STBPMHSX.js → chunk-7RXG6QRZ.js} +51 -11
  19. package/dist/{chunk-77VKQEHF.js → chunk-A2GZF7DC.js} +5 -5
  20. package/dist/{chunk-A3CYT5EX.js → chunk-AD2SYQYC.js} +55 -2
  21. package/dist/chunk-AOCYTWAV.js +449 -0
  22. package/dist/chunk-BEY543CS.js +258 -0
  23. package/dist/{chunk-6N5PTWMY.js → chunk-BP4OYD6A.js} +32 -13
  24. package/dist/chunk-BPGS2WCQ.js +612 -0
  25. package/dist/{chunk-J5HN4RYU.js → chunk-BRXQQJFP.js} +8 -8
  26. package/dist/chunk-CFGTUFWB.js +67 -0
  27. package/dist/{chunk-CBCAPZAA.js → chunk-E25LMLRW.js} +2 -2
  28. package/dist/{chunk-G2DE3C7R.js → chunk-EDRHSCIE.js} +4 -4
  29. package/dist/{chunk-4KLWL3UC.js → chunk-EFADSJET.js} +2 -2
  30. package/dist/{chunk-M6SHUN7Q.js → chunk-FO5ZOVUY.js} +2 -2
  31. package/dist/chunk-FYYLNIL5.js +313 -0
  32. package/dist/{chunk-EPVUXGXG.js → chunk-HV5X7OR2.js} +14 -12
  33. package/dist/{chunk-TZTZS7QK.js → chunk-HXG4IURW.js} +5 -3
  34. package/dist/{chunk-IGLFWIYI.js → chunk-K6WL7QZT.js} +3 -3
  35. package/dist/chunk-K7VKOLQQ.js +15 -0
  36. package/dist/{chunk-BMEMKKIT.js → chunk-KOHPCX4K.js} +2 -2
  37. package/dist/{chunk-V4RXGQ5Q.js → chunk-KRPY7NTG.js} +10 -7
  38. package/dist/chunk-LL4KHSZI.js +22 -0
  39. package/dist/{chunk-KJ5LWLOE.js → chunk-MEQ45TQ4.js} +15 -9
  40. package/dist/{chunk-5UUP6MWO.js → chunk-MV3K5QF2.js} +5 -436
  41. package/dist/{chunk-AO4RKG4M.js → chunk-N4CZJQRK.js} +5 -5
  42. package/dist/{chunk-ARBGF5F7.js → chunk-NILBFAPG.js} +14 -8
  43. package/dist/chunk-OZNBF4L3.js +23 -0
  44. package/dist/{verify-G6V4D2G7.js → chunk-PCZJO5TI.js} +127 -42
  45. package/dist/chunk-QQK64KLB.js +1360 -0
  46. package/dist/{chunk-6EJV5X2W.js → chunk-QQL5RT5M.js} +979 -1619
  47. package/dist/{chunk-LZSJBIVT.js → chunk-QWU7ZBO7.js} +70 -720
  48. package/dist/{chunk-2EHAIA3X.js → chunk-RD5U66HV.js} +3 -3
  49. package/dist/{chunk-OKGUZO2U.js → chunk-SPULKLCF.js} +4 -3
  50. package/dist/{chunk-OQ33BKR3.js → chunk-TTNYS3EA.js} +3 -60
  51. package/dist/chunk-TW3WDMVS.js +677 -0
  52. package/dist/chunk-TZSKNMZG.js +434 -0
  53. package/dist/{chunk-7MNJORFF.js → chunk-UL3WSD3F.js} +6 -1
  54. package/dist/{chunk-7EYHLWU7.js → chunk-UZHIZC5S.js} +7 -7
  55. package/dist/{chunk-QTYWRVRA.js → chunk-VAWWTKDP.js} +8 -8
  56. package/dist/{chunk-X75S7HFS.js → chunk-VEZEGCGW.js} +214 -20
  57. package/dist/{chunk-OHHN2SO4.js → chunk-VMNQ6OZA.js} +98 -202
  58. package/dist/chunk-VSNATDE6.js +122 -0
  59. package/dist/chunk-W6GROXXM.js +69 -0
  60. package/dist/chunk-WPQLXFOZ.js +375 -0
  61. package/dist/{chunk-ORBHGJC5.js → chunk-WR67VIZY.js} +3 -3
  62. package/dist/{chunk-3ZXDFGR5.js → chunk-X6COSD2O.js} +5 -5
  63. package/dist/chunk-ZGVHUX3M.js +66 -0
  64. package/dist/{chunk-MAW544W2.js → chunk-ZWMF7253.js} +4 -4
  65. package/dist/{chunk-MQSRRFWA.js → chunk-ZYKPLLNQ.js} +563 -546
  66. package/dist/cli/index.js +27 -23
  67. package/dist/{clio-4LY5K2AC.js → clio-J5JIOIDS.js} +7 -6
  68. package/dist/{code-nav-7AX6FYE6.js → code-nav-AXCXSBHX.js} +5 -3
  69. package/dist/{config-GTLUW2PR.js → config-OEBMIN2U.js} +37 -27
  70. package/dist/{configure-R6A64DHX.js → configure-PUQOSIXQ.js} +16 -13
  71. package/dist/{context-5VKGUVJJ.js → context-EKDCKUUZ.js} +82 -7
  72. package/dist/{context-RW5HC47S.js → context-MGSE4Z2T.js} +33 -23
  73. package/dist/{context-JFZEJ7W5.js → context-URSXPBCK.js} +17 -9
  74. package/dist/{context-clear-6ZHBAZZT.js → context-clear-KDAJRNUK.js} +33 -23
  75. package/dist/context-working-set-SBKMPPI2.js +1552 -0
  76. package/dist/{dispatch-runner-VKBRCWQC.js → dispatch-runner-MSWN72NK.js} +43 -29
  77. package/dist/{doctor-KI767GSN.js → doctor-7BSE27PJ.js} +10 -10
  78. package/dist/{eval-XSSNATB4.js → eval-IZGDOO4H.js} +9 -8
  79. package/dist/{evidence-UA6AWDQQ.js → evidence-SR7WXB5B.js} +51 -23
  80. package/dist/{evolve-QNTFGV6Z.js → evolve-K7VE2CBX.js} +30 -20
  81. package/dist/{fleet-Q7UOMUSG.js → fleet-7XMJNQNF.js} +48 -38
  82. package/dist/{fleet-preflight-DDN536IT.js → fleet-preflight-AQNAH644.js} +3 -3
  83. package/dist/{init-WBB65ZHQ.js → init-JGNPAYXT.js} +41 -31
  84. package/dist/{memory-MD3O64RI.js → memory-4ALKDJ4Q.js} +32 -22
  85. package/dist/{models-BZU34YWD.js → models-ZMMLFJNN.js} +22 -19
  86. package/dist/{monitor-MEQA5C3I.js → monitor-2F3T5KHP.js} +55 -43
  87. package/dist/{orchestrator-CGFKEP27.js → orchestrator-ORHT43JB.js} +2507 -1896
  88. package/dist/{reset-L2FQEE3E.js → reset-NXGTYNUO.js} +4 -3
  89. package/dist/{run-IV4Q6RLN.js → run-RF4WJGMT.js} +51 -41
  90. package/dist/{share-S5BZQC5I.js → share-UT3W6E4M.js} +5 -4
  91. package/dist/{skills-LQEKRDTN.js → skills-PSACKC5Q.js} +2 -2
  92. package/dist/{skills-eval-3DC4HEWS.js → skills-eval-WJSI55RZ.js} +34 -24
  93. package/dist/{targets-C4SSGQOB.js → targets-PIIRAOYS.js} +23 -20
  94. package/dist/{terminal-lease-IT5JW2NR.js → terminal-lease-ULWXWNVY.js} +5 -4
  95. package/dist/{upgrade-7TT7SQ3G.js → upgrade-346TZ6AV.js} +18 -17
  96. package/dist/{usage-GV4PKT3M.js → usage-6KKXR32N.js} +34 -24
  97. package/dist/verifiers-4UUM6TEE.js +1214 -0
  98. package/dist/verify-X5HDROLA.js +25 -0
  99. package/dist/{wiki-generate-DQF6Z66B.js → wiki-generate-7STOCIFZ.js} +42 -31
  100. package/dist/worker/entry.js +33 -24
  101. package/docs/README.md +8 -7
  102. package/docs/acp.md +1 -1
  103. package/docs/alcf-provider.md +1 -1
  104. package/docs/architecture.md +2 -2
  105. package/docs/artifact-versions.md +1 -1
  106. package/docs/built-in-agents.md +1 -1
  107. package/docs/capacity-and-scheduling.md +1 -1
  108. package/docs/commands-and-modes.md +53 -21
  109. package/docs/config-knobs-audit.md +1 -2
  110. package/docs/configuration-and-targets.md +15 -1
  111. package/docs/context-engine.md +64 -12
  112. package/docs/context-working-set.md +194 -0
  113. package/docs/development-pipeline.md +1 -1
  114. package/docs/documentation-coverage.md +5 -5
  115. package/docs/documentation-guide.md +6 -5
  116. package/docs/environment-variables.md +2 -1
  117. package/docs/eval-runner.md +1 -1
  118. package/docs/evals-internal.md +14 -1
  119. package/docs/evidence-and-memory.md +74 -2
  120. package/docs/evolution.md +1 -1
  121. package/docs/exit-codes-and-output.md +1 -1
  122. package/docs/extensions-and-sharing.md +2 -2
  123. package/docs/fleet-dispatch.md +22 -7
  124. package/docs/glossary.md +21 -1
  125. package/docs/installation-and-lifecycle.md +6 -6
  126. package/docs/middleware-and-components.md +1 -1
  127. package/docs/model-catalog.md +7 -9
  128. package/docs/observability.md +4 -4
  129. package/docs/performance-methodology.md +2 -2
  130. package/docs/proactive-memory.md +1 -1
  131. package/docs/prompt-envelope-and-tools.md +4 -4
  132. package/docs/provider-adapter-cookbook.md +1 -1
  133. package/docs/release-cut-checklist.md +35 -35
  134. package/docs/safety-model.md +23 -4
  135. package/docs/scientific-validation.md +21 -3
  136. package/docs/session-lifecycle.md +3 -3
  137. package/docs/skills-marketplace.md +1 -1
  138. package/docs/tool-usage.md +79 -12
  139. package/docs/trace-store.md +1 -1
  140. package/docs/troubleshooting.md +1 -1
  141. package/docs/tui-design.md +2 -2
  142. package/docs/worker-dispatch-mechanics.md +11 -1
  143. package/package.json +8 -11
  144. package/skills/meta/clio-test/SKILL.md +20 -17
  145. package/skills/meta/clio-test/evals.md +3 -3
  146. package/skills/meta/clio-test/references/harness.md +35 -6
  147. package/skills/meta/clio-test/references/test-map.md +20 -10
  148. package/skills/registry.yaml +2 -2
  149. package/skills/skill-marketplace.json +1 -1
  150. package/src/cli/context-working-set.ts +513 -0
  151. package/src/cli/context.ts +8 -0
  152. package/src/cli/evidence.ts +20 -2
  153. package/src/cli/index.ts +4 -0
  154. package/src/cli/verifiers.ts +325 -0
  155. package/src/core/bash-exec.ts +39 -14
  156. package/src/core/bus-events.ts +19 -4
  157. package/src/core/config.ts +54 -0
  158. package/src/core/defaults.ts +50 -3
  159. package/src/core/git-commit-attribution.ts +46 -21
  160. package/src/core/verification-scripts.ts +6 -0
  161. package/src/domains/agents/builtins/verifier.md +3 -0
  162. package/src/domains/config/classify.ts +1 -0
  163. package/src/domains/config/keybindings.ts +3 -3
  164. package/src/domains/context/working-set/contract.ts +161 -0
  165. package/src/domains/context/working-set/defaults.ts +28 -0
  166. package/src/domains/context/working-set/engine.ts +203 -0
  167. package/src/domains/context/working-set/fold.ts +62 -0
  168. package/src/domains/context/working-set/horizon.ts +38 -0
  169. package/src/domains/context/working-set/marker.ts +103 -0
  170. package/src/domains/context/working-set/path-index.ts +436 -0
  171. package/src/domains/context/working-set/payload.ts +152 -0
  172. package/src/domains/context/working-set/policies/age-horizon.ts +55 -0
  173. package/src/domains/context/working-set/policies/index.ts +21 -0
  174. package/src/domains/context/working-set/policies/structural.ts +160 -0
  175. package/src/domains/context/working-set/project.ts +132 -0
  176. package/src/domains/context/working-set/protect.ts +109 -0
  177. package/src/domains/context/working-set/recall.ts +177 -0
  178. package/src/domains/context/working-set/replay/controls.ts +112 -0
  179. package/src/domains/context/working-set/replay/load-clio.ts +199 -0
  180. package/src/domains/context/working-set/replay/metrics.ts +185 -0
  181. package/src/domains/context/working-set/replay/reference-graph.ts +79 -0
  182. package/src/domains/context/working-set/replay/report.ts +139 -0
  183. package/src/domains/context/working-set/replay/runner.ts +325 -0
  184. package/src/domains/context/working-set/replay/synthetic.ts +422 -0
  185. package/src/domains/context/working-set/replay/trace.ts +21 -0
  186. package/src/domains/context/working-set/visible.ts +54 -0
  187. package/src/domains/evidence/build.ts +112 -45
  188. package/src/domains/evidence/eval.ts +24 -7
  189. package/src/domains/evidence/index.ts +53 -0
  190. package/src/domains/evidence/ordering.ts +12 -0
  191. package/src/domains/evidence/run-trust.ts +221 -0
  192. package/src/domains/evidence/store.ts +46 -6
  193. package/src/domains/evidence/trust-status.ts +854 -0
  194. package/src/domains/evidence/types.ts +26 -0
  195. package/src/domains/middleware/memory-intervention.ts +3 -0
  196. package/src/domains/middleware/stalled-turn.ts +165 -4
  197. package/src/domains/safety/autonomy.ts +1 -1
  198. package/src/domains/safety/default-path-policy.ts +8 -0
  199. package/src/domains/safety/finish-contract.ts +4 -3
  200. package/src/domains/safety/policy-engine.ts +48 -6
  201. package/src/domains/session/compaction/compact.ts +23 -1
  202. package/src/domains/session/compaction/cut-point.ts +2 -0
  203. package/src/domains/session/compaction/tokens.ts +16 -1
  204. package/src/domains/session/context-ledger.ts +2 -0
  205. package/src/domains/session/entries.ts +107 -1
  206. package/src/domains/session/manager.ts +9 -2
  207. package/src/domains/session/migrations/index.ts +22 -3
  208. package/src/engine/acp/server.ts +3 -0
  209. package/src/engine/agent.ts +18 -1
  210. package/src/engine/session.ts +9 -3
  211. package/src/entry/orchestrator.ts +16 -4
  212. package/src/interactive/chat-loop-messages.ts +18 -6
  213. package/src/interactive/chat-panel.ts +571 -244
  214. package/src/interactive/chat-renderer.ts +79 -39
  215. package/src/interactive/context-meter.ts +10 -0
  216. package/src/interactive/context-overlay.ts +81 -6
  217. package/src/interactive/context-recall-command.ts +110 -0
  218. package/src/interactive/editor-submit.ts +26 -1
  219. package/src/interactive/footer/widgets.ts +22 -20
  220. package/src/interactive/footer-panel.ts +6 -1
  221. package/src/interactive/interactive-application.ts +2 -0
  222. package/src/interactive/interactive-event-projection.ts +12 -0
  223. package/src/interactive/interactive-slash-runtime.ts +49 -8
  224. package/src/interactive/model-session-replay.ts +21 -0
  225. package/src/interactive/overlay-general-openers.ts +6 -0
  226. package/src/interactive/overlay-session-lifecycle.ts +8 -4
  227. package/src/interactive/overlays/ask-user.ts +146 -24
  228. package/src/interactive/renderers/tool-execution.ts +167 -56
  229. package/src/interactive/session-transcript.ts +2 -2
  230. package/src/interactive/slash-commands.ts +29 -2
  231. package/src/interactive/status/index.ts +12 -1
  232. package/src/interactive/status/reasoning.ts +87 -0
  233. package/src/interactive/status/summary.ts +13 -2
  234. package/src/interactive/transcript-detail.ts +120 -0
  235. package/src/interactive/turn-context.ts +238 -88
  236. package/src/interactive/turn-middleware.ts +6 -6
  237. package/src/tools/agent-tools.ts +11 -4
  238. package/src/tools/bash.ts +144 -82
  239. package/src/tools/builtin-tool-catalog.ts +18 -6
  240. package/src/tools/context/index.ts +105 -3
  241. package/src/tools/context/surface.ts +3 -2
  242. package/src/tools/core-bootstrap.ts +21 -0
  243. package/src/tools/dispatch-runner.ts +9 -7
  244. package/src/tools/monitor.ts +28 -20
  245. package/src/tools/presentation.ts +107 -0
  246. package/src/tools/registry.ts +65 -7
  247. package/src/tools/result-disposition.ts +550 -0
  248. package/src/tools/result-shaping.ts +262 -19
  249. package/src/tools/safe-exec.ts +2 -0
  250. package/src/tools/verify/authoring.ts +1119 -0
  251. package/src/tools/verify/catalog.ts +346 -0
  252. package/src/tools/verify/index.ts +13 -3
  253. package/src/tools/verify/scripts.ts +135 -37
  254. package/src/tools/verify/surface.ts +9 -5
  255. package/src/tools/worker-evidence.ts +35 -12
  256. package/dist/chunk-MNA4JGU4.js +0 -255
  257. package/dist/chunk-SRF2PJNW.js +0 -184
  258. package/dist/chunk-T6YILFSB.js +0 -80
  259. package/dist/chunk-VAKQQHWR.js +0 -434
@@ -4,6 +4,7 @@
4
4
  * exist. Users edit the file directly or through TUI overlays.
5
5
  */
6
6
 
7
+ import { DEFAULT_WORKING_SET_SETTINGS } from "../domains/context/working-set/defaults.js";
7
8
  import type { TargetDescriptor } from "../domains/providers/types/target-descriptor.js";
8
9
  import type { AutonomyLevel } from "../domains/safety/autonomy.js";
9
10
  import { GUARDRAIL_DEFAULTS, type GuardrailValues } from "./guardrails.js";
@@ -98,6 +99,32 @@ export interface CompactionSettings {
98
99
  systemPrompt?: string;
99
100
  }
100
101
 
102
+ /**
103
+ * Working-set layer settings (`context.workingSet`). The layer decides which
104
+ * tool-result bodies and thinking blocks leave the model's working set when
105
+ * pressure crosses `compaction.threshold`; it records evictions as ledger
106
+ * entries and never rewrites history. Defaults and prose live in
107
+ * src/domains/context/working-set/defaults.ts.
108
+ *
109
+ * - enabled: master switch. Off skips eviction and goes straight to summary
110
+ * compaction (the legacy destructive mask is only reachable through
111
+ * CLIO_CODER_LEGACY_MASK=1).
112
+ * - policy: candidate selection rule set.
113
+ * - target: used/window ratio an applied event batches down to.
114
+ * - protectLastTurns: recent user turns whose observations are never evicted.
115
+ * - minEvictableTokens: results below this estimate are never evicted; the
116
+ * marker would cost more than it saves.
117
+ */
118
+ export type WorkingSetPolicyId = "age-horizon" | "structural-v1";
119
+
120
+ export interface WorkingSetSettings {
121
+ enabled: boolean;
122
+ policy: WorkingSetPolicyId;
123
+ target: number;
124
+ protectLastTurns: number;
125
+ minEvictableTokens: number;
126
+ }
127
+
101
128
  /**
102
129
  * Transient provider retry controls for the interactive chat loop. These are
103
130
  * intentionally small and mirror the session retry helper defaults. Dispatched
@@ -357,6 +384,9 @@ export const DEFAULT_SETTINGS = {
357
384
  threshold: 0.8,
358
385
  excludeLastTurns: 6,
359
386
  } as CompactionSettings,
387
+ context: {
388
+ workingSet: DEFAULT_WORKING_SET_SETTINGS,
389
+ },
360
390
  retry: {
361
391
  enabled: true,
362
392
  maxRetries: 3,
@@ -610,9 +640,9 @@ keybindings: {}
610
640
  # auto master switch for the pre-request compaction trigger.
611
641
  # Manual /context compact always runs the LLM summary.
612
642
  # threshold pressure = estimated_tokens / context_window. Crossing
613
- # it masks stale tool observations first, then runs a
614
- # full LLM summary if pressure stays above the threshold.
615
- # excludeLastTurns recent user turns protected from observation masking.
643
+ # it evicts from the working set first, then runs a full
644
+ # LLM summary if pressure stays above the threshold.
645
+ # excludeLastTurns recent turns protected only by the temporary legacy mask.
616
646
  # model optional pattern (e.g. provider/summary-model-id) for a
617
647
  # dedicated summarization model. Absent ⇒ orchestrator target.
618
648
  # systemPrompt optional path to a prompt-override file.
@@ -623,6 +653,23 @@ compaction:
623
653
  # model: provider/summary-model-id
624
654
  # systemPrompt: ~/.config/clio-coder/prompts/compaction.md
625
655
 
656
+ # Non-destructive working-set eviction before summary compaction.
657
+ # enabled false skips eviction and goes directly to the summary stage.
658
+ # policy structural-v1 evicts by what the session did since
659
+ # (re-reads, edits, resolved failures, consumed listings)
660
+ # and falls back to age only under pressure;
661
+ # age-horizon is the previous age-based selection.
662
+ # target pressure ratio an applied eviction batches down to.
663
+ # protectLastTurns recent user turns whose observations remain in the working set.
664
+ # minEvictableTokens entries below this estimate remain in the working set.
665
+ context:
666
+ workingSet:
667
+ enabled: true
668
+ policy: structural-v1
669
+ target: 0.6
670
+ protectLastTurns: 6
671
+ minEvictableTokens: 200
672
+
626
673
  # Transient provider/stream retry controls for interactive chat.
627
674
  # Retryable errors include overloads, rate limits, 5xx responses, network
628
675
  # resets, and timeouts. Context overflow uses compaction recovery instead.
@@ -1,5 +1,15 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import {
3
+ accessSync,
4
+ chmodSync,
5
+ existsSync,
6
+ constants as fsConstants,
7
+ mkdirSync,
8
+ readFileSync,
9
+ renameSync,
10
+ rmSync,
11
+ writeFileSync,
12
+ } from "node:fs";
3
13
  import { join, resolve } from "node:path";
4
14
  import { AI_AGENT_NAME } from "./agent-environment.js";
5
15
  import { CLIO_COMMIT_TRAILERS, type CommitAttributionEvidence } from "./commit-attribution.js";
@@ -30,7 +40,7 @@ const PROBE_CACHE_MAX_ENTRIES = 32;
30
40
  type RepositoryProbe =
31
41
  | { kind: "outside" }
32
42
  | { kind: "custom"; hooksPath: string }
33
- | { kind: "default"; declaredDefault: boolean };
43
+ | { kind: "default"; declaredDefaultHooksPath: string | null };
34
44
 
35
45
  const probeCache = new Map<string, { at: number; probe: RepositoryProbe }>();
36
46
  let installedHooksDirectory: string | null = null;
@@ -171,27 +181,29 @@ case "$base_count" in
171
181
  ;;
172
182
  esac
173
183
 
174
- # A command may have moved into a different repository after Clio prepared the
175
- # environment. A repository with its own core.hooksPath gets its hook run and
176
- # is never attributed; composing an unknown setup is not attempted.
177
- if [ "$${DEFAULT_HOOKS_EQUIVALENT_ENV}" != '1' ] && git config --get core.hooksPath >/dev/null 2>&1; then
178
- custom_hooks=$(git config --path --get core.hooksPath 2>/dev/null || true)
184
+ # Resolve the current repository's default hooks directory before interpreting
185
+ # the environment prepared for the originating repository. A command may have
186
+ # moved into a different repository in between; an explicit default path is
187
+ # composable only while it still names this same repository's default.
188
+ common_dir=$(git rev-parse --git-common-dir 2>/dev/null || true)
189
+ common_dir_absolute=$(cd "$common_dir" 2>/dev/null && pwd -P || true)
190
+ case "$common_dir_absolute" in
191
+ '') default_hooks_directory=; default_hook= ;;
192
+ *) default_hooks_directory=$common_dir_absolute/hooks; default_hook=$default_hooks_directory/$hook_name ;;
193
+ esac
194
+
195
+ if git config --get core.hooksPath >/dev/null 2>&1 &&
196
+ [ "$${DEFAULT_HOOKS_EQUIVALENT_ENV}" != "$default_hooks_directory" ]; then
197
+ custom_hooks=$(git config --path --get core.hooksPath 2>/dev/null || true)
179
198
  case "$custom_hooks" in
180
199
  '') ;;
181
200
  /*) custom_hook=$custom_hooks/$hook_name ;;
182
201
  *) custom_hook=$PWD/$custom_hooks/$hook_name ;;
183
202
  esac
184
203
  if [ -n "$custom_hooks" ] && [ -x "$custom_hook" ]; then exec "$custom_hook" "$@"; fi
185
- exit 0
204
+ exit 0
186
205
  fi
187
206
 
188
- common_dir=$(git rev-parse --git-common-dir 2>/dev/null || true)
189
- case "$common_dir" in
190
- '') default_hook= ;;
191
- /*) default_hook=$common_dir/hooks/$hook_name ;;
192
- *) default_hook=$PWD/$common_dir/hooks/$hook_name ;;
193
- esac
194
-
195
207
  if [ "$hook_name" != 'prepare-commit-msg' ]; then
196
208
  if [ -n "$default_hook" ] && [ -x "$default_hook" ]; then exec "$default_hook" "$@"; fi
197
209
  exit 0
@@ -258,11 +270,22 @@ function installManagedHook(directory: string, name: string): void {
258
270
  }
259
271
  }
260
272
 
273
+ function managedHooksDirectoryIsIntact(directory: string): boolean {
274
+ try {
275
+ for (const name of MANAGED_HOOK_NAMES) {
276
+ const hook = join(directory, name);
277
+ if (readFileSync(hook, "utf8") !== MANAGED_HOOK_SCRIPT) return false;
278
+ accessSync(hook, fsConstants.X_OK);
279
+ }
280
+ return true;
281
+ } catch {
282
+ return false;
283
+ }
284
+ }
285
+
261
286
  function managedHooksDirectory(): string {
262
287
  const directory = join(clioStateDir(), "git-hooks", `v${MANAGED_HOOK_VERSION}`);
263
- // Installed once per process; one stat afterwards confirms the Clio-owned
264
- // directory is still there rather than re-reading all of its wrappers.
265
- if (installedHooksDirectory === directory && existsSync(join(directory, "prepare-commit-msg"))) return directory;
288
+ if (installedHooksDirectory === directory && managedHooksDirectoryIsIntact(directory)) return directory;
266
289
  mkdirSync(directory, { recursive: true, mode: 0o700 });
267
290
  for (const name of MANAGED_HOOK_NAMES) installManagedHook(directory, name);
268
291
  installedHooksDirectory = directory;
@@ -281,13 +304,13 @@ function gitEnvironmentFingerprint(env: NodeJS.ProcessEnv): string {
281
304
  function probeRepositoryUncached(cwd: string, env: NodeJS.ProcessEnv): RepositoryProbe {
282
305
  if (gitOutput(cwd, env, ["rev-parse", "--is-inside-work-tree"]) !== "true") return { kind: "outside" };
283
306
  const customHooksPath = gitOutput(cwd, env, ["config", "--path", "--get", "core.hooksPath"]);
284
- if (customHooksPath === null) return { kind: "default", declaredDefault: false };
307
+ if (customHooksPath === null) return { kind: "default", declaredDefaultHooksPath: null };
285
308
  const commonDirectory = gitOutput(cwd, env, ["rev-parse", "--git-common-dir"]);
286
309
  const defaultHooksPath = commonDirectory === null ? null : resolve(cwd, commonDirectory, "hooks");
287
310
  if (customHooksPath.length === 0 || resolve(cwd, customHooksPath) !== defaultHooksPath) {
288
311
  return { kind: "custom", hooksPath: customHooksPath };
289
312
  }
290
- return { kind: "default", declaredDefault: true };
313
+ return { kind: "default", declaredDefaultHooksPath: defaultHooksPath };
291
314
  }
292
315
 
293
316
  function probeRepository(cwd: string, env: NodeJS.ProcessEnv, now = Date.now()): RepositoryProbe {
@@ -342,7 +365,9 @@ export function withManagedGitCommitAttributionEnvironment(
342
365
  diagnostic: boundedDiagnostic(`core.hooksPath is set to '${probe.hooksPath}'; Clio commit attribution skipped`),
343
366
  };
344
367
  }
345
- if (probe.declaredDefault) env[DEFAULT_HOOKS_EQUIVALENT_ENV] = "1";
368
+ if (probe.declaredDefaultHooksPath !== null) {
369
+ env[DEFAULT_HOOKS_EQUIVALENT_ENV] = probe.declaredDefaultHooksPath;
370
+ }
346
371
 
347
372
  let hooksDirectory: string;
348
373
  try {
@@ -1,11 +1,17 @@
1
1
  export const VERIFICATION_SCRIPT_FAMILY_HINT = "test*/lint*/build*/typecheck*/check*/format*/ci*";
2
2
 
3
3
  const VERIFICATION_SCRIPT_PATTERN = /^(?:test|lint|build|typecheck|check|format|ci)(?:[:.-].*)?$/;
4
+ const PROJECT_VERIFIER_CHECK_ID_PATTERN = /^[a-z0-9][a-z0-9._:-]*$/;
4
5
 
5
6
  export function isVerificationScriptName(name: string): boolean {
6
7
  return VERIFICATION_SCRIPT_PATTERN.test(name);
7
8
  }
8
9
 
10
+ /** Stable identifier grammar for project-declared verifier catalog checks. */
11
+ export function isProjectVerifierCheckId(name: string): boolean {
12
+ return PROJECT_VERIFIER_CHECK_ID_PATTERN.test(name);
13
+ }
14
+
9
15
  export function declaredVerificationScripts(scripts: Record<string, unknown>): string[] {
10
16
  return Object.keys(scripts)
11
17
  .filter((name) => isVerificationScriptName(name))
@@ -24,6 +24,9 @@ Inspect scripts, docs, recent diffs, and touched files before choosing commands.
24
24
  When a codewiki exists, prefer `code_nav` (symbol, dependents) over broad reads to scope what a diff touches.
25
25
  Run only the checks required for the requested confidence level.
26
26
  Prefer typed validation tools over arbitrary shell execution.
27
+ Call `verify()` before choosing an executable check. It lists package scripts and strict project entries from `.clio-coder/verifiers.yaml` through the same metadata shape.
28
+ Run a project entry only by its listed ID. Do not add args, cwd, timeout, environment, or shell composition; the catalog's exact argv, cwd, and timeout are authoritative.
29
+ Treat scientific validation contracts and generated handbook expectations as advisory evidence requirements. They do not become executable until the project declares a corresponding verifier-catalog entry.
27
30
  Do not edit source files, tests, docs, configs, or generated artifacts from this role.
28
31
  When a gate fails, report the exact command, exit status, relevant error lines, and likely owner.
29
32
  Distinguish pre-existing failures from introduced failures when the evidence allows.
@@ -56,6 +56,7 @@ const NEXT_TURN_FIELDS = new Set<string>([
56
56
  "skills",
57
57
  "delegation",
58
58
  "compaction",
59
+ "context",
59
60
  "retry",
60
61
  ]);
61
62
 
@@ -122,14 +122,14 @@ export const CLIO_APP_KEYBINDINGS = {
122
122
  "clio.tool.expand": {
123
123
  defaultKeys: "alt+o",
124
124
  description:
125
- "Fold or unfold the newest tool call or worker block between its one-line summary and full details (no effect while /output verbose pins them open)",
125
+ "Fold or unfold the newest tool call or worker block between its one-line summary and full details, overriding the /output level for that block",
126
126
  },
127
127
  "clio.tool.expandAll": {
128
128
  // Alt+Shift+letter is commonly consumed by OS keyboard-layout switching.
129
129
  // Keep it discoverable, but pair it with the legacy-safe Ctrl+Alt form.
130
130
  defaultKeys: ["ctrl+alt+o", "alt+shift+o"],
131
131
  description:
132
- "Fold or unfold every tool call and worker block at once (no effect while /output verbose pins them open)",
132
+ "Fold or unfold every tool call and worker block at once, overriding the /output level; changing /output clears the overrides",
133
133
  },
134
134
  "clio.tool.liveOutput": {
135
135
  defaultKeys: "alt+p",
@@ -145,7 +145,7 @@ export const CLIO_APP_KEYBINDINGS = {
145
145
  // Alt+Shift+R as a distinct key event.
146
146
  defaultKeys: ["ctrl+alt+r", "alt+shift+r"],
147
147
  description:
148
- "Toggle all thinking blocks between hidden markers and full bodies (no effect while /output verbose pins them open)",
148
+ "Toggle all thinking blocks between hidden markers and full bodies, overriding the /output level; changing /output clears the overrides",
149
149
  },
150
150
  "clio.editor.external": {
151
151
  defaultKeys: "alt+g",
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Working-set layer contract.
3
+ *
4
+ * The working set is what the model sees on the next request. The ledger is
5
+ * durable, append-only truth. This layer decides which tool-result bodies and
6
+ * thinking blocks leave the working set, records that decision as
7
+ * `contextEviction` entries, and applies it as an in-memory projection when
8
+ * the replay messages are built. Nothing here rewrites the ledger and nothing
9
+ * here calls a model.
10
+ *
11
+ * Shared by the live engine and the replay-lite runner by construction: a
12
+ * policy is a pure function of `PolicyInput`, so the same selection runs in
13
+ * both places. Widening these types is an owner decision; workers build
14
+ * against them.
15
+ */
16
+
17
+ import type { WorkingSetPolicyId, WorkingSetSettings } from "../../../core/defaults.js";
18
+ import type {
19
+ ContextEvictionEntry,
20
+ ContextRecallEntry,
21
+ EvictedItem,
22
+ EvictionReason,
23
+ EvictionTrigger,
24
+ RecallTrigger,
25
+ SessionEntry,
26
+ WorkingSetRef,
27
+ } from "../../session/entries.js";
28
+
29
+ export { EVICTION_REASONS, EVICTION_TRIGGERS, RECALL_TRIGGERS } from "../../session/entries.js";
30
+ export type {
31
+ ContextEvictionEntry,
32
+ ContextRecallEntry,
33
+ EvictedItem,
34
+ EvictionReason,
35
+ EvictionTrigger,
36
+ RecallTrigger,
37
+ WorkingSetPolicyId,
38
+ WorkingSetRef,
39
+ WorkingSetSettings,
40
+ };
41
+
42
+ /**
43
+ * What the fold knows about one evicted unit. Keyed in `WorkingSetView.evicted`
44
+ * by the entry turnId (`ref.entry`); `fold.ts` owns `refKey` / `parseRefKey`.
45
+ */
46
+ export interface EvictedState {
47
+ reason: EvictionReason;
48
+ marker: string;
49
+ by?: string;
50
+ tokensFreed: number;
51
+ /** turnId of the `contextEviction` entry that evicted it (the latest one, after churn). */
52
+ evictedAtTurnId: string;
53
+ policyId: string;
54
+ }
55
+
56
+ /**
57
+ * The fold of every `contextEviction` / `contextRecall` entry on the active
58
+ * path. A recall does not remove its key: the recalled body lives in the
59
+ * recall tool result at the tail of the working set, the marker stays at the
60
+ * original position so the prefix cache is untouched, and repeated recalls of
61
+ * one ref are the churn signal.
62
+ */
63
+ export interface WorkingSetView {
64
+ evicted: ReadonlyMap<string, EvictedState>;
65
+ /** Applied eviction events on the active path. */
66
+ evictionEvents: number;
67
+ /** Items evicted across all events, including re-evictions after recall. */
68
+ itemsEvicted: number;
69
+ /** Recall entries on the active path. `churn = recalls / itemsEvicted`. */
70
+ recalls: number;
71
+ /** Policy that produced the most recent event; null when none. */
72
+ lastPolicyId: string | null;
73
+ /** turnId of the most recent eviction event; null when none. */
74
+ lastEvictionTurnId: string | null;
75
+ }
76
+
77
+ export const EMPTY_WORKING_SET_VIEW: WorkingSetView = Object.freeze({
78
+ evicted: new Map<string, EvictedState>(),
79
+ evictionEvents: 0,
80
+ itemsEvicted: 0,
81
+ recalls: 0,
82
+ lastPolicyId: null,
83
+ lastEvictionTurnId: null,
84
+ });
85
+
86
+ export interface PressureInput {
87
+ /** Estimated tokens in the current working set (projected), same estimator as the live pressure check. */
88
+ tokens: number;
89
+ contextWindow: number;
90
+ /** `compaction.threshold`. */
91
+ threshold: number;
92
+ /** `context.workingSet.target`: the ratio an applied event batches down to. */
93
+ target: number;
94
+ }
95
+
96
+ /**
97
+ * Everything a policy may look at. `entries` are the active-path entries the
98
+ * model can currently see: after the latest `compactionSummary` cut, in ledger
99
+ * order, as `selectVisibleEntries` in visible.ts produces them. They are NOT
100
+ * projected: a policy must consult `view.evicted` to skip units that are
101
+ * already out. The view is folded over the full active path, so a ref evicted
102
+ * before a later compaction is still known. Token counts enter selection only
103
+ * through `settings.minEvictableTokens` and the headroom arithmetic against
104
+ * `pressure.target`; no rule may rank candidates by size or recency score.
105
+ */
106
+ export interface PolicyInput {
107
+ entries: ReadonlyArray<SessionEntry>;
108
+ view: WorkingSetView;
109
+ /** Session working directory for the path index; null when unknown (paths stay relative). */
110
+ cwd: string | null;
111
+ settings: WorkingSetSettings;
112
+ pressure: PressureInput;
113
+ /** chars/4 estimator shared with `context-accounting.ts`, so replay and live agree. */
114
+ estimateTokens: (entry: SessionEntry) => number;
115
+ }
116
+
117
+ /** A unit the policy wants out, with the typed reason. Ordered: apply in this order, stop when headroom is met. */
118
+ export interface EvictionCandidate {
119
+ ref: WorkingSetRef;
120
+ reason: EvictionReason;
121
+ by?: string;
122
+ }
123
+
124
+ export interface WorkingSetPolicy {
125
+ readonly id: WorkingSetPolicyId;
126
+ /**
127
+ * Select candidates. Must be pure and deterministic for a given input.
128
+ * Returns an empty array when nothing qualifies. Units already in
129
+ * `input.view.evicted` must not be returned.
130
+ */
131
+ select(input: PolicyInput): ReadonlyArray<EvictionCandidate>;
132
+ }
133
+
134
+ /** Materialized selection: markers rendered, tokens estimated, ready to become a ledger entry. */
135
+ export interface EvictionPlan {
136
+ policyId: WorkingSetPolicyId;
137
+ items: ReadonlyArray<EvictedItem>;
138
+ tokensBefore: number;
139
+ tokensAfter: number;
140
+ }
141
+
142
+ /** Fields the caller adds when appending the plan as a ledger entry. */
143
+ export type ContextEvictionFields = Omit<ContextEvictionEntry, "turnId" | "parentTurnId" | "timestamp">;
144
+ export type ContextRecallFields = Omit<ContextRecallEntry, "turnId" | "parentTurnId" | "timestamp">;
145
+
146
+ /** Typed failure for recall by ref. `recallErrorMessage` lists the refs that are evicted beside it. */
147
+ export type RecallError =
148
+ | { kind: "not_on_active_path"; ref: string }
149
+ | { kind: "not_evicted"; ref: string }
150
+ | { kind: "invalid_ref"; ref: string };
151
+
152
+ export interface RecallResult {
153
+ ref: WorkingSetRef;
154
+ /** The ledger entry whose body is readmitted. */
155
+ entry: SessionEntry;
156
+ /** Exact original body as the projection would have rendered it before eviction. */
157
+ body: string;
158
+ tokens: number;
159
+ /** Present when the original result was offloaded; recall returns the pointer, never the file. */
160
+ offloadPath?: string;
161
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Working-set settings: user-visible defaults. The structural type lives in
3
+ * `src/core/defaults.ts` beside the rest of the settings tree so core stays
4
+ * free of a backward domain dependency; this module pairs it with the value
5
+ * the DEFAULT_SETTINGS tree and the engine read at runtime.
6
+ *
7
+ * `structural-v1` is the default: typed path-keyed rules first, the age rule
8
+ * last and batched to `target`. On the reproducible 24-trace procedural grid,
9
+ * it meets the recorded default rule at 32k, 64k, and 128k: retention is no
10
+ * lower than `age-horizon`, precision is higher than random, and the target
11
+ * stop remains active above 32k. `age-horizon` stays available as the exact
12
+ * pre-layer selection recorded through the ledger.
13
+ */
14
+
15
+ import type { WorkingSetSettings } from "../../../core/defaults.js";
16
+
17
+ export type { WorkingSetPolicyId, WorkingSetSettings } from "../../../core/defaults.js";
18
+
19
+ export const DEFAULT_WORKING_SET_SETTINGS: WorkingSetSettings = {
20
+ enabled: true,
21
+ policy: "structural-v1",
22
+ target: 0.6,
23
+ protectLastTurns: 6,
24
+ // The procedural floor sweep found marker break-even near 50 tokens. A zero
25
+ // floor saved only 0.167 summaries at 64k and none at 128k while reducing
26
+ // covered retention by 0.0076 and 0.0237. Keep 200 as the churn guard.
27
+ minEvictableTokens: 200,
28
+ };
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Turn a policy's selection into something the session can append.
3
+ *
4
+ * `planEviction` is the whole decision: it asks the policy what should leave,
5
+ * materializes each candidate into an `EvictedItem` (marker rendered, tokens
6
+ * measured), and prices the result against the projection the model will
7
+ * actually receive. It writes nothing and calls no model, so the live engine
8
+ * and the replay-lite runner drive it identically.
9
+ *
10
+ * `buildEvictionFields` is the boring half: the plan plus the trigger facts,
11
+ * shaped as the ledger entry minus the three fields the session owns
12
+ * (`turnId`, `parentTurnId`, `timestamp`).
13
+ */
14
+
15
+ import type { EvictedItem, SessionEntry } from "../../session/entries.js";
16
+ import type {
17
+ ContextEvictionFields,
18
+ EvictedState,
19
+ EvictionCandidate,
20
+ EvictionPlan,
21
+ EvictionTrigger,
22
+ PolicyInput,
23
+ WorkingSetPolicy,
24
+ WorkingSetView,
25
+ } from "./contract.js";
26
+ import { refKey } from "./fold.js";
27
+ import { renderMarker } from "./marker.js";
28
+ import { callPathsByToolCallId } from "./path-index.js";
29
+ import { hasThinking, offloadPathOf, primaryPathOf, toolResultPayload, toolResultText } from "./payload.js";
30
+ import { projectWorkingSet } from "./project.js";
31
+
32
+ /** Paths of the tool calls whose results may be evicted: `callPathsByToolCallId` over the policy input. */
33
+ export type CallPaths = ReadonlyMap<string, string>;
34
+ const NO_CALL_PATHS: CallPaths = new Map();
35
+
36
+ /**
37
+ * The event has no turnId until `session.appendEntry` gives it one, and the
38
+ * projection reads only `reason` and `marker`, so plan-time states carry this
39
+ * placeholder rather than a fabricated id.
40
+ */
41
+ const PENDING_EVENT_TURN_ID = "";
42
+
43
+ /**
44
+ * The stub that replaces this unit's body, or null when there is nothing to
45
+ * evict. Thinking eviction renders no marker at all: the reasoning simply
46
+ * stops being replayed.
47
+ */
48
+ function markerFor(entry: SessionEntry, candidate: EvictionCandidate, callPaths: CallPaths): string | null {
49
+ if (entry.kind !== "message") return null;
50
+ if (entry.role === "assistant") return hasThinking(entry.payload) ? "" : null;
51
+ if (entry.role !== "tool_result") return null;
52
+ const payload = toolResultPayload(entry.payload);
53
+ const toolCallId = typeof payload.obj.toolCallId === "string" ? payload.obj.toolCallId : undefined;
54
+ return renderMarker({
55
+ ref: candidate.ref,
56
+ reason: candidate.reason,
57
+ by: candidate.by,
58
+ toolName: payload.toolName,
59
+ text: toolResultText(payload.result),
60
+ offloadPath: offloadPathOf(payload),
61
+ path: primaryPathOf(payload) ?? (toolCallId === undefined ? undefined : callPaths.get(toolCallId)),
62
+ });
63
+ }
64
+
65
+ function pendingState(candidate: EvictionCandidate, marker: string, policyId: string): EvictedState {
66
+ return {
67
+ reason: candidate.reason,
68
+ marker,
69
+ ...(candidate.by === undefined ? {} : { by: candidate.by }),
70
+ tokensFreed: 0,
71
+ evictedAtTurnId: PENDING_EVENT_TURN_ID,
72
+ policyId,
73
+ };
74
+ }
75
+
76
+ /** A view holding exactly one item, for pricing that item on its own. */
77
+ function soloView(key: string, state: EvictedState): WorkingSetView {
78
+ return {
79
+ evicted: new Map([[key, state]]),
80
+ // Zero events: pricing one body must not also stamp usage invalidation,
81
+ // which would put the cost of a different mechanism in this item's total.
82
+ evictionEvents: 0,
83
+ itemsEvicted: 1,
84
+ recalls: 0,
85
+ lastPolicyId: null,
86
+ lastEvictionTurnId: null,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * The view this plan would produce. `evictionEvents` and `lastEvictionTurnId`
92
+ * stay where they were on purpose: both totals are then measured under the same
93
+ * usage-invalidation state, so `tokensBefore - tokensAfter` is exactly the
94
+ * bodies this event removes and nothing else.
95
+ */
96
+ function viewWithItems(view: WorkingSetView, items: ReadonlyArray<EvictedItem>, policyId: string): WorkingSetView {
97
+ const evicted = new Map(view.evicted);
98
+ for (const item of items) {
99
+ evicted.set(refKey(item.ref), {
100
+ reason: item.reason,
101
+ marker: item.marker,
102
+ ...(item.by === undefined ? {} : { by: item.by }),
103
+ tokensFreed: item.tokensFreed,
104
+ evictedAtTurnId: PENDING_EVENT_TURN_ID,
105
+ policyId,
106
+ });
107
+ }
108
+ return { ...view, evicted, itemsEvicted: view.itemsEvicted + items.length };
109
+ }
110
+
111
+ function sumTokens(entries: ReadonlyArray<SessionEntry>, estimate: (entry: SessionEntry) => number): number {
112
+ let total = 0;
113
+ for (const entry of entries) total += estimate(entry);
114
+ return total;
115
+ }
116
+
117
+ /**
118
+ * What one candidate takes out of the working set: the entry as it stands now
119
+ * minus the entry as the projection would render it. Zero when the candidate
120
+ * does not apply to the entry, and never negative, because a marker longer than
121
+ * the body it replaces is a bad trade, not a negative saving.
122
+ *
123
+ * Exported so a policy can do headroom arithmetic (`structural-v1` rung 6 needs
124
+ * to know when to stop) against the same numbers `planEviction` will record.
125
+ * A policy that priced evictions its own way would report headroom the ledger
126
+ * then contradicts. Pass the same `callPaths` the plan will use, or the marker
127
+ * priced here is a few bytes shorter than the one recorded.
128
+ */
129
+ export function tokensFreedByEviction(
130
+ estimateTokens: (entry: SessionEntry) => number,
131
+ entry: SessionEntry,
132
+ candidate: EvictionCandidate,
133
+ callPaths: CallPaths = NO_CALL_PATHS,
134
+ ): number {
135
+ const marker = markerFor(entry, candidate, callPaths);
136
+ if (marker === null) return 0;
137
+ const key = refKey(candidate.ref);
138
+ const projected = projectWorkingSet([entry], soloView(key, pendingState(candidate, marker, "")))[0] ?? entry;
139
+ return Math.max(0, estimateTokens(entry) - estimateTokens(projected));
140
+ }
141
+
142
+ export function planEviction(policy: WorkingSetPolicy, input: PolicyInput): EvictionPlan | null {
143
+ const candidates = policy.select(input);
144
+ if (candidates.length === 0) return null;
145
+
146
+ const byTurnId = new Map<string, SessionEntry>();
147
+ for (const entry of input.entries) byTurnId.set(entry.turnId, entry);
148
+ const callPaths = callPathsByToolCallId(input.entries);
149
+
150
+ const items: EvictedItem[] = [];
151
+ const claimed = new Set<string>();
152
+ for (const candidate of candidates) {
153
+ const key = refKey(candidate.ref);
154
+ // A policy is contractually forbidden from returning a unit that is
155
+ // already out, and a duplicate inside one selection would double-count
156
+ // the tokens it frees. Both are cheap to refuse here.
157
+ if (input.view.evicted.has(key) || claimed.has(key)) continue;
158
+ const entry = byTurnId.get(key);
159
+ if (entry === undefined) continue;
160
+ const marker = markerFor(entry, candidate, callPaths);
161
+ if (marker === null) continue;
162
+ // A marker at least as long as the body it replaces is a cold turn bought
163
+ // for nothing, whatever the policy's reason. Refused here so no policy can
164
+ // record an eviction that freed nothing.
165
+ const tokensFreed = tokensFreedByEviction(input.estimateTokens, entry, candidate, callPaths);
166
+ if (tokensFreed <= 0) continue;
167
+ claimed.add(key);
168
+ items.push({
169
+ ref: candidate.ref,
170
+ reason: candidate.reason,
171
+ tokensFreed,
172
+ marker,
173
+ ...(candidate.by === undefined ? {} : { by: candidate.by }),
174
+ });
175
+ }
176
+ if (items.length === 0) return null;
177
+
178
+ return {
179
+ policyId: policy.id,
180
+ items,
181
+ tokensBefore: sumTokens(projectWorkingSet(input.entries, input.view), input.estimateTokens),
182
+ tokensAfter: sumTokens(
183
+ projectWorkingSet(input.entries, viewWithItems(input.view, items, policy.id)),
184
+ input.estimateTokens,
185
+ ),
186
+ };
187
+ }
188
+
189
+ export function buildEvictionFields(
190
+ plan: EvictionPlan,
191
+ meta: { trigger: EvictionTrigger; pressureBefore: number | null; snapshotIdBefore: string | null },
192
+ ): ContextEvictionFields {
193
+ return {
194
+ kind: "contextEviction",
195
+ policyId: plan.policyId,
196
+ trigger: meta.trigger,
197
+ evicted: plan.items,
198
+ tokensBefore: plan.tokensBefore,
199
+ tokensAfter: plan.tokensAfter,
200
+ pressureBefore: meta.pressureBefore,
201
+ snapshotIdBefore: meta.snapshotIdBefore,
202
+ };
203
+ }