session-orchestrator 3.19.0 → 3.21.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 (158) 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 +10 -8
  5. package/CHANGELOG.md +494 -0
  6. package/README.md +16 -11
  7. package/agents/analyst.md +1 -1
  8. package/agents/architect-reviewer.md +1 -1
  9. package/agents/code-implementer.md +4 -2
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/dialectic-deriver.md +1 -1
  12. package/agents/docs-writer.md +1 -1
  13. package/agents/memory-proposal-collector.md +1 -1
  14. package/agents/qa-strategist.md +1 -1
  15. package/agents/security-reviewer.md +1 -1
  16. package/agents/session-reviewer.md +42 -1
  17. package/agents/skill-applied-judge.md +1 -1
  18. package/agents/test-writer.md +1 -1
  19. package/agents/ui-developer.md +1 -1
  20. package/agents/ux-evaluator.md +1 -1
  21. package/commands/release.md +60 -0
  22. package/commands/session.md +6 -2
  23. package/docs/USER-GUIDE.md +1 -1
  24. package/docs/instruction-delivery.md +350 -0
  25. package/docs/migration-v3.md +9 -6
  26. package/docs/persona-panel.md +3 -1
  27. package/docs/scope-collision-guard.md +167 -0
  28. package/docs/session-config-reference.md +1 -41
  29. package/docs/session-config-template.md +0 -23
  30. package/hooks/_lib/guard-source-loader.mjs +304 -91
  31. package/hooks/enforce-commands.mjs +216 -17
  32. package/hooks/enforce-scope.mjs +236 -12
  33. package/hooks/hooks-codex.json +1 -1
  34. package/hooks/hooks.json +11 -1
  35. package/hooks/on-session-end.mjs +52 -5
  36. package/hooks/on-session-start.mjs +7 -4
  37. package/hooks/on-stop.mjs +127 -12
  38. package/hooks/post-bash-write-verify.mjs +8 -32
  39. package/hooks/pre-bash-destructive-guard.mjs +146 -59
  40. package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
  41. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  42. package/package.json +2 -2
  43. package/pi/prompts/release.md +12 -0
  44. package/scripts/autopilot.mjs +3 -1
  45. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  46. package/scripts/emit-session.mjs +45 -40
  47. package/scripts/export-hw-learnings.mjs +61 -2
  48. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  49. package/scripts/lib/backlog-scan.mjs +106 -15
  50. package/scripts/lib/build-live-signals.mjs +7 -3
  51. package/scripts/lib/ci-status-banner.mjs +207 -23
  52. package/scripts/lib/command-blocker.mjs +322 -62
  53. package/scripts/lib/git-config-drift.mjs +471 -0
  54. package/scripts/lib/hardening.mjs +9 -9
  55. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  56. package/scripts/lib/io.mjs +193 -7
  57. package/scripts/lib/learnings/affinity.mjs +434 -0
  58. package/scripts/lib/learnings/candidates.mjs +736 -0
  59. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  60. package/scripts/lib/learnings/judgment.mjs +782 -0
  61. package/scripts/lib/learnings/kebab.mjs +128 -0
  62. package/scripts/lib/learnings/select.mjs +704 -0
  63. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  64. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  65. package/scripts/lib/named-vault-resolver.mjs +105 -16
  66. package/scripts/lib/peer-cards/schema.mjs +6 -2
  67. package/scripts/lib/reconcile/emitter.mjs +107 -22
  68. package/scripts/lib/reconcile/engine.mjs +9 -15
  69. package/scripts/lib/reconcile/renderer.mjs +141 -25
  70. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  71. package/scripts/lib/reconcile/writer.mjs +134 -1
  72. package/scripts/lib/redact-spans.mjs +89 -0
  73. package/scripts/lib/scope-baseline.mjs +77 -17
  74. package/scripts/lib/scope-gate.mjs +852 -72
  75. package/scripts/lib/secret-masker.mjs +262 -0
  76. package/scripts/lib/session-close-backfill.mjs +2 -2
  77. package/scripts/lib/session-lock.mjs +34 -10
  78. package/scripts/lib/session-record-repair.mjs +551 -0
  79. package/scripts/lib/session-registry.mjs +9 -1
  80. package/scripts/lib/session-schema/serializer.mjs +54 -0
  81. package/scripts/lib/session-schema.mjs +1 -0
  82. package/scripts/lib/session-token-rollup.mjs +68 -6
  83. package/scripts/lib/soul-resolve.mjs +12 -0
  84. package/scripts/lib/state-md/mission-status.mjs +21 -12
  85. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  86. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  87. package/scripts/lib/validate/check-agents.mjs +77 -5
  88. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  89. package/scripts/lib/validate/check-commands.mjs +2 -20
  90. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  91. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +18 -0
  93. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  94. package/scripts/lib/validate/check-owner-leakage.mjs +188 -20
  95. package/scripts/lib/validate/check-rules.mjs +31 -5
  96. package/scripts/lib/validate/check-skills.mjs +191 -0
  97. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  98. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  99. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  100. package/scripts/lib/validate/check-unwired-features.mjs +757 -0
  101. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  102. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  103. package/scripts/lib/validate/tier-inference.mjs +46 -8
  104. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  105. package/scripts/lib/vault-mirror/process.mjs +264 -31
  106. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  107. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  108. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  109. package/scripts/lib/vcs-repo-spec.mjs +500 -19
  110. package/scripts/print-applicable-rules.mjs +170 -7
  111. package/scripts/print-learnings-index.mjs +501 -0
  112. package/scripts/release.mjs +616 -61
  113. package/scripts/repair-invalid-sessions.mjs +209 -0
  114. package/scripts/site-numbers.mjs +1049 -0
  115. package/scripts/sweep-expired-learnings.mjs +192 -32
  116. package/scripts/validate-plugin.mjs +82 -0
  117. package/scripts/validate-wave-scope.mjs +281 -12
  118. package/scripts/vault-mirror.mjs +26 -1
  119. package/skills/_shared/monitor-patterns.md +24 -4
  120. package/skills/_shared/state-ownership.md +17 -0
  121. package/skills/brainstorm/soul.md +47 -1
  122. package/skills/claude-md-drift-check/SKILL.md +9 -1
  123. package/skills/debug/SKILL.md +4 -1
  124. package/skills/discovery/issue-templates.md +4 -4
  125. package/skills/discovery/probes-code.md +2 -2
  126. package/skills/discovery/probes-feature.md +6 -6
  127. package/skills/discovery/probes-infra.md +2 -2
  128. package/skills/discovery/probes-session.md +5 -5
  129. package/skills/dispatcher/SKILL.md +10 -1
  130. package/skills/evolve/SKILL.md +116 -18
  131. package/skills/frontmatter-guard/SKILL.md +9 -1
  132. package/skills/gitlab-ops/SKILL.md +54 -39
  133. package/skills/gitlab-portfolio/SKILL.md +10 -1
  134. package/skills/grill/soul.md +44 -1
  135. package/skills/memory-cleanup/SKILL.md +18 -5
  136. package/skills/npm-publish/SKILL.md +22 -50
  137. package/skills/persona-panel/SKILL.md +3 -1
  138. package/skills/plan/mode-new.md +23 -5
  139. package/skills/plan/soul.md +46 -3
  140. package/skills/repo-audit/SKILL.md +10 -1
  141. package/skills/session-end/SKILL.md +45 -26
  142. package/skills/session-end/metrics-collection.md +1 -1
  143. package/skills/session-end/phase-3-6-tail.md +30 -1
  144. package/skills/session-end/plan-verification.md +1 -5
  145. package/skills/session-end/session-metrics-write.md +6 -10
  146. package/skills/session-plan/SKILL.md +2 -2
  147. package/skills/session-plan/wave-template.md +1 -1
  148. package/skills/session-start/SKILL.md +15 -1
  149. package/skills/session-start/soul.md +41 -1
  150. package/skills/spinout/SKILL.md +5 -1
  151. package/skills/sunset-review/SKILL.md +11 -1
  152. package/skills/tmux-layout/SKILL.md +7 -2
  153. package/skills/vault-mirror/SKILL.md +10 -1
  154. package/skills/vault-sync/SKILL.md +10 -1
  155. package/skills/vault-sync/validator.mjs +55 -6
  156. package/skills/wave-executor/SKILL.md +1 -5
  157. package/skills/wave-executor/wave-loop.md +77 -82
  158. package/scripts/lib/mission-status-schema.mjs +0 -114
