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.
- package/assets/egregore-launcher.js +58 -6
- package/assets/egregore-launcher.sh +15 -2
- package/lib/claude-runtime.js +2 -0
- package/lib/codex-runtime.js +2 -0
- package/lib/setup.js +20 -3
- package/package.json +2 -2
- package/runtime/claude/.claude/context/commit-format.md +7 -4
- package/runtime/claude/.claude/context/handoff-claiming.md +32 -0
- package/runtime/claude/.claude/context/notification-consent.md +12 -9
- package/runtime/claude/.claude/hooks/boundary-check.sh +14 -331
- package/runtime/claude/.claude/hooks/memory-read-guard.sh +9 -1
- package/runtime/claude/.claude/rules/voice-bedrock.md +6 -2
- package/runtime/claude/.claude/settings.json +1 -1
- package/runtime/claude/.claude/skills/commit/SKILL.md +5 -3
- package/runtime/claude/.claude/skills/dark-mode/SKILL.md +48 -369
- package/runtime/claude/.claude/skills/records/SKILL.md +158 -0
- package/runtime/claude/.claude/skills/reflect/SKILL.md +50 -21
- package/runtime/claude/.claude/skills/review-pr/SKILL.md +4 -2
- package/runtime/claude/.claude/skills/save/SKILL.md +9 -3
- package/runtime/claude/.claude/skills/summon/SKILL.md +138 -207
- package/runtime/claude/.claude/skills/tui-design/SKILL.md +1 -1
- package/runtime/claude/.claude/skills/update/SKILL.md +1 -1
- package/runtime/claude/.claude/skills/view/SKILL.md +52 -16
- package/runtime/claude/.claude/skills/view/compose-scaffold.html +19 -13
- package/runtime/claude/CLAUDE.md +59 -95
- package/runtime/claude/bin/agent.sh +231 -7
- package/runtime/claude/bin/api-call.sh +44 -23
- package/runtime/claude/bin/artifact-register.sh +7 -1
- package/runtime/claude/bin/artifact-writeback.sh +1 -1
- package/runtime/claude/bin/artifacts.sh +66 -265
- package/runtime/claude/bin/attendant.sh +4 -1
- package/runtime/claude/bin/boundary-guard.cjs +1848 -0
- package/runtime/claude/bin/boundary-hook.sh +41 -0
- package/runtime/claude/bin/boundary.sh +15 -155
- package/runtime/claude/bin/capture-run.sh +32 -2
- package/runtime/claude/bin/codex-render-spec.mjs +24 -46
- package/runtime/claude/bin/codex-session-start.sh +19 -0
- package/runtime/claude/bin/codex-skill-render.mjs +18 -0
- package/runtime/claude/bin/config-get.sh +10 -1
- package/runtime/claude/bin/connect-refresh.sh +16 -0
- package/runtime/claude/bin/corpus_map.py +5 -0
- package/runtime/claude/bin/data/capabilities/claude.json +1480 -0
- package/runtime/claude/bin/genui.sh +19 -0
- package/runtime/claude/bin/graph-batch.sh +1 -1
- package/runtime/claude/bin/graph-op.sh +14 -0
- package/runtime/claude/bin/graph.sh +1 -1
- package/runtime/claude/bin/handoff-pr-backfill.sh +1 -1
- package/runtime/claude/bin/handoff-run.sh +11 -6
- package/runtime/claude/bin/ingest-item.sh +1 -1
- package/runtime/claude/bin/ingest-surface.html +22 -4
- package/runtime/claude/bin/issue.sh +1 -1
- package/runtime/claude/bin/knowledge.sh +1 -1
- package/runtime/claude/bin/lib/card-delivery.sh +329 -0
- package/runtime/claude/bin/lib/config.sh +148 -3
- package/runtime/claude/bin/lib/context.sh +38 -67
- package/runtime/claude/bin/lib/genui-entry.py +36 -0
- package/runtime/claude/bin/lib/git-message.sh +25 -6
- package/runtime/claude/bin/lib/git-sync.sh +12 -4
- package/runtime/claude/bin/lib/greeting.sh +255 -28
- package/runtime/claude/bin/lib/handoff_meta.py +294 -0
- package/runtime/claude/bin/lib/health-footer.sh +83 -2
- package/runtime/claude/bin/lib/identity.sh +31 -10
- package/runtime/claude/bin/lib/instance-registry.sh +71 -0
- package/runtime/claude/bin/lib/local_ci_receipt.py +249 -0
- package/runtime/claude/bin/lib/metrics.sh +121 -8
- package/runtime/claude/bin/lib/notices.sh +36 -0
- package/runtime/claude/bin/lib/record_outcomes.py +729 -0
- package/runtime/claude/bin/lib/reflexes.py +267 -0
- package/runtime/claude/bin/lib/retrieval_scorecard.py +624 -0
- package/runtime/claude/bin/lib/session-record-prompt.txt +18 -0
- package/runtime/claude/bin/lib/session_record.py +3747 -0
- package/runtime/claude/bin/lib/session_trail.py +1983 -0
- package/runtime/claude/bin/lib/settings-drift.sh +8 -5
- package/runtime/claude/bin/lib/startup-restart.sh +41 -0
- package/runtime/claude/bin/lib/worktree-links.sh +92 -0
- package/runtime/claude/bin/memory-push.sh +6 -0
- package/runtime/claude/bin/merge-gate.sh +498 -0
- package/runtime/claude/bin/notify.sh +5 -2
- package/runtime/claude/bin/page-access.sh +103 -0
- package/runtime/claude/bin/person.sh +13 -2
- package/runtime/claude/bin/pi-render-spec.mjs +0 -1
- package/runtime/claude/bin/pi-spirit-discovery.mjs +34 -0
- package/runtime/claude/bin/post-update.sh +181 -0
- package/runtime/claude/bin/prime-render-spec.mjs +0 -1
- package/runtime/claude/bin/publish-artifact.sh +89 -42
- package/runtime/claude/bin/qa-inventory.sh +2 -2
- package/runtime/claude/bin/quest.sh +1 -1
- package/runtime/claude/bin/records.sh +11 -0
- package/runtime/claude/bin/reflexes/05-vitals.sh +9 -0
- package/runtime/claude/bin/reflexes/10-open-items.sh +12 -0
- package/runtime/claude/bin/reflexes/15-retrieval.sh +8 -0
- package/runtime/claude/bin/reflexes/20-nightly.sh +100 -0
- package/runtime/claude/bin/reflexes/20-sandbox.sh +85 -0
- package/runtime/claude/bin/reflexes/30-cost.py +116 -0
- package/runtime/claude/bin/reflexes/40-shells.py +51 -0
- package/runtime/claude/bin/reflexes/README.md +61 -0
- package/runtime/claude/bin/reflexes.sh +7 -0
- package/runtime/claude/bin/runtime-identity.sh +140 -42
- package/runtime/claude/bin/screenshot.sh +77 -0
- package/runtime/claude/bin/scroll.sh +1 -1
- package/runtime/claude/bin/search.sh +31 -5
- package/runtime/claude/bin/session-autosave.sh +6 -3
- package/runtime/claude/bin/session-id.sh +147 -0
- package/runtime/claude/bin/session-log.sh +19 -0
- package/runtime/claude/bin/session-record.sh +26 -0
- package/runtime/claude/bin/session-start.sh +121 -151
- package/runtime/claude/bin/settings.sh +13 -8
- package/runtime/claude/bin/spirit-discovery-claude.sh +4 -0
- package/runtime/claude/bin/spirit-discovery-codex.sh +4 -0
- package/runtime/claude/bin/spirit-discovery-pi.sh +4 -0
- package/runtime/claude/bin/spirit-discovery-prime.sh +4 -0
- package/runtime/claude/bin/spirit-discovery.sh +4 -0
- package/runtime/claude/bin/startup-check.sh +1 -1
- package/runtime/claude/bin/telemetry.sh +11 -4
- package/runtime/claude/bin/test-baseline.sh +33 -5
- package/runtime/claude/bin/todo.sh +1 -1
- package/runtime/claude/bin/worktree-create.sh +15 -45
- package/runtime/claude/bin/worktree.sh +6 -0
- package/runtime/claude/docs/spirit-discovery.md +96 -0
- package/runtime/claude/egregore_runtime/__init__.py +18 -12
- package/runtime/claude/egregore_runtime/access.py +608 -0
- package/runtime/claude/egregore_runtime/access_cli.py +190 -0
- package/runtime/claude/egregore_runtime/adapters/invitation.py +5 -3
- package/runtime/claude/egregore_runtime/adapters/qmd.py +521 -47
- package/runtime/claude/egregore_runtime/admin_entry.py +56 -0
- package/runtime/claude/egregore_runtime/artifacts.py +222 -16
- package/runtime/claude/egregore_runtime/contracts.py +27 -0
- package/runtime/claude/egregore_runtime/eligibility.py +5 -12
- package/runtime/claude/egregore_runtime/episode_binding.py +16 -5
- package/runtime/claude/egregore_runtime/github_issues.py +2 -2
- package/runtime/claude/egregore_runtime/handoff_lookup.py +9 -20
- package/runtime/claude/egregore_runtime/harness_cli.py +674 -149
- package/runtime/claude/egregore_runtime/held.py +2436 -0
- package/runtime/claude/egregore_runtime/hosted_api.py +137 -0
- package/runtime/claude/egregore_runtime/identity.py +196 -65
- package/runtime/claude/egregore_runtime/identity_transfer.py +6 -2
- package/runtime/claude/egregore_runtime/ingest_cli.py +2 -2
- package/runtime/claude/egregore_runtime/ingest_index_cli.py +4 -16
- package/runtime/claude/egregore_runtime/installation.py +119 -0
- package/runtime/claude/egregore_runtime/investigation.py +64 -41
- package/runtime/claude/egregore_runtime/investigation_guidance.py +16 -11
- package/runtime/claude/egregore_runtime/issue_cli.py +51 -32
- package/runtime/claude/egregore_runtime/issue_reporting.py +4 -3
- package/runtime/claude/egregore_runtime/issue_triage_service.py +2 -2
- package/runtime/claude/egregore_runtime/issues.py +12 -6
- package/runtime/claude/egregore_runtime/knowledge.py +331 -5
- package/runtime/claude/egregore_runtime/knowledge_cli.py +57 -17
- package/runtime/claude/egregore_runtime/lifecycle.py +88 -22
- package/runtime/claude/egregore_runtime/lifecycle_cli.py +21 -13
- package/runtime/claude/egregore_runtime/line_ids.py +10 -0
- package/runtime/claude/egregore_runtime/memory_push.py +349 -0
- package/runtime/claude/egregore_runtime/observe.py +64 -25
- package/runtime/claude/egregore_runtime/observers.py +19 -0
- package/runtime/claude/egregore_runtime/optional_modules.py +13 -0
- package/runtime/claude/egregore_runtime/outputs_cli.py +321 -0
- package/runtime/claude/egregore_runtime/policy.py +651 -24
- package/runtime/claude/egregore_runtime/private_result.py +2 -1
- package/runtime/claude/egregore_runtime/projection.py +946 -0
- package/runtime/claude/egregore_runtime/quest_cli.py +20 -23
- package/runtime/claude/egregore_runtime/question_cli.py +15 -11
- package/runtime/claude/egregore_runtime/questions.py +10 -10
- package/runtime/claude/egregore_runtime/quests.py +10 -10
- package/runtime/claude/egregore_runtime/record_publish.py +548 -0
- package/runtime/claude/egregore_runtime/record_subject.py +564 -0
- package/runtime/claude/egregore_runtime/research_ingest.py +2 -4
- package/runtime/claude/egregore_runtime/runtime.py +169 -60
- package/runtime/claude/egregore_runtime/sandbox.py +194 -0
- package/runtime/claude/egregore_runtime/scroll_cli.py +22 -32
- package/runtime/claude/egregore_runtime/scrolls.py +23 -15
- package/runtime/claude/egregore_runtime/shadow.py +262 -0
- package/runtime/claude/egregore_runtime/spirit_discovery.py +487 -0
- package/runtime/claude/egregore_runtime/spirit_discovery_cli.py +25 -0
- package/runtime/claude/egregore_runtime/spirit_discovery_transport.py +18 -0
- package/runtime/claude/egregore_runtime/status.py +101 -44
- package/runtime/claude/egregore_runtime/status_cli.py +13 -5
- package/runtime/claude/egregore_runtime/sync.py +39 -21
- package/runtime/claude/egregore_runtime/taste.py +934 -0
- package/runtime/claude/egregore_runtime/taste_cli.py +242 -0
- package/runtime/claude/egregore_runtime/taste_rules.py +91 -0
- package/runtime/claude/egregore_runtime/telemetry.py +26 -3
- package/runtime/claude/egregore_runtime/todo_cli.py +15 -12
- package/runtime/claude/egregore_runtime/todos.py +3 -2
- package/runtime/claude/egregore_runtime/upgrade.py +85 -58
- package/runtime/claude/egregore_runtime/writeback.py +330 -24
- package/runtime/claude/manifest.json +65 -5
- package/runtime/codex/.claude/context/handoff-claiming.md +32 -0
- package/runtime/codex/.claude/context/notification-consent.md +12 -9
- package/runtime/codex/.claude/skills/view/SKILL.md +438 -0
- package/runtime/codex/.codex/hooks.json +11 -0
- package/runtime/codex/.codex/skills/dark-mode/SKILL.md +1 -1
- package/runtime/codex/.codex/skills/records/SKILL.md +29 -0
- package/runtime/codex/.codex/skills/summon/SKILL.md +1 -1
- package/runtime/codex/.codex/skills/view/assets/compose-scaffold.html +19 -13
- package/runtime/codex/.codex/spec-manifest.json +13 -19
- package/runtime/codex/AGENTS.md +54 -105
- package/runtime/codex/bin/agent.sh +231 -7
- package/runtime/codex/bin/api-call.sh +44 -23
- package/runtime/codex/bin/artifact-register.sh +7 -1
- package/runtime/codex/bin/artifact-writeback.sh +1 -1
- package/runtime/codex/bin/artifacts.sh +66 -265
- package/runtime/codex/bin/attendant.sh +4 -1
- package/runtime/codex/bin/boundary-guard.cjs +1848 -0
- package/runtime/codex/bin/boundary-hook.sh +41 -0
- package/runtime/codex/bin/capture-run.sh +32 -2
- package/runtime/codex/bin/codex-render-spec.mjs +24 -46
- package/runtime/codex/bin/codex-session-start.sh +19 -0
- package/runtime/codex/bin/codex-skill-render.mjs +18 -0
- package/runtime/codex/bin/config-get.sh +10 -1
- package/runtime/codex/bin/connect-refresh.sh +16 -0
- package/runtime/codex/bin/corpus_map.py +5 -0
- package/runtime/codex/bin/data/capabilities/codex.json +1458 -0
- package/runtime/codex/bin/genui.sh +19 -0
- package/runtime/codex/bin/graph-batch.sh +1 -1
- package/runtime/codex/bin/graph-op.sh +14 -0
- package/runtime/codex/bin/graph.sh +1 -1
- package/runtime/codex/bin/handoff-pr-backfill.sh +1 -1
- package/runtime/codex/bin/handoff-run.sh +11 -6
- package/runtime/codex/bin/ingest-item.sh +1 -1
- package/runtime/codex/bin/ingest-surface.html +22 -4
- package/runtime/codex/bin/issue.sh +1 -1
- package/runtime/codex/bin/knowledge.sh +1 -1
- package/runtime/codex/bin/lib/card-delivery.sh +329 -0
- package/runtime/codex/bin/lib/config.sh +148 -3
- package/runtime/codex/bin/lib/context.sh +38 -67
- package/runtime/codex/bin/lib/genui-entry.py +36 -0
- package/runtime/codex/bin/lib/git-message.sh +25 -6
- package/runtime/codex/bin/lib/git-sync.sh +12 -4
- package/runtime/codex/bin/lib/greeting.sh +255 -28
- package/runtime/codex/bin/lib/handoff_meta.py +294 -0
- package/runtime/codex/bin/lib/health-footer.sh +83 -2
- package/runtime/codex/bin/lib/identity.sh +31 -10
- package/runtime/codex/bin/lib/instance-registry.sh +71 -0
- package/runtime/codex/bin/lib/local_ci_receipt.py +249 -0
- package/runtime/codex/bin/lib/metrics.sh +121 -8
- package/runtime/codex/bin/lib/notices.sh +36 -0
- package/runtime/codex/bin/lib/record_outcomes.py +729 -0
- package/runtime/codex/bin/lib/reflexes.py +267 -0
- package/runtime/codex/bin/lib/retrieval_scorecard.py +624 -0
- package/runtime/codex/bin/lib/session-record-prompt.txt +18 -0
- package/runtime/codex/bin/lib/session_record.py +3747 -0
- package/runtime/codex/bin/lib/session_trail.py +1983 -0
- package/runtime/codex/bin/lib/settings-drift.sh +8 -5
- package/runtime/codex/bin/lib/startup-restart.sh +41 -0
- package/runtime/codex/bin/lib/worktree-links.sh +92 -0
- package/runtime/codex/bin/memory-push.sh +6 -0
- package/runtime/codex/bin/merge-gate.sh +498 -0
- package/runtime/codex/bin/notify.sh +5 -2
- package/runtime/codex/bin/page-access.sh +103 -0
- package/runtime/codex/bin/person.sh +13 -2
- package/runtime/codex/bin/pi-render-spec.mjs +0 -1
- package/runtime/codex/bin/pi-spirit-discovery.mjs +34 -0
- package/runtime/codex/bin/post-update.sh +181 -0
- package/runtime/codex/bin/prime-render-spec.mjs +0 -1
- package/runtime/codex/bin/publish-artifact.sh +89 -42
- package/runtime/codex/bin/qa-inventory.sh +2 -2
- package/runtime/codex/bin/quest.sh +1 -1
- package/runtime/codex/bin/records.sh +11 -0
- package/runtime/codex/bin/reflexes/05-vitals.sh +9 -0
- package/runtime/codex/bin/reflexes/10-open-items.sh +12 -0
- package/runtime/codex/bin/reflexes/15-retrieval.sh +8 -0
- package/runtime/codex/bin/reflexes/20-nightly.sh +100 -0
- package/runtime/codex/bin/reflexes/20-sandbox.sh +85 -0
- package/runtime/codex/bin/reflexes/30-cost.py +116 -0
- package/runtime/codex/bin/reflexes/40-shells.py +51 -0
- package/runtime/codex/bin/reflexes/README.md +61 -0
- package/runtime/codex/bin/reflexes.sh +7 -0
- package/runtime/codex/bin/runtime-identity.sh +140 -42
- package/runtime/codex/bin/screenshot.sh +77 -0
- package/runtime/codex/bin/scroll.sh +1 -1
- package/runtime/codex/bin/search.sh +31 -5
- package/runtime/codex/bin/session-autosave.sh +6 -3
- package/runtime/codex/bin/session-id.sh +147 -0
- package/runtime/codex/bin/session-log.sh +19 -0
- package/runtime/codex/bin/session-record.sh +26 -0
- package/runtime/codex/bin/settings.sh +13 -8
- package/runtime/codex/bin/spirit-discovery-claude.sh +4 -0
- package/runtime/codex/bin/spirit-discovery-codex.sh +4 -0
- package/runtime/codex/bin/spirit-discovery-pi.sh +4 -0
- package/runtime/codex/bin/spirit-discovery-prime.sh +4 -0
- package/runtime/codex/bin/spirit-discovery.sh +4 -0
- package/runtime/codex/bin/startup-check.sh +1 -1
- package/runtime/codex/bin/telemetry.sh +11 -4
- package/runtime/codex/bin/test-baseline.sh +33 -5
- package/runtime/codex/bin/todo.sh +1 -1
- package/runtime/codex/bin/worktree-create.sh +15 -45
- package/runtime/codex/bin/worktree.sh +6 -0
- package/runtime/codex/docs/spirit-discovery.md +96 -0
- package/runtime/codex/egregore_runtime/__init__.py +18 -12
- package/runtime/codex/egregore_runtime/access.py +608 -0
- package/runtime/codex/egregore_runtime/access_cli.py +190 -0
- package/runtime/codex/egregore_runtime/adapters/invitation.py +5 -3
- package/runtime/codex/egregore_runtime/adapters/qmd.py +521 -47
- package/runtime/codex/egregore_runtime/admin_entry.py +56 -0
- package/runtime/codex/egregore_runtime/artifacts.py +222 -16
- package/runtime/codex/egregore_runtime/contracts.py +27 -0
- package/runtime/codex/egregore_runtime/eligibility.py +5 -12
- package/runtime/codex/egregore_runtime/episode_binding.py +16 -5
- package/runtime/codex/egregore_runtime/github_issues.py +2 -2
- package/runtime/codex/egregore_runtime/handoff_lookup.py +9 -20
- package/runtime/codex/egregore_runtime/harness_cli.py +674 -149
- package/runtime/codex/egregore_runtime/held.py +2436 -0
- package/runtime/codex/egregore_runtime/hosted_api.py +137 -0
- package/runtime/codex/egregore_runtime/identity.py +196 -65
- package/runtime/codex/egregore_runtime/identity_transfer.py +6 -2
- package/runtime/codex/egregore_runtime/ingest_cli.py +2 -2
- package/runtime/codex/egregore_runtime/ingest_index_cli.py +4 -16
- package/runtime/codex/egregore_runtime/installation.py +119 -0
- package/runtime/codex/egregore_runtime/investigation.py +64 -41
- package/runtime/codex/egregore_runtime/investigation_guidance.py +16 -11
- package/runtime/codex/egregore_runtime/issue_cli.py +51 -32
- package/runtime/codex/egregore_runtime/issue_reporting.py +4 -3
- package/runtime/codex/egregore_runtime/issue_triage_service.py +2 -2
- package/runtime/codex/egregore_runtime/issues.py +12 -6
- package/runtime/codex/egregore_runtime/knowledge.py +331 -5
- package/runtime/codex/egregore_runtime/knowledge_cli.py +57 -17
- package/runtime/codex/egregore_runtime/lifecycle.py +88 -22
- package/runtime/codex/egregore_runtime/lifecycle_cli.py +21 -13
- package/runtime/codex/egregore_runtime/line_ids.py +10 -0
- package/runtime/codex/egregore_runtime/memory_push.py +349 -0
- package/runtime/codex/egregore_runtime/observe.py +64 -25
- package/runtime/codex/egregore_runtime/observers.py +19 -0
- package/runtime/codex/egregore_runtime/optional_modules.py +13 -0
- package/runtime/codex/egregore_runtime/outputs_cli.py +321 -0
- package/runtime/codex/egregore_runtime/policy.py +651 -24
- package/runtime/codex/egregore_runtime/private_result.py +2 -1
- package/runtime/codex/egregore_runtime/projection.py +946 -0
- package/runtime/codex/egregore_runtime/quest_cli.py +20 -23
- package/runtime/codex/egregore_runtime/question_cli.py +15 -11
- package/runtime/codex/egregore_runtime/questions.py +10 -10
- package/runtime/codex/egregore_runtime/quests.py +10 -10
- package/runtime/codex/egregore_runtime/record_publish.py +548 -0
- package/runtime/codex/egregore_runtime/record_subject.py +564 -0
- package/runtime/codex/egregore_runtime/research_ingest.py +2 -4
- package/runtime/codex/egregore_runtime/runtime.py +169 -60
- package/runtime/codex/egregore_runtime/sandbox.py +194 -0
- package/runtime/codex/egregore_runtime/scroll_cli.py +22 -32
- package/runtime/codex/egregore_runtime/scrolls.py +23 -15
- package/runtime/codex/egregore_runtime/shadow.py +262 -0
- package/runtime/codex/egregore_runtime/spirit_discovery.py +487 -0
- package/runtime/codex/egregore_runtime/spirit_discovery_cli.py +25 -0
- package/runtime/codex/egregore_runtime/spirit_discovery_transport.py +18 -0
- package/runtime/codex/egregore_runtime/status.py +101 -44
- package/runtime/codex/egregore_runtime/status_cli.py +13 -5
- package/runtime/codex/egregore_runtime/sync.py +39 -21
- package/runtime/codex/egregore_runtime/taste.py +934 -0
- package/runtime/codex/egregore_runtime/taste_cli.py +242 -0
- package/runtime/codex/egregore_runtime/taste_rules.py +91 -0
- package/runtime/codex/egregore_runtime/telemetry.py +26 -3
- package/runtime/codex/egregore_runtime/todo_cli.py +15 -12
- package/runtime/codex/egregore_runtime/todos.py +3 -2
- package/runtime/codex/egregore_runtime/upgrade.py +85 -58
- package/runtime/codex/egregore_runtime/writeback.py +330 -24
- package/runtime/codex/manifest.json +66 -5
- package/runtime/pi/.claude/context/handoff-claiming.md +32 -0
- package/runtime/pi/.claude/context/notification-consent.md +12 -9
- package/runtime/pi/.claude/skills/view/SKILL.md +438 -0
- package/runtime/pi/.codex/hooks.json +11 -0
- package/runtime/pi/.codex/skills/dark-mode/SKILL.md +1 -1
- package/runtime/pi/.codex/skills/records/SKILL.md +29 -0
- package/runtime/pi/.codex/skills/summon/SKILL.md +1 -1
- package/runtime/pi/.codex/skills/view/assets/compose-scaffold.html +19 -13
- package/runtime/pi/.codex/spec-manifest.json +13 -19
- package/runtime/pi/.pi/APPEND_SYSTEM.md +53 -99
- package/runtime/pi/.pi/extensions/egregore.ts +13 -1
- package/runtime/pi/.pi/spec-manifest.json +2 -2
- package/runtime/pi/AGENTS.md +54 -105
- package/runtime/pi/bin/agent.sh +231 -7
- package/runtime/pi/bin/api-call.sh +44 -23
- package/runtime/pi/bin/artifact-register.sh +7 -1
- package/runtime/pi/bin/artifact-writeback.sh +1 -1
- package/runtime/pi/bin/artifacts.sh +66 -265
- package/runtime/pi/bin/attendant.sh +4 -1
- package/runtime/pi/bin/boundary-guard.cjs +1848 -0
- package/runtime/pi/bin/boundary-hook.sh +41 -0
- package/runtime/pi/bin/capture-run.sh +32 -2
- package/runtime/pi/bin/codex-render-spec.mjs +24 -46
- package/runtime/pi/bin/codex-session-start.sh +19 -0
- package/runtime/pi/bin/codex-skill-render.mjs +18 -0
- package/runtime/pi/bin/config-get.sh +10 -1
- package/runtime/pi/bin/connect-refresh.sh +16 -0
- package/runtime/pi/bin/corpus_map.py +5 -0
- package/runtime/pi/bin/data/capabilities/pi.json +1458 -0
- package/runtime/pi/bin/genui.sh +19 -0
- package/runtime/pi/bin/graph-batch.sh +1 -1
- package/runtime/pi/bin/graph-op.sh +14 -0
- package/runtime/pi/bin/graph.sh +1 -1
- package/runtime/pi/bin/handoff-pr-backfill.sh +1 -1
- package/runtime/pi/bin/handoff-run.sh +11 -6
- package/runtime/pi/bin/ingest-item.sh +1 -1
- package/runtime/pi/bin/ingest-surface.html +22 -4
- package/runtime/pi/bin/issue.sh +1 -1
- package/runtime/pi/bin/knowledge.sh +1 -1
- package/runtime/pi/bin/lib/card-delivery.sh +329 -0
- package/runtime/pi/bin/lib/config.sh +148 -3
- package/runtime/pi/bin/lib/context.sh +38 -67
- package/runtime/pi/bin/lib/genui-entry.py +36 -0
- package/runtime/pi/bin/lib/git-message.sh +25 -6
- package/runtime/pi/bin/lib/git-sync.sh +12 -4
- package/runtime/pi/bin/lib/greeting.sh +255 -28
- package/runtime/pi/bin/lib/handoff_meta.py +294 -0
- package/runtime/pi/bin/lib/health-footer.sh +83 -2
- package/runtime/pi/bin/lib/identity.sh +31 -10
- package/runtime/pi/bin/lib/instance-registry.sh +71 -0
- package/runtime/pi/bin/lib/local_ci_receipt.py +249 -0
- package/runtime/pi/bin/lib/metrics.sh +121 -8
- package/runtime/pi/bin/lib/notices.sh +36 -0
- package/runtime/pi/bin/lib/record_outcomes.py +729 -0
- package/runtime/pi/bin/lib/reflexes.py +267 -0
- package/runtime/pi/bin/lib/retrieval_scorecard.py +624 -0
- package/runtime/pi/bin/lib/session-record-prompt.txt +18 -0
- package/runtime/pi/bin/lib/session_record.py +3747 -0
- package/runtime/pi/bin/lib/session_trail.py +1983 -0
- package/runtime/pi/bin/lib/settings-drift.sh +8 -5
- package/runtime/pi/bin/lib/startup-restart.sh +41 -0
- package/runtime/pi/bin/lib/worktree-links.sh +92 -0
- package/runtime/pi/bin/memory-push.sh +6 -0
- package/runtime/pi/bin/merge-gate.sh +498 -0
- package/runtime/pi/bin/notify.sh +5 -2
- package/runtime/pi/bin/page-access.sh +103 -0
- package/runtime/pi/bin/person.sh +13 -2
- package/runtime/pi/bin/pi-branch-policy.mjs +37 -0
- package/runtime/pi/bin/pi-observe-context.mjs +97 -4
- package/runtime/pi/bin/pi-render-spec.mjs +0 -1
- package/runtime/pi/bin/pi-session-start.sh +4 -1
- package/runtime/pi/bin/pi-spirit-discovery.mjs +34 -0
- package/runtime/pi/bin/post-update.sh +181 -0
- package/runtime/pi/bin/prime-render-spec.mjs +0 -1
- package/runtime/pi/bin/publish-artifact.sh +89 -42
- package/runtime/pi/bin/qa-inventory.sh +2 -2
- package/runtime/pi/bin/quest.sh +1 -1
- package/runtime/pi/bin/records.sh +11 -0
- package/runtime/pi/bin/reflexes/05-vitals.sh +9 -0
- package/runtime/pi/bin/reflexes/10-open-items.sh +12 -0
- package/runtime/pi/bin/reflexes/15-retrieval.sh +8 -0
- package/runtime/pi/bin/reflexes/20-nightly.sh +100 -0
- package/runtime/pi/bin/reflexes/20-sandbox.sh +85 -0
- package/runtime/pi/bin/reflexes/30-cost.py +116 -0
- package/runtime/pi/bin/reflexes/40-shells.py +51 -0
- package/runtime/pi/bin/reflexes/README.md +61 -0
- package/runtime/pi/bin/reflexes.sh +7 -0
- package/runtime/pi/bin/runtime-identity.sh +140 -42
- package/runtime/pi/bin/screenshot.sh +77 -0
- package/runtime/pi/bin/scroll.sh +1 -1
- package/runtime/pi/bin/search.sh +31 -5
- package/runtime/pi/bin/session-autosave.sh +6 -3
- package/runtime/pi/bin/session-id.sh +147 -0
- package/runtime/pi/bin/session-log.sh +19 -0
- package/runtime/pi/bin/session-record.sh +26 -0
- package/runtime/pi/bin/settings.sh +13 -8
- package/runtime/pi/bin/spirit-discovery-claude.sh +4 -0
- package/runtime/pi/bin/spirit-discovery-codex.sh +4 -0
- package/runtime/pi/bin/spirit-discovery-pi.sh +4 -0
- package/runtime/pi/bin/spirit-discovery-prime.sh +4 -0
- package/runtime/pi/bin/spirit-discovery.sh +4 -0
- package/runtime/pi/bin/startup-check.sh +1 -1
- package/runtime/pi/bin/telemetry.sh +11 -4
- package/runtime/pi/bin/test-baseline.sh +33 -5
- package/runtime/pi/bin/todo.sh +1 -1
- package/runtime/pi/bin/worktree-create.sh +15 -45
- package/runtime/pi/bin/worktree.sh +6 -0
- package/runtime/pi/docs/spirit-discovery.md +96 -0
- package/runtime/pi/egregore_runtime/__init__.py +18 -12
- package/runtime/pi/egregore_runtime/access.py +608 -0
- package/runtime/pi/egregore_runtime/access_cli.py +190 -0
- package/runtime/pi/egregore_runtime/adapters/invitation.py +5 -3
- package/runtime/pi/egregore_runtime/adapters/qmd.py +521 -47
- package/runtime/pi/egregore_runtime/admin_entry.py +56 -0
- package/runtime/pi/egregore_runtime/artifacts.py +222 -16
- package/runtime/pi/egregore_runtime/contracts.py +27 -0
- package/runtime/pi/egregore_runtime/eligibility.py +5 -12
- package/runtime/pi/egregore_runtime/episode_binding.py +16 -5
- package/runtime/pi/egregore_runtime/github_issues.py +2 -2
- package/runtime/pi/egregore_runtime/handoff_lookup.py +9 -20
- package/runtime/pi/egregore_runtime/harness_cli.py +674 -149
- package/runtime/pi/egregore_runtime/held.py +2436 -0
- package/runtime/pi/egregore_runtime/hosted_api.py +137 -0
- package/runtime/pi/egregore_runtime/identity.py +196 -65
- package/runtime/pi/egregore_runtime/identity_transfer.py +6 -2
- package/runtime/pi/egregore_runtime/ingest_cli.py +2 -2
- package/runtime/pi/egregore_runtime/ingest_index_cli.py +4 -16
- package/runtime/pi/egregore_runtime/installation.py +119 -0
- package/runtime/pi/egregore_runtime/investigation.py +64 -41
- package/runtime/pi/egregore_runtime/investigation_guidance.py +16 -11
- package/runtime/pi/egregore_runtime/issue_cli.py +51 -32
- package/runtime/pi/egregore_runtime/issue_reporting.py +4 -3
- package/runtime/pi/egregore_runtime/issue_triage_service.py +2 -2
- package/runtime/pi/egregore_runtime/issues.py +12 -6
- package/runtime/pi/egregore_runtime/knowledge.py +331 -5
- package/runtime/pi/egregore_runtime/knowledge_cli.py +57 -17
- package/runtime/pi/egregore_runtime/lifecycle.py +88 -22
- package/runtime/pi/egregore_runtime/lifecycle_cli.py +21 -13
- package/runtime/pi/egregore_runtime/line_ids.py +10 -0
- package/runtime/pi/egregore_runtime/memory_push.py +349 -0
- package/runtime/pi/egregore_runtime/observe.py +64 -25
- package/runtime/pi/egregore_runtime/observers.py +19 -0
- package/runtime/pi/egregore_runtime/optional_modules.py +13 -0
- package/runtime/pi/egregore_runtime/outputs_cli.py +321 -0
- package/runtime/pi/egregore_runtime/policy.py +651 -24
- package/runtime/pi/egregore_runtime/private_result.py +2 -1
- package/runtime/pi/egregore_runtime/projection.py +946 -0
- package/runtime/pi/egregore_runtime/quest_cli.py +20 -23
- package/runtime/pi/egregore_runtime/question_cli.py +15 -11
- package/runtime/pi/egregore_runtime/questions.py +10 -10
- package/runtime/pi/egregore_runtime/quests.py +10 -10
- package/runtime/pi/egregore_runtime/record_publish.py +548 -0
- package/runtime/pi/egregore_runtime/record_subject.py +564 -0
- package/runtime/pi/egregore_runtime/research_ingest.py +2 -4
- package/runtime/pi/egregore_runtime/runtime.py +169 -60
- package/runtime/pi/egregore_runtime/sandbox.py +194 -0
- package/runtime/pi/egregore_runtime/scroll_cli.py +22 -32
- package/runtime/pi/egregore_runtime/scrolls.py +23 -15
- package/runtime/pi/egregore_runtime/shadow.py +262 -0
- package/runtime/pi/egregore_runtime/spirit_discovery.py +487 -0
- package/runtime/pi/egregore_runtime/spirit_discovery_cli.py +25 -0
- package/runtime/pi/egregore_runtime/spirit_discovery_transport.py +18 -0
- package/runtime/pi/egregore_runtime/status.py +101 -44
- package/runtime/pi/egregore_runtime/status_cli.py +13 -5
- package/runtime/pi/egregore_runtime/sync.py +39 -21
- package/runtime/pi/egregore_runtime/taste.py +934 -0
- package/runtime/pi/egregore_runtime/taste_cli.py +242 -0
- package/runtime/pi/egregore_runtime/taste_rules.py +91 -0
- package/runtime/pi/egregore_runtime/telemetry.py +26 -3
- package/runtime/pi/egregore_runtime/todo_cli.py +15 -12
- package/runtime/pi/egregore_runtime/todos.py +3 -2
- package/runtime/pi/egregore_runtime/upgrade.py +85 -58
- package/runtime/pi/egregore_runtime/writeback.py +330 -24
- package/runtime/pi/manifest.json +66 -5
- package/runtime/prime/.claude/context/handoff-claiming.md +32 -0
- package/runtime/prime/.claude/context/notification-consent.md +12 -9
- package/runtime/prime/.claude/skills/view/SKILL.md +438 -0
- package/runtime/prime/.codex/hooks.json +11 -0
- package/runtime/prime/.codex/skills/dark-mode/SKILL.md +1 -1
- package/runtime/prime/.codex/skills/records/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/summon/SKILL.md +1 -1
- package/runtime/prime/.codex/skills/view/assets/compose-scaffold.html +19 -13
- package/runtime/prime/.codex/spec-manifest.json +13 -19
- package/runtime/prime/.prime/agent/APPEND_SYSTEM.md +53 -99
- package/runtime/prime/.prime/agent/extensions/egregore.ts +14 -1
- package/runtime/prime/.prime/agent/skills/egregore/SKILL.md +14 -6
- package/runtime/prime/.prime/agent/skills/egregore/src/egregore/__init__.py +66 -1
- package/runtime/prime/.prime/agent/spec-manifest.json +2 -2
- package/runtime/prime/AGENTS.md +54 -105
- package/runtime/prime/bin/agent.sh +231 -7
- package/runtime/prime/bin/api-call.sh +44 -23
- package/runtime/prime/bin/artifact-register.sh +7 -1
- package/runtime/prime/bin/artifact-writeback.sh +1 -1
- package/runtime/prime/bin/artifacts.sh +66 -265
- package/runtime/prime/bin/attendant.sh +4 -1
- package/runtime/prime/bin/boundary-guard.cjs +1848 -0
- package/runtime/prime/bin/boundary-hook.sh +41 -0
- package/runtime/prime/bin/capture-run.sh +32 -2
- package/runtime/prime/bin/codex-render-spec.mjs +24 -46
- package/runtime/prime/bin/codex-session-start.sh +19 -0
- package/runtime/prime/bin/codex-skill-render.mjs +18 -0
- package/runtime/prime/bin/config-get.sh +10 -1
- package/runtime/prime/bin/connect-refresh.sh +16 -0
- package/runtime/prime/bin/corpus_map.py +5 -0
- package/runtime/prime/bin/data/capabilities/prime.json +1458 -0
- package/runtime/prime/bin/genui.sh +19 -0
- package/runtime/prime/bin/graph-batch.sh +1 -1
- package/runtime/prime/bin/graph-op.sh +14 -0
- package/runtime/prime/bin/graph.sh +1 -1
- package/runtime/prime/bin/handoff-pr-backfill.sh +1 -1
- package/runtime/prime/bin/handoff-run.sh +11 -6
- package/runtime/prime/bin/ingest-item.sh +1 -1
- package/runtime/prime/bin/ingest-surface.html +22 -4
- package/runtime/prime/bin/issue.sh +1 -1
- package/runtime/prime/bin/knowledge.sh +1 -1
- package/runtime/prime/bin/lib/card-delivery.sh +329 -0
- package/runtime/prime/bin/lib/config.sh +148 -3
- package/runtime/prime/bin/lib/context.sh +38 -67
- package/runtime/prime/bin/lib/genui-entry.py +36 -0
- package/runtime/prime/bin/lib/git-message.sh +25 -6
- package/runtime/prime/bin/lib/git-sync.sh +12 -4
- package/runtime/prime/bin/lib/greeting.sh +255 -28
- package/runtime/prime/bin/lib/handoff_meta.py +294 -0
- package/runtime/prime/bin/lib/health-footer.sh +83 -2
- package/runtime/prime/bin/lib/identity.sh +31 -10
- package/runtime/prime/bin/lib/instance-registry.sh +71 -0
- package/runtime/prime/bin/lib/local_ci_receipt.py +249 -0
- package/runtime/prime/bin/lib/metrics.sh +121 -8
- package/runtime/prime/bin/lib/notices.sh +36 -0
- package/runtime/prime/bin/lib/record_outcomes.py +729 -0
- package/runtime/prime/bin/lib/reflexes.py +267 -0
- package/runtime/prime/bin/lib/retrieval_scorecard.py +624 -0
- package/runtime/prime/bin/lib/session-record-prompt.txt +18 -0
- package/runtime/prime/bin/lib/session_record.py +3747 -0
- package/runtime/prime/bin/lib/session_trail.py +1983 -0
- package/runtime/prime/bin/lib/settings-drift.sh +8 -5
- package/runtime/prime/bin/lib/startup-restart.sh +41 -0
- package/runtime/prime/bin/lib/worktree-links.sh +92 -0
- package/runtime/prime/bin/memory-push.sh +6 -0
- package/runtime/prime/bin/merge-gate.sh +498 -0
- package/runtime/prime/bin/notify.sh +5 -2
- package/runtime/prime/bin/page-access.sh +103 -0
- package/runtime/prime/bin/person.sh +13 -2
- package/runtime/prime/bin/pi-branch-policy.mjs +37 -0
- package/runtime/prime/bin/pi-observe-context.mjs +97 -4
- package/runtime/prime/bin/pi-render-spec.mjs +0 -1
- package/runtime/prime/bin/pi-spirit-discovery.mjs +34 -0
- package/runtime/prime/bin/post-update.sh +181 -0
- package/runtime/prime/bin/prime-render-spec.mjs +0 -1
- package/runtime/prime/bin/prime-session-start.sh +3 -1
- package/runtime/prime/bin/publish-artifact.sh +89 -42
- package/runtime/prime/bin/qa-inventory.sh +2 -2
- package/runtime/prime/bin/quest.sh +1 -1
- package/runtime/prime/bin/records.sh +11 -0
- package/runtime/prime/bin/reflexes/05-vitals.sh +9 -0
- package/runtime/prime/bin/reflexes/10-open-items.sh +12 -0
- package/runtime/prime/bin/reflexes/15-retrieval.sh +8 -0
- package/runtime/prime/bin/reflexes/20-nightly.sh +100 -0
- package/runtime/prime/bin/reflexes/20-sandbox.sh +85 -0
- package/runtime/prime/bin/reflexes/30-cost.py +116 -0
- package/runtime/prime/bin/reflexes/40-shells.py +51 -0
- package/runtime/prime/bin/reflexes/README.md +61 -0
- package/runtime/prime/bin/reflexes.sh +7 -0
- package/runtime/prime/bin/runtime-identity.sh +140 -42
- package/runtime/prime/bin/screenshot.sh +77 -0
- package/runtime/prime/bin/scroll.sh +1 -1
- package/runtime/prime/bin/search.sh +31 -5
- package/runtime/prime/bin/session-autosave.sh +6 -3
- package/runtime/prime/bin/session-id.sh +147 -0
- package/runtime/prime/bin/session-log.sh +19 -0
- package/runtime/prime/bin/session-record.sh +26 -0
- package/runtime/prime/bin/settings.sh +13 -8
- package/runtime/prime/bin/spirit-discovery-claude.sh +4 -0
- package/runtime/prime/bin/spirit-discovery-codex.sh +4 -0
- package/runtime/prime/bin/spirit-discovery-pi.sh +4 -0
- package/runtime/prime/bin/spirit-discovery-prime.sh +4 -0
- package/runtime/prime/bin/spirit-discovery.sh +4 -0
- package/runtime/prime/bin/startup-check.sh +1 -1
- package/runtime/prime/bin/telemetry.sh +11 -4
- package/runtime/prime/bin/test-baseline.sh +33 -5
- package/runtime/prime/bin/todo.sh +1 -1
- package/runtime/prime/bin/worktree-create.sh +15 -45
- package/runtime/prime/bin/worktree.sh +6 -0
- package/runtime/prime/docs/spirit-discovery.md +96 -0
- package/runtime/prime/egregore_runtime/__init__.py +18 -12
- package/runtime/prime/egregore_runtime/access.py +608 -0
- package/runtime/prime/egregore_runtime/access_cli.py +190 -0
- package/runtime/prime/egregore_runtime/adapters/invitation.py +5 -3
- package/runtime/prime/egregore_runtime/adapters/qmd.py +521 -47
- package/runtime/prime/egregore_runtime/admin_entry.py +56 -0
- package/runtime/prime/egregore_runtime/artifacts.py +222 -16
- package/runtime/prime/egregore_runtime/contracts.py +27 -0
- package/runtime/prime/egregore_runtime/eligibility.py +5 -12
- package/runtime/prime/egregore_runtime/episode_binding.py +16 -5
- package/runtime/prime/egregore_runtime/github_issues.py +2 -2
- package/runtime/prime/egregore_runtime/handoff_lookup.py +9 -20
- package/runtime/prime/egregore_runtime/harness_cli.py +674 -149
- package/runtime/prime/egregore_runtime/held.py +2436 -0
- package/runtime/prime/egregore_runtime/hosted_api.py +137 -0
- package/runtime/prime/egregore_runtime/identity.py +196 -65
- package/runtime/prime/egregore_runtime/identity_transfer.py +6 -2
- package/runtime/prime/egregore_runtime/ingest_cli.py +2 -2
- package/runtime/prime/egregore_runtime/ingest_index_cli.py +4 -16
- package/runtime/prime/egregore_runtime/installation.py +119 -0
- package/runtime/prime/egregore_runtime/investigation.py +64 -41
- package/runtime/prime/egregore_runtime/investigation_guidance.py +16 -11
- package/runtime/prime/egregore_runtime/issue_cli.py +51 -32
- package/runtime/prime/egregore_runtime/issue_reporting.py +4 -3
- package/runtime/prime/egregore_runtime/issue_triage_service.py +2 -2
- package/runtime/prime/egregore_runtime/issues.py +12 -6
- package/runtime/prime/egregore_runtime/knowledge.py +331 -5
- package/runtime/prime/egregore_runtime/knowledge_cli.py +57 -17
- package/runtime/prime/egregore_runtime/lifecycle.py +88 -22
- package/runtime/prime/egregore_runtime/lifecycle_cli.py +21 -13
- package/runtime/prime/egregore_runtime/line_ids.py +10 -0
- package/runtime/prime/egregore_runtime/memory_push.py +349 -0
- package/runtime/prime/egregore_runtime/observe.py +64 -25
- package/runtime/prime/egregore_runtime/observers.py +19 -0
- package/runtime/prime/egregore_runtime/optional_modules.py +13 -0
- package/runtime/prime/egregore_runtime/outputs_cli.py +321 -0
- package/runtime/prime/egregore_runtime/policy.py +651 -24
- package/runtime/prime/egregore_runtime/private_result.py +2 -1
- package/runtime/prime/egregore_runtime/projection.py +946 -0
- package/runtime/prime/egregore_runtime/quest_cli.py +20 -23
- package/runtime/prime/egregore_runtime/question_cli.py +15 -11
- package/runtime/prime/egregore_runtime/questions.py +10 -10
- package/runtime/prime/egregore_runtime/quests.py +10 -10
- package/runtime/prime/egregore_runtime/record_publish.py +548 -0
- package/runtime/prime/egregore_runtime/record_subject.py +564 -0
- package/runtime/prime/egregore_runtime/research_ingest.py +2 -4
- package/runtime/prime/egregore_runtime/runtime.py +169 -60
- package/runtime/prime/egregore_runtime/sandbox.py +194 -0
- package/runtime/prime/egregore_runtime/scroll_cli.py +22 -32
- package/runtime/prime/egregore_runtime/scrolls.py +23 -15
- package/runtime/prime/egregore_runtime/shadow.py +262 -0
- package/runtime/prime/egregore_runtime/spirit_discovery.py +487 -0
- package/runtime/prime/egregore_runtime/spirit_discovery_cli.py +25 -0
- package/runtime/prime/egregore_runtime/spirit_discovery_transport.py +18 -0
- package/runtime/prime/egregore_runtime/status.py +101 -44
- package/runtime/prime/egregore_runtime/status_cli.py +13 -5
- package/runtime/prime/egregore_runtime/sync.py +39 -21
- package/runtime/prime/egregore_runtime/taste.py +934 -0
- package/runtime/prime/egregore_runtime/taste_cli.py +242 -0
- package/runtime/prime/egregore_runtime/taste_rules.py +91 -0
- package/runtime/prime/egregore_runtime/telemetry.py +26 -3
- package/runtime/prime/egregore_runtime/todo_cli.py +15 -12
- package/runtime/prime/egregore_runtime/todos.py +3 -2
- package/runtime/prime/egregore_runtime/upgrade.py +85 -58
- package/runtime/prime/egregore_runtime/writeback.py +330 -24
- package/runtime/prime/manifest.json +66 -5
- package/runtime/claude/bin/thread.sh +0 -9
- package/runtime/claude/egregore_runtime/admin_gate.py +0 -102
- package/runtime/claude/egregore_runtime/thread_cli.py +0 -173
- package/runtime/claude/egregore_runtime/threads.py +0 -521
- package/runtime/codex/bin/thread.sh +0 -9
- package/runtime/codex/egregore_runtime/admin_gate.py +0 -102
- package/runtime/codex/egregore_runtime/thread_cli.py +0 -173
- package/runtime/codex/egregore_runtime/threads.py +0 -521
- package/runtime/pi/bin/thread.sh +0 -9
- package/runtime/pi/egregore_runtime/admin_gate.py +0 -102
- package/runtime/pi/egregore_runtime/thread_cli.py +0 -173
- package/runtime/pi/egregore_runtime/threads.py +0 -521
- package/runtime/prime/bin/thread.sh +0 -9
- package/runtime/prime/egregore_runtime/admin_gate.py +0 -102
- package/runtime/prime/egregore_runtime/thread_cli.py +0 -173
- 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)
|