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,1983 @@
1
+ #!/usr/bin/env python3
2
+ """Read harness transcripts and derive local trails and shared record sections.
3
+
4
+ Only this module reads transcript JSONL. Instance admission is based on physical
5
+ paths for Claude and admitted header working directories for Codex and Pi.
6
+ Python 3.11+, standard library only.
7
+ """
8
+ import argparse
9
+ import hashlib
10
+ import io
11
+ import itertools
12
+ import json
13
+ import math
14
+ import os
15
+ import re
16
+ import shlex
17
+ import stat
18
+ import sys
19
+ from collections import Counter, deque
20
+ from datetime import datetime
21
+ from pathlib import Path
22
+
23
+ SEARCH_RE = re.compile(r"\bsearch\.sh\s+(find|open|query|handoffs|investigate)\b")
24
+ KIND_RE = re.compile(r"--kind[ =]([a-z]+)")
25
+ SLEEP_RE = re.compile(r"^\s*sleep\s+(\d+(?:\.\d+)?)")
26
+ # Claude Code's worktree guard opens every refusal with this sentence, for a
27
+ # shell command it cannot prove stays in the worktree and for an edit aimed at
28
+ # the shared checkout alike. The texts, as found, are fixtures in the tests.
29
+ GUARD_RE = re.compile(r"(?:<tool_use_error>)?This session is isolated in the worktree ")
30
+ # Claude Code reports a command that ran and exited non-zero this way.
31
+ NONZERO_EXIT_RE = re.compile(r"Exit code -?0*[1-9]")
32
+ # A recall that cannot bind to its prompt: the episode binding is missing, or
33
+ # the private receipt carrying its evidence cannot be consumed. The receipt
34
+ # texts are raised in egregore_runtime/private_result.py.
35
+ BINDING_MARKERS = ("episode reference required", "binding unavailable",
36
+ "private receipt belongs to another native prompt",
37
+ "private receipt belongs to another native session",
38
+ "private receipt has no native prompt binding",
39
+ "private receipt is unavailable")
40
+ # A sandbox refuses an operation with these OS errors. The Runtime words a
41
+ # refused retrieval store itself (egregore_runtime/adapters/qmd.py), and when
42
+ # it detects a sandbox it says so: "is not writable (sandbox)" (qmd.py and
43
+ # sandbox.py) and "refused by the sandbox" (qmd.py). Permission denied counts
44
+ # only when it names the Runtime's lock or state directory.
45
+ SANDBOX_RE = re.compile(r"operation not permitted|\bEPERM\b|read-only file system"
46
+ r"|Local retrieval storage is unavailable"
47
+ r"|is not writable \(sandbox\)|refused by the sandbox"
48
+ r"|(?:permission denied|PermissionError)[^\n]*(?:lifecycle\.lock|\.egregore/(?:runtime|mind)/)",
49
+ re.I)
50
+ # Consecutive events further apart than this are idle time, not work.
51
+ ACTIVE_GAP_SECONDS = 300
52
+
53
+
54
+ def _ts(value):
55
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() if value else None
56
+
57
+
58
+ def _mapping(value):
59
+ return value if isinstance(value, dict) else {}
60
+
61
+
62
+ def _string(value, default=''):
63
+ return value if isinstance(value, str) else default
64
+
65
+
66
+ def _number(value, default=0):
67
+ try:
68
+ return value if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) and value >= 0 else default
69
+ except OverflowError:
70
+ return default
71
+
72
+
73
+ def _inline(value):
74
+ """Keep deterministic metadata on one line, without hiding path characters."""
75
+ return re.sub(r'[\x00-\x1f\x7f]', lambda match: '\\u%04x' % ord(match.group()), _string(value))
76
+
77
+
78
+ def _text(content):
79
+ if isinstance(content, str):
80
+ return content
81
+ if isinstance(content, list):
82
+ return "\n".join(_string(b.get("text")) for b in content if isinstance(b, dict))
83
+ return ""
84
+
85
+
86
+ def _pct(values, q):
87
+ if not values:
88
+ return None
89
+ values = sorted(values)
90
+ return values[min(len(values) - 1, int(round(q * (len(values) - 1))))]
91
+
92
+
93
+ def _add_event(spans, ts):
94
+ """Extend the latest stretch of activity with an event, or open a new one."""
95
+ if spans and spans[-1][0] <= ts and ts - spans[-1][1] < ACTIVE_GAP_SECONDS:
96
+ spans[-1][1] = max(spans[-1][1], ts)
97
+ else:
98
+ spans.append([ts, ts])
99
+
100
+
101
+ def _merge_spans(spans):
102
+ """Join stretches closer than the idle gap; out-of-order events fold in here."""
103
+ merged = []
104
+ for start, end in sorted(spans):
105
+ if merged and start - merged[-1][1] < ACTIVE_GAP_SECONDS:
106
+ merged[-1][1] = max(merged[-1][1], end)
107
+ else:
108
+ merged.append([start, end])
109
+ return merged
110
+
111
+
112
+ def _clock(seconds):
113
+ minutes = int(seconds / 60)
114
+ return f'{minutes // 60}h {minutes % 60}m' if minutes >= 60 else f'{minutes}m'
115
+
116
+
117
+ def _recall_failure(text, is_error):
118
+ if any(marker in text for marker in BINDING_MARKERS):
119
+ return "episode-binding"
120
+ if is_error and SANDBOX_RE.search(text):
121
+ return "sandbox"
122
+ if "moved to the background" in text or "timed out" in text.lower():
123
+ return "timeout"
124
+ if "interrupted" in text:
125
+ return "interrupted"
126
+ return "error" if is_error else None
127
+
128
+
129
+ # The receipt attach hook (egregore_runtime/harness_cli.py) reports a failure
130
+ # as hook context, which Claude Code keeps in an attachment entry after the
131
+ # tool result rather than inside it.
132
+ ATTACH_FAILURE = "Runtime private result could not be attached:"
133
+
134
+
135
+ def _strings(value, limit=256):
136
+ """Strings inside a parsed JSON value, breadth first and bounded; keeps nothing.
137
+
138
+ At most `limit` values are visited, four levels deep, fifty children each,
139
+ and each string is read to 4,000 characters.
140
+ """
141
+ queue, visited = deque([(value, 0)]), 0
142
+ while queue and visited < limit:
143
+ item, depth = queue.popleft()
144
+ visited += 1
145
+ if isinstance(item, str):
146
+ yield item[:4000]
147
+ elif depth < 4 and isinstance(item, (dict, list)):
148
+ children = item.values() if isinstance(item, dict) else item
149
+ queue.extend((child, depth + 1) for child in itertools.islice(children, 50))
150
+
151
+
152
+ def _attach_failure(attachment):
153
+ """The recall failure kind of an attach-hook failure in an attachment, else None."""
154
+ for text in _strings(attachment):
155
+ start = text.find(ATTACH_FAILURE)
156
+ if start >= 0:
157
+ return _recall_failure(text[start:].split("\n", 1)[0], True)
158
+ return None
159
+
160
+
161
+ # A hook asks the agent to put a question to the person in these words
162
+ # (.claude/hooks/admin-content-detector.sh). The request is decided when the
163
+ # agent calls AskUserQuestion before the person's next prompt.
164
+ DECISION_REQUEST = "Call AskUserQuestion now"
165
+ # A hook left undecided this many times in one session becomes an open item.
166
+ IGNORED_REQUESTS_RAISED = 3
167
+ # A hook's name is a word, never a phrase of its context: the text before the
168
+ # context's first colon, else its script's name, else Claude Code's hook label.
169
+ HOOK_NAME_RE = re.compile(r"[A-Za-z0-9][\w.:-]{0,63}", re.A)
170
+ HOOK_SCRIPT_RE = re.compile(r"([A-Za-z0-9][\w.-]{0,63})\.(?:sh|bash|zsh|py|mjs|cjs|js|ts|rb|pl)\b", re.A)
171
+ # A quoted stretch of one line, or one word.
172
+ QUOTED_OR_WORD_RE = re.compile(r"""(['"`])([^'"`\n]+)\1|\S+""")
173
+ FILE_NAME_RE = re.compile(r"[\w.-]{1,100}")
174
+ EXTENSION_RE = re.compile(r"\.\w{1,10}$")
175
+
176
+
177
+ def _bare(word):
178
+ return word.lstrip("'\"`([{<").rstrip("'\"`)]}>,;:.!?")
179
+
180
+
181
+ def _path_like(text):
182
+ """A slash, no scheme, and a path's start or a file name's end."""
183
+ return ("/" in text and "://" not in text
184
+ and (text.startswith(("/", "~/", "./", "../")) or bool(EXTENSION_RE.search(text))))
185
+
186
+
187
+ def _file_name(path):
188
+ name = path.rstrip("/").rpartition("/")[2]
189
+ return name if FILE_NAME_RE.fullmatch(name) and name.strip(".") else None
190
+
191
+
192
+ def _first_basename(text):
193
+ """The file name of the first path in a hook's text, else None; never a
194
+ directory. A quoted path is read whole, and only when its closing quote
195
+ ends the path at whitespace or the end of a line; a quoted stretch with a
196
+ slash is never read for paths inside it. An unquoted path ends at a line's
197
+ end, or at a word that cannot continue it after a name with an extension;
198
+ any other end is uncertain ("/private/Acme Corp/notes.md",
199
+ '"/private/Acme"/notes.md'), and nothing is taken."""
200
+ position = 0
201
+ while (match := QUOTED_OR_WORD_RE.search(text, position)):
202
+ position = match.end()
203
+ if match.group(1):
204
+ quoted = match.group(2)
205
+ if "/" not in quoted:
206
+ continue # a quoted phrase that holds no path
207
+ name = _file_name(quoted) if _path_like(quoted) else None
208
+ ends = match.end() == len(text) or text[match.end()].isspace()
209
+ if name and ends and (EXTENSION_RE.search(name) or not re.search(r"\s", quoted)):
210
+ return name
211
+ return None
212
+ path = _bare(match.group())
213
+ if not _path_like(path) or not path.rstrip("/"):
214
+ continue
215
+ name = _file_name(path)
216
+ word = re.match(r"[ \t]*(\S*)", text[match.end():])[1]
217
+ continues = "/" in _bare(word) or bool(EXTENSION_RE.search(_bare(word)))
218
+ if name and (not word or (EXTENSION_RE.search(name) and not continues)):
219
+ return name
220
+ return None
221
+ return None
222
+
223
+
224
+ def _decision_request(attachment):
225
+ """A hook's request that the agent ask the person: the hook's name and the
226
+ file name of the first path it names. Nothing else of the context is kept."""
227
+ try:
228
+ output = json.loads(_string(attachment.get("stdout")))
229
+ except (ValueError, RecursionError):
230
+ return None
231
+ context = _string(_mapping(_mapping(output).get("hookSpecificOutput")).get("additionalContext"))
232
+ if DECISION_REQUEST not in context:
233
+ return None
234
+ head, colon, _ = context.partition(":")
235
+ script = HOOK_SCRIPT_RE.search(_string(attachment.get("command")))
236
+ names = (head.strip() if colon else "", script[1] if script else "", _string(attachment.get("hookName")))
237
+ return {"hook": next((name for name in names if HOOK_NAME_RE.fullmatch(name)), "hook"),
238
+ "file": _first_basename(context)}
239
+
240
+
241
+ # $ per million tokens: input, output, cache read. Cache writes bill at 1.25x
242
+ # input for the 5-minute TTL and 2x for the 1-hour TTL. A request whose input
243
+ # (fresh plus cached) exceeds 200k tokens pays the long-context premium: the
244
+ # input side doubles and output costs 1.5x. Only the 1M-context variants reach it.
245
+ LONG_CONTEXT_TOKENS = 200_000
246
+ PRICES = {
247
+ "claude-fable-5-1": (10.0, 50.0, 0.25),
248
+ "claude-fable-5": (10.0, 50.0, 0.25),
249
+ "claude-opus-5-5": (4.0, 20.0, 0.20),
250
+ "claude-opus-5": (5.0, 25.0, 0.50),
251
+ "claude-sonnet-5": (2.0, 10.0, 0.20),
252
+ "claude-haiku-4-5": (1.0, 5.0, 0.10),
253
+ }
254
+ # OpenAI's published list (developers.openai.com/api/docs/pricing, read
255
+ # 2026-10-16), $ per million tokens: input, cached input, output; the first
256
+ # row up to 272K input tokens per request, the second above it. Codex counts
257
+ # its cached tokens inside its input. codex-auto-review is a label, not a
258
+ # model, so it has no row and its tokens stay unpriced.
259
+ CODEX_LONG_CONTEXT_TOKENS = 272_000
260
+ CODEX_PRICES = {
261
+ "gpt-6-astra": ((10.00, 1.00, 50.00), (20.00, 2.00, 75.00)),
262
+ "gpt-6-luna": ((0.10, 0.01, 0.50), (0.20, 0.02, 0.75)),
263
+ "gpt-5.6-sol": ((4.00, 0.40, 20.00), (8.00, 0.80, 30.00)),
264
+ "gpt-5.6-luna": ((0.20, 0.02, 1.20), (0.40, 0.04, 1.80)),
265
+ "gpt-5.6-terra": ((2.00, 0.20, 12.00), (4.00, 0.40, 18.00)),
266
+ }
267
+
268
+
269
+ def estimate_cost(calls):
270
+ """Price token usage when a session left no cost-state ledger."""
271
+ total = 0.0
272
+ for c in calls:
273
+ if not isinstance(c, dict):
274
+ continue
275
+ price = PRICES.get(_string(c.get("model")).split("[")[0])
276
+ if not price:
277
+ continue
278
+ c = {**c, **{key: _number(c.get(key)) for key in ("input", "output", "cache_read", "cache_write", "cache_write_1h")}}
279
+ inp, out, read = price
280
+ context = c["input"] + c["cache_read"] + c["cache_write"]
281
+ if context > LONG_CONTEXT_TOKENS:
282
+ inp, read, out = inp * 2, read * 2, out * 1.5
283
+ one_hour = c["cache_write_1h"]
284
+ five_min = (c["cache_write"] or 0) - one_hour
285
+ total += (c["input"] * inp + c["output"] * out + c["cache_read"] * read
286
+ + five_min * inp * 1.25 + one_hour * inp * 2.0) / 1e6
287
+ return total
288
+
289
+
290
+ def list_price(calls):
291
+ """Price Codex token usage at list; return the total, its split and what stayed unpriced."""
292
+ total, by_model = 0.0, Counter()
293
+ unpriced = {"models": set(), "input": 0, "output": 0}
294
+ for c in calls:
295
+ if not isinstance(c, dict):
296
+ continue
297
+ model = _string(c.get("model"))
298
+ inp, cached, out = (_number(c.get(key)) for key in ("input", "cache_read", "output"))
299
+ rows = CODEX_PRICES.get(model)
300
+ if not rows:
301
+ unpriced["models"].add(_inline(model) or "unknown model")
302
+ unpriced["input"] += inp
303
+ unpriced["output"] += out
304
+ continue
305
+ price_in, price_cached, price_out = rows[1] if inp > CODEX_LONG_CONTEXT_TOKENS else rows[0]
306
+ cached = min(cached, inp)
307
+ amount = ((inp - cached) * price_in + cached * price_cached + out * price_out) / 1e6
308
+ total += amount
309
+ by_model[model] += amount
310
+ return total, by_model, unpriced
311
+
312
+
313
+ def session_cost(cost_states):
314
+ """cost-state is a running total per process; a resume starts a new one."""
315
+ total, previous, by_model = 0.0, 0.0, Counter()
316
+ finals, prev_state = [], None
317
+ for state in cost_states:
318
+ if not isinstance(state, dict):
319
+ continue
320
+ value = _number(state.get("totalCostUSD", 0), None)
321
+ if value is None:
322
+ continue
323
+ if value < previous and prev_state is not None:
324
+ finals.append(prev_state)
325
+ previous, prev_state = value, state
326
+ if prev_state is not None:
327
+ finals.append(prev_state)
328
+ for state in finals:
329
+ total += _number(state.get("totalCostUSD"))
330
+ for model, usage in _mapping(state.get("modelUsage")).items():
331
+ by_model[model] += _number(_mapping(usage).get("costUSD"))
332
+ return total, by_model
333
+
334
+
335
+ NOTABLE_RE = re.compile(r"search\.sh|publish-artifact|notify\.sh|graph-op|telemetry\.sh|handoff|wrap|save")
336
+
337
+
338
+ def _short(text, n):
339
+ text = " ".join(_string(text).split())
340
+ return text if len(text) <= n else text[: n - 1] + "…"
341
+
342
+
343
+ def _hm(ts):
344
+ return datetime.fromisoformat(ts.replace("Z", "+00:00")).astimezone().strftime("%m-%d %H:%M") if ts else "?"
345
+
346
+
347
+ def _find(projects, prefix, session):
348
+ """Locate a transcript by full session id or unique id prefix."""
349
+ matches = []
350
+ for directory in transcript_folders(projects, prefix):
351
+ matches.extend(p for p in directory.glob(f"{session}*.jsonl")
352
+ if p.is_file() and within_instance(p, projects, prefix))
353
+ if len(matches) == 1:
354
+ return matches[0]
355
+ if matches:
356
+ raise SystemExit(f"{session} is ambiguous: " + ", ".join(m.stem[:12] for m in matches))
357
+ raise SystemExit(f"no transcript for {session}")
358
+
359
+
360
+ def project_root(project_root):
361
+ """Resolve the main checkout without running git or following other instances."""
362
+ root = Path(project_root).resolve()
363
+ git_file = root / '.git'
364
+ if git_file.is_file():
365
+ try:
366
+ value = git_file.read_text().strip()
367
+ if value.startswith('gitdir: '):
368
+ target = Path(value[8:])
369
+ if not target.is_absolute():
370
+ target = root / target
371
+ target = target.resolve()
372
+ parts = target.parts
373
+ for index in range(len(parts) - 2):
374
+ if parts[index:index + 2] == ('.git', 'worktrees'):
375
+ return Path(*parts[:index])
376
+ except (OSError, ValueError):
377
+ pass
378
+ return root
379
+
380
+
381
+ def instance_prefix(project_root):
382
+ return re.sub(r'[/.]', '-', str(globals()['project_root'](project_root)))
383
+
384
+
385
+ def default_prefix():
386
+ return instance_prefix(os.environ.get('EGREGORE_ROOT') or os.getcwd())
387
+
388
+
389
+ def transcript_folders(projects_dir, prefix):
390
+ projects = Path(projects_dir)
391
+ try:
392
+ return sorted(d for d in projects.iterdir()
393
+ if (d.name == prefix or d.name.startswith(prefix + '--'))
394
+ and within_instance(d, projects, prefix) and d.is_dir())
395
+ except OSError:
396
+ return []
397
+
398
+
399
+ def valid_trail_folder(folder, projects_dir, prefix):
400
+ """Admit only real cache directories belonging to this instance's folders."""
401
+ try:
402
+ folder, projects = Path(folder), Path(projects_dir)
403
+ trails = projects.parent.parent / '.egregore' / 'trails'
404
+ if not stat.S_ISDIR(folder.lstat().st_mode):
405
+ return False
406
+ physical = folder.resolve()
407
+ if physical.parent != trails.resolve():
408
+ return False
409
+ return folder.name in {prefix, *(prefix + '--' + name for name in ('codex', 'pi', 'prime'))} or folder.name in {
410
+ item.name for item in transcript_folders(projects, prefix)}
411
+ except (OSError, ValueError, RuntimeError):
412
+ return False
413
+
414
+
415
+ def within_instance(path, projects_dir, prefix):
416
+ """Check physical containment; a sibling sharing a bare prefix is excluded."""
417
+ try:
418
+ relative = Path(path).resolve().relative_to(Path(projects_dir).resolve())
419
+ folder = relative.parts[0]
420
+ return folder == prefix or folder.startswith(prefix + '--')
421
+ except (ValueError, OSError, RuntimeError, IndexError):
422
+ return False
423
+
424
+
425
+ HEADER_LIMIT = 1024 * 1024
426
+
427
+
428
+ def read_header_result(path):
429
+ """Return the bounded first-line JSON value and whether it was readable."""
430
+ descriptor = None
431
+ try:
432
+ descriptor = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
433
+ info = os.fstat(descriptor)
434
+ if not stat.S_ISREG(info.st_mode) or not info.st_mode & 0o444:
435
+ return None, False
436
+ with os.fdopen(descriptor, 'rb', buffering=0) as stream:
437
+ descriptor = None
438
+ line = stream.readline(HEADER_LIMIT + 1)
439
+ if len(line) > HEADER_LIMIT:
440
+ return None, False
441
+ value = json.loads(line)
442
+ return value, True
443
+ except (OSError, ValueError, TypeError, RecursionError):
444
+ return None, False
445
+ finally:
446
+ if descriptor is not None:
447
+ os.close(descriptor)
448
+
449
+
450
+ def read_header(path):
451
+ """Read a bounded mapping header; never block on a pipe or device."""
452
+ value, _ = read_header_result(path)
453
+ return value if isinstance(value, dict) else None
454
+
455
+
456
+ def _header_format(header):
457
+ header = _mapping(header)
458
+ kind, payload = header.get('type'), _mapping(header.get('payload'))
459
+ if kind == 'cost-state' or (kind in ('user', 'assistant') and isinstance(header.get('message'), dict)):
460
+ return 'claude'
461
+ if kind == 'session_meta' and isinstance(payload.get('id'), str) and isinstance(payload.get('cwd'), str):
462
+ return 'codex'
463
+ if (kind == 'session' and type(header.get('version')) is int
464
+ and isinstance(header.get('id'), str) and isinstance(header.get('cwd'), str)):
465
+ return 'pi'
466
+ return None
467
+
468
+
469
+ def probe_format(path, projects_dir=None, prefix=None):
470
+ """Probe native headers, retaining Claude's bounded content detector."""
471
+ kind = _header_format(read_header(path))
472
+ if kind is not None:
473
+ return kind
474
+ projects = Path(projects_dir) if projects_dir is not None else Path.home() / '.claude' / 'projects'
475
+ if within_instance(path, projects, prefix or default_prefix()) and is_claude_code_transcript(path):
476
+ return 'claude'
477
+ return None
478
+
479
+
480
+ def parent_of(header):
481
+ """Normalize every Codex child spelling for both discovery and merging."""
482
+ payload = _mapping(_mapping(header).get('payload', header))
483
+ for name in ('parent_thread_id', 'forked_from_id'):
484
+ if _string(payload.get(name)):
485
+ return payload[name]
486
+ subagent = _mapping(_mapping(payload.get('source')).get('subagent'))
487
+ spawned = _mapping(subagent.get('thread_spawn'))
488
+ for source in (spawned, subagent):
489
+ for name in ('parent_thread_id', 'parent_session_id', 'parent_id', 'parent'):
490
+ if _string(source.get(name)):
491
+ return source[name]
492
+ if _string(payload.get('session_id')) and payload['session_id'] != payload.get('id'):
493
+ return payload['session_id']
494
+ if isinstance(_mapping(payload.get('source')).get('subagent'), dict):
495
+ # A child with missing lineage must never become a standalone record.
496
+ # A self-parent keeps discovery and family traversal fail-closed.
497
+ return _string(payload.get('id')) or '__orphan_subagent__'
498
+ return None
499
+
500
+
501
+ def within_instance_cwd(cwd, root):
502
+ """Admit a harness header only when its physical cwd belongs to the repo."""
503
+ try:
504
+ return bool(isinstance(cwd, str) and Path(cwd).is_absolute()
505
+ and Path(cwd).resolve().is_relative_to(project_root(root)))
506
+ except (OSError, ValueError, RuntimeError):
507
+ return False
508
+
509
+
510
+ def read_session(path, agent='main', root=None, projects_dir=None, prefix=None):
511
+ """The common reader entry point; native cwd gates precede content reads."""
512
+ kind = probe_format(path, projects_dir, prefix or (instance_prefix(root) if root else None))
513
+ if kind == 'claude':
514
+ return read_transcript(path, agent)
515
+ if kind == 'codex':
516
+ return read_codex_rollout(path, agent, root=root)
517
+ if kind == 'pi':
518
+ return read_pi_session(path, agent, root=root)
519
+ raise ValueError('unsupported transcript format')
520
+
521
+
522
+ def is_claude_code_transcript(path):
523
+ """A bounded, nonblocking 1 MiB probe of at most 500 nonempty lines."""
524
+ descriptor = None
525
+ try:
526
+ descriptor = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
527
+ info = os.fstat(descriptor)
528
+ if not stat.S_ISREG(info.st_mode) or not info.st_mode & 0o444:
529
+ return False
530
+ data = os.read(descriptor, 1024 * 1024).decode('utf-8', errors='replace')
531
+ lines = (line for line in data.splitlines() if line.strip())
532
+ for index, line in enumerate(lines):
533
+ if index >= 500:
534
+ break
535
+ try:
536
+ entry = json.loads(line)
537
+ except (ValueError, TypeError, RecursionError):
538
+ continue
539
+ if not isinstance(entry, dict):
540
+ continue
541
+ if entry.get('type') == 'cost-state':
542
+ return True
543
+ if entry.get('type') in ('user', 'assistant') and isinstance(entry.get('message'), dict):
544
+ # Pi also has message objects, but its entries carry a role rather
545
+ # than Claude's top-level user/assistant event types.
546
+ return True
547
+ return False
548
+ except (OSError, ValueError):
549
+ return False
550
+ finally:
551
+ if descriptor is not None:
552
+ os.close(descriptor)
553
+
554
+
555
+ def read_transcript(path, agent='main'):
556
+ """Return original spike facts plus the text/event data needed by records."""
557
+ path = Path(path)
558
+ calls, tools, hooks, prompts, costs = {}, {}, [], [], []
559
+ events, prompt_texts, assistant_texts, said_texts, decided = [], [], [], [], []
560
+ branches, models = set(), set()
561
+ session, first, last, pending_text, pending_full_text = None, None, None, None, None
562
+ compactions, valid_entries, parse_failures, branch = 0, 0, 0, None
563
+ pending_said_blocks, spans = [], []
564
+ packet = None # (session, tool id) of the latest --context-packet recall
565
+ requests, awaiting = [], 0 # hook decision requests; the first still awaiting one
566
+ descriptor = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
567
+ info = os.fstat(descriptor)
568
+ if not stat.S_ISREG(info.st_mode) or not info.st_mode & 0o444:
569
+ os.close(descriptor)
570
+ raise OSError('transcript is not a readable regular file')
571
+ with os.fdopen(descriptor, 'rb') as stream:
572
+ for line in stream:
573
+ if not line.strip():
574
+ continue
575
+ try:
576
+ entry = json.loads(line)
577
+ except (ValueError, TypeError, RecursionError):
578
+ parse_failures += 1
579
+ continue
580
+ if not isinstance(entry, dict):
581
+ parse_failures += 1
582
+ continue
583
+ valid_entries += 1
584
+ session = session or _string(entry.get('sessionId'), None)
585
+ stamp = entry.get('timestamp')
586
+ try:
587
+ ts = _ts(stamp)
588
+ except (ValueError, TypeError, AttributeError):
589
+ ts, stamp = None, None
590
+ if ts:
591
+ first, last = first or ts, ts
592
+ _add_event(spans, ts)
593
+ if _string(entry.get('gitBranch')):
594
+ branch = _inline(entry['gitBranch'])
595
+ branches.add(branch)
596
+ kind = entry.get('type')
597
+ if kind == 'cost-state':
598
+ costs.append(entry)
599
+ elif kind == 'assistant':
600
+ message = entry.get('message') or {}
601
+ if not isinstance(message, dict):
602
+ continue
603
+ rid = entry.get('requestId') or entry.get('uuid')
604
+ if not isinstance(rid, (str, int, type(None))):
605
+ rid = None
606
+ usage = _mapping(message.get('usage'))
607
+ model = _string(message.get('model'), None)
608
+ calls[rid] = {'model': model, 'ts': ts, 'agent': agent,
609
+ 'usage_complete': all(_number(usage.get(key), None) is not None
610
+ for key in ('input_tokens', 'output_tokens')),
611
+ 'input': _number(usage.get('input_tokens')), 'output': _number(usage.get('output_tokens')),
612
+ 'cache_read': _number(usage.get('cache_read_input_tokens')),
613
+ 'cache_write': _number(usage.get('cache_creation_input_tokens')),
614
+ 'cache_write_1h': _number(_mapping(usage.get('cache_creation')).get('ephemeral_1h_input_tokens'))}
615
+ if model and not model.startswith('<'):
616
+ models.add(model)
617
+ blocks = message.get('content') or []
618
+ for block in blocks if isinstance(blocks, list) else []:
619
+ if not isinstance(block, dict):
620
+ continue
621
+ if block.get('type') == 'text' and _string(block.get('text')).strip():
622
+ assistant_texts.append({'ts': stamp, 'text': block['text']})
623
+ pending_text = ('SAID', stamp, _short(block['text'], 500))
624
+ pending_full_text = {'ts': stamp, 'text': block['text']}
625
+ pending_said_blocks.append(pending_full_text)
626
+ elif block.get('type') == 'tool_use':
627
+ name, inp = _string(block.get('name'), None), _mapping(block.get('input'))
628
+ if _string(block.get('id')):
629
+ tools[block['id']] = {'name': name, 'input': inp, 'start': ts, 'agent': agent, 'request': rid}
630
+ if name == 'AskUserQuestion':
631
+ # A question put to the person decides every request awaiting one.
632
+ for request in requests[awaiting:]:
633
+ request['decided'] = True
634
+ awaiting = len(requests)
635
+ if name in ('Write', 'Edit', 'NotebookEdit'):
636
+ events.append(('FILE', stamp, f"{name} {inp.get('file_path', inp.get('notebook_path', ''))}"))
637
+ elif name == 'Bash':
638
+ command = _string(inp.get('command'))
639
+ if GIT_RE.search(command) or NOTABLE_RE.search(command):
640
+ events.append(('RUN', stamp, _short(command, 180)))
641
+ # The attach hook acts only on these calls.
642
+ if '--context-packet' in command and SEARCH_RE.search(command) and _string(block.get('id')):
643
+ packet = (_string(entry.get('sessionId'), None), block['id'])
644
+ elif name == 'Skill':
645
+ events.append(('SKILL', stamp, _string(inp.get('skill'))))
646
+ elif name in ('Agent', 'Task'):
647
+ events.append(('AGENT', stamp, _short(inp.get('description', ''), 120)))
648
+ elif name == 'Workflow':
649
+ events.append(('WORKFLOW', stamp, _short(_string(inp.get('script'))[:400], 160)))
650
+ elif name in ('WebFetch', 'WebSearch'):
651
+ events.append(('WEB', stamp, _short(inp.get('url') or inp.get('query', ''), 120)))
652
+ elif name == 'Artifact':
653
+ events.append(('ARTIFACT', stamp, _short(json.dumps(inp)[:200], 160)))
654
+ elif kind == 'user':
655
+ message = entry.get('message') or {}
656
+ if not isinstance(message, dict):
657
+ continue
658
+ content = message.get('content')
659
+ texts = [content] if isinstance(content, str) else [_string(b.get('text')) for b in content or []
660
+ if isinstance(b, dict) and b.get('type') == 'text'] if isinstance(content, list) else []
661
+ if not entry.get('isMeta'):
662
+ for value in texts:
663
+ if agent == 'main' and not value.startswith('<'):
664
+ prompts.append(ts)
665
+ prompt_texts.append({'ts': stamp, 'text': value})
666
+ if value and not value.startswith('<'):
667
+ if pending_text:
668
+ events.append(pending_text)
669
+ said_texts.extend(pending_said_blocks)
670
+ pending_said_blocks.clear()
671
+ pending_text = None
672
+ events.append(('ASK', stamp, _short(value, 700)))
673
+ # The person's next prompt leaves earlier requests undecided.
674
+ awaiting = len(requests)
675
+ elif value.startswith('<command-name>'):
676
+ name = re.search(r'<command-name>(.*?)</command-name>', value)
677
+ events.append(('CMD', stamp, name.group(1) if name else value[:60]))
678
+ for block in content if isinstance(content, list) else []:
679
+ if not isinstance(block, dict) or block.get('type') != 'tool_result':
680
+ continue
681
+ tool = tools.get(_string(block.get('tool_use_id')))
682
+ if tool and 'end' not in tool:
683
+ text = _text(block.get('content'))
684
+ tool.update(end=ts, error=bool(block.get('is_error')), result=text[:4000])
685
+ if tool['error'] and NONZERO_EXIT_RE.match(text):
686
+ tool['nonzero_exit'] = True
687
+ if tool['name'] == 'AskUserQuestion' and not tool['error']:
688
+ for item in _question_answers(block.get('content'), tool['input']):
689
+ decided.append({'ts': stamp, **item})
690
+ labels = ', '.join(_choice_label(label) for label in item['selected_labels'])
691
+ answer = _short(labels, 160) if labels else 'a free-text answer'
692
+ events.append(('DECIDED', stamp, f'{_decision_header(item)}: {answer} ← "{_short(item["question"], 160)}"'))
693
+ if block.get('is_error') and not entry.get('isMeta'):
694
+ body = block.get('content')
695
+ body = body if isinstance(body, str) else _text(body).replace('\n', ' ')
696
+ events.append(('ERR', stamp, _short(body, 160)))
697
+ elif kind == 'attachment':
698
+ a = _mapping(entry.get('attachment'))
699
+ if a.get('type') == 'hook_success':
700
+ hooks.append({'hook': _string(a.get('hookName'), None), 'command': _string(a.get('command'), None), 'ms': _number(a.get('durationMs'), None)})
701
+ request = _decision_request(a)
702
+ if request:
703
+ requests.append(dict(request, decided=False))
704
+ # An attach failure belongs to the latest packet recall of the
705
+ # same session; only its failure kind is kept.
706
+ own = _string(entry.get('sessionId'), None)
707
+ if packet and packet[1] in tools and (not own or not packet[0] or own == packet[0]):
708
+ failure = _attach_failure(a)
709
+ if failure:
710
+ tools[packet[1]]['attach_failure'] = failure
711
+ elif kind == 'system':
712
+ if entry.get('subtype') == 'stop_hook_summary':
713
+ infos = entry.get('hookInfos')
714
+ for info in infos if isinstance(infos, list) else []:
715
+ if not isinstance(info, dict):
716
+ continue
717
+ hooks.append({'hook': 'Stop', 'command': _string(info.get('command'), None), 'ms': _number(info.get('durationMs'), None)})
718
+ if entry.get('subtype') in ('compact_boundary', 'compaction', 'context_compaction'):
719
+ compactions += 1
720
+ if pending_text:
721
+ events.append(pending_text)
722
+ return {'session': session, 'first': first, 'last': last, 'calls': calls, 'tools': tools,
723
+ 'hooks': hooks, 'prompts': prompts, 'costs': costs, 'events': events,
724
+ 'prompt_texts': prompt_texts, 'assistant_texts': assistant_texts, 'said_texts': said_texts,
725
+ 'decided': decided, 'hook_requests': requests, 'active_spans': _merge_spans(spans),
726
+ 'branches': sorted(branches), 'branch': branch, 'models': sorted(models), 'compactions': compactions,
727
+ 'valid_entries': valid_entries, 'parse_failures': parse_failures, 'harness': 'claude'}
728
+
729
+
730
+ def _native_entries(path, expected, root, facts):
731
+ """Yield admitted entries once, recording parse loss without retaining content."""
732
+ descriptor = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
733
+ info = os.fstat(descriptor)
734
+ if not stat.S_ISREG(info.st_mode) or not info.st_mode & 0o444:
735
+ os.close(descriptor)
736
+ raise OSError('transcript is not a readable regular file')
737
+ with os.fdopen(descriptor, 'rb', buffering=0) as stream:
738
+ first = stream.readline(HEADER_LIMIT + 1)
739
+ try:
740
+ header = json.loads(first) if len(first) <= HEADER_LIMIT else None
741
+ except (ValueError, TypeError, RecursionError):
742
+ header = None
743
+ if _header_format(header) != expected:
744
+ raise ValueError('unsupported transcript format')
745
+ payload = header['payload'] if expected == 'codex' else header
746
+ if not within_instance_cwd(payload.get('cwd'), root or os.environ.get('EGREGORE_ROOT') or os.getcwd()):
747
+ raise ValueError('transcript is outside this instance')
748
+ yield header
749
+ for line in io.BufferedReader(stream):
750
+ if not line.strip():
751
+ continue
752
+ try:
753
+ entry = json.loads(line)
754
+ except (ValueError, TypeError, RecursionError):
755
+ facts['parse_failures'] += 1
756
+ continue
757
+ if isinstance(entry, dict):
758
+ yield entry
759
+ else:
760
+ facts['parse_failures'] += 1
761
+
762
+
763
+ def _native_facts(harness):
764
+ return {'session': None, 'first': None, 'last': None, 'calls': {}, 'tools': {},
765
+ 'hooks': [], 'prompts': [], 'costs': [], 'events': [], 'prompt_texts': [],
766
+ 'assistant_texts': [], 'said_texts': [], 'decided': [], 'hook_requests': [], 'active_spans': [], 'branches': [],
767
+ 'branch': None, 'models': [], 'compactions': 0, 'valid_entries': 0, 'parse_failures': 0,
768
+ 'harness': harness}
769
+
770
+
771
+ def _native_stamp(facts, entry):
772
+ stamp = entry.get('timestamp')
773
+ try:
774
+ ts = _ts(stamp)
775
+ except (ValueError, TypeError, AttributeError):
776
+ ts, stamp = None, None
777
+ if ts is not None:
778
+ facts['first'] = facts['first'] if facts['first'] is not None else ts
779
+ facts['last'] = ts
780
+ _add_event(facts['active_spans'], ts)
781
+ facts['valid_entries'] += 1
782
+ return stamp, ts
783
+
784
+
785
+ def _native_ask(facts, text, stamp, ts, pending):
786
+ if not text or text.startswith('<'):
787
+ return
788
+ if pending:
789
+ facts['events'].append(('SAID', pending[-1]['ts'], _short(pending[-1]['text'], 500)))
790
+ facts['said_texts'].extend(pending)
791
+ pending.clear()
792
+ facts['prompts'].append(ts)
793
+ facts['prompt_texts'].append({'ts': stamp, 'text': text})
794
+ facts['events'].append(('ASK', stamp, _short(text, 700)))
795
+
796
+
797
+ def _native_said(facts, text, stamp, pending):
798
+ if text.strip():
799
+ value = {'ts': stamp, 'text': text}
800
+ facts['assistant_texts'].append(value)
801
+ pending.append(value)
802
+
803
+
804
+ def _native_file(facts, name, value, stamp, repo):
805
+ relative = _relative_file(value, Path(repo))
806
+ if relative is not None:
807
+ facts['events'].append(('FILE', stamp, f'{name} {relative}'))
808
+ elif _string(value):
809
+ # Keep outside-path cardinality without exposing the path in the trail.
810
+ facts['events'].append(('FILE', stamp, f'{name} <outside:{facts["session"]}:{len(facts["events"])}>'))
811
+
812
+
813
+ def _native_error(facts, tool, output, stamp):
814
+ # Successful output is never retained; error output has one shared 500-char cap.
815
+ body = _text(output)[:500]
816
+ tool['result'] = body
817
+ facts['events'].append(('ERR', stamp, _short(body, 500)))
818
+
819
+
820
+ def _native_command(facts, ident, command, stamp, ts, agent, exit_code=None, output='', duration=0):
821
+ command = _string(command)[:4000]
822
+ error = exit_code not in (None, 0)
823
+ tool = {'name': 'Bash', 'input': {'command': command}, 'start': ts - duration if ts is not None else None,
824
+ 'end': ts, 'error': error, 'agent': agent}
825
+ facts['tools'][ident] = tool
826
+ facts['events'].append(('RUN', stamp, _short(command, 180)))
827
+ if error:
828
+ # The command ran and returned a status: not an error of the harness.
829
+ tool['nonzero_exit'] = True
830
+ _native_error(facts, tool, output, stamp)
831
+ return tool
832
+
833
+
834
+ def _duration(value):
835
+ value = _mapping(value)
836
+ return _number(value.get('secs')) + _number(value.get('nanos')) / 1e9
837
+
838
+
839
+ def _codex_answers(text):
840
+ if not text.startswith('<send_user_message_question_reply>'):
841
+ return []
842
+ body = text[len('<send_user_message_question_reply>'):]
843
+ try:
844
+ values, _ = json.JSONDecoder().raw_decode(body.lstrip())
845
+ except (ValueError, TypeError, RecursionError):
846
+ return []
847
+ return [{'header': 'Question', 'question': item['question'], 'answer': item['answer'],
848
+ 'options': [], 'selected_labels': []}
849
+ for item in values if isinstance(item, dict)
850
+ and _string(item.get('question')).strip() and _string(item.get('answer')).strip()] if isinstance(values, list) else []
851
+
852
+
853
+ def _codex_command(value):
854
+ """Recover shell scripts while preserving ordinary argv argument boundaries."""
855
+ if not isinstance(value, list):
856
+ return _string(value)
857
+ argv = [_string(argument) for argument in value]
858
+ if (len(argv) >= 3 and Path(argv[0]).name in ('sh', 'bash', 'zsh', 'dash', 'fish')
859
+ and argv[1] in ('-lc', '-c', '-lic')):
860
+ script = 3 if argv[2] == '--' else 2
861
+ if script < len(argv):
862
+ return argv[script]
863
+ return shlex.join(argv)
864
+
865
+
866
+ def read_codex_rollout(path, agent='main', root=None):
867
+ """Translate Codex's completed items into the shared, privacy-bounded facts."""
868
+ facts, pending, model = _native_facts('codex'), [], None
869
+ repo = root or os.environ.get('EGREGORE_ROOT') or os.getcwd()
870
+ for index, entry in enumerate(_native_entries(path, 'codex', root, facts)):
871
+ stamp, ts = _native_stamp(facts, entry)
872
+ payload, kind = _mapping(entry.get('payload')), entry.get('type')
873
+ if index == 0:
874
+ if ts is None:
875
+ _native_stamp(facts, {'timestamp': payload.get('timestamp')})
876
+ facts['valid_entries'] -= 1
877
+ facts['session'] = payload['id']
878
+ branch = _string(_mapping(payload.get('git')).get('branch'))
879
+ if branch:
880
+ facts['branch'], facts['branches'] = _inline(branch), [_inline(branch)]
881
+ continue
882
+ if kind == 'session_meta':
883
+ # A child repeats its parent's metadata; it never changes identity.
884
+ continue
885
+ if kind == 'turn_context':
886
+ model = _string(payload.get('model'), None)
887
+ if model and model not in facts['models']:
888
+ facts['models'].append(model)
889
+ elif kind == 'token_usage_record':
890
+ usage = _mapping(payload.get('usage'))
891
+ facts['calls'][str(index)] = {'model': model, 'ts': ts, 'agent': agent,
892
+ 'usage_complete': all(_number(usage.get(key), None) is not None
893
+ for key in ('input_tokens', 'output_tokens')),
894
+ 'input': _number(usage.get('input_tokens')), 'output': _number(usage.get('output_tokens')),
895
+ 'cache_read': _number(usage.get('cached_input_tokens')), 'cache_write': 0, 'cache_write_1h': 0}
896
+ elif kind == 'compacted':
897
+ facts['compactions'] += 1
898
+ elif kind == 'event_msg' and payload.get('type') == 'item_completed':
899
+ item = _mapping(payload.get('item'))
900
+ item_kind = item.get('type')
901
+ ident = str(index)
902
+ if item_kind == 'UserMessage':
903
+ content = item.get('content')
904
+ texts = [_string(block.get('text')) for block in content if isinstance(block, dict)] if isinstance(content, list) else [_string(content)]
905
+ for text in texts:
906
+ for answer in _codex_answers(text):
907
+ facts['decided'].append({'ts': stamp, **answer})
908
+ facts['events'].append(('DECIDED', stamp, f'Question: a free-text answer ← "{_short(answer["question"], 160)}"'))
909
+ _native_ask(facts, text, stamp, ts, pending)
910
+ elif item_kind == 'AgentMessage':
911
+ content = item.get('content')
912
+ for block in content if isinstance(content, list) else []:
913
+ if isinstance(block, dict) and block.get('type') == 'Text':
914
+ _native_said(facts, _string(block.get('text')), stamp, pending)
915
+ elif item_kind == 'CommandExecution':
916
+ command = _codex_command(item.get('command'))
917
+ _native_command(facts, ident, command, stamp, ts, agent,
918
+ item.get('exit_code'), item.get('aggregated_output'), _duration(item.get('duration')))
919
+ elif item_kind == 'FileChange':
920
+ facts['tools'][ident] = {'name': 'FileChange', 'input': {}, 'start': ts, 'end': ts,
921
+ 'error': item.get('status') in ('failed', 'error'), 'agent': agent}
922
+ for value, change in _mapping(item.get('changes')).items():
923
+ name = 'Edit' if _mapping(change).get('type') == 'update' else 'Write'
924
+ _native_file(facts, name, value, stamp, repo)
925
+ elif item_kind in ('McpToolCall', 'Extension', 'SubAgentActivity', 'ContextCompaction'):
926
+ name = ('mcp:' + _string(item.get('server')) + '.' + _string(item.get('tool'))
927
+ if item_kind == 'McpToolCall' else item_kind)
928
+ duration = _duration(item.get('duration'))
929
+ facts['tools'][ident] = {'name': name, 'input': {}, 'start': ts - duration if ts is not None else None,
930
+ 'end': ts, 'error': item.get('status') in ('failed', 'error'), 'agent': agent}
931
+ if item_kind == 'McpToolCall':
932
+ facts['events'].append(('RUN', stamp, _short(name, 180)))
933
+ elif item_kind == 'Extension' and item.get('kind') == 'web.search':
934
+ facts['events'].append(('WEB', stamp, _short(item.get('query'), 120)))
935
+ elif item_kind == 'SubAgentActivity':
936
+ facts['events'].append(('AGENT', stamp, _short(item.get('agent_thread_id'), 120)))
937
+ if pending:
938
+ facts['events'].append(('SAID', pending[-1]['ts'], _short(pending[-1]['text'], 500)))
939
+ facts['models'].sort()
940
+ facts['active_spans'] = _merge_spans(facts['active_spans'])
941
+ return facts
942
+
943
+
944
+ def _bounded_arguments(value):
945
+ """Retain tool arguments only to the same local-result bound as Claude."""
946
+ value = _mapping(value)
947
+ result = {}
948
+ remaining = 4000
949
+ for key, item in value.items():
950
+ if remaining <= 0:
951
+ break
952
+ if isinstance(item, str):
953
+ result[key] = item[:remaining]
954
+ remaining -= len(result[key])
955
+ elif isinstance(item, (bool, int, float, type(None))):
956
+ result[key] = item
957
+ return result
958
+
959
+
960
+ def _pi_file_arguments(arguments):
961
+ arguments = _mapping(arguments)
962
+ for name in ('path', 'file_path'):
963
+ if _string(arguments.get(name)):
964
+ yield arguments[name]
965
+ # multi_edit tools may carry several independent file edits in one call.
966
+ edits = arguments.get('edits')
967
+ for edit in edits[:1000] if isinstance(edits, list) else []:
968
+ for name in ('path', 'file_path'):
969
+ if _string(_mapping(edit).get(name)):
970
+ yield edit[name]
971
+
972
+
973
+ def read_pi_session(path, agent='main', root=None):
974
+ """Read the common Pi/Prime v3 message stream and its native cost ledger."""
975
+ harness = 'prime' if '.prime' in Path(path).parts else 'pi'
976
+ facts, pending, total, by_model = _native_facts(harness), [], 0.0, Counter()
977
+ repo = root or os.environ.get('EGREGORE_ROOT') or os.getcwd()
978
+ for index, entry in enumerate(_native_entries(path, 'pi', root, facts)):
979
+ stamp, ts = _native_stamp(facts, entry)
980
+ if index == 0:
981
+ facts['session'] = entry['id']
982
+ continue
983
+ kind = entry.get('type')
984
+ if kind == 'compaction':
985
+ facts['compactions'] += 1
986
+ modified = _mapping(entry.get('details')).get('modifiedFiles')
987
+ for value in modified if isinstance(modified, list) else []:
988
+ _native_file(facts, 'Edit', value, stamp, repo)
989
+ if kind != 'message':
990
+ continue
991
+ message = _mapping(entry.get('message'))
992
+ role, content = message.get('role'), message.get('content')
993
+ if role == 'user':
994
+ _native_ask(facts, _text(content), stamp, ts, pending)
995
+ elif role == 'assistant':
996
+ model, usage = _string(message.get('model'), None), _mapping(message.get('usage'))
997
+ if model and model not in facts['models']:
998
+ facts['models'].append(model)
999
+ facts['calls'][str(index)] = {'model': model, 'ts': ts, 'agent': agent,
1000
+ 'input': _number(usage.get('input')), 'output': _number(usage.get('output')),
1001
+ 'cache_read': _number(usage.get('cacheRead')), 'cache_write': _number(usage.get('cacheWrite')), 'cache_write_1h': 0}
1002
+ amount = _number(_mapping(usage.get('cost')).get('total'), None)
1003
+ facts['calls'][str(index)]['cost_complete'] = amount is not None and amount >= 0
1004
+ if amount is not None:
1005
+ total += amount
1006
+ by_model[model or 'unknown'] += amount
1007
+ facts['costs'].append({'totalCostUSD': total, 'modelUsage': {
1008
+ name: {'costUSD': value} for name, value in by_model.items()}})
1009
+ for block in content if isinstance(content, list) else []:
1010
+ if not isinstance(block, dict):
1011
+ continue
1012
+ if block.get('type') == 'text':
1013
+ _native_said(facts, _string(block.get('text')), stamp, pending)
1014
+ elif block.get('type') == 'toolCall':
1015
+ raw_name, inp = _string(block.get('name')), _bounded_arguments(block.get('arguments'))
1016
+ command = raw_name.lower() == 'bash' or 'command' in inp
1017
+ write_tool = raw_name.lower() in ('write', 'edit', 'multi_edit') or any(word in raw_name.lower() for word in ('write', 'edit'))
1018
+ name = 'Bash' if command else ('Write' if write_tool else raw_name)
1019
+ ident = _string(block.get('id')) or str(index)
1020
+ facts['tools'][ident] = {'name': name, 'input': inp, 'start': ts, 'agent': agent}
1021
+ if command:
1022
+ facts['events'].append(('RUN', stamp, _short(inp.get('command'), 180)))
1023
+ elif write_tool:
1024
+ for value in dict.fromkeys(_pi_file_arguments(block.get('arguments'))):
1025
+ _native_file(facts, 'Write', value, stamp, repo)
1026
+ # Files are consumed from the native FILE events below.
1027
+ facts['tools'][ident]['input'] = {key: value for key, value in inp.items() if key not in ('path', 'file_path')}
1028
+ elif role == 'toolResult':
1029
+ tool = facts['tools'].get(_string(message.get('toolCallId')))
1030
+ if tool and 'end' not in tool:
1031
+ details = _mapping(message.get('details'))
1032
+ exit_code = message.get('exitCode', message.get('exit_code', details.get('exitCode', details.get('exit_code'))))
1033
+ error = bool(message.get('isError')) or exit_code not in (None, 0)
1034
+ tool.update(end=ts, error=error)
1035
+ if exit_code not in (None, 0):
1036
+ tool['nonzero_exit'] = True
1037
+ if error:
1038
+ _native_error(facts, tool, content, stamp)
1039
+ elif role == 'bashExecution':
1040
+ _native_command(facts, str(index), message.get('command'), stamp, ts, agent,
1041
+ message.get('exitCode'), message.get('output'))
1042
+ if pending:
1043
+ facts['events'].append(('SAID', pending[-1]['ts'], _short(pending[-1]['text'], 500)))
1044
+ facts['models'].sort()
1045
+ facts['active_spans'] = _merge_spans(facts['active_spans'])
1046
+ return facts
1047
+
1048
+
1049
+ def trail(path):
1050
+ facts = path if isinstance(path, dict) else read_transcript(path)
1051
+ cost, _ = session_cost(facts['costs'])
1052
+ return facts['events'], cost, set(facts['branches']), set(facts['models'])
1053
+
1054
+
1055
+ def withhold_free_text(text, facts):
1056
+ """Withhold known free-text answers, preserving common short words."""
1057
+ answers = {_string(item.get('answer')) for item in facts.get('decided', [])
1058
+ if not item.get('selected_labels') and len(_string(item.get('answer'))) >= 4}
1059
+ patterns = [(r'\b' if re.match(r'\w', answer[0]) else '') + re.escape(answer) +
1060
+ (r'\b' if re.match(r'\w', answer[-1]) else '')
1061
+ for answer in sorted(answers, key=lambda value: (-len(value), value))]
1062
+ return re.sub('|'.join(patterns), '[withheld]', text, flags=re.I) if patterns else text
1063
+
1064
+
1065
+ def render_trail(path, session=None):
1066
+ facts = path if isinstance(path, dict) else read_transcript(path)
1067
+ events, cost, branches, models = trail(facts)
1068
+ stamps = [t for _, t, _ in events if t]
1069
+ lines = [f"# trail {session or facts.get('session') or 'unknown'}",
1070
+ f"{_hm(min(stamps) if stamps else None)} → {_hm(max(stamps) if stamps else None)} · ${cost:,.2f} · models {', '.join(sorted(models))} · branches {', '.join(sorted(branches))}"]
1071
+ files = sorted({d.split(' ', 1)[1] for k, _, d in events if k == 'FILE'})
1072
+ lines.append(f'files written: {len(files)}')
1073
+ lines.extend(f' {f}' for f in files)
1074
+ lines.append('')
1075
+ previous = None
1076
+ for kind, ts, detail in events:
1077
+ if kind == 'FILE' or (kind, detail) == previous:
1078
+ continue
1079
+ previous = kind, detail
1080
+ lines.append(f'{_hm(ts)} {kind:<8} {detail}')
1081
+ return withhold_free_text('\n'.join(lines), facts)
1082
+
1083
+
1084
+ def _question_answers(content, tool_input):
1085
+ """Pair each answer with its question's header and exact offered labels."""
1086
+ content = _text(content)
1087
+ if not content.startswith('Your questions have been answered:'):
1088
+ return []
1089
+ questions = _mapping(tool_input).get('questions')
1090
+ questions = questions if isinstance(questions, list) else []
1091
+ pairs = []
1092
+ for match in re.finditer(r'"((?:\\.|[^"\\])*)"\s*=\s*"((?:\\.|[^"\\])*)"', content):
1093
+ try:
1094
+ question, answer = (json.loads('"' + value + '"', strict=False) for value in match.groups())
1095
+ except ValueError:
1096
+ continue
1097
+ if question.strip() and answer.strip():
1098
+ definition = next((item for item in questions if isinstance(item, dict)
1099
+ and item.get('question') == question), {})
1100
+ options = definition.get('options')
1101
+ labels = [_string(option.get('label')) for option in options
1102
+ if isinstance(option, dict) and _string(option.get('label')).strip()] if isinstance(options, list) else []
1103
+ selected = _selected_labels(answer, labels, definition.get('multiSelect') is True)
1104
+ pairs.append({'question': question, 'answer': answer,
1105
+ 'header': _string(definition.get('header')),
1106
+ 'options': labels, 'selected_labels': selected})
1107
+ return pairs
1108
+
1109
+
1110
+ def _selected_labels(answer, labels, multi_select):
1111
+ if answer in labels:
1112
+ return [answer]
1113
+ if not multi_select:
1114
+ return []
1115
+ # Match whole offered labels before treating commas as separators: an
1116
+ # option label can itself contain a comma. Each reachable offset is tried
1117
+ # only once, so ambiguous label prefixes do not cause backtracking growth.
1118
+ choices_at = {0: []}
1119
+ for start in range(len(answer)):
1120
+ if start not in choices_at:
1121
+ continue
1122
+ for label in labels:
1123
+ if not answer.startswith(label, start):
1124
+ continue
1125
+ end = start + len(label)
1126
+ chosen = [*choices_at[start], label]
1127
+ if end == len(answer):
1128
+ return chosen
1129
+ separator = re.match(r',\s*', answer[end:])
1130
+ if separator:
1131
+ choices_at.setdefault(end + separator.end(), chosen)
1132
+ return []
1133
+
1134
+
1135
+ def trim_trail(trail, cap=60_000):
1136
+ """Prefer the person's trail events, returning a shared-record trim receipt."""
1137
+ if len(trail) <= cap:
1138
+ return trail, None
1139
+ lines = trail.splitlines(keepends=True)
1140
+ kinds = []
1141
+ for line in lines:
1142
+ match = re.match(r'^(?:\d{2}-\d{2} \d{2}:\d{2}|\?)\s+([A-Z]+)\s', line)
1143
+ kinds.append(match.group(1) if match else None)
1144
+ remaining, removed, counts = len(trail), set(), []
1145
+ for eligible, label in (({'SAID'}, 'assistant'),
1146
+ ({'RUN', 'WEB', 'ARTIFACT', 'AGENT', 'WORKFLOW'}, 'command'),
1147
+ ({'ERR'}, 'error')):
1148
+ count = 0
1149
+ for index, kind in enumerate(kinds):
1150
+ if remaining <= cap:
1151
+ break
1152
+ if kind in eligible:
1153
+ removed.add(index)
1154
+ remaining -= len(lines[index])
1155
+ count += 1
1156
+ if count:
1157
+ counts.append(f'{count} {label} line' + ('s' if count != 1 else ''))
1158
+ trimmed = ''.join(line for index, line in enumerate(lines) if index not in removed)
1159
+ if len(trimmed) > cap:
1160
+ marker = '\n[trail truncated: middle omitted]\n'
1161
+ head = min(40_000, max(0, cap - len(marker)), cap * 2 // 3)
1162
+ tail = max(0, cap - head - len(marker))
1163
+ trimmed = trimmed[:head] + marker[:cap - head] + (trimmed[-tail:] if tail else '')
1164
+ counts.append(f'remaining trail truncated at {cap:,} characters')
1165
+ return trimmed, 'trail trimmed: ' + ', '.join(counts)
1166
+
1167
+ def last_event_time(path, header=None):
1168
+ """Check admission, then find the last event in a bounded one MiB tail."""
1169
+ descriptor = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
1170
+ info = os.fstat(descriptor)
1171
+ if not stat.S_ISREG(info.st_mode) or not info.st_mode & 0o444:
1172
+ os.close(descriptor)
1173
+ raise OSError('transcript is not a readable regular file')
1174
+ last = None
1175
+ with os.fdopen(descriptor, 'rb', buffering=0) as stream:
1176
+ first = stream.readline(HEADER_LIMIT + 1)
1177
+ try:
1178
+ actual = json.loads(first) if len(first) <= HEADER_LIMIT else None
1179
+ except (ValueError, TypeError, RecursionError):
1180
+ actual = None
1181
+ if not isinstance(actual, dict) or (header is not None and actual != header):
1182
+ raise ValueError('transcript header changed after admission')
1183
+ try:
1184
+ last = _ts(actual.get('timestamp') or _mapping(actual.get('payload')).get('timestamp'))
1185
+ except (ValueError, TypeError, AttributeError):
1186
+ pass
1187
+ body_start = stream.tell()
1188
+ tail_start = max(body_start, info.st_size - HEADER_LIMIT)
1189
+ truncated = tail_start > body_start
1190
+ # Include one boundary byte to distinguish a complete first tail line
1191
+ # from the trailing fragment of an earlier, potentially enormous item.
1192
+ stream.seek(tail_start - 1 if truncated else tail_start)
1193
+ tail = stream.read(HEADER_LIMIT + (1 if truncated else 0))
1194
+ if truncated:
1195
+ tail = tail[1:] if tail[:1] == b'\n' else tail.partition(b'\n')[2]
1196
+ for line in reversed(tail.splitlines()):
1197
+ try:
1198
+ entry = json.loads(line)
1199
+ if isinstance(entry, dict):
1200
+ stamp = _ts(entry.get('timestamp'))
1201
+ if stamp is not None:
1202
+ return stamp
1203
+ except (ValueError, TypeError, AttributeError, RecursionError):
1204
+ continue
1205
+ return info.st_mtime if truncated else last
1206
+
1207
+
1208
+ def _codex_index(path, root):
1209
+ """Fixture/standalone fallback; record sweeps supply their persistent index."""
1210
+ path = Path(path)
1211
+ sessions = next((parent for parent in path.parents if parent.name == 'sessions' and parent.parent.name == '.codex'), path.parent)
1212
+ result = {}
1213
+ for candidate in sessions.rglob('*.jsonl'):
1214
+ header = read_header(candidate)
1215
+ payload = _mapping(_mapping(header).get('payload'))
1216
+ if _header_format(header) == 'codex' and within_instance_cwd(payload.get('cwd'), root):
1217
+ result[str(candidate)] = {'id': payload['id'], 'parent': parent_of(header),
1218
+ 'mtime': candidate.stat().st_mtime, 'last_event': last_event_time(candidate, header), 'header': header}
1219
+ return result
1220
+
1221
+
1222
+ def _merge_native(facts, sub, index):
1223
+ facts['calls'].update({f'{key}@{index}': value for key, value in sub['calls'].items()})
1224
+ facts['tools'].update({f'{key}@{index}': value for key, value in sub['tools'].items()})
1225
+ for key in ('hooks', 'prompts', 'costs', 'events', 'prompt_texts', 'assistant_texts', 'said_texts', 'decided'):
1226
+ facts[key].extend(sub[key])
1227
+ for key in ('branches', 'models'):
1228
+ facts[key] = sorted(set(facts[key]) | set(sub[key]))
1229
+ facts['compactions'] += sub['compactions']
1230
+ facts['valid_entries'] += sub['valid_entries']
1231
+ facts['active_spans'] = _merge_spans(facts['active_spans'] + sub['active_spans'])
1232
+ if sub['last'] is not None:
1233
+ facts['last'] = max(facts['last'] or sub['last'], sub['last'])
1234
+
1235
+
1236
+ def load_session(path, projects_dir=None, prefix=None, root=None, codex_index=None, require_root_prompts=False):
1237
+ """Read a transcript and its admitted children through one reader interface."""
1238
+ path = Path(path)
1239
+ projects = Path(projects_dir) if projects_dir is not None else Path.home() / '.claude' / 'projects'
1240
+ harness = probe_format(path, projects, prefix or (instance_prefix(root) if root else None))
1241
+ if harness == 'claude' and prefix is not None and not within_instance(path, projects, prefix):
1242
+ raise ValueError('transcript is outside this instance')
1243
+ facts = read_session(path, root=root, projects_dir=projects, prefix=prefix)
1244
+ facts['cost_sessions'] = [facts['session']] if facts.get('session') else []
1245
+ facts['cost_scope_complete'] = bool(facts['cost_sessions'] and facts.get('calls')
1246
+ and not facts.get('parse_failures'))
1247
+ if harness == 'codex' and require_root_prompts and not facts['prompts']:
1248
+ facts.update(subagents=0, subagent_failures=0, workflow_runs=0)
1249
+ return facts
1250
+ accepted, failures = [], 0
1251
+ if harness == 'codex':
1252
+ index = codex_index if codex_index is not None else _codex_index(path, root or os.environ.get('EGREGORE_ROOT') or os.getcwd())
1253
+ members = {facts['session']}
1254
+ while True:
1255
+ children = {entry['id'] for entry in index.values() if entry.get('parent') in members}
1256
+ if children <= members:
1257
+ break
1258
+ members.update(children)
1259
+ accepted = sorted(Path(value) for value, entry in index.items()
1260
+ if entry.get('id') in members - {facts['session']})
1261
+ if len(accepted) > 50:
1262
+ facts['cost_scope_complete'] = False
1263
+ accepted = accepted[:50]
1264
+ elif harness == 'claude':
1265
+ sub_dir = path.with_suffix('') / 'subagents'
1266
+ # Gate traversal before looking through a possibly symlinked directory.
1267
+ if prefix is not None and within_instance(sub_dir, projects, prefix) and sub_dir.is_dir():
1268
+ for current, dirs, filenames in os.walk(sub_dir, followlinks=False):
1269
+ dirs[:] = sorted(name for name in dirs if within_instance(Path(current) / name, projects, prefix))
1270
+ for name in sorted(filenames):
1271
+ candidate = Path(current) / name
1272
+ if name.endswith('.jsonl') and within_instance(candidate, projects, prefix):
1273
+ accepted.append(candidate)
1274
+ read_subs = []
1275
+ for index, sub_path in enumerate(sorted(accepted)):
1276
+ try:
1277
+ if not sub_path.is_file() or not sub_path.stat().st_mode & 0o444:
1278
+ failures += 1
1279
+ continue
1280
+ sub = read_session(sub_path, 'workflow' if 'workflows' in sub_path.parts else 'sub', root=root,
1281
+ projects_dir=projects, prefix=prefix)
1282
+ if not sub['valid_entries']:
1283
+ failures += 1
1284
+ continue
1285
+ except (OSError, ValueError):
1286
+ failures += 1
1287
+ continue
1288
+ component = sub.get('session')
1289
+ if harness == 'claude' and (not component or component == facts.get('session')):
1290
+ # Claude workers can reuse the parent's session id. Keep their
1291
+ # cost scope distinct without publishing local transcript paths.
1292
+ component = 'claude-worker-' + hashlib.sha256(
1293
+ (str(facts.get('session')) + '/' + str(sub_path.relative_to(path.with_suffix('')))).encode()
1294
+ ).hexdigest()[:32]
1295
+ if component:
1296
+ facts['cost_sessions'].append(component)
1297
+ else:
1298
+ facts['cost_scope_complete'] = False
1299
+ facts['parse_failures'] = facts.get('parse_failures', 0) + sub.get('parse_failures', 0)
1300
+ if not sub.get('calls') or sub.get('parse_failures'):
1301
+ # Missing usage or dropped rows leave the worker's contribution
1302
+ # incomplete even when the parent's own calls have full prices.
1303
+ facts['cost_scope_complete'] = False
1304
+ if harness == 'codex':
1305
+ _merge_native(facts, sub, index)
1306
+ else:
1307
+ facts['calls'].update({f'{key}@{index}': value for key, value in sub['calls'].items()})
1308
+ facts['tools'].update(sub['tools'])
1309
+ # A subagent's events fill the main thread's wait; the outline
1310
+ # keeps active time within the main thread's wall clock.
1311
+ facts['active_spans'] = _merge_spans(facts['active_spans'] + sub['active_spans'])
1312
+ read_subs.append(sub_path)
1313
+ facts['subagents'] = len(read_subs)
1314
+ facts['subagent_failures'] = failures
1315
+ # Codex records native child IDs; Claude records free-text descriptions.
1316
+ announced = ({detail for kind, _, detail in facts.get('events', []) if kind == 'AGENT' and detail}
1317
+ if harness == 'codex' else set())
1318
+ launched = sum(tool.get('name') in ('Agent', 'Task', 'spawn_agent', 'collaboration.spawn_agent')
1319
+ for tool in facts.get('tools', {}).values())
1320
+ facts['cost_scope_complete'] = (facts['cost_scope_complete'] and failures == 0
1321
+ and len(facts['cost_sessions']) == len(set(facts['cost_sessions']))
1322
+ and not (announced - set(facts['cost_sessions']))
1323
+ and launched <= len(read_subs))
1324
+ facts['workflow_runs'] = len({p.parent for p in read_subs if 'workflows' in p.parts})
1325
+ return facts
1326
+
1327
+
1328
+ def skip_reason(facts):
1329
+ """Why a session gets no record, in the words record discovery already counts.
1330
+
1331
+ A session with no tool calls and no prompt after the greeting did no work.
1332
+ Claude answers its first prompt (the launcher's `start`, or whatever was
1333
+ typed) with the greeting; Codex and Pi show the greeting as a card, not a
1334
+ model turn, so there every prompt follows it, and a session without one is
1335
+ skipped even when an agent ran tools in it.
1336
+ """
1337
+ prompts = len(facts.get('prompts') or [])
1338
+ if facts.get('harness') != 'claude':
1339
+ return None if prompts else 'no prompts'
1340
+ return 'no prompts' if prompts <= 1 and not facts.get('tools') else None
1341
+
1342
+
1343
+ def _shell_commands(command):
1344
+ """Tokenize command positions, never punctuation inside quotes or heredocs.
1345
+
1346
+ This is deliberately a conservative shell recognizer, not an interpreter.
1347
+ It does not expand variables, substitutions, aliases or sourced scripts.
1348
+ """
1349
+ if not isinstance(command, str):
1350
+ return []
1351
+ lines, pending = [], []
1352
+ for line in command.splitlines():
1353
+ if pending:
1354
+ if line.strip() == pending[0]:
1355
+ pending.pop(0)
1356
+ continue
1357
+ # Several heredocs can be declared on the same command line; their
1358
+ # bodies follow in declaration order. Strip all bodies before parsing.
1359
+ heredocs = list(re.finditer(r"<<-?\s*(['\"]?)([\w_-]+)\1", line))
1360
+ if heredocs:
1361
+ pending.extend(match.group(2) for match in heredocs)
1362
+ line = line[:heredocs[0].start()]
1363
+ lines.append(line)
1364
+ source = '\n'.join(lines)
1365
+ segments, current, quote, escaped, comment = [], [], None, False, False
1366
+ for character in source:
1367
+ if comment:
1368
+ if character != '\n':
1369
+ continue
1370
+ comment = False
1371
+ if escaped:
1372
+ current.append(character)
1373
+ escaped = False
1374
+ continue
1375
+ if character == '\\' and quote != "'":
1376
+ current.append(character)
1377
+ escaped = True
1378
+ elif quote:
1379
+ current.append(character)
1380
+ if character == quote:
1381
+ quote = None
1382
+ elif character == '#' and (not current or current[-1].isspace()):
1383
+ comment = True
1384
+ elif character in ("'", '\"'):
1385
+ current.append(character)
1386
+ quote = character
1387
+ elif character in ';&|\n':
1388
+ if current:
1389
+ segments.append(''.join(current))
1390
+ current = []
1391
+ else:
1392
+ current.append(character)
1393
+ if current:
1394
+ segments.append(''.join(current))
1395
+ commands = []
1396
+ for segment in segments:
1397
+ lexer = shlex.shlex(segment, posix=True)
1398
+ lexer.whitespace_split = True
1399
+ tokens = []
1400
+ try:
1401
+ tokens.extend(lexer)
1402
+ except ValueError:
1403
+ # An incomplete heredoc argument still leaves the command tokens.
1404
+ pass
1405
+ if tokens:
1406
+ commands.append(tokens)
1407
+ return commands
1408
+
1409
+
1410
+ def git_commands(command, include_context=False):
1411
+ found = []
1412
+ for tokens in _shell_commands(command):
1413
+ while tokens and (re.match(r'^[A-Za-z_][A-Za-z_0-9]*=', tokens[0]) or tokens[0] in ('command', 'env')):
1414
+ tokens = tokens[1:]
1415
+ if not tokens:
1416
+ continue
1417
+ binary = Path(tokens[0]).name
1418
+ arguments = tokens[1:]
1419
+ if binary == 'git':
1420
+ relocated = False
1421
+ while arguments:
1422
+ option = arguments[0]
1423
+ if option in ('-C', '-c', '--git-dir', '--work-tree'):
1424
+ relocated |= option != '-c'
1425
+ arguments = arguments[2:]
1426
+ elif option.startswith(('--git-dir=', '--work-tree=')) or (option.startswith('-C') and len(option) > 2):
1427
+ relocated = True
1428
+ arguments = arguments[1:]
1429
+ else:
1430
+ break
1431
+ if arguments and (arguments[0] in ('commit', 'push', 'merge', 'worktree') or arguments[:2] == ['checkout', '-b']):
1432
+ found.append((binary, arguments, relocated) if include_context else (binary, arguments))
1433
+ elif binary == 'gh' and len(arguments) >= 2 and arguments[0] == 'pr' and arguments[1] in ('create', 'merge'):
1434
+ found.append((binary, arguments, False) if include_context else (binary, arguments))
1435
+ return found
1436
+
1437
+
1438
+ class _GitPattern:
1439
+ """Regex-like search interface with shell-aware command position checks."""
1440
+ def search(self, command):
1441
+ return git_commands(command) or None
1442
+
1443
+
1444
+ # Kept regex-like for the spike's existing `GIT_RE.search(command)` call sites.
1445
+ GIT_RE = _GitPattern()
1446
+ MODEL_SECTIONS = ('Asked', 'Decided', 'Open', 'Risks', 'Friction')
1447
+ # A section line that only says the section is empty or withheld: the whole
1448
+ # line after its bullet, with any trailing punctuation. A real item that
1449
+ # begins with the same words is kept. The record's readers use the same rule.
1450
+ PLACEHOLDER_RE = re.compile(r'(?:[-*+]\s*)?(?:none (?:recorded|noted)|not derived(?: \([^()]*\))?'
1451
+ r'|\(withheld\b[^()]*\))[\s.,;:!?]*', re.I)
1452
+ # The bound on an Open line the record raises itself, its bullet included.
1453
+ OPEN_LINE = 160
1454
+
1455
+
1456
+ def _words(text):
1457
+ return tuple(re.findall(r'\w+', text.casefold()))
1458
+
1459
+
1460
+ def _currency_safe(line):
1461
+ return not (re.search(r'[$€£]\s?\d', line) or
1462
+ (re.search(r'\b(?:salary|equity|compensation|valuation|runway|revenue)\b', line, re.I)
1463
+ and re.search(r'\b\d[\d,.]*\s*[kKM]\b', line)))
1464
+
1465
+
1466
+ def _paraphrase_evidence(facts):
1467
+ """Index source utterances once for the shared-text paraphrase check."""
1468
+ source = [item['text'] for key in ('prompt_texts', 'assistant_texts') for item in facts.get(key, [])]
1469
+ structured = []
1470
+ for tool in facts.get('tools', {}).values():
1471
+ if tool.get('name') != 'AskUserQuestion':
1472
+ continue
1473
+ questions = _mapping(tool.get('input')).get('questions')
1474
+ for question in questions if isinstance(questions, list) else []:
1475
+ question = _mapping(question)
1476
+ structured.append((_string(question.get('question')), 3))
1477
+ options = question.get('options')
1478
+ for option in options if isinstance(options, list) else []:
1479
+ label = _string(_mapping(option).get('label'))
1480
+ source.extend((label, _choice_label(label)))
1481
+ for item in facts.get('decided', []):
1482
+ structured.append((item['question'], 3))
1483
+ if not item.get('selected_labels'):
1484
+ structured.append((item['answer'], 2))
1485
+ runs, short, structured_short = set(), set(), set()
1486
+ for text, minimum in structured:
1487
+ source.append(text)
1488
+ words = _words(text)
1489
+ if minimum <= len(words) < 12:
1490
+ structured_short.add(words)
1491
+ for text in source:
1492
+ words = _words(text)
1493
+ if 0 < len(words) < 12:
1494
+ short.add(words)
1495
+ runs.update(words[i:i + 12] for i in range(max(0, len(words) - 11)))
1496
+ return runs, short, structured_short
1497
+
1498
+
1499
+ def _paraphrase_safe(text, evidence):
1500
+ """Also reject question phrases (3+ words) and free-text phrases (2+)."""
1501
+ runs, short, structured_short = evidence
1502
+ words = _words(text)
1503
+ return (words not in short
1504
+ and not any(words[i:i + 12] in runs for i in range(max(0, len(words) - 11)))
1505
+ and not any(words[i:i + len(source)] == source for source in structured_short
1506
+ for i in range(len(words) - len(source) + 1)))
1507
+
1508
+
1509
+ def _choice_label(label):
1510
+ return re.sub(r'\s*\(recommended\)\s*$', '', label, flags=re.I)
1511
+
1512
+
1513
+ def _decision_header(item):
1514
+ header = _short(item.get('header'), 40) or 'Question'
1515
+ if not _currency_safe(_string(item.get('header'))) or not _currency_safe(header):
1516
+ return 'Question'
1517
+ return header
1518
+
1519
+
1520
+ def guard_model_sections(model_output, facts):
1521
+ """Keep named sections only; reject verbatim runs, whole short blocks, money."""
1522
+ raw = {name: [] for name in MODEL_SECTIONS}
1523
+ current = None
1524
+ for line in (model_output or '').splitlines():
1525
+ heading = re.fullmatch(r'\s*##\s+([^#]+?)\s*', line)
1526
+ if heading:
1527
+ current = heading.group(1) if heading.group(1) in raw else None
1528
+ elif current and line.strip():
1529
+ raw[current].append(line.strip())
1530
+ evidence = _paraphrase_evidence(facts)
1531
+ result = {}
1532
+ for name, lines in raw.items():
1533
+ kept = []
1534
+ for line in lines:
1535
+ if not _paraphrase_safe(line, evidence):
1536
+ continue
1537
+ if not _currency_safe(line):
1538
+ continue
1539
+ kept.append(line)
1540
+ if name == 'Asked':
1541
+ kept = kept[:12]
1542
+ elif name == 'Friction':
1543
+ kept = kept[:1]
1544
+ result[name] = '\n'.join(kept) if kept else '(withheld: paraphrase check failed)'
1545
+ return result
1546
+
1547
+
1548
+ # Descriptive alias for consumers testing the mechanical paraphrase check.
1549
+ paraphrase_guard = guard_model_sections
1550
+
1551
+
1552
+ def _relative_file(value, repo):
1553
+ if not isinstance(value, str) or not value:
1554
+ return None
1555
+ try:
1556
+ candidate = Path(value) if Path(value).is_absolute() else repo / value
1557
+ physical, root = candidate.resolve(), project_root(repo)
1558
+ if physical.is_relative_to(root):
1559
+ # Preserve the supplied repo-relative spelling when it is safe.
1560
+ logical = Path(os.path.abspath(candidate))
1561
+ relative = logical.relative_to(root) if logical.is_relative_to(root) else physical.relative_to(root)
1562
+ if relative.parts[:2] == ('.claude', 'worktrees') and len(relative.parts) > 3:
1563
+ relative = Path(*relative.parts[3:])
1564
+ return _inline(relative.as_posix())
1565
+ # Shared memory is a documented repo-owned symlink. Other symlinks
1566
+ # escaping the project are counted without exposing their paths.
1567
+ for memory in (repo / 'memory', root / 'memory'):
1568
+ if memory.is_dir() and physical.is_relative_to(memory.resolve()):
1569
+ return _inline((Path('memory') / physical.relative_to(memory.resolve())).as_posix())
1570
+ except (OSError, ValueError, RuntimeError):
1571
+ pass
1572
+ return None
1573
+
1574
+
1575
+ def _git_identifier(value, allow_slashes=False):
1576
+ """A bounded ref/name, never an option, URL, credential or filesystem path."""
1577
+ if not isinstance(value, str) or len(value) > 128 or '..' in value:
1578
+ return None
1579
+ pattern = r'[A-Za-z0-9][A-Za-z0-9._-]*(?:/[A-Za-z0-9][A-Za-z0-9._-]*)*' if allow_slashes else r'[A-Za-z0-9][A-Za-z0-9._-]*'
1580
+ return value if re.fullmatch(pattern, value) else None
1581
+
1582
+
1583
+ def _git_operation(args, repo, relocated=False):
1584
+ """Render allowlisted operation fields instead of copying Git arguments."""
1585
+ operation = args[0]
1586
+ if operation == 'push':
1587
+ # Options with values must not shift their values into the remote/ref
1588
+ # slots. Unknown options are omitted along with any attached value.
1589
+ positional, skip = [], False
1590
+ for argument in args[1:]:
1591
+ if skip:
1592
+ skip = False
1593
+ elif argument in ('--repo', '--receive-pack', '--exec', '-o', '--push-option'):
1594
+ skip = True
1595
+ elif not argument.startswith('-'):
1596
+ positional.append(argument)
1597
+ if not positional:
1598
+ return 'git push'
1599
+ remote = _git_identifier(positional[0]) or '<redacted-remote>'
1600
+ refs = [safe for argument in positional[1:] if (safe := _git_identifier(argument))]
1601
+ return 'git push ' + ' '.join([remote, *refs[:8]])
1602
+ if operation == 'merge':
1603
+ refs, skip = [], False
1604
+ for argument in args[1:]:
1605
+ if skip:
1606
+ skip = False
1607
+ elif argument in ('-m', '--message', '-F', '--file', '-s', '--strategy', '-X', '--strategy-option'):
1608
+ skip = True
1609
+ elif not argument.startswith('-') and (safe := _git_identifier(argument, allow_slashes=True)):
1610
+ refs.append(safe)
1611
+ return 'git merge' + (' ' + ' '.join(refs[:8]) if refs else '')
1612
+ if operation == 'worktree':
1613
+ if len(args) < 2 or args[1] not in ('add', 'remove'):
1614
+ return 'git worktree'
1615
+ if relocated:
1616
+ return 'git worktree ' + args[1]
1617
+ # Paths are optional record data: include only a physically admitted
1618
+ # repository-relative path, never a caller's outside path.
1619
+ path, skip = None, False
1620
+ for argument in args[2:]:
1621
+ if skip:
1622
+ skip = False
1623
+ elif argument in ('-b', '-B', '--reason'):
1624
+ skip = True
1625
+ elif not argument.startswith('-'):
1626
+ try:
1627
+ candidate = Path(argument) if Path(argument).is_absolute() else repo / argument
1628
+ if candidate.resolve().is_relative_to(project_root(repo)):
1629
+ path = _relative_file(argument, repo)
1630
+ except (OSError, ValueError, RuntimeError):
1631
+ pass
1632
+ break
1633
+ return 'git worktree ' + args[1] + (' ' + path if path else '')
1634
+ if operation == 'checkout':
1635
+ branch = _git_identifier(args[2], allow_slashes=True) if len(args) > 2 else None
1636
+ return 'git checkout -b' + (' ' + branch if branch else '')
1637
+ return 'git ' + operation
1638
+
1639
+
1640
+ HOOK_ITEM = 'hook asked for a decision and got none: '
1641
+
1642
+
1643
+ def _shareable(text, facts, evidence):
1644
+ """Whether text may enter the shared record: it encodes as UTF-8 and holds
1645
+ nothing the record withholds, neither a known free-text answer nor a
1646
+ currency figure, nor words the paraphrase check keeps out of model lines."""
1647
+ try:
1648
+ text.encode('utf-8')
1649
+ except UnicodeEncodeError:
1650
+ return False
1651
+ return withhold_free_text(text, facts) == text and _currency_safe(text) and _paraphrase_safe(text, evidence)
1652
+
1653
+
1654
+ def _raised_items(facts, flaked):
1655
+ """The Friction line for hook prompts left without a decision, by hook and
1656
+ most frequent first, and the Open lines the record raises itself: a hook
1657
+ left undecided three times or more in the session, and each CI job that
1658
+ flaked on its merges. Hooks are counted by their own names, and only the
1659
+ rendering withholds: a name or file is checked as found, then in its
1660
+ whole line and in the line cut to length; a name that fails is written
1661
+ `hook`, a file that fails is left off. A job is checked as found, then in
1662
+ its rewritten line, and a job that fails is left out. The lines name no
1663
+ owner, so the same hook and file, or the same job, raise one line in
1664
+ every session."""
1665
+ requests = [item for item in facts.get('hook_requests', []) if isinstance(item, dict) and not item.get('decided')]
1666
+ evidence, checked = (_paraphrase_evidence(facts) if requests or flaked else None), {}
1667
+
1668
+ def shared(text):
1669
+ if text not in checked:
1670
+ checked[text] = _shareable(text, facts, evidence)
1671
+ return checked[text]
1672
+
1673
+ fit = lambda text: _short(text, OPEN_LINE - 2)
1674
+ label = lambda name: _inline(name) if name and shared(name) else 'hook'
1675
+ undecided = [(_string(item.get('hook')), _string(item.get('file'))) for item in requests]
1676
+ by_hook = Counter(name for name, _ in undecided)
1677
+ rendered = Counter()
1678
+ for name, count in by_hook.items():
1679
+ rendered[label(name)] += count
1680
+ friction = '- hook prompts without a decision: '
1681
+ if facts.get('harness') not in (None, 'claude'):
1682
+ friction += 'unavailable in this harness'
1683
+ elif rendered:
1684
+ friction += str(sum(rendered.values()))
1685
+ named = f'{friction} (' + ', '.join(f'{name} {count}' for name, count in
1686
+ sorted(rendered.items(), key=lambda pair: (-pair[1], pair[0]))) + ')'
1687
+ friction = named if shared(named) else friction
1688
+ else:
1689
+ friction += '0'
1690
+ lines = []
1691
+ for name, count in sorted(by_hook.items(), key=lambda pair: (-pair[1], label(pair[0]), pair[0])):
1692
+ if count < IGNORED_REQUESTS_RAISED:
1693
+ continue
1694
+ files = Counter(file for hook, file in undecided if hook == name and file and shared(file))
1695
+ candidates = [f'{HOOK_ITEM}{label(name)} on {_inline(files.most_common(1)[0][0])}'] if files else []
1696
+ candidates.append(HOOK_ITEM + label(name))
1697
+ lines.append(next((fit(line) for line in candidates if shared(line) and shared(fit(line))), HOOK_ITEM + 'hook'))
1698
+ for job in flaked or ():
1699
+ job = _string(job)
1700
+ # An Open line's owner follows its last ' — ', so the line keeps no em dash of the job's.
1701
+ line = 'CI flaked: ' + job.replace('—', '–')
1702
+ if job.strip() and shared(job) and shared(line) and shared(fit(line)):
1703
+ lines.append(fit(line))
1704
+ return friction, list(dict.fromkeys(lines))
1705
+
1706
+
1707
+ def cost_coverage(facts, source, unpriced=None):
1708
+ """Coverage of the observed cost scope, not proof of invoice completeness.
1709
+
1710
+ Legacy facts without usage provenance remain partial. A parent ledger
1711
+ cannot establish that its workers' costs were included. Explicit outcome
1712
+ attribution separately accounts for other driver/retry/review sessions.
1713
+ """
1714
+ calls = list(facts.get('calls', {}).values())
1715
+ if source == 'unavailable' or not calls:
1716
+ return 'unavailable'
1717
+ if (facts.get('parse_failures') or facts.get('subagent_failures')
1718
+ or facts.get('cost_scope_complete') is False):
1719
+ return 'partial'
1720
+ harness = facts.get('harness')
1721
+ if harness == 'codex':
1722
+ complete = (not (unpriced or {}).get('models')
1723
+ and all(call.get('usage_complete') is True for call in calls))
1724
+ elif harness in ('pi', 'prime'):
1725
+ complete = source == 'ledger' and all(call.get('cost_complete') is True for call in calls)
1726
+ elif source == 'estimate':
1727
+ complete = all(call.get('usage_complete') is True
1728
+ and _string(call.get('model')).split('[')[0] in PRICES for call in calls)
1729
+ else:
1730
+ # A final running-total event after the last main call establishes the
1731
+ # local ledger horizon. Descendant inclusion is not established by it.
1732
+ states = facts.get('costs', [])
1733
+ final = states[-1] if states else {}
1734
+ try:
1735
+ stamp = _ts(final.get('timestamp'))
1736
+ except (ValueError, TypeError, AttributeError):
1737
+ stamp = None
1738
+ complete = (not facts.get('subagents') and stamp is not None
1739
+ and stamp >= max((call.get('ts') or 0 for call in calls), default=0)
1740
+ and _number(final.get('totalCostUSD'), None) is not None)
1741
+ return 'complete' if complete else 'partial'
1742
+
1743
+
1744
+ def outline(path, repo=None, model_output=None, detail='outline', topic='session', author='', friction_reason=None, git_log=None, person=None, flaked=()):
1745
+ """Build ordered sections and metadata; no writes, subprocesses or model calls.
1746
+
1747
+ `path` may be a facts dictionary from read_transcript/load_session. git_log
1748
+ is optional preobtained `%h %s` text; the reader never invokes git itself.
1749
+ `flaked` names the CI jobs that flaked on merges this session made, as the
1750
+ caller read them from the gate ledger.
1751
+ """
1752
+ facts = path if isinstance(path, dict) else read_transcript(path)
1753
+ repo = Path(os.path.abspath(repo or os.getcwd()))
1754
+ tools = list(facts.get('tools', {}).values())
1755
+ calls = list(facts.get('calls', {}).values())
1756
+ first, last = facts.get('first'), facts.get('last')
1757
+ seconds = max(0, (last or first or 0) - (first or last or 0))
1758
+ duration = _clock(seconds)
1759
+ # Active time sums the gaps under five minutes between events, inside the
1760
+ # wall clock above; duration keeps the idle stretches, as it always has.
1761
+ start = first if first is not None else last
1762
+ active_seconds = sum(max(0, min(end, start + seconds) - max(begin, start))
1763
+ for begin, end in facts.get('active_spans', [])) if start is not None else 0
1764
+ active = _clock(active_seconds)
1765
+ harness = facts.get('harness')
1766
+ cost, by_model = session_cost(facts.get('costs', []))
1767
+ unpriced = None
1768
+ if harness == 'codex':
1769
+ cost, by_model, unpriced = list_price(calls)
1770
+ source = 'list price' if by_model else 'unavailable'
1771
+ else:
1772
+ source = 'ledger' if facts.get('costs') else 'estimate'
1773
+ if source == 'estimate':
1774
+ cost = estimate_cost(calls)
1775
+ model = guard_model_sections(model_output, facts) if model_output is not None else {name: 'not derived (model unavailable)' for name in MODEL_SECTIONS}
1776
+ result_detail = detail if model_output is not None else 'outline-deterministic'
1777
+ files, outside = set(), set()
1778
+ # Native harnesses can report many changed files in one completed tool item.
1779
+ # Their readers normalize FILE events without retaining outside path names.
1780
+ if facts.get('harness') in ('codex', 'pi', 'prime'):
1781
+ for kind, _, detail in facts.get('events', []):
1782
+ if kind != 'FILE' or ' ' not in detail:
1783
+ continue
1784
+ value = detail.split(' ', 1)[1]
1785
+ if value.startswith('<outside:'):
1786
+ outside.add(value)
1787
+ elif (relative := _relative_file(value, repo)) is not None:
1788
+ files.add(relative)
1789
+ recalls = Counter()
1790
+ recall_failures = Counter()
1791
+ recall_failures_by_op = Counter()
1792
+ # A harness error is a result the harness flagged with its own error body; a
1793
+ # command that ran and returned a non-zero status is counted on its own.
1794
+ errors, exits = Counter(), Counter()
1795
+ commits, git_lines = [], []
1796
+ sleeps, guards, published, notifications = [], 0, 0, 0
1797
+ for tool in tools:
1798
+ name, inp = _inline(tool.get('name')), _mapping(tool.get('input'))
1799
+ if tool.get('error'):
1800
+ (exits if tool.get('nonzero_exit') else errors)[name or 'unknown'] += 1
1801
+ # One refusal is one count, however many of the guard's phrases it carries.
1802
+ guards += bool(GUARD_RE.match(_string(tool.get('result'))))
1803
+ if name in ('Write', 'Edit', 'NotebookEdit'):
1804
+ value = _string(inp.get('file_path')) or _string(inp.get('notebook_path'))
1805
+ relative = _relative_file(value, repo)
1806
+ if relative is not None:
1807
+ files.add(relative)
1808
+ elif value:
1809
+ outside.add(value)
1810
+ if name != 'Bash':
1811
+ continue
1812
+ command = _string(inp.get('command'))
1813
+ match = SEARCH_RE.search(command)
1814
+ if match:
1815
+ op = match.group(1)
1816
+ kind = (KIND_RE.search(command) or [None, None])[1] or ('keyword' if op == 'find' else '-')
1817
+ recalls[(op, kind)] += 1
1818
+ failure = _recall_failure(tool.get('result', ''), tool.get('error')) or tool.get('attach_failure')
1819
+ if failure:
1820
+ recall_failures[(kind, failure)] += 1
1821
+ recall_failures_by_op[(op, kind)] += 1
1822
+ sleep = SLEEP_RE.match(command)
1823
+ if sleep:
1824
+ sleeps.append(float(sleep.group(1)))
1825
+ published += bool(re.search(r'\bpublish-artifact(?:\.sh)?\b', command))
1826
+ notifications += bool(re.search(r'\bnotify\.sh\b', command))
1827
+ for binary, args, relocated in git_commands(command, include_context=True):
1828
+ if binary == 'git' and args[0] == 'commit':
1829
+ subject = None
1830
+ for index, token in enumerate(args):
1831
+ if token in ('-m', '--message') and index + 1 < len(args):
1832
+ subject = args[index + 1].split('\n', 1)[0]
1833
+ break
1834
+ if token.startswith('--message='):
1835
+ subject = token.split('=', 1)[1].split('\n', 1)[0]
1836
+ break
1837
+ if token.startswith('-') and not token.startswith('--') and 'm' in token[1:]:
1838
+ suffix = token.split('m', 1)[1]
1839
+ subject = (suffix or (args[index + 1] if index + 1 < len(args) else '')).split('\n', 1)[0]
1840
+ break
1841
+ label = 'git commit' + (': ' + _short(subject, 240) if subject else '')
1842
+ commits.append(label)
1843
+ elif binary == 'gh':
1844
+ label = 'gh ' + ' '.join(args[:2])
1845
+ if args[1] == 'merge' and len(args) > 2 and args[2].isdigit():
1846
+ label += ' #' + args[2]
1847
+ numbers = re.findall(r'(?:/pull/|#)(\d+)\b', tool.get('result', ''))
1848
+ if numbers:
1849
+ label += ' · ' + ', '.join('#' + n for n in dict.fromkeys(numbers))
1850
+ else:
1851
+ label = _git_operation(args, repo, relocated)
1852
+ label = _inline(label)
1853
+ if label not in git_lines:
1854
+ git_lines.append(label)
1855
+ verified = [_inline(line) for line in (git_log or '').splitlines() if line.strip()]
1856
+ commit_count = len(verified) if git_log is not None else len(commits)
1857
+ recall_count, failed = sum(recalls.values()), sum(recall_failures.values())
1858
+ sections = {'Summary': f'{duration} on {topic}; {commit_count} commits, {len(files) + len(outside)} files, {recall_count} recalls ({failed} failed); ${cost:,.2f}'}
1859
+ for name in ('Asked', 'Decided', 'Open', 'Risks'):
1860
+ sections[name] = model[name]
1861
+ multiple_days = (first is not None and last is not None and
1862
+ datetime.fromtimestamp(first).date() != datetime.fromtimestamp(last).date())
1863
+ chosen = []
1864
+ for item in facts.get('decided', []):
1865
+ when = _hm(item.get('ts'))
1866
+ if not multiple_days:
1867
+ when = when[-5:]
1868
+ header = _decision_header(item)
1869
+ labels = item.get('selected_labels') or []
1870
+ answer = ', '.join(_choice_label(label) for label in labels) if labels else 'a free-text answer'
1871
+ rendered_answer = _short(answer, 160)
1872
+ if not _currency_safe(answer) or not _currency_safe(rendered_answer):
1873
+ continue
1874
+ who = _inline(person or author or 'the person')
1875
+ chosen.append(f'- {header}: {rendered_answer} — {who}, {when}')
1876
+ if chosen:
1877
+ derived = [line for line in model['Decided'].splitlines() if not PLACEHOLDER_RE.fullmatch(line.strip())]
1878
+ sections['Decided'] = '\n'.join([*chosen, *derived])
1879
+ hook_friction, raised = _raised_items(facts, flaked)
1880
+ if raised:
1881
+ # After the model's own open items; the section exists even when it wrote none.
1882
+ kept = [line for line in model['Open'].splitlines() if not PLACEHOLDER_RE.fullmatch(line.strip())]
1883
+ sections['Open'] = '\n'.join([*kept, *('- ' + line for line in raised)])
1884
+ if result_detail == 'full':
1885
+ sections['Prompts'] = '\n'.join(withhold_free_text(f"- {_hm(item['ts'])[-5:]} {item['text'][:700]}", facts) for item in facts.get('prompt_texts', [])) or 'none recorded'
1886
+ sections['Said'] = '\n'.join(withhold_free_text(f"- {_hm(item['ts'])[-5:]} {item['text'][:500]}", facts) for item in facts.get('said_texts', [])) or 'none recorded'
1887
+ git_groups = []
1888
+ if git_lines:
1889
+ git_groups.append("attempted (from the session's commands):\n" + '\n'.join('- ' + line for line in git_lines))
1890
+ if verified:
1891
+ git_groups.append('verified (in the repository):\n' + '\n'.join('- ' + line for line in verified))
1892
+ sections['Commits and PRs'] = '\n\n'.join(git_groups) or 'none recorded'
1893
+ file_lines = ['- ' + name for name in sorted(files)[:40]]
1894
+ if len(files) > 40:
1895
+ file_lines.append(f'+{len(files) - 40} more')
1896
+ if outside:
1897
+ file_lines.append(f'outside the repo: {len(outside)} files')
1898
+ sections['Files'] = '\n'.join(file_lines) or 'none recorded'
1899
+ memory = [f'- {op}/{kind}: {count} calls, {recall_failures_by_op[(op, kind)]} failed' for (op, kind), count in sorted(recalls.items())]
1900
+ memory.extend([f'- publish-artifact: {published} calls', f'- notify.sh: {notifications} calls'])
1901
+ memory.extend('- ' + name for name in sorted(files) if name.startswith('memory/'))
1902
+ sections['Memory and artifacts'] = '\n'.join(memory)
1903
+ sections['Related'] = '\n'.join('- ' + name for name in sorted(files) if name.startswith(('memory/handoffs/', 'memory/wraps/'))) or 'none recorded'
1904
+ friction = [f'- tool errors: ' + (', '.join(f'{name} {count}' for name, count in sorted(errors.items())) or '0'),
1905
+ '- non-zero exits: ' + (', '.join(f'{name} {count}' for name, count in sorted(exits.items())) or '0'),
1906
+ f'- worktree-guard refusals: {guards}',
1907
+ '- recall failures: ' + (', '.join(f'{kind}/{failure} {count}' for (kind, failure), count in sorted(recall_failures.items())) or '0'),
1908
+ f'- sleep commands: {len(sleeps)}, {sum(sleeps) / 60:.1f} minutes',
1909
+ f"- compactions: {facts.get('compactions', 0)}",
1910
+ hook_friction]
1911
+ if facts.get('subagent_failures'):
1912
+ friction.append(f"- subagent transcripts unavailable: {facts['subagent_failures']}")
1913
+ if friction_reason:
1914
+ friction.append('- ' + str(friction_reason))
1915
+ friction.append(model['Friction'])
1916
+ sections['Friction'] = '\n'.join(friction)
1917
+ reads = [c['cache_read'] for c in calls if c.get('agent') == 'main' and c.get('cache_read')]
1918
+ cost_lines = [f'- total: ${cost:,.2f} ({source})']
1919
+ coverage = cost_coverage(facts, source, unpriced)
1920
+ if harness == 'codex':
1921
+ inputs = sum(_number(call.get('input')) for call in calls)
1922
+ cached = sum(_number(call.get('cache_read')) for call in calls)
1923
+ outputs = sum(_number(call.get('output')) for call in calls)
1924
+ tokens = f'- tokens: {inputs} input ({cached} cached), {outputs} output'
1925
+ models = ', '.join(sorted(unpriced['models']))
1926
+ if not calls:
1927
+ tokens += '; cost not derived (no token usage recorded)'
1928
+ elif source == 'unavailable':
1929
+ tokens += f'; cost not derived (no price for {models})'
1930
+ elif models:
1931
+ tokens += f"; not priced: {unpriced['input']} input, {unpriced['output']} output (no price for {models})"
1932
+ cost_lines.append(tokens)
1933
+ cost_lines.extend(f'- {_inline(name)}: ${amount:,.2f}' for name, amount in sorted(by_model.items()))
1934
+ if source == 'list price':
1935
+ cost_lines.append('- priced at list; a subscription account pays a flat rate')
1936
+ cost_lines.extend([f'- cache-read tokens p50 per main-thread call: {_pct(reads, .5) or 0:,}', f'- model calls: {len(calls)}', f'- tool calls: {len(tools)}'])
1937
+ cost_lines.append(f'- observed cost coverage: {coverage}')
1938
+ sections['Cost'] = '\n'.join(cost_lines)
1939
+ return {'body': '\n\n'.join(f'## {name}\n{body}' for name, body in sections.items()) + '\n', 'sections': sections,
1940
+ 'detail': result_detail, 'cost': cost, 'cost_usd': cost, 'cost_source': source, 'cost_by_model': dict(by_model),
1941
+ 'cost_coverage': coverage,
1942
+ 'cost_sessions': sorted(set(facts.get('cost_sessions') or
1943
+ ([facts['session']] if facts.get('session') else []))),
1944
+ 'duration': duration, 'duration_seconds': seconds, 'active': active, 'active_seconds': active_seconds,
1945
+ 'first': first, 'last': last,
1946
+ 'branch': facts.get('branch') or next(iter(facts.get('branches', [])), ''),
1947
+ 'commits': commit_count, 'files': len(files) + len(outside), 'recalls': recall_count,
1948
+ 'recall_failures': failed, 'model_calls': len(calls), 'tool_calls': len(tools)}
1949
+
1950
+
1951
+ def main():
1952
+ parser = argparse.ArgumentParser(description=__doc__)
1953
+ parser.add_argument('command', choices=('facts', 'trail', 'outline', 'gate', 'probe'))
1954
+ parser.add_argument('transcript')
1955
+ parser.add_argument('--json', action='store_true')
1956
+ parser.add_argument('--repo', default=os.environ.get('EGREGORE_ROOT') or os.getcwd())
1957
+ args = parser.parse_args()
1958
+ header = read_header(args.transcript)
1959
+ harness = _header_format(header)
1960
+ admitted = (within_instance_cwd(_mapping(header.get('payload') if harness == 'codex' else header).get('cwd'), args.repo)
1961
+ if harness in ('codex', 'pi') else
1962
+ within_instance(args.transcript, Path.home() / '.claude' / 'projects', instance_prefix(args.repo)))
1963
+ if args.command == 'gate':
1964
+ return 0 if admitted else 1
1965
+ if not admitted:
1966
+ print('transcript is outside this instance', file=sys.stderr)
1967
+ return 1
1968
+ if args.command == 'probe':
1969
+ return 0 if probe_format(args.transcript, Path.home() / '.claude' / 'projects',
1970
+ instance_prefix(args.repo)) is not None else 1
1971
+ facts = read_session(args.transcript, root=args.repo)
1972
+ if args.command == 'facts':
1973
+ print(json.dumps(facts, default=str) if args.json else json.dumps(facts, indent=2, default=str))
1974
+ elif args.command == 'trail':
1975
+ print(json.dumps(trail(facts), default=lambda value: sorted(value)) if args.json else render_trail(facts))
1976
+ else:
1977
+ result = outline(facts, repo=args.repo)
1978
+ print(json.dumps(result, default=str) if args.json else result['body'], end='\n' if args.json else '')
1979
+ return 0
1980
+
1981
+
1982
+ if __name__ == '__main__':
1983
+ raise SystemExit(main())