session-orchestrator 5.4.0 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/060-evolve.mdc +5 -5
  5. package/.cursor-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +115 -3
  7. package/README.md +30 -15
  8. package/docs/ci-setup.md +120 -23
  9. package/docs/components.md +7 -7
  10. package/docs/events-schema.md +17 -14
  11. package/docs/install.md +14 -2
  12. package/docs/migration-v4.md +24 -17
  13. package/docs/plugin-architecture-v3.md +1 -1
  14. package/docs/scope-collision-guard.md +19 -15
  15. package/docs/session-config-reference.md +7 -7
  16. package/docs/vault-docs-architecture.md +8 -4
  17. package/hooks/_lib/hook-import-set.json +17 -8
  18. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  19. package/hooks/config-protection.mjs +1 -1
  20. package/hooks/enforce-commands.mjs +28 -13
  21. package/hooks/enforce-scope.mjs +201 -23
  22. package/hooks/hooks-codex.json +1 -1
  23. package/hooks/hooks.json +1 -1
  24. package/hooks/on-session-start.mjs +4 -2
  25. package/hooks/on-stop.mjs +77 -171
  26. package/hooks/post-bash-write-verify.mjs +219 -24
  27. package/hooks/post-edit-validate.mjs +15 -5
  28. package/hooks/post-tool-batch-wave-signal.mjs +41 -156
  29. package/hooks/pre-bash-memory-propose-audit.mjs +28 -11
  30. package/hooks/pre-bash-staging-fence.mjs +29 -8
  31. package/hooks/pre-task-scope-disjoint.mjs +603 -241
  32. package/hooks/wave-scope-commit-guard.mjs +235 -73
  33. package/package.json +9 -8
  34. package/scripts/apply-session-learnings.mjs +35 -14
  35. package/scripts/archive-closed-prds.mjs +38 -11
  36. package/scripts/autopilot.mjs +8 -1
  37. package/scripts/backfill-abandoned-sessions.mjs +80 -5
  38. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  39. package/scripts/codex-install.mjs +3 -0
  40. package/scripts/cursor-install.mjs +46 -2
  41. package/scripts/emit-session.mjs +5 -3
  42. package/scripts/export-hw-learnings.mjs +18 -7
  43. package/scripts/fleet-checkin.mjs +26 -3
  44. package/scripts/lib/autopilot/loop.mjs +25 -10
  45. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  46. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  47. package/scripts/lib/common.mjs +41 -0
  48. package/scripts/lib/config/vault-integration.mjs +151 -16
  49. package/scripts/lib/config.mjs +5 -1
  50. package/scripts/lib/eval/engine.mjs +13 -0
  51. package/scripts/lib/events-retention-banner.mjs +393 -0
  52. package/scripts/lib/events-rotation.mjs +21 -6
  53. package/scripts/lib/events.mjs +51 -3
  54. package/scripts/lib/fleet-protocol.mjs +20 -2
  55. package/scripts/lib/hardening.mjs +1 -0
  56. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  57. package/scripts/lib/instruction-budget-guard.mjs +8 -0
  58. package/scripts/lib/learnings/expiry-sweep.mjs +387 -20
  59. package/scripts/lib/learnings/io.mjs +125 -16
  60. package/scripts/lib/lock-reaper.mjs +38 -14
  61. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  62. package/scripts/lib/named-vault-resolver.mjs +48 -5
  63. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  64. package/scripts/lib/orphan-reaper/reaper-audit.mjs +101 -69
  65. package/scripts/lib/orphan-reaper/scan-throttle.mjs +79 -27
  66. package/scripts/lib/orphan-reaper/scan.mjs +4 -0
  67. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  68. package/scripts/lib/orphan-reaper.mjs +5 -1
  69. package/scripts/lib/platform.mjs +206 -2
  70. package/scripts/lib/plugin-update-banner.mjs +3 -1
  71. package/scripts/lib/process-group.mjs +83 -22
  72. package/scripts/lib/scope-baseline.mjs +116 -25
  73. package/scripts/lib/scope-echo.mjs +7 -0
  74. package/scripts/lib/scope-gate.mjs +201 -27
  75. package/scripts/lib/session-close-backfill.mjs +202 -4
  76. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  77. package/scripts/lib/session-lock-shape.mjs +6 -3
  78. package/scripts/lib/session-lock.mjs +320 -67
  79. package/scripts/lib/session-registry.mjs +41 -16
  80. package/scripts/lib/session-start-probes.mjs +242 -68
  81. package/scripts/lib/session-token-rollup.mjs +39 -34
  82. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  83. package/scripts/lib/tail-window.mjs +27 -3
  84. package/scripts/lib/telemetry/pricing.mjs +5 -2
  85. package/scripts/lib/telemetry/sync.mjs +17 -0
  86. package/scripts/lib/tests-src-ratio.mjs +77 -2
  87. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  88. package/scripts/lib/validate/check-test-value-bans.mjs +320 -32
  89. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  90. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  91. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  92. package/scripts/lib/vault-mirror/process.mjs +350 -62
  93. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  94. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  95. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  96. package/scripts/lib/vault-mirror/telemetry.mjs +18 -11
  97. package/scripts/lib/vault-status/board-writer.mjs +5 -1
  98. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  99. package/scripts/materialize-wave-scope.mjs +7 -4
  100. package/scripts/release.mjs +1 -0
  101. package/scripts/run-migrate-v2-cross-repo.mjs +14 -2
  102. package/scripts/run-quality-gate.mjs +8 -5
  103. package/scripts/self-update.mjs +229 -0
  104. package/scripts/sweep-expired-learnings.mjs +560 -28
  105. package/scripts/validate-wave-scope.mjs +60 -6
  106. package/scripts/vault-backfill.mjs +50 -37
  107. package/scripts/vault-mirror.mjs +170 -33
  108. package/skills/_shared/fleet-protocol.md +74 -71
  109. package/skills/_shared/parallel-aware-auq.md +1 -1
  110. package/skills/_shared/state-ownership.md +38 -14
  111. package/skills/autopilot/SKILL.md +1 -1
  112. package/skills/bootstrap/SKILL.md +5 -3
  113. package/skills/bootstrap/_shared-template.md +96 -1
  114. package/skills/bootstrap/fast-template.md +3 -0
  115. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  116. package/skills/bootstrap/standard-template.md +2 -0
  117. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  118. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  119. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  120. package/skills/docs-orchestrator/SKILL.md +12 -5
  121. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  122. package/skills/evolve/SKILL.md +11 -2
  123. package/skills/evolve/references/evolve-analyze-mode.md +37 -17
  124. package/skills/session-end/learning-patterns.md +3 -3
  125. package/skills/session-end/metrics-collection.md +1 -1
  126. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  127. package/skills/session-end/plan-verification.md +25 -9
  128. package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
  129. package/skills/session-end/session-metrics-write.md +45 -10
  130. package/skills/session-start/SKILL.md +3 -3
  131. package/skills/session-start/references/operations-contract.md +5 -3
  132. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  133. package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
  134. package/skills/vault-mirror/SKILL.md +21 -11
  135. package/skills/wave-executor/SKILL.md +1 -1
  136. package/skills/wave-executor/circuit-breaker.md +1 -1
  137. package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
  138. package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
  139. package/skills/wave-executor/references/wave-loop-review.md +2 -2
  140. package/skills/wave-executor/references/wave-loop-scope-manifest.md +14 -6
  141. package/.agents/skills/navigator/SKILL.md +0 -22
  142. package/.agents/skills/navigator/agents/openai.yaml +0 -5
  143. package/.codex-plugin/skills/navigator/SKILL.md +0 -22
  144. package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
  145. package/.cursor/commands/navigator.md +0 -15
  146. package/.cursor/skills/navigator/SKILL.md +0 -13
  147. package/pi/prompts/navigator.md +0 -12
  148. package/skills/navigator/SKILL.md +0 -193
  149. package/skills/navigator/references/account-check.sh +0 -87
  150. package/skills/navigator/references/fleet-decisions.mjs +0 -231
  151. package/skills/navigator/references/slot-check.sh +0 -114
