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,946 @@
1
+ """Hand-overs to the map viewer: one immutable generation per publish, served only when complete.
2
+
3
+ A hub slot ``~/.egregore/mind/hub/<installation key>/`` holds one egregore's hand-over for the
4
+ person on this machine::
5
+
6
+ stamp.json which installation owns the slot; a foreign stamp is refused
7
+ require.json the holds and grant a served generation must carry
8
+ current.json names the generation to serve; renamed into place, the one swap
9
+ seen.json the source fingerprint and start time of the last publish, for the stale check
10
+ gen/<rev>/ manifest.json, needs.json, memory/ and vectors.f32, never changed once written; two are kept
11
+
12
+ The manifest carries everything else the map's exporter reads (export.py --projection), so it
13
+ reads nothing from a checkout: the config slice, people, the person, and each file's id, sha,
14
+ source mtime (kept on the copy too), history dates and row in ``vectors.f32``. A file has a
15
+ search vector only when redaction left its bytes as read; export imputes the rest.
16
+
17
+ A withdrawal fails closed. When the holds or the grant change, ``require.json`` is renamed into
18
+ place before the build, so a killed or failed build leaves the slot dark, never on the previous
19
+ generation. ``admit`` is every reader's gate and re-reads both files each time.
20
+
21
+ A non-admin copy is redacted by ``held.Redactor``, the one redaction Runtime reads use too: it
22
+ loses the paths, file names, distinctive slugs, artifact ids and long titles of every document the
23
+ hand-over holds.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from contextlib import contextmanager
29
+ from dataclasses import dataclass, replace
30
+ import fcntl
31
+ import hashlib
32
+ import json
33
+ import os
34
+ from pathlib import Path
35
+ import re
36
+ import secrets
37
+ import shutil
38
+ import stat
39
+ import struct
40
+ import subprocess
41
+ import sys
42
+ import time
43
+ from typing import Any, Iterator, Mapping
44
+
45
+ from . import held
46
+ from .optional_modules import optional_module
47
+ from .access import ROOT_MARKER, AuthorizationInputs, _git, authorization_inputs, committed_memory, projection_root
48
+ from .adapters.qmd import read_document_vectors, vectors_receipt
49
+ from .artifacts import artifact_id_from_path
50
+ from .contracts import ActorContext, Permission, PolicyDecision
51
+ from .errors import EgregoreRuntimeError
52
+ from .installation import installation_key, installation_root
53
+ from .policy import bytes_held, check, committed_settings, is_admin, policy_for
54
+
55
+ VERSION = "egregore-projection/2"
56
+ REDACTION_VERSION = "projection-redaction/3"
57
+ DIMENSION = 256 # export.py's default --dimension
58
+ REQUIRE_FIELDS = ("holds_epoch", "held_digest", "grant_digest")
59
+ ORPHAN_SECONDS = 30 * 86400
60
+ JSON_CAP, MANIFEST_CAP, FILE_CAP = 1 << 16, 1 << 27, 1 << 27
61
+ _REV = re.compile(r"[0-9a-f]{24}")
62
+
63
+
64
+ class ProjectionError(EgregoreRuntimeError):
65
+ pass
66
+
67
+
68
+ def hub_root() -> Path:
69
+ return Path.home() / ".egregore" / "mind" / "hub"
70
+
71
+
72
+ def _digest(value: Any) -> str:
73
+ return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
74
+
75
+
76
+ def grant_digest(decision: PolicyDecision) -> str:
77
+ """Who holds the grant, whether it stands, its audience, policy and every action's prefixes."""
78
+ return _digest(["grant/1", decision.allowed, decision.actor_id, decision.org_id, decision.audience,
79
+ decision.scope, decision.policy_epoch, sorted(decision.read_prefixes),
80
+ sorted(decision.append_prefixes), sorted(decision.write_prefixes)])
81
+
82
+
83
+ def operator(root: Path, *, harness: str = "mind", session_id: str = "mind-publish") -> ActorContext:
84
+ from .identity import LocalIdentityResolver
85
+ return LocalIdentityResolver(root).resolve(session_id=session_id, harness=harness)
86
+
87
+
88
+ def hand_over(root: Path, actor: ActorContext) -> PolicyDecision:
89
+ """The operator's read decision in the hand-over scope; nothing is handed over from a read scope."""
90
+ decision = policy_for(authorization_inputs(root)).authorize_action(actor, Permission.READ)
91
+ return replace(decision, scope="hand-over")
92
+
93
+
94
+ # ---- sources -------------------------------------------------------------------------------
95
+
96
+ @dataclass(frozen=True, slots=True)
97
+ class _Source:
98
+ canonical: str
99
+ data: bytes
100
+ sha: str
101
+ mtime_ns: int
102
+ held: int | None
103
+ shown: bool
104
+ title: str
105
+ artifact_id: str
106
+ aliases: tuple[str, ...] # every declared id, slug or session id
107
+
108
+
109
+ def _raise(error: OSError) -> None:
110
+ raise error
111
+
112
+
113
+ def _walk(memory: Path) -> Iterator[tuple[str, Path, os.stat_result]]:
114
+ """Regular Markdown files; a link to a file or a folder is skipped, never followed."""
115
+ for folder, folders, files in os.walk(memory, onerror=_raise):
116
+ folders[:] = sorted(name for name in folders if name != ".git")
117
+ for name in sorted(files):
118
+ path = Path(folder, name)
119
+ info = os.lstat(path)
120
+ if name.lower().endswith(".md") and stat.S_ISREG(info.st_mode): # as export's inventory
121
+ yield "memory/" + path.relative_to(memory).as_posix(), path, info
122
+
123
+
124
+ def _read(path: Path, cap: int = FILE_CAP, *, private: bool = False) -> bytes:
125
+ descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
126
+ try:
127
+ info = os.fstat(descriptor)
128
+ if not stat.S_ISREG(info.st_mode) or info.st_size > cap:
129
+ raise OSError(f"not a regular file within the size cap: {path.name}")
130
+ if private and (info.st_uid != os.getuid() or info.st_nlink != 1 or info.st_mode & 0o077):
131
+ raise OSError(f"not a private, single-link file: {path.name}")
132
+ chunks = []
133
+ while chunk := os.read(descriptor, 1 << 20):
134
+ chunks.append(chunk)
135
+ return b"".join(chunks)
136
+ finally:
137
+ os.close(descriptor)
138
+
139
+
140
+ def fingerprint(root: Path, found: list | None = None) -> str:
141
+ """What a publish reads, by stat only: the stale check, cheap enough for every Runtime call."""
142
+ from . import needs
143
+ found = list(_walk((root / "memory").resolve())) if found is None else found
144
+ rows: list[Any] = [VERSION, REDACTION_VERSION, held.HELD_RULES_VERSION, needs.VERSION,
145
+ time.strftime("%Y-%m-%d", time.gmtime())] # needs.json windows by day
146
+ rows += [(c, i.st_mtime_ns, i.st_ctime_ns, i.st_size, i.st_ino) for c, _, i in found]
147
+ # A commit moves the history dates and a finished vector build the vectors, files unchanged.
148
+ marks = [root / "egregore.json", root / ".egregore-state.json", root / needs.BOARD, vectors_receipt(root),
149
+ *_heads(root / "memory")]
150
+ for path in marks:
151
+ try:
152
+ info = os.stat(path)
153
+ rows.append((path.name, info.st_mtime_ns, info.st_ctime_ns, info.st_size, info.st_ino))
154
+ except OSError:
155
+ rows.append((path.name, None))
156
+ return _digest(rows)
157
+
158
+
159
+ def _heads(memory: Path) -> list[Path]:
160
+ """Where the memory history's head is kept, in a clone or a linked worktree: HEAD and its ref,
161
+ and the shallow list (history reads nothing from a shallow clone until it is deepened)."""
162
+ git = common = memory / ".git"
163
+ try:
164
+ if os.path.isfile(git): # a linked worktree: its own HEAD, the refs in the shared git dir
165
+ git = common = (memory / _read(git, JSON_CAP).decode().strip().removeprefix("gitdir:").strip()).resolve()
166
+ if os.path.isfile(git / "commondir"):
167
+ common = (git / _read(git / "commondir", JSON_CAP).decode().strip()).resolve()
168
+ head = _read(git / "HEAD", JSON_CAP).decode()
169
+ except (OSError, ValueError):
170
+ return [git / "HEAD"]
171
+ return [git / "HEAD", common / "packed-refs", common / "shallow",
172
+ *([common / head[5:].strip()] if head.startswith("ref: refs/") else [])]
173
+
174
+
175
+ # ---- build ---------------------------------------------------------------------------------
176
+
177
+ @dataclass(frozen=True, slots=True)
178
+ class _Scan:
179
+ decision: PolicyDecision
180
+ sources: tuple[_Source, ...]
181
+ redactor: held.Redactor | None # None for an admin, who sees held material
182
+ held_digest: str
183
+ revision: str # the redaction revision, half of every file's reuse key
184
+ root: Path
185
+
186
+ @property
187
+ def shown(self) -> list[_Source]:
188
+ return [source for source in self.sources if source.shown]
189
+
190
+ def require(self) -> dict[str, str]:
191
+ return {"holds_epoch": self.decision.holds_epoch, "held_digest": self.held_digest,
192
+ "grant_digest": grant_digest(self.decision)}
193
+
194
+
195
+ def _documents(decision: PolicyDecision, memory: Path) -> Iterator[tuple[str, bytes, int]]:
196
+ """(path, bytes, mtime) of each Markdown document a hand-over reads: as walked for the operator, as committed at
197
+ the decision's revision for anyone else, so no working-tree byte reaches them. A link is skipped, as the walk
198
+ skips one; a committed document's time is its revision's."""
199
+ if not decision.memory_revision:
200
+ yield from ((canonical, _read(path), info.st_mtime_ns) for canonical, path, info in _walk(memory))
201
+ return
202
+ committed = committed_memory(memory, decision.memory_revision)
203
+ names = sorted(name for name, (mode, _oid) in committed.entries.items()
204
+ if mode in {"100644", "100755"} and name.lower().endswith(".md"))
205
+ committed.prefetch(names)
206
+ when = int(_git(memory, "log", "-1", "--format=%ct", decision.memory_revision).decode().strip()) * 1_000_000_000
207
+ yield from (("memory/" + name, committed.read(name), when) for name in names)
208
+
209
+
210
+ def _scan(decision: PolicyDecision, root: Path) -> _Scan:
211
+ if decision.scope != "hand-over":
212
+ raise ProjectionError("a hand-over is built only from a hand-over decision")
213
+ memory = (root / "memory").resolve()
214
+ sources = []
215
+ for canonical, data, mtime_ns in _documents(decision, memory):
216
+ # Held by the bytes this generation would carry, under every class the hand-over applies, or by the
217
+ # file as checked now: a rewrite between the read and the check can never ship bytes held when read.
218
+ verdict = check(decision, canonical, "read", root=memory) if decision.allowed else None
219
+ number = (bytes_held(decision, canonical, data, root=memory) if decision.allowed and not is_admin(decision)
220
+ else next(iter(held.blob_classes(data)), None))
221
+ if number is None and verdict is not None and verdict.reason.startswith("held:"):
222
+ number = int(verdict.reason.removeprefix("held:"))
223
+ shown = verdict is not None and verdict.allowed and (number is None or is_admin(decision))
224
+ sources.append(_Source(canonical, data, hashlib.sha256(data).hexdigest(), mtime_ns,
225
+ number, shown, *held.describe(canonical, data)))
226
+ held_docs = [s for s in sources if s.held is not None]
227
+ held_digest = _digest([held.HELD_RULES_VERSION,
228
+ [(s.canonical, s.held, s.title, s.artifact_id, s.aliases) for s in held_docs]])
229
+ shown = [s for s in sources if s.shown]
230
+ redactor = None if is_admin(decision) else held.Redactor(
231
+ [(s.canonical, s.title, s.artifact_id, s.aliases) for s in held_docs],
232
+ [s.canonical for s in shown], [s.title for s in shown])
233
+ # Releases (access.release) arrive with the held rules and join this digest there.
234
+ revision = _digest([REDACTION_VERSION, decision.holds_epoch, held_digest, grant_digest(decision),
235
+ decision.audience, redactor.revision() if redactor else "admin"])
236
+ return _Scan(decision, tuple(sources), redactor, held_digest, revision, root)
237
+
238
+
239
+ def derive(scan: _Scan, source: _Source) -> bytes:
240
+ """A file's hand-over bytes, from its source: unchanged for an admin, redacted for anyone else."""
241
+ if scan.redactor is None:
242
+ return source.data
243
+ return scan.redactor.redact(source.data.decode("utf-8", "surrogateescape")).encode("utf-8", "surrogateescape")
244
+
245
+
246
+ def _mkdir(path: Path) -> None:
247
+ try:
248
+ os.mkdir(path, 0o700)
249
+ os.chmod(path, 0o700)
250
+ except FileExistsError:
251
+ pass
252
+ _private_dir(path)
253
+
254
+
255
+ def _write(path: Path, data: bytes, mtime_ns: int | None = None) -> None:
256
+ descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
257
+ try:
258
+ os.fchmod(descriptor, 0o600)
259
+ view = memoryview(data)
260
+ while view:
261
+ view = view[os.write(descriptor, view):]
262
+ finally:
263
+ os.close(descriptor)
264
+ if mtime_ns is not None:
265
+ os.utime(path, ns=(mtime_ns, mtime_ns), follow_symlinks=False)
266
+
267
+
268
+ def _write_json(path: Path, value: Mapping[str, Any]) -> None:
269
+ temporary = path.with_name(f".{path.name}.{os.getpid()}.{secrets.token_hex(4)}.tmp")
270
+ _write(temporary, json.dumps(value, sort_keys=True).encode())
271
+ os.replace(temporary, path)
272
+
273
+
274
+ @dataclass(frozen=True, slots=True)
275
+ class _Inputs:
276
+ """What the exporter reads besides the files: each shown file's history dates and search vector."""
277
+ versions: Mapping[str, list] # path -> [first ISO date, last ISO date, content-changing commits]
278
+ vectors: Mapping[str, list] # path -> the vector QMD holds for exactly the bytes scanned
279
+ fingerprint: str
280
+
281
+ def header(self) -> dict[str, Any]:
282
+ digest = hashlib.sha256()
283
+ for path in sorted(self.vectors):
284
+ digest.update(path.encode() + b"\0" + _pack(self.vectors[path]))
285
+ return {"dimension": DIMENSION, "fingerprint": self.fingerprint, "digest": digest.hexdigest()}
286
+
287
+
288
+ def _pack(vector: list) -> bytes:
289
+ return struct.pack(f"<{DIMENSION}f", *vector)
290
+
291
+
292
+ def _inputs(scan: _Scan, root: Path) -> _Inputs:
293
+ from . import history
294
+ shown = {source.canonical: source.sha for source in scan.shown}
295
+ memory = (root / "memory").resolve()
296
+ cache = Path.home() / ".egregore" / "runtime" / "history" / f"{_digest(str(memory))[:16]}.json"
297
+ try: # optional: without an index export falls back to its own representation
298
+ read = read_document_vectors(root, sorted(shown), DIMENSION, hashes=shown)
299
+ except (OSError, ValueError):
300
+ read = {"vectors": {}, "fingerprint": ""}
301
+ return _Inputs(history.document_dates(memory, sorted(shown), cache), read["vectors"], read["fingerprint"])
302
+
303
+
304
+ def _materialize(scan: _Scan, dest: Path, previous: Admission | None, inputs: _Inputs) -> list[dict[str, Any]]:
305
+ """Write ``dest/memory``: a file whose (source sha256, redaction revision) matches the previous
306
+ generation is copied from it, every other file is derived again from its source."""
307
+ old = {}
308
+ if previous and previous.manifest.get("redaction_revision") == scan.revision:
309
+ old = {entry["path"]: entry for entry in previous.manifest["files"]}
310
+ _mkdir(dest / "memory")
311
+ made, entries = {dest, dest / "memory"}, []
312
+ for source in scan.shown:
313
+ target = folder = dest
314
+ for part in source.canonical.split("/"):
315
+ folder, target = target, target / part
316
+ if folder not in made:
317
+ _mkdir(folder)
318
+ made.add(folder)
319
+ data, entry = None, old.get(source.canonical)
320
+ if entry and entry["sha"] == source.sha:
321
+ try:
322
+ data = _read(previous.generation / source.canonical, private=True)
323
+ data = data if hashlib.sha256(data).hexdigest() == entry["bytes_sha"] else None
324
+ except OSError:
325
+ data = None
326
+ if data is None:
327
+ data = derive(scan, source)
328
+ _write(target, data, source.mtime_ns)
329
+ versions, ident = inputs.versions.get(source.canonical), source.artifact_id
330
+ if scan.redactor is not None and scan.redactor.spans(ident): # a declared id that spells a held name
331
+ ident = artifact_id_from_path(source.canonical.removeprefix("memory/"))
332
+ entries.append({"path": source.canonical, "id": ident, "sha": source.sha,
333
+ "bytes_sha": hashlib.sha256(data).hexdigest(), "size": len(data),
334
+ "mtime_ns": source.mtime_ns, **({"held": source.held} if source.held else {}),
335
+ **({"versions": versions} if versions else {})})
336
+ held_paths = {s.canonical for s in scan.sources if s.held is not None}
337
+ if scan.redactor is not None and any(entry["path"] in held_paths for entry in entries):
338
+ raise ProjectionError("leak check failed: a held path is in the hand-over")
339
+ return entries
340
+
341
+
342
+ def _generation(scan: _Scan, dest: Path, header: Mapping[str, Any], previous: Admission | None,
343
+ inputs: _Inputs, *, with_needs: bool = True) -> None:
344
+ _mkdir(dest)
345
+ files = _materialize(scan, dest, previous, inputs)
346
+ rows = []
347
+ for entry in files: # a vector describes the bytes it was read for: a redacted copy gets none
348
+ if entry["path"] in inputs.vectors and entry["bytes_sha"] == entry["sha"]:
349
+ entry["vector"] = len(rows)
350
+ rows.append(_pack(inputs.vectors[entry["path"]]))
351
+ _write(dest / "vectors.f32", b"".join(rows))
352
+ manifest = {**header, "generation": dest.name, "files": files}
353
+ # A shown file's path is held to the hand-over by identity above; every other value by every held
354
+ # name, one at a time. Field names are this module's words, not memory's, except map.names', which
355
+ # are what people wrote.
356
+ scanned = [{k: v for k, v in manifest.items() if k != "files"},
357
+ [{k: v for k, v in entry.items() if k != "path"} for entry in files]]
358
+ if scan.redactor is not None and any(scan.redactor.spans(text) for text in
359
+ (*_strings(scanned, False), *_strings(manifest["config"]["map"]["names"], True))):
360
+ raise ProjectionError("leak check failed: a held name is in the manifest")
361
+ if with_needs:
362
+ from . import needs
363
+ needs.write(scan, dest) # what needs the person, before the manifest that completes the generation
364
+ _write(dest / "manifest.json", json.dumps(manifest, sort_keys=True).encode())
365
+
366
+
367
+ def _strings(value: Any, keys: bool) -> Iterator[str]:
368
+ if isinstance(value, dict):
369
+ for key, item in value.items():
370
+ yield from (str(key),) if keys else ()
371
+ yield from _strings(item, keys)
372
+ elif isinstance(value, list):
373
+ for item in value:
374
+ yield from _strings(item, keys)
375
+ elif isinstance(value, str):
376
+ yield value
377
+
378
+
379
+ def read_vectors(generation: Path, manifest: Mapping[str, Any]) -> dict[str, Any]:
380
+ """An admitted generation's vectors in ``read_document_vectors``'s shape, for export.py --projection."""
381
+ meta, width = manifest["vectors"], DIMENSION * 4
382
+ rows = {entry["path"]: entry["vector"] for entry in manifest["files"] if "vector" in entry}
383
+ data = _read(Path(generation) / "vectors.f32", FILE_CAP, private=True)
384
+ if meta.get("dimension") != DIMENSION or len(data) != len(rows) * width or sorted(rows.values()) != list(range(len(rows))):
385
+ raise ProjectionError("the generation's vectors do not match its manifest")
386
+ return {"vectors": {path: list(struct.unpack_from(f"<{DIMENSION}f", data, row * width)) for path, row in rows.items()},
387
+ "fingerprint": meta.get("fingerprint", ""), "dimension": DIMENSION}
388
+
389
+
390
+ def build(decision: PolicyDecision, dest: Path, *, root: Path, previous: Admission | None = None) -> None:
391
+ """One generation at ``dest`` for a hand-over decision: a slot, and later a root or a snapshot."""
392
+ root = Path(root).resolve()
393
+ scan = _scan(decision, root)
394
+ if not decision.allowed:
395
+ raise ProjectionError("this decision hands nothing over")
396
+ inputs = _inputs(scan, root)
397
+ _generation(scan, Path(dest), _header(scan, root, inputs), previous, inputs)
398
+
399
+
400
+ def _header(scan: _Scan, root: Path, inputs: _Inputs, settings: AuthorizationInputs | None = None) -> dict[str, Any]:
401
+ """``settings``: another principal's committed inputs; the operator's are the working tree's."""
402
+ from .identity import organization_member_presentations
403
+ try:
404
+ config = json.loads(settings.settings if settings else (root / "egregore.json").read_text(encoding="utf-8"))
405
+ config = config if isinstance(config, dict) else {}
406
+ except (OSError, ValueError):
407
+ config = {}
408
+ names = config.get("map") if isinstance(config.get("map"), dict) else {}
409
+ sliced = {"org_name": config.get("org_name"), "slug": config.get("slug"), "repos": config.get("repos", []),
410
+ "github_org": config.get("github_org"), "repo_name": config.get("repo_name"),
411
+ "map": {"names": names.get("names")}, "removed": sorted((settings or authorization_inputs(root)).removed)}
412
+ shown = {source.canonical: source.data for source in scan.shown} # the profiles as scanned, never re-read
413
+ people = [{"person_id": m.person_id, "display_name": m.display_name, "aliases": list(m.aliases)}
414
+ for m in organization_member_presentations(root, decision=None, sources=shown,
415
+ removed=settings.removed if settings else None)]
416
+ return {"version": VERSION, "complete": True, "principal": scan.decision.actor_id,
417
+ "audience": scan.decision.audience, **scan.require(), "redaction_revision": scan.revision,
418
+ "source_revision": _digest([(s.canonical, s.sha) for s in scan.shown]), "vectors": inputs.header(),
419
+ "config": scan.redactor.scrub(sliced) if scan.redactor else sliced,
420
+ "people": scan.redactor.scrub(people) if scan.redactor else people}
421
+
422
+
423
+ # ---- the slot ------------------------------------------------------------------------------
424
+
425
+ def _private_dir(path: Path) -> None:
426
+ info = os.lstat(path)
427
+ if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
428
+ raise OSError(f"not a private folder: {path.name}")
429
+
430
+
431
+ def _quiet_json(path: Path) -> dict | None:
432
+ try:
433
+ value = json.loads(_read(path, JSON_CAP, private=True))
434
+ return value if isinstance(value, dict) else None
435
+ except (OSError, ValueError):
436
+ return None
437
+
438
+
439
+ @contextmanager
440
+ def _locked(path: Path, *, wait: bool = True) -> Iterator[bool]:
441
+ descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
442
+ try:
443
+ try:
444
+ fcntl.flock(descriptor, fcntl.LOCK_EX | (0 if wait else fcntl.LOCK_NB))
445
+ except BlockingIOError:
446
+ yield False
447
+ return
448
+ yield True
449
+ finally:
450
+ os.close(descriptor)
451
+
452
+
453
+ def _stamp(root: Path) -> dict[str, str]:
454
+ key = installation_key(root)
455
+ org_id = str(json.loads((root / "egregore.json").read_text(encoding="utf-8")).get("org_id")).strip()
456
+ return {"key": key, "org_id": org_id, "installation": str(installation_root(root))}
457
+
458
+
459
+ def _claim(slot: Path, stamp: Mapping[str, str]) -> None:
460
+ try:
461
+ _mkdir(slot)
462
+ if not os.path.lexists(slot / "stamp.json"):
463
+ _write_json(slot / "stamp.json", stamp)
464
+ except OSError as exc:
465
+ raise ProjectionError(f"the map slot cannot be used: {exc}") from exc
466
+ if _quiet_json(slot / "stamp.json") != stamp:
467
+ raise ProjectionError("this map slot belongs to another installation")
468
+
469
+
470
+ def _sweep(hub: Path, own: str) -> None:
471
+ """An orphan tile ages out: a slot whose installation is gone and untouched for 30 days."""
472
+ cutoff = time.time() - ORPHAN_SECONDS
473
+ for entry in os.scandir(hub):
474
+ if entry.name == own or not entry.is_dir(follow_symlinks=False):
475
+ continue
476
+ slot = Path(entry.path)
477
+ try:
478
+ installation = (_quiet_json(slot / "stamp.json") or {}).get("installation")
479
+ if os.lstat(slot).st_uid != os.getuid() or (isinstance(installation, str) and os.path.isdir(installation)):
480
+ continue
481
+ touched = [os.lstat(p).st_mtime for p in (slot, slot / "current.json", slot / "require.json",
482
+ slot / "stamp.json") if os.path.lexists(p)]
483
+ if max(touched) < cutoff:
484
+ shutil.rmtree(slot)
485
+ except OSError:
486
+ continue
487
+
488
+
489
+ @dataclass(frozen=True, slots=True)
490
+ class Admission:
491
+ state: str # "ready" or "dark"
492
+ reason: str
493
+ generation: Path | None = None
494
+ manifest: Mapping[str, Any] | None = None
495
+
496
+ @property
497
+ def ready(self) -> bool:
498
+ return self.state == "ready"
499
+
500
+
501
+ def admit(slot: Path) -> Admission:
502
+ """The generation to serve, or dark. Nothing is cached: call it on every request."""
503
+ slot = Path(slot)
504
+ try:
505
+ _private_dir(slot)
506
+ _private_dir(slot / "gen")
507
+ pointer = json.loads(_read(slot / "current.json", JSON_CAP, private=True))
508
+ require = json.loads(_read(slot / "require.json", JSON_CAP, private=True))
509
+ rev = pointer.get("generation")
510
+ if not isinstance(rev, str) or not _REV.fullmatch(rev):
511
+ return Admission("dark", "no generation")
512
+ generation = slot / "gen" / rev
513
+ _private_dir(generation)
514
+ manifest = json.loads(_read(generation / "manifest.json", MANIFEST_CAP, private=True))
515
+ if manifest.get("complete") is not True or manifest.get("generation") != rev or manifest.get("key") != slot.name:
516
+ return Admission("dark", "incomplete generation")
517
+ stamp = json.loads(_read(slot / "stamp.json", JSON_CAP, private=True))
518
+ if stamp.get("key") != slot.name or stamp.get("org_id") != manifest.get("org_id"):
519
+ return Admission("dark", "another installation's slot")
520
+ if any(not require.get(field) or manifest.get(field) != require.get(field) for field in REQUIRE_FIELDS):
521
+ return Admission("dark", "being rebuilt")
522
+ found = {}
523
+ memory = generation / "memory"
524
+ _private_dir(memory)
525
+ for folder, folders, files in os.walk(memory, onerror=_raise):
526
+ for name in folders:
527
+ _private_dir(Path(folder, name))
528
+ for name in files:
529
+ info = os.lstat(Path(folder, name))
530
+ if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1
531
+ or info.st_mode & 0o077 or info.st_size > FILE_CAP):
532
+ return Admission("dark", "a file is not private")
533
+ found["memory/" + Path(folder, name).relative_to(memory).as_posix()] = info.st_size
534
+ if found != {entry["path"]: entry["size"] for entry in manifest["files"]}:
535
+ return Admission("dark", "files differ from the manifest")
536
+ except (OSError, ValueError, KeyError, TypeError, AttributeError):
537
+ return Admission("dark", "unreadable")
538
+ return Admission("ready", "admitted", generation, manifest)
539
+
540
+
541
+ # ---- publish -------------------------------------------------------------------------------
542
+
543
+ def publish(root: Path, *, wait: bool = True) -> str:
544
+ """Hand the operator's view over to the slot; a no-op until the viewer creates the hub.
545
+
546
+ Returns "no-hub", "busy", "denied", "superseded", "unchanged" or "published".
547
+ """
548
+ root = installation_root(Path(root).resolve()) # every worktree hands over the installation's view
549
+ if projection_root(root): # a read-only copy hands nothing over, whatever its settings name
550
+ return "denied"
551
+ hub = hub_root()
552
+ if not os.path.isdir(hub) or os.path.islink(hub):
553
+ return "no-hub"
554
+ stamp = _stamp(root)
555
+ slot = hub / stamp["key"]
556
+ _claim(slot, stamp)
557
+ with _locked(slot / ".build.lock", wait=wait) as locked:
558
+ if not locked:
559
+ return "busy"
560
+ seen: dict[str, Any] = {}
561
+ try:
562
+ return _publish(root, slot, stamp, seen)
563
+ finally:
564
+ if seen and (_quiet_json(slot / "seen.json") or {}).get("fingerprint") != seen["fingerprint"]:
565
+ _write_json(slot / "seen.json", seen)
566
+
567
+
568
+ def _publish(root: Path, slot: Path, stamp: Mapping[str, str], seen: dict[str, Any]) -> str:
569
+ from . import needs
570
+ before = _quiet_json(slot / "require.json")
571
+ actor = operator(root) # resolved after ``before``: a later identity change voids this publish
572
+ decision = hand_over(root, actor)
573
+ if before is None or any(before.get(k) != v for k, v in _pair(decision).items()):
574
+ with _locked(slot / ".require.lock"): # a known withdrawal is recorded before anything can fail
575
+ if _quiet_json(slot / "require.json") != before:
576
+ return "superseded"
577
+ before = _pending(decision)
578
+ _write_json(slot / "require.json", before)
579
+ last = _quiet_json(slot / "seen.json") or {}
580
+ if before.get("held_digest") != "pending" and not is_admin(decision) \
581
+ and _held_changed(_walked(root), last.get("since", 0), decision, (root / "memory").resolve()):
582
+ with _locked(slot / ".require.lock"): # held since the last publish: dark before a scan can fail
583
+ if _quiet_json(slot / "require.json") != before:
584
+ return "superseded"
585
+ before = _pending(decision)
586
+ _write_json(slot / "require.json", before)
587
+ seen.update(since=time.time_ns(), fingerprint=fingerprint(root))
588
+ _sweep(slot.parent, slot.name)
589
+ pointer = _quiet_json(slot / "current.json") or {}
590
+ scan = _scan(decision, root)
591
+ require = scan.require()
592
+ with _locked(slot / ".require.lock"):
593
+ now = _quiet_json(slot / "require.json")
594
+ if now != before:
595
+ return "superseded" # a withdrawal landed after this publish read its inputs
596
+ if now != require:
597
+ _write_json(slot / "require.json", require)
598
+ if not scan.decision.allowed:
599
+ return "denied"
600
+ current = admit(slot)
601
+ inputs = _inputs(scan, root)
602
+ header = {**_header(scan, root, inputs), "key": stamp["key"], "org_id": stamp["org_id"],
603
+ "person": {"actor_id": actor.actor.actor_id, "display_name": actor.actor.display_name,
604
+ "handle": actor.actor.aliases.get("github.username"), "account_id": actor.actor.account_id,
605
+ "person_id": actor.actor.aliases.get("legacy.person")}}
606
+ if current.ready and {k: v for k, v in current.manifest.items() if k not in ("generation", "files")} == header \
607
+ and {e["path"]: (e["sha"], e["mtime_ns"], e.get("versions")) for e in current.manifest["files"]} \
608
+ == {s.canonical: (s.sha, s.mtime_ns, inputs.versions.get(s.canonical)) for s in scan.shown} \
609
+ and needs.fresh(scan, current.generation):
610
+ return "unchanged"
611
+ _mkdir(slot / "gen")
612
+ rev = f"{time.time_ns():016x}{secrets.token_hex(4)}"
613
+ generation = slot / "gen" / rev
614
+ try:
615
+ _generation(scan, generation, header, current if current.ready else None, inputs)
616
+ with _locked(slot / ".require.lock"):
617
+ if _quiet_json(slot / "require.json") != require:
618
+ raise _Superseded()
619
+ _write_json(slot / "current.json", {"generation": rev})
620
+ except _Superseded:
621
+ shutil.rmtree(generation, ignore_errors=True)
622
+ return "superseded"
623
+ except BaseException:
624
+ shutil.rmtree(generation, ignore_errors=True)
625
+ raise
626
+ keep = {rev, pointer.get("generation")}
627
+ for entry in os.scandir(slot / "gen"):
628
+ if entry.name not in keep and entry.is_dir(follow_symlinks=False):
629
+ shutil.rmtree(entry.path, ignore_errors=True)
630
+ return "published"
631
+
632
+
633
+ class _Superseded(Exception):
634
+ pass
635
+
636
+
637
+ # ---- the Runtime observer ------------------------------------------------------------------
638
+
639
+ def _pair(decision: PolicyDecision) -> dict[str, str]:
640
+ return {"holds_epoch": decision.holds_epoch, "grant_digest": grant_digest(decision)}
641
+
642
+
643
+ def _pending(decision: PolicyDecision) -> dict[str, str]:
644
+ """A dark requirement no manifest matches. Each one is unique, so a publish comparing
645
+ require.json against what it read sees every withdrawal, even a repeated one."""
646
+ return {**_pair(decision), "held_digest": "pending", "withdrawal": secrets.token_hex(8)}
647
+
648
+
649
+ def refresh(root: Path, *, harness: str = "mind", session_id: str = "mind-publish") -> str:
650
+ """Synchronous publish on a withdrawal, a detached one when the slot is stale, else nothing."""
651
+ if any(optional_module(name) is None for name in (".mind_cli", ".needs", ".history")):
652
+ return "disabled"
653
+ root = installation_root(Path(root).resolve())
654
+ stamp = _stamp(root)
655
+ slot = hub_root() / stamp["key"]
656
+ _claim(slot, stamp)
657
+ actor = operator(root, harness=harness, session_id=session_id)
658
+ decision = hand_over(root, actor)
659
+ pair = _pair(decision)
660
+ with _locked(slot / ".require.lock"): # the grant first: nothing below can lose it
661
+ require = _quiet_json(slot / "require.json")
662
+ withdrawn = require is not None and any(require.get(k) != v for k, v in pair.items())
663
+ if withdrawn:
664
+ _write_json(slot / "require.json", _pending(decision))
665
+ seen = _quiet_json(slot / "seen.json") or {}
666
+ found = _walked(root)
667
+ stale = found is None or seen.get("fingerprint") != fingerprint(root, found)
668
+ if (not withdrawn and stale and require is not None and not is_admin(decision)
669
+ and _held_changed(found, seen.get("since", 0), decision, (root / "memory").resolve())):
670
+ with _locked(slot / ".require.lock"): # unconditional: it also voids a publish in flight
671
+ _write_json(slot / "require.json", _pending(decision))
672
+ withdrawn = True
673
+ if withdrawn:
674
+ return publish(root, wait=True)
675
+ if stale:
676
+ return "started" if _spawn(root, slot) else "running"
677
+ return "fresh"
678
+
679
+
680
+ def _walked(root: Path) -> list | None:
681
+ """Every Markdown file of memory, or None when memory moved under the walk or a folder cannot be read."""
682
+ try:
683
+ return list(_walk((root / "memory").resolve()))
684
+ except OSError:
685
+ return None
686
+
687
+
688
+ def _held_changed(found: list | None, since: int, decision: PolicyDecision, memory: Path) -> bool:
689
+ """A document changed since the last publish began and the hand-over holds it now, under any class.
690
+ It may be served, or in a publish still running, so it is treated as a withdrawal. So is a walk
691
+ that failed, or a changed document that cannot be read: neither can show that nothing turned held."""
692
+ if found is None:
693
+ return True
694
+ margin = since - 2_000_000_000 # coarse file-system clocks
695
+
696
+ def held_now(canonical: str, path: Path) -> bool:
697
+ try:
698
+ data = path.read_bytes()
699
+ except FileNotFoundError:
700
+ return False # gone: the stale fingerprint republishes without it
701
+ except OSError:
702
+ return True
703
+ return bytes_held(decision, canonical, data, root=memory) is not None
704
+ return any(info.st_ctime_ns >= margin and held_now(canonical, path) for canonical, path, info in found)
705
+
706
+
707
+ def _spawn(root: Path, slot: Path) -> bool:
708
+ """Start one detached publish. The child inherits the slot's launch lease and holds it until it
709
+ exits, so Runtime calls arriving meanwhile start nothing more."""
710
+ lease = os.open(slot / ".launch.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
711
+ try:
712
+ try:
713
+ fcntl.flock(lease, fcntl.LOCK_EX | fcntl.LOCK_NB)
714
+ except BlockingIOError:
715
+ return False
716
+ env = {**os.environ, "EGREGORE_ROOT": str(root), "PYTHONSAFEPATH": "1",
717
+ "PYTHONPATH": str(Path(__file__).resolve().parents[1])}
718
+ subprocess.Popen([sys.executable, "-m", f"{__package__}.harness_cli", "mind", "publish", "--detached"],
719
+ cwd=root, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
720
+ stderr=subprocess.DEVNULL, start_new_session=True, close_fds=True, pass_fds=(lease,))
721
+ return True
722
+ finally:
723
+ os.close(lease)
724
+
725
+
726
+ def observer(root: Path):
727
+ """The Runtime's projection observer. It refreshes once as the Runtime is composed, so every
728
+ Runtime call checks the slot before it answers (a denied or empty one included), and again after
729
+ each authorized operation. Nothing happens until the map viewer creates the hub."""
730
+ root = Path(root)
731
+ if projection_root(root): # a read-only copy hands nothing over
732
+ return lambda event=None: None
733
+
734
+ def notice(event=None) -> None:
735
+ if os.path.isdir(hub_root()):
736
+ refresh(root, harness=getattr(event, "harness", "mind"),
737
+ session_id=getattr(event, "session_id", "mind-publish"))
738
+ try:
739
+ notice()
740
+ except Exception:
741
+ pass # as _notify isolates observers: the Runtime call goes on
742
+ return notice
743
+
744
+
745
+ # ---- a projection root ---------------------------------------------------------------------
746
+
747
+ READ_ONLY = "This is a read-only copy; writing back comes with linked egregores"
748
+ REBUILDING = "this read-only copy is being rebuilt; try again shortly"
749
+ ROOT_VERSION = "egregore-projection-root/1"
750
+ _GENERATIONS = ("a" * 24, "b" * 24) # two fixed names, so a Runtime and its search index over the copy keep a path
751
+ _SERVES: dict[str, tuple[float, bool]] = {}
752
+
753
+
754
+ def serves(memory: Path) -> bool:
755
+ """Whether ``memory`` (``DIR/gen/<rev>/memory``) is the generation its projection root admits: false while the
756
+ root is dark, rebuilt or serving another, and for a generation of anything but a projection root (a map slot).
757
+ Judged again after ``held.FRESH_SECONDS``, so a process that outlives a withdrawal stops reading the generation
758
+ it began on."""
759
+ memory, now = Path(memory).resolve(), time.monotonic()
760
+ seen = _SERVES.get(str(memory))
761
+ if seen is None or now - seen[0] >= held.FRESH_SECONDS:
762
+ generation = memory.parent
763
+ found = admit(generation.parent.parent) if memory.name == "memory" and generation.parent.name == "gen" \
764
+ and projection_root(generation.parent.parent) else None
765
+ seen = _SERVES[str(memory)] = (now, bool(found and found.ready and found.generation == generation))
766
+ return seen[1]
767
+
768
+
769
+ def materialize(root: Path, decision: PolicyDecision, dest: Path) -> tuple[str, int]:
770
+ """``access materialize --as P --to DIR``: an outside principal's committed read grant as a read-only copy.
771
+
772
+ DIR is built as a slot is: ``require.json`` first, one generation, ``current.json`` naming it and the same reuse
773
+ key; ``DIR/memory`` links to it for a Runtime started over DIR, which finds the marker and reads only the
774
+ generation DIR admits (``access.authorization_inputs``, ``policy.check``), so no reader pays for the holds again.
775
+ The sources are memory at the decision's committed revision and the committed settings, judged with no hint or
776
+ index, and no vectors or needs: of what DIR's reader can write, only a previous copy is read back, for a file
777
+ whose source and redaction revision are unchanged. Anything that may withdraw (another revision of memory,
778
+ another grant or hold set, or a refused principal) empties DIR before the build: no link, no generation, so a
779
+ killed re-run leaves it dark. Returns "published" or "unchanged", and the documents served.
780
+ """
781
+ root, dest = Path(root).resolve(), Path(os.path.abspath(dest))
782
+ if decision.audience != "outside" or decision.scope != "hand-over":
783
+ raise ProjectionError("a read-only copy is made only from another principal's committed grant")
784
+ if not decision.allowed or not decision.memory_revision:
785
+ _revoke(root, decision, dest)
786
+ raise ProjectionError("; ".join(decision.reasons))
787
+ handle = decision.actor_id.removeprefix("principal:")
788
+ dest = _place(dest)
789
+ stamp = _owner(root, decision, dest)
790
+ _claim(dest, stamp)
791
+ _write_json(dest / ROOT_MARKER, {"version": ROOT_VERSION, "principal": handle, "note": READ_ONLY})
792
+ with _locked(dest / ".build.lock"):
793
+ if _quiet_json(dest / "stamp.json") != stamp:
794
+ raise ProjectionError("this folder holds another copy")
795
+ settings = committed_settings(root, decision) # under the lock: a run decided earlier never lands later
796
+ if settings is None:
797
+ raise ProjectionError("the committed settings moved while this ran; run it again")
798
+ _purge(dest)
799
+ pair = {**_pair(decision), "memory_revision": decision.memory_revision}
800
+ before, mark = _quiet_json(dest / "require.json"), _watermark(stamp)
801
+ if not all(_descends(root, (seen or {}).get("memory_revision"), decision.memory_revision)
802
+ for seen in (before, _quiet_json(mark))):
803
+ raise ProjectionError("this checkout's memory is behind the copy's; fetch memory and run again")
804
+ if before is None or any(before.get(k) != v for k, v in pair.items()):
805
+ _withdraw(dest, {**_pending(decision), **pair}) # it may withdraw: dark before anything can fail
806
+ os.makedirs(mark.parent, mode=0o700, exist_ok=True)
807
+ _write_json(mark, {"memory_revision": decision.memory_revision})
808
+ scan = _scan(decision, root)
809
+ require = {**scan.require(), "memory_revision": decision.memory_revision}
810
+ if _quiet_json(dest / "require.json") != require:
811
+ _withdraw(dest, require) # held, released or renamed since: nothing served survives
812
+ inputs, current = _Inputs({}, {}, ""), admit(dest)
813
+ header = {**_header(scan, root, inputs, settings), "key": dest.name, "org_id": decision.org_id,
814
+ "memory_revision": decision.memory_revision}
815
+ config = header["config"]
816
+ _write_json(dest / "egregore.json", {"org_name": config.get("org_name") or "Egregore",
817
+ "slug": config.get("slug") or "copy", "mode": "local"})
818
+ if not os.path.lexists(dest / ".egregore-state.json"):
819
+ _write_json(dest / ".egregore-state.json", {"github_username": handle, "display_name": handle})
820
+ if current.ready and {k: v for k, v in current.manifest.items() if k not in ("generation", "files")} == header \
821
+ and {e["path"]: e["sha"] for e in current.manifest["files"]} == {s.canonical: s.sha for s in scan.shown}:
822
+ _serve(dest, current.generation.name)
823
+ return "unchanged", len(scan.shown)
824
+ # The generation served is kept for readers mid-read; one emptied away is built again under its own name,
825
+ # so a Runtime and its search index over the copy keep their path.
826
+ pointer = (_quiet_json(dest / "current.json") or {}).get("generation")
827
+ rev = pointer if pointer in _GENERATIONS and not os.path.lexists(dest / "gen" / pointer) \
828
+ else _GENERATIONS[pointer == _GENERATIONS[0]]
829
+ _mkdir(dest / "gen")
830
+ _remove(dest / "gen" / rev)
831
+ try:
832
+ _generation(scan, dest / "gen" / rev, header, current if current.ready else None, inputs, with_needs=False)
833
+ _write_json(dest / "current.json", {"generation": rev})
834
+ except BaseException:
835
+ _remove(dest / "gen" / rev)
836
+ raise
837
+ _serve(dest, rev)
838
+ return "published", len(scan.shown)
839
+
840
+
841
+ def _place(dest: Path) -> Path:
842
+ """DIR: a new or empty folder, or an earlier copy, outside every Git work tree and the map's hub."""
843
+ parent = dest.parent.resolve()
844
+ if not dest.name or not parent.is_dir():
845
+ raise ProjectionError("the folder's parent must exist")
846
+ dest = parent / dest.name
847
+ try:
848
+ inside = bool(_git(parent, "rev-parse", "--git-dir")) # a work tree or a Git folder: either is a repository
849
+ except subprocess.CalledProcessError:
850
+ inside = False
851
+ if inside or dest.is_relative_to(hub_root().resolve()):
852
+ raise ProjectionError("choose a folder outside every Git repository and the map's hub")
853
+ if os.path.lexists(dest) and not projection_root(dest) and (os.path.islink(dest) or not os.path.isdir(dest)
854
+ or os.listdir(dest)):
855
+ raise ProjectionError("choose an empty folder, or one an earlier copy made")
856
+ return dest
857
+
858
+
859
+ def _owner(root: Path, decision: PolicyDecision, dest: Path) -> dict[str, str]:
860
+ """The stamp a copy carries: this installation's, for this principal of this organization."""
861
+ return {"key": dest.name, "org_id": decision.org_id, "principal": decision.actor_id,
862
+ "installation": str(installation_root(root))}
863
+
864
+
865
+ def _revoke(root: Path, decision: PolicyDecision, dest: Path) -> None:
866
+ """A refused principal's earlier copy from this installation goes dark before the refusal is reported."""
867
+ try:
868
+ dest = dest.parent.resolve() / dest.name
869
+ _private_dir(dest)
870
+ if not projection_root(dest):
871
+ return
872
+ except OSError:
873
+ return
874
+ with _locked(dest / ".build.lock"):
875
+ if _quiet_json(dest / "stamp.json") == _owner(root, decision, dest):
876
+ _withdraw(dest, _pending(decision))
877
+
878
+
879
+ def _watermark(stamp: Mapping[str, str]) -> Path:
880
+ """Where the sponsor records the newest memory revision it copied for this principal, to any folder however
881
+ spelled: outside DIR, so DIR's reader can neither delete nor rewind it (``require.json`` is the reader's)."""
882
+ owner = {field: stamp[field] for field in ("org_id", "principal", "installation")}
883
+ return Path.home() / ".egregore" / "runtime" / "copies" / f"{_digest(owner)[:24]}.json"
884
+
885
+
886
+ def _descends(root: Path, served: object, revision: str) -> bool:
887
+ """Whether memory at ``revision`` is the copy's ``served`` revision or newer, so a sponsor whose memory is
888
+ behind never rolls the copy back. A value this module did not write could only come from DIR's reader."""
889
+ if not isinstance(served, str) or not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", served):
890
+ return True
891
+ try:
892
+ _git((root / "memory").resolve(), "merge-base", "--is-ancestor", served, revision)
893
+ except subprocess.CalledProcessError:
894
+ return False
895
+ return True
896
+
897
+
898
+ def _withdraw(dest: Path, require: Mapping[str, str]) -> None:
899
+ """Dark at once: every generation leaves DIR in one rename, then the requirement none meets is recorded and the
900
+ link goes; the generations are deleted outside DIR, so a killed run leaves none in it to read."""
901
+ if os.path.lexists(dest / "gen"):
902
+ os.rename(dest / "gen", dest.parent / f".{dest.name}.withdrawn.{secrets.token_hex(6)}")
903
+ _write_json(dest / "require.json", require)
904
+ _unlink(dest / "memory")
905
+ _purge(dest)
906
+
907
+
908
+ def _purge(dest: Path) -> None:
909
+ """Delete generations an earlier, killed withdrawal moved beside DIR."""
910
+ for name in os.listdir(dest.parent):
911
+ if name.startswith(f".{dest.name}.withdrawn."):
912
+ _remove(dest.parent / name)
913
+
914
+
915
+ def _serve(dest: Path, rev: str) -> None:
916
+ """``DIR/memory`` names the generation served, and no other generation stays to be read. The others are removed
917
+ through DIR/gen opened without following a link, so a link planted there removes nothing outside DIR."""
918
+ link, target = dest / "memory", f"gen/{rev}/memory"
919
+ if not (os.path.islink(link) and os.readlink(link) == target):
920
+ _unlink(link)
921
+ os.symlink(target, link)
922
+ generations = os.open(dest / "gen", os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
923
+ try:
924
+ for entry in os.listdir(generations):
925
+ if entry == rev:
926
+ continue
927
+ if stat.S_ISDIR(os.lstat(entry, dir_fd=generations).st_mode):
928
+ shutil.rmtree(entry, dir_fd=generations)
929
+ else:
930
+ os.unlink(entry, dir_fd=generations)
931
+ finally:
932
+ os.close(generations)
933
+
934
+
935
+ def _unlink(link: Path) -> None:
936
+ if os.path.islink(link):
937
+ os.unlink(link)
938
+ elif os.path.lexists(link):
939
+ raise ProjectionError("DIR/memory is not the copy's link; move it away and run again")
940
+
941
+
942
+ def _remove(path: Path) -> None:
943
+ if os.path.isdir(path) and not os.path.islink(path):
944
+ shutil.rmtree(path)
945
+ elif os.path.lexists(path):
946
+ os.unlink(path)