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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { readFileSync, readdirSync, statSync, existsSync, realpathSync, lstatSync, openSync, fstatSync, closeSync, constants } from 'node:fs';
|
|
3
|
+
import { readFileSync, readdirSync, statSync, existsSync, realpathSync, lstatSync, openSync, fstatSync, closeSync, readSync, constants } from 'node:fs';
|
|
4
4
|
import { join, extname, relative, basename, sep, resolve, dirname, isAbsolute } from 'node:path';
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
6
|
import { argv } from 'node:process';
|
|
@@ -21,18 +21,75 @@ const isMain =
|
|
|
21
21
|
|
|
22
22
|
const pluginRoot = argv[2] ? canonicalPath(resolve(argv[2])) : argv[2];
|
|
23
23
|
const includeUntracked = argv.slice(3).includes('--include-untracked');
|
|
24
|
-
|
|
24
|
+
// --public-export (#1631): the root is a curated public snapshot. Adds the path check against
|
|
25
|
+
// the export policy and implies --require-owner-patterns (a public tree is never scanned blind).
|
|
26
|
+
const publicExport = argv.slice(3).includes('--public-export');
|
|
27
|
+
const requireOwnerPatterns = argv.slice(3).includes('--require-owner-patterns') || publicExport;
|
|
25
28
|
const packedIndex = argv.indexOf('--packed-files');
|
|
26
29
|
const packedInventoryPath = packedIndex >= 0 ? argv[packedIndex + 1] : null;
|
|
27
30
|
if (isMain && packedIndex >= 0 && (!packedInventoryPath || packedInventoryPath.startsWith('--'))) {
|
|
28
31
|
console.error('packed-files requires an inventory path'); process.exit(1);
|
|
29
32
|
}
|
|
30
33
|
if (isMain && !pluginRoot) {
|
|
31
|
-
console.error('Usage: check-owner-leakage.mjs <plugin-root> [--include-untracked]');
|
|
34
|
+
console.error('Usage: check-owner-leakage.mjs <plugin-root> [--include-untracked] [--require-owner-patterns] [--public-export]');
|
|
32
35
|
process.exit(1);
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
// Host policy stays outside source and package; this file remains standalone.
|
|
39
|
+
|
|
40
|
+
// Public-export policy (#1631, #1594). Lives here, not in scripts/lib/github-export.mjs,
|
|
41
|
+
// because vendored copies carry this file alone; the export CLI imports it from here.
|
|
42
|
+
export const PUBLIC_EXPORT_POLICY_FILE = '.orchestrator/policy/public-export.json';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Validate a public-export policy document.
|
|
46
|
+
* Each `exclude[].path` is an exact repo-relative file, or a directory prefix ending in
|
|
47
|
+
* `/` — no globs, no absolute paths, no `.`/`..` segments.
|
|
48
|
+
* @param {unknown} policy parsed JSON
|
|
49
|
+
* @returns {{ ok: true, excludes: string[] } | { ok: false, reason: string }}
|
|
50
|
+
*/
|
|
51
|
+
export function validatePublicExportPolicy(policy) {
|
|
52
|
+
if (!policy || typeof policy !== 'object' || Array.isArray(policy) || policy.version !== 1) return { ok: false, reason: 'policy must be an object with version 1' };
|
|
53
|
+
if (!Array.isArray(policy.exclude) || policy.exclude.length === 0) return { ok: false, reason: 'exclude must be a non-empty array' };
|
|
54
|
+
const excludes = [];
|
|
55
|
+
for (const entry of policy.exclude) {
|
|
56
|
+
const p = entry?.path;
|
|
57
|
+
if (typeof p !== 'string' || !p || p.startsWith('/') || /[*?[\]{}\\]/.test(p) || [...p].some((c) => c.charCodeAt(0) < 32)
|
|
58
|
+
|| p.replace(/\/$/, '').split('/').some((part) => !part || part === '.' || part === '..')) {
|
|
59
|
+
return { ok: false, reason: 'invalid exclude path (exact file or dir prefix ending in "/", no globs)' };
|
|
60
|
+
}
|
|
61
|
+
excludes.push(p);
|
|
62
|
+
}
|
|
63
|
+
return { ok: true, excludes };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Read and validate `<root>/.orchestrator/policy/public-export.json`.
|
|
68
|
+
* @param {string} root
|
|
69
|
+
* @returns {{ ok: true, excludes: string[] } | { ok: false, reason: string }}
|
|
70
|
+
*/
|
|
71
|
+
export function loadPublicExportPolicy(root) {
|
|
72
|
+
let parsed;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(readFileSync(join(root, PUBLIC_EXPORT_POLICY_FILE), 'utf8'));
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return { ok: false, reason: err?.code === 'ENOENT' ? `${PUBLIC_EXPORT_POLICY_FILE} missing` : `${PUBLIC_EXPORT_POLICY_FILE} unreadable or not JSON` };
|
|
77
|
+
}
|
|
78
|
+
return validatePublicExportPolicy(parsed);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* True when a repo-relative path falls under a public-export exclude. A directory
|
|
83
|
+
* prefix matches only at a path boundary: `docs/audits/` excludes `docs/audits/x.md`
|
|
84
|
+
* but never the sibling `docs/auditsX.md`.
|
|
85
|
+
* @param {string} relPath forward-slash repo-relative path
|
|
86
|
+
* @param {string[]} excludes
|
|
87
|
+
* @returns {boolean}
|
|
88
|
+
*/
|
|
89
|
+
export function isPublicExportExcluded(relPath, excludes) {
|
|
90
|
+
const norm = relPath.replace(/\\/g, '/');
|
|
91
|
+
return excludes.some((p) => (p.endsWith('/') ? norm.startsWith(p) : norm === p));
|
|
92
|
+
}
|
|
36
93
|
export const VAULT_CLEAR_SLUGS = new Set();
|
|
37
94
|
|
|
38
95
|
// Mirrors the zero-import private-config-dir resolver because vendored copies carry this file alone.
|
|
@@ -47,12 +104,24 @@ function policySource(env = process.env) {
|
|
|
47
104
|
const dir = own || (xdg ? join(xdg, 'session-orchestrator') : join(homedir(), '.config', 'session-orchestrator'));
|
|
48
105
|
return (env.SO_OWNER_PATTERNS_FILE || '').trim() || join(dir, 'owner-patterns.json');
|
|
49
106
|
}
|
|
107
|
+
// #1629: a refused DIRECTORY is a host-setup fault (umask 000 leaves the dir 0777), not a
|
|
108
|
+
// leak — it carries the dir so the one-time warning can name it instead of a bare CP1.
|
|
109
|
+
function unsafeDir(dir, st) {
|
|
110
|
+
return Object.assign(new Error('unsafe-dir'), { dir, why: st.isSymbolicLink() ? 'a symlink' : !st.isDirectory() ? 'not a directory' : `mode ${(st.mode & 0o777).toString(8)}, writable by group/others` });
|
|
111
|
+
}
|
|
50
112
|
function checkedHostFile(filename) {
|
|
51
113
|
if (!isAbsolute(filename)) throw new Error('unsafe');
|
|
114
|
+
// Windows has no POSIX mode bits: Node reports 0o666 (0o444 read-only) for every
|
|
115
|
+
// file and directory, so the group/other-write and 0600 checks below would refuse
|
|
116
|
+
// EVERY policy file there (measured on Windows 11, #1612). They apply on POSIX only;
|
|
117
|
+
// the symlink, hard-link, repo-ancestor and size checks still apply everywhere.
|
|
118
|
+
// Ceiling (BV-004): Windows ACLs are not inspected — revisit if the policy file
|
|
119
|
+
// ever carries secrets rather than private names.
|
|
120
|
+
const posixModes = process.platform !== 'win32';
|
|
52
121
|
// Canonical OS aliases (/tmp on macOS) are allowed above the private source parent.
|
|
53
122
|
let cursor = dirname(filename);
|
|
54
123
|
const parent = lstatSync(cursor);
|
|
55
|
-
if (!parent.isDirectory() || parent.isSymbolicLink() || (parent.mode & 0o022)) throw
|
|
124
|
+
if (!parent.isDirectory() || parent.isSymbolicLink() || (posixModes && (parent.mode & 0o022))) throw unsafeDir(cursor, parent);
|
|
56
125
|
const canonicalParent = realpathSync(cursor);
|
|
57
126
|
// Reject repo-local data even if ignored; walk every ancestor for a .git marker.
|
|
58
127
|
cursor = canonicalParent;
|
|
@@ -64,27 +133,50 @@ function checkedHostFile(filename) {
|
|
|
64
133
|
cursor = dirname(filename);
|
|
65
134
|
while (cursor !== dirname(cursor)) {
|
|
66
135
|
const st = lstatSync(cursor);
|
|
67
|
-
if ((st.isSymbolicLink() && st.uid !== 0) || (st.uid !== 0 && (st.mode & 0o022))) throw
|
|
136
|
+
if ((st.isSymbolicLink() && st.uid !== 0) || (posixModes && st.uid !== 0 && (st.mode & 0o022))) throw unsafeDir(cursor, st);
|
|
68
137
|
cursor = dirname(cursor);
|
|
69
138
|
}
|
|
70
139
|
const st = lstatSync(filename);
|
|
71
|
-
if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o7777) !== 0o600 || st.nlink !== 1 || (typeof process.getuid === 'function' && st.uid !== process.getuid())) throw new Error('unsafe');
|
|
140
|
+
if (!st.isFile() || st.isSymbolicLink() || (posixModes && (st.mode & 0o7777) !== 0o600) || st.nlink !== 1 || (typeof process.getuid === 'function' && st.uid !== process.getuid())) throw new Error('unsafe');
|
|
72
141
|
if (st.size > POLICY_LIMIT_BYTES) throw new Error('invalid');
|
|
73
142
|
const fd = openSync(filename, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
74
143
|
try {
|
|
75
144
|
const opened = fstatSync(fd);
|
|
76
|
-
if (!opened.isFile() || opened.ino !== st.ino || opened.dev !== st.dev || (opened.mode & 0o7777) !== 0o600 || opened.size > POLICY_LIMIT_BYTES) throw new Error('unsafe');
|
|
145
|
+
if (!opened.isFile() || opened.ino !== st.ino || opened.dev !== st.dev || (posixModes && (opened.mode & 0o7777) !== 0o600) || opened.size > POLICY_LIMIT_BYTES) throw new Error('unsafe');
|
|
77
146
|
const raw = readFileSync(fd, 'utf8');
|
|
78
147
|
if (Buffer.byteLength(raw) > POLICY_LIMIT_BYTES) throw new Error('invalid');
|
|
79
148
|
return raw;
|
|
80
149
|
} finally { closeSync(fd); }
|
|
81
150
|
}
|
|
151
|
+
// Redacted label for a refused directory: env-var name or `~`-relative, never the raw path
|
|
152
|
+
// (a custom SO_CONFIG_HOME may carry private names). Ancestors render as `<label>/..`.
|
|
153
|
+
function configDirLabel(dir, filename, env) {
|
|
154
|
+
const patterns = (env.SO_OWNER_PATTERNS_FILE || '').trim(), own = (env.SO_CONFIG_HOME || '').trim(), xdg = (env.XDG_CONFIG_HOME || '').trim();
|
|
155
|
+
const [base, label] = patterns ? [dirname(filename), 'dirname($SO_OWNER_PATTERNS_FILE)'] : own ? [own, '$SO_CONFIG_HOME'] : xdg ? [join(xdg, 'session-orchestrator'), '$XDG_CONFIG_HOME/session-orchestrator'] : [homedir(), '~'];
|
|
156
|
+
const rel = relative(base, dir);
|
|
157
|
+
if (!rel) return label;
|
|
158
|
+
return rel.split(sep).every((part) => part === '..') || label === '~' && !rel.startsWith('..') ? `${label}/${rel.split(sep).join('/')}` : 'an ancestor directory of the owner policy';
|
|
159
|
+
}
|
|
160
|
+
const warnedUnsafeDirs = new Set();
|
|
161
|
+
function warnUnsafeConfigDir(err, filename, env) {
|
|
162
|
+
if (warnedUnsafeDirs.has(err.dir)) return;
|
|
163
|
+
warnedUnsafeDirs.add(err.dir);
|
|
164
|
+
const label = configDirLabel(err.dir, filename, env);
|
|
165
|
+
// chmod cannot repair a symlink or a non-directory — name the fix that works.
|
|
166
|
+
const fix = err.why === 'a symlink' ? `replace the symlink ${label} with a real directory (mode 700)`
|
|
167
|
+
: err.why === 'not a directory' ? `replace ${label} with a real directory (mode 700)`
|
|
168
|
+
: err.dir === dirname(filename) ? `chmod 700 ${label}` : `chmod go-w ${label}`;
|
|
169
|
+
console.error(`WARN owner-patterns: unsafe config directory ${label} (${err.why}); owner policy not loaded, so every segment fails closed as CP1 — not a leak. Fix: ${fix}`);
|
|
170
|
+
}
|
|
82
171
|
export function inspectOwnerPatterns({ filePath, env = process.env, refresh = false } = {}) {
|
|
83
172
|
const filename = filePath ?? policySource(env);
|
|
84
173
|
if (!refresh && ownerCache?.filename === filename) return ownerCache.result;
|
|
85
174
|
let result;
|
|
86
175
|
try {
|
|
87
|
-
|
|
176
|
+
// #1613: Windows PowerShell 5.1 (`Set-Content -Encoding utf8`) writes a UTF-8 BOM,
|
|
177
|
+
// which JSON.parse rejects — strip it so such a policy is not read as invalid.
|
|
178
|
+
const raw = checkedHostFile(filename);
|
|
179
|
+
const policy = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw);
|
|
88
180
|
if (!policy || Array.isArray(policy) || policy.version !== 1 || Object.keys(policy).some((key) => key !== 'version' && !POLICY_KEYS.includes(key))) throw new Error('invalid');
|
|
89
181
|
for (const key of POLICY_KEYS) {
|
|
90
182
|
if (!Object.hasOwn(policy, key)) policy[key] = [];
|
|
@@ -94,7 +186,13 @@ export function inspectOwnerPatterns({ filePath, env = process.env, refresh = fa
|
|
|
94
186
|
if (!POLICY_KEYS.slice(0, 6).concat('personalNames').some((key) => policy[key].length)) throw new Error('invalid');
|
|
95
187
|
result = { status: 'ok', policy };
|
|
96
188
|
} catch (err) {
|
|
97
|
-
|
|
189
|
+
if (err.message === 'unsafe-dir') {
|
|
190
|
+
// Fail-closed unchanged: an unsafe directory never supplies (or waives) a policy.
|
|
191
|
+
result = { status: 'unsafe', reason: 'unsafe-config-dir' };
|
|
192
|
+
warnUnsafeConfigDir(err, filename, env);
|
|
193
|
+
} else {
|
|
194
|
+
result = { status: err.code === 'ENOENT' ? 'missing' : err.message === 'unsafe' || err.code === 'ELOOP' ? 'unsafe' : 'invalid', reason: err.code ?? (err.message === 'unsafe' ? 'unsafe-source' : 'invalid-source') };
|
|
195
|
+
}
|
|
98
196
|
}
|
|
99
197
|
ownerCache = {filename, result};
|
|
100
198
|
VAULT_CLEAR_SLUGS.clear();
|
|
@@ -394,9 +492,14 @@ export async function getConfidentialNamePatterns({ loadHostPaths } = {}) {
|
|
|
394
492
|
const TEXT_EXTS = new Set([
|
|
395
493
|
'.md',
|
|
396
494
|
'.mdx',
|
|
495
|
+
'.mdc',
|
|
397
496
|
'.mjs',
|
|
497
|
+
'.cjs',
|
|
398
498
|
'.js',
|
|
399
499
|
'.ts',
|
|
500
|
+
'.tsx',
|
|
501
|
+
'.py',
|
|
502
|
+
'.toml',
|
|
400
503
|
'.json',
|
|
401
504
|
'.jsonl',
|
|
402
505
|
'.yml',
|
|
@@ -411,11 +514,41 @@ const TEXT_EXTS = new Set([
|
|
|
411
514
|
|
|
412
515
|
const DOTFILE_ALLOWLIST = new Set(['.env.example', '.nvmrc', '.vault.yaml']);
|
|
413
516
|
|
|
517
|
+
const BINARY_SNIFF_BYTES = 8192;
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Cheap binary sniff for extensionless files (LICENSE, Dockerfile, .husky hooks,
|
|
521
|
+
* fixture launchers): a NUL byte in the first 8 KB means binary, the same
|
|
522
|
+
* heuristic git uses. An unreadable file counts as text so the scan loop
|
|
523
|
+
* reports it as "file unreadable" instead of skipping it silently.
|
|
524
|
+
* @param {string} filePath
|
|
525
|
+
* @returns {boolean}
|
|
526
|
+
*/
|
|
527
|
+
function looksLikeText(filePath) {
|
|
528
|
+
let fd;
|
|
529
|
+
try {
|
|
530
|
+
fd = openSync(filePath, 'r');
|
|
531
|
+
const buf = Buffer.alloc(BINARY_SNIFF_BYTES);
|
|
532
|
+
const n = readSync(fd, buf, 0, BINARY_SNIFF_BYTES, 0);
|
|
533
|
+
return !buf.subarray(0, n).includes(0);
|
|
534
|
+
} catch {
|
|
535
|
+
return true;
|
|
536
|
+
} finally {
|
|
537
|
+
if (fd !== undefined) closeSync(fd);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Formats that are binary by definition: never scanned, whatever their first bytes look like.
|
|
542
|
+
const BINARY_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.ico', '.icns', '.pdf', '.woff', '.woff2', '.ttf', '.otf', '.eot', '.zip', '.gz', '.tgz', '.bz2', '.xz', '.7z', '.mp3', '.mp4', '.mov', '.webm', '.wav', '.ogg', '.wasm']);
|
|
543
|
+
|
|
414
544
|
function isTextFile(filePath) {
|
|
415
545
|
const base = basename(filePath);
|
|
416
546
|
if (DOTFILE_ALLOWLIST.has(base)) return true;
|
|
417
547
|
const ext = extname(filePath);
|
|
418
|
-
|
|
548
|
+
// A listed extension is text; any other file (no extension, or an unknown one such as .tmpl,
|
|
549
|
+
// .template, .lock) is scanned unless it looks binary, so a new text format is never skipped.
|
|
550
|
+
if (TEXT_EXTS.has(ext)) return true;
|
|
551
|
+
return !BINARY_EXTS.has(ext.toLowerCase()) && looksLikeText(filePath);
|
|
419
552
|
}
|
|
420
553
|
|
|
421
554
|
function getTrackedFiles() {
|
|
@@ -534,7 +667,9 @@ const SELF_EXCLUSIONS = new Set([
|
|
|
534
667
|
const scanFiles = textFiles.filter((f) => {
|
|
535
668
|
const rel = relative(pluginRoot, f).replace(/\\/g, '/');
|
|
536
669
|
if (!packedInventoryPath && rel.startsWith('.orchestrator/audits/')) return false;
|
|
537
|
-
|
|
670
|
+
// --public-export scans everything that ships: these self-exclusions exist for the
|
|
671
|
+
// private repo's own CI, and a public snapshot must not carry their literals unscanned.
|
|
672
|
+
if (!packedInventoryPath && !publicExport && SELF_EXCLUSIONS.has(rel)) return false;
|
|
538
673
|
return true;
|
|
539
674
|
});
|
|
540
675
|
|
|
@@ -548,6 +683,19 @@ if (cp11.disabledReason) {
|
|
|
548
683
|
fail(`<scan-wide> — CP11 (guard disabled): ${cp11.disabledReason}`);
|
|
549
684
|
}
|
|
550
685
|
|
|
686
|
+
// --public-export: path-based, because a content pattern cannot recognise e.g. a
|
|
687
|
+
// preference card or an audit report. Runs over ALL tracked files, not only text files.
|
|
688
|
+
if (publicExport) {
|
|
689
|
+
const exportPolicy = loadPublicExportPolicy(pluginRoot);
|
|
690
|
+
if (!exportPolicy.ok) {
|
|
691
|
+
fail(`<scan-wide> — PX (public-export policy unavailable): ${exportPolicy.reason}`);
|
|
692
|
+
} else {
|
|
693
|
+
const leakedPaths = allFiles.map((f) => relative(pluginRoot, f).replace(/\\/g, '/')).filter((rel) => isPublicExportExcluded(rel, exportPolicy.excludes));
|
|
694
|
+
for (const rel of leakedPaths) fail(`${safeDiagnosticPath(rel, cp11Patterns)} — PX (path excluded from public export)`);
|
|
695
|
+
if (leakedPaths.length === 0) pass(`no public-export excluded path among ${allFiles.length} files`);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
551
699
|
const violations = [];
|
|
552
700
|
|
|
553
701
|
for (const filePath of scanFiles) {
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-test-posix-shell.mjs — tests must not spawn a POSIX shell by bare name.
|
|
4
|
+
*
|
|
5
|
+
* WHY. On native Windows `sh` is not on PATH and `bash` resolves to
|
|
6
|
+
* C:\Windows\System32\bash.exe, which is WSL and cannot see Windows paths
|
|
7
|
+
* (measured 2026-10-22 on the Windows 11 test PC, `where sh & where bash`,
|
|
8
|
+
* SO#1613). A test that runs `spawnSync('sh', …)` or `execFileSync('/bin/bash', …)`
|
|
9
|
+
* is therefore red on Windows for a reason that has nothing to do with the code
|
|
10
|
+
* under test. The native run at fd8d3673 counted 301 red tests in exactly this
|
|
11
|
+
* class; the same session moved ~25 test files to `posixShell()` from
|
|
12
|
+
* `tests/_helpers/posix-shell.mjs`, which returns the name unchanged off Windows
|
|
13
|
+
* and Git Bash's sh.exe/bash.exe on win32. This check keeps the class from
|
|
14
|
+
* coming back.
|
|
15
|
+
*
|
|
16
|
+
* RULE. In tracked `tests/**\/*.mjs`, a finding is:
|
|
17
|
+
* - a call to `spawn` / `spawnSync` / `execFile` / `execFileSync` (bare or as a
|
|
18
|
+
* member, e.g. `cp.spawnSync`) whose FIRST argument is the string literal
|
|
19
|
+
* `sh`, `bash`, `/bin/sh` or `/bin/bash` in any quote style;
|
|
20
|
+
* - a call to `exec` / `execSync` whose command string literal starts with
|
|
21
|
+
* `sh `, `bash `, `/bin/sh ` or `/bin/bash `.
|
|
22
|
+
* A call through `posixShell(...)` is never a finding (its first argument is not
|
|
23
|
+
* a literal). `tests/_helpers/posix-shell.mjs` itself is exempt.
|
|
24
|
+
*
|
|
25
|
+
* METHOD. Textual, over the shared lexer `scripts/lib/js-mask.mjs`: the call
|
|
26
|
+
* name and its opening quote are matched on the fully masked source (comments
|
|
27
|
+
* and literal interiors blanked), so a commented-out spawn and fixture source
|
|
28
|
+
* embedded in a template literal are not scanned as code; the literal VALUE is
|
|
29
|
+
* then read at the same offset from the comments-only mask (offsets are
|
|
30
|
+
* preserved by both modes).
|
|
31
|
+
* Named ceiling (BV-004): a shell name passed through a variable
|
|
32
|
+
* (`const SH = 'sh'; spawnSync(SH, …)`) is not seen. REVISIT if a native
|
|
33
|
+
* Windows run shows that shape red.
|
|
34
|
+
*
|
|
35
|
+
* BASELINE. Three files are changed by open merge requests (!239/!237, !240) at the
|
|
36
|
+
* time this check landed, so the #1613 session left them untouched; each is
|
|
37
|
+
* baselined below (WARN, not FAIL) and gets converted after those MRs land. An
|
|
38
|
+
* entry whose file no longer has a finding FAILs as `baseline-stale` so a
|
|
39
|
+
* converted file does not leave a permanent exemption behind; an entry with an
|
|
40
|
+
* empty reason FAILs as `baseline-missing-reason` (same discipline as
|
|
41
|
+
* `check-hooks-emit-event-guard.mjs`). Every other site FAILs.
|
|
42
|
+
*
|
|
43
|
+
* Usage: check-test-posix-shell.mjs <repo-root>
|
|
44
|
+
* Output: ` PASS: …` / ` WARN: …` / ` FAIL: …` lines, then
|
|
45
|
+
* `Results: N passed, M failed`. Exit 0 = clean (baselined WARNs allowed),
|
|
46
|
+
* 1 = at least one new finding or baseline defect, 2 = tool error.
|
|
47
|
+
*
|
|
48
|
+
* Import-safety: importing this module executes nothing — the isMain guard at
|
|
49
|
+
* the bottom is the only side-effecting path.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { readFileSync } from 'node:fs';
|
|
53
|
+
import path from 'node:path';
|
|
54
|
+
import { maskSource } from '../js-mask.mjs';
|
|
55
|
+
import { listRepoFiles } from './repo-files.mjs';
|
|
56
|
+
import { isMainModule } from '../is-main-module.mjs';
|
|
57
|
+
|
|
58
|
+
const SCAN_DIR = 'tests';
|
|
59
|
+
|
|
60
|
+
/** The helper that resolves the shell; it may name the shells itself. */
|
|
61
|
+
const EXEMPT_FILES = new Set(['tests/_helpers/posix-shell.mjs']);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Baseline — Map<repo-relative file, reason>. Keyed per file, not per line:
|
|
65
|
+
* the open MRs reshape these files, and a line key would silently stop matching.
|
|
66
|
+
*/
|
|
67
|
+
const BASELINE_FILES = new Map([
|
|
68
|
+
[
|
|
69
|
+
'tests/skills/tmux-layout.test.mjs',
|
|
70
|
+
'#1613: file is changed by an open MR (!240); convert to posixShell() after it lands',
|
|
71
|
+
],
|
|
72
|
+
[
|
|
73
|
+
'tests/so-hud/rules.test.mjs',
|
|
74
|
+
'#1613: file is changed by an open MR (!239/!237); convert to posixShell() after it lands',
|
|
75
|
+
],
|
|
76
|
+
[
|
|
77
|
+
'tests/scripts/sweep-expired-learnings-cli.test.mjs',
|
|
78
|
+
'#1613: file is changed by an open MR (!239/!237); convert to posixShell() after it lands',
|
|
79
|
+
],
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
const SHELL_NAMES = new Set(['sh', 'bash', '/bin/sh', '/bin/bash']);
|
|
83
|
+
const SHELL_COMMAND_PREFIX_RE = /^(?:\/bin\/)?(?:sh|bash) /;
|
|
84
|
+
|
|
85
|
+
/** Call name plus open paren plus a quote, on the masked source. */
|
|
86
|
+
const CALL_RE = /\b(spawn|spawnSync|execFile|execFileSync|exec|execSync)\s*\(\s*(['"`])/g;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read the string literal that opens at `start` (a quote char) in `text`.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} text comments-only masked source
|
|
92
|
+
* @param {number} start offset of the opening quote
|
|
93
|
+
* @returns {string | null} the literal's value, or null when it is not a
|
|
94
|
+
* single-line literal without `${` interpolation
|
|
95
|
+
*/
|
|
96
|
+
function literalAt(text, start) {
|
|
97
|
+
const quote = text[start];
|
|
98
|
+
const end = text.indexOf(quote, start + 1);
|
|
99
|
+
if (end === -1) return null;
|
|
100
|
+
const value = text.slice(start + 1, end);
|
|
101
|
+
if (value.includes('\n') || value.includes('${')) return null;
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Find bare-shell spawn call sites in one source text.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} source
|
|
109
|
+
* @returns {{line: number, call: string, shell: string}[]}
|
|
110
|
+
*/
|
|
111
|
+
export function findBareShellCalls(source) {
|
|
112
|
+
const masked = maskSource(source);
|
|
113
|
+
const literals = maskSource(source, { keepLiterals: true });
|
|
114
|
+
/** @type {{line: number, call: string, shell: string}[]} */
|
|
115
|
+
const calls = [];
|
|
116
|
+
for (const m of masked.matchAll(CALL_RE)) {
|
|
117
|
+
const call = m[1];
|
|
118
|
+
const quoteAt = m.index + m[0].length - 1;
|
|
119
|
+
const value = literalAt(literals, quoteAt);
|
|
120
|
+
if (value === null) continue;
|
|
121
|
+
const isExecFamily = call === 'exec' || call === 'execSync';
|
|
122
|
+
const hit = isExecFamily ? SHELL_COMMAND_PREFIX_RE.test(value) : SHELL_NAMES.has(value);
|
|
123
|
+
if (!hit) continue;
|
|
124
|
+
const line = source.slice(0, m.index).split('\n').length;
|
|
125
|
+
calls.push({ line, call, shell: isExecFamily ? value.split(' ')[0] : value });
|
|
126
|
+
}
|
|
127
|
+
return calls;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @typedef {{kind: 'bare-shell', file: string, line: number, call: string, shell: string, baselined: boolean}} ShellFinding
|
|
132
|
+
* @typedef {{kind: 'baseline-stale' | 'baseline-missing-reason', file: string, message: string}} BaselineFinding
|
|
133
|
+
*/
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Scan tracked `tests/**\/*.mjs` under `repoRoot`.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} repoRoot
|
|
139
|
+
* @param {{baseline?: Map<string, string>}} [options] `baseline` defaults to the
|
|
140
|
+
* shipped {@link BASELINE_FILES}; injectable so tests exercise the baseline
|
|
141
|
+
* paths against a tmp fixture.
|
|
142
|
+
* @returns {{findings: (ShellFinding | BaselineFinding)[], readErrors: {file: string, message: string}[]}}
|
|
143
|
+
*/
|
|
144
|
+
export function scanTestPosixShell(repoRoot, { baseline = BASELINE_FILES } = {}) {
|
|
145
|
+
const files = listRepoFiles(repoRoot, { dirs: [SCAN_DIR], exts: ['mjs'] });
|
|
146
|
+
/** @type {(ShellFinding | BaselineFinding)[]} */
|
|
147
|
+
const findings = [];
|
|
148
|
+
/** @type {{file: string, message: string}[]} */
|
|
149
|
+
const readErrors = [];
|
|
150
|
+
/** @type {Set<string>} */
|
|
151
|
+
const matchedBaseline = new Set();
|
|
152
|
+
|
|
153
|
+
for (const absFile of files) {
|
|
154
|
+
const rel = path.relative(repoRoot, absFile).split(path.sep).join('/');
|
|
155
|
+
if (EXEMPT_FILES.has(rel)) continue;
|
|
156
|
+
let source;
|
|
157
|
+
try {
|
|
158
|
+
source = readFileSync(absFile, 'utf8');
|
|
159
|
+
} catch (err) {
|
|
160
|
+
readErrors.push({ file: rel, message: `unreadable: ${err?.message ?? String(err)}` });
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (!/\b(?:spawn|exec)/.test(source)) continue; // cheap textual pre-filter
|
|
164
|
+
|
|
165
|
+
const calls = findBareShellCalls(source);
|
|
166
|
+
if (calls.length === 0) continue;
|
|
167
|
+
const baselined = baseline.has(rel);
|
|
168
|
+
if (baselined) {
|
|
169
|
+
matchedBaseline.add(rel);
|
|
170
|
+
if (String(baseline.get(rel) ?? '').trim() === '') {
|
|
171
|
+
findings.push({
|
|
172
|
+
kind: 'baseline-missing-reason',
|
|
173
|
+
file: rel,
|
|
174
|
+
message: 'baseline entry has no reason — name the linked issue or remove the entry',
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
for (const c of calls) findings.push({ kind: 'bare-shell', file: rel, ...c, baselined });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (const file of baseline.keys()) {
|
|
182
|
+
if (matchedBaseline.has(file)) continue;
|
|
183
|
+
findings.push({
|
|
184
|
+
kind: 'baseline-stale',
|
|
185
|
+
file,
|
|
186
|
+
message: 'baseline entry no longer has a bare shell spawn (converted or removed) — remove the entry',
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { findings, readErrors };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Run the check and print the validate-plugin line vocabulary.
|
|
195
|
+
*
|
|
196
|
+
* @param {string} repoRoot
|
|
197
|
+
* @param {{baseline?: Map<string, string>}} [options] forwarded to {@link scanTestPosixShell}
|
|
198
|
+
* @returns {number} 0 = clean, 1 = new finding(s), 2 = tool error
|
|
199
|
+
*/
|
|
200
|
+
export function runCheckTestPosixShell(repoRoot, options) {
|
|
201
|
+
console.log('--- Check: tests spawn a POSIX shell via posixShell(), not by bare name (#1613) ---');
|
|
202
|
+
|
|
203
|
+
const { findings, readErrors } = scanTestPosixShell(repoRoot, options);
|
|
204
|
+
if (readErrors.length > 0) {
|
|
205
|
+
for (const e of readErrors) console.log(` FAIL: ${e.file} — ${e.message}`);
|
|
206
|
+
console.log('');
|
|
207
|
+
console.log(`Results: 0 passed, ${readErrors.length} failed`);
|
|
208
|
+
return 2;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const shellFindings = /** @type {ShellFinding[]} */ (findings.filter((f) => f.kind === 'bare-shell'));
|
|
212
|
+
const baselineIssues = /** @type {BaselineFinding[]} */ (findings.filter((f) => f.kind !== 'bare-shell'));
|
|
213
|
+
const fresh = shellFindings.filter((f) => !f.baselined);
|
|
214
|
+
const old = shellFindings.filter((f) => f.baselined);
|
|
215
|
+
|
|
216
|
+
if (fresh.length === 0 && baselineIssues.length === 0) {
|
|
217
|
+
console.log(
|
|
218
|
+
old.length === 0
|
|
219
|
+
? ' PASS: no test spawns sh/bash by bare name'
|
|
220
|
+
: ` PASS: no NEW bare sh/bash spawn in tests/ (${old.length} pre-existing, baselined — see #1613)`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
for (const f of old) {
|
|
224
|
+
console.log(` WARN: ${f.file}:${f.line} — ${f.call}('${f.shell}' …) by bare name (baselined #1613, file is in an open MR)`);
|
|
225
|
+
}
|
|
226
|
+
for (const f of fresh) {
|
|
227
|
+
console.log(
|
|
228
|
+
` FAIL: ${f.file}:${f.line} — ${f.call}('${f.shell}' …) by bare name; on native Windows sh is not on PATH and bash is WSL — use posixShell() from tests/_helpers/posix-shell.mjs`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
for (const f of baselineIssues) console.log(` FAIL: ${f.file} — ${f.message} (${f.kind})`);
|
|
232
|
+
|
|
233
|
+
const failCount = fresh.length + baselineIssues.length;
|
|
234
|
+
console.log('');
|
|
235
|
+
console.log(`Results: ${failCount === 0 ? 1 : 0} passed, ${failCount} failed`);
|
|
236
|
+
return failCount > 0 ? 1 : 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (isMainModule(import.meta.url)) {
|
|
240
|
+
const root = process.argv[2];
|
|
241
|
+
if (!root) {
|
|
242
|
+
console.error('Usage: check-test-posix-shell.mjs <repo-root>');
|
|
243
|
+
process.exit(2);
|
|
244
|
+
}
|
|
245
|
+
process.exit(runCheckTestPosixShell(path.resolve(root)));
|
|
246
|
+
}
|
|
@@ -292,6 +292,8 @@ import path from 'node:path';
|
|
|
292
292
|
|
|
293
293
|
import { sizingSubject } from '../learnings/sizing-subject.mjs';
|
|
294
294
|
import { isMainModule } from '../is-main-module.mjs';
|
|
295
|
+
import { resolveLedgerPath } from '../ledger-path.mjs';
|
|
296
|
+
import { resolveEventsLedgerRoot } from '../platform.mjs';
|
|
295
297
|
|
|
296
298
|
/** Documented config surface — every `yaml` fence in this file is a declaration. */
|
|
297
299
|
const TEMPLATE_REL = 'docs/session-config-template.md';
|
|
@@ -1248,7 +1250,7 @@ export function collectHandKeyedLearningSubjects(pluginRoot) {
|
|
|
1248
1250
|
let judged = 0;
|
|
1249
1251
|
let unattributed = 0;
|
|
1250
1252
|
|
|
1251
|
-
const learnings = readJsonl(
|
|
1253
|
+
const learnings = readJsonl(resolveLedgerPath(LEARNINGS_REL, pluginRoot));
|
|
1252
1254
|
if (learnings.length === 0) return { findings, scanned: { judged, unattributed } };
|
|
1253
1255
|
|
|
1254
1256
|
// A session record can be addressed by either identity form (see
|
|
@@ -1257,7 +1259,7 @@ export function collectHandKeyedLearningSubjects(pluginRoot) {
|
|
|
1257
1259
|
// a resolvable record is never mis-counted as unattributed.
|
|
1258
1260
|
/** @type {Map<string, Record<string, unknown>>} */
|
|
1259
1261
|
const sessionsById = new Map();
|
|
1260
|
-
for (const record of readJsonl(path.join(pluginRoot, SESSIONS_REL))) {
|
|
1262
|
+
for (const record of readJsonl(path.join(resolveEventsLedgerRoot(pluginRoot), SESSIONS_REL))) {
|
|
1261
1263
|
for (const field of ['session_id', 'semantic_session_id']) {
|
|
1262
1264
|
const id = record[field];
|
|
1263
1265
|
if (typeof id === 'string' && id !== '' && !sessionsById.has(id)) sessionsById.set(id, record);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Separate WARN-only check for gh pr commands rejected with --repo/-R (#1024).
|
|
4
|
+
* Shares corpus discovery and call-shape parsing with the bare-repo census,
|
|
5
|
+
* but its findings advise naming the PR or dropping the flag, never adding it.
|
|
6
|
+
* Coverage ceilings (literal argv, shell fences/strings) are shared with that census.
|
|
7
|
+
*/
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { inspectVcsRepoFlag } from './check-vcs-repo-flag.mjs';
|
|
10
|
+
import { isMainModule } from '../is-main-module.mjs';
|
|
11
|
+
|
|
12
|
+
/** @param {string} pluginRoot independent check envelope */
|
|
13
|
+
export function inspectVcsRepoFlagPositional(pluginRoot) {
|
|
14
|
+
const result = inspectVcsRepoFlag(pluginRoot, { rejected: true });
|
|
15
|
+
return {
|
|
16
|
+
ok: result.ok,
|
|
17
|
+
toolError: result.toolError,
|
|
18
|
+
summary: {
|
|
19
|
+
filesScanned: result.summary.filesScanned,
|
|
20
|
+
unresolvedArgv: result.summary.unresolvedArgv,
|
|
21
|
+
findings: result.findings.length,
|
|
22
|
+
byKind: result.summary.byKind,
|
|
23
|
+
},
|
|
24
|
+
findings: result.findings,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (isMainModule(import.meta.url)) {
|
|
29
|
+
const args = process.argv.slice(2);
|
|
30
|
+
const usage =
|
|
31
|
+
'Usage: check-vcs-repo-flag-positional.mjs [<plugin-root>] [--json]\n' +
|
|
32
|
+
'Exit: 0 check completed (findings WARN-only) · 1 usage error · 2 tool error';
|
|
33
|
+
const unknown = args.filter((arg) => arg.startsWith('-') && !['--json', '--help'].includes(arg));
|
|
34
|
+
const roots = args.filter((arg) => !arg.startsWith('-'));
|
|
35
|
+
if (unknown.length || roots.length > 1) {
|
|
36
|
+
console.error(usage);
|
|
37
|
+
process.exitCode = 1;
|
|
38
|
+
} else if (args.includes('--help')) {
|
|
39
|
+
console.log(usage);
|
|
40
|
+
} else {
|
|
41
|
+
const result = inspectVcsRepoFlagPositional(path.resolve(roots[0] ?? process.cwd()));
|
|
42
|
+
if (args.includes('--json')) console.log(JSON.stringify(result, null, 2));
|
|
43
|
+
else {
|
|
44
|
+
console.log('--- Check: --repo/-R requiring a PR positional (WARN-only) ---');
|
|
45
|
+
for (const finding of result.findings) {
|
|
46
|
+
console.log(
|
|
47
|
+
` ${result.toolError ? 'FAIL' : 'WARN'}: [${finding.kind}] ${finding.file}:${finding.line} — ${finding.message}`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (!result.toolError)
|
|
51
|
+
console.log(
|
|
52
|
+
` PASS: scanned ${result.summary.filesScanned} file(s), ${result.findings.length} rejected repo flag(s), ${result.summary.unresolvedArgv} variable argv array(s) unjudged`,
|
|
53
|
+
);
|
|
54
|
+
console.log(`Results: ${result.toolError ? '0 passed, 1 failed' : '1 passed, 0 failed'}`);
|
|
55
|
+
}
|
|
56
|
+
process.exitCode = result.toolError ? 2 : 0;
|
|
57
|
+
}
|
|
58
|
+
}
|