@@ -9,11 +9,14 @@
9
9
  * `canonicalizeLine()` collapses every known textual encoding of a path
10
10
  * (URL-percent, dash-as-separator, backslash, double-slash, `\uXXXX`/`%uXXXX`
11
11
  * escapes, HTML/numeric entities, unicode homoglyph slashes/dashes, case)
12
- * into ONE canonical slash-form, and the owner-secret patterns are matched
13
- * ONCE against that canonical form (plus the raw line, for belt-and-braces).
14
- * A novel encoding of the same path normalizes to the same canonical string,
15
- * so it is caught structurally no new regex required. Err toward
16
- * over-matching: this is a security guard, a false-positive is cheap, a
12
+ * into ONE canonical slash-form. CP1 is matched against that canonical form;
13
+ * the four DOT-anchored host/IP rules (CP2, CP3, CP7, CP8) are matched against
14
+ * the raw line AND the canonical form (#1080). The remaining rules stay RAW by
15
+ * measurement, not oversight see the `canon` comment in the line loop for why
16
+ * the slash-anchored (CP4, CP10) and slug-anchored (CP6) rules must not be
17
+ * canonicalized. A novel encoding of the same path normalizes to the same
18
+ * canonical string, so it is caught structurally — no new regex required. Err
19
+ * toward over-matching: this is a security guard, a false-positive is cheap, a
17
20
  * false-negative ships a leak to the public mirror.
18
21
  *
19
22
  * Usage: check-owner-leakage.mjs <plugin-root>
@@ -65,6 +68,13 @@
65
68
  * (a real string-literal events.gotzendorfer.at elsewhere in that file still FAILs)
66
69
  * 6. tests/scripts/export-hw-learnings.test.mjs — exempt from P8 ONLY: the RFC1918
67
70
  * IPs there are the redaction subject of the anonymizeString suite, not leaks.
71
+ * 7. The public-site URL in the three PUBLISHED site pages (site/index.html,
72
+ * site/impressum/index.html, site/datenschutz/index.html) — the domain is a
73
+ * legally mandated publication (Impressum, §5 ECG) plus the JSON-LD
74
+ * publisher/author identity and the rel="author" footer link. Like every
75
+ * exclusion above it is LINE-SCOPED and reached only via isAllowlisted(), which
76
+ * only CP3 and CP7 consult — CP1/CP2/CP4/CP5/CP6/CP8/CP10/CP11 still fail on
77
+ * these files, on the very same line. (#1076)
68
78
  *
69
79
  * Exit codes:
70
80
  * 0 — all checks passed
@@ -434,6 +444,41 @@ const CP6_INPROCESS_PATTERNS = PRIVATE_SLUGS
434
444
  /** CP7: catch-all gotzendorfer.at (must not match allowlist) */
435
445
  const CP7 = /gotzendorfer\.at/;
436
446
 
447
+ /**
448
+ * GLOBAL twins of the two allowlist-consulting domain rules, used to count
449
+ * OCCURRENCES in the raw vs the canonical form of a line (#1080 Finding A).
450
+ *
451
+ * Why an occurrence COUNT and not simply a second `.test()` on the canonical
452
+ * form: CP3 and CP7 are the only rules whose verdict is filtered by
453
+ * isAllowlisted(), and that allowlist matches a LINE FORM — the sanctioned
454
+ * Impressum/author URL, and the exact events doc-comment contract line.
455
+ * Canonicalization folds every `-` run to `/`, which mangles precisely that
456
+ * prose: ` * - No literal ...` canonicalizes to ` * / No literal ...`. So
457
+ * re-running isAllowlisted() against the canonical form would fail to recognise
458
+ * its own sanctioned line and turn this gate — and with it .husky/pre-commit —
459
+ * permanently red on a clean tree. Measured, not reasoned: that exact exclusion
460
+ * regex returns true on the raw line and false on its canonical form.
461
+ *
462
+ * The occurrence count separates the two cases without touching the allowlist:
463
+ * when the canonical form carries MORE domain tokens than the raw line, the
464
+ * surplus was produced by DECODING an escape, and a decoded token can never be
465
+ * the sanctioned publication the allowlist covers — so it bypasses the allowlist.
466
+ * When the counts are equal, the rule behaves exactly as it always did and
467
+ * consults the allowlist on the raw line.
468
+ */
469
+ const CP3_G = /\bevents\.gotzendorfer\.at\b/g;
470
+ const CP7_G = /gotzendorfer\.at/g;
471
+
472
+ /**
473
+ * Count matches of a GLOBAL regex in `s` (0 when none).
474
+ * @param {RegExp} re a regex carrying the `g` flag
475
+ * @param {string} s
476
+ * @returns {number}
477
+ */
478
+ function countMatches(re, s) {
479
+ return (s.match(re) || []).length;
480
+ }
481
+
437
482
  /**
438
483
  * CP8: full RFC1918 private dotted-quad — internal-IP leak.
439
484
  * Matches only literal 4-octet private IPs (10.x.x.x, 192.168.x.x, 172.16-31.x.x).
@@ -511,6 +556,29 @@ function escapeRegex(s) {
511
556
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
512
557
  }
513
558
 
559
+ // DELIBERATE DUPLICATE — do not replace the function below with an import from
560
+ // `scripts/lib/redact-spans.mjs`. This scanner is a documented STANDALONE
561
+ // SINGLE-FILE vendoring target (`.claude/rules/security.md` § "Owner-Privacy
562
+ // Pre-Commit Hook": consumer repos copy exactly this ONE file into their tree as
563
+ // a pre-commit stage). A static import resolves in-tree but throws
564
+ // ERR_MODULE_NOT_FOUND in every vendored copy — under the husky stage that means
565
+ // empty stdout + exit 1 on a CLEAN tree, i.e. every commit blocked. Not
566
+ // hypothetical: `tests/husky/pre-commit-owner-leakage.test.mjs` cpSync()s this
567
+ // single file into a tmp repo, and the static-import variant turned all three of
568
+ // its cases red (#974).
569
+ //
570
+ // The dynamic-import degrade used by getConfidentialNamePatterns() is NOT
571
+ // available here. That helper degrades to `[]` — CP11 goes inert, CP1–CP10 keep
572
+ // running, nothing leaks. A failed REDACTION has the opposite failure direction:
573
+ // it prints confidential names verbatim into a PUBLIC GitHub-Actions log, which
574
+ // is precisely the exposure this function exists to prevent (Fix 1 below). The
575
+ // redaction sink must be unconditionally present, so it lives inline.
576
+ //
577
+ // `scripts/lib/redact-spans.mjs` is the shared primitive for IN-TREE consumers
578
+ // (e.g. `scripts/lib/secret-masker.mjs`); this copy serves the vendored path.
579
+ // The two are pinned byte-for-byte against each other by the drift guard in
580
+ // `tests/lib/redact-spans.test.mjs` — change one and that suite goes red until
581
+ // both agree again.
514
582
  /**
515
583
  * Redact every confidential-name span from `line`, ORDER-INDEPENDENTLY (Fix 1 + Fix 2).
516
584
  *
@@ -622,7 +690,47 @@ async function getConfidentialNamePatterns() {
622
690
  // ---------------------------------------------------------------------------
623
691
  // Text-scan extension allowlist (spec A.2)
624
692
  // ---------------------------------------------------------------------------
625
- const TEXT_EXTS = new Set(['.md', '.mjs', '.js', '.ts', '.json', '.yml', '.yaml', '.sh', '.txt']);
693
+ // '.html' (#1076): the site/ tree is PUBLICLY SHIPPED (vercel.json outputDirectory:
694
+ // "site"), so it is the highest-consequence class to scan, yet it was ungated here and
695
+ // therefore invisible to all eleven CP rules.
696
+ //
697
+ // CORRECTION (#1080 Finding A). An earlier revision of this comment justified '.html'
698
+ // partly by claiming its addition "revives canonicalizeLine()'s HTML-entity decoding,
699
+ // which was dead in practice". That was true of exactly ONE of the eleven rules.
700
+ // matchOwnerPath() (CP1) was the SOLE consumer of the canonical form — CP2-CP8, CP10
701
+ // and CP11 each tested the RAW line — so an entity-encoded private host inside an href
702
+ // (a link the BROWSER resolves and the scanner did not) still reported nothing. The
703
+ // per-line canonical re-test in the scan loop below is what actually closes that, and
704
+ // only for the four dot-anchored rules; see the `canon` comment there for why the rest
705
+ // stay raw.
706
+ //
707
+ // '.xml', '.svg', '.css' (#1080 Finding B): the PUBLICLY-SHIPPED sentence above is true
708
+ // VERBATIM of site/sitemap.xml and site/favicon.svg — same directory, same publication,
709
+ // same consequence — which the '.html' addition walked past. Measured with one identical
710
+ // planted defect per class before adding: the .html and .txt copies FAILed, the .xml and
711
+ // .svg copies reported nothing. Cost is zero: the five tracked files in these classes
712
+ // (assets/icon.svg, assets/og-card.svg, site/favicon.svg, site/sitemap.xml,
713
+ // templates/static-html/styles.css) carry no hits — 1542 -> 1547 scanned, 0 findings.
714
+ // `.jsonl` closes a gap this very wave opened: the harvested golden-record fixture at
715
+ // tests/lib/vault-mirror/fixtures/golden-sessions.jsonl is tracked production data that
716
+ // `.json` does not match, so the scanner would have skipped it for good. Measured before
717
+ // adding: all 7 tracked `.jsonl` files pass (1525 -> 1532 scanned, 0 findings).
718
+ const TEXT_EXTS = new Set([
719
+ '.md',
720
+ '.mjs',
721
+ '.js',
722
+ '.ts',
723
+ '.json',
724
+ '.jsonl',
725
+ '.yml',
726
+ '.yaml',
727
+ '.sh',
728
+ '.txt',
729
+ '.html',
730
+ '.xml',
731
+ '.svg',
732
+ '.css',
733
+ ]);
626
734
 
627
735
  // Dotfiles to include (checked by basename, BEFORE the extension gate —
628
736
  // extname('.env.example') is '.example' (truthy), so an extension-first check
@@ -723,16 +831,33 @@ function isAllowlisted(relPath, line) {
723
831
  '.claude-plugin/plugin.json',
724
832
  '.claude-plugin/marketplace.json',
725
833
  '.codex-plugin/plugin.json',
834
+ // A.4 exclusion 7 (#1076): the three PUBLISHED static pages (vercel.json
835
+ // outputDirectory: "site"). Rule-based, never file-based — membership here only
836
+ // buys the line-form test below, and isAllowlisted() is consulted by CP3 and CP7
837
+ // ONLY. A SELF_EXCLUSIONS entry would instead have switched off all eleven rules
838
+ // for these files; that broad form is deliberately NOT used.
839
+ 'site/index.html',
840
+ 'site/impressum/index.html',
841
+ 'site/datenschutz/index.html',
726
842
  ]);
727
843
  const inAllowlistedFile = ALLOWLISTED_URL_PATHS.has(norm);
728
844
 
729
845
  if (inAllowlistedFile) {
730
846
  // Check that the only gotzendorfer.at occurrences on this line are sanctioned URLs or emails
731
847
  const SANCTIONED_URL = /https?:\/\/gotzendorfer\.at\b/g;
848
+ // The published site uses the www. host throughout, which SANCTIONED_URL cannot
849
+ // match — it requires the bare domain IMMEDIATELY after the scheme. Without this
850
+ // second form the three site entries above would exclude NOTHING and the gate
851
+ // (including .husky/pre-commit) would be permanently red. The two forms are
852
+ // DISJOINT: the www. prefix is mandatory here and impossible there, so no line
853
+ // can be counted twice (README.md stays at 1 token / 1 sanctioned match).
854
+ const SANCTIONED_PUBLIC_SITE = /(?:https?:\/\/)?www\.gotzendorfer\.at\b/g;
732
855
  const allGotzOnLine = [...line.matchAll(/gotzendorfer\.at/g)];
733
856
  const sanctionedUrlMatches = [...line.matchAll(SANCTIONED_URL)];
857
+ const sanctionedSiteMatches = [...line.matchAll(SANCTIONED_PUBLIC_SITE)];
734
858
  const emailMatches = [...line.matchAll(SANCTIONED_EMAILS)];
735
- const totalSanctioned = sanctionedUrlMatches.length + emailMatches.length;
859
+ const totalSanctioned =
860
+ sanctionedUrlMatches.length + sanctionedSiteMatches.length + emailMatches.length;
736
861
  if (allGotzOnLine.length > 0 && allGotzOnLine.length === totalSanctioned) {
737
862
  return true;
738
863
  }
@@ -781,8 +906,6 @@ const textFiles = allFiles.filter(isTextFile);
781
906
  // - .orchestrator/audits/** never scanned (A.2/A.4-5)
782
907
  // - This guard's own source file (pattern-doc-comments define the scanner — not leaks).
783
908
  // - This guard's own test file (string-literal fixtures exercise the detector — not leaks).
784
- // - Persona content-lint tests (assert template files don't contain leakage strings;
785
- // the assertion literals themselves match the scanner regex — fixtures, not leaks).
786
909
  // Self-exclusions are the design-time fix for the latent bug exposed when scanner
787
910
  // fixture files transition from untracked → tracked in the same commit that tightens
788
911
  // detection (commit a68e94f for the original two; commit 95c8237 deep-3 W4 added the
@@ -791,7 +914,9 @@ const textFiles = allFiles.filter(isTextFile);
791
914
  const SELF_EXCLUSIONS = new Set([
792
915
  'scripts/lib/validate/check-owner-leakage.mjs',
793
916
  'tests/lib/validate/check-owner-leakage.test.mjs',
794
- 'tests/templates/personas/content-lint.test.mjs',
917
+ // content-lint.test.mjs entry removed with the file itself (#985 Tier A,
918
+ // 2026-08-05) — a dangling exclusion would pre-authorize any future file
919
+ // at that path to bypass this scanner.
795
920
  'tests/husky/pre-commit-owner-leakage.test.mjs',
796
921
  // #634: encoding-contract fixtures (`-Users-bernhardg-` expected-value literals
797
922
  // are load-bearing for the resolveMemoryDir() assertions; P9 would self-flag them)
@@ -839,6 +964,33 @@ for (const filePath of scanFiles) {
839
964
  lines.forEach((line, idx) => {
840
965
  const lineNum = idx + 1;
841
966
 
967
+ // The canonical form of this line, computed ONCE (#1080 Finding A). Until now
968
+ // matchOwnerPath (CP1) was the ONLY consumer of canonicalizeLine; every other rule
969
+ // tested the raw line, so an entity-encoded private host inside an href resolved in
970
+ // the browser and reported nothing here. The four DOT-anchored rules (CP2, CP3, CP7,
971
+ // CP8) are re-tested against it below.
972
+ //
973
+ // Why exactly those four, and not every rule — the discriminator is which character
974
+ // the rule ANCHORS on, and it was measured, not assumed:
975
+ // - Canonicalization can FABRICATE a '/' out of any benign hyphen run
976
+ // (`@goetzendorfer-team` -> `@goetzendorfer/team`, `~-Projects-Bernhard` ->
977
+ // `~/Projects/Bernhard`; both flip false -> true under canon). So the
978
+ // SLASH-anchored rules CP4 and CP10 would gain folding artifacts, and stay raw.
979
+ // - It can never fabricate a '.' from a separator: `gitlab-gotzendorfer-at`
980
+ // canonicalizes to `gitlab/gotzendorfer/at`, dot-free. A '.' appears in the
981
+ // canonical form ONLY by decoding an escape — which is the evasion itself. So for
982
+ // the DOT-anchored rules (CP2, CP3, CP7, CP8) every canonical-only hit is a
983
+ // decoded evasion by construction, and the false-positive surface is empty.
984
+ // - CP6 stays raw for the mirror-image reason: it anchors on `\b`-delimited slug
985
+ // literals, and the dash folding SHREDS five of the seven slugs
986
+ // (`mail-assistant` -> `mail/assistant`, which `\bmail-assistant\b` no longer
987
+ // matches), so a canonical test there is a no-op at best.
988
+ // - CP5 anchors on an identifier and CP11 on host-local names; neither is a path or
989
+ // host spelling, so neither is re-tested.
990
+ // Repo-wide cost of the four, measured over 1547 tracked files before landing: 0 new
991
+ // findings.
992
+ const canon = canonicalizeLine(line);
993
+
842
994
  // CP1: personal home path — CANONICALIZED match (#661). One structural rule
843
995
  // replaces the slash-form (P1, #631) + dash-encoded (P9, #634) treadmill and
844
996
  // catches percent/unicode/backslash/homoglyph encodings of the same path.
@@ -847,14 +999,22 @@ for (const filePath of scanFiles) {
847
999
  violations.push({ relPath, lineNum, pattern: ownerPathHit, lineContent: line.trim() });
848
1000
  }
849
1001
 
850
- // CP2: private GitLab host
851
- if (CP2.test(line)) {
1002
+ // CP2: private GitLab host — raw OR canonical (#1080). ONE `if` per rule, so a
1003
+ // line matching BOTH forms still reports exactly ONE violation. (The pre-existing
1004
+ // 'CP2: private GitLab host' scan row, which expects fails: 2 for the CP2+CP7 pair,
1005
+ // is the standing guard against a double-count regression here.) CP2 consults no
1006
+ // allowlist, so the raw/canonical split CP3 and CP7 need does not arise.
1007
+ if (CP2.test(line) || CP2.test(canon)) {
852
1008
  violations.push({ relPath, lineNum, pattern: 'CP2 (gitlab.gotzendorfer.at)', lineContent: line.trim() });
853
1009
  }
854
1010
 
855
- // CP3: private events domain — check exclusion 6 first
856
- if (CP3.test(line)) {
857
- if (!isAllowlisted(relPath, line)) {
1011
+ // CP3: private events domain — the raw form consults the exclusion allowlist
1012
+ // exactly as before; a DECODED surplus occurrence (canonical count > raw count)
1013
+ // bypasses it, because an encoded spelling is never the sanctioned publication.
1014
+ // See CP3_G / CP7_G for why this is a count and not isAllowlisted(relPath, canon).
1015
+ const cp3Decoded = countMatches(CP3_G, canon) > countMatches(CP3_G, line);
1016
+ if (CP3.test(line) || cp3Decoded) {
1017
+ if (cp3Decoded || !isAllowlisted(relPath, line)) {
858
1018
  violations.push({ relPath, lineNum, pattern: 'CP3 (events.gotzendorfer.at)', lineContent: line.trim() });
859
1019
  }
860
1020
  }
@@ -879,15 +1039,23 @@ for (const filePath of scanFiles) {
879
1039
  }
880
1040
  }
881
1041
 
882
- // CP7: catch-all gotzendorfer.at — check exclusion allowlist
883
- if (CP7.test(line)) {
884
- if (!isAllowlisted(relPath, line)) {
1042
+ // CP7: catch-all gotzendorfer.at — same split as CP3: the raw form consults the
1043
+ // exclusion allowlist, a decoded surplus occurrence bypasses it. This is the arm
1044
+ // that catches the sharpest shape of #1080 Finding A — a WORKING link on a published
1045
+ // page, `<a href="https://gitlab&#46;gotzendorfer&#46;at/...">`, which the browser
1046
+ // resolves and the raw-only rule read as ordinary text.
1047
+ const cp7Decoded = countMatches(CP7_G, canon) > countMatches(CP7_G, line);
1048
+ if (CP7.test(line) || cp7Decoded) {
1049
+ if (cp7Decoded || !isAllowlisted(relPath, line)) {
885
1050
  violations.push({ relPath, lineNum, pattern: 'CP7 (gotzendorfer.at catch-all)', lineContent: line.trim() });
886
1051
  }
887
1052
  }
888
1053
 
889
- // CP8: full RFC1918 private dotted-quad — internal IP leak (redaction-test fixtures exempt)
890
- if (CP8.test(line) && !CP8_ALLOWLIST.has(relPath)) {
1054
+ // CP8: full RFC1918 private dotted-quad — internal IP leak (redaction-test fixtures
1055
+ // exempt). Raw OR canonical: `10&#46;11&#46;12&#46;13` decodes to a literal quad.
1056
+ // The allowlist here is PATH-scoped, not line-scoped, so it needs no raw/canonical
1057
+ // split — it applies identically to both forms.
1058
+ if ((CP8.test(line) || CP8.test(canon)) && !CP8_ALLOWLIST.has(relPath)) {
891
1059
  violations.push({ relPath, lineNum, pattern: 'CP8 (RFC1918 private IP)', lineContent: line.trim() });
892
1060
  }
893
1061
 
@@ -23,6 +23,13 @@
23
23
  // identical `hasEmptyGlobs` fix below.
24
24
  // (b) learning-key must be present (traceability back to the emitter source).
25
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.
26
33
  //
27
34
  // (2) HANDWRITTEN rules (no `auto-generated: true` — #880 FA5, WARN-only,
28
35
  // NEVER affects the exit code). The auto-generated brandmauer above only
@@ -154,9 +161,9 @@ if (mdFiles.length === 0) {
154
161
  }
155
162
 
156
163
  // 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).
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.
160
167
  const autoGeneratedEntries = [];
161
168
  const handwrittenEntries = [];
162
169
 
@@ -173,8 +180,27 @@ for (const name of mdFiles.sort()) {
173
180
  let parsed;
174
181
  try {
175
182
  parsed = parseGlobsFrontmatter(contents);
176
- } catch {
177
- // Malformed frontmatter not auditable by either branch, 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
+ );
178
204
  continue;
179
205
  }
180
206
 
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ // check-skills.mjs — Validate the frontmatter contract of every skills/<name>/SKILL.md.
3
+ // Usage: check-skills.mjs <plugin-root>
4
+ // Outputs lines of the form " PASS: ..." / " FAIL: ..."
5
+ // Exit 0 = all checks passed; exit 1 = at least one failure.
6
+ //
7
+ // WHY A REAL PARSER: the sibling check-agents.mjs validates agent frontmatter with
8
+ // line-oriented regexes. That is exactly why 12 of 46 SKILL.md frontmatter blocks
9
+ // could sit unparseable in the tree unnoticed — an unquoted single-line
10
+ // `description:` containing a `: ` (e.g. "Iron Law: NO FIXES") is not YAML, but a
11
+ // regex that only looks for `^description:` sees nothing wrong. Claude Code's own
12
+ // loader is lenient, so the defect was latent rather than visible. This check
13
+ // follows check-commands.mjs instead: a real js-yaml CORE_SCHEMA parse (R8) is the
14
+ // rule the other five hang off, because none of the field rules can be evaluated
15
+ // on a block that does not parse.
16
+ //
17
+ // DELIBERATELY NOT IMPLEMENTED — see the report for #<wave>:
18
+ // * A block-scalar ban. check-agents.mjs bans `description: >` for agents/*.md
19
+ // because the agent loader cannot read it. For SKILL.md the sign is REVERSED:
20
+ // the folded block scalar is the only form that makes the `: ` collision above
21
+ // structurally impossible, 23 of 46 files already use it, and the 12 repairs
22
+ // that made this check green all landed on it. Porting the agent rule here
23
+ // would red 35 of 46 files and forbid the fix.
24
+ // * Length ceilings on `name` / `description`. No spec vendored in this repo
25
+ // states one, and an invented requirement is worse than none. (Measured
26
+ // 2026-08-15 at the repairing commit: longest name 22 chars, longest
27
+ // description 1012 chars — a hypothetical 1024 ceiling would sit 12 chars
28
+ // above the live corpus and go red on the next sentence added.)
29
+
30
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
31
+ import { join } from 'node:path';
32
+ import yaml from 'js-yaml';
33
+
34
+ import { extractInitialFrontmatter } from './frontmatter-block.mjs';
35
+
36
+ const [, , pluginRoot] = process.argv;
37
+
38
+ if (!pluginRoot) {
39
+ console.error('Usage: check-skills.mjs <plugin-root>');
40
+ process.exit(1);
41
+ }
42
+
43
+ const SKILLS_DIR_NAME = 'skills';
44
+ const SKILL_FILE = 'SKILL.md';
45
+ const KEBAB_CASE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
46
+
47
+ let passed = 0;
48
+ let failed = 0;
49
+
50
+ function pass(msg) {
51
+ console.log(` PASS: ${msg}`);
52
+ passed++;
53
+ }
54
+
55
+ function fail(msg) {
56
+ console.log(` FAIL: ${msg}`);
57
+ failed++;
58
+ }
59
+
60
+ /**
61
+ * Enumerate `skills/<dir>/SKILL.md` one level below the skills root.
62
+ *
63
+ * Depth is capped at one level on purpose: R5 (name === directory name) is only
64
+ * meaningful for a skill that owns its immediate directory. Measured 2026-08-15:
65
+ * `find skills -mindepth 3 -name SKILL.md` returns 0, so the cap loses nothing
66
+ * today. Revisit if nested skill packages ever land.
67
+ *
68
+ * @param {string} skillsDir - absolute path to the skills root
69
+ * @returns {Array<{ dirName: string, relPath: string, absPath: string }>} sorted by directory name
70
+ */
71
+ function collectSkillFiles(skillsDir) {
72
+ const found = [];
73
+ for (const dirName of readdirSync(skillsDir).sort()) {
74
+ const dirPath = join(skillsDir, dirName);
75
+ let isDir;
76
+ try {
77
+ isDir = statSync(dirPath).isDirectory();
78
+ } catch {
79
+ isDir = false;
80
+ }
81
+ if (!isDir) continue;
82
+
83
+ const absPath = join(dirPath, SKILL_FILE);
84
+ if (!existsSync(absPath)) continue;
85
+
86
+ found.push({ dirName, relPath: `${SKILLS_DIR_NAME}/${dirName}/${SKILL_FILE}`, absPath });
87
+ }
88
+ return found;
89
+ }
90
+
91
+ /**
92
+ * Apply the frontmatter contract to one SKILL.md.
93
+ *
94
+ * Rules: R1 frontmatter block present · R8 block parses as a YAML mapping ·
95
+ * R2 `name` present · R3 `name` is kebab-case · R5 `name` === directory name ·
96
+ * R6 `description` present and non-empty.
97
+ *
98
+ * @param {{ dirName: string, relPath: string, absPath: string }} skill
99
+ * @returns {boolean} true when every rule holds
100
+ */
101
+ function validateSkillFrontmatter(skill) {
102
+ const before = failed;
103
+ const content = readFileSync(skill.absPath, 'utf8');
104
+
105
+ // R1 — frontmatter block present.
106
+ const extracted = extractInitialFrontmatter(content);
107
+ if (!extracted.ok) {
108
+ fail(`${skill.relPath}: ${extracted.diagnostic}`);
109
+ return false;
110
+ }
111
+
112
+ // R8 — the block is valid YAML. Everything below depends on this holding.
113
+ let frontmatter;
114
+ try {
115
+ frontmatter = yaml.load(extracted.yamlText, { schema: yaml.CORE_SCHEMA });
116
+ } catch (error) {
117
+ const reason = error?.reason ?? error?.message ?? String(error);
118
+ const location = error?.mark
119
+ ? ` at frontmatter line ${error.mark.line + 1}, column ${error.mark.column + 1}`
120
+ : '';
121
+ fail(`${skill.relPath}: invalid YAML frontmatter: ${reason}${location}`);
122
+ return false;
123
+ }
124
+
125
+ if (frontmatter === null || typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
126
+ fail(`${skill.relPath}: YAML frontmatter must be a non-null mapping/object`);
127
+ return false;
128
+ }
129
+
130
+ // R2 — name present.
131
+ const name = frontmatter.name;
132
+ if (typeof name !== 'string' || name.trim() === '') {
133
+ fail(`${skill.relPath}: missing or empty required frontmatter field: name`);
134
+ } else {
135
+ // R3 — name is kebab-case.
136
+ if (!KEBAB_CASE.test(name)) {
137
+ fail(`${skill.relPath}: name must be kebab-case (^[a-z0-9]+(-[a-z0-9]+)*$), got: '${name}'`);
138
+ }
139
+ // R5 — name matches the directory that owns the skill.
140
+ if (name !== skill.dirName) {
141
+ fail(`${skill.relPath}: name '${name}' does not match its directory '${skill.dirName}'`);
142
+ }
143
+ }
144
+
145
+ // R6 — description present and non-empty.
146
+ const description = frontmatter.description;
147
+ if (typeof description !== 'string' || description.trim() === '') {
148
+ fail(`${skill.relPath}: missing or empty required frontmatter field: description`);
149
+ }
150
+
151
+ return failed === before;
152
+ }
153
+
154
+ // ============================================================================
155
+ // Check: skill frontmatter (SKILL.md)
156
+ // ============================================================================
157
+ console.log('--- Check: skill frontmatter (SKILL.md) ---');
158
+
159
+ const skillsDir = join(pluginRoot, SKILLS_DIR_NAME);
160
+
161
+ if (!existsSync(skillsDir)) {
162
+ fail(`skills directory not found at conventional location: ./${SKILLS_DIR_NAME}`);
163
+ } else {
164
+ let skills;
165
+ try {
166
+ skills = collectSkillFiles(skillsDir);
167
+ } catch (error) {
168
+ skills = [];
169
+ fail(`skills directory is unreadable: ${error?.message ?? String(error)}`);
170
+ }
171
+
172
+ if (skills.length === 0) {
173
+ fail(`skills directory contains no ${SKILL_FILE} files`);
174
+ } else {
175
+ pass(`skills directory contains ${skills.length} ${SKILL_FILE} files`);
176
+
177
+ let valid = 0;
178
+ for (const skill of skills) {
179
+ if (validateSkillFrontmatter(skill)) valid++;
180
+ }
181
+
182
+ if (valid === skills.length) {
183
+ pass(`all ${skills.length} ${SKILL_FILE} frontmatter blocks parse as YAML and carry a kebab-case name matching their directory plus a non-empty description`);
184
+ }
185
+ }
186
+ }
187
+
188
+ console.log('');
189
+ console.log(`Results: ${passed} passed, ${failed} failed`);
190
+
191
+ process.exit(failed > 0 ? 1 : 0);