session-orchestrator 5.2.0 → 5.3.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/.agents/skills/architecture/SKILL.md +3 -1
- package/.agents/skills/autopilot/SKILL.md +5 -1
- package/.agents/skills/autopilot/agents/openai.yaml +5 -0
- package/.agents/skills/bootstrap/SKILL.md +5 -1
- package/.agents/skills/bootstrap/agents/openai.yaml +5 -0
- package/.agents/skills/brainstorm/SKILL.md +5 -1
- package/.agents/skills/brainstorm/agents/openai.yaml +5 -0
- package/.agents/skills/claude-md-drift-check/SKILL.md +3 -1
- package/.agents/skills/close/SKILL.md +5 -1
- package/.agents/skills/close/agents/openai.yaml +5 -0
- package/.agents/skills/convergence-monitoring/SKILL.md +4 -2
- package/.agents/skills/debug/SKILL.md +5 -1
- package/.agents/skills/debug/agents/openai.yaml +5 -0
- package/.agents/skills/discovery/SKILL.md +5 -1
- package/.agents/skills/discovery/agents/openai.yaml +5 -0
- package/.agents/skills/dispatcher/SKILL.md +5 -1
- package/.agents/skills/dispatcher/agents/openai.yaml +5 -0
- package/.agents/skills/docs-orchestrator/SKILL.md +3 -1
- package/.agents/skills/ecosystem-health/SKILL.md +3 -1
- package/.agents/skills/eli5/SKILL.md +5 -1
- package/.agents/skills/eli5/agents/openai.yaml +5 -0
- package/.agents/skills/eval/SKILL.md +6 -2
- package/.agents/skills/eval/agents/openai.yaml +5 -0
- package/.agents/skills/evolve/SKILL.md +6 -2
- package/.agents/skills/evolve/agents/openai.yaml +5 -0
- package/.agents/skills/frontmatter-guard/SKILL.md +3 -1
- package/.agents/skills/gitlab-ops/SKILL.md +3 -1
- package/.agents/skills/gitlab-portfolio/SKILL.md +3 -1
- package/.agents/skills/go/SKILL.md +5 -1
- package/.agents/skills/go/agents/openai.yaml +5 -0
- package/.agents/skills/grill/SKILL.md +5 -1
- package/.agents/skills/grill/agents/openai.yaml +5 -0
- package/.agents/skills/harness-audit/SKILL.md +5 -1
- package/.agents/skills/harness-audit/agents/openai.yaml +5 -0
- package/.agents/skills/hook-development/SKILL.md +3 -1
- package/.agents/skills/mcp-builder/SKILL.md +3 -1
- package/.agents/skills/memory-cleanup/SKILL.md +5 -1
- package/.agents/skills/memory-cleanup/agents/openai.yaml +5 -0
- package/.agents/skills/mode-selector/SKILL.md +3 -1
- package/.agents/skills/npm-publish/SKILL.md +4 -2
- package/.agents/skills/peekaboo-driver/SKILL.md +3 -1
- package/.agents/skills/persona-panel/SKILL.md +5 -1
- package/.agents/skills/persona-panel/agents/openai.yaml +5 -0
- package/.agents/skills/plan/SKILL.md +5 -1
- package/.agents/skills/plan/agents/openai.yaml +5 -0
- package/.agents/skills/playwright-driver/SKILL.md +3 -1
- package/.agents/skills/portfolio/SKILL.md +5 -1
- package/.agents/skills/portfolio/agents/openai.yaml +5 -0
- package/.agents/skills/quality-gates/SKILL.md +3 -1
- package/.agents/skills/reconcile/SKILL.md +5 -1
- package/.agents/skills/reconcile/agents/openai.yaml +5 -0
- package/.agents/skills/release/SKILL.md +5 -1
- package/.agents/skills/release/agents/openai.yaml +5 -0
- package/.agents/skills/remote-offload/SKILL.md +3 -1
- package/.agents/skills/repo-audit/SKILL.md +5 -1
- package/.agents/skills/repo-audit/agents/openai.yaml +5 -0
- package/.agents/skills/session/SKILL.md +21 -0
- package/.agents/skills/session/agents/openai.yaml +5 -0
- package/.agents/skills/session-end/SKILL.md +3 -1
- package/.agents/skills/session-plan/SKILL.md +3 -1
- package/.agents/skills/session-start/SKILL.md +3 -1
- package/.agents/skills/spinout/SKILL.md +5 -1
- package/.agents/skills/spinout/agents/openai.yaml +5 -0
- package/.agents/skills/sunset-review/SKILL.md +5 -1
- package/.agents/skills/sunset-review/agents/openai.yaml +5 -0
- package/.agents/skills/templates-ack/SKILL.md +21 -0
- package/.agents/skills/templates-ack/agents/openai.yaml +5 -0
- package/.agents/skills/test/SKILL.md +5 -1
- package/.agents/skills/test/agents/openai.yaml +5 -0
- package/.agents/skills/test-runner/SKILL.md +3 -1
- package/.agents/skills/tmux-layout/SKILL.md +3 -1
- package/.agents/skills/using-orchestrator/SKILL.md +3 -1
- package/.agents/skills/ux-grill/SKILL.md +5 -1
- package/.agents/skills/ux-grill/agents/openai.yaml +5 -0
- package/.agents/skills/vault-mirror/SKILL.md +3 -1
- package/.agents/skills/vault-sync/SKILL.md +3 -1
- package/.agents/skills/wave-executor/SKILL.md +3 -1
- package/.agents/skills/write-executable-plan/SKILL.md +3 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +4 -4
- package/.codex-plugin/skills/convergence-monitoring/SKILL.md +1 -3
- package/.codex-plugin/skills/eval/SKILL.md +1 -1
- package/.codex-plugin/skills/evolve/SKILL.md +1 -1
- package/.codex-plugin/skills/npm-publish/SKILL.md +1 -3
- package/.codex-plugin/skills/session/SKILL.md +1 -1
- package/.cursor/commands/eval.md +1 -1
- package/.cursor/commands/session.md +1 -1
- package/.cursor/rules/000-session-orchestrator.mdc +0 -2
- package/.cursor/rules/050-plan.mdc +1 -1
- package/.cursor/skills/convergence-monitoring/SKILL.md +1 -0
- package/.cursor/skills/eval/SKILL.md +1 -1
- package/.cursor/skills/npm-publish/SKILL.md +1 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +12 -3
- package/AGENTS.md +3 -2
- package/CHANGELOG.md +136 -0
- package/README.md +9 -9
- package/SECURITY.md +12 -0
- package/agents/dialectic-deriver.md +13 -10
- package/agents/eval-judge.md +67 -45
- package/agents/skill-applied-judge.md +34 -19
- package/commands/session.md +7 -3
- package/docs/baseline.md +12 -6
- package/docs/codex-setup.md +14 -2
- package/docs/components.md +7 -5
- package/docs/events-schema.md +56 -9
- package/docs/rule-authoring.md +58 -6
- package/docs/session-config-reference.md +100 -7
- package/docs/session-config-template.md +31 -2
- package/docs/telemetry.md +2 -0
- package/hooks/_lib/hook-import-set.json +85 -8
- package/hooks/_lib/subagent-transcript.mjs +582 -31
- package/hooks/config-protection.mjs +11 -3
- package/hooks/cwd-change-restore.mjs +11 -3
- package/hooks/enforce-commands.mjs +70 -23
- package/hooks/enforce-scope.mjs +143 -33
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/loop-guard.mjs +11 -3
- package/hooks/on-session-end.mjs +58 -23
- package/hooks/on-session-start.mjs +48 -11
- package/hooks/on-stop.mjs +168 -22
- package/hooks/operator-steer.mjs +11 -3
- package/hooks/post-bash-issue-budget-refund.mjs +18 -8
- package/hooks/post-bash-write-verify.mjs +3 -2
- package/hooks/post-edit-import-probe.mjs +17 -9
- package/hooks/post-edit-validate.mjs +13 -5
- package/hooks/post-subagent-discovery-validator.mjs +98 -13
- package/hooks/post-tool-batch-wave-signal.mjs +200 -38
- package/hooks/post-tool-failure-corrective-context.mjs +11 -5
- package/hooks/post-tooluse-frontend-slop.mjs +10 -4
- package/hooks/pre-auq-clarity.mjs +15 -2
- package/hooks/pre-bash-destructive-guard.mjs +80 -9
- package/hooks/pre-bash-issue-budget.mjs +16 -11
- package/hooks/pre-bash-memory-propose-audit.mjs +86 -54
- package/hooks/pre-bash-sessions-ledger-guard.mjs +391 -20
- package/hooks/pre-bash-staging-fence.mjs +335 -31
- package/hooks/pre-bash-templates-first.mjs +19 -14
- package/hooks/pre-task-scope-disjoint.mjs +233 -2
- package/hooks/subagent-telemetry.mjs +15 -19
- package/hooks/wave-scope-commit-guard.mjs +197 -100
- package/monitors/monitors.json +1 -1
- package/output-styles/wave-summary.md +1 -1
- package/package.json +1 -1
- package/pi/prompts/eval.md +1 -1
- package/pi/prompts/session.md +1 -1
- package/rules/README.md +1 -1
- package/rules/opt-in-domain/prompt-caching.md +1 -1
- package/rules/opt-in-stack/backend-data.md +1 -1
- package/rules/opt-in-stack/backend.md +3 -3
- package/rules/opt-in-stack/frontend.md +1 -1
- package/rules/opt-in-stack/security-web.md +3 -3
- package/rules/opt-in-stack/swift.md +1 -1
- package/scripts/autopilot.mjs +23 -2
- package/scripts/backfill-abandoned-sessions.mjs +117 -15
- package/scripts/check-sessions-integrity.mjs +300 -0
- package/scripts/dialectic-deriver.mjs +50 -13
- package/scripts/emit-session.mjs +75 -29
- package/scripts/eval-session.mjs +65 -3
- package/scripts/generate-agents-skills.mjs +102 -29
- package/scripts/generate-cursor-adapter.mjs +61 -16
- package/scripts/lib/agent-status.mjs +2 -31
- package/scripts/lib/auq/clarity.mjs +10 -2
- package/scripts/lib/auq/parse.mjs +12 -31
- package/scripts/lib/auq/schema.mjs +56 -41
- package/scripts/lib/auto-dialectic.mjs +304 -15
- package/scripts/lib/autopilot/flags.mjs +12 -1
- package/scripts/lib/autopilot/kill-switches.mjs +6 -3
- package/scripts/lib/autopilot/loop.mjs +14 -1
- package/scripts/lib/autopilot/stall-sampler.mjs +80 -23
- package/scripts/lib/ci-status-banner.mjs +376 -16
- package/scripts/lib/command-blocker.mjs +275 -28
- package/scripts/lib/config/dialectic.mjs +12 -3
- package/scripts/lib/config/gate.mjs +74 -0
- package/scripts/lib/config/reaper.mjs +162 -0
- package/scripts/lib/config.mjs +14 -0
- package/scripts/lib/convergence-monitor.mjs +74 -11
- package/scripts/lib/ecosystem-health.mjs +11 -0
- package/scripts/lib/eval/engine.mjs +421 -53
- package/scripts/lib/eval/judge.mjs +463 -40
- package/scripts/lib/eval/schema.mjs +10 -1
- package/scripts/lib/events-rotation.mjs +221 -25
- package/scripts/lib/events-schema.mjs +114 -0
- package/scripts/lib/events.mjs +524 -5
- package/scripts/lib/frontmatter-guard.mjs +21 -10
- package/scripts/lib/gates/gate-baseline.mjs +27 -2
- package/scripts/lib/gates/gate-full.mjs +28 -3
- package/scripts/lib/gates/gate-helpers.mjs +243 -21
- package/scripts/lib/gates/gate-incremental.mjs +28 -3
- package/scripts/lib/gates/gate-per-file.mjs +27 -2
- package/scripts/lib/gitlab-portfolio/markdown-writer.mjs +6 -1
- package/scripts/lib/instruction-budget-guard.mjs +146 -4
- package/scripts/lib/io.mjs +42 -8
- package/scripts/lib/issue-close-strip-labels.mjs +207 -49
- package/scripts/lib/js-mask.mjs +197 -0
- package/scripts/lib/learnings/evolve-telemetry.mjs +11 -7
- package/scripts/lib/maintenance-due-banner.mjs +53 -88
- package/scripts/lib/orphan-reaper.mjs +1588 -0
- package/scripts/lib/peer-cards/merger.mjs +48 -10
- package/scripts/lib/peer-cards/reader.mjs +78 -2
- package/scripts/lib/process-group.mjs +899 -0
- package/scripts/lib/quality-gate.mjs +107 -28
- package/scripts/lib/reconcile/backlog.mjs +368 -0
- package/scripts/lib/reconcile/engine.mjs +55 -188
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +302 -60
- package/scripts/lib/reconcile/sanitize.mjs +69 -3
- package/scripts/lib/reconcile-nudge-banner.mjs +138 -45
- package/scripts/lib/resource-probe/parsers.mjs +31 -0
- package/scripts/lib/rule-loader.mjs +41 -12
- package/scripts/lib/scope-echo.mjs +39 -2
- package/scripts/lib/scope-gate.mjs +605 -1
- package/scripts/lib/session-close-backfill.mjs +33 -6
- package/scripts/lib/session-id.mjs +9 -20
- package/scripts/lib/session-invocation.mjs +20 -0
- package/scripts/lib/session-schema/constants.mjs +30 -2
- package/scripts/lib/session-schema/normalizer.mjs +56 -4
- package/scripts/lib/session-schema.mjs +8 -3
- package/scripts/lib/session-start-probes.mjs +95 -10
- package/scripts/lib/sessions-canonical.mjs +23 -0
- package/scripts/lib/sessions-integrity-banner.mjs +7 -1
- package/scripts/lib/sessions-staleness-banner.mjs +193 -51
- package/scripts/lib/skill-evidence-window.mjs +891 -0
- package/scripts/lib/skill-evolution/candidate-intake.mjs +133 -12
- package/scripts/lib/skill-evolution/engine.mjs +18 -9
- package/scripts/lib/skill-judge.mjs +45 -3
- package/scripts/lib/tail-window.mjs +56 -0
- package/scripts/lib/telemetry/schema.mjs +30 -0
- package/scripts/lib/telemetry/sync.mjs +61 -6
- package/scripts/lib/telemetry-flush-health-banner.mjs +4 -22
- package/scripts/lib/test-runner/issue-reconcile.mjs +48 -16
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +72 -13
- package/scripts/lib/user-invocable-skills.mjs +23 -3
- package/scripts/lib/ux-grill/reconcile.mjs +48 -22
- package/scripts/lib/validate/check-agents-skills.mjs +26 -15
- package/scripts/lib/validate/check-cursor-adapter.mjs +1 -0
- package/scripts/lib/validate/check-entry-guard.mjs +13 -50
- package/scripts/lib/validate/check-hook-entry-guards.mjs +636 -0
- package/scripts/lib/validate/check-pi-prompts.mjs +1 -0
- package/scripts/lib/validate/check-rules.mjs +7 -5
- package/scripts/lib/validate/check-skill-links.mjs +9 -1
- package/scripts/lib/validate/check-skill-script-paths.mjs +239 -27
- package/scripts/lib/validate/check-test-git-config-target.mjs +24 -34
- package/scripts/lib/validate/check-untracked-test-deps.mjs +7 -102
- package/scripts/lib/validate/check-unwired-features.mjs +130 -27
- package/scripts/lib/validate/check-validator-registration.mjs +34 -10
- package/scripts/lib/validate/confidential-names.mjs +10 -0
- package/scripts/lib/validate-vendored-rules.mjs +4 -3
- package/scripts/lib/vault-mirror/namespace.mjs +46 -8
- package/scripts/lib/vault-mirror/process.mjs +10 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +12 -2
- package/scripts/lib/vault-status/narrative-mirror.mjs +31 -7
- package/scripts/lib/vault-yaml.mjs +118 -0
- package/scripts/lib/worktree/lifecycle.mjs +153 -1
- package/scripts/release-session-lock.mjs +305 -0
- package/scripts/release.mjs +30 -5
- package/scripts/resolve-session-invocation.mjs +59 -0
- package/scripts/run-quality-gate.mjs +156 -17
- package/scripts/sweep-expired-rules.mjs +14 -3
- package/scripts/validate-plugin.mjs +12 -0
- package/scripts/validate-wave-scope.mjs +32 -105
- package/scripts/vault-mirror.mjs +9 -1
- package/skills/_shared/platform-tools.md +23 -11
- package/skills/autopilot/SKILL.md +22 -7
- package/skills/claude-md-drift-check/SKILL.md +1 -1
- package/skills/convergence-monitoring/README.md +8 -1
- package/skills/convergence-monitoring/SIGNALS.md +50 -6
- package/skills/convergence-monitoring/SKILL.md +15 -6
- package/skills/eval/SKILL.md +39 -24
- package/skills/eval/rubric-v1.md +1 -0
- package/skills/eval/rubric-v2.md +457 -0
- package/skills/evolve/SKILL.md +1 -1
- package/skills/evolve/references/evolve-dialectic-mode.md +42 -25
- package/skills/gitlab-ops/SKILL.md +3 -2
- package/skills/npm-publish/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +11 -0
- package/skills/session-end/SKILL.md +13 -16
- package/skills/session-end/discovery-scan.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +55 -9
- package/skills/session-end/references/phase-5-issue-cleanup.md +9 -14
- package/skills/session-end/session-metrics-write.md +10 -0
- package/skills/session-plan/SKILL.md +17 -5
- package/skills/session-plan/references/session-plan-task-classification.md +2 -2
- package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -1
- package/skills/ux-grill/SKILL.md +1 -1
- package/skills/wave-executor/SKILL.md +8 -4
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +5 -3
- package/skills/wave-executor/references/wave-loop-dispatch.md +2 -1
- package/.codex-plugin/skills/convergence-monitoring/agents/openai.yaml +0 -5
- package/.codex-plugin/skills/npm-publish/agents/openai.yaml +0 -5
- package/.cursor/commands/convergence-monitoring.md +0 -13
- package/.cursor/commands/npm-publish.md +0 -13
- package/pi/prompts/convergence-monitoring.md +0 -11
- package/pi/prompts/npm-publish.md +0 -11
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-hook-entry-guards.mjs — recurrence guard for #1393: every hook
|
|
4
|
+
* REGISTERED in a platform manifest must run its handler ONLY when it is the
|
|
5
|
+
* script node was invoked with. #1422.
|
|
6
|
+
*
|
|
7
|
+
* ## The gap this fills (and why the existing checker cannot)
|
|
8
|
+
*
|
|
9
|
+
* `./check-entry-guard.mjs` (#1371) censuses guards that are ALREADY THERE for
|
|
10
|
+
* symlink-fragile comparison forms. Its oracle only ever looks at statement
|
|
11
|
+
* fragments that mention the invocation-path expression — so a hook file
|
|
12
|
+
* carrying NO guard at all is never seen by it and passes trivially. That is
|
|
13
|
+
* exactly the regression #1393 fixed across 25 hooks, and exactly the shape
|
|
14
|
+
* nothing would have caught coming back.
|
|
15
|
+
*
|
|
16
|
+
* The two checkers are complements, not overlaps:
|
|
17
|
+
*
|
|
18
|
+
* | checker | question |
|
|
19
|
+
* |------------------------|-------------------------------------------|
|
|
20
|
+
* | `check-entry-guard` | is the guard that EXISTS written safely? |
|
|
21
|
+
* | this one | is there a guard AT ALL, around the entry? |
|
|
22
|
+
*
|
|
23
|
+
* ## Why it matters, measured (#1393, 2026-09-20)
|
|
24
|
+
*
|
|
25
|
+
* Before the sweep, under `SO_HOOK_PROFILE=off`, EVERY one of the 11
|
|
26
|
+
* registered hooks in one agent's half killed the process that merely
|
|
27
|
+
* IMPORTED it — the profile gate's `process.exit(0)` sat at module top level.
|
|
28
|
+
* Under a default environment only 3 of 28 were observably broken, because
|
|
29
|
+
* the exit is CONDITIONAL. A defect that is invisible in the common
|
|
30
|
+
* environment and total in the uncommon one is precisely the kind worth a
|
|
31
|
+
* mechanical oracle rather than a reviewer's attention.
|
|
32
|
+
*
|
|
33
|
+
* The cost side has a name too: `post-tool-failure-corrective-context.mjs`
|
|
34
|
+
* did not merely exit on import — it WROTE a PostToolUseFailure envelope onto
|
|
35
|
+
* the importer's stdout.
|
|
36
|
+
*
|
|
37
|
+
* ## Population
|
|
38
|
+
*
|
|
39
|
+
* The union of every `.mjs` path appearing in a `command` string of the four
|
|
40
|
+
* platform manifests: `hooks/hooks.json` (Claude Code), `hooks-codex.json`,
|
|
41
|
+
* `hooks-cursor.json`, `hooks-pi.json`. The three foreign manifests are
|
|
42
|
+
* subsets of the first today, but they are read anyway so a hook registered
|
|
43
|
+
* ONLY on a foreign platform can never fall out of the census. A manifest
|
|
44
|
+
* file that does not exist is SKIPPED (not an error): a consumer repo, or a
|
|
45
|
+
* fork, may legitimately ship fewer platforms.
|
|
46
|
+
*
|
|
47
|
+
* A path is normalised by taking the last `hooks` path SEGMENT onward, so
|
|
48
|
+
* `"$CURSOR_PLUGIN_ROOT/hooks/on-stop.mjs"` and `hooks/on-stop.mjs` are one
|
|
49
|
+
* entry, while a hypothetical `subhooks/x.mjs` is not mistaken for one (the
|
|
50
|
+
* match is on a whole segment, never a substring).
|
|
51
|
+
*
|
|
52
|
+
* ## Findings
|
|
53
|
+
*
|
|
54
|
+
* - `missing-entry-guard` — the file has no guard construct at all, or a
|
|
55
|
+
* module-top-level `main(...)` call sits OUTSIDE one. Either way a bare
|
|
56
|
+
* `import()` of the file runs the handler.
|
|
57
|
+
* - `toplevel-profile-exit` — a `shouldRunHook(...)` call at module top level,
|
|
58
|
+
* outside the guard, in a statement that also calls `process.exit`. This is
|
|
59
|
+
* the #1393 defect verbatim: importing the hook terminates the IMPORTER
|
|
60
|
+
* whenever the profile happens to disable that hook.
|
|
61
|
+
*
|
|
62
|
+
* `unregistered-file` is deliberately NOT a finding here — whether a hook file
|
|
63
|
+
* on disk is wired into a manifest is `check-plugin-hooks`-shaped work, a
|
|
64
|
+
* different question with a different population.
|
|
65
|
+
*
|
|
66
|
+
* ## Method (AST, not regex)
|
|
67
|
+
*
|
|
68
|
+
* `@babel/parser` (already a `dependencies` entry, used for the identical
|
|
69
|
+
* domain by `check-hooks-emit-event-guard.mjs` and
|
|
70
|
+
* `check-guard-requires-parity.mjs`), `sourceType: 'module'` with
|
|
71
|
+
* `topLevelAwait` + `importMeta`. The two questions asked — "does this call
|
|
72
|
+
* sit lexically inside an `if` whose test is the guard" and "is this call at
|
|
73
|
+
* module top level, i.e. inside no function" — are questions about lexical
|
|
74
|
+
* nesting, which a text scan answers only by accident: a brace inside a
|
|
75
|
+
* string, a template literal or a regex literal defeats brace counting, and
|
|
76
|
+
* #1383 is this repo's own record of a regex literal blanking a guard for a
|
|
77
|
+
* lexer that did not know regex literals.
|
|
78
|
+
*
|
|
79
|
+
* A file that fails to parse is a tool error (exit 2), never a silent skip.
|
|
80
|
+
*
|
|
81
|
+
* ## Recognised guard forms
|
|
82
|
+
*
|
|
83
|
+
* if (isMainModule(import.meta.url)) { … } // 24 of 27 hooks
|
|
84
|
+
* if (invokedAsScript()) { … } // the documented inline
|
|
85
|
+
* // form, 3 of 27 hooks
|
|
86
|
+
* const isMain = isMainModule(import.meta.url); // the indirected variant
|
|
87
|
+
* if (isMain) { … }
|
|
88
|
+
*
|
|
89
|
+
* NAMED CEILING (BV-004): the entry call this checker recognises is
|
|
90
|
+
* `main(...)` by name. A hook naming its entry differently is still covered by
|
|
91
|
+
* the stronger of the two rules — a file with NO guard construct at all is a
|
|
92
|
+
* finding regardless of how its entry is spelled — so the ceiling costs only
|
|
93
|
+
* the second rule (a SECOND, unguarded entry call in a file that does have a
|
|
94
|
+
* guard). REVISIT by extending `ENTRY_NAMES` if a hook ever names its entry
|
|
95
|
+
* something else; do not weaken the rule to "any top-level call", which would
|
|
96
|
+
* flag every legitimate top-level `const X = loadThing()`.
|
|
97
|
+
*
|
|
98
|
+
* ## Mode: BLOCKING
|
|
99
|
+
*
|
|
100
|
+
* Census at HEAD 98d06598 (2026-09-22): 27 registered `.mjs`, 27 guarded,
|
|
101
|
+
* 0 top-level profile exits. A blocking gate is only dishonest when it is red
|
|
102
|
+
* on arrival for work nobody intends to do; this one is green on arrival
|
|
103
|
+
* because #1393 drained the backlog first.
|
|
104
|
+
*
|
|
105
|
+
* Usage: check-hook-entry-guards.mjs [<repo-root>]
|
|
106
|
+
* Output: ` PASS: …` / ` FAIL: …` lines (two leading spaces), then
|
|
107
|
+
* `Results: N passed, M failed`. Exit 0 = clean, 1 = finding(s), 2 = tool
|
|
108
|
+
* error (unparseable/unreadable hook, or an empty census).
|
|
109
|
+
*
|
|
110
|
+
* Import-safety: importing this module MUST NOT execute anything — the
|
|
111
|
+
* isMain guard at the bottom is the only side-effecting path. (This checker
|
|
112
|
+
* would report itself otherwise.)
|
|
113
|
+
*/
|
|
114
|
+
|
|
115
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
116
|
+
import path from 'node:path';
|
|
117
|
+
import { parse } from '@babel/parser';
|
|
118
|
+
import { isMainModule } from '../is-main-module.mjs';
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Exempted hook files, `Map<repo-relative-path, reason>`.
|
|
122
|
+
*
|
|
123
|
+
* EMPTY on arrival and kept as a frozen MECHANISM rather than deleted — the
|
|
124
|
+
* same shape `check-entry-guard.mjs`'s `ALLOWLIST` and
|
|
125
|
+
* `check-hooks-emit-event-guard.mjs`'s `BASELINE_UNGUARDED` already use in
|
|
126
|
+
* this directory: the next hand-verified exception is exactly what would
|
|
127
|
+
* re-populate it, and an entry that carries no reason from day one rots into
|
|
128
|
+
* a silent permanent suppression. A baselined file still REPORTS (as a WARN),
|
|
129
|
+
* and an entry that no longer matches any finding is itself a FAIL
|
|
130
|
+
* (`baseline-stale`), so the list drains itself.
|
|
131
|
+
*
|
|
132
|
+
* @type {Map<string, string>}
|
|
133
|
+
*/
|
|
134
|
+
export const BASELINE = Object.freeze(new Map());
|
|
135
|
+
|
|
136
|
+
/** The four platform manifests, repo-relative. A missing one is skipped. */
|
|
137
|
+
export const MANIFESTS = Object.freeze([
|
|
138
|
+
'hooks/hooks.json',
|
|
139
|
+
'hooks/hooks-codex.json',
|
|
140
|
+
'hooks/hooks-cursor.json',
|
|
141
|
+
'hooks/hooks-pi.json',
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
/** Entry-function names treated as "runs the handler". See the NAMED CEILING. */
|
|
145
|
+
const ENTRY_NAMES = new Set(['main']);
|
|
146
|
+
|
|
147
|
+
/** Node types that open a new function scope — crossing one leaves top level. */
|
|
148
|
+
const FUNCTION_TYPES = new Set([
|
|
149
|
+
'FunctionDeclaration',
|
|
150
|
+
'FunctionExpression',
|
|
151
|
+
'ArrowFunctionExpression',
|
|
152
|
+
'ObjectMethod',
|
|
153
|
+
'ClassMethod',
|
|
154
|
+
'ClassPrivateMethod',
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
/** AST metadata keys with no code nesting — never descended into. */
|
|
158
|
+
const SKIP_KEYS = new Set([
|
|
159
|
+
'loc', 'start', 'end', 'extra', 'tokens', 'comments', 'errors',
|
|
160
|
+
'leadingComments', 'trailingComments', 'innerComments',
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The exact shape a registered hook's tail must have. Quoted verbatim in
|
|
165
|
+
* every remedy so the fix needs no second file open.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} hookName basename without extension
|
|
168
|
+
* @returns {string}
|
|
169
|
+
*/
|
|
170
|
+
function targetForm(hookName) {
|
|
171
|
+
return (
|
|
172
|
+
`if (isMainModule(import.meta.url)) { ` +
|
|
173
|
+
`if (!shouldRunHook('${hookName}')) process.exit(0); main()... }`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const WHY =
|
|
178
|
+
'#1393: a bare `import()` of a registered hook must run no handler and must ' +
|
|
179
|
+
'not terminate the importing process — measured under SO_HOOK_PROFILE=off, ' +
|
|
180
|
+
'every hook whose profile gate sat at module top level killed its importer, ' +
|
|
181
|
+
"and one of them wrote a PostToolUseFailure envelope onto the importer's stdout";
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Population
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Every `command` string value anywhere inside a parsed manifest.
|
|
189
|
+
*
|
|
190
|
+
* @param {unknown} node
|
|
191
|
+
* @param {string[]} out
|
|
192
|
+
* @returns {void}
|
|
193
|
+
*/
|
|
194
|
+
function collectCommands(node, out) {
|
|
195
|
+
if (Array.isArray(node)) {
|
|
196
|
+
for (const item of node) collectCommands(item, out);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (!node || typeof node !== 'object') return;
|
|
200
|
+
for (const [key, value] of Object.entries(node)) {
|
|
201
|
+
if (key === 'command' && typeof value === 'string') out.push(value);
|
|
202
|
+
else collectCommands(value, out);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Normalise one whitespace/quote-delimited token to a repo-relative hook path,
|
|
208
|
+
* anchored on a whole `hooks` path SEGMENT (so `subhooks/x.mjs` is not one).
|
|
209
|
+
*
|
|
210
|
+
* @param {string} token
|
|
211
|
+
* @returns {string | null}
|
|
212
|
+
*/
|
|
213
|
+
function normaliseHookPath(token) {
|
|
214
|
+
const segments = token.split('/');
|
|
215
|
+
const idx = segments.lastIndexOf('hooks');
|
|
216
|
+
if (idx === -1) return null;
|
|
217
|
+
return segments.slice(idx).join('/');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const MJS_TOKEN = /[^\s"']*\/?hooks\/[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*\.mjs/g;
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The union population: every `.mjs` registered by any present manifest.
|
|
224
|
+
*
|
|
225
|
+
* @param {string} repoRoot absolute repo root
|
|
226
|
+
* @returns {{files: string[], manifests: {rel: string, present: boolean, count: number}[], errors: {file: string, message: string}[]}}
|
|
227
|
+
*/
|
|
228
|
+
export function collectRegisteredHooks(repoRoot) {
|
|
229
|
+
/** @type {Map<string, Set<string>>} hook path -> manifests registering it */
|
|
230
|
+
const registry = new Map();
|
|
231
|
+
const manifests = [];
|
|
232
|
+
const errors = [];
|
|
233
|
+
|
|
234
|
+
for (const rel of MANIFESTS) {
|
|
235
|
+
const abs = path.join(repoRoot, rel);
|
|
236
|
+
if (!existsSync(abs)) {
|
|
237
|
+
manifests.push({ rel, present: false, count: 0 });
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
let parsed;
|
|
241
|
+
try {
|
|
242
|
+
parsed = JSON.parse(readFileSync(abs, 'utf8'));
|
|
243
|
+
} catch (err) {
|
|
244
|
+
errors.push({ file: rel, message: `unparseable manifest: ${err?.message ?? String(err)}` });
|
|
245
|
+
manifests.push({ rel, present: true, count: 0 });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
/** @type {string[]} */
|
|
249
|
+
const commands = [];
|
|
250
|
+
collectCommands(parsed, commands);
|
|
251
|
+
let count = 0;
|
|
252
|
+
for (const command of commands) {
|
|
253
|
+
MJS_TOKEN.lastIndex = 0;
|
|
254
|
+
let match;
|
|
255
|
+
while ((match = MJS_TOKEN.exec(command)) !== null) {
|
|
256
|
+
const hookPath = normaliseHookPath(match[0]);
|
|
257
|
+
if (!hookPath) continue;
|
|
258
|
+
if (!registry.has(hookPath)) registry.set(hookPath, new Set());
|
|
259
|
+
registry.get(hookPath).add(rel);
|
|
260
|
+
count += 1;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
manifests.push({ rel, present: true, count });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return { files: [...registry.keys()].sort(), manifests, errors };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// AST oracle
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* @param {string} source
|
|
275
|
+
* @param {string} filename repo-relative, for parse-error messages only
|
|
276
|
+
* @returns {import('@babel/parser').ParseResult<import('@babel/types').File>}
|
|
277
|
+
*/
|
|
278
|
+
function parseModule(source, filename) {
|
|
279
|
+
return parse(source, {
|
|
280
|
+
sourceType: 'module',
|
|
281
|
+
sourceFilename: filename,
|
|
282
|
+
errorRecovery: false,
|
|
283
|
+
plugins: ['topLevelAwait', 'importMeta'],
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* `isMainModule(import.meta.url)` or `invokedAsScript()`.
|
|
289
|
+
*
|
|
290
|
+
* @param {any} node
|
|
291
|
+
* @returns {boolean}
|
|
292
|
+
*/
|
|
293
|
+
function isGuardCall(node) {
|
|
294
|
+
if (!node || node.type !== 'CallExpression' || node.callee?.type !== 'Identifier') return false;
|
|
295
|
+
const name = node.callee.name;
|
|
296
|
+
if (name === 'invokedAsScript') return node.arguments.length === 0;
|
|
297
|
+
if (name !== 'isMainModule') return false;
|
|
298
|
+
const arg = node.arguments[0];
|
|
299
|
+
return (
|
|
300
|
+
arg?.type === 'MemberExpression' &&
|
|
301
|
+
arg.property?.type === 'Identifier' &&
|
|
302
|
+
arg.property.name === 'url' &&
|
|
303
|
+
arg.object?.type === 'MetaProperty'
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Module-level `const X = <guard call>` names — the indirected guard form
|
|
309
|
+
* (`const isMain = invokedAsScript(); if (isMain) {…}`), used by
|
|
310
|
+
* `hooks/post-bash-write-verify.mjs` among others.
|
|
311
|
+
*
|
|
312
|
+
* @param {any} program
|
|
313
|
+
* @returns {Set<string>}
|
|
314
|
+
*/
|
|
315
|
+
function guardVariableNames(program) {
|
|
316
|
+
const names = new Set();
|
|
317
|
+
for (const stmt of program.body) {
|
|
318
|
+
if (stmt.type !== 'VariableDeclaration') continue;
|
|
319
|
+
for (const decl of stmt.declarations) {
|
|
320
|
+
if (decl.id?.type === 'Identifier' && isGuardCall(decl.init)) names.add(decl.id.name);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return names;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Is this `if` test the entry guard? Accepts the direct call, the indirected
|
|
328
|
+
* identifier, and either side of a `&&`/`||` (a guard ANDed with an extra
|
|
329
|
+
* condition still gates the body behind the guard).
|
|
330
|
+
*
|
|
331
|
+
* @param {any} node
|
|
332
|
+
* @param {Set<string>} guardVars
|
|
333
|
+
* @returns {boolean}
|
|
334
|
+
*/
|
|
335
|
+
function isGuardTest(node, guardVars) {
|
|
336
|
+
if (!node) return false;
|
|
337
|
+
if (isGuardCall(node)) return true;
|
|
338
|
+
if (node.type === 'Identifier') return guardVars.has(node.name);
|
|
339
|
+
if (node.type === 'LogicalExpression') {
|
|
340
|
+
return isGuardTest(node.left, guardVars) || isGuardTest(node.right, guardVars);
|
|
341
|
+
}
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Walk the ancestor chain outward.
|
|
347
|
+
*
|
|
348
|
+
* @param {{node: any, key: string | number}[]} ancestors outer→inner
|
|
349
|
+
* @param {Set<string>} guardVars
|
|
350
|
+
* @returns {{inFunction: boolean, inGuard: boolean}}
|
|
351
|
+
*/
|
|
352
|
+
function classifyPosition(ancestors, guardVars) {
|
|
353
|
+
let inFunction = false;
|
|
354
|
+
let inGuard = false;
|
|
355
|
+
for (let i = ancestors.length - 1; i >= 0; i -= 1) {
|
|
356
|
+
const { node, key } = ancestors[i];
|
|
357
|
+
if (FUNCTION_TYPES.has(node.type)) inFunction = true;
|
|
358
|
+
if (
|
|
359
|
+
node.type === 'IfStatement' &&
|
|
360
|
+
key === 'consequent' &&
|
|
361
|
+
isGuardTest(node.test, guardVars)
|
|
362
|
+
) {
|
|
363
|
+
inGuard = true;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return { inFunction, inGuard };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* @typedef {{kind: 'missing-entry-guard' | 'toplevel-profile-exit', file: string, line: number, detail: string}} Finding
|
|
371
|
+
*/
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Analyse one hook module body.
|
|
375
|
+
*
|
|
376
|
+
* @param {string} source
|
|
377
|
+
* @param {string} rel repo-relative path (finding key + parse-error message)
|
|
378
|
+
* @returns {{findings: Finding[], guardCount: number, entryCalls: number}}
|
|
379
|
+
*/
|
|
380
|
+
export function analyzeHookSource(source, rel) {
|
|
381
|
+
const ast = parseModule(source, rel);
|
|
382
|
+
const program = ast.program;
|
|
383
|
+
const guardVars = guardVariableNames(program);
|
|
384
|
+
|
|
385
|
+
let guardCount = 0;
|
|
386
|
+
let entryCalls = 0;
|
|
387
|
+
/** @type {Finding[]} */
|
|
388
|
+
const findings = [];
|
|
389
|
+
const hookName = path.basename(rel, '.mjs');
|
|
390
|
+
|
|
391
|
+
/** Top-level statement index → does it contain a top-level `process.exit` call? */
|
|
392
|
+
const exitStatements = new Set();
|
|
393
|
+
/** Candidate top-level unguarded `shouldRunHook` calls, keyed by statement index. */
|
|
394
|
+
const profileCalls = [];
|
|
395
|
+
|
|
396
|
+
let stmtIndex = -1;
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* @param {any} node
|
|
400
|
+
* @param {{node: any, key: string | number}[]} ancestors
|
|
401
|
+
*/
|
|
402
|
+
function visit(node, ancestors) {
|
|
403
|
+
if (Array.isArray(node)) {
|
|
404
|
+
for (const item of node) visit(item, ancestors);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (!node || typeof node !== 'object' || typeof node.type !== 'string') return;
|
|
408
|
+
|
|
409
|
+
if (node.type === 'IfStatement' && isGuardTest(node.test, guardVars)) guardCount += 1;
|
|
410
|
+
|
|
411
|
+
if (node.type === 'CallExpression') {
|
|
412
|
+
const callee = node.callee;
|
|
413
|
+
const calleeName = callee?.type === 'Identifier' ? callee.name : null;
|
|
414
|
+
|
|
415
|
+
if (calleeName && ENTRY_NAMES.has(calleeName)) {
|
|
416
|
+
const { inFunction, inGuard } = classifyPosition(ancestors, guardVars);
|
|
417
|
+
if (!inFunction) {
|
|
418
|
+
entryCalls += 1;
|
|
419
|
+
if (!inGuard) {
|
|
420
|
+
findings.push({
|
|
421
|
+
kind: 'missing-entry-guard',
|
|
422
|
+
file: rel,
|
|
423
|
+
line: node.loc?.start?.line ?? 0,
|
|
424
|
+
detail:
|
|
425
|
+
`module-top-level \`${calleeName}()\` call outside any entry guard — ` +
|
|
426
|
+
`importing this file RUNS the handler. Wrap it: ${targetForm(hookName)}`,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (calleeName === 'shouldRunHook') {
|
|
433
|
+
const { inFunction, inGuard } = classifyPosition(ancestors, guardVars);
|
|
434
|
+
if (!inFunction && !inGuard) {
|
|
435
|
+
profileCalls.push({ stmtIndex, line: node.loc?.start?.line ?? 0 });
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (
|
|
440
|
+
callee?.type === 'MemberExpression' &&
|
|
441
|
+
callee.object?.type === 'Identifier' &&
|
|
442
|
+
callee.object.name === 'process' &&
|
|
443
|
+
callee.property?.type === 'Identifier' &&
|
|
444
|
+
callee.property.name === 'exit'
|
|
445
|
+
) {
|
|
446
|
+
const { inFunction } = classifyPosition(ancestors, guardVars);
|
|
447
|
+
if (!inFunction) exitStatements.add(stmtIndex);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
for (const [key, child] of Object.entries(node)) {
|
|
452
|
+
if (SKIP_KEYS.has(key)) continue;
|
|
453
|
+
if (child && typeof child === 'object') {
|
|
454
|
+
ancestors.push({ node, key });
|
|
455
|
+
visit(child, ancestors);
|
|
456
|
+
ancestors.pop();
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
for (let i = 0; i < program.body.length; i += 1) {
|
|
462
|
+
stmtIndex = i;
|
|
463
|
+
visit(program.body[i], [{ node: program, key: 'body' }]);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
for (const call of profileCalls) {
|
|
467
|
+
if (!exitStatements.has(call.stmtIndex)) continue;
|
|
468
|
+
findings.push({
|
|
469
|
+
kind: 'toplevel-profile-exit',
|
|
470
|
+
file: rel,
|
|
471
|
+
line: call.line,
|
|
472
|
+
detail:
|
|
473
|
+
'module-top-level `shouldRunHook(…)` whose statement calls `process.exit` — ' +
|
|
474
|
+
'importing this file TERMINATES the importing process whenever the profile ' +
|
|
475
|
+
`disables this hook. Move the gate inside the entry guard: ${targetForm(hookName)}`,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (guardCount === 0) {
|
|
480
|
+
findings.unshift({
|
|
481
|
+
kind: 'missing-entry-guard',
|
|
482
|
+
file: rel,
|
|
483
|
+
line: 0,
|
|
484
|
+
detail:
|
|
485
|
+
'no entry guard anywhere in the file — neither `if (isMainModule(import.meta.url))` ' +
|
|
486
|
+
`nor \`if (invokedAsScript())\`. Required tail: ${targetForm(hookName)}`,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return { findings, guardCount, entryCalls };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Scan every registered hook.
|
|
495
|
+
*
|
|
496
|
+
* @param {string} repoRoot absolute repo root
|
|
497
|
+
* @param {{baseline?: Map<string, string>}} [options] `baseline` defaults to
|
|
498
|
+
* the module-level {@link BASELINE} (empty in production); injectable so
|
|
499
|
+
* tests exercise the WARN / `baseline-stale` paths without mutating it.
|
|
500
|
+
* @returns {{findings: Finding[], baselineFindings: {key: string, message: string}[], toolErrors: {file: string, message: string}[], registered: string[], guarded: number, manifests: {rel: string, present: boolean, count: number}[]}}
|
|
501
|
+
*/
|
|
502
|
+
export function scanHookEntryGuards(repoRoot, { baseline = BASELINE } = {}) {
|
|
503
|
+
const { files, manifests, errors } = collectRegisteredHooks(repoRoot);
|
|
504
|
+
/** @type {Finding[]} */
|
|
505
|
+
const findings = [];
|
|
506
|
+
/** @type {{file: string, message: string}[]} */
|
|
507
|
+
const toolErrors = [...errors];
|
|
508
|
+
const flagged = new Set();
|
|
509
|
+
let guarded = 0;
|
|
510
|
+
|
|
511
|
+
for (const rel of files) {
|
|
512
|
+
const abs = path.join(repoRoot, rel);
|
|
513
|
+
let source;
|
|
514
|
+
try {
|
|
515
|
+
source = readFileSync(abs, 'utf8');
|
|
516
|
+
} catch (err) {
|
|
517
|
+
toolErrors.push({ file: rel, message: `registered but unreadable: ${err?.message ?? String(err)}` });
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
let result;
|
|
521
|
+
try {
|
|
522
|
+
result = analyzeHookSource(source, rel);
|
|
523
|
+
} catch (err) {
|
|
524
|
+
toolErrors.push({ file: rel, message: `parse failed: ${err?.message ?? String(err)}` });
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
if (result.findings.length === 0) guarded += 1;
|
|
528
|
+
else flagged.add(rel);
|
|
529
|
+
for (const finding of result.findings) {
|
|
530
|
+
findings.push({ ...finding, baselined: baseline.has(rel) });
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** @type {{key: string, message: string}[]} */
|
|
535
|
+
const baselineFindings = [];
|
|
536
|
+
for (const [key, reason] of baseline.entries()) {
|
|
537
|
+
if (!flagged.has(key)) {
|
|
538
|
+
baselineFindings.push({
|
|
539
|
+
key,
|
|
540
|
+
message: 'baseline entry no longer matches a finding (guarded or deregistered) — remove it',
|
|
541
|
+
});
|
|
542
|
+
} else if (String(reason ?? '').trim() === '') {
|
|
543
|
+
baselineFindings.push({
|
|
544
|
+
key,
|
|
545
|
+
message: 'baseline entry has no reason — name the linked issue or remove the entry',
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return { findings, baselineFindings, toolErrors, registered: files, guarded, manifests };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
554
|
+
// CLI
|
|
555
|
+
// ---------------------------------------------------------------------------
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Run the check, printing the validate-plugin line vocabulary.
|
|
559
|
+
*
|
|
560
|
+
* @param {string} repoRoot absolute repo root
|
|
561
|
+
* @param {{baseline?: Map<string, string>}} [options] forwarded to {@link scanHookEntryGuards}
|
|
562
|
+
* @returns {number} 0 clean · 1 finding(s) · 2 tool error (incl. empty census)
|
|
563
|
+
*/
|
|
564
|
+
export function runCheckHookEntryGuards(repoRoot, options) {
|
|
565
|
+
const { findings, baselineFindings, toolErrors, registered, guarded, manifests } =
|
|
566
|
+
scanHookEntryGuards(repoRoot, options);
|
|
567
|
+
|
|
568
|
+
if (toolErrors.length > 0) {
|
|
569
|
+
for (const e of toolErrors) console.log(` FAIL: ${e.file} — ${e.message}`);
|
|
570
|
+
console.log('');
|
|
571
|
+
console.log(`Results: 0 passed, ${toolErrors.length} failed`);
|
|
572
|
+
return 2;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Vacuum guard: an empty census is never green. A manifest that moved, a
|
|
576
|
+
// command shape this extractor stopped recognising, or a fixture root with
|
|
577
|
+
// no hooks at all would otherwise report PASS over nothing — the exact
|
|
578
|
+
// failure mode this checker exists to prevent, one level up.
|
|
579
|
+
if (registered.length === 0) {
|
|
580
|
+
const present = manifests.filter((m) => m.present).map((m) => m.rel);
|
|
581
|
+
console.log(
|
|
582
|
+
` FAIL: no registered hook .mjs found in ${present.length} present manifest(s)` +
|
|
583
|
+
`${present.length > 0 ? ` (${present.join(', ')})` : ''} — an empty census cannot be clean`,
|
|
584
|
+
);
|
|
585
|
+
console.log('');
|
|
586
|
+
console.log('Results: 0 passed, 1 failed');
|
|
587
|
+
return 2;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const live = findings.filter((f) => !f.baselined);
|
|
591
|
+
const warned = findings.filter((f) => f.baselined);
|
|
592
|
+
|
|
593
|
+
if (live.length === 0 && baselineFindings.length === 0) {
|
|
594
|
+
const presentCount = manifests.filter((m) => m.present).length;
|
|
595
|
+
console.log(
|
|
596
|
+
` PASS: ${guarded}/${registered.length} registered hook module(s) across ` +
|
|
597
|
+
`${presentCount} manifest(s) run their entry only under an entry guard ` +
|
|
598
|
+
'(0 missing-entry-guard, 0 toplevel-profile-exit)',
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
for (const f of warned) {
|
|
602
|
+
console.log(` WARN: ${f.file}:${f.line} — ${f.kind}: ${f.detail} (baselined)`);
|
|
603
|
+
}
|
|
604
|
+
for (const f of live) {
|
|
605
|
+
console.log(` FAIL: ${f.file}:${f.line} — ${f.kind}: ${f.detail}. WHY — ${WHY}`);
|
|
606
|
+
}
|
|
607
|
+
for (const b of baselineFindings) {
|
|
608
|
+
console.log(` FAIL: ${b.key} — ${b.message}`);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const failCount = live.length + baselineFindings.length;
|
|
612
|
+
console.log('');
|
|
613
|
+
console.log(`Results: ${failCount === 0 ? 1 : 0} passed, ${failCount} failed`);
|
|
614
|
+
return failCount > 0 ? 1 : 0;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (isMainModule(import.meta.url)) {
|
|
618
|
+
const argv = process.argv.slice(2);
|
|
619
|
+
const usage =
|
|
620
|
+
'Usage: check-hook-entry-guards.mjs [<repo-root>]\n' +
|
|
621
|
+
'Exit: 0 clean · 1 missing entry guard / top-level profile exit · 2 tool error';
|
|
622
|
+
if (argv.includes('--help')) {
|
|
623
|
+
console.log(usage);
|
|
624
|
+
process.exitCode = 0;
|
|
625
|
+
} else {
|
|
626
|
+
const unknown = argv.filter((a) => a.startsWith('--') && a !== '--help');
|
|
627
|
+
if (unknown.length > 0) {
|
|
628
|
+
console.error(`Unknown flag(s): ${unknown.join(', ')}\n${usage}`);
|
|
629
|
+
process.exitCode = 1;
|
|
630
|
+
} else {
|
|
631
|
+
const positional = argv.filter((a) => !a.startsWith('--'));
|
|
632
|
+
console.log('--- Check: registered hook entry guards (#1422) ---');
|
|
633
|
+
process.exitCode = runCheckHookEntryGuards(path.resolve(positional[0] ?? process.cwd()));
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
@@ -35,6 +35,7 @@ if (!existsSync(generator)) {
|
|
|
35
35
|
} else {
|
|
36
36
|
const detail = ((result.stdout ?? '') + (result.stderr ?? '')).trim();
|
|
37
37
|
fail(`pi prompt wrappers are stale${detail ? `: ${detail}` : ''}`);
|
|
38
|
+
console.log(' Remedy: node scripts/generate-pi-prompts.mjs');
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
|
|
@@ -60,9 +60,9 @@
|
|
|
60
60
|
// Order and duplicates are irrelevant (a glob list is matched
|
|
61
61
|
// any-of), so the comparison is over the sorted unique set.
|
|
62
62
|
// NOT flagged: `paths:` alone. It is the form the native
|
|
63
|
-
// documentation prescribes and the
|
|
64
|
-
//
|
|
65
|
-
//
|
|
63
|
+
// documentation prescribes, and the primary downstream consumer
|
|
64
|
+
// (projects-baseline) carries `paths:` on every scoped rule —
|
|
65
|
+
// measured count: docs/baseline.md. The
|
|
66
66
|
// `globs:`-is-canonical preference for VENDORED rules is a separate,
|
|
67
67
|
// warn-level concern already owned by
|
|
68
68
|
// scripts/lib/validate-vendored-rules.mjs (~:289, issue #742) and is
|
|
@@ -330,8 +330,10 @@ for (const name of mdFiles.sort()) {
|
|
|
330
330
|
'("rules without a paths field are loaded unconditionally and apply to all files" — ' +
|
|
331
331
|
'code.claude.com/docs/en/memory § Path-specific rules). `globs:` is the Cursor field name, so this rule ' +
|
|
332
332
|
'is scoped for rule-loader.mjs and Cursor but loads ALWAYS-ON in Claude Code — a silent instruction-budget ' +
|
|
333
|
-
'failure (#1108). Add a paths: key carrying the same patterns as globs
|
|
334
|
-
'canonical
|
|
333
|
+
'failure (#1108). Add a paths: key carrying the same patterns as globs:. Under .claude/rules/ paths: is ' +
|
|
334
|
+
'the canonical scope key (docs/rule-authoring.md) — keep globs: alongside it only if this rule is ALSO ' +
|
|
335
|
+
'vendored out through rules/, where globs: is the vendored form (#742); otherwise paths: alone is enough ' +
|
|
336
|
+
'and globs: can be dropped.',
|
|
335
337
|
);
|
|
336
338
|
} else if (!parity.ok && parity.kind === 'divergent') {
|
|
337
339
|
fail(
|
|
@@ -62,7 +62,15 @@ import { isMainModule } from '../is-main-module.mjs';
|
|
|
62
62
|
* and 0 checked links today and would NOT by itself have caught that defect — those citations are
|
|
63
63
|
* INLINE-CODE (`` `commands/go.md` ``), which this checker deliberately skips (see DELIBERATE
|
|
64
64
|
* NON-CHECKS). It is a forward guard for the day a real link is authored there, not the catcher
|
|
65
|
-
* for the backticked-citation class
|
|
65
|
+
* for the backticked-citation class.
|
|
66
|
+
*
|
|
67
|
+
* That last clause read "and that class has no gate in this repo" until #1384 P3, when it stopped
|
|
68
|
+
* being true: `check-skill-script-paths.mjs` — which DOES judge complete inline-code spans against
|
|
69
|
+
* `MARKDOWN_CITATION_RE` — took the same two roots and `.mdc` into its own `SCAN_DIRS`, and is
|
|
70
|
+
* blocking via `scripts/validate-plugin.mjs`. Measured 2026-09-18: its corpus went 295 → 329 files
|
|
71
|
+
* and it reported the `commands/{go,close}.md` class of defect (4 blocking findings on first run).
|
|
72
|
+
* So the division of labour is: LINKS here, backticked CITATIONS there — neither surface is
|
|
73
|
+
* ungated.
|
|
66
74
|
*/
|
|
67
75
|
export const SCAN_DIRS = Object.freeze(['skills', 'commands', 'agents', '.claude/rules', 'docs', '.cursor/rules']);
|
|
68
76
|
|