session-orchestrator 5.12.0 → 5.13.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 (385) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +4 -1
  3. package/.codex-plugin/agents/explorer.toml +1 -1
  4. package/.codex-plugin/agents/session-reviewer.toml +2 -2
  5. package/.codex-plugin/agents/wave-worker.toml +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.cursor-plugin/plugin.json +1 -1
  8. package/.orchestrator/policy/blocked-commands.json +8 -0
  9. package/.orchestrator/policy/public-export.json +30 -0
  10. package/CHANGELOG.md +162 -4
  11. package/README.md +126 -127
  12. package/agents/analyst.md +7 -2
  13. package/agents/architect-reviewer.md +7 -2
  14. package/agents/code-implementer.md +4 -2
  15. package/agents/db-specialist.md +9 -2
  16. package/agents/dialectic-deriver.md +9 -3
  17. package/agents/docs-writer.md +7 -2
  18. package/agents/eval-judge.md +7 -1
  19. package/agents/qa-strategist.md +7 -2
  20. package/agents/security-reviewer.md +9 -2
  21. package/agents/session-reviewer.md +18 -2
  22. package/agents/skill-applied-judge.md +7 -1
  23. package/agents/test-writer.md +9 -2
  24. package/agents/ui-developer.md +9 -2
  25. package/agents/ux-evaluator.md +6 -1
  26. package/commands/session.md +1 -1
  27. package/commands/templates-ack.md +89 -18
  28. package/docs/agent-authoring.md +30 -11
  29. package/docs/ci-setup.md +38 -2
  30. package/docs/codex-setup.md +33 -9
  31. package/docs/components.md +13 -2
  32. package/docs/cursor-setup.md +3 -3
  33. package/docs/events-schema.md +5 -4
  34. package/docs/evidence-candidates.md +63 -0
  35. package/docs/fleet-checkin-audit.md +7 -4
  36. package/docs/github-mirror-protection.md +150 -3
  37. package/docs/install.md +90 -17
  38. package/docs/instruction-delivery.md +1 -1
  39. package/docs/migration-v4.md +2 -2
  40. package/docs/mods.md +118 -0
  41. package/docs/owner-config-schema.md +1 -0
  42. package/docs/plugin-architecture-v3.md +1 -1
  43. package/docs/pm-skills-marketplace.md +4 -3
  44. package/docs/rule-authoring.md +24 -13
  45. package/docs/scope-collision-guard.md +2 -1
  46. package/docs/session-config-reference.md +49 -15
  47. package/docs/session-config-template.md +30 -22
  48. package/docs/sessions-ledger-repair.md +137 -0
  49. package/docs/vault-docs-architecture.md +1 -1
  50. package/hooks/_lib/hook-import-set.json +406 -73
  51. package/hooks/_lib/hook-root.mjs +301 -0
  52. package/hooks/_lib/lock-bootstrap.mjs +434 -4
  53. package/hooks/_lib/orchestrator-ignore.mjs +3 -0
  54. package/hooks/_lib/subagent-transcript.mjs +2 -2
  55. package/hooks/_lib/vcs-create-matcher.mjs +13 -17
  56. package/hooks/cwd-change-restore.mjs +5 -1
  57. package/hooks/enforce-commands.mjs +4 -3
  58. package/hooks/enforce-scope.mjs +11 -1
  59. package/hooks/hooks-codex.json +48 -2
  60. package/hooks/hooks.json +31 -31
  61. package/hooks/on-session-end.mjs +38 -15
  62. package/hooks/on-session-start.mjs +139 -49
  63. package/hooks/on-stop.mjs +11 -6
  64. package/hooks/post-bash-issue-budget-refund.mjs +6 -1
  65. package/hooks/post-bash-write-verify.mjs +8 -4
  66. package/hooks/post-edit-validate.mjs +45 -52
  67. package/hooks/post-subagent-discovery-validator.mjs +2 -2
  68. package/hooks/post-tool-batch-wave-signal.mjs +8 -4
  69. package/hooks/post-tool-failure-corrective-context.mjs +5 -1
  70. package/hooks/pre-bash-destructive-guard.mjs +220 -8
  71. package/hooks/pre-bash-issue-budget.mjs +6 -1
  72. package/hooks/pre-bash-sessions-ledger-guard.mjs +691 -123
  73. package/hooks/pre-bash-staging-fence.mjs +258 -14
  74. package/hooks/pre-bash-templates-first.mjs +28 -27
  75. package/hooks/run-node.sh +1 -1
  76. package/hooks/skill-invocation-telemetry.mjs +15 -8
  77. package/hooks/subagent-telemetry.mjs +77 -14
  78. package/hooks/wave-scope-commit-guard.mjs +143 -14
  79. package/mods/so-hud/.claude-plugin/plugin.json +11 -0
  80. package/mods/so-hud/README.md +57 -0
  81. package/mods/so-hud/hooks/beacon-io.mjs +101 -0
  82. package/mods/so-hud/hooks/heredoc-scan.ts +394 -0
  83. package/mods/so-hud/hooks/hooks.json +1 -0
  84. package/mods/so-hud/hooks/logic.ts +182 -0
  85. package/mods/so-hud/hooks/register.tsx +670 -0
  86. package/mods/so-hud/hooks/rules.ts +2998 -0
  87. package/mods/so-hud/tsconfig.json +8 -0
  88. package/mods/so-hud/types/index.d.ts +36 -0
  89. package/package.json +4 -2
  90. package/rules/_index.md +1 -1
  91. package/rules/always-on/ask-via-tool.md +1 -1
  92. package/rules/always-on/loop-and-monitor.md +17 -194
  93. package/rules/always-on/parallel-sessions.md +3 -1
  94. package/rules/opt-in-stack/backend-data.md +1 -1
  95. package/rules/opt-in-stack/backend.md +1 -1
  96. package/rules/opt-in-stack/security-web.md +5 -1
  97. package/scripts/agent-usage-census.mjs +79 -0
  98. package/scripts/apply-session-learnings.mjs +34 -6
  99. package/scripts/archive-closed-prds.mjs +21 -5
  100. package/scripts/autopilot.mjs +11 -5
  101. package/scripts/backfill-abandoned-sessions.mjs +13 -3
  102. package/scripts/backfill-evidence-digest.mjs +2 -1
  103. package/scripts/backfill-learnings-from-vault.mjs +61 -6
  104. package/scripts/backfill-sessions.mjs +16 -2
  105. package/scripts/check-package-manager.mjs +26 -8
  106. package/scripts/check-sessions-integrity.mjs +4 -1
  107. package/scripts/ci/assert-vitest-green.mjs +3 -0
  108. package/scripts/ci/windows-smoke.mjs +201 -0
  109. package/scripts/close-precheck.mjs +55 -0
  110. package/scripts/dialectic-deriver.mjs +20 -12
  111. package/scripts/emit-event.mjs +2 -2
  112. package/scripts/emit-session.mjs +239 -20
  113. package/scripts/eval-session.mjs +17 -3
  114. package/scripts/export-hw-learnings.mjs +29 -2
  115. package/scripts/fleet-checkin-audit.mjs +8 -4
  116. package/scripts/fleet-checkin.mjs +39 -27
  117. package/scripts/gc-stale-worktrees.mjs +6 -1
  118. package/scripts/github-export.mjs +128 -0
  119. package/scripts/github-protection-audit.mjs +3 -1
  120. package/scripts/handover-queue-check.mjs +209 -0
  121. package/scripts/harness-audit.mjs +6 -4
  122. package/scripts/lib/agent-frontmatter.mjs +57 -10
  123. package/scripts/lib/agent-status.mjs +125 -7
  124. package/scripts/lib/agent-usage-census.mjs +394 -0
  125. package/scripts/lib/auto-dialectic.mjs +17 -10
  126. package/scripts/lib/auto-dream.mjs +22 -14
  127. package/scripts/lib/autopilot/loop.mjs +3 -1
  128. package/scripts/lib/autopilot/recent-runs.mjs +4 -2
  129. package/scripts/lib/autopilot/stall-sampler.mjs +2 -1
  130. package/scripts/lib/autopilot/telemetry.mjs +5 -1
  131. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  132. package/scripts/lib/baseline-archetypes.mjs +10 -2
  133. package/scripts/lib/blocked-commands-policy.mjs +4 -3
  134. package/scripts/lib/build-live-signals.mjs +13 -9
  135. package/scripts/lib/ci-status-banner.mjs +329 -24
  136. package/scripts/lib/codex/plugin-contract.mjs +8 -0
  137. package/scripts/lib/cold-start-detector.mjs +5 -2
  138. package/scripts/lib/command-blocker.mjs +603 -43
  139. package/scripts/lib/common.mjs +3 -2
  140. package/scripts/lib/config/cross-repo.mjs +74 -7
  141. package/scripts/lib/config/host-paths.mjs +6 -2
  142. package/scripts/lib/config/section-extractor.mjs +65 -11
  143. package/scripts/lib/config/template-keys.mjs +153 -0
  144. package/scripts/lib/config/vault-integration.mjs +1 -0
  145. package/scripts/lib/config.mjs +2 -2
  146. package/scripts/lib/coordinator-snapshot.mjs +1 -1
  147. package/scripts/lib/discovery/triage-state.mjs +2 -1
  148. package/scripts/lib/dispatcher/rank.mjs +5 -7
  149. package/scripts/lib/ecosystem-health.mjs +2 -2
  150. package/scripts/lib/eval/engine.mjs +3 -2
  151. package/scripts/lib/eval/session-resolve.mjs +6 -2
  152. package/scripts/lib/eval/sink.mjs +4 -3
  153. package/scripts/lib/events-retention-banner.mjs +3 -2
  154. package/scripts/lib/events-schema.mjs +10 -0
  155. package/scripts/lib/events.mjs +23 -12
  156. package/scripts/lib/evolve/autonomy-verdict.mjs +4 -9
  157. package/scripts/lib/evolve/autopilot-effectiveness.mjs +8 -18
  158. package/scripts/lib/fleet-protocol.mjs +4 -0
  159. package/scripts/lib/gates/gate-full.mjs +172 -11
  160. package/scripts/lib/gates/gate-helpers.mjs +236 -23
  161. package/scripts/lib/git-config-drift.mjs +141 -7
  162. package/scripts/lib/github-export.mjs +447 -0
  163. package/scripts/lib/gitlab-portfolio/cli.mjs +16 -5
  164. package/scripts/lib/handover-queue-marks.mjs +159 -0
  165. package/scripts/lib/hardware-pattern-detector.mjs +103 -13
  166. package/scripts/lib/harness-audit/categories/category1.mjs +16 -4
  167. package/scripts/lib/harness-audit/categories/category4.mjs +11 -8
  168. package/scripts/lib/harness-audit/categories/category6.mjs +25 -5
  169. package/scripts/lib/harness-audit/categories/category9.mjs +10 -4
  170. package/scripts/lib/host-identity.mjs +9 -5
  171. package/scripts/lib/http-request.mjs +64 -0
  172. package/scripts/lib/instruction-budget-guard.mjs +30 -22
  173. package/scripts/lib/io.mjs +6 -3
  174. package/scripts/lib/issue-budget-reconcile.mjs +3 -2
  175. package/scripts/lib/issue-budget.mjs +36 -7
  176. package/scripts/lib/learnings/evidence-candidates.mjs +154 -0
  177. package/scripts/lib/learnings/expiry-sweep.mjs +99 -7
  178. package/scripts/lib/learnings/io.mjs +289 -15
  179. package/scripts/lib/learnings/select.mjs +53 -4
  180. package/scripts/lib/learnings/store-role.mjs +209 -0
  181. package/scripts/lib/learnings/surface.mjs +46 -7
  182. package/scripts/lib/ledger-path.mjs +113 -0
  183. package/scripts/lib/maintenance-due-banner.mjs +63 -12
  184. package/scripts/lib/memory-banner.mjs +5 -6
  185. package/scripts/lib/memory-proposals/collector.mjs +5 -2
  186. package/scripts/lib/memory-proposals/sink.mjs +7 -4
  187. package/scripts/lib/memory-proposals/store.mjs +22 -5
  188. package/scripts/lib/mode-selector-accuracy.mjs +2 -1
  189. package/scripts/lib/no-follow-open.mjs +57 -0
  190. package/scripts/lib/orphan-reaper/reaper-audit.mjs +4 -2
  191. package/scripts/lib/own-lock-root.mjs +95 -0
  192. package/scripts/lib/owner-interview.mjs +2 -1
  193. package/scripts/lib/owner-yaml.mjs +70 -7
  194. package/scripts/lib/platform.mjs +161 -9
  195. package/scripts/lib/plugin-update-banner.mjs +15 -11
  196. package/scripts/lib/pre-push-local-partial.mjs +117 -0
  197. package/scripts/lib/pre-push-remote-gate.mjs +151 -25
  198. package/scripts/lib/process-group.mjs +270 -34
  199. package/scripts/lib/project-hygiene.mjs +28 -0
  200. package/scripts/lib/quality-gate.mjs +33 -9
  201. package/scripts/lib/reconcile/backlog.mjs +29 -15
  202. package/scripts/lib/reconcile/engine.mjs +26 -2
  203. package/scripts/lib/reconcile/idempotency.mjs +65 -3
  204. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +53 -12
  205. package/scripts/lib/reconcile/verdict-ledger.mjs +224 -0
  206. package/scripts/lib/reconcile-nudge-banner.mjs +40 -6
  207. package/scripts/lib/resource-probe/evaluate.mjs +32 -9
  208. package/scripts/lib/resource-probe/probe-platform.mjs +3 -1
  209. package/scripts/lib/rule-loader.mjs +20 -10
  210. package/scripts/lib/scope-baseline.mjs +123 -8
  211. package/scripts/lib/scope-echo.mjs +5 -2
  212. package/scripts/lib/scope-gate.mjs +55 -20
  213. package/scripts/lib/session-close-backfill.mjs +75 -7
  214. package/scripts/lib/session-end/close-precheck.mjs +151 -0
  215. package/scripts/lib/session-end/phase-skip.mjs +8 -7
  216. package/scripts/lib/session-end/tail-runner.mjs +7 -2
  217. package/scripts/lib/session-id.mjs +9 -1
  218. package/scripts/lib/session-identity/own-session.mjs +63 -0
  219. package/scripts/lib/session-lock.mjs +114 -0
  220. package/scripts/lib/session-record-repair.mjs +245 -35
  221. package/scripts/lib/session-registry.mjs +72 -9
  222. package/scripts/lib/session-schema/constants.mjs +9 -0
  223. package/scripts/lib/session-schema/filters.mjs +56 -1
  224. package/scripts/lib/session-schema/validator.mjs +15 -5
  225. package/scripts/lib/session-start-probes.mjs +149 -22
  226. package/scripts/lib/session-token-rollup.mjs +2 -1
  227. package/scripts/lib/session-work-trees.mjs +166 -0
  228. package/scripts/lib/sessions-canonical.mjs +61 -3
  229. package/scripts/lib/sessions-integrity-banner.mjs +17 -39
  230. package/scripts/lib/sessions-staleness-banner.mjs +8 -5
  231. package/scripts/lib/sh-quote.mjs +1 -0
  232. package/scripts/lib/skill-evidence-window.mjs +5 -1
  233. package/scripts/lib/skill-health/join.mjs +10 -9
  234. package/scripts/lib/skill-invocations-schema.mjs +13 -0
  235. package/scripts/lib/skill-judgments-schema.mjs +6 -2
  236. package/scripts/lib/snapshot-probe.mjs +414 -0
  237. package/scripts/lib/so-hud/collect.mjs +49 -5
  238. package/scripts/lib/so-hud/runtime.mjs +15 -4
  239. package/scripts/lib/spiral-carryover.mjs +8 -1
  240. package/scripts/lib/state-md/frontmatter-mutators.mjs +266 -8
  241. package/scripts/lib/state-md/idle-reset.mjs +14 -5
  242. package/scripts/lib/state-md/mission-status.mjs +5 -1
  243. package/scripts/lib/state-md-peer-guard.mjs +2 -0
  244. package/scripts/lib/state-md.mjs +31 -8
  245. package/scripts/lib/sunset/walker.mjs +4 -3
  246. package/scripts/lib/telemetry/consent.mjs +4 -2
  247. package/scripts/lib/telemetry/queue.mjs +3 -3
  248. package/scripts/lib/telemetry/sync.mjs +45 -9
  249. package/scripts/lib/test-runner/artifact-paths.mjs +10 -2
  250. package/scripts/lib/tmux-layout/layouts.mjs +20 -3
  251. package/scripts/lib/ux-grill/paths.mjs +5 -1
  252. package/scripts/lib/validate/check-agents.mjs +37 -6
  253. package/scripts/lib/validate/check-component-paths.mjs +25 -1
  254. package/scripts/lib/validate/check-guard-requires-parity.mjs +3 -3
  255. package/scripts/lib/validate/check-hooks-symmetry.mjs +53 -32
  256. package/scripts/lib/validate/check-json-files.mjs +3 -1
  257. package/scripts/lib/validate/check-learning-provenance.mjs +3 -2
  258. package/scripts/lib/validate/check-learnings-shell-writes.mjs +10 -4
  259. package/scripts/lib/validate/check-metrics-ledger-root.mjs +234 -0
  260. package/scripts/lib/validate/check-owner-leakage.mjs +159 -11
  261. package/scripts/lib/validate/check-test-posix-shell.mjs +246 -0
  262. package/scripts/lib/validate/check-unwired-features.mjs +4 -2
  263. package/scripts/lib/validate/check-vcs-repo-flag-positional.mjs +58 -0
  264. package/scripts/lib/validate/check-vcs-repo-flag.mjs +259 -73
  265. package/scripts/lib/validate/check-workflow-continue-on-error.mjs +63 -0
  266. package/scripts/lib/validate/confidential-names.mjs +3 -1
  267. package/scripts/lib/vault-mirror/migrate-ids.mjs +321 -0
  268. package/scripts/lib/vault-mirror/namespace.mjs +19 -7
  269. package/scripts/lib/vault-mirror/process.mjs +56 -27
  270. package/scripts/lib/vault-mirror/prune.mjs +5 -2
  271. package/scripts/lib/vault-mirror/render-learnings.mjs +3 -3
  272. package/scripts/lib/vault-mirror/render-sessions.mjs +36 -53
  273. package/scripts/lib/vault-mirror/retention.mjs +2 -2
  274. package/scripts/lib/vault-mirror/utils.mjs +79 -0
  275. package/scripts/lib/vault-staleness-banner.mjs +4 -7
  276. package/scripts/lib/vault-status/board-writer.mjs +2 -1
  277. package/scripts/lib/vault-status/narrative-mirror.mjs +66 -7
  278. package/scripts/lib/wave-executor/foreign-dispatch.mjs +20 -0
  279. package/scripts/lib/win-process-tree.mjs +137 -0
  280. package/scripts/lib/workspace.mjs +1 -1
  281. package/scripts/lib/worktree/constants.mjs +1 -1
  282. package/scripts/lib/worktree/lifecycle.mjs +1 -1
  283. package/scripts/lib/worktree/listing.mjs +1 -1
  284. package/scripts/lib/zx.mjs +50 -0
  285. package/scripts/mcp-server.mjs +16 -7
  286. package/scripts/mcp-server.sh +13 -83
  287. package/scripts/measure-policy-cache-effectiveness.mjs +7 -5
  288. package/scripts/memory-propose.mjs +10 -3
  289. package/scripts/migrate-cold-start-seed.mjs +7 -0
  290. package/scripts/migrate-legacy-learnings.sh +22 -0
  291. package/scripts/migrate-sessions-jsonl.mjs +16 -2
  292. package/scripts/migrate-vault-paths.mjs +29 -13
  293. package/scripts/parse-config.mjs +71 -14
  294. package/scripts/print-learnings-index.mjs +25 -2
  295. package/scripts/release-session-lock.mjs +94 -7
  296. package/scripts/release.mjs +451 -24
  297. package/scripts/relocate-vault-corpus.mjs +11 -7
  298. package/scripts/repair-invalid-sessions.mjs +68 -3
  299. package/scripts/resolve-session-invocation.mjs +10 -17
  300. package/scripts/run-migrate-v2-cross-repo.mjs +56 -1
  301. package/scripts/run-quality-gate.mjs +33 -7
  302. package/scripts/self-update.mjs +56 -7
  303. package/scripts/session-commit-files.mjs +133 -0
  304. package/scripts/site-numbers.mjs +52 -9
  305. package/scripts/snapshot-probe.mjs +156 -0
  306. package/scripts/so-hud.mjs +29 -6
  307. package/scripts/state-md-idle-reset.mjs +2 -1
  308. package/scripts/sweep-expired-learnings.mjs +54 -6
  309. package/scripts/sweep-expired-rules.mjs +4 -1
  310. package/scripts/validate-plugin.mjs +22 -0
  311. package/scripts/vault-integration-watcher.mjs +54 -6
  312. package/scripts/vault-mirror.mjs +9 -0
  313. package/skills/_shared/fleet-protocol.md +142 -1
  314. package/skills/_shared/instruction-file-resolution.md +1 -1
  315. package/skills/_shared/monitor-patterns.md +44 -25
  316. package/skills/_shared/platform-tools.md +7 -0
  317. package/skills/_shared/state-ownership.md +3 -2
  318. package/skills/architecture/references/domain-model.md +1 -1
  319. package/skills/bootstrap/SKILL.md +3 -1
  320. package/skills/bootstrap/_shared-template.md +4 -4
  321. package/skills/bootstrap/fast-template.md +1 -1
  322. package/skills/bootstrap/private-contract.md +1 -1
  323. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +17 -9
  324. package/skills/bootstrap/templates/agents/project-code-review.md +5 -1
  325. package/skills/bootstrap/templates/agents/project-discovery.md +5 -1
  326. package/skills/bootstrap/templates/agents/project-quality-gate.md +5 -1
  327. package/skills/claude-md-drift-check/SKILL.md +3 -3
  328. package/skills/claude-md-drift-check/checker.mjs +61 -101
  329. package/skills/close/SKILL.md +20 -7
  330. package/skills/discovery/probes/docs-staleness.mjs +7 -5
  331. package/skills/discovery/probes/frontend-slop.mjs +7 -3
  332. package/skills/discovery/probes/ssot-code-diff.mjs +5 -3
  333. package/skills/discovery/probes/supply-chain-slopcheck.mjs +10 -8
  334. package/skills/discovery/probes/vault-narrative-staleness.mjs +30 -10
  335. package/skills/discovery/probes/vault-staleness.mjs +5 -3
  336. package/skills/discovery/probes-session.md +3 -0
  337. package/skills/discovery/probes-vault.md +2 -0
  338. package/skills/dispatcher/SKILL.md +1 -1
  339. package/skills/eval/SKILL.md +3 -1
  340. package/skills/evolve/SKILL.md +2 -0
  341. package/skills/evolve/references/evolve-analyze-mode.md +3 -3
  342. package/skills/evolve/references/evolve-dialectic-mode.md +1 -1
  343. package/skills/gitlab-ops/SKILL.md +7 -6
  344. package/skills/gitlab-portfolio/SKILL.md +2 -2
  345. package/skills/go/SKILL.md +13 -1
  346. package/skills/npm-publish/SKILL.md +5 -2
  347. package/skills/plan/SKILL.md +23 -10
  348. package/skills/plan/mode-feature.md +13 -8
  349. package/skills/plan/mode-new.md +6 -14
  350. package/skills/plan/prd-feature-template.md +19 -0
  351. package/skills/plan/prd-full-template.md +12 -5
  352. package/skills/plan/prd-premortem-panel.md +48 -0
  353. package/skills/plan/prd-reviewer-prompt.md +11 -3
  354. package/skills/plan/soul.md +1 -1
  355. package/skills/portfolio/SKILL.md +5 -6
  356. package/skills/quality-gates/SKILL.md +2 -4
  357. package/skills/reconcile/SKILL.md +27 -4
  358. package/skills/release/SKILL.md +16 -8
  359. package/skills/session-end/SKILL.md +94 -11
  360. package/skills/session-end/phase-3-2-docs-verification.md +1 -0
  361. package/skills/session-end/phase-3-6-tail.md +11 -7
  362. package/skills/session-end/phase-3-7a-recommendations.md +9 -2
  363. package/skills/session-end/plan-verification.md +16 -1
  364. package/skills/session-end/references/phase-3-documentation-updates.md +14 -12
  365. package/skills/session-end/references/phase-5-issue-cleanup.md +1 -1
  366. package/skills/session-end/references/session-summary-template.md +4 -0
  367. package/skills/session-end/session-metrics-write.md +7 -4
  368. package/skills/session-plan/SKILL.md +5 -3
  369. package/skills/session-start/SKILL.md +9 -3
  370. package/skills/session-start/phase-4-5-resource-health.md +11 -5
  371. package/skills/session-start/phase-8-5-express-path.md +4 -3
  372. package/skills/session-start/references/phase-1-2-session-lock.md +7 -5
  373. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -2
  374. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  375. package/skills/session-start/references/phase-4-ssot-environment-check.md +3 -2
  376. package/skills/tmux-layout/SKILL.md +2 -2
  377. package/skills/vault-sync/validator.mjs +3 -2
  378. package/skills/wave-executor/SKILL.md +5 -4
  379. package/skills/wave-executor/references/wave-executor-state-init.md +3 -3
  380. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -5
  381. package/skills/wave-executor/references/wave-loop-review.md +13 -5
  382. package/skills/wave-executor/references/wave-loop-scope-manifest.md +1 -1
  383. package/assets/og-card.png +0 -0
  384. package/assets/og-card.svg +0 -47
  385. package/assets/wave-lifecycle.svg +0 -98
