@event4u/agent-config 9.25.0 → 9.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +58 -143
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/conformance.md +21 -2
  5. package/dist/agent-src/commands/review/changes.md +6 -5
  6. package/dist/agent-src/commands/roadmap/next.md +256 -0
  7. package/dist/agent-src/commands/roadmap.md +12 -4
  8. package/dist/agent-src/contexts/execution/subagent-routing.md +4 -1
  9. package/dist/agent-src/rules/active-remediation.md +2 -0
  10. package/dist/agent-src/rules/analysis-skill-routing.md +2 -0
  11. package/dist/agent-src/rules/architecture.md +2 -0
  12. package/dist/agent-src/rules/artifact-drafting-protocol.md +2 -0
  13. package/dist/agent-src/rules/artifact-engagement-recording.md +2 -0
  14. package/dist/agent-src/rules/augment-edit-discipline.md +2 -0
  15. package/dist/agent-src/rules/autonomous-execution.md +2 -0
  16. package/dist/agent-src/rules/brand-consistency.md +2 -0
  17. package/dist/agent-src/rules/brand-source-of-truth.md +2 -0
  18. package/dist/agent-src/rules/broken-access-control.md +2 -0
  19. package/dist/agent-src/rules/cli-output-handling.md +2 -0
  20. package/dist/agent-src/rules/code-comment-discipline.md +2 -0
  21. package/dist/agent-src/rules/code-provenance.md +2 -0
  22. package/dist/agent-src/rules/command-suggestion-policy.md +2 -0
  23. package/dist/agent-src/rules/commit-conventions.md +2 -0
  24. package/dist/agent-src/rules/communication-through-line.md +2 -0
  25. package/dist/agent-src/rules/content-quoting-floor.md +2 -0
  26. package/dist/agent-src/rules/context-hygiene.md +10 -0
  27. package/dist/agent-src/rules/copilot-routing.md +2 -0
  28. package/dist/agent-src/rules/cross-source-consistency.md +2 -0
  29. package/dist/agent-src/rules/decision-revisit-gate.md +2 -0
  30. package/dist/agent-src/rules/delegation-policy.md +5 -2
  31. package/dist/agent-src/rules/design-fidelity.md +19 -1
  32. package/dist/agent-src/rules/design-review-after-ui-write.md +120 -0
  33. package/dist/agent-src/rules/devcontainer-routing.md +2 -0
  34. package/dist/agent-src/rules/doc-screenshot-hygiene.md +2 -0
  35. package/dist/agent-src/rules/docker-commands.md +2 -0
  36. package/dist/agent-src/rules/domain-adoption-policy.md +2 -0
  37. package/dist/agent-src/rules/domain-safety-disclaimer.md +2 -0
  38. package/dist/agent-src/rules/domain-safety-pii.md +2 -0
  39. package/dist/agent-src/rules/domain-safety-retention.md +2 -0
  40. package/dist/agent-src/rules/downstream-changes.md +2 -0
  41. package/dist/agent-src/rules/engineering-safety-floor.md +2 -0
  42. package/dist/agent-src/rules/evaluator-independence.md +19 -3
  43. package/dist/agent-src/rules/external-code-graph-interop.md +2 -0
  44. package/dist/agent-src/rules/external-reference-deep-dive.md +2 -0
  45. package/dist/agent-src/rules/fast-path-marker-visibility.md +2 -0
  46. package/dist/agent-src/rules/finance-safety-floor.md +2 -0
  47. package/dist/agent-src/rules/framework-neutrality-in-generic-skills.md +2 -0
  48. package/dist/agent-src/rules/git-history-discipline.md +3 -1
  49. package/dist/agent-src/rules/guidelines.md +2 -0
  50. package/dist/agent-src/rules/history-discipline.md +2 -0
  51. package/dist/agent-src/rules/icon-consistency.md +2 -0
  52. package/dist/agent-src/rules/image-likeness-and-rights.md +2 -0
  53. package/dist/agent-src/rules/improve-before-implement.md +2 -0
  54. package/dist/agent-src/rules/invite-challenge.md +2 -0
  55. package/dist/agent-src/rules/laravel-routing.md +2 -0
  56. package/dist/agent-src/rules/laravel-translations.md +2 -0
  57. package/dist/agent-src/rules/legal-safety-floor.md +2 -0
  58. package/dist/agent-src/rules/lethal-trifecta-guard.md +2 -0
  59. package/dist/agent-src/rules/linked-projects-onboarding-gate.md +2 -0
  60. package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +2 -0
  61. package/dist/agent-src/rules/markdown-safe-codeblocks.md +2 -0
  62. package/dist/agent-src/rules/media-governance-routing.md +2 -0
  63. package/dist/agent-src/rules/media-sync-ground-truth.md +2 -0
  64. package/dist/agent-src/rules/minimal-safe-diff.md +2 -0
  65. package/dist/agent-src/rules/missing-tool-handling.md +2 -0
  66. package/dist/agent-src/rules/model-recommendation.md +2 -0
  67. package/dist/agent-src/rules/no-attribution-footers.md +2 -0
  68. package/dist/agent-src/rules/no-decorative-emojis-in-git-surfaces.md +2 -0
  69. package/dist/agent-src/rules/no-pr-progress-comments.md +2 -0
  70. package/dist/agent-src/rules/no-roadmap-references.md +2 -0
  71. package/dist/agent-src/rules/notes-first-reasoning.md +2 -0
  72. package/dist/agent-src/rules/onboarding-gate.md +2 -0
  73. package/dist/agent-src/rules/output-discipline.md +2 -0
  74. package/dist/agent-src/rules/package-ci-checks.md +2 -0
  75. package/dist/agent-src/rules/persona-governance.md +2 -0
  76. package/dist/agent-src/rules/php-coding.md +2 -0
  77. package/dist/agent-src/rules/prefer-enums-over-literals.md +2 -0
  78. package/dist/agent-src/rules/preservation-guard.md +2 -0
  79. package/dist/agent-src/rules/provider-lifecycle-discipline.md +2 -0
  80. package/dist/agent-src/rules/question-not-instruction.md +2 -0
  81. package/dist/agent-src/rules/reviewer-awareness.md +2 -0
  82. package/dist/agent-src/rules/roadmap-ci-steps-policy.md +2 -0
  83. package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
  84. package/dist/agent-src/rules/role-mode-adherence.md +2 -0
  85. package/dist/agent-src/rules/rule-type-governance.md +2 -0
  86. package/dist/agent-src/rules/runtime-safety.md +2 -0
  87. package/dist/agent-src/rules/scale-discipline.md +2 -0
  88. package/dist/agent-src/rules/secret-vcs-guard.md +2 -0
  89. package/dist/agent-src/rules/security-sensitive-stop.md +2 -0
  90. package/dist/agent-src/rules/senior-engineering-discipline.md +2 -0
  91. package/dist/agent-src/rules/session-canary.md +63 -19
  92. package/dist/agent-src/rules/settings-ask-protocol.md +180 -0
  93. package/dist/agent-src/rules/size-enforcement.md +2 -0
  94. package/dist/agent-src/rules/skill-improvement-trigger.md +2 -0
  95. package/dist/agent-src/rules/skill-quality.md +2 -0
  96. package/dist/agent-src/rules/slash-command-routing-policy.md +2 -0
  97. package/dist/agent-src/rules/source-confidentiality.md +2 -0
  98. package/dist/agent-src/rules/source-discovery-gate.md +2 -0
  99. package/dist/agent-src/rules/source-of-truth.md +2 -0
  100. package/dist/agent-src/rules/spreadsheet-source-quality.md +2 -0
  101. package/dist/agent-src/rules/strategy-safety-floor.md +2 -0
  102. package/dist/agent-src/rules/symfony-routing.md +2 -0
  103. package/dist/agent-src/rules/think-before-action.md +2 -0
  104. package/dist/agent-src/rules/token-budget-discipline.md +8 -3
  105. package/dist/agent-src/rules/token-efficiency.md +2 -0
  106. package/dist/agent-src/rules/token-optimizer-maintenance.md +2 -0
  107. package/dist/agent-src/rules/tool-safety.md +2 -0
  108. package/dist/agent-src/rules/ui-audit-gate.md +7 -0
  109. package/dist/agent-src/rules/untrusted-input-defense.md +2 -0
  110. package/dist/agent-src/rules/upstream-proposal.md +2 -0
  111. package/dist/agent-src/rules/user-interaction.md +5 -2
  112. package/dist/agent-src/rules/user-interrupt-priority.md +2 -0
  113. package/dist/agent-src/skills/recursive-verification/SKILL.md +13 -0
  114. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  115. package/dist/cli/registry.js +2 -0
  116. package/dist/cli/registry.js.map +1 -1
  117. package/dist/cli-delegate/{chunk-SJQMJI7M.js → chunk-3KJIMFTB.js} +1 -1
  118. package/dist/cli-delegate/{chunk-BSLUHV2F.js → chunk-BCPIOAGV.js} +148 -133
  119. package/dist/cli-delegate/cmd_conformance.js +1 -1
  120. package/dist/cli-delegate/cmd_doctor.js +1 -1
  121. package/dist/cli-delegate/cmd_export.js +1 -1
  122. package/dist/cli-delegate/cmd_prune.js +1 -1
  123. package/dist/cli-delegate/cmd_route_audit.js +4 -1
  124. package/dist/cli-delegate/cmd_route_explain.js +4 -1
  125. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  126. package/dist/cli-delegate/cmd_sync.js +1 -1
  127. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  128. package/dist/cli-delegate/cmd_validate.js +1 -1
  129. package/dist/discovery/deprecation-report.md +1 -1
  130. package/dist/discovery/discovery-manifest.json +191 -120
  131. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  132. package/dist/discovery/discovery-manifest.summary.md +4 -4
  133. package/dist/discovery/orphan-report.md +1 -1
  134. package/dist/discovery/packs.json +11 -8
  135. package/dist/discovery/trust-report.md +3 -3
  136. package/dist/discovery/workspaces.json +9 -6
  137. package/dist/hooks/dispatch.js +1148 -349
  138. package/dist/install/install.mjs +1 -1
  139. package/dist/mcp/registry-manifest.json +3 -3
  140. package/dist/mcp/server.json +2 -2
  141. package/dist/mcp/server.mjs +571 -417
  142. package/dist/router.json +1 -1
  143. package/dist/scripts/condense.js +33 -2
  144. package/dist/scripts/condense.js.map +1 -1
  145. package/dist/server/io/yamlIO.js +85 -0
  146. package/dist/server/io/yamlIO.js.map +1 -1
  147. package/dist/server/nicknameResolver.js +116 -0
  148. package/dist/server/nicknameResolver.js.map +1 -0
  149. package/dist/server/routes/ping.js +29 -11
  150. package/dist/server/routes/ping.js.map +1 -1
  151. package/dist/server/routes/settings.js +1 -12
  152. package/dist/server/routes/settings.js.map +1 -1
  153. package/dist/server/routes/wizard.js +52 -2
  154. package/dist/server/routes/wizard.js.map +1 -1
  155. package/dist/shared/interactiveContext.js +73 -0
  156. package/dist/shared/interactiveContext.js.map +1 -0
  157. package/dist/shared/nicknamePrefill.js +59 -0
  158. package/dist/shared/nicknamePrefill.js.map +1 -0
  159. package/dist/shared/settingsAsks.js +128 -0
  160. package/dist/shared/settingsAsks.js.map +1 -0
  161. package/dist/shared/settingsCarveOut.js +118 -0
  162. package/dist/shared/settingsCarveOut.js.map +1 -0
  163. package/dist/shared/settingsConsent.js +93 -0
  164. package/dist/shared/settingsConsent.js.map +1 -0
  165. package/dist/shared/settingsSurface.js +7 -1
  166. package/dist/shared/settingsSurface.js.map +1 -1
  167. package/dist/ui/assets/index-DmR0mbk4.js.map +1 -1
  168. package/package.json +1 -1
  169. package/src/config/agents-paths.yml +15 -0
  170. package/src/config/evaluator-budgets.json +4 -4
  171. package/src/config/gate-coverage.yml +29 -1
  172. package/src/config/gate-violation-baselines.json +13 -7
  173. package/src/scripts/_cli/cmd_doctor.ts +6 -21
  174. package/src/scripts/_cli/cmd_route_audit.ts +4 -1
  175. package/src/scripts/_cli/cmd_route_explain.ts +9 -1
  176. package/src/scripts/_dispatch.bash +18 -0
  177. package/src/scripts/_lib/git_common_dir.ts +128 -0
  178. package/src/scripts/_lib/obligation_frequency.ts +391 -0
  179. package/src/scripts/_lib/session_register.ts +361 -0
  180. package/src/scripts/ai-video/stitch.sh +27 -9
  181. package/src/scripts/before_complete_hook.ts +23 -4
  182. package/src/scripts/bench_ab_v2_run.ts +272 -16
  183. package/src/scripts/bench_ab_v2_stats.ts +156 -3
  184. package/src/scripts/check_completion_review.ts +80 -3
  185. package/src/scripts/check_corpus_staleness.ts +692 -0
  186. package/src/scripts/check_enforcement_coverage.ts +192 -7
  187. package/src/scripts/check_gate_coverage.ts +67 -0
  188. package/src/scripts/check_hook_bundle_freshness.ts +183 -0
  189. package/src/scripts/check_rule_projection_integrity.ts +364 -0
  190. package/src/scripts/condense.ts +36 -2
  191. package/src/scripts/conformance_scan.ts +82 -17
  192. package/src/scripts/dispatch_r2_reviewer.ts +31 -2
  193. package/src/scripts/generate_settings_reference.ts +232 -0
  194. package/src/scripts/git_authorization_hook.ts +75 -10
  195. package/src/scripts/hook_manifest.yaml +87 -27
  196. package/src/scripts/hooks/block_no_verify.ts +59 -2
  197. package/src/scripts/hooks/block_unauthorized_git.ts +199 -16
  198. package/src/scripts/hooks/concern_registry.ts +2 -0
  199. package/src/scripts/hooks/design_slop_hook.ts +13 -1
  200. package/src/scripts/hooks/dispatch_hook.ts +97 -2
  201. package/src/scripts/hooks/evidence_independence.ts +40 -6
  202. package/src/scripts/language_mirror_hook.ts +256 -17
  203. package/src/scripts/lint_abstraction_thresholds.ts +41 -8
  204. package/src/scripts/lint_agents_layout.ts +2 -0
  205. package/src/scripts/lint_hook_manifest.ts +29 -0
  206. package/src/scripts/memory_learn_hook.ts +109 -6
  207. package/src/scripts/orchestration_backfill.ts +354 -0
  208. package/src/scripts/pr_url_reminder_hook.ts +17 -2
  209. package/src/scripts/release.ts +180 -37
  210. package/src/scripts/schemas/review-findings.schema.json +99 -0
  211. package/src/scripts/schemas/rule.schema.json +5 -0
  212. package/src/scripts/session_canary_hook.ts +35 -3
  213. package/src/scripts/session_register_hook.ts +288 -0
  214. package/src/scripts/sessions_cli.ts +140 -0
  215. package/src/scripts/validate_frontmatter.ts +68 -0
  216. package/src/server/io/yamlIO.ts +87 -0
  217. package/src/shared/interactiveContext.ts +98 -0
  218. package/src/shared/nicknamePrefill.ts +96 -0
  219. package/src/shared/settingsAsks.ts +176 -0
  220. package/src/shared/settingsCarveOut.ts +143 -0
  221. package/src/shared/settingsConsent.ts +136 -0
  222. package/src/shared/settingsSurface.ts +7 -1
