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,934 @@
1
+ """Record what people like in generated interfaces and read it back as words.
2
+
3
+ Only deliberate human acts are recorded: liked, disliked, chose (one interface
4
+ over another), adopted (make this my default) and retracted. Nothing is
5
+ inferred from what a page happened to use, and nothing is scored: the summary
6
+ is plain counts plus the person's own words, dated.
7
+
8
+ Events carry words only. A target is never opened, so no page source or client
9
+ text enters memory. A registry id, hosted URL or claude.ai artifact link is
10
+ stored as given; a local file is stored relative to the repository when it is
11
+ inside it and by its file name otherwise, so shared memory never holds a home
12
+ directory or the folders around it. Each event is one immutable JSON file,
13
+ ``memory/taste/<month>/<actor folder>/<time>-<kind>-<event id>.json``, created
14
+ once through the Runtime writeback: a new file cannot be replaced by a rebase,
15
+ and two machines never write the same path. The name carries the kind and id,
16
+ so finding an event, retracting it and resolving defaults read every file by
17
+ name; only the reactions ``taste`` shows are bounded. Search indexes Markdown
18
+ only, so taste adds no search hits.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+ from datetime import UTC, datetime
25
+ import hashlib
26
+ import json
27
+ import os
28
+ from pathlib import Path, PureWindowsPath
29
+ import re
30
+ from typing import Any, Callable, Iterable, Mapping
31
+ from urllib.parse import unquote, urlsplit
32
+ import uuid
33
+
34
+ from .taste_rules import EGREGORE_RULES
35
+ from .artifacts import ArtifactConflictError
36
+ from .contracts import ActorContext, CanonicalFilesWritebackEvent, Permission, PolicyDecision, WritebackStatus
37
+ from .errors import AuthorizationDenied
38
+ from .policy import allows, action_for
39
+
40
+
41
+ TASTE_SCHEMA = "egregore.taste/v1"
42
+ SUMMARY_SCHEMA = "egregore.taste-summary/v1"
43
+ EVENT_KINDS = frozenset({"liked", "disliked", "chose", "adopted", "retracted"})
44
+ ADOPT_KINDS = ("palette", "pattern", "principle")
45
+ ADOPT_SCOPES = ("person", "org")
46
+ WHOLE_THING = "the whole thing"
47
+ READING_NOTE = "Words to consider, never a template to copy."
48
+
49
+ MAX_WORDS = 1000
50
+ MAX_TARGET = 2048
51
+ MAX_FIELD = 200
52
+ MAX_ABOUT = 8
53
+ MAX_ABOUT_CHARS = 60
54
+ TOP_PATTERNS = 12
55
+ # Palettes and patterns are defaults (the latest wins); principles are rules
56
+ # that accumulate until someone retracts one.
57
+ DEFAULT_KINDS = ("palette", "pattern")
58
+ HOUSE_RULES = 24
59
+ # A pattern the organization named on this many different pages is part of
60
+ # its emergent taste.
61
+ EMERGENT_PAGES = 2
62
+ LAYER_NOTE = (
63
+ "Read in this order; a later layer wins where they conflict, and the must-pass "
64
+ "checks (contrast, accessibility, colours from tokens) always hold."
65
+ )
66
+ LAST_QUOTES = 8
67
+ LAST_CHOICES = 8
68
+ QUOTE_CHARS = 240
69
+ MAX_EVENT_FILES = 10000 # reactions ``taste`` shows; lifecycle and shared ids read every file
70
+ MAX_EVENT_BYTES = 64 * 1024
71
+ LIFECYCLE_KINDS = frozenset({"adopted", "retracted"})
72
+ TEXT_FIELDS = ("words", "region", "task", "audience", "form", "scope", "runtime")
73
+ _EVENT_NAME = re.compile(
74
+ r"(?P<time>\d{8}T\d{6}Z)-(?P<kind>liked|disliked|chose|adopted|retracted)-"
75
+ r"(?P<id>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.json"
76
+ )
77
+
78
+ _RUNTIMES = {"claude": "claude", "claude-code": "claude", "codex": "codex", "pi": "pi"}
79
+ _COMMIT_MESSAGES = {
80
+ "liked": "docs(taste): record a liked interface",
81
+ "disliked": "docs(taste): record a disliked interface",
82
+ "chose": "docs(taste): record an interface choice",
83
+ "adopted": "docs(taste): record an adopted default",
84
+ "retracted": "docs(taste): retract a taste event",
85
+ }
86
+
87
+
88
+ class TasteError(ValueError):
89
+ """A taste event or query is invalid."""
90
+
91
+
92
+ def _slug(value: str) -> str:
93
+ return re.sub(r"[^a-z0-9]+", "-", str(value).casefold()).strip("-")
94
+
95
+
96
+ def _iso(moment: datetime) -> str:
97
+ return moment.astimezone(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
98
+
99
+
100
+ def _parse_ts(value: str) -> datetime:
101
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC)
102
+
103
+
104
+ def actor_folder(actor_id: str) -> str:
105
+ """The folder holding one actor's events, named from the Runtime actor id.
106
+
107
+ An id the Runtime issues (lowercase letters, digits and '_') is used as it
108
+ is. Any other id becomes a slug plus a digest of the exact id, so ids that
109
+ slug alike ("Renç", "Ren") never share a folder, even on a disk that
110
+ ignores case; the '-' keeps the two forms apart.
111
+ """
112
+
113
+ if re.fullmatch(r"[a-z0-9_]{1,64}", actor_id):
114
+ return actor_id
115
+ digest = hashlib.sha256(actor_id.encode("utf-8")).hexdigest()[:16]
116
+ return f"{_slug(actor_id)[:40] or 'actor'}-{digest}"
117
+
118
+
119
+ def _serialize(event: Mapping[str, Any]) -> str:
120
+ return json.dumps(event, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
121
+
122
+
123
+ def _reaction(event: Mapping[str, Any]) -> dict[str, Any]:
124
+ """What a retry must repeat: the event without when, where and by whom."""
125
+
126
+ return {key: value for key, value in event.items() if key not in {"ts", "actor", "runtime"}}
127
+
128
+
129
+ def runtime_name(harness: str, environ: Mapping[str, str] | None = None) -> str:
130
+ """Name the runtime that recorded an event: claude, codex, pi or unknown."""
131
+
132
+ known = _RUNTIMES.get(str(harness or "").strip().casefold())
133
+ if known:
134
+ return known
135
+ environment = os.environ if environ is None else environ
136
+ if environment.get("CLAUDECODE") == "1":
137
+ return "claude"
138
+ if environment.get("CODEX_THREAD_ID"):
139
+ return "codex"
140
+ return "unknown"
141
+
142
+
143
+ def target_kind(ref: str) -> str:
144
+ """Classify a target as given, without opening it."""
145
+
146
+ if ref.casefold().startswith(("http://", "https://")):
147
+ parts = urlsplit(ref)
148
+ host = (parts.hostname or "").casefold()
149
+ if (host == "claude.ai" or host.endswith(".claude.ai")) and "/artifact" in parts.path:
150
+ return "claude-artifact"
151
+ return "url"
152
+ if "/" in ref or "\\" in ref or ref.startswith((".", "~")) or re.search(r"\.[A-Za-z0-9]{1,5}$", ref):
153
+ return "path"
154
+ return "registry"
155
+
156
+
157
+ def clean_target(ref: str | None, *, label: str = "target") -> str:
158
+ value = (ref or "").strip()
159
+ if not value:
160
+ raise TasteError(
161
+ f"{label} is required: a registry id, hosted URL, local path or claude.ai artifact link"
162
+ )
163
+ # A target names the interface. Markup, line breaks or a very long value
164
+ # mean page contents were passed instead, and those never enter memory.
165
+ if len(value) > MAX_TARGET or any(mark in value for mark in ("\n", "\r", "<", ">")):
166
+ raise TasteError(f"{label} must name the interface (id, URL or path), not carry its contents")
167
+ return value
168
+
169
+
170
+ def shareable_path(ref: str, *, repository_root: Path, cwd: Path) -> str:
171
+ """A local file as shared memory may hold it: relative to the repository
172
+ when inside it, otherwise its file name alone. A full path would carry an
173
+ OS user name and the client folders around the file."""
174
+
175
+ value = ref
176
+ if value.casefold().startswith("file:"):
177
+ value = unquote(urlsplit(value).path) or value
178
+ windows = PureWindowsPath(value)
179
+ if os.sep == "/" and ("\\" in value or windows.drive):
180
+ if windows.drive or windows.root:
181
+ # C:\x, C:x, \x and \\host\share\x never name a file in this checkout.
182
+ return windows.name or "file"
183
+ value = windows.as_posix() # ..\x is ../x, contained like any relative path
184
+ path = Path(os.path.expanduser(value))
185
+ if not path.is_absolute():
186
+ path = cwd / path
187
+ resolved = path.resolve()
188
+ if resolved.is_relative_to(repository_root):
189
+ return resolved.relative_to(repository_root).as_posix() or "."
190
+ return Path(os.path.normpath(path)).name or "file"
191
+
192
+
193
+ def clean_words(words: str | None, *, required: bool) -> str | None:
194
+ if words is None or not words.strip():
195
+ if required:
196
+ raise TasteError("--words is required: what the person said, verbatim")
197
+ return None
198
+ if len(words) > MAX_WORDS:
199
+ raise TasteError(f"--words holds what the person said, up to {MAX_WORDS} characters")
200
+ return words.strip()
201
+
202
+
203
+ def clean_about(values: Iterable[str] | None) -> list[str]:
204
+ phrases: list[str] = []
205
+ for raw in values or ():
206
+ phrase = " ".join(str(raw).split()).casefold()
207
+ if not phrase:
208
+ continue
209
+ if len(phrase) > MAX_ABOUT_CHARS:
210
+ raise TasteError(f"--about takes short phrases of {MAX_ABOUT_CHARS} characters or fewer")
211
+ if phrase not in phrases:
212
+ phrases.append(phrase)
213
+ if len(phrases) > MAX_ABOUT:
214
+ raise TasteError(f"name at most {MAX_ABOUT} patterns with --about")
215
+ return phrases
216
+
217
+
218
+ def clean_field(value: str | None, name: str, *, label: str | None = None) -> str | None:
219
+ """A short plain description on one line. ``>`` is allowed (a CSS child
220
+ selector, "clarity > decoration"); ``<`` is not, since markup starts there."""
221
+
222
+ if value is None:
223
+ return None
224
+ text = " ".join(value.split())
225
+ if not text:
226
+ return None
227
+ field = label or f"--{name}"
228
+ if "<" in text:
229
+ raise TasteError(f"{field} cannot contain '<': describe the part in words, without markup")
230
+ if len(text) > MAX_FIELD:
231
+ raise TasteError(f"{field} is {len(text)} characters; keep it to {MAX_FIELD} or fewer")
232
+ return text
233
+
234
+
235
+ def _optional_text(value: Any) -> bool:
236
+ return value is None or isinstance(value, str)
237
+
238
+
239
+ def _reference(value: Any) -> bool:
240
+ return isinstance(value, dict) and isinstance(value.get("ref"), str) and _optional_text(value.get("ref_kind"))
241
+
242
+
243
+ def valid_event(value: Any) -> bool:
244
+ """One taste event whose every field the reader uses has the type it
245
+ expects: JSON that parses but carries a list where a word belongs is
246
+ skipped like a torn file, never left to break a read."""
247
+
248
+ if not (isinstance(value, dict) and value.get("schema") == TASTE_SCHEMA):
249
+ return False
250
+ actor, about = value.get("actor"), value.get("about")
251
+ if not (
252
+ isinstance(value.get("id"), str)
253
+ and isinstance(value.get("kind"), str)
254
+ and value["kind"] in EVENT_KINDS
255
+ and isinstance(actor, dict)
256
+ and isinstance(actor.get("id"), str)
257
+ and _optional_text(actor.get("name"))
258
+ and _reference(value.get("target"))
259
+ and (value.get("over") is None or _reference(value["over"]))
260
+ and (about is None or (isinstance(about, list) and all(isinstance(phrase, str) for phrase in about)))
261
+ and all(_optional_text(value.get(field)) for field in TEXT_FIELDS)
262
+ and isinstance(value.get("ts"), str)
263
+ ):
264
+ return False
265
+ try:
266
+ _parse_ts(value["ts"])
267
+ except (ValueError, OverflowError, OSError):
268
+ return False
269
+ return True
270
+
271
+
272
+ @dataclass(frozen=True, slots=True)
273
+ class EventFile:
274
+ """An event file as its name describes it; nothing has been read yet."""
275
+
276
+ path: Path
277
+ relative: str
278
+ time: str
279
+ kind: str
280
+ id: str
281
+
282
+
283
+ @dataclass(frozen=True, slots=True)
284
+ class TasteReceipt:
285
+ event: Mapping[str, Any]
286
+ duplicate: bool
287
+ writeback: CanonicalFilesWritebackEvent
288
+
289
+ @property
290
+ def recorded(self) -> bool:
291
+ """The event is committed to canonical memory on this machine."""
292
+ return (
293
+ self.writeback.status is not WritebackStatus.REJECTED
294
+ and self.writeback.git_revision is not None
295
+ )
296
+
297
+ def to_dict(self) -> dict[str, Any]:
298
+ return {
299
+ "event": dict(self.event),
300
+ "duplicate": self.duplicate,
301
+ "writeback": self.writeback.to_dict(),
302
+ }
303
+
304
+
305
+ class TasteService:
306
+ """Record taste events through the Runtime and read them back as words."""
307
+
308
+ def __init__(
309
+ self,
310
+ runtime,
311
+ *,
312
+ clock: Callable[[], datetime] | None = None,
313
+ id_factory: Callable[[], str] | None = None,
314
+ environ: Mapping[str, str] | None = None,
315
+ warn: Callable[[str], None] | None = None,
316
+ ) -> None:
317
+ if runtime.canonical_root is None:
318
+ raise RuntimeError("taste needs canonical memory")
319
+ self.runtime = runtime
320
+ self.memory_root = Path(runtime.canonical_root).resolve()
321
+ # Local file targets are stored relative to this checkout.
322
+ instance = getattr(runtime, "instance_root", None)
323
+ self.repository_root = Path(instance or Path(runtime.canonical_root).parent).resolve()
324
+ self.clock = clock or (lambda: datetime.now(UTC))
325
+ self.id_factory = id_factory or (lambda: str(uuid.uuid4()))
326
+ self.environ = environ
327
+ self.warn = warn or (lambda _line: None)
328
+
329
+ # --- paths and authorization ------------------------------------------
330
+
331
+ @staticmethod
332
+ def event_path(actor: ActorContext, event: Mapping[str, Any]) -> str:
333
+ """One file per event, e.g. ``taste/2026-09/actor_ada/20260924T100000Z-liked-<id>.json``."""
334
+
335
+ moment = _parse_ts(event["ts"])
336
+ return (
337
+ f"taste/{moment:%Y-%m}/{actor_folder(actor.actor.actor_id)}/"
338
+ f"{moment:%Y%m%dT%H%M%SZ}-{event['kind']}-{event['id']}.json"
339
+ )
340
+
341
+ def _require(self, actor: ActorContext, permission: Permission, resource: str, path: str) -> PolicyDecision:
342
+ decision = self.runtime.authorize(actor, permission, (resource,))
343
+ if not decision.allowed:
344
+ raise AuthorizationDenied("; ".join(decision.reasons) or f"taste {permission.value} denied")
345
+ if not allows(decision, path, action_for(decision.permission), root=self.memory_root):
346
+ raise AuthorizationDenied("taste path is outside the actor's authorized scope")
347
+ return decision
348
+
349
+ def files(self, actor: ActorContext, name: str = "*.json") -> list[EventFile]:
350
+ """Event files whose names match the glob ``name``, oldest first,
351
+ described by their names alone: no file is opened."""
352
+
353
+ decision = self._require(actor, Permission.READ, "taste", "memory/taste")
354
+ root = self.memory_root / "taste"
355
+ found: list[EventFile] = []
356
+ for path in sorted(root.glob(f"*/*/{name}")) if root.is_dir() else ():
357
+ relative = path.relative_to(self.memory_root).as_posix()
358
+ if not allows(decision, f"memory/{relative}", "read", root=self.memory_root):
359
+ continue # each event file passes the predicate, not only its folder
360
+ match = _EVENT_NAME.fullmatch(path.name)
361
+ if match is None:
362
+ self.warn(f"skipped memory/{relative}: not one taste event")
363
+ continue
364
+ found.append(EventFile(path, relative, *match.group("time", "kind", "id")))
365
+ return sorted(found, key=lambda file: (file.time, file.relative))
366
+
367
+ def read(self, files: Iterable[EventFile]) -> list[tuple[str, dict[str, Any]]]:
368
+ """The events in ``files``, one per id, each with the file it came from.
369
+
370
+ A file is skipped with one warning line when it does not hold one
371
+ valid event of the kind and id its name gives. Copies of one reaction
372
+ under an id (a retry, or the same event from two machines) count once
373
+ silently; different reactions under one id keep the earliest, with a
374
+ warning naming both files.
375
+ """
376
+
377
+ rows: list[tuple[str, dict[str, Any]]] = []
378
+ for file in files:
379
+ # Read a file only at its own path: a symbolic link to it or to a
380
+ # directory above it would read files outside memory/taste.
381
+ if file.path.resolve() != file.path or not file.path.is_file():
382
+ continue
383
+ try:
384
+ if file.path.stat().st_size > MAX_EVENT_BYTES:
385
+ raise ValueError("too large")
386
+ value = json.loads(file.path.read_text(encoding="utf-8"))
387
+ except (OSError, ValueError, RecursionError):
388
+ value = None
389
+ if valid_event(value) and (value["kind"], value["id"]) == (file.kind, file.id):
390
+ rows.append((file.relative, value))
391
+ else:
392
+ self.warn(f"skipped memory/{file.relative}: not one taste event")
393
+ kept: dict[str, tuple[str, dict[str, Any]]] = {}
394
+ for source, event in sorted(rows, key=lambda row: (row[1]["ts"], row[0])):
395
+ first_source, first = kept.setdefault(event["id"], (source, event))
396
+ if first_source != source and (
397
+ first["actor"]["id"] != event["actor"]["id"] or _reaction(first) != _reaction(event)
398
+ ):
399
+ self.warn(
400
+ f"memory/{first_source} and memory/{source} hold different reactions under "
401
+ f"event {event['id']}; counting memory/{first_source}"
402
+ )
403
+ return list(kept.values())
404
+
405
+ def events(self, actor: ActorContext) -> list[tuple[str, dict[str, Any]]]:
406
+ """The events ``taste`` reads back: every adopted default and every
407
+ retraction, and the latest ``MAX_EVENT_FILES`` reactions. Every id
408
+ more than one file names is resolved first, reading each copy, so the
409
+ bound decides only which words are shown, never which copy counts."""
410
+
411
+ copies: dict[str, list[EventFile]] = {}
412
+ for file in self.files(actor):
413
+ copies.setdefault(file.id, []).append(file)
414
+ shared = [file for files in copies.values() if len(files) > 1 for file in files]
415
+ named = {file.relative: file for file in shared}
416
+ # One entry per event: the file that counts, and its event once read.
417
+ entries: list[tuple[EventFile, dict[str, Any] | None]] = [
418
+ (named[source], event) for source, event in self.read(shared)
419
+ ] + [(files[0], None) for files in copies.values() if len(files) == 1]
420
+ reactions = sorted(
421
+ (file for file, _ in entries if file.kind not in LIFECYCLE_KINDS),
422
+ key=lambda file: (file.time, file.relative),
423
+ )
424
+ if len(reactions) > MAX_EVENT_FILES:
425
+ self.warn(f"read the latest {MAX_EVENT_FILES} of {len(reactions)} reaction files")
426
+ hidden = set(reactions[:-MAX_EVENT_FILES])
427
+ entries = [(file, event) for file, event in entries if file not in hidden]
428
+ rows = [(file.relative, event) for file, event in entries if event is not None]
429
+ rows += self.read([file for file, event in entries if event is None])
430
+ return sorted(rows, key=lambda row: (row[1]["ts"], row[0]))
431
+
432
+ def event(self, actor: ActorContext, event_id: str) -> tuple[str, dict[str, Any]] | None:
433
+ """The event with this id, found by file name among every file."""
434
+
435
+ try:
436
+ name = f"*-{uuid.UUID(event_id)}.json"
437
+ except ValueError:
438
+ return None
439
+ rows = self.read(self.files(actor, name))
440
+ return rows[0] if rows else None
441
+
442
+ # --- recording ---------------------------------------------------------
443
+
444
+ def _event(self, actor: ActorContext, kind: str, event_id: str | None) -> dict[str, Any]:
445
+ identifier = (event_id or "").strip() or self.id_factory()
446
+ try:
447
+ identifier = str(uuid.UUID(identifier)) # one spelling, as file names carry it
448
+ except ValueError as exc:
449
+ raise TasteError("event id must be a UUID") from exc
450
+ return {
451
+ "schema": TASTE_SCHEMA,
452
+ "id": identifier,
453
+ "ts": _iso(self.clock()),
454
+ "actor": {"id": actor.actor.actor_id, "name": actor.actor.display_name},
455
+ "kind": kind,
456
+ "runtime": runtime_name(actor.harness, self.environ),
457
+ }
458
+
459
+ def _target(self, ref: str) -> dict[str, str]:
460
+ kind = target_kind(ref)
461
+ if kind == "path":
462
+ ref = shareable_path(ref, repository_root=self.repository_root, cwd=Path.cwd())
463
+ return {"ref": ref, "ref_kind": kind}
464
+
465
+ @staticmethod
466
+ def _context(event: dict[str, Any], **fields: str | None) -> None:
467
+ for name, value in fields.items():
468
+ text = clean_field(value, name)
469
+ if text is not None:
470
+ event[name] = text
471
+
472
+ def _record(self, actor: ActorContext, event: dict[str, Any], *, caller_id: bool) -> TasteReceipt:
473
+ path = self.event_path(actor, event)
474
+ self._require(actor, Permission.WRITE, f"taste:{actor.actor.actor_id}", f"memory/{path}")
475
+ if caller_id:
476
+ # A retry reuses the id its first attempt printed. That attempt may
477
+ # have written its file without committing it, possibly in an
478
+ # earlier month, so the retry commits the file holding the id: the
479
+ # event counts once. A different reaction under that id is refused.
480
+ found = self.event(actor, event["id"])
481
+ if found is not None:
482
+ source, stored = found
483
+ if stored["actor"]["id"] != actor.actor.actor_id:
484
+ raise TasteError("that event id belongs to someone else's event")
485
+ if _reaction(stored) != _reaction(event):
486
+ raise ArtifactConflictError(
487
+ f"event {event['id']} is already recorded as a different reaction "
488
+ f"(memory/{source}); record this one without --id"
489
+ )
490
+ self._require(actor, Permission.WRITE, f"taste:{actor.actor.actor_id}", f"memory/{source}")
491
+ return TasteReceipt(stored, True, self._create(actor, source, stored))
492
+ return TasteReceipt(event, False, self._create(actor, path, event))
493
+
494
+ def _create(self, actor: ActorContext, path: str, event: Mapping[str, Any]) -> CanonicalFilesWritebackEvent:
495
+ # The Runtime's own writeback, as every canonical write takes it.
496
+ return self.runtime._writeback(actor).create_file(
497
+ actor, f"memory/{path}", _serialize(event),
498
+ memory_root=self.memory_root, message=_COMMIT_MESSAGES[event["kind"]],
499
+ )
500
+
501
+ def react(
502
+ self,
503
+ actor: ActorContext,
504
+ kind: str,
505
+ target: str,
506
+ *,
507
+ words: str | None,
508
+ about: Iterable[str] = (),
509
+ region: str | None = None,
510
+ task: str | None = None,
511
+ audience: str | None = None,
512
+ form: str | None = None,
513
+ event_id: str | None = None,
514
+ ) -> TasteReceipt:
515
+ if kind not in {"liked", "disliked"}:
516
+ raise TasteError("a reaction is liked or disliked")
517
+ ref = clean_target(target)
518
+ event = self._event(actor, kind, event_id)
519
+ event["target"] = self._target(ref)
520
+ event["words"] = clean_words(words, required=True)
521
+ event["about"] = clean_about(about)
522
+ self._context(event, region=region, task=task, audience=audience, form=form)
523
+ return self._record(actor, event, caller_id=bool(event_id))
524
+
525
+ def choose(
526
+ self,
527
+ actor: ActorContext,
528
+ winner: str,
529
+ loser: str,
530
+ *,
531
+ words: str | None = None,
532
+ about: Iterable[str] = (),
533
+ task: str | None = None,
534
+ audience: str | None = None,
535
+ form: str | None = None,
536
+ event_id: str | None = None,
537
+ ) -> TasteReceipt:
538
+ chosen = clean_target(winner, label="chosen interface")
539
+ other = clean_target(loser, label="--over")
540
+ if chosen == other:
541
+ raise TasteError("a choice needs two different interfaces")
542
+ event = self._event(actor, "chose", event_id)
543
+ event["target"] = self._target(chosen)
544
+ event["over"] = self._target(other)
545
+ text = clean_words(words, required=False)
546
+ if text is not None:
547
+ event["words"] = text
548
+ event["about"] = clean_about(about)
549
+ self._context(event, task=task, audience=audience, form=form)
550
+ return self._record(actor, event, caller_id=bool(event_id))
551
+
552
+ def adopt(
553
+ self,
554
+ actor: ActorContext,
555
+ adopt_kind: str,
556
+ value: str,
557
+ *,
558
+ scope: str = "person",
559
+ words: str | None = None,
560
+ event_id: str | None = None,
561
+ ) -> TasteReceipt:
562
+ if adopt_kind not in ADOPT_KINDS:
563
+ raise TasteError("adopt a palette, pattern or principle")
564
+ if scope not in ADOPT_SCOPES:
565
+ raise TasteError("--scope is person or org")
566
+ chosen = clean_field(value, adopt_kind, label=f"the {adopt_kind}")
567
+ if chosen is None:
568
+ raise TasteError(f"name the {adopt_kind} to adopt")
569
+ event = self._event(actor, "adopted", event_id)
570
+ event["target"] = {"ref": chosen, "ref_kind": adopt_kind}
571
+ event["scope"] = scope
572
+ text = clean_words(words, required=False)
573
+ if text is not None:
574
+ event["words"] = text
575
+ return self._record(actor, event, caller_id=bool(event_id))
576
+
577
+ def retract(
578
+ self,
579
+ actor: ActorContext,
580
+ target_id: str,
581
+ *,
582
+ words: str | None = None,
583
+ event_id: str | None = None,
584
+ ) -> TasteReceipt:
585
+ found = self.event(actor, (target_id or "").strip())
586
+ if found is None:
587
+ raise TasteError("no taste event has that id")
588
+ original = found[1]
589
+ wanted = original["id"]
590
+ if original["actor"]["id"] != actor.actor.actor_id:
591
+ raise TasteError("only the person who recorded an event can retract it")
592
+ if original["kind"] == "retracted":
593
+ raise TasteError("a retraction cannot be retracted; record the reaction again")
594
+ event = self._event(actor, "retracted", event_id)
595
+ # A retraction counts as its id resolves across every file sharing
596
+ # it: a later file reusing an earlier event's id retracts nothing.
597
+ files = self.files(actor)
598
+ retractions = {file.id for file in files if file.kind == "retracted"}
599
+ if any(
600
+ row["kind"] == "retracted"
601
+ and row["target"].get("ref") == wanted
602
+ and row["actor"]["id"] == actor.actor.actor_id
603
+ and row["id"] != event["id"]
604
+ for _, row in self.read([file for file in files if file.id in retractions])
605
+ ):
606
+ raise TasteError("that event is already retracted")
607
+ event["target"] = {"ref": wanted, "ref_kind": "event"}
608
+ text = clean_words(words, required=False)
609
+ if text is not None:
610
+ event["words"] = text
611
+ return self._record(actor, event, caller_id=bool(event_id))
612
+
613
+ # --- reading back ------------------------------------------------------
614
+
615
+ def summary(
616
+ self,
617
+ actor: ActorContext,
618
+ *,
619
+ person: str | None = None,
620
+ task: str | None = None,
621
+ form: str | None = None,
622
+ ) -> dict[str, Any]:
623
+ return summarize(
624
+ [row for _, row in self.events(actor)], viewer=actor, person=person, task=task, form=form,
625
+ )
626
+
627
+
628
+ def _date(event: Mapping[str, Any]) -> str:
629
+ return str(event["ts"])[:10]
630
+
631
+
632
+ def _quote(words: Any) -> str | None:
633
+ if not isinstance(words, str) or not words.strip():
634
+ return None
635
+ text = " ".join(words.split())
636
+ return text if len(text) <= QUOTE_CHARS else text[: QUOTE_CHARS - 1].rstrip() + "…"
637
+
638
+
639
+ def _words_of(value: Any) -> set[str]:
640
+ return {word for word in re.findall(r"[a-z0-9]+", str(value or "").casefold()) if len(word) >= 4}
641
+
642
+
643
+ def _near(query: str | None, value: Any) -> bool:
644
+ if not query or not value:
645
+ return False
646
+ left, right = query.casefold().strip(), str(value).casefold().strip()
647
+ return bool(left and right and (left in right or right in left or _words_of(left) & _words_of(right)))
648
+
649
+
650
+ def _resolve_person(
651
+ events: list[Mapping[str, Any]], viewer: ActorContext, person: str | None
652
+ ) -> tuple[str | None, str]:
653
+ query = (person or "").strip()
654
+ if query.casefold() in {"", "me", "myself"}:
655
+ return viewer.actor.actor_id, viewer.actor.display_name
656
+ wanted = query.casefold()
657
+ viewer_names = {
658
+ viewer.actor.actor_id.casefold(),
659
+ viewer.actor.display_name.casefold(),
660
+ *(str(value).casefold() for value in viewer.actor.aliases.values()),
661
+ }
662
+ if wanted in viewer_names:
663
+ return viewer.actor.actor_id, viewer.actor.display_name
664
+ names: dict[str, str] = {}
665
+ for event in sorted(events, key=lambda row: row["ts"]):
666
+ actor_id = event["actor"]["id"]
667
+ name = str(event["actor"].get("name") or "")
668
+ if wanted in {actor_id.casefold(), name.casefold()} or _slug(query) == _slug(name):
669
+ names[actor_id] = name or actor_id
670
+ if len(names) > 1:
671
+ raise TasteError(f"more than one person matches {query!r}; pass their actor id")
672
+ if names:
673
+ actor_id, name = next(iter(names.items()))
674
+ return actor_id, name
675
+ return None, query
676
+
677
+
678
+ def _adopted_entry(event: Mapping[str, Any]) -> dict[str, Any]:
679
+ return {
680
+ "value": event["target"].get("ref"),
681
+ "date": _date(event),
682
+ "by": event["actor"].get("name") or event["actor"]["id"],
683
+ "words": _quote(event.get("words")),
684
+ "id": event["id"],
685
+ }
686
+
687
+
688
+ def _latest_adopted(events: Iterable[Mapping[str, Any]]) -> dict[str, dict[str, Any]]:
689
+ """One default per kind, the latest; principles accumulate as rules instead."""
690
+
691
+ latest: dict[str, dict[str, Any]] = {}
692
+ for event in sorted(events, key=lambda row: row["ts"]):
693
+ latest[str(event["target"].get("ref_kind"))] = _adopted_entry(event)
694
+ return {kind: latest[kind] for kind in DEFAULT_KINDS if kind in latest}
695
+
696
+
697
+ def _principles(events: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]:
698
+ """Every adopted principle is a standing rule, newest first; the same
699
+ wording adopted twice appears once, as its latest adoption."""
700
+
701
+ rules: dict[str, dict[str, Any]] = {}
702
+ for event in sorted(events, key=lambda row: row["ts"]):
703
+ if event["target"].get("ref_kind") == "principle":
704
+ rules.pop(_phrase_key(event["target"].get("ref")), None)
705
+ rules[_phrase_key(event["target"].get("ref"))] = _adopted_entry(event)
706
+ return list(reversed(rules.values()))[:HOUSE_RULES]
707
+
708
+
709
+ def _phrase_key(value: Any) -> str:
710
+ return " ".join(str(value or "").casefold().split())
711
+
712
+
713
+ def _emergent(events: Iterable[Mapping[str, Any]], kind: str) -> list[dict[str, Any]]:
714
+ """What the whole organization keeps naming when it likes or dislikes a
715
+ page: a pattern counts once it was named on two or more different pages.
716
+ Computed on every read; nothing is written or approved."""
717
+
718
+ stats: dict[str, dict[str, Any]] = {}
719
+ for event in sorted((row for row in events if row["kind"] == kind), key=lambda row: row["ts"]):
720
+ for phrase in event.get("about") or []:
721
+ key = _phrase_key(phrase)
722
+ if not key or key == WHOLE_THING:
723
+ continue
724
+ entry = stats.setdefault(key, {"pattern": phrase, "pages": set(), "people": set(), "last": "", "words": None})
725
+ entry["pattern"] = phrase
726
+ # Two sections of one page (paper#intro, paper#results) are one page.
727
+ entry["pages"].add(str(event["target"].get("ref")).split("#", 1)[0])
728
+ entry["people"].add(event["actor"]["id"])
729
+ entry["last"] = event["ts"]
730
+ entry["words"] = _quote(event.get("words")) or entry["words"]
731
+ held = [entry for entry in stats.values() if len(entry["pages"]) >= EMERGENT_PAGES]
732
+ held.sort(key=lambda entry: _phrase_key(entry["pattern"]))
733
+ held.sort(key=lambda entry: entry["last"], reverse=True)
734
+ held.sort(key=lambda entry: (len(entry["pages"]), len(entry["people"])), reverse=True)
735
+ return [
736
+ {
737
+ "pattern": entry["pattern"],
738
+ "pages": len(entry["pages"]),
739
+ "people": len(entry["people"]),
740
+ "last": entry["last"][:10],
741
+ "words": entry["words"],
742
+ }
743
+ for entry in held[:TOP_PATTERNS]
744
+ ]
745
+
746
+
747
+ def summarize(
748
+ events: Iterable[Mapping[str, Any]],
749
+ *,
750
+ viewer: ActorContext,
751
+ person: str | None = None,
752
+ task: str | None = None,
753
+ form: str | None = None,
754
+ ) -> dict[str, Any]:
755
+ """Plain counts and the person's own words, bounded, with retractions honoured."""
756
+
757
+ unique: dict[str, Mapping[str, Any]] = {}
758
+ for event in sorted((row for row in events if valid_event(row)), key=lambda row: row["ts"]):
759
+ unique.setdefault(event["id"], event)
760
+ rows = list(unique.values())
761
+ retracted: set[str] = set()
762
+ for event in rows:
763
+ if event["kind"] != "retracted":
764
+ continue
765
+ original = unique.get(str(event["target"].get("ref")))
766
+ if original and original["kind"] != "retracted" and original["actor"]["id"] == event["actor"]["id"]:
767
+ retracted.add(original["id"])
768
+ active = [row for row in rows if row["kind"] != "retracted" and row["id"] not in retracted]
769
+
770
+ person_id, name = _resolve_person(rows, viewer, person)
771
+ own = [row for row in active if person_id is not None and row["actor"]["id"] == person_id]
772
+ if own:
773
+ name = str(own[-1]["actor"].get("name") or name)
774
+
775
+ def ranked(kind: str) -> list[dict[str, Any]]:
776
+ stats: dict[str, dict[str, Any]] = {}
777
+ for event in (row for row in own if row["kind"] == kind):
778
+ for phrase in event.get("about") or [WHOLE_THING]:
779
+ entry = stats.setdefault(phrase, {"pattern": phrase, "count": 0, "last": "", "words": None})
780
+ entry["count"] += 1
781
+ entry["last"] = event["ts"]
782
+ entry["words"] = _quote(event.get("words")) or entry["words"]
783
+ ordered = sorted(stats.values(), key=lambda entry: entry["pattern"])
784
+ ordered.sort(key=lambda entry: entry["last"], reverse=True)
785
+ ordered.sort(key=lambda entry: entry["count"], reverse=True)
786
+ return [{**entry, "last": entry["last"][:10]} for entry in ordered[:TOP_PATTERNS]]
787
+
788
+ def relevant(event: Mapping[str, Any]) -> bool:
789
+ return _near(task, event.get("task")) or _near(form, event.get("form"))
790
+
791
+ choices = sorted((row for row in own if row["kind"] == "chose"), key=lambda row: row["ts"], reverse=True)
792
+ spoken = [row for row in own if row["kind"] in {"liked", "disliked", "chose"} and _quote(row.get("words"))]
793
+ spoken.sort(key=lambda row: row["ts"], reverse=True)
794
+ spoken.sort(key=relevant, reverse=True)
795
+ reactions = [row for row in own if row["kind"] in {"liked", "disliked", "chose"}]
796
+ dates = sorted(_date(row) for row in reactions)
797
+ return {
798
+ "schema": SUMMARY_SCHEMA,
799
+ "person": {"id": person_id, "name": name},
800
+ "counts": {kind: sum(1 for row in own if row["kind"] == kind) for kind in ("liked", "disliked", "chose")},
801
+ "first": dates[0] if dates else None,
802
+ "last": dates[-1] if dates else None,
803
+ "task": task,
804
+ "form": form,
805
+ "matching": sum(1 for row in reactions if relevant(row)) if (task or form) else None,
806
+ "liked": ranked("liked"),
807
+ "disliked": ranked("disliked"),
808
+ "chose": [
809
+ {
810
+ "date": _date(row),
811
+ "chose": row["target"].get("ref"),
812
+ "over": (row.get("over") or {}).get("ref"),
813
+ "words": _quote(row.get("words")),
814
+ "id": row["id"],
815
+ }
816
+ for row in choices[:LAST_CHOICES]
817
+ ],
818
+ "rules": {
819
+ "egregore": [dict(rule) for rule in EGREGORE_RULES],
820
+ "org": _principles(row for row in active if row["kind"] == "adopted" and row.get("scope") == "org"),
821
+ "person": _principles(row for row in own if row["kind"] == "adopted" and row.get("scope") == "person"),
822
+ },
823
+ "emergent": {
824
+ "liked": _emergent(active, "liked"),
825
+ "disliked": _emergent(active, "disliked"),
826
+ },
827
+ "adopted": {
828
+ "person": _latest_adopted(
829
+ row for row in own if row["kind"] == "adopted" and row.get("scope") == "person"
830
+ ),
831
+ "org": _latest_adopted(
832
+ row for row in active if row["kind"] == "adopted" and row.get("scope") == "org"
833
+ ),
834
+ },
835
+ "quotes": [
836
+ {
837
+ "date": _date(row),
838
+ "kind": row["kind"],
839
+ "target": row["target"].get("ref"),
840
+ "task": row.get("task"),
841
+ "form": row.get("form"),
842
+ "words": _quote(row.get("words")),
843
+ "id": row["id"],
844
+ }
845
+ for row in spoken[:LAST_QUOTES]
846
+ ],
847
+ "note": READING_NOTE,
848
+ }
849
+
850
+
851
+ def render_text(summary: Mapping[str, Any]) -> str:
852
+ """Words only: what the person liked and disliked, their choices and defaults."""
853
+
854
+ name = summary["person"]["name"]
855
+ counts = summary["counts"]
856
+ total = sum(counts.values())
857
+ header = f"Taste for {name}"
858
+ if total:
859
+ header += (
860
+ f" · {counts['liked']} liked · {counts['disliked']} disliked · {counts['chose']} chosen"
861
+ f" · {summary['first']} to {summary['last']}"
862
+ )
863
+ lines = [header, READING_NOTE, LAYER_NOTE]
864
+ focus = " / ".join(value for value in (summary.get("task"), summary.get("form")) if value)
865
+ if focus and total:
866
+ lines.append(
867
+ f"{summary['matching']} of {total} reactions were about work like “{focus}”; "
868
+ "their words come first below."
869
+ )
870
+
871
+ def said(words: str | None) -> str:
872
+ return f" · “{words}”" if words else ""
873
+
874
+ rules = summary["rules"]
875
+ lines += ["", "Egregore rules (every organization)"]
876
+ lines += [
877
+ f"- {rule['rule']}"
878
+ + ("" if rule["skip"] == "Never." else f" Skip it for {rule['skip'][:1].lower()}{rule['skip'][1:]}")
879
+ for rule in rules["egregore"]
880
+ ]
881
+ if rules["org"]:
882
+ lines += ["", "House rules (this organization)"]
883
+ lines += [f"- {rule['value']} · {rule['date']} · by {rule['by']}{said(rule['words'])}" for rule in rules["org"]]
884
+ org_defaults = summary["adopted"]["org"]
885
+ if org_defaults:
886
+ lines += ["", "Organization defaults"]
887
+ lines += [
888
+ f"- {kind}: {entry['value']} · {entry['date']} · by {entry['by']}{said(entry['words'])}"
889
+ for kind, entry in org_defaults.items()
890
+ ]
891
+ for key, title in (("liked", "Your organization tends to like"), ("disliked", "Your organization tends to avoid")):
892
+ if summary["emergent"][key]:
893
+ lines += ["", title]
894
+ lines += [
895
+ f"- {entry['pattern']} · {entry['pages']} pages · {entry['people']} "
896
+ + ("person" if entry["people"] == 1 else "people")
897
+ + f" · last {entry['last']}{said(entry['words'])}"
898
+ for entry in summary["emergent"][key]
899
+ ]
900
+ if rules["person"]:
901
+ lines += ["", f"Rules {name} adopted"]
902
+ lines += [f"- {rule['value']} · {rule['date']}{said(rule['words'])}" for rule in rules["person"]]
903
+ if summary["adopted"]["person"]:
904
+ lines += ["", f"Defaults {name} adopted"]
905
+ lines += [
906
+ f"- {kind}: {entry['value']} · {entry['date']}{said(entry['words'])}"
907
+ for kind, entry in summary["adopted"]["person"].items()
908
+ ]
909
+
910
+ for key, title in (("liked", f"{name} liked most"), ("disliked", f"{name} disliked most")):
911
+ if summary[key]:
912
+ lines += ["", title]
913
+ lines += [
914
+ f"- {entry['pattern']} · {entry['count']}× · last {entry['last']}{said(entry['words'])}"
915
+ for entry in summary[key]
916
+ ]
917
+ if summary["chose"]:
918
+ lines += ["", "Chose"]
919
+ lines += [
920
+ f"- {entry['date']} · {entry['chose']} over {entry['over']}{said(entry['words'])}"
921
+ for entry in summary["chose"]
922
+ ]
923
+ if summary["quotes"]:
924
+ lines += ["", "Their words"]
925
+ for entry in summary["quotes"]:
926
+ about = ", ".join(value for value in (entry.get("task"), entry.get("form")) if value)
927
+ lines.append(
928
+ f"- {entry['date']} · {entry['kind']} {entry['target']}"
929
+ + (f" ({about})" if about else "")
930
+ + said(entry["words"])
931
+ )
932
+ if not total and not summary["adopted"]["person"] and not rules["person"]:
933
+ lines += ["", f"No recorded taste for {name} yet."]
934
+ return "\n".join(lines) + "\n"