@zerwiz/ymir 0.1.4 → 0.1.7
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/.agents/README.md +291 -0
- package/.agents/agents/bragi-marketer.md +111 -0
- package/.agents/agents/brokk.md +352 -0
- package/.agents/agents/forseti-reviewer.md +93 -0
- package/.agents/agents/frigg-consent.md +77 -0
- package/.agents/agents/groa-update.md +77 -0
- package/.agents/agents/hnoss-designer.md +69 -0
- package/.agents/agents/huginn-researcher.md +99 -0
- package/.agents/agents/jord-projects.md +78 -0
- package/.agents/agents/kvasir-scout.md +91 -0
- package/.agents/agents/mimir-planner.md +92 -0
- package/.agents/agents/muninn-stow.md +77 -0
- package/.agents/agents/saga-bearings.md +78 -0
- package/.agents/agents/sindri-developer.md +114 -0
- package/.agents/agents/snotra-documenter.md +105 -0
- package/.agents/agents/syn-recovery.md +78 -0
- package/.agents/agents/tyr-check.md +78 -0
- package/.agents/agents/urdh-hold.md +77 -0
- package/.agents/agents/volundr-smith.md +80 -0
- package/.agents/agents/vor-diagnostics.md +77 -0
- package/.agents/assets/agents/naming.md +50 -0
- package/.agents/assets/agents/registry.md +93 -0
- package/.agents/assets/agents/runtime.md +36 -0
- package/.agents/assets/agents/toon-tasks-cli.md +42 -0
- package/.agents/assets/templates/company_entity.template.md +37 -0
- package/.agents/backend/README.md +74 -0
- package/.agents/backend/backends/cmux.sh +649 -0
- package/.agents/backend/backends/herdr-eventwait.py +157 -0
- package/.agents/backend/backends/herdr-workspace-move.py +114 -0
- package/.agents/backend/backends/herdr.sh +3386 -0
- package/.agents/backend/backends/orca.sh +290 -0
- package/.agents/backend/backends/tmux.sh +357 -0
- package/.agents/backend/backends/zellij.sh +700 -0
- package/.agents/backend/fm-afk-launch.sh +654 -0
- package/.agents/backend/fm-afk-return.sh +241 -0
- package/.agents/backend/fm-afk-start.sh +172 -0
- package/.agents/backend/fm-arm-command-policy.mjs +960 -0
- package/.agents/backend/fm-arm-pretool-check.sh +200 -0
- package/.agents/backend/fm-backend-hometag-lib.sh +52 -0
- package/.agents/backend/fm-backend.sh +981 -0
- package/.agents/backend/fm-backlog-handoff.sh +909 -0
- package/.agents/backend/fm-backlog-receive.sh +187 -0
- package/.agents/backend/fm-backlog-transition-lib.sh +779 -0
- package/.agents/backend/fm-bearings-board.sh +204 -0
- package/.agents/backend/fm-bearings-snapshot.sh +546 -0
- package/.agents/backend/fm-bootstrap.sh +1568 -0
- package/.agents/backend/fm-branch-outcome.sh +203 -0
- package/.agents/backend/fm-branch-prompt.sh +101 -0
- package/.agents/backend/fm-brief.sh +452 -0
- package/.agents/backend/fm-busy-event.sh +234 -0
- package/.agents/backend/fm-busy-lib.sh +986 -0
- package/.agents/backend/fm-captain-hold.sh +1001 -0
- package/.agents/backend/fm-cd-command-policy.mjs +153 -0
- package/.agents/backend/fm-cd-pretool-check.sh +190 -0
- package/.agents/backend/fm-check-lib.sh +74 -0
- package/.agents/backend/fm-check-register.sh +42 -0
- package/.agents/backend/fm-check-unregister.sh +52 -0
- package/.agents/backend/fm-classify-lib.sh +1770 -0
- package/.agents/backend/fm-claude-stop-autoarm.sh +334 -0
- package/.agents/backend/fm-composer-lib.sh +1416 -0
- package/.agents/backend/fm-config-inherit-lib.sh +1202 -0
- package/.agents/backend/fm-config-push.sh +254 -0
- package/.agents/backend/fm-control-lib.sh +251 -0
- package/.agents/backend/fm-control.sh +878 -0
- package/.agents/backend/fm-crew-state.sh +625 -0
- package/.agents/backend/fm-cursor-lib.sh +243 -0
- package/.agents/backend/fm-decision-hold.sh +232 -0
- package/.agents/backend/fm-doc-audience-check.sh +269 -0
- package/.agents/backend/fm-dod-lib.sh +67 -0
- package/.agents/backend/fm-ensure-agents-md.sh +253 -0
- package/.agents/backend/fm-extension-launch-barrier.mjs +129 -0
- package/.agents/backend/fm-extension.mjs +2577 -0
- package/.agents/backend/fm-extension.sh +16 -0
- package/.agents/backend/fm-ff-lib.sh +420 -0
- package/.agents/backend/fm-fleet-snapshot.sh +1463 -0
- package/.agents/backend/fm-fleet-sync.sh +458 -0
- package/.agents/backend/fm-fleet-view.sh +96 -0
- package/.agents/backend/fm-gate-refuse-lib.sh +102 -0
- package/.agents/backend/fm-guard.sh +242 -0
- package/.agents/backend/fm-harness.sh +195 -0
- package/.agents/backend/fm-herdr-ci-cleanup.sh +115 -0
- package/.agents/backend/fm-herdr-lab.sh +346 -0
- package/.agents/backend/fm-herdr-session-cleanup.sh +337 -0
- package/.agents/backend/fm-home-seed.sh +984 -0
- package/.agents/backend/fm-home-summary-refresh.sh +255 -0
- package/.agents/backend/fm-hook-host-lib.sh +36 -0
- package/.agents/backend/fm-inactive-reconcile.sh +531 -0
- package/.agents/backend/fm-inbox.sh +399 -0
- package/.agents/backend/fm-install-actionlint.sh +84 -0
- package/.agents/backend/fm-install-herdr.sh +96 -0
- package/.agents/backend/fm-install-shellcheck.sh +84 -0
- package/.agents/backend/fm-install-treehouse.sh +95 -0
- package/.agents/backend/fm-kimi-turnend-hook.sh +276 -0
- package/.agents/backend/fm-lease-lib.sh +218 -0
- package/.agents/backend/fm-lease.sh +189 -0
- package/.agents/backend/fm-line-cap-lib.sh +51 -0
- package/.agents/backend/fm-lint-workflows.sh +137 -0
- package/.agents/backend/fm-lint.sh +591 -0
- package/.agents/backend/fm-lock-lib.sh +104 -0
- package/.agents/backend/fm-lock.sh +107 -0
- package/.agents/backend/fm-marker-lib.sh +12 -0
- package/.agents/backend/fm-merge-local.sh +74 -0
- package/.agents/backend/fm-merge-outcome-lib.sh +138 -0
- package/.agents/backend/fm-nm-run-lib.sh +124 -0
- package/.agents/backend/fm-on.sh +125 -0
- package/.agents/backend/fm-operational-input.sh +253 -0
- package/.agents/backend/fm-peek.sh +46 -0
- package/.agents/backend/fm-pending-reply-lib.sh +1409 -0
- package/.agents/backend/fm-pr-check.sh +135 -0
- package/.agents/backend/fm-pr-lib.sh +1016 -0
- package/.agents/backend/fm-pr-merge.sh +707 -0
- package/.agents/backend/fm-pr-poll.sh +110 -0
- package/.agents/backend/fm-primary-scope-lib.sh +33 -0
- package/.agents/backend/fm-procevent-extension-capture.pl +259 -0
- package/.agents/backend/fm-procevent-lavish.sh +628 -0
- package/.agents/backend/fm-procevent-lib.sh +892 -0
- package/.agents/backend/fm-procevent-quota.sh +290 -0
- package/.agents/backend/fm-procevent-remote-reply.sh +577 -0
- package/.agents/backend/fm-procevent-when.sh +504 -0
- package/.agents/backend/fm-procevent.sh +1466 -0
- package/.agents/backend/fm-project-mode.sh +92 -0
- package/.agents/backend/fm-project-origin-lib.sh +180 -0
- package/.agents/backend/fm-promote.sh +282 -0
- package/.agents/backend/fm-public-followup-emit.sh +260 -0
- package/.agents/backend/fm-public-followup-lib.sh +398 -0
- package/.agents/backend/fm-public-followup.sh +1330 -0
- package/.agents/backend/fm-push-transition-lib.sh +168 -0
- package/.agents/backend/fm-quota-axi-lib.sh +93 -0
- package/.agents/backend/fm-quota-choose.sh +384 -0
- package/.agents/backend/fm-remote-delta-read.sh +189 -0
- package/.agents/backend/fm-remote-doctor.sh +800 -0
- package/.agents/backend/fm-remote-entrypoint.sh +193 -0
- package/.agents/backend/fm-remote-file.sh +234 -0
- package/.agents/backend/fm-remote-home-provision.sh +259 -0
- package/.agents/backend/fm-remote-home-seed.sh +259 -0
- package/.agents/backend/fm-remote-inherit-push.sh +90 -0
- package/.agents/backend/fm-remote-inherit.sh +179 -0
- package/.agents/backend/fm-remote-job-lib.sh +1241 -0
- package/.agents/backend/fm-remote-job-reap-orphans.sh +137 -0
- package/.agents/backend/fm-remote-job-worker.sh +1124 -0
- package/.agents/backend/fm-remote-readiness-lib.sh +44 -0
- package/.agents/backend/fm-remote-secondmate-control.sh +340 -0
- package/.agents/backend/fm-review-diff.sh +158 -0
- package/.agents/backend/fm-secondmate-charter-lib.sh +44 -0
- package/.agents/backend/fm-secondmate-nudge-lib.sh +68 -0
- package/.agents/backend/fm-secondmate-parent-lib.sh +70 -0
- package/.agents/backend/fm-secondmate-reconcile.sh +371 -0
- package/.agents/backend/fm-secondmate-registry-lib.sh +311 -0
- package/.agents/backend/fm-secondmate-report.sh +88 -0
- package/.agents/backend/fm-send.sh +1059 -0
- package/.agents/backend/fm-session-lock-lib.sh +176 -0
- package/.agents/backend/fm-session-start.sh +970 -0
- package/.agents/backend/fm-sessionstart-cursor.sh +40 -0
- package/.agents/backend/fm-sessionstart-nudge.sh +45 -0
- package/.agents/backend/fm-sessionstart-run.sh +145 -0
- package/.agents/backend/fm-spawn.sh +3153 -0
- package/.agents/backend/fm-startup-memory-budget-lib.sh +224 -0
- package/.agents/backend/fm-startup-memory-budget.sh +94 -0
- package/.agents/backend/fm-startup-network.sh +642 -0
- package/.agents/backend/fm-stow-cascade.sh +251 -0
- package/.agents/backend/fm-subagent-pretool-check.sh +207 -0
- package/.agents/backend/fm-supervise-daemon.sh +1732 -0
- package/.agents/backend/fm-supervision-instructions.sh +215 -0
- package/.agents/backend/fm-supervision-lib.sh +90 -0
- package/.agents/backend/fm-supervisor-target-lib.sh +78 -0
- package/.agents/backend/fm-tangle-lib.sh +53 -0
- package/.agents/backend/fm-task-inbox-lib.sh +402 -0
- package/.agents/backend/fm-tasks-axi-lib.sh +123 -0
- package/.agents/backend/fm-teardown.sh +2922 -0
- package/.agents/backend/fm-test-isolation-proof.sh +588 -0
- package/.agents/backend/fm-test-run.sh +2191 -0
- package/.agents/backend/fm-timeout-lib.sh +141 -0
- package/.agents/backend/fm-timing-lib.sh +179 -0
- package/.agents/backend/fm-tmux-lib.sh +291 -0
- package/.agents/backend/fm-tool-update-check.sh +898 -0
- package/.agents/backend/fm-trace-context-lib.sh +227 -0
- package/.agents/backend/fm-transition-lib.sh +103 -0
- package/.agents/backend/fm-turnend-guard-cursor.sh +391 -0
- package/.agents/backend/fm-turnend-guard-grok.sh +90 -0
- package/.agents/backend/fm-turnend-guard.sh +432 -0
- package/.agents/backend/fm-update.sh +110 -0
- package/.agents/backend/fm-vendor-auth-probe.sh +190 -0
- package/.agents/backend/fm-voice-client.py +1373 -0
- package/.agents/backend/fm-voice-relay.py +1256 -0
- package/.agents/backend/fm-wake-drain.sh +592 -0
- package/.agents/backend/fm-wake-grant.sh +127 -0
- package/.agents/backend/fm-wake-lib.sh +1849 -0
- package/.agents/backend/fm-watch-arm.sh +602 -0
- package/.agents/backend/fm-watch-checkpoint.sh +109 -0
- package/.agents/backend/fm-watch.sh +1962 -0
- package/.agents/backend/fm-x-dismiss.sh +116 -0
- package/.agents/backend/fm-x-followup.sh +286 -0
- package/.agents/backend/fm-x-lib.sh +1000 -0
- package/.agents/backend/fm-x-link.sh +234 -0
- package/.agents/backend/fm-x-poll.sh +185 -0
- package/.agents/backend/fm-x-reply.sh +385 -0
- package/.agents/backend/fm_voice_frame.py +166 -0
- package/.agents/backend/fm_voice_records.py +574 -0
- package/.agents/backend/model-bridge.py +360 -0
- package/.agents/backend/opencode-go-bridge.py +30 -0
- package/.agents/bus/README.md +12 -0
- package/.agents/bus/messages.json +1 -0
- package/.agents/config/agents.machine.example.yaml +16 -0
- package/.agents/config/agents.yaml.example +72 -0
- package/.agents/config/crew-dispatch.json +22 -0
- package/.agents/config/cron.yaml +11 -0
- package/.agents/config/eindri-dispatch.json +67 -0
- package/.agents/config/eindri-harness +1 -0
- package/.agents/config/startup-memory-budget +1 -0
- package/.agents/filebrowser/.filebrowser.json +6 -0
- package/.agents/filebrowser/README.md +11 -0
- package/.agents/gateway/README.md +12 -0
- package/.agents/github/README.md +15 -0
- package/.agents/github/webhooks/.gitkeep +0 -0
- package/.agents/github/workflows/.gitkeep +0 -0
- package/.agents/harness/opencode/plugins/lib/rodd-operational-input.js +37 -0
- package/.agents/harness/opencode/plugins/package.json +4 -0
- package/.agents/harness/opencode/plugins/saga-sessionstart.js +77 -0
- package/.agents/harness/opencode/plugins/syn-cd-check.js +61 -0
- package/.agents/harness/opencode/plugins/syn-pretool-check.js +68 -0
- package/.agents/harness/opencode/plugins/syn-turnend-guard.js +105 -0
- package/.agents/harness/opencode/plugins/syn-watch-arm.js +521 -0
- package/.agents/memory/README.md +46 -0
- package/.agents/memory/runes_audit.md +5 -0
- package/.agents/migrations/0001-hodd-layout.sh +38 -0
- package/.agents/migrations/0002-a2a-mcp.sh +27 -0
- package/.agents/migrations/0003-private-data-separation.sh +121 -0
- package/.agents/migrations/0004-hoard-and-realms.sh +104 -0
- package/.agents/migrations/0005-roots-out-of-tree.sh +158 -0
- package/.agents/sandbox/Dockerfile.utgard +19 -0
- package/.agents/sandbox/README.md +14 -0
- package/.agents/sandbox/utgard.config.json +14 -0
- package/.agents/skills/README.md +59 -0
- package/.agents/skills/bragi-marketing/README.md +3 -0
- package/.agents/skills/bragi-marketing/SKILL.md +82 -0
- package/.agents/skills/eindri-homes/README.md +3 -0
- package/.agents/skills/eindri-homes/SKILL.md +291 -0
- package/.agents/skills/eindri-homes/assets/control-plane.md +122 -0
- package/.agents/skills/eindri-homes/assets/dispatch.md +80 -0
- package/.agents/skills/frigg-consent/README.md +3 -0
- package/.agents/skills/frigg-consent/SKILL.md +56 -0
- package/.agents/skills/galdr-ymirsystem/README.md +3 -0
- package/.agents/skills/galdr-ymirsystem/SKILL.md +159 -0
- package/.agents/skills/galdr-ymirsystem/assets/README.md +74 -0
- package/.agents/skills/galdr-ymirsystem/assets/brokk-distro-runtime.md +390 -0
- package/.agents/skills/galdr-ymirsystem/assets/build-method.md +113 -0
- package/.agents/skills/galdr-ymirsystem/assets/build-tool-categories.md +178 -0
- package/.agents/skills/galdr-ymirsystem/assets/eindri-orchestration.md +518 -0
- package/.agents/skills/galdr-ymirsystem/assets/eindri-profiles.md +136 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/README.md +736 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/claude-code.md +197 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/codex.md +191 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/cursor.md +193 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/opencode.md +262 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/pi.md +295 -0
- package/.agents/skills/galdr-ymirsystem/assets/hlidskjalf-ui.md +539 -0
- package/.agents/skills/galdr-ymirsystem/assets/installation.md +763 -0
- package/.agents/skills/galdr-ymirsystem/assets/local-models/HOST-DATA.example.md +72 -0
- package/.agents/skills/galdr-ymirsystem/assets/local-models.md +274 -0
- package/.agents/skills/galdr-ymirsystem/assets/memory-well.md +163 -0
- package/.agents/skills/galdr-ymirsystem/assets/nornir-jobs.md +415 -0
- package/.agents/skills/galdr-ymirsystem/assets/norse-naming.md +325 -0
- package/.agents/skills/galdr-ymirsystem/assets/odrerir-hall.md +133 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot/einherjar-spawn.schema.json +81 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot/herdr-profile.toml +75 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot/pi-profile.yml +101 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot/supervision-tree.yml +163 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot-guide.md +58 -0
- package/.agents/skills/galdr-ymirsystem/assets/porting-upstream-to-norse.md +301 -0
- package/.agents/skills/galdr-ymirsystem/assets/principles.md +385 -0
- package/.agents/skills/galdr-ymirsystem/assets/reference-adoption.md +99 -0
- package/.agents/skills/galdr-ymirsystem/assets/registry.md +379 -0
- package/.agents/skills/galdr-ymirsystem/assets/runtime-compliance.md +463 -0
- package/.agents/skills/galdr-ymirsystem/assets/runtime-components.md +553 -0
- package/.agents/skills/galdr-ymirsystem/assets/smidja.md +180 -0
- package/.agents/skills/galdr-ymirsystem/schemas/toon-schemas.md +190 -0
- package/.agents/skills/galdr-ymirsystem/scripts/README.md +45 -0
- package/.agents/skills/galdr-ymirsystem/scripts/bench-one.sh +248 -0
- package/.agents/skills/galdr-ymirsystem/scripts/compliance-check.sh +503 -0
- package/.agents/skills/galdr-ymirsystem/scripts/gpu-sample.sh +43 -0
- package/.agents/skills/galdr-ymirsystem/scripts/stop-all.sh +34 -0
- package/.agents/skills/galdr-ymirsystem/scripts/toon-check.py +188 -0
- package/.agents/skills/gjallarhorn-relay/README.md +3 -0
- package/.agents/skills/gjallarhorn-relay/SKILL.md +283 -0
- package/.agents/skills/groa-update/SKILL.md +90 -0
- package/.agents/skills/hamr-adapters/README.md +3 -0
- package/.agents/skills/hamr-adapters/SKILL.md +123 -0
- package/.agents/skills/hamr-adapters/references/common/control-and-recovery.md +37 -0
- package/.agents/skills/hamr-adapters/references/common/dispatch.md +32 -0
- package/.agents/skills/hamr-adapters/references/common/model-and-effort.md +42 -0
- package/.agents/skills/hamr-adapters/references/common/primary-hooks.md +40 -0
- package/.agents/skills/hamr-adapters/references/harness/claude.md +55 -0
- package/.agents/skills/hamr-adapters/references/harness/codex.md +43 -0
- package/.agents/skills/hamr-adapters/references/harness/cursor.md +75 -0
- package/.agents/skills/hamr-adapters/references/harness/grok.md +69 -0
- package/.agents/skills/hamr-adapters/references/harness/kimi.md +51 -0
- package/.agents/skills/hamr-adapters/references/harness/muse.md +70 -0
- package/.agents/skills/hamr-adapters/references/harness/opencode.md +42 -0
- package/.agents/skills/hamr-adapters/references/harness/pi.md +56 -0
- package/.agents/skills/herdr-panes/README.md +3 -0
- package/.agents/skills/herdr-panes/SKILL.md +92 -0
- package/.agents/skills/herdr-panes/assets/herdr-backend.md +346 -0
- package/.agents/skills/herdr-panes/assets/tmux-backend.md +115 -0
- package/.agents/skills/hnoss-design/DESIGN.md +19 -0
- package/.agents/skills/hnoss-design/README.md +3 -0
- package/.agents/skills/hnoss-design/SKILL.md +83 -0
- package/.agents/skills/hvild-afk/README.md +3 -0
- package/.agents/skills/hvild-afk/SKILL.md +241 -0
- package/.agents/skills/jord-projects/README.md +3 -0
- package/.agents/skills/jord-projects/SKILL.md +92 -0
- package/.agents/skills/lifecycle/SKILL.md +56 -0
- package/.agents/skills/lifecycle/smoke_test.sh +57 -0
- package/.agents/skills/lifecycle/start.sh +21 -0
- package/.agents/skills/lifecycle/status.sh +35 -0
- package/.agents/skills/lifecycle/stop.sh +16 -0
- package/.agents/skills/muninn-stow/README.md +3 -0
- package/.agents/skills/muninn-stow/SKILL.md +309 -0
- package/.agents/skills/no-mistakes/SKILL.md +379 -0
- package/.agents/skills/nornir-schedule/README.md +3 -0
- package/.agents/skills/nornir-schedule/SKILL.md +25 -0
- package/.agents/skills/nornir-schedule/assets/events.md +137 -0
- package/.agents/skills/nornir-schedule/assets/quota.md +131 -0
- package/.agents/skills/nsr-compliance/README.md +3 -0
- package/.agents/skills/nsr-compliance/SKILL.md +29 -0
- package/.agents/skills/nsr-compliance/assets/nsr/README.md +40 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/README.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/git-workflow.md +18 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/security.md +22 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/tech-stack.md +8 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/SKILL.md +71 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/config/core_four.yaml +16 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_danger.sh +102 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_wiring.sh +69 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/validate_code.sh +29 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/verify_docs.py +66 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/generate.sh +109 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/result_envelope.json +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/task_envelope.json +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/runner.py +303 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/telemetry/logger.py +112 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/README.md +18 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/config/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/README.md +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_env.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_paths.sh +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_platform.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/envelopes/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/install.sh +125 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/telemetry/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/README.md +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/README.md +36 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/SKILL-template.md +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/README.md +25 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/SKILL.md +13 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/create_branch.sh +6 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/safe_commit.sh +9 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/sync_upstream.sh +6 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/README.md +27 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/SKILL.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/smoke_test.sh +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/start.sh +19 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/status.sh +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/stop.sh +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/README.md +18 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/core_four.yaml +13 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/README.md +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_danger.sh +102 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_env.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_paths.sh +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_platform.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_wiring.sh +69 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/result_envelope.json +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/task_envelope.json +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/runner.py +303 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/install.sh +125 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/logger.py +112 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/README.md +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/api.md +17 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/compliance-wiring.md +155 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/frontend.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/README.md +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/README.md +16 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/template-README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/deploy.sh +49 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/README.md +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/development.env.example +17 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/production.env.example +17 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/staging.env.example +17 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/template.env.example +8 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/README.md +26 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/agent.md +7 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/developers/dev-1.md +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/linux.md +9 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/macos.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/shared.md +19 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/windows.md +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/README.md +25 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/_template.md +41 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/hosting-1.md +41 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/README.md +21 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/README.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/deployment.md +19 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/disaster-recovery.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/ops-tasks.md +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/features/README.md +16 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/project-skills.md +146 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/software-compliance-research.md +269 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/working-with-agents.md +260 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/AGENTS.md +38 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/ARCHITECTURE.md +36 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/BEST_PRACTICES.md +27 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/CI_CD.md +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/FEATURES.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/README.md +22 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/RULES.md +37 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/STRUCTURE.md +45 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/TECH_STACK.md +28 -0
- package/.agents/skills/nsr-compliance/assets/nsr/scaffold-spec.md +63 -0
- package/.agents/skills/nsr-compliance/assets/nsr/scripts/scaffold.sh +103 -0
- package/.agents/skills/nsr-compliance/assets/nsr/scripts/validate.sh +52 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/SKILL.md +71 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/config/core_four.yaml +16 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_danger.sh +102 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_wiring.sh +69 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/validate_code.sh +29 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/verify_docs.py +66 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/generate.sh +109 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/result_envelope.json +14 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/task_envelope.json +20 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/runner.py +303 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/telemetry/logger.py +112 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/README.md +18 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/config/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/README.md +12 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_env.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_paths.sh +20 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_platform.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/envelopes/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/install.sh +125 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/telemetry/README.md +11 -0
- package/.agents/skills/pr-ops/README.md +3 -0
- package/.agents/skills/pr-ops/SKILL.md +109 -0
- package/.agents/skills/ratatoskr-a2a/README.md +3 -0
- package/.agents/skills/ratatoskr-a2a/SKILL.md +148 -0
- package/.agents/skills/rules-check-drift/SKILL.md +81 -0
- package/.agents/skills/saga-bearings/README.md +3 -0
- package/.agents/skills/saga-bearings/SKILL.md +26 -0
- package/.agents/skills/saga-bearings/assets/bearings.md +161 -0
- package/.agents/skills/saga-bearings/assets/board-template.html +731 -0
- package/.agents/skills/saga-bearings/assets/recap.md +56 -0
- package/.agents/skills/syn-recovery/README.md +3 -0
- package/.agents/skills/syn-recovery/SKILL.md +54 -0
- package/.agents/skills/tyr-check/README.md +3 -0
- package/.agents/skills/tyr-check/SKILL.md +218 -0
- package/.agents/skills/urdh-hold/README.md +3 -0
- package/.agents/skills/urdh-hold/SKILL.md +25 -0
- package/.agents/skills/urdh-hold/assets/decisions.md +15 -0
- package/.agents/skills/urdh-hold/assets/hold.md +54 -0
- package/.agents/skills/vor-diagnostics/README.md +3 -0
- package/.agents/skills/vor-diagnostics/SKILL.md +68 -0
- package/.agents/skills/ymir-host/README.md +3 -0
- package/.agents/skills/ymir-host/SKILL.md +35 -0
- package/.agents/skills/ymir-host/assets/desktop.md +36 -0
- package/.agents/skills/ymir-host/assets/install.md +69 -0
- package/.agents/skills/ymir-host/assets/omarchy.md +297 -0
- package/.agents/skills/ymir-host/assets/runbooks.md +15 -0
- package/.agents/skills/ymir-host/assets/thjazi.md +220 -0
- package/.agents/skills/ymir-host/assets/update.md +64 -0
- package/.agents/tests/assets/board-render-harness.mjs +117 -0
- package/.agents/tests/cmux-test-safety.sh +51 -0
- package/.agents/tests/fixtures.sh +291 -0
- package/.agents/tests/fm-afk-inject-e2e.test.sh +428 -0
- package/.agents/tests/fm-afk-inject-herdr-e2e.test.sh +536 -0
- package/.agents/tests/fm-afk-launch.test.sh +961 -0
- package/.agents/tests/fm-afk-pi-herdr-return-e2e.test.sh +297 -0
- package/.agents/tests/fm-afk-return.test.sh +283 -0
- package/.agents/tests/fm-arm-pretool-check.test.sh +482 -0
- package/.agents/tests/fm-ask-user-authority.test.sh +42 -0
- package/.agents/tests/fm-backend-autodetect-smoke.test.sh +176 -0
- package/.agents/tests/fm-backend-cmux-smoke.test.sh +188 -0
- package/.agents/tests/fm-backend-cmux.test.sh +1166 -0
- package/.agents/tests/fm-backend-herdr-eventwait-smoke.test.sh +136 -0
- package/.agents/tests/fm-backend-herdr-eventwait.test.py +106 -0
- package/.agents/tests/fm-backend-herdr-focus-flash-e2e.test.sh +409 -0
- package/.agents/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh +437 -0
- package/.agents/tests/fm-backend-herdr-presentation-e2e.test.sh +1424 -0
- package/.agents/tests/fm-backend-herdr-prune-safety-e2e.test.sh +180 -0
- package/.agents/tests/fm-backend-herdr-respawn-idem-e2e.test.sh +182 -0
- package/.agents/tests/fm-backend-herdr-smoke.test.sh +355 -0
- package/.agents/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh +251 -0
- package/.agents/tests/fm-backend-herdr.test.sh +4606 -0
- package/.agents/tests/fm-backend-orca.test.sh +1357 -0
- package/.agents/tests/fm-backend-tmux-smoke.test.sh +173 -0
- package/.agents/tests/fm-backend-zellij-smoke.test.sh +205 -0
- package/.agents/tests/fm-backend-zellij.test.sh +1360 -0
- package/.agents/tests/fm-backend.test.sh +1142 -0
- package/.agents/tests/fm-backlog-atomicity.test.sh +2302 -0
- package/.agents/tests/fm-backlog-handoff.test.sh +1360 -0
- package/.agents/tests/fm-bearings-board-render.test.sh +135 -0
- package/.agents/tests/fm-bearings-board.test.sh +414 -0
- package/.agents/tests/fm-bearings-snapshot.test.sh +1990 -0
- package/.agents/tests/fm-bootstrap-network-parallel.test.sh +316 -0
- package/.agents/tests/fm-bootstrap.test.sh +1178 -0
- package/.agents/tests/fm-branch-supervision.test.sh +552 -0
- package/.agents/tests/fm-brief.test.sh +785 -0
- package/.agents/tests/fm-busy-adapter-wiring.test.sh +324 -0
- package/.agents/tests/fm-busy-state.test.sh +465 -0
- package/.agents/tests/fm-calm-pi-extension.test.sh +3979 -0
- package/.agents/tests/fm-captain-hold-lifecycle.test.sh +1187 -0
- package/.agents/tests/fm-cd-pretool-check.test.sh +400 -0
- package/.agents/tests/fm-check-unregister.test.sh +198 -0
- package/.agents/tests/fm-classify-corr-token.test.sh +543 -0
- package/.agents/tests/fm-classify-decision-key.test.sh +340 -0
- package/.agents/tests/fm-claude-stop-autoarm-live-e2e.test.sh +164 -0
- package/.agents/tests/fm-claude-stop-autoarm.test.sh +1190 -0
- package/.agents/tests/fm-cmux-claude-composer-live-e2e.test.sh +110 -0
- package/.agents/tests/fm-codex-continuity-live-e2e.test.sh +55 -0
- package/.agents/tests/fm-composer-ghost.test.sh +716 -0
- package/.agents/tests/fm-composer-lib.test.sh +666 -0
- package/.agents/tests/fm-composer-matrix-live-e2e.test.sh +220 -0
- package/.agents/tests/fm-control-herdr-smoke.test.sh +149 -0
- package/.agents/tests/fm-control-relaunch.test.sh +1530 -0
- package/.agents/tests/fm-control.test.sh +910 -0
- package/.agents/tests/fm-crew-state.test.sh +1610 -0
- package/.agents/tests/fm-cursor-harness.test.sh +404 -0
- package/.agents/tests/fm-cursor-primary-live-e2e.test.sh +217 -0
- package/.agents/tests/fm-cursor-primary.test.sh +708 -0
- package/.agents/tests/fm-daemon.test.sh +2740 -0
- package/.agents/tests/fm-documentation-audiences.test.sh +141 -0
- package/.agents/tests/fm-ensure-agents-md.test.sh +371 -0
- package/.agents/tests/fm-extension-binding.test.sh +2187 -0
- package/.agents/tests/fm-fleet-snapshot-view.test.sh +816 -0
- package/.agents/tests/fm-fleet-sync.test.sh +721 -0
- package/.agents/tests/fm-gate-refuse.test.sh +356 -0
- package/.agents/tests/fm-gitignore-config.test.sh +89 -0
- package/.agents/tests/fm-gotmp.test.sh +230 -0
- package/.agents/tests/fm-grok-continuity-live-e2e.test.sh +112 -0
- package/.agents/tests/fm-grok-harness.test.sh +117 -0
- package/.agents/tests/fm-grok-stop-live-e2e.test.sh +219 -0
- package/.agents/tests/fm-guard-stale-banner.test.sh +709 -0
- package/.agents/tests/fm-harness-adapter-instructions-live-e2e.test.sh +134 -0
- package/.agents/tests/fm-harness-adapter-references.test.sh +30 -0
- package/.agents/tests/fm-harness-liveness-drift-live-e2e.test.sh +151 -0
- package/.agents/tests/fm-herdr-lab.test.sh +243 -0
- package/.agents/tests/fm-herdr-session-cleanup-e2e.test.sh +146 -0
- package/.agents/tests/fm-herdr-session-cleanup.test.sh +329 -0
- package/.agents/tests/fm-herdr-submit-confirm-live-e2e.test.sh +125 -0
- package/.agents/tests/fm-herdr-version-floor-live-e2e.test.sh +144 -0
- package/.agents/tests/fm-home-summary-refresh.test.sh +971 -0
- package/.agents/tests/fm-inactive-reconcile.test.sh +485 -0
- package/.agents/tests/fm-kimi-harness.test.sh +687 -0
- package/.agents/tests/fm-lint-workflows.test.sh +532 -0
- package/.agents/tests/fm-lint.test.sh +1023 -0
- package/.agents/tests/fm-muse-harness.test.sh +932 -0
- package/.agents/tests/fm-muse-signals-live-e2e.test.sh +205 -0
- package/.agents/tests/fm-no-mistakes-required.test.sh +72 -0
- package/.agents/tests/fm-on.test.sh +507 -0
- package/.agents/tests/fm-opencode-primary-live-e2e.test.sh +357 -0
- package/.agents/tests/fm-operational-input.test.sh +160 -0
- package/.agents/tests/fm-peek-remote.test.sh +110 -0
- package/.agents/tests/fm-pending-reply.test.sh +1288 -0
- package/.agents/tests/fm-pi-branch-extension.test.sh +3182 -0
- package/.agents/tests/fm-pi-branch-live-e2e.test.sh +527 -0
- package/.agents/tests/fm-pi-primary-live-e2e.test.sh +345 -0
- package/.agents/tests/fm-pi-primary-types.test.sh +68 -0
- package/.agents/tests/fm-pi-watch-extension.test.sh +2843 -0
- package/.agents/tests/fm-pr-check-security.test.sh +2140 -0
- package/.agents/tests/fm-pr-merge.test.sh +2135 -0
- package/.agents/tests/fm-procevent-quota.test.sh +225 -0
- package/.agents/tests/fm-procevent-when.test.sh +406 -0
- package/.agents/tests/fm-procevent.test.sh +1745 -0
- package/.agents/tests/fm-project-origin.test.sh +110 -0
- package/.agents/tests/fm-public-followup.test.sh +2272 -0
- package/.agents/tests/fm-quota-array-dispatch-live-e2e.test.sh +526 -0
- package/.agents/tests/fm-quota-choose.test.sh +626 -0
- package/.agents/tests/fm-remote-backlog-handoff.test.sh +453 -0
- package/.agents/tests/fm-remote-doctor.test.sh +625 -0
- package/.agents/tests/fm-remote-entrypoint.test.sh +59 -0
- package/.agents/tests/fm-remote-job-orphan-reap.test.sh +205 -0
- package/.agents/tests/fm-remote-job.test.sh +768 -0
- package/.agents/tests/fm-remote-reply.test.sh +520 -0
- package/.agents/tests/fm-remote-secondmate-lifecycle-e2e.test.sh +1256 -0
- package/.agents/tests/fm-remote-secondmate-parent-binding.test.sh +326 -0
- package/.agents/tests/fm-remote-secondmate-trace-context.test.sh +310 -0
- package/.agents/tests/fm-remote-transport-lanes.test.sh +431 -0
- package/.agents/tests/fm-review-diff.test.sh +176 -0
- package/.agents/tests/fm-secondmate-harness.test.sh +2600 -0
- package/.agents/tests/fm-secondmate-lifecycle-e2e.test.sh +259 -0
- package/.agents/tests/fm-secondmate-liveness.test.sh +559 -0
- package/.agents/tests/fm-secondmate-reconcile.test.sh +732 -0
- package/.agents/tests/fm-secondmate-safety.test.sh +2972 -0
- package/.agents/tests/fm-secondmate-sync.test.sh +913 -0
- package/.agents/tests/fm-send-inbox-doorbell-live-e2e.test.sh +206 -0
- package/.agents/tests/fm-send-inbox.test.sh +352 -0
- package/.agents/tests/fm-send-popup-settle.test.sh +167 -0
- package/.agents/tests/fm-send-remote-delivery.test.sh +796 -0
- package/.agents/tests/fm-send-resolve-key.test.sh +551 -0
- package/.agents/tests/fm-send-secondmate-marker-herdr-e2e.test.sh +187 -0
- package/.agents/tests/fm-send-secondmate-marker.test.sh +278 -0
- package/.agents/tests/fm-send-settle.test.sh +147 -0
- package/.agents/tests/fm-send-strict.test.sh +241 -0
- package/.agents/tests/fm-session-lock-ancestry.test.sh +365 -0
- package/.agents/tests/fm-session-start.test.sh +2515 -0
- package/.agents/tests/fm-sessionstart-hook-live-e2e.test.sh +631 -0
- package/.agents/tests/fm-sessionstart-instruction-refresh-live-e2e.test.sh +230 -0
- package/.agents/tests/fm-sessionstart-nudge.test.sh +1039 -0
- package/.agents/tests/fm-shared-captain-inheritance.test.sh +404 -0
- package/.agents/tests/fm-spawn-batch.test.sh +148 -0
- package/.agents/tests/fm-spawn-dispatch-profile.test.sh +827 -0
- package/.agents/tests/fm-spawn-pool-base-freshen.test.sh +440 -0
- package/.agents/tests/fm-spawn-worktree-settle.test.sh +147 -0
- package/.agents/tests/fm-startup-memory-budget.test.sh +333 -0
- package/.agents/tests/fm-startup-network.test.sh +712 -0
- package/.agents/tests/fm-stow-cascade.test.sh +370 -0
- package/.agents/tests/fm-subagent-pretool-check.test.sh +291 -0
- package/.agents/tests/fm-supervision-events.test.sh +156 -0
- package/.agents/tests/fm-supervision-instructions.test.sh +189 -0
- package/.agents/tests/fm-tangle-guard.test.sh +277 -0
- package/.agents/tests/fm-task-delivery.test.sh +420 -0
- package/.agents/tests/fm-task-inbox.test.sh +513 -0
- package/.agents/tests/fm-teardown-endpoint-safety.test.sh +374 -0
- package/.agents/tests/fm-teardown.test.sh +2665 -0
- package/.agents/tests/fm-test-fixture-cleanup.test.sh +172 -0
- package/.agents/tests/fm-test-fixtures.test.sh +132 -0
- package/.agents/tests/fm-test-isolation-proof.test.sh +278 -0
- package/.agents/tests/fm-test-run.test.sh +1196 -0
- package/.agents/tests/fm-tmux-agent-liveness.test.sh +354 -0
- package/.agents/tests/fm-tmux-submit-busy.test.sh +340 -0
- package/.agents/tests/fm-tool-update-check.test.sh +1044 -0
- package/.agents/tests/fm-trace-context-lib.test.sh +253 -0
- package/.agents/tests/fm-trace-context-spawn.test.sh +581 -0
- package/.agents/tests/fm-transition-lib.test.sh +54 -0
- package/.agents/tests/fm-turnend-guard.test.sh +1822 -0
- package/.agents/tests/fm-update.test.sh +304 -0
- package/.agents/tests/fm-vendor-auth-probe.test.sh +395 -0
- package/.agents/tests/fm-voice-relay.test.sh +4741 -0
- package/.agents/tests/fm-wake-daemon-lifecycle-e2e.test.sh +171 -0
- package/.agents/tests/fm-wake-drain-open-decisions-cursor.test.sh +356 -0
- package/.agents/tests/fm-wake-drain-open-decisions.test.sh +226 -0
- package/.agents/tests/fm-wake-drain-unread-status.test.sh +379 -0
- package/.agents/tests/fm-wake-queue.test.sh +1224 -0
- package/.agents/tests/fm-watch-arm.test.sh +821 -0
- package/.agents/tests/fm-watch-checkpoint.test.sh +87 -0
- package/.agents/tests/fm-watch-recovery-loop.test.sh +223 -0
- package/.agents/tests/fm-watch-triage.test.sh +3889 -0
- package/.agents/tests/fm-watcher-lock.test.sh +1133 -0
- package/.agents/tests/fm-x-mode.test.sh +3042 -0
- package/.agents/tests/gleipnir-guard-pretool-check.test.sh +51 -0
- package/.agents/tests/gleipnir-machine-lock.test.sh +130 -0
- package/.agents/tests/herdr-test-safety.sh +42 -0
- package/.agents/tests/lib.sh +315 -0
- package/.agents/tests/remote-herdr-fixture.sh +125 -0
- package/.agents/tests/secondmate-helpers.sh +193 -0
- package/.agents/tests/smoke.test.sh +93 -0
- package/.agents/tests/wake-helpers.sh +338 -0
- package/.agents/tests/zellij-test-safety.sh +60 -0
- package/.agents/tools/README.md +10 -0
- package/.agents/tools/bin/tasks-cli +267 -0
- package/.agents/tools/tasks-cli.ts +216 -0
- package/AGENTS.md +498 -0
- package/CHANGELOG.md +1913 -0
- package/LICENSE +1 -1
- package/NOTICE +63 -0
- package/README.md +114 -60
- package/RULES/01-domains.md +51 -0
- package/RULES/02-agents.md +78 -0
- package/RULES/03-houses.md +61 -0
- package/RULES/04-hoard.md +97 -0
- package/RULES/05-platforms.md +67 -0
- package/RULES/06-append-only.md +108 -0
- package/RULES/07-config.md +101 -0
- package/RULES/08-delivery-gate.md +75 -0
- package/RULES/09-electron.md +58 -0
- package/RULES/README.md +22 -0
- package/bin/README.md +107 -0
- package/bin/a2a-mcp.sh +133 -0
- package/bin/a2a-serve.py +103 -0
- package/bin/a2a-serve.sh +8 -0
- package/bin/a2a-talk.sh +50 -0
- package/bin/a2abridge-ensure.sh +108 -0
- package/bin/agent-run.sh +43 -0
- package/bin/agents-config.sh +256 -0
- package/bin/apodex-smoke-test.sh +105 -0
- package/bin/bifrost-bridge.sh +131 -0
- package/bin/bifrost-ingress.sh +77 -0
- package/bin/bootstrap-macos.sh +112 -0
- package/bin/bootstrap-windows.ps1 +112 -0
- package/bin/branch-guard.sh +64 -0
- package/bin/brokk +73 -0
- package/bin/brokk-classify-lib.sh +1770 -0
- package/bin/brokk-lease-lib.sh +222 -0
- package/bin/brokk-lease.sh +193 -0
- package/bin/brokk-lint.sh +65 -0
- package/bin/brokk-send.sh +51 -0
- package/bin/brokk-update.sh +9 -0
- package/bin/brokk-wake-grant.sh +127 -0
- package/bin/brokk-wake-lib.sh +1849 -0
- package/bin/changelog-assemble.sh +154 -0
- package/bin/changelog-guard.sh +197 -0
- package/bin/crash-sense.sh +173 -0
- package/bin/design-check.sh +80 -0
- package/bin/design-icon.sh +153 -0
- package/bin/desktop-place.sh +224 -0
- package/bin/docs-guard.sh +39 -0
- package/bin/editor-place.sh +225 -0
- package/bin/eindri-acclaim.sh +47 -0
- package/bin/eindri-control.sh +28 -0
- package/bin/eindri-role.sh +83 -0
- package/bin/eindri-seat.sh +83 -0
- package/bin/eindri-seen.sh +54 -0
- package/bin/eindri-send.sh +15 -0
- package/bin/eindri-start.sh +98 -0
- package/bin/eindri-watch.sh +57 -0
- package/bin/einherjar-spawn.sh +576 -0
- package/bin/eir-doctor.sh +158 -0
- package/bin/erindi-brief.sh +271 -0
- package/bin/feature-inventory.sh +73 -0
- package/bin/fleet-apply.sh +81 -0
- package/bin/github-deploy.sh +94 -0
- package/bin/gjallarhorn-expose.sh +136 -0
- package/bin/gjallarhorn-notify.sh +71 -0
- package/bin/gjallarhorn-purge.sh +67 -0
- package/bin/gjallarhorn-tunnel.sh +85 -0
- package/bin/gleipnir-lock-lib.sh +284 -0
- package/bin/groa-update.sh +130 -0
- package/bin/hall-snapshot.sh +111 -0
- package/bin/hamr-harness.sh +246 -0
- package/bin/heimdall-ssh-keys.sh +374 -0
- package/bin/herdr-agents.py +44 -0
- package/bin/herdr-ensure.sh +104 -0
- package/bin/herdr-run.sh +452 -0
- package/bin/hermes-ensure.sh +88 -0
- package/bin/hlidskjalf-agents.sh +172 -0
- package/bin/hlidskjalf-usage.sh +196 -0
- package/bin/hoard-guard.sh +135 -0
- package/bin/hoard-lib.sh +105 -0
- package/bin/hodd.sh +102 -0
- package/bin/host-sense.sh +199 -0
- package/bin/huginn-research-worker.sh +121 -0
- package/bin/local-model-lock.sh +70 -0
- package/bin/mimir-bridge.py +259 -0
- package/bin/mimir-bridge.sh +100 -0
- package/bin/mimir-ingest.sh +131 -0
- package/bin/mimir-reflect.sh +27 -0
- package/bin/mimir.sh +113 -0
- package/bin/mjollnir-webhook.sh +72 -0
- package/bin/mjollnir.sh +104 -0
- package/bin/model-hardware.sh +96 -0
- package/bin/model-resolve.sh +160 -0
- package/bin/models-detect.sh +178 -0
- package/bin/nornir-cron-start.sh +162 -0
- package/bin/nornir-job-daily-briefing.sh +191 -0
- package/bin/nornir-job-git-sync.sh +152 -0
- package/bin/nornir-job-hall-snapshot.sh +28 -0
- package/bin/nornir-job-memory-housekeeping.sh +114 -0
- package/bin/nornir-job-nsr-compliance.sh +54 -0
- package/bin/nornir-job-observer.sh +166 -0
- package/bin/nornir-job-skillopt-sleep.sh +76 -0
- package/bin/npm-publish.sh +136 -0
- package/bin/omarchy-hook-install.sh +60 -0
- package/bin/omarchy-install.sh +161 -0
- package/bin/omarchy-plugins.sh +187 -0
- package/bin/omarchy-sense.sh +162 -0
- package/bin/perm-guard.sh +30 -0
- package/bin/pi-agent.sh +68 -0
- package/bin/pi-ensure.sh +77 -0
- package/bin/pi-local.sh +37 -0
- package/bin/pi-model-agnostic.sh +71 -0
- package/bin/pi-seat.sh +63 -0
- package/bin/prereq-ensure.sh +166 -0
- package/bin/private-guard.sh +58 -0
- package/bin/project-git.sh +69 -0
- package/bin/public-guard.sh +71 -0
- package/bin/ratatoskr.sh +49 -0
- package/bin/realm-lib.sh +32 -0
- package/bin/repo-scrub.sh +62 -0
- package/bin/rodd-operational-input.sh +250 -0
- package/bin/runes-append.sh +198 -0
- package/bin/saga-session-start.sh +172 -0
- package/bin/saga-sessionstart-run.sh +79 -0
- package/bin/saga-wake-drain.sh +41 -0
- package/bin/secret-guard.sh +63 -0
- package/bin/sessrumnir-ensure.sh +100 -0
- package/bin/sessrumnir-sync.sh +237 -0
- package/bin/sessrumnir.sh +104 -0
- package/bin/skillopt-setup.sh +76 -0
- package/bin/skuld-branch-outcome.sh +203 -0
- package/bin/skuld-branch-prompt.sh +101 -0
- package/bin/smidja-bootstrap.sh +94 -0
- package/bin/smidja-observe.sh +86 -0
- package/bin/syn-arm-pretool-check.sh +57 -0
- package/bin/syn-asset-pretool-check.sh +89 -0
- package/bin/syn-cd-pretool-check.sh +28 -0
- package/bin/syn-guard-pretool-check.sh +83 -0
- package/bin/syn-spawn-pretool-check.sh +58 -0
- package/bin/syn-turnend-guard.sh +36 -0
- package/bin/syn-watch-arm.sh +99 -0
- package/bin/tailscale-sync.sh +203 -0
- package/bin/telegram-bot.sh +82 -0
- package/bin/toolchain.sh +74 -0
- package/bin/ui-truth-check.sh +75 -0
- package/bin/utgard.sh +111 -0
- package/bin/valhalla.sh +115 -0
- package/bin/valknut-load.sh +298 -0
- package/bin/vor-crew-state.sh +124 -0
- package/bin/wedge-notify.sh +60 -0
- package/bin/workspace-provision.sh +80 -0
- package/bin/workspace-rag.sh +163 -0
- package/bin/wyrd-db.sh +47 -0
- package/bin/yggdrasil.sh +152 -0
- package/bin/ymir-edit.sh +145 -0
- package/bin/ymir-install.sh +806 -0
- package/bin/ymir-invite.sh +93 -0
- package/bin/ymir-isolation.sh +88 -0
- package/bin/ymir-migrate.sh +61 -0
- package/bin/ymir-plan.sh +356 -0
- package/bin/ymir-platform.sh +348 -0
- package/bin/ymir-say.sh +110 -0
- package/bin/ymir-setup-auth.sh +119 -0
- package/bin/ymir-validate.sh +193 -0
- package/bin/ymir.js +74 -16
- package/docs/Architecture.md +339 -0
- package/docs/design.md +351 -0
- package/docs/installations/README.md +63 -0
- package/docs/installations/ymir-home.md +93 -0
- package/docs/integration.md +105 -0
- package/docs/lore.md +1060 -0
- package/docs/research/agent-harnesses.md +290 -0
- package/docs/runbooks/README.md +19 -0
- package/docs/runbooks/a2a.md +91 -0
- package/docs/runbooks/agent-permissions.md +99 -0
- package/docs/runbooks/agents.md +57 -0
- package/docs/runbooks/models.md +69 -0
- package/docs/runbooks/realm-onboarding.md +60 -0
- package/docs/runbooks/secrets-and-hoard.md +60 -0
- package/docs/runbooks/updates-and-migrations.md +110 -0
- package/docs/runbooks/worktrees-isolation.md +72 -0
- package/docs/session-start.md +105 -0
- package/docs/skillopt-integration.md +321 -0
- package/docs/sync-system.md +66 -0
- package/docs/workspaces.md +104 -0
- package/docs/ymir-rut.md +260 -0
- package/package.json +54 -14
- package/packaging/build.sh +74 -0
- package/packaging/macos/Ymir Installer.command +43 -0
- package/packaging/windows/ymir-setup.nsi +74 -0
- package/scripts/README.md +22 -0
- package/scripts/electron.sh +269 -0
- package/scripts/lower.sh +34 -0
- package/scripts/raise.sh +42 -0
- package/scripts/start.sh +217 -0
- package/scripts/stop.sh +113 -0
- package/install.sh +0 -35
|
@@ -0,0 +1,3182 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Tests for the tracked Pi supervision-branch extension
|
|
3
|
+
# (.pi/extensions/fm-branch-supervision.ts): wake dispatch acceptance and
|
|
4
|
+
# gating, the two-stage noise filter's second stage (verdict-driven delivery
|
|
5
|
+
# into main), store-first durability through the real bin/fm-branch-outcome.sh,
|
|
6
|
+
# the byte-stable tool order and per-home prompt_cache_key hook, the dialog
|
|
7
|
+
# mirror, and branch-session persistence. The Pi SDK is stubbed (scriptable
|
|
8
|
+
# in-process sessions); every fleet-record behavior runs the REAL bin scripts.
|
|
9
|
+
set -u
|
|
10
|
+
|
|
11
|
+
# shellcheck source=tests/lib.sh
|
|
12
|
+
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
|
|
13
|
+
|
|
14
|
+
TMP_ROOT=$(fm_test_tmproot fm-pi-branch-extension)
|
|
15
|
+
EXT="$ROOT/.pi/extensions/fm-branch-supervision.ts"
|
|
16
|
+
export NODE_NO_WARNINGS=1
|
|
17
|
+
|
|
18
|
+
# Keep JavaScript heredocs outside command substitutions. Stock macOS Bash
|
|
19
|
+
# 3.2 reparses quotes and template literals inside that combination.
|
|
20
|
+
install_pi_branch_extension_fixture() {
|
|
21
|
+
local repo=$1
|
|
22
|
+
mkdir -p \
|
|
23
|
+
"$repo/.pi/extensions/lib" \
|
|
24
|
+
"$repo/node_modules/@earendil-works/pi-coding-agent" \
|
|
25
|
+
"$repo/node_modules/@earendil-works/pi-ai" \
|
|
26
|
+
"$repo/node_modules/@earendil-works/pi-tui" \
|
|
27
|
+
"$repo/node_modules/typebox"
|
|
28
|
+
cp "$EXT" "$repo/.pi/extensions/fm-branch-supervision.ts"
|
|
29
|
+
cp "$ROOT/.pi/extensions/lib/fm-branch-dispatch.ts" "$repo/.pi/extensions/lib/fm-branch-dispatch.ts"
|
|
30
|
+
cp "$ROOT/.pi/extensions/lib/fm-branch-model-picker.ts" "$repo/.pi/extensions/lib/fm-branch-model-picker.ts"
|
|
31
|
+
cp "$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" "$repo/.pi/extensions/lib/fm-calm-visibility.ts"
|
|
32
|
+
cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$repo/.pi/extensions/lib/fm-operational-input.ts"
|
|
33
|
+
mkdir -p "$repo/bin"
|
|
34
|
+
cp "$ROOT/bin/fm-operational-input.sh" "$repo/bin/fm-operational-input.sh"
|
|
35
|
+
chmod +x "$repo/bin/fm-operational-input.sh"
|
|
36
|
+
cat > "$repo/node_modules/@earendil-works/pi-coding-agent/package.json" <<'JSON'
|
|
37
|
+
{"name":"@earendil-works/pi-coding-agent","type":"module","exports":"./index.js"}
|
|
38
|
+
JSON
|
|
39
|
+
cat > "$repo/node_modules/@earendil-works/pi-coding-agent/index.js" <<'JS'
|
|
40
|
+
import { writeFileSync } from "node:fs";
|
|
41
|
+
|
|
42
|
+
export function getAgentDir() {
|
|
43
|
+
return "/stub-agent-dir";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getMarkdownTheme() {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function keyHint(_keybinding, description) {
|
|
51
|
+
return `ctrl+o ${description}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class ToolExecutionComponent {
|
|
55
|
+
updateResult(result) {
|
|
56
|
+
this.result = result;
|
|
57
|
+
}
|
|
58
|
+
render() {
|
|
59
|
+
return (this.result?.content ?? [])
|
|
60
|
+
.filter((item) => item.type === "text")
|
|
61
|
+
.flatMap((item) => item.text.split("\n"));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class UserMessageComponent {}
|
|
66
|
+
|
|
67
|
+
export class DynamicBorder {
|
|
68
|
+
constructor(color) {
|
|
69
|
+
this.color = color;
|
|
70
|
+
}
|
|
71
|
+
invalidate() {}
|
|
72
|
+
render() {
|
|
73
|
+
return ["--"];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class ModelRuntime {
|
|
78
|
+
constructor() {
|
|
79
|
+
this.models = (globalThis.__fmBranchStaticModels?.() ?? []).map((model) => ({ ...model }));
|
|
80
|
+
this.authenticated = new Set(this.models.filter((model) => model.storedAuth !== false).map((model) => model.provider));
|
|
81
|
+
}
|
|
82
|
+
static async create() {
|
|
83
|
+
const queuedError = globalThis.__fmModelRuntimeErrors?.shift();
|
|
84
|
+
if (queuedError) throw new Error(queuedError);
|
|
85
|
+
if (globalThis.__fmModelRuntimeError) throw new Error(globalThis.__fmModelRuntimeError);
|
|
86
|
+
const runtime = new ModelRuntime();
|
|
87
|
+
(globalThis.__fmModelRuntimes ??= []).push(runtime);
|
|
88
|
+
return runtime;
|
|
89
|
+
}
|
|
90
|
+
getModel(provider, id) {
|
|
91
|
+
return this.models.find((model) => model.provider === provider && model.id === id);
|
|
92
|
+
}
|
|
93
|
+
hasConfiguredAuth(provider) {
|
|
94
|
+
return this.authenticated.has(provider);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export class DefaultResourceLoader {
|
|
98
|
+
constructor(options) {
|
|
99
|
+
this.options = options;
|
|
100
|
+
(globalThis.__fmLoaders ??= []).push(this);
|
|
101
|
+
}
|
|
102
|
+
async reload() {
|
|
103
|
+
this.reloaded = true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export class SessionManager {
|
|
108
|
+
constructor(file) {
|
|
109
|
+
this.file = file;
|
|
110
|
+
}
|
|
111
|
+
static create(cwd, dir) {
|
|
112
|
+
globalThis.__fmCreateCount = (globalThis.__fmCreateCount ?? 0) + 1;
|
|
113
|
+
const sm = new SessionManager(`${dir}/created-${globalThis.__fmCreateCount}.jsonl`);
|
|
114
|
+
sm.created = true;
|
|
115
|
+
writeFileSync(sm.file, "");
|
|
116
|
+
(globalThis.__fmSessionManagers ??= []).push(sm);
|
|
117
|
+
return sm;
|
|
118
|
+
}
|
|
119
|
+
static open(path) {
|
|
120
|
+
const sm = new SessionManager(path);
|
|
121
|
+
sm.opened = true;
|
|
122
|
+
(globalThis.__fmSessionManagers ??= []).push(sm);
|
|
123
|
+
return sm;
|
|
124
|
+
}
|
|
125
|
+
getSessionFile() {
|
|
126
|
+
return this.file;
|
|
127
|
+
}
|
|
128
|
+
buildSessionContext() {
|
|
129
|
+
const model = globalThis.__fmRecordedModels?.get(this.file) ?? null;
|
|
130
|
+
return { messages: model ? [{ role: "assistant", content: [], provider: model.provider, model: model.modelId }] : [], thinkingLevel: "medium", model };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function createBashToolDefinition(cwd, options) {
|
|
135
|
+
return {
|
|
136
|
+
name: "bash",
|
|
137
|
+
label: "stub bash",
|
|
138
|
+
description: "stub bash",
|
|
139
|
+
parameters: { type: "object" },
|
|
140
|
+
__cwd: cwd,
|
|
141
|
+
__options: options,
|
|
142
|
+
execute: async (_toolCallId, params) => {
|
|
143
|
+
if (!globalThis.__fmExecuteBranchBash) return { content: [], details: undefined };
|
|
144
|
+
const initial = { command: String(params.command ?? ""), cwd, env: { ...process.env } };
|
|
145
|
+
const context = options.spawnHook ? options.spawnHook(initial) : initial;
|
|
146
|
+
return globalThis.__fmExecuteBranchBash(context);
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function createAgentSession(options) {
|
|
152
|
+
if (globalThis.__fmCreateSessionError) throw new Error(globalThis.__fmCreateSessionError);
|
|
153
|
+
globalThis.__fmCreateStarted = (globalThis.__fmCreateStarted ?? 0) + 1;
|
|
154
|
+
if (globalThis.__fmCreateGate) await globalThis.__fmCreateGate;
|
|
155
|
+
if (options.model && (!options.modelRuntime || !options.modelRuntime.getModel(options.model.provider, options.model.id))) {
|
|
156
|
+
throw new Error(`branch runtime cannot use ${options.model.provider}/${options.model.id}`);
|
|
157
|
+
}
|
|
158
|
+
const session = {
|
|
159
|
+
options,
|
|
160
|
+
ops: [],
|
|
161
|
+
disposed: false,
|
|
162
|
+
async prompt(text) {
|
|
163
|
+
if (globalThis.__fmPromptGate) {
|
|
164
|
+
globalThis.__fmPromptStarted = true;
|
|
165
|
+
await globalThis.__fmPromptGate;
|
|
166
|
+
}
|
|
167
|
+
session.ops.push({ kind: "prompt", text });
|
|
168
|
+
(globalThis.__fmPrompts ??= []).push(text);
|
|
169
|
+
await globalThis.__fmOnBranchPrompt?.({ session, text });
|
|
170
|
+
},
|
|
171
|
+
async sendCustomMessage(message, opts) {
|
|
172
|
+
if (globalThis.__fmMirrorGate) {
|
|
173
|
+
globalThis.__fmMirrorStarted = true;
|
|
174
|
+
await globalThis.__fmMirrorGate;
|
|
175
|
+
}
|
|
176
|
+
session.ops.push({ kind: "custom", message, opts });
|
|
177
|
+
(globalThis.__fmMirrors ??= []).push(message);
|
|
178
|
+
},
|
|
179
|
+
dispose() {
|
|
180
|
+
session.disposed = true;
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
const restoredModel = options.model
|
|
184
|
+
? { provider: options.model.provider, modelId: options.model.id }
|
|
185
|
+
: globalThis.__fmRecordedModels?.get(options.sessionManager.getSessionFile());
|
|
186
|
+
if (restoredModel) (globalThis.__fmRecordedModels ??= new Map()).set(options.sessionManager.getSessionFile(), restoredModel);
|
|
187
|
+
(globalThis.__fmSessions ??= []).push(session);
|
|
188
|
+
return { session, extensionsResult: {} };
|
|
189
|
+
}
|
|
190
|
+
JS
|
|
191
|
+
cat > "$repo/node_modules/@earendil-works/pi-ai/package.json" <<'JSON'
|
|
192
|
+
{"name":"@earendil-works/pi-ai","type":"module","exports":"./index.js"}
|
|
193
|
+
JSON
|
|
194
|
+
cat > "$repo/node_modules/@earendil-works/pi-ai/index.js" <<'JS'
|
|
195
|
+
// Pi's own thinking-level rules, reproduced from the installed package so the
|
|
196
|
+
// portable regression can drive models with different effort ceilings. The
|
|
197
|
+
// live guard (tests/fm-pi-branch-live-e2e.test.sh) is what proves the real
|
|
198
|
+
// vendor surface still behaves this way.
|
|
199
|
+
const EXTENDED_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
200
|
+
|
|
201
|
+
export function getSupportedThinkingLevels(model) {
|
|
202
|
+
if (!model.reasoning) return ["off"];
|
|
203
|
+
return EXTENDED_THINKING_LEVELS.filter((level) => {
|
|
204
|
+
const mapped = model.thinkingLevelMap?.[level];
|
|
205
|
+
if (mapped === null) return false;
|
|
206
|
+
if (level === "xhigh" || level === "max") return mapped !== undefined;
|
|
207
|
+
return true;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function clampThinkingLevel(model, level) {
|
|
212
|
+
const available = getSupportedThinkingLevels(model);
|
|
213
|
+
if (available.includes(level)) return level;
|
|
214
|
+
const requested = EXTENDED_THINKING_LEVELS.indexOf(level);
|
|
215
|
+
if (requested === -1) return available[0] ?? "off";
|
|
216
|
+
for (let i = requested; i < EXTENDED_THINKING_LEVELS.length; i += 1) {
|
|
217
|
+
if (available.includes(EXTENDED_THINKING_LEVELS[i])) return EXTENDED_THINKING_LEVELS[i];
|
|
218
|
+
}
|
|
219
|
+
for (let i = requested - 1; i >= 0; i -= 1) {
|
|
220
|
+
if (available.includes(EXTENDED_THINKING_LEVELS[i])) return EXTENDED_THINKING_LEVELS[i];
|
|
221
|
+
}
|
|
222
|
+
return available[0] ?? "off";
|
|
223
|
+
}
|
|
224
|
+
JS
|
|
225
|
+
cat > "$repo/node_modules/@earendil-works/pi-tui/package.json" <<'JSON'
|
|
226
|
+
{"name":"@earendil-works/pi-tui","type":"module","exports":"./index.js"}
|
|
227
|
+
JSON
|
|
228
|
+
cat > "$repo/node_modules/@earendil-works/pi-tui/index.js" <<'JS'
|
|
229
|
+
export class Text {
|
|
230
|
+
constructor(text, paddingX, paddingY) {
|
|
231
|
+
this.text = text;
|
|
232
|
+
this.paddingX = paddingX;
|
|
233
|
+
this.paddingY = paddingY;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export class Container {
|
|
238
|
+
constructor() {
|
|
239
|
+
this.children = [];
|
|
240
|
+
}
|
|
241
|
+
addChild(child) {
|
|
242
|
+
this.children.push(child);
|
|
243
|
+
}
|
|
244
|
+
clear() {
|
|
245
|
+
this.children = [];
|
|
246
|
+
}
|
|
247
|
+
render() {
|
|
248
|
+
return this.children.flatMap((child) => child.render?.() ?? []);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export class Box extends Container {
|
|
253
|
+
constructor(paddingX, paddingY, bgFn) {
|
|
254
|
+
super();
|
|
255
|
+
this.paddingX = paddingX;
|
|
256
|
+
this.paddingY = paddingY;
|
|
257
|
+
this.bgFn = bgFn;
|
|
258
|
+
}
|
|
259
|
+
setBgFn(bgFn) {
|
|
260
|
+
this.bgFn = bgFn;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export class Input {
|
|
265
|
+
constructor() {
|
|
266
|
+
this.value = "";
|
|
267
|
+
this.focused = false;
|
|
268
|
+
}
|
|
269
|
+
getValue() {
|
|
270
|
+
return this.value;
|
|
271
|
+
}
|
|
272
|
+
setValue(value) {
|
|
273
|
+
this.value = value;
|
|
274
|
+
}
|
|
275
|
+
handleInput(data) {
|
|
276
|
+
this.value = data === "\u007f" ? this.value.slice(0, -1) : this.value + data;
|
|
277
|
+
}
|
|
278
|
+
invalidate() {}
|
|
279
|
+
render() {
|
|
280
|
+
return [this.value];
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Records every construction so a driver can assert the rows and the visible
|
|
285
|
+
// bound the extension asked Pi's real SelectList for. Navigation keys arrive
|
|
286
|
+
// as their keybinding ids because the driver's fake keybindings manager
|
|
287
|
+
// matches an id against the raw key data.
|
|
288
|
+
export class SelectList {
|
|
289
|
+
constructor(items, maxVisible, theme) {
|
|
290
|
+
this.items = items;
|
|
291
|
+
this.maxVisible = maxVisible;
|
|
292
|
+
this.theme = theme;
|
|
293
|
+
this.selectedIndex = 0;
|
|
294
|
+
(globalThis.__fmPickerLists ??= []).push({ items: items.map((item) => ({ ...item })), maxVisible });
|
|
295
|
+
}
|
|
296
|
+
handleInput(data) {
|
|
297
|
+
if (data === "tui.select.down") {
|
|
298
|
+
this.selectedIndex = Math.min(this.selectedIndex + 1, this.items.length - 1);
|
|
299
|
+
} else if (data === "tui.select.up") {
|
|
300
|
+
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
301
|
+
} else if (data === "tui.select.confirm") {
|
|
302
|
+
const item = this.items[this.selectedIndex];
|
|
303
|
+
if (item) this.onSelect?.(item);
|
|
304
|
+
} else if (data === "tui.select.cancel") {
|
|
305
|
+
this.onCancel?.();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
getSelectedItem() {
|
|
309
|
+
return this.items[this.selectedIndex] ?? null;
|
|
310
|
+
}
|
|
311
|
+
invalidate() {}
|
|
312
|
+
render() {
|
|
313
|
+
return this.items.slice(0, this.maxVisible).map((item) => item.label);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function fuzzyFilter(items, query, getText) {
|
|
318
|
+
const needle = query.toLowerCase();
|
|
319
|
+
return items.filter((item) => getText(item).toLowerCase().includes(needle));
|
|
320
|
+
}
|
|
321
|
+
JS
|
|
322
|
+
cat > "$repo/node_modules/typebox/package.json" <<'JSON'
|
|
323
|
+
{"name":"typebox","type":"module","exports":"./index.js"}
|
|
324
|
+
JSON
|
|
325
|
+
cat > "$repo/node_modules/typebox/index.js" <<'JS'
|
|
326
|
+
export const Type = {
|
|
327
|
+
Object(properties, options) {
|
|
328
|
+
return { type: "object", properties, ...(options ?? {}) };
|
|
329
|
+
},
|
|
330
|
+
String(options) {
|
|
331
|
+
return { type: "string", ...(options ?? {}) };
|
|
332
|
+
},
|
|
333
|
+
Number(options) {
|
|
334
|
+
return { type: "number", ...(options ?? {}) };
|
|
335
|
+
},
|
|
336
|
+
Boolean(options) {
|
|
337
|
+
return { type: "boolean", ...(options ?? {}) };
|
|
338
|
+
},
|
|
339
|
+
Optional(schema) {
|
|
340
|
+
return { ...schema, optional: true };
|
|
341
|
+
},
|
|
342
|
+
Literal(value) {
|
|
343
|
+
return { const: value };
|
|
344
|
+
},
|
|
345
|
+
Union(schemas, options) {
|
|
346
|
+
return { anyOf: schemas, ...(options ?? {}) };
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
JS
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
# Shared driver preamble: a fake main-session ExtensionAPI with a synchronous
|
|
353
|
+
# event bus (mirrors pi's EventEmitter-backed bus), captured handlers, and
|
|
354
|
+
# captured main-bound messages.
|
|
355
|
+
DRIVER_PRELUDE_FILE="$TMP_ROOT/driver-prelude.js"
|
|
356
|
+
cat > "$DRIVER_PRELUDE_FILE" <<'JS'
|
|
357
|
+
const { spawnSync } = await import("node:child_process");
|
|
358
|
+
const { mkdirSync, writeFileSync } = await import("node:fs");
|
|
359
|
+
const { pathToFileURL } = await import("node:url");
|
|
360
|
+
|
|
361
|
+
const home = process.env.FM_HOME;
|
|
362
|
+
const realRoot = process.env.FM_ROOT_OVERRIDE;
|
|
363
|
+
const approvedProject = `${home}/projects/approved`;
|
|
364
|
+
mkdirSync(`${home}/state`, { recursive: true });
|
|
365
|
+
mkdirSync(`${home}/config`, { recursive: true });
|
|
366
|
+
mkdirSync(approvedProject, { recursive: true });
|
|
367
|
+
writeFileSync(`${home}/state/branch-driver.meta`, `project=${approvedProject}\nwindow=fm-branch-driver\n`);
|
|
368
|
+
// Supervision is default-on for every task: no captain grant file gates it
|
|
369
|
+
// any more.
|
|
370
|
+
// The branch acts only for the session that owns the fleet lock; drivers own
|
|
371
|
+
// it by default, while cold-start and secondary-session scenarios opt out.
|
|
372
|
+
if (!process.env.FM_TEST_SKIP_LOCK) {
|
|
373
|
+
writeFileSync(`${home}/state/.lock`, `${process.pid}\n`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Stubbed model surface: Pi's own catalog and selector dialog, scripted.
|
|
377
|
+
// registryModels is what ctx.modelRegistry serves, uiSelections queues the
|
|
378
|
+
// captain's answers to ctx.ui.select, and notices records what the command
|
|
379
|
+
// told the captain.
|
|
380
|
+
const registryModels = [];
|
|
381
|
+
const uiSelections = [];
|
|
382
|
+
const uiPrompts = [];
|
|
383
|
+
const notices = [];
|
|
384
|
+
const commands = new Map();
|
|
385
|
+
let mainModel = { provider: "anthropic", id: "main-model" };
|
|
386
|
+
// Main's own effort, which Pi answers through pi.getThinkingLevel(). Drivers
|
|
387
|
+
// change it through setMainThinkingLevel and then fire Pi's own
|
|
388
|
+
// thinking_level_select event, exactly as Pi does for a real /settings pick.
|
|
389
|
+
let mainThinkingLevel = "medium";
|
|
390
|
+
function setMainThinkingLevel(level) {
|
|
391
|
+
mainThinkingLevel = level;
|
|
392
|
+
}
|
|
393
|
+
globalThis.__fmBranchStaticModels = () => registryModels
|
|
394
|
+
.filter((model) => model.branchAvailable !== false)
|
|
395
|
+
.map((model) => ({ ...model }));
|
|
396
|
+
const modelRegistry = {
|
|
397
|
+
getAvailable: () => registryModels.filter((model) => model.mainAvailable !== false).slice(),
|
|
398
|
+
find: (provider, id) => registryModels.find((model) => model.provider === provider && model.id === id),
|
|
399
|
+
hasConfiguredAuth: (model) => model.mainAvailable !== false,
|
|
400
|
+
};
|
|
401
|
+
function makeCtx(extra) {
|
|
402
|
+
return {
|
|
403
|
+
modelRegistry,
|
|
404
|
+
get model() {
|
|
405
|
+
return mainModel;
|
|
406
|
+
},
|
|
407
|
+
ui: {
|
|
408
|
+
select(title, options) {
|
|
409
|
+
uiPrompts.push({ title, options });
|
|
410
|
+
return Promise.resolve(uiSelections.shift());
|
|
411
|
+
},
|
|
412
|
+
notify(message, type) {
|
|
413
|
+
notices.push({ message, type });
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
...(extra ?? {}),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// A TUI-mode context whose ui.custom runs the extension's real picker
|
|
421
|
+
// component headlessly: the smidja receives a fake renderer, a pass-through
|
|
422
|
+
// theme, and a keybindings manager that matches a keybinding id against the
|
|
423
|
+
// raw key data, and then the next queued keystroke script is fed to the
|
|
424
|
+
// component's own handleInput. Keystrokes are either a keybinding id
|
|
425
|
+
// (navigation) or literal characters (search). mainModelWrites records every
|
|
426
|
+
// attempt to move the captain's own model, which pinning the branch must
|
|
427
|
+
// never do.
|
|
428
|
+
const uiKeystrokes = [];
|
|
429
|
+
const mainModelWrites = [];
|
|
430
|
+
function makeTuiCtx(extra) {
|
|
431
|
+
const base = makeCtx(extra);
|
|
432
|
+
return {
|
|
433
|
+
...base,
|
|
434
|
+
get model() {
|
|
435
|
+
return mainModel;
|
|
436
|
+
},
|
|
437
|
+
mode: "tui",
|
|
438
|
+
settingsManager: {
|
|
439
|
+
setDefaultModelAndProvider(provider, id) {
|
|
440
|
+
mainModelWrites.push({ provider, id });
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
setModel(provider, id) {
|
|
444
|
+
mainModelWrites.push({ provider, id });
|
|
445
|
+
},
|
|
446
|
+
ui: {
|
|
447
|
+
...base.ui,
|
|
448
|
+
async custom(smidja) {
|
|
449
|
+
let result;
|
|
450
|
+
let settled = false;
|
|
451
|
+
const component = await smidja(
|
|
452
|
+
{
|
|
453
|
+
requestRender() {},
|
|
454
|
+
},
|
|
455
|
+
{ fg: (_color, text) => text, bold: (text) => text },
|
|
456
|
+
{ matches: (data, id) => data === id },
|
|
457
|
+
(value) => {
|
|
458
|
+
result = value;
|
|
459
|
+
settled = true;
|
|
460
|
+
},
|
|
461
|
+
);
|
|
462
|
+
component.render(80);
|
|
463
|
+
for (const key of uiKeystrokes.shift() ?? ["tui.select.confirm"]) {
|
|
464
|
+
if (settled) break;
|
|
465
|
+
component.handleInput(key);
|
|
466
|
+
}
|
|
467
|
+
if (!settled) throw new Error("the picker script ended without a selection or a cancellation");
|
|
468
|
+
return result;
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const busHandlers = new Map();
|
|
475
|
+
const bus = {
|
|
476
|
+
on(channel, handler) {
|
|
477
|
+
busHandlers.set(channel, [...(busHandlers.get(channel) ?? []), handler]);
|
|
478
|
+
return () => {};
|
|
479
|
+
},
|
|
480
|
+
emit(channel, data) {
|
|
481
|
+
for (const handler of busHandlers.get(channel) ?? []) handler(data);
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
const piHandlers = new Map();
|
|
485
|
+
const sentToMain = [];
|
|
486
|
+
const mainUserMessages = [];
|
|
487
|
+
const mainTools = [];
|
|
488
|
+
const renderers = new Map();
|
|
489
|
+
const pi = {
|
|
490
|
+
events: bus,
|
|
491
|
+
on(event, handler) {
|
|
492
|
+
piHandlers.set(event, [...(piHandlers.get(event) ?? []), handler]);
|
|
493
|
+
},
|
|
494
|
+
registerTool(tool) {
|
|
495
|
+
mainTools.push(tool);
|
|
496
|
+
},
|
|
497
|
+
registerCommand(name, options) {
|
|
498
|
+
commands.set(name, options);
|
|
499
|
+
},
|
|
500
|
+
registerMessageRenderer(customType, renderer) {
|
|
501
|
+
renderers.set(customType, renderer);
|
|
502
|
+
},
|
|
503
|
+
sendMessage(message, options) {
|
|
504
|
+
sentToMain.push({ message, options: options ?? {} });
|
|
505
|
+
},
|
|
506
|
+
sendUserMessage(content, options) {
|
|
507
|
+
mainUserMessages.push({ content, options: options ?? {} });
|
|
508
|
+
},
|
|
509
|
+
getThinkingLevel() {
|
|
510
|
+
if (globalThis.__fmThinkingLevelError) throw new Error(globalThis.__fmThinkingLevelError);
|
|
511
|
+
return mainThinkingLevel;
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
function fire(event, payload, ctx) {
|
|
515
|
+
for (const handler of piHandlers.get(event) ?? []) handler(payload, ctx);
|
|
516
|
+
}
|
|
517
|
+
function makeOffer(message, projects = [approvedProject], heartbeat = false, eligible = projects.length > 0 || heartbeat) {
|
|
518
|
+
const offer = {
|
|
519
|
+
message,
|
|
520
|
+
projects,
|
|
521
|
+
heartbeat,
|
|
522
|
+
eligible,
|
|
523
|
+
accepted: false,
|
|
524
|
+
accept() {
|
|
525
|
+
offer.accepted = true;
|
|
526
|
+
},
|
|
527
|
+
};
|
|
528
|
+
return offer;
|
|
529
|
+
}
|
|
530
|
+
function dispatch(message, projects, heartbeat, eligible) {
|
|
531
|
+
const offer = makeOffer(message, projects, heartbeat, eligible);
|
|
532
|
+
if (offer.eligible) {
|
|
533
|
+
const row = offer.heartbeat
|
|
534
|
+
? "1\t1\theartbeat\theartbeat\theartbeat\n"
|
|
535
|
+
: `1\t1\tsignal\tbranch-driver.status\t${message}\n`;
|
|
536
|
+
writeFileSync(`${home}/state/.wake-queue`, row);
|
|
537
|
+
}
|
|
538
|
+
bus.emit("fm-branch-supervision:dispatch", offer);
|
|
539
|
+
return offer;
|
|
540
|
+
}
|
|
541
|
+
async function settle(predicate, label) {
|
|
542
|
+
for (let i = 0; i < 250; i += 1) {
|
|
543
|
+
if (predicate()) return;
|
|
544
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
545
|
+
}
|
|
546
|
+
throw new Error(`timed out waiting for ${label}`);
|
|
547
|
+
}
|
|
548
|
+
function outcomeScript(args) {
|
|
549
|
+
const result = spawnSync("bash", [`${realRoot}/bin/fm-branch-outcome.sh`, ...args], {
|
|
550
|
+
encoding: "utf8",
|
|
551
|
+
env: { ...process.env, FM_HOME: home, FM_STATE_OVERRIDE: `${home}/state` },
|
|
552
|
+
});
|
|
553
|
+
if (result.status !== 0) throw new Error(`fm-branch-outcome.sh ${args.join(" ")} failed: ${result.stderr}`);
|
|
554
|
+
return (result.stdout || "").trim();
|
|
555
|
+
}
|
|
556
|
+
const mod = await import(pathToFileURL(process.env.PLUGIN).href);
|
|
557
|
+
mod.default(pi);
|
|
558
|
+
JS
|
|
559
|
+
DRIVER_PRELUDE=$(cat "$DRIVER_PRELUDE_FILE")
|
|
560
|
+
|
|
561
|
+
test_branch_dispatch_two_stage_filter_and_prefix_contract() {
|
|
562
|
+
local repo home out status
|
|
563
|
+
repo="$TMP_ROOT/dispatch-root"
|
|
564
|
+
home="$TMP_ROOT/dispatch-home"
|
|
565
|
+
mkdir -p "$home/state" "$home/config"
|
|
566
|
+
install_pi_branch_extension_fixture "$repo"
|
|
567
|
+
PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
|
|
568
|
+
DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
|
|
569
|
+
const prelude = process.env.DRIVER_PRELUDE;
|
|
570
|
+
await eval(`(async () => { ${prelude}; globalThis.__t = { pi, fire, dispatch, settle, outcomeScript, sentToMain, mainUserMessages, mainTools, renderers, home, realRoot }; })()`);
|
|
571
|
+
const { pi, fire, dispatch, settle, outcomeScript, sentToMain, mainUserMessages, mainTools, renderers, home, realRoot } = globalThis.__t;
|
|
572
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
573
|
+
|
|
574
|
+
writeFileSync(`${home}/state/.lock`, `${process.ppid}\n`);
|
|
575
|
+
|
|
576
|
+
// 1. An accepted wake reaches the branch session, never main.
|
|
577
|
+
const offer = dispatch("signal: task-9 done: PR https://example.com/pr/9 checks green");
|
|
578
|
+
if (!offer.accepted) throw new Error("branch did not accept the wake offer");
|
|
579
|
+
await settle(() => (globalThis.__fmPrompts ?? []).length === 1, "branch wake prompt");
|
|
580
|
+
const wakePrompt = globalThis.__fmPrompts[0];
|
|
581
|
+
if (!wakePrompt.includes("FIRSTMATE SUPERVISION WAKE: signal: task-9 done")) {
|
|
582
|
+
throw new Error(`branch prompt lost the wake reason: ${wakePrompt}`);
|
|
583
|
+
}
|
|
584
|
+
if (mainUserMessages.length !== 0) throw new Error("accepted wake leaked to main as a user message");
|
|
585
|
+
|
|
586
|
+
// 2. Byte-stable prefix contract: same tool names in the same order, a
|
|
587
|
+
// generator-produced system prompt, no project resources, and the branch bash
|
|
588
|
+
// carries the deterministic actor identity.
|
|
589
|
+
const session = globalThis.__fmSessions[0];
|
|
590
|
+
if (JSON.stringify(session.options.tools) !== JSON.stringify(["read", "bash", "fm_branch_report"])) {
|
|
591
|
+
throw new Error(`unexpected tool order: ${JSON.stringify(session.options.tools)}`);
|
|
592
|
+
}
|
|
593
|
+
const loader = globalThis.__fmLoaders[0];
|
|
594
|
+
for (const key of ["noExtensions", "noSkills", "noPromptTemplates", "noThemes", "noContextFiles"]) {
|
|
595
|
+
if (loader.options[key] !== true) throw new Error(`branch loader must set ${key}`);
|
|
596
|
+
}
|
|
597
|
+
if (!loader.options.systemPrompt || !loader.options.systemPrompt.startsWith("You are the SUPERVISION BRANCH")) {
|
|
598
|
+
throw new Error("branch system prompt is not the generator's output");
|
|
599
|
+
}
|
|
600
|
+
if (loader.options.systemPrompt.length < 4096) throw new Error("branch prompt is below the provider caching minimum");
|
|
601
|
+
const bashTool = session.options.customTools.find((tool) => tool.name === "bash");
|
|
602
|
+
const hooked = bashTool.__options.spawnHook({ command: "true", cwd: "/x", env: { PATH: "/bin" } });
|
|
603
|
+
if (hooked.env.FM_SUPERVISION_ACTOR !== "branch") throw new Error("branch bash does not inject the branch actor");
|
|
604
|
+
if (hooked.env.FM_LEASE_HOLDER_PID !== String(process.ppid)) throw new Error("branch bash does not pin the verified session-lock holder pid");
|
|
605
|
+
|
|
606
|
+
// 3. Shared per-home prompt_cache_key: overrides only payloads that already
|
|
607
|
+
// carry one, stable within the home.
|
|
608
|
+
let cacheHandler = null;
|
|
609
|
+
const smidjaEntry = loader.options.extensionFactories[0];
|
|
610
|
+
const smidja = typeof smidjaEntry === "function" ? smidjaEntry : smidjaEntry.smidja;
|
|
611
|
+
smidja({ on: (event, handler) => { if (event === "before_provider_request") cacheHandler = handler; } });
|
|
612
|
+
if (!cacheHandler) throw new Error("branch cache-key hook not registered");
|
|
613
|
+
const rewriteA = cacheHandler({ type: "before_provider_request", payload: { prompt_cache_key: "session-a", model: "m" } });
|
|
614
|
+
const rewriteB = cacheHandler({ type: "before_provider_request", payload: { prompt_cache_key: "session-b", model: "m" } });
|
|
615
|
+
if (!rewriteA.prompt_cache_key.startsWith("fm-branch-")) throw new Error(`unexpected cache key: ${rewriteA.prompt_cache_key}`);
|
|
616
|
+
if (rewriteA.prompt_cache_key !== rewriteB.prompt_cache_key) throw new Error("branch cache key varies within one home");
|
|
617
|
+
if (rewriteA.model !== "m") throw new Error("cache-key hook dropped payload fields");
|
|
618
|
+
const untouched = cacheHandler({ type: "before_provider_request", payload: { model: "m" } });
|
|
619
|
+
if (untouched !== undefined) throw new Error("cache-key hook rewrote a provider payload with no prompt_cache_key");
|
|
620
|
+
console.log(`CACHE_KEY=${rewriteA.prompt_cache_key}`);
|
|
621
|
+
|
|
622
|
+
// 4. Two-stage filter, stage 2: routine while main is idle appends with no
|
|
623
|
+
// turn; routine while main is busy defers to after the captain's next prompt;
|
|
624
|
+
// captain-relevant appends and triggers exactly one turn. Store rows are
|
|
625
|
+
// written BEFORE the merge note and marked read after it.
|
|
626
|
+
const report = session.options.customTools.find((tool) => tool.name === "fm_branch_report");
|
|
627
|
+
const r1 = await report.execute("call-1", { task: "task-9", verdict: "routine", summary: "worker healthy, no action needed", wake: "signal: working" }, undefined, undefined, {});
|
|
628
|
+
if (r1.isError) throw new Error(`routine report failed: ${JSON.stringify(r1)}`);
|
|
629
|
+
if (sentToMain.length !== 1) throw new Error("routine report did not merge exactly one note");
|
|
630
|
+
if (sentToMain[0].message.customType !== "fm-branch-merge") throw new Error("merge note has the wrong custom type");
|
|
631
|
+
if (sentToMain[0].options.triggerTurn) throw new Error("routine idle merge must not trigger a turn");
|
|
632
|
+
if (sentToMain[0].options.deliverAs) throw new Error("routine idle merge must append immediately");
|
|
633
|
+
fire("agent_start", {});
|
|
634
|
+
await report.execute("call-2", { task: "task-9", verdict: "routine", summary: "still healthy" }, undefined, undefined, {});
|
|
635
|
+
if (sentToMain[1].options.deliverAs !== "nextTurn" || sentToMain[1].options.triggerTurn) {
|
|
636
|
+
throw new Error(`routine busy merge must defer to nextTurn without a turn: ${JSON.stringify(sentToMain[1].options)}`);
|
|
637
|
+
}
|
|
638
|
+
fire("agent_end", {});
|
|
639
|
+
await report.execute("call-3", { task: "task-9", verdict: "captain", summary: "PR https://example.com/pr/9 checks green, ready for review" }, undefined, undefined, {});
|
|
640
|
+
if (sentToMain[2].options.triggerTurn !== true || sentToMain[2].options.deliverAs !== "followUp") {
|
|
641
|
+
throw new Error(`captain merge must trigger exactly one follow-up turn: ${JSON.stringify(sentToMain[2].options)}`);
|
|
642
|
+
}
|
|
643
|
+
if (typeof sentToMain[0].message.content !== "string" || !sentToMain[0].message.content.startsWith("⛵ ")) {
|
|
644
|
+
throw new Error(`routine note missing sailboat prefix: ${sentToMain[0].message.content}`);
|
|
645
|
+
}
|
|
646
|
+
if (/branch merged|\[routine\]|\[captain\]/.test(sentToMain[0].message.content)) {
|
|
647
|
+
throw new Error(`routine note still has boilerplate: ${sentToMain[0].message.content}`);
|
|
648
|
+
}
|
|
649
|
+
// A routine note is rendered (display: true); a captain-facing note must
|
|
650
|
+
// never be printed or rendered at all - the follow-up turn triggered above
|
|
651
|
+
// is itself the captain-visible outcome. display: false is the exact flag
|
|
652
|
+
// Pi's own chat renderer and HTML export both gate on before ever calling a
|
|
653
|
+
// customType renderer, so this is the authoritative "never printed" proof.
|
|
654
|
+
if (sentToMain[0].message.display !== true) {
|
|
655
|
+
throw new Error(`routine note must render: display=${sentToMain[0].message.display}`);
|
|
656
|
+
}
|
|
657
|
+
if (sentToMain[2].message.display !== false) {
|
|
658
|
+
throw new Error(`captain note must never be printed or rendered: display=${sentToMain[2].message.display}`);
|
|
659
|
+
}
|
|
660
|
+
if (typeof sentToMain[2].message.content !== "string" || sentToMain[2].message.content.includes("⚓")) {
|
|
661
|
+
throw new Error(`captain note must carry no anchor glyph now that it is never rendered: ${sentToMain[2].message.content}`);
|
|
662
|
+
}
|
|
663
|
+
if (!sentToMain[2].message.content.includes("task-9: PR https://example.com/pr/9")) {
|
|
664
|
+
throw new Error(`captain note lost its outcome: ${sentToMain[2].message.content}`);
|
|
665
|
+
}
|
|
666
|
+
if (/branch merged|\[routine\]|\[captain\]/.test(sentToMain[2].message.content)) {
|
|
667
|
+
throw new Error(`captain note still has boilerplate: ${sentToMain[2].message.content}`);
|
|
668
|
+
}
|
|
669
|
+
// What main's model actually receives. Pi keeps only `content` when it turns a
|
|
670
|
+
// custom message into a provider message - customType, display, and details are
|
|
671
|
+
// all dropped - so `content` IS the delivered payload, and these two files are
|
|
672
|
+
// the exact bytes main's model would read. The bash side classifies them with
|
|
673
|
+
// the REAL bin/fm-operational-input.sh so the protocol's own executable, not a
|
|
674
|
+
// pattern in this test, decides what was delivered. Pi's half of that contract
|
|
675
|
+
// is proven separately against the real SDK in fm-pi-branch-live-e2e.test.sh.
|
|
676
|
+
writeFileSync(`${home}/state/delivered-captain-note`, sentToMain[2].message.content);
|
|
677
|
+
writeFileSync(`${home}/state/delivered-routine-note`, sentToMain[0].message.content);
|
|
678
|
+
if (sentToMain.filter((sent) => sent.options.triggerTurn).length !== 1) {
|
|
679
|
+
throw new Error("one captain outcome must open exactly one turn on main");
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// The store (the owned durable contract) holds all three outcomes in order,
|
|
683
|
+
// and each merged note advanced the read cursor.
|
|
684
|
+
const rows = readFileSync(`${home}/state/branch-outcomes.jsonl`, "utf8").trim().split("\n").map((line) => JSON.parse(line));
|
|
685
|
+
if (rows.length !== 3) throw new Error(`expected 3 store rows, got ${rows.length}`);
|
|
686
|
+
if (rows[0].verdict !== "routine" || rows[2].verdict !== "captain") throw new Error("store verdicts out of order");
|
|
687
|
+
if (rows[0].wake !== "signal: working") throw new Error("store lost the wake reason");
|
|
688
|
+
if (outcomeScript(["unread"]) !== "") throw new Error("merged outcomes were not marked read");
|
|
689
|
+
|
|
690
|
+
// 5. Main-side surfaces: the on-demand store reader tool and the merge-note
|
|
691
|
+
// renderer.
|
|
692
|
+
const outcomesTool = mainTools.find((tool) => tool.name === "fm_branch_outcomes");
|
|
693
|
+
if (!outcomesTool) throw new Error("fm_branch_outcomes was not registered on main");
|
|
694
|
+
const renderTheme = {
|
|
695
|
+
fg(_color, text) { return text; },
|
|
696
|
+
bg(_color, text) { return text; },
|
|
697
|
+
bold(text) { return text; },
|
|
698
|
+
};
|
|
699
|
+
const renderContext = { state: {}, isError: false, isPartial: false };
|
|
700
|
+
const stockResult = { content: [{ type: "text", text: "OUTCOME_DUMP" }] };
|
|
701
|
+
const calmOffCall = outcomesTool.renderCall({}, renderTheme, renderContext);
|
|
702
|
+
const calmOffResult = outcomesTool.renderResult(stockResult, { expanded: false, isPartial: false }, renderTheme, renderContext);
|
|
703
|
+
if (calmOffCall.constructor.name !== "Box" || calmOffCall.paddingX !== 1 || calmOffCall.paddingY !== 1) {
|
|
704
|
+
throw new Error("fm_branch_outcomes changed its ordinary shell rendering");
|
|
705
|
+
}
|
|
706
|
+
if (calmOffResult.constructor.name !== "Container" || calmOffCall.children[0]?.text !== "fm_branch_outcomes" || calmOffCall.children[1]?.text !== "OUTCOME_DUMP") {
|
|
707
|
+
throw new Error("fm_branch_outcomes changed its ordinary call or result rendering");
|
|
708
|
+
}
|
|
709
|
+
const legacyStockResult = {
|
|
710
|
+
content: [{
|
|
711
|
+
type: "text",
|
|
712
|
+
text: Array.from({ length: 12 }, (_, index) => `LEGACY_OUTCOME_${String(index + 1).padStart(2, "0")}`).join("\n"),
|
|
713
|
+
}],
|
|
714
|
+
};
|
|
715
|
+
const legacyRenderContext = { state: {}, isError: false, isPartial: false };
|
|
716
|
+
const legacyCall = outcomesTool.renderCall({}, renderTheme, legacyRenderContext);
|
|
717
|
+
outcomesTool.renderResult(legacyStockResult, { expanded: false, isPartial: false }, renderTheme, legacyRenderContext);
|
|
718
|
+
const collapsedLegacyText = legacyCall.children[1]?.text;
|
|
719
|
+
if (!collapsedLegacyText?.includes("LEGACY_OUTCOME_12") || collapsedLegacyText.includes("more lines")) {
|
|
720
|
+
throw new Error("legacy all-line stock capability did not preserve collapsed Calm-off output");
|
|
721
|
+
}
|
|
722
|
+
outcomesTool.renderResult(legacyStockResult, { expanded: true, isPartial: false }, renderTheme, legacyRenderContext);
|
|
723
|
+
if (legacyCall.children[1]?.text !== collapsedLegacyText) {
|
|
724
|
+
throw new Error("legacy all-line stock capability changed expanded Calm-off output");
|
|
725
|
+
}
|
|
726
|
+
pi.events.emit("firstmate:calm-presentation", { active: true, stockExportRendering: false });
|
|
727
|
+
const calmOnCall = outcomesTool.renderCall({}, renderTheme, renderContext);
|
|
728
|
+
const calmOnResult = outcomesTool.renderResult(stockResult, { expanded: false, isPartial: false }, renderTheme, renderContext);
|
|
729
|
+
if (calmOnCall.constructor.name !== "Container" || calmOnCall.render(100).length !== 0 || calmOnResult.constructor.name !== "Container" || calmOnResult.render(100).length !== 0) {
|
|
730
|
+
throw new Error("fm_branch_outcomes remained visible while Calm was on");
|
|
731
|
+
}
|
|
732
|
+
pi.events.emit("firstmate:calm-presentation", { active: false, stockExportRendering: false });
|
|
733
|
+
if (outcomesTool.renderCall({}, renderTheme, renderContext).constructor.name !== "Box" || outcomesTool.renderResult(stockResult, { expanded: false, isPartial: false }, renderTheme, renderContext).constructor.name !== "Container") {
|
|
734
|
+
throw new Error("fm_branch_outcomes did not restore ordinary rendering when Calm was turned off");
|
|
735
|
+
}
|
|
736
|
+
pi.events.emit("firstmate:calm-presentation", { active: true, stockExportRendering: true });
|
|
737
|
+
let exportCallFellBack = false;
|
|
738
|
+
let exportResultFellBack = false;
|
|
739
|
+
try {
|
|
740
|
+
outcomesTool.renderCall({}, renderTheme, renderContext);
|
|
741
|
+
} catch {
|
|
742
|
+
exportCallFellBack = true;
|
|
743
|
+
}
|
|
744
|
+
try {
|
|
745
|
+
outcomesTool.renderResult(stockResult, { expanded: false, isPartial: false }, renderTheme, renderContext);
|
|
746
|
+
} catch {
|
|
747
|
+
exportResultFellBack = true;
|
|
748
|
+
}
|
|
749
|
+
if (!exportCallFellBack || !exportResultFellBack) {
|
|
750
|
+
throw new Error("fm_branch_outcomes replaced Pi stock export rendering");
|
|
751
|
+
}
|
|
752
|
+
const listed = await outcomesTool.execute("call-4", { recent: 2 }, undefined, undefined, {});
|
|
753
|
+
const listedText = listed.content[0].text;
|
|
754
|
+
if (listedText.split("\n").length !== 2 || !listedText.includes("checks green")) {
|
|
755
|
+
throw new Error(`fm_branch_outcomes did not read the store: ${listedText}`);
|
|
756
|
+
}
|
|
757
|
+
if (!renderers.has("fm-branch-merge")) throw new Error("merge-note renderer missing");
|
|
758
|
+
const assertRenderedNote = (note, glyph) => {
|
|
759
|
+
const fgCalls = [];
|
|
760
|
+
const rendered = renderers.get("fm-branch-merge")(
|
|
761
|
+
{ content: note },
|
|
762
|
+
{ expanded: false },
|
|
763
|
+
{
|
|
764
|
+
fg(color, text) {
|
|
765
|
+
fgCalls.push({ color, text });
|
|
766
|
+
return text;
|
|
767
|
+
},
|
|
768
|
+
},
|
|
769
|
+
);
|
|
770
|
+
if (!String(rendered.text).includes(glyph)) throw new Error(`renderer dropped ${glyph}: ${rendered.text}`);
|
|
771
|
+
if (String(rendered.text).includes("branch merged")) throw new Error(`renderer kept boilerplate: ${rendered.text}`);
|
|
772
|
+
if (rendered.paddingX === 0 && rendered.paddingY === 0) {
|
|
773
|
+
throw new Error("renderer still pads with 0,0 instead of outputPad");
|
|
774
|
+
}
|
|
775
|
+
if (rendered.paddingX !== 1 || rendered.paddingY !== 0) {
|
|
776
|
+
throw new Error(
|
|
777
|
+
`renderer padding should match real Pi messages (outputPad, 0), got ${rendered.paddingX},${rendered.paddingY}`,
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
const glyphCalls = fgCalls.filter((call) => call.text === glyph);
|
|
781
|
+
if (glyphCalls.length !== 1 || glyphCalls[0].color === "dim") {
|
|
782
|
+
throw new Error(`icon ${glyph} must carry color, not dim: ${JSON.stringify(fgCalls)}`);
|
|
783
|
+
}
|
|
784
|
+
const restCalls = fgCalls.filter((call) => call.text !== glyph);
|
|
785
|
+
if (restCalls.length === 0 || restCalls.some((call) => call.color !== "dim")) {
|
|
786
|
+
throw new Error(`note remainder must be dim: ${JSON.stringify(fgCalls)}`);
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
assertRenderedNote(sentToMain[0].message.content, "⛵");
|
|
790
|
+
process.exit(0);
|
|
791
|
+
EOF
|
|
792
|
+
status=$?
|
|
793
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
794
|
+
expect_code 0 "$status" "branch dispatch, prefix contract, and two-stage filter must hold: $out"
|
|
795
|
+
case "$out" in
|
|
796
|
+
CACHE_KEY=fm-branch-*) ;;
|
|
797
|
+
*) fail "cache key line missing from driver output: $out" ;;
|
|
798
|
+
esac
|
|
799
|
+
pass "branch owns accepted wakes with a stable prefix contract and verdict-driven merge delivery"
|
|
800
|
+
|
|
801
|
+
# The delivered captain payload must identify itself to main's model. When it
|
|
802
|
+
# did not, main could not tell an incoming outcome from its own earlier answer
|
|
803
|
+
# and re-emitted that answer instead of relaying the outcome, silently losing
|
|
804
|
+
# it. The real protocol executable is the oracle here: it decides the kind and
|
|
805
|
+
# extracts the body, so this asserts delivered behavior rather than a shape
|
|
806
|
+
# this test already knows.
|
|
807
|
+
local kind body
|
|
808
|
+
kind=$(./bin/fm-operational-input.sh kind < "$home/state/delivered-captain-note") \
|
|
809
|
+
|| fail "captain outcome reaches main's model as unattributed text the model cannot tell from its own answer"
|
|
810
|
+
[ "$kind" = branch-outcome ] \
|
|
811
|
+
|| fail "captain outcome delivered as kind '$kind', not branch-outcome"
|
|
812
|
+
body=$(./bin/fm-operational-input.sh body < "$home/state/delivered-captain-note") \
|
|
813
|
+
|| fail "captain outcome envelope carries no readable body"
|
|
814
|
+
case "$body" in
|
|
815
|
+
*"This is a supervision outcome delivered automatically by the supervision branch."*"It was not typed by the captain."*"task-9: PR https://example.com/pr/9"*) ;;
|
|
816
|
+
*) fail "captain outcome body lost its self-description or the outcome itself: $body" ;;
|
|
817
|
+
esac
|
|
818
|
+
# Event ownership and conversational judgment are separate contracts. The
|
|
819
|
+
# delivered instruction forbids reprocessing the fleet event but leaves main
|
|
820
|
+
# free to decide how the outcome belongs in the captain conversation.
|
|
821
|
+
case "$body" in
|
|
822
|
+
*"The fleet event is already handled: do not re-drain, re-run, or acknowledge it."*) ;;
|
|
823
|
+
*) fail "captain outcome body lost the event-ownership boundary: $body" ;;
|
|
824
|
+
esac
|
|
825
|
+
case "$body" in
|
|
826
|
+
*"This outcome is captain-facing: give the captain a visible response now."*"Use your judgment over the wording and how to incorporate it, not whether to surface it."*) ;;
|
|
827
|
+
*) fail "captain outcome body made visibility optional or removed wording judgment: $body" ;;
|
|
828
|
+
esac
|
|
829
|
+
case "$body" in
|
|
830
|
+
*"An outcome that directly answers an explicit captain request is captain-facing"*"regardless of whether it is healthy, routine, measured, actionable, or requires a decision."*) ;;
|
|
831
|
+
*) fail "captain outcome body lost the unconditional explicit-request rule: $body" ;;
|
|
832
|
+
esac
|
|
833
|
+
# The routine note is rendered in the TUI, and its renderer reads the glyph off
|
|
834
|
+
# the front of this same string, so it must stay plain text.
|
|
835
|
+
if ./bin/fm-operational-input.sh kind < "$home/state/delivered-routine-note" >/dev/null 2>&1; then
|
|
836
|
+
fail "routine note must stay plain rendered text, not typed operational input"
|
|
837
|
+
fi
|
|
838
|
+
pass "a captain outcome reaches main's model as typed, self-describing input while routine notes stay plain"
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
test_requested_healthy_outcome_and_unsolicited_routine_outcome_delivery() {
|
|
842
|
+
local repo home out status
|
|
843
|
+
repo="$TMP_ROOT/requested-outcome-root"
|
|
844
|
+
home="$TMP_ROOT/requested-outcome-home"
|
|
845
|
+
mkdir -p "$home/state" "$home/config"
|
|
846
|
+
install_pi_branch_extension_fixture "$repo"
|
|
847
|
+
PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
|
|
848
|
+
DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
|
|
849
|
+
const prelude = process.env.DRIVER_PRELUDE;
|
|
850
|
+
await eval(`(async () => { ${prelude}; globalThis.__t = { fire, dispatch, settle, sentToMain, outcomeScript, mainTools, home, realRoot }; })()`);
|
|
851
|
+
const { fire, dispatch, settle, sentToMain, outcomeScript, mainTools, home, realRoot } = globalThis.__t;
|
|
852
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
853
|
+
import { spawnSync } from "node:child_process";
|
|
854
|
+
|
|
855
|
+
const fleetOperations = [];
|
|
856
|
+
globalThis.__fmExecuteBranchBash = async (context) => {
|
|
857
|
+
const actor = spawnSync(
|
|
858
|
+
"bash",
|
|
859
|
+
["-c", '. "$1"; fm_lease_actor', "_", `${realRoot}/bin/fm-lease-lib.sh`],
|
|
860
|
+
{ encoding: "utf8", cwd: context.cwd, env: context.env },
|
|
861
|
+
);
|
|
862
|
+
if (actor.status !== 0) throw new Error(`branch bash actor resolution failed: ${actor.stderr}`);
|
|
863
|
+
const result = spawnSync("bash", ["-c", context.command], {
|
|
864
|
+
encoding: "utf8",
|
|
865
|
+
cwd: context.cwd,
|
|
866
|
+
env: context.env,
|
|
867
|
+
});
|
|
868
|
+
fleetOperations.push({ command: context.command, actor: actor.stdout.trim(), status: result.status });
|
|
869
|
+
return {
|
|
870
|
+
content: [{ type: "text", text: `${result.stdout}${result.stderr}` }],
|
|
871
|
+
details: { stdout: result.stdout, stderr: result.stderr, exitCode: result.status, actor: actor.stdout.trim() },
|
|
872
|
+
isError: result.status !== 0,
|
|
873
|
+
};
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
async function runFleetCommand(session, args) {
|
|
877
|
+
const bash = session.options.customTools.find((tool) => tool.name === "bash");
|
|
878
|
+
const command = ["bin/fm-wake-drain.sh", ...args].join(" ");
|
|
879
|
+
const result = await bash.execute(`fleet-${fleetOperations.length}`, { command }, undefined, undefined, {});
|
|
880
|
+
if (result.isError) throw new Error(`fleet command failed: ${JSON.stringify(result)}`);
|
|
881
|
+
return result.details;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function directlyRequestsResourceReport(mirror) {
|
|
885
|
+
const latestCaptain = mirror.at(-1) ?? "";
|
|
886
|
+
const words = new Set(latestCaptain.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
|
|
887
|
+
const requestsDelivery = ["give", "provide", "send", "show"].some((word) => words.has(word));
|
|
888
|
+
const namesReport = ["report", "status", "measurement"].some((word) => words.has(word));
|
|
889
|
+
const namesResources = ["resource", "resources", "cpu", "memory"].some((word) => words.has(word));
|
|
890
|
+
return requestsDelivery && namesReport && namesResources;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
globalThis.__fmOnBranchPrompt = async ({ session }) => {
|
|
894
|
+
const mirror = session.ops
|
|
895
|
+
.filter((op) => op.kind === "custom" && op.message.customType === "fm-main-mirror")
|
|
896
|
+
.map((op) => op.message.content);
|
|
897
|
+
const directlyRequested = directlyRequestsResourceReport(mirror);
|
|
898
|
+
const drained = await runFleetCommand(session, []);
|
|
899
|
+
const ack = drained.stderr.match(/--ack-through ([0-9]+) --recovery-generation ([A-Za-z0-9._-]+)/);
|
|
900
|
+
if (!ack) throw new Error(`drain did not return its acknowledgement command: ${drained.stderr}`);
|
|
901
|
+
const report = session.options.customTools.find((tool) => tool.name === "fm_branch_report");
|
|
902
|
+
const verdictDescription = report.parameters.properties.verdict.description;
|
|
903
|
+
if (!verdictDescription.includes("unconditionally") ||
|
|
904
|
+
!verdictDescription.includes("directly answers an explicit captain request") ||
|
|
905
|
+
!verdictDescription.includes("regardless of whether it is healthy, routine, measured, actionable, or requires a decision")) {
|
|
906
|
+
throw new Error(`branch provider received conflicting verdict semantics: ${verdictDescription}`);
|
|
907
|
+
}
|
|
908
|
+
const result = await report.execute(
|
|
909
|
+
`resource-result-${fleetOperations.length}`,
|
|
910
|
+
{
|
|
911
|
+
task: "task-resource",
|
|
912
|
+
verdict: directlyRequested ? "captain" : "routine",
|
|
913
|
+
summary: "healthy resource report: CPU 12%, memory 41%",
|
|
914
|
+
wake: "signal: healthy resource result",
|
|
915
|
+
},
|
|
916
|
+
undefined,
|
|
917
|
+
undefined,
|
|
918
|
+
{},
|
|
919
|
+
);
|
|
920
|
+
if (result.isError) throw new Error(`branch report failed: ${JSON.stringify(result)}`);
|
|
921
|
+
await runFleetCommand(session, ["--ack-through", ack[1], "--recovery-generation", ack[2]]);
|
|
922
|
+
};
|
|
923
|
+
|
|
924
|
+
const explicitRequest = "Please give me a fresh mini system-resource report.";
|
|
925
|
+
const longRequests = [
|
|
926
|
+
`${explicitRequest}${" head context".repeat(500)}`,
|
|
927
|
+
`${"middle context ".repeat(250)}${explicitRequest}${" middle context".repeat(250)}`,
|
|
928
|
+
`${"tail context ".repeat(500)}${explicitRequest}`,
|
|
929
|
+
];
|
|
930
|
+
const requestedPrompts = [...longRequests, "FIRSTMATE give me a fresh system-resource report."];
|
|
931
|
+
// Match Pi's real AgentSession.prompt ordering: before_agent_start receives
|
|
932
|
+
// the expanded prompt before _runAgentPrompt appends its user message to the
|
|
933
|
+
// SessionManager. Keeping entries stale at the hook boundary is the regression.
|
|
934
|
+
const entries = [];
|
|
935
|
+
const mainCtx = {
|
|
936
|
+
model: { provider: "anthropic", id: "main-model" },
|
|
937
|
+
sessionManager: {
|
|
938
|
+
getSessionFile: () => `${home}/main.jsonl`,
|
|
939
|
+
getEntries: () => entries,
|
|
940
|
+
},
|
|
941
|
+
};
|
|
942
|
+
const operational = spawnSync(
|
|
943
|
+
"bash",
|
|
944
|
+
[`${realRoot}/bin/fm-operational-input.sh`, "encode", "watcher"],
|
|
945
|
+
{ encoding: "utf8", input: "operational watcher injection" },
|
|
946
|
+
);
|
|
947
|
+
if (operational.status !== 0) throw new Error(`could not create operational input: ${operational.stderr}`);
|
|
948
|
+
fire("before_agent_start", { prompt: operational.stdout }, mainCtx);
|
|
949
|
+
entries.push({ type: "message", message: { role: "user", content: operational.stdout } });
|
|
950
|
+
const unsolicitedPrompt = "Please keep responses concise while monitoring the fleet.";
|
|
951
|
+
fire("before_agent_start", { prompt: unsolicitedPrompt }, mainCtx);
|
|
952
|
+
entries.push({ type: "message", message: { role: "user", content: unsolicitedPrompt } });
|
|
953
|
+
fire("agent_start", {}, mainCtx);
|
|
954
|
+
fire("agent_end", {}, mainCtx);
|
|
955
|
+
const legacyOperational = "FIRSTMATE_OP: give me a fresh system-resource report.";
|
|
956
|
+
fire("before_agent_start", { prompt: legacyOperational }, mainCtx);
|
|
957
|
+
entries.push({ type: "message", message: { role: "user", content: legacyOperational } });
|
|
958
|
+
fire("agent_start", {}, mainCtx);
|
|
959
|
+
const unsolicited = dispatch("signal: healthy resource result");
|
|
960
|
+
if (!unsolicited.accepted) throw new Error("branch did not accept the unsolicited result");
|
|
961
|
+
await settle(() => fleetOperations.length === 2, "unsolicited result acknowledgement");
|
|
962
|
+
if (sentToMain.length !== 1 || sentToMain[0].options.triggerTurn) {
|
|
963
|
+
throw new Error(`unsolicited healthy result opened a main turn: ${JSON.stringify(sentToMain)}`);
|
|
964
|
+
}
|
|
965
|
+
const sailboat = sentToMain[0];
|
|
966
|
+
if (sailboat.message.display !== true || !sailboat.message.content.startsWith("⛵ task-resource:")) {
|
|
967
|
+
throw new Error(`unsolicited healthy result was not a rendered sailboat note: ${JSON.stringify(sailboat)}`);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const outcomes = mainTools.find((tool) => tool.name === "fm_branch_outcomes");
|
|
971
|
+
if (!outcomes) throw new Error("main did not receive its outcome-reading permission surface");
|
|
972
|
+
const visibleToMain = await outcomes.execute("main-reads-sailboat", { recent: 1 }, undefined, undefined, {});
|
|
973
|
+
const mainOutcomeText = visibleToMain.content.map((item) => item.text ?? "").join("\n");
|
|
974
|
+
if (visibleToMain.isError || !mainOutcomeText.includes("healthy resource report: CPU 12%, memory 41%")) {
|
|
975
|
+
throw new Error(`main could not use the sailboat content through its existing permission path: ${JSON.stringify(visibleToMain)}`);
|
|
976
|
+
}
|
|
977
|
+
if (fleetOperations.length !== 2) throw new Error("main's outcome read reprocessed the fleet event");
|
|
978
|
+
|
|
979
|
+
for (let index = 0; index < requestedPrompts.length; index += 1) {
|
|
980
|
+
const content = requestedPrompts[index];
|
|
981
|
+
if (index < longRequests.length && content.length <= 4000) {
|
|
982
|
+
throw new Error(`request fixture ${index} did not exceed the mirror bound`);
|
|
983
|
+
}
|
|
984
|
+
fire("before_agent_start", { prompt: content }, mainCtx);
|
|
985
|
+
// Pi persists this only after every before_agent_start handler has returned.
|
|
986
|
+
entries.push({ type: "message", message: { role: "user", content } });
|
|
987
|
+
fire("agent_start", {}, mainCtx);
|
|
988
|
+
const requested = dispatch("signal: healthy resource result");
|
|
989
|
+
if (!requested.accepted) throw new Error(`branch did not accept requested result ${index}`);
|
|
990
|
+
await settle(() => fleetOperations.length === 4 + (index * 2), `requested result ${index} acknowledgement`);
|
|
991
|
+
const deliveredRequestMirror = globalThis.__fmSessions[0].ops
|
|
992
|
+
.filter((op) => op.kind === "custom" && op.message.customType === "fm-main-mirror")
|
|
993
|
+
.at(-1)?.message.content;
|
|
994
|
+
if (deliveredRequestMirror !== `[captain] ${content}`) {
|
|
995
|
+
throw new Error(`pre-turn-end mirror changed long captain request ${index}`);
|
|
996
|
+
}
|
|
997
|
+
const turns = sentToMain.filter((sent) => sent.options.triggerTurn === true);
|
|
998
|
+
if (turns.length !== index + 1 || turns.at(-1).options.deliverAs !== "followUp") {
|
|
999
|
+
throw new Error(`requested result ${index} did not open exactly one main turn: ${JSON.stringify(sentToMain)}`);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
const mirroredCaptainText = globalThis.__fmSessions[0].ops
|
|
1003
|
+
.filter((op) => op.kind === "custom" && op.message.customType === "fm-main-mirror")
|
|
1004
|
+
.map((op) => op.message.content);
|
|
1005
|
+
for (const content of [unsolicitedPrompt, ...requestedPrompts]) {
|
|
1006
|
+
const copies = mirroredCaptainText.filter((text) => text === `[captain] ${content}`).length;
|
|
1007
|
+
if (copies !== 1) throw new Error(`current captain prompt was mirrored ${copies} times instead of once`);
|
|
1008
|
+
}
|
|
1009
|
+
if (mirroredCaptainText.some((text) =>
|
|
1010
|
+
text.includes("operational watcher injection") || text.includes("FIRSTMATE_OP: give me a fresh system-resource report")
|
|
1011
|
+
)) {
|
|
1012
|
+
throw new Error("canonical current or legacy operational input entered captain mirror context");
|
|
1013
|
+
}
|
|
1014
|
+
if ((globalThis.__fmPrompts ?? []).length !== 5) throw new Error("a handled fleet wake was rerun");
|
|
1015
|
+
if (sentToMain.length !== 5) throw new Error(`one result was reprocessed into ${sentToMain.length} main messages`);
|
|
1016
|
+
if (fleetOperations.length !== 10 || fleetOperations.some((operation) => operation.status !== 0)) {
|
|
1017
|
+
throw new Error(`fleet event ownership repeated or failed work: ${JSON.stringify(fleetOperations)}`);
|
|
1018
|
+
}
|
|
1019
|
+
if (fleetOperations.some((operation) => operation.actor !== "branch")) {
|
|
1020
|
+
throw new Error(`main took fleet-event ownership: ${JSON.stringify(fleetOperations)}`);
|
|
1021
|
+
}
|
|
1022
|
+
if (existsSync(`${home}/state/.wake-queue`) && readFileSync(`${home}/state/.wake-queue`, "utf8") !== "") {
|
|
1023
|
+
throw new Error("acknowledged fleet wake remained queued for another owner");
|
|
1024
|
+
}
|
|
1025
|
+
const rows = readFileSync(`${home}/state/branch-outcomes.jsonl`, "utf8").trim().split("\n").map((line) => JSON.parse(line));
|
|
1026
|
+
if (rows.length !== 5 || rows[0].verdict !== "routine" || rows.slice(1).some((row) => row.verdict !== "captain")) {
|
|
1027
|
+
throw new Error(`provider classifications were not recorded once in order: ${JSON.stringify(rows)}`);
|
|
1028
|
+
}
|
|
1029
|
+
if (outcomeScript(["unread"]) !== "") throw new Error("merged outcomes remained unread for redelivery");
|
|
1030
|
+
process.exit(0);
|
|
1031
|
+
EOF
|
|
1032
|
+
status=$?
|
|
1033
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
1034
|
+
expect_code 0 "$status" "requested and unsolicited healthy outcomes must follow their distinct public delivery paths: $out"
|
|
1035
|
+
pass "requested and unsolicited healthy outcomes keep distinct delivery and event ownership"
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
test_captain_outcome_encoding_failure_delivers_plain_instruction() {
|
|
1039
|
+
local repo home out status
|
|
1040
|
+
repo="$TMP_ROOT/encoding-fallback-root"
|
|
1041
|
+
home="$TMP_ROOT/encoding-fallback-home"
|
|
1042
|
+
mkdir -p "$home/state" "$home/config"
|
|
1043
|
+
install_pi_branch_extension_fixture "$repo"
|
|
1044
|
+
PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
|
|
1045
|
+
FM_OPERATIONAL_INPUT_SCRIPT="$repo/bin/missing-operational-input" \
|
|
1046
|
+
DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
|
|
1047
|
+
const prelude = process.env.DRIVER_PRELUDE;
|
|
1048
|
+
await eval(`(async () => { ${prelude}; globalThis.__t = { dispatch, settle, sentToMain }; })()`);
|
|
1049
|
+
const { dispatch, settle, sentToMain } = globalThis.__t;
|
|
1050
|
+
|
|
1051
|
+
if (!dispatch("signal: encoding fallback probe").accepted) {
|
|
1052
|
+
throw new Error("branch did not accept the encoding-fallback wake");
|
|
1053
|
+
}
|
|
1054
|
+
await settle(() => (globalThis.__fmPrompts ?? []).length === 1, "encoding-fallback branch prompt");
|
|
1055
|
+
const session = globalThis.__fmSessions[0];
|
|
1056
|
+
const report = session.options.customTools.find((tool) => tool.name === "fm_branch_report");
|
|
1057
|
+
const result = await report.execute(
|
|
1058
|
+
"encoding-fallback",
|
|
1059
|
+
{ task: "task-fallback", verdict: "captain", summary: "PR https://example.com/pr/fallback is ready" },
|
|
1060
|
+
undefined,
|
|
1061
|
+
undefined,
|
|
1062
|
+
{},
|
|
1063
|
+
);
|
|
1064
|
+
if (result.isError) throw new Error(`fallback report failed: ${JSON.stringify(result)}`);
|
|
1065
|
+
if (sentToMain.length !== 1) throw new Error(`fallback delivered ${sentToMain.length} notes instead of one`);
|
|
1066
|
+
const delivered = sentToMain[0];
|
|
1067
|
+
if (delivered.message.display !== false) throw new Error("fallback captain note became visible");
|
|
1068
|
+
if (delivered.options.triggerTurn !== true || delivered.options.deliverAs !== "followUp") {
|
|
1069
|
+
throw new Error(`fallback changed turn delivery: ${JSON.stringify(delivered.options)}`);
|
|
1070
|
+
}
|
|
1071
|
+
if (delivered.message.content.includes("FIRSTMATE_OP:")) {
|
|
1072
|
+
throw new Error(`fallback unexpectedly carried an envelope: ${delivered.message.content}`);
|
|
1073
|
+
}
|
|
1074
|
+
if (!delivered.message.content.includes("The fleet event is already handled: do not re-drain, re-run, or acknowledge it.") ||
|
|
1075
|
+
!delivered.message.content.includes("This outcome is captain-facing: give the captain a visible response now.") ||
|
|
1076
|
+
!delivered.message.content.includes("Use your judgment over the wording and how to incorporate it, not whether to surface it.") ||
|
|
1077
|
+
!delivered.message.content.includes("task-fallback: PR https://example.com/pr/fallback is ready")) {
|
|
1078
|
+
throw new Error(`fallback lost its instruction or outcome: ${delivered.message.content}`);
|
|
1079
|
+
}
|
|
1080
|
+
process.exit(0);
|
|
1081
|
+
EOF
|
|
1082
|
+
status=$?
|
|
1083
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
1084
|
+
expect_code 0 "$status" "captain outcome encoding failure must degrade to plain instructed delivery: $out"
|
|
1085
|
+
pass "a broken operational encoder still delivers one invisible instructed captain outcome as a follow-up"
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
test_branch_cache_key_is_per_home_stable() {
|
|
1089
|
+
local repo home_a home_b key_a1 key_a2 key_b
|
|
1090
|
+
repo="$TMP_ROOT/cache-key-root"
|
|
1091
|
+
home_a="$TMP_ROOT/cache-key-home-a"
|
|
1092
|
+
home_b="$TMP_ROOT/cache-key-home-b"
|
|
1093
|
+
mkdir -p "$home_a/state" "$home_a/config" "$home_b/state" "$home_b/config"
|
|
1094
|
+
install_pi_branch_extension_fixture "$repo"
|
|
1095
|
+
probe() {
|
|
1096
|
+
PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$1" FM_ROOT_OVERRIDE="$ROOT" \
|
|
1097
|
+
DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module 2>&1 <<'EOF'
|
|
1098
|
+
const prelude = process.env.DRIVER_PRELUDE;
|
|
1099
|
+
await eval(`(async () => { ${prelude}; globalThis.__t = { dispatch, settle }; })()`);
|
|
1100
|
+
const { dispatch, settle } = globalThis.__t;
|
|
1101
|
+
dispatch("signal: cache probe");
|
|
1102
|
+
await settle(() => (globalThis.__fmPrompts ?? []).length === 1, "branch wake prompt");
|
|
1103
|
+
const loader = globalThis.__fmLoaders[0];
|
|
1104
|
+
const entry = loader.options.extensionFactories[0];
|
|
1105
|
+
let handler = null;
|
|
1106
|
+
(typeof entry === "function" ? entry : entry.smidja)({ on: (e, h) => { if (e === "before_provider_request") handler = h; } });
|
|
1107
|
+
const rewritten = handler({ type: "before_provider_request", payload: { prompt_cache_key: "x" } });
|
|
1108
|
+
console.log(rewritten.prompt_cache_key);
|
|
1109
|
+
process.exit(0);
|
|
1110
|
+
EOF
|
|
1111
|
+
}
|
|
1112
|
+
key_a1=$(probe "$home_a") || fail "cache-key probe A1 failed: $key_a1"
|
|
1113
|
+
key_a2=$(probe "$home_a") || fail "cache-key probe A2 failed: $key_a2"
|
|
1114
|
+
key_b=$(probe "$home_b") || fail "cache-key probe B failed: $key_b"
|
|
1115
|
+
[ -n "$key_a1" ] || fail "empty cache key from probe A1"
|
|
1116
|
+
[ "$key_a1" = "$key_a2" ] || fail "cache key not stable across branch sessions in one home: $key_a1 vs $key_a2"
|
|
1117
|
+
[ "$key_a1" != "$key_b" ] || fail "cache key does not separate homes: $key_a1"
|
|
1118
|
+
pass "branch prompt_cache_key is stable per home across sessions and distinct between homes"
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
test_branch_default_on_heartbeat_afk_and_fallback() {
|
|
1122
|
+
local repo broken home out status
|
|
1123
|
+
repo="$TMP_ROOT/gating-root"
|
|
1124
|
+
broken="$TMP_ROOT/gating-broken-root"
|
|
1125
|
+
home="$TMP_ROOT/gating-home"
|
|
1126
|
+
mkdir -p "$home/state" "$home/config" "$broken/bin"
|
|
1127
|
+
install_pi_branch_extension_fixture "$repo"
|
|
1128
|
+
cp "$ROOT/bin/fm-lease.sh" "$ROOT/bin/fm-lease-lib.sh" "$ROOT/bin/fm-wake-lib.sh" "$ROOT/bin/fm-wake-grant.sh" "$broken/bin/"
|
|
1129
|
+
cat > "$broken/bin/fm-branch-prompt.sh" <<'SH'
|
|
1130
|
+
#!/usr/bin/env bash
|
|
1131
|
+
echo "synthetic generator failure" >&2
|
|
1132
|
+
exit 1
|
|
1133
|
+
SH
|
|
1134
|
+
chmod +x "$broken/bin/fm-branch-prompt.sh"
|
|
1135
|
+
PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
|
|
1136
|
+
DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
|
|
1137
|
+
const prelude = process.env.DRIVER_PRELUDE;
|
|
1138
|
+
await eval(`(async () => { ${prelude}; globalThis.__t = { dispatch, fire, settle, home, sentToMain }; })()`);
|
|
1139
|
+
const { dispatch, fire, settle, home, sentToMain } = globalThis.__t;
|
|
1140
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
1141
|
+
|
|
1142
|
+
// Default-on: with no config/pi-supervision-branch grant file present at
|
|
1143
|
+
// all (this driver never writes one), a task-scoped wake is still accepted
|
|
1144
|
+
// and activation still runs.
|
|
1145
|
+
if (existsSync(`${home}/config/pi-supervision-branch`)) {
|
|
1146
|
+
throw new Error("test fixture unexpectedly wrote a grant file");
|
|
1147
|
+
}
|
|
1148
|
+
fire("session_start", {});
|
|
1149
|
+
if (!dispatch("signal: default-on task wake").accepted) {
|
|
1150
|
+
throw new Error("a task-scoped wake was refused with no grant file present");
|
|
1151
|
+
}
|
|
1152
|
+
if (!existsSync(`${home}/state/.pi-branch-extension-loaded`)) {
|
|
1153
|
+
throw new Error("default-on activation did not write the diagnostic marker");
|
|
1154
|
+
}
|
|
1155
|
+
await settle(() => (globalThis.__fmPrompts ?? []).length === 1, "default-on task wake prompt");
|
|
1156
|
+
|
|
1157
|
+
// The real watcher emits a bare heartbeat only after its cheap bash scan has
|
|
1158
|
+
// flagged a fleet pass as possibly captain-relevant. The branch accepts that
|
|
1159
|
+
// wake without a resolved project and performs the deeper review.
|
|
1160
|
+
if (!dispatch("heartbeat", [], true, true).accepted) {
|
|
1161
|
+
throw new Error("eligible heartbeat offer was refused");
|
|
1162
|
+
}
|
|
1163
|
+
if (dispatch("heartbeat", [], true, false).accepted) {
|
|
1164
|
+
throw new Error("heartbeat with a main-owned queue row was accepted");
|
|
1165
|
+
}
|
|
1166
|
+
await settle(() => (globalThis.__fmPrompts ?? []).length === 2, "heartbeat wake prompt");
|
|
1167
|
+
|
|
1168
|
+
// The branch can downgrade its deeper review to a silent routine outcome or
|
|
1169
|
+
// escalate a captain-worthy finding into one main turn.
|
|
1170
|
+
const heartbeatSession = globalThis.__fmSessions[globalThis.__fmSessions.length - 1];
|
|
1171
|
+
const heartbeatReport = heartbeatSession.options.customTools.find((tool) => tool.name === "fm_branch_report");
|
|
1172
|
+
await heartbeatReport.execute(
|
|
1173
|
+
"heartbeat-noop",
|
|
1174
|
+
{ task: "fleet", verdict: "routine", summary: "fleet reviewed, nothing changed", silent: true },
|
|
1175
|
+
undefined,
|
|
1176
|
+
undefined,
|
|
1177
|
+
{},
|
|
1178
|
+
);
|
|
1179
|
+
const noopMerge = sentToMain[sentToMain.length - 1];
|
|
1180
|
+
if (noopMerge.options.triggerTurn) throw new Error("a no-op heartbeat pass must not open a main turn");
|
|
1181
|
+
if (noopMerge.message.display !== false) throw new Error("a no-op heartbeat pass must not render a merge note");
|
|
1182
|
+
const storedNoop = readFileSync(`${home}/state/branch-outcomes.jsonl`, "utf8")
|
|
1183
|
+
.trim()
|
|
1184
|
+
.split("\n")
|
|
1185
|
+
.map((line) => JSON.parse(line))
|
|
1186
|
+
.find((row) => row.task === "fleet" && row.summary === "fleet reviewed, nothing changed");
|
|
1187
|
+
if (!storedNoop || storedNoop.verdict !== "routine" || storedNoop.silent !== true) {
|
|
1188
|
+
throw new Error("the silent no-op heartbeat disposition was not stored durably");
|
|
1189
|
+
}
|
|
1190
|
+
await heartbeatReport.execute(
|
|
1191
|
+
"fleet-routine-action",
|
|
1192
|
+
{ task: "fleet", verdict: "routine", summary: "reconciled the backlog after completed work" },
|
|
1193
|
+
undefined,
|
|
1194
|
+
undefined,
|
|
1195
|
+
{},
|
|
1196
|
+
);
|
|
1197
|
+
const fleetRoutineMerge = sentToMain[sentToMain.length - 1];
|
|
1198
|
+
if (fleetRoutineMerge.message.display !== true) throw new Error("a fleet routine action must render");
|
|
1199
|
+
if (!fleetRoutineMerge.message.content.startsWith("⛵ fleet: reconciled the backlog after completed work")) {
|
|
1200
|
+
throw new Error(`fleet routine action note changed: ${fleetRoutineMerge.message.content}`);
|
|
1201
|
+
}
|
|
1202
|
+
await heartbeatReport.execute(
|
|
1203
|
+
"task-routine",
|
|
1204
|
+
{ task: "task-9", verdict: "routine", summary: "worker healthy, no action needed" },
|
|
1205
|
+
undefined,
|
|
1206
|
+
undefined,
|
|
1207
|
+
{},
|
|
1208
|
+
);
|
|
1209
|
+
const taskRoutineMerge = sentToMain[sentToMain.length - 1];
|
|
1210
|
+
if (taskRoutineMerge.message.display !== true) throw new Error("a task-scoped routine outcome must render");
|
|
1211
|
+
if (!taskRoutineMerge.message.content.startsWith("⛵ task-9: worker healthy, no action needed")) {
|
|
1212
|
+
throw new Error(`task-scoped routine note changed: ${taskRoutineMerge.message.content}`);
|
|
1213
|
+
}
|
|
1214
|
+
await heartbeatReport.execute(
|
|
1215
|
+
"heartbeat-finding",
|
|
1216
|
+
{ task: "fleet", verdict: "captain", summary: "task-2 has been stuck for an hour" },
|
|
1217
|
+
undefined,
|
|
1218
|
+
undefined,
|
|
1219
|
+
{},
|
|
1220
|
+
);
|
|
1221
|
+
const captainMerge = sentToMain[sentToMain.length - 1];
|
|
1222
|
+
if (captainMerge.options.triggerTurn !== true) throw new Error("a captain-worthy heartbeat finding must open a main turn");
|
|
1223
|
+
if (captainMerge.message.display !== false) throw new Error("the heartbeat captain-facing note must not be printed");
|
|
1224
|
+
|
|
1225
|
+
// Every other fleet-wide or unresolvable wake (empty projects, not a
|
|
1226
|
+
// heartbeat) still keeps the wake-to-main path.
|
|
1227
|
+
if (dispatch("check: unresolved fleet event", []).accepted) {
|
|
1228
|
+
throw new Error("branch accepted an unscoped, non-heartbeat fleet wake");
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// Away mode still owns supervision regardless of default-on eligibility.
|
|
1232
|
+
writeFileSync(`${home}/state/.afk`, "");
|
|
1233
|
+
if (dispatch("signal: while afk").accepted) throw new Error("branch accepted a wake during away mode");
|
|
1234
|
+
rmSync(`${home}/state/.afk`);
|
|
1235
|
+
if (!dispatch("signal: gates cleared").accepted) throw new Error("branch refused a wake with gates cleared");
|
|
1236
|
+
await settle(() => (globalThis.__fmPrompts ?? []).length === 3, "branch wake prompts");
|
|
1237
|
+
process.exit(0);
|
|
1238
|
+
EOF
|
|
1239
|
+
status=$?
|
|
1240
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
1241
|
+
expect_code 0 "$status" "default-on eligibility, heartbeat routing, and afk gating must bind: $out"
|
|
1242
|
+
|
|
1243
|
+
PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$TMP_ROOT/gating-home-2" FM_ROOT_OVERRIDE="$broken" \
|
|
1244
|
+
DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
|
|
1245
|
+
const prelude = process.env.DRIVER_PRELUDE;
|
|
1246
|
+
await eval(`(async () => { ${prelude}; globalThis.__t = { dispatch, settle, mainUserMessages }; })()`);
|
|
1247
|
+
const { dispatch, settle, mainUserMessages } = globalThis.__t;
|
|
1248
|
+
|
|
1249
|
+
// A branch that cannot come up must degrade to today's behavior: the accepted
|
|
1250
|
+
// wake falls back to main with the failure named, and later wakes are no
|
|
1251
|
+
// longer accepted (no wake is ever lost).
|
|
1252
|
+
if (!dispatch("signal: first wake").accepted) throw new Error("first offer was not accepted");
|
|
1253
|
+
await settle(() => mainUserMessages.length === 1, "fallback delivery to main");
|
|
1254
|
+
const fallback = mainUserMessages[0].content;
|
|
1255
|
+
if (!fallback.includes("FIRSTMATE WATCHER WAKE: signal: first wake")) throw new Error(`fallback lost the wake: ${fallback}`);
|
|
1256
|
+
if (!fallback.includes("Supervision branch unavailable")) throw new Error(`fallback did not name the branch failure: ${fallback}`);
|
|
1257
|
+
if (mainUserMessages[0].options.deliverAs !== "followUp") throw new Error("fallback must deliver as a follow-up");
|
|
1258
|
+
if (dispatch("signal: second wake").accepted) throw new Error("broken branch kept accepting wakes");
|
|
1259
|
+
process.exit(0);
|
|
1260
|
+
EOF
|
|
1261
|
+
status=$?
|
|
1262
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
1263
|
+
expect_code 0 "$status" "broken-branch fallback must return wakes to main: $out"
|
|
1264
|
+
pass "branch default-on eligibility (task-scoped, heartbeat, afk) binds and a broken branch falls back to main"
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
test_branch_predrain_recheck_keeps_a_heartbeat_a_co_present_check_arrives_under() {
|
|
1268
|
+
local repo home out status
|
|
1269
|
+
repo="$TMP_ROOT/predrain-recheck-root"
|
|
1270
|
+
home="$TMP_ROOT/predrain-recheck-home"
|
|
1271
|
+
mkdir -p "$home/state" "$home/config"
|
|
1272
|
+
install_pi_branch_extension_fixture "$repo"
|
|
1273
|
+
PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
|
|
1274
|
+
DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
|
|
1275
|
+
const prelude = process.env.DRIVER_PRELUDE;
|
|
1276
|
+
await eval(`(async () => { ${prelude}; globalThis.__t = { dispatch, fire, home, mainUserMessages }; })()`);
|
|
1277
|
+
const { dispatch, fire, home, mainUserMessages } = globalThis.__t;
|
|
1278
|
+
import { appendFileSync, readFileSync } from "node:fs";
|
|
1279
|
+
|
|
1280
|
+
fire("session_start", {});
|
|
1281
|
+
let releasePrompt;
|
|
1282
|
+
globalThis.__fmPromptGate = new Promise((resolve) => { releasePrompt = resolve; });
|
|
1283
|
+
const offer = dispatch("heartbeat", [], true, true);
|
|
1284
|
+
if (!offer.accepted) throw new Error("eligible heartbeat offer was not accepted");
|
|
1285
|
+
// A main-only notice arrives between offer acceptance and the branch's own
|
|
1286
|
+
// drain. It must not carry the fleet review into the captain's chat.
|
|
1287
|
+
appendFileSync(`${home}/state/.wake-queue`, "2\t2\tcheck\tx-inbox\tcheck: pending x mention\n");
|
|
1288
|
+
for (let i = 0; i < 250 && !globalThis.__fmPromptStarted && mainUserMessages.length === 0; i += 1) {
|
|
1289
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1290
|
+
}
|
|
1291
|
+
if (mainUserMessages.length !== 0) {
|
|
1292
|
+
throw new Error(`a co-present check row rode the heartbeat into main: ${JSON.stringify(mainUserMessages)}`);
|
|
1293
|
+
}
|
|
1294
|
+
if (!globalThis.__fmPromptStarted) {
|
|
1295
|
+
throw new Error("the branch was never prompted even though the heartbeat stayed eligible");
|
|
1296
|
+
}
|
|
1297
|
+
const snapshot = readFileSync(`${home}/state/.branch-eligible-rows`, "utf8").trim().split("\n");
|
|
1298
|
+
if (!snapshot.includes("1")) throw new Error(`eligible-row snapshot omitted the heartbeat row: ${snapshot}`);
|
|
1299
|
+
if (snapshot.includes("2")) throw new Error(`eligible-row snapshot granted the main-owned row: ${snapshot}`);
|
|
1300
|
+
releasePrompt();
|
|
1301
|
+
const queue = readFileSync(`${home}/state/.wake-queue`, "utf8");
|
|
1302
|
+
if (!queue.includes("\theartbeat\t") || !queue.includes("\tcheck\t")) {
|
|
1303
|
+
throw new Error(`the pre-drain recheck mutated the queued set: ${queue}`);
|
|
1304
|
+
}
|
|
1305
|
+
process.exit(0);
|
|
1306
|
+
EOF
|
|
1307
|
+
status=$?
|
|
1308
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
1309
|
+
expect_code 0 "$status" "a co-present check row must not carry a heartbeat review into main: $out"
|
|
1310
|
+
pass "a heartbeat review survives a check row arriving before its drain"
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
# The non-heartbeat half of the same recheck: a check-kind row that arrives
|
|
1314
|
+
# after a signal/stale offer is accepted must stay main-owned WITHOUT bouncing
|
|
1315
|
+
# the branch's own eligible row back to main
|
|
1316
|
+
# (docs/watcher-continuity.md "Per-actor acknowledgement"). A check row is
|
|
1317
|
+
# main-owned in every mode, so the heartbeat case proven above and this one
|
|
1318
|
+
# resolve the same way.
|
|
1319
|
+
test_branch_predrain_recheck_excludes_new_main_owned_row_without_deferring_eligible_work() {
|
|
1320
|
+
local repo home out status
|
|
1321
|
+
repo="$TMP_ROOT/predrain-partial-root"
|
|
1322
|
+
home="$TMP_ROOT/predrain-partial-home"
|
|
1323
|
+
mkdir -p "$home/state" "$home/config"
|
|
1324
|
+
install_pi_branch_extension_fixture "$repo"
|
|
1325
|
+
PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
|
|
1326
|
+
DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
|
|
1327
|
+
const prelude = process.env.DRIVER_PRELUDE;
|
|
1328
|
+
await eval(`(async () => { ${prelude}; globalThis.__t = { dispatch, fire, home, mainUserMessages }; })()`);
|
|
1329
|
+
const { dispatch, fire, home, mainUserMessages } = globalThis.__t;
|
|
1330
|
+
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
1331
|
+
|
|
1332
|
+
fire("session_start", {});
|
|
1333
|
+
let releasePrompt;
|
|
1334
|
+
globalThis.__fmPromptGate = new Promise((resolve) => { releasePrompt = resolve; });
|
|
1335
|
+
const offer = dispatch("signal: task-local wake");
|
|
1336
|
+
if (!offer.accepted) throw new Error("eligible task-local offer was not accepted");
|
|
1337
|
+
// A main-only notice arrives while main is still finishing its own earlier
|
|
1338
|
+
// turn - unacked, still sitting in the queue - between offer acceptance and
|
|
1339
|
+
// the branch's own drain.
|
|
1340
|
+
appendFileSync(`${home}/state/.wake-queue`, "2\t2\tcheck\tx-inbox\tcheck: pending x mention\n");
|
|
1341
|
+
for (let i = 0; i < 250 && !globalThis.__fmPromptStarted && mainUserMessages.length === 0; i += 1) {
|
|
1342
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1343
|
+
}
|
|
1344
|
+
if (mainUserMessages.length !== 0) {
|
|
1345
|
+
throw new Error(`a co-present main-owned row bounced the whole mixed queue to main: ${JSON.stringify(mainUserMessages)}`);
|
|
1346
|
+
}
|
|
1347
|
+
if (!globalThis.__fmPromptStarted) {
|
|
1348
|
+
throw new Error("the branch was never prompted even though its own row stayed eligible");
|
|
1349
|
+
}
|
|
1350
|
+
const snapshot = readFileSync(`${home}/state/.branch-eligible-rows`, "utf8").trim().split("\n");
|
|
1351
|
+
if (!snapshot.includes("1")) throw new Error(`eligible-row snapshot omitted the task-local row: ${snapshot}`);
|
|
1352
|
+
if (snapshot.includes("2")) throw new Error(`eligible-row snapshot granted the main-owned row: ${snapshot}`);
|
|
1353
|
+
const queue = readFileSync(`${home}/state/.wake-queue`, "utf8");
|
|
1354
|
+
if (!queue.includes("\tcheck\tx-inbox\t")) {
|
|
1355
|
+
throw new Error(`the main-owned row must remain queued for main, untouched: ${queue}`);
|
|
1356
|
+
}
|
|
1357
|
+
releasePrompt();
|
|
1358
|
+
for (let i = 0; i < 250 && (globalThis.__fmPrompts ?? []).length === 0; i += 1) {
|
|
1359
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1360
|
+
}
|
|
1361
|
+
for (let i = 0; i < 250 && existsSync(`${home}/state/.branch-eligible-rows`); i += 1) {
|
|
1362
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1363
|
+
}
|
|
1364
|
+
if (existsSync(`${home}/state/.branch-eligible-rows`)) {
|
|
1365
|
+
throw new Error("settled branch prompt retained its row grant");
|
|
1366
|
+
}
|
|
1367
|
+
process.exit(0);
|
|
1368
|
+
EOF
|
|
1369
|
+
status=$?
|
|
1370
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
1371
|
+
expect_code 0 "$status" "pre-drain eligibility re-check must exclude only the new main-owned row: $out"
|
|
1372
|
+
pass "pre-drain eligibility re-check excludes a newly main-owned row without deferring eligible work"
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
test_settled_branch_prompt_releases_unacknowledged_grant() {
|
|
1376
|
+
local repo home out status
|
|
1377
|
+
repo="$TMP_ROOT/settled-grant-root"
|
|
1378
|
+
home="$TMP_ROOT/settled-grant-home"
|
|
1379
|
+
mkdir -p "$home/state" "$home/config"
|
|
1380
|
+
install_pi_branch_extension_fixture "$repo"
|
|
1381
|
+
PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
|
|
1382
|
+
DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
|
|
1383
|
+
const prelude = process.env.DRIVER_PRELUDE;
|
|
1384
|
+
await eval(`(async () => { ${prelude}; globalThis.__t = { dispatch, fire, home, realRoot }; })()`);
|
|
1385
|
+
const { dispatch, fire, home, realRoot } = globalThis.__t;
|
|
1386
|
+
const { spawnSync } = await import("node:child_process");
|
|
1387
|
+
const { existsSync } = await import("node:fs");
|
|
1388
|
+
|
|
1389
|
+
fire("session_start", {});
|
|
1390
|
+
if (!dispatch("signal: unacknowledged branch wake").accepted) {
|
|
1391
|
+
throw new Error("eligible wake was not accepted");
|
|
1392
|
+
}
|
|
1393
|
+
for (let i = 0; i < 250 && (globalThis.__fmPrompts ?? []).length === 0; i += 1) {
|
|
1394
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1395
|
+
}
|
|
1396
|
+
if ((globalThis.__fmPrompts ?? []).length !== 1) throw new Error("branch prompt did not settle");
|
|
1397
|
+
for (let i = 0; i < 250 && existsSync(`${home}/state/.branch-eligible-rows`); i += 1) {
|
|
1398
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1399
|
+
}
|
|
1400
|
+
if (existsSync(`${home}/state/.branch-eligible-rows`)) {
|
|
1401
|
+
throw new Error("settled prompt left its unacknowledged grant active");
|
|
1402
|
+
}
|
|
1403
|
+
const drain = spawnSync("bash", [`${realRoot}/bin/fm-wake-drain.sh`], {
|
|
1404
|
+
encoding: "utf8",
|
|
1405
|
+
env: { ...process.env, FM_HOME: home, FM_STATE_OVERRIDE: `${home}/state`, FM_ROOT_OVERRIDE: realRoot },
|
|
1406
|
+
});
|
|
1407
|
+
if (drain.status !== 0) throw new Error(`main drain failed after grant release: ${drain.stderr}`);
|
|
1408
|
+
if (!drain.stdout.includes("\tsignal\tbranch-driver.status\t")) {
|
|
1409
|
+
throw new Error(`main could not replay the unacknowledged branch row: ${drain.stdout}`);
|
|
1410
|
+
}
|
|
1411
|
+
process.exit(0);
|
|
1412
|
+
EOF
|
|
1413
|
+
status=$?
|
|
1414
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
1415
|
+
expect_code 0 "$status" "settled branch turns must release residual grants for main replay: $out"
|
|
1416
|
+
pass "a settled branch turn releases an unacknowledged grant for main replay"
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
test_main_owned_grant_result_falls_back_to_main() {
|
|
1420
|
+
local repo home out status
|
|
1421
|
+
repo="$TMP_ROOT/main-owned-fallback-root"
|
|
1422
|
+
home="$TMP_ROOT/main-owned-fallback-home"
|
|
1423
|
+
mkdir -p "$home/state" "$home/config"
|
|
1424
|
+
install_pi_branch_extension_fixture "$repo"
|
|
1425
|
+
PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
|
|
1426
|
+
DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
|
|
1427
|
+
const prelude = process.env.DRIVER_PRELUDE;
|
|
1428
|
+
await eval(`(async () => { ${prelude}; globalThis.__t = { dispatch, fire, home, mainUserMessages }; })()`);
|
|
1429
|
+
const { dispatch, fire, home, mainUserMessages } = globalThis.__t;
|
|
1430
|
+
import { writeFileSync } from "node:fs";
|
|
1431
|
+
|
|
1432
|
+
fire("session_start", {});
|
|
1433
|
+
const offer = dispatch("signal: interrupted main claim");
|
|
1434
|
+
if (!offer.accepted) throw new Error("eligible wake was not accepted before the ownership recheck");
|
|
1435
|
+
writeFileSync(`${home}/state/.main-eligible-rows`, "1\n");
|
|
1436
|
+
for (let i = 0; i < 250 && mainUserMessages.length === 0; i += 1) {
|
|
1437
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1438
|
+
}
|
|
1439
|
+
if ((globalThis.__fmPrompts ?? []).length !== 0) {
|
|
1440
|
+
throw new Error("branch prompted for a row already claimed by main");
|
|
1441
|
+
}
|
|
1442
|
+
if (mainUserMessages.length !== 1) {
|
|
1443
|
+
throw new Error(`main-owned row was silently absorbed: ${JSON.stringify(mainUserMessages)}`);
|
|
1444
|
+
}
|
|
1445
|
+
if (!String(mainUserMessages[0].content).includes("FIRSTMATE WATCHER WAKE: signal: interrupted main claim")) {
|
|
1446
|
+
throw new Error(`fallback lost the durable wake: ${mainUserMessages[0].content}`);
|
|
1447
|
+
}
|
|
1448
|
+
if (mainUserMessages[0].options.deliverAs !== "followUp") {
|
|
1449
|
+
throw new Error("main-owned fallback was not delivered as a follow-up");
|
|
1450
|
+
}
|
|
1451
|
+
process.exit(0);
|
|
1452
|
+
EOF
|
|
1453
|
+
status=$?
|
|
1454
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
1455
|
+
expect_code 0 "$status" "a main-owned grant result must still deliver the wake to main: $out"
|
|
1456
|
+
pass "a stale main claim cannot silently suppress later wake delivery"
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
test_branch_predrain_recheck_noops_already_drained_wake() {
|
|
1460
|
+
local repo home out status
|
|
1461
|
+
repo="$TMP_ROOT/predrain-empty-root"
|
|
1462
|
+
home="$TMP_ROOT/predrain-empty-home"
|
|
1463
|
+
mkdir -p "$home/state" "$home/config"
|
|
1464
|
+
install_pi_branch_extension_fixture "$repo"
|
|
1465
|
+
PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
|
|
1466
|
+
DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
|
|
1467
|
+
const prelude = process.env.DRIVER_PRELUDE;
|
|
1468
|
+
await eval(`(async () => { ${prelude}; globalThis.__t = { dispatch, fire, home, mainUserMessages }; })()`);
|
|
1469
|
+
const { dispatch, fire, home, mainUserMessages } = globalThis.__t;
|
|
1470
|
+
import { writeFileSync } from "node:fs";
|
|
1471
|
+
|
|
1472
|
+
fire("session_start", {});
|
|
1473
|
+
let releaseFirst;
|
|
1474
|
+
globalThis.__fmPromptGate = new Promise((resolve) => {
|
|
1475
|
+
releaseFirst = resolve;
|
|
1476
|
+
});
|
|
1477
|
+
if (!dispatch("signal: first queued wake").accepted) throw new Error("first wake was not accepted");
|
|
1478
|
+
for (let i = 0; i < 250 && !globalThis.__fmPromptStarted; i += 1) {
|
|
1479
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1480
|
+
}
|
|
1481
|
+
if (!globalThis.__fmPromptStarted) throw new Error("first branch prompt did not start");
|
|
1482
|
+
if (!dispatch("heartbeat", [], true, true).accepted) throw new Error("queued heartbeat was not accepted");
|
|
1483
|
+
writeFileSync(`${home}/state/.wake-queue`, "");
|
|
1484
|
+
releaseFirst();
|
|
1485
|
+
for (let i = 0; i < 250 && (globalThis.__fmPrompts ?? []).length < 1; i += 1) {
|
|
1486
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1487
|
+
}
|
|
1488
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1489
|
+
if ((globalThis.__fmPrompts ?? []).length !== 1) {
|
|
1490
|
+
throw new Error(`already-drained wake prompted again: ${JSON.stringify(globalThis.__fmPrompts ?? [])}`);
|
|
1491
|
+
}
|
|
1492
|
+
if (mainUserMessages.length !== 0) {
|
|
1493
|
+
throw new Error(`already-drained wake fell back to main: ${JSON.stringify(mainUserMessages)}`);
|
|
1494
|
+
}
|
|
1495
|
+
process.exit(0);
|
|
1496
|
+
EOF
|
|
1497
|
+
status=$?
|
|
1498
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
1499
|
+
expect_code 0 "$status" "an already-drained serialized wake must no-op without main fallback: $out"
|
|
1500
|
+
pass "pre-drain eligibility re-check no-ops an already-drained wake"
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
test_branch_mirror_filters_order_and_cursor() {
|
|
1504
|
+
local repo home out status
|
|
1505
|
+
repo="$TMP_ROOT/mirror-root"
|
|
1506
|
+
home="$TMP_ROOT/mirror-home"
|
|
1507
|
+
mkdir -p "$home/state" "$home/config"
|
|
1508
|
+
install_pi_branch_extension_fixture "$repo"
|
|
1509
|
+
PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
|
|
1510
|
+
DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
|
|
1511
|
+
const prelude = process.env.DRIVER_PRELUDE;
|
|
1512
|
+
await eval(`(async () => { ${prelude}; globalThis.__t = { fire, dispatch, settle, home }; })()`);
|
|
1513
|
+
const { fire, dispatch, settle, home } = globalThis.__t;
|
|
1514
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
1515
|
+
|
|
1516
|
+
const entries = [
|
|
1517
|
+
{ type: "message", message: { role: "user", content: `never merge task-7 without my word ${"h".repeat(5000)} old-history-tail` } },
|
|
1518
|
+
{ type: "message", message: { role: "assistant", content: [{ type: "text", text: "aye, holding task-7" }, { type: "toolCall", id: "t1" }] } },
|
|
1519
|
+
{ type: "message", message: { role: "user", content: "FIRSTMATE_OP: v1 watcher: operational injection" } },
|
|
1520
|
+
{ type: "message", message: { role: "toolResult", content: "tool output stays in main" } },
|
|
1521
|
+
{ type: "custom", message: { role: "custom", customType: "fm-branch-merge", content: "merged note" } },
|
|
1522
|
+
{ type: "compaction", summary: "compacted" },
|
|
1523
|
+
{ type: "message", message: { role: "user", content: `pad ${"x".repeat(5000)}\ntail: retain this request` } },
|
|
1524
|
+
];
|
|
1525
|
+
const ctx = {
|
|
1526
|
+
sessionManager: {
|
|
1527
|
+
getSessionFile: () => `${home}/main-1.jsonl`,
|
|
1528
|
+
getEntries: () => entries,
|
|
1529
|
+
},
|
|
1530
|
+
};
|
|
1531
|
+
|
|
1532
|
+
// Dialog collected at main's turn_end, delivered into the branch BEFORE the
|
|
1533
|
+
// next wake, tagged and filtered: no tool traffic, no operational injections,
|
|
1534
|
+
// no merge notes, long messages capped.
|
|
1535
|
+
fire("turn_end", {}, ctx);
|
|
1536
|
+
dispatch("signal: after mirror");
|
|
1537
|
+
await settle(() => (globalThis.__fmPrompts ?? []).length === 1, "branch wake prompt");
|
|
1538
|
+
const session = globalThis.__fmSessions[0];
|
|
1539
|
+
const kinds = session.ops.map((op) => op.kind);
|
|
1540
|
+
if (JSON.stringify(kinds) !== JSON.stringify(["custom", "custom", "custom", "prompt"])) {
|
|
1541
|
+
throw new Error(`mirror must land before the wake: ${JSON.stringify(kinds)}`);
|
|
1542
|
+
}
|
|
1543
|
+
const mirrored = session.ops.filter((op) => op.kind === "custom").map((op) => op.message);
|
|
1544
|
+
if (mirrored.some((m) => m.customType !== "fm-main-mirror")) throw new Error("mirror used the wrong custom type");
|
|
1545
|
+
if (mirrored.some((m) => m.display !== false)) throw new Error("mirrored context must be silent");
|
|
1546
|
+
if (!mirrored[0].content.startsWith("[captain] never merge task-7 without my word") ||
|
|
1547
|
+
!mirrored[0].content.includes("[mirror truncated:") ||
|
|
1548
|
+
!mirrored[0].content.endsWith("old-history-tail")) {
|
|
1549
|
+
throw new Error(`older captain history was not bounded: ${mirrored[0].content}`);
|
|
1550
|
+
}
|
|
1551
|
+
if (mirrored[1].content !== "[main] aye, holding task-7") throw new Error(`bad main mirror: ${mirrored[1].content}`);
|
|
1552
|
+
if (mirrored[2].content !== `[captain] ${entries[6].message.content}`) {
|
|
1553
|
+
throw new Error("current captain dialog was not preserved completely");
|
|
1554
|
+
}
|
|
1555
|
+
if (mirrored.some((m) => m.content.includes("operational injection") || m.content.includes("tool output") || m.content.includes("merged note"))) {
|
|
1556
|
+
throw new Error("mirror leaked operational, tool, or merge-note traffic");
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// The durable cursor advances: a second turn_end mirrors only NEW dialog.
|
|
1560
|
+
entries.push({ type: "message", message: { role: "user", content: "actually, task-7 may merge when green" } });
|
|
1561
|
+
fire("turn_end", {}, ctx);
|
|
1562
|
+
await settle(() => session.ops.filter((op) => op.kind === "custom").length === 4, "incremental mirror");
|
|
1563
|
+
const latest = session.ops[session.ops.length - 1];
|
|
1564
|
+
if (latest.message.content !== "[captain] actually, task-7 may merge when green") {
|
|
1565
|
+
throw new Error(`incremental mirror re-sent old dialog or lost the new line: ${latest.message.content}`);
|
|
1566
|
+
}
|
|
1567
|
+
if (!existsSync(`${home}/state/.branch-mirror-cursor`)) throw new Error("mirror cursor is not durable");
|
|
1568
|
+
const cursor = JSON.parse(readFileSync(`${home}/state/.branch-mirror-cursor`, "utf8"));
|
|
1569
|
+
if (cursor.file !== `${home}/main-1.jsonl` || cursor.index !== entries.length) {
|
|
1570
|
+
throw new Error(`cursor did not advance with the session file: ${JSON.stringify(cursor)}`);
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// A replacement main session re-anchors: dialog mirrors from its start.
|
|
1574
|
+
const ctx2 = {
|
|
1575
|
+
sessionManager: {
|
|
1576
|
+
getSessionFile: () => `${home}/main-2.jsonl`,
|
|
1577
|
+
getEntries: () => [{ type: "message", message: { role: "user", content: "fresh session standing order" } }],
|
|
1578
|
+
},
|
|
1579
|
+
};
|
|
1580
|
+
fire("turn_end", {}, ctx2);
|
|
1581
|
+
await settle(() => session.ops.filter((op) => op.kind === "custom").length === 5, "replacement-session mirror");
|
|
1582
|
+
const fresh = session.ops[session.ops.length - 1];
|
|
1583
|
+
if (fresh.message.content !== "[captain] fresh session standing order") {
|
|
1584
|
+
throw new Error(`replacement session did not re-anchor the mirror: ${fresh.message.content}`);
|
|
1585
|
+
}
|
|
1586
|
+
process.exit(0);
|
|
1587
|
+
EOF
|
|
1588
|
+
status=$?
|
|
1589
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
1590
|
+
expect_code 0 "$status" "mirror filtering, ordering, and cursor must hold: $out"
|
|
1591
|
+
pass "dialog mirror filters tool and operational traffic, lands before wakes, and keeps a durable cursor"
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
test_branch_session_persists_across_process_restarts() {
|
|
1595
|
+
local repo home out status
|
|
1596
|
+
repo="$TMP_ROOT/persist-root"
|
|
1597
|
+
home="$TMP_ROOT/persist-home"
|
|
1598
|
+
mkdir -p "$home/state" "$home/config"
|
|
1599
|
+
install_pi_branch_extension_fixture "$repo"
|
|
1600
|
+
run_once() {
|
|
1601
|
+
PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
|
|
1602
|
+
DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module 2>&1 <<'EOF'
|
|
1603
|
+
const prelude = process.env.DRIVER_PRELUDE;
|
|
1604
|
+
await eval(`(async () => { ${prelude}; globalThis.__t = { dispatch, settle }; })()`);
|
|
1605
|
+
const { dispatch, settle } = globalThis.__t;
|
|
1606
|
+
dispatch("signal: persistence probe");
|
|
1607
|
+
await settle(() => (globalThis.__fmPrompts ?? []).length === 1, "branch wake prompt");
|
|
1608
|
+
const sm = globalThis.__fmSessionManagers[0];
|
|
1609
|
+
console.log(`${sm.opened ? "opened" : "created"} ${sm.getSessionFile()}`);
|
|
1610
|
+
process.exit(0);
|
|
1611
|
+
EOF
|
|
1612
|
+
}
|
|
1613
|
+
out=$(run_once) || fail "first branch session run failed: $out"
|
|
1614
|
+
# Path.join normalizes the doubled slash macOS TMPDIR introduces, so match
|
|
1615
|
+
# on the home-relative tail rather than the raw $home prefix.
|
|
1616
|
+
case "$out" in
|
|
1617
|
+
"created "*"/persist-home/state/branch-session/"*.jsonl) ;;
|
|
1618
|
+
*) fail "first run did not create a session under state/branch-session: $out" ;;
|
|
1619
|
+
esac
|
|
1620
|
+
first_file=${out#created }
|
|
1621
|
+
[ -f "$home/state/.branch-session" ] || fail "branch session pointer was not recorded"
|
|
1622
|
+
out=$(run_once) || fail "second branch session run failed: $out"
|
|
1623
|
+
[ "$out" = "opened $first_file" ] \
|
|
1624
|
+
|| fail "restart did not reopen the persistent branch session (got: $out; want: opened $first_file)"
|
|
1625
|
+
pass "branch session persists across process restarts through the recorded pointer"
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
test_branch_model_pin_applies_and_absent_pin_keeps_the_default() {
|
|
1629
|
+
local repo home out status
|
|
1630
|
+
repo="$TMP_ROOT/modelpin-root"
|
|
1631
|
+
home="$TMP_ROOT/modelpin-home"
|
|
1632
|
+
mkdir -p "$home/state" "$home/config"
|
|
1633
|
+
install_pi_branch_extension_fixture "$repo"
|
|
1634
|
+
PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
|
|
1635
|
+
DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
|
|
1636
|
+
const prelude = process.env.DRIVER_PRELUDE;
|
|
1637
|
+
await eval(`(async () => { ${prelude}; globalThis.__t = { fire, dispatch, settle, makeCtx, registryModels, home }; })()`);
|
|
1638
|
+
const { fire, dispatch, settle, makeCtx, registryModels, home } = globalThis.__t;
|
|
1639
|
+
import { rmSync, writeFileSync } from "node:fs";
|
|
1640
|
+
|
|
1641
|
+
registryModels.push({ provider: "anthropic", id: "main-model" }, { provider: "openai", id: "cheap-1" });
|
|
1642
|
+
|
|
1643
|
+
// 1. No pin and main's model not known yet: the build falls back to the
|
|
1644
|
+
// pre-feature path of passing no override rather than refusing to build, so
|
|
1645
|
+
// a wake is never lost over model choice.
|
|
1646
|
+
fire("session_start", {});
|
|
1647
|
+
dispatch("signal: main model unknown");
|
|
1648
|
+
await settle(() => (globalThis.__fmSessions ?? []).length === 1, "unknown-main-model branch build");
|
|
1649
|
+
if ("model" in globalThis.__fmSessions[0].options) {
|
|
1650
|
+
throw new Error("an unknown main model must fall back to passing no model override");
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// 2. No pin, main's model known: the branch follows MAIN's own model,
|
|
1654
|
+
// applied explicitly.
|
|
1655
|
+
fire("session_shutdown", {});
|
|
1656
|
+
fire("session_start", {}, makeCtx());
|
|
1657
|
+
dispatch("signal: unpinned probe");
|
|
1658
|
+
await settle(() => (globalThis.__fmSessions ?? []).length === 2, "unpinned branch build");
|
|
1659
|
+
const unpinned = globalThis.__fmSessions[1].options.model;
|
|
1660
|
+
if (!unpinned || unpinned.provider !== "anthropic" || unpinned.id !== "main-model") {
|
|
1661
|
+
throw new Error(`an absent pin must follow main's own model: ${JSON.stringify(unpinned)}`);
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// 3. Pin present: the very next build carries exactly that model, resolved
|
|
1665
|
+
// out of Pi's own catalog.
|
|
1666
|
+
writeFileSync(`${home}/config/supervision-branch-model`, "openai/cheap-1\n");
|
|
1667
|
+
fire("session_shutdown", {});
|
|
1668
|
+
fire("session_start", {}, makeCtx());
|
|
1669
|
+
dispatch("signal: pinned probe");
|
|
1670
|
+
await settle(() => (globalThis.__fmSessions ?? []).length === 3, "pinned branch build");
|
|
1671
|
+
const pinned = globalThis.__fmSessions[2].options.model;
|
|
1672
|
+
if (!pinned || pinned.provider !== "openai" || pinned.id !== "cheap-1") {
|
|
1673
|
+
throw new Error(`pinned build did not use the pinned model: ${JSON.stringify(pinned)}`);
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
// 4. The reopen path (/new, /resume, /fork, reload all replace the session
|
|
1677
|
+
// in-process) reopens the SAME persistent branch conversation and still
|
|
1678
|
+
// applies the pin.
|
|
1679
|
+
fire("session_shutdown", {});
|
|
1680
|
+
fire("session_start", {}, makeCtx());
|
|
1681
|
+
dispatch("signal: reopened probe");
|
|
1682
|
+
await settle(() => (globalThis.__fmSessions ?? []).length === 4, "reopened branch build");
|
|
1683
|
+
const reopened = globalThis.__fmSessions[3].options.model;
|
|
1684
|
+
if (!reopened || reopened.provider !== "openai" || reopened.id !== "cheap-1") {
|
|
1685
|
+
throw new Error(`reopened build did not use the pinned model: ${JSON.stringify(reopened)}`);
|
|
1686
|
+
}
|
|
1687
|
+
const manager = globalThis.__fmSessions[3].options.sessionManager;
|
|
1688
|
+
if (!manager.opened) throw new Error("reopen did not continue the persistent branch conversation");
|
|
1689
|
+
|
|
1690
|
+
// 5. Clearing the pin makes the REOPENED branch follow main again. This is
|
|
1691
|
+
// the case Pi's own session restore would otherwise get wrong: the branch
|
|
1692
|
+
// conversation still records the pinned model, so only an explicit override
|
|
1693
|
+
// keeps "follow main" honest.
|
|
1694
|
+
rmSync(`${home}/config/supervision-branch-model`);
|
|
1695
|
+
fire("session_shutdown", {});
|
|
1696
|
+
fire("session_start", {}, makeCtx());
|
|
1697
|
+
dispatch("signal: unpinned again");
|
|
1698
|
+
await settle(() => (globalThis.__fmSessions ?? []).length === 5, "post-clear branch build");
|
|
1699
|
+
const cleared = globalThis.__fmSessions[4];
|
|
1700
|
+
if (!cleared.options.sessionManager.opened) {
|
|
1701
|
+
throw new Error("the post-clear build must still reopen the persistent branch conversation");
|
|
1702
|
+
}
|
|
1703
|
+
if (cleared.options.model?.id === "cheap-1") {
|
|
1704
|
+
throw new Error("clearing the pin left the branch on the previously pinned model");
|
|
1705
|
+
}
|
|
1706
|
+
if (cleared.options.model?.provider !== "anthropic" || cleared.options.model?.id !== "main-model") {
|
|
1707
|
+
throw new Error(`clearing the pin did not return the branch to main's model: ${JSON.stringify(cleared.options.model)}`);
|
|
1708
|
+
}
|
|
1709
|
+
process.exit(0);
|
|
1710
|
+
EOF
|
|
1711
|
+
status=$?
|
|
1712
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
1713
|
+
expect_code 0 "$status" "the current pin state must decide the model on every branch build: $out"
|
|
1714
|
+
pass "the current pin state binds every branch create and reopen, and clearing it returns the branch to main's model"
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
test_unpinned_branch_follows_main_model_changes_live() {
|
|
1718
|
+
local repo home out status
|
|
1719
|
+
repo="$TMP_ROOT/model-live-root"
|
|
1720
|
+
home="$TMP_ROOT/model-live-home"
|
|
1721
|
+
mkdir -p "$home/state" "$home/config"
|
|
1722
|
+
install_pi_branch_extension_fixture "$repo"
|
|
1723
|
+
PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
|
|
1724
|
+
DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
|
|
1725
|
+
const prelude = process.env.DRIVER_PRELUDE;
|
|
1726
|
+
await eval(`(async () => { ${prelude}; globalThis.__t = { fire, dispatch, settle, makeCtx, registryModels, home }; })()`);
|
|
1727
|
+
const { fire, dispatch, settle, makeCtx, registryModels, home } = globalThis.__t;
|
|
1728
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
1729
|
+
|
|
1730
|
+
registryModels.push(
|
|
1731
|
+
{ provider: "anthropic", id: "main-model" },
|
|
1732
|
+
{ provider: "anthropic", id: "replacement-model" },
|
|
1733
|
+
{ provider: "openai", id: "cheap-1" },
|
|
1734
|
+
);
|
|
1735
|
+
fire("session_start", {}, makeCtx());
|
|
1736
|
+
dispatch("signal: before main model change");
|
|
1737
|
+
await settle(() => (globalThis.__fmSessions ?? []).length === 1, "initial unpinned branch build");
|
|
1738
|
+
const original = globalThis.__fmSessions[0];
|
|
1739
|
+
if (original.options.model?.id !== "main-model") throw new Error("the unpinned branch did not start on main's model");
|
|
1740
|
+
|
|
1741
|
+
fire("model_select", { model: { provider: "anthropic", id: "replacement-model" } });
|
|
1742
|
+
await settle(() => original.disposed, "live unpinned branch release");
|
|
1743
|
+
dispatch("signal: after main model change");
|
|
1744
|
+
await settle(() => (globalThis.__fmSessions ?? []).length === 2, "replacement unpinned branch build");
|
|
1745
|
+
const following = globalThis.__fmSessions[1];
|
|
1746
|
+
if (following.options.model?.id !== "replacement-model") {
|
|
1747
|
+
throw new Error(`the unpinned branch did not follow main's model change: ${JSON.stringify(following.options.model)}`);
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
writeFileSync(`${home}/config/supervision-branch-model`, "openai/cheap-1\n");
|
|
1751
|
+
fire("session_shutdown", {});
|
|
1752
|
+
fire("session_start", {}, makeCtx());
|
|
1753
|
+
dispatch("signal: establish pinned branch");
|
|
1754
|
+
await settle(() => (globalThis.__fmSessions ?? []).length === 3, "pinned branch build");
|
|
1755
|
+
const pinned = globalThis.__fmSessions[2];
|
|
1756
|
+
if (pinned.options.model?.id !== "cheap-1") throw new Error("the pinned branch did not use its pin");
|
|
1757
|
+
const promptsBefore = pinned.ops.filter((op) => op.kind === "prompt").length;
|
|
1758
|
+
|
|
1759
|
+
fire("model_select", { model: { provider: "anthropic", id: "replacement-model" } });
|
|
1760
|
+
dispatch("signal: pinned after main model change");
|
|
1761
|
+
await settle(() => pinned.ops.filter((op) => op.kind === "prompt").length === promptsBefore + 1, "pinned branch wake");
|
|
1762
|
+
if (pinned.disposed || globalThis.__fmSessions.length !== 3) {
|
|
1763
|
+
throw new Error("a main model change replaced the pinned branch");
|
|
1764
|
+
}
|
|
1765
|
+
if (readFileSync(`${home}/config/supervision-branch-model`, "utf8") !== "openai/cheap-1\n") {
|
|
1766
|
+
throw new Error("a main model change disturbed the supervision pin");
|
|
1767
|
+
}
|
|
1768
|
+
process.exit(0);
|
|
1769
|
+
EOF
|
|
1770
|
+
status=$?
|
|
1771
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
1772
|
+
expect_code 0 "$status" "unpinned branches must follow main model changes while pins remain authoritative: $out"
|
|
1773
|
+
pass "unpinned branches follow main model changes live while pinned branches stay fixed"
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
test_supervision_model_command_persists_and_rebinds_the_live_branch() {
|
|
1777
|
+
local repo home out status
|
|
1778
|
+
repo="$TMP_ROOT/modelcmd-root"
|
|
1779
|
+
home="$TMP_ROOT/modelcmd-home"
|
|
1780
|
+
mkdir -p "$home/state" "$home/config"
|
|
1781
|
+
install_pi_branch_extension_fixture "$repo"
|
|
1782
|
+
PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
|
|
1783
|
+
DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
|
|
1784
|
+
const prelude = process.env.DRIVER_PRELUDE;
|
|
1785
|
+
await eval(`(async () => { ${prelude}; globalThis.__t = { fire, dispatch, settle, makeCtx, commands, registryModels, uiSelections, uiPrompts, notices, home }; })()`);
|
|
1786
|
+
const { fire, dispatch, settle, makeCtx, commands, registryModels, uiSelections, uiPrompts, notices, home } = globalThis.__t;
|
|
1787
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
1788
|
+
|
|
1789
|
+
registryModels.push(
|
|
1790
|
+
{ provider: "anthropic", id: "main-model" },
|
|
1791
|
+
{ provider: "openai-codex", id: "cheap-oauth", authKind: "oauth" },
|
|
1792
|
+
{ provider: "dynamic", id: "extension-only", branchAvailable: false },
|
|
1793
|
+
);
|
|
1794
|
+
const command = commands.get("supervision-model");
|
|
1795
|
+
if (!command) throw new Error("the supervision-model command was not registered");
|
|
1796
|
+
|
|
1797
|
+
fire("session_start", {}, makeCtx());
|
|
1798
|
+
dispatch("signal: before the pick");
|
|
1799
|
+
await settle(() => (globalThis.__fmSessions ?? []).length === 1, "pre-pick branch build");
|
|
1800
|
+
const firstSession = globalThis.__fmSessions[0];
|
|
1801
|
+
if (firstSession.options.model?.id !== "main-model") {
|
|
1802
|
+
throw new Error(`the branch started on something other than main's model before any pick: ${JSON.stringify(firstSession.options.model)}`);
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
// The picker offers Pi's branch-runnable catalog plus following main, and the
|
|
1806
|
+
// captain's pick is persisted as the one-line config value.
|
|
1807
|
+
uiSelections.push("openai-codex/cheap-oauth");
|
|
1808
|
+
await command.handler("", makeCtx());
|
|
1809
|
+
const offered = uiPrompts[0];
|
|
1810
|
+
if (offered.options[0] !== "Follow main (anthropic/main-model)") {
|
|
1811
|
+
throw new Error(`the picker must offer following main first: ${JSON.stringify(offered.options)}`);
|
|
1812
|
+
}
|
|
1813
|
+
if (!offered.options.includes("openai-codex/cheap-oauth") || !offered.options.includes("anthropic/main-model")) {
|
|
1814
|
+
throw new Error(`the picker omitted a model available to the isolated branch: ${JSON.stringify(offered.options)}`);
|
|
1815
|
+
}
|
|
1816
|
+
if (offered.options.includes("dynamic/extension-only")) {
|
|
1817
|
+
throw new Error(`the picker offered a main-session-only provider: ${JSON.stringify(offered.options)}`);
|
|
1818
|
+
}
|
|
1819
|
+
const pinFile = `${home}/config/supervision-branch-model`;
|
|
1820
|
+
if (readFileSync(pinFile, "utf8") !== "openai-codex/cheap-oauth\n") {
|
|
1821
|
+
throw new Error(`unexpected persisted pin: ${JSON.stringify(readFileSync(pinFile, "utf8"))}`);
|
|
1822
|
+
}
|
|
1823
|
+
if ((statSync(pinFile).mode & 0o777) !== 0o600) throw new Error("the pin must be written private to the operator");
|
|
1824
|
+
if (!notices.some((notice) => notice.message.includes("openai-codex/cheap-oauth"))) {
|
|
1825
|
+
throw new Error(`the captain was not told which model the branch now uses: ${JSON.stringify(notices)}`);
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
// The live branch is released, so the pick binds on the next wake instead of
|
|
1829
|
+
// waiting for a session replacement - and the same persistent conversation
|
|
1830
|
+
// comes back under the new model.
|
|
1831
|
+
await settle(() => firstSession.disposed, "live branch release after the pick");
|
|
1832
|
+
dispatch("signal: after the pick");
|
|
1833
|
+
await settle(() => (globalThis.__fmSessions ?? []).length === 2, "post-pick branch build");
|
|
1834
|
+
const repinned = globalThis.__fmSessions[1];
|
|
1835
|
+
if (!repinned.options.model || repinned.options.model.id !== "cheap-oauth") {
|
|
1836
|
+
throw new Error(`the pick did not bind the next branch build: ${JSON.stringify(repinned.options.model)}`);
|
|
1837
|
+
}
|
|
1838
|
+
if (repinned.options.model.authKind !== "oauth") {
|
|
1839
|
+
throw new Error(`the branch runtime changed the stored OAuth credential semantics: ${JSON.stringify(repinned.options.model)}`);
|
|
1840
|
+
}
|
|
1841
|
+
if (!repinned.options.sessionManager.opened) throw new Error("the pick must keep the branch conversation, not start a new one");
|
|
1842
|
+
|
|
1843
|
+
// Following main again clears the file and actually returns the branch to
|
|
1844
|
+
// main's model, rather than letting the reopened session restore the pin.
|
|
1845
|
+
const clearNoticeCount = notices.length;
|
|
1846
|
+
uiSelections.push("Follow main (anthropic/main-model)");
|
|
1847
|
+
await command.handler("", makeCtx());
|
|
1848
|
+
if (existsSync(pinFile)) throw new Error("following main must remove the pin file");
|
|
1849
|
+
const clearNotices = notices.slice(clearNoticeCount);
|
|
1850
|
+
if (clearNotices.length !== 1 || clearNotices[0].type !== "info" || !clearNotices[0].message.includes("anthropic/main-model")) {
|
|
1851
|
+
throw new Error(`following main did not report the model actually applied: ${JSON.stringify(clearNotices)}`);
|
|
1852
|
+
}
|
|
1853
|
+
await settle(() => repinned.disposed, "live branch release after clearing");
|
|
1854
|
+
dispatch("signal: after clearing");
|
|
1855
|
+
await settle(() => (globalThis.__fmSessions ?? []).length === 3, "post-clear branch build");
|
|
1856
|
+
const followed = globalThis.__fmSessions[2].options.model;
|
|
1857
|
+
if (followed?.id === "cheap-oauth") throw new Error("following main left the branch on the cleared pin's model");
|
|
1858
|
+
if (followed?.provider !== "anthropic" || followed?.id !== "main-model") {
|
|
1859
|
+
throw new Error(`following main did not apply main's own model: ${JSON.stringify(followed)}`);
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
// A pick made while the old-model branch build is in flight invalidates that
|
|
1863
|
+
// build. The accepted wake continues on a second build under the newest pin.
|
|
1864
|
+
uiSelections.push("Follow main (anthropic/main-model)");
|
|
1865
|
+
await command.handler("", makeCtx());
|
|
1866
|
+
let releaseCreate;
|
|
1867
|
+
globalThis.__fmCreateGate = new Promise((resolve) => { releaseCreate = resolve; });
|
|
1868
|
+
const createsBeforeRace = globalThis.__fmCreateStarted;
|
|
1869
|
+
dispatch("signal: model race");
|
|
1870
|
+
await settle(() => globalThis.__fmCreateStarted === createsBeforeRace + 1, "in-flight old-model build");
|
|
1871
|
+
uiSelections.push("openai-codex/cheap-oauth");
|
|
1872
|
+
await command.handler("", makeCtx());
|
|
1873
|
+
releaseCreate();
|
|
1874
|
+
await settle(() => (globalThis.__fmSessions ?? []).length === 5, "replacement build after in-flight pick");
|
|
1875
|
+
const staleBuild = globalThis.__fmSessions[3];
|
|
1876
|
+
const winningBuild = globalThis.__fmSessions[4];
|
|
1877
|
+
if (!staleBuild.disposed || staleBuild.options.model?.id === "cheap-oauth") {
|
|
1878
|
+
throw new Error("the in-flight old-model build was adopted after the pick");
|
|
1879
|
+
}
|
|
1880
|
+
if (winningBuild.options.model?.id !== "cheap-oauth") {
|
|
1881
|
+
throw new Error(`the newest pin did not win the in-flight build race: ${JSON.stringify(winningBuild.options.model)}`);
|
|
1882
|
+
}
|
|
1883
|
+
await settle(() => (globalThis.__fmPrompts ?? []).some((prompt) => prompt.includes("signal: model race")), "raced wake prompt");
|
|
1884
|
+
|
|
1885
|
+
// A cancelled picker changes nothing.
|
|
1886
|
+
uiSelections.push(undefined);
|
|
1887
|
+
await command.handler("", makeCtx());
|
|
1888
|
+
if (readFileSync(pinFile, "utf8") !== "openai-codex/cheap-oauth\n") throw new Error("a cancelled picker must not change the pin");
|
|
1889
|
+
|
|
1890
|
+
// If the isolated runtime cannot load, the old pin remains and no success
|
|
1891
|
+
// notification is emitted.
|
|
1892
|
+
const noticeCount = notices.length;
|
|
1893
|
+
globalThis.__fmModelRuntimeError = "synthetic stored-credential load failure";
|
|
1894
|
+
await command.handler("", makeCtx());
|
|
1895
|
+
delete globalThis.__fmModelRuntimeError;
|
|
1896
|
+
if (readFileSync(pinFile, "utf8") !== "openai-codex/cheap-oauth\n") throw new Error("an unapplied model replaced the working pin");
|
|
1897
|
+
const newNotices = notices.slice(noticeCount);
|
|
1898
|
+
if (newNotices.length !== 1 || newNotices[0].type !== "error") {
|
|
1899
|
+
throw new Error(`an unapplied model emitted a success notification: ${JSON.stringify(newNotices)}`);
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
// If the picker loads but resolving main after Follow main fails, the pin is
|
|
1903
|
+
// cleared and the captain receives an honest warning rather than a rejection
|
|
1904
|
+
// or a false success notice.
|
|
1905
|
+
const clearFailureNoticeCount = notices.length;
|
|
1906
|
+
globalThis.__fmModelRuntimeErrors = [null, "synthetic post-clear runtime failure"];
|
|
1907
|
+
uiSelections.push("Follow main (anthropic/main-model)");
|
|
1908
|
+
await command.handler("", makeCtx());
|
|
1909
|
+
if (existsSync(pinFile)) throw new Error("following main did not clear the pin before its resolution warning");
|
|
1910
|
+
const clearFailureNotices = notices.slice(clearFailureNoticeCount);
|
|
1911
|
+
if (
|
|
1912
|
+
clearFailureNotices.length !== 1 ||
|
|
1913
|
+
clearFailureNotices[0].type !== "warning" ||
|
|
1914
|
+
!clearFailureNotices[0].message.includes("synthetic post-clear runtime failure")
|
|
1915
|
+
) {
|
|
1916
|
+
throw new Error(`post-clear resolution failure was not reported honestly: ${JSON.stringify(clearFailureNotices)}`);
|
|
1917
|
+
}
|
|
1918
|
+
process.exit(0);
|
|
1919
|
+
EOF
|
|
1920
|
+
status=$?
|
|
1921
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
1922
|
+
expect_code 0 "$status" "the supervision-model command must persist the pick and rebind the live branch: $out"
|
|
1923
|
+
pass "supervision-model command persists the captain's pick and rebinds the live branch"
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
test_branch_effort_pin_applies_and_absent_pin_follows_main() {
|
|
1927
|
+
local repo home out status
|
|
1928
|
+
repo="$TMP_ROOT/effortpin-root"
|
|
1929
|
+
home="$TMP_ROOT/effortpin-home"
|
|
1930
|
+
mkdir -p "$home/state" "$home/config"
|
|
1931
|
+
install_pi_branch_extension_fixture "$repo"
|
|
1932
|
+
PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
|
|
1933
|
+
DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
|
|
1934
|
+
const prelude = process.env.DRIVER_PRELUDE;
|
|
1935
|
+
await eval(`(async () => { ${prelude}; globalThis.__t = { fire, dispatch, settle, makeCtx, registryModels, setMainThinkingLevel, home }; })()`);
|
|
1936
|
+
const { fire, dispatch, settle, makeCtx, registryModels, setMainThinkingLevel, home } = globalThis.__t;
|
|
1937
|
+
import { rmSync, writeFileSync } from "node:fs";
|
|
1938
|
+
|
|
1939
|
+
// main-model reasons up to Pi's "high"; deep-1 also maps xhigh and max.
|
|
1940
|
+
registryModels.push(
|
|
1941
|
+
{ provider: "anthropic", id: "main-model", reasoning: true },
|
|
1942
|
+
{ provider: "openai", id: "deep-1", reasoning: true, thinkingLevelMap: { xhigh: "xhigh", max: "max" } },
|
|
1943
|
+
);
|
|
1944
|
+
const effortPin = `${home}/config/supervision-branch-effort`;
|
|
1945
|
+
const modelPin = `${home}/config/supervision-branch-model`;
|
|
1946
|
+
function rebuild(reason, expected) {
|
|
1947
|
+
fire("session_shutdown", {});
|
|
1948
|
+
fire("session_start", {}, makeCtx());
|
|
1949
|
+
dispatch(`signal: ${reason}`);
|
|
1950
|
+
return settle(() => (globalThis.__fmSessions ?? []).length === expected, `${reason} branch build`);
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
// 1. No effort pin: the branch applies MAIN's own current effort explicitly,
|
|
1954
|
+
// so a reopened conversation cannot restore the level it last ran under.
|
|
1955
|
+
fire("session_start", {}, makeCtx());
|
|
1956
|
+
dispatch("signal: unpinned effort");
|
|
1957
|
+
await settle(() => (globalThis.__fmSessions ?? []).length === 1, "unpinned-effort branch build");
|
|
1958
|
+
if (globalThis.__fmSessions[0].options.thinkingLevel !== "medium") {
|
|
1959
|
+
throw new Error(`an absent effort pin must follow main's own effort: ${globalThis.__fmSessions[0].options.thinkingLevel}`);
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
// 2. An effort-only pin binds the next build and leaves the model following
|
|
1963
|
+
// main, so the two pins are independently expressible.
|
|
1964
|
+
writeFileSync(effortPin, "low\n");
|
|
1965
|
+
await rebuild("effort-only pin", 2);
|
|
1966
|
+
const effortOnly = globalThis.__fmSessions[1].options;
|
|
1967
|
+
if (effortOnly.thinkingLevel !== "low") {
|
|
1968
|
+
throw new Error(`the effort pin did not bind the build: ${effortOnly.thinkingLevel}`);
|
|
1969
|
+
}
|
|
1970
|
+
if (effortOnly.model?.id !== "main-model") {
|
|
1971
|
+
throw new Error(`an effort-only pin must leave the model following main: ${JSON.stringify(effortOnly.model)}`);
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
// 3. The reopen path applies the effort pin too, over whatever the restored
|
|
1975
|
+
// branch conversation recorded.
|
|
1976
|
+
await rebuild("effort pin reopen", 3);
|
|
1977
|
+
if (globalThis.__fmSessions[2].options.thinkingLevel !== "low") {
|
|
1978
|
+
throw new Error(`the reopened build dropped the effort pin: ${globalThis.__fmSessions[2].options.thinkingLevel}`);
|
|
1979
|
+
}
|
|
1980
|
+
if (!globalThis.__fmSessions[2].options.sessionManager.opened) {
|
|
1981
|
+
throw new Error("the effort-pinned reopen must continue the persistent branch conversation");
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
// 4. A model-only pin leaves the effort following main, the mirror image of
|
|
1985
|
+
// case 2.
|
|
1986
|
+
rmSync(effortPin);
|
|
1987
|
+
writeFileSync(modelPin, "openai/deep-1\n");
|
|
1988
|
+
setMainThinkingLevel("high");
|
|
1989
|
+
await rebuild("model-only pin", 4);
|
|
1990
|
+
const modelOnly = globalThis.__fmSessions[3].options;
|
|
1991
|
+
if (modelOnly.model?.id !== "deep-1") throw new Error("the model-only pin did not bind the build");
|
|
1992
|
+
if (modelOnly.thinkingLevel !== "high") {
|
|
1993
|
+
throw new Error(`a model-only pin must leave the effort following main: ${modelOnly.thinkingLevel}`);
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
// 5. Clearing the effort pin is the case Pi's own restore would get wrong:
|
|
1997
|
+
// the branch conversation still records the pinned level, so main's current
|
|
1998
|
+
// effort has to be applied explicitly rather than merely omitted.
|
|
1999
|
+
writeFileSync(effortPin, "max\n");
|
|
2000
|
+
await rebuild("effort repinned", 5);
|
|
2001
|
+
if (globalThis.__fmSessions[4].options.thinkingLevel !== "max") {
|
|
2002
|
+
throw new Error(`deep-1 must accept Pi's max level: ${globalThis.__fmSessions[4].options.thinkingLevel}`);
|
|
2003
|
+
}
|
|
2004
|
+
rmSync(effortPin);
|
|
2005
|
+
setMainThinkingLevel("minimal");
|
|
2006
|
+
await rebuild("effort cleared", 6);
|
|
2007
|
+
if (globalThis.__fmSessions[5].options.thinkingLevel === "max") {
|
|
2008
|
+
throw new Error("clearing the effort pin left the branch on the previously pinned level");
|
|
2009
|
+
}
|
|
2010
|
+
if (globalThis.__fmSessions[5].options.thinkingLevel !== "minimal") {
|
|
2011
|
+
throw new Error(`clearing the effort pin did not return the branch to main's effort: ${globalThis.__fmSessions[5].options.thinkingLevel}`);
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
// 6. Pi owns the clamp: a pinned level the branch's model cannot run becomes
|
|
2015
|
+
// that model's nearest supported level instead of refusing the branch.
|
|
2016
|
+
rmSync(modelPin);
|
|
2017
|
+
writeFileSync(effortPin, "max\n");
|
|
2018
|
+
await rebuild("clamped effort pin", 7);
|
|
2019
|
+
const clamped = globalThis.__fmSessions[6].options;
|
|
2020
|
+
if (clamped.model?.id !== "main-model") throw new Error("the clamp probe must run on main's own model");
|
|
2021
|
+
if (clamped.thinkingLevel !== "high") {
|
|
2022
|
+
throw new Error(`an unsupported pinned level must clamp to the model's nearest level: ${clamped.thinkingLevel}`);
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
// 7. A token Pi does not recognize is no pin at all, exactly like an
|
|
2026
|
+
// unparseable model pin, and never a silent downgrade to no reasoning.
|
|
2027
|
+
writeFileSync(effortPin, "deep-thought\n");
|
|
2028
|
+
setMainThinkingLevel("medium");
|
|
2029
|
+
await rebuild("unrecognized effort pin", 8);
|
|
2030
|
+
if (globalThis.__fmSessions[7].options.thinkingLevel !== "medium") {
|
|
2031
|
+
throw new Error(`an unrecognized effort token must behave as no pin: ${globalThis.__fmSessions[7].options.thinkingLevel}`);
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
// 8. When Pi cannot report main's own effort either, the build falls back to
|
|
2035
|
+
// passing no effort override at all rather than losing the wake.
|
|
2036
|
+
rmSync(effortPin);
|
|
2037
|
+
globalThis.__fmThinkingLevelError = "synthetic unbound extension runtime";
|
|
2038
|
+
await rebuild("unknown main effort", 9);
|
|
2039
|
+
delete globalThis.__fmThinkingLevelError;
|
|
2040
|
+
if ("thinkingLevel" in globalThis.__fmSessions[8].options) {
|
|
2041
|
+
throw new Error("an unknowable main effort must fall back to passing no effort override");
|
|
2042
|
+
}
|
|
2043
|
+
process.exit(0);
|
|
2044
|
+
EOF
|
|
2045
|
+
status=$?
|
|
2046
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
2047
|
+
expect_code 0 "$status" "the current effort pin state must decide the branch effort on every build: $out"
|
|
2048
|
+
pass "the effort pin binds every branch create and reopen, and clearing it returns the branch to main's effort"
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
test_unpinned_branch_follows_main_effort_changes_live() {
|
|
2052
|
+
local repo home out status
|
|
2053
|
+
repo="$TMP_ROOT/effort-live-root"
|
|
2054
|
+
home="$TMP_ROOT/effort-live-home"
|
|
2055
|
+
mkdir -p "$home/state" "$home/config"
|
|
2056
|
+
install_pi_branch_extension_fixture "$repo"
|
|
2057
|
+
PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
|
|
2058
|
+
DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
|
|
2059
|
+
const prelude = process.env.DRIVER_PRELUDE;
|
|
2060
|
+
await eval(`(async () => { ${prelude}; globalThis.__t = { fire, dispatch, settle, makeCtx, registryModels, setMainThinkingLevel, home }; })()`);
|
|
2061
|
+
const { fire, dispatch, settle, makeCtx, registryModels, setMainThinkingLevel, home } = globalThis.__t;
|
|
2062
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2063
|
+
|
|
2064
|
+
registryModels.push({ provider: "anthropic", id: "main-model", reasoning: true });
|
|
2065
|
+
const effortPin = `${home}/config/supervision-branch-effort`;
|
|
2066
|
+
|
|
2067
|
+
fire("session_start", {}, makeCtx());
|
|
2068
|
+
dispatch("signal: before main effort change");
|
|
2069
|
+
await settle(() => (globalThis.__fmSessions ?? []).length === 1, "initial branch build");
|
|
2070
|
+
const original = globalThis.__fmSessions[0];
|
|
2071
|
+
if (original.options.thinkingLevel !== "medium") throw new Error("the unpinned branch did not start on main's effort");
|
|
2072
|
+
|
|
2073
|
+
// A mid-session effort change releases the live branch, so the next wake
|
|
2074
|
+
// reopens the same conversation at main's new effort without waiting for a
|
|
2075
|
+
// session replacement.
|
|
2076
|
+
setMainThinkingLevel("high");
|
|
2077
|
+
fire("thinking_level_select", { level: "high", previousLevel: "medium" });
|
|
2078
|
+
await settle(() => original.disposed, "live branch release after main's effort change");
|
|
2079
|
+
dispatch("signal: after main effort change");
|
|
2080
|
+
await settle(() => (globalThis.__fmSessions ?? []).length === 2, "post-change branch build");
|
|
2081
|
+
const following = globalThis.__fmSessions[1];
|
|
2082
|
+
if (following.options.thinkingLevel !== "high") {
|
|
2083
|
+
throw new Error(`the unpinned branch did not follow main's effort change: ${following.options.thinkingLevel}`);
|
|
2084
|
+
}
|
|
2085
|
+
if (!following.options.sessionManager.opened) {
|
|
2086
|
+
throw new Error("following main's effort must keep the persistent branch conversation");
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
// A pinned branch is authoritative: main's effort changes leave both the live
|
|
2090
|
+
// branch and the stored pin alone.
|
|
2091
|
+
writeFileSync(effortPin, "minimal\n");
|
|
2092
|
+
fire("session_shutdown", {});
|
|
2093
|
+
fire("session_start", {}, makeCtx());
|
|
2094
|
+
dispatch("signal: pinned effort");
|
|
2095
|
+
await settle(() => (globalThis.__fmSessions ?? []).length === 3, "pinned effort branch build");
|
|
2096
|
+
const pinned = globalThis.__fmSessions[2];
|
|
2097
|
+
if (pinned.options.thinkingLevel !== "minimal") throw new Error("the pinned branch did not use its effort pin");
|
|
2098
|
+
|
|
2099
|
+
setMainThinkingLevel("high");
|
|
2100
|
+
fire("thinking_level_select", { level: "high", previousLevel: "minimal" });
|
|
2101
|
+
dispatch("signal: pinned after main effort change");
|
|
2102
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2103
|
+
if (pinned.disposed) throw new Error("a main effort change replaced the effort-pinned branch");
|
|
2104
|
+
if (readFileSync(effortPin, "utf8") !== "minimal\n") {
|
|
2105
|
+
throw new Error("a main effort change disturbed the supervision effort pin");
|
|
2106
|
+
}
|
|
2107
|
+
process.exit(0);
|
|
2108
|
+
EOF
|
|
2109
|
+
status=$?
|
|
2110
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
2111
|
+
expect_code 0 "$status" "unpinned branches must follow main effort changes while pins remain authoritative: $out"
|
|
2112
|
+
pass "unpinned branches follow main effort changes live while pinned branches stay fixed"
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
test_supervision_model_picker_is_bounded_searchable_and_branch_only() {
|
|
2116
|
+
local repo home out status
|
|
2117
|
+
repo="$TMP_ROOT/pickerux-root"
|
|
2118
|
+
home="$TMP_ROOT/pickerux-home"
|
|
2119
|
+
mkdir -p "$home/state" "$home/config"
|
|
2120
|
+
install_pi_branch_extension_fixture "$repo"
|
|
2121
|
+
PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
|
|
2122
|
+
DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
|
|
2123
|
+
const prelude = process.env.DRIVER_PRELUDE;
|
|
2124
|
+
await eval(`(async () => { ${prelude}; globalThis.__t = { fire, makeCtx, makeTuiCtx, commands, registryModels, uiSelections, uiKeystrokes, mainModelWrites, home }; })()`);
|
|
2125
|
+
const { fire, makeCtx, makeTuiCtx, commands, registryModels, uiSelections, uiKeystrokes, mainModelWrites, home } = globalThis.__t;
|
|
2126
|
+
import { readFileSync } from "node:fs";
|
|
2127
|
+
|
|
2128
|
+
// A catalog long enough that rendering it whole would run off a terminal,
|
|
2129
|
+
// plus one distinctively named model to search for and one model the
|
|
2130
|
+
// isolated branch runtime cannot run.
|
|
2131
|
+
registryModels.push({ provider: "anthropic", id: "main-model" });
|
|
2132
|
+
for (let i = 1; i <= 30; i += 1) registryModels.push({ provider: "anthropic", id: `bulk-model-${i}` });
|
|
2133
|
+
registryModels.push({ provider: "openai-codex", id: "cheap-oauth", authKind: "oauth" });
|
|
2134
|
+
registryModels.push({ provider: "dynamic", id: "extension-only", branchAvailable: false });
|
|
2135
|
+
|
|
2136
|
+
const command = commands.get("supervision-model");
|
|
2137
|
+
if (!command) throw new Error("the supervision-model command was not registered");
|
|
2138
|
+
fire("session_start", {}, makeCtx());
|
|
2139
|
+
|
|
2140
|
+
// The captain types a search query and confirms the one row it leaves.
|
|
2141
|
+
globalThis.__fmPickerLists = [];
|
|
2142
|
+
uiKeystrokes.push(["c", "h", "e", "a", "p", "tui.select.confirm"]);
|
|
2143
|
+
uiSelections.push(undefined); // the effort step is cancelled, leaving that choice alone
|
|
2144
|
+
await command.handler("", makeTuiCtx());
|
|
2145
|
+
|
|
2146
|
+
const lists = globalThis.__fmPickerLists;
|
|
2147
|
+
if (lists.length < 2) throw new Error(`typing a query must rebuild the list: ${JSON.stringify(lists.map((l) => l.items.length))}`);
|
|
2148
|
+
const opened = lists[0];
|
|
2149
|
+
if (opened.maxVisible !== 10) {
|
|
2150
|
+
throw new Error(`the model list must stay bounded rather than rendering every row: maxVisible=${opened.maxVisible}`);
|
|
2151
|
+
}
|
|
2152
|
+
if (opened.items[0].label !== "Follow main (anthropic/main-model)") {
|
|
2153
|
+
throw new Error(`following main must be the first row: ${JSON.stringify(opened.items.slice(0, 2))}`);
|
|
2154
|
+
}
|
|
2155
|
+
if (opened.items.length !== 33) {
|
|
2156
|
+
throw new Error(`the opened list must offer following main plus every branch-runnable model: ${opened.items.length}`);
|
|
2157
|
+
}
|
|
2158
|
+
if (opened.items.some((item) => item.label.includes("extension-only"))) {
|
|
2159
|
+
throw new Error("the picker widened past the branch runtime's eligibility filter");
|
|
2160
|
+
}
|
|
2161
|
+
const filtered = lists[lists.length - 1];
|
|
2162
|
+
if (filtered.items.length !== 1 || filtered.items[0].value !== "openai-codex/cheap-oauth") {
|
|
2163
|
+
throw new Error(`the search query did not narrow the list: ${JSON.stringify(filtered.items)}`);
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
// The pick lands on the supervision branch alone.
|
|
2167
|
+
if (readFileSync(`${home}/config/supervision-branch-model`, "utf8") !== "openai-codex/cheap-oauth\n") {
|
|
2168
|
+
throw new Error("the searched-for pick was not persisted as the supervision branch model");
|
|
2169
|
+
}
|
|
2170
|
+
if (mainModelWrites.length !== 0) {
|
|
2171
|
+
throw new Error(`pinning the branch moved the captain's own model: ${JSON.stringify(mainModelWrites)}`);
|
|
2172
|
+
}
|
|
2173
|
+
if (makeCtx().model.id !== "main-model") throw new Error("the captain's own conversation model changed");
|
|
2174
|
+
|
|
2175
|
+
// A query that matches following main keeps that row first, and escape
|
|
2176
|
+
// leaves every choice standing.
|
|
2177
|
+
globalThis.__fmPickerLists = [];
|
|
2178
|
+
uiKeystrokes.push(["m", "a", "i", "n", "tui.select.cancel"]);
|
|
2179
|
+
await command.handler("", makeTuiCtx());
|
|
2180
|
+
const mainQuery = globalThis.__fmPickerLists[globalThis.__fmPickerLists.length - 1];
|
|
2181
|
+
if (mainQuery.items[0].label !== "Follow main (anthropic/main-model)") {
|
|
2182
|
+
throw new Error(`a matching query must keep following main first: ${JSON.stringify(mainQuery.items.slice(0, 2))}`);
|
|
2183
|
+
}
|
|
2184
|
+
if (mainQuery.items.length < 2) throw new Error("a matching query dropped the models it also matched");
|
|
2185
|
+
if (readFileSync(`${home}/config/supervision-branch-model`, "utf8") !== "openai-codex/cheap-oauth\n") {
|
|
2186
|
+
throw new Error("cancelling the picker changed the standing pin");
|
|
2187
|
+
}
|
|
2188
|
+
process.exit(0);
|
|
2189
|
+
EOF
|
|
2190
|
+
status=$?
|
|
2191
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
2192
|
+
expect_code 0 "$status" "the model picker must be bounded, searchable, and branch-only: $out"
|
|
2193
|
+
pass "supervision-model opens a bounded searchable list, follow main first, and pins the branch alone"
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
test_branch_model_picker_keeps_follow_main_first_under_ranking() {
|
|
2197
|
+
local repo out status
|
|
2198
|
+
repo="$TMP_ROOT/pickerlib-root"
|
|
2199
|
+
mkdir -p "$repo/.pi/extensions/lib"
|
|
2200
|
+
cp "$ROOT/.pi/extensions/lib/fm-branch-model-picker.ts" "$repo/.pi/extensions/lib/fm-branch-model-picker.ts"
|
|
2201
|
+
LIB="$repo/.pi/extensions/lib/fm-branch-model-picker.ts" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
|
|
2202
|
+
import { pathToFileURL } from "node:url";
|
|
2203
|
+
|
|
2204
|
+
const { buildBranchModelItems, filterBranchPickerItems, BRANCH_PICKER_MAX_VISIBLE, FOLLOW_MAIN_VALUE } = await import(
|
|
2205
|
+
pathToFileURL(process.env.LIB).href
|
|
2206
|
+
);
|
|
2207
|
+
|
|
2208
|
+
const items = buildBranchModelItems("Follow main (anthropic/main-model)", ["anthropic/main-model", "openai/mainly-cheap"], null);
|
|
2209
|
+
if (items[0].value !== FOLLOW_MAIN_VALUE) throw new Error("following main must be built as the first row");
|
|
2210
|
+
if (items[0].description !== "current") throw new Error("an absent pin must mark following main as the current choice");
|
|
2211
|
+
if (items[2].description !== undefined) throw new Error("a model that is not pinned must not be marked current");
|
|
2212
|
+
|
|
2213
|
+
const pinned = buildBranchModelItems("Follow main (anthropic/main-model)", ["anthropic/main-model"], "anthropic/main-model");
|
|
2214
|
+
if (pinned[0].description !== undefined) throw new Error("a pinned branch must not mark following main as current");
|
|
2215
|
+
if (pinned[1].description !== "current") throw new Error("the pinned model must be marked as the current choice");
|
|
2216
|
+
|
|
2217
|
+
// A ranking filter is free to sort a better match ahead of following main;
|
|
2218
|
+
// the picker must still show following main first whenever it matches.
|
|
2219
|
+
const rankReversing = (list, query, getText) =>
|
|
2220
|
+
list.filter((item) => getText(item).toLowerCase().includes(query.toLowerCase())).reverse();
|
|
2221
|
+
const matched = filterBranchPickerItems(items, "main", rankReversing);
|
|
2222
|
+
if (matched[0].value !== FOLLOW_MAIN_VALUE) {
|
|
2223
|
+
throw new Error(`ranking moved following main out of first place: ${JSON.stringify(matched)}`);
|
|
2224
|
+
}
|
|
2225
|
+
if (matched.length !== 3) throw new Error(`a matching query dropped rows it should keep: ${JSON.stringify(matched)}`);
|
|
2226
|
+
|
|
2227
|
+
const narrowed = filterBranchPickerItems(items, "openai", rankReversing);
|
|
2228
|
+
if (narrowed.length !== 1 || narrowed[0].value !== "openai/mainly-cheap") {
|
|
2229
|
+
throw new Error(`a query that excludes following main must drop it: ${JSON.stringify(narrowed)}`);
|
|
2230
|
+
}
|
|
2231
|
+
const unfiltered = filterBranchPickerItems(items, " ", rankReversing);
|
|
2232
|
+
if (unfiltered.length !== items.length || unfiltered[0].value !== FOLLOW_MAIN_VALUE) {
|
|
2233
|
+
throw new Error("an empty query must keep the built order");
|
|
2234
|
+
}
|
|
2235
|
+
if (BRANCH_PICKER_MAX_VISIBLE !== 10) throw new Error("the picker must keep a bounded visible row count");
|
|
2236
|
+
process.exit(0);
|
|
2237
|
+
EOF
|
|
2238
|
+
status=$?
|
|
2239
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
2240
|
+
expect_code 0 "$status" "the picker's ordering and filtering must hold: $out"
|
|
2241
|
+
pass "branch model picker keeps follow main first and filters the eligible catalog"
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
test_supervision_model_command_picks_effort_after_the_model() {
|
|
2245
|
+
local repo home out status
|
|
2246
|
+
repo="$TMP_ROOT/effortcmd-root"
|
|
2247
|
+
home="$TMP_ROOT/effortcmd-home"
|
|
2248
|
+
mkdir -p "$home/state" "$home/config"
|
|
2249
|
+
install_pi_branch_extension_fixture "$repo"
|
|
2250
|
+
PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
|
|
2251
|
+
DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
|
|
2252
|
+
const prelude = process.env.DRIVER_PRELUDE;
|
|
2253
|
+
await eval(`(async () => { ${prelude}; globalThis.__t = { fire, dispatch, settle, makeCtx, commands, registryModels, uiSelections, uiPrompts, notices, setMainThinkingLevel, home }; })()`);
|
|
2254
|
+
const { fire, dispatch, settle, makeCtx, commands, registryModels, uiSelections, uiPrompts, notices, setMainThinkingLevel, home } = globalThis.__t;
|
|
2255
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2256
|
+
|
|
2257
|
+
// main-model reasons up to Pi's "high"; deep-1 also maps xhigh and max, so
|
|
2258
|
+
// the two models genuinely offer different menus.
|
|
2259
|
+
registryModels.push(
|
|
2260
|
+
{ provider: "anthropic", id: "main-model", reasoning: true },
|
|
2261
|
+
{ provider: "openai", id: "deep-1", reasoning: true, thinkingLevelMap: { xhigh: "xhigh", max: "max" } },
|
|
2262
|
+
{ provider: "openai", id: "shallow-1", reasoning: true },
|
|
2263
|
+
);
|
|
2264
|
+
const command = commands.get("supervision-model");
|
|
2265
|
+
const effortPin = `${home}/config/supervision-branch-effort`;
|
|
2266
|
+
const modelPin = `${home}/config/supervision-branch-model`;
|
|
2267
|
+
fire("session_start", {}, makeCtx());
|
|
2268
|
+
|
|
2269
|
+
// One command, two steps in order: the model picker, then an effort picker
|
|
2270
|
+
// built from Pi's own supported levels for the model just chosen.
|
|
2271
|
+
uiSelections.push("openai/deep-1", "xhigh");
|
|
2272
|
+
await command.handler("", makeCtx());
|
|
2273
|
+
if (uiPrompts.length !== 2) throw new Error(`the command must show the model picker then the effort picker: ${uiPrompts.length}`);
|
|
2274
|
+
if (!uiPrompts[0].title.startsWith("Supervision branch model")) {
|
|
2275
|
+
throw new Error(`the first step must be the model picker: ${uiPrompts[0].title}`);
|
|
2276
|
+
}
|
|
2277
|
+
const effortStep = uiPrompts[1];
|
|
2278
|
+
if (!effortStep.title.startsWith("Supervision branch effort")) {
|
|
2279
|
+
throw new Error(`the second step must be the effort picker: ${effortStep.title}`);
|
|
2280
|
+
}
|
|
2281
|
+
if (effortStep.options[0] !== "Follow main (medium)") {
|
|
2282
|
+
throw new Error(`the effort picker must offer following main first: ${JSON.stringify(effortStep.options)}`);
|
|
2283
|
+
}
|
|
2284
|
+
if (JSON.stringify(effortStep.options.slice(1)) !== JSON.stringify(["off", "minimal", "low", "medium", "high", "xhigh", "max"])) {
|
|
2285
|
+
throw new Error(`the effort picker must offer Pi's own levels for the chosen model: ${JSON.stringify(effortStep.options)}`);
|
|
2286
|
+
}
|
|
2287
|
+
if (readFileSync(effortPin, "utf8") !== "xhigh\n") {
|
|
2288
|
+
throw new Error(`unexpected persisted effort pin: ${JSON.stringify(readFileSync(effortPin, "utf8"))}`);
|
|
2289
|
+
}
|
|
2290
|
+
if ((statSync(effortPin).mode & 0o777) !== 0o600) throw new Error("the effort pin must be written private to the operator");
|
|
2291
|
+
if (readFileSync(modelPin, "utf8") !== "openai/deep-1\n") throw new Error("the model pick was not persisted alongside the effort pick");
|
|
2292
|
+
const bothNotice = notices[notices.length - 1];
|
|
2293
|
+
if (!bothNotice.message.includes("openai/deep-1") || !bothNotice.message.includes("xhigh")) {
|
|
2294
|
+
throw new Error(`the captain was not told both applied choices: ${JSON.stringify(bothNotice)}`);
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
// Both choices bind the next branch build together.
|
|
2298
|
+
dispatch("signal: after both picks");
|
|
2299
|
+
await settle(() => (globalThis.__fmSessions ?? []).length === 1, "branch build after both picks");
|
|
2300
|
+
const both = globalThis.__fmSessions[0].options;
|
|
2301
|
+
if (both.model?.id !== "deep-1" || both.thinkingLevel !== "xhigh") {
|
|
2302
|
+
throw new Error(`the picks did not bind the branch build: ${JSON.stringify({ model: both.model?.id, effort: both.thinkingLevel })}`);
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
// The effort picker's menu follows the model chosen in the SAME invocation,
|
|
2306
|
+
// not the model the branch was running a moment ago: main-model stops at
|
|
2307
|
+
// Pi's "high", so xhigh and max disappear.
|
|
2308
|
+
uiSelections.push("Follow main (anthropic/main-model)", "high");
|
|
2309
|
+
await command.handler("", makeCtx());
|
|
2310
|
+
const narrowed = uiPrompts[uiPrompts.length - 1];
|
|
2311
|
+
if (narrowed.options.includes("xhigh") || narrowed.options.includes("max")) {
|
|
2312
|
+
throw new Error(`the effort menu did not follow the model just chosen: ${JSON.stringify(narrowed.options)}`);
|
|
2313
|
+
}
|
|
2314
|
+
if (!narrowed.title.includes("now: xhigh")) {
|
|
2315
|
+
throw new Error(`the effort picker must show the standing pin: ${narrowed.title}`);
|
|
2316
|
+
}
|
|
2317
|
+
if (readFileSync(effortPin, "utf8") !== "high\n") throw new Error("the second effort pick was not persisted");
|
|
2318
|
+
if (existsSync(modelPin)) throw new Error("following main did not clear the model pin");
|
|
2319
|
+
|
|
2320
|
+
// Cancelling the effort step leaves the standing effort choice alone while
|
|
2321
|
+
// the model pick made in the same invocation still applies, and the captain
|
|
2322
|
+
// is told what the branch will actually run at.
|
|
2323
|
+
const cancelNoticeCount = notices.length;
|
|
2324
|
+
uiSelections.push("openai/deep-1");
|
|
2325
|
+
await command.handler("", makeCtx());
|
|
2326
|
+
if (readFileSync(effortPin, "utf8") !== "high\n") throw new Error("a cancelled effort step changed the standing effort pin");
|
|
2327
|
+
if (readFileSync(modelPin, "utf8") !== "openai/deep-1\n") throw new Error("a cancelled effort step discarded the model pick");
|
|
2328
|
+
const cancelNotices = notices.slice(cancelNoticeCount);
|
|
2329
|
+
if (cancelNotices.length !== 1 || cancelNotices[0].type !== "info" || !cancelNotices[0].message.includes("high")) {
|
|
2330
|
+
throw new Error(`a cancelled effort step must still report the standing effort: ${JSON.stringify(cancelNotices)}`);
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
// Following main for effort clears the pin and reports main's own level.
|
|
2334
|
+
const followNoticeCount = notices.length;
|
|
2335
|
+
uiSelections.push("openai/deep-1", "Follow main (medium)");
|
|
2336
|
+
await command.handler("", makeCtx());
|
|
2337
|
+
if (existsSync(effortPin)) throw new Error("following main must remove the effort pin file");
|
|
2338
|
+
const followNotices = notices.slice(followNoticeCount);
|
|
2339
|
+
if (followNotices.length !== 1 || !followNotices[0].message.includes("Effort follows main (medium)")) {
|
|
2340
|
+
throw new Error(`following main for effort was not reported honestly: ${JSON.stringify(followNotices)}`);
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
// Honest reporting when Pi's own clamp will lower a standing pin: "max" is
|
|
2344
|
+
// offered on deep-1, but moving the branch back to main-model, which tops out
|
|
2345
|
+
// at Pi's "high", must be reported as the level the branch will really run at
|
|
2346
|
+
// rather than as the raw pin - and the captain's raw pick is kept, so
|
|
2347
|
+
// returning to deep-1 restores it.
|
|
2348
|
+
uiSelections.push("openai/deep-1", "max");
|
|
2349
|
+
await command.handler("", makeCtx());
|
|
2350
|
+
if (readFileSync(effortPin, "utf8") !== "max\n") throw new Error("the max effort pick was not persisted");
|
|
2351
|
+
const clampNoticeCount = notices.length;
|
|
2352
|
+
uiSelections.push("Follow main (anthropic/main-model)");
|
|
2353
|
+
await command.handler("", makeCtx());
|
|
2354
|
+
const clampNotices = notices.slice(clampNoticeCount);
|
|
2355
|
+
if (clampNotices.length !== 1 || !clampNotices[0].message.includes("Effort: max, which this model runs at high.")) {
|
|
2356
|
+
throw new Error(`a clamped standing effort pin was not reported honestly: ${JSON.stringify(clampNotices)}`);
|
|
2357
|
+
}
|
|
2358
|
+
if (readFileSync(effortPin, "utf8") !== "max\n") throw new Error("reporting a clamp rewrote the captain's raw effort pick");
|
|
2359
|
+
dispatch("signal: clamped build");
|
|
2360
|
+
await settle(() => (globalThis.__fmSessions ?? []).length === 2, "clamped branch build");
|
|
2361
|
+
if (globalThis.__fmSessions[1].options.thinkingLevel !== "high") {
|
|
2362
|
+
throw new Error(`the clamped build did not run at the reported level: ${globalThis.__fmSessions[1].options.thinkingLevel}`);
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
// When main's model cannot be resolved, the effort step derives Pi's level
|
|
2366
|
+
// set from the model recorded by the persistent branch conversation.
|
|
2367
|
+
uiSelections.push("openai/shallow-1", "max");
|
|
2368
|
+
await command.handler("", makeCtx());
|
|
2369
|
+
dispatch("signal: record shallow branch model");
|
|
2370
|
+
await settle(() => (globalThis.__fmSessions ?? []).length === 3, "shallow branch build");
|
|
2371
|
+
registryModels.find((model) => model.id === "main-model").branchAvailable = false;
|
|
2372
|
+
const restoredNoticeCount = notices.length;
|
|
2373
|
+
uiSelections.push("Follow main (anthropic/main-model)");
|
|
2374
|
+
await command.handler("", makeCtx());
|
|
2375
|
+
const restoredPicker = uiPrompts[uiPrompts.length - 1];
|
|
2376
|
+
if (JSON.stringify(restoredPicker.options.slice(1)) !== JSON.stringify(["off", "minimal", "low", "medium", "high"])) {
|
|
2377
|
+
throw new Error(`the unresolved-main picker did not use Pi's levels for the recorded branch model: ${JSON.stringify(restoredPicker.options)}`);
|
|
2378
|
+
}
|
|
2379
|
+
const restoredNotices = notices.slice(restoredNoticeCount);
|
|
2380
|
+
if (restoredNotices.length !== 1 || !restoredNotices[0].message.includes("Effort: max, which this model runs at high.")) {
|
|
2381
|
+
throw new Error(`the recorded branch model did not make clamp reporting honest: ${JSON.stringify(restoredNotices)}`);
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
// If neither main nor the recorded branch model can be resolved, no invented
|
|
2385
|
+
// catalog is offered and the report says the applied level is unknown.
|
|
2386
|
+
registryModels.find((model) => model.id === "shallow-1").branchAvailable = false;
|
|
2387
|
+
const unknownNoticeCount = notices.length;
|
|
2388
|
+
uiSelections.push("Follow main (anthropic/main-model)");
|
|
2389
|
+
await command.handler("", makeCtx());
|
|
2390
|
+
const unknownPicker = uiPrompts[uiPrompts.length - 1];
|
|
2391
|
+
if (JSON.stringify(unknownPicker.options) !== JSON.stringify(["Follow main (medium)"])) {
|
|
2392
|
+
throw new Error(`the unknown-model picker invented effort levels: ${JSON.stringify(unknownPicker.options)}`);
|
|
2393
|
+
}
|
|
2394
|
+
const unknownNotices = notices.slice(unknownNoticeCount);
|
|
2395
|
+
if (unknownNotices.length !== 1 || !unknownNotices[0].message.includes("cannot be determined")) {
|
|
2396
|
+
throw new Error(`the unknown effective effort was reported as applied: ${JSON.stringify(unknownNotices)}`);
|
|
2397
|
+
}
|
|
2398
|
+
process.exit(0);
|
|
2399
|
+
EOF
|
|
2400
|
+
status=$?
|
|
2401
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
2402
|
+
expect_code 0 "$status" "the effort step must follow the model step and persist independently: $out"
|
|
2403
|
+
pass "supervision-model runs an effort picker after the model picker and persists both independently"
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
test_unusable_model_pin_falls_back_to_main() {
|
|
2407
|
+
local repo home out status
|
|
2408
|
+
repo="$TMP_ROOT/modelbad-root"
|
|
2409
|
+
home="$TMP_ROOT/modelbad-home"
|
|
2410
|
+
mkdir -p "$home/state" "$home/config"
|
|
2411
|
+
install_pi_branch_extension_fixture "$repo"
|
|
2412
|
+
PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
|
|
2413
|
+
DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
|
|
2414
|
+
const prelude = process.env.DRIVER_PRELUDE;
|
|
2415
|
+
await eval(`(async () => { ${prelude}; globalThis.__t = { fire, dispatch, settle, makeCtx, registryModels, mainUserMessages, home }; })()`);
|
|
2416
|
+
const { fire, dispatch, settle, makeCtx, registryModels, mainUserMessages, home } = globalThis.__t;
|
|
2417
|
+
import { writeFileSync } from "node:fs";
|
|
2418
|
+
|
|
2419
|
+
registryModels.push(
|
|
2420
|
+
{ provider: "anthropic", id: "main-model" },
|
|
2421
|
+
{ provider: "dynamic", id: "extension-only", branchAvailable: false },
|
|
2422
|
+
);
|
|
2423
|
+
|
|
2424
|
+
// A pin the isolated branch runtime cannot hand back is never a silent
|
|
2425
|
+
// downgrade onto main's model, even when main's session knows that model.
|
|
2426
|
+
writeFileSync(`${home}/config/supervision-branch-model`, "dynamic/extension-only\n");
|
|
2427
|
+
fire("session_start", {}, makeCtx());
|
|
2428
|
+
dispatch("signal: unusable pin probe");
|
|
2429
|
+
await settle(() => mainUserMessages.length === 1, "fallback to main");
|
|
2430
|
+
const delivered = mainUserMessages[0].content;
|
|
2431
|
+
if (!delivered.includes("dynamic/extension-only") || !delivered.includes("supervision model pin")) {
|
|
2432
|
+
throw new Error(`the fallback did not name the unusable pin: ${delivered}`);
|
|
2433
|
+
}
|
|
2434
|
+
if ((globalThis.__fmSessions ?? []).length !== 0) throw new Error("an unusable pin must not build a branch session");
|
|
2435
|
+
|
|
2436
|
+
// An unparseable file is simply no pin, so supervision keeps working and the
|
|
2437
|
+
// branch follows main's own model.
|
|
2438
|
+
writeFileSync(`${home}/config/supervision-branch-model`, "not-a-model-reference\n");
|
|
2439
|
+
fire("session_shutdown", {});
|
|
2440
|
+
fire("session_start", {}, makeCtx());
|
|
2441
|
+
dispatch("signal: unparseable pin probe");
|
|
2442
|
+
await settle(() => (globalThis.__fmSessions ?? []).length === 1, "unparseable-pin branch build");
|
|
2443
|
+
const unparseable = globalThis.__fmSessions[0].options.model;
|
|
2444
|
+
if (unparseable?.provider !== "anthropic" || unparseable?.id !== "main-model") {
|
|
2445
|
+
throw new Error(`an unparseable pin must be treated as no pin and follow main: ${JSON.stringify(unparseable)}`);
|
|
2446
|
+
}
|
|
2447
|
+
process.exit(0);
|
|
2448
|
+
EOF
|
|
2449
|
+
status=$?
|
|
2450
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
2451
|
+
expect_code 0 "$status" "an unusable model pin must fall back to main and an unparseable one must be no pin: $out"
|
|
2452
|
+
pass "an unusable model pin falls back to main and an unparseable one is treated as no pin"
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
test_replacement_activation_cleans_leases_and_retries_failure() {
|
|
2456
|
+
local repo home fakebin out status real_bash
|
|
2457
|
+
repo="$TMP_ROOT/activation-root"
|
|
2458
|
+
home="$TMP_ROOT/activation-home"
|
|
2459
|
+
fakebin="$home/fakebin"
|
|
2460
|
+
real_bash=$(command -v bash)
|
|
2461
|
+
mkdir -p "$home/state" "$home/config" "$fakebin"
|
|
2462
|
+
install_pi_branch_extension_fixture "$repo"
|
|
2463
|
+
cat > "$fakebin/bash" <<'SH'
|
|
2464
|
+
#!/bin/sh
|
|
2465
|
+
if [ "$1" = "$FM_TEST_LEASE_SCRIPT" ] && [ ! -e "$FM_TEST_FAIL_MARKER" ]; then
|
|
2466
|
+
: > "$FM_TEST_FAIL_MARKER"
|
|
2467
|
+
exit 7
|
|
2468
|
+
fi
|
|
2469
|
+
exec "$FM_TEST_REAL_BASH" "$@"
|
|
2470
|
+
SH
|
|
2471
|
+
chmod +x "$fakebin/bash"
|
|
2472
|
+
PATH="$fakebin:$PATH" PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
|
|
2473
|
+
FM_TEST_REAL_BASH="$real_bash" FM_TEST_LEASE_SCRIPT="$ROOT/bin/fm-lease.sh" \
|
|
2474
|
+
FM_TEST_FAIL_MARKER="$home/state/release-failed-once" DRIVER_PRELUDE="$DRIVER_PRELUDE" \
|
|
2475
|
+
node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
|
|
2476
|
+
const prelude = process.env.DRIVER_PRELUDE;
|
|
2477
|
+
await eval(`(async () => { ${prelude}; globalThis.__t = { fire, dispatch, settle, home, realRoot }; })()`);
|
|
2478
|
+
const { fire, dispatch, settle, home } = globalThis.__t;
|
|
2479
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
2480
|
+
|
|
2481
|
+
writeFileSync(`${home}/state/.lease-task-old`, `branch\t${process.pid}\t123\n`);
|
|
2482
|
+
|
|
2483
|
+
fire("session_start", {});
|
|
2484
|
+
if (!existsSync(`${home}/state/.lease-task-old`)) throw new Error("failed activation incorrectly committed lease cleanup");
|
|
2485
|
+
const offer = dispatch("signal: retry activation");
|
|
2486
|
+
if (!offer.accepted) throw new Error("later boundary did not retry failed activation");
|
|
2487
|
+
if (existsSync(`${home}/state/.lease-task-old`)) throw new Error("replacement activation did not clean the prior branch lease");
|
|
2488
|
+
await settle(() => (globalThis.__fmPrompts ?? []).length === 1, "post-retry wake prompt");
|
|
2489
|
+
process.exit(0);
|
|
2490
|
+
EOF
|
|
2491
|
+
status=$?
|
|
2492
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
2493
|
+
expect_code 0 "$status" "replacement activation must clean leases and retry failures: $out"
|
|
2494
|
+
pass "replacement activation cleans old branch leases and retries failed cleanup"
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
test_cold_start_activates_after_lock_acquisition() {
|
|
2498
|
+
local repo home out status
|
|
2499
|
+
repo="$TMP_ROOT/coldstart-root"
|
|
2500
|
+
home="$TMP_ROOT/coldstart-home"
|
|
2501
|
+
mkdir -p "$home/state" "$home/config"
|
|
2502
|
+
install_pi_branch_extension_fixture "$repo"
|
|
2503
|
+
PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
|
|
2504
|
+
FM_TEST_SKIP_LOCK=1 DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
|
|
2505
|
+
const prelude = process.env.DRIVER_PRELUDE;
|
|
2506
|
+
await eval(`(async () => { ${prelude}; globalThis.__t = { dispatch, settle, home }; })()`);
|
|
2507
|
+
const { dispatch, settle, home } = globalThis.__t;
|
|
2508
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
2509
|
+
|
|
2510
|
+
// An ordinary cold Pi start: session_start fires BEFORE the session acquires
|
|
2511
|
+
// the fleet lock (fm-sessionstart-run.sh acquires it later). Ownership must
|
|
2512
|
+
// be evaluated lazily per action, never latched at session_start.
|
|
2513
|
+
if (dispatch("signal: before lock").accepted) throw new Error("branch accepted a wake before the lock existed");
|
|
2514
|
+
if (existsSync(`${home}/state/.pi-branch-extension-loaded`)) {
|
|
2515
|
+
throw new Error("branch wrote its marker before owning the lock");
|
|
2516
|
+
}
|
|
2517
|
+
writeFileSync(`${home}/state/.lock`, `${process.pid}\n`);
|
|
2518
|
+
if (!dispatch("signal: after lock").accepted) throw new Error("branch refused a wake after the lock was acquired");
|
|
2519
|
+
await settle(() => (globalThis.__fmPrompts ?? []).length === 1, "post-lock branch wake prompt");
|
|
2520
|
+
if (!existsSync(`${home}/state/.pi-branch-extension-loaded`)) {
|
|
2521
|
+
throw new Error("owned activation did not write the diagnostic marker");
|
|
2522
|
+
}
|
|
2523
|
+
process.exit(0);
|
|
2524
|
+
EOF
|
|
2525
|
+
status=$?
|
|
2526
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
2527
|
+
expect_code 0 "$status" "cold-start lazy lock-ownership activation must hold: $out"
|
|
2528
|
+
pass "branch activates on a cold start once the lock is acquired, never before"
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
test_queued_actions_recheck_lock_ownership() {
|
|
2532
|
+
local repo home out status
|
|
2533
|
+
repo="$TMP_ROOT/queued-ownership-root"
|
|
2534
|
+
home="$TMP_ROOT/queued-ownership-home"
|
|
2535
|
+
mkdir -p "$home/state" "$home/config"
|
|
2536
|
+
install_pi_branch_extension_fixture "$repo"
|
|
2537
|
+
PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
|
|
2538
|
+
DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
|
|
2539
|
+
const prelude = process.env.DRIVER_PRELUDE;
|
|
2540
|
+
await eval(`(async () => { ${prelude}; globalThis.__t = { fire, dispatch, settle, home, mainUserMessages }; })()`);
|
|
2541
|
+
const { fire, dispatch, settle, home, mainUserMessages } = globalThis.__t;
|
|
2542
|
+
import { existsSync, unlinkSync } from "node:fs";
|
|
2543
|
+
|
|
2544
|
+
let releasePrompt;
|
|
2545
|
+
globalThis.__fmPromptGate = new Promise((resolve) => { releasePrompt = resolve; });
|
|
2546
|
+
if (!dispatch("signal: active wake").accepted) throw new Error("first wake was not accepted");
|
|
2547
|
+
await settle(() => globalThis.__fmPromptStarted === true, "blocked first prompt");
|
|
2548
|
+
if (!dispatch("signal: queued wake").accepted) throw new Error("queued wake was not accepted");
|
|
2549
|
+
const entries = [{ type: "message", message: { role: "user", content: "queued mirror must stay undelivered" } }];
|
|
2550
|
+
fire("turn_end", {}, {
|
|
2551
|
+
sessionManager: { getSessionFile: () => `${home}/main.jsonl`, getEntries: () => entries },
|
|
2552
|
+
});
|
|
2553
|
+
unlinkSync(`${home}/state/.lock`);
|
|
2554
|
+
releasePrompt();
|
|
2555
|
+
await settle(() => mainUserMessages.length === 1, "lost-ownership fallback");
|
|
2556
|
+
if (!mainUserMessages[0].content.includes("FIRSTMATE WATCHER WAKE: signal: queued wake")) {
|
|
2557
|
+
throw new Error(`queued wake did not fall back to main: ${mainUserMessages[0].content}`);
|
|
2558
|
+
}
|
|
2559
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
2560
|
+
const session = globalThis.__fmSessions[0];
|
|
2561
|
+
if (session.ops.some((op) => op.kind === "custom")) throw new Error("queued mirror appended after lock ownership was lost");
|
|
2562
|
+
if (existsSync(`${home}/state/.branch-mirror-cursor`)) throw new Error("queued mirror advanced its cursor after lock ownership was lost");
|
|
2563
|
+
process.exit(0);
|
|
2564
|
+
EOF
|
|
2565
|
+
status=$?
|
|
2566
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
2567
|
+
expect_code 0 "$status" "queued branch actions must recheck lock ownership: $out"
|
|
2568
|
+
pass "queued wakes and mirrors stop mutating branch state after lock ownership is lost"
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
test_stale_generation_boundaries_are_side_effect_free() {
|
|
2572
|
+
local repo home out status
|
|
2573
|
+
repo="$TMP_ROOT/stale-boundaries-root"
|
|
2574
|
+
home="$TMP_ROOT/stale-boundaries-home"
|
|
2575
|
+
mkdir -p "$home/state" "$home/config"
|
|
2576
|
+
install_pi_branch_extension_fixture "$repo"
|
|
2577
|
+
PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
|
|
2578
|
+
DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
|
|
2579
|
+
const prelude = process.env.DRIVER_PRELUDE;
|
|
2580
|
+
await eval(`(async () => { ${prelude}; globalThis.__t = { fire, dispatch, settle, home, sentToMain }; })()`);
|
|
2581
|
+
const { fire, dispatch, settle, home, sentToMain } = globalThis.__t;
|
|
2582
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2583
|
+
|
|
2584
|
+
if (!dispatch("signal: establish old branch").accepted) throw new Error("old branch wake was not accepted");
|
|
2585
|
+
await settle(() => (globalThis.__fmPrompts ?? []).length === 1, "old branch prompt");
|
|
2586
|
+
const oldSession = globalThis.__fmSessions[0];
|
|
2587
|
+
const oldReport = oldSession.options.customTools.find((tool) => tool.name === "fm_branch_report");
|
|
2588
|
+
const oldBash = oldSession.options.customTools.find((tool) => tool.name === "bash");
|
|
2589
|
+
|
|
2590
|
+
let releaseMirror;
|
|
2591
|
+
globalThis.__fmMirrorGate = new Promise((resolve) => { releaseMirror = resolve; });
|
|
2592
|
+
const oldEntries = [{ type: "message", message: { role: "user", content: "old generation mirror" } }];
|
|
2593
|
+
fire("turn_end", {}, {
|
|
2594
|
+
sessionManager: { getSessionFile: () => `${home}/old-main.jsonl`, getEntries: () => oldEntries },
|
|
2595
|
+
});
|
|
2596
|
+
await settle(() => globalThis.__fmMirrorStarted === true, "blocked old mirror delivery");
|
|
2597
|
+
fire("session_shutdown", {});
|
|
2598
|
+
fire("session_start", {});
|
|
2599
|
+
const newEntries = [{ type: "message", message: { role: "user", content: "new generation mirror" } }];
|
|
2600
|
+
fire("turn_end", {}, {
|
|
2601
|
+
sessionManager: { getSessionFile: () => `${home}/new-main.jsonl`, getEntries: () => newEntries },
|
|
2602
|
+
});
|
|
2603
|
+
|
|
2604
|
+
const reportResult = await oldReport.execute(
|
|
2605
|
+
"stale-report",
|
|
2606
|
+
{ task: "task-stale", verdict: "captain", summary: "must not append or merge" },
|
|
2607
|
+
undefined,
|
|
2608
|
+
undefined,
|
|
2609
|
+
{},
|
|
2610
|
+
);
|
|
2611
|
+
if (!reportResult.isError) throw new Error("stale report tool was not refused");
|
|
2612
|
+
let bashRefused = false;
|
|
2613
|
+
try {
|
|
2614
|
+
oldBash.__options.spawnHook({
|
|
2615
|
+
command: "bin/fm-lease.sh claim task-stale --actor branch",
|
|
2616
|
+
cwd: home,
|
|
2617
|
+
env: {},
|
|
2618
|
+
});
|
|
2619
|
+
} catch {
|
|
2620
|
+
bashRefused = true;
|
|
2621
|
+
}
|
|
2622
|
+
if (!bashRefused) throw new Error("stale bash tool was not refused");
|
|
2623
|
+
if (existsSync(`${home}/state/branch-outcomes.jsonl`)) throw new Error("stale report appended an outcome");
|
|
2624
|
+
if (existsSync(`${home}/state/.lease-task-stale`)) throw new Error("stale bash claimed a lease");
|
|
2625
|
+
if (sentToMain.length !== 0) throw new Error("stale report merged a note into main");
|
|
2626
|
+
|
|
2627
|
+
releaseMirror();
|
|
2628
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
2629
|
+
if (!dispatch("signal: establish replacement branch").accepted) throw new Error("replacement wake was not accepted");
|
|
2630
|
+
await settle(() => (globalThis.__fmPrompts ?? []).length === 2, "replacement branch prompt");
|
|
2631
|
+
await settle(
|
|
2632
|
+
() => (globalThis.__fmMirrors ?? []).some((message) => message.content === "[captain] new generation mirror"),
|
|
2633
|
+
"replacement mirror delivery",
|
|
2634
|
+
);
|
|
2635
|
+
const cursor = JSON.parse(readFileSync(`${home}/state/.branch-mirror-cursor`, "utf8"));
|
|
2636
|
+
if (cursor.file !== `${home}/new-main.jsonl` || cursor.index !== 1) {
|
|
2637
|
+
throw new Error(`stale mirror continuation changed the replacement cursor: ${JSON.stringify(cursor)}`);
|
|
2638
|
+
}
|
|
2639
|
+
process.exit(0);
|
|
2640
|
+
EOF
|
|
2641
|
+
status=$?
|
|
2642
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
2643
|
+
expect_code 0 "$status" "stale branch boundaries must perform no side effects: $out"
|
|
2644
|
+
pass "stale reports, shells, mirrors, cursors, leases, and prompts perform no side effects"
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
test_secondary_session_stays_inert() {
|
|
2648
|
+
local repo home out status foreign_pid
|
|
2649
|
+
repo="$TMP_ROOT/secondary-root"
|
|
2650
|
+
home="$TMP_ROOT/secondary-home"
|
|
2651
|
+
mkdir -p "$home/state" "$home/config"
|
|
2652
|
+
install_pi_branch_extension_fixture "$repo"
|
|
2653
|
+
# The fleet lock is owned by ANOTHER live process that is NOT in the
|
|
2654
|
+
# driver's ancestry (a sibling sleeper), so the driver is a secondary
|
|
2655
|
+
# session: it must accept nothing, write no marker, and release no leases.
|
|
2656
|
+
sleep 60 &
|
|
2657
|
+
foreign_pid=$!
|
|
2658
|
+
printf 'branch\t%s\t123\n' "$foreign_pid" > "$home/state/.lease-task-x"
|
|
2659
|
+
PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
|
|
2660
|
+
FM_TEST_SKIP_LOCK=1 FM_TEST_LOCK_PID=$foreign_pid DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
|
|
2661
|
+
const prelude = process.env.DRIVER_PRELUDE;
|
|
2662
|
+
await eval(`(async () => { ${prelude}; globalThis.__t = { dispatch, home }; })()`);
|
|
2663
|
+
const { dispatch, home } = globalThis.__t;
|
|
2664
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
2665
|
+
writeFileSync(`${home}/state/.lock`, `${process.env.FM_TEST_LOCK_PID}\n`);
|
|
2666
|
+
if (dispatch("signal: secondary probe").accepted) throw new Error("secondary session accepted a wake it does not own");
|
|
2667
|
+
if (existsSync(`${home}/state/.pi-branch-extension-loaded`)) {
|
|
2668
|
+
throw new Error("secondary session wrote the primary's marker");
|
|
2669
|
+
}
|
|
2670
|
+
if (!existsSync(`${home}/state/.lease-task-x`)) {
|
|
2671
|
+
throw new Error("secondary session released the primary's branch lease");
|
|
2672
|
+
}
|
|
2673
|
+
process.exit(0);
|
|
2674
|
+
EOF
|
|
2675
|
+
status=$?
|
|
2676
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
2677
|
+
kill "$foreign_pid" 2>/dev/null || true
|
|
2678
|
+
expect_code 0 "$status" "a secondary session must stay inert: $out"
|
|
2679
|
+
pass "a Pi session that does not own the lock accepts nothing and mutates no branch state"
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
test_rebind_remirrors_undelivered_dialog_from_durable_cursor() {
|
|
2683
|
+
local repo home out status
|
|
2684
|
+
repo="$TMP_ROOT/rebind-root"
|
|
2685
|
+
home="$TMP_ROOT/rebind-home"
|
|
2686
|
+
mkdir -p "$home/state" "$home/config"
|
|
2687
|
+
install_pi_branch_extension_fixture "$repo"
|
|
2688
|
+
PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \
|
|
2689
|
+
DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
|
|
2690
|
+
const prelude = process.env.DRIVER_PRELUDE;
|
|
2691
|
+
await eval(`(async () => { ${prelude}; globalThis.__t = { fire, home }; })()`);
|
|
2692
|
+
const { fire, home } = globalThis.__t;
|
|
2693
|
+
import { writeFileSync } from "node:fs";
|
|
2694
|
+
import { pathToFileURL } from "node:url";
|
|
2695
|
+
|
|
2696
|
+
// Instance A collects dialog at turn_end while no branch exists yet (nothing
|
|
2697
|
+
// delivered, durable cursor unmoved), then the extension instance is replaced
|
|
2698
|
+
// (/new, /resume, reload). The replacement must reconstruct exclusively from
|
|
2699
|
+
// the durable cursor and re-mirror the undelivered dialog - never drop it.
|
|
2700
|
+
const entries = [
|
|
2701
|
+
{ type: "message", message: { role: "user", content: "standing order: never merge task-7" } },
|
|
2702
|
+
];
|
|
2703
|
+
const ctx = {
|
|
2704
|
+
sessionManager: { getSessionFile: () => `${home}/main-1.jsonl`, getEntries: () => entries },
|
|
2705
|
+
};
|
|
2706
|
+
fire("turn_end", {}, ctx);
|
|
2707
|
+
fire("session_shutdown", {});
|
|
2708
|
+
|
|
2709
|
+
// Replacement instance: fresh import simulates Pi rebinding the extension.
|
|
2710
|
+
const replacementHandlers = new Map();
|
|
2711
|
+
const replacementBus = {
|
|
2712
|
+
on(channel, handler) {
|
|
2713
|
+
replacementHandlers.set(channel, [...(replacementHandlers.get(channel) ?? []), handler]);
|
|
2714
|
+
return () => {};
|
|
2715
|
+
},
|
|
2716
|
+
emit(channel, data) {
|
|
2717
|
+
for (const handler of replacementHandlers.get(channel) ?? []) handler(data);
|
|
2718
|
+
},
|
|
2719
|
+
};
|
|
2720
|
+
const replacementPiHandlers = new Map();
|
|
2721
|
+
const replacementPi = {
|
|
2722
|
+
events: replacementBus,
|
|
2723
|
+
on(event, handler) {
|
|
2724
|
+
replacementPiHandlers.set(event, [...(replacementPiHandlers.get(event) ?? []), handler]);
|
|
2725
|
+
},
|
|
2726
|
+
registerTool() {},
|
|
2727
|
+
registerCommand() {},
|
|
2728
|
+
registerMessageRenderer() {},
|
|
2729
|
+
sendMessage() {},
|
|
2730
|
+
sendUserMessage() {},
|
|
2731
|
+
};
|
|
2732
|
+
const replacement = await import(`${pathToFileURL(process.env.PLUGIN).href}?rebind=1`);
|
|
2733
|
+
replacement.default(replacementPi);
|
|
2734
|
+
for (const handler of replacementPiHandlers.get("session_start") ?? []) handler({}, ctx);
|
|
2735
|
+
for (const handler of replacementPiHandlers.get("turn_end") ?? []) handler({}, ctx);
|
|
2736
|
+
writeFileSync(`${home}/state/.wake-queue`, "1\t1\tsignal\tbranch-driver.status\tsignal: after rebind\n");
|
|
2737
|
+
const offer = {
|
|
2738
|
+
message: "signal: after rebind",
|
|
2739
|
+
projects: [`${home}/projects/approved`],
|
|
2740
|
+
heartbeat: false,
|
|
2741
|
+
eligible: true,
|
|
2742
|
+
accepted: false,
|
|
2743
|
+
accept() {
|
|
2744
|
+
offer.accepted = true;
|
|
2745
|
+
},
|
|
2746
|
+
};
|
|
2747
|
+
replacementBus.emit("fm-branch-supervision:dispatch", offer);
|
|
2748
|
+
if (!offer.accepted) throw new Error("replacement instance refused the wake");
|
|
2749
|
+
for (let i = 0; i < 250; i += 1) {
|
|
2750
|
+
const mirrors = (globalThis.__fmMirrors ?? []).map((m) => m.content);
|
|
2751
|
+
if (mirrors.includes("[captain] standing order: never merge task-7")) break;
|
|
2752
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
2753
|
+
}
|
|
2754
|
+
const mirrors = (globalThis.__fmMirrors ?? []).map((m) => m.content);
|
|
2755
|
+
if (!mirrors.includes("[captain] standing order: never merge task-7")) {
|
|
2756
|
+
throw new Error(`replacement dropped undelivered dialog: ${JSON.stringify(mirrors)}`);
|
|
2757
|
+
}
|
|
2758
|
+
process.exit(0);
|
|
2759
|
+
EOF
|
|
2760
|
+
status=$?
|
|
2761
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
2762
|
+
expect_code 0 "$status" "rebind must re-mirror undelivered dialog from the durable cursor: $out"
|
|
2763
|
+
pass "an extension rebind re-mirrors undelivered dialog instead of dropping it"
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
# Direct unit coverage of fm-branch-dispatch.ts's classification, independent
|
|
2767
|
+
# of the Pi SDK stub: every legitimately main-only class (docs/pi-supervision-
|
|
2768
|
+
# branch.md) stays excluded from eligibleSeqs no matter its check-kind key,
|
|
2769
|
+
# a mixed queue keeps its task-local rows eligible without those main-only
|
|
2770
|
+
# rows vetoing the scan, and the eligible-row snapshot writer names exactly
|
|
2771
|
+
# the eligible set.
|
|
2772
|
+
test_branch_dispatch_classifies_main_only_rows_and_writes_the_eligible_snapshot() {
|
|
2773
|
+
local repo home out status
|
|
2774
|
+
repo="$TMP_ROOT/dispatch-classify-root"
|
|
2775
|
+
home="$TMP_ROOT/dispatch-classify-home"
|
|
2776
|
+
mkdir -p "$repo/.pi/extensions/lib" "$home/state" "$home/projects/approved"
|
|
2777
|
+
cp "$ROOT/.pi/extensions/lib/fm-branch-dispatch.ts" "$repo/.pi/extensions/lib/fm-branch-dispatch.ts"
|
|
2778
|
+
cp "$ROOT/.pi/extensions/lib/fm-branch-model-picker.ts" "$repo/.pi/extensions/lib/fm-branch-model-picker.ts"
|
|
2779
|
+
printf 'project=%s/projects/approved\nwindow=fm-window\n' "$home" > "$home/state/task-a.meta"
|
|
2780
|
+
LIB="$repo/.pi/extensions/lib/fm-branch-dispatch.ts" FM_HOME="$home" GRANT="$ROOT/bin/fm-wake-grant.sh" \
|
|
2781
|
+
node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF'
|
|
2782
|
+
import { pathToFileURL } from "node:url";
|
|
2783
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2784
|
+
|
|
2785
|
+
const { activateEligibleRowsOwner, scopeForUnreadWake, writeEligibleRowsSnapshot, releaseEligibleRowsSnapshot, BRANCH_ELIGIBLE_ROWS_FILE } =
|
|
2786
|
+
await import(pathToFileURL(process.env.LIB).href);
|
|
2787
|
+
const state = `${process.env.FM_HOME}/state`;
|
|
2788
|
+
const project = `${process.env.FM_HOME}/projects/approved`;
|
|
2789
|
+
|
|
2790
|
+
// Every legitimately main-only class is a check-kind row under a different
|
|
2791
|
+
// key; classification never looks at the key, only the kind, so one
|
|
2792
|
+
// representative per named class is sufficient coverage.
|
|
2793
|
+
const mainOnlyRows = [
|
|
2794
|
+
"1\t1\tcheck\tx-inbox\tcheck: pending x mention",
|
|
2795
|
+
"1\t1\tcheck\tsome-poll.check.sh\tcheck: some-poll.check.sh: merged",
|
|
2796
|
+
"1\t1\tcheck\tunauthenticated-state-checks\tcheck: rejected unauthenticated state checks",
|
|
2797
|
+
];
|
|
2798
|
+
for (const row of mainOnlyRows) {
|
|
2799
|
+
writeFileSync(`${state}/.wake-queue`, row);
|
|
2800
|
+
const scope = scopeForUnreadWake(state, false);
|
|
2801
|
+
if (scope.eligible || scope.eligibleSeqs.length !== 0) {
|
|
2802
|
+
throw new Error(`a main-only class was offered to the branch: ${row} -> ${JSON.stringify(scope)}`);
|
|
2803
|
+
}
|
|
2804
|
+
if (scope.corrupted) throw new Error(`an ordinary main-only row must not read as corrupted: ${row}`);
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
writeFileSync(
|
|
2808
|
+
`${state}/.wake-queue`,
|
|
2809
|
+
[
|
|
2810
|
+
"1\t1\tcheck\tx-inbox",
|
|
2811
|
+
"1\t2\tsignal\ttask-a.status\tsignal: task-a.status",
|
|
2812
|
+
].join("\n"),
|
|
2813
|
+
);
|
|
2814
|
+
const truncated = scopeForUnreadWake(state, false);
|
|
2815
|
+
if (!truncated.corrupted || truncated.eligible || truncated.eligibleSeqs.length !== 0) {
|
|
2816
|
+
throw new Error(`a four-field queue row was not classified as corruption: ${JSON.stringify(truncated)}`);
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
// A mixed queue: the main-only row (seq 1) never vetoes the task-local rows
|
|
2820
|
+
// (seq 2, 3) - the reproduction from the task.
|
|
2821
|
+
writeFileSync(
|
|
2822
|
+
`${state}/.wake-queue`,
|
|
2823
|
+
[
|
|
2824
|
+
"1\t1\tcheck\tx-inbox\tcheck: pending x mention",
|
|
2825
|
+
"1\t2\tsignal\ttask-a.status\tsignal: task-a.status",
|
|
2826
|
+
"1\t3\tstale\tfm-window\tstale: fm-window",
|
|
2827
|
+
].join("\n"),
|
|
2828
|
+
);
|
|
2829
|
+
const mixed = scopeForUnreadWake(state, false);
|
|
2830
|
+
if (!mixed.eligible) throw new Error(`mixed queue with eligible task-local rows must stay eligible: ${JSON.stringify(mixed)}`);
|
|
2831
|
+
if (mixed.eligibleSeqs.slice().sort().join(",") !== "2,3") {
|
|
2832
|
+
throw new Error(`eligibleSeqs must name exactly the task-local rows: ${JSON.stringify(mixed)}`);
|
|
2833
|
+
}
|
|
2834
|
+
if (!mixed.projects.includes(project)) {
|
|
2835
|
+
throw new Error(`eligible project context lost: ${JSON.stringify(mixed.projects)}`);
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
if (!activateEligibleRowsOwner(state, process.env.GRANT, process.pid, "fixture")) {
|
|
2839
|
+
throw new Error("branch owner activation failed");
|
|
2840
|
+
}
|
|
2841
|
+
if (writeEligibleRowsSnapshot(state, mixed.eligibleSeqs, process.env.GRANT, "fixture") !== "published") {
|
|
2842
|
+
throw new Error("snapshot write reported failure");
|
|
2843
|
+
}
|
|
2844
|
+
const snapshot = readFileSync(`${state}/${BRANCH_ELIGIBLE_ROWS_FILE}`, "utf8").trim().split("\n");
|
|
2845
|
+
if (snapshot.join(",") !== "2,3") throw new Error(`snapshot did not name exactly the eligible rows: ${snapshot}`);
|
|
2846
|
+
|
|
2847
|
+
// An empty eligible set is refused rather than clearing the snapshot to
|
|
2848
|
+
// nothing - a caller must never overwrite a live snapshot with an empty one.
|
|
2849
|
+
if (writeEligibleRowsSnapshot(state, [], process.env.GRANT, "fixture") !== "error") {
|
|
2850
|
+
throw new Error("an empty eligible set must not be written");
|
|
2851
|
+
}
|
|
2852
|
+
if (!releaseEligibleRowsSnapshot(state, process.env.GRANT, "fixture")) throw new Error("snapshot release failed");
|
|
2853
|
+
writeFileSync(`${state}/.main-eligible-rows`, "2\n");
|
|
2854
|
+
if (writeEligibleRowsSnapshot(state, ["2"], process.env.GRANT, "fixture") !== "main-owned") {
|
|
2855
|
+
throw new Error("a row already claimed by main was not reported as main-owned");
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
// A heartbeat is not vetoed or ridden into main by a co-present check row.
|
|
2859
|
+
// The check row is permanently main-owned in every mode, so it is excluded
|
|
2860
|
+
// from the claim rather than deferring a fleet review that has nothing to do
|
|
2861
|
+
// with it - the captain's reproduction.
|
|
2862
|
+
writeFileSync(
|
|
2863
|
+
`${state}/.wake-queue`,
|
|
2864
|
+
[
|
|
2865
|
+
"1\t1\tcheck\tx-inbox\tcheck: pending x mention",
|
|
2866
|
+
"1\t2\theartbeat\theartbeat\theartbeat",
|
|
2867
|
+
"1\t3\tsignal\ttask-a.status\tsignal: task-a.status",
|
|
2868
|
+
].join("\n"),
|
|
2869
|
+
);
|
|
2870
|
+
const heartbeatMixed = scopeForUnreadWake(state, true);
|
|
2871
|
+
if (!heartbeatMixed.eligible || heartbeatMixed.corrupted) {
|
|
2872
|
+
throw new Error(`a co-present check row rode a heartbeat into main: ${JSON.stringify(heartbeatMixed)}`);
|
|
2873
|
+
}
|
|
2874
|
+
if (heartbeatMixed.eligibleSeqs.slice().sort().join(",") !== "2,3") {
|
|
2875
|
+
throw new Error(`a heartbeat claim must cover every branch-ownable row and no check row: ${JSON.stringify(heartbeatMixed)}`);
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
// All-or-nothing is unchanged in what it actually guarantees: a heartbeat
|
|
2879
|
+
// review takes every branch-ownable row or none of them, so a row this scan
|
|
2880
|
+
// cannot resolve still defers the whole review to main.
|
|
2881
|
+
writeFileSync(
|
|
2882
|
+
`${state}/.wake-queue`,
|
|
2883
|
+
[
|
|
2884
|
+
"1\t1\theartbeat\theartbeat\theartbeat",
|
|
2885
|
+
"1\t2\tsignal\tno-such-task.status\tsignal: no-such-task.status",
|
|
2886
|
+
].join("\n"),
|
|
2887
|
+
);
|
|
2888
|
+
const heartbeatUnresolvable = scopeForUnreadWake(state, true);
|
|
2889
|
+
if (heartbeatUnresolvable.eligible || !heartbeatUnresolvable.corrupted) {
|
|
2890
|
+
throw new Error(`an unresolvable row must still defer a heartbeat review: ${JSON.stringify(heartbeatUnresolvable)}`);
|
|
2891
|
+
}
|
|
2892
|
+
writeFileSync(
|
|
2893
|
+
`${state}/.wake-queue`,
|
|
2894
|
+
[
|
|
2895
|
+
"1\t1\theartbeat\theartbeat\theartbeat",
|
|
2896
|
+
"1\t2\tinvented\tsomething\tinvented: not a kind fm_wake_append emits",
|
|
2897
|
+
].join("\n"),
|
|
2898
|
+
);
|
|
2899
|
+
const heartbeatUnknownKind = scopeForUnreadWake(state, true);
|
|
2900
|
+
if (heartbeatUnknownKind.eligible || !heartbeatUnknownKind.corrupted) {
|
|
2901
|
+
throw new Error(`an unknown row kind must still defer a heartbeat review: ${JSON.stringify(heartbeatUnknownKind)}`);
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
// A queue holding nothing but main-only rows leaves a heartbeat with nothing
|
|
2905
|
+
// to hand over, so it is not offered rather than granted an empty claim.
|
|
2906
|
+
writeFileSync(`${state}/.wake-queue`, "1\t1\tcheck\tx-inbox\tcheck: pending x mention");
|
|
2907
|
+
const heartbeatNothingOwnable = scopeForUnreadWake(state, true);
|
|
2908
|
+
if (heartbeatNothingOwnable.eligible || heartbeatNothingOwnable.eligibleSeqs.length !== 0) {
|
|
2909
|
+
throw new Error(`a heartbeat was offered with no branch-ownable row: ${JSON.stringify(heartbeatNothingOwnable)}`);
|
|
2910
|
+
}
|
|
2911
|
+
if (heartbeatNothingOwnable.corrupted) {
|
|
2912
|
+
throw new Error(`a purely main-only queue is ordinary absence, not a fault: ${JSON.stringify(heartbeatNothingOwnable)}`);
|
|
2913
|
+
}
|
|
2914
|
+
process.exit(0);
|
|
2915
|
+
EOF
|
|
2916
|
+
status=$?
|
|
2917
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
2918
|
+
expect_code 0 "$status" "main-only classification and eligible-row snapshot contract must hold: $out"
|
|
2919
|
+
pass "scopeForUnreadWake excludes every main-only class without vetoing eligible task-local rows, and writes the eligible snapshot"
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
# The model picker's bounded scrolling and its search ranking are Pi's own
|
|
2923
|
+
# SelectList and fuzzyFilter, so the guarantee only holds while the installed
|
|
2924
|
+
# Pi still exports them and still bounds what it renders. Stubs cannot answer
|
|
2925
|
+
# that, so this runs against the real package and skips when it is absent.
|
|
2926
|
+
test_real_pi_picker_primitives_stay_bounded_and_searchable() {
|
|
2927
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
2928
|
+
echo "skip: node not found for the Pi picker primitives test"
|
|
2929
|
+
return
|
|
2930
|
+
fi
|
|
2931
|
+
local package_dir fixture original_dir out status
|
|
2932
|
+
package_dir=${FM_PI_PACKAGE_DIR:-"$(npm root -g 2>/dev/null)/@earendil-works/pi-coding-agent"}
|
|
2933
|
+
if [ ! -f "$package_dir/package.json" ]; then
|
|
2934
|
+
echo "skip: installed @earendil-works/pi-coding-agent package not found"
|
|
2935
|
+
return
|
|
2936
|
+
fi
|
|
2937
|
+
fixture="$TMP_ROOT/real-picker-primitives"
|
|
2938
|
+
mkdir -p "$fixture/lib" "$fixture/node_modules/@earendil-works"
|
|
2939
|
+
cp "$ROOT/.pi/extensions/lib/fm-branch-model-picker.ts" "$fixture/lib/fm-branch-model-picker.ts"
|
|
2940
|
+
ln -s "$package_dir" "$fixture/node_modules/@earendil-works/pi-coding-agent"
|
|
2941
|
+
ln -s "$package_dir/node_modules/@earendil-works/pi-tui" "$fixture/node_modules/@earendil-works/pi-tui"
|
|
2942
|
+
original_dir=$PWD
|
|
2943
|
+
cd "$fixture" || fail "could not enter the Pi picker primitives fixture"
|
|
2944
|
+
LIB="$fixture/lib/fm-branch-model-picker.ts" PI_VERSION_FILE="$package_dir/package.json" \
|
|
2945
|
+
node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'JS'
|
|
2946
|
+
import { pathToFileURL } from "node:url";
|
|
2947
|
+
import { readFileSync } from "node:fs";
|
|
2948
|
+
|
|
2949
|
+
const version = JSON.parse(readFileSync(process.env.PI_VERSION_FILE, "utf8")).version;
|
|
2950
|
+
const { Input, SelectList, fuzzyFilter } = await import("@earendil-works/pi-tui");
|
|
2951
|
+
const { DynamicBorder } = await import("@earendil-works/pi-coding-agent");
|
|
2952
|
+
for (const [name, value] of [
|
|
2953
|
+
["Input", Input],
|
|
2954
|
+
["SelectList", SelectList],
|
|
2955
|
+
["fuzzyFilter", fuzzyFilter],
|
|
2956
|
+
["DynamicBorder", DynamicBorder],
|
|
2957
|
+
]) {
|
|
2958
|
+
if (typeof value !== "function") {
|
|
2959
|
+
throw new Error(`installed pi ${version} no longer exports ${name}, which the supervision model picker renders through`);
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
const { buildBranchModelItems, filterBranchPickerItems, BRANCH_PICKER_MAX_VISIBLE } = await import(
|
|
2964
|
+
pathToFileURL(process.env.LIB).href
|
|
2965
|
+
);
|
|
2966
|
+
const labels = Array.from({ length: 40 }, (_, i) => `anthropic/bulk-model-${i + 1}`);
|
|
2967
|
+
labels.push("openai-codex/cheap-oauth");
|
|
2968
|
+
const items = buildBranchModelItems("Follow main (anthropic/main-model)", labels, null);
|
|
2969
|
+
|
|
2970
|
+
// Pi's own list renders a bounded window plus at most one scroll indicator,
|
|
2971
|
+
// which is what keeps a long catalog inside the dialog.
|
|
2972
|
+
const passthrough = (text) => text;
|
|
2973
|
+
const list = new SelectList(items, BRANCH_PICKER_MAX_VISIBLE, {
|
|
2974
|
+
selectedPrefix: passthrough,
|
|
2975
|
+
selectedText: passthrough,
|
|
2976
|
+
description: passthrough,
|
|
2977
|
+
scrollInfo: passthrough,
|
|
2978
|
+
noMatch: passthrough,
|
|
2979
|
+
});
|
|
2980
|
+
const lines = list.render(80);
|
|
2981
|
+
if (lines.length > BRANCH_PICKER_MAX_VISIBLE + 1) {
|
|
2982
|
+
throw new Error(`installed pi ${version} rendered ${lines.length} rows for ${items.length} models instead of a bounded window`);
|
|
2983
|
+
}
|
|
2984
|
+
if (!lines[0].includes("Follow main")) {
|
|
2985
|
+
throw new Error(`installed pi ${version} did not render the first row the picker opens on`);
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
// Pi's own fuzzy ranking drives the search box, and following main stays first.
|
|
2989
|
+
const searched = filterBranchPickerItems(items, "cheap", fuzzyFilter);
|
|
2990
|
+
if (searched.length !== 1 || searched[0].value !== "openai-codex/cheap-oauth") {
|
|
2991
|
+
throw new Error(`installed pi ${version} fuzzy search did not narrow the catalog: ${JSON.stringify(searched)}`);
|
|
2992
|
+
}
|
|
2993
|
+
const mainSearch = filterBranchPickerItems(items, "main", fuzzyFilter);
|
|
2994
|
+
if (mainSearch.length === 0 || mainSearch[0].label !== "Follow main (anthropic/main-model)") {
|
|
2995
|
+
throw new Error(`installed pi ${version} fuzzy ranking moved following main out of first place`);
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
// The search box is Pi's own single-line input.
|
|
2999
|
+
const input = new Input();
|
|
3000
|
+
input.handleInput("c");
|
|
3001
|
+
input.handleInput("h");
|
|
3002
|
+
if (input.getValue() !== "ch") {
|
|
3003
|
+
throw new Error(`installed pi ${version} Input no longer accumulates typed characters for the picker's search box`);
|
|
3004
|
+
}
|
|
3005
|
+
JS
|
|
3006
|
+
status=$?
|
|
3007
|
+
cd "$original_dir" || fail "could not leave the Pi picker primitives fixture"
|
|
3008
|
+
out=$(cat "$TMP_ROOT/node-output")
|
|
3009
|
+
expect_code 0 "$status" "the installed Pi must still provide the picker's bounded searchable primitives: $out"
|
|
3010
|
+
[ -z "$out" ] || fail "Pi picker primitives test printed output: $out"
|
|
3011
|
+
pass "the installed Pi still bounds the picker's list and ranks its search"
|
|
3012
|
+
}
|
|
3013
|
+
|
|
3014
|
+
test_outcomes_tool_uses_stock_execution_and_export_consumers() {
|
|
3015
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
3016
|
+
echo "skip: node not found for Pi outcomes rendering test"
|
|
3017
|
+
return
|
|
3018
|
+
fi
|
|
3019
|
+
local package_dir fixture out status
|
|
3020
|
+
package_dir=${FM_PI_PACKAGE_DIR:-"$(npm root -g 2>/dev/null)/@earendil-works/pi-coding-agent"}
|
|
3021
|
+
if [ ! -f "$package_dir/package.json" ]; then
|
|
3022
|
+
echo "skip: installed @earendil-works/pi-coding-agent package not found"
|
|
3023
|
+
return
|
|
3024
|
+
fi
|
|
3025
|
+
fixture="$TMP_ROOT/stock-render-consumers"
|
|
3026
|
+
mkdir -p "$fixture/.pi/extensions/lib" "$fixture/node_modules/@earendil-works"
|
|
3027
|
+
cp "$EXT" "$fixture/.pi/extensions/fm-branch-supervision.ts"
|
|
3028
|
+
cp "$ROOT/.pi/extensions/lib/fm-branch-dispatch.ts" "$fixture/.pi/extensions/lib/fm-branch-dispatch.ts"
|
|
3029
|
+
cp "$ROOT/.pi/extensions/lib/fm-branch-model-picker.ts" "$fixture/.pi/extensions/lib/fm-branch-model-picker.ts"
|
|
3030
|
+
cp "$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" "$fixture/.pi/extensions/lib/fm-calm-visibility.ts"
|
|
3031
|
+
cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$fixture/.pi/extensions/lib/fm-operational-input.ts"
|
|
3032
|
+
ln -s "$package_dir" "$fixture/node_modules/@earendil-works/pi-coding-agent"
|
|
3033
|
+
ln -s "$package_dir/node_modules/@earendil-works/pi-tui" "$fixture/node_modules/@earendil-works/pi-tui"
|
|
3034
|
+
ln -s "$package_dir/node_modules/@earendil-works/pi-ai" "$fixture/node_modules/@earendil-works/pi-ai"
|
|
3035
|
+
ln -s "$package_dir/node_modules/typebox" "$fixture/node_modules/typebox"
|
|
3036
|
+
|
|
3037
|
+
out=$(cd "$fixture" && EXT="$fixture/.pi/extensions/fm-branch-supervision.ts" PI_PACKAGE_DIR="$package_dir" node --input-type=module 2>&1 <<'JS'
|
|
3038
|
+
import { pathToFileURL } from "node:url";
|
|
3039
|
+
|
|
3040
|
+
const packageRoot = process.env.PI_PACKAGE_DIR;
|
|
3041
|
+
const [{ ToolExecutionComponent }, { createToolHtmlRenderer }, { initTheme, theme }] = await Promise.all([
|
|
3042
|
+
import(pathToFileURL(`${packageRoot}/dist/modes/interactive/components/tool-execution.js`).href),
|
|
3043
|
+
import(pathToFileURL(`${packageRoot}/dist/core/export-html/tool-renderer.js`).href),
|
|
3044
|
+
import(pathToFileURL(`${packageRoot}/dist/modes/interactive/theme/theme.js`).href),
|
|
3045
|
+
]);
|
|
3046
|
+
initTheme("dark");
|
|
3047
|
+
|
|
3048
|
+
const listeners = new Map();
|
|
3049
|
+
const tools = [];
|
|
3050
|
+
const pi = {
|
|
3051
|
+
events: {
|
|
3052
|
+
on(name, listener) {
|
|
3053
|
+
listeners.set(name, [...(listeners.get(name) ?? []), listener]);
|
|
3054
|
+
},
|
|
3055
|
+
emit(name, data) {
|
|
3056
|
+
for (const listener of listeners.get(name) ?? []) listener(data);
|
|
3057
|
+
},
|
|
3058
|
+
},
|
|
3059
|
+
on() {},
|
|
3060
|
+
registerCommand() {},
|
|
3061
|
+
registerMessageRenderer() {},
|
|
3062
|
+
registerTool(tool) { tools.push(tool); },
|
|
3063
|
+
sendMessage() {},
|
|
3064
|
+
sendUserMessage() {},
|
|
3065
|
+
};
|
|
3066
|
+
const extension = await import(`${pathToFileURL(process.env.EXT).href}?consumer=${Date.now()}`);
|
|
3067
|
+
extension.default(pi);
|
|
3068
|
+
const actualDefinition = tools.find((tool) => tool.name === "fm_branch_outcomes");
|
|
3069
|
+
if (!actualDefinition) throw new Error("fm_branch_outcomes was not registered");
|
|
3070
|
+
const stockDefinition = { ...actualDefinition };
|
|
3071
|
+
delete stockDefinition.renderShell;
|
|
3072
|
+
delete stockDefinition.renderCall;
|
|
3073
|
+
delete stockDefinition.renderResult;
|
|
3074
|
+
|
|
3075
|
+
const args = { recent: 2 };
|
|
3076
|
+
const result = {
|
|
3077
|
+
content: [{
|
|
3078
|
+
type: "text",
|
|
3079
|
+
text: [
|
|
3080
|
+
"\x1b[31mOUTCOME_ONE\x1b[0m",
|
|
3081
|
+
"OUT\u0000COME_TWO\uFFF9",
|
|
3082
|
+
"OUTCOME_THREE",
|
|
3083
|
+
"OUTCOME_FOUR",
|
|
3084
|
+
"OUTCOME_FIVE",
|
|
3085
|
+
"OUTCOME_SIX",
|
|
3086
|
+
"OUTCOME_SEVEN",
|
|
3087
|
+
"OUTCOME_EIGHT",
|
|
3088
|
+
"OUTCOME_NINE",
|
|
3089
|
+
"OUTCOME_TEN",
|
|
3090
|
+
"OUTCOME_ELEVEN",
|
|
3091
|
+
"OUTCOME_TWELVE",
|
|
3092
|
+
].join("\r\n"),
|
|
3093
|
+
}],
|
|
3094
|
+
details: { ok: true },
|
|
3095
|
+
isError: false,
|
|
3096
|
+
};
|
|
3097
|
+
const ui = { requestRender() {} };
|
|
3098
|
+
const stockRow = new ToolExecutionComponent("fm_branch_outcomes", "stock", args, { showImages: false }, stockDefinition, ui, process.cwd());
|
|
3099
|
+
const actualRow = new ToolExecutionComponent("fm_branch_outcomes", "actual", args, { showImages: false }, actualDefinition, ui, process.cwd());
|
|
3100
|
+
for (const row of [stockRow, actualRow]) {
|
|
3101
|
+
row.markExecutionStarted();
|
|
3102
|
+
row.setArgsComplete();
|
|
3103
|
+
row.updateResult(result);
|
|
3104
|
+
}
|
|
3105
|
+
const collapsedStock = stockRow.render(100);
|
|
3106
|
+
const collapsedActual = actualRow.render(100);
|
|
3107
|
+
if (JSON.stringify(collapsedActual) !== JSON.stringify(collapsedStock)) {
|
|
3108
|
+
throw new Error("Calm-off ToolExecutionComponent rendering differs from Pi stock");
|
|
3109
|
+
}
|
|
3110
|
+
const collapsedText = collapsedStock.join("\n");
|
|
3111
|
+
if (collapsedText.includes("OUTCOME_TWELVE") || !collapsedText.includes("more lines") || !collapsedText.includes("to expand")) {
|
|
3112
|
+
throw new Error("stock rendering fixture did not exercise its collapsed preview and expansion hint");
|
|
3113
|
+
}
|
|
3114
|
+
stockRow.setExpanded(true);
|
|
3115
|
+
actualRow.setExpanded(true);
|
|
3116
|
+
const expandedStock = stockRow.render(100);
|
|
3117
|
+
const expandedActual = actualRow.render(100);
|
|
3118
|
+
if (JSON.stringify(expandedActual) !== JSON.stringify(expandedStock)) {
|
|
3119
|
+
throw new Error("expanded Calm-off ToolExecutionComponent rendering differs from Pi stock");
|
|
3120
|
+
}
|
|
3121
|
+
if (!expandedStock.join("\n").includes("OUTCOME_TWELVE") || JSON.stringify(expandedStock) === JSON.stringify(collapsedStock)) {
|
|
3122
|
+
throw new Error("stock rendering fixture did not exercise expanded output");
|
|
3123
|
+
}
|
|
3124
|
+
pi.events.emit("firstmate:calm-presentation", { active: true, stockExportRendering: false });
|
|
3125
|
+
actualRow.invalidate();
|
|
3126
|
+
if (actualRow.render(100).length !== 0) {
|
|
3127
|
+
throw new Error("Calm-on ToolExecutionComponent row remained visible");
|
|
3128
|
+
}
|
|
3129
|
+
pi.events.emit("firstmate:calm-presentation", { active: false, stockExportRendering: false });
|
|
3130
|
+
actualRow.invalidate();
|
|
3131
|
+
if (JSON.stringify(actualRow.render(100)) !== JSON.stringify(stockRow.render(100))) {
|
|
3132
|
+
throw new Error("ToolExecutionComponent rendering did not restore after live toggle");
|
|
3133
|
+
}
|
|
3134
|
+
|
|
3135
|
+
pi.events.emit("firstmate:calm-presentation", { active: true, stockExportRendering: true });
|
|
3136
|
+
const stockHtml = createToolHtmlRenderer({ getToolDefinition: () => stockDefinition, theme, cwd: process.cwd() });
|
|
3137
|
+
const actualHtml = createToolHtmlRenderer({ getToolDefinition: () => actualDefinition, theme, cwd: process.cwd() });
|
|
3138
|
+
const stockCall = stockHtml.renderCall("stock-html", "fm_branch_outcomes", args);
|
|
3139
|
+
const actualCall = actualHtml.renderCall("actual-html", "fm_branch_outcomes", args);
|
|
3140
|
+
const stockResult = stockHtml.renderResult("stock-html", "fm_branch_outcomes", result.content, result.details, false);
|
|
3141
|
+
const actualResult = actualHtml.renderResult("actual-html", "fm_branch_outcomes", result.content, result.details, false);
|
|
3142
|
+
if (actualCall !== undefined || actualResult !== undefined || stockCall !== undefined || stockResult !== undefined) {
|
|
3143
|
+
throw new Error("stock export rendering did not delegate to Pi's structured fallback");
|
|
3144
|
+
}
|
|
3145
|
+
JS
|
|
3146
|
+
)
|
|
3147
|
+
status=$?
|
|
3148
|
+
expect_code 0 "$status" "Pi outcomes rendering consumers must preserve stock behavior: $out"
|
|
3149
|
+
[ -z "$out" ] || fail "Pi outcomes rendering consumer test printed output: $out"
|
|
3150
|
+
pass "fm_branch_outcomes hides through ToolExecutionComponent while Calm-off and HTML export stay stock"
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
test_outcomes_tool_uses_stock_execution_and_export_consumers
|
|
3154
|
+
test_real_pi_picker_primitives_stay_bounded_and_searchable
|
|
3155
|
+
test_branch_dispatch_two_stage_filter_and_prefix_contract
|
|
3156
|
+
test_requested_healthy_outcome_and_unsolicited_routine_outcome_delivery
|
|
3157
|
+
test_captain_outcome_encoding_failure_delivers_plain_instruction
|
|
3158
|
+
test_branch_dispatch_classifies_main_only_rows_and_writes_the_eligible_snapshot
|
|
3159
|
+
test_branch_cache_key_is_per_home_stable
|
|
3160
|
+
test_branch_default_on_heartbeat_afk_and_fallback
|
|
3161
|
+
test_branch_predrain_recheck_keeps_a_heartbeat_a_co_present_check_arrives_under
|
|
3162
|
+
test_branch_predrain_recheck_excludes_new_main_owned_row_without_deferring_eligible_work
|
|
3163
|
+
test_settled_branch_prompt_releases_unacknowledged_grant
|
|
3164
|
+
test_main_owned_grant_result_falls_back_to_main
|
|
3165
|
+
test_branch_predrain_recheck_noops_already_drained_wake
|
|
3166
|
+
test_branch_mirror_filters_order_and_cursor
|
|
3167
|
+
test_branch_session_persists_across_process_restarts
|
|
3168
|
+
test_branch_model_pin_applies_and_absent_pin_keeps_the_default
|
|
3169
|
+
test_unpinned_branch_follows_main_model_changes_live
|
|
3170
|
+
test_supervision_model_command_persists_and_rebinds_the_live_branch
|
|
3171
|
+
test_supervision_model_picker_is_bounded_searchable_and_branch_only
|
|
3172
|
+
test_branch_model_picker_keeps_follow_main_first_under_ranking
|
|
3173
|
+
test_branch_effort_pin_applies_and_absent_pin_follows_main
|
|
3174
|
+
test_unpinned_branch_follows_main_effort_changes_live
|
|
3175
|
+
test_supervision_model_command_picks_effort_after_the_model
|
|
3176
|
+
test_unusable_model_pin_falls_back_to_main
|
|
3177
|
+
test_replacement_activation_cleans_leases_and_retries_failure
|
|
3178
|
+
test_cold_start_activates_after_lock_acquisition
|
|
3179
|
+
test_queued_actions_recheck_lock_ownership
|
|
3180
|
+
test_stale_generation_boundaries_are_side_effect_free
|
|
3181
|
+
test_secondary_session_stays_inert
|
|
3182
|
+
test_rebind_remirrors_undelivered_dialog_from_durable_cursor
|