@@ -7,10 +7,14 @@
7
7
  *
8
8
  * WHY THIS EXISTS — the measured defect, not a general reminder.
9
9
  *
10
- * A conformance audit of 30 sessions (2026-08-06) found ~470 assistant turns
10
+ * A conformance audit of 30 sessions (2026-08-06) found assistant turns
11
11
  * answering a German prompt in English, across 11 sessions, three of them a
12
- * 100 % English run. The obvious reading is "the model ignored a rule". The
13
- * transcripts say otherwise:
12
+ * 100 % English run. The count this comment first carried, ~470, was an early
13
+ * intermediate figure and is superseded: that audit's own final baseline is
14
+ * **626**, and conformance round 5 (2026-08-07) measures **641** over an
15
+ * overlapping-but-not-identical 30-session window. Both are published side by
16
+ * side rather than treated as a trend, because the windows differ. The obvious
17
+ * reading is "the model ignored a rule". The transcripts say otherwise:
14
18
  *
15
19
  * **47 skill- and slash-command bodies arrive in the `user` role** across 21
16
20
  * of the 30 sessions. In the worst session the last user-role content before
@@ -33,15 +37,35 @@
33
37
  * context does not otherwise contain — which language the human wrote in.
34
38
  *
35
39
  * State: `agents/state/language-mirror.json`
