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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +4 -1
- package/.codex-plugin/agents/explorer.toml +1 -1
- package/.codex-plugin/agents/session-reviewer.toml +2 -2
- package/.codex-plugin/agents/wave-worker.toml +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +8 -0
- package/.orchestrator/policy/public-export.json +30 -0
- package/CHANGELOG.md +162 -4
- package/README.md +126 -127
- package/agents/analyst.md +7 -2
- package/agents/architect-reviewer.md +7 -2
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +9 -2
- package/agents/dialectic-deriver.md +9 -3
- package/agents/docs-writer.md +7 -2
- package/agents/eval-judge.md +7 -1
- package/agents/qa-strategist.md +7 -2
- package/agents/security-reviewer.md +9 -2
- package/agents/session-reviewer.md +18 -2
- package/agents/skill-applied-judge.md +7 -1
- package/agents/test-writer.md +9 -2
- package/agents/ui-developer.md +9 -2
- package/agents/ux-evaluator.md +6 -1
- package/commands/session.md +1 -1
- package/commands/templates-ack.md +89 -18
- package/docs/agent-authoring.md +30 -11
- package/docs/ci-setup.md +38 -2
- package/docs/codex-setup.md +33 -9
- package/docs/components.md +13 -2
- package/docs/cursor-setup.md +3 -3
- package/docs/events-schema.md +5 -4
- package/docs/evidence-candidates.md +63 -0
- package/docs/fleet-checkin-audit.md +7 -4
- package/docs/github-mirror-protection.md +150 -3
- package/docs/install.md +90 -17
- package/docs/instruction-delivery.md +1 -1
- package/docs/migration-v4.md +2 -2
- package/docs/mods.md +118 -0
- package/docs/owner-config-schema.md +1 -0
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/pm-skills-marketplace.md +4 -3
- package/docs/rule-authoring.md +24 -13
- package/docs/scope-collision-guard.md +2 -1
- package/docs/session-config-reference.md +49 -15
- package/docs/session-config-template.md +30 -22
- package/docs/sessions-ledger-repair.md +137 -0
- package/docs/vault-docs-architecture.md +1 -1
- package/hooks/_lib/hook-import-set.json +406 -73
- package/hooks/_lib/hook-root.mjs +301 -0
- package/hooks/_lib/lock-bootstrap.mjs +434 -4
- package/hooks/_lib/orchestrator-ignore.mjs +3 -0
- package/hooks/_lib/subagent-transcript.mjs +2 -2
- package/hooks/_lib/vcs-create-matcher.mjs +13 -17
- package/hooks/cwd-change-restore.mjs +5 -1
- package/hooks/enforce-commands.mjs +4 -3
- package/hooks/enforce-scope.mjs +11 -1
- package/hooks/hooks-codex.json +48 -2
- package/hooks/hooks.json +31 -31
- package/hooks/on-session-end.mjs +38 -15
- package/hooks/on-session-start.mjs +139 -49
- package/hooks/on-stop.mjs +11 -6
- package/hooks/post-bash-issue-budget-refund.mjs +6 -1
- package/hooks/post-bash-write-verify.mjs +8 -4
- package/hooks/post-edit-validate.mjs +45 -52
- package/hooks/post-subagent-discovery-validator.mjs +2 -2
- package/hooks/post-tool-batch-wave-signal.mjs +8 -4
- package/hooks/post-tool-failure-corrective-context.mjs +5 -1
- package/hooks/pre-bash-destructive-guard.mjs +220 -8
- package/hooks/pre-bash-issue-budget.mjs +6 -1
- package/hooks/pre-bash-sessions-ledger-guard.mjs +691 -123
- package/hooks/pre-bash-staging-fence.mjs +258 -14
- package/hooks/pre-bash-templates-first.mjs +28 -27
- package/hooks/run-node.sh +1 -1
- package/hooks/skill-invocation-telemetry.mjs +15 -8
- package/hooks/subagent-telemetry.mjs +77 -14
- package/hooks/wave-scope-commit-guard.mjs +143 -14
- package/mods/so-hud/.claude-plugin/plugin.json +11 -0
- package/mods/so-hud/README.md +57 -0
- package/mods/so-hud/hooks/beacon-io.mjs +101 -0
- package/mods/so-hud/hooks/heredoc-scan.ts +394 -0
- package/mods/so-hud/hooks/hooks.json +1 -0
- package/mods/so-hud/hooks/logic.ts +182 -0
- package/mods/so-hud/hooks/register.tsx +670 -0
- package/mods/so-hud/hooks/rules.ts +2998 -0
- package/mods/so-hud/tsconfig.json +8 -0
- package/mods/so-hud/types/index.d.ts +36 -0
- package/package.json +4 -2
- package/rules/_index.md +1 -1
- package/rules/always-on/ask-via-tool.md +1 -1
- package/rules/always-on/loop-and-monitor.md +17 -194
- package/rules/always-on/parallel-sessions.md +3 -1
- package/rules/opt-in-stack/backend-data.md +1 -1
- package/rules/opt-in-stack/backend.md +1 -1
- package/rules/opt-in-stack/security-web.md +5 -1
- package/scripts/agent-usage-census.mjs +79 -0
- package/scripts/apply-session-learnings.mjs +34 -6
- package/scripts/archive-closed-prds.mjs +21 -5
- package/scripts/autopilot.mjs +11 -5
- package/scripts/backfill-abandoned-sessions.mjs +13 -3
- package/scripts/backfill-evidence-digest.mjs +2 -1
- package/scripts/backfill-learnings-from-vault.mjs +61 -6
- package/scripts/backfill-sessions.mjs +16 -2
- package/scripts/check-package-manager.mjs +26 -8
- package/scripts/check-sessions-integrity.mjs +4 -1
- package/scripts/ci/assert-vitest-green.mjs +3 -0
- package/scripts/ci/windows-smoke.mjs +201 -0
- package/scripts/close-precheck.mjs +55 -0
- package/scripts/dialectic-deriver.mjs +20 -12
- package/scripts/emit-event.mjs +2 -2
- package/scripts/emit-session.mjs +239 -20
- package/scripts/eval-session.mjs +17 -3
- package/scripts/export-hw-learnings.mjs +29 -2
- package/scripts/fleet-checkin-audit.mjs +8 -4
- package/scripts/fleet-checkin.mjs +39 -27
- package/scripts/gc-stale-worktrees.mjs +6 -1
- package/scripts/github-export.mjs +128 -0
- package/scripts/github-protection-audit.mjs +3 -1
- package/scripts/handover-queue-check.mjs +209 -0
- package/scripts/harness-audit.mjs +6 -4
- package/scripts/lib/agent-frontmatter.mjs +57 -10
- package/scripts/lib/agent-status.mjs +125 -7
- package/scripts/lib/agent-usage-census.mjs +394 -0
- package/scripts/lib/auto-dialectic.mjs +17 -10
- package/scripts/lib/auto-dream.mjs +22 -14
- package/scripts/lib/autopilot/loop.mjs +3 -1
- package/scripts/lib/autopilot/recent-runs.mjs +4 -2
- package/scripts/lib/autopilot/stall-sampler.mjs +2 -1
- package/scripts/lib/autopilot/telemetry.mjs +5 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/baseline-archetypes.mjs +10 -2
- package/scripts/lib/blocked-commands-policy.mjs +4 -3
- package/scripts/lib/build-live-signals.mjs +13 -9
- package/scripts/lib/ci-status-banner.mjs +329 -24
- package/scripts/lib/codex/plugin-contract.mjs +8 -0
- package/scripts/lib/cold-start-detector.mjs +5 -2
- package/scripts/lib/command-blocker.mjs +603 -43
- package/scripts/lib/common.mjs +3 -2
- package/scripts/lib/config/cross-repo.mjs +74 -7
- package/scripts/lib/config/host-paths.mjs +6 -2
- package/scripts/lib/config/section-extractor.mjs +65 -11
- package/scripts/lib/config/template-keys.mjs +153 -0
- package/scripts/lib/config/vault-integration.mjs +1 -0
- package/scripts/lib/config.mjs +2 -2
- package/scripts/lib/coordinator-snapshot.mjs +1 -1
- package/scripts/lib/discovery/triage-state.mjs +2 -1
- package/scripts/lib/dispatcher/rank.mjs +5 -7
- package/scripts/lib/ecosystem-health.mjs +2 -2
- package/scripts/lib/eval/engine.mjs +3 -2
- package/scripts/lib/eval/session-resolve.mjs +6 -2
- package/scripts/lib/eval/sink.mjs +4 -3
- package/scripts/lib/events-retention-banner.mjs +3 -2
- package/scripts/lib/events-schema.mjs +10 -0
- package/scripts/lib/events.mjs +23 -12
- package/scripts/lib/evolve/autonomy-verdict.mjs +4 -9
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +8 -18
- package/scripts/lib/fleet-protocol.mjs +4 -0
- package/scripts/lib/gates/gate-full.mjs +172 -11
- package/scripts/lib/gates/gate-helpers.mjs +236 -23
- package/scripts/lib/git-config-drift.mjs +141 -7
- package/scripts/lib/github-export.mjs +447 -0
- package/scripts/lib/gitlab-portfolio/cli.mjs +16 -5
- package/scripts/lib/handover-queue-marks.mjs +159 -0
- package/scripts/lib/hardware-pattern-detector.mjs +103 -13
- package/scripts/lib/harness-audit/categories/category1.mjs +16 -4
- package/scripts/lib/harness-audit/categories/category4.mjs +11 -8
- package/scripts/lib/harness-audit/categories/category6.mjs +25 -5
- package/scripts/lib/harness-audit/categories/category9.mjs +10 -4
- package/scripts/lib/host-identity.mjs +9 -5
- package/scripts/lib/http-request.mjs +64 -0
- package/scripts/lib/instruction-budget-guard.mjs +30 -22
- package/scripts/lib/io.mjs +6 -3
- package/scripts/lib/issue-budget-reconcile.mjs +3 -2
- package/scripts/lib/issue-budget.mjs +36 -7
- package/scripts/lib/learnings/evidence-candidates.mjs +154 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +99 -7
- package/scripts/lib/learnings/io.mjs +289 -15
- package/scripts/lib/learnings/select.mjs +53 -4
- package/scripts/lib/learnings/store-role.mjs +209 -0
- package/scripts/lib/learnings/surface.mjs +46 -7
- package/scripts/lib/ledger-path.mjs +113 -0
- package/scripts/lib/maintenance-due-banner.mjs +63 -12
- package/scripts/lib/memory-banner.mjs +5 -6
- package/scripts/lib/memory-proposals/collector.mjs +5 -2
- package/scripts/lib/memory-proposals/sink.mjs +7 -4
- package/scripts/lib/memory-proposals/store.mjs +22 -5
- package/scripts/lib/mode-selector-accuracy.mjs +2 -1
- package/scripts/lib/no-follow-open.mjs +57 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +4 -2
- package/scripts/lib/own-lock-root.mjs +95 -0
- package/scripts/lib/owner-interview.mjs +2 -1
- package/scripts/lib/owner-yaml.mjs +70 -7
- package/scripts/lib/platform.mjs +161 -9
- package/scripts/lib/plugin-update-banner.mjs +15 -11
- package/scripts/lib/pre-push-local-partial.mjs +117 -0
- package/scripts/lib/pre-push-remote-gate.mjs +151 -25
- package/scripts/lib/process-group.mjs +270 -34
- package/scripts/lib/project-hygiene.mjs +28 -0
- package/scripts/lib/quality-gate.mjs +33 -9
- package/scripts/lib/reconcile/backlog.mjs +29 -15
- package/scripts/lib/reconcile/engine.mjs +26 -2
- package/scripts/lib/reconcile/idempotency.mjs +65 -3
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +53 -12
- package/scripts/lib/reconcile/verdict-ledger.mjs +224 -0
- package/scripts/lib/reconcile-nudge-banner.mjs +40 -6
- package/scripts/lib/resource-probe/evaluate.mjs +32 -9
- package/scripts/lib/resource-probe/probe-platform.mjs +3 -1
- package/scripts/lib/rule-loader.mjs +20 -10
- package/scripts/lib/scope-baseline.mjs +123 -8
- package/scripts/lib/scope-echo.mjs +5 -2
- package/scripts/lib/scope-gate.mjs +55 -20
- package/scripts/lib/session-close-backfill.mjs +75 -7
- package/scripts/lib/session-end/close-precheck.mjs +151 -0
- package/scripts/lib/session-end/phase-skip.mjs +8 -7
- package/scripts/lib/session-end/tail-runner.mjs +7 -2
- package/scripts/lib/session-id.mjs +9 -1
- package/scripts/lib/session-identity/own-session.mjs +63 -0
- package/scripts/lib/session-lock.mjs +114 -0
- package/scripts/lib/session-record-repair.mjs +245 -35
- package/scripts/lib/session-registry.mjs +72 -9
- package/scripts/lib/session-schema/constants.mjs +9 -0
- package/scripts/lib/session-schema/filters.mjs +56 -1
- package/scripts/lib/session-schema/validator.mjs +15 -5
- package/scripts/lib/session-start-probes.mjs +149 -22
- package/scripts/lib/session-token-rollup.mjs +2 -1
- package/scripts/lib/session-work-trees.mjs +166 -0
- package/scripts/lib/sessions-canonical.mjs +61 -3
- package/scripts/lib/sessions-integrity-banner.mjs +17 -39
- package/scripts/lib/sessions-staleness-banner.mjs +8 -5
- package/scripts/lib/sh-quote.mjs +1 -0
- package/scripts/lib/skill-evidence-window.mjs +5 -1
- package/scripts/lib/skill-health/join.mjs +10 -9
- package/scripts/lib/skill-invocations-schema.mjs +13 -0
- package/scripts/lib/skill-judgments-schema.mjs +6 -2
- package/scripts/lib/snapshot-probe.mjs +414 -0
- package/scripts/lib/so-hud/collect.mjs +49 -5
- package/scripts/lib/so-hud/runtime.mjs +15 -4
- package/scripts/lib/spiral-carryover.mjs +8 -1
- package/scripts/lib/state-md/frontmatter-mutators.mjs +266 -8
- package/scripts/lib/state-md/idle-reset.mjs +14 -5
- package/scripts/lib/state-md/mission-status.mjs +5 -1
- package/scripts/lib/state-md-peer-guard.mjs +2 -0
- package/scripts/lib/state-md.mjs +31 -8
- package/scripts/lib/sunset/walker.mjs +4 -3
- package/scripts/lib/telemetry/consent.mjs +4 -2
- package/scripts/lib/telemetry/queue.mjs +3 -3
- package/scripts/lib/telemetry/sync.mjs +45 -9
- package/scripts/lib/test-runner/artifact-paths.mjs +10 -2
- package/scripts/lib/tmux-layout/layouts.mjs +20 -3
- package/scripts/lib/ux-grill/paths.mjs +5 -1
- package/scripts/lib/validate/check-agents.mjs +37 -6
- package/scripts/lib/validate/check-component-paths.mjs +25 -1
- package/scripts/lib/validate/check-guard-requires-parity.mjs +3 -3
- package/scripts/lib/validate/check-hooks-symmetry.mjs +53 -32
- package/scripts/lib/validate/check-json-files.mjs +3 -1
- package/scripts/lib/validate/check-learning-provenance.mjs +3 -2
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +10 -4
- package/scripts/lib/validate/check-metrics-ledger-root.mjs +234 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +159 -11
- package/scripts/lib/validate/check-test-posix-shell.mjs +246 -0
- package/scripts/lib/validate/check-unwired-features.mjs +4 -2
- package/scripts/lib/validate/check-vcs-repo-flag-positional.mjs +58 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +259 -73
- package/scripts/lib/validate/check-workflow-continue-on-error.mjs +63 -0
- package/scripts/lib/validate/confidential-names.mjs +3 -1
- package/scripts/lib/vault-mirror/migrate-ids.mjs +321 -0
- package/scripts/lib/vault-mirror/namespace.mjs +19 -7
- package/scripts/lib/vault-mirror/process.mjs +56 -27
- package/scripts/lib/vault-mirror/prune.mjs +5 -2
- package/scripts/lib/vault-mirror/render-learnings.mjs +3 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +36 -53
- package/scripts/lib/vault-mirror/retention.mjs +2 -2
- package/scripts/lib/vault-mirror/utils.mjs +79 -0
- package/scripts/lib/vault-staleness-banner.mjs +4 -7
- package/scripts/lib/vault-status/board-writer.mjs +2 -1
- package/scripts/lib/vault-status/narrative-mirror.mjs +66 -7
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +20 -0
- package/scripts/lib/win-process-tree.mjs +137 -0
- package/scripts/lib/workspace.mjs +1 -1
- package/scripts/lib/worktree/constants.mjs +1 -1
- package/scripts/lib/worktree/lifecycle.mjs +1 -1
- package/scripts/lib/worktree/listing.mjs +1 -1
- package/scripts/lib/zx.mjs +50 -0
- package/scripts/mcp-server.mjs +16 -7
- package/scripts/mcp-server.sh +13 -83
- package/scripts/measure-policy-cache-effectiveness.mjs +7 -5
- package/scripts/memory-propose.mjs +10 -3
- package/scripts/migrate-cold-start-seed.mjs +7 -0
- package/scripts/migrate-legacy-learnings.sh +22 -0
- package/scripts/migrate-sessions-jsonl.mjs +16 -2
- package/scripts/migrate-vault-paths.mjs +29 -13
- package/scripts/parse-config.mjs +71 -14
- package/scripts/print-learnings-index.mjs +25 -2
- package/scripts/release-session-lock.mjs +94 -7
- package/scripts/release.mjs +451 -24
- package/scripts/relocate-vault-corpus.mjs +11 -7
- package/scripts/repair-invalid-sessions.mjs +68 -3
- package/scripts/resolve-session-invocation.mjs +10 -17
- package/scripts/run-migrate-v2-cross-repo.mjs +56 -1
- package/scripts/run-quality-gate.mjs +33 -7
- package/scripts/self-update.mjs +56 -7
- package/scripts/session-commit-files.mjs +133 -0
- package/scripts/site-numbers.mjs +52 -9
- package/scripts/snapshot-probe.mjs +156 -0
- package/scripts/so-hud.mjs +29 -6
- package/scripts/state-md-idle-reset.mjs +2 -1
- package/scripts/sweep-expired-learnings.mjs +54 -6
- package/scripts/sweep-expired-rules.mjs +4 -1
- package/scripts/validate-plugin.mjs +22 -0
- package/scripts/vault-integration-watcher.mjs +54 -6
- package/scripts/vault-mirror.mjs +9 -0
- package/skills/_shared/fleet-protocol.md +142 -1
- package/skills/_shared/instruction-file-resolution.md +1 -1
- package/skills/_shared/monitor-patterns.md +44 -25
- package/skills/_shared/platform-tools.md +7 -0
- package/skills/_shared/state-ownership.md +3 -2
- package/skills/architecture/references/domain-model.md +1 -1
- package/skills/bootstrap/SKILL.md +3 -1
- package/skills/bootstrap/_shared-template.md +4 -4
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/bootstrap/private-contract.md +1 -1
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +17 -9
- package/skills/bootstrap/templates/agents/project-code-review.md +5 -1
- package/skills/bootstrap/templates/agents/project-discovery.md +5 -1
- package/skills/bootstrap/templates/agents/project-quality-gate.md +5 -1
- package/skills/claude-md-drift-check/SKILL.md +3 -3
- package/skills/claude-md-drift-check/checker.mjs +61 -101
- package/skills/close/SKILL.md +20 -7
- package/skills/discovery/probes/docs-staleness.mjs +7 -5
- package/skills/discovery/probes/frontend-slop.mjs +7 -3
- package/skills/discovery/probes/ssot-code-diff.mjs +5 -3
- package/skills/discovery/probes/supply-chain-slopcheck.mjs +10 -8
- package/skills/discovery/probes/vault-narrative-staleness.mjs +30 -10
- package/skills/discovery/probes/vault-staleness.mjs +5 -3
- package/skills/discovery/probes-session.md +3 -0
- package/skills/discovery/probes-vault.md +2 -0
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/eval/SKILL.md +3 -1
- package/skills/evolve/SKILL.md +2 -0
- package/skills/evolve/references/evolve-analyze-mode.md +3 -3
- package/skills/evolve/references/evolve-dialectic-mode.md +1 -1
- package/skills/gitlab-ops/SKILL.md +7 -6
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/go/SKILL.md +13 -1
- package/skills/npm-publish/SKILL.md +5 -2
- package/skills/plan/SKILL.md +23 -10
- package/skills/plan/mode-feature.md +13 -8
- package/skills/plan/mode-new.md +6 -14
- package/skills/plan/prd-feature-template.md +19 -0
- package/skills/plan/prd-full-template.md +12 -5
- package/skills/plan/prd-premortem-panel.md +48 -0
- package/skills/plan/prd-reviewer-prompt.md +11 -3
- package/skills/plan/soul.md +1 -1
- package/skills/portfolio/SKILL.md +5 -6
- package/skills/quality-gates/SKILL.md +2 -4
- package/skills/reconcile/SKILL.md +27 -4
- package/skills/release/SKILL.md +16 -8
- package/skills/session-end/SKILL.md +94 -11
- package/skills/session-end/phase-3-2-docs-verification.md +1 -0
- package/skills/session-end/phase-3-6-tail.md +11 -7
- package/skills/session-end/phase-3-7a-recommendations.md +9 -2
- package/skills/session-end/plan-verification.md +16 -1
- package/skills/session-end/references/phase-3-documentation-updates.md +14 -12
- package/skills/session-end/references/phase-5-issue-cleanup.md +1 -1
- package/skills/session-end/references/session-summary-template.md +4 -0
- package/skills/session-end/session-metrics-write.md +7 -4
- package/skills/session-plan/SKILL.md +5 -3
- package/skills/session-start/SKILL.md +9 -3
- package/skills/session-start/phase-4-5-resource-health.md +11 -5
- package/skills/session-start/phase-8-5-express-path.md +4 -3
- package/skills/session-start/references/phase-1-2-session-lock.md +7 -5
- package/skills/session-start/references/phase-1-5-session-continuity.md +2 -2
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +3 -2
- package/skills/tmux-layout/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +3 -2
- package/skills/wave-executor/SKILL.md +5 -4
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -3
- package/skills/wave-executor/references/wave-loop-dispatch.md +11 -5
- package/skills/wave-executor/references/wave-loop-review.md +13 -5
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +1 -1
- package/assets/og-card.png +0 -0
- package/assets/og-card.svg +0 -47
- 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
|
+
}
|