@@ -0,0 +1,670 @@
1
+ import type { EngineInterface, Register } from 'claude-code'
2
+ import type { SoBeacon, SoProfile, SoSnapshot } from '../types/index'
3
+ import { ageBeacon, completeState, configHooksFire, displayText, findings, fireableHooks, holdsProfile, PROFILES, profileLines, profileOf, profileText, statusText, validId, validateBeacon } from './logic'
4
+ import { CD_SCRIPT, classifyAllOutward, collisionVerdict, findCollisions, findTraps, GITLINKS_SCRIPT, gitlinksFromRun, hasSecret, KIND_LABEL, plainSourceRefs, pushDestinations, pushNeedsConfirm, pushRemote, pushSources, redactDeep, redactText, REPO_CONFIG_READ, repoConfigEntries, trapVerdict, UNKNOWN_DIR, type Outward, type PushConfig, type PushFacts, type RepoConfig, type SecretHit } from './rules'
5
+
6
+ // Only these closures cross helper boundaries; the engine table never does.
7
+ type IO = {
8
+ run: EngineInterface['process']['run']
9
+ read: (path: string) => Promise<string>
10
+ list: EngineInterface['fs']['list']
11
+ now: EngineInterface['clock']['now']
12
+ env: () => ReturnType<EngineInterface['env']['get']>
13
+ storeGet: EngineInterface['store']['get']
14
+ storeSet: EngineInterface['store']['set']
15
+ sessionId: EngineInterface['session']['id']
16
+ cwd: EngineInterface['session']['cwd']
17
+ model: EngineInterface['session']['model']
18
+ usage: EngineInterface['session']['usage']
19
+ pluginRoot: () => string
20
+ }
21
+
22
+ // `tool` and `text` form the hashed vector (ADR-009 "Klick-Provenienz" of lead-session-orchestrator): the tool name stands where a CLI program would.
23
+ type Receipt = { aktion: 'aussenwirkung' | 'kollision'; tool: string; text: string; art: string }
24
+ type Assessed = { hit: Outward; cwd: string | null; facts: PushFacts | null; needs: boolean }
25
+
26
+ const CONFIRM_WINDOW_MS = 30_000
27
+ // Ceiling for retrying `claude plugin list` without a Session Orchestrator root (F13): an install made mid-session shows
28
+ // within 5 minutes, `so-hud.mjs --register-root` at the next refresh (that file is read every time). Revisit if 5 minutes is too slow.
29
+ const PLUGIN_LIST_RETRY_MS = 5 * 60_000
30
+ const STEP_ONE = ['Weiter zur Bestätigung', 'Ablehnen'] as const
31
+ const STEP_TWO = ['Jetzt genau einmal ausführen', 'Ablehnen'] as const
32
+ // Tools whose `command` is a shell line: traps, outward classification and receipts apply to each.
33
+ const COMMAND_TOOLS: ReadonlySet<string> = new Set(['Bash', 'Monitor', 'PowerShell'])
34
+ // The push configuration nobody set: the most permissive one pushNeedsConfirm accepts (#1569).
35
+ const UNSET_PUSH_CONFIG: PushConfig = { push: [], remotePush: [], remoteMirror: null, remotePushDefault: null, branchPushRemote: null, submoduleRecurse: null }
36
+ // A repository with no key set (#1573): the most permissive repoConfig pushNeedsConfirm accepts.
37
+ const NO_REPO_CONFIG: RepoConfig = []
38
+ // Lists the hooks folder one name per line, prints nothing when no such path exists, and exits 2 when something
39
+ // other than a folder is there. The folder is passed as an argument, never as source.
40
+ const HOOKS_SCRIPT = `d="$1"; if [ -d "$d" ]; then ls -A -- "$d/"; elif [ -e "$d" ] || [ -L "$d" ]; then exit 2; fi`
41
+ // husky v9 (#1574): core.hooksPath is `<dir>/_`, each `_/<name>` a generated stub sourcing `_/h`, and `h` runs the user's
42
+ // `<dir>/<name>` when that file exists, else exits 0 before anything else runs. Verifies `h` starts with that exact
43
+ // prefix and every named stub is byte-for-byte husky's (trailing newlines aside): exit 0 when both hold, 4 when either
44
+ // differs (not recognisably husky), anything else when a read failed. Folder, stub, prefix and names are arguments.
45
+ const HUSKY_SCRIPT = `d="$1"; stub="$2"; prefix="$3"; shift 3; h=$(cat -- "$d/h") || exit 3; case "$h" in "$prefix"*) ;; *) exit 4 ;; esac; for n in "$@"; do if [ -e "$d/$n" ] || [ -L "$d/$n" ]; then s=$(cat -- "$d/$n") || exit 3; [ "$s" = "$stub" ] || exit 4; fi; done`
46
+ // What husky 9.1 writes (node_modules/husky/index.js and its `husky` file, measured 9.1.7); another version asks.
47
+ const HUSKY_STUB = '#!/usr/bin/env sh\n. "$(dirname "$0")/h"'
48
+ const HUSKY_H_PREFIX = '#!/usr/bin/env sh\n[ "$HUSKY" = "2" ] && set -x\nn=$(basename "$0")\ns=$(dirname "$(dirname "$0")")/$n\n\n[ ! -f "$s" ] && exit 0\n'
49
+ // The raw session id the collector accepts for --session-id (#1730): the same form as UUID_RE in
50
+ // scripts/lib/session-id.mjs (version 1-8, RFC variant). Any other id is not passed on, so it cannot read as an
51
+ // older collector and switch the flag off.
52
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
53
+
54
+ // The mod's lib has no DOM types; Web Crypto and TextEncoder are globals in the mod engine (as in rules.ts).
55
+ type WebGlobals = {
56
+ crypto: { subtle: { digest(algorithm: string, data: Uint8Array): Promise<ArrayBuffer> } }
57
+ TextEncoder: new () => { encode(input: string): Uint8Array }
58
+ }
59
+ async function sha256(text: string): Promise<string> {
60
+ const { crypto, TextEncoder } = globalThis as unknown as WebGlobals
61
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
62
+ return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('')
63
+ }
64
+
65
+ export const register: Register = (on, options) => {
66
+ let io: IO
67
+ let current: SoSnapshot | null = null
68
+ let own: SoBeacon | null = null
69
+ let interactive = false
70
+ let refreshing = false
71
+ let sessionIdFlag = true
72
+ let ending = false
73
+ let cancel: (() => void) | undefined
74
+ let beaconWrites: Promise<unknown> = Promise.resolve()
75
+ const problems = new Set<string>()
76
+ // Calls already passed downstream; a repeated id never runs twice. One entry per tool call — a session's
77
+ // few thousand calls stay small; revisit if sessions ever run unbounded.
78
+ const passed = new Set<string>()
79
+ // What the gate read off a call before anything could fail, for its `.catch` (dropped once the call settles).
80
+ const classes = new Map<string, { outward: boolean; sec008: boolean }>()
81
+ const profile: SoProfile = profileOf(options.strictness)
82
+ // Profile band (#1552). Closure state, because $.state refuses writes while a render hook draws. `chosen` stays
83
+ // null until session.start has read it, so a chosen profile never flashes the band; `opened` is /profile's.
84
+ let chosen: boolean | null = null
85
+ let opened = false
86
+ // "Schließen" hides the band for this session only; it returns at the next session start while nothing is chosen.
87
+ let dismissed = false
88
+ let choosing = false
89
+ // When root() last asked `claude plugin list`; a miss is never stored, so without this every 15-second refresh spawned it.
90
+ let pluginListAt: number | null = null
91
+ const helper = async (io: IO, action: string, id = '', input: unknown = null) => {
92
+ const result = await io.run(['node', `${io.pluginRoot()}/hooks/beacon-io.mjs`, action, id], { stdin: JSON.stringify(input), timeoutMs: 3000 })
93
+ if (result.exitCode !== 0) throw new Error('beacon-io')
94
+ return JSON.parse(result.stdout) as unknown
95
+ }
96
+ const snapshot = async (io: IO): Promise<SoSnapshot | null> => current ? {
97
+ ...current, age_ms: Math.max(0, await io.now() - Date.parse(current.measured_at)),
98
+ degraded: [...new Set([...current.degraded, ...problems])],
99
+ } : null
100
+ const root = async (io: IO): Promise<string | null> => {
101
+ if (typeof options.soRoot === 'string' && options.soRoot.startsWith('/')) {
102
+ await io.storeSet('so-root', options.soRoot)
103
+ return options.soRoot
104
+ }
105
+ const cached = await io.storeGet('so-root')
106
+ if (typeof cached === 'string' && cached.startsWith('/')) return cached
107
+ const home = await io.env()
108
+ let found: string | null = null
109
+ if (home) {
110
+ try { const p = (await io.read(`${home}/.config/session-orchestrator/hud/so-root`)).trim(); if (p.startsWith('/')) found = p } catch { /* try installed plugins */ }
111
+ }
112
+ // At most one `claude plugin list` per PLUGIN_LIST_RETRY_MS while no root is found, whatever its outcome.
113
+ const now = found ? null : await io.now()
114
+ if (now !== null && (pluginListAt === null || now - pluginListAt >= PLUGIN_LIST_RETRY_MS)) {
115
+ pluginListAt = now
116
+ const result = await io.run(['claude', 'plugin', 'list', '--json'], { timeoutMs: 3000 })
117
+ if (result.exitCode === 0) {
118
+ const data = JSON.parse(result.stdout)
119
+ const rows: unknown[] = Array.isArray(data) ? data : Array.isArray(data.plugins) ? data.plugins : []
120
+ for (const row of rows) {
121
+ const p = row as { name?: string; id?: string; installPath?: string }
122
+ if ((p.name ?? p.id ?? '').split('@')[0] === 'session-orchestrator' && p.installPath?.startsWith('/')) { found = p.installPath; break }
123
+ }
124
+ }
125
+ }
126
+ if (found) await io.storeSet('so-root', found)
127
+ return found
128
+ }
129
+ const write = async (io: IO, change: Partial<SoBeacon> = {}) => {
130
+ if (ending || !own) return
131
+ const now = new Date(await io.now()).toISOString()
132
+ if (ending || !own) return
133
+ own = { ...own, ...change, seit: change.zustand && change.zustand !== own.zustand ? now : own.seit, aktualisiert_at: now }
134
+ const value = { ...own }
135
+ beaconWrites = beaconWrites.catch(() => undefined).then(() => helper(io, 'write', value.session_id, value))
136
+ await beaconWrites.catch(() => { problems.add('beacon-write-failed') })
137
+ }
138
+ const readBeacons = async (io: IO): Promise<SoBeacon[]> => {
139
+ const home = await io.env()
140
+ if (!home) return []
141
+ const dir = `${home}/.config/session-orchestrator/hud/beacons`
142
+ const entries = await io.list(dir).catch(() => [])
143
+ const beacons: SoBeacon[] = []
144
+ for (const f of entries) {
145
+ if (f.kind !== 'file' || f.isLink || f.size > 4096 || !f.name.endsWith('.json')) continue
146
+ try { const b: unknown = JSON.parse(await io.read(`${dir}/${f.name}`)); if (validateBeacon(b) && f.name === `${b.session_id}.json`) beacons.push(b) } catch { /* skip unreadable */ }
147
+ }
148
+ const alive = await helper(io, 'alive', '', beacons.map(b => b.pid)).catch(() => []) as number[]
149
+ const now = await io.now()
150
+ return beacons.map(b => ageBeacon(b, now, b.pid !== null && alive.includes(b.pid)))
151
+ }
152
+ const refresh = async (io: IO) => {
153
+ if (refreshing || ending) return
154
+ refreshing = true
155
+ try {
156
+ const soRoot = await root(io)
157
+ if (!soRoot) throw new Error('not-configured')
158
+ // #1730: the session's agents are read beside its own lock, which need not be the cwd's checkout. A collector
159
+ // older than #1730 refuses the unknown flag with the usage exit 2: then it is dropped for the rest of the session.
160
+ const argv = ['node', `${soRoot}/scripts/so-hud.mjs`, '--json', '--repo-root', await io.cwd()]
161
+ const sid = await io.sessionId()
162
+ const withId = sessionIdFlag && UUID_RE.test(sid)
163
+ let result = await io.run(withId ? [...argv, '--session-id', sid] : argv, { timeoutMs: 3000 })
164
+ if (withId && result.exitCode === 2) {
165
+ sessionIdFlag = false
166
+ result = await io.run(argv, { timeoutMs: 3000 })
167
+ }
168
+ if (result.exitCode !== 0) throw new Error('collector-failed')
169
+ const value = JSON.parse(result.stdout) as SoSnapshot
170
+ if (value.schema_version !== 1 || !Number.isFinite(Date.parse(value.measured_at)) || !value.wave || !value.state || !value.gate || !value.vault || !Array.isArray(value.peers) || !Array.isArray(value.degraded)) throw new Error('invalid-snapshot')
171
+ current = value
172
+ for (const reason of ['not-configured', 'refresh-failed', 'collector-failed', 'invalid-snapshot']) problems.delete(reason)
173
+ } catch (error) {
174
+ for (const reason of ['not-configured', 'refresh-failed', 'collector-failed', 'invalid-snapshot']) problems.delete(reason)
175
+ const reason = error instanceof Error ? error.message : ''
176
+ problems.add(['not-configured', 'collector-failed', 'invalid-snapshot'].includes(reason) ? reason : 'refresh-failed')
177
+ }
178
+ finally { refreshing = false }
179
+ try {
180
+ const usage = await io.usage()
181
+ await write(io, { kontext_prozent: usage.context.percent ?? null, welle: current?.wave.number ?? null })
182
+ } catch { problems.add('usage-unavailable') }
183
+ }
184
+
185
+ // ---- Protection gates (#1551): host reads for one call ----------------------------
186
+ const resolveCwd = async (io: IO, dir: string | null): Promise<string | null> => {
187
+ const sessionCwd = await io.cwd()
188
+ if (dir === null) return sessionCwd
189
+ // A folder the rules could not pin down asks without relying on how the host resolves the sentinel.
190
+ if (dir === UNKNOWN_DIR || dir.startsWith(`${UNKNOWN_DIR}/`)) return null
191
+ const ran = await io.run(['bash', '-c', CD_SCRIPT, 'so-hud', dir], { cwd: sessionCwd, timeoutMs: 5000 })
192
+ const out = ran.exitCode === 0 ? ran.stdout.trim() : ''
193
+ return out.startsWith('/') ? out : null
194
+ }
195
+ const pushFacts = async (io: IO, hit: Outward, cwd: string): Promise<PushFacts> => {
196
+ const git = async (args: string[]) => {
197
+ const ran = await io.run(['git', ...args], { cwd, timeoutMs: 5000 })
198
+ const out = ran.exitCode === 0 ? ran.stdout.trim() : ''
199
+ return { ok: ran.exitCode === 0, value: out === '' ? null : out }
200
+ }
201
+ const current = (await git(['rev-parse', '--abbrev-ref', 'HEAD'])).value
202
+ const upstream = (await git(['rev-parse', '--abbrev-ref', '@{u}'])).value
203
+ const remote = current !== null && current !== 'HEAD' ? (await git(['config', `branch.${current}.remote`])).value : null
204
+ const target = pushRemote(hit, remote)
205
+ // Config-defined remotes only: `git remote get-url` also answers a legacy .git/remotes/<name> file, whose `Push:` line
206
+ // can send a branch to main without any `remote.<name>.push` the configuration read below could see (#1569).
207
+ const remoteUrlKnown = !target.startsWith('-') && (await git(['config', '--get', `remote.${target}.url`])).value !== null
208
+ const dsts = pushDestinations(hit, current, upstream, target)
209
+ let tagRefs: string[] | null = null
210
+ if (dsts !== null) {
211
+ tagRefs = []
212
+ for (const dst of dsts) if ((await git(['rev-parse', '--verify', '--quiet', `refs/tags/${dst}`])).ok) tagRefs.push(dst)
213
+ }
214
+ const facts: PushFacts = { current, upstream, remote, remoteUrlKnown, tagRefs, pushConfig: null }
215
+ // Each later fact as measured, or else its most permissive value: what a fact decides is judged with the rest clean.
216
+ const clean = (): PushFacts => ({ hooks: false, repoConfig: NO_REPO_CONFIG, gitlinks: false, sourceRefs: plainSourceRefs(hit), ...facts })
217
+ // Read only what decides: a push that asks with nothing configured and no hooks asks whatever is there.
218
+ if (!pushNeedsConfirm(hit, { ...clean(), pushConfig: UNSET_PUSH_CONFIG })) {
219
+ facts.pushConfig = await pushConfig(io, cwd, target, current)
220
+ // #1570: each named source as git resolves it — a symbolic ref or a `refs/<name>` must not stand in for the branch.
221
+ if (!pushNeedsConfirm(hit, clean()) && pushNeedsConfirm(hit, { ...clean(), sourceRefs: undefined })) {
222
+ const sourceRefs: (string | null)[] = []
223
+ for (const src of pushSources(hit)) sourceRefs.push((await git(['rev-parse', '--symbolic-full-name', src])).value)
224
+ facts.sourceRefs = sourceRefs
225
+ }
226
+ // Each of the rest only where it decides: the push runs with it clean and asks with it unread.
227
+ if (!pushNeedsConfirm(hit, clean()) && pushNeedsConfirm(hit, { ...clean(), hooks: undefined })) facts.hooks = await hooksFact(io, cwd, hit.gitBefore)
228
+ // The repository's keys (#1573): only a push behind a git step reads them.
229
+ if (!pushNeedsConfirm(hit, clean()) && pushNeedsConfirm(hit, { ...clean(), repoConfig: undefined })) facts.repoConfig = await repoConfig(io, cwd)
230
+ // …and whether a step can walk into a submodule or embedded repository (#1573 review). Order matters: the count
231
+ // runs `git ls-files`, which itself runs a configured core.fsmonitor, so it must come after repoConfig — a
232
+ // program-carrying fsmonitor makes clean() ask there and this read is skipped. Pinned by schutz.test.ts.
233
+ if (!pushNeedsConfirm(hit, clean()) && pushNeedsConfirm(hit, { ...clean(), gitlinks: undefined })) facts.gitlinks = await gitlinksFact(io, cwd)
234
+ }
235
+ return facts
236
+ }
237
+ /** Whether the push folder's index holds a gitlink; null when the count failed (asks). */
238
+ const gitlinksFact = async (io: IO, cwd: string): Promise<boolean | null> => {
239
+ const ran = await io.run(['bash', '-c', GITLINKS_SCRIPT, 'so-hud-gitlinks'], { cwd, timeoutMs: 5000 })
240
+ return ran.isStdoutTruncated ? null : gitlinksFromRun(ran.exitCode, ran.stdout)
241
+ }
242
+ /** Every configured key with its scope (#1573), one `git config` call; null when it fails, warns or does not parse (asks). */
243
+ const repoConfig = async (io: IO, cwd: string): Promise<RepoConfig | null> => {
244
+ const ran = await io.run(['git', ...REPO_CONFIG_READ], { cwd, timeoutMs: 5000 })
245
+ if (ran.exitCode !== 0 || ran.isStdoutTruncated || ran.stderr !== '') return null
246
+ return repoConfigEntries(ran.stdout)
247
+ }
248
+ /**
249
+ * Whether a git step earlier on the line may run a hook that moves HEAD or the push configuration (MR !133), judged
250
+ * only on the hook names those steps can fire (#1574, {@link fireableHooks}; an unmapped step: any name):
251
+ * true when a hook defined in config runs on one of them, or the effective hooks folder (`git rev-parse --git-path
252
+ * hooks`, which follows core.hooksPath) holds a file of that name (or `<name>.<ext>`, `*.sample` aside). In a
253
+ * husky v9 folder ({@link HUSKY_SCRIPT}) a stub counts only when the user's `<dir>/<name>` beside `_` exists.
254
+ * false only when all of that was measured absent; null when any read failed or did not parse. Measured in the
255
+ * push's folder, so a step run in another repo (`cd ../b && git commit && cd - && git push`) is judged by this
256
+ * repo's hooks; revisit if that form shows up.
257
+ */
258
+ const hooksFact = async (io: IO, cwd: string, steps: readonly string[] | undefined): Promise<boolean | null> => {
259
+ const names = fireableHooks(steps)
260
+ const hooksPath = await io.run(['git', 'config', '--get', 'core.hooksPath'], { cwd, timeoutMs: 5000 })
261
+ const hooksPathSet = hooksPath.exitCode === 0 && hooksPath.stdout.trim() !== ''
262
+ if (!hooksPathSet && (hooksPath.exitCode !== 1 || hooksPath.stdout !== '' || hooksPath.stderr !== '')) return null
263
+ // Hooks defined in config (`hook.<name>.command` + `.event`, any level, includes followed) run like a file in the
264
+ // folder (measured, git 2.56). Exit 1 with nothing printed is none set.
265
+ const configured = await io.run(['git', 'config', '--null', '--get-regexp', '^hook\\.'], { cwd, timeoutMs: 5000 })
266
+ if (configured.stderr !== '' || configured.isStdoutTruncated) return null
267
+ if (configured.exitCode === 0 && configured.stdout !== '') {
268
+ const fires = configHooksFire(configured.stdout, names)
269
+ if (fires !== false) return fires
270
+ } else if (configured.exitCode !== 1 || configured.stdout !== '') return null
271
+ // `.git/hooks`, core.hooksPath, or the common folder's in a worktree; a relative path is relative to cwd, where the listing runs.
272
+ const where = await io.run(['git', 'rev-parse', '--git-path', 'hooks'], { cwd, timeoutMs: 5000 })
273
+ const dir = where.exitCode === 0 && !where.isStdoutTruncated ? where.stdout.replace(/\n$/, '') : ''
274
+ if (dir === '') return null
275
+ const list = async (folder: string): Promise<string[] | null> => {
276
+ const listed = await io.run(['bash', '-c', HOOKS_SCRIPT, 'so-hud-hooks', folder], { cwd, timeoutMs: 5000 })
277
+ // A name holding a newline splits, but its last part ends as the name does, so it still counts.
278
+ return listed.exitCode !== 0 || listed.isStdoutTruncated ? null : listed.stdout.split('\n').filter(name => name !== '')
279
+ }
280
+ const entries = await list(dir)
281
+ if (entries === null) return null
282
+ const wanted = names === null ? null : [...names]
283
+ // Case-insensitive: on APFS or NTFS git runs `PRE-COMMIT` as pre-commit, and husky's `[ -f ]` finds `.husky/Pre-Commit`.
284
+ // On a case-sensitive filesystem this over-counts, which only asks (fail-closed).
285
+ const fires = (name: string): boolean => {
286
+ const lower = name.toLowerCase()
287
+ return !lower.endsWith('.sample') && (wanted === null || wanted.some(n => lower === n || lower.startsWith(`${n}.`)))
288
+ }
289
+ const hooks = entries.filter(fires)
290
+ const folder = dir.replace(/\/+$/, '')
291
+ const husky = hooksPathSet && (folder === '_' || folder.endsWith('/_')) && entries.includes('h')
292
+ if (!husky || wanted === null || hooks.length === 0) return hooks.length > 0
293
+ // husky: a `<name>.<ext>` beside the stubs is no husky file; a stub is checked, then the user's file beside `_`.
294
+ const stubs = wanted.filter(n => entries.includes(n))
295
+ if (hooks.length > stubs.length) return true
296
+ const verified = await io.run(['bash', '-c', HUSKY_SCRIPT, 'so-hud-husky', folder, HUSKY_STUB, HUSKY_H_PREFIX, ...stubs], { cwd, timeoutMs: 5000 })
297
+ if (verified.exitCode === 4) return true
298
+ if (verified.exitCode !== 0) return null
299
+ // `h` runs `$(dirname "$(dirname "$0")")/<name>`: the folder above `_`, taken as written, as `dirname` does.
300
+ const user = await list(folder === '_' ? '.' : folder.slice(0, -2) || '/')
301
+ return user === null ? null : user.some(name => stubs.includes(name.toLowerCase()))
302
+ }
303
+ /** The push configuration (#1569 P2), each key read with `git config --null`; null when any read failed. */
304
+ const pushConfig = async (io: IO, cwd: string, target: string, current: string | null): Promise<PushConfig | null> => {
305
+ // The entries of one read: [] for unset (exit 1, nothing printed), null for any other failure.
306
+ const read = async (args: string[]): Promise<string[] | null> => {
307
+ const ran = await io.run(['git', 'config', '--null', ...args], { cwd, timeoutMs: 5000 })
308
+ if (ran.isStdoutTruncated) return null
309
+ if (ran.exitCode === 1 && ran.stdout === '' && ran.stderr === '') return []
310
+ return ran.exitCode === 0 && ran.stdout.endsWith('\0') ? ran.stdout.slice(0, -1).split('\0') : null
311
+ }
312
+ // A key read with --get: one entry, or none when unset; undefined when the output does not parse.
313
+ const one = (entries: string[] | null): string | null | undefined => entries === null || entries.length > 1 ? undefined : entries[0] ?? null
314
+ const push = await read(['--get-regexp', '^push\\.'])
315
+ const remotePush = await read(['--get-all', `remote.${target}.push`])
316
+ const remoteMirror = one(await read(['--type=bool', '--get', `remote.${target}.mirror`]))
317
+ const remotePushDefault = one(await read(['--get', 'remote.pushDefault']))
318
+ const branchPushRemote = current === null || current === 'HEAD' ? null : one(await read(['--get', `branch.${current}.pushRemote`]))
319
+ const submoduleRecurse = one(await read(['--type=bool', '--get', 'submodule.recurse']))
320
+ if (push === null || remotePush === null || remoteMirror === undefined || remotePushDefault === undefined || branchPushRemote === undefined || submoduleRecurse === undefined) return null
321
+ // `key\nvalue`, or the key alone when it was written without `=`.
322
+ const entries = push.map(entry => entry.includes('\n') ? [entry.slice(0, entry.indexOf('\n')), entry.slice(entry.indexOf('\n') + 1)] as const : [entry, null] as const)
323
+ return { push: entries, remotePush, remoteMirror, remotePushDefault, branchPushRemote, submoduleRecurse }
324
+ }
325
+ const assess = async (io: IO, hit: Outward): Promise<Assessed> => {
326
+ let cwd: string | null = null
327
+ let facts: PushFacts | null = null
328
+ try {
329
+ cwd = await resolveCwd(io, hit.dir)
330
+ if (hit.kind === 'push' && cwd !== null) facts = await pushFacts(io, hit, cwd)
331
+ } catch { facts = null } // a failed measurement only ever asks (fail-closed)
332
+ return { hit, cwd, facts, needs: hit.kind !== 'push' || facts === null || pushNeedsConfirm(hit, facts) }
333
+ }
334
+ // The commits a push sends, measured — never guessed. Only a single destination that is the branch
335
+ // checked out has a known source (HEAD); every other shape says it could not be measured.
336
+ const effect = async (io: IO, a: Assessed): Promise<string[]> => {
337
+ const head = `${KIND_LABEL[a.hit.kind]}: ${displayText(a.hit.label)}`
338
+ if (a.hit.kind !== 'push') return [head]
339
+ const unmeasured = [head, 'Ausgehende Commits: nicht messbar']
340
+ if (a.cwd === null || a.facts === null || a.hit.args.some(arg => !arg.startsWith('-') && arg.includes(':'))) return unmeasured
341
+ const remote = pushRemote(a.hit, a.facts.remote)
342
+ const dsts = pushDestinations(a.hit, a.facts.current, a.facts.upstream, remote)
343
+ if (dsts?.length !== 1 || dsts[0] !== a.facts.current || remote.startsWith('-')) return unmeasured
344
+ const ran = await io.run(['git', 'log', '--oneline', '-n', '10', `${remote}/${dsts[0]}..HEAD`], { cwd: a.cwd, timeoutMs: 5000 }).catch(() => null)
345
+ if (ran?.exitCode !== 0) return unmeasured
346
+ const lines = ran.stdout.split('\n').map(line => line.trim()).filter(Boolean)
347
+ return [head, `Ausgehende Commits nach ${remote}/${dsts[0]}: ${lines.length === 0 ? 'keine' : lines.length}`, ...lines.map(line => ` ${displayText(line)}`)]
348
+ }
349
+ /** Appends one receipt; false when it could not be written. Never throws: a write failure must not deny or re-run. */
350
+ const receipt = async (io: IO, r: Receipt, ergebnis: 'ausgefuehrt' | 'abgelehnt' | 'verfallen' | 'ohne-zeichenflaeche', exit: number | null): Promise<boolean> => {
351
+ try {
352
+ const sessionId = await io.sessionId()
353
+ await helper(io, 'aktion', sessionId, { zeit: new Date(await io.now()).toISOString(), session_id: sessionId, vorgang: 'pane-aktion',
354
+ aktion: r.aktion, argv_sha256: await sha256(JSON.stringify([r.tool, r.text])), exit, profil: profile, ergebnis, art: r.art })
355
+ return true
356
+ } catch {
357
+ problems.add('receipt-failed')
358
+ return false
359
+ }
360
+ }
361
+
362
+ const initialize = async (io: IO, cwd: string) => {
363
+ ending = false
364
+ const id = await io.sessionId()
365
+ if (own?.session_id !== id) current = null
366
+ if (validId(id)) {
367
+ const now = new Date(await io.now()).toISOString()
368
+ const identity = await helper(io, 'identity').catch(() => null) as { pid: number } | null
369
+ own = { schema_version: 1, session_id: id, name: null, repo_name: cwd.split('/').at(-1) ?? null, branch: null,
370
+ pid: identity?.pid ?? null, zustand: 'unbekannt', seit: now, warte_grund: null, heuristisch: false,
371
+ kontext_prozent: null, letzter_request_at: null, modell: await io.model().catch(() => null), welle: null, aktualisiert_at: now }
372
+ }
373
+ await refresh(io)
374
+ }
375
+
376
+ // Register the whole chain before any engine call. engine.create is post-order, never caught.
377
+ on('engine.create', async (_$, e, next) => {
378
+ const beneath = await next(e)
379
+ io = {
380
+ run: (argv, init) => beneath.process.run(argv, init),
381
+ read: (path) => beneath.fs.read(path),
382
+ list: (path) => beneath.fs.list(path),
383
+ now: () => beneath.clock.now(),
384
+ env: () => beneath.env.get('HOME'),
385
+ storeGet: (key) => beneath.store.get(key),
386
+ storeSet: (key, value) => beneath.store.set(key, value),
387
+ sessionId: () => beneath.session.id(),
388
+ cwd: () => beneath.session.cwd(),
389
+ model: () => beneath.session.model(),
390
+ usage: () => beneath.session.usage(),
391
+ pluginRoot: () => beneath.plugin.root,
392
+ }
393
+ return { ...beneath, so: { version: () => 1 as const, snapshot: () => snapshot(io), beacons: () => readBeacons(io), profile: () => profile } }
394
+ })
395
+ on('session.start', async ($, e, next) => {
396
+ interactive = e.isInteractive
397
+ // Band and /profile need no Session Orchestrator root, collector or vault (F13), so they come before initialize.
398
+ await $.command.register({ name: 'profile', description: 'so-hud: Profil zeigen und wechseln (locker, ausgewogen, streng)', immediate: true })
399
+ .catch(() => { try { $.ui.toast('[so-hud] /profile ließ sich nicht anlegen; Profil in /config wechseln.', { timeoutMs: 8000 }) } catch { /* best effort */ } })
400
+ // Chosen = this mod stored a choice, or any settings source holds one (also set in /config by hand). A failed read shows the band.
401
+ try { chosen = await $.store.get('profile-chosen') === true || holdsProfile(await $.settings.read(), $.plugin.name) }
402
+ catch { chosen = false }
403
+ dismissed = false
404
+ if (interactive) $.ui.invalidate('ui.render')
405
+ await initialize(io, e.cwd)
406
+ const updateUI = async () => {
407
+ if (interactive && !ending) {
408
+ $.ui.status(statusText(await snapshot(io), profile, 'de', [...problems]))
409
+ $.ui.invalidate('ui.render')
410
+ }
411
+ }
412
+ await updateUI()
413
+ cancel?.()
414
+ const timer = $.clock.every(15_000, async () => {
415
+ try { await refresh(io); await updateUI() }
416
+ catch { problems.add('refresh-failed') }
417
+ })
418
+ cancel = () => timer.cancel()
419
+ return next(e)
420
+ }).catch(($, e, next) => next(e))
421
+ on('turn.start', async ($, e, next) => {
422
+ if (ending || !own || own.session_id !== await io.sessionId()) {
423
+ await initialize(io, await io.cwd())
424
+ const updateUI = async () => {
425
+ if (interactive && !ending) {
426
+ $.ui.status(statusText(await snapshot(io), profile, 'de', [...problems]))
427
+ $.ui.invalidate('ui.render')
428
+ }
429
+ }
430
+ await updateUI()
431
+ cancel?.()
432
+ const timer = $.clock.every(15_000, async () => {
433
+ try { await refresh(io); await updateUI() }
434
+ catch { problems.add('refresh-failed') }
435
+ })
436
+ cancel = () => timer.cancel()
437
+ }
438
+ await write(io, { zustand: 'arbeitet', warte_grund: null, heuristisch: false, letzter_request_at: new Date(await io.now()).toISOString() })
439
+ return next(e)
440
+ }).catch(($, e, next) => next(e))
441
+ on('turn.complete', async ($, e, next) => {
442
+ if (!e.agentId) await write(io, completeState(e.answer))
443
+ return next(e)
444
+ }).catch(($, e, next) => next(e))
445
+ // The protection gate (#1551). Registered before the beacon hook: a plugin's registrations nest in order,
446
+ // first outermost, so a deny here never reaches the beacon and a confirmed call runs through it once.
447
+ on('tool.call', async ($, e, next) => {
448
+ const id = e.tool_use_id
449
+ if (passed.has(id)) return { deny: '[so-hud] Dieser Aufruf wurde bereits weitergereicht und läuft nicht erneut.' }
450
+ const args = e as unknown as Record<string, unknown>
451
+ const command = COMMAND_TOOLS.has(e.tool) && typeof args.command === 'string' ? args.command : null
452
+ const traps = command === null ? [] : findTraps(command)
453
+ const outward = command === null ? [] : classifyAllOutward(command)
454
+ classes.set(id, { outward: outward.length > 0, sec008: traps.some(trap => trap.klasse === 'sec008') })
455
+ const done = <T,>(value: T): T => { classes.delete(id); return value }
456
+ const toast = (text: string) => { try { $.ui.toast(text, { timeoutMs: 8000 }) } catch { /* best effort, never a deny */ } }
457
+ type Ergebnis = Parameters<typeof receipt>[2]
458
+ const record = async (list: readonly Receipt[], ergebnis: Ergebnis, exit: number | null) => {
459
+ let ok = true
460
+ for (const r of list) ok = await receipt(io, r, ergebnis, exit) && ok
461
+ if (!ok) toast('[so-hud] Aktionsbeleg ließ sich nicht schreiben.')
462
+ }
463
+ const after: Receipt[] = []
464
+ // Who can be asked is decided per call: an SDK host or an attached app draws although the session started
465
+ // non-interactive. Only a run with no surface at all (plain -p) has nobody to ask. A failing read throws
466
+ // into the gate's `.catch`, which refuses (fail-closed).
467
+ let askable: boolean | null = null
468
+ const canAsk = async () => {
469
+ if (askable === null) askable = interactive || (await $.session.surfaces()).length > 0
470
+ return askable
471
+ }
472
+
473
+ // Traps the rules name (SEC-008 refuses on every profile).
474
+ const verdict = trapVerdict(traps, profile)
475
+ if (verdict.aktion === 'ablehnen') return done({ deny: verdict.text })
476
+ if (verdict.aktion === 'warnen') toast(verdict.text)
477
+
478
+ // Actions with an effect outside (F9): two steps inside the held call, never deny-and-rerun.
479
+ if (command !== null && outward.length > 0) {
480
+ const measure = async () => {
481
+ const all: Assessed[] = []
482
+ for (const hit of outward) all.push(await assess(io, hit))
483
+ const needing = all.filter(a => a.needs)
484
+ const effects: string[][] = []
485
+ for (const a of needing) effects.push(await effect(io, a))
486
+ return { needing, effects, key: JSON.stringify([all.map(a => [a.needs, a.cwd, a.facts]), effects]) }
487
+ }
488
+ const { needing, effects, key } = await measure()
489
+ const receipts = needing.map(a => ({ aktion: 'aussenwirkung' as const, tool: e.tool, text: command, art: a.hit.kind }))
490
+ if (needing.length > 0 && !await canAsk()) {
491
+ // No surface to confirm on: the CLI's own gates decide; the mod only records.
492
+ await record(receipts, 'ohne-zeichenflaeche', null)
493
+ } else if (needing.length > 0) {
494
+ const labels = [...new Set(needing.map(a => KIND_LABEL[a.hit.kind]))].join(' + ')
495
+ const lines = [`Befehl: ${displayText(await redactText(command, []), 300)}`]
496
+ needing.forEach((a, i) => lines.push(`Ordner: ${displayText(a.cwd ?? 'nicht auflösbar', 300)}`, ...effects[i] ?? []))
497
+ const decline = async (ergebnis: 'abgelehnt' | 'verfallen', text: string) => {
498
+ await record(receipts, ergebnis, null)
499
+ return done({ deny: text })
500
+ }
501
+ const refused = `[so-hud] Der Owner hat ${labels} abgelehnt; nichts ausgeführt. Denselben Aufruf nicht erneut versuchen.`
502
+ const first = await $.ui.ask([...lines, `${labels} freigeben?`].join('\n'), { options: [...STEP_ONE], header: 'Außenwirkung' }).catch(() => null)
503
+ if (first !== STEP_ONE[0]) return decline('abgelehnt', refused)
504
+ const firstAt = await io.now()
505
+ // Step two shows the facts measured again; a push whose facts moved since step one is not confirmed on old ones.
506
+ const again = await measure()
507
+ if (again.key !== key) return decline('abgelehnt', '[so-hud] Stand hat sich geändert; neu bestätigen. Nichts ausgeführt.')
508
+ const second = await $.ui.ask([...again.effects.flat(), `${labels} jetzt genau einmal ausführen?`].join('\n'), { options: [...STEP_TWO], header: 'Bestätigen' }).catch(() => null)
509
+ if (await io.now() - firstAt > CONFIRM_WINDOW_MS) return decline('verfallen', '[so-hud] Bestätigung verfallen; nichts ausgeführt.')
510
+ if (second !== STEP_TWO[0]) return decline('abgelehnt', refused)
511
+ after.push(...receipts)
512
+ }
513
+ }
514
+
515
+ // Collisions with a peer's wave scope (F8), on the cached snapshot only: no process start here.
516
+ const filePath = ['Edit', 'Write', 'NotebookEdit'].includes(e.tool)
517
+ ? typeof args.file_path === 'string' ? args.file_path : typeof args.notebook_path === 'string' ? args.notebook_path : null : null
518
+ if (filePath !== null && current !== null) {
519
+ const { hits } = findCollisions({ filePath, repoRoot: current.repo.root, ownSessionId: await io.sessionId(), peers: current.peers })
520
+ const collision = collisionVerdict(hits, profile)
521
+ if (collision.aktion === 'ablehnen') return done({ deny: collision.text })
522
+ if (collision.aktion === 'warnen') toast(collision.text)
523
+ if (collision.aktion === 'fragen') {
524
+ const r: Receipt = { aktion: 'kollision', tool: e.tool, text: filePath, art: e.tool.toLowerCase() }
525
+ const asking = await canAsk()
526
+ const answer = asking ? await $.ui.ask(`${collision.text}\nTrotzdem in ${displayText(filePath)} schreiben?`, { options: [...collision.optionen], header: 'Kollision' }).catch(() => null) : null
527
+ if (answer === 'Trotzdem') after.push(r)
528
+ else {
529
+ if (!asking) {
530
+ const who = [...new Set(hits.map(hit => hit.session_id))].join(', ')
531
+ return done({ deny: `[so-hud] Kollision mit ${who} und niemand zum Fragen da (kopfloser Lauf); nichts geschrieben.` })
532
+ }
533
+ await record([r], 'abgelehnt', null)
534
+ const sid = (await io.sessionId()).slice(0, 8)
535
+ return done({ deny: answer === 'Worktree vorschlagen'
536
+ ? `${collision.text}\nNichts geschrieben. Vorschlag: git -C ${current.repo.root} worktree add ${current.repo.root}-${sid} -b wt/${sid} — dort weiterarbeiten.`
537
+ : `${collision.text}\n[so-hud] Abgebrochen; nichts geschrieben. Nicht erneut versuchen, ohne den Owner zu fragen.` })
538
+ }
539
+ }
540
+ }
541
+
542
+ passed.add(id)
543
+ const ran = await next(e)
544
+ if (after.length > 0) {
545
+ const outcome = ran.deny !== undefined ? 'abgelehnt' : 'ausgefuehrt'
546
+ const code = (ran.result as { exitCode?: unknown } | undefined)?.exitCode
547
+ const exit = ran.deny !== undefined ? null : typeof code === 'number' && Number.isSafeInteger(code) ? code : ran.isError === true ? 1 : 0
548
+ await record(after, outcome, exit)
549
+ }
550
+ if (ran.deny !== undefined) return done(ran)
551
+
552
+ // Secret shield (SEC-008): what reaches the model never carries a token.
553
+ // Both channels are checked: a hook's own `{ result }` carries no `text`, and an empty or clean `text` says
554
+ // nothing about `result` — either may be what the model reads.
555
+ const serialized = JSON.stringify(ran.result ?? null)
556
+ if (!hasSecret(ran.text ?? '') && !hasSecret(serialized)) return done(ran)
557
+ const hits: SecretHit[] = []
558
+ const kinds = () => [...new Set(hits.map(hit => hit.name))].join(', ')
559
+ if (ran.isError === true) {
560
+ const text = await redactText(ran.text || serialized, hits)
561
+ toast(`[so-hud] Secret in der Ausgabe geschwärzt (${kinds()}); rotieren.`)
562
+ return done({ deny: `[so-hud] Fehlerausgabe enthielt ein Secret und ist geschwärzt:\n${text}` })
563
+ }
564
+ const result = await redactDeep(ran.result, hits) as typeof ran.result
565
+ const text = ran.text === undefined ? {} : { text: await redactText(ran.text, hits) }
566
+ toast(`[so-hud] Secret in der Ausgabe geschwärzt (${kinds()}); rotieren.`)
567
+ return done({ result, ...text, context: [`[so-hud] Die Ausgabe enthielt ein Secret (${kinds()}) und ist geschwärzt. Den Wert nicht erneut ausgeben; den Owner auf Rotation hinweisen.`] })
568
+ }).catch(($, e) => {
569
+ const id = e.tool_use_id
570
+ const seen = classes.get(id)
571
+ classes.delete(id)
572
+ if (passed.has(id)) return { deny: '[so-hud] Ausgabe zurückgehalten (Fehler im Schutz-Hook).' }
573
+ if (profile === 'locker' && seen !== undefined && !seen.outward && !seen.sec008) {
574
+ try { $.ui.toast('[so-hud] Schutz-Hook fehlgeschlagen; der Aufruf läuft ungeprüft (Profil locker).', { timeoutMs: 8000 }) } catch { /* best effort */ }
575
+ return undefined
576
+ }
577
+ return { deny: '[so-hud] Schutz-Hook fehlgeschlagen; der Aufruf läuft nicht.' }
578
+ })
579
+ // The engine refuses a second unmatched registration of one event; this match-all matcher (every tool name)
580
+ // lets the beacon hook stand beneath the gate. The gate itself stays unmatched, so no call can slip past it.
581
+ on('tool.call', { tool: /^/ }, async ($, e, next) => {
582
+ if (e.agentId) return next(e)
583
+ if (e.tool === 'AskUserQuestion') await write(io, { zustand: 'wartet', warte_grund: 'dialog', heuristisch: false })
584
+ else if (own?.warte_grund === 'freigabe') await write(io, { zustand: 'arbeitet', warte_grund: null, heuristisch: false })
585
+ const result = await next(e)
586
+ // No telemetry error can cause this tool to execute twice.
587
+ await write(io, { zustand: 'arbeitet', warte_grund: null, heuristisch: false }).catch(() => { problems.add('beacon-write-failed') })
588
+ return result
589
+ }).catch(($, e, next) => next(e))
590
+ on('tool.check', async ($, e, next) => {
591
+ const result = await next(e)
592
+ if (result.decision === 'ask' && !e.agentId && e.tool_use_id && e.tool !== 'AskUserQuestion') await write(io, { zustand: 'wartet', warte_grund: 'freigabe', heuristisch: false }).catch(() => { problems.add('beacon-write-failed') })
593
+ return result
594
+ }).catch(($, e, next) => next(e))
595
+ on('session.end', async ($, e, next) => {
596
+ ending = true
597
+ cancel?.()
598
+ await beaconWrites.catch(() => undefined)
599
+ if (own) await helper(io, 'remove', own.session_id).catch(() => { problems.add('beacon-remove-failed') })
600
+ own = null
601
+ return next(e)
602
+ }).catch(($, e, next) => next(e))
603
+ // /profile (#1552): answered here without next, so it runs no model turn and adds no `context`; the `text` row is stored
604
+ // in the transcript, so the model reads it on its next turn.
605
+ on('command.run', { command: 'profile' }, async ($) => {
606
+ opened = true
607
+ try { $.ui.invalidate('ui.render') } catch { /* nothing drawn to refresh (claude -p) */ }
608
+ return { text: profileText(profile, chosen === true) }
609
+ })
610
+ on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
611
+ if (e.props.hasSurvey) return next(e)
612
+ const below = await next(e)
613
+ let lines: string[] = []
614
+ try {
615
+ const usage = await io.usage()
616
+ lines = findings(await snapshot(io), usage.context, own?.letzter_request_at ?? null, await io.now(), [...problems])
617
+ } catch { /* the profile band still draws */ }
618
+ const showProfile = opened || (chosen === false && !dismissed)
619
+ if (!lines.length && !showProfile) return below
620
+ const { Box, Text, Button } = $.ui.resolve(e)
621
+ const toast = (text: string) => { try { $.ui.toast(text, { timeoutMs: 8000 }) } catch { /* best effort */ } }
622
+ // Persists through the /config row of this plugin's strictness field; a successful set reloads the plugin.
623
+ const choose = async (value: SoProfile) => {
624
+ if (choosing) return
625
+ choosing = true
626
+ // The flag is written before $.config.set: a successful set reloads the plugin, so this instance may
627
+ // never run past it, and the default profile may leave no setting holdsProfile can see. Every path
628
+ // that stores nothing puts the flag back as it was.
629
+ let before: unknown
630
+ const restore = async () => {
631
+ try { await (before === undefined ? $.store.delete('profile-chosen') : $.store.set('profile-chosen', before)) } catch { /* best effort */ }
632
+ }
633
+ try {
634
+ before = await $.store.get('profile-chosen').catch(() => undefined)
635
+ await $.store.set('profile-chosen', true).catch(() => undefined)
636
+ const name = $.plugin.name
637
+ const row = (await $.config.list()).find(r => r.key === `${name}.strictness` || (r.provider.plugin === name && r.key.endsWith('.strictness')))
638
+ if (!row) {
639
+ await restore()
640
+ return toast('[so-hud] Profil nicht gespeichert: /config hat keinen Eintrag strictness von so-hud.')
641
+ }
642
+ const set = await $.config.set({ key: row.key, value })
643
+ if (set.deny !== undefined) {
644
+ await restore()
645
+ return toast(`[so-hud] Profil nicht gespeichert: ${displayText(set.deny)}`)
646
+ }
647
+ chosen = true
648
+ opened = false
649
+ toast(`[so-hud] Profil ${value} gespeichert (/config).`)
650
+ } catch {
651
+ await restore()
652
+ toast('[so-hud] Profil nicht gespeichert (Fehler beim Schreiben).')
653
+ }
654
+ finally {
655
+ choosing = false
656
+ try { $.ui.invalidate('ui.render') } catch { /* instance already reloaded */ }
657
+ }
658
+ }
659
+ // No hotkeys on the profile buttons: a bare digit typed into an empty prompt presses a band Button, and a
660
+ // letter can take another mod's band hotkey — either would change the host-wide profile by accident.
661
+ const band = showProfile ? <Box flexDirection="column">
662
+ {profileLines(profile, chosen === true).map((line, i) => <Text key={`profil-zeile-${i}`} dimColor={i > 0}>{line}</Text>)}
663
+ <Box gap={2}>
664
+ {PROFILES.map(p => <Button key={`profil-${p}`} label={p} variant={p === profile ? 'primary' : undefined} onPress={() => void choose(p)} />)}
665
+ {opened ? <Button key="profil-schliessen" label="Schließen" role="dismiss" onPress={() => { opened = false; dismissed = true; $.ui.invalidate('ui.render') }} /> : null}
666
+ </Box>
667
+ </Box> : null
668
+ return <Box flexDirection="column">{band}{lines.length ? <Text color="yellow">{lines.join(' · ')}</Text> : null}{below}</Box>
669
+ }).catch(($, e, next) => next(e))
670
+ }