session-orchestrator 3.20.0 → 3.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/000-session-orchestrator.mdc +3 -2
- package/.cursor/rules/030-wave-execution.mdc +10 -8
- package/.cursor/rules/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +8 -8
- package/CHANGELOG.md +515 -0
- package/README.md +16 -11
- package/agents/analyst.md +1 -1
- package/agents/architect-reviewer.md +1 -1
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +1 -1
- package/agents/dialectic-deriver.md +1 -1
- package/agents/docs-writer.md +1 -1
- package/agents/memory-proposal-collector.md +7 -5
- package/agents/qa-strategist.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +42 -1
- package/agents/skill-applied-judge.md +1 -1
- package/agents/test-writer.md +1 -1
- package/agents/ui-developer.md +1 -1
- package/agents/ux-evaluator.md +1 -1
- package/commands/eli5.md +33 -0
- package/commands/release.md +62 -0
- package/commands/test.md +2 -2
- package/docs/components.md +6 -5
- package/docs/migration-v3.md +9 -6
- package/docs/persona-panel.md +3 -1
- package/docs/scope-collision-guard.md +167 -0
- package/docs/session-config-reference.md +31 -8
- package/hooks/_lib/lock-bootstrap.mjs +19 -13
- package/hooks/enforce-scope.mjs +103 -3
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +76 -97
- package/hooks/on-session-start.mjs +195 -104
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +17 -18
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +3 -1
- package/pi/prompts/eli5.md +12 -0
- package/pi/prompts/release.md +12 -0
- package/scripts/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +10 -9
- package/scripts/emit-session.mjs +42 -0
- package/scripts/export-hw-learnings.mjs +61 -2
- package/scripts/lib/auq/clarity.mjs +1314 -0
- package/scripts/lib/auq/parse.mjs +1006 -0
- package/scripts/lib/auq/schema.mjs +1457 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/backlog-scan.mjs +106 -15
- package/scripts/lib/build-live-signals.mjs +7 -3
- package/scripts/lib/ci-status-banner.mjs +267 -77
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
- package/scripts/lib/config/vault-integration.mjs +12 -1
- package/scripts/lib/dispatcher/rank.mjs +4 -7
- package/scripts/lib/gates/gate-full.mjs +3 -3
- package/scripts/lib/gates/gate-helpers.mjs +17 -6
- package/scripts/lib/git-config-drift.mjs +471 -0
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +432 -7
- package/scripts/lib/issue-budget.mjs +63 -9
- package/scripts/lib/learnings/select.mjs +157 -3
- package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
- package/scripts/lib/mirror-issues-banner.mjs +266 -0
- package/scripts/lib/named-vault-resolver.mjs +105 -16
- package/scripts/lib/owner-interview.mjs +78 -32
- package/scripts/lib/peer-cards/schema.mjs +6 -2
- package/scripts/lib/peer-discovery.mjs +73 -22
- package/scripts/lib/project-hygiene.mjs +64 -4
- package/scripts/lib/reconcile/renderer.mjs +17 -4
- package/scripts/lib/reconcile/writer.mjs +69 -30
- package/scripts/lib/redact-spans.mjs +89 -0
- package/scripts/lib/resource-probe/evaluate.mjs +330 -149
- package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
- package/scripts/lib/resource-probe.mjs +18 -2
- package/scripts/lib/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +658 -0
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-registry.mjs +9 -1
- package/scripts/lib/spiral-carryover.mjs +23 -2
- package/scripts/lib/state-md/mission-status.mjs +164 -58
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
- package/scripts/lib/validate/check-commands.mjs +2 -20
- package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
- package/scripts/lib/validate/check-rules.mjs +153 -9
- package/scripts/lib/validate/check-skills.mjs +191 -0
- package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
- package/scripts/lib/validate/check-unwired-features.mjs +219 -11
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
- package/scripts/lib/validate/frontmatter-block.mjs +61 -0
- package/scripts/lib/validate/tier-inference.mjs +46 -8
- package/scripts/lib/vault-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -8
- package/scripts/lib/vault-mirror/namespace.mjs +146 -1
- package/scripts/lib/vault-mirror/process.mjs +264 -31
- package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
- package/scripts/lib/vault-status/board-writer.mjs +300 -56
- package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
- package/scripts/lib/vcs-repo-spec.mjs +680 -30
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/materialize-wave-scope.mjs +281 -0
- package/scripts/print-learnings-index.mjs +30 -3
- package/scripts/release.mjs +983 -107
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/validate-plugin.mjs +64 -0
- package/scripts/validate-wave-scope.mjs +286 -12
- package/scripts/vault-backfill.mjs +32 -5
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/parallel-aware-auq.md +30 -24
- package/skills/_shared/parallel-aware-preamble.md +31 -2
- package/skills/_shared/state-ownership.md +49 -6
- package/skills/bootstrap/SKILL.md +2 -1
- package/skills/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- package/skills/discovery/SKILL.md +28 -24
- package/skills/discovery/issue-templates.md +4 -4
- package/skills/discovery/probes-code.md +2 -2
- package/skills/discovery/probes-feature.md +6 -6
- package/skills/discovery/probes-infra.md +2 -2
- package/skills/discovery/probes-session.md +5 -5
- package/skills/dispatcher/SKILL.md +10 -1
- package/skills/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +73 -59
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/memory-cleanup/SKILL.md +20 -7
- package/skills/npm-publish/SKILL.md +23 -51
- package/skills/peekaboo-driver/SKILL.md +3 -3
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +42 -12
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +3 -3
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +97 -22
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +37 -2
- package/skills/session-end/session-metrics-write.md +4 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +82 -36
- package/skills/session-start/phase-2-5-docs-planning.md +8 -8
- package/skills/session-start/phase-4-5-resource-health.md +82 -19
- package/skills/session-start/soul.md +110 -0
- package/skills/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/tmux-layout/SKILL.md +7 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- package/skills/vault-mirror/SKILL.md +10 -1
- package/skills/vault-sync/SKILL.md +10 -1
- package/skills/vault-sync/validator.mjs +55 -6
- package/skills/wave-executor/wave-loop.md +64 -12
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/scripts/lib/mission-status-schema.mjs +0 -114
- package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
- package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
- package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
|
@@ -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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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 —
|
|
856
|
-
|
|
857
|
-
|
|
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 —
|
|
883
|
-
|
|
884
|
-
|
|
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.gotzendorfer.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
|
|
890
|
-
|
|
1054
|
+
// CP8: full RFC1918 private dotted-quad — internal IP leak (redaction-test fixtures
|
|
1055
|
+
// exempt). Raw OR canonical: `10.11.12.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
|
|
|
@@ -30,6 +30,51 @@
|
|
|
30
30
|
// parse error as always-on with empty meta, so the skipped file is
|
|
31
31
|
// exactly the one that loads everywhere and clears every gate. See
|
|
32
32
|
// the inline rationale at the parse site below.
|
|
33
|
+
// (e) HARNESS-PARITY (#1108): a rule that expresses a path restriction
|
|
34
|
+
// must express it in `paths:`, because `paths:` is the ONLY key
|
|
35
|
+
// Claude Code's own rule loader reads. Its documentation
|
|
36
|
+
// (code.claude.com/docs/en/memory § "Path-specific rules") is
|
|
37
|
+
// explicit: rules are scoped "using YAML frontmatter with the
|
|
38
|
+
// `paths` field", and "rules without a `paths` field are loaded
|
|
39
|
+
// unconditionally and apply to all files". `globs:` is the CURSOR
|
|
40
|
+
// field name. A `globs:`-only rule is therefore scoped for
|
|
41
|
+
// rule-loader.mjs (#795 alias) and for Cursor, yet ALWAYS-ON for
|
|
42
|
+
// Claude Code — the rule *looks* scoped everywhere it is inspected
|
|
43
|
+
// and is silently loaded everywhere it is used.
|
|
44
|
+
// Measured 2026-08-22 before the fix: 16 of 31 rule files carried
|
|
45
|
+
// `globs:` and 0 carried `paths:`, so all 31 loaded unconditionally
|
|
46
|
+
// — 186,993 bytes ≈ 46,700 tokens per dispatch, 72,195 of them
|
|
47
|
+
// unwanted (testing.md alone is 36,431 bytes and carries
|
|
48
|
+
// `tier: wave-only`, i.e. is explicitly meant to be conditional).
|
|
49
|
+
// This check is the recurrence guard for that fix, not the fix.
|
|
50
|
+
// It is formulated as a PARITY check rather than a presence check,
|
|
51
|
+
// which folds two defects into one invariant: the rule must load in
|
|
52
|
+
// the SAME contexts under Claude Code's loader (which reads
|
|
53
|
+
// `paths:`, treating absence as always-on) as under rule-loader.mjs
|
|
54
|
+
// (which reads `globs:` and falls back to `paths:` — #795, globs
|
|
55
|
+
// wins). Two ways to violate it:
|
|
56
|
+
// - `globs:` present and non-empty, `paths:` ABSENT → always-on
|
|
57
|
+
// in Claude Code.
|
|
58
|
+
// - both present with DIFFERENT pattern sets → Claude Code scopes
|
|
59
|
+
// on one list, rule-loader.mjs and Cursor on the other.
|
|
60
|
+
// Order and duplicates are irrelevant (a glob list is matched
|
|
61
|
+
// any-of), so the comparison is over the sorted unique set.
|
|
62
|
+
// NOT flagged: `paths:` alone. It is the form the native
|
|
63
|
+
// documentation prescribes and the form the primary downstream
|
|
64
|
+
// consumer uses exclusively (projects-baseline: 26 rule files, all
|
|
65
|
+
// `paths:`, 0 `globs:` — rule-loader.mjs module doc). The
|
|
66
|
+
// `globs:`-is-canonical preference for VENDORED rules is a separate,
|
|
67
|
+
// warn-level concern already owned by
|
|
68
|
+
// scripts/lib/validate-vendored-rules.mjs (~:289, issue #742) and is
|
|
69
|
+
// deliberately not duplicated here.
|
|
70
|
+
// NOT flagged either: an EMPTY `globs: []`. That shape is already
|
|
71
|
+
// owned — with an accurate, opposite message — by the dedicated
|
|
72
|
+
// empty-globs branches below (#880 QA Defect 1 / #892), and firing
|
|
73
|
+
// a second, contradictory-sounding finding ("never loads" beside
|
|
74
|
+
// "always-on in Claude Code") on one file is exactly the confusion
|
|
75
|
+
// those fixes went out of their way to remove. The parity check
|
|
76
|
+
// therefore requires a NON-EMPTY `globs:` list; populating it is the
|
|
77
|
+
// prescribed remedy for `globs: []`, at which point parity binds.
|
|
33
78
|
//
|
|
34
79
|
// (2) HANDWRITTEN rules (no `auto-generated: true` — #880 FA5, WARN-only,
|
|
35
80
|
// NEVER affects the exit code). The auto-generated brandmauer above only
|
|
@@ -70,9 +115,10 @@
|
|
|
70
115
|
//
|
|
71
116
|
// Usage: check-rules.mjs <plugin-root>
|
|
72
117
|
// Outputs lines of the form " PASS: ..." / " FAIL: ..." / " WARN: ...".
|
|
73
|
-
// Exit 0 = all AUTO-GENERATED invariants
|
|
74
|
-
//
|
|
75
|
-
//
|
|
118
|
+
// Exit 0 = all AUTO-GENERATED invariants plus the two cohort-independent
|
|
119
|
+
// invariants (d) frontmatter-parses and (e) harness-parity are satisfied (WARN
|
|
120
|
+
// lines from the handwritten check NEVER affect the exit code); exit 1 = at
|
|
121
|
+
// least one FAIL.
|
|
76
122
|
|
|
77
123
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
78
124
|
import { join } from 'node:path';
|
|
@@ -132,6 +178,69 @@ function hasFrontmatterKey(contents, key) {
|
|
|
132
178
|
return Boolean(keyMatch && keyMatch[1] && keyMatch[1].trim() !== '');
|
|
133
179
|
}
|
|
134
180
|
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Per-key list access for the harness-parity check (#1108).
|
|
183
|
+
//
|
|
184
|
+
// `parseGlobsFrontmatter()` deliberately COLLAPSES `globs:` and `paths:` into
|
|
185
|
+
// one value ("callers never see which key produced the value" — rule-loader.mjs
|
|
186
|
+
// module doc, #795 precedence). The parity check needs both lists separately,
|
|
187
|
+
// and re-deriving list parsing here would be a second frontmatter parser — the
|
|
188
|
+
// exact drift class that produced #840. So instead of re-parsing, the OTHER key
|
|
189
|
+
// is MASKED to a name the parser ignores (any key that is neither globs/paths
|
|
190
|
+
// nor in SCALAR_META_KEYS is dropped without error, and its indented ` - x`
|
|
191
|
+
// continuation lines are skipped as "another block's continuation"), and the
|
|
192
|
+
// SAME parser then returns the surviving key's list verbatim — flow style,
|
|
193
|
+
// block style, quote stripping and provenance-header tolerance included.
|
|
194
|
+
//
|
|
195
|
+
// Masking cannot make a parseable file unparseable: it renames a key, so the
|
|
196
|
+
// line keeps its colon. Callers reach this only for files that already parsed.
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
const MASKED_KEY_PREFIX = 'x-check-rules-masked-';
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Returns the value of exactly ONE of the two scope keys, ignoring the other —
|
|
202
|
+
* `null` when that key is absent, `[]` when present but empty.
|
|
203
|
+
*
|
|
204
|
+
* @param {string} contents - raw file contents (must already parse)
|
|
205
|
+
* @param {'globs'|'paths'} key - the key whose list to return
|
|
206
|
+
* @returns {string[] | null}
|
|
207
|
+
*/
|
|
208
|
+
function scopeKeyValue(contents, key) {
|
|
209
|
+
const other = key === 'globs' ? 'paths' : 'globs';
|
|
210
|
+
const masked = contents.replace(
|
|
211
|
+
new RegExp(`^${other}:`, 'gm'),
|
|
212
|
+
`${MASKED_KEY_PREFIX}${other}:`,
|
|
213
|
+
);
|
|
214
|
+
return parseGlobsFrontmatter(masked).globs;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Sorted, de-duplicated copy — glob lists are matched any-of, so neither
|
|
218
|
+
* order nor repetition changes which files a rule applies to. */
|
|
219
|
+
function normalizePatterns(list) {
|
|
220
|
+
return [...new Set(list)].sort();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Harness-parity verdict for one rule file (#1108) — see module doc (e).
|
|
225
|
+
*
|
|
226
|
+
* @param {string} contents - raw file contents (must already parse)
|
|
227
|
+
* @returns {{ ok: true } | { ok: false, kind: 'missing-paths'|'divergent', globs: string[], paths: string[] | null }}
|
|
228
|
+
*/
|
|
229
|
+
function checkHarnessParity(contents) {
|
|
230
|
+
const globs = scopeKeyValue(contents, 'globs');
|
|
231
|
+
// Only a NON-EMPTY globs list expresses a path restriction. Absent globs →
|
|
232
|
+
// whatever `paths:` says is what every loader sees. Empty globs → owned by
|
|
233
|
+
// the dedicated empty-globs branches, which prescribe populating it.
|
|
234
|
+
if (!Array.isArray(globs) || globs.length === 0) return { ok: true };
|
|
235
|
+
|
|
236
|
+
const paths = scopeKeyValue(contents, 'paths');
|
|
237
|
+
if (paths === null) return { ok: false, kind: 'missing-paths', globs, paths };
|
|
238
|
+
|
|
239
|
+
const same =
|
|
240
|
+
JSON.stringify(normalizePatterns(globs)) === JSON.stringify(normalizePatterns(paths));
|
|
241
|
+
return same ? { ok: true } : { ok: false, kind: 'divergent', globs, paths };
|
|
242
|
+
}
|
|
243
|
+
|
|
135
244
|
// ============================================================================
|
|
136
245
|
// Check: .claude/rules/ rule invariants
|
|
137
246
|
// ============================================================================
|
|
@@ -207,17 +316,44 @@ for (const name of mdFiles.sort()) {
|
|
|
207
316
|
const { globs, meta } = parsed;
|
|
208
317
|
const rel = `.claude/rules/${name}`;
|
|
209
318
|
|
|
319
|
+
// (e) HARNESS-PARITY (#1108) — cohort-independent, like the parse check
|
|
320
|
+
// above: `paths:` is the only scope key Claude Code's own loader reads, so a
|
|
321
|
+
// `globs:`-only or divergently-scoped rule loads differently there than it
|
|
322
|
+
// does under rule-loader.mjs. Emitted here, in file order, and recorded on
|
|
323
|
+
// the entry so the cohort branches below suppress their PASS line — a file
|
|
324
|
+
// must never both PASS and FAIL.
|
|
325
|
+
const parity = checkHarnessParity(contents);
|
|
326
|
+
if (!parity.ok && parity.kind === 'missing-paths') {
|
|
327
|
+
fail(
|
|
328
|
+
`${rel} — declares globs: (${parity.globs.length} pattern(s)) but no paths: — Claude Code's native rule ` +
|
|
329
|
+
'loader reads ONLY the `paths` frontmatter field and treats a rule without it as unconditional ' +
|
|
330
|
+
'("rules without a paths field are loaded unconditionally and apply to all files" — ' +
|
|
331
|
+
'code.claude.com/docs/en/memory § Path-specific rules). `globs:` is the Cursor field name, so this rule ' +
|
|
332
|
+
'is scoped for rule-loader.mjs and Cursor but loads ALWAYS-ON in Claude Code — a silent instruction-budget ' +
|
|
333
|
+
'failure (#1108). Add a paths: key carrying the same patterns as globs: (keep globs: — it stays the ' +
|
|
334
|
+
'canonical form for vendored rules, validate-vendored-rules.mjs #742).',
|
|
335
|
+
);
|
|
336
|
+
} else if (!parity.ok && parity.kind === 'divergent') {
|
|
337
|
+
fail(
|
|
338
|
+
`${rel} — globs: and paths: declare DIFFERENT pattern sets (globs: ${JSON.stringify(parity.globs)} vs ` +
|
|
339
|
+
`paths: ${JSON.stringify(parity.paths)}) — Claude Code's native loader scopes on paths:, while ` +
|
|
340
|
+
'rule-loader.mjs and Cursor scope on globs: (globs: wins silently when both are present, #795). The rule ' +
|
|
341
|
+
'therefore loads in different contexts on different harnesses (#1108). Make the two lists carry the same ' +
|
|
342
|
+
'patterns — order and duplicates are irrelevant.',
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
210
346
|
if (meta['auto-generated'] === true) {
|
|
211
|
-
autoGeneratedEntries.push({ rel, globs, meta });
|
|
347
|
+
autoGeneratedEntries.push({ rel, globs, meta, parityOk: parity.ok });
|
|
212
348
|
} else {
|
|
213
|
-
handwrittenEntries.push({ rel, globs, meta, contents });
|
|
349
|
+
handwrittenEntries.push({ rel, globs, meta, contents, parityOk: parity.ok });
|
|
214
350
|
}
|
|
215
351
|
}
|
|
216
352
|
|
|
217
353
|
// ---------------------------------------------------------------------------
|
|
218
354
|
// Auto-generated branch (FA4 #697) — UNCHANGED behaviour, hard-fail.
|
|
219
355
|
// ---------------------------------------------------------------------------
|
|
220
|
-
for (const { rel, globs, meta } of autoGeneratedEntries) {
|
|
356
|
+
for (const { rel, globs, meta, parityOk } of autoGeneratedEntries) {
|
|
221
357
|
// (a) never-always-on: must have at least one activation axis.
|
|
222
358
|
// globs is one of: null (no frontmatter / no globs key), [] (key present
|
|
223
359
|
// but EMPTY — the #892 QA-Defect special case, see module doc), or a
|
|
@@ -264,9 +400,11 @@ for (const { rel, globs, meta } of autoGeneratedEntries) {
|
|
|
264
400
|
// an empty globs array, which is dead-not-passing regardless of host-class)
|
|
265
401
|
// AND learning-key AND expires-at. When any failed, the FAIL line(s) above
|
|
266
402
|
// already emitted.
|
|
403
|
+
// `parityOk` joins the conjunction for the same reason `hasEmptyGlobs` does:
|
|
404
|
+
// its FAIL already emitted above, and a file must never both PASS and FAIL.
|
|
267
405
|
const lkOk = Object.prototype.hasOwnProperty.call(meta, 'learning-key');
|
|
268
406
|
const eaOk = Object.prototype.hasOwnProperty.call(meta, 'expires-at');
|
|
269
|
-
if (!hasEmptyGlobs && (hasGlobs || hasHostClass) && lkOk && eaOk) {
|
|
407
|
+
if (!hasEmptyGlobs && (hasGlobs || hasHostClass) && lkOk && eaOk && parityOk) {
|
|
270
408
|
pass(`${rel} — auto-generated rule satisfies all invariants`);
|
|
271
409
|
}
|
|
272
410
|
}
|
|
@@ -283,7 +421,7 @@ console.log(
|
|
|
283
421
|
'--- Check: .claude/rules/ handwritten-rule activation + review-date (warn mode, #880) ---',
|
|
284
422
|
);
|
|
285
423
|
|
|
286
|
-
for (const { rel, globs, meta, contents } of handwrittenEntries) {
|
|
424
|
+
for (const { rel, globs, meta, contents, parityOk } of handwrittenEntries) {
|
|
287
425
|
// `globs` (the merged globs/paths value from parseGlobsFrontmatter — #795
|
|
288
426
|
// alias, `globs:` wins when both are present) is one of:
|
|
289
427
|
// - null → neither key present
|
|
@@ -333,7 +471,13 @@ for (const { rel, globs, meta, contents } of handwrittenEntries) {
|
|
|
333
471
|
);
|
|
334
472
|
}
|
|
335
473
|
|
|
336
|
-
|
|
474
|
+
// `parityOk` joins the conjunction for the same reason `hasEmptyGlobs` does
|
|
475
|
+
// (#1108): its FAIL already emitted in the parse loop, and a file must never
|
|
476
|
+
// both PASS and FAIL. This is the one place a handwritten rule's PASS is
|
|
477
|
+
// withheld for a HARD-fail reason — the parity invariant is cohort-
|
|
478
|
+
// independent by construction, so it is not part of this branch's
|
|
479
|
+
// deliberately warn-only posture.
|
|
480
|
+
if (!hasEmptyGlobs && hasAxis && hasReviewDate && parityOk) {
|
|
337
481
|
pass(`${rel} — handwritten rule has an activation axis and a review-date`);
|
|
338
482
|
}
|
|
339
483
|
}
|