@event4u/agent-config 9.25.0 → 9.27.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/CHANGELOG.md +58 -143
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/conformance.md +21 -2
- package/dist/agent-src/commands/review/changes.md +6 -5
- package/dist/agent-src/commands/roadmap/next.md +256 -0
- package/dist/agent-src/commands/roadmap.md +12 -4
- package/dist/agent-src/contexts/execution/subagent-routing.md +4 -1
- 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 +2 -0
- package/dist/agent-src/rules/augment-edit-discipline.md +2 -0
- package/dist/agent-src/rules/autonomous-execution.md +2 -0
- package/dist/agent-src/rules/brand-consistency.md +2 -0
- package/dist/agent-src/rules/brand-source-of-truth.md +2 -0
- 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 +10 -0
- package/dist/agent-src/rules/copilot-routing.md +2 -0
- package/dist/agent-src/rules/cross-source-consistency.md +2 -0
- package/dist/agent-src/rules/decision-revisit-gate.md +2 -0
- package/dist/agent-src/rules/delegation-policy.md +5 -2
- package/dist/agent-src/rules/design-fidelity.md +19 -1
- package/dist/agent-src/rules/design-review-after-ui-write.md +120 -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 +19 -3
- package/dist/agent-src/rules/external-code-graph-interop.md +2 -0
- 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 +2 -0
- 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 +2 -0
- 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 +2 -0
- 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 +2 -0
- 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 +2 -0
- 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 +2 -0
- package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
- package/dist/agent-src/rules/role-mode-adherence.md +2 -0
- 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 +2 -0
- 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/senior-engineering-discipline.md +2 -0
- package/dist/agent-src/rules/session-canary.md +63 -19
- package/dist/agent-src/rules/settings-ask-protocol.md +180 -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 +2 -0
- 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 +8 -3
- 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 +2 -0
- package/dist/agent-src/rules/ui-audit-gate.md +7 -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 +5 -2
- package/dist/agent-src/rules/user-interrupt-priority.md +2 -0
- package/dist/agent-src/skills/recursive-verification/SKILL.md +13 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-SJQMJI7M.js → chunk-3KJIMFTB.js} +1 -1
- package/dist/cli-delegate/{chunk-BSLUHV2F.js → chunk-BCPIOAGV.js} +148 -133
- package/dist/cli-delegate/cmd_conformance.js +1 -1
- package/dist/cli-delegate/cmd_doctor.js +1 -1
- package/dist/cli-delegate/cmd_export.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +1 -1
- package/dist/cli-delegate/cmd_route_audit.js +4 -1
- package/dist/cli-delegate/cmd_route_explain.js +4 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +1 -1
- package/dist/cli-delegate/cmd_uninstall.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +191 -120
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +11 -8
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +9 -6
- package/dist/hooks/dispatch.js +1148 -349
- package/dist/install/install.mjs +1 -1
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +571 -417
- package/dist/router.json +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/nicknameResolver.js +116 -0
- package/dist/server/nicknameResolver.js.map +1 -0
- package/dist/server/routes/ping.js +29 -11
- package/dist/server/routes/ping.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/interactiveContext.js +73 -0
- package/dist/shared/interactiveContext.js.map +1 -0
- package/dist/shared/nicknamePrefill.js +59 -0
- package/dist/shared/nicknamePrefill.js.map +1 -0
- package/dist/shared/settingsAsks.js +128 -0
- package/dist/shared/settingsAsks.js.map +1 -0
- package/dist/shared/settingsCarveOut.js +118 -0
- package/dist/shared/settingsCarveOut.js.map +1 -0
- package/dist/shared/settingsConsent.js +93 -0
- package/dist/shared/settingsConsent.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/package.json +1 -1
- package/src/config/agents-paths.yml +15 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +29 -1
- package/src/config/gate-violation-baselines.json +13 -7
- package/src/scripts/_cli/cmd_doctor.ts +6 -21
- package/src/scripts/_cli/cmd_route_audit.ts +4 -1
- package/src/scripts/_cli/cmd_route_explain.ts +9 -1
- package/src/scripts/_dispatch.bash +18 -0
- package/src/scripts/_lib/git_common_dir.ts +128 -0
- package/src/scripts/_lib/obligation_frequency.ts +391 -0
- package/src/scripts/_lib/session_register.ts +361 -0
- package/src/scripts/ai-video/stitch.sh +27 -9
- package/src/scripts/before_complete_hook.ts +23 -4
- package/src/scripts/bench_ab_v2_run.ts +272 -16
- package/src/scripts/bench_ab_v2_stats.ts +156 -3
- package/src/scripts/check_completion_review.ts +80 -3
- package/src/scripts/check_corpus_staleness.ts +692 -0
- package/src/scripts/check_enforcement_coverage.ts +192 -7
- package/src/scripts/check_gate_coverage.ts +67 -0
- package/src/scripts/check_hook_bundle_freshness.ts +183 -0
- package/src/scripts/check_rule_projection_integrity.ts +364 -0
- package/src/scripts/condense.ts +36 -2
- package/src/scripts/conformance_scan.ts +82 -17
- package/src/scripts/dispatch_r2_reviewer.ts +31 -2
- package/src/scripts/generate_settings_reference.ts +232 -0
- package/src/scripts/git_authorization_hook.ts +75 -10
- package/src/scripts/hook_manifest.yaml +87 -27
- package/src/scripts/hooks/block_no_verify.ts +59 -2
- package/src/scripts/hooks/block_unauthorized_git.ts +199 -16
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/hooks/design_slop_hook.ts +13 -1
- package/src/scripts/hooks/dispatch_hook.ts +97 -2
- package/src/scripts/hooks/evidence_independence.ts +40 -6
- package/src/scripts/language_mirror_hook.ts +256 -17
- package/src/scripts/lint_abstraction_thresholds.ts +41 -8
- package/src/scripts/lint_agents_layout.ts +2 -0
- package/src/scripts/lint_hook_manifest.ts +29 -0
- package/src/scripts/memory_learn_hook.ts +109 -6
- package/src/scripts/orchestration_backfill.ts +354 -0
- package/src/scripts/pr_url_reminder_hook.ts +17 -2
- package/src/scripts/release.ts +180 -37
- package/src/scripts/schemas/review-findings.schema.json +99 -0
- package/src/scripts/schemas/rule.schema.json +5 -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/interactiveContext.ts +98 -0
- package/src/shared/nicknamePrefill.ts +96 -0
- package/src/shared/settingsAsks.ts +176 -0
- package/src/shared/settingsCarveOut.ts +143 -0
- package/src/shared/settingsConsent.ts +136 -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
|
|
@@ -363,6 +363,7 @@ export function main(argv: readonly string[]): number {
|
|
|
363
363
|
);
|
|
364
364
|
}
|
|
365
365
|
const hardening = report_hardening_ratchet();
|
|
366
|
+
const selfTest = report_self_test_ratchet();
|
|
366
367
|
if (failed.length > 0) {
|
|
367
368
|
process.stdout.write(`❌ ${String(failed.length)} gate(s) failed the coverage floor.\n`);
|
|
368
369
|
return 1;
|
|
@@ -370,6 +371,9 @@ export function main(argv: readonly string[]): number {
|
|
|
370
371
|
if (hardening !== 0) {
|
|
371
372
|
return hardening;
|
|
372
373
|
}
|
|
374
|
+
if (selfTest !== 0) {
|
|
375
|
+
return selfTest;
|
|
376
|
+
}
|
|
373
377
|
process.stdout.write('✅ every enforced gate cleared its coverage floor.\n');
|
|
374
378
|
return 0;
|
|
375
379
|
}
|
|
@@ -400,6 +404,69 @@ export function report_hardening_ratchet(): number {
|
|
|
400
404
|
return verdict.ok ? 0 : 1;
|
|
401
405
|
}
|
|
402
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Registered gates that carry no `--self-test` and no exemption — the count
|
|
409
|
+
* that must not RISE.
|
|
410
|
+
*
|
|
411
|
+
* `_lib/gate_self_test.ts` proves a gate DISCRIMINATES: it drives the real
|
|
412
|
+
* binary through cases that must reject and cases that must accept, with a
|
|
413
|
+
* `minRejectCases` floor so an all-accepting suite cannot satisfy it. A gate
|
|
414
|
+
* that emits an enforced `scanned:` count has proven it read something; only a
|
|
415
|
+
* self-test proves the reading changes the verdict.
|
|
416
|
+
*
|
|
417
|
+
* ## Why this is a NON-ADOPTER count and not an adoption column
|
|
418
|
+
*
|
|
419
|
+
* The roadmap item asked for adoption as a column in `gate-coverage.yml`,
|
|
420
|
+
* ratcheted upward. That is the exact shape `report_hardening_ratchet` above
|
|
421
|
+
* rejects in this same file — a coverage-percentage ratchet "tracks how far the
|
|
422
|
+
* fix has spread and can never regress, so it grades the solution instead of
|
|
423
|
+
* the problem". Inverting it fixes that: the number below can regress (a new
|
|
424
|
+
* registered gate written without a self-test raises it), and the target is 0.
|
|
425
|
+
* That is also the shape the sibling adoption ratchet already uses —
|
|
426
|
+
* `check_gate_completeness`'s ledger baseline is a shrink-only count of
|
|
427
|
+
* non-adopters, not a rising percentage.
|
|
428
|
+
*
|
|
429
|
+
* Population is the MANIFEST, not every script under `src/scripts`: a self-test
|
|
430
|
+
* is a claim about a gate CI runs against a floor. Padding the manifest to move
|
|
431
|
+
* this number would add a floor per row, which is the opposite of gaming.
|
|
432
|
+
*
|
|
433
|
+
* Exemption marker: `// self-test-exempt: <reason>` with a non-empty reason. A
|
|
434
|
+
* bare marker does not count — same discipline as `// no-index:` and
|
|
435
|
+
* `// ledger-exempt:`.
|
|
436
|
+
*/
|
|
437
|
+
export function list_self_test_non_adopters(
|
|
438
|
+
dir = path.join(REPO_ROOT, 'src/scripts'),
|
|
439
|
+
ids: ReadonlySet<string> = enforced_manifest_ids(),
|
|
440
|
+
): string[] {
|
|
441
|
+
const out: string[] = [];
|
|
442
|
+
for (const id of [...ids].sort()) {
|
|
443
|
+
let src: string;
|
|
444
|
+
try {
|
|
445
|
+
src = fs.readFileSync(path.join(dir, `${id}.ts`), 'utf8');
|
|
446
|
+
} catch {
|
|
447
|
+
// A registered id with no readable script cannot prove discrimination.
|
|
448
|
+
out.push(id);
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
const adopts = /_lib\/gate_self_test\.js/.test(src);
|
|
452
|
+
const exempt = /\/\/\s*self-test-exempt:\s*\S/.test(src);
|
|
453
|
+
if (!adopts && !exempt) out.push(id);
|
|
454
|
+
}
|
|
455
|
+
return out;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Ratchet wrapper for {@link list_self_test_non_adopters}. */
|
|
459
|
+
export function report_self_test_ratchet(): number {
|
|
460
|
+
const missing = list_self_test_non_adopters();
|
|
461
|
+
const verdict = checkRatchet({
|
|
462
|
+
gate: 'gate-self-test:registered-non-adopters',
|
|
463
|
+
actual: missing.length,
|
|
464
|
+
repoRoot: REPO_ROOT,
|
|
465
|
+
});
|
|
466
|
+
process.stdout.write(` ${verdict.ok ? '✅' : '❌'} ${verdict.message}\n`);
|
|
467
|
+
return verdict.ok ? 0 : 1;
|
|
468
|
+
}
|
|
469
|
+
|
|
403
470
|
/**
|
|
404
471
|
* Manifest ids whose `scanned:` line is actually ENFORCED against a floor.
|
|
405
472
|
*
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Fail when the self-hosted hook bundle is older than the hook sources it
|
|
4
|
+
* bundles — i.e. when this repo's own agent is running a hook fix that has
|
|
5
|
+
* not been built.
|
|
6
|
+
*
|
|
7
|
+
* WHY THIS EXISTS — measured, not hypothetical.
|
|
8
|
+
*
|
|
9
|
+
* The Claude hook command resolves its dispatcher in two steps: the installed
|
|
10
|
+
* package copy (`node_modules/@event4u/agent-config/dist/hooks/dispatch.js`)
|
|
11
|
+
* if present, else this repo's OWN `dist/hooks/dispatch.js`. In the
|
|
12
|
+
* agent-config repo the second branch always wins — that is the dogfooding
|
|
13
|
+
* path, and it is the only place these hooks are exercised before release.
|
|
14
|
+
*
|
|
15
|
+
* `dist/hooks/dispatch.js` is UNTRACKED (a build artifact) and is produced
|
|
16
|
+
* only by `npm run build:hooks`, which no Taskfile target, no `task ci`
|
|
17
|
+
* target, and no workflow invokes. `prepack` runs the full build, so a
|
|
18
|
+
* PUBLISHED package is always fresh and consumers are unaffected. The gap is
|
|
19
|
+
* local and it is exactly where the testing happens.
|
|
20
|
+
*
|
|
21
|
+
* Consequence, observed on 2026-08-06: the conformance round-2 PR (#1195)
|
|
22
|
+
* merged four hook fixes — among them the language-mirror pin — while the
|
|
23
|
+
* running bundle was from 07:27 that morning, before any of them existed.
|
|
24
|
+
* Ten hook sources were newer than the bundle. In the very session that
|
|
25
|
+
* shipped the language fix, the scanner counted four German-prompt /
|
|
26
|
+
* English-reply violations: the fix was merged, correct, and not loaded.
|
|
27
|
+
*
|
|
28
|
+
* A hook fix that cannot reach the agent that wrote it is indistinguishable
|
|
29
|
+
* from no fix, and nothing said so. This gate says so.
|
|
30
|
+
*
|
|
31
|
+
* WHAT IT DOES NOT DO: it never builds. Building into a live hook path is a
|
|
32
|
+
* documented hazard in this repo (an esbuild run that overwrites the
|
|
33
|
+
* dispatcher a running hook is executing can wedge the tool loop), so the
|
|
34
|
+
* remedy is printed for a human to run deliberately.
|
|
35
|
+
*
|
|
36
|
+
* Exit codes: 0 = fresh, or no self-hosted bundle to check · 1 = stale.
|
|
37
|
+
*/
|
|
38
|
+
import * as fs from "node:fs";
|
|
39
|
+
import * as path from "node:path";
|
|
40
|
+
import { fileURLToPath } from "node:url";
|
|
41
|
+
|
|
42
|
+
import { parse as parseYaml } from "yaml";
|
|
43
|
+
|
|
44
|
+
import { reportScanned } from "./_lib/scan_scope.js";
|
|
45
|
+
|
|
46
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
47
|
+
const REPO_ROOT = path.resolve(HERE, "..", "..");
|
|
48
|
+
const BUNDLE = path.join("dist", "hooks", "dispatch.js");
|
|
49
|
+
const MANIFEST = path.join("src", "scripts", "hook_manifest.yaml");
|
|
50
|
+
const HOOK_DIR = path.join("src", "scripts", "hooks");
|
|
51
|
+
const REMEDY = "npm run build:hooks";
|
|
52
|
+
|
|
53
|
+
/** Every source whose edit changes the bundle's behaviour. */
|
|
54
|
+
export function bundledSources(root: string): string[] {
|
|
55
|
+
const out = new Set<string>();
|
|
56
|
+
|
|
57
|
+
const manifestAbs = path.join(root, MANIFEST);
|
|
58
|
+
if (fs.existsSync(manifestAbs)) {
|
|
59
|
+
out.add(MANIFEST);
|
|
60
|
+
try {
|
|
61
|
+
const doc = parseYaml(fs.readFileSync(manifestAbs, "utf-8")) as {
|
|
62
|
+
concerns?: Record<string, { script?: string }>;
|
|
63
|
+
};
|
|
64
|
+
for (const def of Object.values(doc?.concerns ?? {})) {
|
|
65
|
+
const s = def?.script;
|
|
66
|
+
// A concern script is bundled wherever it lives, not only under hooks/.
|
|
67
|
+
if (typeof s === "string" && s.endsWith(".ts")) out.add(s);
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// A malformed manifest is another gate's problem; still check the dir.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const hookDirAbs = path.join(root, HOOK_DIR);
|
|
75
|
+
if (fs.existsSync(hookDirAbs)) {
|
|
76
|
+
for (const e of fs.readdirSync(hookDirAbs, { withFileTypes: true })) {
|
|
77
|
+
if (e.isFile() && e.name.endsWith(".ts")) out.add(path.join(HOOK_DIR, e.name));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return [...out].filter((rel) => fs.existsSync(path.join(root, rel))).sort();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface Result {
|
|
85
|
+
/** No self-hosted bundle → the installed package copy is authoritative. */
|
|
86
|
+
skipped: boolean;
|
|
87
|
+
bundleMtimeMs: number;
|
|
88
|
+
/** Sources strictly newer than the bundle, newest first. */
|
|
89
|
+
stale: { file: string; mtimeMs: number }[];
|
|
90
|
+
checked: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function check(root: string): Result {
|
|
94
|
+
const bundleAbs = path.join(root, BUNDLE);
|
|
95
|
+
if (!fs.existsSync(bundleAbs)) {
|
|
96
|
+
return { skipped: true, bundleMtimeMs: 0, stale: [], checked: 0 };
|
|
97
|
+
}
|
|
98
|
+
const bundleMtimeMs = fs.statSync(bundleAbs).mtimeMs;
|
|
99
|
+
const sources = bundledSources(root);
|
|
100
|
+
const stale = sources
|
|
101
|
+
.map((file) => ({ file, mtimeMs: fs.statSync(path.join(root, file)).mtimeMs }))
|
|
102
|
+
.filter((s) => s.mtimeMs > bundleMtimeMs)
|
|
103
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
104
|
+
return { skipped: false, bundleMtimeMs, stale, checked: sources.length };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function stamp(ms: number): string {
|
|
108
|
+
return new Date(ms).toISOString().replace("T", " ").slice(0, 16);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function main(argv: string[] = process.argv.slice(2)): number {
|
|
112
|
+
const root = REPO_ROOT;
|
|
113
|
+
const quiet = argv.includes("--quiet");
|
|
114
|
+
const r = check(root);
|
|
115
|
+
|
|
116
|
+
// Scope hardening: publish the count and refuse a silent zero. The one
|
|
117
|
+
// legitimate zero is the absent self-hosted bundle — an optional input, and
|
|
118
|
+
// the reason survives the manifest's own test ("if the scan root were
|
|
119
|
+
// deleted, would this still make sense?"): with no bundle there is no stale
|
|
120
|
+
// dispatcher to catch, which is a correct verdict rather than blindness.
|
|
121
|
+
reportScanned({
|
|
122
|
+
gate: "check_hook_bundle_freshness",
|
|
123
|
+
scanned: r.checked,
|
|
124
|
+
units: "bundled hook source(s)",
|
|
125
|
+
roots: [BUNDLE, HOOK_DIR, MANIFEST],
|
|
126
|
+
...(r.skipped
|
|
127
|
+
? {
|
|
128
|
+
allowEmpty:
|
|
129
|
+
"OPTIONAL_INPUT: no self-hosted dist/hooks/dispatch.js — this repo is not " +
|
|
130
|
+
"running its own bundle (fresh checkout / CI), so there is nothing to compare against.",
|
|
131
|
+
}
|
|
132
|
+
: {}),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// A gate that scans nothing must SAY it scanned nothing — a silent green
|
|
136
|
+
// here is indistinguishable from a pass.
|
|
137
|
+
if (r.skipped) {
|
|
138
|
+
if (!quiet) {
|
|
139
|
+
process.stdout.write(
|
|
140
|
+
`✅ OK hook bundle: no self-hosted ${BUNDLE} — the installed package copy is in use, nothing to check\n`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (r.stale.length === 0) {
|
|
147
|
+
if (!quiet) {
|
|
148
|
+
process.stdout.write(
|
|
149
|
+
`✅ OK hook bundle: fresh (built ${stamp(r.bundleMtimeMs)}, ${String(r.checked)} bundled source(s) checked)\n`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
console.error(
|
|
156
|
+
`❌ hook bundle is STALE — the running dispatcher predates ${r.stale.length} of ${r.checked} bundled source(s).`,
|
|
157
|
+
);
|
|
158
|
+
console.error(` ${BUNDLE} built ${stamp(r.bundleMtimeMs)}; newer sources:`);
|
|
159
|
+
for (const s of r.stale.slice(0, 10)) {
|
|
160
|
+
console.error(` ${stamp(s.mtimeMs)} ${s.file}`);
|
|
161
|
+
}
|
|
162
|
+
if (r.stale.length > 10) console.error(` … and ${r.stale.length - 10} more`);
|
|
163
|
+
console.error("");
|
|
164
|
+
console.error(` Your hooks are running the OLD code. Rebuild: ${REMEDY}`);
|
|
165
|
+
console.error(
|
|
166
|
+
" (This gate never builds for you — writing into a live hook path can wedge the tool loop.)",
|
|
167
|
+
);
|
|
168
|
+
return 1;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const invokedDirectly = ((): boolean => {
|
|
172
|
+
const entry = process.argv[1];
|
|
173
|
+
if (entry === undefined) return false;
|
|
174
|
+
try {
|
|
175
|
+
return fs.realpathSync(path.resolve(entry)) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
176
|
+
} catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
})();
|
|
180
|
+
|
|
181
|
+
if (invokedDirectly) {
|
|
182
|
+
process.exit(main());
|
|
183
|
+
}
|