session-orchestrator 3.17.0 → 3.20.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 (221) 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 +185 -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/commands/session.md +6 -2
  21. package/docs/USER-GUIDE.md +9 -4
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +6 -6
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +794 -0
  28. package/docs/rule-authoring.md +58 -9
  29. package/docs/session-config-reference.md +245 -50
  30. package/docs/session-config-template.md +39 -26
  31. package/hooks/_lib/guard-source-loader.mjs +680 -0
  32. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  33. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  34. package/hooks/config-protection.mjs +0 -0
  35. package/hooks/enforce-commands.mjs +226 -19
  36. package/hooks/enforce-scope.mjs +133 -9
  37. package/hooks/hooks-codex.json +1 -1
  38. package/hooks/hooks-cursor.json +11 -2
  39. package/hooks/hooks-pi.json +10 -0
  40. package/hooks/hooks.json +21 -1
  41. package/hooks/on-session-end.mjs +178 -18
  42. package/hooks/on-session-start.mjs +30 -4
  43. package/hooks/post-bash-write-verify.mjs +977 -0
  44. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  45. package/hooks/pre-bash-destructive-guard.mjs +616 -164
  46. package/hooks/pre-bash-issue-budget.mjs +167 -0
  47. package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
  48. package/hooks/pre-bash-templates-first.mjs +96 -63
  49. package/hooks/subagent-telemetry.mjs +527 -37
  50. package/package.json +6 -3
  51. package/pi/prompts/contract-version-bump.md +12 -0
  52. package/rules/README.md +32 -0
  53. package/scripts/archive-closed-prds.mjs +12 -22
  54. package/scripts/autopilot-multi.mjs +103 -20
  55. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  56. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  57. package/scripts/check-doc-consistency.sh +17 -1
  58. package/scripts/emit-session.mjs +3 -40
  59. package/scripts/eval-session.mjs +50 -9
  60. package/scripts/fleet-instruction-scan.mjs +141 -0
  61. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  62. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  63. package/scripts/lib/backlog-scan.mjs +39 -6
  64. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  65. package/scripts/lib/ci-status-banner.mjs +75 -12
  66. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  67. package/scripts/lib/command-blocker.mjs +1273 -58
  68. package/scripts/lib/config/config-protection.mjs +2 -1
  69. package/scripts/lib/config/drift-check.mjs +9 -1
  70. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  71. package/scripts/lib/config/issue-budget.mjs +123 -0
  72. package/scripts/lib/config/reconcile.mjs +21 -0
  73. package/scripts/lib/config/section-extractor.mjs +121 -1
  74. package/scripts/lib/config-schema.mjs +23 -3
  75. package/scripts/lib/config.mjs +17 -0
  76. package/scripts/lib/convergence-monitor.mjs +49 -3
  77. package/scripts/lib/description-surface.mjs +535 -0
  78. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  79. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  80. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  81. package/scripts/lib/eval/engine.mjs +47 -5
  82. package/scripts/lib/events.mjs +59 -7
  83. package/scripts/lib/gates/gate-full.mjs +15 -3
  84. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  85. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  86. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  87. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  88. package/scripts/lib/handover-gate.mjs +7 -3
  89. package/scripts/lib/hardening.mjs +9 -9
  90. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  91. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  92. package/scripts/lib/io.mjs +345 -10
  93. package/scripts/lib/issue-budget.mjs +269 -0
  94. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  95. package/scripts/lib/label-scope.mjs +47 -0
  96. package/scripts/lib/learnings/affinity.mjs +434 -0
  97. package/scripts/lib/learnings/candidates.mjs +736 -0
  98. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  99. package/scripts/lib/learnings/judgment.mjs +782 -0
  100. package/scripts/lib/learnings/kebab.mjs +128 -0
  101. package/scripts/lib/learnings/schema.mjs +43 -3
  102. package/scripts/lib/learnings/select.mjs +550 -0
  103. package/scripts/lib/lock-reaper.mjs +1 -2
  104. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  105. package/scripts/lib/peer-discovery.mjs +645 -0
  106. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  107. package/scripts/lib/product-repo-detect.mjs +9 -8
  108. package/scripts/lib/project-hygiene.mjs +432 -0
  109. package/scripts/lib/quality-gate.mjs +167 -0
  110. package/scripts/lib/recommendations-v0.mjs +1 -1
  111. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  112. package/scripts/lib/reconcile/emitter.mjs +128 -24
  113. package/scripts/lib/reconcile/engine.mjs +156 -54
  114. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  115. package/scripts/lib/reconcile/renderer.mjs +141 -25
  116. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  117. package/scripts/lib/reconcile/writer.mjs +95 -1
  118. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  119. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  120. package/scripts/lib/resource-probe.mjs +19 -0
  121. package/scripts/lib/rule-loader.mjs +6 -0
  122. package/scripts/lib/scope-baseline.mjs +564 -0
  123. package/scripts/lib/scope-gate.mjs +568 -145
  124. package/scripts/lib/session-close-backfill.mjs +63 -8
  125. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  126. package/scripts/lib/session-id.mjs +221 -41
  127. package/scripts/lib/session-lock.mjs +304 -6
  128. package/scripts/lib/session-record-repair.mjs +551 -0
  129. package/scripts/lib/session-schema/constants.mjs +22 -3
  130. package/scripts/lib/session-schema/serializer.mjs +54 -0
  131. package/scripts/lib/session-schema/validator.mjs +16 -0
  132. package/scripts/lib/session-schema.mjs +1 -0
  133. package/scripts/lib/session-token-rollup.mjs +68 -6
  134. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  135. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  136. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  137. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  138. package/scripts/lib/soul-resolve.mjs +12 -0
  139. package/scripts/lib/spiral-carryover.mjs +142 -30
  140. package/scripts/lib/state-md/mission-status.mjs +53 -3
  141. package/scripts/lib/subagents-schema.mjs +43 -9
  142. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  143. package/scripts/lib/tests-src-ratio.mjs +484 -0
  144. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  145. package/scripts/lib/validate/check-agents.mjs +56 -0
  146. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  147. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  148. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  149. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  150. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  151. package/scripts/lib/validate/check-rules.mjs +244 -36
  152. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  153. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  154. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  155. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  156. package/scripts/lib/vault-archive.mjs +17 -2
  157. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  158. package/scripts/lib/vault-mirror/process.mjs +30 -0
  159. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  160. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  161. package/scripts/lib/wave-resource-gate.mjs +115 -11
  162. package/scripts/lib/worktree/listing.mjs +44 -7
  163. package/scripts/mcp-server.sh +17 -3
  164. package/scripts/measure-context-overhead.sh +151 -0
  165. package/scripts/memory-propose.mjs +72 -9
  166. package/scripts/print-applicable-rules.mjs +218 -16
  167. package/scripts/print-learnings-index.mjs +474 -0
  168. package/scripts/release.mjs +534 -0
  169. package/scripts/repair-invalid-sessions.mjs +209 -0
  170. package/scripts/run-quality-gate.mjs +123 -5
  171. package/scripts/sweep-expired-learnings.mjs +192 -32
  172. package/scripts/validate-plugin.mjs +21 -0
  173. package/scripts/validate-wave-scope.mjs +182 -17
  174. package/scripts/vault-integration-watcher.mjs +32 -10
  175. package/skills/_shared/config-reading.md +2 -2
  176. package/skills/bootstrap/fast-template.md +1 -1
  177. package/skills/brainstorm/soul.md +47 -1
  178. package/skills/claude-md-drift-check/checker.mjs +145 -28
  179. package/skills/contract-version-bump/SKILL.md +219 -0
  180. package/skills/discovery/SKILL.md +4 -4
  181. package/skills/discovery/issue-templates.md +11 -11
  182. package/skills/discovery/probes-audit.md +1 -1
  183. package/skills/discovery/probes-feature.md +1 -1
  184. package/skills/discovery/probes-session.md +26 -5
  185. package/skills/ecosystem-health/SKILL.md +1 -1
  186. package/skills/ecosystem-health/wizard.md +4 -4
  187. package/skills/evolve/SKILL.md +117 -18
  188. package/skills/gitlab-ops/SKILL.md +25 -12
  189. package/skills/gitlab-portfolio/SKILL.md +2 -2
  190. package/skills/grill/soul.md +44 -1
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +17 -1
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/plan/soul.md +46 -3
  199. package/skills/quality-gates/SKILL.md +1 -1
  200. package/skills/reconcile/SKILL.md +21 -4
  201. package/skills/session-end/SKILL.md +34 -36
  202. package/skills/session-end/discovery-scan.md +4 -2
  203. package/skills/session-end/drift-operations.md +4 -4
  204. package/skills/session-end/metrics-collection.md +13 -0
  205. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  206. package/skills/session-end/phase-3-6-tail.md +32 -2
  207. package/skills/session-end/plan-verification.md +6 -7
  208. package/skills/session-end/session-metrics-write.md +2 -0
  209. package/skills/session-end/vault-operations.md +1 -1
  210. package/skills/session-end/verification-checklist.md +1 -1
  211. package/skills/session-plan/SKILL.md +6 -2
  212. package/skills/session-plan/wave-template.md +2 -0
  213. package/skills/session-start/SKILL.md +75 -7
  214. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  215. package/skills/session-start/soul.md +41 -1
  216. package/skills/test-runner/SKILL.md +2 -2
  217. package/skills/vault-sync/validator.mjs +108 -7
  218. package/skills/wave-executor/SKILL.md +6 -7
  219. package/skills/wave-executor/circuit-breaker.md +2 -0
  220. package/skills/wave-executor/wave-loop.md +198 -80
  221. package/templates/_shared/loop.md +4 -4
