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