36
- * { "language": "de"|"en"|"und", "detected_at": iso8601,
37
- * "prompt_chars": int, "de_markers": int, "en_markers": int,
38
- * "session_id": str }
40
+ * { "language": "de"|"en"|"und", "source": "prompt"|"system-locale",
41
+ * "detected_at": iso8601, "prompt_chars": int, "de_markers": int,
42
+ * "en_markers": int, "session_id": str }
39
43
  *
40
44
  * Undetermined prompts (a bare "1", "ok", a pasted URL) leave a previous pin
41
45
  * UNTOUCHED rather than clearing it — a short continuation does not change the
42
46
  * conversation's language, and clearing on every terse turn would reproduce the
43
47
  * exact drift this hook removes.
44
48
  *
49
+ * FIRST TURN OF A SESSION — the system-locale fallback.
50
+ *
51
+ * The keep-previous-pin rule has nothing to keep on the very first prompt. If
52
+ * that prompt is terse ("weiter", "1", a pasted URL) the verdict is `und`, no
53
+ * pin is written, and the turn runs with no pin at all — which is the drift
54
+ * this hook exists to remove, occurring precisely where the conversation has
55
+ * the least other evidence. So when there is no previous pin AND the prompt is
56
+ * undetermined, the environment's locale supplies a starting language, recorded
57
+ * as `source: "system-locale"` so the weaker provenance is legible rather than
58
+ * indistinguishable from a read of the user's own words.
59
+ *
60
+ * A locale pin is a floor, never a ceiling: the FIRST prompt carrying real
61
+ * markers replaces it, because `source: "prompt"` always wins. That is what
62
+ * makes "any later explicit statement by the user overrides it" true without a
63
+ * second mechanism — the ordinary path already outranks the fallback.
64
+ *
65
+ * Locales outside the classifier's two languages stay `und` on purpose. Mapping
66
+ * `fr_FR` to English because the classifier has no French would pin a language
67
+ * the user never wrote, which is worse than carrying no pin.
68
+ *
45
69
  * Never blocks (`fail_closed: false`, `severity: advisory`). Exit 0 always.
46
70
  */
47
71
  import fs from "node:fs";
@@ -52,6 +76,13 @@ import { atomic_write_json } from "./hooks/state_io.js";
52
76
  import { readHookStdin } from "./hooks/hook_stdin.js";
53
77
 
54
78
  const EXIT_ALLOW = 0;