@@ -1,21 +1,78 @@
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).
26
+ // Plus, ahead of the auto/handwritten split and binding on EVERY rule file
27
+ // regardless of cohort:
28
+ // (d) the frontmatter must PARSE (#1015). An unparseable file used to be
29
+ // skipped as "not auditable"; rule-loader.mjs, however, treats a
30
+ // parse error as always-on with empty meta, so the skipped file is
31
+ // exactly the one that loads everywhere and clears every gate. See
32
+ // the inline rationale at the parse site below.
8
33
  //
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).
34
+ // (2) HANDWRITTEN rules (no `auto-generated: true` #880 FA5, WARN-only,
35
+ // NEVER affects the exit code). The auto-generated brandmauer above only
36
+ // binds the MACHINE author (the reconcile emitter) a human authoring a
37
+ // rule file by hand bypasses it entirely. #880's finding: several
38
+ // handwritten rules in this repo carry only a `tier:` axis (no
39
+ // `globs`/`paths`/`host-class`) and no periodic-review marker, and nothing
40
+ // in the system ever prompts a re-review. This second, symmetric check
41
+ // extends the SAME invariant shape to handwritten rules, starting in WARN
42
+ // mode — a hard gate is a later, deliberate step (see
43
+ // docs/rule-authoring.md § "Handwritten Rule Review Date (#880 FA5)").
44
+ // A handwritten rule is flagged when it is missing EITHER:
45
+ // (a) an activation axis — a non-empty `globs`/`paths` array, a
46
+ // `host-class` key, OR a `tier` key. `tier:` counts: rule-loader.mjs
47
+ // `applyGates()` honours it as a real load-context gate (excludes
48
+ // `coordinator-only` rules from wave context and vice versa) — see
49
+ // docs/rule-authoring.md § "Tier gating (issue #692)".
50
+ // SPECIAL CASE — an EMPTY `globs`/`paths` array (`globs: []`) is
51
+ // NOT the "no axis" case and is NOT always-on: rule-loader.mjs
52
+ // unconditionally excludes on `globs.length === 0` AFTER gating
53
+ // runs, so the rule never loads in any context — even one where a
54
+ // co-present `tier:`/`host-class:` key would otherwise satisfy the
55
+ // axis. This branch gets its own distinct WARN, fired independent
56
+ // of `tier`/`host-class`, because neither can rescue an empty
57
+ // globs array from that unconditional exclusion (#880 QA Defect 1
58
+ // fix — see the FAIL/WARN inversion this replaced in git blame).
59
+ // (b) a `review-date` key (ISO 8601 date) — a periodic-review marker,
60
+ // DELIBERATELY DISTINCT from `expires-at`. rule-loader.mjs treats
61
+ // `expires-at` as a live EXPIRY gate (`applyGates`): reusing it on a
62
+ // hand-authored always-on safety rule (e.g. security.md) would make
63
+ // that rule silently stop loading the day the date passes — an
64
+ // unacceptable behaviour change for a metadata-only review marker.
65
+ // `review-date` is inert: it is NOT in rule-loader.mjs's
66
+ // SCALAR_META_KEYS allowlist (contract-locked, not modified by
67
+ // #880), so `parseGlobsFrontmatter()` never surfaces it and
68
+ // `loadApplicableRules()` never gates on it — it is parsed only by
69
+ // THIS script, via the local `hasFrontmatterKey()` helper below.
15
70
  //