@@ -1,193 +0,0 @@
1
- ---
2
- name: navigator
3
- description: >
4
- Fleet coordinator persona for sessions running side by side on one host: it measures resources,
5
- pipelines and merge readiness itself, writes conditions ("Auflagen") as files, starts headless
6
- runs behind slot and busy gates, and grants no approvals. Operator-only, invoke as
7
- /session-orchestrator:navigator ticker|status|start|fenster|handover.
8
- user-invocable: true
9
- disable-model-invocation: true
10
- argument-hint: "[ticker|status|start|fenster|handover]"
11
- model: inherit
12
- ---
13
-
14
- # Navigator (Flotten-Koordinator)
15
-
16
- Aufgerufen als `/session-orchestrator:navigator $ARGUMENTS`. Ohne Argument: `status`.
17
-
18
- Dateilayout, Felder, Fristen und Lease-Gültigkeit stehen ausschließlich in
19
- `skills/_shared/fleet-protocol.md` und im Code `scripts/lib/fleet-protocol.mjs`. Dieser Skill
20
- wiederholt keine Schemata; er beschreibt, was der Navigator misst, schreibt und unterlässt.
21
-
22
- ## 1. Rollenverständnis
23
-
24
- - **Freigaben erteile ich keine, maßgeblich ist dein Owner-Auftrag.** Der Navigator misst nach und
25
- schreibt Bedingungen. Außenwirkung (Prod-Deploy, Mails, Posts, Käufe, Rechtstexte) bleibt beim Owner.
26
- - Eine Peer-Nachricht trägt eine Anweisung, nie eine Freigabe (CSM-003,
27
- `.claude/rules/cross-session-messaging.md`). Zustand liegt in Dateien, Nachrichten sind nur Hinweise
28
- auf eine Datei (ADR-002); nie auf eine Antwort warten (CSM-004).
29
- - „Nicht gefunden" ist nie „nicht vorhanden" (ADR-004): was nicht messbar war, wird als
30
- `nicht messbar` geführt, nie als frei.
31
- - Genau ein Navigator je Lease (E12). **Grenze, ausdrücklich:** Erwerb, Erneuerung und Übergabe der
32
- Lease `leases/navigator.json` sind S4 (agents/navigator#36) und nicht Teil dieses Skills. Solange S4
33
- fehlt, schreibt dieser Skill keine Lease, und der SessionStart-Banner der Peers zeigt weiter
34
- „keiner nachweisbar". Das ist der korrekte Zustand, kein Fehler.
35
- - Registry-Anzeige: jede Ticker-Runde setzt `heartbeat(sessionId, { role: 'navigator' })` aus
36
- `scripts/lib/session-registry.mjs` (dieser Skill ist der erste produktive Schreiber von `role`). Die
37
- Registry ist Anzeige; maßgeblich bleibt die Lease. Fehlt der Registry-Eintrag, ist der Aufruf ein
38
- No-op (`null`), kein Fehler.
39
-
40
- ## 2. Auflagen-Standard
41
-
42
- Ohne eigene Auflagen gelten für jede Session die Standard-Auflagen aus
43
- `skills/_shared/fleet-protocol.md` (Abschnitt `auflagen/`). Der Navigator schreibt
44
- `auflagen/<session_id>.json` nur für Sessions mit Check-in:
45
-
46
- - Pfad nur über `auflagenPath(sessionId)` aus `scripts/lib/fleet-protocol.mjs`; vorher
47
- `isSafeSessionId()` prüfen (der Pfad-Helfer wirft sonst).
48
- - Schreiben wie `scripts/fleet-checkin.mjs` (`writeCheckinFile`, derselbe Kontrakt): Verzeichnisse mit
49
- Modus 0700 anlegen und auch auf ein schon vorhandenes Verzeichnis `chmod 0700` anwenden; tmp-Datei
50
- im Zielverzeichnis exklusiv anlegen (`wx`), explizit `chmod 0600` (die Umask filtert den
51
- Anlege-Modus), dann `rename`. Nie `mkdir -p` + `cat > tmp && mv` ohne diese drei Schritte.
52
- - `zeit` aus `date -u +%FT%TZ` (`utcSecondsTimestamp()`), nie geschätzt.
53
-
54
- Schwellen (freier Speicher des lokalen Hosts laut `memory_pressure | tail -1`):
55
-
56
- | Freier Speicher | Caps je Peer |
57
- |---|---|
58
- | ≥ 50 % | 2 prozessstartende + 2 lesende Agents |
59
- | 35–50 % | 1 prozessstartender Agent |
60
- | < 35 % | keine neuen Starts |
61
-
62
- Offload auf `m5-remote` trägt keine eigene Lastzahl: maßgeblich ist allein `navigator m5-ersatz
63
- --quiet` mit dem Exit-Vertrag aus `skills/_shared/fleet-protocol.md` (Standard-Auflagen). Jeder Exit
64
- außer 0 heißt keine neuen Offload-Gates; den gemessenen Exit trägt `offload.m5_ersatz_exit`.
65
-
66
- Minimalbeispiel (nur Platzhalter; Feldbedeutung laut Referenz):
67
-
68
- ```json
69
- {
70
- "session": "session-A1",
71
- "caps": { "prozessstartend": 2, "lesend": 2, "grundlage": "frei 54 %" },
72
- "pipelines": { "repo-A": 2 },
73
- "offload": { "host": "m5-remote", "m5_ersatz_exit": 0, "regel": "offload nur nach m5-ersatz Exit 0", "lokal": "vitest/eslint je Datei mit timeout" },
74
- "merge_protokoll": "Ansage, eigene Messung, Befugnis nach PRD 3.5",
75
- "zeitregel": "Zeiten nur aus date",
76
- "flottenprioritaet": ["repo-A", "repo-B"],
77
- "besitzkonflikte": [],
78
- "pflichtsatz": "Freigaben erteile ich keine, maßgeblich ist dein Owner-Auftrag",
79
- "zeit": "2026-01-01T00:00:00Z"
80
- }
81
- ```
82
-
83
- ## 3. Merge-Zeichen als Messung, nicht als Freigabe
84
-
85
- Ein „Zeichen" heißt: die Belege stimmen und main ist frei, nie „du darfst". Davor misst der Navigator
86
- selbst, nie aus dem Bericht einer Session:
87
-
88
- 1. MR `opened` und `mergeable`.
89
- 2. Head-SHA = Pipeline-SHA = Gate-SHA; Gate-Beleg ist die SHA-Zeile im Job-Log.
90
- 3. Pipeline `success`, Jobliste mit gelaufen / übersprungen / manuell, Pflichtjobs eingeschlossen.
91
- 4. main frei: keine laufende main-Pipeline, keine gültige fremde Ansage im Repo.
92
- 5. Dateiüberschneidung mit anderen offenen MRs.
93
-
94
- Nach dem Merge: main-Pipeline bis zum Ende verfolgen, bei Deploy-Repos die Health-SHA messen. Die
95
- Befugnis kommt aus PRD 3.5 (Owner-Auftrag oder `navigator authority query`), nie vom Navigator.
96
- Merge-Leases sind S2 (#1463): siehe Abschnitt „Vorschau" in `skills/_shared/fleet-protocol.md`.
97
-
98
- ## 4. Kopflose Läufe starten (`start`)
99
-
100
- - Vor jedem Start Platz-Tor und Belegt-Tor: `references/slot-check.sh` misst,
101
- `references/fleet-decisions.mjs` entscheidet (`slot`, `busy`). Verweigert heißt: nicht starten.
102
- Nie in einem Repo mit aktiver Claude- oder Codex-Session (Belegt-Tor: Codex-Rollout mit diesem
103
- Checkout jünger als 30 min oder `session.lock` jünger als 6 h). Das Platz-Tor zählt kopflose Läufe
104
- je Host (4 / 3 / 0 bei ≥ 50 % / ≥ 35 % / darunter freiem Speicher, Last1 > 40 schließt alles) und ist
105
- eine andere Größe als die Agent-Caps je Peer aus Abschnitt 2.
106
- - Brief als Datei; er verlangt, vor jeder Welle und jedem MR `auflagen/` und `merge/` zu lesen.
107
- - Phase 1 mit fester Session-ID, spätere Phasen per `--resume`. Obergrenze je Lauf über
108
- `timeout -k 60 <sekunden>` (ganze Sekunden; das Platz-Tor zählt Läufe an genau diesem Wrapper). Protokolldateien `phaseN.meta`, `phaseN.jsonl`, `phaseN.err`; je Lauf eine
109
- Parkplatz-Datei (nur anhängen, nie überschreiben). Ablage je Lauf unter
110
- `<NAVIGATOR_CONFIG_DIR>/fleet/<lauf>/` (Konvention, kein Skript liest sie).
111
- - Hintergrund-Tasks und Monitor sterben mit dem Turn-Ende; ein alleinstehendes langes `sleep` blockt
112
- der Harness; die `until`/`case`-Form kehrt sofort zurück, weil `case` ohne Treffer 0 liefert.
113
- **Wartemuster**, wörtlich:
114
-
115
- `timeout 540 bash -c 'while :; do s=$(<status-befehl>); case "$s" in success|failed|canceled|skipped|manual) echo "$s"; exit 0;; esac; sleep 20; done'; echo "rc=$?"`
116
-
117
- Exit 124 heißt weiter warten: denselben Befehl wiederholen. Der Turn endet nie, solange etwas läuft.
118
-
119
- ## 5. Pfad-Regel für Offload
120
-
121
- `offload gate|run` immer mit dem **Worktree-Pfad**, nie mit dem Repo-Namen (sonst wird der
122
- Haupt-Checkout gespiegelt). Host nur über den Alias `m5-remote`. Im entfernten Befehl kein `timeout`
123
- und keine Login-Shell. Details: `skills/remote-offload/SKILL.md`.
124
-
125
- ## 6. Codex-Läufe
126
-
127
- - `codex exec` mit `--json` und `-o <datei>`; Fortsetzung per `codex exec resume <thread-id>`.
128
- - Codex-Läufe mergen nie. Eine Claude-Session prüft ihr Ergebnis unabhängig, bevor irgendetwas
129
- gemergt wird.
130
- - Laufende Codex-Sessions sind nur lesend beobachtbar (Rollout-Dateien unter
131
- `$NAVIGATOR_CODEX_HOME/sessions`, mtime < 3 min = aktiv in der Lage; das Belegt-Tor aus Abschnitt 4
132
- zählt konservativer, 30 min) und per Nachricht nicht erreichbar.
133
-
134
- ## 7. Phase-2-Fortsetzung bei Abbruch
135
-
136
- Endet ein Lauf früh (Exit ≠ 0, „killed"-Task im Log, Bericht fehlt): Parkplatz-Datei und die letzten
137
- `result`-Zeilen lesen, Zustand selbst messen (Worktree, HEAD, Gate-Job), dann Phase 2 mit einem kurzen
138
- Fortsetzungsbrief starten, wieder hinter beiden Toren.
139
-
140
- ## 8. Konto-Wache
141
-
142
- - Nutzbar: 5h < 97 % und 7d < 97 %. Das nutzbare Konto mit dem frühesten 7d-Reset (≤ 3 Tage) zuerst
143
- aufbrauchen; ferne Konten fair nach Verbrauch. Toleranz 1 800 s bei gleichem Reset gegen Flattern.
144
- - Fehlende Daten = nicht messbar, nie „frei".
145
- - Werkzeug: `references/account-check.sh` liest `NAVIGATOR_ACCOUNT_CMD` und gibt nur die Entscheidung
146
- aus (Ziel als Slot-Nummer, nie als Alias); ungesetzt meldet es „nicht messbar". Nie Konto-Rohdaten
147
- ausgeben.
148
- - Ein Wechsel wirkt auf alle Sessions des Hosts und braucht eine dauerhafte Owner-Freigabe; ohne sie
149
- nur eine Empfehlung.
150
-
151
- ## 9. KI-Budget-Regel
152
-
153
- Bezahlter API-Key nur produktiv. Tests, Evals und CI-Proben laufen mit Mock, Kassette oder
154
- Abo-/CLI-Modell. Tages- und Wochenverbrauch aus den Peer-Meldungen in die Lage-Datei; die Schwelle
155
- für einen Owner-Punkt ist Konfiguration, nie eine erfundene Zahl.
156
-
157
- ## 10. Unterbefehle
158
-
159
- | Befehl | Was er tut |
160
- |---|---|
161
- | `ticker` | Eine Runde (unten). Wiederholung nur über `/loop`, nie selbst gebaut. |
162
- | `status` | Liest Registry, Lease, `checkin/`, `auflagen/`; schreibt nichts. |
163
- | `start` | Tore aus Abschnitt 4, dann Lauf. |
164
- | `fenster` | Merge-Fenster je Repo als Messung nach Abschnitt 3. |
165
- | `handover` | Übergabedatei an den Nachfolger. `uebergabe_an` in der Lease ist S4 und wird hier nur beschrieben. |
166
-
167
- **Ticker-Runde:** (1) Zeit aus `date`; (2) Ressourcen (Speicher, Last lokal und `m5-remote`, Platte);
168
- (3) Peers per `ListAgents`; (4) Codex nur lesend; (5) kopflose Läufe (`phaseN.meta`, Parkplatz);
169
- (6) Pipelines per `glab api "projects/:id/pipelines?status=running"`; (7) Waisen: Prozesse mit PPID 1
170
- aus Flotten-Repos, nur melden, beenden nur mit Register-Treffer (HR-107); (8) Owner-Punkte; (9) Konto;
171
- (10) Budget. Danach `heartbeat(sessionId, { role: 'navigator' })`. Antwort eine Zeile, bei Befund
172
- höchstens sechs.
173
-
174
- **Kopflos:** unter `claude -p` sind nur `session` und `plan` als eingebaute Namen reserviert. Bis
175
- `/navigator` ohne Namespace gemessen ist, gilt `/session-orchestrator:navigator`.
176
-
177
- ## Konfiguration
178
-
179
- Nur über Umgebungsvariablen (getrimmt; leer zählt als nicht gesetzt):
180
-
181
- | Variable | Default | Zweck |
182
- |---|---|---|
183
- | `NAVIGATOR_CONFIG_DIR` | `~/.config/navigator` | Protokollwurzel (`navigatorDir()`, S1) |
184
- | `NAVIGATOR_CODEX_HOME` | `~/.codex` | Codex-Rollouts, nur lesend |
185
- | `NAVIGATOR_ACCOUNT_CMD` | ungesetzt | Konto-Messung (wird als Shell-Befehl ausgeführt, Owner-eigen); ungesetzt = nicht messbar |
186
- | `NAVIGATOR_ACCOUNT_SWITCH_CMD` | ungesetzt | Wechselbefehl; `--apply` zeigt ihn nur an, führt ihn nie aus |
187
- | `FLEET_SLOT_OVERRIDE`, `FLEET_BUSY_OVERRIDE` | ungesetzt | `1` = Owner-Override je Tor (Grund `override` in der Entscheidung) |
188
-
189
- ## See Also
190
-
191
- `skills/_shared/fleet-protocol.md` · `skills/remote-offload/SKILL.md` ·
192
- `.claude/rules/cross-session-messaging.md` · `.claude/rules/parallel-sessions.md` ·
193
- `skills/_shared/state-ownership.md` · PRD `docs/prd/2026-09-27-navigator-peer-protokoll.md`
@@ -1,87 +0,0 @@
1
- #!/usr/bin/env bash
2
- # account-check.sh — account watch: recommend which account this host should run on.
3
- #
4
- # Usage: account-check.sh [--apply]
5
- #
6
- # Runs $NAVIGATOR_ACCOUNT_CMD (must print JSON rows: an array, or {"accounts": [...]}, each row
7
- # {slot, alias?, active, p7, p5, resetAt}) and pipes it into `fleet-decisions.mjs account`.
8
- # Prints ONLY the decision line — raw account rows never reach stdout or stderr (SEC-008).
9
- #
10
- # --apply does not switch. It prints the command that WOULD run via $NAVIGATOR_ACCOUNT_SWITCH_CMD
11
- # and refuses if that variable is unset: a switch affects every session on the host and needs a
12
- # durable owner approval, so this script only ever recommends.
13
- #
14
- # NAVIGATOR_ACCOUNT_CMD is executed as shell code by design: it is operator-owned configuration.
15
- #
16
- # Env (leading/trailing whitespace is trimmed; a whitespace-only value counts as unset):
17
- # NAVIGATOR_ACCOUNT_CMD command printing the account rows as JSON (required)
18
- # NAVIGATOR_ACCOUNT_SWITCH_CMD switch command, shown with the target appended under --apply
19
- #
20
- # Exit: 0 stay/switch recommended, 1 no usable account, 2 bad input/usage, 3 not measurable.
21
- set -u
22
-
23
- apply=0
24
- case "${1:-}" in
25
- '') ;;
26
- --apply) apply=1 ;;
27
- *) echo "usage: account-check.sh [--apply]" >&2; exit 2 ;;
28
- esac
29
-
30
- # Trim leading/trailing whitespace.
31
- trim() {
32
- local v=$1
33
- v=${v#"${v%%[![:space:]]*}"}
34
- v=${v%"${v##*[![:space:]]}"}
35
- printf '%s' "$v"
36
- }
37
- account_cmd=$(trim "${NAVIGATOR_ACCOUNT_CMD:-}")
38
- switch_cmd=$(trim "${NAVIGATOR_ACCOUNT_SWITCH_CMD:-}")
39
-
40
- if [ -z "$account_cmd" ]; then
41
- echo "account watch: not measurable (NAVIGATOR_ACCOUNT_CMD unset)"
42
- exit 3
43
- fi
44
-
45
- here=$(cd "$(dirname "$0")" && pwd)
46
- # Cap the operator command at 40 s where a timeout binary exists (GNU coreutils `timeout`, Homebrew
47
- # `gtimeout`); a stock macOS host has neither, and the command then runs uncapped rather than failing
48
- # as "not measurable" (measured on the offload host: `command -v timeout` and `gtimeout` both empty).
49
- # No bash array here: under `set -u` an empty array expansion fails on bash 3.2 (stock macOS).
50
- run_capped() {
51
- if command -v timeout >/dev/null 2>&1; then timeout 40 "$@"
52
- elif command -v gtimeout >/dev/null 2>&1; then gtimeout 40 "$@"
53
- else "$@"
54
- fi
55
- }
56
- rows=$(run_capped bash -c "$account_cmd" 2>/dev/null)
57
- cmd_rc=$?
58
- if [ "$cmd_rc" -ne 0 ] || [ -z "$rows" ]; then
59
- echo "account watch: not measurable (account command failed, rc=$cmd_rc)"
60
- exit 3
61
- fi
62
-
63
- decision=$(printf '%s' "$rows" | node "$here/fleet-decisions.mjs" account 2>/dev/null)
64
- rc=$?
65
- if [ "$rc" -eq 2 ]; then
66
- echo "account watch: not measurable (account command output is not valid account JSON)"
67
- exit 2
68
- fi
69
- if { [ "$rc" -ne 0 ] && [ "$rc" -ne 1 ]; } || [ -z "$decision" ]; then
70
- echo "account watch: not measurable (decision unavailable, rc=$rc)"
71
- exit 3
72
- fi
73
- echo "account: $decision"
74
-
75
- if [ "$apply" -eq 1 ] && [ "$rc" -eq 0 ]; then
76
- if [ -z "$switch_cmd" ]; then
77
- echo "apply refused: NAVIGATOR_ACCOUNT_SWITCH_CMD unset" >&2
78
- exit 2
79
- fi
80
- target=$(printf '%s' "$decision" | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{const d=JSON.parse(s);process.stdout.write(d.action==="switch"?String(d.target):"")})')
81
- if [ -n "$target" ]; then
82
- echo "would run: $switch_cmd $target (not executed — switching needs a durable owner approval)"
83
- else
84
- echo "would run: nothing (stay)"
85
- fi
86
- fi
87
- exit "$rc"
@@ -1,231 +0,0 @@
1
- /**
2
- * skills/navigator/references/fleet-decisions.mjs
3
- *
4
- * Pure decision functions for the navigator's headless fleet: whether a new
5
- * headless run may start on this host (slot gate), whether a target repo is
6
- * already occupied by another session (busy gate), and which account the host
7
- * should run on (account watch).
8
- *
9
- * Measurement lives elsewhere — `slot-check.sh` and `account-check.sh` in this
10
- * directory collect the raw numbers and pipe them in here. This module reads
11
- * no environment variables, no files and no clock except the `now` argument,
12
- * so every rule below is testable with plain values.
13
- *
14
- * Shared principle: a value that could not be measured is never read as
15
- * "free". Every gate fails closed on missing, NaN or non-number input; only
16
- * an explicit `override: true` (an owner decision) bypasses a gate.
17
- *
18
- * Slot gate thresholds (free memory % from the OS pressure verdict):
19
- * free >= 50 → cap 4
20
- * free >= 35 → cap 3
21
- * otherwise → cap 0
22
- * load1 > 40 → cap 0 regardless of memory
23
- * allowed = runs < cap
24
- *
25
- * Busy gate: busy when any Codex rollout for the repo is younger than 30 min
26
- * or any `session.lock` is younger than 6 h (counts measured by the caller).
27
- *
28
- * Account watch: usable = 5-hour and 7-day usage both below 97 %. Among usable
29
- * rows the earliest 7-day reset within 3 days wins (tie-break lower 7-day
30
- * usage); if none is that near, the lowest 7-day usage wins. The active row is
31
- * kept when its reset lies within 1800 s of the target's, either side (same
32
- * reset, no flapping). A reset date in the past is stale data and the row is
33
- * skipped. Rows are identified by `slot` only — the alias never reaches the
34
- * output (SEC-008).
35
- *
36
- * CLI: `node fleet-decisions.mjs slot|busy|account` reads one JSON document
37
- * from stdin and prints the decision as one JSON line.
38
- * exit 0 — allowed / not busy / stay or switch
39
- * exit 1 — refused / busy / no usable account
40
- * exit 2 — unparseable input or unknown subcommand
41
- */
42
-
43
- import { readFileSync } from 'node:fs';
44
- import { fileURLToPath } from 'node:url';
45
-
46
- // ---------------------------------------------------------------------------
47
- // Constants
48
- // ---------------------------------------------------------------------------
49
-
50
- const FREE_PCT_CAP4 = 50;
51
- const FREE_PCT_CAP3 = 35;
52
- const LOAD1_MAX = 40;
53
- const USAGE_LIMIT_PCT = 97;
54
- const NEAR_RESET_MS = 3 * 24 * 60 * 60 * 1000;
55
- const RESET_TIE_MS = 1800 * 1000;
56
-
57
- /** @param {unknown} v @returns {v is number} */
58
- function isNum(v) {
59
- return typeof v === 'number' && Number.isFinite(v);
60
- }
61
-
62
- // ---------------------------------------------------------------------------
63
- // Slot gate
64
- // ---------------------------------------------------------------------------
65
-
66
- /**
67
- * Decide whether one more headless run may start on this host.
68
- *
69
- * @param {{ freePct?: unknown, load1?: unknown, runs?: unknown, override?: unknown }} input
70
- * @returns {{ allowed: boolean, cap: number, reason: string }}
71
- */
72
- export function slotDecision({ freePct, load1, runs, override } = {}) {
73
- const memOk = isNum(freePct);
74
- const loadOk = isNum(load1);
75
- let cap = 0;
76
- if (memOk && loadOk) {
77
- if (freePct >= FREE_PCT_CAP4) cap = 4;
78
- else if (freePct >= FREE_PCT_CAP3) cap = 3;
79
- if (load1 > LOAD1_MAX) cap = 0;
80
- }
81
- if (override === true) return { allowed: true, cap, reason: 'override' };
82
-
83
- const ctx = `(free ${memOk ? `${freePct}%` : '?'}, load1 ${loadOk ? load1 : '?'})`;
84
- if (!memOk) return { allowed: false, cap: 0, reason: `no slot: free memory not measurable ${ctx}` };
85
- if (!loadOk) return { allowed: false, cap: 0, reason: `no slot: load1 not measurable ${ctx}` };
86
- if (!isNum(runs) || !Number.isInteger(runs) || runs < 0) {
87
- return { allowed: false, cap, reason: `no slot: run count not measurable ${ctx}` };
88
- }
89
- const allowed = runs < cap;
90
- return { allowed, cap, reason: `${allowed ? 'slot free' : 'no slot'}: ${runs} runs, cap ${cap} ${ctx}` };
91
- }
92
-
93
- // ---------------------------------------------------------------------------
94
- // Busy gate
95
- // ---------------------------------------------------------------------------
96
-
97
- /**
98
- * Decide whether a repo is occupied by another session.
99
- *
100
- * @param {{ codexRolloutsFresh?: unknown, lockFresh?: unknown, override?: unknown }} input
101
- * @returns {{ busy: boolean, reason: string }}
102
- */
103
- export function busyDecision({ codexRolloutsFresh, lockFresh, override } = {}) {
104
- if (override === true) return { busy: false, reason: 'override' };
105
- const valid = (v) => isNum(v) && v >= 0;
106
- if (!valid(codexRolloutsFresh) || !valid(lockFresh)) {
107
- return { busy: true, reason: 'busy: occupancy not measurable (codex rollouts or session.lock count missing)' };
108
- }
109
- const counts = `${codexRolloutsFresh} codex rollouts (30 min), ${lockFresh} session.lock (6 h)`;
110
- if (codexRolloutsFresh > 0 || lockFresh > 0) return { busy: true, reason: `busy: ${counts}` };
111
- return { busy: false, reason: `free: ${counts}` };
112
- }
113
-
114
- // ---------------------------------------------------------------------------
115
- // Account watch
116
- // ---------------------------------------------------------------------------
117
-
118
- /**
119
- * Reduce one raw account row to the fields the decision needs, or null when it
120
- * is not measurable. Copies nothing else — in particular not the alias. Never
121
- * throws: a hostile row (throwing getter, proxy) counts as not measurable.
122
- *
123
- * @param {unknown} r
124
- * @param {number} nowMs
125
- * @returns {{ id: string|number, active: boolean, p7: number, p5: number, resetMs: number } | null}
126
- */
127
- function parseAccountRow(r, nowMs) {
128
- try {
129
- if (!r || typeof r !== 'object') return null;
130
- const { slot, active, p7, p5, resetAt } = /** @type {Record<string, unknown>} */ (r);
131
- const slotOk = (typeof slot === 'string' && slot !== '') || isNum(slot);
132
- const resetMs = typeof resetAt === 'string' ? Date.parse(resetAt) : NaN;
133
- if (!slotOk || !isNum(p7) || !isNum(p5) || !Number.isFinite(resetMs) || resetMs < nowMs) return null;
134
- return { id: /** @type {string|number} */ (slot), active: active === true, p7, p5, resetMs };
135
- } catch {
136
- return null;
137
- }
138
- }
139
-
140
- /**
141
- * Pick the account the host should run on. Never throws; malformed rows
142
- * (non-string/non-number slot, missing usage, unparseable or past reset) are
143
- * skipped and counted in the reason. The output carries the slot only, never
144
- * the alias (SEC-008).
145
- *
146
- * @param {Array<{ slot: unknown, alias?: string, active: boolean, p7: number|null, p5: number|null, resetAt: string|null }>} rows
147
- * @param {Date|number} now
148
- * @returns {{ action: 'stay'|'switch'|'none', target: string|number|null, reason: string }}
149
- */
150
- export function chooseAccount(rows, now) {
151
- const nowMs = now instanceof Date ? now.getTime() : Number(now);
152
- if (!Number.isFinite(nowMs)) return { action: 'none', target: null, reason: 'no decision: now is not a valid time' };
153
- const list = Array.isArray(rows) ? rows : [];
154
-
155
- let skipped = 0;
156
- const parsed = [];
157
- for (const r of list) {
158
- const row = parseAccountRow(r, nowMs);
159
- if (row === null) skipped += 1;
160
- else parsed.push(row);
161
- }
162
- const skipNote = skipped > 0 ? `; ${skipped} row(s) skipped without data` : '';
163
-
164
- const usable = parsed.filter((r) => r.p5 < USAGE_LIMIT_PCT && r.p7 < USAGE_LIMIT_PCT);
165
- const active = parsed.find((r) => r.active === true) ?? null;
166
- const activeUsable = active !== null && usable.includes(active);
167
-
168
- if (usable.length === 0) {
169
- return { action: 'none', target: null, reason: `no usable account (all >= ${USAGE_LIMIT_PCT}% or without data)${skipNote}` };
170
- }
171
-
172
- const near = usable.filter((r) => r.resetMs - nowMs <= NEAR_RESET_MS);
173
- let target;
174
- if (near.length > 0) {
175
- target = near.reduce((a, b) => (b.resetMs < a.resetMs || (b.resetMs === a.resetMs && b.p7 < a.p7) ? b : a));
176
- } else {
177
- target = usable.reduce((a, b) => (b.p7 < a.p7 ? b : a));
178
- }
179
-
180
- if (activeUsable && active !== target && Math.abs(active.resetMs - target.resetMs) <= RESET_TIE_MS) {
181
- target = active;
182
- }
183
-
184
- const desc = (r) => `${r.id} (7d ${r.p7}%, reset in ${((r.resetMs - nowMs) / 86400000).toFixed(1)} d)`;
185
- if (active === target) return { action: 'stay', target: target.id, reason: `stay on ${desc(target)}${skipNote}` };
186
- return {
187
- action: 'switch',
188
- target: target.id,
189
- reason: `switch ${active ? active.id : '-'} -> ${desc(target)}${skipNote}`,
190
- };
191
- }
192
-
193
- // ---------------------------------------------------------------------------
194
- // CLI
195
- // ---------------------------------------------------------------------------
196
-
197
- function runCli(argv) {
198
- const mode = argv[2];
199
- if (mode !== 'slot' && mode !== 'busy' && mode !== 'account') {
200
- process.stderr.write('usage: node fleet-decisions.mjs slot|busy|account < input.json\n');
201
- return 2;
202
- }
203
- let input;
204
- try {
205
- input = JSON.parse(readFileSync(0, 'utf8'));
206
- } catch {
207
- process.stderr.write('fleet-decisions: stdin is not valid JSON\n');
208
- return 2;
209
- }
210
- if (mode === 'slot' || mode === 'busy') {
211
- if (!input || typeof input !== 'object' || Array.isArray(input)) {
212
- process.stderr.write(`fleet-decisions: ${mode} expects one JSON object\n`);
213
- return 2;
214
- }
215
- const d = mode === 'slot' ? slotDecision(input) : busyDecision(input);
216
- process.stdout.write(`${JSON.stringify(d)}\n`);
217
- return mode === 'slot' ? (d.allowed ? 0 : 1) : d.busy ? 1 : 0;
218
- }
219
- const rows = Array.isArray(input) ? input : input && Array.isArray(input.accounts) ? input.accounts : null;
220
- if (!rows) {
221
- process.stderr.write('fleet-decisions: account expects a JSON array or {"accounts": [...]}\n');
222
- return 2;
223
- }
224
- const d = chooseAccount(rows, Date.now());
225
- process.stdout.write(`${JSON.stringify(d)}\n`);
226
- return d.action === 'none' ? 1 : 0;
227
- }
228
-
229
- if (process.argv[1] === fileURLToPath(import.meta.url)) {
230
- process.exitCode = runCli(process.argv);
231
- }
@@ -1,114 +0,0 @@
1
- #!/usr/bin/env bash
2
- # slot-check.sh — measure the slot gate and the busy gate for one repo, decide via fleet-decisions.mjs.
3
- #
4
- # Usage: slot-check.sh <repo-path>
5
- #
6
- # Measures only; every rule lives in fleet-decisions.mjs next to this file.
7
- # slot gate: free memory % (memory_pressure), 1-min load (vm.loadavg, integer),
8
- # running headless runs (`timeout -k 60 <n>[smhd] claude -p` + `... codex exec`);
9
- # anchored on the timeout wrapper because one Codex run spawns three processes matching `codex exec`
10
- # busy gate: Codex rollouts for <repo-path> younger than 30 min, <repo-path>/.orchestrator/session.lock
11
- # younger than 6 h
12
- # A value that cannot be measured is passed as null; the decision module fails closed on it.
13
- #
14
- # Env:
15
- # NAVIGATOR_CODEX_HOME Codex home directory (default: $HOME/.codex; empty/whitespace = unset)
16
- # FLEET_SLOT_OVERRIDE=1 owner override for the slot gate
17
- # FLEET_BUSY_OVERRIDE=1 owner override for the busy gate
18
- #
19
- # Exit: 0 both gates pass, 1 at least one gate refuses, 2 usage error.
20
- set -u
21
-
22
- if [ "$#" -ne 1 ] || [ -z "$1" ]; then
23
- echo "usage: slot-check.sh <repo-path>" >&2
24
- exit 2
25
- fi
26
- repo=${1%/}
27
- here=$(cd "$(dirname "$0")" && pwd)
28
- decide="$here/fleet-decisions.mjs"
29
-
30
- # Trim leading/trailing whitespace; a whitespace-only value counts as unset.
31
- trim() {
32
- local v=$1
33
- v=${v#"${v%%[![:space:]]*}"}
34
- v=${v%"${v##*[![:space:]]}"}
35
- printf '%s' "$v"
36
- }
37
- codex_home=$(trim "${NAVIGATOR_CODEX_HOME:-}")
38
- [ -n "$codex_home" ] || codex_home=$HOME/.codex
39
-
40
- # Rollouts record the cwd as the session saw it; match both the logical and the physical path.
41
- repo_abs=$(cd "$repo" 2>/dev/null && pwd)
42
- repo_phys=$(cd "$repo" 2>/dev/null && pwd -P)
43
-
44
- # Print the argument if it is a non-negative integer, else the JSON literal null.
45
- num_or_null() {
46
- case "$1" in
47
- ''|*[!0-9]*) printf 'null' ;;
48
- *) printf '%s' "$1" ;;
49
- esac
50
- }
51
-
52
- # --- slot gate measurements ---------------------------------------------------
53
- free_pct=$(memory_pressure 2>/dev/null | awk '/free percentage/{gsub("%","",$NF); print $NF}')
54
- load1=$(sysctl -n vm.loadavg 2>/dev/null | awk '{print int($2)}')
55
- # Count pgrep matches: rc 0 → count, rc 1 → 0 (no match), rc >= 2 → empty (not measurable).
56
- count_runs() {
57
- local out rc
58
- out=$(pgrep -f "$1")
59
- rc=$?
60
- case "$rc" in
61
- 0) printf '%s' "$out" | grep -c . ;;
62
- 1) printf '0' ;;
63
- *) printf '' ;;
64
- esac
65
- }
66
- runs=''
67
- if command -v pgrep >/dev/null 2>&1; then
68
- claude_runs=$(count_runs '^timeout -k 60 [0-9]+[smhd]? claude -p')
69
- codex_runs=$(count_runs '^timeout -k 60 [0-9]+[smhd]? codex exec')
70
- if [ -n "$claude_runs" ] && [ -n "$codex_runs" ]; then
71
- runs=$((claude_runs + codex_runs))
72
- fi
73
- fi
74
-
75
- # --- busy gate measurements ---------------------------------------------------
76
- # Only the first line of each rollout is matched, as a fixed string; file content is never evaluated.
77
- if [ -z "$repo_abs" ] || [ -z "$repo_phys" ]; then
78
- # Repo path not resolvable: both busy counts are unknown, the decision fails closed.
79
- codex_fresh=''
80
- lock_fresh=''
81
- else
82
- codex_fresh=0
83
- if [ -d "$codex_home/sessions" ]; then
84
- codex_fresh=$(find "$codex_home/sessions" -name 'rollout-*.jsonl' -mmin -30 2>/dev/null |
85
- while IFS= read -r f; do
86
- head -1 "$f" 2>/dev/null |
87
- grep -qF -e "\"cwd\":\"$repo_abs\"" -e "\"cwd\":\"$repo_phys\"" && echo x
88
- done | wc -l | tr -d ' ')
89
- fi
90
- lock_fresh=$(find "$repo_abs/.orchestrator" -maxdepth 1 -name session.lock -mmin -360 2>/dev/null | wc -l | tr -d ' ')
91
- fi
92
-
93
- slot_override=false
94
- [ "${FLEET_SLOT_OVERRIDE:-0}" = 1 ] && slot_override=true
95
- busy_override=false
96
- [ "${FLEET_BUSY_OVERRIDE:-0}" = 1 ] && busy_override=true
97
-
98
- slot_json=$(printf '{"freePct":%s,"load1":%s,"runs":%s,"override":%s}' \
99
- "$(num_or_null "$free_pct")" "$(num_or_null "$load1")" "$(num_or_null "$runs")" "$slot_override")
100
- busy_json=$(printf '{"codexRolloutsFresh":%s,"lockFresh":%s,"override":%s}' \
101
- "$(num_or_null "$codex_fresh")" "$(num_or_null "$lock_fresh")" "$busy_override")
102
-
103
- slot_out=$(printf '%s' "$slot_json" | node "$decide" slot)
104
- slot_rc=$?
105
- busy_out=$(printf '%s' "$busy_json" | node "$decide" busy)
106
- busy_rc=$?
107
-
108
- echo "slot: $slot_out"
109
- echo "busy: $busy_out"
110
-
111
- if [ "$slot_rc" -ne 0 ] || [ "$busy_rc" -ne 0 ]; then
112
- exit 1
113
- fi
114
- exit 0