79
+ // Severity is taken from the EXIT CODE, not from the `decision` field in the
80
+ // stdout payload. This hook shipped returning EXIT_ALLOW while writing
81
+ // {"decision":"warn", …}, so the dispatcher reduced it to `allow`, emitted
82
+ // nothing, and the pin reached the model on no path — a state write with no
83
+ // delivery, for the audit's largest failure class (626 turns). Round 2, found
84
+ // by tracing the delivery rather than re-reading the unit tests.
85
+ const EXIT_WARN = 2;
55
86
 
56
87
  export const STATE_FILE = path.join("agents", "state", "language-mirror.json");
57
88
 
@@ -68,7 +99,7 @@ function _isObject(v: unknown): v is JsonObject {
68
99
  * ("fixe die ci", "mach weiter") where content words carry no signal.
69
100
  */
70
101
  const DE_MARKERS =
71
- /\b(der|die|das|den|dem|und|ist|sind|nicht|kein|keine|mach|mache|bitte|warum|wieso|weshalb|weiter|kannst|sollst|soll|musst|muss|hast|habe|haben|wir|ich|du|dann|noch|auch|schon|wenn|aber|alle|mit|ohne|damit|dass|wurde|werden|gibt|nochmal|erstell|erstelle|erstelle|fehler|regeln|arbeite|analysiere|fixe|nimm|lege|prüfe|pruefe|schau|zeig|mir|dir|sich|eine|einen|einem|einer|vom|zum|zur|beim|im|am)\b|[äöüÄÖÜß]/gi;
102
+ /\b(der|die|das|den|dem|und|ist|sind|nicht|kein|keine|mach|mache|bitte|warum|wieso|weshalb|weiter|kannst|sollst|soll|musst|muss|hast|habe|haben|wir|ich|du|dann|noch|auch|schon|wenn|aber|alle|mit|ohne|damit|dass|wurde|werden|gibt|nochmal|erstell|erstelle|erstelle|fehler|regeln|arbeite|analysiere|fixe|nimm|lege|prüfe|pruefe|schau|zeig|mir|dir|sich|eine|einen|einem|einer|vom|zum|zur|beim|im)\b|[äöüÄÖÜß]/gi;
72
103
 
73
104
  /**
74
105
  * English function words that a German sentence would not contain. Chosen to
@@ -96,9 +127,45 @@ export interface Classification {
96
127
  * Below `MIN_MARKERS` on both sides the verdict is `und` (undetermined) and the
97
128
  * caller keeps any previous pin.
98
129
  */
130
+ /**
131
+ * Strip the parts of a prompt that are PASTED rather than written.
132
+ *
133
+ * Without this the classifier reproduced the very defect it exists to fix: one
134
+ * German instruction plus a pasted English stack trace scored `en=18 / de=2`
135
+ * and pinned English — then asserted emphatically that English was the mirror
136
+ * target. The maintainer's most common prompt shape is exactly that.
137
+ */
138
+ export function instructionText(prompt: string): string {
139
+ const OUTPUT_HEAD =
140
+ /^\s*(To\s+\S+|remote:|error:|fatal:|hint:|warning:|npm ERR!|Error:|Traceback|Exception|[A-Za-z]*Error\b|at\s+\S+:\d+|\s{4,}\S|[-+]{3}\s|@@\s|\$\s|>\s|\||\d+\s*\|)/i;
141
+ const kept: string[] = [];
142
+ let inPaste = false;
143
+ for (const line of prompt.replace(/```[\s\S]*?```/g, "\n\n").split("\n")) {
144
+ if (!line.trim()) {
145
+ // A blank line ends a pasted block — this is how pastes actually look,
146
+ // and it is what makes the filter work on a MULTI-LINE trace whose
147
+ // continuation lines carry no marker of their own.
148
+ inPaste = false;
149
+ kept.push(line);
150
+ continue;
151
+ }
152
+ if (OUTPUT_HEAD.test(line)) {
153
+ inPaste = true;
154
+ continue;
155
+ }
156
+ if (inPaste) {
157
+ continue;
158
+ }
159
+ kept.push(line);
160
+ }
161
+ return kept.join("\n");
162
+ }
163
+
164
+
99
165
  export function classify(prompt: string): Classification {
100
- const de = (prompt.match(DE_MARKERS) ?? []).length;
101
- const en = (prompt.match(EN_MARKERS) ?? []).length;
166
+ const text = instructionText(prompt);
167
+ const de = (text.match(DE_MARKERS) ?? []).length;
168
+ const en = (text.match(EN_MARKERS) ?? []).length;
102
169
  if (de < MIN_MARKERS && en < MIN_MARKERS) {
103
170
  return { language: "und", de_markers: de, en_markers: en };
104
171
  }
@@ -110,9 +177,97 @@ const LANGUAGE_NAME: Record<Exclude<Verdict, "und">, string> = {
110
177
  en: "English",
111
178
  };
112
179
 
180
+ /**
181
+ * How a pin came to be — the provenance the roadmap's Phase 4 asks for.
182
+ *
183
+ * `prompt` means the user's own words carried the markers. `system-locale`
184
+ * means they did not and the environment answered instead. The distinction is
185
+ * load-bearing rather than decorative: a locale pin is the one a user may
186
+ * legitimately want to contradict, and the notice says which it is so that
187
+ * contradicting it does not require guessing where it came from.
188
+ */
189
+ export type PinSource = "prompt" | "system-locale";
190
+
191
+ /**
192
+ * The env vars that carry a POSIX locale, most specific first.
193
+ *
194
+ * `LC_ALL` overrides everything by definition; `LC_MESSAGES` governs the
195
+ * language of messages specifically, which is exactly this question; `LANG` is
196
+ * the fallback for both. `LANGUAGE` is deliberately last — it is a GNU
197
+ * extension holding a colon-separated PREFERENCE LIST, so it answers a
198
+ * different question and only its first entry is comparable.
199
+ */
200
+ const LOCALE_ENV_KEYS = ["LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"] as const;
201
+
202
+ /**
203
+ * Map an environment locale onto the classifier's verdict space.
204
+ *
205
+ * Returns `und` for a locale the classifier cannot mirror, for `C`/`POSIX`
206
+ * (which name no human language), and for an unset environment. Only the
207
+ * primary subtag is read: `de_AT.UTF-8`, `de-CH`, and `de` are one answer.
208
+ */
209
+ export function systemLocaleVerdict(env: NodeJS.ProcessEnv = process.env): Verdict {
210
+ for (const key of LOCALE_ENV_KEYS) {
211
+ const raw = (env[key] ?? "").trim();
212
+ if (!raw) {
213
+ continue;
214
+ }
215
+ // `LANGUAGE` may hold `de:en:fr`; the leading entry is the preference.
216
+ const first = raw.split(":")[0] ?? "";
217
+ // Strip the territory and codeset: `de_AT.UTF-8@euro` → `de`.
218
+ const primary = (first.split(/[._@-]/)[0] ?? "").toLowerCase();
219
+ if (primary === "c" || primary === "posix" || !primary) {
220
+ continue;
221
+ }
222
+ if (primary === "de") {
223
+ return "de";
224
+ }
225
+ if (primary === "en") {
226
+ return "en";
227
+ }
228
+ // A locale the classifier has no markers for. Stop rather than fall
229
+ // through to a less specific variable: the user HAS stated a language and
230
+ // it is neither of the two, so the honest answer is "no pin".
231
+ return "und";
232
+ }
233
+ return "und";
234
+ }
235
+
236
+ /**
237
+ * The one-line notice the dispatcher surfaces for this turn.
238
+ *
239
+ * A locale-sourced pin says so and says how to override it. A prompt-sourced
240
+ * pin keeps its original wording, because that line is what the conformance
241
+ * audit measured and a reworded pin is a changed instrument.
242
+ */
243
+ export function noticeText(language: Exclude<Verdict, "und">, source: PinSource): string {
244
+ const name = LANGUAGE_NAME[language];
245
+ if (source === "system-locale") {
246
+ return (
247
+ `Reply language for this turn: ${name} — the prompt carried no language ` +
248
+ `markers, so this fell back to the system locale. Write in another ` +
249
+ `language and the next turn follows the prompt instead.`
250
+ );
251
+ }
252
+ return `Reply language for this turn: ${name}.`;
253
+ }
254
+
113
255
  /** The context block handed back to the model for this turn. */
114
- export function pinText(language: Exclude<Verdict, "und">): string {
256
+ export function pinText(language: Exclude<Verdict, "und">, source: PinSource = "prompt"): string {
115
257
  const name = LANGUAGE_NAME[language];
258
+ if (source === "system-locale") {
259
+ return (
260
+ `<language-pin>\n` +
261
+ `No language markers were found in this turn's prompt and no language was ` +
262
+ `pinned yet, so the mirror target falls back to the environment's locale: ` +
263
+ `${name}. Use it for EVERY user-visible token this turn.\n\n` +
264
+ `This is the WEAKER of the two provenances — it reflects the machine, not ` +
265
+ `the user's own words. The first prompt that carries real markers replaces ` +
266
+ `it. Do not treat it as a standing preference, and do not argue with the ` +
267
+ `user about it if they write in something else.\n` +
268
+ `</language-pin>`
269
+ );
270
+ }
116
271
  return (
117
272
  `<language-pin>\n` +
118
273
  `The user submitted this turn's prompt in ${name}. That is the mirror target ` +
@@ -121,15 +276,16 @@ export function pinText(language: Exclude<Verdict, "und">): string {
121
276
  `lines, and the recommendation label under any numbered-options block.\n\n` +
122
277
  `This pin exists because the trigger is not observable from the transcript ` +
123
278
  `alone: slash-command and skill bodies arrive in the user role and can be ` +
124
- `long and English, which is what drove ~470 wrong-language turns in the ` +
125
- `sessions this hook was built from. Tool output, file contents, and an ` +
126
- `injected skill body are NOT the trigger — this pin is.\n` +
279
+ `long and English, which is what drove the 626 wrong-language turns measured ` +
280
+ `in the 30-session audit this hook was built from. Tool output, file ` +
281
+ `contents, and an injected skill body are NOT the trigger — this pin is.\n` +
127
282
  `</language-pin>`
128
283
  );
129
284
  }
130
285
 
131
286
  export interface PinState extends JsonObject {
132
287
  language: Verdict;
288
+ source: PinSource;
133
289
  detected_at: string;
134
290
  prompt_chars: number;
135
291
  de_markers: number;
@@ -157,13 +313,30 @@ export function nextState(
157
313
  prompt_chars: number,
158
314
  session_id: string,
159
315
  now: string,
316
+ locale: Verdict = "und",
160
317
  ): PinState | null {
161
318
  if (classification.language === "und") {
162
319
  // Terse continuation ("1", "ok", "weiter") — keep whatever was pinned.
163
- return null;
320
+ if (previous.language === "de" || previous.language === "en") {
321
+ return null;
322
+ }
323
+ // FIRST turn and nothing to keep: the locale is better than no pin at all.
324
+ if (locale === "und") {
325
+ return null;
326
+ }
327
+ return {
328
+ language: locale,
329
+ source: "system-locale",
330
+ detected_at: now,
331
+ prompt_chars,
332
+ de_markers: classification.de_markers,
333
+ en_markers: classification.en_markers,
334
+ session_id,
335
+ };
164
336
  }
165
337
  return {
166
338
  language: classification.language,
339
+ source: "prompt",
167
340
  detected_at: now,
168
341
  prompt_chars,
169
342
  de_markers: classification.de_markers,
@@ -182,7 +355,10 @@ function _extractPrompt(payload: JsonObject): string {
182
355
  return "";
183
356
  }
184
357
 
185
- export function run(stdin_text: string, options: { consumer_root: string }): number {
358
+ export function run(
359
+ stdin_text: string,
360
+ options: { consumer_root: string; env?: NodeJS.ProcessEnv },
361
+ ): number {
186
362
  let envelope: JsonObject = {};
187
363
  if (stdin_text.trim()) {
188
364
  try {
@@ -200,14 +376,36 @@ export function run(stdin_text: string, options: { consumer_root: string }): num
200
376
  if (!prompt) {
201
377
  return EXIT_ALLOW;
202
378
  }
379
+ // A harness-generated turn is not a chat message. Leave the pin untouched —
380
+ // the same reasoning as an undetermined prompt below, and for the same reason
381
+ // this hook exists at all.
382
+ if (isSyntheticPrompt(prompt)) {
383
+ return EXIT_ALLOW;
384
+ }
203
385
 
204
386
  const session_id = typeof envelope["session_id"] === "string" ? envelope["session_id"] : "";
205
387
  const target = path.join(options.consumer_root, STATE_FILE);
206
388
  const previous = _loadState(target);
207
389
  const classification = classify(prompt);
208
- const next = nextState(previous, classification, prompt.length, session_id, new Date().toISOString());
390
+ const next = nextState(
391
+ previous,
392
+ classification,
393
+ prompt.length,
394
+ session_id,
395
+ new Date().toISOString(),
396
+ // Injectable so the suite can pin a locale instead of inheriting the
397
+ // machine's — a test whose verdict depends on the developer's `LANG` is a
398
+ // test that passes here and fails in CI for reasons unrelated to the diff.
399
+ systemLocaleVerdict(options.env ?? process.env),
400
+ );
209
401
 
210
402
  const effective = next?.language ?? (previous.language as Verdict | undefined);
403
+ // A state file written before `source` existed carries only what the old
404
+ // version could produce — a prompt reading. Absent is therefore `prompt`,
405
+ // never "unknown": treating it as unknown would reword the audited notice
406
+ // for every pre-existing session.
407
+ const effectiveSource: PinSource =
408
+ next?.source ?? (previous.source === "system-locale" ? "system-locale" : "prompt");
211
409
  if (next) {
212
410
  try {
213
411
  atomic_write_json(target, next);
@@ -218,12 +416,53 @@ export function run(stdin_text: string, options: { consumer_root: string }): num
218
416
 
219
417
  if (effective === "de" || effective === "en") {
220
418
  process.stdout.write(
221
- `${JSON.stringify({ decision: "warn", reason: `Reply language for this turn: ${LANGUAGE_NAME[effective]}.`, additional_context: pinText(effective) })}\n`,
419
+ `${JSON.stringify({ decision: "warn", reason: noticeText(effective, effectiveSource), additional_context: pinText(effective, effectiveSource) })}\n`,
222
420
  );
421
+ return EXIT_WARN;
223
422
  }
224
423
  return EXIT_ALLOW;
225
424
  }
226
425
 
426
+ /**
427
+ * Is this "prompt" a harness-generated turn rather than something the human typed?
428
+ *
429
+ * MEASURED, IN THIS HOOK'S OWN AUDIT (round 5, 2026-08-07). The state file read
430
+ * mid-session:
431
+ *
432
+ * { "language": "en", "source": "prompt", "prompt_chars": 6627,
433
+ * "de_markers": 0, "en_markers": 63 }
434
+ *
435
+ * No human wrote those 6,627 characters. They were a background-task completion
436
+ * notification, injected as a user turn, which `user_prompt_submit` sees and this
437
+ * hook classified as the trigger — flipping a German session to `en` for every
438
+ * later turn, and recording `source: "prompt"`, a false provenance claim.
439
+ *
440
+ * This is the SAME defect the hook was built to remove: non-chat content read as
441
+ * the trigger. The header above explains at length why a skill body must not set
442
+ * the pin; `user_prompt_submit` was chosen because "a skill body never reaches
443
+ * it". Synthetic turns do reach it, so the event alone was never sufficient — the
444
+ * content test has to exist too.
445
+ *
446
+ * The markers are structural, not linguistic: the harness stamps these turns
447
+ * with fixed envelope tags. Matching prose would misfire on a human quoting a
448
+ * notification; matching the tags cannot, because a human typing `<task-notification>`
449
+ * is quoting, and a quote does not open at character zero of the turn.
450
+ *
451
+ * Deliberately conservative: an unrecognised synthetic shape falls through and
452
+ * pins as before. Under-filtering keeps today's behaviour; over-filtering would
453
+ * silently stop pinning real prompts, which is the worse failure.
454
+ */
455
+ export function isSyntheticPrompt(prompt: string): boolean {
456
+ const head = prompt.trimStart().slice(0, 400);
457
+ return (
458
+ head.startsWith("[SYSTEM NOTIFICATION - NOT USER INPUT]") ||
459
+ head.startsWith("<task-notification>") ||
460
+ head.startsWith("<system-reminder>") ||
461
+ head.startsWith("<local-command-caveat>") ||
462
+ /^\[SYSTEM NOTIFICATION\b/.test(head)
463
+ );
464
+ }
465
+
227
466
  export function main(argv?: string[]): number {
228
467
  const args = argv ?? process.argv.slice(2);
229
468
  let consumer_root = process.cwd();
@@ -16,10 +16,18 @@
16
16
  * `abstraction-thresholds` (the canon citation). A bare number is drift.
17
17
  * A heading is its own block; a threshold-stating heading is satisfied
18
18
  * when the block immediately below it carries the citation.
19
- * 2. PINNING — the six deliberate sites are pinned to their current
20
- * number + scope (SITES), and the canon's four table rows are pinned
21
- * (CANON_ROWS). Changing either side without the other is a finding:
22
- * the canon and its citing sites move together or not at all.
19
+ * 2. PINNING — every deliberate site is pinned to its current scope
20
+ * (SITES), and the canon's four table rows are pinned (CANON_ROWS).
21
+ * Changing either side without the other is a finding: the canon and
22
+ * its citing sites move together or not at all.
23
+ *
24
+ * The site COUNT is deliberately not written here. This comment and
25
+ * the one above `check_pins` both said "six" while `SITES` held seven
26
+ * (two entries share `component-oriented-and-oop-development.md`, so
27
+ * six distinct files became seven pins). Nothing pinned the literal —
28
+ * the test derives its expectation from `SITES.length` — so the number
29
+ * was free to rot, and did. Read the count off `SITES`; do not restate
30
+ * it in prose that no gate checks.
23
31
  *
24
32
  * ## Regex tuning record (2026-08-03, measured against the real tree)
25
33
  *
@@ -65,12 +73,37 @@ export const CANON_CITATION = 'abstraction-thresholds';
65
73
  export const THRESHOLD_VERB = /\b(extract\w*|componenti[sz]\w*|abstraction)\b/i;
66
74
 
67
75
  /**
68
- * … and (b) states a numeric repetition bar. Three branches:
76
+ * … and (b) states a numeric repetition bar. Four branches:
69
77
  * cardinal count + repetition-noun · ordinal (repetition|occurrence only —
70
- * see the tuning record above) · `≥ N` + repetition-noun.
78
+ * see the tuning record above) · `≥ N` + repetition-noun · a repetition VERB
79
+ * carrying its count as a word-form adverb (`once` / `twice` / `thrice`).
80
+ *
81
+ * The fourth branch closes a measured hole: "extract when duplicated twice"
82
+ * states a real numeric bar and matched NOTHING. The cardinal branch needs a
83
+ * numeral from its own alternation immediately before a repetition-noun, and in
84
+ * that phrasing the count IS the adverb — there is no numeral and no noun. So
85
+ * the most natural English phrasing of the bar was the one shape that passed
86
+ * the gate silently.
87
+ *
88
+ * It is verb-anchored on purpose. A bare `\b(once|twice|thrice)\b` would fire on
89
+ * any block that happens to say "run it twice" near the word "extract"; binding
90
+ * the adverb to a duplication verb keeps the qualitative prose the tuning record
91
+ * protects finding-free, and adds no allowlist.
92
+ *
93
+ * The verb set is narrow because a wider one was MEASURED and rejected. A first
94
+ * cut also accepted `appears|occurs|used`, ran against the real tree, and
95
+ * returned exactly one hit: `gated-reach/SKILL.md`'s reddit-parsing gotcha, "one
96
+ * id **appears twice**, once as the real comment and once as a … stub". Its block
97
+ * is the whole Gotcha bullet list, which separately contains "extraction" — in
98
+ * the yt-dlp sense, an unrelated meaning of the word. Both halves fired on
99
+ * vocabulary coincidence. Dropping the three weak verbs kills that hit without an
100
+ * allowlist and still catches the reported phrasing (`duplicat\w+` covers
101
+ * "duplicated twice"). Widening the verb set again means re-measuring: the block
102
+ * granularity is blank-line-coarse, so a weak verb only has to co-occur with a
103
+ * homonym of "extract" somewhere in the same paragraph run.
71
104
  */
72
105
  export const NUMERIC_REPETITION =
73
- /(\b(one|two|three|four|five|\d+)\s*(\+|×|x)?\s*(real\s+)?(repetitions?|repeats?|times|uses?|duplications?|occurrences?)\b)|(\b(second|third|fourth)\s+(real\s+)?(repetitions?|occurrences?)\b)|(≥\s*\d+\s*(real\s+)?(repetitions?|repeats?|times|uses?|duplications?|occurrences?))/i;
106
+ /(\b(one|two|three|four|five|\d+)\s*(\+|×|x)?\s*(real\s+)?(repetitions?|repeats?|times|uses?|duplications?|occurrences?)\b)|(\b(second|third|fourth)\s+(real\s+)?(repetitions?|occurrences?)\b)|(≥\s*\d+\s*(real\s+)?(repetitions?|repeats?|times|uses?|duplications?|occurrences?))|(\b(duplicat\w+|repeat\w+|recur\w+)\s+(only\s+)?(once|twice|thrice)\b)/i;
74
107
 
75
108
  /** The canon's four table rows — all must match CANON_FILE. */
76
109
  export const CANON_ROWS: ReadonlyArray<{ row: RegExp; why: string }> = [
@@ -249,7 +282,7 @@ export function scan_citations(repoRoot: string): { findings: Finding[]; scanned
249
282
  return { findings, scanned };
250
283
  }
251
284
 
252
- /** Bidirectional pins: canon rows + the six citing sites. */
285
+ /** Bidirectional pins: canon rows + every citing site in SITES. */
253
286
  export function check_pins(repoRoot: string): Finding[] {
254
287
  const findings: Finding[] = [];
255
288
 
@@ -63,6 +63,7 @@ const ALLOWED_SOURCE_DIRS: ReadonlySet<string> = new Set([
63
63
  'notes', // legacy; new files go to evidence/notes/
64
64
  'reports', // legacy; new files go to evidence/reports/ (see agents-layout.md)
65
65
  'overrides', // * consumer
66
+ 'proposals', // * consumer — curated self-improvement proposals, tracked
66
67
  'recruit-sessions',
67
68
  'reference',
68
69
  'roadmap-assets',
@@ -88,6 +89,7 @@ const CONSUMER_EXPECTED_ENTRIES: ReadonlySet<string> = new Set([
88
89
  // Optional but legitimate consumer entries (will not trigger warnings)
89
90
  'knowledge',
90
91
  'memory',
92
+ 'proposals',
91
93
  'roadmaps',
92
94
  'tmp',
93
95
  'tmp.old',
@@ -148,6 +148,35 @@ function _check_concerns(manifest: YamlObject, errors: string[]): Set<string> {
148
148
  if (!_isFile(path.join(REPO_ROOT, script))) {
149
149
  errors.push(`concerns.${name}: script not found at '${script}'`);
150
150
  }
151
+ // `tools:` — the optional per-concern tool filter the dispatcher applies
152
+ // in-process (`_concern_matches_tool`). Validated because the failure mode
153
+ // is silent: the dispatcher fails toward RUNNING the concern on anything
154
+ // malformed, so a typo (`tool:`, a bare string, `[]`) would look like a
155
+ // working filter while filtering nothing. An unvalidated key is worse than
156
+ // no key.
157
+ if ("tools" in spec) {
158
+ const tools = spec["tools"];
159
+ if (!Array.isArray(tools)) {
160
+ errors.push(
161
+ `concerns.${name}: 'tools' must be a list of tool names ` +
162
+ `(got ${pyTypeName(tools)}) — omit the key for "every event"`,
163
+ );
164
+ } else if (tools.length === 0) {
165
+ errors.push(
166
+ `concerns.${name}: 'tools' is an empty list — omit the key, or use ` +
167
+ `["*"], rather than a filter that reads as "no tools"`,
168
+ );
169
+ } else {
170
+ for (const t of tools) {
171
+ if (typeof t !== "string" || t.trim() === "") {
172
+ errors.push(
173
+ `concerns.${name}: 'tools' entries must be non-empty strings, ` +
174
+ `got ${pyTypeName(t)}`,
175
+ );
176
+ }
177
+ }
178
+ }
179
+ }
151
180
  names.add(name);
152
181
  }
153
182
  return names;
@@ -29,6 +29,20 @@ import fs from 'node:fs';
29
29
  import path from 'node:path';
30
30
  import { fileURLToPath, pathToFileURL } from 'node:url';
31
31
 
32
+ // This hook is the first in the tree to import from `src/shared/`, and its
33
+ // contract is fail-open — but the `try/catch` that delivers that lives in
34
+ // `main()`, so an import-time throw would escape it. The concern is real and
35
+ // the answer is measured rather than assumed: esbuild INLINES both modules into
36
+ // `dist/hooks/dispatch.js` (verified by building the bundle to a scratch path
37
+ // and grepping for `consentVerdict` and `withheld-machine-inferred`), so the
38
+ // installed path performs no runtime module resolution at all. Both modules are
39
+ // also pure declarations with zero top-level side effects, so there is nothing
40
+ // at import time that can throw. If either fact stops holding — a dynamic
41
+ // import, a module-level read — the fail-open guarantee weakens and this
42
+ // import has to move inside the guard.
43
+ import type { SettingsClass } from '../shared/settingsClasses.js';
44
+ import { consentVerdict, type ConsentVerdict } from '../shared/settingsConsent.js';
45
+
32
46
  import {
33
47
  buildSidecar,
34
48
  LESSONS_NAME,
@@ -40,18 +54,54 @@ const SETTINGS_FILE = '.agent-settings.yml';
40
54
  /** Wall-clock budget for the whole aggregation (ms). */
41
55
  export const BUDGET_MS = 2000;
42
56
 
57
+ /** The key this hook is gated on. */
58
+ export const LEARN_KEY = 'memory.learn_on_session_end';
59
+
43
60
  /**
44
- * Minimal `.agent-settings.yml` reader for `memory.learn_on_session_end`
45
- * (same hand-rolled mini-parser pattern as the sibling hooks — no YAML
46
- * dependency in the hook hot path).
61
+ * The key's class, pinned rather than parsed.
62
+ *
63
+ * `classOfPath` would give the same answer, but only after reading and parsing
64
+ * `docs/contracts/settings-classes.md` — a multi-hundred-row markdown file — on
65
+ * every session teardown, which is exactly the cost the 2 s budget above exists
66
+ * to avoid. The drift risk a hardcoded constant carries is neutralised the
67
+ * cheap way instead: `tests/scripts/memory_learn_hook.test.ts` asserts the
68
+ * contract still classifies this key `B`, so a reclassification reds CI rather
69
+ * than silently unbinding the gate.
47
70
  */
48
- export function enabled(root: string): boolean {
71
+ export const LEARN_KEY_CLASS: SettingsClass = 'B';
72
+
73
+ /**
74
+ * Whether `memory.learn_on_session_end` reads as enabled, or `undefined` when
75
+ * the file or the key is absent (same hand-rolled mini-parser pattern as the
76
+ * sibling hooks — no YAML dependency in the hook hot path).
77
+ *
78
+ * Normalised strictly: the literal `true` and nothing else enables the hook. A
79
+ * crude parser must not let `yes` / `1` / `maybe` read as a permission, and
80
+ * `isConservativeDefault` would treat any non-empty string as permissive — so
81
+ * the normalisation happens here, before the consent check, not inside it.
82
+ *
83
+ * WHAT THE TRI-STATE DOES AND DOES NOT SAY. `undefined` means "no answer was
84
+ * found" — the key is absent, the file is absent, OR the file could not be read
85
+ * at all (permissions, a directory at the path, an I/O error). `false` means
86
+ * EITHER a deliberate `false` OR a present-but-malformed scalar. So there are
87
+ * two collapses, not one: a user who writes `learn_on_session_end: yes` and a
88
+ * user whose settings file is unreadable both get a silent, permanent no-op
89
+ * with no diagnostic.
90
+ *
91
+ * The fail-safe direction is deliberate in both cases — a hook that writes
92
+ * files must not be enabled by a value nobody typed on purpose, nor by a file
93
+ * nobody could read. The ambiguity is the cost, and it is stated here rather
94
+ * than implied by a "raw scalar" this function does not actually return.
95
+ * Diagnosing either shape belongs to `settings:check`, not to a fail-open
96
+ * teardown hook.
97
+ */
98
+ export function readLearnValue(root: string): boolean | undefined {
49
99
  const p = path.join(root, SETTINGS_FILE);
50
100
  let text: string;
51
101
  try {
52
102
  text = fs.readFileSync(p, 'utf8');
53
103
  } catch {
54
- return false;
104
+ return undefined;
55
105
  }
56
106
  let inMemory = false;
57
107
  for (const rawLine of text.split('\n')) {
@@ -69,7 +119,60 @@ export function enabled(root: string): boolean {
69
119
  if (m) return m[1] === 'true';
70
120
  }
71
121
  }
72
- return false;
122
+ return undefined;
123
+ }
124
+
125
+ /**
126
+ * The consent verdict for running the aggregation.
127
+ *
128
+ * This is the first production caller of `consentVerdict` — Phase 5 step 4 of
129
+ * `road-to-zero-ceremony-settings` asks the consent-gated action to verify the
130
+ * recorded DECISION rather than read the bare value, and this session_end
131
+ * concern is the only action in the tree that a class-B key actually gates.
132
+ *
133
+ * `handEdited: true` is a fact about the path, not a shortcut: the file read
134
+ * here is the PROJECT-LOCAL `.agent-settings.yml`, which
135
+ * `docs/contracts/settings-classes.md` names as a file only a human writes —
136
+ * `settings:set` and the GUI both write the user-global file instead. So on
137
+ * this path a permissive value IS the recorded decision and the verdict is
138
+ * `granted`.
139
+ *
140
+ * WHAT THIS CHANGES TODAY: nothing observable. Stated plainly because the
141
+ * opposite was claimed first and it was wrong. With `cls` a constant,
142
+ * `handEdited` a constant `true`, and the value already reduced to a boolean by
143
+ * `readLearnValue`, every discriminating input to `consentVerdict` is fixed, so
144
+ * `enabled()` returns exactly what the previous `=== 'true'` read returned —
145
+ * including for `yes` / `1` / `on`, which that read already rejected. The
146
+ * normalisation is not new and this call did not narrow it.
147
+ *
148
+ * WHAT IT DOES BUY, which is smaller than it sounds and worth being exact
149
+ * about: the obligation is expressed in code instead of prose, so
150
+ * `consentVerdict` has a real caller and stops being a library nobody invokes;
151
+ * the class binding is asserted against the contract by a test, so a
152
+ * reclassification reds CI (it does NOT re-route the hook at runtime — the
153
+ * class is a hardcoded constant); and the day this hook learns to read the
154
+ * user-global file, the sidecar check is already the thing standing in front of
155
+ * it rather than a change someone must remember to make.
156
+ *
157
+ * KNOWN GAP, recorded rather than fixed here: the hook reads only the
158
+ * project-local file, so a user who enables this in the GUI — which writes the
159
+ * user-global file plus the provenance sidecar — never triggers it. Reading the
160
+ * user-global file is where the sidecar's `auto-detected`-refusing power would
161
+ * actually discriminate, but it is also a behaviour change (a hook that writes
162
+ * files would start firing for users it does not fire for today), so it belongs
163
+ * to a decision rather than to this step. Filed in the roadmap.
164
+ */
165
+ export function learnConsent(root: string): ConsentVerdict {
166
+ return consentVerdict({
167
+ cls: LEARN_KEY_CLASS,
168
+ value: readLearnValue(root) ?? false,
169
+ handEdited: true,
170
+ });
171
+ }
172
+
173
+ /** `true` only when the recorded decision permits the aggregation. */
174
+ export function enabled(root: string): boolean {
175
+ return learnConsent(root) === 'granted';
73
176
  }
74
177
 
75
178
  /** Run the aggregation. Returns the one-line marker to emit, or null. */