@event4u/agent-config 9.26.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 +57 -229
- package/dist/agent-src/commands/analyze/conformance.md +19 -0
- package/dist/agent-src/commands/roadmap/next.md +65 -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 +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 +2 -0
- package/dist/agent-src/rules/design-fidelity.md +2 -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 +13 -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 +61 -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 +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 +2 -0
- 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 +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/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_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 +109 -109
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +796 -289
- package/dist/install/install.mjs +1 -1
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +571 -417
- 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/package.json +1 -1
- package/src/config/evaluator-budgets.json +4 -4
- package/src/scripts/_cli/cmd_doctor.ts +6 -21
- 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/check_enforcement_coverage.ts +192 -7
- package/src/scripts/check_rule_projection_integrity.ts +364 -0
- package/src/scripts/condense.ts +36 -2
- package/src/scripts/conformance_scan.ts +35 -0
- package/src/scripts/generate_settings_reference.ts +232 -0
- package/src/scripts/hook_manifest.yaml +68 -27
- package/src/scripts/hooks/block_no_verify.ts +56 -1
- package/src/scripts/hooks/block_unauthorized_git.ts +67 -1
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/language_mirror_hook.ts +56 -6
- package/src/scripts/orchestration_backfill.ts +354 -0
- package/src/scripts/release.ts +142 -36
- 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/settingsCarveOut.ts +143 -0
- package/src/shared/settingsSurface.ts +7 -1
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared per-session register — so a starting session can see that another
|
|
3
|
+
* session is already live on this repo, on which branch, and on which roadmap.
|
|
4
|
+
*
|
|
5
|
+
* ## Where it lives, and why that needs no new machinery
|
|
6
|
+
*
|
|
7
|
+
* `<git-common-dir>/agent-sessions/<session_id>.json`. The common git directory
|
|
8
|
+
* resolves to the **same** directory from every worktree of a repo, so the
|
|
9
|
+
* register is shared by construction: no new directory to create, no sync step,
|
|
10
|
+
* no generator, and never tracked by git. Resolution is
|
|
11
|
+
* `_lib/git_common_dir.ts` — reused, not re-implemented, because a repo with two
|
|
12
|
+
* different answers to "where is the common dir" is a bug waiting for a
|
|
13
|
+
* symlinked parent.
|
|
14
|
+
*
|
|
15
|
+
* Runtime state under `agents/runtime/state/` cannot serve this purpose:
|
|
16
|
+
* concerns run with `CWD = envelope.workspace_root`, so in a worktree that path
|
|
17
|
+
* is the worktree's own. Measured 2026-08-07 — the main checkout's state
|
|
18
|
+
* directory was populated and a fresh worktree's did not exist at all. Two
|
|
19
|
+
* sessions in two worktrees share nothing there.
|
|
20
|
+
*
|
|
21
|
+
* ## One file per session — one writer each
|
|
22
|
+
*
|
|
23
|
+
* Each session owns exactly one file and is its only writer, so there is no
|
|
24
|
+
* concurrent-write case to solve: atomicity comes from write-temp + rename
|
|
25
|
+
* (`_lib/fs_atomic.ts`), the file never grows, compaction does not exist, the
|
|
26
|
+
* reader does a `readdir` plus N small reads, and cleanup is an `unlink` of
|
|
27
|
+
* expired files during the `session_start` read.
|
|
28
|
+
*
|
|
29
|
+
* The append-only JSONL alternative buys concurrency safety this layout does not
|
|
30
|
+
* need and pays for it with unbounded growth (one record per turn per session
|
|
31
|
+
* once heartbeating), a rotation problem (rotating under live appenders loses
|
|
32
|
+
* heartbeats to the unlinked inode — the classic logrotate defect), and
|
|
33
|
+
* fold-the-whole-file reads.
|
|
34
|
+
*
|
|
35
|
+
* ## Liveness is a heartbeat inside the record — never file mtime
|
|
36
|
+
*
|
|
37
|
+
* `last_seen` is rewritten every turn by a hook, and expiry compares that field.
|
|
38
|
+
* File mtime is explicitly ruled out: `worktree_cleanup_check.ts:65-68` records
|
|
39
|
+
* the failure — plain `git status` refreshes the on-disk index and bumps the very
|
|
40
|
+
* mtime being read as liveness, which moved 10 worktrees from safe to live
|
|
41
|
+
* between two consecutive runs. A field only this concern writes has no such
|
|
42
|
+
* coupling.
|
|
43
|
+
*
|
|
44
|
+
* ## Two accepted limits — declared, not hidden
|
|
45
|
+
*
|
|
46
|
+
* 1. **Idle is indistinguishable from crashed.** A session left open over lunch
|
|
47
|
+
* stops heartbeating, expires, and releases its claim although the user
|
|
48
|
+
* returns. No hook-based heartbeat can tell that apart from a crash. This is
|
|
49
|
+
* accepted, and it belongs in user-facing documentation: walking away for
|
|
50
|
+
* longer than the TTL means another session may claim your branch.
|
|
51
|
+
* 2. **This is advisory, not a mutex.** Two sessions can claim in the same
|
|
52
|
+
* millisecond. Nothing here provides exclusion, and no later feature may be
|
|
53
|
+
* built on it as if it did.
|
|
54
|
+
*
|
|
55
|
+
* ## Failure is always open
|
|
56
|
+
*
|
|
57
|
+
* Every function here swallows its errors and degrades to "no register": a
|
|
58
|
+
* session that cannot read or write it still starts, and behaves exactly as
|
|
59
|
+
* sessions did before this existed.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import fs from 'node:fs';
|
|
63
|
+
import path from 'node:path';
|
|
64
|
+
|
|
65
|
+
import { write_atomic } from './fs_atomic.js';
|
|
66
|
+
import { git_common_dir } from './git_common_dir.js';
|
|
67
|
+
|
|
68
|
+
/** Directory name under the common git dir. Never tracked; git ignores unknown
|
|
69
|
+
* directories inside `.git` by construction. */
|
|
70
|
+
export const REGISTER_DIRNAME = 'agent-sessions';
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Per-host TTL in seconds — how long a record may go without a heartbeat before
|
|
74
|
+
* a reader treats the session as gone.
|
|
75
|
+
*
|
|
76
|
+
* **Derived from data for exactly one host, and honest about the rest.** The
|
|
77
|
+
* chat-history corpus in this repo holds 71 records across 5 sessions over 24.5
|
|
78
|
+
* hours, and every one of them is `claude`; 77 % of the gaps come from a single
|
|
79
|
+
* session. The per-host split the design asked for is **not measurable** from
|
|
80
|
+
* that, so no interpolated values are shipped for hosts nobody observed. Full
|
|
81
|
+
* measurement record: `agents/evidence/analysis/parallel-session-register-phase1.md`.
|
|
82
|
+
*
|
|
83
|
+
* `claude` is taken from the **raw, unfiltered** p99 (13 705 s) and the raw
|
|
84
|
+
* user→user max (14 199 s), rounded up to the next hour. Deliberately *not* the
|
|
85
|
+
* idle-filtered p95: filtering gaps > 30 min characterises turn cadence
|
|
86
|
+
* correctly and is wrong as an expiry basis, because those gaps occur **inside**
|
|
87
|
+
* live sessions — 35 % of user→user gaps exceed 30 minutes while the session is
|
|
88
|
+
* demonstrably still working. A TTL from the filtered p95 would expire a live
|
|
89
|
+
* claim on roughly a third of turns, which is the collision this register exists
|
|
90
|
+
* to prevent, re-created by its own mitigation.
|
|
91
|
+
*/
|
|
92
|
+
export const TTL_MEASURED_SECONDS: Readonly<Record<string, number>> = Object.freeze({
|
|
93
|
+
claude: 14_400, // 4 h — raw p99 13 705 s / raw user→user max 14 199 s, rounded up
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* TTL for a host with **no** measurements: `3 ×` the one measured p99.
|
|
98
|
+
*
|
|
99
|
+
* The name says `MEASURED` above and `DEFAULT` here on purpose (council
|
|
100
|
+
* 2026-08-07, both members): a map holding one real entry plus a fallback is
|
|
101
|
+
* honest only if it is *named* as that, rather than presented as a populated
|
|
102
|
+
* per-host table. Six empty slots would be speculation wearing a data structure.
|
|
103
|
+
*
|
|
104
|
+
* The council also moved this number down from a proposed 24 h. The argument
|
|
105
|
+
* against 24 h: the evidence for a wide spread is that the same host's raw p99
|
|
106
|
+
* in another project corpus is 53 903 s, i.e. a 4× range — which does not
|
|
107
|
+
* justify a 6× extrapolation, and a full day makes the register useless on an
|
|
108
|
+
* unmeasured host, since a crashed session blocks a roadmap claim until
|
|
109
|
+
* tomorrow. 12 h stays comfortably above every observed live-session gap while
|
|
110
|
+
* keeping the claim recoverable within a working day.
|
|
111
|
+
*/
|
|
112
|
+
export const TTL_DEFAULT_SECONDS = 43_200; // 12 h
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolve the TTL for a record, from **the record's own platform** — never the
|
|
116
|
+
* reader's. Session B on one host reading session A's record must apply A's
|
|
117
|
+
* cadence, because the TTL describes how often A heartbeats.
|
|
118
|
+
*/
|
|
119
|
+
export function ttl_seconds_for(platform: string | null | undefined): number {
|
|
120
|
+
const key = String(platform ?? '').trim().toLowerCase();
|
|
121
|
+
return TTL_MEASURED_SECONDS[key] ?? TTL_DEFAULT_SECONDS;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** True when this platform's TTL is a measured value rather than the fallback. */
|
|
125
|
+
export function ttl_is_measured(platform: string | null | undefined): boolean {
|
|
126
|
+
const key = String(platform ?? '').trim().toLowerCase();
|
|
127
|
+
return Object.prototype.hasOwnProperty.call(TTL_MEASURED_SECONDS, key);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Platforms whose `stop` slot means **the task really ended**, so the register
|
|
132
|
+
* entry is deleted there instead of merely refreshed.
|
|
133
|
+
*
|
|
134
|
+
* The general rule is the opposite and stays the default: `stop` is a *second
|
|
135
|
+
* heartbeat carrier*, never a deregistration trigger, because on Claude Code the
|
|
136
|
+
* native `Stop` fires after **every assistant reply** — deregistering there
|
|
137
|
+
* would kill a live session's own record after its first reply.
|
|
138
|
+
*
|
|
139
|
+
* Cline is the one host where that reading is wrong. Its `stop` is mapped from
|
|
140
|
+
* `TaskCancel` (`hook_manifest.yaml:473`) and is typed `per-event` rather than
|
|
141
|
+
* `per-turn` (`_lib/obligation_frequency.ts:269-271`), while its `session_end`
|
|
142
|
+
* is mapped from `TaskComplete` (`:472`). A **cancelled** Cline task therefore
|
|
143
|
+
* reaches neither deregistration path and leaks its claim until the TTL — and
|
|
144
|
+
* cancel-and-restart is a routine Cline workflow, not an edge case.
|
|
145
|
+
*
|
|
146
|
+
* **This is an explicit allow-list, not a computed condition** (council
|
|
147
|
+
* 2026-08-07, both members). Deriving it from `slot_frequency(...) ===
|
|
148
|
+
* 'per-event'` would mean a future platform silently acquires deregistration
|
|
149
|
+
* behaviour the moment someone types it that way, in a code path where the
|
|
150
|
+
* failure mode is "a live session deleted its own claim". A human adds a line
|
|
151
|
+
* here, deliberately, or the platform keeps the safe default.
|
|
152
|
+
*
|
|
153
|
+
* The safety model is unchanged either way: TTL remains the correctness basis on
|
|
154
|
+
* every platform including Cline. This only frees the claim sooner.
|
|
155
|
+
*/
|
|
156
|
+
export const DEREGISTER_ON_STOP_PLATFORMS: ReadonlySet<string> = new Set(['cline']);
|
|
157
|
+
|
|
158
|
+
/** True when `stop` on this platform means the task ended, not "a reply landed". */
|
|
159
|
+
export function stop_means_session_end(platform: string | null | undefined): boolean {
|
|
160
|
+
return DEREGISTER_ON_STOP_PLATFORMS.has(String(platform ?? '').trim().toLowerCase());
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Platforms where a per-turn heartbeat actually fires today.
|
|
165
|
+
*
|
|
166
|
+
* Deliberately **not** derived from the frequency lattice. That instrument
|
|
167
|
+
* models *slot presence*, and two hosts have a slot that never fires:
|
|
168
|
+
*
|
|
169
|
+
* - `cursor` — `user_prompt_submit` and `stop` are **IDE-only**; the CLI fires
|
|
170
|
+
* only shell-execution hooks (`hook_manifest.yaml:365-366`,
|
|
171
|
+
* `chat-history-platform-hooks.md:214`). The lattice has no IDE/CLI dimension,
|
|
172
|
+
* so it reports cursor covered.
|
|
173
|
+
* - `cowork` — slots are structurally wired but lifecycle events do not fire
|
|
174
|
+
* (`hook_manifest.yaml:346-351`).
|
|
175
|
+
* - `copilot` — no hook surface at all (`hook_manifest.yaml:428-429`), excluded
|
|
176
|
+
* from the join by declaration.
|
|
177
|
+
*
|
|
178
|
+
* A session on an excluded host still *registers* if a `session_start` reaches
|
|
179
|
+
* it, and simply stops being visible after its TTL. It is never reported as
|
|
180
|
+
* covered.
|
|
181
|
+
*/
|
|
182
|
+
export const HEARTBEAT_REACHABLE_PLATFORMS: ReadonlySet<string> = new Set([
|
|
183
|
+
'claude',
|
|
184
|
+
'augment', // post-reply `stop` only — sufficient for a liveness stamp
|
|
185
|
+
'cline', // pre-reply `user_prompt_submit` only
|
|
186
|
+
'windsurf',
|
|
187
|
+
'gemini',
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
/** One live session, as seen by every other session on the repo. */
|
|
191
|
+
export interface SessionRecord {
|
|
192
|
+
/** Host-provided session id; also the filename stem. */
|
|
193
|
+
session_id: string;
|
|
194
|
+
/** Host platform — decides which TTL applies to THIS record. */
|
|
195
|
+
platform: string;
|
|
196
|
+
/** Absolute path of the worktree this session is working in. */
|
|
197
|
+
worktree: string;
|
|
198
|
+
/** Branch at the last heartbeat. Re-read every beat; sessions check out. */
|
|
199
|
+
branch: string | null;
|
|
200
|
+
/** Roadmap slug this session claims, or null when it has not picked one. */
|
|
201
|
+
roadmap_slug: string | null;
|
|
202
|
+
/** ISO-8601 UTC, set once at registration. */
|
|
203
|
+
started_at: string;
|
|
204
|
+
/** ISO-8601 UTC, rewritten every heartbeat. The liveness signal. */
|
|
205
|
+
last_seen: string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Filename-safe stem for a session id. Path separators and `..` collapse to `_`
|
|
210
|
+
* so a hostile or merely odd id cannot escape the register directory — the same
|
|
211
|
+
* traversal guard the dispatcher applies to its feedback directory.
|
|
212
|
+
*
|
|
213
|
+
* The fallback fires when the sanitised stem carries **no alphanumeric
|
|
214
|
+
* character**, not merely when it is empty. `'///'` sanitises to `'___'`, which
|
|
215
|
+
* is a legal filename and a useless one: it distinguishes nothing, so two
|
|
216
|
+
* different odd session ids would silently share a record. Requiring one
|
|
217
|
+
* alphanumeric makes the degenerate case land on the explicit marker instead.
|
|
218
|
+
*/
|
|
219
|
+
export function safe_stem(session_id: string): string {
|
|
220
|
+
const collapsed = String(session_id ?? '')
|
|
221
|
+
.replace(/\.\./g, '_')
|
|
222
|
+
.replace(/[/\\]/g, '_')
|
|
223
|
+
.replace(/[^A-Za-z0-9._-]/g, '_')
|
|
224
|
+
.slice(0, 128);
|
|
225
|
+
return /[A-Za-z0-9]/.test(collapsed) ? collapsed : 'unknown-session';
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The register directory for a workspace, or `null` when there is no git common
|
|
230
|
+
* dir to anchor it to. Does **not** create the directory — writers do.
|
|
231
|
+
*/
|
|
232
|
+
export function register_dir(workspace_root: string): string | null {
|
|
233
|
+
const common = git_common_dir(workspace_root);
|
|
234
|
+
return common === null ? null : path.join(common, REGISTER_DIRNAME);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** ISO-8601 UTC at seconds precision, matching the chat-history stamp format. */
|
|
238
|
+
export function iso_now(now: Date = new Date()): string {
|
|
239
|
+
return now.toISOString().replace(/\.\d{3}Z$/, '+00:00');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Parse an ISO stamp to epoch ms; `null` when unparseable. */
|
|
243
|
+
function epoch_ms(stamp: string | null | undefined): number | null {
|
|
244
|
+
if (typeof stamp !== 'string' || stamp.length === 0) return null;
|
|
245
|
+
const ms = Date.parse(stamp);
|
|
246
|
+
return Number.isFinite(ms) ? ms : null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** True when `rec` has not been heard from within its own platform's TTL. */
|
|
250
|
+
export function is_expired(rec: SessionRecord, now: Date = new Date()): boolean {
|
|
251
|
+
const seen = epoch_ms(rec.last_seen);
|
|
252
|
+
if (seen === null) return true; // unreadable stamp → not evidence of life
|
|
253
|
+
const age_s = (now.getTime() - seen) / 1000;
|
|
254
|
+
return age_s > ttl_seconds_for(rec.platform);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function is_record(v: unknown): v is SessionRecord {
|
|
258
|
+
if (typeof v !== 'object' || v === null) return false;
|
|
259
|
+
const o = v as Record<string, unknown>;
|
|
260
|
+
return typeof o['session_id'] === 'string' && typeof o['last_seen'] === 'string';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Write (or overwrite) this session's own record. Returns `true` on success.
|
|
265
|
+
* Never throws — a session that cannot write the register still starts.
|
|
266
|
+
*/
|
|
267
|
+
export function write_record(dir: string, rec: SessionRecord): boolean {
|
|
268
|
+
try {
|
|
269
|
+
const target = path.join(dir, `${safe_stem(rec.session_id)}.json`);
|
|
270
|
+
write_atomic(target, `${JSON.stringify(rec, null, 2)}\n`);
|
|
271
|
+
return true;
|
|
272
|
+
} catch {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Read this session's own record back, or `null` when it is absent/unreadable. */
|
|
278
|
+
export function read_own_record(dir: string, session_id: string): SessionRecord | null {
|
|
279
|
+
try {
|
|
280
|
+
const target = path.join(dir, `${safe_stem(session_id)}.json`);
|
|
281
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(target, 'utf-8'));
|
|
282
|
+
return is_record(parsed) ? parsed : null;
|
|
283
|
+
} catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Remove this session's own record. Best-effort; a failure is not an error. */
|
|
289
|
+
export function delete_record(dir: string, session_id: string): boolean {
|
|
290
|
+
try {
|
|
291
|
+
fs.unlinkSync(path.join(dir, `${safe_stem(session_id)}.json`));
|
|
292
|
+
return true;
|
|
293
|
+
} catch {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export interface ReadLiveOptions {
|
|
299
|
+
/** Unlink expired records as they are encountered. Default `false`. */
|
|
300
|
+
prune?: boolean;
|
|
301
|
+
/** Clock injection for tests. */
|
|
302
|
+
now?: Date;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Every live record in the register, optionally pruning expired ones as it goes.
|
|
307
|
+
*
|
|
308
|
+
* Expiry is evaluated per record against **its own** platform's TTL. A file that
|
|
309
|
+
* does not parse as a record is treated as expired: it carries no evidence of
|
|
310
|
+
* life, and leaving it would make the register grow without bound on a host that
|
|
311
|
+
* writes garbage.
|
|
312
|
+
*
|
|
313
|
+
* Returns `[]` on any failure — including "the directory does not exist", which
|
|
314
|
+
* is the normal state before the first session registers.
|
|
315
|
+
*/
|
|
316
|
+
export function read_live_records(dir: string, options: ReadLiveOptions = {}): SessionRecord[] {
|
|
317
|
+
const now = options.now ?? new Date();
|
|
318
|
+
const prune = options.prune === true;
|
|
319
|
+
let names: string[];
|
|
320
|
+
try {
|
|
321
|
+
names = fs.readdirSync(dir).filter((n) => n.endsWith('.json'));
|
|
322
|
+
} catch {
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
const live: SessionRecord[] = [];
|
|
326
|
+
for (const name of names) {
|
|
327
|
+
const full = path.join(dir, name);
|
|
328
|
+
let rec: SessionRecord | null = null;
|
|
329
|
+
try {
|
|
330
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(full, 'utf-8'));
|
|
331
|
+
rec = is_record(parsed) ? parsed : null;
|
|
332
|
+
} catch {
|
|
333
|
+
rec = null;
|
|
334
|
+
}
|
|
335
|
+
if (rec === null || is_expired(rec, now)) {
|
|
336
|
+
if (prune) {
|
|
337
|
+
try {
|
|
338
|
+
fs.unlinkSync(full);
|
|
339
|
+
} catch {
|
|
340
|
+
/* another session may have pruned it first — fine */
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
live.push(rec);
|
|
346
|
+
}
|
|
347
|
+
return live;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Live records belonging to sessions other than `session_id` — the set a
|
|
352
|
+
* starting session actually cares about.
|
|
353
|
+
*/
|
|
354
|
+
export function foreign_live_records(
|
|
355
|
+
dir: string,
|
|
356
|
+
session_id: string,
|
|
357
|
+
options: ReadLiveOptions = {},
|
|
358
|
+
): SessionRecord[] {
|
|
359
|
+
const own = safe_stem(session_id);
|
|
360
|
+
return read_live_records(dir, options).filter((r) => safe_stem(r.session_id) !== own);
|
|
361
|
+
}
|
|
@@ -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
|