create-egregore 0.21.6 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (719) hide show
  1. package/assets/egregore-launcher.js +58 -6
  2. package/assets/egregore-launcher.sh +15 -2
  3. package/lib/claude-runtime.js +2 -0
  4. package/lib/codex-runtime.js +2 -0
  5. package/lib/setup.js +20 -3
  6. package/package.json +2 -2
  7. package/runtime/claude/.claude/context/commit-format.md +7 -4
  8. package/runtime/claude/.claude/context/handoff-claiming.md +32 -0
  9. package/runtime/claude/.claude/context/notification-consent.md +12 -9
  10. package/runtime/claude/.claude/hooks/boundary-check.sh +14 -331
  11. package/runtime/claude/.claude/hooks/memory-read-guard.sh +9 -1
  12. package/runtime/claude/.claude/rules/voice-bedrock.md +6 -2
  13. package/runtime/claude/.claude/settings.json +1 -1
  14. package/runtime/claude/.claude/skills/commit/SKILL.md +5 -3
  15. package/runtime/claude/.claude/skills/dark-mode/SKILL.md +48 -369
  16. package/runtime/claude/.claude/skills/records/SKILL.md +158 -0
  17. package/runtime/claude/.claude/skills/reflect/SKILL.md +50 -21
  18. package/runtime/claude/.claude/skills/review-pr/SKILL.md +4 -2
  19. package/runtime/claude/.claude/skills/save/SKILL.md +9 -3
  20. package/runtime/claude/.claude/skills/summon/SKILL.md +138 -207
  21. package/runtime/claude/.claude/skills/tui-design/SKILL.md +1 -1
  22. package/runtime/claude/.claude/skills/update/SKILL.md +1 -1
  23. package/runtime/claude/.claude/skills/view/SKILL.md +52 -16
  24. package/runtime/claude/.claude/skills/view/compose-scaffold.html +19 -13
  25. package/runtime/claude/CLAUDE.md +59 -95
  26. package/runtime/claude/bin/agent.sh +231 -7
  27. package/runtime/claude/bin/api-call.sh +44 -23
  28. package/runtime/claude/bin/artifact-register.sh +7 -1
  29. package/runtime/claude/bin/artifact-writeback.sh +1 -1
  30. package/runtime/claude/bin/artifacts.sh +66 -265
  31. package/runtime/claude/bin/attendant.sh +4 -1
  32. package/runtime/claude/bin/boundary-guard.cjs +1848 -0
  33. package/runtime/claude/bin/boundary-hook.sh +41 -0
  34. package/runtime/claude/bin/boundary.sh +15 -155
  35. package/runtime/claude/bin/capture-run.sh +32 -2
  36. package/runtime/claude/bin/codex-render-spec.mjs +24 -46
  37. package/runtime/claude/bin/codex-session-start.sh +19 -0
  38. package/runtime/claude/bin/codex-skill-render.mjs +18 -0
  39. package/runtime/claude/bin/config-get.sh +10 -1
  40. package/runtime/claude/bin/connect-refresh.sh +16 -0
  41. package/runtime/claude/bin/corpus_map.py +5 -0
  42. package/runtime/claude/bin/data/capabilities/claude.json +1480 -0
  43. package/runtime/claude/bin/genui.sh +19 -0
  44. package/runtime/claude/bin/graph-batch.sh +1 -1
  45. package/runtime/claude/bin/graph-op.sh +14 -0
  46. package/runtime/claude/bin/graph.sh +1 -1
  47. package/runtime/claude/bin/handoff-pr-backfill.sh +1 -1
  48. package/runtime/claude/bin/handoff-run.sh +11 -6
  49. package/runtime/claude/bin/ingest-item.sh +1 -1
  50. package/runtime/claude/bin/ingest-surface.html +22 -4
  51. package/runtime/claude/bin/issue.sh +1 -1
  52. package/runtime/claude/bin/knowledge.sh +1 -1
  53. package/runtime/claude/bin/lib/card-delivery.sh +329 -0
  54. package/runtime/claude/bin/lib/config.sh +148 -3
  55. package/runtime/claude/bin/lib/context.sh +38 -67
  56. package/runtime/claude/bin/lib/genui-entry.py +36 -0
  57. package/runtime/claude/bin/lib/git-message.sh +25 -6
  58. package/runtime/claude/bin/lib/git-sync.sh +12 -4
  59. package/runtime/claude/bin/lib/greeting.sh +255 -28
  60. package/runtime/claude/bin/lib/handoff_meta.py +294 -0
  61. package/runtime/claude/bin/lib/health-footer.sh +83 -2
  62. package/runtime/claude/bin/lib/identity.sh +31 -10
  63. package/runtime/claude/bin/lib/instance-registry.sh +71 -0
  64. package/runtime/claude/bin/lib/local_ci_receipt.py +249 -0
  65. package/runtime/claude/bin/lib/metrics.sh +121 -8
  66. package/runtime/claude/bin/lib/notices.sh +36 -0
  67. package/runtime/claude/bin/lib/record_outcomes.py +729 -0
  68. package/runtime/claude/bin/lib/reflexes.py +267 -0
  69. package/runtime/claude/bin/lib/retrieval_scorecard.py +624 -0
  70. package/runtime/claude/bin/lib/session-record-prompt.txt +18 -0
  71. package/runtime/claude/bin/lib/session_record.py +3747 -0
  72. package/runtime/claude/bin/lib/session_trail.py +1983 -0
  73. package/runtime/claude/bin/lib/settings-drift.sh +8 -5
  74. package/runtime/claude/bin/lib/startup-restart.sh +41 -0
  75. package/runtime/claude/bin/lib/worktree-links.sh +92 -0
  76. package/runtime/claude/bin/memory-push.sh +6 -0
  77. package/runtime/claude/bin/merge-gate.sh +498 -0
  78. package/runtime/claude/bin/notify.sh +5 -2
  79. package/runtime/claude/bin/page-access.sh +103 -0
  80. package/runtime/claude/bin/person.sh +13 -2
  81. package/runtime/claude/bin/pi-render-spec.mjs +0 -1
  82. package/runtime/claude/bin/pi-spirit-discovery.mjs +34 -0
  83. package/runtime/claude/bin/post-update.sh +181 -0
  84. package/runtime/claude/bin/prime-render-spec.mjs +0 -1
  85. package/runtime/claude/bin/publish-artifact.sh +89 -42
  86. package/runtime/claude/bin/qa-inventory.sh +2 -2
  87. package/runtime/claude/bin/quest.sh +1 -1
  88. package/runtime/claude/bin/records.sh +11 -0
  89. package/runtime/claude/bin/reflexes/05-vitals.sh +9 -0
  90. package/runtime/claude/bin/reflexes/10-open-items.sh +12 -0
  91. package/runtime/claude/bin/reflexes/15-retrieval.sh +8 -0
  92. package/runtime/claude/bin/reflexes/20-nightly.sh +100 -0
  93. package/runtime/claude/bin/reflexes/20-sandbox.sh +85 -0
  94. package/runtime/claude/bin/reflexes/30-cost.py +116 -0
  95. package/runtime/claude/bin/reflexes/40-shells.py +51 -0
  96. package/runtime/claude/bin/reflexes/README.md +61 -0
  97. package/runtime/claude/bin/reflexes.sh +7 -0
  98. package/runtime/claude/bin/runtime-identity.sh +140 -42
  99. package/runtime/claude/bin/screenshot.sh +77 -0
  100. package/runtime/claude/bin/scroll.sh +1 -1
  101. package/runtime/claude/bin/search.sh +31 -5
  102. package/runtime/claude/bin/session-autosave.sh +6 -3
  103. package/runtime/claude/bin/session-id.sh +147 -0
  104. package/runtime/claude/bin/session-log.sh +19 -0
  105. package/runtime/claude/bin/session-record.sh +26 -0
  106. package/runtime/claude/bin/session-start.sh +121 -151
  107. package/runtime/claude/bin/settings.sh +13 -8
  108. package/runtime/claude/bin/spirit-discovery-claude.sh +4 -0
  109. package/runtime/claude/bin/spirit-discovery-codex.sh +4 -0
  110. package/runtime/claude/bin/spirit-discovery-pi.sh +4 -0
  111. package/runtime/claude/bin/spirit-discovery-prime.sh +4 -0
  112. package/runtime/claude/bin/spirit-discovery.sh +4 -0
  113. package/runtime/claude/bin/startup-check.sh +1 -1
  114. package/runtime/claude/bin/telemetry.sh +11 -4
  115. package/runtime/claude/bin/test-baseline.sh +33 -5
  116. package/runtime/claude/bin/todo.sh +1 -1
  117. package/runtime/claude/bin/worktree-create.sh +15 -45
  118. package/runtime/claude/bin/worktree.sh +6 -0
  119. package/runtime/claude/docs/spirit-discovery.md +96 -0
  120. package/runtime/claude/egregore_runtime/__init__.py +18 -12
  121. package/runtime/claude/egregore_runtime/access.py +608 -0
  122. package/runtime/claude/egregore_runtime/access_cli.py +190 -0
  123. package/runtime/claude/egregore_runtime/adapters/invitation.py +5 -3
  124. package/runtime/claude/egregore_runtime/adapters/qmd.py +521 -47
  125. package/runtime/claude/egregore_runtime/admin_entry.py +56 -0
  126. package/runtime/claude/egregore_runtime/artifacts.py +222 -16
  127. package/runtime/claude/egregore_runtime/contracts.py +27 -0
  128. package/runtime/claude/egregore_runtime/eligibility.py +5 -12
  129. package/runtime/claude/egregore_runtime/episode_binding.py +16 -5
  130. package/runtime/claude/egregore_runtime/github_issues.py +2 -2
  131. package/runtime/claude/egregore_runtime/handoff_lookup.py +9 -20
  132. package/runtime/claude/egregore_runtime/harness_cli.py +674 -149
  133. package/runtime/claude/egregore_runtime/held.py +2436 -0
  134. package/runtime/claude/egregore_runtime/hosted_api.py +137 -0
  135. package/runtime/claude/egregore_runtime/identity.py +196 -65
  136. package/runtime/claude/egregore_runtime/identity_transfer.py +6 -2
  137. package/runtime/claude/egregore_runtime/ingest_cli.py +2 -2
  138. package/runtime/claude/egregore_runtime/ingest_index_cli.py +4 -16
  139. package/runtime/claude/egregore_runtime/installation.py +119 -0
  140. package/runtime/claude/egregore_runtime/investigation.py +64 -41
  141. package/runtime/claude/egregore_runtime/investigation_guidance.py +16 -11
  142. package/runtime/claude/egregore_runtime/issue_cli.py +51 -32
  143. package/runtime/claude/egregore_runtime/issue_reporting.py +4 -3
  144. package/runtime/claude/egregore_runtime/issue_triage_service.py +2 -2
  145. package/runtime/claude/egregore_runtime/issues.py +12 -6
  146. package/runtime/claude/egregore_runtime/knowledge.py +331 -5
  147. package/runtime/claude/egregore_runtime/knowledge_cli.py +57 -17
  148. package/runtime/claude/egregore_runtime/lifecycle.py +88 -22
  149. package/runtime/claude/egregore_runtime/lifecycle_cli.py +21 -13
  150. package/runtime/claude/egregore_runtime/line_ids.py +10 -0
  151. package/runtime/claude/egregore_runtime/memory_push.py +349 -0
  152. package/runtime/claude/egregore_runtime/observe.py +64 -25
  153. package/runtime/claude/egregore_runtime/observers.py +19 -0
  154. package/runtime/claude/egregore_runtime/optional_modules.py +13 -0
  155. package/runtime/claude/egregore_runtime/outputs_cli.py +321 -0
  156. package/runtime/claude/egregore_runtime/policy.py +651 -24
  157. package/runtime/claude/egregore_runtime/private_result.py +2 -1
  158. package/runtime/claude/egregore_runtime/projection.py +946 -0
  159. package/runtime/claude/egregore_runtime/quest_cli.py +20 -23
  160. package/runtime/claude/egregore_runtime/question_cli.py +15 -11
  161. package/runtime/claude/egregore_runtime/questions.py +10 -10
  162. package/runtime/claude/egregore_runtime/quests.py +10 -10
  163. package/runtime/claude/egregore_runtime/record_publish.py +548 -0
  164. package/runtime/claude/egregore_runtime/record_subject.py +564 -0
  165. package/runtime/claude/egregore_runtime/research_ingest.py +2 -4
  166. package/runtime/claude/egregore_runtime/runtime.py +169 -60
  167. package/runtime/claude/egregore_runtime/sandbox.py +194 -0
  168. package/runtime/claude/egregore_runtime/scroll_cli.py +22 -32
  169. package/runtime/claude/egregore_runtime/scrolls.py +23 -15
  170. package/runtime/claude/egregore_runtime/shadow.py +262 -0
  171. package/runtime/claude/egregore_runtime/spirit_discovery.py +487 -0
  172. package/runtime/claude/egregore_runtime/spirit_discovery_cli.py +25 -0
  173. package/runtime/claude/egregore_runtime/spirit_discovery_transport.py +18 -0
  174. package/runtime/claude/egregore_runtime/status.py +101 -44
  175. package/runtime/claude/egregore_runtime/status_cli.py +13 -5
  176. package/runtime/claude/egregore_runtime/sync.py +39 -21
  177. package/runtime/claude/egregore_runtime/taste.py +934 -0
  178. package/runtime/claude/egregore_runtime/taste_cli.py +242 -0
  179. package/runtime/claude/egregore_runtime/taste_rules.py +91 -0
  180. package/runtime/claude/egregore_runtime/telemetry.py +26 -3
  181. package/runtime/claude/egregore_runtime/todo_cli.py +15 -12
  182. package/runtime/claude/egregore_runtime/todos.py +3 -2
  183. package/runtime/claude/egregore_runtime/upgrade.py +85 -58
  184. package/runtime/claude/egregore_runtime/writeback.py +330 -24
  185. package/runtime/claude/manifest.json +65 -5
  186. package/runtime/codex/.claude/context/handoff-claiming.md +32 -0
  187. package/runtime/codex/.claude/context/notification-consent.md +12 -9
  188. package/runtime/codex/.claude/skills/view/SKILL.md +438 -0
  189. package/runtime/codex/.codex/hooks.json +11 -0
  190. package/runtime/codex/.codex/skills/dark-mode/SKILL.md +1 -1
  191. package/runtime/codex/.codex/skills/records/SKILL.md +29 -0
  192. package/runtime/codex/.codex/skills/summon/SKILL.md +1 -1
  193. package/runtime/codex/.codex/skills/view/assets/compose-scaffold.html +19 -13
  194. package/runtime/codex/.codex/spec-manifest.json +13 -19
  195. package/runtime/codex/AGENTS.md +54 -105
  196. package/runtime/codex/bin/agent.sh +231 -7
  197. package/runtime/codex/bin/api-call.sh +44 -23
  198. package/runtime/codex/bin/artifact-register.sh +7 -1
  199. package/runtime/codex/bin/artifact-writeback.sh +1 -1
  200. package/runtime/codex/bin/artifacts.sh +66 -265
  201. package/runtime/codex/bin/attendant.sh +4 -1
  202. package/runtime/codex/bin/boundary-guard.cjs +1848 -0
  203. package/runtime/codex/bin/boundary-hook.sh +41 -0
  204. package/runtime/codex/bin/capture-run.sh +32 -2
  205. package/runtime/codex/bin/codex-render-spec.mjs +24 -46
  206. package/runtime/codex/bin/codex-session-start.sh +19 -0
  207. package/runtime/codex/bin/codex-skill-render.mjs +18 -0
  208. package/runtime/codex/bin/config-get.sh +10 -1
  209. package/runtime/codex/bin/connect-refresh.sh +16 -0
  210. package/runtime/codex/bin/corpus_map.py +5 -0
  211. package/runtime/codex/bin/data/capabilities/codex.json +1458 -0
  212. package/runtime/codex/bin/genui.sh +19 -0
  213. package/runtime/codex/bin/graph-batch.sh +1 -1
  214. package/runtime/codex/bin/graph-op.sh +14 -0
  215. package/runtime/codex/bin/graph.sh +1 -1
  216. package/runtime/codex/bin/handoff-pr-backfill.sh +1 -1
  217. package/runtime/codex/bin/handoff-run.sh +11 -6
  218. package/runtime/codex/bin/ingest-item.sh +1 -1
  219. package/runtime/codex/bin/ingest-surface.html +22 -4
  220. package/runtime/codex/bin/issue.sh +1 -1
  221. package/runtime/codex/bin/knowledge.sh +1 -1
  222. package/runtime/codex/bin/lib/card-delivery.sh +329 -0
  223. package/runtime/codex/bin/lib/config.sh +148 -3
  224. package/runtime/codex/bin/lib/context.sh +38 -67
  225. package/runtime/codex/bin/lib/genui-entry.py +36 -0
  226. package/runtime/codex/bin/lib/git-message.sh +25 -6
  227. package/runtime/codex/bin/lib/git-sync.sh +12 -4
  228. package/runtime/codex/bin/lib/greeting.sh +255 -28
  229. package/runtime/codex/bin/lib/handoff_meta.py +294 -0
  230. package/runtime/codex/bin/lib/health-footer.sh +83 -2
  231. package/runtime/codex/bin/lib/identity.sh +31 -10
  232. package/runtime/codex/bin/lib/instance-registry.sh +71 -0
  233. package/runtime/codex/bin/lib/local_ci_receipt.py +249 -0
  234. package/runtime/codex/bin/lib/metrics.sh +121 -8
  235. package/runtime/codex/bin/lib/notices.sh +36 -0
  236. package/runtime/codex/bin/lib/record_outcomes.py +729 -0
  237. package/runtime/codex/bin/lib/reflexes.py +267 -0
  238. package/runtime/codex/bin/lib/retrieval_scorecard.py +624 -0
  239. package/runtime/codex/bin/lib/session-record-prompt.txt +18 -0
  240. package/runtime/codex/bin/lib/session_record.py +3747 -0
  241. package/runtime/codex/bin/lib/session_trail.py +1983 -0
  242. package/runtime/codex/bin/lib/settings-drift.sh +8 -5
  243. package/runtime/codex/bin/lib/startup-restart.sh +41 -0
  244. package/runtime/codex/bin/lib/worktree-links.sh +92 -0
  245. package/runtime/codex/bin/memory-push.sh +6 -0
  246. package/runtime/codex/bin/merge-gate.sh +498 -0
  247. package/runtime/codex/bin/notify.sh +5 -2
  248. package/runtime/codex/bin/page-access.sh +103 -0
  249. package/runtime/codex/bin/person.sh +13 -2
  250. package/runtime/codex/bin/pi-render-spec.mjs +0 -1
  251. package/runtime/codex/bin/pi-spirit-discovery.mjs +34 -0
  252. package/runtime/codex/bin/post-update.sh +181 -0
  253. package/runtime/codex/bin/prime-render-spec.mjs +0 -1
  254. package/runtime/codex/bin/publish-artifact.sh +89 -42
  255. package/runtime/codex/bin/qa-inventory.sh +2 -2
  256. package/runtime/codex/bin/quest.sh +1 -1
  257. package/runtime/codex/bin/records.sh +11 -0
  258. package/runtime/codex/bin/reflexes/05-vitals.sh +9 -0
  259. package/runtime/codex/bin/reflexes/10-open-items.sh +12 -0
  260. package/runtime/codex/bin/reflexes/15-retrieval.sh +8 -0
  261. package/runtime/codex/bin/reflexes/20-nightly.sh +100 -0
  262. package/runtime/codex/bin/reflexes/20-sandbox.sh +85 -0
  263. package/runtime/codex/bin/reflexes/30-cost.py +116 -0
  264. package/runtime/codex/bin/reflexes/40-shells.py +51 -0
  265. package/runtime/codex/bin/reflexes/README.md +61 -0
  266. package/runtime/codex/bin/reflexes.sh +7 -0
  267. package/runtime/codex/bin/runtime-identity.sh +140 -42
  268. package/runtime/codex/bin/screenshot.sh +77 -0
  269. package/runtime/codex/bin/scroll.sh +1 -1
  270. package/runtime/codex/bin/search.sh +31 -5
  271. package/runtime/codex/bin/session-autosave.sh +6 -3
  272. package/runtime/codex/bin/session-id.sh +147 -0
  273. package/runtime/codex/bin/session-log.sh +19 -0
  274. package/runtime/codex/bin/session-record.sh +26 -0
  275. package/runtime/codex/bin/settings.sh +13 -8
  276. package/runtime/codex/bin/spirit-discovery-claude.sh +4 -0
  277. package/runtime/codex/bin/spirit-discovery-codex.sh +4 -0
  278. package/runtime/codex/bin/spirit-discovery-pi.sh +4 -0
  279. package/runtime/codex/bin/spirit-discovery-prime.sh +4 -0
  280. package/runtime/codex/bin/spirit-discovery.sh +4 -0
  281. package/runtime/codex/bin/startup-check.sh +1 -1
  282. package/runtime/codex/bin/telemetry.sh +11 -4
  283. package/runtime/codex/bin/test-baseline.sh +33 -5
  284. package/runtime/codex/bin/todo.sh +1 -1
  285. package/runtime/codex/bin/worktree-create.sh +15 -45
  286. package/runtime/codex/bin/worktree.sh +6 -0
  287. package/runtime/codex/docs/spirit-discovery.md +96 -0
  288. package/runtime/codex/egregore_runtime/__init__.py +18 -12
  289. package/runtime/codex/egregore_runtime/access.py +608 -0
  290. package/runtime/codex/egregore_runtime/access_cli.py +190 -0
  291. package/runtime/codex/egregore_runtime/adapters/invitation.py +5 -3
  292. package/runtime/codex/egregore_runtime/adapters/qmd.py +521 -47
  293. package/runtime/codex/egregore_runtime/admin_entry.py +56 -0
  294. package/runtime/codex/egregore_runtime/artifacts.py +222 -16
  295. package/runtime/codex/egregore_runtime/contracts.py +27 -0
  296. package/runtime/codex/egregore_runtime/eligibility.py +5 -12
  297. package/runtime/codex/egregore_runtime/episode_binding.py +16 -5
  298. package/runtime/codex/egregore_runtime/github_issues.py +2 -2
  299. package/runtime/codex/egregore_runtime/handoff_lookup.py +9 -20
  300. package/runtime/codex/egregore_runtime/harness_cli.py +674 -149
  301. package/runtime/codex/egregore_runtime/held.py +2436 -0
  302. package/runtime/codex/egregore_runtime/hosted_api.py +137 -0
  303. package/runtime/codex/egregore_runtime/identity.py +196 -65
  304. package/runtime/codex/egregore_runtime/identity_transfer.py +6 -2
  305. package/runtime/codex/egregore_runtime/ingest_cli.py +2 -2
  306. package/runtime/codex/egregore_runtime/ingest_index_cli.py +4 -16
  307. package/runtime/codex/egregore_runtime/installation.py +119 -0
  308. package/runtime/codex/egregore_runtime/investigation.py +64 -41
  309. package/runtime/codex/egregore_runtime/investigation_guidance.py +16 -11
  310. package/runtime/codex/egregore_runtime/issue_cli.py +51 -32
  311. package/runtime/codex/egregore_runtime/issue_reporting.py +4 -3
  312. package/runtime/codex/egregore_runtime/issue_triage_service.py +2 -2
  313. package/runtime/codex/egregore_runtime/issues.py +12 -6
  314. package/runtime/codex/egregore_runtime/knowledge.py +331 -5
  315. package/runtime/codex/egregore_runtime/knowledge_cli.py +57 -17
  316. package/runtime/codex/egregore_runtime/lifecycle.py +88 -22
  317. package/runtime/codex/egregore_runtime/lifecycle_cli.py +21 -13
  318. package/runtime/codex/egregore_runtime/line_ids.py +10 -0
  319. package/runtime/codex/egregore_runtime/memory_push.py +349 -0
  320. package/runtime/codex/egregore_runtime/observe.py +64 -25
  321. package/runtime/codex/egregore_runtime/observers.py +19 -0
  322. package/runtime/codex/egregore_runtime/optional_modules.py +13 -0
  323. package/runtime/codex/egregore_runtime/outputs_cli.py +321 -0
  324. package/runtime/codex/egregore_runtime/policy.py +651 -24
  325. package/runtime/codex/egregore_runtime/private_result.py +2 -1
  326. package/runtime/codex/egregore_runtime/projection.py +946 -0
  327. package/runtime/codex/egregore_runtime/quest_cli.py +20 -23
  328. package/runtime/codex/egregore_runtime/question_cli.py +15 -11
  329. package/runtime/codex/egregore_runtime/questions.py +10 -10
  330. package/runtime/codex/egregore_runtime/quests.py +10 -10
  331. package/runtime/codex/egregore_runtime/record_publish.py +548 -0
  332. package/runtime/codex/egregore_runtime/record_subject.py +564 -0
  333. package/runtime/codex/egregore_runtime/research_ingest.py +2 -4
  334. package/runtime/codex/egregore_runtime/runtime.py +169 -60
  335. package/runtime/codex/egregore_runtime/sandbox.py +194 -0
  336. package/runtime/codex/egregore_runtime/scroll_cli.py +22 -32
  337. package/runtime/codex/egregore_runtime/scrolls.py +23 -15
  338. package/runtime/codex/egregore_runtime/shadow.py +262 -0
  339. package/runtime/codex/egregore_runtime/spirit_discovery.py +487 -0
  340. package/runtime/codex/egregore_runtime/spirit_discovery_cli.py +25 -0
  341. package/runtime/codex/egregore_runtime/spirit_discovery_transport.py +18 -0
  342. package/runtime/codex/egregore_runtime/status.py +101 -44
  343. package/runtime/codex/egregore_runtime/status_cli.py +13 -5
  344. package/runtime/codex/egregore_runtime/sync.py +39 -21
  345. package/runtime/codex/egregore_runtime/taste.py +934 -0
  346. package/runtime/codex/egregore_runtime/taste_cli.py +242 -0
  347. package/runtime/codex/egregore_runtime/taste_rules.py +91 -0
  348. package/runtime/codex/egregore_runtime/telemetry.py +26 -3
  349. package/runtime/codex/egregore_runtime/todo_cli.py +15 -12
  350. package/runtime/codex/egregore_runtime/todos.py +3 -2
  351. package/runtime/codex/egregore_runtime/upgrade.py +85 -58
  352. package/runtime/codex/egregore_runtime/writeback.py +330 -24
  353. package/runtime/codex/manifest.json +66 -5
  354. package/runtime/pi/.claude/context/handoff-claiming.md +32 -0
  355. package/runtime/pi/.claude/context/notification-consent.md +12 -9
  356. package/runtime/pi/.claude/skills/view/SKILL.md +438 -0
  357. package/runtime/pi/.codex/hooks.json +11 -0
  358. package/runtime/pi/.codex/skills/dark-mode/SKILL.md +1 -1
  359. package/runtime/pi/.codex/skills/records/SKILL.md +29 -0
  360. package/runtime/pi/.codex/skills/summon/SKILL.md +1 -1
  361. package/runtime/pi/.codex/skills/view/assets/compose-scaffold.html +19 -13
  362. package/runtime/pi/.codex/spec-manifest.json +13 -19
  363. package/runtime/pi/.pi/APPEND_SYSTEM.md +53 -99
  364. package/runtime/pi/.pi/extensions/egregore.ts +13 -1
  365. package/runtime/pi/.pi/spec-manifest.json +2 -2
  366. package/runtime/pi/AGENTS.md +54 -105
  367. package/runtime/pi/bin/agent.sh +231 -7
  368. package/runtime/pi/bin/api-call.sh +44 -23
  369. package/runtime/pi/bin/artifact-register.sh +7 -1
  370. package/runtime/pi/bin/artifact-writeback.sh +1 -1
  371. package/runtime/pi/bin/artifacts.sh +66 -265
  372. package/runtime/pi/bin/attendant.sh +4 -1
  373. package/runtime/pi/bin/boundary-guard.cjs +1848 -0
  374. package/runtime/pi/bin/boundary-hook.sh +41 -0
  375. package/runtime/pi/bin/capture-run.sh +32 -2
  376. package/runtime/pi/bin/codex-render-spec.mjs +24 -46
  377. package/runtime/pi/bin/codex-session-start.sh +19 -0
  378. package/runtime/pi/bin/codex-skill-render.mjs +18 -0
  379. package/runtime/pi/bin/config-get.sh +10 -1
  380. package/runtime/pi/bin/connect-refresh.sh +16 -0
  381. package/runtime/pi/bin/corpus_map.py +5 -0
  382. package/runtime/pi/bin/data/capabilities/pi.json +1458 -0
  383. package/runtime/pi/bin/genui.sh +19 -0
  384. package/runtime/pi/bin/graph-batch.sh +1 -1
  385. package/runtime/pi/bin/graph-op.sh +14 -0
  386. package/runtime/pi/bin/graph.sh +1 -1
  387. package/runtime/pi/bin/handoff-pr-backfill.sh +1 -1
  388. package/runtime/pi/bin/handoff-run.sh +11 -6
  389. package/runtime/pi/bin/ingest-item.sh +1 -1
  390. package/runtime/pi/bin/ingest-surface.html +22 -4
  391. package/runtime/pi/bin/issue.sh +1 -1
  392. package/runtime/pi/bin/knowledge.sh +1 -1
  393. package/runtime/pi/bin/lib/card-delivery.sh +329 -0
  394. package/runtime/pi/bin/lib/config.sh +148 -3
  395. package/runtime/pi/bin/lib/context.sh +38 -67
  396. package/runtime/pi/bin/lib/genui-entry.py +36 -0
  397. package/runtime/pi/bin/lib/git-message.sh +25 -6
  398. package/runtime/pi/bin/lib/git-sync.sh +12 -4
  399. package/runtime/pi/bin/lib/greeting.sh +255 -28
  400. package/runtime/pi/bin/lib/handoff_meta.py +294 -0
  401. package/runtime/pi/bin/lib/health-footer.sh +83 -2
  402. package/runtime/pi/bin/lib/identity.sh +31 -10
  403. package/runtime/pi/bin/lib/instance-registry.sh +71 -0
  404. package/runtime/pi/bin/lib/local_ci_receipt.py +249 -0
  405. package/runtime/pi/bin/lib/metrics.sh +121 -8
  406. package/runtime/pi/bin/lib/notices.sh +36 -0
  407. package/runtime/pi/bin/lib/record_outcomes.py +729 -0
  408. package/runtime/pi/bin/lib/reflexes.py +267 -0
  409. package/runtime/pi/bin/lib/retrieval_scorecard.py +624 -0
  410. package/runtime/pi/bin/lib/session-record-prompt.txt +18 -0
  411. package/runtime/pi/bin/lib/session_record.py +3747 -0
  412. package/runtime/pi/bin/lib/session_trail.py +1983 -0
  413. package/runtime/pi/bin/lib/settings-drift.sh +8 -5
  414. package/runtime/pi/bin/lib/startup-restart.sh +41 -0
  415. package/runtime/pi/bin/lib/worktree-links.sh +92 -0
  416. package/runtime/pi/bin/memory-push.sh +6 -0
  417. package/runtime/pi/bin/merge-gate.sh +498 -0
  418. package/runtime/pi/bin/notify.sh +5 -2
  419. package/runtime/pi/bin/page-access.sh +103 -0
  420. package/runtime/pi/bin/person.sh +13 -2
  421. package/runtime/pi/bin/pi-branch-policy.mjs +37 -0
  422. package/runtime/pi/bin/pi-observe-context.mjs +97 -4
  423. package/runtime/pi/bin/pi-render-spec.mjs +0 -1
  424. package/runtime/pi/bin/pi-session-start.sh +4 -1
  425. package/runtime/pi/bin/pi-spirit-discovery.mjs +34 -0
  426. package/runtime/pi/bin/post-update.sh +181 -0
  427. package/runtime/pi/bin/prime-render-spec.mjs +0 -1
  428. package/runtime/pi/bin/publish-artifact.sh +89 -42
  429. package/runtime/pi/bin/qa-inventory.sh +2 -2
  430. package/runtime/pi/bin/quest.sh +1 -1
  431. package/runtime/pi/bin/records.sh +11 -0
  432. package/runtime/pi/bin/reflexes/05-vitals.sh +9 -0
  433. package/runtime/pi/bin/reflexes/10-open-items.sh +12 -0
  434. package/runtime/pi/bin/reflexes/15-retrieval.sh +8 -0
  435. package/runtime/pi/bin/reflexes/20-nightly.sh +100 -0
  436. package/runtime/pi/bin/reflexes/20-sandbox.sh +85 -0
  437. package/runtime/pi/bin/reflexes/30-cost.py +116 -0
  438. package/runtime/pi/bin/reflexes/40-shells.py +51 -0
  439. package/runtime/pi/bin/reflexes/README.md +61 -0
  440. package/runtime/pi/bin/reflexes.sh +7 -0
  441. package/runtime/pi/bin/runtime-identity.sh +140 -42
  442. package/runtime/pi/bin/screenshot.sh +77 -0
  443. package/runtime/pi/bin/scroll.sh +1 -1
  444. package/runtime/pi/bin/search.sh +31 -5
  445. package/runtime/pi/bin/session-autosave.sh +6 -3
  446. package/runtime/pi/bin/session-id.sh +147 -0
  447. package/runtime/pi/bin/session-log.sh +19 -0
  448. package/runtime/pi/bin/session-record.sh +26 -0
  449. package/runtime/pi/bin/settings.sh +13 -8
  450. package/runtime/pi/bin/spirit-discovery-claude.sh +4 -0
  451. package/runtime/pi/bin/spirit-discovery-codex.sh +4 -0
  452. package/runtime/pi/bin/spirit-discovery-pi.sh +4 -0
  453. package/runtime/pi/bin/spirit-discovery-prime.sh +4 -0
  454. package/runtime/pi/bin/spirit-discovery.sh +4 -0
  455. package/runtime/pi/bin/startup-check.sh +1 -1
  456. package/runtime/pi/bin/telemetry.sh +11 -4
  457. package/runtime/pi/bin/test-baseline.sh +33 -5
  458. package/runtime/pi/bin/todo.sh +1 -1
  459. package/runtime/pi/bin/worktree-create.sh +15 -45
  460. package/runtime/pi/bin/worktree.sh +6 -0
  461. package/runtime/pi/docs/spirit-discovery.md +96 -0
  462. package/runtime/pi/egregore_runtime/__init__.py +18 -12
  463. package/runtime/pi/egregore_runtime/access.py +608 -0
  464. package/runtime/pi/egregore_runtime/access_cli.py +190 -0
  465. package/runtime/pi/egregore_runtime/adapters/invitation.py +5 -3
  466. package/runtime/pi/egregore_runtime/adapters/qmd.py +521 -47
  467. package/runtime/pi/egregore_runtime/admin_entry.py +56 -0
  468. package/runtime/pi/egregore_runtime/artifacts.py +222 -16
  469. package/runtime/pi/egregore_runtime/contracts.py +27 -0
  470. package/runtime/pi/egregore_runtime/eligibility.py +5 -12
  471. package/runtime/pi/egregore_runtime/episode_binding.py +16 -5
  472. package/runtime/pi/egregore_runtime/github_issues.py +2 -2
  473. package/runtime/pi/egregore_runtime/handoff_lookup.py +9 -20
  474. package/runtime/pi/egregore_runtime/harness_cli.py +674 -149
  475. package/runtime/pi/egregore_runtime/held.py +2436 -0
  476. package/runtime/pi/egregore_runtime/hosted_api.py +137 -0
  477. package/runtime/pi/egregore_runtime/identity.py +196 -65
  478. package/runtime/pi/egregore_runtime/identity_transfer.py +6 -2
  479. package/runtime/pi/egregore_runtime/ingest_cli.py +2 -2
  480. package/runtime/pi/egregore_runtime/ingest_index_cli.py +4 -16
  481. package/runtime/pi/egregore_runtime/installation.py +119 -0
  482. package/runtime/pi/egregore_runtime/investigation.py +64 -41
  483. package/runtime/pi/egregore_runtime/investigation_guidance.py +16 -11
  484. package/runtime/pi/egregore_runtime/issue_cli.py +51 -32
  485. package/runtime/pi/egregore_runtime/issue_reporting.py +4 -3
  486. package/runtime/pi/egregore_runtime/issue_triage_service.py +2 -2
  487. package/runtime/pi/egregore_runtime/issues.py +12 -6
  488. package/runtime/pi/egregore_runtime/knowledge.py +331 -5
  489. package/runtime/pi/egregore_runtime/knowledge_cli.py +57 -17
  490. package/runtime/pi/egregore_runtime/lifecycle.py +88 -22
  491. package/runtime/pi/egregore_runtime/lifecycle_cli.py +21 -13
  492. package/runtime/pi/egregore_runtime/line_ids.py +10 -0
  493. package/runtime/pi/egregore_runtime/memory_push.py +349 -0
  494. package/runtime/pi/egregore_runtime/observe.py +64 -25
  495. package/runtime/pi/egregore_runtime/observers.py +19 -0
  496. package/runtime/pi/egregore_runtime/optional_modules.py +13 -0
  497. package/runtime/pi/egregore_runtime/outputs_cli.py +321 -0
  498. package/runtime/pi/egregore_runtime/policy.py +651 -24
  499. package/runtime/pi/egregore_runtime/private_result.py +2 -1
  500. package/runtime/pi/egregore_runtime/projection.py +946 -0
  501. package/runtime/pi/egregore_runtime/quest_cli.py +20 -23
  502. package/runtime/pi/egregore_runtime/question_cli.py +15 -11
  503. package/runtime/pi/egregore_runtime/questions.py +10 -10
  504. package/runtime/pi/egregore_runtime/quests.py +10 -10
  505. package/runtime/pi/egregore_runtime/record_publish.py +548 -0
  506. package/runtime/pi/egregore_runtime/record_subject.py +564 -0
  507. package/runtime/pi/egregore_runtime/research_ingest.py +2 -4
  508. package/runtime/pi/egregore_runtime/runtime.py +169 -60
  509. package/runtime/pi/egregore_runtime/sandbox.py +194 -0
  510. package/runtime/pi/egregore_runtime/scroll_cli.py +22 -32
  511. package/runtime/pi/egregore_runtime/scrolls.py +23 -15
  512. package/runtime/pi/egregore_runtime/shadow.py +262 -0
  513. package/runtime/pi/egregore_runtime/spirit_discovery.py +487 -0
  514. package/runtime/pi/egregore_runtime/spirit_discovery_cli.py +25 -0
  515. package/runtime/pi/egregore_runtime/spirit_discovery_transport.py +18 -0
  516. package/runtime/pi/egregore_runtime/status.py +101 -44
  517. package/runtime/pi/egregore_runtime/status_cli.py +13 -5
  518. package/runtime/pi/egregore_runtime/sync.py +39 -21
  519. package/runtime/pi/egregore_runtime/taste.py +934 -0
  520. package/runtime/pi/egregore_runtime/taste_cli.py +242 -0
  521. package/runtime/pi/egregore_runtime/taste_rules.py +91 -0
  522. package/runtime/pi/egregore_runtime/telemetry.py +26 -3
  523. package/runtime/pi/egregore_runtime/todo_cli.py +15 -12
  524. package/runtime/pi/egregore_runtime/todos.py +3 -2
  525. package/runtime/pi/egregore_runtime/upgrade.py +85 -58
  526. package/runtime/pi/egregore_runtime/writeback.py +330 -24
  527. package/runtime/pi/manifest.json +66 -5
  528. package/runtime/prime/.claude/context/handoff-claiming.md +32 -0
  529. package/runtime/prime/.claude/context/notification-consent.md +12 -9
  530. package/runtime/prime/.claude/skills/view/SKILL.md +438 -0
  531. package/runtime/prime/.codex/hooks.json +11 -0
  532. package/runtime/prime/.codex/skills/dark-mode/SKILL.md +1 -1
  533. package/runtime/prime/.codex/skills/records/SKILL.md +29 -0
  534. package/runtime/prime/.codex/skills/summon/SKILL.md +1 -1
  535. package/runtime/prime/.codex/skills/view/assets/compose-scaffold.html +19 -13
  536. package/runtime/prime/.codex/spec-manifest.json +13 -19
  537. package/runtime/prime/.prime/agent/APPEND_SYSTEM.md +53 -99
  538. package/runtime/prime/.prime/agent/extensions/egregore.ts +14 -1
  539. package/runtime/prime/.prime/agent/skills/egregore/SKILL.md +14 -6
  540. package/runtime/prime/.prime/agent/skills/egregore/src/egregore/__init__.py +66 -1
  541. package/runtime/prime/.prime/agent/spec-manifest.json +2 -2
  542. package/runtime/prime/AGENTS.md +54 -105
  543. package/runtime/prime/bin/agent.sh +231 -7
  544. package/runtime/prime/bin/api-call.sh +44 -23
  545. package/runtime/prime/bin/artifact-register.sh +7 -1
  546. package/runtime/prime/bin/artifact-writeback.sh +1 -1
  547. package/runtime/prime/bin/artifacts.sh +66 -265
  548. package/runtime/prime/bin/attendant.sh +4 -1
  549. package/runtime/prime/bin/boundary-guard.cjs +1848 -0
  550. package/runtime/prime/bin/boundary-hook.sh +41 -0
  551. package/runtime/prime/bin/capture-run.sh +32 -2
  552. package/runtime/prime/bin/codex-render-spec.mjs +24 -46
  553. package/runtime/prime/bin/codex-session-start.sh +19 -0
  554. package/runtime/prime/bin/codex-skill-render.mjs +18 -0
  555. package/runtime/prime/bin/config-get.sh +10 -1
  556. package/runtime/prime/bin/connect-refresh.sh +16 -0
  557. package/runtime/prime/bin/corpus_map.py +5 -0
  558. package/runtime/prime/bin/data/capabilities/prime.json +1458 -0
  559. package/runtime/prime/bin/genui.sh +19 -0
  560. package/runtime/prime/bin/graph-batch.sh +1 -1
  561. package/runtime/prime/bin/graph-op.sh +14 -0
  562. package/runtime/prime/bin/graph.sh +1 -1
  563. package/runtime/prime/bin/handoff-pr-backfill.sh +1 -1
  564. package/runtime/prime/bin/handoff-run.sh +11 -6
  565. package/runtime/prime/bin/ingest-item.sh +1 -1
  566. package/runtime/prime/bin/ingest-surface.html +22 -4
  567. package/runtime/prime/bin/issue.sh +1 -1
  568. package/runtime/prime/bin/knowledge.sh +1 -1
  569. package/runtime/prime/bin/lib/card-delivery.sh +329 -0
  570. package/runtime/prime/bin/lib/config.sh +148 -3
  571. package/runtime/prime/bin/lib/context.sh +38 -67
  572. package/runtime/prime/bin/lib/genui-entry.py +36 -0
  573. package/runtime/prime/bin/lib/git-message.sh +25 -6
  574. package/runtime/prime/bin/lib/git-sync.sh +12 -4
  575. package/runtime/prime/bin/lib/greeting.sh +255 -28
  576. package/runtime/prime/bin/lib/handoff_meta.py +294 -0
  577. package/runtime/prime/bin/lib/health-footer.sh +83 -2
  578. package/runtime/prime/bin/lib/identity.sh +31 -10
  579. package/runtime/prime/bin/lib/instance-registry.sh +71 -0
  580. package/runtime/prime/bin/lib/local_ci_receipt.py +249 -0
  581. package/runtime/prime/bin/lib/metrics.sh +121 -8
  582. package/runtime/prime/bin/lib/notices.sh +36 -0
  583. package/runtime/prime/bin/lib/record_outcomes.py +729 -0
  584. package/runtime/prime/bin/lib/reflexes.py +267 -0
  585. package/runtime/prime/bin/lib/retrieval_scorecard.py +624 -0
  586. package/runtime/prime/bin/lib/session-record-prompt.txt +18 -0
  587. package/runtime/prime/bin/lib/session_record.py +3747 -0
  588. package/runtime/prime/bin/lib/session_trail.py +1983 -0
  589. package/runtime/prime/bin/lib/settings-drift.sh +8 -5
  590. package/runtime/prime/bin/lib/startup-restart.sh +41 -0
  591. package/runtime/prime/bin/lib/worktree-links.sh +92 -0
  592. package/runtime/prime/bin/memory-push.sh +6 -0
  593. package/runtime/prime/bin/merge-gate.sh +498 -0
  594. package/runtime/prime/bin/notify.sh +5 -2
  595. package/runtime/prime/bin/page-access.sh +103 -0
  596. package/runtime/prime/bin/person.sh +13 -2
  597. package/runtime/prime/bin/pi-branch-policy.mjs +37 -0
  598. package/runtime/prime/bin/pi-observe-context.mjs +97 -4
  599. package/runtime/prime/bin/pi-render-spec.mjs +0 -1
  600. package/runtime/prime/bin/pi-spirit-discovery.mjs +34 -0
  601. package/runtime/prime/bin/post-update.sh +181 -0
  602. package/runtime/prime/bin/prime-render-spec.mjs +0 -1
  603. package/runtime/prime/bin/prime-session-start.sh +3 -1
  604. package/runtime/prime/bin/publish-artifact.sh +89 -42
  605. package/runtime/prime/bin/qa-inventory.sh +2 -2
  606. package/runtime/prime/bin/quest.sh +1 -1
  607. package/runtime/prime/bin/records.sh +11 -0
  608. package/runtime/prime/bin/reflexes/05-vitals.sh +9 -0
  609. package/runtime/prime/bin/reflexes/10-open-items.sh +12 -0
  610. package/runtime/prime/bin/reflexes/15-retrieval.sh +8 -0
  611. package/runtime/prime/bin/reflexes/20-nightly.sh +100 -0
  612. package/runtime/prime/bin/reflexes/20-sandbox.sh +85 -0
  613. package/runtime/prime/bin/reflexes/30-cost.py +116 -0
  614. package/runtime/prime/bin/reflexes/40-shells.py +51 -0
  615. package/runtime/prime/bin/reflexes/README.md +61 -0
  616. package/runtime/prime/bin/reflexes.sh +7 -0
  617. package/runtime/prime/bin/runtime-identity.sh +140 -42
  618. package/runtime/prime/bin/screenshot.sh +77 -0
  619. package/runtime/prime/bin/scroll.sh +1 -1
  620. package/runtime/prime/bin/search.sh +31 -5
  621. package/runtime/prime/bin/session-autosave.sh +6 -3
  622. package/runtime/prime/bin/session-id.sh +147 -0
  623. package/runtime/prime/bin/session-log.sh +19 -0
  624. package/runtime/prime/bin/session-record.sh +26 -0
  625. package/runtime/prime/bin/settings.sh +13 -8
  626. package/runtime/prime/bin/spirit-discovery-claude.sh +4 -0
  627. package/runtime/prime/bin/spirit-discovery-codex.sh +4 -0
  628. package/runtime/prime/bin/spirit-discovery-pi.sh +4 -0
  629. package/runtime/prime/bin/spirit-discovery-prime.sh +4 -0
  630. package/runtime/prime/bin/spirit-discovery.sh +4 -0
  631. package/runtime/prime/bin/startup-check.sh +1 -1
  632. package/runtime/prime/bin/telemetry.sh +11 -4
  633. package/runtime/prime/bin/test-baseline.sh +33 -5
  634. package/runtime/prime/bin/todo.sh +1 -1
  635. package/runtime/prime/bin/worktree-create.sh +15 -45
  636. package/runtime/prime/bin/worktree.sh +6 -0
  637. package/runtime/prime/docs/spirit-discovery.md +96 -0
  638. package/runtime/prime/egregore_runtime/__init__.py +18 -12
  639. package/runtime/prime/egregore_runtime/access.py +608 -0
  640. package/runtime/prime/egregore_runtime/access_cli.py +190 -0
  641. package/runtime/prime/egregore_runtime/adapters/invitation.py +5 -3
  642. package/runtime/prime/egregore_runtime/adapters/qmd.py +521 -47
  643. package/runtime/prime/egregore_runtime/admin_entry.py +56 -0
  644. package/runtime/prime/egregore_runtime/artifacts.py +222 -16
  645. package/runtime/prime/egregore_runtime/contracts.py +27 -0
  646. package/runtime/prime/egregore_runtime/eligibility.py +5 -12
  647. package/runtime/prime/egregore_runtime/episode_binding.py +16 -5
  648. package/runtime/prime/egregore_runtime/github_issues.py +2 -2
  649. package/runtime/prime/egregore_runtime/handoff_lookup.py +9 -20
  650. package/runtime/prime/egregore_runtime/harness_cli.py +674 -149
  651. package/runtime/prime/egregore_runtime/held.py +2436 -0
  652. package/runtime/prime/egregore_runtime/hosted_api.py +137 -0
  653. package/runtime/prime/egregore_runtime/identity.py +196 -65
  654. package/runtime/prime/egregore_runtime/identity_transfer.py +6 -2
  655. package/runtime/prime/egregore_runtime/ingest_cli.py +2 -2
  656. package/runtime/prime/egregore_runtime/ingest_index_cli.py +4 -16
  657. package/runtime/prime/egregore_runtime/installation.py +119 -0
  658. package/runtime/prime/egregore_runtime/investigation.py +64 -41
  659. package/runtime/prime/egregore_runtime/investigation_guidance.py +16 -11
  660. package/runtime/prime/egregore_runtime/issue_cli.py +51 -32
  661. package/runtime/prime/egregore_runtime/issue_reporting.py +4 -3
  662. package/runtime/prime/egregore_runtime/issue_triage_service.py +2 -2
  663. package/runtime/prime/egregore_runtime/issues.py +12 -6
  664. package/runtime/prime/egregore_runtime/knowledge.py +331 -5
  665. package/runtime/prime/egregore_runtime/knowledge_cli.py +57 -17
  666. package/runtime/prime/egregore_runtime/lifecycle.py +88 -22
  667. package/runtime/prime/egregore_runtime/lifecycle_cli.py +21 -13
  668. package/runtime/prime/egregore_runtime/line_ids.py +10 -0
  669. package/runtime/prime/egregore_runtime/memory_push.py +349 -0
  670. package/runtime/prime/egregore_runtime/observe.py +64 -25
  671. package/runtime/prime/egregore_runtime/observers.py +19 -0
  672. package/runtime/prime/egregore_runtime/optional_modules.py +13 -0
  673. package/runtime/prime/egregore_runtime/outputs_cli.py +321 -0
  674. package/runtime/prime/egregore_runtime/policy.py +651 -24
  675. package/runtime/prime/egregore_runtime/private_result.py +2 -1
  676. package/runtime/prime/egregore_runtime/projection.py +946 -0
  677. package/runtime/prime/egregore_runtime/quest_cli.py +20 -23
  678. package/runtime/prime/egregore_runtime/question_cli.py +15 -11
  679. package/runtime/prime/egregore_runtime/questions.py +10 -10
  680. package/runtime/prime/egregore_runtime/quests.py +10 -10
  681. package/runtime/prime/egregore_runtime/record_publish.py +548 -0
  682. package/runtime/prime/egregore_runtime/record_subject.py +564 -0
  683. package/runtime/prime/egregore_runtime/research_ingest.py +2 -4
  684. package/runtime/prime/egregore_runtime/runtime.py +169 -60
  685. package/runtime/prime/egregore_runtime/sandbox.py +194 -0
  686. package/runtime/prime/egregore_runtime/scroll_cli.py +22 -32
  687. package/runtime/prime/egregore_runtime/scrolls.py +23 -15
  688. package/runtime/prime/egregore_runtime/shadow.py +262 -0
  689. package/runtime/prime/egregore_runtime/spirit_discovery.py +487 -0
  690. package/runtime/prime/egregore_runtime/spirit_discovery_cli.py +25 -0
  691. package/runtime/prime/egregore_runtime/spirit_discovery_transport.py +18 -0
  692. package/runtime/prime/egregore_runtime/status.py +101 -44
  693. package/runtime/prime/egregore_runtime/status_cli.py +13 -5
  694. package/runtime/prime/egregore_runtime/sync.py +39 -21
  695. package/runtime/prime/egregore_runtime/taste.py +934 -0
  696. package/runtime/prime/egregore_runtime/taste_cli.py +242 -0
  697. package/runtime/prime/egregore_runtime/taste_rules.py +91 -0
  698. package/runtime/prime/egregore_runtime/telemetry.py +26 -3
  699. package/runtime/prime/egregore_runtime/todo_cli.py +15 -12
  700. package/runtime/prime/egregore_runtime/todos.py +3 -2
  701. package/runtime/prime/egregore_runtime/upgrade.py +85 -58
  702. package/runtime/prime/egregore_runtime/writeback.py +330 -24
  703. package/runtime/prime/manifest.json +66 -5
  704. package/runtime/claude/bin/thread.sh +0 -9
  705. package/runtime/claude/egregore_runtime/admin_gate.py +0 -102
  706. package/runtime/claude/egregore_runtime/thread_cli.py +0 -173
  707. package/runtime/claude/egregore_runtime/threads.py +0 -521
  708. package/runtime/codex/bin/thread.sh +0 -9
  709. package/runtime/codex/egregore_runtime/admin_gate.py +0 -102
  710. package/runtime/codex/egregore_runtime/thread_cli.py +0 -173
  711. package/runtime/codex/egregore_runtime/threads.py +0 -521
  712. package/runtime/pi/bin/thread.sh +0 -9
  713. package/runtime/pi/egregore_runtime/admin_gate.py +0 -102
  714. package/runtime/pi/egregore_runtime/thread_cli.py +0 -173
  715. package/runtime/pi/egregore_runtime/threads.py +0 -521
  716. package/runtime/prime/bin/thread.sh +0 -9
  717. package/runtime/prime/egregore_runtime/admin_gate.py +0 -102
  718. package/runtime/prime/egregore_runtime/thread_cli.py +0 -173
  719. package/runtime/prime/egregore_runtime/threads.py +0 -521
