@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,364 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * check_rule_projection_integrity.ts — the host rule trees are COMPLETE and
4
+ * FRESH against `dist/agent-src/rules/`, read as they stand on disk.
5
+ *
6
+ * WHY THIS GATE EXISTS (road-to-conformance-round5 Phase 1, measured 2026-08-07)
7
+ * ---------------------------------------------------------------------------
8
+ * `docs/contracts/rule-router.md:234` states that nothing loads
9
+ * `dist/router.json` at runtime, so **projection is the only reach mechanism
10
+ * there is**: a non-kernel rule activates by the model's judgment over text
11
+ * already in context, and the per-tool rule tree is what puts it there. That
12
+ * makes the state of the projection load-bearing, and it was broken:
13
+ *
14
+ * - `.claude/rules/` in the working checkout held **92** entries. A
15
+ * regeneration in a clean worktree produces **108**. The tree was last
16
+ * generated **2026-07-05**, and **21 rules added since then had no entry** —
17
+ * among them `secret-vcs-guard`, `broken-access-control`,
18
+ * `senior-engineering-discipline`, `evaluator-independence`,
19
+ * `session-canary`, `settings-ask-protocol`, `active-remediation`. At
20
+ * project scope those 21 obligations were carried by nothing. The same 21
21
+ * were absent from `.clinerules/` and `.cursor/rules/*.md`.
22
+ * - The trees are gitignored and untracked, so no diff review sees the gap.
23
+ *
24
+ * WHY THE EXISTING GATES DO NOT CATCH IT
25
+ * --------------------------------------
26
+ * - `check_bridge_derivation` **regenerates** the trees and then diffs, so it
27
+ * can only ever observe a fixpoint; both CI and the pre-push chain run it
28
+ * ahead of anything else, which is why five weeks of staleness were invisible.
29
+ * Its symlink leg asserts that entries which EXIST resolve — a missing entry
30
+ * is not a wrong entry.
31
+ * - `check_generator_output_coverage.ts:37` asserts only that the output
32
+ * *root* is classified in the ignore manifest, never its contents.
33
+ * - `check_condensation.ts` stops at `dist/`.
34
+ *
35
+ * So the last hop — the one that decides what the model actually sees — was
36
+ * ungated. This gate reads the tree WITHOUT touching it: no regeneration, no
37
+ * writes. That is the whole design constraint, and it is why the check is a
38
+ * separate script rather than a third leg on `check_bridge_derivation`.
39
+ *
40
+ * HONEST SCOPE — where each leg has teeth
41
+ * ---------------------------------------
42
+ * On a fresh CI checkout the trees do not exist at all (gitignored), so CI must
43
+ * run this AFTER `task generate-tools`, where it is a **generator-regression**
44
+ * check: it proves the generator emitted every rule it planned to. The stale
45
+ * case is caught on a developer's working checkout, which is why the pre-push
46
+ * `preflight` chain runs it FIRST — before `check_bridge_derivation` regenerates
47
+ * and erases the evidence. Both registrations are deliberate; neither subsumes
48
+ * the other.
49
+ *
50
+ * THE TWO ASSERTIONS
51
+ * ------------------
52
+ * 1. COMPLETENESS. Every rule the generator would emit has an entry in every
53
+ * active host rule tree. The expected set comes from
54
+ * `condense.projected_rule_trees()` — the generator's OWN emit plan, which
55
+ * `generate_rule_symlinks` also consumes. It is deliberately NOT a list in
56
+ * this file and deliberately NOT `dist/agent-src/rules/` verbatim: a
57
+ * hand-maintained array goes obsolete silently, and the dist directory
58
+ * over-counts by the ADR-004 `type: manual` rules the trees omit on purpose
59
+ * (113 files on disk, 5 manual, 108 projected). A completeness check keyed on
60
+ * a non-canonical source propagates that source's mistakes as requirements.
61
+ * 2. FRESHNESS. No rule in `dist/agent-src/rules/` may be newer than the
62
+ * projection entry pointing at it. Entries are symlinks, so the comparison
63
+ * uses `lstat` on the LINK, never `stat`: `stat` follows the link and returns
64
+ * the target's own mtime, which would compare the dist file with itself and
65
+ * make the assertion vacuous. Verified empirically before wiring — with a
66
+ * target touched after the link was created, `lstat(link).mtimeMs !==
67
+ * stat(target).mtimeMs` while `stat(link).mtimeMs === stat(target).mtimeMs`.
68
+ * Under `lean_projection: thin` the entries are real files rather than
69
+ * symlinks; `lstat` is correct there too (the file's own mtime).
70
+ *
71
+ * NOT IN SCOPE. `.cursor/rules/*.mdc` and `.windsurf/rules/` are written by
72
+ * different generators with their own filters; this gate covers the three trees
73
+ * `condense.TOOL_DIRS` projects (`.claude/rules`, `.cursor/rules`, `.clinerules`)
74
+ * and their `.md` entries, which is where the measured defect was.
75
+ *
76
+ * CLI: `--root <path>` (default: this repo) · `--quiet` suppresses the
77
+ * per-target ledger line. Exit 0 = complete and fresh, or no tool active at all
78
+ * (announced on stderr — see `auditRuleProjection`); 1 = any gap, any stale
79
+ * entry, a ledger accounting error, or a dead scan scope.
80
+ */
81
+ import * as fs from 'node:fs';
82
+ import * as path from 'node:path';
83
+ import process from 'node:process';
84
+ import { fileURLToPath, pathToFileURL } from 'node:url';
85
+
86
+ import { GateLedger, UnaccountedTargetsError } from './_lib/gate_ledger.js';
87
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
88
+ import { _resetStateForTest as _pointCondenseAt, projected_rule_trees } from './condense.js';
89
+
90
+ const _HERE = path.resolve(fileURLToPath(import.meta.url));
91
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
92
+
93
+ /** Where the projected rules are condensed to — the freshness comparison's left side. */
94
+ const DIST_RULES = path.join('dist', 'agent-src', 'rules');
95
+
96
+ export type ProjectionFindingKind = 'missing' | 'stale' | 'dangling';
97
+
98
+ export interface ProjectionFinding {
99
+ /** Repo-relative tool dir, e.g. `.claude/rules`. */
100
+ tree: string;
101
+ /** Rule basename, e.g. `secret-vcs-guard.md`. */
102
+ rule: string;
103
+ kind: ProjectionFindingKind;
104
+ message: string;
105
+ }
106
+
107
+ export interface ProjectionAudit {
108
+ findings: ProjectionFinding[];
109
+ /** `tree` → whether the directory exists at all; an absent tree fails every rule under it. */
110
+ treePresent: Record<string, boolean>;
111
+ ledger: GateLedger;
112
+ }
113
+
114
+ function _lstatOrNull(p: string): fs.Stats | null {
115
+ try {
116
+ return fs.lstatSync(p);
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Audit `expected` (tool dir → rule basenames) against the trees under `repoRoot`.
124
+ *
125
+ * Pure read: every branch stats, none writes. `expected` is injected rather than
126
+ * computed here so the caller controls which root the generator's emit plan was
127
+ * derived from, and so a test can pin an expectation without a settings file.
128
+ */
129
+ export function auditRuleProjection(
130
+ repoRoot: string,
131
+ expected: Readonly<Record<string, readonly string[]>>,
132
+ ): ProjectionAudit {
133
+ const ledger = new GateLedger('check_rule_projection_integrity');
134
+ const findings: ProjectionFinding[] = [];
135
+ const treePresent: Record<string, boolean> = {};
136
+
137
+ for (const [tree, rules] of Object.entries(expected)) {
138
+ ledger.plan(rules.map((r) => `${tree}/${r}`));
139
+ }
140
+
141
+ // The planned set is every (tree × rule) pair, and zero has TWO causes that
142
+ // are not the same defect. `projected_rule_trees()` returns one key per
143
+ // ACTIVE tool dir, so the key count is the discriminator:
144
+ //
145
+ // - keys > 0, rules == 0 → the rule corpus is dead. No rule reached
146
+ // `dist/agent-src/rules/`, or every scope filter rejected every rule,
147
+ // and a green checkmark here would cover an assertion never evaluated.
148
+ // This still throws.
149
+ // - keys == 0 → `agents/.agent-tools.yml` selects zero tools, which
150
+ // `_active_tools()` honours by design. There is no projection surface
151
+ // in this checkout, so there is nothing to be stale or missing. That is
152
+ // an absent optional surface, not a moved root.
153
+ //
154
+ // Conflating the two made the gate unsatisfiable on a supported config: it
155
+ // runs FIRST in the pre-push `preflight` chain, so a maintainer who
156
+ // deactivates the per-tool trees (they duplicate a globally installed
157
+ // `~/.claude`) could not push at all, with no fix available short of the
158
+ // env escape hatch. A gate whose only compliant path is to change an
159
+ // unrelated setting is not a gate. `main()` prints the skip explicitly —
160
+ // the empty case must be LOUD, never a silent green.
161
+ const noActiveTree = Object.keys(expected).length === 0;
162
+ assertScanned({
163
+ gate: 'check_rule_projection_integrity',
164
+ scanned: Object.values(expected).reduce((n, rules) => n + rules.length, 0),
165
+ units: 'projected rule entries',
166
+ roots: [DIST_RULES, ...Object.keys(expected)],
167
+ ...(noActiveTree
168
+ ? {
169
+ allowEmpty:
170
+ 'OPTIONAL_INPUT: no host rule tree is active — agents/.agent-tools.yml ' +
171
+ 'selects zero tools, so the surface this gate audits does not exist in ' +
172
+ 'this checkout. With one or more active tools the emit plan carries the ' +
173
+ 'full rule set by construction, so a dead dist/agent-src/rules/ still fails here.',
174
+ }
175
+ : {}),
176
+ });
177
+
178
+ for (const [tree, rules] of Object.entries(expected)) {
179
+ const treeDir = path.join(repoRoot, tree);
180
+ const treeStat = _lstatOrNull(treeDir);
181
+ treePresent[tree] = treeStat !== null && treeStat.isDirectory();
182
+
183
+ for (const rule of rules) {
184
+ const target = `${tree}/${rule}`;
185
+ const entry = _lstatOrNull(path.join(treeDir, rule));
186
+ if (entry === null) {
187
+ findings.push({
188
+ tree,
189
+ rule,
190
+ kind: 'missing',
191
+ message: treePresent[tree]
192
+ ? 'no projection entry — the rule reaches the model at no project scope'
193
+ : 'projection tree does not exist',
194
+ });
195
+ ledger.fail(target, 'missing projection entry');
196
+ continue;
197
+ }
198
+
199
+ const dist = path.join(repoRoot, DIST_RULES, rule);
200
+ let distStat: fs.Stats;
201
+ try {
202
+ distStat = fs.statSync(dist);
203
+ } catch {
204
+ // Unreachable while `expected` derives from this same dist tree,
205
+ // but reachable if the tree changes under a running gate. Report
206
+ // rather than crash: a projection entry whose source is gone is
207
+ // a dangling link, which is a real defect either way.
208
+ findings.push({
209
+ tree,
210
+ rule,
211
+ kind: 'dangling',
212
+ message: `projection entry has no source at ${DIST_RULES}/${rule}`,
213
+ });
214
+ ledger.fail(target, 'dangling projection entry');
215
+ continue;
216
+ }
217
+
218
+ // `entry` is an lstat: for a symlink that is the LINK's mtime, which
219
+ // is what "when was this projected" means. `statSync` here follows
220
+ // nothing — dist rules are regular files.
221
+ if (distStat.mtimeMs > entry.mtimeMs) {
222
+ findings.push({
223
+ tree,
224
+ rule,
225
+ kind: 'stale',
226
+ message:
227
+ `source is newer than the projection entry ` +
228
+ `(${DIST_RULES}/${rule} ${new Date(distStat.mtimeMs).toISOString()} > ` +
229
+ `entry ${new Date(entry.mtimeMs).toISOString()})`,
230
+ });
231
+ ledger.fail(target, 'stale projection entry');
232
+ continue;
233
+ }
234
+
235
+ ledger.complete(target);
236
+ }
237
+ }
238
+
239
+ return { findings, treePresent, ledger };
240
+ }
241
+
242
+ /** Group findings by tree, preserving rule order, for a report a reader can act on. */
243
+ export function renderFindings(audit: ProjectionAudit): string[] {
244
+ const lines: string[] = [];
245
+ const byTree = new Map<string, ProjectionFinding[]>();
246
+ for (const f of audit.findings) {
247
+ const bucket = byTree.get(f.tree);
248
+ if (bucket === undefined) byTree.set(f.tree, [f]);
249
+ else bucket.push(f);
250
+ }
251
+ for (const [tree, group] of byTree) {
252
+ const missing = group.filter((f) => f.kind === 'missing');
253
+ const stale = group.filter((f) => f.kind === 'stale');
254
+ const dangling = group.filter((f) => f.kind === 'dangling');
255
+ if (audit.treePresent[tree] !== true) {
256
+ lines.push(`${tree}: tree does not exist — ${String(group.length)} rule(s) reach the model at no project scope`);
257
+ }
258
+ if (missing.length > 0) {
259
+ lines.push(`${tree}: ${String(missing.length)} rule(s) missing from the projection:`);
260
+ for (const f of missing) lines.push(` - ${f.rule}`);
261
+ }
262
+ if (stale.length > 0) {
263
+ lines.push(`${tree}: ${String(stale.length)} projection entry/entries older than their source:`);
264
+ for (const f of stale) lines.push(` - ${f.rule} — ${f.message}`);
265
+ }
266
+ if (dangling.length > 0) {
267
+ lines.push(`${tree}: ${String(dangling.length)} dangling projection entry/entries:`);
268
+ for (const f of dangling) lines.push(` - ${f.rule} — ${f.message}`);
269
+ }
270
+ }
271
+ return lines;
272
+ }
273
+
274
+ export function main(argv?: readonly string[]): number {
275
+ const args = argv ?? process.argv.slice(2);
276
+ const quiet = args.includes('--quiet');
277
+ const idx = args.indexOf('--root');
278
+ const root = path.resolve(idx !== -1 ? (args[idx + 1] ?? REPO_ROOT) : REPO_ROOT);
279
+
280
+ // `condense`'s emit plan reads `MODULE_STATE`, whose only root seam is this
281
+ // reassignment. Repointing it is what makes `--root` mean the same thing for
282
+ // the expected set and for the trees being audited — without it the gate
283
+ // would compare this repo's rules against another checkout's projection.
284
+ _pointCondenseAt(root);
285
+
286
+ let audit: ProjectionAudit;
287
+ try {
288
+ audit = auditRuleProjection(root, projected_rule_trees());
289
+ } catch (exc) {
290
+ if (exc instanceof DeadScopeError) {
291
+ process.stderr.write(`❌ ${exc.message}\n`);
292
+ return 1;
293
+ }
294
+ throw exc;
295
+ }
296
+
297
+ // Zero active trees is a legitimate config (see `auditRuleProjection`), but
298
+ // it means this run audited nothing. Say so on its own line instead of
299
+ // letting the success line below report "0 entries across 0 trees", which
300
+ // reads as a pass to every human and every log scraper.
301
+ if (Object.keys(audit.treePresent).length === 0) {
302
+ process.stderr.write(
303
+ '⚠️ check_rule_projection_integrity: no host rule tree is active ' +
304
+ '(agents/.agent-tools.yml selects zero tools) — nothing to audit. This gate ' +
305
+ 'has teeth only where a tool is active; CI activates all eight.\n',
306
+ );
307
+ return 0;
308
+ }
309
+
310
+ const lines = renderFindings(audit);
311
+
312
+ let tally;
313
+ try {
314
+ tally = quiet ? audit.ledger.finalize() : audit.ledger.report();
315
+ } catch (exc) {
316
+ if (exc instanceof UnaccountedTargetsError) {
317
+ process.stderr.write(`❌ ${exc.message}\n`);
318
+ return 1;
319
+ }
320
+ throw exc;
321
+ }
322
+
323
+ if (lines.length > 0) {
324
+ for (const l of lines) process.stderr.write(`❌ ${l}\n`);
325
+ process.stderr.write(
326
+ `\nThe host rule trees are the ONLY carrier for a non-kernel rule's prose ` +
327
+ `(docs/contracts/rule-router.md § no runtime router). Regenerate with ` +
328
+ `\`task generate-tools\`, then re-run this gate.\n`,
329
+ );
330
+ return 1;
331
+ }
332
+
333
+ if (!quiet) {
334
+ process.stdout.write(
335
+ `✅ ${String(tally.completed)} rule projection entries complete and fresh across ` +
336
+ `${String(Object.keys(audit.treePresent).length)} host rule tree(s)\n`,
337
+ );
338
+ }
339
+ return 0;
340
+ }
341
+
342
+ function _isCliEntry(): boolean {
343
+ if (process.argv[1] === undefined) {
344
+ return false;
345
+ }
346
+ if (import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
347
+ return true;
348
+ }
349
+ // A symlinked invocation makes the raw URLs differ (import.meta.url is the
350
+ // resolved real path, argv[1] keeps the symlink). Compare realpaths, or the
351
+ // CLI silently no-ops when run through a projection — the bundled-CLI-entry
352
+ // landmine this repo has already paid for once.
353
+ try {
354
+ return (
355
+ fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(path.resolve(process.argv[1]))
356
+ );
357
+ } catch {
358
+ return false;
359
+ }
360
+ }
361
+
362
+ if (_isCliEntry()) {
363
+ process.exit(main());
364
+ }
@@ -1108,6 +1108,36 @@ function _scoped_rule_basenames(): string[] {
1108
1108
  .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
1109
1109
  }
1110
1110
 
1111
+ /**
1112
+ * The emit plan of {@link generate_rule_symlinks}: active tool dir → the exact
1113
+ * rule basenames it projects there.
1114
+ *
1115
+ * Exported so a completeness gate can assert a host rule tree matches the
1116
+ * generator WITHOUT regenerating it. That distinction is the whole point:
1117
+ * `check_bridge_derivation` regenerates and then diffs, so it can only ever see
1118
+ * a fixpoint, and a five-week-stale tree on a working checkout is invisible to
1119
+ * it. A gate that reads the tree as it stands needs the expected set from
1120
+ * somewhere, and every other candidate source is wrong in a way that
1121
+ * propagates: a hand-maintained array goes obsolete silently, and
1122
+ * `dist/agent-src/rules/` over-counts by the ADR-004 `type: manual` rules these
1123
+ * trees deliberately omit (113 files, 5 manual, 108 projected).
1124
+ *
1125
+ * `generate_rule_symlinks` consumes this too, so there is one emit plan rather
1126
+ * than a gate's reconstruction of one.
1127
+ */
1128
+ export function projected_rule_trees(
1129
+ user_home: string = process.env['HOME'] ?? os.homedir(),
1130
+ ): Record<string, string[]> {
1131
+ const rules = _scoped_rule_basenames();
1132
+ const dedup_on = _read_projection_scope_dedup();
1133
+ const out: Record<string, string[]> = {};
1134
+ for (const tool_dir of Object.keys(_filter_tool_dirs(TOOL_DIRS))) {
1135
+ const skip = dedup_on ? _dedupable_rules(tool_dir, rules, user_home) : new Set<string>();
1136
+ out[tool_dir] = rules.filter((r) => !skip.has(r));
1137
+ }
1138
+ return out;
1139
+ }
1140
+
1111
1141
  export function generate_rule_symlinks(): number {
1112
1142
  const rules = _scoped_rule_basenames();
1113
1143
  const tool_dirs = _filter_tool_dirs(TOOL_DIRS);
@@ -1130,14 +1160,18 @@ export function generate_rule_symlinks(): number {
1130
1160
  );
1131
1161
  }
1132
1162
 
1133
- const dedup_on = _read_projection_scope_dedup();
1134
1163
  const user_home = process.env['HOME'] ?? os.homedir();
1164
+ // Read the emit plan from the exported function rather than recomputing the
1165
+ // dedup skip here, so the gate that asserts this tree is complete and the
1166
+ // code that writes it cannot disagree.
1167
+ const plan = projected_rule_trees(user_home);
1135
1168
  const skipped_per_tool: Record<string, Set<string>> = {};
1136
1169
 
1137
1170
  let total = 0;
1138
1171
  for (const [tool_dir, rel_prefix] of Object.entries(tool_dirs)) {
1139
1172
  const target_dir = path.join(MODULE_STATE.PROJECT_ROOT, tool_dir);
1140
- const skip = dedup_on ? _dedupable_rules(tool_dir, rules, user_home) : new Set<string>();
1173
+ const emit_here = new Set(plan[tool_dir] ?? rules);
1174
+ const skip = new Set(rules.filter((r) => !emit_here.has(r)));
1141
1175
  skipped_per_tool[tool_dir] = skip;
1142
1176
  _mkdirp(target_dir);
1143
1177
  // Clean stale symlinks — a rule that became de-duplicable this run is
@@ -42,6 +42,16 @@ export interface Violation {
42
42
  session: string;
43
43
  at: string;
44
44
  detail: string;
45
+ /**
46
+ * Language-pin only. Assistant turns since the last genuine user prompt, and
47
+ * whether a compaction boundary fell in between. Together they separate the
48
+ * two failures the round-5 audit found hiding under one count: a pin that was
49
+ * ABSENT because compaction removed it, and a pin that was PRESENT and
50
+ * ignored. The first is a state defect; the second is non-compliance, and
51
+ * only the second is unfixable by re-injection.
52
+ */
53
+ turns_since_prompt?: number;
54
+ compaction_since_prompt?: boolean;
45
55
  }
46
56
 
47
57
  export interface SessionReport {
@@ -165,6 +175,12 @@ export function scanSession(sessionId: string, lines: string[]): SessionReport {
165
175
  let authorized = new Set<GitOp>();
166
176
  let sawPending = false;
167
177
  let evaluationsThisTurn = 0;
178
+ // Round-5 provenance for language violations. The round-5 audit could only
179
+ // separate "the pin was absent" from "the pin was ignored" by reconstructing
180
+ // both facts from the raw store afterwards; recording them here is what makes
181
+ // that split reproducible against a future corpus instead of re-argued.
182
+ let turnsSincePrompt = 0;
183
+ let compactionSincePrompt = false;
168
184
 
169
185
  for (const line of lines) {
170
186
  if (!line.trim()) {
@@ -196,13 +212,29 @@ export function scanSession(sessionId: string, lines: string[]): SessionReport {
196
212
  continue;
197
213
  }
198
214
 
215
+ // A compaction boundary removes context the pin lived in. It is not a turn
216
+ // and it is not a prompt, but it changes what the model can still see — so
217
+ // it is recorded, not skipped silently.
218
+ if (entry["type"] === "system" && entry["subtype"] === "compact_boundary") {
219
+ compactionSincePrompt = true;
220
+ continue;
221
+ }
222
+
199
223
  const ut = userText(entry);
200
224
  if (ut !== null) {
225
+ // A compaction SUMMARY arrives as a `user` entry. Treating it as a chat
226
+ // message would let its language reset the pin — the exact defect class
227
+ // that made the first detector report 303 instead of 626.
228
+ if (entry["isCompactSummary"] === true) {
229
+ continue;
230
+ }
201
231
  if (isInjectedBody(ut)) {
202
232
  // Not a chat message — it changes neither the pin nor the ledger.
203
233
  continue;
204
234
  }
205
235
  report.user_turns += 1;
236
+ turnsSincePrompt = 0;
237
+ compactionSincePrompt = false;
206
238
  const c = classify(ut);
207
239
  if (c.language !== "und") {
208
240
  pinned = c.language;
@@ -222,6 +254,7 @@ export function scanSession(sessionId: string, lines: string[]): SessionReport {
222
254
  const prose = assistantText(entry);
223
255
  if (prose !== null) {
224
256
  report.assistant_turns += 1;
257
+ turnsSincePrompt += 1;
225
258
  if (pinned === "de") {
226
259
  const first = prose.split("\n").find((l) => l.trim()) ?? "";
227
260
  if (HARNESS_TEXT.test(first.trim())) {
@@ -234,6 +267,8 @@ export function scanSession(sessionId: string, lines: string[]): SessionReport {
234
267
  session: sessionId,
235
268
  at,
236
269
  detail: `German pin, English reply opener: "${first.slice(0, 110)}"`,
270
+ turns_since_prompt: turnsSincePrompt,
271
+ compaction_since_prompt: compactionSincePrompt,
237
272
  });
238
273
  }
239
274
  }