session-orchestrator 3.17.0 → 3.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +105 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/docs/USER-GUIDE.md +8 -3
  21. package/docs/ci-setup.md +121 -7
  22. package/docs/codex-setup.md +1 -1
  23. package/docs/components.md +6 -6
  24. package/docs/cursor-setup.md +22 -9
  25. package/docs/events-schema.md +5 -1
  26. package/docs/instruction-delivery.md +444 -0
  27. package/docs/rule-authoring.md +58 -9
  28. package/docs/session-config-reference.md +244 -9
  29. package/docs/session-config-template.md +39 -3
  30. package/hooks/_lib/guard-source-loader.mjs +467 -0
  31. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  32. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  33. package/hooks/config-protection.mjs +0 -0
  34. package/hooks/enforce-commands.mjs +10 -2
  35. package/hooks/hooks-codex.json +1 -1
  36. package/hooks/hooks-cursor.json +11 -2
  37. package/hooks/hooks-pi.json +10 -0
  38. package/hooks/hooks.json +21 -1
  39. package/hooks/on-session-end.mjs +178 -18
  40. package/hooks/on-session-start.mjs +23 -0
  41. package/hooks/post-bash-write-verify.mjs +977 -0
  42. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  43. package/hooks/pre-bash-destructive-guard.mjs +525 -160
  44. package/hooks/pre-bash-issue-budget.mjs +167 -0
  45. package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
  46. package/hooks/pre-bash-templates-first.mjs +96 -63
  47. package/hooks/subagent-telemetry.mjs +527 -37
  48. package/package.json +5 -2
  49. package/pi/prompts/contract-version-bump.md +12 -0
  50. package/rules/README.md +32 -0
  51. package/scripts/archive-closed-prds.mjs +12 -22
  52. package/scripts/autopilot-multi.mjs +103 -20
  53. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  54. package/scripts/check-doc-consistency.sh +17 -1
  55. package/scripts/eval-session.mjs +50 -9
  56. package/scripts/fleet-instruction-scan.mjs +141 -0
  57. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  58. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  59. package/scripts/lib/backlog-scan.mjs +39 -6
  60. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  61. package/scripts/lib/ci-status-banner.mjs +75 -12
  62. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  63. package/scripts/lib/command-blocker.mjs +1013 -58
  64. package/scripts/lib/config/config-protection.mjs +2 -1
  65. package/scripts/lib/config/drift-check.mjs +9 -1
  66. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  67. package/scripts/lib/config/issue-budget.mjs +123 -0
  68. package/scripts/lib/config/reconcile.mjs +21 -0
  69. package/scripts/lib/config/section-extractor.mjs +121 -1
  70. package/scripts/lib/config-schema.mjs +23 -3
  71. package/scripts/lib/config.mjs +17 -0
  72. package/scripts/lib/convergence-monitor.mjs +49 -3
  73. package/scripts/lib/description-surface.mjs +535 -0
  74. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  75. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  76. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  77. package/scripts/lib/eval/engine.mjs +47 -5
  78. package/scripts/lib/events.mjs +59 -7
  79. package/scripts/lib/gates/gate-full.mjs +15 -3
  80. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  81. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  82. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  83. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  84. package/scripts/lib/handover-gate.mjs +7 -3
  85. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  86. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  87. package/scripts/lib/io.mjs +345 -10
  88. package/scripts/lib/issue-budget.mjs +269 -0
  89. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  90. package/scripts/lib/label-scope.mjs +47 -0
  91. package/scripts/lib/learnings/schema.mjs +43 -3
  92. package/scripts/lib/lock-reaper.mjs +1 -2
  93. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  94. package/scripts/lib/peer-discovery.mjs +645 -0
  95. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  96. package/scripts/lib/product-repo-detect.mjs +9 -8
  97. package/scripts/lib/project-hygiene.mjs +432 -0
  98. package/scripts/lib/quality-gate.mjs +167 -0
  99. package/scripts/lib/recommendations-v0.mjs +1 -1
  100. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  101. package/scripts/lib/reconcile/emitter.mjs +23 -4
  102. package/scripts/lib/reconcile/engine.mjs +147 -39
  103. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  104. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  105. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  106. package/scripts/lib/resource-probe.mjs +19 -0
  107. package/scripts/lib/rule-loader.mjs +6 -0
  108. package/scripts/lib/scope-baseline.mjs +564 -0
  109. package/scripts/lib/scope-gate.mjs +399 -98
  110. package/scripts/lib/session-close-backfill.mjs +61 -6
  111. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  112. package/scripts/lib/session-id.mjs +221 -41
  113. package/scripts/lib/session-lock.mjs +304 -6
  114. package/scripts/lib/session-schema/constants.mjs +22 -3
  115. package/scripts/lib/session-schema/validator.mjs +16 -0
  116. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  117. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  118. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  119. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  120. package/scripts/lib/spiral-carryover.mjs +142 -30
  121. package/scripts/lib/state-md/mission-status.mjs +53 -3
  122. package/scripts/lib/subagents-schema.mjs +43 -9
  123. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  124. package/scripts/lib/tests-src-ratio.mjs +484 -0
  125. package/scripts/lib/validate/check-agents.mjs +56 -0
  126. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  127. package/scripts/lib/validate/check-rules.mjs +217 -35
  128. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  129. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  130. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  131. package/scripts/lib/vault-archive.mjs +17 -2
  132. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  133. package/scripts/lib/vault-mirror/process.mjs +30 -0
  134. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  135. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  136. package/scripts/lib/wave-resource-gate.mjs +115 -11
  137. package/scripts/lib/worktree/listing.mjs +44 -7
  138. package/scripts/mcp-server.sh +17 -3
  139. package/scripts/measure-context-overhead.sh +151 -0
  140. package/scripts/memory-propose.mjs +72 -9
  141. package/scripts/print-applicable-rules.mjs +51 -12
  142. package/scripts/release.mjs +534 -0
  143. package/scripts/run-quality-gate.mjs +123 -5
  144. package/scripts/validate-wave-scope.mjs +182 -17
  145. package/scripts/vault-integration-watcher.mjs +32 -10
  146. package/skills/_shared/config-reading.md +2 -2
  147. package/skills/bootstrap/fast-template.md +1 -1
  148. package/skills/claude-md-drift-check/checker.mjs +145 -28
  149. package/skills/contract-version-bump/SKILL.md +219 -0
  150. package/skills/discovery/SKILL.md +4 -4
  151. package/skills/discovery/issue-templates.md +11 -11
  152. package/skills/discovery/probes-audit.md +1 -1
  153. package/skills/discovery/probes-feature.md +1 -1
  154. package/skills/discovery/probes-session.md +26 -5
  155. package/skills/ecosystem-health/SKILL.md +1 -1
  156. package/skills/ecosystem-health/wizard.md +4 -4
  157. package/skills/evolve/SKILL.md +1 -0
  158. package/skills/gitlab-ops/SKILL.md +20 -12
  159. package/skills/gitlab-portfolio/SKILL.md +2 -2
  160. package/skills/hook-development/SKILL.md +1 -1
  161. package/skills/mode-selector/SKILL.md +1 -1
  162. package/skills/npm-publish/SKILL.md +17 -1
  163. package/skills/plan/SKILL.md +5 -5
  164. package/skills/plan/mode-feature.md +4 -4
  165. package/skills/plan/mode-new.md +10 -10
  166. package/skills/plan/mode-retro.md +1 -1
  167. package/skills/quality-gates/SKILL.md +1 -1
  168. package/skills/reconcile/SKILL.md +21 -4
  169. package/skills/session-end/SKILL.md +34 -13
  170. package/skills/session-end/discovery-scan.md +4 -2
  171. package/skills/session-end/drift-operations.md +4 -4
  172. package/skills/session-end/metrics-collection.md +13 -0
  173. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  174. package/skills/session-end/phase-3-6-tail.md +2 -1
  175. package/skills/session-end/plan-verification.md +5 -2
  176. package/skills/session-end/vault-operations.md +1 -1
  177. package/skills/session-end/verification-checklist.md +1 -1
  178. package/skills/session-plan/SKILL.md +6 -2
  179. package/skills/session-plan/wave-template.md +2 -0
  180. package/skills/session-start/SKILL.md +73 -7
  181. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  182. package/skills/test-runner/SKILL.md +2 -2
  183. package/skills/vault-sync/validator.mjs +108 -7
  184. package/skills/wave-executor/SKILL.md +5 -2
  185. package/skills/wave-executor/circuit-breaker.md +2 -0
  186. package/skills/wave-executor/wave-loop.md +163 -10
  187. package/templates/_shared/loop.md +4 -4
