@event4u/agent-config 9.26.0 → 9.28.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/AGENTS.md +12 -11
- package/CHANGELOG.md +134 -207
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/conformance.md +43 -0
- package/dist/agent-src/commands/roadmap/next.md +65 -3
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +10 -3
- package/dist/agent-src/rules/active-remediation.md +2 -0
- package/dist/agent-src/rules/analysis-skill-routing.md +2 -0
- package/dist/agent-src/rules/architecture.md +2 -0
- package/dist/agent-src/rules/artifact-drafting-protocol.md +2 -0
- package/dist/agent-src/rules/artifact-engagement-recording.md +6 -1
- package/dist/agent-src/rules/augment-edit-discipline.md +2 -0
- package/dist/agent-src/rules/autonomous-execution.md +3 -1
- package/dist/agent-src/rules/brand-consistency.md +2 -0
- package/dist/agent-src/rules/brand-source-of-truth.md +35 -6
- package/dist/agent-src/rules/broken-access-control.md +2 -0
- package/dist/agent-src/rules/cli-output-handling.md +2 -0
- package/dist/agent-src/rules/code-comment-discipline.md +2 -0
- package/dist/agent-src/rules/code-provenance.md +2 -0
- package/dist/agent-src/rules/command-suggestion-policy.md +2 -0
- package/dist/agent-src/rules/commit-conventions.md +2 -0
- package/dist/agent-src/rules/communication-through-line.md +2 -0
- package/dist/agent-src/rules/content-quoting-floor.md +2 -0
- package/dist/agent-src/rules/context-hygiene.md +13 -1
- package/dist/agent-src/rules/copilot-routing.md +2 -0
- package/dist/agent-src/rules/council-availability.md +93 -0
- package/dist/agent-src/rules/cross-source-consistency.md +5 -0
- package/dist/agent-src/rules/decision-revisit-gate.md +2 -0
- package/dist/agent-src/rules/delegation-policy.md +31 -5
- package/dist/agent-src/rules/design-fidelity.md +5 -0
- package/dist/agent-src/rules/design-review-after-ui-write.md +4 -0
- package/dist/agent-src/rules/devcontainer-routing.md +2 -0
- package/dist/agent-src/rules/doc-screenshot-hygiene.md +2 -0
- package/dist/agent-src/rules/docker-commands.md +2 -0
- package/dist/agent-src/rules/domain-adoption-policy.md +2 -0
- package/dist/agent-src/rules/domain-safety-disclaimer.md +2 -0
- package/dist/agent-src/rules/domain-safety-pii.md +2 -0
- package/dist/agent-src/rules/domain-safety-retention.md +2 -0
- package/dist/agent-src/rules/downstream-changes.md +2 -0
- package/dist/agent-src/rules/engineering-safety-floor.md +2 -0
- package/dist/agent-src/rules/evaluator-independence.md +14 -3
- package/dist/agent-src/rules/external-code-graph-interop.md +4 -1
- package/dist/agent-src/rules/external-reference-deep-dive.md +2 -0
- package/dist/agent-src/rules/fast-path-marker-visibility.md +2 -0
- package/dist/agent-src/rules/finance-safety-floor.md +3 -1
- package/dist/agent-src/rules/framework-neutrality-in-generic-skills.md +2 -0
- package/dist/agent-src/rules/git-history-discipline.md +3 -1
- package/dist/agent-src/rules/guidelines.md +2 -0
- package/dist/agent-src/rules/history-discipline.md +5 -1
- package/dist/agent-src/rules/icon-consistency.md +2 -0
- package/dist/agent-src/rules/image-likeness-and-rights.md +2 -0
- package/dist/agent-src/rules/improve-before-implement.md +2 -0
- package/dist/agent-src/rules/invite-challenge.md +2 -0
- package/dist/agent-src/rules/laravel-routing.md +2 -0
- package/dist/agent-src/rules/laravel-translations.md +2 -0
- package/dist/agent-src/rules/legal-safety-floor.md +3 -1
- package/dist/agent-src/rules/lethal-trifecta-guard.md +2 -0
- package/dist/agent-src/rules/linked-projects-onboarding-gate.md +2 -0
- package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +2 -0
- package/dist/agent-src/rules/markdown-safe-codeblocks.md +2 -0
- package/dist/agent-src/rules/media-governance-routing.md +2 -0
- package/dist/agent-src/rules/media-sync-ground-truth.md +2 -0
- package/dist/agent-src/rules/minimal-safe-diff.md +2 -0
- package/dist/agent-src/rules/missing-tool-handling.md +2 -0
- package/dist/agent-src/rules/model-recommendation.md +2 -0
- package/dist/agent-src/rules/no-attribution-footers.md +2 -0
- package/dist/agent-src/rules/no-decorative-emojis-in-git-surfaces.md +2 -0
- package/dist/agent-src/rules/no-pr-progress-comments.md +5 -1
- package/dist/agent-src/rules/no-roadmap-references.md +2 -0
- package/dist/agent-src/rules/notes-first-reasoning.md +2 -0
- package/dist/agent-src/rules/onboarding-gate.md +5 -1
- package/dist/agent-src/rules/output-discipline.md +2 -0
- package/dist/agent-src/rules/package-ci-checks.md +2 -0
- package/dist/agent-src/rules/persona-governance.md +2 -0
- package/dist/agent-src/rules/php-coding.md +2 -0
- package/dist/agent-src/rules/prefer-enums-over-literals.md +2 -0
- package/dist/agent-src/rules/preservation-guard.md +2 -0
- package/dist/agent-src/rules/provider-lifecycle-discipline.md +2 -0
- package/dist/agent-src/rules/question-not-instruction.md +2 -0
- package/dist/agent-src/rules/reviewer-awareness.md +2 -0
- package/dist/agent-src/rules/roadmap-ci-steps-policy.md +6 -1
- package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
- package/dist/agent-src/rules/role-mode-adherence.md +3 -1
- package/dist/agent-src/rules/rule-type-governance.md +2 -0
- package/dist/agent-src/rules/runtime-safety.md +2 -0
- package/dist/agent-src/rules/scale-discipline.md +5 -1
- package/dist/agent-src/rules/secret-vcs-guard.md +2 -0
- package/dist/agent-src/rules/security-sensitive-stop.md +2 -0
- package/dist/agent-src/rules/self-repair-loop.md +103 -0
- package/dist/agent-src/rules/senior-engineering-discipline.md +2 -0
- package/dist/agent-src/rules/session-canary.md +62 -19
- package/dist/agent-src/rules/settings-ask-protocol.md +4 -0
- package/dist/agent-src/rules/size-enforcement.md +2 -0
- package/dist/agent-src/rules/skill-improvement-trigger.md +2 -0
- package/dist/agent-src/rules/skill-quality.md +2 -0
- package/dist/agent-src/rules/slash-command-routing-policy.md +2 -0
- package/dist/agent-src/rules/source-confidentiality.md +2 -0
- package/dist/agent-src/rules/source-discovery-gate.md +2 -0
- package/dist/agent-src/rules/source-of-truth.md +2 -0
- package/dist/agent-src/rules/spreadsheet-source-quality.md +2 -0
- package/dist/agent-src/rules/strategy-safety-floor.md +3 -1
- package/dist/agent-src/rules/symfony-routing.md +2 -0
- package/dist/agent-src/rules/think-before-action.md +2 -0
- package/dist/agent-src/rules/token-budget-discipline.md +5 -1
- package/dist/agent-src/rules/token-efficiency.md +2 -0
- package/dist/agent-src/rules/token-optimizer-maintenance.md +2 -0
- package/dist/agent-src/rules/tool-safety.md +7 -1
- package/dist/agent-src/rules/ui-audit-gate.md +4 -0
- package/dist/agent-src/rules/untrusted-input-defense.md +2 -0
- package/dist/agent-src/rules/upstream-proposal.md +2 -0
- package/dist/agent-src/rules/user-interaction.md +2 -0
- package/dist/agent-src/rules/user-interrupt-priority.md +2 -0
- package/dist/agent-src/skills/adversarial-review/SKILL.md +1 -1
- package/dist/agent-src/skills/analysis-autonomous-mode/SKILL.md +1 -1
- package/dist/agent-src/skills/performance-analysis/SKILL.md +1 -1
- package/dist/agent-src/skills/persona-improvement/SKILL.md +1 -1
- package/dist/agent-src/skills/project-analyzer/SKILL.md +1 -1
- package/dist/agent-src/skills/security-audit/SKILL.md +1 -1
- package/dist/agent-src/skills/sequential-thinking/SKILL.md +1 -1
- package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +1 -1
- package/dist/agent-src/skills/universal-project-analysis/SKILL.md +1 -1
- package/dist/agent-src/templates/AGENTS.md +6 -10
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +9 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-22GCIJ2O.js +270 -0
- package/dist/cli-delegate/{chunk-BSLUHV2F.js → chunk-2BE5I2MN.js} +156 -141
- package/dist/cli-delegate/{chunk-AIIUU73N.js → chunk-7OMQCH4V.js} +1 -1
- package/dist/cli-delegate/{chunk-HJ6DAJHR.js → chunk-GB2FSUEE.js} +57 -7
- package/dist/cli-delegate/{chunk-WR6GTRHJ.js → chunk-JXARCGVN.js} +3 -3
- package/dist/cli-delegate/{chunk-SJQMJI7M.js → chunk-PQSAQKHW.js} +467 -305
- package/dist/cli-delegate/chunk-REKCMYOU.js +2991 -0
- package/dist/cli-delegate/cmd_analyze_session.js +1 -1
- package/dist/cli-delegate/cmd_brand_status.js +134 -0
- package/dist/cli-delegate/cmd_conformance.js +7 -7
- package/dist/cli-delegate/cmd_converge.js +3 -3
- package/dist/cli-delegate/cmd_doctor.js +4 -4
- package/dist/cli-delegate/cmd_explain.js +80 -330
- package/dist/cli-delegate/cmd_export.js +5 -5
- package/dist/cli-delegate/cmd_fleet.js +3 -3
- package/dist/cli-delegate/cmd_handoff.js +2 -2
- package/dist/cli-delegate/cmd_mcp_available.js +433 -0
- package/dist/cli-delegate/cmd_migrate.js +3 -3
- package/dist/cli-delegate/cmd_packs_active.js +153 -0
- package/dist/cli-delegate/cmd_preflight.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +5 -5
- package/dist/cli-delegate/cmd_route_audit.js +8 -8
- package/dist/cli-delegate/cmd_route_explain.js +4 -4
- package/dist/cli-delegate/cmd_settings_get.js +272 -0
- package/dist/cli-delegate/cmd_settings_migrate.js +5 -5
- package/dist/cli-delegate/cmd_settings_set.js +7 -2967
- package/dist/cli-delegate/cmd_sync.js +5 -5
- package/dist/cli-delegate/cmd_uninstall.js +5 -5
- package/dist/cli-delegate/cmd_update.js +4 -4
- package/dist/cli-delegate/cmd_upgrade.js +4 -4
- package/dist/cli-delegate/cmd_validate.js +5 -5
- package/dist/cli-delegate/cmd_versions.js +4 -4
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +184 -124
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +7 -5
- package/dist/discovery/trust-report.md +11 -11
- package/dist/discovery/workspaces.json +6 -4
- package/dist/hooks/dispatch.js +9080 -5701
- package/dist/install/install.mjs +698 -508
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +698 -437
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/agent_settings.js +80 -15
- package/dist/scripts/_lib/agent_settings.js.map +1 -1
- package/dist/scripts/condense.js +33 -2
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/io/yamlIO.js +85 -0
- package/dist/server/io/yamlIO.js.map +1 -1
- package/dist/server/routes/settings.js +1 -12
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/server/routes/wizard.js +52 -2
- package/dist/server/routes/wizard.js.map +1 -1
- package/dist/shared/settingsCarveOut.js +118 -0
- package/dist/shared/settingsCarveOut.js.map +1 -0
- package/dist/shared/settingsSurface.js +7 -1
- package/dist/shared/settingsSurface.js.map +1 -1
- package/dist/ui/assets/index-DmR0mbk4.js.map +1 -1
- package/docs/contracts/settings-classes.md +29 -8
- package/docs/guidelines/agent-infra/memory-access.md +14 -3
- package/package.json +1 -1
- package/src/agent-src/templates/AGENTS.md +6 -10
- package/src/config/budgets.yml +29 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +26 -0
- package/src/scripts/_cli/cmd_brand_status.ts +174 -0
- package/src/scripts/_cli/cmd_doctor.ts +6 -21
- package/src/scripts/_cli/cmd_mcp_available.ts +266 -0
- package/src/scripts/_cli/cmd_packs_active.ts +225 -0
- package/src/scripts/_cli/cmd_settings_get.ts +324 -0
- package/src/scripts/_dispatch.bash +75 -0
- package/src/scripts/_lib/agent_settings.ts +82 -16
- package/src/scripts/_lib/carrier_divergence.ts +135 -0
- package/src/scripts/_lib/git_common_dir.ts +128 -0
- package/src/scripts/_lib/host_capability.ts +84 -1
- package/src/scripts/_lib/obligation_frequency.ts +391 -0
- package/src/scripts/_lib/prompt_shape.ts +88 -0
- package/src/scripts/_lib/review_skipped_record.ts +102 -0
- package/src/scripts/_lib/rule_layer_overlap.ts +254 -0
- package/src/scripts/_lib/self_repair.ts +342 -0
- package/src/scripts/_lib/self_repair_store.ts +100 -0
- package/src/scripts/_lib/session_register.ts +361 -0
- package/src/scripts/_lib/transcript_entry.ts +80 -0
- package/src/scripts/check_branch_freshness.ts +255 -0
- package/src/scripts/check_council_config_location.ts +125 -1
- package/src/scripts/check_enforcement_coverage.ts +192 -7
- package/src/scripts/check_rule_projection_integrity.ts +364 -0
- package/src/scripts/check_standing_rule_delivery.ts +314 -0
- package/src/scripts/condense.ts +36 -2
- package/src/scripts/config/profiles.ts +13 -3
- package/src/scripts/conformance_scan.ts +307 -24
- package/src/scripts/council_availability_hook.ts +188 -0
- package/src/scripts/council_cli.ts +126 -7
- package/src/scripts/generate_settings_reference.ts +232 -0
- package/src/scripts/hook_manifest.yaml +155 -33
- package/src/scripts/hooks/block_no_verify.ts +95 -3
- package/src/scripts/hooks/block_unauthorized_git.ts +217 -2
- package/src/scripts/hooks/concern_registry.ts +12 -0
- package/src/scripts/hooks/delegation_nudge_hook.ts +450 -0
- package/src/scripts/hooks/end_review_nudge_hook.ts +726 -0
- package/src/scripts/hooks/orchestration_record_hook.ts +342 -0
- package/src/scripts/install.ts +151 -0
- package/src/scripts/language_mirror_hook.ts +49 -6
- package/src/scripts/measure_scope_dedup.ts +19 -13
- package/src/scripts/onboarding_gate_hook.ts +40 -1
- package/src/scripts/orchestration_backfill.ts +354 -0
- package/src/scripts/orchestration_record.ts +4 -1
- package/src/scripts/preamble_byte_census.ts +9 -20
- package/src/scripts/release.ts +142 -36
- package/src/scripts/report_carrier_divergence.ts +364 -0
- package/src/scripts/report_skill_activation.ts +257 -0
- package/src/scripts/report_skill_obligation_violations.ts +579 -0
- package/src/scripts/routing_doctor.ts +77 -6
- package/src/scripts/schemas/rule.schema.json +5 -0
- package/src/scripts/self_repair_cli.ts +240 -0
- package/src/scripts/self_repair_hook.ts +241 -0
- package/src/scripts/session_canary_hook.ts +35 -3
- package/src/scripts/session_register_hook.ts +288 -0
- package/src/scripts/sessions_cli.ts +140 -0
- package/src/scripts/validate_frontmatter.ts +68 -0
- package/src/server/io/yamlIO.ts +87 -0
- package/src/shared/settingsCarveOut.ts +143 -0
- package/src/shared/settingsSurface.ts +7 -1
|
@@ -38,6 +38,16 @@ import * as path from 'node:path';
|
|
|
38
38
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
39
39
|
|
|
40
40
|
import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
|
|
41
|
+
import { KERNEL_RULE_ID_SET } from './_lib/kernel_rules.js';
|
|
42
|
+
import {
|
|
43
|
+
carrier_frequency_by_platform,
|
|
44
|
+
covers,
|
|
45
|
+
covers_any,
|
|
46
|
+
is_frequency,
|
|
47
|
+
parse_hook_platforms,
|
|
48
|
+
type Frequency,
|
|
49
|
+
type PlatformBinding,
|
|
50
|
+
} from './_lib/obligation_frequency.js';
|
|
41
51
|
|
|
42
52
|
const _HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
43
53
|
const REPO_ROOT = path.resolve(_HERE, '..', '..');
|
|
@@ -73,6 +83,25 @@ export type Resolution =
|
|
|
73
83
|
| 'missing' // declared target does not exist at all
|
|
74
84
|
| 'none'; // honest, recorded gap
|
|
75
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Whether the carrier's firing set covers the obligation's — the question
|
|
88
|
+
* `effective` does not answer.
|
|
89
|
+
*
|
|
90
|
+
* `covered` — some declared carrier covers the obligation on every
|
|
91
|
+
* hook-capable platform.
|
|
92
|
+
* `gap` — at least one hook-capable platform where no declared carrier
|
|
93
|
+
* covers it. `gap_platforms` names them.
|
|
94
|
+
* `declared-gap` — the rule declares `enforced_by: none`; the gap is stated in
|
|
95
|
+
* the rule's own text, so it is honest, not a defect.
|
|
96
|
+
* `unclassified` — no `obligation_frequency` to join against. The nine kernel
|
|
97
|
+
* rules sit here: `block_kernel_rule_writes.ts` denies agent
|
|
98
|
+
* writes to them, so the field cannot be populated by the same
|
|
99
|
+
* pass that populated the other 105.
|
|
100
|
+
* `unmeasured` — the rule declares no carrier at all; model-carried by design,
|
|
101
|
+
* making no claim this join could falsify.
|
|
102
|
+
*/
|
|
103
|
+
export type FrequencyVerdict = 'covered' | 'gap' | 'declared-gap' | 'unclassified' | 'unmeasured';
|
|
104
|
+
|
|
76
105
|
export interface RuleCoverage {
|
|
77
106
|
id: string;
|
|
78
107
|
tier: string;
|
|
@@ -82,6 +111,21 @@ export interface RuleCoverage {
|
|
|
82
111
|
/** Strongest resolution, by the ordering in `RANK`. */
|
|
83
112
|
effective: Resolution;
|
|
84
113
|
notes: string[];
|
|
114
|
+
/** Declared obligation period (frontmatter), or null when undeclared. */
|
|
115
|
+
obligation_frequency: Frequency | null;
|
|
116
|
+
/**
|
|
117
|
+
* Carrier period per hook-capable platform — never a single scalar.
|
|
118
|
+
*
|
|
119
|
+
* A scalar is wrong twice over: copilot has no hook surface at all, so a
|
|
120
|
+
* weakest-platform collapse turns one platform property into a finding on
|
|
121
|
+
* every hook-carried rule; and cline maps `stop` from `TaskCancel`, so the
|
|
122
|
+
* same slot means per-turn on six platforms and per-interruption there. Both
|
|
123
|
+
* are invisible in one number.
|
|
124
|
+
*/
|
|
125
|
+
carrier_frequency: Record<string, string> | null;
|
|
126
|
+
frequency_verdict: FrequencyVerdict;
|
|
127
|
+
/** Hook-capable platforms where no declared carrier covers the obligation. */
|
|
128
|
+
gap_platforms: string[];
|
|
85
129
|
}
|
|
86
130
|
|
|
87
131
|
/** Strength order — a rule is credited with its strongest resolving backstop. */
|
|
@@ -396,14 +440,87 @@ export function strongest(resolutions: Resolution[]): Resolution {
|
|
|
396
440
|
return best;
|
|
397
441
|
}
|
|
398
442
|
|
|
443
|
+
// ----------------------------------------------------------- frequency join
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Join a rule's declared obligation period against its carriers' firing periods.
|
|
447
|
+
*
|
|
448
|
+
* Runs per hook-capable platform, never as one scalar. A rule is covered only
|
|
449
|
+
* when SOME declared carrier covers it on EVERY hook-capable platform; the
|
|
450
|
+
* platforms where none does are named, because "uncovered on windsurf" and
|
|
451
|
+
* "uncovered everywhere" are different findings with different fixes.
|
|
452
|
+
*
|
|
453
|
+
* `validator:` / `test:` carriers are SWEEP carriers — they fire once and read
|
|
454
|
+
* the whole tree, so their reach is bounded by what lands in an artefact rather
|
|
455
|
+
* than by how often they run. Modelling them as per-commit point carriers would
|
|
456
|
+
* make every `validator:`-carried rule with a per-edit obligation a finding at
|
|
457
|
+
* once: one modelling error rendered as a fifth of the corpus.
|
|
458
|
+
*/
|
|
459
|
+
export function join_frequency(
|
|
460
|
+
obligation: Frequency | null,
|
|
461
|
+
declared: string[],
|
|
462
|
+
binding: PlatformBinding,
|
|
463
|
+
): {
|
|
464
|
+
verdict: FrequencyVerdict;
|
|
465
|
+
carrier_frequency: Record<string, string> | null;
|
|
466
|
+
gap_platforms: string[];
|
|
467
|
+
} {
|
|
468
|
+
if (obligation === null) {
|
|
469
|
+
return { verdict: 'unclassified', carrier_frequency: null, gap_platforms: [] };
|
|
470
|
+
}
|
|
471
|
+
if (declared.length === 0) {
|
|
472
|
+
return { verdict: 'unmeasured', carrier_frequency: null, gap_platforms: [] };
|
|
473
|
+
}
|
|
474
|
+
if (declared.every((d) => d === 'none')) {
|
|
475
|
+
return { verdict: 'declared-gap', carrier_frequency: null, gap_platforms: [] };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const has_sweep = declared.some((d) => d.startsWith('validator:') || d.startsWith('test:'));
|
|
479
|
+
const hook_concerns = declared
|
|
480
|
+
.filter((d) => d.startsWith('hook:'))
|
|
481
|
+
.map((d) => d.slice('hook:'.length));
|
|
482
|
+
|
|
483
|
+
const per_platform: Record<string, string> = {};
|
|
484
|
+
const gap_platforms: string[] = [];
|
|
485
|
+
|
|
486
|
+
for (const platform of binding.slots.keys()) {
|
|
487
|
+
if (binding.fallback_only.has(platform)) continue;
|
|
488
|
+
|
|
489
|
+
let covered = false;
|
|
490
|
+
const seen: Frequency[] = [];
|
|
491
|
+
|
|
492
|
+
if (has_sweep && covers({ frequency: 'per-commit', mode: 'sweep' }, obligation)) {
|
|
493
|
+
covered = true;
|
|
494
|
+
}
|
|
495
|
+
for (const concern of hook_concerns) {
|
|
496
|
+
for (const f of carrier_frequency_by_platform(concern, binding)[platform] ?? []) {
|
|
497
|
+
if (!seen.includes(f)) seen.push(f);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
if (covers_any(seen, obligation)) covered = true;
|
|
501
|
+
|
|
502
|
+
per_platform[platform] =
|
|
503
|
+
seen.length > 0 ? seen.join('+') : has_sweep ? 'sweep' : 'absent';
|
|
504
|
+
if (!covered) gap_platforms.push(platform);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
verdict: gap_platforms.length === 0 ? 'covered' : 'gap',
|
|
509
|
+
carrier_frequency: per_platform,
|
|
510
|
+
gap_platforms,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
399
514
|
// -------------------------------------------------------------------- report
|
|
400
515
|
|
|
401
516
|
export function collect(): RuleCoverage[] {
|
|
402
517
|
const ci_corpus = load_corpus(WORKFLOW_DIRS);
|
|
403
518
|
const local_corpus = load_corpus(TASK_DIRS, TASK_FILES);
|
|
404
|
-
const
|
|
405
|
-
?
|
|
406
|
-
:
|
|
519
|
+
const manifest_text = fs.existsSync(HOOK_MANIFEST)
|
|
520
|
+
? fs.readFileSync(HOOK_MANIFEST, 'utf-8')
|
|
521
|
+
: '';
|
|
522
|
+
const hooks = manifest_text ? parse_hook_manifest(manifest_text) : new Map<string, boolean>();
|
|
523
|
+
const binding = parse_hook_platforms(manifest_text);
|
|
407
524
|
const exists = (rel: string): boolean => fs.existsSync(path.join(REPO_ROOT, rel));
|
|
408
525
|
// Seeded separately so the umbrella expansion answers "reachable from CI"
|
|
409
526
|
// rather than "reachable from anything". The expansion itself was correct;
|
|
@@ -425,14 +542,36 @@ export function collect(): RuleCoverage[] {
|
|
|
425
542
|
resolutions.push(resolution);
|
|
426
543
|
if (note) notes.push(note);
|
|
427
544
|
}
|
|
545
|
+
const id = name.replace(/\.md$/, '');
|
|
546
|
+
const raw_freq = fm['obligation_frequency'];
|
|
547
|
+
const obligation: Frequency | null = is_frequency(raw_freq) ? raw_freq : null;
|
|
548
|
+
const { verdict, carrier_frequency, gap_platforms } = join_frequency(
|
|
549
|
+
obligation,
|
|
550
|
+
declared,
|
|
551
|
+
binding,
|
|
552
|
+
);
|
|
553
|
+
// Say WHY a row is unclassified. For the nine kernel rules the answer is
|
|
554
|
+
// structural rather than an authoring lapse — block_kernel_rule_writes
|
|
555
|
+
// denies the write — and a reader who cannot tell those apart will read
|
|
556
|
+
// the same bucket as either "expected" or "someone forgot".
|
|
557
|
+
if (verdict === 'unclassified' && KERNEL_RULE_ID_SET.has(id)) {
|
|
558
|
+
notes.push(
|
|
559
|
+
'no obligation_frequency: kernel rule, and block_kernel_rule_writes denies ' +
|
|
560
|
+
'the write with no agent-accessible override',
|
|
561
|
+
);
|
|
562
|
+
}
|
|
428
563
|
out.push({
|
|
429
|
-
id
|
|
564
|
+
id,
|
|
430
565
|
tier: String(fm['tier'] ?? '—'),
|
|
431
566
|
type: String(fm['type'] ?? '—'),
|
|
432
567
|
declared,
|
|
433
568
|
resolutions,
|
|
434
569
|
effective: strongest(resolutions),
|
|
435
570
|
notes,
|
|
571
|
+
obligation_frequency: obligation,
|
|
572
|
+
carrier_frequency,
|
|
573
|
+
frequency_verdict: verdict,
|
|
574
|
+
gap_platforms,
|
|
436
575
|
});
|
|
437
576
|
}
|
|
438
577
|
return out;
|
|
@@ -450,6 +589,16 @@ export interface Summary {
|
|
|
450
589
|
missing: number;
|
|
451
590
|
undeclared: number;
|
|
452
591
|
blocking_pct: number;
|
|
592
|
+
/**
|
|
593
|
+
* Rules whose declared carrier does not fire often enough to cover their
|
|
594
|
+
* declared obligation, on at least one hook-capable platform. The number
|
|
595
|
+
* this roadmap exists to produce — and the one `--check` ratchets, because
|
|
596
|
+
* `blocking` cannot see it: `session-canary` is declared, wired, firing, and
|
|
597
|
+
* in this bucket.
|
|
598
|
+
*/
|
|
599
|
+
frequency_gap: number;
|
|
600
|
+
/** Rules with no `obligation_frequency` to join — the nine kernel rules. */
|
|
601
|
+
frequency_unclassified: number;
|
|
453
602
|
}
|
|
454
603
|
|
|
455
604
|
export function summarise(rows: RuleCoverage[]): Summary {
|
|
@@ -466,6 +615,8 @@ export function summarise(rows: RuleCoverage[]): Summary {
|
|
|
466
615
|
missing: count((r) => r.effective === 'missing'),
|
|
467
616
|
undeclared: rows.length - declared.length,
|
|
468
617
|
blocking_pct: rows.length === 0 ? 0 : Math.round((blocking / rows.length) * 1000) / 10,
|
|
618
|
+
frequency_gap: count((r) => r.frequency_verdict === 'gap'),
|
|
619
|
+
frequency_unclassified: count((r) => r.frequency_verdict === 'unclassified'),
|
|
469
620
|
};
|
|
470
621
|
}
|
|
471
622
|
|
|
@@ -513,9 +664,13 @@ function main(argv: string[]): number {
|
|
|
513
664
|
{
|
|
514
665
|
_doc:
|
|
515
666
|
'Enforcement-coverage baseline. `blocking` counts rules whose strongest ' +
|
|
516
|
-
'enforced_by resolves to something that can fail a build.
|
|
517
|
-
'
|
|
518
|
-
'
|
|
667
|
+
'enforced_by resolves to something that can fail a build. `frequency_gap` ' +
|
|
668
|
+
'counts rules whose carrier does not fire often enough to cover their ' +
|
|
669
|
+
'declared obligation on some hook-capable platform — a rule can be ' +
|
|
670
|
+
'blocking and in that bucket at once. Coverage is a ratchet: --check fails ' +
|
|
671
|
+
'when blocking falls, or unwired / local_only / missing / frequency_gap ' +
|
|
672
|
+
'rises. Regenerate intentionally with --write-baseline when the change is ' +
|
|
673
|
+
'the point.',
|
|
519
674
|
summary,
|
|
520
675
|
},
|
|
521
676
|
null,
|
|
@@ -543,6 +698,27 @@ function main(argv: string[]): number {
|
|
|
543
698
|
);
|
|
544
699
|
}
|
|
545
700
|
|
|
701
|
+
lines.push(
|
|
702
|
+
` frequency: ${summary.frequency_gap} gap · ${summary.frequency_unclassified} unclassified ` +
|
|
703
|
+
`(kernel — block_kernel_rule_writes denies the field)`,
|
|
704
|
+
);
|
|
705
|
+
|
|
706
|
+
const gaps = rows.filter((r) => r.frequency_verdict === 'gap');
|
|
707
|
+
if (gaps.length > 0) {
|
|
708
|
+
lines.push('');
|
|
709
|
+
lines.push(' frequency gaps — carrier fires, but not often enough:');
|
|
710
|
+
for (const r of gaps) {
|
|
711
|
+
const where =
|
|
712
|
+
r.gap_platforms.length === Object.keys(r.carrier_frequency ?? {}).length
|
|
713
|
+
? 'every hook-capable platform'
|
|
714
|
+
: r.gap_platforms.join(', ');
|
|
715
|
+
lines.push(
|
|
716
|
+
` · ${r.id}: obligation ${r.obligation_frequency}, carrier ` +
|
|
717
|
+
`${r.declared.join(' + ')} — uncovered on ${where}`,
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
546
722
|
const problems = rows.filter((r) => r.notes.length > 0);
|
|
547
723
|
if (problems.length > 0) {
|
|
548
724
|
lines.push('');
|
|
@@ -569,6 +745,15 @@ function main(argv: string[]): number {
|
|
|
569
745
|
if (summary.unwired > base.unwired) {
|
|
570
746
|
regressions.push(`unwired declarations rose: ${base.unwired} → ${summary.unwired}`);
|
|
571
747
|
}
|
|
748
|
+
// The bucket `blocking` structurally cannot see: a rule can be declared,
|
|
749
|
+
// wired, firing and still mis-slotted. `??` tolerates a pre-frequency
|
|
750
|
+
// baseline so the field's own introduction does not red the ratchet.
|
|
751
|
+
if (summary.frequency_gap > (base.frequency_gap ?? Number.POSITIVE_INFINITY)) {
|
|
752
|
+
regressions.push(
|
|
753
|
+
`frequency gaps rose: ${base.frequency_gap} → ${summary.frequency_gap} ` +
|
|
754
|
+
`(a rule's carrier no longer fires often enough for its obligation)`,
|
|
755
|
+
);
|
|
756
|
+
}
|
|
572
757
|
// A validator dropping out of a workflow back into taskfile-only is the
|
|
573
758
|
// exact regression this split was built to name. Without this line the
|
|
574
759
|
// headline would fall and only the `blocking` check would notice, which
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* check_rule_projection_integrity.ts — the host rule trees are COMPLETE and
|
|
4
|
+
* FRESH against `dist/agent-src/rules/`, read as they stand on disk.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS GATE EXISTS (road-to-conformance-round5 Phase 1, measured 2026-08-07)
|
|
7
|
+
* ---------------------------------------------------------------------------
|
|
8
|
+
* `docs/contracts/rule-router.md:234` states that nothing loads
|
|
9
|
+
* `dist/router.json` at runtime, so **projection is the only reach mechanism
|
|
10
|
+
* there is**: a non-kernel rule activates by the model's judgment over text
|
|
11
|
+
* already in context, and the per-tool rule tree is what puts it there. That
|
|
12
|
+
* makes the state of the projection load-bearing, and it was broken:
|
|
13
|
+
*
|
|
14
|
+
* - `.claude/rules/` in the working checkout held **92** entries. A
|
|
15
|
+
* regeneration in a clean worktree produces **108**. The tree was last
|
|
16
|
+
* generated **2026-07-05**, and **21 rules added since then had no entry** —
|
|
17
|
+
* among them `secret-vcs-guard`, `broken-access-control`,
|
|
18
|
+
* `senior-engineering-discipline`, `evaluator-independence`,
|
|
19
|
+
* `session-canary`, `settings-ask-protocol`, `active-remediation`. At
|
|
20
|
+
* project scope those 21 obligations were carried by nothing. The same 21
|
|
21
|
+
* were absent from `.clinerules/` and `.cursor/rules/*.md`.
|
|
22
|
+
* - The trees are gitignored and untracked, so no diff review sees the gap.
|
|
23
|
+
*
|
|
24
|
+
* WHY THE EXISTING GATES DO NOT CATCH IT
|
|
25
|
+
* --------------------------------------
|
|
26
|
+
* - `check_bridge_derivation` **regenerates** the trees and then diffs, so it
|
|
27
|
+
* can only ever observe a fixpoint; both CI and the pre-push chain run it
|
|
28
|
+
* ahead of anything else, which is why five weeks of staleness were invisible.
|
|
29
|
+
* Its symlink leg asserts that entries which EXIST resolve — a missing entry
|
|
30
|
+
* is not a wrong entry.
|
|
31
|
+
* - `check_generator_output_coverage.ts:37` asserts only that the output
|
|
32
|
+
* *root* is classified in the ignore manifest, never its contents.
|
|
33
|
+
* - `check_condensation.ts` stops at `dist/`.
|
|
34
|
+
*
|
|
35
|
+
* So the last hop — the one that decides what the model actually sees — was
|
|
36
|
+
* ungated. This gate reads the tree WITHOUT touching it: no regeneration, no
|
|
37
|
+
* writes. That is the whole design constraint, and it is why the check is a
|
|
38
|
+
* separate script rather than a third leg on `check_bridge_derivation`.
|
|
39
|
+
*
|
|
40
|
+
* HONEST SCOPE — where each leg has teeth
|
|
41
|
+
* ---------------------------------------
|
|
42
|
+
* On a fresh CI checkout the trees do not exist at all (gitignored), so CI must
|
|
43
|
+
* run this AFTER `task generate-tools`, where it is a **generator-regression**
|
|
44
|
+
* check: it proves the generator emitted every rule it planned to. The stale
|
|
45
|
+
* case is caught on a developer's working checkout, which is why the pre-push
|
|
46
|
+
* `preflight` chain runs it FIRST — before `check_bridge_derivation` regenerates
|
|
47
|
+
* and erases the evidence. Both registrations are deliberate; neither subsumes
|
|
48
|
+
* the other.
|
|
49
|
+
*
|
|
50
|
+
* THE TWO ASSERTIONS
|
|
51
|
+
* ------------------
|
|
52
|
+
* 1. COMPLETENESS. Every rule the generator would emit has an entry in every
|
|
53
|
+
* active host rule tree. The expected set comes from
|
|
54
|
+
* `condense.projected_rule_trees()` — the generator's OWN emit plan, which
|
|
55
|
+
* `generate_rule_symlinks` also consumes. It is deliberately NOT a list in
|
|
56
|
+
* this file and deliberately NOT `dist/agent-src/rules/` verbatim: a
|
|
57
|
+
* hand-maintained array goes obsolete silently, and the dist directory
|
|
58
|
+
* over-counts by the ADR-004 `type: manual` rules the trees omit on purpose
|
|
59
|
+
* (113 files on disk, 5 manual, 108 projected). A completeness check keyed on
|
|
60
|
+
* a non-canonical source propagates that source's mistakes as requirements.
|
|
61
|
+
* 2. FRESHNESS. No rule in `dist/agent-src/rules/` may be newer than the
|
|
62
|
+
* projection entry pointing at it. Entries are symlinks, so the comparison
|
|
63
|
+
* uses `lstat` on the LINK, never `stat`: `stat` follows the link and returns
|
|
64
|
+
* the target's own mtime, which would compare the dist file with itself and
|
|
65
|
+
* make the assertion vacuous. Verified empirically before wiring — with a
|
|
66
|
+
* target touched after the link was created, `lstat(link).mtimeMs !==
|
|
67
|
+
* stat(target).mtimeMs` while `stat(link).mtimeMs === stat(target).mtimeMs`.
|
|
68
|
+
* Under `lean_projection: thin` the entries are real files rather than
|
|
69
|
+
* symlinks; `lstat` is correct there too (the file's own mtime).
|
|
70
|
+
*
|
|
71
|
+
* NOT IN SCOPE. `.cursor/rules/*.mdc` and `.windsurf/rules/` are written by
|
|
72
|
+
* different generators with their own filters; this gate covers the three trees
|
|
73
|
+
* `condense.TOOL_DIRS` projects (`.claude/rules`, `.cursor/rules`, `.clinerules`)
|
|
74
|
+
* and their `.md` entries, which is where the measured defect was.
|
|
75
|
+
*
|
|
76
|
+
* CLI: `--root <path>` (default: this repo) · `--quiet` suppresses the
|
|
77
|
+
* per-target ledger line. Exit 0 = complete and fresh, or no tool active at all
|
|
78
|
+
* (announced on stderr — see `auditRuleProjection`); 1 = any gap, any stale
|
|
79
|
+
* entry, a ledger accounting error, or a dead scan scope.
|
|
80
|
+
*/
|
|
81
|
+
import * as fs from 'node:fs';
|
|
82
|
+
import * as path from 'node:path';
|
|
83
|
+
import process from 'node:process';
|
|
84
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
85
|
+
|
|
86
|
+
import { GateLedger, UnaccountedTargetsError } from './_lib/gate_ledger.js';
|
|
87
|
+
import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
|
|
88
|
+
import { _resetStateForTest as _pointCondenseAt, projected_rule_trees } from './condense.js';
|
|
89
|
+
|
|
90
|
+
const _HERE = path.resolve(fileURLToPath(import.meta.url));
|
|
91
|
+
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
92
|
+
|
|
93
|
+
/** Where the projected rules are condensed to — the freshness comparison's left side. */
|
|
94
|
+
const DIST_RULES = path.join('dist', 'agent-src', 'rules');
|
|
95
|
+
|
|
96
|
+
export type ProjectionFindingKind = 'missing' | 'stale' | 'dangling';
|
|
97
|
+
|
|
98
|
+
export interface ProjectionFinding {
|
|
99
|
+
/** Repo-relative tool dir, e.g. `.claude/rules`. */
|
|
100
|
+
tree: string;
|
|
101
|
+
/** Rule basename, e.g. `secret-vcs-guard.md`. */
|
|
102
|
+
rule: string;
|
|
103
|
+
kind: ProjectionFindingKind;
|
|
104
|
+
message: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface ProjectionAudit {
|
|
108
|
+
findings: ProjectionFinding[];
|
|
109
|
+
/** `tree` → whether the directory exists at all; an absent tree fails every rule under it. */
|
|
110
|
+
treePresent: Record<string, boolean>;
|
|
111
|
+
ledger: GateLedger;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function _lstatOrNull(p: string): fs.Stats | null {
|
|
115
|
+
try {
|
|
116
|
+
return fs.lstatSync(p);
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Audit `expected` (tool dir → rule basenames) against the trees under `repoRoot`.
|
|
124
|
+
*
|
|
125
|
+
* Pure read: every branch stats, none writes. `expected` is injected rather than
|
|
126
|
+
* computed here so the caller controls which root the generator's emit plan was
|
|
127
|
+
* derived from, and so a test can pin an expectation without a settings file.
|
|
128
|
+
*/
|
|
129
|
+
export function auditRuleProjection(
|
|
130
|
+
repoRoot: string,
|
|
131
|
+
expected: Readonly<Record<string, readonly string[]>>,
|
|
132
|
+
): ProjectionAudit {
|
|
133
|
+
const ledger = new GateLedger('check_rule_projection_integrity');
|
|
134
|
+
const findings: ProjectionFinding[] = [];
|
|
135
|
+
const treePresent: Record<string, boolean> = {};
|
|
136
|
+
|
|
137
|
+
for (const [tree, rules] of Object.entries(expected)) {
|
|
138
|
+
ledger.plan(rules.map((r) => `${tree}/${r}`));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The planned set is every (tree × rule) pair, and zero has TWO causes that
|
|
142
|
+
// are not the same defect. `projected_rule_trees()` returns one key per
|
|
143
|
+
// ACTIVE tool dir, so the key count is the discriminator:
|
|
144
|
+
//
|
|
145
|
+
// - keys > 0, rules == 0 → the rule corpus is dead. No rule reached
|
|
146
|
+
// `dist/agent-src/rules/`, or every scope filter rejected every rule,
|
|
147
|
+
// and a green checkmark here would cover an assertion never evaluated.
|
|
148
|
+
// This still throws.
|
|
149
|
+
// - keys == 0 → `agents/.agent-tools.yml` selects zero tools, which
|
|
150
|
+
// `_active_tools()` honours by design. There is no projection surface
|
|
151
|
+
// in this checkout, so there is nothing to be stale or missing. That is
|
|
152
|
+
// an absent optional surface, not a moved root.
|
|
153
|
+
//
|
|
154
|
+
// Conflating the two made the gate unsatisfiable on a supported config: it
|
|
155
|
+
// runs FIRST in the pre-push `preflight` chain, so a maintainer who
|
|
156
|
+
// deactivates the per-tool trees (they duplicate a globally installed
|
|
157
|
+
// `~/.claude`) could not push at all, with no fix available short of the
|
|
158
|
+
// env escape hatch. A gate whose only compliant path is to change an
|
|
159
|
+
// unrelated setting is not a gate. `main()` prints the skip explicitly —
|
|
160
|
+
// the empty case must be LOUD, never a silent green.
|
|
161
|
+
const noActiveTree = Object.keys(expected).length === 0;
|
|
162
|
+
assertScanned({
|
|
163
|
+
gate: 'check_rule_projection_integrity',
|
|
164
|
+
scanned: Object.values(expected).reduce((n, rules) => n + rules.length, 0),
|
|
165
|
+
units: 'projected rule entries',
|
|
166
|
+
roots: [DIST_RULES, ...Object.keys(expected)],
|
|
167
|
+
...(noActiveTree
|
|
168
|
+
? {
|
|
169
|
+
allowEmpty:
|
|
170
|
+
'OPTIONAL_INPUT: no host rule tree is active — agents/.agent-tools.yml ' +
|
|
171
|
+
'selects zero tools, so the surface this gate audits does not exist in ' +
|
|
172
|
+
'this checkout. With one or more active tools the emit plan carries the ' +
|
|
173
|
+
'full rule set by construction, so a dead dist/agent-src/rules/ still fails here.',
|
|
174
|
+
}
|
|
175
|
+
: {}),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
for (const [tree, rules] of Object.entries(expected)) {
|
|
179
|
+
const treeDir = path.join(repoRoot, tree);
|
|
180
|
+
const treeStat = _lstatOrNull(treeDir);
|
|
181
|
+
treePresent[tree] = treeStat !== null && treeStat.isDirectory();
|
|
182
|
+
|
|
183
|
+
for (const rule of rules) {
|
|
184
|
+
const target = `${tree}/${rule}`;
|
|
185
|
+
const entry = _lstatOrNull(path.join(treeDir, rule));
|
|
186
|
+
if (entry === null) {
|
|
187
|
+
findings.push({
|
|
188
|
+
tree,
|
|
189
|
+
rule,
|
|
190
|
+
kind: 'missing',
|
|
191
|
+
message: treePresent[tree]
|
|
192
|
+
? 'no projection entry — the rule reaches the model at no project scope'
|
|
193
|
+
: 'projection tree does not exist',
|
|
194
|
+
});
|
|
195
|
+
ledger.fail(target, 'missing projection entry');
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const dist = path.join(repoRoot, DIST_RULES, rule);
|
|
200
|
+
let distStat: fs.Stats;
|
|
201
|
+
try {
|
|
202
|
+
distStat = fs.statSync(dist);
|
|
203
|
+
} catch {
|
|
204
|
+
// Unreachable while `expected` derives from this same dist tree,
|
|
205
|
+
// but reachable if the tree changes under a running gate. Report
|
|
206
|
+
// rather than crash: a projection entry whose source is gone is
|
|
207
|
+
// a dangling link, which is a real defect either way.
|
|
208
|
+
findings.push({
|
|
209
|
+
tree,
|
|
210
|
+
rule,
|
|
211
|
+
kind: 'dangling',
|
|
212
|
+
message: `projection entry has no source at ${DIST_RULES}/${rule}`,
|
|
213
|
+
});
|
|
214
|
+
ledger.fail(target, 'dangling projection entry');
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// `entry` is an lstat: for a symlink that is the LINK's mtime, which
|
|
219
|
+
// is what "when was this projected" means. `statSync` here follows
|
|
220
|
+
// nothing — dist rules are regular files.
|
|
221
|
+
if (distStat.mtimeMs > entry.mtimeMs) {
|
|
222
|
+
findings.push({
|
|
223
|
+
tree,
|
|
224
|
+
rule,
|
|
225
|
+
kind: 'stale',
|
|
226
|
+
message:
|
|
227
|
+
`source is newer than the projection entry ` +
|
|
228
|
+
`(${DIST_RULES}/${rule} ${new Date(distStat.mtimeMs).toISOString()} > ` +
|
|
229
|
+
`entry ${new Date(entry.mtimeMs).toISOString()})`,
|
|
230
|
+
});
|
|
231
|
+
ledger.fail(target, 'stale projection entry');
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
ledger.complete(target);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return { findings, treePresent, ledger };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Group findings by tree, preserving rule order, for a report a reader can act on. */
|
|
243
|
+
export function renderFindings(audit: ProjectionAudit): string[] {
|
|
244
|
+
const lines: string[] = [];
|
|
245
|
+
const byTree = new Map<string, ProjectionFinding[]>();
|
|
246
|
+
for (const f of audit.findings) {
|
|
247
|
+
const bucket = byTree.get(f.tree);
|
|
248
|
+
if (bucket === undefined) byTree.set(f.tree, [f]);
|
|
249
|
+
else bucket.push(f);
|
|
250
|
+
}
|
|
251
|
+
for (const [tree, group] of byTree) {
|
|
252
|
+
const missing = group.filter((f) => f.kind === 'missing');
|
|
253
|
+
const stale = group.filter((f) => f.kind === 'stale');
|
|
254
|
+
const dangling = group.filter((f) => f.kind === 'dangling');
|
|
255
|
+
if (audit.treePresent[tree] !== true) {
|
|
256
|
+
lines.push(`${tree}: tree does not exist — ${String(group.length)} rule(s) reach the model at no project scope`);
|
|
257
|
+
}
|
|
258
|
+
if (missing.length > 0) {
|
|
259
|
+
lines.push(`${tree}: ${String(missing.length)} rule(s) missing from the projection:`);
|
|
260
|
+
for (const f of missing) lines.push(` - ${f.rule}`);
|
|
261
|
+
}
|
|
262
|
+
if (stale.length > 0) {
|
|
263
|
+
lines.push(`${tree}: ${String(stale.length)} projection entry/entries older than their source:`);
|
|
264
|
+
for (const f of stale) lines.push(` - ${f.rule} — ${f.message}`);
|
|
265
|
+
}
|
|
266
|
+
if (dangling.length > 0) {
|
|
267
|
+
lines.push(`${tree}: ${String(dangling.length)} dangling projection entry/entries:`);
|
|
268
|
+
for (const f of dangling) lines.push(` - ${f.rule} — ${f.message}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return lines;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function main(argv?: readonly string[]): number {
|
|
275
|
+
const args = argv ?? process.argv.slice(2);
|
|
276
|
+
const quiet = args.includes('--quiet');
|
|
277
|
+
const idx = args.indexOf('--root');
|
|
278
|
+
const root = path.resolve(idx !== -1 ? (args[idx + 1] ?? REPO_ROOT) : REPO_ROOT);
|
|
279
|
+
|
|
280
|
+
// `condense`'s emit plan reads `MODULE_STATE`, whose only root seam is this
|
|
281
|
+
// reassignment. Repointing it is what makes `--root` mean the same thing for
|
|
282
|
+
// the expected set and for the trees being audited — without it the gate
|
|
283
|
+
// would compare this repo's rules against another checkout's projection.
|
|
284
|
+
_pointCondenseAt(root);
|
|
285
|
+
|
|
286
|
+
let audit: ProjectionAudit;
|
|
287
|
+
try {
|
|
288
|
+
audit = auditRuleProjection(root, projected_rule_trees());
|
|
289
|
+
} catch (exc) {
|
|
290
|
+
if (exc instanceof DeadScopeError) {
|
|
291
|
+
process.stderr.write(`❌ ${exc.message}\n`);
|
|
292
|
+
return 1;
|
|
293
|
+
}
|
|
294
|
+
throw exc;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Zero active trees is a legitimate config (see `auditRuleProjection`), but
|
|
298
|
+
// it means this run audited nothing. Say so on its own line instead of
|
|
299
|
+
// letting the success line below report "0 entries across 0 trees", which
|
|
300
|
+
// reads as a pass to every human and every log scraper.
|
|
301
|
+
if (Object.keys(audit.treePresent).length === 0) {
|
|
302
|
+
process.stderr.write(
|
|
303
|
+
'⚠️ check_rule_projection_integrity: no host rule tree is active ' +
|
|
304
|
+
'(agents/.agent-tools.yml selects zero tools) — nothing to audit. This gate ' +
|
|
305
|
+
'has teeth only where a tool is active; CI activates all eight.\n',
|
|
306
|
+
);
|
|
307
|
+
return 0;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const lines = renderFindings(audit);
|
|
311
|
+
|
|
312
|
+
let tally;
|
|
313
|
+
try {
|
|
314
|
+
tally = quiet ? audit.ledger.finalize() : audit.ledger.report();
|
|
315
|
+
} catch (exc) {
|
|
316
|
+
if (exc instanceof UnaccountedTargetsError) {
|
|
317
|
+
process.stderr.write(`❌ ${exc.message}\n`);
|
|
318
|
+
return 1;
|
|
319
|
+
}
|
|
320
|
+
throw exc;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (lines.length > 0) {
|
|
324
|
+
for (const l of lines) process.stderr.write(`❌ ${l}\n`);
|
|
325
|
+
process.stderr.write(
|
|
326
|
+
`\nThe host rule trees are the ONLY carrier for a non-kernel rule's prose ` +
|
|
327
|
+
`(docs/contracts/rule-router.md § no runtime router). Regenerate with ` +
|
|
328
|
+
`\`task generate-tools\`, then re-run this gate.\n`,
|
|
329
|
+
);
|
|
330
|
+
return 1;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (!quiet) {
|
|
334
|
+
process.stdout.write(
|
|
335
|
+
`✅ ${String(tally.completed)} rule projection entries complete and fresh across ` +
|
|
336
|
+
`${String(Object.keys(audit.treePresent).length)} host rule tree(s)\n`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
return 0;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function _isCliEntry(): boolean {
|
|
343
|
+
if (process.argv[1] === undefined) {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
if (import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
// A symlinked invocation makes the raw URLs differ (import.meta.url is the
|
|
350
|
+
// resolved real path, argv[1] keeps the symlink). Compare realpaths, or the
|
|
351
|
+
// CLI silently no-ops when run through a projection — the bundled-CLI-entry
|
|
352
|
+
// landmine this repo has already paid for once.
|
|
353
|
+
try {
|
|
354
|
+
return (
|
|
355
|
+
fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(path.resolve(process.argv[1]))
|
|
356
|
+
);
|
|
357
|
+
} catch {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (_isCliEntry()) {
|
|
363
|
+
process.exit(main());
|
|
364
|
+
}
|