16
71
  // 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.
72
+ // Outputs lines of the form " PASS: ..." / " FAIL: ..." / " WARN: ...".
73
+ // Exit 0 = all AUTO-GENERATED invariants satisfied (WARN lines from the
74
+ // handwritten check NEVER affect the exit code); exit 1 = at least one
75
+ // auto-generated FAIL.
19
76
 
20
77
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
21
78
  import { join } from 'node:path';
@@ -32,6 +89,7 @@ const RULES_DIR = join(pluginRoot, '.claude', 'rules');
32
89
 
33
90
  let passed = 0;
34
91
  let failed = 0;
92
+ let warned = 0;
35
93
 
36
94
  function pass(msg) {
37
95
  console.log(` PASS: ${msg}`);
@@ -43,8 +101,39 @@ function fail(msg) {
43
101
  failed++;
44
102
  }
45
103
 
104
+ function warn(msg) {
105
+ console.log(` WARN: ${msg}`);
106
+ warned++;
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Local frontmatter helper for the `review-date` key (#880 FA5). Deliberately
111
+ // NOT routed through rule-loader.mjs's `parseGlobsFrontmatter()` — that
112
+ // module is contract-locked and its SCALAR_META_KEYS allowlist does not (and
113
+ // should not) recognise `review-date`, per the module-doc rationale above.
114
+ // This is a minimal, self-contained frontmatter-block scan mirroring
115
+ // rule-loader.mjs's own FRONTMATTER_RE, not a general YAML parser.
116
+ // ---------------------------------------------------------------------------
117
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
118
+
119
+ /**
120
+ * Returns true when the frontmatter block of `contents` contains a top-level
121
+ * `key:` line carrying a non-empty value.
122
+ *
123
+ * @param {string} contents - raw file contents
124
+ * @param {string} key - frontmatter key name (no colon)
125
+ * @returns {boolean}
126
+ */
127
+ function hasFrontmatterKey(contents, key) {
128
+ const match = FRONTMATTER_RE.exec(contents);
129
+ if (!match) return false;
130
+ const keyRe = new RegExp(`^${key}:\\s*(\\S.*)?$`, 'm');
131
+ const keyMatch = keyRe.exec(match[1]);
132
+ return Boolean(keyMatch && keyMatch[1] && keyMatch[1].trim() !== '');
133
+ }
134
+
46
135
  // ============================================================================
47
- // Check: auto-generated rule invariants
136
+ // Check: .claude/rules/ rule invariants
48
137
  // ============================================================================
49
138
  console.log('--- Check: .claude/rules/ auto-generated rule invariants ---');
50
139
 
@@ -52,7 +141,7 @@ console.log('--- Check: .claude/rules/ auto-generated rule invariants ---');
52
141
  if (!existsSync(RULES_DIR)) {
53
142
  pass('no .claude/rules/ directory found — nothing to validate');
54
143
  console.log('');
55
- console.log(`Results: ${passed} passed, ${failed} failed`);
144
+ console.log(`Results: ${passed} passed, ${failed} failed, ${warned} warned`);
56
145
  process.exit(0);
57
146
  }
58
147
 
@@ -67,12 +156,16 @@ try {
67
156
  if (mdFiles.length === 0) {
68
157
  pass('no .md rule files found — nothing to validate');
69
158
  console.log('');
70
- console.log(`Results: ${passed} passed, ${failed} failed`);
159
+ console.log(`Results: ${passed} passed, ${failed} failed, ${warned} warned`);
71
160
  process.exit(0);
72
161
  }
73
162
 
74
- // Track whether we found any auto-generated rules at all.
75
- let autoGeneratedCount = 0;
163
+ // Parse every rule file once, splitting into the auto-generated and
164
+ // handwritten cohorts. Malformed frontmatter → a cohort-independent hard FAIL
165
+ // (#1015). It was previously skipped from BOTH checks ("neither PASS'd nor
166
+ // FAIL'd"); see the parse site below for why that abstention was a blind spot.
167
+ const autoGeneratedEntries = [];
168
+ const handwrittenEntries = [];
76
169
 
77
170
  for (const name of mdFiles.sort()) {
78
171
  const filePath = join(RULES_DIR, name);
@@ -87,29 +180,72 @@ for (const name of mdFiles.sort()) {
87
180
  let parsed;
88
181
  try {
89
182
  parsed = parseGlobsFrontmatter(contents);
90
- } catch {
91
- // Malformed frontmatternot an auto-generated rule, skip.
183
+ } catch (err) {
184
+ // MALFORMED FRONTMATTER IS A HARD FAIL (#1015) it used to `continue`.
185
+ //
186
+ // The skip looked like a neutral abstention ("not auditable by either
187
+ // branch") but was in fact this validator's single blind spot, and it was
188
+ // blind to precisely the worst state. rule-loader.mjs catches the SAME
189
+ // throw (~:500-507), falls back to `globs = null, meta = {}, parseError =
190
+ // true`, and then (~:519-530) pushes the entry with `alwaysOn: true`.
191
+ // Empty meta means applyGates() has nothing to gate on, so the file also
192
+ // clears tier/host-class/mode/EXPIRY gating by design ("a rule is never
193
+ // silently dropped"). Net effect: the one file this branch declined to
194
+ // audit is the one file the loader loads ALWAYS-ON, in every context,
195
+ // forever — the exact state the never-always-on invariant (#668/#687)
196
+ // exists to forbid, and the landing state of a frontmatter-injection whose
197
+ // payload happens to be colon-less (an injected `\n` + a line with no `:`).
198
+ // Unparseable therefore means UNSAFE, not "unknown": FAIL, never skip.
199
+ fail(
200
+ `.claude/rules/${name} — frontmatter does not parse (${err.message}) — rule-loader.mjs treats a parse ` +
201
+ 'error as ALWAYS-ON with EMPTY meta, so this file loads in every context and clears every gate ' +
202
+ '(no expiry, no tier, no host-class, no mode). Fix the frontmatter or remove the file.',
203
+ );
92
204
  continue;
93
205
  }
94
206
 
95
207
  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
208
  const rel = `.claude/rules/${name}`;
102
209
 
210
+ if (meta['auto-generated'] === true) {
211
+ autoGeneratedEntries.push({ rel, globs, meta });
212
+ } else {
213
+ handwrittenEntries.push({ rel, globs, meta, contents });
214
+ }
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Auto-generated branch (FA4 #697) — UNCHANGED behaviour, hard-fail.
219
+ // ---------------------------------------------------------------------------
220
+ for (const { rel, globs, meta } of autoGeneratedEntries) {
103
221
  // (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.
222
+ // globs is one of: null (no frontmatter / no globs key), [] (key present
223
+ // but EMPTY the #892 QA-Defect special case, see module doc), or a
224
+ // populated array. host-class absent → no host-class axis.
225
+ const hasEmptyGlobs = Array.isArray(globs) && globs.length === 0;
106
226
  const hasGlobs = Array.isArray(globs) && globs.length > 0;
107
227
  const hasHostClass = Object.prototype.hasOwnProperty.call(meta, 'host-class');
108
228
 
109
- if (!hasGlobs && !hasHostClass) {
229
+ if (hasEmptyGlobs) {
230
+ // An empty globs array is NOT "no axis at all" and is NOT "always-on" —
231
+ // it is the opposite: rule-loader.mjs's unconditional globs.length === 0
232
+ // exclusion (~line 526) fires AFTER gating, so this rule never loads in
233
+ // ANY context, even one that also carries a host-class: key. Fire
234
+ // independent of hasHostClass — host-class cannot rescue an empty globs
235
+ // array from that unconditional exclusion (#892 QA Defect fix — see the
236
+ // module doc for the full rule-loader.mjs citation and the identical
237
+ // handwritten-branch fix this mirrors, #880 QA Defect 1).
238
+ fail(
239
+ `${rel} — auto-generated rule has an empty globs array (globs: []) — this rule matches NOTHING and never ` +
240
+ 'loads in ANY context, even one that also carries a host-class: key (rule-loader.mjs excludes on ' +
241
+ 'globs.length === 0 unconditionally, after gating runs). This is the OPPOSITE of "always-on" but still ' +
242
+ 'violates the never-always-on invariant (#668/#687) because the rule never activates at all. Populate ' +
243
+ 'globs: with real patterns or remove the file.',
244
+ );
245
+ } else if (!hasGlobs && !hasHostClass) {
110
246
  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.',
247
+ `${rel} — auto-generated rule is always-on (no activation axis: globs is absent AND host-class absent). ` +
248
+ 'This violates the never-always-on invariant (#668/#687). Add a globs filter or host-class axis.',
113
249
  );
114
250
  }
115
251
 
@@ -123,21 +259,93 @@ for (const name of mdFiles.sort()) {
123
259
  fail(`${rel} — auto-generated rule is missing required frontmatter key: expires-at`);
124
260
  }
125
261
 
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.
262
+ // Emit a PASS line only when the rule satisfies ALL three invariants: a
263
+ // genuine activation axis (a NON-EMPTY globs array, or host-class NEVER
264
+ // an empty globs array, which is dead-not-passing regardless of host-class)
265
+ // AND learning-key AND expires-at. When any failed, the FAIL line(s) above
266
+ // already emitted.
129
267
  const lkOk = Object.prototype.hasOwnProperty.call(meta, 'learning-key');
130
268
  const eaOk = Object.prototype.hasOwnProperty.call(meta, 'expires-at');
131
- if ((hasGlobs || hasHostClass) && lkOk && eaOk) {
269
+ if (!hasEmptyGlobs && (hasGlobs || hasHostClass) && lkOk && eaOk) {
132
270
  pass(`${rel} — auto-generated rule satisfies all invariants`);
133
271
  }
134
272
  }
135
273
 
136
- if (autoGeneratedCount === 0) {
274
+ if (autoGeneratedEntries.length === 0) {
137
275
  pass('no auto-generated rules found — nothing to validate');
138
276
  }
139
277
 
278
+ // ---------------------------------------------------------------------------
279
+ // Handwritten branch (#880 FA5) — WARN-only, non-fatal, never affects exit code.
280
+ // ---------------------------------------------------------------------------
281
+ console.log('');
282
+ console.log(
283
+ '--- Check: .claude/rules/ handwritten-rule activation + review-date (warn mode, #880) ---',
284
+ );
285
+
286
+ for (const { rel, globs, meta, contents } of handwrittenEntries) {
287
+ // `globs` (the merged globs/paths value from parseGlobsFrontmatter — #795
288
+ // alias, `globs:` wins when both are present) is one of:
289
+ // - null → neither key present
290
+ // - [] (Array, length 0) → key present but EMPTY
291
+ // - [...] (Array, length>0) → key present and populated
292
+ const hasEmptyGlobs = Array.isArray(globs) && globs.length === 0;
293
+ const hasGlobsAxis = Array.isArray(globs) && globs.length > 0;
294
+ const hasHostClassAxis = Object.prototype.hasOwnProperty.call(meta, 'host-class');
295
+ const hasTierAxis = Object.prototype.hasOwnProperty.call(meta, 'tier');
296
+ const hasAxis = hasGlobsAxis || hasHostClassAxis || hasTierAxis;
297
+ const hasReviewDate = hasFrontmatterKey(contents, 'review-date');
298
+
299
+ if (hasEmptyGlobs) {
300
+ // An empty globs/paths array is NOT the same condition as "no axis at
301
+ // all", and it is NOT "always-on" — it is the opposite. Per
302
+ // rule-loader.mjs's loadApplicableRules() (~line 526): once frontmatter
303
+ // parses, tier/host-class/mode/expiry gating (applyGates) runs FIRST and
304
+ // can only ADD exclusions; if the rule survives that gating, the loader
305
+ // THEN unconditionally excludes it when `globs.length === 0` ("matches
306
+ // nothing (intentionally scoped out)") — this check fires regardless of
307
+ // whether a tier:/host-class: key would otherwise have satisfied the
308
+ // axis requirement. So a rule with `globs: []` never loads in ANY
309
+ // context, even one paired with `tier: always`. Always warn with the
310
+ // accurate "dead rule" message here, independent of `hasAxis` — a
311
+ // co-present tier:/host-class: key cannot rescue an empty globs array
312
+ // from this unconditional exclusion, so it must not suppress the warning
313
+ // either (#880 QA Defect 1 fix).
314
+ warn(
315
+ `${rel} — handwritten rule has an empty globs/paths array (globs: [] or paths: []) — this rule matches ` +
316
+ 'NOTHING and never loads in ANY context, even one that also carries a tier:/host-class: key ' +
317
+ '(rule-loader.mjs excludes on globs.length === 0 unconditionally, after gating runs). This is the ' +
318
+ 'OPPOSITE of "always-on". If this is a deliberately disabled rule, consider removing the file instead; ' +
319
+ 'otherwise populate globs:/paths: with real patterns.',
320
+ );
321
+ } else if (!hasAxis) {
322
+ warn(
323
+ `${rel} — handwritten rule has no activation axis (globs/paths/host-class/tier all absent) — loads ` +
324
+ 'always-on in every context. Not a build failure yet (warn mode, #880); add a tier: or globs: axis ' +
325
+ 'if this rule should be scoped.',
326
+ );
327
+ }
328
+
329
+ if (!hasReviewDate) {
330
+ warn(
331
+ `${rel} — handwritten rule is missing a review-date (no periodic-review marker). ` +
332
+ 'See docs/rule-authoring.md § "Handwritten Rule Review Date (#880 FA5)".',
333
+ );
334
+ }
335
+
336
+ if (!hasEmptyGlobs && hasAxis && hasReviewDate) {
337
+ pass(`${rel} — handwritten rule has an activation axis and a review-date`);
338
+ }
339
+ }
340
+
341
+ if (handwrittenEntries.length === 0) {
342
+ // Deliberately NOT routed through pass() — this is bookkeeping, not an
343
+ // audited invariant, and must not inflate the `passed` count that
344
+ // auto-generated-only fixtures assert an exact value against.
345
+ console.log(' (no handwritten rules found — nothing to check)');
346
+ }
347
+
140
348
  console.log('');
141
- console.log(`Results: ${passed} passed, ${failed} failed`);
349
+ console.log(`Results: ${passed} passed, ${failed} failed, ${warned} warned`);
142
350
 
143
351
  process.exit(failed > 0 ? 1 : 0);