session-orchestrator 5.11.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 +12 -1
- 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 +180 -3
- package/README.md +126 -117
- 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 +4 -2
- 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 +102 -25
- 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 +55 -21
- package/docs/session-config-template.md +32 -24
- 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 +158 -59
- 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 -3
- 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 +73 -3
- 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/remote-hosts.mjs +2 -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 +3 -3
- 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 +21 -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-end/vault-orphans.mjs +205 -0
- package/scripts/lib/session-id.mjs +9 -1
- package/scripts/lib/session-identity/own-session.mjs +63 -0
- package/scripts/lib/session-invocation.mjs +14 -3
- package/scripts/lib/session-lock.mjs +114 -0
- package/scripts/lib/session-record-repair.mjs +245 -35
- package/scripts/lib/session-registry.mjs +85 -12
- 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 +174 -0
- package/scripts/lib/so-hud/runtime.mjs +87 -0
- 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 +248 -0
- package/scripts/lib/state-md/mission-status.mjs +5 -1
- package/scripts/lib/state-md/recommendations.mjs +26 -4
- 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 +22 -9
- package/scripts/lib/vault-mirror/process.mjs +197 -65
- package/scripts/lib/vault-mirror/prune.mjs +5 -2
- package/scripts/lib/vault-mirror/render-learnings.mjs +8 -7
- 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 +106 -13
- package/scripts/lib/vault-staleness-banner.mjs +4 -7
- package/scripts/lib/vault-status/board-writer.mjs +34 -5
- 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 +57 -8
- 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 +68 -0
- package/scripts/state-md-idle-reset.mjs +82 -0
- 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/scripts/vault-orphans.mjs +171 -0
- package/skills/_shared/fleet-protocol.md +166 -3
- 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 +5 -4
- package/skills/architecture/references/domain-model.md +1 -1
- package/skills/autopilot/SKILL.md +3 -2
- 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 +97 -13
- 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-7e-vault-orphans.md +63 -0
- package/skills/session-end/references/phase-3-documentation-updates.md +25 -13
- package/skills/session-end/references/phase-5-issue-cleanup.md +1 -1
- package/skills/session-end/references/session-summary-template.md +8 -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 +10 -4
- package/skills/session-start/phase-4-5-resource-health.md +11 -5
- package/skills/session-start/phase-7-5-mode-selector.md +3 -1
- 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 +13 -5
- 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/AGENTS.md +0 -207
- package/assets/og-card.png +0 -0
- package/assets/og-card.svg +0 -47
- package/assets/wave-lifecycle.svg +0 -98
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
// ---- Heredoc bodies the shell expands (#1696) ---------------------------------
|
|
2
|
+
//
|
|
3
|
+
// One pass over a command text with an explicit state machine (quotes, `$'…'`, `${…}`, `$(…)`, backticks,
|
|
4
|
+
// arithmetic, comments) and a queue of the heredoc operators read on the current line. At a newline that the
|
|
5
|
+
// shell reads as a command terminator, the queued bodies follow in order; each body line is read once, so the
|
|
6
|
+
// whole scan is linear in the text. The scanner judges nothing itself: `secretIn` names the secret a body line
|
|
7
|
+
// expands, `prints` says whether the stage a heredoc feeds reaches the transcript — both supplied by rules.ts.
|
|
8
|
+
|
|
9
|
+
/** A stage of a pipeline as the scanner cut it: `[start, end)` in the text, -1 while open; `pipe`: the stage after `|`. */
|
|
10
|
+
type Stage = { start: number; end: number; pipe: Stage | null; cuts: Array<[number, number]>; verdict: boolean | undefined }
|
|
11
|
+
|
|
12
|
+
type Pending = { delimiter: string; quoted: boolean; tabs: boolean; stage: Stage; nested: boolean }
|
|
13
|
+
|
|
14
|
+
/** What a stage feeds: its text without the heredoc operators, and the next stage (`null`: unreadable, `undefined`: none). */
|
|
15
|
+
export type HeredocStage = { text: string | null; next: string | null | undefined; nested: boolean }
|
|
16
|
+
|
|
17
|
+
export type HeredocFinding =
|
|
18
|
+
| { kind: 'body'; name: string; delimiter: string }
|
|
19
|
+
| { kind: 'fail-closed'; reason: 'unbalanced' | 'too-many' | 'no-delimiter' | 'unreadable' }
|
|
20
|
+
|
|
21
|
+
// Frames of the state machine. `code` frames read commands and heredoc operators; the others only quoting.
|
|
22
|
+
type Frame = 'root' | 'subst' | 'paren' | 'procsub' | 'tick' | 'dq' | 'brace' | 'dqbrace' | 'arith' | 'bracket'
|
|
23
|
+
|
|
24
|
+
// Heredoc operators queued on one line before the bodies are read. Real commands use one or two; past the cap the
|
|
25
|
+
// line is not read (the operator count was the lever of the reverted attempt's quadratic run) — fail-closed.
|
|
26
|
+
// NAMED CEILING (BV-004): 16; revisit if a legitimate command needs more heredocs on one line.
|
|
27
|
+
const MAX_PENDING = 16
|
|
28
|
+
// The characters after which an unquoted `#` starts a comment (the start of a word).
|
|
29
|
+
const WORD_BREAK = /[\s;&|()<>]/
|
|
30
|
+
// Where an unquoted heredoc delimiter word ends. Not `\r`: the shell keeps it in the word, so `<<EOF` on a CRLF line
|
|
31
|
+
// closes only at `EOF\r` — ending the word there too keeps the body from running past the shell's closing line.
|
|
32
|
+
const WORD_END = /[ \t\n;&|<>()]/
|
|
33
|
+
|
|
34
|
+
// Reserved words after which the next word is a command again (`if x; then case …`).
|
|
35
|
+
const COMMAND_WORDS: ReadonlySet<string> = new Set(['then', 'do', 'else', 'elif', 'if', 'while', 'until', '!', 'time'])
|
|
36
|
+
|
|
37
|
+
const CODE: ReadonlySet<Frame> = new Set(['root', 'subst', 'paren', 'procsub', 'tick'])
|
|
38
|
+
// Frames whose output a command reads instead of the transcript seeing it directly: judged as printing (fail-closed).
|
|
39
|
+
const CAPTURED: ReadonlySet<Frame> = new Set(['subst', 'procsub', 'tick'])
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Reads every heredoc in `text` and returns the first one whose body expands a secret while its stage prints
|
|
43
|
+
* (`prints`), or a fail-closed finding: an unbalanced quote or construct at the end of the text, more than
|
|
44
|
+
* {@link MAX_PENDING} operators on one line, or an operator without a delimiter word. A quoted delimiter
|
|
45
|
+
* (`<<'EOF'`, `<<"EOF"`, `<<\EOF`, any quote in the word) keeps its body literal and is never a finding. Whether a
|
|
46
|
+
* fail-closed finding matters (a secret named anywhere in the text) is the caller's call. Stops at the first finding.
|
|
47
|
+
*/
|
|
48
|
+
export function scanHeredocs(
|
|
49
|
+
text: string,
|
|
50
|
+
secretIn: (line: string) => string | undefined,
|
|
51
|
+
prints: (stage: HeredocStage) => boolean,
|
|
52
|
+
): HeredocFinding | null {
|
|
53
|
+
const frames: Frame[] = ['root']
|
|
54
|
+
// One open stage per code frame, parallel to `frames` (null for quoting frames).
|
|
55
|
+
const stages: Array<Stage | null> = [newStage(0)]
|
|
56
|
+
let pending: Pending[] = []
|
|
57
|
+
// Per frame: `(`/`[` inside arithmetic, `{` inside a brace, open `case` words inside a code frame.
|
|
58
|
+
const depth: number[] = [0]
|
|
59
|
+
const n = text.length
|
|
60
|
+
// The `)` that ended the last case pattern: the word after it is a command (`a) case y in …`), unlike after `$(x)`.
|
|
61
|
+
let patternClose = -1
|
|
62
|
+
|
|
63
|
+
const top = (): Frame => frames[frames.length - 1] ?? 'root'
|
|
64
|
+
const push = (frame: Frame, at: number): void => {
|
|
65
|
+
frames.push(frame)
|
|
66
|
+
stages.push(CODE.has(frame) ? newStage(at) : null)
|
|
67
|
+
depth.push(0)
|
|
68
|
+
}
|
|
69
|
+
const pop = (at: number): void => {
|
|
70
|
+
const stage = stages[stages.length - 1]
|
|
71
|
+
if (stage !== null && stage !== undefined && stage.end === -1) stage.end = at
|
|
72
|
+
frames.pop()
|
|
73
|
+
stages.pop()
|
|
74
|
+
depth.pop()
|
|
75
|
+
}
|
|
76
|
+
// Ends the open stage of the current code frame at `at`; a pipe links it to the stage that starts at `from`.
|
|
77
|
+
const cut = (at: number, from: number, pipe: boolean): void => {
|
|
78
|
+
const stage = stages[stages.length - 1]
|
|
79
|
+
if (stage === null || stage === undefined) return
|
|
80
|
+
stage.end = at
|
|
81
|
+
const next = newStage(from)
|
|
82
|
+
if (pipe) stage.pipe = next
|
|
83
|
+
stages[stages.length - 1] = next
|
|
84
|
+
}
|
|
85
|
+
const nested = (): boolean => frames.some(frame => CAPTURED.has(frame))
|
|
86
|
+
|
|
87
|
+
for (let i = 0; i < n; i += 1) {
|
|
88
|
+
const ch = text[i] ?? ''
|
|
89
|
+
const next = text[i + 1] ?? ''
|
|
90
|
+
const frame = top()
|
|
91
|
+
|
|
92
|
+
if (frame === 'dq' || frame === 'dqbrace' || frame === 'brace') {
|
|
93
|
+
if (ch === '\\') i += 1
|
|
94
|
+
else if (frame === 'dq' && ch === '"') pop(i)
|
|
95
|
+
else if (frame !== 'dq' && ch === '}') pop(i)
|
|
96
|
+
// A raw `{` inside `${…}` (`"${x:-{}"`): the shells disagree on which `}` closes it — fail-closed (#1696 M2).
|
|
97
|
+
// A nested `${` opens its own frame through openDollar.
|
|
98
|
+
else if (frame !== 'dq' && ch === '{') return { kind: 'fail-closed', reason: 'unreadable' }
|
|
99
|
+
else if (frame !== 'dq' && ch === '"') push('dq', i)
|
|
100
|
+
else if (frame === 'brace' && ch === "'") {
|
|
101
|
+
const close = text.indexOf("'", i + 1)
|
|
102
|
+
if (close === -1) return { kind: 'fail-closed', reason: 'unbalanced' }
|
|
103
|
+
i = close
|
|
104
|
+
} else if (ch === '$' || ch === '`') i = openDollar(i, frame === 'brace' ? 'brace' : 'dqbrace')
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
if (frame === 'arith' || frame === 'bracket') {
|
|
108
|
+
const [open, close] = frame === 'arith' ? ['(', ')'] : ['[', ']']
|
|
109
|
+
const d = depth[depth.length - 1] ?? 0
|
|
110
|
+
// A newline, or `$((`/`((` closed by `) )` instead of `))`, is a command in a subshell, not arithmetic: the
|
|
111
|
+
// shells run `$((cat <<EOF … ) )` (#1696 M1) — fail-closed rather than reading its heredoc as arithmetic.
|
|
112
|
+
if (ch === '\n') return { kind: 'fail-closed', reason: 'unreadable' }
|
|
113
|
+
if (ch === open) depth[depth.length - 1] = d + 1
|
|
114
|
+
else if (ch === close && d > 0) depth[depth.length - 1] = d - 1
|
|
115
|
+
else if (ch === close) {
|
|
116
|
+
if (frame === 'arith' && next !== ')') return { kind: 'fail-closed', reason: 'unreadable' }
|
|
117
|
+
if (frame === 'arith') i += 1
|
|
118
|
+
pop(i)
|
|
119
|
+
}
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// A code frame.
|
|
124
|
+
if (ch === '\\') {
|
|
125
|
+
i += 1
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
if (ch === "'") {
|
|
129
|
+
const close = text.indexOf("'", i + 1)
|
|
130
|
+
if (close === -1) return { kind: 'fail-closed', reason: 'unbalanced' }
|
|
131
|
+
i = close
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
if (ch === '"') {
|
|
135
|
+
push('dq', i)
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
if (ch === '$' || ch === '`') {
|
|
139
|
+
if (ch === '`' && frame === 'tick') pop(i)
|
|
140
|
+
else i = openDollar(i, 'code')
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
if (ch === '#' && (i === 0 || WORD_BREAK.test(text[i - 1] ?? ''))) {
|
|
144
|
+
const nl = text.indexOf('\n', i)
|
|
145
|
+
i = (nl === -1 ? n : nl) - 1
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
if (ch === '<' && next === '<' && text[i + 2] === '<') {
|
|
149
|
+
i += 2
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
if (ch === '<' && next === '<') {
|
|
153
|
+
const op = readOperator(i + 2)
|
|
154
|
+
if (op === null) return { kind: 'fail-closed', reason: 'no-delimiter' }
|
|
155
|
+
if (op === 'unbalanced') return { kind: 'fail-closed', reason: 'unbalanced' }
|
|
156
|
+
const stage = stages[stages.length - 1] ?? newStage(i)
|
|
157
|
+
stage.cuts.push([i, op.end])
|
|
158
|
+
pending.push({ delimiter: op.delimiter, quoted: op.quoted, tabs: op.tabs, stage, nested: nested() })
|
|
159
|
+
if (pending.length > MAX_PENDING) return { kind: 'fail-closed', reason: 'too-many' }
|
|
160
|
+
i = op.end - 1
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
if ((ch === '<' || ch === '>') && next === '(') {
|
|
164
|
+
push('procsub', i + 2)
|
|
165
|
+
i += 1
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
if (ch === '(' && next === '(') {
|
|
169
|
+
push('arith', i)
|
|
170
|
+
i += 1
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
if (ch === '(') {
|
|
174
|
+
cut(i, i + 1, false)
|
|
175
|
+
push('paren', i + 1)
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
if ((ch === 'c' || ch === 'e') && (i === 0 || WORD_BREAK.test(text[i - 1] ?? '')) && commandPosition(i)) {
|
|
179
|
+
// In a code frame the depth counts open `case` words: the `)` after a pattern closes no `$(` (#1696:
|
|
180
|
+
// `"$(case x in x) cat <<A;; esac …)"` read the `)` as the end and the heredoc as quoted text). Only in
|
|
181
|
+
// command position: `echo esac` is an argument and closes nothing (M3).
|
|
182
|
+
const word = /^(?:case|esac)(?=[ \t\n;&|<>()]|$)/.exec(text.slice(i, i + 5))?.[0]
|
|
183
|
+
const d = depth[depth.length - 1] ?? 0
|
|
184
|
+
if (word === 'case') depth[depth.length - 1] = d + 1
|
|
185
|
+
else if (word === 'esac' && d > 0) depth[depth.length - 1] = d - 1
|
|
186
|
+
if (word !== undefined) i += 3
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
if (ch === ')') {
|
|
190
|
+
if ((depth[depth.length - 1] ?? 0) > 0) patternClose = i
|
|
191
|
+
else if (frame === 'subst' || frame === 'paren' || frame === 'procsub') pop(i)
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
if (ch === '|') {
|
|
195
|
+
if (next === '|') {
|
|
196
|
+
cut(i, i + 2, false)
|
|
197
|
+
i += 1
|
|
198
|
+
} else {
|
|
199
|
+
cut(i, i + (next === '&' ? 2 : 1), true)
|
|
200
|
+
if (next === '&') i += 1
|
|
201
|
+
}
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
if (ch === ';' || ch === '&') {
|
|
205
|
+
// `&&`, `;;`, a lone `&` end the command; `&>` / `>&` / `<&` are redirections, read with the stage.
|
|
206
|
+
if (ch === '&' && (next === '>' || text[i - 1] === '>' || text[i - 1] === '<')) continue
|
|
207
|
+
const width = next === ch ? 2 : 1
|
|
208
|
+
cut(i, i + width, false)
|
|
209
|
+
i += width - 1
|
|
210
|
+
continue
|
|
211
|
+
}
|
|
212
|
+
if (ch === '\n') {
|
|
213
|
+
cut(i, i + 1, false)
|
|
214
|
+
if (pending.length === 0) continue
|
|
215
|
+
const found = readBodies(i + 1)
|
|
216
|
+
if (typeof found !== 'number') return found
|
|
217
|
+
pending = []
|
|
218
|
+
// The bodies end at `found`, the start of the next command line; the code frame is unchanged.
|
|
219
|
+
const stage = stages[stages.length - 1]
|
|
220
|
+
if (stage !== null && stage !== undefined) stage.start = found
|
|
221
|
+
i = found - 1
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// Operators on the last line with no newline after them have no body.
|
|
225
|
+
return frames.length === 1 ? null : { kind: 'fail-closed', reason: 'unbalanced' }
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Whether the word at `at` stands where a command starts: at the text start, after `;`, `&`, `|`, `(`, `{`, a
|
|
229
|
+
* backtick, a newline or the `)` of a case pattern, or after a reserved word that takes a command (`then`, `do`, …). Reads back over the
|
|
230
|
+
* blanks and the one word before `at` only, so every character is read back at most once (linear).
|
|
231
|
+
*/
|
|
232
|
+
function commandPosition(at: number): boolean {
|
|
233
|
+
let p = at - 1
|
|
234
|
+
while (p >= 0 && (text[p] === ' ' || text[p] === '\t')) p -= 1
|
|
235
|
+
if (p < 0 || p === patternClose || /[;&|({`\n]/.test(text[p] ?? '')) return true
|
|
236
|
+
let w = p
|
|
237
|
+
while (w >= 0 && /[a-z!]/.test(text[w] ?? '')) w -= 1
|
|
238
|
+
if (w >= 0 && !/[\s;&|({`]/.test(text[w] ?? '')) return false
|
|
239
|
+
return COMMAND_WORDS.has(text.slice(w + 1, p + 1))
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Opens the construct a `$` or backtick at `at` starts; returns the index the loop continues from. */
|
|
243
|
+
function openDollar(at: number, context: 'code' | 'brace' | 'dqbrace'): number {
|
|
244
|
+
const ch = text[at]
|
|
245
|
+
const next = text[at + 1]
|
|
246
|
+
if (ch === '`') {
|
|
247
|
+
push('tick', at + 1)
|
|
248
|
+
return at
|
|
249
|
+
}
|
|
250
|
+
if (next === '(' && text[at + 2] === '(') {
|
|
251
|
+
push('arith', at)
|
|
252
|
+
return at + 2
|
|
253
|
+
}
|
|
254
|
+
if (next === '(') {
|
|
255
|
+
push('subst', at + 2)
|
|
256
|
+
return at + 1
|
|
257
|
+
}
|
|
258
|
+
if (next === '[') {
|
|
259
|
+
push('bracket', at)
|
|
260
|
+
return at + 1
|
|
261
|
+
}
|
|
262
|
+
if (next === '{') {
|
|
263
|
+
push(context === 'code' || context === 'brace' ? 'brace' : 'dqbrace', at)
|
|
264
|
+
return at + 1
|
|
265
|
+
}
|
|
266
|
+
if (next === "'" && context !== 'dqbrace') {
|
|
267
|
+
// `$'…'`: a backslash escapes the next character, `\'` included.
|
|
268
|
+
for (let j = at + 2; j < n; j += 1) {
|
|
269
|
+
if (text[j] === '\\') j += 1
|
|
270
|
+
else if (text[j] === "'") return j
|
|
271
|
+
}
|
|
272
|
+
return n
|
|
273
|
+
}
|
|
274
|
+
return at
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** The delimiter word after `<<` at `from`; null when there is none, 'unbalanced' for an open quote in it. */
|
|
278
|
+
function readOperator(from: number): { delimiter: string; quoted: boolean; tabs: boolean; end: number } | null | 'unbalanced' {
|
|
279
|
+
let j = from
|
|
280
|
+
const tabs = text[j] === '-'
|
|
281
|
+
if (tabs) j += 1
|
|
282
|
+
while (text[j] === ' ' || text[j] === '\t') j += 1
|
|
283
|
+
const parts: string[] = []
|
|
284
|
+
let quoted = false
|
|
285
|
+
for (; j < n; j += 1) {
|
|
286
|
+
const c = text[j] ?? ''
|
|
287
|
+
if (WORD_END.test(c)) break
|
|
288
|
+
// A substitution in the delimiter word (`<<$(x)`): the shells take it literally, this reading would end the
|
|
289
|
+
// word at its `(` — fail-closed instead of guessing (#1696 M4).
|
|
290
|
+
if ((c === '$' && text[j + 1] === '(') || c === '`') return null
|
|
291
|
+
if (c === '\\') {
|
|
292
|
+
// `\` + newline continues the line and quotes nothing: `<<EO\<LF>F` is the unquoted delimiter EOF.
|
|
293
|
+
if (text[j + 1] !== '\n') {
|
|
294
|
+
quoted = true
|
|
295
|
+
parts.push(text[j + 1] ?? '')
|
|
296
|
+
}
|
|
297
|
+
j += 1
|
|
298
|
+
} else if (c === "'" || c === '"') {
|
|
299
|
+
quoted = true
|
|
300
|
+
let k = j + 1
|
|
301
|
+
for (; k < n && text[k] !== c; k += 1) {
|
|
302
|
+
if (c === '"' && text[k] === '\\') k += 1
|
|
303
|
+
}
|
|
304
|
+
if (k >= n) return 'unbalanced'
|
|
305
|
+
parts.push(text.slice(j + 1, k))
|
|
306
|
+
j = k
|
|
307
|
+
} else parts.push(c)
|
|
308
|
+
}
|
|
309
|
+
const delimiter = parts.join('')
|
|
310
|
+
return delimiter === '' && !quoted ? null : { delimiter, quoted, tabs, end: j }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Reads the queued bodies from `from` in order: each runs to its closing line or to the end of the text. Returns
|
|
315
|
+
* the index past the last closing line, or the first finding. Each stage is judged at most once (its verdict is
|
|
316
|
+
* kept), so many heredocs feeding one stage cost one judgement.
|
|
317
|
+
*/
|
|
318
|
+
function readBodies(from: number): number | HeredocFinding {
|
|
319
|
+
let at = from
|
|
320
|
+
for (const doc of pending) {
|
|
321
|
+
let hit = false
|
|
322
|
+
let closed = false
|
|
323
|
+
// An unquoted body joins a line that ends in an odd run of `\` with the next one, as the shell does: neither
|
|
324
|
+
// `foo\` + `EOF` closes the body nor does `$T_TO\` + `KEN` hide the name. A quoted body keeps every line.
|
|
325
|
+
let carried = ''
|
|
326
|
+
// Whether the current physical line continues an earlier one: `carried` alone cannot tell, because a continued
|
|
327
|
+
// line that was only `\` leaves it empty (#1696 R2: `<<-EOF`, `\`, `<tab>EOF` does not close in zsh).
|
|
328
|
+
let continued = false
|
|
329
|
+
while (at <= n) {
|
|
330
|
+
const nl = text.indexOf('\n', at)
|
|
331
|
+
const end = nl === -1 ? n : nl
|
|
332
|
+
let physical = text.slice(at, end)
|
|
333
|
+
at = nl === -1 ? n + 1 : nl + 1
|
|
334
|
+
// `<<-` strips the tabs at the start of the logical line only: a continued line keeps its own (#1696 M5).
|
|
335
|
+
if (doc.tabs && !continued) physical = physical.replace(/^\t+/, '')
|
|
336
|
+
if (!doc.quoted && nl !== -1 && oddSlashes(physical)) {
|
|
337
|
+
carried += physical.slice(0, -1)
|
|
338
|
+
continued = true
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
341
|
+
const line = carried + physical
|
|
342
|
+
carried = ''
|
|
343
|
+
continued = false
|
|
344
|
+
if (line === doc.delimiter) {
|
|
345
|
+
closed = true
|
|
346
|
+
break
|
|
347
|
+
}
|
|
348
|
+
if (hit) continue
|
|
349
|
+
const name = secretIn(line)
|
|
350
|
+
if (name === undefined) continue
|
|
351
|
+
hit = true
|
|
352
|
+
if (!doc.quoted && judge(doc)) return { kind: 'body', name, delimiter: doc.delimiter }
|
|
353
|
+
}
|
|
354
|
+
// A literal body that never closes swallowed the rest of the text unread: if the shell closed it where this
|
|
355
|
+
// reading did not (a delimiter told apart differently), a secret behind it went unseen — fail-closed.
|
|
356
|
+
if (doc.quoted && hit && !closed) return { kind: 'fail-closed', reason: 'unbalanced' }
|
|
357
|
+
}
|
|
358
|
+
return Math.min(at, n)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function judge(doc: Pending): boolean {
|
|
362
|
+
const { stage } = doc
|
|
363
|
+
if (stage.verdict === undefined) {
|
|
364
|
+
const pipe = stage.pipe
|
|
365
|
+
stage.verdict = prints({
|
|
366
|
+
text: stage.end === -1 ? null : stageText(stage),
|
|
367
|
+
next: pipe === null ? undefined : pipe.end === -1 ? null : stageText(pipe),
|
|
368
|
+
nested: doc.nested,
|
|
369
|
+
})
|
|
370
|
+
}
|
|
371
|
+
return stage.verdict
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function stageText(stage: Stage): string {
|
|
375
|
+
let out = ''
|
|
376
|
+
let from = stage.start
|
|
377
|
+
for (const [a, b] of stage.cuts) {
|
|
378
|
+
out += `${text.slice(from, a)} `
|
|
379
|
+
from = b
|
|
380
|
+
}
|
|
381
|
+
return out + text.slice(from, stage.end)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Whether a line ends in an odd run of backslashes (the last one escapes the newline). */
|
|
386
|
+
function oddSlashes(line: string): boolean {
|
|
387
|
+
let count = 0
|
|
388
|
+
for (let j = line.length - 1; j >= 0 && line[j] === '\\'; j -= 1) count += 1
|
|
389
|
+
return count % 2 === 1
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function newStage(start: number): Stage {
|
|
393
|
+
return { start, end: -1, pipe: null, cuts: [], verdict: undefined }
|
|
394
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "modules": ["./register.tsx"] }
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { SoBeacon, SoProfile, SoSnapshot } from '../types/index'
|
|
2
|
+
|
|
3
|
+
export function ownerQuestion(answer: string): boolean {
|
|
4
|
+
let fence: string | null = null
|
|
5
|
+
const plain = answer.split('\n').filter(line => {
|
|
6
|
+
const mark = /^\s{0,3}(`{3,}|~{3,})/.exec(line)?.[1]
|
|
7
|
+
if (mark) {
|
|
8
|
+
if (!fence) fence = mark
|
|
9
|
+
else if (mark[0] === fence[0] && mark.length >= fence.length) fence = null
|
|
10
|
+
return false
|
|
11
|
+
}
|
|
12
|
+
return fence === null
|
|
13
|
+
}).join('\n').trim()
|
|
14
|
+
return plain.endsWith('?') || /weiter mit|soll ich|welche option|freigabe für/i.test(plain.slice(-400))
|
|
15
|
+
}
|
|
16
|
+
export const tables = {
|
|
17
|
+
de: { red: 'rot', error: 'Fehler', off: 'aus', partial: 'teilweise gemessen', setup: 'nicht eingerichtet', norun: 'noch kein Lauf', measurement: 'Messung fehlgeschlagen', mirror: 'Vault-Spiegel fehlgeschlagen', peer: 'Peer im selben Working Copy', scope: 'Peer ohne bekannten Scope im selben Working Copy', context: 'Kontext', cold: 'Cache kalt' },
|
|
18
|
+
en: { red: 'red', error: 'error', off: 'off', partial: 'partially measured', setup: 'not configured', norun: 'no run yet', measurement: 'Measurement failed', mirror: 'Vault mirror failed', peer: 'Peer in the same working copy', scope: 'Peer with unknown scope in the same working copy', context: 'Context', cold: 'Cache cold' },
|
|
19
|
+
} as const
|
|
20
|
+
/** Bound each displayed field; replace terminal controls and line separators. */
|
|
21
|
+
export function displayText(value: unknown, limit = 120): string {
|
|
22
|
+
const plain = String(value ?? '').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, ' ')
|
|
23
|
+
return plain.length <= limit ? plain : `${plain.slice(0, limit - 1)}…`
|
|
24
|
+
}
|
|
25
|
+
const measurementProblems = new Set(['refresh-failed', 'collector-failed', 'invalid-snapshot'])
|
|
26
|
+
function measurementFailure(problems: readonly string[]): string[] {
|
|
27
|
+
return problems.filter(p => measurementProblems.has(p))
|
|
28
|
+
}
|
|
29
|
+
export function time(at: string | null): string {
|
|
30
|
+
if (!at || !Number.isFinite(Date.parse(at))) return '?'
|
|
31
|
+
const d = new Date(at)
|
|
32
|
+
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
33
|
+
}
|
|
34
|
+
export function statusText(s: SoSnapshot | null, profile: SoProfile, language: keyof typeof tables = 'de', problems: readonly string[] = []): string {
|
|
35
|
+
const t = tables[language]
|
|
36
|
+
if (!s) {
|
|
37
|
+
const failed = measurementFailure(problems)
|
|
38
|
+
return displayText(`SO ${failed.length ? `${t.measurement} (${failed.join(', ')})` : t.setup} · ${profile}`)
|
|
39
|
+
}
|
|
40
|
+
const g = s.gate.last
|
|
41
|
+
const vault = s.vault
|
|
42
|
+
const bad = vault.last_run && (vault.last_run.aborted || (vault.last_run.failed ?? 0) > 0)
|
|
43
|
+
// enabled null has two causes: no vault-integration block (not configured) or an unreadable CLAUDE.md/AGENTS.md (unknown).
|
|
44
|
+
const vaultUnset = s.degraded.includes('config:unreadable') ? '?' : t.setup
|
|
45
|
+
// Enabled but never mirrored is a state, not an unknown; '?' stays for a run whose time does not parse.
|
|
46
|
+
const vaultOn = vault.last_run === null ? t.norun : time(vault.last_run?.at ?? null)
|
|
47
|
+
// A repo where no session ever wrote events is fresh, not partially measured (F13): nothing is missing there.
|
|
48
|
+
const partial = s.degraded.some(d => d !== 'events:absent')
|
|
49
|
+
return displayText([s.state.session_type, s.wave.number !== null ? `W${s.wave.number}/${s.state.total_waves ?? '?'}` : null,
|
|
50
|
+
s.wave.role, agentsText(s.agents),
|
|
51
|
+
g ? `Gate ${g.passed === true ? 'ok' : g.passed === false ? t.red : '?'} ${time(g.at)}` : null,
|
|
52
|
+
`${s.degraded.some(d => /discovery|registry/.test(d)) ? '?' : s.peers.length} Peer`, `Vault ${vault.enabled === false ? t.off : bad ? t.error : vault.enabled === true ? vaultOn : vaultUnset}`, profile,
|
|
53
|
+
partial ? t.partial : null].filter(Boolean).map(value => displayText(value)).join(' · '), 800)
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The running agents of this session from the collector's live map (#1580, #1633): a number is measured, `null`
|
|
57
|
+
* is unknown (`? Agents`, a map older than the session says nothing). Shown not at all without a session
|
|
58
|
+
* (`no-session`: nothing to count for) or from a collector before #1580 that sends no `running` — never as 0.
|
|
59
|
+
*/
|
|
60
|
+
function agentsText(agents: SoSnapshot['agents'] | undefined): string | null {
|
|
61
|
+
const running = agents?.running
|
|
62
|
+
if (running === undefined || agents?.stale_reason === 'no-session') return null
|
|
63
|
+
const decayed = agents?.decayed
|
|
64
|
+
// Agents without a Stop past 24 h (a parallel collector change): counted apart, shown only when there are some.
|
|
65
|
+
const stale = typeof decayed === 'number' && Number.isSafeInteger(decayed) && decayed > 0 ? ` (+${decayed} ?)` : ''
|
|
66
|
+
return `${typeof running === 'number' && Number.isSafeInteger(running) && running >= 0 ? running : '?'} Agents${stale}`
|
|
67
|
+
}
|
|
68
|
+
export function findings(s: SoSnapshot | null, context: { percent?: number; tokens?: number }, lastRequest: string | null, now: number, problems: readonly string[] = []): string[] {
|
|
69
|
+
const failed = measurementFailure(problems)
|
|
70
|
+
const out: string[] = failed.length ? [`${tables.de.measurement}: ${failed.join(', ')}`] : []
|
|
71
|
+
if (s?.gate.last?.passed === false) out.push(`Gate rot: ${s.gate.last.variant ?? '?'} ${s.gate.last.check_failed === true ? 'check_failed' : s.gate.last.exit_code ?? '?'}`)
|
|
72
|
+
if (s?.peers.some(p => p.gleiches_working_copy)) out.push(s.peers.some(p => p.gleiches_working_copy && p.scope_paths === null) ? tables.de.scope : tables.de.peer)
|
|
73
|
+
if (s?.vault.enabled && s.vault.last_run && (s.vault.last_run.aborted || (s.vault.last_run.failed ?? 0) > 0)) out.push(`${tables.de.mirror}: ${s.vault.last_run.aborted ?? s.vault.last_run.failed}`)
|
|
74
|
+
if ((context.percent ?? -1) >= 80) out.push(`Kontext ${context.percent} %`)
|
|
75
|
+
if (lastRequest && now - Date.parse(lastRequest) >= 3_600_000 && (context.tokens ?? 0) >= 100_000) out.push(tables.de.cold)
|
|
76
|
+
return out.map(value => displayText(value))
|
|
77
|
+
}
|
|
78
|
+
export const validId = (v: unknown): v is string => typeof v === 'string' && /^[a-zA-Z0-9_-]{1,200}$/.test(v)
|
|
79
|
+
export function validateBeacon(v: unknown): v is SoBeacon {
|
|
80
|
+
if (!v || typeof v !== 'object') return false
|
|
81
|
+
const b = v as SoBeacon
|
|
82
|
+
const date = (s: unknown) => typeof s === 'string' && Number.isFinite(Date.parse(s))
|
|
83
|
+
const nullableString = (s: unknown) => s === null || typeof s === 'string'
|
|
84
|
+
return b.schema_version === 1 && validId(b.session_id) && ['arbeitet', 'wartet', 'bereit', 'blockiert', 'unbekannt'].includes(b.zustand)
|
|
85
|
+
&& date(b.seit) && date(b.aktualisiert_at) && (b.letzter_request_at === null || date(b.letzter_request_at))
|
|
86
|
+
&& [b.name, b.repo_name, b.branch, b.modell].every(nullableString)
|
|
87
|
+
&& (b.pid === null || Number.isSafeInteger(b.pid) && b.pid > 0)
|
|
88
|
+
&& (b.welle === null || Number.isSafeInteger(b.welle) && b.welle > 0)
|
|
89
|
+
&& (b.kontext_prozent === null || Number.isFinite(b.kontext_prozent) && b.kontext_prozent >= 0 && b.kontext_prozent <= 100)
|
|
90
|
+
&& typeof b.heuristisch === 'boolean' && [null, 'dialog', 'freigabe', 'frage'].includes(b.warte_grund)
|
|
91
|
+
&& (b.zustand === 'wartet' ? b.warte_grund !== null : b.warte_grund === null)
|
|
92
|
+
&& b.heuristisch === (b.warte_grund === 'frage')
|
|
93
|
+
}
|
|
94
|
+
export function ageBeacon(b: SoBeacon, now: number, alive: boolean): SoBeacon {
|
|
95
|
+
return now - Date.parse(b.aktualisiert_at) > 120_000 && !alive
|
|
96
|
+
? { ...b, zustand: 'unbekannt', warte_grund: null, heuristisch: false } : b
|
|
97
|
+
}
|
|
98
|
+
export function completeState(answer: string): Pick<SoBeacon, 'zustand' | 'warte_grund' | 'heuristisch'> {
|
|
99
|
+
return ownerQuestion(answer) ? { zustand: 'wartet', warte_grund: 'frage', heuristisch: true } : { zustand: 'bereit', warte_grund: null, heuristisch: false }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---- Which hooks the git steps before a push can fire (#1574 proposal 3) ----
|
|
103
|
+
// Per step as rules.ts records it (`gitStep`), the hook names githooks(5) of git 2.56 lists for that command.
|
|
104
|
+
// A ref update fires reference-transaction, an index write post-index-change; commit and fetch end in
|
|
105
|
+
// `gc --auto` (pre-auto-gc); a step that reads the index may query a core.fsmonitor hook. Options a step may carry
|
|
106
|
+
// are not seen here (`commit --amend` → post-rewrite, `--no-verify` skips pre-commit), so each entry is the union.
|
|
107
|
+
const FIREABLE_HOOKS: ReadonlyMap<string, readonly string[]> = new Map<string, readonly string[]>([
|
|
108
|
+
['add', ['post-index-change', 'fsmonitor-watchman']],
|
|
109
|
+
['status', ['post-index-change', 'fsmonitor-watchman']],
|
|
110
|
+
['diff', ['post-index-change', 'fsmonitor-watchman']],
|
|
111
|
+
['ls-files', ['fsmonitor-watchman']],
|
|
112
|
+
['commit', ['pre-commit', 'prepare-commit-msg', 'commit-msg', 'post-commit', 'post-rewrite', 'reference-transaction', 'post-index-change', 'pre-auto-gc', 'fsmonitor-watchman']],
|
|
113
|
+
['fetch', ['reference-transaction', 'pre-auto-gc']],
|
|
114
|
+
['checkout -- <paths>', ['post-checkout', 'post-index-change', 'fsmonitor-watchman']],
|
|
115
|
+
...['log', 'show', 'log [signature]', 'show [signature]', 'rev-parse', 'branch --show-current', 'tag --list'].map((step): [string, readonly string[]] => [step, []]),
|
|
116
|
+
])
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The hook names the git steps earlier on a push's line can fire; null when any step is not in the map, which
|
|
120
|
+
* means any hook may run (fail-closed). Steps outside rules.ts's LOCAL_GIT already ask there, so they need no entry.
|
|
121
|
+
*/
|
|
122
|
+
export function fireableHooks(steps: readonly string[] | undefined): ReadonlySet<string> | null {
|
|
123
|
+
const names = new Set<string>()
|
|
124
|
+
for (const step of steps ?? []) {
|
|
125
|
+
const fired = FIREABLE_HOOKS.get(step)
|
|
126
|
+
if (fired === undefined) return null
|
|
127
|
+
for (const name of fired) names.add(name)
|
|
128
|
+
}
|
|
129
|
+
return names
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Whether a hook defined in config (`git config --null --get-regexp ^hook\.` output) runs on one of `names`
|
|
134
|
+
* (null: on any event): a `hook.<friendly-name>.event` naming one of them, or a friendly name that is one.
|
|
135
|
+
* `.enabled` is not honoured (a disabled hook still counts). null when the output does not parse or holds a
|
|
136
|
+
* `hook.*` key git 2.56 does not document (asks).
|
|
137
|
+
*/
|
|
138
|
+
export function configHooksFire(output: string, names: ReadonlySet<string> | null): boolean | null {
|
|
139
|
+
if (!output.endsWith('\0')) return null
|
|
140
|
+
let fires = false
|
|
141
|
+
for (const entry of output.slice(0, -1).split('\0')) {
|
|
142
|
+
const cut = entry.indexOf('\n')
|
|
143
|
+
const key = cut === -1 ? entry : entry.slice(0, cut)
|
|
144
|
+
if (key === 'hook.jobs') continue
|
|
145
|
+
const parts = /^hook\.(.+)\.(command|event|enabled|parallel|jobs)$/.exec(key)
|
|
146
|
+
if (!parts) return null
|
|
147
|
+
const [, friendly = '', variable] = parts
|
|
148
|
+
if (cut === -1 && (variable === 'event' || variable === 'command')) return null
|
|
149
|
+
const value = entry.slice(cut + 1).trim().toLowerCase()
|
|
150
|
+
if (variable === 'event' && value !== '' && (names === null || names.has(value))) fires = true
|
|
151
|
+
if (variable === 'command' && (names === null || names.has(friendly.toLowerCase()))) fires = true
|
|
152
|
+
}
|
|
153
|
+
return fires
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---- Profile band and /profile (#1552). SSOT of the effects: docs/mods.md § Profiles. ----
|
|
157
|
+
export const PROFILES: readonly SoProfile[] = ['locker', 'ausgewogen', 'streng']
|
|
158
|
+
const PROFILE_EFFECT: Record<SoProfile, string> = {
|
|
159
|
+
locker: 'Regel-Fallen und Kollisionen nur Warnung.',
|
|
160
|
+
ausgewogen: 'Regel-Fallen abgelehnt, Kollision fragt.',
|
|
161
|
+
streng: 'Regel-Fallen und Kollisionen abgelehnt.',
|
|
162
|
+
}
|
|
163
|
+
const PROFILE_ALWAYS = 'Auf jedem Profil: Secrets ausgeben wird abgelehnt, Außenwirkung wird zweimal bestätigt (interaktiv).'
|
|
164
|
+
/** Any value outside the three profiles falls back to the default `ausgewogen`, as the engine does for a stored value outside `options`. */
|
|
165
|
+
export function profileOf(value: unknown): SoProfile {
|
|
166
|
+
return value === 'locker' || value === 'streng' ? value : 'ausgewogen'
|
|
167
|
+
}
|
|
168
|
+
/** The band's lines: the profile (marked when it is only the default), what it loosens or locks, what holds on every profile. */
|
|
169
|
+
export function profileLines(profile: SoProfile, chosen: boolean): string[] {
|
|
170
|
+
return [`Profil: ${profile}${chosen ? '' : ' (Vorgabe, noch nicht gewählt)'}`, PROFILE_EFFECT[profile], PROFILE_ALWAYS]
|
|
171
|
+
}
|
|
172
|
+
/** /profile's output: readable alone in `claude -p "/profile"`, where no band is drawn. */
|
|
173
|
+
export function profileText(profile: SoProfile, chosen: boolean): string {
|
|
174
|
+
return [...profileLines(profile, chosen), `Wechseln: im Band über dem Prompt (${PROFILES.join(', ')}) oder in /config, Eintrag strictness von so-hud.`].join('\n')
|
|
175
|
+
}
|
|
176
|
+
/** Whether settings hold an explicit strictness for this plugin under `pluginConfigs[<plugin>[@<marketplace>]].options`. */
|
|
177
|
+
export function holdsProfile(settings: unknown, plugin: string): boolean {
|
|
178
|
+
const configs: unknown = (settings as { pluginConfigs?: unknown } | null)?.pluginConfigs
|
|
179
|
+
if (!configs || typeof configs !== 'object') return false
|
|
180
|
+
return Object.entries(configs).some(([key, entry]) => key.split('@')[0] === plugin
|
|
181
|
+
&& (PROFILES as readonly unknown[]).includes((entry as { options?: { strictness?: unknown } } | null)?.options?.strictness))
|
|
182
|
+
}
|