@@ -0,0 +1,3747 @@
1
+ """Session-record lifecycle and personal controls; transcript parsing is in session_trail.
2
+
3
+ All content writes use capture-run.sh. Record captures and batch publication share
4
+ one lock keyed by the physical memory repository. No pull, merge, or rebase runs.
5
+ Native hook admission reads only a bounded header; parsing and model calls run
6
+ in the detached worker.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import atexit
12
+ from collections import Counter
13
+ from contextlib import contextmanager
14
+ from datetime import date, datetime, timedelta, timezone
15
+ import hashlib
16
+ import importlib.util
17
+ import json
18
+ import math
19
+ import os
20
+ from pathlib import Path
21
+ import re
22
+ import shutil
23
+ import signal
24
+ import stat
25
+ import string
26
+ import subprocess
27
+ import sys
28
+ import tempfile
29
+ import textwrap
30
+ import time
31
+ import unicodedata
32
+ from uuid import UUID
33
+
34
+ import session_trail as st
35
+
36
+ SELF = Path(__file__).resolve()
37
+ SAFE_ID = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
38
+
39
+ # Public presentation code has no private producer dependency. Older partial
40
+ # installations can still render human records; unknown native data stays out.
41
+ _subject_path = SELF.parents[2] / "egregore_runtime/record_subject.py"
42
+ _subject = None
43
+ if _subject_path.is_file():
44
+ _spec = importlib.util.spec_from_file_location("egregore_record_subject", _subject_path)
45
+ _subject = importlib.util.module_from_spec(_spec)
46
+ _spec.loader.exec_module(_subject)
47
+
48
+
49
+ def classify_record(meta):
50
+ if _subject is not None:
51
+ return _subject.classify_record(meta)
52
+ marked = any(isinstance(key, str) and key.strip().strip("\"'").casefold() in
53
+ {"producer", "producer_principal_id", "producer_identity", "record_owner_actor_id", "subject_kind"}
54
+ and (key != "subject_kind" or value != "human-session") for key, value in meta.items())
55
+ return "unsupported-native" if marked else "human-session"
56
+
57
+
58
+ def record_subject(meta, text=""):
59
+ return _subject.record_subject(meta, text) if _subject else {"subject_kind": classify_record(meta)}
60
+
61
+
62
+ def native_record_reader(ctx):
63
+ """Read-only Runtime authority for new native presentation; no identity migration."""
64
+ try:
65
+ sys.path.insert(0, str(SELF.parents[2]))
66
+ from egregore_runtime.identity import LocalIdentityResolver
67
+ from egregore_runtime.access import authorization_inputs
68
+ from egregore_runtime.contracts import Permission
69
+ from egregore_runtime.policy import policy_for, check
70
+ actor = LocalIdentityResolver(ctx.root, persist_migrations=False).resolve(session_id="records-view", harness="shell")
71
+ state = read_json(ctx.state_path)
72
+ if (actor.actor.actor_id != state.get("actor_id") or not actor.membership
73
+ or actor.membership.membership_id != state.get("membership_id")
74
+ or actor.membership.actor_id != actor.actor.actor_id or actor.membership.status != "active"
75
+ or actor.membership.org_id != actor.profile.org_id):
76
+ return "", None
77
+ decision = policy_for(authorization_inputs(ctx.root)).authorize_action(actor, Permission.READ)
78
+ def reader(path, meta):
79
+ verdict = check(decision, "memory/" + Path(path).relative_to(ctx.memory).as_posix(), root=ctx.memory)
80
+ if not verdict.allowed:
81
+ return "held" if verdict.reason.startswith("held:") else False
82
+ return "held" if native_record_held(ctx, str(meta.get("session", ""))) else True
83
+ return actor.actor.actor_id, reader
84
+ except (ImportError, OSError, ValueError, TypeError, KeyError):
85
+ return "", None
86
+
87
+
88
+ def _native_control_directory(path, *, optional=True):
89
+ try:
90
+ info = path.lstat()
91
+ except FileNotFoundError:
92
+ if optional:
93
+ return False
94
+ raise ValueError("native record control coverage unavailable")
95
+ if not stat.S_ISDIR(info.st_mode) or not info.st_mode & 0o444 or not info.st_mode & 0o111:
96
+ raise ValueError("native record control coverage unavailable")
97
+ return True
98
+
99
+
100
+ def _native_control_object(pairs):
101
+ value = {}
102
+ for key, item in pairs:
103
+ if key in value:
104
+ raise ValueError("native record control coverage unavailable")
105
+ value[key] = item
106
+ return value
107
+
108
+
109
+ def _native_control_inventory(ctx, *, deadline_monotonic=None):
110
+ """Strict read-only control coverage; no transcript or canonical inventory.
111
+
112
+ At most 4096 visited trail-directory entries, 64 instance trail roots,
113
+ 4096 hold entries and 4096 alias rows are admitted. Pi/Prime alias files
114
+ are each at most 1 MiB; every selected root
115
+ must satisfy the existing instance-folder authority. Unknown or damaged
116
+ controls refuse coverage rather than silently meaning 'unheld'.
117
+ """
118
+ def current():
119
+ if deadline_monotonic is not None and time.monotonic() >= deadline_monotonic:
120
+ raise ValueError("native record control coverage unavailable")
121
+ current()
122
+ parent, trails = ctx.home / ".egregore", ctx.home / ".egregore/trails"
123
+ if not _native_control_directory(parent) or not _native_control_directory(trails):
124
+ return set(), []
125
+ names = {ctx.prefix, ctx.folder, *(ctx.prefix + "--" + name for name in ("codex", "pi", "prime"))}
126
+ with os.scandir(trails) as folders:
127
+ for visited, folder in enumerate(folders, 1):
128
+ current()
129
+ if visited > 4096:
130
+ raise ValueError("native record control coverage unavailable")
131
+ if folder.name.startswith(ctx.prefix + "--"):
132
+ names.add(folder.name)
133
+ if len(names) > 64:
134
+ raise ValueError("native record control coverage unavailable")
135
+ markers, rows, seen_hashes, entries = set(), [], {}, 0
136
+ for name in sorted(names):
137
+ current()
138
+ if "/" in name or name not in {ctx.prefix, ctx.folder} and not name.startswith(ctx.prefix + "--"):
139
+ raise ValueError("native record control coverage unavailable")
140
+ base = trails / name
141
+ if not _native_control_directory(base):
142
+ continue
143
+ known = {ctx.prefix, *(ctx.prefix + "--" + name for name in ("codex", "pi", "prime"))}
144
+ if name in known:
145
+ if not st.valid_trail_folder(base, ctx.projects, ctx.prefix):
146
+ raise ValueError("native record control coverage unavailable")
147
+ else:
148
+ # Exact legacy counterpart, using the same within-instance owner
149
+ # predicate as valid_trail_folder, without enumerating transcripts.
150
+ project = ctx.projects / name
151
+ if (base.resolve().parent != trails.resolve() or not _native_control_directory(project, optional=False)
152
+ or not st.within_instance(project, ctx.projects, ctx.prefix)):
153
+ raise ValueError("native record control coverage unavailable")
154
+ holds = base / "holds"
155
+ if _native_control_directory(holds):
156
+ with os.scandir(holds) as found:
157
+ for marker in found:
158
+ current()
159
+ entries += 1
160
+ info = marker.stat(follow_symlinks=False)
161
+ if entries > 4096 or not SAFE_ID.fullmatch(marker.name) or not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
162
+ raise ValueError("native record control coverage unavailable")
163
+ markers.add(marker.name)
164
+ aliases_path = base / "aliases.jsonl"
165
+ try:
166
+ info = aliases_path.lstat()
167
+ except FileNotFoundError:
168
+ continue
169
+ if (name not in {ctx.prefix + "--pi", ctx.prefix + "--prime"} or not stat.S_ISREG(info.st_mode)
170
+ or not info.st_mode & 0o444 or info.st_nlink != 1 or info.st_size > 1024 * 1024):
171
+ raise ValueError("native record control coverage unavailable")
172
+ descriptor = os.open(aliases_path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
173
+ with os.fdopen(descriptor, "rb") as stream:
174
+ before = os.fstat(stream.fileno())
175
+ if not stat.S_ISREG(before.st_mode) or before.st_ino != info.st_ino or before.st_dev != info.st_dev:
176
+ raise ValueError("native record control coverage unavailable")
177
+ data = stream.read(1024 * 1024 + 1)
178
+ after = os.fstat(stream.fileno())
179
+ if len(data) > 1024 * 1024 or (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns) or len(data) != before.st_size:
180
+ raise ValueError("native record control coverage unavailable")
181
+ for line in data.decode("utf-8").splitlines():
182
+ current()
183
+ row = json.loads(line, object_pairs_hook=_native_control_object)
184
+ if (not isinstance(row, dict) or not isinstance(row.get("path"), str) or len(row["path"]) > 4096
185
+ or not isinstance(row.get("hash"), str) or not re.fullmatch(r"[a-f0-9]{64}", row["hash"])
186
+ or not isinstance(row.get("session"), str) or not SAFE_ID.fullmatch(row["session"])
187
+ or row["hash"] in seen_hashes and seen_hashes[row["hash"]] != row["session"]):
188
+ raise ValueError("native record control coverage unavailable")
189
+ seen_hashes[row["hash"]] = row["session"]
190
+ rows.append(row)
191
+ if len(rows) > 4096:
192
+ raise ValueError("native record control coverage unavailable")
193
+ current()
194
+ return markers, rows
195
+
196
+
197
+ def native_record_holds(ctx, sids, *, deadline_monotonic=None):
198
+ """One strict, bounded personal-control inventory for selected native IDs."""
199
+ if (not isinstance(sids, (tuple, list, set, frozenset)) or len(sids) > 64
200
+ or any(not isinstance(sid, str) or not SAFE_ID.fullmatch(sid) for sid in sids)
201
+ or deadline_monotonic is not None and (type(deadline_monotonic) not in (int, float)
202
+ or not math.isfinite(deadline_monotonic))):
203
+ raise ValueError("native record control coverage unavailable")
204
+ def current():
205
+ if deadline_monotonic is not None and time.monotonic() >= deadline_monotonic:
206
+ raise ValueError("native record control coverage unavailable")
207
+ current()
208
+ markers, rows = _native_control_inventory(ctx, deadline_monotonic=deadline_monotonic)
209
+ current()
210
+ # Same transitive session/hash graph as native_record_held; never interpret
211
+ # a failed inventory as an empty set of holds or recover from transcripts.
212
+ related = {}
213
+ for row in rows:
214
+ current()
215
+ left, right = row["session"], row["hash"]
216
+ related.setdefault(left, set()).add(right)
217
+ related.setdefault(right, set()).add(left)
218
+ held_ids = set()
219
+ for sid in set(sids):
220
+ pending, seen = [sid], set()
221
+ while pending:
222
+ current()
223
+ item = pending.pop()
224
+ if item in seen:
225
+ continue
226
+ seen.add(item)
227
+ if item in markers:
228
+ held_ids.add(sid)
229
+ break
230
+ pending.extend(related.get(item, ()) - seen if item in related else ())
231
+ current()
232
+ return frozenset(held_ids)
233
+
234
+
235
+ def native_record_held(ctx, sid, *, strict=False):
236
+ """Native control/alias holds only; never recover aliases from transcripts."""
237
+ if not SAFE_ID.fullmatch(sid):
238
+ if strict:
239
+ raise ValueError("native record control coverage unavailable")
240
+ return False
241
+ if strict:
242
+ return sid in native_record_holds(ctx, (sid,))
243
+ identities = {sid}
244
+ rows = list(aliases(ctx))
245
+ while True:
246
+ related = {value for row in rows if row["session"] in identities or row["hash"] in identities
247
+ for value in (row["session"], row["hash"])}
248
+ if related <= identities:
249
+ break
250
+ identities.update(related)
251
+ for base in trail_bases(ctx):
252
+ directory = base / "holds"
253
+ try:
254
+ if not stat.S_ISDIR(directory.lstat().st_mode) or directory.resolve().parent != base.resolve():
255
+ continue
256
+ if any(SAFE_ID.fullmatch(sid) and stat.S_ISREG((directory / sid).lstat().st_mode) for sid in identities if (directory / sid).exists()):
257
+ return True
258
+ except OSError:
259
+ continue
260
+ return False
261
+
262
+
263
+ def read_json(path, default=None):
264
+ try:
265
+ value = json.loads(Path(path).read_text())
266
+ return value if isinstance(value, dict) else ({} if default is None else default)
267
+ except (OSError, ValueError):
268
+ return {} if default is None else default
269
+
270
+
271
+ def private_dir(path):
272
+ path = Path(path)
273
+ path.mkdir(parents=True, exist_ok=True, mode=0o700)
274
+ path.chmod(0o700)
275
+ return path
276
+
277
+
278
+ def atomic(path, text, mode=0o600):
279
+ path = Path(path)
280
+ path.parent.mkdir(parents=True, exist_ok=True)
281
+ fd, tmp = tempfile.mkstemp(prefix=".record-", dir=path.parent)
282
+ try:
283
+ os.fchmod(fd, mode)
284
+ with os.fdopen(fd, "w") as stream:
285
+ stream.write(text)
286
+ os.replace(tmp, path)
287
+ finally:
288
+ if os.path.exists(tmp):
289
+ os.unlink(tmp)
290
+
291
+
292
+ def alive(pid):
293
+ try:
294
+ os.kill(int(pid), 0)
295
+ return int(pid) > 0
296
+ except PermissionError:
297
+ return True
298
+ except (ValueError, OSError, TypeError):
299
+ return False
300
+
301
+
302
+ class Lock:
303
+ """mkdir lock, explicit ownership, atomic owner handover, stale recovery."""
304
+ def __init__(self, path, mode="write"):
305
+ self.path, self.mode = Path(path), mode
306
+
307
+ def owner(self):
308
+ try:
309
+ return int((self.path / "owner").read_text())
310
+ except (OSError, ValueError):
311
+ return None
312
+
313
+ def stale(self):
314
+ owner = self.owner()
315
+ if owner is not None:
316
+ return not alive(owner)
317
+ try:
318
+ return not (self.path / "owner").exists() and time.time() - self.path.stat().st_mtime > 60
319
+ except OSError:
320
+ return False
321
+
322
+ def acquire(self, wait=0):
323
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
324
+ until = time.monotonic() + wait
325
+ while True:
326
+ for attempt in range(2):
327
+ try:
328
+ self.path.mkdir(mode=0o700)
329
+ atomic(self.path / "owner", str(os.getpid()))
330
+ atomic(self.path / "mode", self.mode)
331
+ atomic(self.path / "since", str(time.time()))
332
+ atexit.register(self.release)
333
+ return True
334
+ except FileExistsError:
335
+ # Give an entrant time to initialize owner after mkdir;
336
+ # a crash in that gap becomes recoverable after one minute.
337
+ if attempt == 0 and self.stale():
338
+ recovery = self.path.with_name(self.path.name + ".recovering")
339
+ try:
340
+ recovery.mkdir()
341
+ except OSError:
342
+ break
343
+ try:
344
+ if self.stale():
345
+ shutil.rmtree(self.path)
346
+ except OSError:
347
+ pass
348
+ finally:
349
+ recovery.rmdir()
350
+ continue
351
+ break
352
+ if time.monotonic() >= until:
353
+ return False
354
+ time.sleep(0.05)
355
+
356
+ def release(self):
357
+ if self.owner() == os.getpid():
358
+ shutil.rmtree(self.path, ignore_errors=True)
359
+
360
+ def handover(self, pid):
361
+ if self.owner() != os.getpid():
362
+ raise RuntimeError("session lock ownership changed")
363
+ atomic(self.path / "owner", str(pid))
364
+
365
+
366
+ class Context:
367
+ def __init__(self, root, folder=None):
368
+ self.root = Path(root).resolve()
369
+ self.home = Path(os.environ["HOME"])
370
+ self.projects = self.home / ".claude/projects"
371
+ self.prefix = st.instance_prefix(self.root)
372
+ self.folder = folder or self.prefix
373
+ self.base = self.home / ".egregore/trails" / self.folder
374
+ self.memory = (self.root / "memory").resolve()
375
+ self.state_path = self.root / ".egregore-state.json"
376
+ self.reload()
377
+
378
+ def reload(self):
379
+ self.config = read_json(self.root / "egregore.json")
380
+ self.policy = self.config.get("features", {}).get("session_records", {})
381
+ if not isinstance(self.policy, dict):
382
+ self.policy = {}
383
+ self.state = read_json(self.state_path)
384
+ self.actor_id = self.state.get("actor_id")
385
+ self.author = re.sub(r"[^a-zA-Z0-9_-]", "", self.state.get("github_username", ""))
386
+ self.display = self.state.get("display_name", self.author)
387
+ return self
388
+
389
+ def identities(self):
390
+ """The names this person is known by in the records, casefolded: the
391
+ GitHub username and the display name."""
392
+ return {str(value).casefold() for value in (self.author, self.display) if value}
393
+
394
+ def enabled(self):
395
+ self.reload()
396
+ return self.policy.get("enabled", False) is True and self.state.get("session_records_enabled", True) is True
397
+
398
+ def held(self, sid, path=None):
399
+ identities = {sid}
400
+ markers = set()
401
+ for base in trail_bases(self):
402
+ directory = base / "holds"
403
+ try:
404
+ if (not stat.S_ISDIR(directory.lstat().st_mode)
405
+ or directory.resolve().parent != base.resolve()):
406
+ continue
407
+ markers.update(item.name for item in directory.iterdir()
408
+ if SAFE_ID.fullmatch(item.name) and stat.S_ISREG(item.lstat().st_mode))
409
+ except OSError:
410
+ continue
411
+ if not markers:
412
+ return False
413
+ if path is not None:
414
+ info = transcript_info(self, path)
415
+ if info:
416
+ identities.add(info[1])
417
+ if info[0] in ("pi", "prime"):
418
+ identities.update(path_identity(value) for value in (path, Path(path).resolve()))
419
+ # Aliases connect a producer's original spelling with the durable native
420
+ # id. Resolve the complete equivalence class regardless of line order.
421
+ rows = list(aliases(self))
422
+ while True:
423
+ related = {value for row in rows
424
+ if row["session"] in identities or row["hash"] in identities
425
+ for value in (row["session"], row["hash"])}
426
+ if related <= identities:
427
+ break
428
+ identities.update(related)
429
+ if identities & markers:
430
+ return True
431
+ # Older Pi sessions may predate alias capture. Publications only carry
432
+ # the native id, so recover physical-path hashes from admitted headers
433
+ # when a hash hold exists. No Codex index or transcript body is needed.
434
+ hashes = {value for value in markers if re.fullmatch(r"[a-f0-9]{64}", value)}
435
+ if path is None and hashes:
436
+ for harness in ("pi", "prime"):
437
+ base = self.home / ("." + harness) / "agent/sessions"
438
+ for directory, dirs, files in os.walk(base, followlinks=False):
439
+ if len(Path(directory).relative_to(base).parts) >= 3:
440
+ dirs[:] = []
441
+ for name in files:
442
+ candidate = Path(directory) / name
443
+ if candidate.suffix != ".jsonl" or not regular(candidate):
444
+ continue
445
+ try:
446
+ values = {path_identity(candidate), path_identity(candidate.resolve())}
447
+ except (OSError, RuntimeError):
448
+ continue
449
+ if not values & hashes:
450
+ continue
451
+ info = transcript_info(self, candidate)
452
+ if info and info[0] in ("pi", "prime") and info[1] in identities:
453
+ return True
454
+ return False
455
+
456
+ def safe_base(self):
457
+ if self.base.exists() or self.base.is_symlink():
458
+ return st.valid_trail_folder(self.base, self.projects, self.prefix)
459
+ names = {self.prefix, *(self.prefix + "--" + h for h in ("codex", "pi", "prime")),
460
+ *(p.name for p in st.transcript_folders(self.projects, self.prefix))}
461
+ return self.folder in names and self.base.parent == self.home / ".egregore/trails"
462
+
463
+ def model_enabled(self):
464
+ self.reload()
465
+ return self.policy.get("model", "haiku") != "none" and self.state.get("session_records_model_call", True) is not False
466
+
467
+ def lock(self, sid, mode="write"):
468
+ return Lock(self.base / "locks" / sid, mode)
469
+
470
+ def error(self, reason):
471
+ if not self.safe_base():
472
+ return
473
+ private_dir(self.base)
474
+ path = self.base / "errors.log"
475
+ try:
476
+ old = path.read_text()[-900_000:] if path.exists() else ""
477
+ atomic(path, (old + f"{datetime.now(timezone.utc).isoformat()} {reason}\n")[-1_000_000:])
478
+ except OSError:
479
+ pass
480
+
481
+ def set_state(self, key, value):
482
+ # A worktree links the checkout's state file (bin/worktree-create.sh):
483
+ # the write follows the link, so the installation keeps one state and
484
+ # no answer given in a worktree is stranded in a copy that replaced it.
485
+ target = self.state_path.resolve()
486
+ lock = Lock(target.with_name(".egregore-record-settings.lock"))
487
+ if not lock.acquire(wait=10):
488
+ raise RuntimeError("personal settings are busy")
489
+ try:
490
+ state = read_json(target)
491
+ state[key] = value
492
+ atomic(target, json.dumps(state, indent=2) + "\n")
493
+ finally:
494
+ lock.release()
495
+ self.reload()
496
+
497
+ def memory_lock(self):
498
+ key = hashlib.sha256(str(self.memory).encode()).hexdigest()[:16]
499
+ return Lock(self.home / ".egregore/locks" / ("memory-" + key))
500
+
501
+
502
+ def metadata(text):
503
+ result = {}
504
+ front = text.split("---", 2)
505
+ if text.startswith("---\n") and len(front) == 3:
506
+ for line in front[1].splitlines():
507
+ key, sep, value = line.partition(":")
508
+ if sep:
509
+ value = value.strip()
510
+ try:
511
+ value = json.loads(value)
512
+ except ValueError:
513
+ pass
514
+ result[key] = value
515
+ for key, value in re.findall(r"^\*\*([^*]+)\*\*:\s*(.*)$", text, re.M):
516
+ if key == "Date":
517
+ result["started_at"] = value
518
+ result.setdefault(key.lower(), value)
519
+ return result
520
+
521
+
522
+ def sessions_directory(memory):
523
+ """Authorize the physical memory root before traversing any session path."""
524
+ try:
525
+ root = Path(memory).resolve(strict=True)
526
+ sessions = root / "sessions"
527
+ if stat.S_ISDIR(sessions.lstat().st_mode) and sessions.resolve(strict=True).is_relative_to(root):
528
+ return sessions
529
+ except (OSError, RuntimeError):
530
+ pass
531
+ return None
532
+
533
+
534
+ def dated_record_paths(sessions, start, end):
535
+ """Records live at sessions/YYYY-MM/DD-*.md, named by their UTC date. Keep
536
+ the month folders and day names within a day of the window, so a local date
537
+ near midnight is never missed; a name without a day prefix is kept."""
538
+ first, last = start - timedelta(days=1), end + timedelta(days=1)
539
+ month = date(first.year, first.month, 1)
540
+ while month <= last:
541
+ try:
542
+ paths = sorted((sessions / month.strftime("%Y-%m")).glob("*.md"))
543
+ except OSError:
544
+ paths = []
545
+ for path in paths:
546
+ day = re.match(r"(\d{2})-", path.name)
547
+ try:
548
+ when = month.replace(day=int(day[1])) if day else None
549
+ except ValueError:
550
+ when = None
551
+ if when is None or first <= when <= last:
552
+ yield path
553
+ month = (month + timedelta(days=32)).replace(day=1)
554
+
555
+
556
+ def record_files(memory, start=None, end=None):
557
+ """Every record, or with a window only the files named inside it."""
558
+ sessions = sessions_directory(memory)
559
+ if sessions is None:
560
+ return
561
+ paths = sorted(sessions.glob("**/*.md")) if start is None else dated_record_paths(sessions, start, end or start)
562
+ for path in paths:
563
+ try:
564
+ if stat.S_ISREG(path.lstat().st_mode) and path.resolve().is_relative_to(sessions):
565
+ with path.open("rb") as stream:
566
+ text = stream.read(256 * 1024).decode("utf-8", errors="replace")
567
+ yield path, metadata(text), text
568
+ except (OSError, UnicodeError):
569
+ continue
570
+
571
+
572
+ def all_records(ctx):
573
+ yield from record_files(ctx.memory)
574
+
575
+
576
+ def digest_key(line):
577
+ return " ".join("".join(c for c in line.casefold()
578
+ if c not in string.punctuation and not unicodedata.category(c).startswith("P")).split())
579
+
580
+
581
+ def digest_lines(text):
582
+ """Only the three record sections participate; prompt/detail sections do not.
583
+ A placeholder line is skipped only when it is the whole line, so a real
584
+ item that begins "None recorded for the failed restore" is read."""
585
+ section = None
586
+ seen = set()
587
+ for raw in text.splitlines():
588
+ heading = re.fullmatch(r"##\s+(.+?)\s*", raw)
589
+ if heading:
590
+ name = heading[1]
591
+ if name in ("Prompts", "Said"):
592
+ break
593
+ section = name if name in ("Decided", "Open", "Risks") and name not in seen else None
594
+ seen.add(name)
595
+ continue
596
+ line = raw.strip()
597
+ bullet = re.match(r"^[-*+]\s+", line)
598
+ if not section or not line or (section != "Risks" and not bullet):
599
+ continue
600
+ if bullet:
601
+ line = line[bullet.end():].strip()
602
+ if st.PLACEHOLDER_RE.fullmatch(line):
603
+ continue
604
+ yield section, line
605
+
606
+
607
+ # Open items. An item's id is the first six hex digits of the SHA-256 of its
608
+ # digest key, the normalised line without its owner, so two records raising
609
+ # the same item share one id. The ledger beside the records holds events of
610
+ # ids and states only, never item text, and the last event for an id wins.
611
+ # Resolution and relevance are separate. A mention is evidence, never an
612
+ # instruction to reopen or renew an item. Open items become stale fourteen
613
+ # days after their first observation or explicit reactivation. Stale items
614
+ # remain discoverable but leave the active carry; terminal events stay terminal
615
+ # until an explicit reactivation. Reads never append events.
616
+ CARRY_DAYS = 14
617
+ STALE_DAYS = 14
618
+ EXPIRE_DAYS = STALE_DAYS # Compatibility for callers; no history expires.
619
+ LEDGER = "open-items.jsonl"
620
+ ITEM_STATES = ("closed", "filed", "expired", "active", "stale", "superseded", "dismissed")
621
+ ITEM_ID = re.compile(r"[0-9a-f]{6}")
622
+ # Records often write the absence of an owner in place of one.
623
+ NO_OWNER = re.compile(
624
+ r"^(?:owner\s*:?\s*)?(?:unstated|unowned|unassigned|unknown|unclear|tbd|none|nobody|no one)\b"
625
+ r"|\b(?:no owner|owners? (?:unstated|unknown|unclear|tbd|to decide|not stated)|unstated owner"
626
+ r"|ownership (?:pending|unclear|unstated))\b", re.I)
627
+ ISSUE_FLAGS = ("--kind", "improvement", "--area", "other", "--severity", "normal")
628
+ ISSUE_LABELS = ["kind:improvement", "area:other", "severity:normal"]
629
+ # The reflex runner cuts a probe line at this length (bin/lib/reflexes.py).
630
+ REFLEX_LINE = 160
631
+
632
+
633
+ def item_id(key):
634
+ return hashlib.sha256(key.encode("utf-8")).hexdigest()[:6]
635
+
636
+
637
+ def open_owner(line):
638
+ """An Open bullet's item, its owner as written, and whether it names none."""
639
+ item, sep, written = line.rpartition(" — ")
640
+ item, written = (item.strip(), " ".join(written.split())) if sep else (line, "")
641
+ owner = re.sub(r"^owner\s*:\s*", "", written, flags=re.I).rstrip(" .")
642
+ return item, owner, not owner or bool(NO_OWNER.search(written))
643
+
644
+
645
+ def clip(value, limit):
646
+ value = " ".join(str(value).split())
647
+ return value if len(value) <= limit else value[:max(1, limit - 1)] + "…"
648
+
649
+
650
+ def record_day(meta):
651
+ """A record's local date and start, from its offset timestamp or its date."""
652
+ try:
653
+ started = datetime.fromisoformat(str(meta.get("started_at", "")).replace("Z", "+00:00"))
654
+ if started.tzinfo is not None:
655
+ stamp = started.astimezone()
656
+ return stamp.date(), stamp
657
+ except ValueError:
658
+ pass
659
+ try:
660
+ return date.fromisoformat(str(meta.get("date", ""))[:10]), None
661
+ except ValueError:
662
+ return None, None
663
+
664
+
665
+ def default_owner(owner, ownerless, author):
666
+ """The owner an Open line names, else the author of the record that left
667
+ it: a thread nobody named belongs to the person whose session raised it.
668
+ A record without an author leaves it unowned, as before."""
669
+ if not ownerless:
670
+ return owner
671
+ return author if author and author != "unstated" else "unowned"
672
+
673
+
674
+ def note_raise(raised, ident, order, item, owner, ownerless, topic, author=""):
675
+ """Keep an item's first date, its newest wording, its newest named owner
676
+ and the author of its newest raise."""
677
+ info = raised.setdefault(ident, {"first": order[0], "latest": None, "owner": None})
678
+ info["first"] = min(info["first"], order[0])
679
+ if info["latest"] is None or order > info["latest"][0]:
680
+ info["latest"] = (order, item, topic, author)
681
+ if not ownerless and (info["owner"] is None or order > info["owner"][0]):
682
+ info["owner"] = (order, owner)
683
+
684
+
685
+ def raised_row(ident, info, today):
686
+ order, item, topic, author = info["latest"]
687
+ named = info["owner"]
688
+ return {"id": ident, "line": item, "topic": topic, "path": order[2],
689
+ "age": (today - info["first"]).days, "first": info["first"].isoformat(),
690
+ "ownerless": named is None, "owner": default_owner(named[1] if named else "", named is None, author)}
691
+
692
+
693
+ def owner_label(row, default=" by default"):
694
+ """An owner as the digest and the table show it: named, or the record's
695
+ author marked as a default."""
696
+ return row["owner"] + (default if row["ownerless"] and row["owner"] != "unowned" else "")
697
+
698
+
699
+ def mine(rows, identities):
700
+ """The rows whose owner is one of the person's identities."""
701
+ return [row for row in rows if row["owner"].casefold() in identities]
702
+
703
+
704
+ def ledger_lines(memory, name):
705
+ """Stream complete history with bounded lines; malformed large lines are skipped."""
706
+ sessions = sessions_directory(memory)
707
+ if sessions is None:
708
+ return
709
+ try:
710
+ descriptor = os.open(sessions / name, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | os.O_NONBLOCK)
711
+ with os.fdopen(descriptor, "rb") as stream:
712
+ if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
713
+ return
714
+ while True:
715
+ line = stream.readline(65537)
716
+ if not line:
717
+ break
718
+ if len(line) > 65536:
719
+ while line and not line.endswith(b"\n"):
720
+ line = stream.readline(65537)
721
+ continue
722
+ yield line.decode("utf-8", errors="replace")
723
+ except OSError:
724
+ return
725
+
726
+
727
+ def ledger_events(memory):
728
+ """The latest explicit disposition, without losing old closures to a read window."""
729
+ latest = {}
730
+ for index, line in enumerate(ledger_lines(memory, LEDGER)):
731
+ try:
732
+ event = json.loads(line)
733
+ except (ValueError, RecursionError):
734
+ continue
735
+ if (not isinstance(event, dict) or not isinstance(event.get("id"), str)
736
+ or not ITEM_ID.fullmatch(event["id"]) or event.get("state") not in ITEM_STATES):
737
+ continue
738
+ order = (event_time(event.get("at")) or 0.0, index)
739
+ if event["id"] not in latest or order >= latest[event["id"]][0]:
740
+ latest[event["id"]] = (order, event)
741
+ return {ident: event for ident, (_, event) in latest.items()}
742
+
743
+
744
+ def gate_flakes(memory, session):
745
+ """The CI jobs that flaked on the merges a session made, in ledger order.
746
+
747
+ The merge gate appends one line per decision to sessions/gate.jsonl; a
748
+ merge names the session that ran it and the jobs that failed once and
749
+ passed on a re-run. Blank, malformed and unknown lines are skipped, never
750
+ fatal, and the aggregate `Full CI result` job is never a flake. A name
751
+ that cannot be written as UTF-8 is skipped, so no line stops the record.
752
+ """
753
+ jobs, seen = [], set()
754
+ for line in ledger_lines(memory, GATE_LEDGER) if session else ():
755
+ try:
756
+ event = json.loads(line)
757
+ except (ValueError, RecursionError):
758
+ continue
759
+ if not isinstance(event, dict) or event.get("kind") != "merge" or event.get("session") != session:
760
+ continue
761
+ flaked = event.get("flaked")
762
+ for name in flaked if isinstance(flaked, list) else ():
763
+ name = " ".join(name.split()) if isinstance(name, str) else ""
764
+ if (not name or len(name) > 120 or name.startswith("Full CI result")
765
+ or re.search(r"[\x00-\x1f\x7f]", name) or name in seen):
766
+ continue
767
+ try:
768
+ name.encode("utf-8")
769
+ except UnicodeEncodeError:
770
+ continue
771
+ seen.add(name)
772
+ jobs.append(name)
773
+ return jobs
774
+
775
+
776
+ def raise_is_open(event, raised_at):
777
+ """Repeated evidence cannot reopen a resolved item."""
778
+ return event is None or event.get("state") in ("active", "stale")
779
+
780
+
781
+ def collect_digest(memory, *, author="", person="", days=1, since=None, today=None, records=None,
782
+ carry_days=CARRY_DAYS, events=None, actor_id="", native_reader=None):
783
+ """Project open resolution and active/stale relevance from append-only history.
784
+
785
+ Original age never resets. Only an explicit active event renews relevance;
786
+ reading or mentioning an old item cannot reactivate it. carry_days=0 skips
787
+ the historical projection for callers that only need recent record facts.
788
+ """
789
+ today = today or datetime.now().date()
790
+ if isinstance(today, str):
791
+ today = date.fromisoformat(today)
792
+ start = date.fromisoformat(since) if isinstance(since, str) else since
793
+ start = start or today - timedelta(days=max(0, days))
794
+ carry_days = max(0, min(int(carry_days), STALE_DAYS))
795
+ earliest = date.min if carry_days else start
796
+ result = {"since": start.isoformat(), "today": today.isoformat(), "sessions": 0, "yours": 0,
797
+ "others": 0, "decided": [], "open": [], "risks": [], "carried": [], "stale": [],
798
+ "spirit_intents": 0, "your_spirit_intents": 0, "spirit_records": [],
799
+ "unsupported_native": 0, "held_records": 0,
800
+ "spirit_coverage": "bounded" if native_reader else "unavailable"}
801
+ identities = {str(value).casefold() for value in (author, person) if value}
802
+ seen = {section: set() for section in ("Decided", "Open", "Risks")}
803
+ events = ledger_events(memory) if events is None else events
804
+ raised = {}
805
+ rows = (record_files(memory, start=None if carry_days else earliest, end=today) if records is None
806
+ else sorted(records, key=lambda row: str(row[0])))
807
+ for path, meta, text in rows:
808
+ if meta.get("kind") != "session-record":
809
+ continue
810
+ recorded, local_stamp = record_day(meta)
811
+ if recorded is None or not earliest <= recorded <= today:
812
+ continue
813
+ window = start <= recorded
814
+ subject = classify_record(meta)
815
+ if subject != "human-session":
816
+ access = native_reader(path, meta) if native_reader and window else False
817
+ if not access:
818
+ continue
819
+ projection = record_subject(meta, text)
820
+ if access == "held" or projection.get("held"):
821
+ result["held_records"] += 1
822
+ elif subject == "unsupported-native":
823
+ result["unsupported_native"] += 1
824
+ else:
825
+ result["spirit_intents"] += 1
826
+ result["your_spirit_intents"] += bool(actor_id and projection["record_owner_actor_id"] == actor_id)
827
+ if len(result["spirit_records"]) < 40:
828
+ result["spirit_records"].append(projection)
829
+ continue
830
+ who = " ".join(str(meta.get("from") or meta.get("author") or "unstated").split())
831
+ if window:
832
+ result["sessions"] += 1
833
+ if who.casefold() in identities:
834
+ result["yours"] += 1
835
+ topic = " ".join(str(meta.get("topic") or "session").split())
836
+ stamp = local_stamp.strftime("%m-%d %H:%M") if local_stamp is not None else recorded.strftime("%m-%d") + " 00:00"
837
+ relative = str(Path(path).relative_to(Path(memory).resolve()))
838
+ # The record's own time; a record with only a date counts from its local midnight.
839
+ raised_at = (local_stamp.timestamp() if local_stamp is not None
840
+ else datetime(recorded.year, recorded.month, recorded.day).astimezone().timestamp())
841
+ order = (recorded, raised_at, relative)
842
+ for section, line in digest_lines(text):
843
+ if section == "Open":
844
+ item, owner, ownerless = open_owner(line)
845
+ key = digest_key(item)
846
+ if not key:
847
+ continue
848
+ ident = item_id(key)
849
+ live = raise_is_open(events.get(ident), raised_at)
850
+ if carry_days and live:
851
+ note_raise(raised, ident, order, item, owner, ownerless, topic, who)
852
+ if window and live and key not in seen["Open"]:
853
+ seen["Open"].add(key)
854
+ result["open"].append({"id": ident, "line": item, "topic": topic, "path": relative,
855
+ "owner": default_owner(owner, ownerless, who), "ownerless": ownerless})
856
+ continue
857
+ if not window:
858
+ continue
859
+ row = {"line": line, "topic": topic, "path": relative}
860
+ if section == "Decided":
861
+ attribution = re.search(r"\s+—\s*([^—]+?),\s*((?:\d{2}-\d{2}\s+)?\d{2}:\d{2})\s*$", line)
862
+ row.update(who=who, time=stamp)
863
+ if attribution:
864
+ own_time = attribution[2]
865
+ row.update(line=line[:attribution.start()].strip(), who=attribution[1].strip(),
866
+ time=own_time if len(own_time) > 5 else recorded.strftime("%m-%d ") + own_time)
867
+ key = digest_key(row["line"])
868
+ if not key or key in seen[section]:
869
+ continue
870
+ seen[section].add(key)
871
+ result[section.lower()].append(row)
872
+ result["others"] = result["sessions"] - result["yours"]
873
+ for ident, info in raised.items():
874
+ row = raised_row(ident, info, today)
875
+ event = events.get(ident)
876
+ renewed = event_time(event.get("at")) if event and event.get("state") == "active" else None
877
+ relevant_since = (datetime.fromtimestamp(renewed).date()
878
+ if renewed is not None else info["first"])
879
+ row["relevance_age"] = max(0, (today - relevant_since).days)
880
+ row["relevance_since"] = relevant_since.isoformat()
881
+ stale = (event is not None and event.get("state") == "stale") or row["relevance_age"] > STALE_DAYS
882
+ row["relevance"] = "stale" if stale else "active"
883
+ row["resolution"] = "open"
884
+ if stale:
885
+ result["stale"].append(row)
886
+ elif row["relevance_age"] <= carry_days:
887
+ result["carried"].append(row)
888
+ for section in ("carried", "stale"):
889
+ result[section].sort(key=lambda row: (-row["age"], row["path"], row["line"]))
890
+ if carry_days:
891
+ active = {row["id"] for row in result["carried"]}
892
+ result["open"] = [row for row in result["open"] if row["id"] in active]
893
+ return result
894
+
895
+
896
+ def digest(ctx, argv):
897
+ parser = argparse.ArgumentParser(prog="records.sh digest")
898
+ window = parser.add_mutually_exclusive_group()
899
+ window.add_argument("--days", type=int, default=1)
900
+ window.add_argument("--since")
901
+ parser.add_argument("--all", action="store_true", help="include all authors (the default)")
902
+ try:
903
+ args = parser.parse_args(argv)
904
+ actor_id, reader = native_record_reader(ctx)
905
+ data = collect_digest(ctx.memory, author=ctx.author, person=ctx.display,
906
+ days=args.days, since=args.since, actor_id=actor_id, native_reader=reader)
907
+ except SystemExit:
908
+ return 0
909
+ except (ValueError, OSError, OverflowError) as exc:
910
+ print("Session digest unavailable: " + str(exc))
911
+ return 0
912
+ # Still open and currently relevant, including before the display window.
913
+ shown = {row["id"] for row in data["open"]}
914
+ carried = [row for row in data["carried"] if row["id"] not in shown] if data["since"] <= data["today"] else []
915
+ if any(data[section] for section in ("decided", "open", "risks")):
916
+ others = f", {data['others']} others" if data["others"] else ""
917
+ print(f"Since {data['since']} · {data['sessions']} sessions ({data['yours']} yours{others})")
918
+ elif not any(data[key] for key in ("spirit_intents", "unsupported_native", "held_records")):
919
+ print(f"Nothing recorded since {data['since']}.")
920
+ if data["spirit_intents"]:
921
+ noun = "intent" if data["spirit_intents"] == 1 else "intents"
922
+ print(f"Spirit records · {data['spirit_intents']} {noun} ({data['your_spirit_intents']} yours) · partial coverage")
923
+ for row in data["spirit_records"][:8]:
924
+ state = row.get("state") or {}
925
+ print(f" - {row['control_id']} · {row['producer_principal_id']} · {state.get('execution', 'unknown')} · outcome {state.get('outcome', 'unknown')} · verification {state.get('verification', 'unknown')} · through {row.get('through') or 'unknown'}")
926
+ if data["unsupported_native"]:
927
+ print(f"{data['unsupported_native']} unsupported spirit records · metadata unavailable")
928
+ if data["held_records"]:
929
+ print(f"{data['held_records']} held records · details withheld")
930
+ for section, cap in (("Decided", 8), ("Open", 8), ("Carried", 8), ("Risks", 4)):
931
+ rows = carried if section == "Carried" else data[section.lower()]
932
+ if not rows:
933
+ continue
934
+ print(section)
935
+ for row in rows[:cap]:
936
+ if section in ("Open", "Carried"):
937
+ age = f"{row['age']}d " if section == "Carried" else ""
938
+ line = f" - {row['id']} {age}{row['line']} — {owner_label(row)}"
939
+ else:
940
+ line = " - " + row["line"]
941
+ if section == "Decided":
942
+ line += f" — {row['who']}, {row['time']}"
943
+ line += " · " + row["topic"]
944
+ suffix = " · " + row["path"]
945
+ room = max(1, 180 - len(suffix))
946
+ print((line if len(line) <= room else line[:room - 1] + "…") + suffix)
947
+ if section == "Carried" and len(rows) > cap:
948
+ print(f" … {len(rows) - cap} more · /records open")
949
+ return 0
950
+
951
+
952
+ def item_argument(value):
953
+ ident = str(value).strip().lower()
954
+ if not ITEM_ID.fullmatch(ident):
955
+ raise ValueError("an open item id is six hex digits, as records.sh open prints it")
956
+ return ident
957
+
958
+
959
+ def find_item(ctx, ident):
960
+ """The item behind an id: as carried, or else from every record that raised it."""
961
+ today = datetime.now().date()
962
+ carried = collect_digest(ctx.memory, days=0, today=today, events={})["carried"]
963
+ found = next((row for row in carried if row["id"] == ident), None)
964
+ if found is not None:
965
+ return found
966
+ raised = {}
967
+ for path, meta, text in record_files(ctx.memory):
968
+ if meta.get("kind") != "session-record" or classify_record(meta) != "human-session":
969
+ continue
970
+ recorded, local_stamp = record_day(meta)
971
+ if recorded is None or recorded > today:
972
+ continue
973
+ order = (recorded, local_stamp.timestamp() if local_stamp is not None else 0.0,
974
+ str(Path(path).relative_to(ctx.memory)))
975
+ topic = " ".join(str(meta.get("topic") or "session").split())
976
+ who = " ".join(str(meta.get("from") or meta.get("author") or "unstated").split())
977
+ for section, line in digest_lines(text):
978
+ if section == "Open":
979
+ item, owner, ownerless = open_owner(line)
980
+ key = digest_key(item)
981
+ if key and item_id(key) == ident:
982
+ note_raise(raised, ident, order, item, owner, ownerless, topic, who)
983
+ return raised_row(ident, raised[ident], today) if ident in raised else None
984
+
985
+
986
+ def append_ledger(ctx, name, event, message, validate=None):
987
+ """Append one JSON object to the ledger memory/sessions/<name> under the
988
+ memory lock and commit it like every records write. Writers only ever
989
+ append; the union merge rule beside the ledger keeps both sides' lines when
990
+ two people's histories meet."""
991
+ if name not in (LEDGER, GATE_LEDGER, "outcomes.jsonl", "interventions.jsonl"):
992
+ raise ValueError("unknown records ledger; nothing was written")
993
+ sessions = sessions_directory(ctx.memory)
994
+ if sessions is None:
995
+ candidate = ctx.memory / "sessions"
996
+ if (name in ("outcomes.jsonl", "interventions.jsonl") and ctx.memory.is_dir()
997
+ and not candidate.exists() and not candidate.is_symlink()):
998
+ sessions = candidate
999
+ else:
1000
+ raise ValueError("this egregore has no session records; nothing was written")
1001
+ label = name.removesuffix(".jsonl")
1002
+ ledger, attributes = sessions / name, sessions / ".gitattributes"
1003
+ lock = ctx.memory_lock()
1004
+ if not lock.acquire(wait=120):
1005
+ raise RuntimeError("memory writer busy; nothing was written")
1006
+ try:
1007
+ # Recheck permissions and the expected previous event under the same
1008
+ # lock as the append. False means an idempotent retry already exists.
1009
+ if validate is not None and validate() is False:
1010
+ return event
1011
+ # A first outcome may precede the first completed session record.
1012
+ # Create its directory only after policy validation, under the lock.
1013
+ sessions.mkdir(mode=0o755, exist_ok=True)
1014
+ if sessions_directory(ctx.memory) != sessions:
1015
+ raise ValueError("the session records directory is unavailable; nothing was written")
1016
+ data = (json.dumps(event, ensure_ascii=False) + "\n").encode("utf-8")
1017
+ if len(data) > 65536:
1018
+ raise ValueError("records event exceeds the ledger limit; nothing was written")
1019
+ try:
1020
+ descriptor = os.open(ledger, os.O_RDWR | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o644)
1021
+ except OSError as exc:
1022
+ raise ValueError(f"the {label} ledger cannot be opened; nothing was written") from exc
1023
+ try:
1024
+ info = os.fstat(descriptor)
1025
+ if not stat.S_ISREG(info.st_mode):
1026
+ raise ValueError(f"the {label} ledger is not a regular file; nothing was written")
1027
+ if info.st_size and os.pread(descriptor, 1, info.st_size - 1) != b"\n":
1028
+ data = b"\n" + data
1029
+ os.write(descriptor, data)
1030
+ finally:
1031
+ os.close(descriptor)
1032
+ paths = [ledger]
1033
+ rule = name + " merge=union"
1034
+ try:
1035
+ current = attributes.read_text(encoding="utf-8") if stat.S_ISREG(attributes.lstat().st_mode) else None
1036
+ except FileNotFoundError:
1037
+ current = ""
1038
+ if current is not None:
1039
+ if rule not in current.splitlines():
1040
+ atomic(attributes, current + ("\n" if current and not current.endswith("\n") else "") + rule + "\n", 0o644)
1041
+ paths.append(attributes)
1042
+ commit(ctx, paths, message)
1043
+ finally:
1044
+ lock.release()
1045
+ return event
1046
+
1047
+
1048
+ def append_event(ctx, ident, state, message, issue=None):
1049
+ """One open-items event: the id, its state, who and when, and a filed item's issue."""
1050
+ if not ctx.actor_id:
1051
+ raise ValueError("Runtime actor identity is unavailable; nothing was written")
1052
+ event = {"id": ident, "state": state, "by": ctx.actor_id,
1053
+ "at": datetime.now(timezone.utc).isoformat(timespec="seconds")}
1054
+ if issue:
1055
+ event["issue"] = issue
1056
+ return append_ledger(ctx, LEDGER, event, message)
1057
+
1058
+
1059
+ # The merge gate's decisions (bin/merge-gate.sh): one line per merge and per
1060
+ # refusal of red checks without a receipt, beside the open-items ledger.
1061
+ GATE_LEDGER = "gate.jsonl"
1062
+ GATE_KINDS = ("merge", "refuse")
1063
+ # CI's aggregate job fails whenever another job does, so it is never a flake.
1064
+ GATE_AGGREGATE = re.compile(r"Full CI result(?: — .*)?")
1065
+ GATE_JOB_NAME = 120
1066
+ GATE_COUNT = re.compile(r"0|[1-9][0-9]{0,8}")
1067
+ GATE_POSITIVE = re.compile(r"[1-9][0-9]{0,8}")
1068
+
1069
+
1070
+ def harness_session_id():
1071
+ """The running harness's own session id, the one its session record carries."""
1072
+ return (os.environ.get("EGREGORE_NATIVE_SESSION_ID") or os.environ.get("CLAUDE_CODE_SESSION_ID")
1073
+ or os.environ.get("CODEX_THREAD_ID"))
1074
+
1075
+
1076
+ def gate_session(root, flag=None):
1077
+ """Only a native session can join a decision to a session record."""
1078
+ if flag is not None:
1079
+ if not SAFE_ID.fullmatch(flag):
1080
+ raise ValueError("--session takes a session id; nothing was written")
1081
+ return flag
1082
+ session = harness_session_id()
1083
+ return session if session and SAFE_ID.fullmatch(session) else None
1084
+
1085
+
1086
+ class LedgerArguments(argparse.ArgumentParser):
1087
+ """An argument error as one line, the reason the gate prints, not a usage text."""
1088
+ def error(self, message):
1089
+ raise ValueError(message + "; nothing was written")
1090
+
1091
+
1092
+ def gate_event(argv):
1093
+ """Append one merge-gate decision to the gate ledger and print the line."""
1094
+ parser = LedgerArguments(prog="session_record.py gate-event", add_help=False)
1095
+ for flag in ("--root", "--kind", "--pr", "--head", "--base", "--repo", "--green", "--neutral", "--red"):
1096
+ parser.add_argument(flag, required=True)
1097
+ for flag in ("--memory", "--attempts", "--session"):
1098
+ parser.add_argument(flag)
1099
+ parser.add_argument("--flaked", action="append")
1100
+ parser.add_argument("--recovered", action="append")
1101
+ parser.add_argument("--receipt", action="store_true")
1102
+ parser.add_argument("--dry-run", action="store_true")
1103
+ args = parser.parse_args(argv)
1104
+ checks = {name: getattr(args, name) for name in ("green", "neutral", "red")}
1105
+ flaked = [job for job in args.flaked or [] if not GATE_AGGREGATE.fullmatch(job)]
1106
+ recovered = [job for job in args.recovered or [] if not GATE_AGGREGATE.fullmatch(job)]
1107
+ if args.kind not in GATE_KINDS:
1108
+ raise ValueError("--kind is merge or refuse; nothing was written")
1109
+ if not GATE_POSITIVE.fullmatch(args.pr):
1110
+ raise ValueError("--pr takes a pull request number; nothing was written")
1111
+ if not re.fullmatch(r"[0-9a-f]{40}", args.head):
1112
+ raise ValueError("--head takes the full 40-hex commit; nothing was written")
1113
+ if not re.fullmatch(r"\S{1,255}", args.base):
1114
+ raise ValueError("--base takes a branch name; nothing was written")
1115
+ if not re.fullmatch(r"[A-Za-z0-9_-]{1,100}/(?!\.\.?\Z)[A-Za-z0-9_.-]{1,100}", args.repo):
1116
+ raise ValueError("--repo takes OWNER/NAME; nothing was written")
1117
+ if not all(GATE_COUNT.fullmatch(value) for value in checks.values()):
1118
+ raise ValueError("--green, --neutral and --red take counts; nothing was written")
1119
+ if args.attempts is not None and not GATE_POSITIVE.fullmatch(args.attempts):
1120
+ raise ValueError("--attempts takes a positive count; nothing was written")
1121
+ if any(not job.strip() or len(job) > GATE_JOB_NAME for job in flaked + recovered):
1122
+ raise ValueError(f"--flaked and --recovered take a job name of 1 to {GATE_JOB_NAME} characters; "
1123
+ "nothing was written")
1124
+ ctx = Context(args.root)
1125
+ if args.memory:
1126
+ ctx.memory = Path(args.memory).resolve()
1127
+ event = {"at": datetime.now(timezone.utc).isoformat(timespec="seconds"), "kind": args.kind,
1128
+ "pr": int(args.pr), "head": args.head, "base": args.base, "repo": args.repo,
1129
+ "checks": {name: int(value) for name, value in checks.items()}}
1130
+ if args.attempts is not None:
1131
+ event["attempts"] = int(args.attempts)
1132
+ event["flaked"] = list(dict.fromkeys(flaked))
1133
+ # A job is one or the other: a failure in an earlier attempt is proof and
1134
+ # a cancel is not, so a job named in both flaked.
1135
+ event["recovered"] = [job for job in dict.fromkeys(recovered) if job not in event["flaked"]]
1136
+ event["receipt"] = args.receipt
1137
+ if not ctx.actor_id:
1138
+ raise ValueError("Runtime actor identity is unavailable; nothing was written")
1139
+ event["by"] = ctx.actor_id
1140
+ session = gate_session(ctx.root, args.session)
1141
+ # A decision made outside a harness session is still recorded, without a
1142
+ # session, so it joins no record; records off or a held session withhold it.
1143
+ if not ctx.enabled() or (session and ctx.held(session)):
1144
+ print("record controls withhold this gate event", file=sys.stderr)
1145
+ return 3
1146
+ if session:
1147
+ event["session"] = session
1148
+ if not args.dry_run:
1149
+ append_ledger(ctx, GATE_LEDGER, event, f"chore(sessions): record gate verdict for #{event['pr']}")
1150
+ print(json.dumps(event, ensure_ascii=False))
1151
+ return 0
1152
+
1153
+
1154
+ def open_items(ctx, argv):
1155
+ parser = argparse.ArgumentParser(prog="records.sh open")
1156
+ selection = parser.add_mutually_exclusive_group()
1157
+ selection.add_argument("--stale", action="store_true")
1158
+ selection.add_argument("--all", action="store_true")
1159
+ parser.add_argument("--mine", action="store_true", help="only the items you own, named or by default")
1160
+ parser.add_argument("--json", action="store_true")
1161
+ args = parser.parse_args(argv)
1162
+ data = collect_digest(ctx.memory, author=ctx.author, person=ctx.display, days=0)
1163
+ rows = data["stale"] if args.stale else data["carried"]
1164
+ if args.all:
1165
+ rows = sorted(data["carried"] + data["stale"], key=lambda row: (-row["age"], row["id"]))
1166
+ if args.mine:
1167
+ rows = mine(rows, ctx.identities())
1168
+ if args.json:
1169
+ print(json.dumps({"active_count": len(data["carried"]), "stale_count": len(data["stale"]),
1170
+ "items": rows}, ensure_ascii=False))
1171
+ return 0
1172
+ label = "open but stale" if args.stale else ("open" if args.all else "active open")
1173
+ label += " items of yours" if args.mine else " items"
1174
+ if not rows:
1175
+ print(f"No {label}.")
1176
+ return 0
1177
+ print(f"{len(rows)} {label}, oldest first")
1178
+ width = max(len(clip(owner_label(row, " (default)"), 24)) for row in rows)
1179
+ for row in rows:
1180
+ status = f" [{row['relevance']}]" if args.all or args.stale else ""
1181
+ print(f"{row['id']} {row['age']:>2}d{status} {clip(owner_label(row, ' (default)'), 24):<{width}} "
1182
+ f"{clip(row['line'], 160)} · {row['path']}")
1183
+ if not args.stale and data["stale"]:
1184
+ print(f"{len(data['stale'])} open but stale · /records open --stale")
1185
+ print("Close one with /records close <id>; draft an issue for one with /records file <id>.")
1186
+ return 0
1187
+
1188
+
1189
+ def relevance_item(ctx, argv, state):
1190
+ parser = argparse.ArgumentParser(prog="records.sh " + ("reactivate" if state == "active" else "stale"))
1191
+ parser.add_argument("id")
1192
+ ident = item_argument(parser.parse_args(argv).id)
1193
+ item = find_item(ctx, ident)
1194
+ if item is None:
1195
+ raise ValueError(f"no open item {ident} in the session records; nothing was written")
1196
+ previous = ledger_events(ctx.memory).get(ident)
1197
+ if previous and previous.get("state") == "filed":
1198
+ raise ValueError("this item is filed; continue in its linked issue instead of reactivating the carry")
1199
+ if state == "stale" and previous and previous.get("state") not in ("active", "stale"):
1200
+ raise ValueError("this item is resolved; only explicit reactivation can reopen it")
1201
+ append_event(ctx, ident, state, f"chore(sessions): {'reactivate' if state == 'active' else 'mark stale'} open item {ident}")
1202
+ print(f"{'Reactivated' if state == 'active' else 'Marked stale'} {ident}; original age {item['age']}d · {item['path']}")
1203
+ return 0
1204
+
1205
+
1206
+ def close_item(ctx, argv):
1207
+ parser = argparse.ArgumentParser(prog="records.sh close")
1208
+ parser.add_argument("id")
1209
+ ident = item_argument(parser.parse_args(argv).id)
1210
+ item = find_item(ctx, ident)
1211
+ if item is None:
1212
+ raise ValueError(f"no open item {ident} in the session records; nothing was written")
1213
+ before = ledger_events(ctx.memory).get(ident)
1214
+ append_event(ctx, ident, "closed", f"chore(sessions): close open item {ident}")
1215
+ again = " (already closed)" if before and before.get("state") == "closed" else ""
1216
+ print(f"Closed {ident}{again} · {item['line']} · {item['path']}")
1217
+ return 0
1218
+
1219
+
1220
+ def issue_text(ident, item):
1221
+ """The draft's title and description: the item line, record path, owner and age."""
1222
+ days = item["age"]
1223
+ return clip(item["line"], 200), "\n".join((
1224
+ item["line"], "",
1225
+ f"Open item {ident} from the session records.",
1226
+ f"Record: memory/{item['path']}",
1227
+ f"Owner: {owner_label(item, ' (by default)')}",
1228
+ f"Open for {days} day{'s' if days != 1 else ''}, since {item['first']}."))
1229
+
1230
+
1231
+ def run_issue(ctx, arguments):
1232
+ """One call into the issue flow; its JSON receipt, or its own error."""
1233
+ env = dict(clean_env(), EGREGORE_ROOT=str(ctx.root))
1234
+ try:
1235
+ result = subprocess.run(["bash", str(ctx.root / "bin/issue.sh"), *arguments], env=env, cwd=ctx.root,
1236
+ capture_output=True, text=True, timeout=180)
1237
+ except (OSError, subprocess.TimeoutExpired) as exc:
1238
+ raise RuntimeError("the issue flow did not answer; nothing was written") from exc
1239
+ if result.returncode:
1240
+ detail = (result.stderr.strip() or result.stdout.strip() or f"exit {result.returncode}").splitlines()
1241
+ raise RuntimeError(detail[-1] + "; nothing was written")
1242
+ try:
1243
+ receipt = json.loads(result.stdout)
1244
+ except ValueError:
1245
+ receipt = None
1246
+ if not isinstance(receipt, dict):
1247
+ raise RuntimeError("the issue flow returned no receipt; nothing was written")
1248
+ return receipt
1249
+
1250
+
1251
+ def issue_draft(ctx, request, ident):
1252
+ """The issue flow's durable draft for a request, refused unless it is this item's."""
1253
+ try:
1254
+ valid = str(UUID(request)) == request
1255
+ except ValueError:
1256
+ valid = False
1257
+ if not valid:
1258
+ raise ValueError("--confirm takes the request id that records.sh file printed; nothing was filed")
1259
+ path = ctx.root / ".egregore/runtime/issues/requests" / (request + ".json")
1260
+ try:
1261
+ info = path.lstat()
1262
+ if not stat.S_ISREG(info.st_mode) or info.st_size > 1024 * 1024:
1263
+ raise ValueError
1264
+ state = json.loads(path.read_text(encoding="utf-8"))
1265
+ except (OSError, ValueError):
1266
+ raise ValueError(f"no issue draft {request} on this machine; nothing was filed") from None
1267
+ payload = state.get("payload") if isinstance(state, dict) else None
1268
+ if (not isinstance(payload, dict) or state.get("request_id") != request or state.get("operation") != "create"
1269
+ or not isinstance(payload.get("title"), str) or not isinstance(payload.get("body"), str)
1270
+ or payload.get("labels") != ISSUE_LABELS
1271
+ or f"Open item {ident} from the session records." not in payload["body"].splitlines()):
1272
+ raise ValueError(f"request {request} is not the issue draft for open item {ident}; nothing was filed")
1273
+ return payload
1274
+
1275
+
1276
+ def file_item(ctx, argv):
1277
+ """Prepare an issue draft; file it only on --confirm with the draft's request id."""
1278
+ parser = argparse.ArgumentParser(prog="records.sh file")
1279
+ parser.add_argument("id")
1280
+ parser.add_argument("--confirm", metavar="REQUEST_ID")
1281
+ args = parser.parse_args(argv)
1282
+ ident = item_argument(args.id)
1283
+ if not ctx.author:
1284
+ raise ValueError("your GitHub username is not in .egregore-state.json; nothing was drafted")
1285
+ item = find_item(ctx, ident)
1286
+ if item is None:
1287
+ raise ValueError(f"no open item {ident} in the session records; nothing was drafted")
1288
+ last = ledger_events(ctx.memory).get(ident)
1289
+ if last and last.get("state") == "filed":
1290
+ print(f"{ident} is already filed" + (": " + last["issue"] if isinstance(last.get("issue"), str) else ""))
1291
+ return 0
1292
+ if args.confirm is None:
1293
+ title, description = issue_text(ident, item)
1294
+ receipt = run_issue(ctx, ["create", "--title", title, "--description", description, *ISSUE_FLAGS, "--draft"])
1295
+ request = receipt.get("request_id")
1296
+ if receipt.get("status") != "draft" or not isinstance(request, str):
1297
+ raise RuntimeError("the issue flow prepared no draft; nothing was written")
1298
+ print(f"Issue draft for open item {ident} · nothing is filed yet")
1299
+ if isinstance(receipt.get("repo"), str):
1300
+ print(" tracker: " + receipt["repo"])
1301
+ print(" title: " + title)
1302
+ print(" description:")
1303
+ for line in description.splitlines():
1304
+ print((" " + line).rstrip())
1305
+ print(" request: " + request)
1306
+ print(f"Once the person confirms this draft: bash bin/records.sh file {ident} --confirm {request}")
1307
+ return 0
1308
+ payload = issue_draft(ctx, args.confirm, ident)
1309
+ receipt = run_issue(ctx, ["create", "--title", payload["title"], "--description", payload["body"],
1310
+ *ISSUE_FLAGS, "--request-id", args.confirm])
1311
+ url = receipt.get("github_url")
1312
+ if receipt.get("status") != "submitted" or not isinstance(url, str) or not url.startswith("https://github.com/"):
1313
+ raise RuntimeError(f"the issue was not filed ({receipt.get('status', 'no status')}); nothing was written")
1314
+ try:
1315
+ append_event(ctx, ident, "filed", f"chore(sessions): file open item {ident} as an issue", issue=url)
1316
+ except (ValueError, RuntimeError, OSError) as exc:
1317
+ # The same request id reconciles without a second issue, then records the event.
1318
+ print(f"Filed {ident} as {url}, but its ledger event was not recorded ({exc}). "
1319
+ "Run the same command again to record it.")
1320
+ return 1
1321
+ print(f"Filed {ident} as {url}")
1322
+ return 0
1323
+
1324
+
1325
+ def open_items_line(memory, today=None, identities=()):
1326
+ """Keep current work bounded while making retained stale obligations
1327
+ visible. With the person's identities, the line counts the items that are
1328
+ theirs and a second line names their oldest three, so a thread left
1329
+ behind reaches the one who left it."""
1330
+ data = collect_digest(memory, days=0, today=today)
1331
+ rows, stale = data["carried"], data["stale"]
1332
+ tail = f" · {len(stale)} open but stale · /records open --stale" if stale else ""
1333
+ if not rows:
1334
+ return tail.lstrip(" ·")
1335
+ yours = mine(rows, identities)
1336
+ pointer = f" · {len(yours)} yours · /records open --mine" if yours else ""
1337
+ head = f"{len(rows)} open item{'s' if len(rows) != 1 else ''} carried (oldest {rows[0]['age']}d: \""
1338
+ room = min(60, REFLEX_LINE - len(head) - len('\")') - len(pointer) - len(tail))
1339
+ first = head + clip(rows[0]["line"], max(10, room)) + '\")' + pointer + tail
1340
+ if not yours:
1341
+ return first
1342
+ oldest = yours[:3]
1343
+ lead = "yours, oldest: "
1344
+ fixed = len(lead) + sum(len(f"{row['id']} {row['age']}d \"\"") for row in oldest) + 3 * (len(oldest) - 1)
1345
+ each = max(10, min(60, (REFLEX_LINE - fixed) // len(oldest)))
1346
+ second = lead + " · ".join(f"{row['id']} {row['age']}d \"{clip(row['line'], each)}\"" for row in oldest)
1347
+ return first + "\n" + second
1348
+
1349
+
1350
+ # Vitals: the organization's pulse over the last VITALS_DAYS days, today
1351
+ # included, beside the VITALS_DAYS before them. Sessions, harnesses, cost and
1352
+ # friction come from the records, read once and dated as the digest dates
1353
+ # them; merges, refusals, flaked jobs and receipts come from the merge gate's
1354
+ # ledger beside them. A held stub is a session with a cost and no friction.
1355
+ VITALS_DAYS = 7
1356
+ VITALS_MAX_DAYS = 366
1357
+ FRICTION_HEADING = re.compile(r"^##[ \t]+Friction[ \t\r]*$", re.M)
1358
+ FRICTION_COUNTS = re.compile(r"- (tool errors|non-zero exits|worktree-guard refusals|recall failures): (.*)")
1359
+ FRICTION_COUNT = re.compile(r"(?:^|\s)(\d{1,9})$")
1360
+
1361
+
1362
+ def record_friction(text):
1363
+ """The counts on a record's tool-error, non-zero-exit, worktree-guard and
1364
+ recall-failure lines. The last Friction heading is the writer's, since a
1365
+ full record's prompt excerpts come before it; each counter counts once."""
1366
+ headings = list(FRICTION_HEADING.finditer(text))
1367
+ if not headings:
1368
+ return None
1369
+ section = text[headings[-1].end():]
1370
+ end = re.search(r"^##[ \t]", section, re.M)
1371
+ total, seen = 0, set()
1372
+ for line in section[:end.start() if end else None].splitlines():
1373
+ match = FRICTION_COUNTS.fullmatch(line.strip())
1374
+ if match is None or match[1] in seen:
1375
+ continue
1376
+ counts = [int(found[1]) for found in (FRICTION_COUNT.search(part.strip()) for part in match[2].split(","))
1377
+ if found]
1378
+ if counts:
1379
+ seen.add(match[1])
1380
+ total += sum(counts)
1381
+ return total if seen else None
1382
+
1383
+
1384
+ def vitals_cost(value):
1385
+ """A finite, nonnegative cost; unavailable is distinct from measured zero."""
1386
+ if isinstance(value, bool):
1387
+ return None
1388
+ try:
1389
+ number = float(value)
1390
+ except (TypeError, ValueError):
1391
+ return None
1392
+ return number if math.isfinite(number) and number >= 0 else None
1393
+
1394
+
1395
+ def gate_observations(memory):
1396
+ """The merge gate's merge and refuse events, read through ledger_lines.
1397
+ Coverage describes the file read, not historical capture: partial when a
1398
+ non-blank line does not parse or a line too long to read was skipped.
1399
+ A blank, malformed or unknown line is skipped, never fatal."""
1400
+ sessions = sessions_directory(memory)
1401
+ if sessions is None:
1402
+ return [], "missing"
1403
+ try:
1404
+ info = (sessions / GATE_LEDGER).lstat()
1405
+ except FileNotFoundError:
1406
+ return [], "missing"
1407
+ except OSError:
1408
+ return [], "unavailable"
1409
+ if not stat.S_ISREG(info.st_mode):
1410
+ return [], "unavailable"
1411
+ events, coverage, read = [], "complete", 0
1412
+ for line in ledger_lines(memory, GATE_LEDGER):
1413
+ read += len(line.encode("utf-8"))
1414
+ try:
1415
+ event = json.loads(line)
1416
+ except (ValueError, RecursionError):
1417
+ if line.strip():
1418
+ coverage = "partial"
1419
+ continue
1420
+ if isinstance(event, dict) and event.get("kind") in GATE_KINDS:
1421
+ events.append(event)
1422
+ # ledger_lines skips a line past its bound and yields nothing it cannot open.
1423
+ if read < info.st_size:
1424
+ coverage = "partial" if read else "unavailable"
1425
+ return events, coverage
1426
+
1427
+
1428
+ def vitals(memory, *, days=VITALS_DAYS, today=None):
1429
+ """The last ``days`` days to today as ``now``, beside the ``days`` before
1430
+ them as ``before``. A record counts on its local date, as in the digest; a
1431
+ gate event counts on the local date of its time. Writes nothing."""
1432
+ today = today or datetime.now().date()
1433
+ if isinstance(today, str):
1434
+ today = date.fromisoformat(today)
1435
+ days = max(1, min(int(days), VITALS_MAX_DAYS))
1436
+ start = today - timedelta(days=days - 1)
1437
+ earliest = start - timedelta(days=days)
1438
+ tally = {name: {"sessions": 0, "by_harness": Counter(), "cost": 0.0, "priced": 0, "friction": 0, "detailed": 0,
1439
+ "merges": 0, "refused": 0, "flaked": 0, "receipts": 0,
1440
+ "retried": 0, "retry_observations": 0} for name in ("now", "before")}
1441
+ for _, meta, text in record_files(memory, start=earliest, end=today):
1442
+ recorded = record_day(meta)[0] if meta.get("kind") == "session-record" else None
1443
+ if recorded is None or not earliest <= recorded <= today:
1444
+ continue
1445
+ period = tally["now" if recorded >= start else "before"]
1446
+ harness = meta.get("harness")
1447
+ period["sessions"] += 1
1448
+ period["by_harness"][" ".join(harness.split()).lower() if isinstance(harness, str) and harness.strip()
1449
+ else "unknown"] += 1
1450
+ cost = vitals_cost(meta.get("cost_usd")) if meta.get("cost_source") != "unavailable" else None
1451
+ if cost is not None:
1452
+ period["cost"] += cost
1453
+ period["priced"] += 1
1454
+ if meta.get("detail") != "stub":
1455
+ friction = record_friction(text)
1456
+ if friction is not None:
1457
+ period["detailed"] += 1
1458
+ period["friction"] += friction
1459
+ events, gate_coverage = gate_observations(memory)
1460
+ for event in events:
1461
+ stamp = event_time(event.get("at"))
1462
+ try:
1463
+ day = datetime.fromtimestamp(stamp).date() if stamp is not None else None
1464
+ except (OverflowError, OSError, ValueError):
1465
+ day = None
1466
+ if day is None or not earliest <= day <= today:
1467
+ if day is None:
1468
+ gate_coverage = "partial"
1469
+ continue
1470
+ period = tally["now" if day >= start else "before"]
1471
+ attempts = event.get("attempts")
1472
+ if isinstance(attempts, int) and not isinstance(attempts, bool) and attempts >= 1:
1473
+ period["retry_observations"] += 1
1474
+ period["retried"] += attempts > 1
1475
+ if event["kind"] == "refuse":
1476
+ period["refused"] += 1
1477
+ continue
1478
+ flaked = event["flaked"] if isinstance(event.get("flaked"), list) else []
1479
+ period["merges"] += 1
1480
+ period["flaked"] += sum(isinstance(job, str) and bool(job.strip()) for job in flaked)
1481
+ period["receipts"] += event.get("receipt") is True
1482
+ result = {"days": days, "today": today.isoformat(), "gate_coverage": gate_coverage,
1483
+ "gate_capture_coverage": "unknown", "gate_counts_basis": "observed_events"}
1484
+ for name, period in tally.items():
1485
+ result[name] = {"sessions": period["sessions"],
1486
+ "by_harness": dict(sorted(period["by_harness"].items(), key=lambda item: (-item[1], item[0]))),
1487
+ "cost_usd": round(period["cost"], 2) if period["priced"] else None,
1488
+ "priced_sessions": period["priced"],
1489
+ "friction": round(period["friction"] / period["detailed"], 1) if period["detailed"] else None,
1490
+ "friction_sessions": period["detailed"],
1491
+ "retried_decisions": period["retried"] if period["retry_observations"] else None,
1492
+ "retry_observations": period["retry_observations"],
1493
+ **{key: int(period[key]) if gate_coverage in ("complete", "partial") else None
1494
+ for key in ("merges", "refused", "flaked", "receipts")}}
1495
+ result["unit_economics"] = {
1496
+ "status": "unavailable", "cost_per_accepted_outcome_usd": None,
1497
+ "accepted_outcomes": None, "joined_sessions": None,
1498
+ "reason": "Verified accepted outcomes are not joined to native sessions and their complete costs.",
1499
+ }
1500
+ result["findings"] = vitals_findings(result)
1501
+ return result
1502
+
1503
+
1504
+ def vitals_findings(data):
1505
+ """Deterministic proposals from aggregate evidence. No inference of intent,
1506
+ acceptance or savings; no reads, writes, subprocesses or model calls."""
1507
+ now, before = data["now"], data["before"]
1508
+ findings = []
1509
+
1510
+ def add(code, severity, observation, coverage, action, verification):
1511
+ findings.append({"code": code, "severity": severity, "observation": observation,
1512
+ "coverage": coverage, "action": action, "verification": verification})
1513
+
1514
+ if not now["sessions"]:
1515
+ add("records_missing", "warning", "No session records are available in the current period.",
1516
+ {"sessions": 0, "days": data["days"]},
1517
+ "Check eligible record capture before using this period as a baseline.",
1518
+ "An eligible unheld session produces a current record; no activity is inferred from missing records.")
1519
+ priced = {name: {"observed": period["priced_sessions"], "total": period["sessions"]}
1520
+ for name, period in (("now", now), ("before", before))}
1521
+ if any(row["observed"] < row["total"] for row in priced.values()):
1522
+ add("cost_coverage_gap", "warning",
1523
+ f"Cost is available for {now['priced_sessions']}/{now['sessions']} current records "
1524
+ f"and {before['priced_sessions']}/{before['sessions']} previous records.",
1525
+ priced,
1526
+ "Repair cost-source coverage for eligible records and keep unpriced sessions visible.",
1527
+ "Each total names its priced denominator; an unpriced session never contributes a measured zero.")
1528
+ unknown = {name: period["by_harness"].get("unknown", 0) for name, period in (("now", now), ("before", before))}
1529
+ if any(unknown.values()):
1530
+ add("harness_coverage_gap", "warning",
1531
+ f"Harness is unknown for {unknown['now']} current and {unknown['before']} previous records.",
1532
+ {"unknown": unknown, "held_policy": "Do not recover metadata withheld by a hold."},
1533
+ "Preserve verified harness metadata in future unheld records; keep held and legacy omissions unknown.",
1534
+ "Unheld records name their verified harness; held stubs remain unchanged and count as unknown.")
1535
+ measured = {name: {"observed": period["friction_sessions"], "total": period["sessions"]}
1536
+ for name, period in (("now", now), ("before", before))}
1537
+ if any(row["observed"] < row["total"] for row in measured.values()):
1538
+ add("friction_coverage_gap", "info",
1539
+ "Some records have no measured friction counters.",
1540
+ measured,
1541
+ "Keep unobserved and held records outside the friction denominator.",
1542
+ "Missing counters remain null, and each comparison names its measured-record count.")
1543
+ if now["friction"] is not None:
1544
+ previous = f"{before['friction']:.1f}" if before["friction"] is not None else "unavailable"
1545
+ add("friction_signal", "warning" if now["friction"] > 0 else "info",
1546
+ f"Friction counters average {now['friction']:.1f} per measured current record; previous: {previous}.",
1547
+ {**measured, "limitation": "Counters overlap; this is not a unique-error rate or proof of improvement."},
1548
+ "Inspect repeated failure classes and verify one repair against comparable records.",
1549
+ "The repaired failure no longer reproduces; compare the same counter definitions and report coverage.")
1550
+ gate = data["gate_coverage"]
1551
+ if gate != "complete":
1552
+ add("gate_coverage_gap", "warning",
1553
+ f"Merge-gate ledger read coverage is {gate}.",
1554
+ {"ledger": gate, "merges": now["merges"], "refusals": now["refused"]},
1555
+ "Verify gate-ledger capture and bounded-read coverage before interpreting merge or refusal counts.",
1556
+ "An isolated merge and refusal each leave one valid event; missing and partial evidence stay labelled.")
1557
+ add("gate_capture_coverage_gap", "warning",
1558
+ "Gate counts include observed events only; capture coverage over both periods is unknown.",
1559
+ {"temporal": data["gate_capture_coverage"], "counts_basis": data["gate_counts_basis"], "ledger": gate,
1560
+ "limitation": "A complete ledger read does not prove continuous capture or that all eligible decisions were recorded."},
1561
+ "Establish and verify a prospective capture window while retaining unknown historical coverage.",
1562
+ "Check eligible decisions within that window; zero observed events never asserts zero actual decisions, "
1563
+ "and the first event does not establish capture start.")
1564
+ if now["retried_decisions"]:
1565
+ add("ci_retries_observed", "warning",
1566
+ f"{now['retried_decisions']} gate decisions observed CI attempts above one.",
1567
+ {"observed_decisions": now["retry_observations"], "ledger": gate,
1568
+ "limitation": "Repeated decisions may refer to the same head; this is not a count of unique reruns."},
1569
+ "Identify the repeated jobs and fix their deterministic failure or flake without broadening reruns.",
1570
+ "The affected job passes its targeted repeat check and a later measured head needs one CI attempt.")
1571
+ if now["refused"]:
1572
+ add("gate_refusals_observed", "warning",
1573
+ f"The gate recorded {now['refused']} refusals in the current period.",
1574
+ {"ledger": gate, "refusals": now["refused"]},
1575
+ "Inspect refusal reasons and repair the failing check or invalid submission; preserve the merge gate.",
1576
+ "The original refusal is reproduced, then a corrected submission passes the unchanged gate.")
1577
+ if now["flaked"]:
1578
+ add("ci_flakes_observed", "warning",
1579
+ f"Merged decisions name {now['flaked']} flaked-job observations.",
1580
+ {"ledger": gate, "limitation": "The same job can appear on more than one merge."},
1581
+ "Reproduce the named flaky jobs with isolated fixtures and repair the cause.",
1582
+ "Focused repeated runs pass without treating an aggregate result as a flaky job.")
1583
+ add("accepted_outcome_link_missing", "info",
1584
+ "Cost per accepted outcome is unavailable.",
1585
+ {"status": "unavailable", "accepted_outcomes": None, "joined_sessions": None,
1586
+ "limitation": "Record-date costs and merge counts do not establish accepted outcomes or task costs."},
1587
+ "Join verified accepted outcomes to native sessions and complete task costs before calculating unit cost.",
1588
+ "Acceptance, native-session attribution and cost coverage are verified together; merges alone are never the denominator.")
1589
+ return findings
1590
+
1591
+
1592
+ def vitals_findings_text(data):
1593
+ """Render the explicit scorecard with the shared terminal width."""
1594
+ rows = ["◦ RECORDS SCORECARD"]
1595
+ for finding in data["findings"]:
1596
+ rows.append(None)
1597
+ rows.extend(textwrap.wrap(("● " if finding["severity"] == "warning" else "◦ ")
1598
+ + finding["observation"], width=68))
1599
+ rows.extend(textwrap.wrap("→ " + finding["action"], width=68))
1600
+ rows.extend(textwrap.wrap("Verify: " + finding["verification"], width=68))
1601
+ for reference in finding.get("evidence", [])[:3]:
1602
+ rows.extend(textwrap.wrap("Evidence: " + reference, width=68))
1603
+ limitation = finding["coverage"].get("limitation")
1604
+ if limitation:
1605
+ rows.extend(textwrap.wrap("Coverage: " + limitation, width=68))
1606
+ return "\n".join(["┌" + "─" * 70 + "┐",
1607
+ *("├" + "─" * 70 + "┤" if row is None else f"│ {row:<68}│" for row in rows),
1608
+ "└" + "─" * 70 + "┘"])
1609
+
1610
+
1611
+ def vitals_text(label, period, gate, per):
1612
+ """One period's line: its sessions, the harnesses when more than one has
1613
+ records, cost, friction, and the gate events when either period has any."""
1614
+ def count(number, noun):
1615
+ return f"{number} {noun}{'' if number == 1 else 's'}"
1616
+ cost = period["cost_usd"]
1617
+ money = "cost unavailable"
1618
+ if cost is not None:
1619
+ cost = round(cost, 2)
1620
+ money = f"${cost:,.0f}" if cost >= 10 else f"${cost:.2f}" if cost > 0 else "$0"
1621
+ if period.get("priced_sessions", period["sessions"]) < period["sessions"]:
1622
+ money += f" ({period['priced_sessions']}/{period['sessions']} priced)"
1623
+ friction = period["friction"]
1624
+ tail = f" · {money} · friction " + (f"{friction:.1f}{per}" if friction is not None else "unavailable")
1625
+ if gate:
1626
+ tail += " · observed " + count(period["merges"], "merge") + "".join(
1627
+ ", " + text for number, text in ((period["flaked"], f"{period['flaked']} flaked"),
1628
+ (period["refused"], f"{period['refused']} refused"),
1629
+ (period["receipts"], count(period["receipts"], "receipt"))) if number)
1630
+ if gate == "partial":
1631
+ tail += " (partial)"
1632
+ head = f"{label} {count(period['sessions'], 'session')}"
1633
+ if len(period["by_harness"]) > 1:
1634
+ line = head + " (" + ", ".join(f"{number} {name}" for name, number in period["by_harness"].items()) + ")" + tail
1635
+ if len(line) <= REFLEX_LINE:
1636
+ return line
1637
+ return clip(head + tail, REFLEX_LINE)
1638
+
1639
+
1640
+ def vitals_line(data, quiet=True):
1641
+ """The vitals in two lines of at most REFLEX_LINE characters. Quiet, as the
1642
+ startup card prints them: nothing under two sessions in the period, and no
1643
+ before line when the period before it had none."""
1644
+ now, before = data["now"], data["before"]
1645
+ if quiet and now["sessions"] < 2:
1646
+ return ""
1647
+ gate = any(period["merges"] or period["refused"] for period in (now, before))
1648
+ if gate and data.get("gate_coverage") == "partial":
1649
+ gate = "partial"
1650
+ lines = [vitals_text(f"{data['days']} day{'' if data['days'] == 1 else 's'}:", now, gate, " a session"),
1651
+ vitals_text("before:", before, gate, "")]
1652
+ if quiet and not before["sessions"]:
1653
+ lines.pop()
1654
+ return "\n".join(lines)
1655
+
1656
+
1657
+ def existing(ctx, sid):
1658
+ return next((r for r in all_records(ctx) if r[1].get("session") == sid), None)
1659
+
1660
+
1661
+ def native_record_adapter(required=True):
1662
+ """Private producer is optional in public bundles; absence never grants access."""
1663
+ try:
1664
+ import session_record_producers
1665
+ return session_record_producers
1666
+ except ImportError as exc:
1667
+ if required:
1668
+ raise ValueError("native spirit records are unsupported in this installation") from exc
1669
+ return None
1670
+
1671
+
1672
+ def owned_record(ctx, meta):
1673
+ if meta.get("producer"):
1674
+ adapter = native_record_adapter(required=False)
1675
+ return bool(adapter and adapter.owned(ctx, meta))
1676
+ identities = {str(value).casefold() for value in (ctx.author, ctx.display) if value}
1677
+ return any(str(meta.get(key, "")).casefold() in identities for key in ("from", "author"))
1678
+
1679
+
1680
+ def clean_env():
1681
+ return {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
1682
+
1683
+
1684
+ def git(ctx, *args, check=False):
1685
+ result = subprocess.run(["git", "-C", str(ctx.memory), *args], env=clean_env(),
1686
+ capture_output=True, text=True, timeout=60)
1687
+ if check and result.returncode:
1688
+ raise RuntimeError("memory write failed: " + result.stderr.strip()[:1000])
1689
+ return result
1690
+
1691
+
1692
+ def head_record_text(ctx, path):
1693
+ """Read only the bounded metadata window of a record stored in HEAD."""
1694
+ name = str(Path(path).relative_to(ctx.memory))
1695
+ error = "memory write failed: cannot read HEAD:" + name
1696
+ try:
1697
+ process = subprocess.Popen(["git", "-C", str(ctx.memory), "show", "HEAD:" + name],
1698
+ env=clean_env(), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
1699
+ except OSError as exc:
1700
+ raise RuntimeError(error) from exc
1701
+ try:
1702
+ limit = 256 * 1024
1703
+ raw = process.stdout.read(limit + 1)
1704
+ if len(raw) > limit:
1705
+ return raw[:limit].decode("utf-8", errors="replace")
1706
+ try:
1707
+ status = process.wait(timeout=5)
1708
+ except subprocess.TimeoutExpired as exc:
1709
+ raise RuntimeError(error) from exc
1710
+ if status:
1711
+ raise RuntimeError(error)
1712
+ return raw.decode("utf-8", errors="replace")
1713
+ finally:
1714
+ process.stdout.close()
1715
+ if process.poll() is None:
1716
+ process.terminate()
1717
+ try:
1718
+ process.wait(timeout=5)
1719
+ except subprocess.TimeoutExpired:
1720
+ process.kill()
1721
+ process.wait()
1722
+
1723
+
1724
+ def deleted_record_rows(ctx):
1725
+ """Read deleted session metadata without opening any working-tree path."""
1726
+ result = git(ctx, "diff", "--name-only", "-z", "--diff-filter=D", "--no-renames",
1727
+ "HEAD", "--", "sessions/")
1728
+ if result.returncode:
1729
+ # A new memory repository has no historical paths to recover. Other
1730
+ # diff failures still stop publication rather than hiding deletions.
1731
+ if (git(ctx, "rev-parse", "--is-inside-work-tree").stdout.strip() == "true"
1732
+ and git(ctx, "rev-parse", "--verify", "--quiet", "HEAD").returncode):
1733
+ return
1734
+ raise RuntimeError("memory write failed: " + result.stderr.strip()[:1000])
1735
+ names = result.stdout.split("\0")
1736
+ for name in filter(None, names):
1737
+ path = ctx.memory / name
1738
+ text = head_record_text(ctx, path)
1739
+ yield path, metadata(text), text
1740
+
1741
+
1742
+ def deleted_records(ctx, sid):
1743
+ """Recover this session's deletion side after an interrupted held rename."""
1744
+ return [path for path, meta, _ in deleted_record_rows(ctx) if meta.get("session") == sid]
1745
+
1746
+
1747
+ def redaction_record(ctx, sid):
1748
+ old = existing(ctx, sid)
1749
+ if old:
1750
+ return old
1751
+ return next((row for row in deleted_record_rows(ctx) if row[1].get("session") == sid), None)
1752
+
1753
+
1754
+ def waiting_path(ctx):
1755
+ key = hashlib.sha256(str(ctx.memory).encode()).hexdigest()[:16]
1756
+ return ctx.home / ".egregore/trails" / ("pending-" + key + ".json")
1757
+
1758
+
1759
+ def push(ctx):
1760
+ pending = waiting_path(ctx)
1761
+ if git(ctx, "remote", "get-url", "origin").returncode:
1762
+ return True
1763
+ # Every outgoing ancestor is checked by the shared transport owner. The
1764
+ # human path cannot publish an earlier native checkpoint hidden at the tip.
1765
+ try:
1766
+ sys.path.insert(0, str(SELF.parents[2]))
1767
+ from egregore_runtime.memory_push import push_memory
1768
+ branch = git(ctx, "symbolic-ref", "--quiet", "--short", "HEAD").stdout.strip()
1769
+ result = push_memory(ctx.memory, branch=branch)
1770
+ except (ImportError, OSError, ValueError, RuntimeError):
1771
+ result = {"status": "unknown", "cause": "guard_unavailable"}
1772
+ if result.get("status") not in {"pushed", "already-current"}:
1773
+ previous = read_json(pending)
1774
+ previous["waiting_push"] = max(1, previous.get("waiting_push", 0))
1775
+ atomic(pending, json.dumps(previous))
1776
+ ctx.error("guarded publication pending; commit remains local: " + str(result.get("cause", "unknown")))
1777
+ return False
1778
+ previous = read_json(pending)
1779
+ if previous.get("native_publication"):
1780
+ # A human-only push/retry cannot acknowledge or discard a separate
1781
+ # native owner's exact pending attempt (including a lost receipt).
1782
+ previous["waiting_push"] = 0
1783
+ atomic(pending, json.dumps(previous))
1784
+ else:
1785
+ pending.unlink(missing_ok=True)
1786
+ return True
1787
+
1788
+
1789
+ def require_human_publication(ctx, paths):
1790
+ """The human publication path cannot adopt private native checkpoints."""
1791
+ selected = {Path(path) for path in paths}
1792
+ for path in selected:
1793
+ if not path.is_relative_to(ctx.memory) or not path.resolve().is_relative_to(ctx.memory) or path.is_symlink():
1794
+ raise ValueError("record publication path is outside canonical memory")
1795
+ if path.is_file():
1796
+ with path.open("rb") as stream:
1797
+ meta = metadata(stream.read(256 * 1024).decode("utf-8", errors="replace"))
1798
+ if meta.get("producer"):
1799
+ raise ValueError("native record publication requires its own fresh admission")
1800
+ if any(path in selected and meta.get("producer") for path, meta, _ in deleted_record_rows(ctx)):
1801
+ raise ValueError("native record deletion requires its own fresh publication admission")
1802
+
1803
+
1804
+ def commit(ctx, paths, message, before_publish=None):
1805
+ """Caller holds the memory lock, which also serializes every hold marker."""
1806
+ require_human_publication(ctx, paths)
1807
+ identity = []
1808
+ for key, default in (("user.name", ctx.display or ctx.author),
1809
+ ("user.email", f"{ctx.author}@users.noreply.github.com")):
1810
+ if not git(ctx, "config", "--get", key).stdout.strip():
1811
+ identity += ["-c", key + "=" + default]
1812
+
1813
+ def stage():
1814
+ require_human_publication(ctx, paths)
1815
+ # A hold may rename a selected record before publication. Stage both
1816
+ # sides of that change in the same commit, including callback additions.
1817
+ relative = list(dict.fromkeys(str(Path(p).relative_to(ctx.memory)) for p in paths))
1818
+ if not relative:
1819
+ return []
1820
+ tracked = set(git(ctx, "ls-files", "-z", "--", *relative, check=True).stdout.split("\0"))
1821
+ selected = [name for name in relative if (ctx.memory / name).exists() or name in tracked]
1822
+ if selected:
1823
+ git(ctx, "add", "--", *selected, check=True)
1824
+ # Staged deletions disappear from ls-files but still need the pathspec.
1825
+ tracked.update(git(ctx, "diff", "--cached", "--name-only", "--no-renames", "-z", "--", *relative,
1826
+ check=True).stdout.split("\0"))
1827
+ return [name for name in relative if (ctx.memory / name).exists() or name in tracked]
1828
+
1829
+ if before_publish is not None and not before_publish():
1830
+ return
1831
+ selected = stage()
1832
+ if selected and git(ctx, "diff", "--cached", "--quiet", "--", *selected).returncode:
1833
+ # Holds cannot change under this lock. Re-check personal/org settings
1834
+ # immediately before the commit as well, and stage any resulting stub.
1835
+ if before_publish is not None and not before_publish():
1836
+ return
1837
+ selected = stage()
1838
+ if selected and git(ctx, "diff", "--cached", "--quiet", "--", *selected).returncode:
1839
+ git(ctx, *identity, "commit", "--quiet", "-m", message, "--", *selected, check=True)
1840
+ push(ctx)
1841
+
1842
+
1843
+ def publish_held_deletions(ctx, sid):
1844
+ """Finish an interrupted no-stub hold without rebuilding the transcript."""
1845
+ lock = ctx.memory_lock()
1846
+ if not lock.acquire(wait=120):
1847
+ raise RuntimeError("memory writer busy")
1848
+ try:
1849
+ ctx.reload()
1850
+ if not ctx.held(sid) or ctx.state.get("session_records_hold_stub", True) is not False:
1851
+ return
1852
+ removed = [path for path, meta, _ in deleted_record_rows(ctx)
1853
+ if meta.get("session") == sid and not meta.get("producer")]
1854
+ if removed:
1855
+ commit(ctx, removed, "chore(sessions): remove held record")
1856
+ finally:
1857
+ lock.release()
1858
+
1859
+
1860
+ def regular(path):
1861
+ try:
1862
+ return path.is_file() and bool(path.stat().st_mode & 0o444)
1863
+ except OSError:
1864
+ return False
1865
+
1866
+
1867
+ def staging_directory(ctx, path, create=False):
1868
+ """Validate every directory component before creating or reading staging."""
1869
+ path = Path(path)
1870
+ if not ctx.safe_base():
1871
+ raise ValueError("invalid backfill staging folder")
1872
+ try:
1873
+ parts = path.relative_to(ctx.base).parts
1874
+ if not parts or any(part in (".", "..") for part in parts):
1875
+ raise ValueError("invalid backfill staging folder")
1876
+ if create:
1877
+ private_dir(ctx.base)
1878
+ current = ctx.base
1879
+ for part in parts:
1880
+ current = current / part
1881
+ if create and not current.exists() and not current.is_symlink():
1882
+ current.mkdir(mode=0o700)
1883
+ if not stat.S_ISDIR(current.lstat().st_mode) or not current.resolve().is_relative_to(ctx.base.resolve()):
1884
+ raise ValueError("invalid backfill staging folder")
1885
+ except OSError as exc:
1886
+ raise ValueError("invalid backfill staging folder") from exc
1887
+ return path
1888
+
1889
+
1890
+ def staging_file(ctx, stage, path):
1891
+ stage = staging_directory(ctx, stage)
1892
+ path = Path(path)
1893
+ try:
1894
+ if not path.is_relative_to(stage) or not stat.S_ISREG(path.lstat().st_mode):
1895
+ raise ValueError("invalid backfill staging file")
1896
+ if not path.resolve().is_relative_to(stage.resolve()):
1897
+ raise ValueError("invalid backfill staging file")
1898
+ staging_directory(ctx, path.parent)
1899
+ except OSError as exc:
1900
+ raise ValueError("invalid backfill staging file") from exc
1901
+ return path
1902
+
1903
+
1904
+ def staged_records(ctx, stage):
1905
+ stage = staging_directory(ctx, stage)
1906
+ # os.walk never descends symlinks. Refuse an invalid entry before reading
1907
+ # any staged record through it, even if its target is inside this folder.
1908
+ for directory, dirs, files in os.walk(stage, followlinks=False):
1909
+ for name in dirs:
1910
+ staging_directory(ctx, Path(directory) / name)
1911
+ for name in files:
1912
+ path = staging_file(ctx, stage, Path(directory) / name)
1913
+ if path.suffix == ".md":
1914
+ text = path.read_text()
1915
+ yield path, metadata(text), text
1916
+
1917
+
1918
+ def path_identity(path):
1919
+ # Exactly the producer's sha256(String(sessionFile)), without normalization.
1920
+ return hashlib.sha256(str(path).encode()).hexdigest()
1921
+
1922
+
1923
+ def trail_bases(ctx):
1924
+ bases = {ctx.base, ctx.home / ".egregore/trails" / ctx.prefix}
1925
+ bases.update((ctx.home / ".egregore/trails").glob(ctx.prefix + "--*"))
1926
+ return [base for base in bases if st.valid_trail_folder(base, ctx.projects, ctx.prefix)]
1927
+
1928
+
1929
+ def aliases(ctx):
1930
+ for base in trail_bases(ctx):
1931
+ if base.name not in (ctx.prefix + "--pi", ctx.prefix + "--prime"):
1932
+ continue
1933
+ path = base / "aliases.jsonl"
1934
+ try:
1935
+ if not stat.S_ISREG(path.lstat().st_mode) or path.stat().st_size > 1024 * 1024:
1936
+ continue
1937
+ with path.open() as stream:
1938
+ for line in stream:
1939
+ try:
1940
+ row = json.loads(line)
1941
+ if (isinstance(row, dict) and isinstance(row.get("path"), str)
1942
+ and isinstance(row.get("hash"), str) and re.fullmatch(r"[a-f0-9]{64}", row["hash"])
1943
+ and isinstance(row.get("session"), str) and SAFE_ID.fullmatch(row["session"])):
1944
+ yield row
1945
+ except (ValueError, TypeError):
1946
+ continue
1947
+ except OSError:
1948
+ continue
1949
+
1950
+
1951
+ def codex_human_root(header):
1952
+ payload = header.get("payload", {}) if isinstance(header, dict) else {}
1953
+ return (isinstance(payload, dict) and payload.get("originator") in ("codex-tui", "Codex Desktop")
1954
+ and payload.get("source") in ("cli", "vscode") and not st.parent_of(header))
1955
+
1956
+
1957
+ def pi_session_paths(ctx):
1958
+ for harness in ("pi", "prime"):
1959
+ base = ctx.home / ("." + harness) / "agent/sessions"
1960
+ for directory, dirs, files in os.walk(base, followlinks=False):
1961
+ if len(Path(directory).relative_to(base).parts) >= 3:
1962
+ dirs[:] = []
1963
+ for name in sorted(files):
1964
+ path = Path(directory) / name
1965
+ if path.suffix == ".jsonl":
1966
+ yield path
1967
+
1968
+
1969
+ def recover_pi_alias(ctx, identifier):
1970
+ """Recover a producer hash from bounded native roots, never from content."""
1971
+ if not re.fullmatch(r"[a-f0-9]{64}", identifier):
1972
+ return None
1973
+ for path in pi_session_paths(ctx):
1974
+ for spelling in dict.fromkeys((str(path), str(path.resolve()))):
1975
+ if path_identity(spelling) != identifier:
1976
+ continue
1977
+ info = transcript_info(ctx, path)
1978
+ if info and info[0] in ("pi", "prime"):
1979
+ return {"hash": identifier, "path": spelling, "session": info[1]}
1980
+ return None
1981
+
1982
+
1983
+ def remember_pi_alias(ctx, row):
1984
+ """Share the producer's bounded alias ledger; a hold does not depend on IO success."""
1985
+ lock = ctx.base / ".aliases.lock"
1986
+ acquired = False
1987
+ try:
1988
+ if not ctx.safe_base():
1989
+ return
1990
+ private_dir(ctx.base)
1991
+ for attempt in range(2):
1992
+ try:
1993
+ lock.mkdir(mode=0o700)
1994
+ acquired = True
1995
+ break
1996
+ except FileExistsError:
1997
+ if attempt == 0 and stat.S_ISDIR(lock.lstat().st_mode) and time.time() - lock.stat().st_mtime > 60:
1998
+ lock.rmdir()
1999
+ else:
2000
+ return
2001
+ path = ctx.base / "aliases.jsonl"
2002
+ descriptor = os.open(path, os.O_CREAT | os.O_RDWR | os.O_APPEND | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
2003
+ with os.fdopen(descriptor, "r+") as stream:
2004
+ info = os.fstat(stream.fileno())
2005
+ if not stat.S_ISREG(info.st_mode) or info.st_size > 1024 * 1024:
2006
+ return
2007
+ lines = stream.read(1024 * 1024 + 1).splitlines()
2008
+ for line in lines:
2009
+ try:
2010
+ known = json.loads(line)
2011
+ if isinstance(known, dict) and known.get("hash") == row["hash"]:
2012
+ return
2013
+ except ValueError:
2014
+ pass
2015
+ encoded = json.dumps(row) + "\n"
2016
+ if info.st_size + len(encoded.encode()) <= 1024 * 1024:
2017
+ stream.write(encoded)
2018
+ except (OSError, ValueError):
2019
+ pass
2020
+ finally:
2021
+ if acquired:
2022
+ try:
2023
+ lock.rmdir()
2024
+ except OSError:
2025
+ pass
2026
+
2027
+
2028
+ def inspect_transcript(ctx, path, header_result=None, headers_only=False):
2029
+ """Return an admitted identity or an exact, nonexception skip reason."""
2030
+ path = Path(path)
2031
+ if not regular(path):
2032
+ return None, "unreadable header"
2033
+ if st.within_instance(path, ctx.projects, ctx.prefix):
2034
+ if not SAFE_ID.fullmatch(path.stem):
2035
+ return None, "unsupported"
2036
+ header, readable = st.read_header_result(path)
2037
+ if headers_only:
2038
+ # Counting opens first lines only. A Claude transcript may begin with
2039
+ # a summary or metadata entry, so path admission decides unless the
2040
+ # first line is unmistakably another harness's header.
2041
+ native = st._header_format(header)
2042
+ kind = native if native in ("codex", "pi") else ("claude" if readable else None)
2043
+ else:
2044
+ kind = st.probe_format(path, ctx.projects, ctx.prefix)
2045
+ if kind != "claude":
2046
+ return None, "unsupported" if readable else "unreadable header"
2047
+ return ("claude", path.stem, header if isinstance(header, dict) else {}), None
2048
+ header, readable = st.read_header_result(path) if header_result is None else header_result
2049
+ if not readable:
2050
+ return None, "unreadable header"
2051
+ if not isinstance(header, dict):
2052
+ return None, "unsupported"
2053
+ kind = header.get("type")
2054
+ if kind == "session_meta":
2055
+ payload = header.get("payload", {})
2056
+ if not isinstance(payload, dict):
2057
+ return None, "unsupported"
2058
+ harness = "codex"
2059
+ elif kind == "session" and type(header.get("version")) is int:
2060
+ payload = header
2061
+ harness = "prime" if (ctx.home / ".prime/agent/sessions") in path.parents or (ctx.home / ".prime/agent/sessions").resolve() in path.resolve().parents else "pi"
2062
+ else:
2063
+ return None, "unsupported"
2064
+ sid, cwd = payload.get("id"), payload.get("cwd")
2065
+ if not isinstance(sid, str) or not SAFE_ID.fullmatch(sid) or not isinstance(cwd, str):
2066
+ return None, "unsupported"
2067
+ if not st.within_instance_cwd(cwd, ctx.root):
2068
+ return None, "other project"
2069
+ return (harness, sid, header), None
2070
+
2071
+
2072
+ def transcript_info(ctx, path):
2073
+ return inspect_transcript(ctx, path)[0]
2074
+
2075
+
2076
+ def folder_for(ctx, path):
2077
+ if st.within_instance(path, ctx.projects, ctx.prefix):
2078
+ return Path(path).resolve().relative_to(ctx.projects.resolve()).parts[0]
2079
+ info = transcript_info(ctx, path)
2080
+ if not info:
2081
+ raise ValueError("transcript is outside this instance")
2082
+ return ctx.prefix + "--" + info[0]
2083
+
2084
+
2085
+ def event_time(value):
2086
+ try:
2087
+ return datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp()
2088
+ except (TypeError, ValueError, OverflowError):
2089
+ return None
2090
+
2091
+
2092
+ def codex_header_index(ctx, skipped=None):
2093
+ """One complete, admitted family inventory; unchanged headers are cached."""
2094
+ owner = Context(ctx.root, ctx.prefix + "--codex")
2095
+ if not owner.safe_base():
2096
+ return {}
2097
+ cache_path = owner.base / "header-index.json"
2098
+ cache = {}
2099
+ try:
2100
+ if stat.S_ISREG(cache_path.lstat().st_mode) and cache_path.stat().st_size <= 16 * 1024 * 1024:
2101
+ cache = read_json(cache_path)
2102
+ except OSError:
2103
+ pass
2104
+ result = {}
2105
+ base = ctx.home / ".codex/sessions"
2106
+ for directory, dirs, files in os.walk(base, followlinks=False):
2107
+ dirs.sort(reverse=True)
2108
+ for name in sorted(files):
2109
+ if not name.startswith("rollout-") or not name.endswith(".jsonl"):
2110
+ continue
2111
+ path = Path(directory) / name
2112
+ if not regular(path):
2113
+ if skipped is not None:
2114
+ skipped["unreadable header"] += 1
2115
+ continue
2116
+ try:
2117
+ info = path.stat()
2118
+ key = [str(path.resolve()), info.st_size, info.st_mtime_ns]
2119
+ saved = cache.get(str(path), {})
2120
+ saved_header = saved.get("header", {}) if isinstance(saved, dict) else {}
2121
+ saved_payload = saved_header.get("payload", {}) if isinstance(saved_header, dict) else {}
2122
+ valid_cache = (isinstance(saved, dict) and isinstance(saved_header, dict) and isinstance(saved_payload, dict)
2123
+ and saved_header.get("type") == "session_meta"
2124
+ and isinstance(saved_payload.get("cwd"), str)
2125
+ and saved_payload.get("id") == saved.get("id")
2126
+ and saved.get("version") == 2
2127
+ and isinstance(saved.get("id"), str) and SAFE_ID.fullmatch(saved["id"])
2128
+ and (saved.get("parent") is None or isinstance(saved["parent"], str))
2129
+ and isinstance(saved.get("mtime"), (float, int))
2130
+ and (saved.get("last_event") is None or
2131
+ isinstance(saved["last_event"], (float, int)) and math.isfinite(saved["last_event"])))
2132
+ if valid_cache and saved.get("key") == key:
2133
+ row = saved
2134
+ else:
2135
+ info_result, reason = inspect_transcript(ctx, path)
2136
+ if not info_result or info_result[0] != "codex":
2137
+ if skipped is not None:
2138
+ skipped[reason or "unsupported"] += 1
2139
+ continue
2140
+ _, sid, header = info_result
2141
+ payload = header["payload"]
2142
+ parent = st.parent_of(header)
2143
+ row = {"id": sid, "parent": parent, "mtime": info.st_mtime,
2144
+ "last_event": None,
2145
+ "key": key, "version": 2,
2146
+ "header": {"type": "session_meta", "timestamp": header.get("timestamp") or payload.get("timestamp"),
2147
+ "payload": {"id": sid, "cwd": payload["cwd"],
2148
+ "originator": payload.get("originator"),
2149
+ "source": payload.get("source") if isinstance(payload.get("source"), str) else None,
2150
+ "parent_thread_id": parent}}}
2151
+ if not st.within_instance_cwd(row.get("header", {}).get("payload", {}).get("cwd"), ctx.root):
2152
+ if skipped is not None:
2153
+ skipped["other project"] += 1
2154
+ continue
2155
+ result[str(path)] = row
2156
+ except Exception as exc:
2157
+ if skipped is not None:
2158
+ skipped["failed"] += 1
2159
+ owner.error("header index failed: " + str(exc))
2160
+ continue
2161
+ private_dir(owner.base)
2162
+ atomic(cache_path, json.dumps(result))
2163
+ return result
2164
+
2165
+
2166
+ def family_rows(index, path):
2167
+ root = index.get(str(path))
2168
+ if not root:
2169
+ return []
2170
+ rows, seen = [(str(path), root)], {root["id"]}
2171
+ pending = [root["id"]]
2172
+ children = {}
2173
+ for name, row in index.items():
2174
+ children.setdefault(row.get("parent"), []).append((name, row))
2175
+ while pending:
2176
+ for name, row in children.get(pending.pop(), []):
2177
+ if row["id"] in seen:
2178
+ continue
2179
+ seen.add(row["id"])
2180
+ rows.append((name, row))
2181
+ pending.append(row["id"])
2182
+ return rows
2183
+
2184
+
2185
+ def family_watermark(ctx, index, path):
2186
+ """Read missing event watermarks only after a family passes admission."""
2187
+ family = family_rows(index, path)
2188
+ changed = False
2189
+ for name, row in family:
2190
+ if row.get("last_event") is None:
2191
+ info = transcript_info(ctx, name)
2192
+ if not info or info[0] != "codex":
2193
+ raise ValueError("family transcript changed after admission")
2194
+ row["last_event"] = st.last_event_time(name, info[2]) or row["mtime"]
2195
+ changed = True
2196
+ if changed:
2197
+ owner = Context(ctx.root, ctx.prefix + "--codex")
2198
+ if owner.safe_base():
2199
+ atomic(private_dir(owner.base) / "header-index.json", json.dumps(index))
2200
+ return max((row["last_event"] for _, row in family), default=Path(path).stat().st_mtime)
2201
+
2202
+
2203
+ def discover_paths(ctx, codex_index=None, skipped=None):
2204
+ for folder in st.transcript_folders(ctx.projects, ctx.prefix):
2205
+ for path in Path(folder).glob("*.jsonl"):
2206
+ if st.within_instance(path, ctx.projects, ctx.prefix) and regular(path):
2207
+ yield path
2208
+ index = codex_header_index(ctx, skipped=skipped) if codex_index is None else codex_index
2209
+ for name, row in index.items():
2210
+ if not row.get("parent"):
2211
+ yield Path(name)
2212
+ for harness in ("pi", "prime"):
2213
+ base = ctx.home / ("." + harness) / "agent/sessions"
2214
+ for directory, dirs, files in os.walk(base, followlinks=False):
2215
+ if len(Path(directory).relative_to(base).parts) >= 3:
2216
+ dirs[:] = []
2217
+ for name in sorted(files):
2218
+ path = Path(directory) / name
2219
+ if path.suffix != ".jsonl":
2220
+ continue
2221
+ try:
2222
+ info, reason = inspect_transcript(ctx, path)
2223
+ if info:
2224
+ yield path
2225
+ elif skipped is not None:
2226
+ skipped[reason] += 1
2227
+ except Exception as exc:
2228
+ if skipped is not None:
2229
+ skipped["failed"] += 1
2230
+ ctx.error("discovery failed: " + str(exc))
2231
+
2232
+
2233
+ def refresh_index(ctx, codex_index=None, rows=None):
2234
+ if rows is None:
2235
+ rows, _ = candidates(ctx, ctx.policy.get("retroactive_days", 90), include_live=True, codex_index=codex_index)
2236
+ oldest = min((row[0] for row in rows), default=None)
2237
+ data = {"unrecorded_count": len(rows),
2238
+ "oldest_date": datetime.fromtimestamp(oldest, timezone.utc).date().isoformat() if oldest else None,
2239
+ "days": max(0, int((time.time() - oldest) / 86400)) if oldest else 0,
2240
+ "waiting_push": read_json(waiting_path(ctx)).get("waiting_push", 0)}
2241
+ main = Context(ctx.root)
2242
+ if main.safe_base():
2243
+ base = private_dir(main.base)
2244
+ atomic(base / "index.json", json.dumps(data))
2245
+ return data
2246
+
2247
+
2248
+ def probe(path, ctx=None):
2249
+ return st.probe_format(path, projects_dir=ctx.projects if ctx else None,
2250
+ prefix=ctx.prefix if ctx else None) is not None
2251
+
2252
+
2253
+ def cli_flags(ctx):
2254
+ """Once per CLI version establish *every* isolation flag, never trust defaults."""
2255
+ executable = shutil.which("claude")
2256
+ if not executable:
2257
+ return None, "claude unavailable"
2258
+ env = clean_env()
2259
+ for key in ("CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "NODE_OPTIONS", "TRACEPARENT"):
2260
+ env.pop(key, None)
2261
+ # Explicit /tmp ignores TMPDIR, which may point inside the project.
2262
+ with tempfile.TemporaryDirectory(prefix="egregore-record-cli-", dir="/tmp") as empty:
2263
+ try:
2264
+ version_run = subprocess.run([executable, "--version"], cwd=empty, env=env,
2265
+ capture_output=True, text=True, timeout=5)
2266
+ if version_run.returncode or not version_run.stdout.strip():
2267
+ return None, "claude version check failed"
2268
+ version = re.sub(r"[^a-zA-Z0-9_.-]", "-", version_run.stdout.strip())[:120]
2269
+ cache = ctx.home / ".egregore/trails" / ("cli-" + version + ".json")
2270
+ checked = read_json(cache)
2271
+ if checked.get("executable") != executable or checked.get("version") != version or checked.get("isolation_revision") != 2:
2272
+ help_run = subprocess.run([executable, "--help"], cwd=empty, env=env,
2273
+ capture_output=True, text=True, timeout=5)
2274
+ if help_run.returncode:
2275
+ return None, "claude --help check failed"
2276
+ flags = set(re.findall(r"--[a-zA-Z][a-zA-Z-]*", help_run.stdout))
2277
+ required = {"--strict-mcp-config", "--mcp-config", "--setting-sources",
2278
+ "--model", "--output-format"}
2279
+ missing = sorted(required - flags)
2280
+ if "--tools" not in flags:
2281
+ # A historical deny list cannot prove completeness for an
2282
+ # unfamiliar CLI version. Require the empty allowlist flag.
2283
+ missing.append("--tools")
2284
+ checked = {"version": version, "executable": executable, "missing": missing, "isolation_revision": 2}
2285
+ private_dir(cache.parent)
2286
+ atomic(cache, json.dumps(checked))
2287
+ if checked.get("missing"):
2288
+ return None, "model skipped: missing " + ", ".join(checked["missing"])
2289
+ except (OSError, subprocess.TimeoutExpired):
2290
+ return None, "claude isolation check could not run"
2291
+ return [executable, "--tools", "", "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}',
2292
+ "--setting-sources", ""], None
2293
+
2294
+
2295
+ def model_call(ctx, sid, trail):
2296
+ if not ctx.enabled() or ctx.held(sid):
2297
+ return None, "held or disabled during build"
2298
+ if not ctx.model_enabled():
2299
+ return None, "model call disabled"
2300
+ flags, reason = cli_flags(ctx)
2301
+ if flags is None:
2302
+ return None, reason
2303
+ # Re-check after capability discovery, immediately before starting the call.
2304
+ if not ctx.enabled() or ctx.held(sid) or not ctx.model_enabled():
2305
+ return None, "held or disabled during build"
2306
+ trail, truncation_reason = st.trim_trail(trail)
2307
+ env = clean_env()
2308
+ for key in ("CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "NODE_OPTIONS", "TRACEPARENT"):
2309
+ env.pop(key, None)
2310
+ timeout = float(os.environ.get("EGREGORE_RECORD_MODEL_TIMEOUT_S", ctx.policy.get("model_timeout_s", 240)))
2311
+ prompt = (SELF.parent / "session-record-prompt.txt").read_text().replace("PERSON_NAME", ctx.display or ctx.author)
2312
+ args = flags + ["-p", prompt,
2313
+ "--model", str(ctx.policy.get("model", "haiku")), "--output-format", "text"]
2314
+ with tempfile.TemporaryDirectory(prefix="egregore-record-model-", dir="/tmp") as empty:
2315
+ child = subprocess.Popen(args, cwd=empty, env=env, stdin=subprocess.PIPE,
2316
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True)
2317
+ try:
2318
+ output, _ = child.communicate(trail, timeout=max(0.1, timeout))
2319
+ if child.returncode:
2320
+ return None, f"model unavailable (exit {child.returncode})" + ("; " + truncation_reason if truncation_reason else "")
2321
+ if not re.search(r"(?m)^## (Asked|Decided|Open|Risks)\s*$", output):
2322
+ return None, "model returned no record sections" + ("; " + truncation_reason if truncation_reason else "")
2323
+ return output, truncation_reason
2324
+ except subprocess.TimeoutExpired:
2325
+ # The model is its own process group; kill children too (no orphaned sleep/tools).
2326
+ try:
2327
+ os.killpg(child.pid, signal.SIGKILL)
2328
+ except ProcessLookupError:
2329
+ pass
2330
+ child.communicate()
2331
+ return None, "model timed out" + ("; " + truncation_reason if truncation_reason else "")
2332
+
2333
+
2334
+ def stub_data(data):
2335
+ data = dict(data)
2336
+ if data.get("producer"):
2337
+ data.update(topic="held", branch="", detail="stub", summary="held spirit record · metrics unavailable", body="")
2338
+ return data
2339
+ data["topic"] = "held"
2340
+ data["branch"] = "held"
2341
+ data["date"] = data["date"][:10]
2342
+ data["detail"] = "stub"
2343
+ data["summary"] = f"held session · {data.get('duration', 'unknown')} · ${float(data.get('cost_usd', 0)):.2f}"
2344
+ data["body"] = ""
2345
+ return data
2346
+
2347
+
2348
+ def record_text(data):
2349
+ if data.get("producer"):
2350
+ return native_record_adapter().render_record(data)
2351
+ stub = data.get("detail") == "stub"
2352
+ if stub:
2353
+ data = stub_data(data)
2354
+ fields = {"kind": "session-record",
2355
+ "from": data["author"], "date": data["date"][:10],
2356
+ "session": data["session"], "detail": data["detail"], "status": data["status"],
2357
+ "record_revision": data["record_revision"], "cost_usd": data.get("cost_usd", 0),
2358
+ "cost_source": data.get("cost_source", "estimate"), "duration": data.get("duration", "unknown")}
2359
+ if not stub:
2360
+ # Active time sits beside the wall clock; a held stub keeps its whitelist.
2361
+ if data.get("active"):
2362
+ fields["active"] = data["active"]
2363
+ fields.update(capture_schema="egregore-capture/v1", capture_mode="baseline", topic=data["topic"])
2364
+ if data.get("harness"):
2365
+ fields["harness"] = data["harness"]
2366
+ if data.get("through"):
2367
+ fields["through"] = data["through"]
2368
+ for key in ("cost_coverage", "cost_sessions"):
2369
+ if key in data:
2370
+ fields[key] = data[key]
2371
+ if not stub and data.get("backfill"):
2372
+ fields["backfill"] = True
2373
+ # Simple scalar YAML with human-readable enum values; JSON quoting for arbitrary text.
2374
+ literal = {"kind", "detail", "status", "cost_source", "capture_schema", "capture_mode"}
2375
+ lines = ["---"] + [f"{k}: {v if k in literal else json.dumps(v, ensure_ascii=False)}" for k, v in fields.items()] + ["---", "", f"# Session: {data['topic']}", ""]
2376
+ if stub:
2377
+ return "\n".join(lines) + "\n## Summary\n\n" + data["summary"] + "\n"
2378
+ for key, val in (("Capture Schema", "egregore-capture/v1"), ("Capture Mode", "baseline"),
2379
+ ("Date", data["date"]), ("Author", data["author"]), ("To", data["author"]),
2380
+ ("Branch", data["branch"]), ("Session", data["session"]), ("Duration", data["duration"]),
2381
+ *((("Active", data["active"]),) if data.get("active") else ())):
2382
+ lines.append(f"**{key}**: {val}")
2383
+ return "\n".join(lines) + "\n\n## Summary\n\n" + data["summary"] + "\n\n" + data.get("body", "").strip() + "\n"
2384
+
2385
+
2386
+ def output_path(ctx, data):
2387
+ old = existing(ctx, data["session"])
2388
+ date = data["date"][:10]
2389
+ if data.get("detail") == "stub":
2390
+ month = old[0].parent if old else ctx.memory / "sessions" / date[:7]
2391
+ stem = f"{date[8:10]}-{data['author'].lower()}-held-"
2392
+ choices = [month / (stem + identifier + ".md")
2393
+ for identifier in dict.fromkeys((data["session"][:8], data["session"]))]
2394
+ # Keep a previous full-ID fallback even if its short name becomes free.
2395
+ if old and old[0] in choices:
2396
+ return old[0]
2397
+ for path in choices:
2398
+ if not path.exists() and not path.is_symlink():
2399
+ return path
2400
+ if not path.is_symlink() and stat.S_ISREG(path.lstat().st_mode):
2401
+ with path.open("rb") as stream:
2402
+ meta = metadata(stream.read(256 * 1024).decode("utf-8", errors="replace"))
2403
+ if meta.get("session") == data["session"]:
2404
+ return path
2405
+ raise ValueError("held record filename is already in use")
2406
+ if old:
2407
+ return old[0]
2408
+ slug = re.sub(r"[^a-z0-9]+", "-", data["topic"].lower()).strip("-")[:50] or "session"
2409
+ path = ctx.memory / "sessions" / date[:7] / f"{date[8:10]}-{data['author'].lower()}-{slug}.md"
2410
+ if path.exists():
2411
+ path = path.with_name(path.stem + "-" + data["session"] + ".md")
2412
+ return path
2413
+
2414
+
2415
+ def apply_record_hold(ctx, path, meta, force=False):
2416
+ """Sanitize an unpublished working file while the memory lock is held."""
2417
+ ctx.reload()
2418
+ sid = str(meta.get("session", ""))
2419
+ if not force and not ctx.held(sid):
2420
+ return [path]
2421
+ deleted = deleted_records(ctx, sid)
2422
+ if not path.exists():
2423
+ return [path, *deleted]
2424
+ if ctx.state.get("session_records_hold_stub", True) is False:
2425
+ path.unlink(missing_ok=True)
2426
+ else:
2427
+ held = dict(meta, author=meta.get("from", ctx.author), session=sid,
2428
+ topic=meta.get("topic", "session"),
2429
+ date=meta.get("started_at", meta.get("date", datetime.now(timezone.utc).isoformat())),
2430
+ branch=meta.get("branch", "session"), status=meta.get("status", "in progress"),
2431
+ record_revision=int(meta.get("record_revision", 0)) + (meta.get("detail") != "stub"))
2432
+ held = stub_data(held)
2433
+ target = output_path(ctx, held)
2434
+ text = record_text(held)
2435
+ if target != path:
2436
+ path.unlink()
2437
+ # If writing the neutral file is interrupted, HEAD identifies the
2438
+ # deleted side for the next hold/build to recover. Never leave both.
2439
+ atomic(target, text, 0o644)
2440
+ return [target, *deleted]
2441
+ return [path, *deleted]
2442
+
2443
+
2444
+ def pending_records(ctx):
2445
+ """Own working records left behind by earlier sessions; never other authors."""
2446
+ dirty = set(git(ctx, "diff", "--name-only", "-z", "HEAD", "--", "sessions", check=True).stdout.split("\0"))
2447
+ dirty.update(git(ctx, "ls-files", "--others", "--exclude-standard", "-z", "--", "sessions", check=True).stdout.split("\0"))
2448
+ for path, meta, text in all_records(ctx):
2449
+ if meta.get("producer"):
2450
+ continue # Native checkpoints need their own fresh publication admission.
2451
+ if not text.startswith("---\n") or not re.search(r"(?m)^from:", text.split("---", 2)[1]):
2452
+ continue
2453
+ if str(path.relative_to(ctx.memory)) not in dirty or not owned_record(ctx, {"from": meta.get("from")}):
2454
+ continue
2455
+ sid = str(meta.get("session", ""))
2456
+ if SAFE_ID.fullmatch(sid):
2457
+ target = resolve_context(ctx, sid)[0]
2458
+ yield target, path, meta
2459
+
2460
+
2461
+ def publish_record(ctx, data, target, removed=(), meta=None):
2462
+ if data.get("producer") or meta and meta.get("producer"):
2463
+ raise ValueError("native record publication requires its own fresh admission")
2464
+ selected = [(ctx, target, meta if meta is not None else metadata(record_text(data)))]
2465
+ if data["mode"] == "end":
2466
+ selected.extend(row for row in pending_records(ctx) if row[1] != target)
2467
+
2468
+ paths = [path for _, path, _ in selected] + list(removed)
2469
+ require_human_publication(ctx, paths)
2470
+
2471
+ def before_publish():
2472
+ for owner, path, meta in selected:
2473
+ paths.extend(item for item in apply_record_hold(owner, path, meta, force=data["mode"] == "redact")
2474
+ if item not in paths)
2475
+ return data["mode"] == "redact" or ctx.enabled()
2476
+
2477
+ commit(ctx, paths, f"chore(sessions): record {data['topic']}",
2478
+ before_publish=before_publish)
2479
+
2480
+
2481
+ def capture(ctx, data, staging=None, *, _native=None):
2482
+ """Canonical writer: capture-run for transcripts; typed host permit for native facts.
2483
+
2484
+ Native permits are in-process only and rechecked under the same memory lock.
2485
+ Standalone JSON cannot enter that path or publish a native checkpoint.
2486
+ """
2487
+ if data.get("producer") or _native is not None:
2488
+ adapter = native_record_adapter()
2489
+ if not isinstance(_native, adapter._NativeCapture) or staging:
2490
+ raise ValueError("native records require a registered host source resolver")
2491
+ if not ctx.safe_base():
2492
+ return None
2493
+ if staging:
2494
+ staging_directory(ctx, staging)
2495
+ lock = ctx.memory_lock()
2496
+ if not staging and not lock.acquire(wait=0 if _native else 120):
2497
+ raise RuntimeError("memory writer busy")
2498
+ try:
2499
+ ctx.reload()
2500
+ old = existing(ctx, data["session"])
2501
+ if old and old[1].get("producer") and _native is None:
2502
+ raise ValueError("native record replacement requires host admission")
2503
+ if _native is not None:
2504
+ data = _native.prepare(ctx, old)
2505
+ if data is None:
2506
+ return _native.target
2507
+ held = ctx.held(data["session"]) or data["mode"] == "redact"
2508
+ if old:
2509
+ if not owned_record(ctx, old[1]):
2510
+ raise ValueError("that record belongs to another person")
2511
+ revision = int(old[1].get("record_revision", 0))
2512
+ stale = data["record_revision"] <= revision
2513
+ final = old[1].get("status") == "final" and data["mode"] not in ("end", "redact", "sweep", "backfill") and data.get("harness") not in ("codex", "pi", "prime")
2514
+ if stale or final:
2515
+ removed = deleted_records(ctx, data["session"]) if held and not staging else []
2516
+ if removed and _native is None:
2517
+ publish_record(ctx, stub_data(data), old[0], removed, meta=old[1])
2518
+ return old[0] if _native is not None else None
2519
+ if data["mode"] != "redact" and not ctx.enabled():
2520
+ return None
2521
+ if held:
2522
+ if ctx.state.get("session_records_hold_stub", True) is False:
2523
+ if not staging:
2524
+ if _native is not None:
2525
+ _native.recheck(ctx, data)
2526
+ if old:
2527
+ old[0].unlink()
2528
+ removed = deleted_records(ctx, data["session"])
2529
+ if (old or removed) and data["mode"] != "checkpoint" and _native is None:
2530
+ publish_record(ctx, stub_data(data), old[0] if old else removed[0], removed)
2531
+ return None
2532
+ data = stub_data(data)
2533
+ target = _native.target if _native is not None else output_path(ctx, data)
2534
+ if staging:
2535
+ # A queued end can derive a new topic before a batch publishes.
2536
+ # Keep that session's first staging path, just as memory does.
2537
+ target = next((p for p, meta, _ in staged_records(ctx, staging)
2538
+ if meta.get("session") == data["session"]),
2539
+ Path(staging) / target.relative_to(ctx.memory))
2540
+ # Distinct sessions can share a date and topic within one batch.
2541
+ if target.exists() and metadata(staging_file(ctx, staging, target).read_text()).get("session") != data["session"]:
2542
+ target = target.with_name(target.stem + "-" + data["session"] + ".md")
2543
+ staging_directory(ctx, target.parent, create=True)
2544
+ if target.exists() or target.is_symlink():
2545
+ staging_file(ctx, staging, target)
2546
+ if _native is None:
2547
+ destination_root = Path(staging).resolve() if staging else ctx.memory
2548
+ if target.is_symlink() or not target.resolve().is_relative_to(destination_root):
2549
+ raise ValueError("record destination is outside its admitted root")
2550
+ if target.is_file():
2551
+ with target.open("rb") as stream:
2552
+ destination = metadata(stream.read(256 * 1024).decode("utf-8", errors="replace"))
2553
+ if destination.get("producer"):
2554
+ raise ValueError("native record replacement requires host admission")
2555
+ text = record_text(data)
2556
+ if _native is not None:
2557
+ _native.recheck(ctx, data)
2558
+ removed = []
2559
+ if not staging and old and old[0] != target:
2560
+ old[0].unlink()
2561
+ removed.append(old[0])
2562
+ atomic(target, text, 0o644)
2563
+ if not staging and held:
2564
+ removed.extend(deleted_records(ctx, data["session"]))
2565
+ if not staging and _native is None and (data["mode"] != "checkpoint" or removed):
2566
+ publish_record(ctx, data, target, removed)
2567
+ return target
2568
+ finally:
2569
+ lock.release()
2570
+
2571
+
2572
+ def capture_via_engine(ctx, data, staging=None):
2573
+ command = ["bash", str(ctx.root / "bin/capture-run.sh"), "--mode", "baseline", "--author", data["author"],
2574
+ "--display-name", ctx.display, "--topic", data["topic"], "--summary", data["summary"],
2575
+ "--session-id", data["session"], "--branch", data["branch"], "--date", data["date"],
2576
+ "--duration", data["duration"], "--replace-session", "--record-metadata", json.dumps({k: v for k, v in data.items() if k != "body"}),
2577
+ "--project-root", str(ctx.root), "--record-folder", ctx.folder, "--no-reconcile", "--async-push"]
2578
+ if staging:
2579
+ command += ["--staging", str(staging)]
2580
+ result = subprocess.run(command, input=data.get("body", ""), text=True, capture_output=True, env=clean_env())
2581
+ if result.returncode:
2582
+ raise RuntimeError("capture failed: " + result.stderr[-1000:])
2583
+ return result.stdout.strip()
2584
+
2585
+
2586
+ def redact_locked(ctx, sid):
2587
+ old = redaction_record(ctx, sid)
2588
+ if not old:
2589
+ return "none yet"
2590
+ path, meta, _ = old
2591
+ adapter = native_record_adapter() if meta.get("producer") else None
2592
+ if not owned_record(ctx, meta):
2593
+ raise ValueError("that record belongs to another person")
2594
+ if adapter is not None:
2595
+ return adapter.withdraw_locked(ctx, sid)
2596
+ data = {"author": ctx.author, "session": sid, "topic": meta.get("topic", "session"),
2597
+ "date": meta.get("started_at", meta.get("date", datetime.now(timezone.utc).isoformat())),
2598
+ "branch": meta.get("branch", "session"), "duration": meta.get("duration", "unknown"),
2599
+ "cost_usd": meta.get("cost_usd", 0), "cost_source": meta.get("cost_source", "estimate"),
2600
+ "status": meta.get("status", "final"), "record_revision": int(meta.get("record_revision", 0)) + 1,
2601
+ "detail": "stub", "mode": "redact", "body": "", "summary": "held session"}
2602
+ capture_via_engine(ctx, stub_data(data))
2603
+ return "stubbed" if ctx.state.get("session_records_hold_stub", True) is not False else "removed"
2604
+
2605
+
2606
+ def repository_history(ctx, facts):
2607
+ """Read-only enrichment at runtime; fixture suites can prohibit project Git."""
2608
+ if os.environ.get("EGREGORE_RECORD_NO_PROJECT_GIT") == "1" or not (ctx.root / ".git").exists():
2609
+ return None
2610
+ if not facts.get("first") or not facts.get("last"):
2611
+ return None
2612
+ start = datetime.fromtimestamp(facts["first"], timezone.utc).isoformat()
2613
+ end = datetime.fromtimestamp(facts["last"], timezone.utc).isoformat()
2614
+ # Parallel sessions share one author and overlapping hours, so a time window
2615
+ # alone credits every session with every commit. Read only the branches this
2616
+ # session's transcript ran on.
2617
+ refs = session_refs(ctx, facts)
2618
+ if not refs:
2619
+ return None
2620
+ try:
2621
+ result = subprocess.run(["git", "-C", str(ctx.root), "log", "--format=%h %s",
2622
+ "--author=" + ctx.author, "--since=" + start, "--until=" + end,
2623
+ "--end-of-options", *refs, "--"],
2624
+ env=clean_env(), capture_output=True, text=True, timeout=5)
2625
+ return result.stdout if result.returncode == 0 else None
2626
+ except (OSError, subprocess.TimeoutExpired):
2627
+ return None
2628
+
2629
+
2630
+ SAFE_BRANCH = re.compile(r"^[A-Za-z0-9._/-]{1,200}$")
2631
+
2632
+
2633
+ def session_refs(ctx, facts):
2634
+ """Each branch the session ran on, as a local ref or else its origin copy."""
2635
+ refs = []
2636
+ for branch in sorted(set(facts.get("branches") or []) | {facts.get("branch") or ""}):
2637
+ if not SAFE_BRANCH.match(branch) or branch.startswith("-") or ".." in branch:
2638
+ continue
2639
+ for ref in ("refs/heads/" + branch, "refs/remotes/origin/" + branch):
2640
+ try:
2641
+ found = subprocess.run(["git", "-C", str(ctx.root), "rev-parse", "--verify", "--quiet",
2642
+ ref + "^{commit}"], env=clean_env(), capture_output=True,
2643
+ text=True, timeout=5)
2644
+ except (OSError, subprocess.TimeoutExpired):
2645
+ return []
2646
+ if found.returncode == 0:
2647
+ refs.append(ref)
2648
+ break
2649
+ return refs
2650
+
2651
+
2652
+ # The live cost line. A checkpoint build prices the running session with the
2653
+ # record's own outline; the first time the session passes a mark it leaves one
2654
+ # line beside the session's debounce stamp, and the next Stop hook prints it.
2655
+ COST_MARK_USD = 200.0
2656
+ CODEX_MARK_INPUT_TOKENS = 10_000_000
2657
+
2658
+
2659
+ def _compact(count):
2660
+ value, unit = float(count), ""
2661
+ for name in ("K", "M", "B"):
2662
+ if round(value, 1) < 1000:
2663
+ break
2664
+ value, unit = value / 1000, name
2665
+ return (f"{value:.1f}".removesuffix(".0") if unit else str(int(count))) + unit
2666
+
2667
+
2668
+ def cost_marks(harness, cost, source, input_tokens):
2669
+ """The marks a running session has passed: priced cost, or Codex input tokens."""
2670
+ marks = []
2671
+ if source != "unavailable" and cost >= COST_MARK_USD:
2672
+ marks.append("usd-200")
2673
+ if harness == "codex" and input_tokens >= CODEX_MARK_INPUT_TOKENS:
2674
+ marks.append("codex-input-10m")
2675
+ return marks
2676
+
2677
+
2678
+ def cost_line(harness, cost, source, input_tokens, model_calls):
2679
+ priced = source != "unavailable" and round(cost, 2) > 0
2680
+ if priced:
2681
+ head = f"this session has cost ${cost:,.2f}" + (" at list price" if source == "list price" else "") + " so far"
2682
+ else:
2683
+ head = f"this session has used {_compact(input_tokens)} input tokens so far"
2684
+ detail = f"{model_calls} model calls"
2685
+ if harness == "codex" and priced:
2686
+ detail = f"{_compact(input_tokens)} input tokens, {detail}"
2687
+ return f"{head} ({detail}) · consider a handoff or a fresh session"
2688
+
2689
+
2690
+ def queue_cost_line(ctx, sid, facts, outline):
2691
+ """Leave one line for the next stop when the session passes a mark it has not passed."""
2692
+ try:
2693
+ calls = [call for call in facts.get("calls", {}).values() if isinstance(call, dict)]
2694
+ harness = facts.get("harness") or "claude"
2695
+ cost, source = float(outline.get("cost") or 0), outline.get("cost_source")
2696
+ inputs = sum(call.get("input") or 0 for call in calls)
2697
+ marks = cost_marks(harness, cost, source, inputs)
2698
+ if not marks:
2699
+ return None
2700
+ stamps = ctx.base / "stamps"
2701
+ ledger = stamps / (sid + ".cost")
2702
+ announced = read_json(ledger).get("announced")
2703
+ announced = [mark for mark in announced if isinstance(mark, str)] if isinstance(announced, list) else []
2704
+ if set(marks) <= set(announced):
2705
+ return None
2706
+ line = cost_line(harness, cost, source, inputs, outline.get("model_calls", len(calls)))
2707
+ # The line before the ledger: an interrupted write repeats a line, never loses one.
2708
+ atomic(stamps / (sid + ".cost-line"), line + "\n")
2709
+ atomic(ledger, json.dumps({"announced": sorted(set(announced) | set(marks))}))
2710
+ return line
2711
+ except Exception:
2712
+ return None
2713
+
2714
+
2715
+ def take_cost_line(ctx, sid):
2716
+ """The pending cost line, at most once: concurrent stops race on one rename."""
2717
+ pending = ctx.base / "stamps" / (sid + ".cost-line")
2718
+ taken = pending.with_name(f"{pending.name}.{os.getpid()}")
2719
+ try:
2720
+ os.replace(pending, taken)
2721
+ except OSError:
2722
+ return None
2723
+ text = ""
2724
+ try:
2725
+ with taken.open("rb") as stream:
2726
+ text = stream.read(512).decode("utf-8", errors="replace")
2727
+ except OSError:
2728
+ pass
2729
+ try:
2730
+ taken.unlink()
2731
+ except OSError:
2732
+ pass
2733
+ return " ".join(re.sub(r"[\x00-\x1f\x7f]", " ", text).split()) or None
2734
+
2735
+
2736
+ def say(fd, line):
2737
+ """One line on the hook's stdout; session-record.sh keeps it on this descriptor."""
2738
+ if line:
2739
+ try:
2740
+ os.write(fd, (line + "\n").encode("utf-8"))
2741
+ except (OSError, ValueError, OverflowError):
2742
+ pass
2743
+
2744
+
2745
+ def build(ctx, args):
2746
+ sid = args.session_id
2747
+ path = Path(args.transcript)
2748
+ info = transcript_info(ctx, path)
2749
+ if not info:
2750
+ return "failed"
2751
+ harness, native, header = info
2752
+ if harness != "claude":
2753
+ sid = args.session_id = native
2754
+ if harness == "codex" and not codex_human_root(header):
2755
+ return "skipped"
2756
+ ctx.reload()
2757
+ index = getattr(args, "codex_index", None)
2758
+ if index is None:
2759
+ index = codex_header_index(ctx) if harness == "codex" else {}
2760
+ family = family_rows(index, path)
2761
+ modified = max((Path(member).stat().st_mtime for member, _ in family), default=path.stat().st_mtime)
2762
+ idle = time.time() - modified
2763
+ # The caller owns this session's lock; every descendant must still be idle.
2764
+ if args.mode in ("backfill", "sweep") and idle < 600:
2765
+ return "live"
2766
+ if args.mode in ("backfill", "sweep") and ctx.held(sid, args.transcript):
2767
+ return "skipped"
2768
+ old = existing(ctx, sid)
2769
+ if old and old[1].get("producer"):
2770
+ return "skipped" # Native facts are never reconstructed as a human transcript.
2771
+ if not old and ctx.held(sid, args.transcript) and ctx.state.get("session_records_hold_stub", True) is False:
2772
+ if args.mode == "end" and ctx.safe_base():
2773
+ publish_held_deletions(ctx, sid)
2774
+ return "skipped"
2775
+ if args.staging:
2776
+ staged = next((row for row in staged_records(ctx, args.staging)
2777
+ if row[1].get("session") == sid), None)
2778
+ if staged and (old is None or int(staged[1].get("record_revision", 0)) > int(old[1].get("record_revision", 0))):
2779
+ old = staged
2780
+ if old and old[1].get("status") == "final" and args.mode != "end" and harness == "claude":
2781
+ return "skipped"
2782
+ if not ctx.safe_base() or not ctx.enabled() or not ctx.author or not ctx.memory.is_dir():
2783
+ return "skipped"
2784
+ if not getattr(args, "probed", False) and not probe(path, ctx):
2785
+ return "failed"
2786
+ # The shared module owns main and gated subagent parsing.
2787
+ facts = st.load_session(path, projects_dir=ctx.projects, prefix=ctx.prefix, root=ctx.root,
2788
+ codex_index=index if harness == "codex" else None, require_root_prompts=True)
2789
+ if st.skip_reason(facts):
2790
+ return "skipped"
2791
+ trail = st.render_trail(facts)
2792
+ private_dir(ctx.base)
2793
+ atomic(ctx.base / (sid + ".md"), trail)
2794
+ output, reason = (None, "held session") if ctx.held(sid, args.transcript) else model_call(ctx, sid, trail)
2795
+ if not ctx.enabled():
2796
+ return "skipped"
2797
+ requested = args.detail or ctx.state.get("session_records_detail", ctx.policy.get("default_detail", "outline"))
2798
+ branch = facts.get("branch") or next(iter(sorted(facts.get("branches", []))), "")
2799
+ topic = re.sub(r"^(?:dev/[^/]+/|feature/|bugfix/)", "", branch).replace("-", " ")
2800
+ if not branch or branch.casefold() in {"head", "(detached)"} or branch in {ctx.config.get("base_branch", "develop"), "develop", "main", "master"}:
2801
+ # Only *guarded* model output can supply the file name.
2802
+ preliminary = st.outline(facts, repo=ctx.root, model_output=output, topic="session",
2803
+ person=ctx.display or ctx.author, friction_reason=reason)
2804
+ first_asked = preliminary["sections"].get("Asked", "")
2805
+ if isinstance(first_asked, list):
2806
+ first_asked = "\n".join(first_asked)
2807
+ first_asked = first_asked.strip().splitlines()[0] if first_asked.strip() else ""
2808
+ topic = "-".join(re.findall(r"[a-z0-9]+", first_asked.lower())[:4]) if output and not first_asked.startswith(("(withheld", "not derived")) else "session"
2809
+ history = repository_history(ctx, facts)
2810
+ outline = st.outline(facts, repo=ctx.root, model_output=output, detail=requested, topic=topic, author=ctx.author,
2811
+ person=ctx.display or ctx.author,
2812
+ friction_reason=reason, git_log=history, flaked=gate_flakes(ctx.memory, sid))
2813
+ if args.mode == "checkpoint":
2814
+ queue_cost_line(ctx, sid, facts, outline)
2815
+ stamp = outline.get("first") or time.time()
2816
+ date = datetime.fromtimestamp(stamp, timezone.utc).isoformat() if isinstance(stamp, (int, float)) else stamp
2817
+ body = outline["body"]
2818
+ sections = outline["sections"]
2819
+ summary = sections.get("Summary", "")
2820
+ if isinstance(summary, list):
2821
+ summary = "\n".join(summary)
2822
+ if body.startswith("## Summary"):
2823
+ body = body.partition("\n## ")[2]
2824
+ body = "## " + body if body else ""
2825
+ status = "in progress" if args.mode == "checkpoint" else "final"
2826
+ if harness != "claude":
2827
+ status = ("final" if args.mode == "end" or idle >= 86400
2828
+ or (old and old[1].get("status") == "final") else "in progress")
2829
+ through = (family_watermark(ctx, index, path) if harness == "codex" else
2830
+ round(modified, 6) if harness in ("pi", "prime") else facts.get("last") or stamp)
2831
+ data = {"author": args.author or ctx.author, "session": sid, "topic": topic or "session", "date": date,
2832
+ "harness": harness, "through": datetime.fromtimestamp(through, timezone.utc).isoformat(),
2833
+ "branch": branch, "duration": outline["duration"], "active": outline["active"], "cost_usd": outline["cost"],
2834
+ "cost_source": outline["cost_source"], "cost_coverage": outline["cost_coverage"],
2835
+ "cost_sessions": outline["cost_sessions"], "status": status,
2836
+ "record_revision": int(old[1].get("record_revision", 0)) + 1 if old else 1,
2837
+ "detail": outline["detail"], "mode": args.mode, "body": body, "summary": summary,
2838
+ "backfill": args.mode == "backfill" or bool(args.staging)}
2839
+ if not ctx.enabled():
2840
+ return "skipped"
2841
+ if ctx.held(sid, args.transcript):
2842
+ data = stub_data(data)
2843
+ result = capture_via_engine(ctx, data, args.staging)
2844
+ atomic(ctx.base / "stamps" / sid, str(time.time()))
2845
+ if not args.staging:
2846
+ refresh_index(ctx)
2847
+ return "built" if result else "skipped"
2848
+
2849
+
2850
+ def finish_session(ctx, lock, args=None):
2851
+ """Every session-lock holder drains final requests before giving up ownership."""
2852
+ prior = redaction_record(ctx, lock.path.name)
2853
+ if prior and prior[1].get("producer"):
2854
+ lock.release()
2855
+ return
2856
+ marker = lock.path.with_name(lock.path.name + ".end-requested")
2857
+ while lock.owner() == os.getpid():
2858
+ while marker.exists():
2859
+ pending = argparse.Namespace(**vars(args)) if args is not None else None
2860
+ if pending is None or not pending.transcript:
2861
+ _, sid, transcript = resolve_context(ctx, lock.path.name)
2862
+ if not transcript:
2863
+ break
2864
+ pending = record_parser().parse_args(["--root", str(ctx.root), "--mode", "end",
2865
+ "--session-id", sid, "--transcript", str(transcript)])
2866
+ marker.unlink(missing_ok=True)
2867
+ pending.mode = "end"
2868
+ pending.probed = False
2869
+ try:
2870
+ build(ctx, pending)
2871
+ except Exception as exc:
2872
+ ctx.error(str(exc))
2873
+ unresolved = marker.exists()
2874
+ lock.release()
2875
+ if unresolved or not marker.exists() or not lock.acquire():
2876
+ return
2877
+
2878
+
2879
+ def run_worker(ctx, args, lock):
2880
+ try:
2881
+ # Entry hands over owner before returning; no transcript opens before this.
2882
+ until = time.monotonic() + 5
2883
+ while lock.owner() != os.getpid():
2884
+ if time.monotonic() > until:
2885
+ return 0
2886
+ time.sleep(0.01)
2887
+ if not probe(Path(args.transcript), ctx):
2888
+ return 0
2889
+ args.probed = True
2890
+ marker = lock.path.with_name(lock.path.name + ".end-requested")
2891
+ while True:
2892
+ if marker.exists():
2893
+ marker.unlink(missing_ok=True)
2894
+ args.mode = "end"
2895
+ try:
2896
+ build(ctx, args)
2897
+ except Exception as exc:
2898
+ ctx.error(str(exc))
2899
+ if marker.exists():
2900
+ continue
2901
+ lock.release()
2902
+ # Close the release/request race. The end entrant also retries after
2903
+ # depositing its marker; exactly one can acquire the next lock.
2904
+ if marker.exists() and lock.acquire():
2905
+ continue
2906
+ break
2907
+ finally:
2908
+ finish_session(ctx, lock, args)
2909
+ return 0
2910
+
2911
+
2912
+ def record(args):
2913
+ ctx = Context(args.project_root or args.root, args.folder)
2914
+ if args.mode == "sweep" and not args.transcript:
2915
+ return sweep(ctx, args)
2916
+ if args.mode == "redact":
2917
+ if not args.session_id or not SAFE_ID.fullmatch(args.session_id):
2918
+ return 0
2919
+ ctx = resolve_context(ctx, args.session_id)[0]
2920
+ if not ctx.safe_base():
2921
+ return 0
2922
+ lock = ctx.lock(args.session_id, "redact")
2923
+ if not lock.acquire(wait=120):
2924
+ ctx.error("redact timed out waiting for session worker")
2925
+ return 1
2926
+ native_record = redaction_record(ctx, args.session_id)
2927
+ native_record = bool(native_record and native_record[1].get("producer"))
2928
+ try:
2929
+ redact_locked(ctx, args.session_id)
2930
+ if not native_record:
2931
+ refresh_index(ctx)
2932
+ finally:
2933
+ if native_record:
2934
+ lock.release() # A durable intent owns no human transcript-end request.
2935
+ else:
2936
+ finish_session(ctx, lock, args)
2937
+ return 0
2938
+ if not args.transcript:
2939
+ try:
2940
+ hook = json.load(sys.stdin)
2941
+ except (ValueError, OSError):
2942
+ return 0
2943
+ args.transcript = hook.get("transcript_path")
2944
+ args.session_id = hook.get("session_id")
2945
+ if not args.transcript:
2946
+ return 0
2947
+ path = Path(args.transcript)
2948
+ # Native formats must expose an admitted cwd before any body is opened.
2949
+ info = transcript_info(ctx, path)
2950
+ if not info:
2951
+ # An interrupted hold's deletion needs no transcript content. Keep this
2952
+ # cleanup separate from admission to build any new record.
2953
+ if (args.mode == "end" and args.session_id == path.stem
2954
+ and SAFE_ID.fullmatch(path.stem) and regular(path)
2955
+ and st.within_instance(path, ctx.projects, ctx.prefix)):
2956
+ target = Context(ctx.root, folder_for(ctx, path))
2957
+ if (target.safe_base() and target.enabled() and target.author and target.memory.is_dir()
2958
+ and target.held(args.session_id) and target.state.get("session_records_hold_stub", True) is False):
2959
+ publish_held_deletions(target, args.session_id)
2960
+ return 0
2961
+ if info[0] != "claude":
2962
+ args.session_id = info[1]
2963
+ if info[0] == "codex" and not codex_human_root(info[2]):
2964
+ return 0
2965
+ if not args.session_id or not SAFE_ID.fullmatch(args.session_id):
2966
+ return 0
2967
+ ctx = Context(ctx.root, folder_for(ctx, path))
2968
+ if not ctx.safe_base():
2969
+ return 0
2970
+ lock = ctx.lock(args.session_id, args.mode)
2971
+ if args.worker:
2972
+ return run_worker(ctx, args, lock)
2973
+ if not ctx.enabled() or not regular(path) or not ctx.author or not ctx.memory.is_dir():
2974
+ return 0
2975
+ if args.mode == "checkpoint" and getattr(args, "notice_fd", None) is not None:
2976
+ # The one line a checkpoint prints: a cost mark the last build passed.
2977
+ say(args.notice_fd, take_cost_line(ctx, args.session_id))
2978
+ if ctx.held(args.session_id, args.transcript) and ctx.state.get("session_records_hold_stub", True) is False:
2979
+ if args.mode == "end":
2980
+ publish_held_deletions(ctx, args.session_id)
2981
+ return 0
2982
+ acquired = lock.acquire()
2983
+ marker = lock.path.with_name(lock.path.name + ".end-requested")
2984
+ if not acquired:
2985
+ if args.mode != "end":
2986
+ return 0
2987
+ atomic(marker, "end")
2988
+ acquired = lock.acquire()
2989
+ if not acquired:
2990
+ return 0
2991
+ try:
2992
+ if marker.exists():
2993
+ marker.unlink(missing_ok=True)
2994
+ args.mode = "end"
2995
+ stamp = ctx.base / "stamps" / args.session_id
2996
+ if args.mode == "checkpoint" and stamp.exists() and time.time() - stamp.stat().st_mtime < args.debounce:
2997
+ return 0
2998
+ atomic(stamp, str(time.time()))
2999
+ if args.mode in ("backfill", "sweep") or args.staging:
3000
+ return 0 if build(ctx, args) == "built" else 1
3001
+ command = [sys.executable, str(SELF), "record", "--root", str(ctx.root), "--project-root", str(ctx.root),
3002
+ "--mode", args.mode, "--transcript", str(args.transcript), "--session-id", args.session_id,
3003
+ "--folder=" + ctx.folder, "--worker"]
3004
+ if args.detail:
3005
+ command += ["--detail", args.detail]
3006
+ if args.author:
3007
+ command += ["--author", args.author]
3008
+ if args.display_name:
3009
+ command += ["--display-name", args.display_name]
3010
+ child = subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
3011
+ stderr=subprocess.DEVNULL, start_new_session=True, close_fds=True)
3012
+ lock.handover(child.pid)
3013
+ finally:
3014
+ finish_session(ctx, lock, args)
3015
+ return 0
3016
+
3017
+
3018
+ def resolve_context(ctx, identifier):
3019
+ if not SAFE_ID.fullmatch(identifier):
3020
+ raise ValueError("invalid session id")
3021
+ matches = {}
3022
+ alias_ids = {row["session"] for row in aliases(ctx) if row["hash"] == identifier}
3023
+ for path in discover_paths(ctx):
3024
+ info = transcript_info(ctx, path)
3025
+ if info and (info[1].startswith(identifier) or info[1] in alias_ids
3026
+ or (info[0] in ("pi", "prime") and identifier in (path_identity(path), path_identity(path.resolve())))):
3027
+ matches[info[1]] = (Context(ctx.root, folder_for(ctx, path)), path)
3028
+ live_owned = set()
3029
+ for rows, deleted in ((all_records(ctx), False), (deleted_record_rows(ctx), True)):
3030
+ for _, meta, _ in rows:
3031
+ sid = str(meta.get("session", ""))
3032
+ if sid.startswith(identifier) and SAFE_ID.fullmatch(sid):
3033
+ if meta.get("producer"):
3034
+ native_record_adapter()
3035
+ if not (deleted and sid in live_owned) and not owned_record(ctx, meta):
3036
+ raise ValueError("that record belongs to another person")
3037
+ if not deleted:
3038
+ live_owned.add(sid)
3039
+ matches.setdefault(sid, (ctx, None))
3040
+ for folder in (ctx.home / ".egregore/trails").glob(ctx.prefix + "*"):
3041
+ if not st.valid_trail_folder(folder, ctx.projects, ctx.prefix):
3042
+ continue
3043
+ for path in folder.glob(identifier + "*.md"):
3044
+ if SAFE_ID.fullmatch(path.stem):
3045
+ if path.stem not in matches or matches[path.stem][1] is None:
3046
+ matches[path.stem] = (Context(ctx.root, folder.name), None)
3047
+ if len(matches) > 1:
3048
+ raise ValueError("session prefix is ambiguous")
3049
+ if matches:
3050
+ sid, (found_ctx, path) = next(iter(matches.items()))
3051
+ return found_ctx, sid, path
3052
+ return ctx, identifier, None
3053
+
3054
+
3055
+ def hold(ctx, identifier, exact=False):
3056
+ if not SAFE_ID.fullmatch(identifier):
3057
+ raise ValueError("invalid session id")
3058
+ pi_hold = os.environ.get("EGREGORE_NATIVE_HARNESS") in ("pi", "prime")
3059
+ alias = next((row for row in aliases(ctx) if row["hash"] == identifier), None)
3060
+ if pi_hold and alias is None:
3061
+ alias = recover_pi_alias(ctx, identifier)
3062
+ if pi_hold and alias:
3063
+ info = transcript_info(ctx, alias["path"])
3064
+ harness = info[0] if info and info[0] in ("pi", "prime") else os.environ["EGREGORE_NATIVE_HARNESS"]
3065
+ target, sid = Context(ctx.root, ctx.prefix + "--" + harness), alias["session"]
3066
+ else:
3067
+ target, sid, _ = resolve_context(ctx, identifier)
3068
+ if exact and sid != identifier:
3069
+ target, sid = ctx, identifier
3070
+ old = redaction_record(target, sid)
3071
+ if old and not owned_record(ctx, old[1]):
3072
+ raise ValueError("that record belongs to another person")
3073
+ if not target.safe_base():
3074
+ raise ValueError("personal trail folder is outside this instance")
3075
+ if not exact and not old and not resolve_context(ctx, identifier)[2] and not (target.base / (sid + ".md")).is_file():
3076
+ raise ValueError("no session for that id in this instance")
3077
+ # A hold takes effect between publications, never during one. Release the
3078
+ # memory lock before waiting on the session lock to avoid lock inversion.
3079
+ memory_lock = target.memory_lock()
3080
+ if not memory_lock.acquire(wait=120):
3081
+ raise RuntimeError("memory writer busy; no hold was written")
3082
+ try:
3083
+ old = redaction_record(target, sid)
3084
+ if old and not owned_record(target, old[1]):
3085
+ raise ValueError("that record belongs to another person")
3086
+ had_record = old is not None
3087
+ private_dir(target.base / "holds")
3088
+ atomic(target.base / "holds" / sid, datetime.now(timezone.utc).isoformat())
3089
+ if pi_hold:
3090
+ atomic(target.base / "holds" / identifier, datetime.now(timezone.utc).isoformat())
3091
+ if alias:
3092
+ atomic(target.base / "holds" / alias["session"], datetime.now(timezone.utc).isoformat())
3093
+ remember_pi_alias(target, alias)
3094
+ finally:
3095
+ memory_lock.release()
3096
+ result = subprocess.run(["bash", str(ctx.root / "bin/session-record.sh"), "--mode", "redact",
3097
+ "--session-id", sid, "--project-root", str(ctx.root)],
3098
+ env=clean_env(), capture_output=True, text=True)
3099
+ if result.returncode:
3100
+ raise RuntimeError("hold saved; existing record is busy, retry records redact " + sid)
3101
+ if pi_hold and alias is None and exact and not had_record:
3102
+ print("Hold recorded for this session's identity; its existing record could not be located. "
3103
+ "The hook identity will apply at the next publication.")
3104
+ return
3105
+ after = existing(target, sid)
3106
+ outcome = "stubbed" if after else "removed" if had_record else "none yet"
3107
+ print("Session held · existing record " + outcome)
3108
+
3109
+
3110
+ def enroll(ctx):
3111
+ """Keep the first time this person answered the past-session offer."""
3112
+ since = event_time(ctx.state.get("session_records_since"))
3113
+ if since is None:
3114
+ now = datetime.now(timezone.utc)
3115
+ ctx.set_state("session_records_since", now.isoformat())
3116
+ since = now.timestamp()
3117
+ return since
3118
+
3119
+
3120
+ def history_since(ctx):
3121
+ """The earliest session start the sweep may newly publish, or None once the
3122
+ person accepted past sessions with `backfill --yes`. Seeing the offer is not
3123
+ acceptance, and neither is an existing record: an install that answered
3124
+ before enrollment existed is enrolled at its first sweep."""
3125
+ if ctx.state.get("session_records_backfill") == "accepted":
3126
+ return None
3127
+ return enroll(ctx)
3128
+
3129
+
3130
+ def candidates(ctx, days, include_live=False, codex_index=None, discovery_skipped=None, since=None):
3131
+ """With `since`, a session without a record qualifies only when it began at
3132
+ or after that time; an unknown start never qualifies. Existing records keep
3133
+ their refresh and finalization."""
3134
+ recorded = {m.get("session"): m for _, m, _ in all_records(ctx) if m.get("kind") == "session-record"}
3135
+ result, skipped = [], Counter(discovery_skipped or {})
3136
+ now = time.time()
3137
+ index = codex_header_index(ctx, skipped=skipped) if codex_index is None else codex_index
3138
+ for path in discover_paths(ctx, codex_index=index, skipped=skipped):
3139
+ try:
3140
+ cached = index.get(str(path))
3141
+ info, reason = inspect_transcript(ctx, path,
3142
+ header_result=(cached["header"], True) if cached else None, headers_only=include_live)
3143
+ if not info:
3144
+ skipped[reason] += 1
3145
+ continue
3146
+ harness, sid, header = info
3147
+ if harness == "codex" and not codex_human_root(header):
3148
+ skipped["agent-driven"] += 1
3149
+ continue
3150
+ target = Context(ctx.root, folder_for(ctx, path) if harness == "claude" else ctx.prefix + "--" + harness)
3151
+ if target.held(sid, path if harness in ("pi", "prime") else None):
3152
+ skipped["held"] += 1
3153
+ continue
3154
+ old = recorded.get(sid)
3155
+ if old and old.get("detail") == "stub":
3156
+ skipped["already recorded"] += 1
3157
+ continue
3158
+ family = family_rows(index, path) if harness == "codex" else []
3159
+ modified = max((row["mtime"] for _, row in family), default=path.stat().st_mtime)
3160
+ idle = now - modified
3161
+ finalizing = bool(old and old.get("status") == "in progress" and idle >= 86400)
3162
+ # A Claude record is finished by its own SessionEnd hook. A killed tab
3163
+ # never runs it, so the sweep finishes only a record left in progress
3164
+ # for a day, the rule the native harnesses already follow.
3165
+ if old and harness == "claude" and not finalizing:
3166
+ skipped["already recorded"] += 1
3167
+ continue
3168
+ if idle < 600 and not include_live:
3169
+ skipped["live"] += 1
3170
+ continue
3171
+ through = max((row.get("last_event") if row.get("last_event") is not None else row["mtime"]
3172
+ for _, row in family), default=round(modified, 6))
3173
+ known = harness != "codex" or all(row.get("last_event") is not None for _, row in family)
3174
+ # Cached watermarks decide unchanged records before any body read.
3175
+ if old and known and not finalizing and through <= (event_time(old.get("through")) or 0):
3176
+ skipped["already recorded"] += 1
3177
+ continue
3178
+ start = event_time(header.get("timestamp")) or modified
3179
+ if include_live:
3180
+ if modified < now - days * 86400 and not finalizing:
3181
+ skipped["outside the window"] += 1
3182
+ continue
3183
+ duration = max(0, modified - start)
3184
+ result.append((modified, path, {"duration_seconds": duration,
3185
+ "duration": f"{int(duration // 60)}m", "cost": 0, "commits": 0}, 0))
3186
+ continue
3187
+ enrolled = since is not None and not old
3188
+ begun = event_time(header.get("timestamp"))
3189
+ if enrolled and begun is not None and begun < since:
3190
+ skipped["before enrollment"] += 1
3191
+ continue
3192
+ if harness != "claude" and through < now - days * 86400 and not finalizing:
3193
+ skipped["outside the window"] += 1
3194
+ continue
3195
+ if harness == "codex":
3196
+ through = family_watermark(ctx, index, path)
3197
+ if old and not finalizing and through <= (event_time(old.get("through")) or 0):
3198
+ skipped["already recorded"] += 1
3199
+ continue
3200
+ if harness != "claude" and through < now - days * 86400 and not finalizing:
3201
+ skipped["outside the window"] += 1
3202
+ continue
3203
+ if not probe(path, ctx):
3204
+ _, readable = st.read_header_result(path)
3205
+ skipped["unsupported" if readable else "unreadable header"] += 1
3206
+ continue
3207
+ facts = st.load_session(path, projects_dir=ctx.projects, prefix=ctx.prefix, root=ctx.root,
3208
+ codex_index=index if harness == "codex" else None, require_root_prompts=True)
3209
+ skip = st.skip_reason(facts)
3210
+ if skip:
3211
+ skipped[skip] += 1
3212
+ continue
3213
+ if enrolled:
3214
+ starts = [value for value in (begun, facts.get("first")) if value is not None]
3215
+ if not starts:
3216
+ skipped["start unknown"] += 1
3217
+ continue
3218
+ if min(starts) < since:
3219
+ skipped["before enrollment"] += 1
3220
+ continue
3221
+ start = facts.get("first") or path.stat().st_mtime
3222
+ activity = through if harness != "claude" else start
3223
+ if activity < now - days * 86400 and not finalizing:
3224
+ skipped["outside the window"] += 1
3225
+ continue
3226
+ out = st.outline(facts, repo=ctx.root)
3227
+ if not old and out["duration_seconds"] < 120 and out["commits"] == 0:
3228
+ skipped["shorter than 2 minutes with no commits"] += 1
3229
+ continue
3230
+ result.append((through if harness != "claude" else start, path, out, len(facts["prompts"])))
3231
+ except Exception as exc:
3232
+ skipped["failed"] += 1
3233
+ ctx.error("candidate failed: " + str(exc))
3234
+ return sorted(result, key=lambda r: (-r[0], str(r[1]))), skipped
3235
+
3236
+
3237
+ def publish_batch(ctx, staged, skipped, failed, sources=None, codex_index=None):
3238
+ lock = ctx.memory_lock()
3239
+ if not lock.acquire(wait=120):
3240
+ raise RuntimeError("memory writer busy; staged records retained")
3241
+ paths, dates, published_contexts = [], [], []
3242
+ native_blocked = False
3243
+ locks = []
3244
+ try:
3245
+ for target_ctx, path in staged:
3246
+ try:
3247
+ text = staging_file(target_ctx, target_ctx.base / "backfill-staging", path).read_text()
3248
+ except (ValueError, OSError) as exc:
3249
+ failed += 1
3250
+ target_ctx.error(str(exc))
3251
+ continue
3252
+ data = metadata(text)
3253
+ sid = data["session"]
3254
+ old = existing(ctx, sid)
3255
+ if data.get("producer") or old and old[1].get("producer"):
3256
+ skipped["native producer"] += 1
3257
+ native_blocked = True
3258
+ continue # Never claim/drain a native intent as a human session.
3259
+ session_lock = target_ctx.lock(sid, "backfill-publish")
3260
+ # Never wait while holding the memory lock: a live worker can be
3261
+ # waiting for it. Retry that session on the next batch instead.
3262
+ if not session_lock.acquire():
3263
+ skipped["busy"] += 1
3264
+ continue
3265
+ locks.append((target_ctx, session_lock))
3266
+ old = existing(ctx, sid)
3267
+ replace_legacy = old and old[1].get("kind") != "session-record" and owned_record(target_ctx, old[1])
3268
+ native = data.get("harness") in ("codex", "pi", "prime")
3269
+ source = (sources or {}).get(sid)
3270
+ if native and source:
3271
+ info = transcript_info(target_ctx, source)
3272
+ if not info:
3273
+ skipped["failed"] += 1
3274
+ continue
3275
+ if info[0] == "codex":
3276
+ index = codex_index if codex_index is not None else codex_header_index(ctx)
3277
+ family = family_rows(index, source)
3278
+ modified = max((Path(name).stat().st_mtime for name, _ in family), default=source.stat().st_mtime)
3279
+ latest = family_watermark(ctx, index, source)
3280
+ else:
3281
+ modified = source.stat().st_mtime
3282
+ latest = round(modified, 6)
3283
+ if time.time() - modified < 600 or latest > (event_time(data.get("through")) or 0):
3284
+ skipped["live"] += 1
3285
+ path.unlink(missing_ok=True)
3286
+ continue
3287
+ # A Claude record replaced here is one a killed tab left in progress.
3288
+ finishing = (data.get("harness") == "claude" and old and old[1].get("status") == "in progress"
3289
+ and data.get("status") == "final")
3290
+ newer = (native or finishing) and old and int(data.get("record_revision", 0)) > int(old[1].get("record_revision", 0))
3291
+ if not target_ctx.enabled() or target_ctx.held(sid) or (old and not replace_legacy and not newer):
3292
+ skipped["held" if target_ctx.held(sid) else "already recorded or disabled"] += 1
3293
+ path.unlink(missing_ok=True)
3294
+ continue
3295
+ target = old[0] if old else ctx.memory / "sessions" / data["date"][:7] / path.name
3296
+ if target.exists() and not old:
3297
+ target = target.with_name(target.stem + "-" + sid + ".md")
3298
+ if target.is_symlink() or not target.resolve().is_relative_to(ctx.memory):
3299
+ skipped["invalid destination"] += 1
3300
+ continue
3301
+ if target.is_file():
3302
+ with target.open("rb") as stream:
3303
+ destination = metadata(stream.read(256 * 1024).decode("utf-8", errors="replace"))
3304
+ if destination.get("producer"):
3305
+ skipped["native producer"] += 1
3306
+ native_blocked = True
3307
+ continue
3308
+ if not old:
3309
+ skipped["occupied destination"] += 1
3310
+ continue
3311
+ target.parent.mkdir(parents=True, exist_ok=True)
3312
+ os.replace(path, target)
3313
+ paths.append(target)
3314
+ dates.append(data["date"][:10])
3315
+ published_contexts.append((target_ctx, target, data))
3316
+ if paths:
3317
+ message = f"chore(sessions): record {len(paths)} past sessions ({min(dates)} → {max(dates)})"
3318
+ message += f"\n\nBuilt: {len(paths)}\nSkipped: " + ", ".join(f"{k}={v}" for k, v in sorted(skipped.items())) + f"\nFailed: {failed}"
3319
+ def before_publish():
3320
+ for target_ctx, target, meta in published_contexts:
3321
+ paths.extend(item for item in apply_record_hold(target_ctx, target, meta) if item not in paths)
3322
+ if not target_ctx.held(meta["session"]) and not target_ctx.enabled():
3323
+ return False
3324
+ return True
3325
+ commit(ctx, paths, message, before_publish=before_publish)
3326
+ ctx.set_state("session_records_backfilled_through", max(dates))
3327
+ elif not native_blocked:
3328
+ push(ctx)
3329
+ finally:
3330
+ lock.release()
3331
+ for target_ctx, session_lock in locks:
3332
+ finish_session(target_ctx, session_lock)
3333
+ return len(published_contexts), failed
3334
+
3335
+
3336
+ def sweep(ctx, args):
3337
+ """Always count in a detached worker; consent gates publication only."""
3338
+ ctx = Context(ctx.root)
3339
+ if not ctx.safe_base():
3340
+ return 0
3341
+ lock = ctx.lock("sweep", "sweep")
3342
+ stamp = ctx.base / "sweep.stamp"
3343
+ if not args.worker:
3344
+ if not lock.acquire():
3345
+ return 0
3346
+ try:
3347
+ command = [sys.executable, str(SELF), "record", "--root", str(ctx.root),
3348
+ "--mode", "sweep", "--worker"]
3349
+ if args.force:
3350
+ command.append("--force")
3351
+ child = subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
3352
+ stderr=subprocess.DEVNULL, start_new_session=True, close_fds=True)
3353
+ lock.handover(child.pid)
3354
+ finally:
3355
+ lock.release()
3356
+ return 0
3357
+ try:
3358
+ until = time.monotonic() + 5
3359
+ while lock.owner() != os.getpid():
3360
+ if time.monotonic() > until:
3361
+ return 0
3362
+ time.sleep(0.01)
3363
+ discovery_skipped = Counter()
3364
+ index = codex_header_index(ctx, skipped=discovery_skipped)
3365
+ counted, skipped = candidates(ctx, ctx.policy.get("retroactive_days", 90), include_live=True,
3366
+ codex_index=index, discovery_skipped=discovery_skipped)
3367
+ refresh_index(ctx, codex_index=index, rows=counted)
3368
+ if (not ctx.enabled() or not ctx.author or not ctx.memory.is_dir()
3369
+ or ctx.state.get("session_records_noticed") is not True):
3370
+ return 0
3371
+ since = history_since(ctx)
3372
+ if not args.force and stamp.exists() and time.time() - stamp.stat().st_mtime < 6 * 3600:
3373
+ return 0
3374
+ atomic(stamp, str(time.time()))
3375
+ rows, skipped = candidates(ctx, ctx.policy.get("retroactive_days", 90),
3376
+ codex_index=index, discovery_skipped=discovery_skipped, since=since)
3377
+ staged, failed, sources = [], skipped.pop("failed", 0), {}
3378
+ for _, path, _, _ in rows[:20]:
3379
+ ctx.reload()
3380
+ if not ctx.enabled() or ctx.state.get("session_records_noticed") is not True:
3381
+ break
3382
+ info, reason = inspect_transcript(ctx, path)
3383
+ if not info:
3384
+ skipped[reason] += 1
3385
+ continue
3386
+ sid = info[1]
3387
+ target = Context(ctx.root, folder_for(ctx, path))
3388
+ if not target.safe_base():
3389
+ skipped["invalid trail folder"] += 1
3390
+ continue
3391
+ stage = target.base / "backfill-staging"
3392
+ arguments = record_parser().parse_args(["--root", str(ctx.root), "--mode", "sweep",
3393
+ "--session-id", sid, "--transcript", str(path), "--staging", str(stage)])
3394
+ arguments.codex_index = index
3395
+ sources[sid] = path
3396
+ session_lock = target.lock(sid, "sweep")
3397
+ if not session_lock.acquire():
3398
+ skipped["busy"] += 1
3399
+ continue
3400
+ try:
3401
+ staging_directory(target, stage, create=True)
3402
+ status = build(target, arguments)
3403
+ if status == "built":
3404
+ found = next((p for p, meta, _ in staged_records(target, stage)
3405
+ if meta.get("session") == sid), None)
3406
+ if found:
3407
+ staged.append((target, found))
3408
+ else:
3409
+ failed += 1
3410
+ elif status == "failed":
3411
+ failed += 1
3412
+ else:
3413
+ skipped[status] += 1
3414
+ except Exception as exc:
3415
+ failed += 1
3416
+ target.error(str(exc))
3417
+ finally:
3418
+ finish_session(target, session_lock, arguments)
3419
+ index = codex_header_index(ctx)
3420
+ if ctx.enabled() and ctx.state.get("session_records_noticed") is True:
3421
+ publish_batch(ctx, staged, skipped, failed, sources=sources, codex_index=index)
3422
+ refresh_index(ctx, codex_index=index)
3423
+ except Exception as exc:
3424
+ ctx.error("sweep failed: " + str(exc))
3425
+ finally:
3426
+ lock.release()
3427
+ return 0
3428
+
3429
+
3430
+ def show_native_record(ctx, identifier):
3431
+ """Return False for human IDs. Native viewing never discovers transcripts."""
3432
+ if not SAFE_ID.fullmatch(identifier):
3433
+ raise ValueError("invalid session id")
3434
+ candidates = []
3435
+ for index, (path, meta, text) in enumerate(all_records(ctx)):
3436
+ if index >= 4096:
3437
+ raise ValueError("record inventory exceeds the viewing bound")
3438
+ if str(meta.get("session", "")).startswith(identifier):
3439
+ candidates.append((path, meta, text))
3440
+ native = [row for row in candidates if classify_record(row[1]) != "human-session"]
3441
+ if not native:
3442
+ if identifier.startswith("spirit-"):
3443
+ raise ValueError("no spirit record for that id in this instance")
3444
+ return False
3445
+ if len(candidates) != 1:
3446
+ raise ValueError("record prefix is ambiguous")
3447
+ path, meta, text = native[0]
3448
+ actor_id, reader = native_record_reader(ctx)
3449
+ if not reader or meta.get("record_owner_actor_id") != actor_id:
3450
+ raise ValueError("that spirit record is not available to this owner")
3451
+ access = reader(path, meta)
3452
+ if not access:
3453
+ raise ValueError("spirit record read is not permitted")
3454
+ projection = record_subject(meta, text)
3455
+ if access == "held" or projection.get("held"):
3456
+ projection = {"subject_kind": "durable-intent", "held": True}
3457
+ print(json.dumps(projection, indent=2, ensure_ascii=False))
3458
+ return True
3459
+
3460
+
3461
+ def records(ctx, argv):
3462
+ action = argv[0] if argv else "status"
3463
+ rest = argv[1:]
3464
+ if action in ("outcome", "intervention"):
3465
+ import record_outcomes
3466
+ return record_outcomes.command(sys.modules[__name__], ctx, rest, kind=action)
3467
+ if action == "digest":
3468
+ return digest(ctx, rest)
3469
+ if action == "open":
3470
+ return open_items(ctx, rest)
3471
+ if action == "close":
3472
+ return close_item(ctx, rest)
3473
+ if action in ("reactivate", "stale"):
3474
+ return relevance_item(ctx, rest, "active" if action == "reactivate" else "stale")
3475
+ if action == "file":
3476
+ return file_item(ctx, rest)
3477
+ if action in ("on", "off", "noticed"):
3478
+ ctx.set_state("session_records_noticed" if action == "noticed" else "session_records_enabled", action != "off")
3479
+ if action == "noticed":
3480
+ enroll(ctx)
3481
+ print("Session records: " + action)
3482
+ elif action == "detail":
3483
+ if not rest or rest[0] not in ("outline", "full"):
3484
+ raise ValueError("usage: records.sh detail outline|full")
3485
+ ctx.set_state("session_records_detail", rest[0])
3486
+ print("Session record detail: " + rest[0])
3487
+ elif action == "hold":
3488
+ sid = harness_session_id()
3489
+ if not sid:
3490
+ print("Session could not be identified; no hold was written.")
3491
+ return 1
3492
+ hold(ctx, sid, exact=True)
3493
+ elif action == "redact":
3494
+ if not rest:
3495
+ raise ValueError("usage: records.sh redact <id-or-prefix>")
3496
+ hold(ctx, rest[0])
3497
+ elif action == "show":
3498
+ if not rest:
3499
+ raise ValueError("usage: records.sh show <id-or-prefix>")
3500
+ if show_native_record(ctx, rest[0]):
3501
+ return 0
3502
+ target, sid, path = resolve_context(ctx, rest[0])
3503
+ if not target.safe_base():
3504
+ raise ValueError("personal trail folder is outside this instance")
3505
+ trail_path = target.base / (sid + ".md")
3506
+ if path and not transcript_info(ctx, path):
3507
+ raise ValueError("no transcript for that session in this instance")
3508
+ if not trail_path.resolve().is_relative_to(target.base.resolve()):
3509
+ raise ValueError("personal trail is outside this instance")
3510
+ if not trail_path.exists():
3511
+ if not path:
3512
+ raise ValueError("no transcript for that session in this instance")
3513
+ if not probe(path, ctx):
3514
+ raise ValueError("session transcript could not be read")
3515
+ private_dir(target.base)
3516
+ atomic(trail_path, st.render_trail(st.load_session(path, projects_dir=ctx.projects, prefix=ctx.prefix, root=ctx.root,
3517
+ codex_index=codex_header_index(ctx))))
3518
+ print(trail_path.read_text(), end="")
3519
+ elif action == "status":
3520
+ if "--push" in rest:
3521
+ lock = ctx.memory_lock()
3522
+ if lock.acquire(wait=120):
3523
+ try:
3524
+ push(ctx)
3525
+ finally:
3526
+ lock.release()
3527
+ index = refresh_index(ctx)
3528
+ print("org policy: " + ("on" if ctx.policy.get("enabled") is True else "off"))
3529
+ own = ctx.state.get("session_records_enabled", "inherit")
3530
+ print("yours: " + ("inherit" if own == "inherit" else "on" if own else "off"))
3531
+ print("detail: " + str(ctx.state.get("session_records_detail", ctx.policy.get("default_detail", "outline"))))
3532
+ print("hold stub: " + str(ctx.state.get("session_records_hold_stub", True)).lower())
3533
+ print("backfilled through: " + str(ctx.state.get("session_records_backfilled_through", "never")))
3534
+ print(f"{index['unrecorded_count']} unrecorded sessions on this machine")
3535
+ if index["waiting_push"]:
3536
+ print(f"{index['waiting_push']} commit waiting to push")
3537
+ elif action == "sweep":
3538
+ args = record_parser().parse_args(["--root", str(ctx.root), "--mode", "sweep", *rest])
3539
+ return sweep(ctx, args)
3540
+ elif action == "backfill":
3541
+ parser = argparse.ArgumentParser(prog="records.sh backfill")
3542
+ parser.add_argument("--days", type=int, default=ctx.policy.get("retroactive_days", 90))
3543
+ parser.add_argument("--dry-run", action="store_true")
3544
+ parser.add_argument("--yes", action="store_true")
3545
+ args = parser.parse_args(rest)
3546
+ discovery_skipped = Counter()
3547
+ index = codex_header_index(ctx, skipped=discovery_skipped)
3548
+ rows, skipped = candidates(ctx, args.days, codex_index=index, discovery_skipped=discovery_skipped)
3549
+ calls = len(rows) if rows and ctx.enabled() and ctx.model_enabled() and cli_flags(ctx)[0] is not None else 0
3550
+ print("date session prompts duration cost")
3551
+ for start, path, out, prompts in rows:
3552
+ print(f"{datetime.fromtimestamp(start, timezone.utc):%Y-%m-%d} {path.stem[:12]:12} {prompts:7} {out['duration']:8} ${out['cost']:.2f}")
3553
+ print(f"{calls} model calls")
3554
+ for reason in ("held", "shorter than 2 minutes with no commits", "already recorded", "outside the window", "live",
3555
+ "agent-driven", "no prompts", "unreadable header", "other project", "unsupported"):
3556
+ print(f"skipped {reason}: {skipped[reason]}")
3557
+ if not args.yes or args.dry_run:
3558
+ print(f"failed: {skipped['failed']}")
3559
+ refresh_index(ctx)
3560
+ return 0
3561
+ # The one acceptance of past sessions; the sweep reads it, never infers it.
3562
+ ctx.set_state("session_records_backfill_accepted_at", datetime.now(timezone.utc).isoformat())
3563
+ ctx.set_state("session_records_backfill", "accepted")
3564
+ staged, failed, sources = [], skipped.pop("failed", 0), {}
3565
+ for _, path, _, _ in rows:
3566
+ target = Context(ctx.root, folder_for(ctx, path))
3567
+ if not target.safe_base():
3568
+ skipped["invalid trail folder"] += 1
3569
+ continue
3570
+ sid = transcript_info(target, path)[1]
3571
+ stage = target.base / "backfill-staging"
3572
+ arguments = record_parser().parse_args(["--root", str(ctx.root), "--mode", "backfill", "--transcript", str(path), "--session-id", sid, "--staging", str(stage)])
3573
+ arguments.codex_index = index
3574
+ sources[sid] = path
3575
+ lock = target.lock(sid, "backfill")
3576
+ if not lock.acquire():
3577
+ skipped["busy"] += 1
3578
+ continue
3579
+ try:
3580
+ if time.time() - path.stat().st_mtime < 600:
3581
+ skipped["live"] += 1
3582
+ continue
3583
+ staging_directory(target, stage, create=True)
3584
+ status = build(target, arguments)
3585
+ if status == "built":
3586
+ found = next((p for p, meta, _ in staged_records(target, stage) if meta.get("session") == sid), None)
3587
+ if found:
3588
+ staged.append((target, found))
3589
+ else:
3590
+ failed += 1
3591
+ elif status == "failed":
3592
+ failed += 1
3593
+ elif status == "live":
3594
+ skipped["live"] += 1
3595
+ else:
3596
+ skipped["held or disabled"] += 1
3597
+ except Exception as exc:
3598
+ failed += 1
3599
+ target.error(str(exc))
3600
+ finally:
3601
+ finish_session(target, lock, arguments)
3602
+ index = codex_header_index(ctx)
3603
+ built, failed = publish_batch(ctx, staged, skipped, failed, sources=sources, codex_index=index)
3604
+ refresh_index(ctx, codex_index=index)
3605
+ print(f"skipped live: {skipped['live']}")
3606
+ print(f"built: {built}; skipped: {sum(skipped.values())}; failed: {failed}")
3607
+ else:
3608
+ raise ValueError("usage: records.sh status|on|off|detail|hold|redact|noticed|backfill|sweep|show|digest|open|close|stale|reactivate|file|outcome|intervention")
3609
+ return 0
3610
+
3611
+
3612
+ def record_parser():
3613
+ parser = argparse.ArgumentParser()
3614
+ parser.add_argument("--root", required=True)
3615
+ parser.add_argument("--project-root")
3616
+ parser.add_argument("--mode", choices=("end", "checkpoint", "backfill", "redact", "sweep"), required=True)
3617
+ parser.add_argument("--debounce", type=float, default=0)
3618
+ parser.add_argument("--transcript")
3619
+ parser.add_argument("--session-id")
3620
+ parser.add_argument("--author")
3621
+ parser.add_argument("--display-name")
3622
+ parser.add_argument("--detail", choices=("outline", "full"))
3623
+ parser.add_argument("--staging")
3624
+ parser.add_argument("--folder")
3625
+ parser.add_argument("--worker", action="store_true")
3626
+ parser.add_argument("--force", action="store_true")
3627
+ parser.add_argument("--notice-fd", type=int)
3628
+ return parser
3629
+
3630
+
3631
+ def main():
3632
+ if sys.argv[1] == "probe":
3633
+ return 0 if st.probe_format(Path(sys.argv[2])) is not None else 1
3634
+ if sys.argv[1] == "record":
3635
+ args = record_parser().parse_args(sys.argv[2:])
3636
+ try:
3637
+ return record(args)
3638
+ except Exception as exc:
3639
+ if args.worker or args.mode == "redact":
3640
+ Context(args.project_root or args.root, args.folder).error(str(exc))
3641
+ return 1 if args.mode == "redact" else 0
3642
+ if sys.argv[1] == "capture":
3643
+ parser = argparse.ArgumentParser()
3644
+ parser.add_argument("--root", required=True)
3645
+ parser.add_argument("--folder")
3646
+ parser.add_argument("--data")
3647
+ for field in ("author", "topic", "summary", "session-id", "branch", "date", "duration"):
3648
+ parser.add_argument("--" + field)
3649
+ parser.add_argument("--staging")
3650
+ args = parser.parse_args(sys.argv[2:])
3651
+ ctx = Context(args.root, args.folder)
3652
+ if args.data:
3653
+ data = json.loads(args.data)
3654
+ else:
3655
+ if not args.author or not args.session_id or not SAFE_ID.fullmatch(args.session_id):
3656
+ raise ValueError("--replace-session requires author and session id")
3657
+ old = existing(ctx, args.session_id)
3658
+ data = dict(author=args.author, session=args.session_id, topic=args.topic or "session",
3659
+ summary=args.summary or args.topic or "session", branch=args.branch or "session",
3660
+ date=args.date or datetime.now(timezone.utc).isoformat(), duration=args.duration or "unknown",
3661
+ status="final", detail="outline-deterministic", mode="end", cost_usd=0, cost_source="estimate",
3662
+ record_revision=int(old[1].get("record_revision", 0)) + 1 if old else 1)
3663
+ data["body"] = sys.stdin.read()
3664
+ result = capture(ctx, data, args.staging)
3665
+ if result:
3666
+ print(result)
3667
+ return 0
3668
+ if sys.argv[1] == "gate-event":
3669
+ # The merge gate's ledger line (bin/merge-gate.sh). A refusal is one
3670
+ # line on stderr, the reason the gate prints.
3671
+ try:
3672
+ return gate_event(sys.argv[2:])
3673
+ except (ValueError, RuntimeError, OSError, subprocess.SubprocessError) as exc:
3674
+ print(str(exc), file=sys.stderr)
3675
+ return 1
3676
+ if sys.argv[1] == "open-items-line":
3677
+ # The reflex probe (bin/reflexes/10-open-items.sh), silent while the
3678
+ # organization has session records off.
3679
+ parser = argparse.ArgumentParser(prog="session_record.py open-items-line")
3680
+ parser.add_argument("--root", required=True)
3681
+ parser.add_argument("--memory")
3682
+ args = parser.parse_args(sys.argv[2:])
3683
+ root = Path(args.root)
3684
+ features = read_json(root / "egregore.json").get("features")
3685
+ policy = features.get("session_records") if isinstance(features, dict) else None
3686
+ if not isinstance(policy, dict) or policy.get("enabled") is not True:
3687
+ return 0
3688
+ line = open_items_line(Path(args.memory) if args.memory else root / "memory",
3689
+ identities=Context(root).identities())
3690
+ if line:
3691
+ print(line)
3692
+ return 0
3693
+ if sys.argv[1] in ("vitals", "vitals-line"):
3694
+ # vitals prints both lines, or --json for the audit page and the Mind;
3695
+ # vitals-line is the reflex probe (bin/reflexes/05-vitals.sh), silent
3696
+ # while the organization has session records off.
3697
+ reflex = sys.argv[1] == "vitals-line"
3698
+ parser = argparse.ArgumentParser(prog="session_record.py " + sys.argv[1])
3699
+ parser.add_argument("--root", required=True)
3700
+ parser.add_argument("--memory")
3701
+ parser.add_argument("--days", type=int, default=VITALS_DAYS)
3702
+ parser.add_argument("--today", type=date.fromisoformat, help="YYYY-MM-DD; the local date by default")
3703
+ if not reflex:
3704
+ parser.add_argument("--json", action="store_true")
3705
+ parser.add_argument("--findings", action="store_true", help="show evidence, next actions and verification")
3706
+ args = parser.parse_args(sys.argv[2:])
3707
+ if not 1 <= args.days <= VITALS_MAX_DAYS:
3708
+ parser.error(f"--days takes 1 to {VITALS_MAX_DAYS}")
3709
+ root = Path(args.root)
3710
+ features = read_json(root / "egregore.json").get("features")
3711
+ policy = features.get("session_records") if isinstance(features, dict) else None
3712
+ enabled = (isinstance(policy, dict) and policy.get("enabled") is True
3713
+ and read_json(root / ".egregore-state.json").get("session_records_enabled", True) is True)
3714
+ if not enabled:
3715
+ if not reflex:
3716
+ print(json.dumps({"status": "disabled", "reason": "session-records-off", "findings": []})
3717
+ if args.json else "Scorecard unavailable while session recording is off.")
3718
+ return 0
3719
+ data = vitals(Path(args.memory) if args.memory else root / "memory", days=args.days, today=args.today)
3720
+ if not reflex and (args.json or args.findings):
3721
+ import record_outcomes
3722
+ details = record_outcomes.scorecard(sys.modules[__name__],
3723
+ Path(args.memory) if args.memory else root / "memory",
3724
+ ctx=Context(root))
3725
+ data["unit_economics"] = details.pop("unit_economics")
3726
+ findings = details.pop("findings")
3727
+ data["outcome_scorecard"] = details
3728
+ data["findings"] = [row for row in data["findings"] if row["code"] != "accepted_outcome_link_missing"] + findings
3729
+ text = (vitals_line(data, quiet=True) if reflex else json.dumps(data) if args.json else
3730
+ vitals_findings_text(data) if args.findings else vitals_line(data, quiet=False))
3731
+ if text:
3732
+ print(text)
3733
+ return 0
3734
+ if sys.argv[1] in ("records", "digest"):
3735
+ parser = argparse.ArgumentParser(add_help=False)
3736
+ parser.add_argument("--root", required=True)
3737
+ args, remaining = parser.parse_known_args(sys.argv[2:])
3738
+ try:
3739
+ return records(Context(args.root), (["digest"] if sys.argv[1] == "digest" else []) + remaining)
3740
+ except (ValueError, RuntimeError, OSError) as exc:
3741
+ print(str(exc))
3742
+ return 0 if sys.argv[1] == "digest" or remaining[:1] == ["digest"] else 1
3743
+ return 2
3744
+
3745
+
3746
+ if __name__ == "__main__":
3747
+ raise SystemExit(main())