@@ -1,12 +1,31 @@
1
1
  #!/usr/bin/env node
2
2
  // scripts/lib/validate/check-hooks-symmetry.mjs
3
- // Verify event-key + handler-file symmetry across hooks/{hooks,hooks-codex,hooks-cursor,hooks-pi}.json
3
+ // Verify event-key + handler-file + per-event handler-set symmetry across
4
+ // hooks/{hooks,hooks-codex,hooks-cursor,hooks-pi}.json (Check 6: #942)
5
+ // Check 6 projects platform-native event namespaces onto the logical Claude
6
+ // events first, then refuses to report parity for any counterpart event that
7
+ // lands on no hooks.json event — whether that is one dropped projection or the
8
+ // whole namespace (a vacuum-true PASS — see Check 6).
4
9
  // Exit 0 = all checks pass, 1 = any check fails
5
10
  // Stdout: ' PASS: ...' / ' FAIL: ...' lines + 'Results: N passed, M failed'
6
11
 
7
12
  import { readFileSync, existsSync, readdirSync } from 'node:fs';
8
13
  import { join } from 'node:path';
9
14
 
15
+ // The pi projection is IMPORTED, not copied: pi-hook-bridge.mjs is the runtime
16
+ // that actually rewrites `hook_event_name` for every Pi event, so it is the one
17
+ // source of truth for which pi event becomes which Claude event (#953). A local
18
+ // literal here was a second copy of the same 5 pairs — it could drift silently,
19
+ // and the validator would then certify a projection the runtime does not use.
20
+ // Deliberately NOT the same for `cursorEventMap` below: nothing at runtime
21
+ // projects Cursor events (Cursor calls the handlers directly), so the
22
+ // validator's copy is the only witness there. Even if a runtime source existed,
23
+ // reading Check 3's expectation from the artefact it checks would make the
24
+ // check self-certifying — the "undocumented pi-native event" test at
25
+ // tests/scripts/check-hooks-symmetry.test.mjs would become unreachable. Keep
26
+ // cursorEventMap validator-owned; do not "helpfully" collapse it too.
27
+ import { PI_TO_CANONICAL_EVENT } from '../pi-hook-bridge.mjs';
28
+
10
29
  const PLUGIN_ROOT = process.argv[2];
