@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.
Files changed (174) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +57 -229
  3. package/dist/agent-src/commands/analyze/conformance.md +19 -0
  4. package/dist/agent-src/commands/roadmap/next.md +65 -3
  5. package/dist/agent-src/rules/active-remediation.md +2 -0
  6. package/dist/agent-src/rules/analysis-skill-routing.md +2 -0
  7. package/dist/agent-src/rules/architecture.md +2 -0
  8. package/dist/agent-src/rules/artifact-drafting-protocol.md +2 -0
  9. package/dist/agent-src/rules/artifact-engagement-recording.md +2 -0
  10. package/dist/agent-src/rules/augment-edit-discipline.md +2 -0
  11. package/dist/agent-src/rules/autonomous-execution.md +2 -0
  12. package/dist/agent-src/rules/brand-consistency.md +2 -0
  13. package/dist/agent-src/rules/brand-source-of-truth.md +2 -0
  14. package/dist/agent-src/rules/broken-access-control.md +2 -0
  15. package/dist/agent-src/rules/cli-output-handling.md +2 -0
  16. package/dist/agent-src/rules/code-comment-discipline.md +2 -0
  17. package/dist/agent-src/rules/code-provenance.md +2 -0
  18. package/dist/agent-src/rules/command-suggestion-policy.md +2 -0
  19. package/dist/agent-src/rules/commit-conventions.md +2 -0
  20. package/dist/agent-src/rules/communication-through-line.md +2 -0
  21. package/dist/agent-src/rules/content-quoting-floor.md +2 -0
  22. package/dist/agent-src/rules/context-hygiene.md +10 -0
  23. package/dist/agent-src/rules/copilot-routing.md +2 -0
  24. package/dist/agent-src/rules/cross-source-consistency.md +2 -0
  25. package/dist/agent-src/rules/decision-revisit-gate.md +2 -0
  26. package/dist/agent-src/rules/delegation-policy.md +2 -0
  27. package/dist/agent-src/rules/design-fidelity.md +2 -0
  28. package/dist/agent-src/rules/design-review-after-ui-write.md +4 -0
  29. package/dist/agent-src/rules/devcontainer-routing.md +2 -0
  30. package/dist/agent-src/rules/doc-screenshot-hygiene.md +2 -0
  31. package/dist/agent-src/rules/docker-commands.md +2 -0
  32. package/dist/agent-src/rules/domain-adoption-policy.md +2 -0
  33. package/dist/agent-src/rules/domain-safety-disclaimer.md +2 -0
  34. package/dist/agent-src/rules/domain-safety-pii.md +2 -0
  35. package/dist/agent-src/rules/domain-safety-retention.md +2 -0
  36. package/dist/agent-src/rules/downstream-changes.md +2 -0
  37. package/dist/agent-src/rules/engineering-safety-floor.md +2 -0
  38. package/dist/agent-src/rules/evaluator-independence.md +13 -3
  39. package/dist/agent-src/rules/external-code-graph-interop.md +2 -0
  40. package/dist/agent-src/rules/external-reference-deep-dive.md +2 -0
  41. package/dist/agent-src/rules/fast-path-marker-visibility.md +2 -0
  42. package/dist/agent-src/rules/finance-safety-floor.md +2 -0
  43. package/dist/agent-src/rules/framework-neutrality-in-generic-skills.md +2 -0
  44. package/dist/agent-src/rules/git-history-discipline.md +3 -1
  45. package/dist/agent-src/rules/guidelines.md +2 -0
  46. package/dist/agent-src/rules/history-discipline.md +2 -0
  47. package/dist/agent-src/rules/icon-consistency.md +2 -0
  48. package/dist/agent-src/rules/image-likeness-and-rights.md +2 -0
  49. package/dist/agent-src/rules/improve-before-implement.md +2 -0
  50. package/dist/agent-src/rules/invite-challenge.md +2 -0
  51. package/dist/agent-src/rules/laravel-routing.md +2 -0
  52. package/dist/agent-src/rules/laravel-translations.md +2 -0
  53. package/dist/agent-src/rules/legal-safety-floor.md +2 -0
  54. package/dist/agent-src/rules/lethal-trifecta-guard.md +2 -0
  55. package/dist/agent-src/rules/linked-projects-onboarding-gate.md +2 -0
  56. package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +2 -0
  57. package/dist/agent-src/rules/markdown-safe-codeblocks.md +2 -0
  58. package/dist/agent-src/rules/media-governance-routing.md +2 -0
  59. package/dist/agent-src/rules/media-sync-ground-truth.md +2 -0
  60. package/dist/agent-src/rules/minimal-safe-diff.md +2 -0
  61. package/dist/agent-src/rules/missing-tool-handling.md +2 -0
  62. package/dist/agent-src/rules/model-recommendation.md +2 -0
  63. package/dist/agent-src/rules/no-attribution-footers.md +2 -0
  64. package/dist/agent-src/rules/no-decorative-emojis-in-git-surfaces.md +2 -0
  65. package/dist/agent-src/rules/no-pr-progress-comments.md +2 -0
  66. package/dist/agent-src/rules/no-roadmap-references.md +2 -0
  67. package/dist/agent-src/rules/notes-first-reasoning.md +2 -0
  68. package/dist/agent-src/rules/onboarding-gate.md +2 -0
  69. package/dist/agent-src/rules/output-discipline.md +2 -0
  70. package/dist/agent-src/rules/package-ci-checks.md +2 -0
  71. package/dist/agent-src/rules/persona-governance.md +2 -0
  72. package/dist/agent-src/rules/php-coding.md +2 -0
  73. package/dist/agent-src/rules/prefer-enums-over-literals.md +2 -0
  74. package/dist/agent-src/rules/preservation-guard.md +2 -0
  75. package/dist/agent-src/rules/provider-lifecycle-discipline.md +2 -0
  76. package/dist/agent-src/rules/question-not-instruction.md +2 -0
  77. package/dist/agent-src/rules/reviewer-awareness.md +2 -0
  78. package/dist/agent-src/rules/roadmap-ci-steps-policy.md +2 -0
  79. package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
  80. package/dist/agent-src/rules/role-mode-adherence.md +2 -0
  81. package/dist/agent-src/rules/rule-type-governance.md +2 -0
  82. package/dist/agent-src/rules/runtime-safety.md +2 -0
  83. package/dist/agent-src/rules/scale-discipline.md +2 -0
  84. package/dist/agent-src/rules/secret-vcs-guard.md +2 -0
  85. package/dist/agent-src/rules/security-sensitive-stop.md +2 -0
  86. package/dist/agent-src/rules/senior-engineering-discipline.md +2 -0
  87. package/dist/agent-src/rules/session-canary.md +61 -19
  88. package/dist/agent-src/rules/settings-ask-protocol.md +4 -0
  89. package/dist/agent-src/rules/size-enforcement.md +2 -0
  90. package/dist/agent-src/rules/skill-improvement-trigger.md +2 -0
  91. package/dist/agent-src/rules/skill-quality.md +2 -0
  92. package/dist/agent-src/rules/slash-command-routing-policy.md +2 -0
  93. package/dist/agent-src/rules/source-confidentiality.md +2 -0
  94. package/dist/agent-src/rules/source-discovery-gate.md +2 -0
  95. package/dist/agent-src/rules/source-of-truth.md +2 -0
  96. package/dist/agent-src/rules/spreadsheet-source-quality.md +2 -0
  97. package/dist/agent-src/rules/strategy-safety-floor.md +2 -0
  98. package/dist/agent-src/rules/symfony-routing.md +2 -0
  99. package/dist/agent-src/rules/think-before-action.md +2 -0
  100. package/dist/agent-src/rules/token-budget-discipline.md +2 -0
  101. package/dist/agent-src/rules/token-efficiency.md +2 -0
  102. package/dist/agent-src/rules/token-optimizer-maintenance.md +2 -0
  103. package/dist/agent-src/rules/tool-safety.md +2 -0
  104. package/dist/agent-src/rules/ui-audit-gate.md +4 -0
  105. package/dist/agent-src/rules/untrusted-input-defense.md +2 -0
  106. package/dist/agent-src/rules/upstream-proposal.md +2 -0
  107. package/dist/agent-src/rules/user-interaction.md +2 -0
  108. package/dist/agent-src/rules/user-interrupt-priority.md +2 -0
  109. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  110. package/dist/cli/registry.js +2 -0
  111. package/dist/cli/registry.js.map +1 -1
  112. package/dist/cli-delegate/{chunk-SJQMJI7M.js → chunk-3KJIMFTB.js} +1 -1
  113. package/dist/cli-delegate/{chunk-BSLUHV2F.js → chunk-BCPIOAGV.js} +148 -133
  114. package/dist/cli-delegate/cmd_conformance.js +1 -1
  115. package/dist/cli-delegate/cmd_doctor.js +1 -1
  116. package/dist/cli-delegate/cmd_export.js +1 -1
  117. package/dist/cli-delegate/cmd_prune.js +1 -1
  118. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  119. package/dist/cli-delegate/cmd_sync.js +1 -1
  120. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  121. package/dist/cli-delegate/cmd_validate.js +1 -1
  122. package/dist/discovery/deprecation-report.md +1 -1
  123. package/dist/discovery/discovery-manifest.json +109 -109
  124. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  125. package/dist/discovery/discovery-manifest.summary.md +1 -1
  126. package/dist/discovery/orphan-report.md +1 -1
  127. package/dist/discovery/packs.json +2 -2
  128. package/dist/discovery/trust-report.md +1 -1
  129. package/dist/discovery/workspaces.json +2 -2
  130. package/dist/hooks/dispatch.js +796 -289
  131. package/dist/install/install.mjs +1 -1
  132. package/dist/mcp/registry-manifest.json +2 -2
  133. package/dist/mcp/server.json +2 -2
  134. package/dist/mcp/server.mjs +571 -417
  135. package/dist/scripts/condense.js +33 -2
  136. package/dist/scripts/condense.js.map +1 -1
  137. package/dist/server/io/yamlIO.js +85 -0
  138. package/dist/server/io/yamlIO.js.map +1 -1
  139. package/dist/server/routes/settings.js +1 -12
  140. package/dist/server/routes/settings.js.map +1 -1
  141. package/dist/server/routes/wizard.js +52 -2
  142. package/dist/server/routes/wizard.js.map +1 -1
  143. package/dist/shared/settingsCarveOut.js +118 -0
  144. package/dist/shared/settingsCarveOut.js.map +1 -0
  145. package/dist/shared/settingsSurface.js +7 -1
  146. package/dist/shared/settingsSurface.js.map +1 -1
  147. package/dist/ui/assets/index-DmR0mbk4.js.map +1 -1
  148. package/package.json +1 -1
  149. package/src/config/evaluator-budgets.json +4 -4
  150. package/src/scripts/_cli/cmd_doctor.ts +6 -21
  151. package/src/scripts/_dispatch.bash +18 -0
  152. package/src/scripts/_lib/git_common_dir.ts +128 -0
  153. package/src/scripts/_lib/obligation_frequency.ts +391 -0
  154. package/src/scripts/_lib/session_register.ts +361 -0
  155. package/src/scripts/check_enforcement_coverage.ts +192 -7
  156. package/src/scripts/check_rule_projection_integrity.ts +364 -0
  157. package/src/scripts/condense.ts +36 -2
  158. package/src/scripts/conformance_scan.ts +35 -0
  159. package/src/scripts/generate_settings_reference.ts +232 -0
  160. package/src/scripts/hook_manifest.yaml +68 -27
  161. package/src/scripts/hooks/block_no_verify.ts +56 -1
  162. package/src/scripts/hooks/block_unauthorized_git.ts +67 -1
  163. package/src/scripts/hooks/concern_registry.ts +2 -0
  164. package/src/scripts/language_mirror_hook.ts +56 -6
  165. package/src/scripts/orchestration_backfill.ts +354 -0
  166. package/src/scripts/release.ts +142 -36
  167. package/src/scripts/schemas/rule.schema.json +5 -0
  168. package/src/scripts/session_canary_hook.ts +35 -3
  169. package/src/scripts/session_register_hook.ts +288 -0
  170. package/src/scripts/sessions_cli.ts +140 -0
  171. package/src/scripts/validate_frontmatter.ts +68 -0
  172. package/src/server/io/yamlIO.ts +87 -0
  173. package/src/shared/settingsCarveOut.ts +143 -0
  174. 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 hooks = fs.existsSync(HOOK_MANIFEST)
405
- ? parse_hook_manifest(fs.readFileSync(HOOK_MANIFEST, 'utf-8'))
406
- : new Map<string, boolean>();
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: name.replace(/\.md$/, ''),
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. Coverage is a ' +
517
- 'ratchet: --check fails when blocking falls or unwired rises. Regenerate ' +
518
- 'intentionally with --write-baseline when the change is the point.',
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