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