11
30
  if (!PLUGIN_ROOT) {
12
31
  console.error('Usage: check-hooks-symmetry.mjs <plugin-root>');
@@ -25,12 +44,125 @@ const DOCUMENTED_ASYMMETRIES = {
25
44
  // Claude/Codex events with no Pi-native v1 mapping yet.
26
45
  piMissingFromMain: ['PostToolUseFailure', 'PostToolBatch', 'SubagentStart', 'SubagentStop', 'CwdChanged'],
27
46
  // Pi-native extension events that map onto Claude/Codex hook events.
28
- piEventMap: {
29
- session_start: 'SessionStart',
30
- session_shutdown: 'SessionEnd',
31
- tool_call: 'PreToolUse',
32
- tool_result: 'PostToolUse',
33
- agent_end: 'Stop',
47
+ // Bound to the runtime constant (see the import comment): the projection this
48
+ // validator checks hooks-pi.json against is the SAME object pi-hook-bridge.mjs
49
+ // applies to live payloads, so the two cannot drift apart.
50
+ piEventMap: PI_TO_CANONICAL_EVENT,
51
+ // Cursor-IDE-native events projected onto the logical Claude events they
52
+ // correspond to. WITHOUT this projection Check 6 compared a FOREIGN event
53
+ // namespace against hooks.json: not one key matched, the per-event loop
54
+ // `continue`d on every iteration, and hooks-cursor.json reported
55
+ // "handler sets match hooks.json (documented asymmetries: 0)" while missing
56
+ // 20 of 22 handlers — a vacuum-true PASS. Check 6's projection guard now
57
+ // fails closed PER declared event (#946), so dropping a single entry from
58
+ // this map is caught too, not only the loss of every projection: the first
59
+ // version tested `sharedEvents.length === 0`, under which removing just
60
+ // `afterFileEdit` left the run green and merely moved cursor's documented-
61
+ // asymmetry count from 12 to 8.
62
+ // afterFileEdit fires AFTER the edit (hooks-cursor.json `note`,
63
+ // docs/cursor-setup.md) → it projects onto PostToolUse, never PreToolUse.
64
+ cursorEventMap: {
65
+ beforeShellExecution: 'PreToolUse',
66
+ afterFileEdit: 'PostToolUse',
67
+ },
68
+ // Check 6 (#942): handlers wired on a Claude event but intentionally absent
69
+ // from the SAME logical event on a counterpart manifest. Checks 1-3 compare
70
+ // event KEYS and Check 4 handler EXISTENCE — a handler wired on only one
71
+ // platform was structurally invisible to all four (the #942 blind spot:
72
+ // post-bash-write-verify.mjs shipped Claude-only and nothing flagged it).
73
+ // A handler missing on a shared event and NOT listed here → FAIL.
74
+ handlerAsymmetries: {
75
+ codex: {
76
+ // Codex 0.144.4 has no payload adapter (docs/codex-setup.md § Hook
77
+ // Surface and Trust): Edit/Write handlers expect Claude payload shapes,
78
+ // Bash guards expect tool_name === 'Bash'. Wiring them would be a silent
79
+ // no-op, not enforcement (#919-P2 class) — "pretending the payloads are
80
+ // compatible would create false enforcement".
81
+ PreToolUse: [
82
+ 'skill-invocation-telemetry.mjs',
83
+ 'enforce-scope.mjs',
84
+ 'config-protection.mjs',
85
+ 'pre-bash-destructive-guard.mjs',
86
+ 'pre-bash-staging-fence.mjs',
87
+ 'pre-bash-memory-propose-audit.mjs',
88
+ // pre-bash-sessions-ledger-guard (#958): same Bash-payload dependency
89
+ // as the guards above — it reads tool_name === 'Bash' and
90
+ // tool_input.command, neither of which any Codex bridge delivers
91
+ // (measured 2026-07-31: `grep -rn "tool_name" scripts/lib/codex/*.mjs`
92
+ // → 0 matches, exit 1). A manifest entry would be a silent no-op, not
93
+ // enforcement — #919-P2 class. Gap registered, not faked.
94
+ 'pre-bash-sessions-ledger-guard.mjs',
95
+ 'pre-bash-templates-first.mjs',
96
+ 'pre-bash-issue-budget.mjs',
97
+ 'enforce-commands.mjs',
98
+ ],
99
+ // post-bash-write-verify (#942): needs tool_name === 'Bash', which no
100
+ // Codex bridge delivers — documented exception until an adapter exists.
101
+ // Measured 2026-07-31: `grep -rn "tool_name" scripts/lib/codex/*.mjs`
102
+ // → 0 matches (exit 1), so a manifest entry here would be a silent
103
+ // no-op, not enforcement. Same #919-P2 class as the PreToolUse block.
104
+ PostToolUse: [
105
+ 'post-edit-validate.mjs',
106
+ 'post-tooluse-frontend-slop.mjs',
107
+ 'post-bash-write-verify.mjs',
108
+ ],
109
+ SubagentStart: ['subagent-telemetry.mjs'],
110
+ SubagentStop: ['subagent-telemetry.mjs', 'post-subagent-discovery-validator.mjs'],
111
+ },
112
+ pi: {
113
+ // skill-invocation-telemetry: Pi has no Skill tool (pi-hook-bridge
114
+ // TOOL_NAME_MAP) — the matcher can never fire.
115
+ // pre-bash-templates-first + pre-bash-issue-budget (#946): status-quo
116
+ // gaps predating #942. Operator decision 2026-07-31 — NOT ported, gap
117
+ // registered instead; #946 tracks the port-or-justify follow-up.
118
+ // pre-bash-sessions-ledger-guard (#958): NOT ported. Pi has a payload
119
+ // adapter (pi-hook-bridge.mjs), so unlike codex/cursor this one is
120
+ // portable — but wiring it is a separate decision with its own test
121
+ // surface, and the operator's 2026-07-31 rule is that a gap gets
122
+ // registered rather than faked. #958 tracks the port-or-justify.
123
+ PreToolUse: [
124
+ 'skill-invocation-telemetry.mjs',
125
+ 'pre-bash-sessions-ledger-guard.mjs', // #958
126
+ 'pre-bash-templates-first.mjs', // #946
127
+ 'pre-bash-issue-budget.mjs', // #946
128
+ ],
129
+ },
130
+ // Cursor is NOT wired (#919) and will not be: hooks-cursor.json is a
131
+ // mapping REFERENCE, not live enforcement. Every handler expects Claude
132
+ // Code payload shapes (tool_name === 'Bash', tool_input.command) and emits
133
+ // a Claude PreToolUse envelope; fed a Cursor payload enforce-commands.mjs
134
+ // short-circuits at gate G1 and writes 0 bytes to stdout AND stderr with
135
+ // exit 0, so the harness sees no decision and the command runs. Cursor
136
+ // needs an input/output adapter like scripts/lib/pi-hook-bridge.mjs; none
137
+ // exists. Operator decision 2026-07-31 — gap registered, not closed:
138
+ // #919 (Cursor no-op) tracks the adapter, #946 the allowlist-provenance
139
+ // rule that every entry here names its issue. These entries exist so the
140
+ // gap is MACHINE-readable (Check 6 counts them) rather than prose-only.
141
+ cursor: {
142
+ // #919: beforeShellExecution → PreToolUse. Only enforce-commands.mjs is
143
+ // mapped at all; the other eight PreToolUse handlers have no Cursor
144
+ // mapping whatsoever.
145
+ PreToolUse: [
146
+ 'skill-invocation-telemetry.mjs', // #919
147
+ 'enforce-scope.mjs', // #919
148
+ 'config-protection.mjs', // #919
149
+ 'pre-bash-destructive-guard.mjs', // #919
150
+ 'pre-bash-staging-fence.mjs', // #919
151
+ 'pre-bash-memory-propose-audit.mjs', // #919
152
+ 'pre-bash-sessions-ledger-guard.mjs',// #919 (#958 — needs tool_name === 'Bash')
153
+ 'pre-bash-templates-first.mjs', // #919
154
+ 'pre-bash-issue-budget.mjs', // #919
155
+ ],
156
+ // #919: afterFileEdit → PostToolUse. Cursor maps enforce-scope.mjs here
157
+ // (post-hoc warning only); none of Claude's four PostToolUse handlers
158
+ // has a Cursor mapping.
159
+ PostToolUse: [
160
+ 'post-edit-validate.mjs', // #919
161
+ 'post-tooluse-frontend-slop.mjs', // #919
162
+ 'post-bash-write-verify.mjs', // #919 (#942 class — needs tool_name === 'Bash')
163
+ 'loop-guard.mjs', // #919
164
+ ],
165
+ },
34
166
  },
35
167
  };
36
168
 
@@ -163,9 +295,10 @@ if (piJson) {
163
295
  // Handles two shapes:
164
296
  // Claude/Codex: hooks[event] = Array<{ hooks: Array<{ command: "node \"$CLAUDE_PLUGIN_ROOT/hooks/foo.mjs\"" }> }>
165
297
  // Cursor: hooks[event] = { script: "hooks/foo.mjs" }
166
- function extractHandlers(json) {
167
- const handlers = new Set();
168
- for (const event of Object.values(json.hooks || {})) {
298
+ function extractHandlersByEvent(json) {
299
+ const byEvent = {};
300
+ for (const [eventName, event] of Object.entries(json.hooks || {})) {
301
+ const handlers = new Set();
169
302
  const matchers = Array.isArray(event) ? event : [event];
170
303
  for (const m of matchers) {
171
304
  // Cursor shape: script field at matcher level
@@ -181,6 +314,15 @@ function extractHandlers(json) {
181
314
  if (match) handlers.add(match[1]);
182
315
  }
183
316
  }
317
+ byEvent[eventName] = handlers;
318
+ }
319
+ return byEvent;
320
+ }
321
+
322
+ function extractHandlers(json) {
323
+ const handlers = new Set();
324
+ for (const set of Object.values(extractHandlersByEvent(json))) {
325
+ for (const h of set) handlers.add(h);
184
326
  }
185
327
  return handlers;
186
328
  }
@@ -252,6 +394,98 @@ if (unreferenced.length === 0) {
252
394
  pass(`hooks/ has ${unreferenced.length} unreferenced .mjs files (allowed; they may be library helpers): ${unreferenced.join(', ')}`);
253
395
  }
254
396
 
397
+ // Step 9 (#942): per-event handler-SET parity, Claude → counterpart.
398
+ // Checks 1-3 compare event keys, Check 4 handler existence — neither sees a
399
+ // handler wired on one platform only. For every logical event the counterpart
400
+ // DECLARES (event-level absences stay governed by Checks 1-3), each Claude
401
+ // handler must be present on the counterpart or listed in
402
+ // DOCUMENTED_ASYMMETRIES.handlerAsymmetries. Undocumented gap → FAIL.
403
+ console.log('');
404
+ console.log('--- Check 6: per-event handler-set parity across manifests (#942) ---');
405
+ const claudeHandlersByEvent = extractHandlersByEvent(claudeJson);
406
+
407
+ // Pi and Cursor declare platform-native event names; project them onto the
408
+ // logical main events before comparing, or the whole comparison is vacuous.
409
+ // Native events with no mapping are carried through under their own name so
410
+ // the projection guard below still sees them as declared.
411
+ //
412
+ // The merge is a UNION, not an assignment: two native events may project onto
413
+ // the SAME logical event (a harness with `before_shell` AND `before_edit` both
414
+ // mapping to PreToolUse, or — reachable today via the identity fallback — a
415
+ // counterpart that declares the logical event alongside a native event that
416
+ // projects onto it). Assignment let the second write silently discard the
417
+ // first set, so its handlers resurfaced as a phantom "undocumented asymmetry"
418
+ // or vanished from the comparison entirely.
419
+ function handlersByMainEvent(json, eventMap) {
420
+ const byNativeEvent = extractHandlersByEvent(json);
421
+ const byMain = {};
422
+ for (const [nativeEvent, handlers] of Object.entries(byNativeEvent)) {
423
+ const target = eventMap[nativeEvent] || nativeEvent;
424
+ byMain[target] = new Set([...(byMain[target] ?? []), ...handlers]);
425
+ }
426
+ return byMain;
427
+ }
428
+
429
+ const HANDLER_PARITY_COUNTERPARTS = [
430
+ { file: 'hooks-codex.json', key: 'codex', byEvent: codexJson ? extractHandlersByEvent(codexJson) : null },
431
+ {
432
+ file: 'hooks-cursor.json',
433
+ key: 'cursor',
434
+ byEvent: cursorJson ? handlersByMainEvent(cursorJson, DOCUMENTED_ASYMMETRIES.cursorEventMap) : null,
435
+ },
436
+ {
437
+ file: 'hooks-pi.json',
438
+ key: 'pi',
439
+ byEvent: piJson ? handlersByMainEvent(piJson, DOCUMENTED_ASYMMETRIES.piEventMap) : null,
440
+ },
441
+ ];
442
+
443
+ for (const { file, key, byEvent } of HANDLER_PARITY_COUNTERPARTS) {
444
+ if (!byEvent) {
445
+ pass(`${file} absent (optional config) — handler parity skipped`);
446
+ continue;
447
+ }
448
+ // Projection guard: every event a counterpart declares must land on a
449
+ // logical event hooks.json also declares. An unprojected event makes the
450
+ // per-event loop below `continue` on the logical event it was supposed to
451
+ // cover, so those handlers are never compared and Check 6 still reports
452
+ // PASS — only the `documented asymmetries:` count moves. Losing ALL
453
+ // projections is how hooks-cursor.json passed with 20 of 22 handlers
454
+ // missing; losing ONE is the same blindness at smaller scale, so the guard
455
+ // fails closed per event, not only on the empty intersection (#946).
456
+ const declaredEvents = Object.keys(byEvent);
457
+ const sharedEvents = declaredEvents.filter((e) => Object.hasOwn(claudeHandlersByEvent, e));
458
+ const unprojectedEvents = declaredEvents.filter((e) => !Object.hasOwn(claudeHandlersByEvent, e));
459
+ if (declaredEvents.length > 0 && sharedEvents.length === 0) {
460
+ // Whole-namespace loss keeps its own message: nothing at all was compared.
461
+ fail(`${file} shares ZERO logical events with hooks.json after projection (declares: ${declaredEvents.join(', ')}) — handler parity would pass vacuously; add an event projection to DOCUMENTED_ASYMMETRIES`);
462
+ continue;
463
+ }
464
+ if (unprojectedEvents.length > 0) {
465
+ fail(`${file} declares events with no logical counterpart in hooks.json after projection: ${unprojectedEvents.join(', ')} — handlers on the logical events they stand for are never compared; add a projection to DOCUMENTED_ASYMMETRIES.${key}EventMap`);
466
+ continue;
467
+ }
468
+
469
+ const allow = DOCUMENTED_ASYMMETRIES.handlerAsymmetries[key] || {};
470
+ const undocumented = [];
471
+ let documentedCount = 0;
472
+ for (const [event, claudeSet] of Object.entries(claudeHandlersByEvent)) {
473
+ const counterpartSet = byEvent[event];
474
+ if (!counterpartSet) continue; // event not declared on counterpart — Checks 1-3 govern
475
+ const allowed = new Set(allow[event] || []);
476
+ for (const handler of claudeSet) {
477
+ if (counterpartSet.has(handler)) continue;
478
+ if (allowed.has(handler)) { documentedCount++; continue; }
479
+ undocumented.push(`${event} → ${handler}`);
480
+ }
481
+ }
482
+ if (undocumented.length === 0) {
483
+ pass(`${file} per-event handler sets match hooks.json (documented asymmetries: ${documentedCount})`);
484
+ } else {
485
+ fail(`${file} missing UNDOCUMENTED handlers on shared events: ${undocumented.join(', ')}`);
486
+ }
487
+ }
488
+
255
489
  // Final
256
490
  console.log('');
257
491
  console.log(`Results: ${passed} passed, ${failed} failed`);
@@ -1,21 +1,71 @@
1
1
  #!/usr/bin/env node
2
- // check-rules.mjs — Validate .claude/rules/*.md files with auto-generated: true
3
- // against the never-always-on invariant (FA4 #697 backstop for the reconcile emitter
4
- // brandmauer in scripts/lib/reconcile/emitter.mjs ~line 193).
2
+ // check-rules.mjs — Validate .claude/rules/*.md files against two invariants:
5
3
  //
6
- // Rules WITHOUT `auto-generated: true` in their frontmatter are SKIPPED — this
7
- // gate only validates generated rules. Handwritten rules are not audited here.
4
+ // (1) AUTO-GENERATED rules (`auto-generated: true` FA4 #697 backstop for the
5
+ // reconcile emitter brandmauer in scripts/lib/reconcile/emitter.mjs ~line
6
+ // 193). HARD-FAIL, exit-code-driving:
7
+ // (a) never-always-on: must have at least one activation axis — a
8
+ // non-empty `globs` array OR a `host-class` key. A rule that is
9
+ // always-on (globs null/absent AND no host-class) is a budget
10
+ // violation (#668 / #687).
11
+ // SPECIAL CASE (#892 QA Defect — the auto-generated twin of #880 QA
12
+ // Defect 1) — an EMPTY `globs` array (`globs: []`) is NOT the
13
+ // "no axis" case and is NOT always-on: rule-loader.mjs
14
+ // unconditionally excludes on `globs.length === 0` (~line 526)
15
+ // AFTER gating runs, so the rule never loads in ANY context — even
16
+ // one where a co-present `host-class:` key would otherwise satisfy
17
+ // the axis. Before #892 this branch silently mis-reported (or, when
18
+ // paired with `host-class:`, silently missed entirely) this case
19
+ // under the inverted "always-on" FAIL message. This gets its own
20
+ // distinct FAIL, fired independent of `host-class`, because
21
+ // `host-class` cannot rescue an empty globs array from that
22
+ // unconditional exclusion — mirrors the handwritten branch's
23
+ // identical `hasEmptyGlobs` fix below.
24
+ // (b) learning-key must be present (traceability back to the emitter source).
25
+ // (c) expires-at must be present (auto-generated rules must have a TTL).
8
26
  //
9
- // For every auto-generated rule, three invariants are checked:
10
- // (a) never-always-on: must have at least one activation axis — a non-empty
11
- // `globs` array OR a `host-class` key. A rule that is always-on
12
- // (globs null/empty AND no host-class) is a budget violation (#668 / #687).
13
- // (b) learning-key must be present (traceability back to the emitter source).
14
- // (c) expires-at must be present (auto-generated rules must have a TTL).
27
+ // (2) HANDWRITTEN rules (no `auto-generated: true` #880 FA5, WARN-only,
28
+ // NEVER affects the exit code). The auto-generated brandmauer above only
29
+ // binds the MACHINE author (the reconcile emitter) a human authoring a
30
+ // rule file by hand bypasses it entirely. #880's finding: several
31
+ // handwritten rules in this repo carry only a `tier:` axis (no
32
+ // `globs`/`paths`/`host-class`) and no periodic-review marker, and nothing
33
+ // in the system ever prompts a re-review. This second, symmetric check
34
+ // extends the SAME invariant shape to handwritten rules, starting in WARN
35
+ // mode — a hard gate is a later, deliberate step (see
36
+ // docs/rule-authoring.md § "Handwritten Rule Review Date (#880 FA5)").
37
+ // A handwritten rule is flagged when it is missing EITHER:
38
+ // (a) an activation axis — a non-empty `globs`/`paths` array, a
39
+ // `host-class` key, OR a `tier` key. `tier:` counts: rule-loader.mjs
40
+ // `applyGates()` honours it as a real load-context gate (excludes
41
+ // `coordinator-only` rules from wave context and vice versa) — see
42
+ // docs/rule-authoring.md § "Tier gating (issue #692)".
43
+ // SPECIAL CASE — an EMPTY `globs`/`paths` array (`globs: []`) is
44
+ // NOT the "no axis" case and is NOT always-on: rule-loader.mjs
45
+ // unconditionally excludes on `globs.length === 0` AFTER gating
46
+ // runs, so the rule never loads in any context — even one where a
47
+ // co-present `tier:`/`host-class:` key would otherwise satisfy the
48
+ // axis. This branch gets its own distinct WARN, fired independent
49
+ // of `tier`/`host-class`, because neither can rescue an empty
50
+ // globs array from that unconditional exclusion (#880 QA Defect 1
51
+ // fix — see the FAIL/WARN inversion this replaced in git blame).
52
+ // (b) a `review-date` key (ISO 8601 date) — a periodic-review marker,
53
+ // DELIBERATELY DISTINCT from `expires-at`. rule-loader.mjs treats
54
+ // `expires-at` as a live EXPIRY gate (`applyGates`): reusing it on a
55
+ // hand-authored always-on safety rule (e.g. security.md) would make
56
+ // that rule silently stop loading the day the date passes — an
57
+ // unacceptable behaviour change for a metadata-only review marker.
58
+ // `review-date` is inert: it is NOT in rule-loader.mjs's
59
+ // SCALAR_META_KEYS allowlist (contract-locked, not modified by
60
+ // #880), so `parseGlobsFrontmatter()` never surfaces it and
61
+ // `loadApplicableRules()` never gates on it — it is parsed only by
62
+ // THIS script, via the local `hasFrontmatterKey()` helper below.
15
63
  //
16
64
  // Usage: check-rules.mjs <plugin-root>
17
- // Outputs lines of the form " PASS: ..." / " FAIL: ..."
18
- // Exit 0 = all invariants satisfied (or no auto-generated rules found); exit 1 = at least one failure.
65
+ // Outputs lines of the form " PASS: ..." / " FAIL: ..." / " WARN: ...".
66
+ // Exit 0 = all AUTO-GENERATED invariants satisfied (WARN lines from the
67
+ // handwritten check NEVER affect the exit code); exit 1 = at least one
68
+ // auto-generated FAIL.
19
69
 
20
70
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
21
71
  import { join } from 'node:path';
@@ -32,6 +82,7 @@ const RULES_DIR = join(pluginRoot, '.claude', 'rules');
32
82
 
33
83
  let passed = 0;
34
84
  let failed = 0;
85
+ let warned = 0;
35
86
 
36
87
  function pass(msg) {
37
88
  console.log(` PASS: ${msg}`);
@@ -43,8 +94,39 @@ function fail(msg) {
43
94
  failed++;
44
95
  }
45
96
 
97
+ function warn(msg) {
98
+ console.log(` WARN: ${msg}`);
99
+ warned++;
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Local frontmatter helper for the `review-date` key (#880 FA5). Deliberately
104
+ // NOT routed through rule-loader.mjs's `parseGlobsFrontmatter()` — that
105
+ // module is contract-locked and its SCALAR_META_KEYS allowlist does not (and
106
+ // should not) recognise `review-date`, per the module-doc rationale above.
107
+ // This is a minimal, self-contained frontmatter-block scan mirroring
108
+ // rule-loader.mjs's own FRONTMATTER_RE, not a general YAML parser.
109
+ // ---------------------------------------------------------------------------
110
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
111
+
112
+ /**
113
+ * Returns true when the frontmatter block of `contents` contains a top-level
114
+ * `key:` line carrying a non-empty value.
115
+ *
116
+ * @param {string} contents - raw file contents
117
+ * @param {string} key - frontmatter key name (no colon)
118
+ * @returns {boolean}
119
+ */
120
+ function hasFrontmatterKey(contents, key) {
121
+ const match = FRONTMATTER_RE.exec(contents);
122
+ if (!match) return false;
123
+ const keyRe = new RegExp(`^${key}:\\s*(\\S.*)?$`, 'm');
124
+ const keyMatch = keyRe.exec(match[1]);
125
+ return Boolean(keyMatch && keyMatch[1] && keyMatch[1].trim() !== '');
126
+ }
127
+
46
128
  // ============================================================================
47
- // Check: auto-generated rule invariants
129
+ // Check: .claude/rules/ rule invariants
48
130
  // ============================================================================
49
131
  console.log('--- Check: .claude/rules/ auto-generated rule invariants ---');
50
132
 
@@ -52,7 +134,7 @@ console.log('--- Check: .claude/rules/ auto-generated rule invariants ---');
52
134
  if (!existsSync(RULES_DIR)) {
53
135
  pass('no .claude/rules/ directory found — nothing to validate');
54
136
  console.log('');
55
- console.log(`Results: ${passed} passed, ${failed} failed`);
137
+ console.log(`Results: ${passed} passed, ${failed} failed, ${warned} warned`);
56
138
  process.exit(0);
57
139
  }
58
140
 
@@ -67,12 +149,16 @@ try {
67
149
  if (mdFiles.length === 0) {
68
150
  pass('no .md rule files found — nothing to validate');
69
151
  console.log('');
70
- console.log(`Results: ${passed} passed, ${failed} failed`);
152
+ console.log(`Results: ${passed} passed, ${failed} failed, ${warned} warned`);
71
153
  process.exit(0);
72
154
  }
73
155
 
74
- // Track whether we found any auto-generated rules at all.
75
- let autoGeneratedCount = 0;
156
+ // Parse every rule file once, splitting into the auto-generated and
157
+ // handwritten cohorts. Malformed frontmatter → skipped from BOTH checks
158
+ // (matches pre-#880 behaviour: a rule whose frontmatter cannot be parsed is
159
+ // neither PASS'd nor FAIL'd).
160
+ const autoGeneratedEntries = [];
161
+ const handwrittenEntries = [];
76
162
 
77
163
  for (const name of mdFiles.sort()) {
78
164
  const filePath = join(RULES_DIR, name);
@@ -88,28 +174,52 @@ for (const name of mdFiles.sort()) {
88
174
  try {
89
175
  parsed = parseGlobsFrontmatter(contents);
90
176
  } catch {
91
- // Malformed frontmatter — not an auto-generated rule, skip.
177
+ // Malformed frontmatter — not auditable by either branch, skip.
92
178
  continue;
93
179
  }
94
180
 
95
181
  const { globs, meta } = parsed;
96
-
97
- // Only validate rules that explicitly declare auto-generated: true.
98
- if (meta['auto-generated'] !== true) continue;
99
-
100
- autoGeneratedCount++;
101
182
  const rel = `.claude/rules/${name}`;
102
183
 
184
+ if (meta['auto-generated'] === true) {
185
+ autoGeneratedEntries.push({ rel, globs, meta });
186
+ } else {
187
+ handwrittenEntries.push({ rel, globs, meta, contents });
188
+ }
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Auto-generated branch (FA4 #697) — UNCHANGED behaviour, hard-fail.
193
+ // ---------------------------------------------------------------------------
194
+ for (const { rel, globs, meta } of autoGeneratedEntries) {
103
195
  // (a) never-always-on: must have at least one activation axis.
104
- // globs is null (no frontmatter / no globs key) OR empty array → always-on.
105
- // host-class absent no host-class axis.
196
+ // globs is one of: null (no frontmatter / no globs key), [] (key present
197
+ // but EMPTY the #892 QA-Defect special case, see module doc), or a
198
+ // populated array. host-class absent → no host-class axis.
199
+ const hasEmptyGlobs = Array.isArray(globs) && globs.length === 0;
106
200
  const hasGlobs = Array.isArray(globs) && globs.length > 0;
107
201
  const hasHostClass = Object.prototype.hasOwnProperty.call(meta, 'host-class');
108
202
 
109
- if (!hasGlobs && !hasHostClass) {
203
+ if (hasEmptyGlobs) {
204
+ // An empty globs array is NOT "no axis at all" and is NOT "always-on" —
205
+ // it is the opposite: rule-loader.mjs's unconditional globs.length === 0
206
+ // exclusion (~line 526) fires AFTER gating, so this rule never loads in
207
+ // ANY context, even one that also carries a host-class: key. Fire
208
+ // independent of hasHostClass — host-class cannot rescue an empty globs
209
+ // array from that unconditional exclusion (#892 QA Defect fix — see the
210
+ // module doc for the full rule-loader.mjs citation and the identical
211
+ // handwritten-branch fix this mirrors, #880 QA Defect 1).
110
212
  fail(
111
- `${rel} — auto-generated rule is always-on (no activation axis: globs is absent/empty AND host-class absent). ` +
112
- 'This violates the never-always-on invariant (#668/#687). Add a globs filter or host-class axis.',
213
+ `${rel} — auto-generated rule has an empty globs array (globs: []) this rule matches NOTHING and never ` +
214
+ 'loads in ANY context, even one that also carries a host-class: key (rule-loader.mjs excludes on ' +
215
+ 'globs.length === 0 unconditionally, after gating runs). This is the OPPOSITE of "always-on" but still ' +
216
+ 'violates the never-always-on invariant (#668/#687) because the rule never activates at all. Populate ' +
217
+ 'globs: with real patterns or remove the file.',
218
+ );
219
+ } else if (!hasGlobs && !hasHostClass) {
220
+ fail(
221
+ `${rel} — auto-generated rule is always-on (no activation axis: globs is absent AND host-class absent). ` +
222
+ 'This violates the never-always-on invariant (#668/#687). Add a globs filter or host-class axis.',
113
223
  );
114
224
  }
115
225
 
@@ -123,21 +233,93 @@ for (const name of mdFiles.sort()) {
123
233
  fail(`${rel} — auto-generated rule is missing required frontmatter key: expires-at`);
124
234
  }
125
235
 
126
- // Emit a PASS line only when the rule satisfies ALL three invariants:
127
- // an activation axis (globs or host-class) AND learning-key AND expires-at.
128
- // When any failed, the FAIL line(s) above already emitted.
236
+ // Emit a PASS line only when the rule satisfies ALL three invariants: a
237
+ // genuine activation axis (a NON-EMPTY globs array, or host-class NEVER
238
+ // an empty globs array, which is dead-not-passing regardless of host-class)
239
+ // AND learning-key AND expires-at. When any failed, the FAIL line(s) above
240
+ // already emitted.
129
241
  const lkOk = Object.prototype.hasOwnProperty.call(meta, 'learning-key');
130
242
  const eaOk = Object.prototype.hasOwnProperty.call(meta, 'expires-at');
131
- if ((hasGlobs || hasHostClass) && lkOk && eaOk) {
243
+ if (!hasEmptyGlobs && (hasGlobs || hasHostClass) && lkOk && eaOk) {
132
244
  pass(`${rel} — auto-generated rule satisfies all invariants`);
133
245
  }
134
246
  }
135
247
 
136
- if (autoGeneratedCount === 0) {
248
+ if (autoGeneratedEntries.length === 0) {
137
249
  pass('no auto-generated rules found — nothing to validate');
138
250
  }
139
251
 
252
+ // ---------------------------------------------------------------------------
253
+ // Handwritten branch (#880 FA5) — WARN-only, non-fatal, never affects exit code.
254
+ // ---------------------------------------------------------------------------
255
+ console.log('');
256
+ console.log(
257
+ '--- Check: .claude/rules/ handwritten-rule activation + review-date (warn mode, #880) ---',
258
+ );
259
+
260
+ for (const { rel, globs, meta, contents } of handwrittenEntries) {
261
+ // `globs` (the merged globs/paths value from parseGlobsFrontmatter — #795
262
+ // alias, `globs:` wins when both are present) is one of:
263
+ // - null → neither key present
264
+ // - [] (Array, length 0) → key present but EMPTY
265
+ // - [...] (Array, length>0) → key present and populated
266
+ const hasEmptyGlobs = Array.isArray(globs) && globs.length === 0;
267
+ const hasGlobsAxis = Array.isArray(globs) && globs.length > 0;
268
+ const hasHostClassAxis = Object.prototype.hasOwnProperty.call(meta, 'host-class');
269
+ const hasTierAxis = Object.prototype.hasOwnProperty.call(meta, 'tier');
270
+ const hasAxis = hasGlobsAxis || hasHostClassAxis || hasTierAxis;
271
+ const hasReviewDate = hasFrontmatterKey(contents, 'review-date');
272
+
273
+ if (hasEmptyGlobs) {
274
+ // An empty globs/paths array is NOT the same condition as "no axis at
275
+ // all", and it is NOT "always-on" — it is the opposite. Per
276
+ // rule-loader.mjs's loadApplicableRules() (~line 526): once frontmatter
277
+ // parses, tier/host-class/mode/expiry gating (applyGates) runs FIRST and
278
+ // can only ADD exclusions; if the rule survives that gating, the loader
279
+ // THEN unconditionally excludes it when `globs.length === 0` ("matches
280
+ // nothing (intentionally scoped out)") — this check fires regardless of
281
+ // whether a tier:/host-class: key would otherwise have satisfied the
282
+ // axis requirement. So a rule with `globs: []` never loads in ANY
283
+ // context, even one paired with `tier: always`. Always warn with the
284
+ // accurate "dead rule" message here, independent of `hasAxis` — a
285
+ // co-present tier:/host-class: key cannot rescue an empty globs array
286
+ // from this unconditional exclusion, so it must not suppress the warning
287
+ // either (#880 QA Defect 1 fix).
288
+ warn(
289
+ `${rel} — handwritten rule has an empty globs/paths array (globs: [] or paths: []) — this rule matches ` +
290
+ 'NOTHING and never loads in ANY context, even one that also carries a tier:/host-class: key ' +
291
+ '(rule-loader.mjs excludes on globs.length === 0 unconditionally, after gating runs). This is the ' +
292
+ 'OPPOSITE of "always-on". If this is a deliberately disabled rule, consider removing the file instead; ' +
293
+ 'otherwise populate globs:/paths: with real patterns.',
294
+ );
295
+ } else if (!hasAxis) {
296
+ warn(
297
+ `${rel} — handwritten rule has no activation axis (globs/paths/host-class/tier all absent) — loads ` +
298
+ 'always-on in every context. Not a build failure yet (warn mode, #880); add a tier: or globs: axis ' +
299
+ 'if this rule should be scoped.',
300
+ );
301
+ }
302
+
303
+ if (!hasReviewDate) {
304
+ warn(
305
+ `${rel} — handwritten rule is missing a review-date (no periodic-review marker). ` +
306
+ 'See docs/rule-authoring.md § "Handwritten Rule Review Date (#880 FA5)".',
307
+ );
308
+ }
309
+
310
+ if (!hasEmptyGlobs && hasAxis && hasReviewDate) {
311
+ pass(`${rel} — handwritten rule has an activation axis and a review-date`);
312
+ }
313
+ }
314
+
315
+ if (handwrittenEntries.length === 0) {
316
+ // Deliberately NOT routed through pass() — this is bookkeeping, not an
317
+ // audited invariant, and must not inflate the `passed` count that
318
+ // auto-generated-only fixtures assert an exact value against.
319
+ console.log(' (no handwritten rules found — nothing to check)');
320
+ }
321
+
140
322
  console.log('');
141
- console.log(`Results: ${passed} passed, ${failed} failed`);
323
+ console.log(`Results: ${passed} passed, ${failed} failed, ${warned} warned`);
142
324
 
143
325
  process.exit(failed > 0 ? 1 : 0);