@event4u/agent-config 9.25.0 → 9.26.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 (80) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +87 -0
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/conformance.md +2 -2
  5. package/dist/agent-src/commands/review/changes.md +6 -5
  6. package/dist/agent-src/commands/roadmap/next.md +194 -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/delegation-policy.md +3 -2
  10. package/dist/agent-src/rules/design-fidelity.md +17 -1
  11. package/dist/agent-src/rules/design-review-after-ui-write.md +116 -0
  12. package/dist/agent-src/rules/evaluator-independence.md +9 -3
  13. package/dist/agent-src/rules/session-canary.md +2 -0
  14. package/dist/agent-src/rules/settings-ask-protocol.md +176 -0
  15. package/dist/agent-src/rules/token-budget-discipline.md +6 -3
  16. package/dist/agent-src/rules/ui-audit-gate.md +3 -0
  17. package/dist/agent-src/rules/user-interaction.md +3 -2
  18. package/dist/agent-src/skills/recursive-verification/SKILL.md +13 -0
  19. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  20. package/dist/cli-delegate/cmd_route_audit.js +4 -1
  21. package/dist/cli-delegate/cmd_route_explain.js +4 -1
  22. package/dist/discovery/deprecation-report.md +1 -1
  23. package/dist/discovery/discovery-manifest.json +94 -23
  24. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  25. package/dist/discovery/discovery-manifest.summary.md +4 -4
  26. package/dist/discovery/orphan-report.md +1 -1
  27. package/dist/discovery/packs.json +11 -8
  28. package/dist/discovery/trust-report.md +3 -3
  29. package/dist/discovery/workspaces.json +9 -6
  30. package/dist/hooks/dispatch.js +355 -63
  31. package/dist/mcp/registry-manifest.json +3 -3
  32. package/dist/mcp/server.json +2 -2
  33. package/dist/router.json +1 -1
  34. package/dist/server/nicknameResolver.js +116 -0
  35. package/dist/server/nicknameResolver.js.map +1 -0
  36. package/dist/server/routes/ping.js +29 -11
  37. package/dist/server/routes/ping.js.map +1 -1
  38. package/dist/shared/interactiveContext.js +73 -0
  39. package/dist/shared/interactiveContext.js.map +1 -0
  40. package/dist/shared/nicknamePrefill.js +59 -0
  41. package/dist/shared/nicknamePrefill.js.map +1 -0
  42. package/dist/shared/settingsAsks.js +128 -0
  43. package/dist/shared/settingsAsks.js.map +1 -0
  44. package/dist/shared/settingsConsent.js +93 -0
  45. package/dist/shared/settingsConsent.js.map +1 -0
  46. package/package.json +1 -1
  47. package/src/config/agents-paths.yml +15 -0
  48. package/src/config/gate-coverage.yml +29 -1
  49. package/src/config/gate-violation-baselines.json +13 -7
  50. package/src/scripts/_cli/cmd_route_audit.ts +4 -1
  51. package/src/scripts/_cli/cmd_route_explain.ts +9 -1
  52. package/src/scripts/ai-video/stitch.sh +27 -9
  53. package/src/scripts/before_complete_hook.ts +23 -4
  54. package/src/scripts/bench_ab_v2_run.ts +272 -16
  55. package/src/scripts/bench_ab_v2_stats.ts +156 -3
  56. package/src/scripts/check_completion_review.ts +80 -3
  57. package/src/scripts/check_corpus_staleness.ts +692 -0
  58. package/src/scripts/check_gate_coverage.ts +67 -0
  59. package/src/scripts/check_hook_bundle_freshness.ts +183 -0
  60. package/src/scripts/conformance_scan.ts +47 -17
  61. package/src/scripts/dispatch_r2_reviewer.ts +31 -2
  62. package/src/scripts/git_authorization_hook.ts +75 -10
  63. package/src/scripts/hook_manifest.yaml +19 -0
  64. package/src/scripts/hooks/block_no_verify.ts +3 -1
  65. package/src/scripts/hooks/block_unauthorized_git.ts +133 -16
  66. package/src/scripts/hooks/design_slop_hook.ts +13 -1
  67. package/src/scripts/hooks/dispatch_hook.ts +97 -2
  68. package/src/scripts/hooks/evidence_independence.ts +40 -6
  69. package/src/scripts/language_mirror_hook.ts +200 -11
  70. package/src/scripts/lint_abstraction_thresholds.ts +41 -8
  71. package/src/scripts/lint_agents_layout.ts +2 -0
  72. package/src/scripts/lint_hook_manifest.ts +29 -0
  73. package/src/scripts/memory_learn_hook.ts +109 -6
  74. package/src/scripts/pr_url_reminder_hook.ts +17 -2
  75. package/src/scripts/release.ts +38 -1
  76. package/src/scripts/schemas/review-findings.schema.json +99 -0
  77. package/src/shared/interactiveContext.ts +98 -0
  78. package/src/shared/nicknamePrefill.ts +96 -0
  79. package/src/shared/settingsAsks.ts +176 -0
  80. package/src/shared/settingsConsent.ts +136 -0
@@ -33,15 +33,35 @@
33
33
  * context does not otherwise contain — which language the human wrote in.
34
34
  *
35
35
  * 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 }
36
+ * { "language": "de"|"en"|"und", "source": "prompt"|"system-locale",
37
+ * "detected_at": iso8601, "prompt_chars": int, "de_markers": int,
38
+ * "en_markers": int, "session_id": str }
39
39
  *
40
40
  * Undetermined prompts (a bare "1", "ok", a pasted URL) leave a previous pin
41
41
  * UNTOUCHED rather than clearing it — a short continuation does not change the
42
42
  * conversation's language, and clearing on every terse turn would reproduce the
43
43
  * exact drift this hook removes.
44
44
  *
45
+ * FIRST TURN OF A SESSION — the system-locale fallback.
46
+ *
47
+ * The keep-previous-pin rule has nothing to keep on the very first prompt. If
48
+ * that prompt is terse ("weiter", "1", a pasted URL) the verdict is `und`, no
49
+ * pin is written, and the turn runs with no pin at all — which is the drift
50
+ * this hook exists to remove, occurring precisely where the conversation has
51
+ * the least other evidence. So when there is no previous pin AND the prompt is
52
+ * undetermined, the environment's locale supplies a starting language, recorded
53
+ * as `source: "system-locale"` so the weaker provenance is legible rather than
54
+ * indistinguishable from a read of the user's own words.
55
+ *
56
+ * A locale pin is a floor, never a ceiling: the FIRST prompt carrying real
57
+ * markers replaces it, because `source: "prompt"` always wins. That is what
58
+ * makes "any later explicit statement by the user overrides it" true without a
59
+ * second mechanism — the ordinary path already outranks the fallback.
60
+ *
61
+ * Locales outside the classifier's two languages stay `und` on purpose. Mapping
62
+ * `fr_FR` to English because the classifier has no French would pin a language
63
+ * the user never wrote, which is worse than carrying no pin.
64
+ *
45
65
  * Never blocks (`fail_closed: false`, `severity: advisory`). Exit 0 always.
46
66
  */
47
67
  import fs from "node:fs";
@@ -52,6 +72,13 @@ import { atomic_write_json } from "./hooks/state_io.js";
52
72
  import { readHookStdin } from "./hooks/hook_stdin.js";
53
73
 
54
74
  const EXIT_ALLOW = 0;
75
+ // Severity is taken from the EXIT CODE, not from the `decision` field in the
76
+ // stdout payload. This hook shipped returning EXIT_ALLOW while writing
77
+ // {"decision":"warn", …}, so the dispatcher reduced it to `allow`, emitted
78
+ // nothing, and the pin reached the model on no path — a state write with no
79
+ // delivery, for the audit's largest failure class (626 turns). Round 2, found
80
+ // by tracing the delivery rather than re-reading the unit tests.
81
+ const EXIT_WARN = 2;
55
82
 
56
83
  export const STATE_FILE = path.join("agents", "state", "language-mirror.json");
57
84
 
@@ -68,7 +95,7 @@ function _isObject(v: unknown): v is JsonObject {
68
95
  * ("fixe die ci", "mach weiter") where content words carry no signal.
69
96
  */
70
97
  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;
98
+ /\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
99
 
73
100
  /**
74
101
  * English function words that a German sentence would not contain. Chosen to
@@ -96,9 +123,45 @@ export interface Classification {
96
123
  * Below `MIN_MARKERS` on both sides the verdict is `und` (undetermined) and the
97
124
  * caller keeps any previous pin.
98
125
  */
126
+ /**
127
+ * Strip the parts of a prompt that are PASTED rather than written.
128
+ *
129
+ * Without this the classifier reproduced the very defect it exists to fix: one
130
+ * German instruction plus a pasted English stack trace scored `en=18 / de=2`
131
+ * and pinned English — then asserted emphatically that English was the mirror
132
+ * target. The maintainer's most common prompt shape is exactly that.
133
+ */
134
+ export function instructionText(prompt: string): string {
135
+ const OUTPUT_HEAD =
136
+ /^\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;
137
+ const kept: string[] = [];
138
+ let inPaste = false;
139
+ for (const line of prompt.replace(/```[\s\S]*?```/g, "\n\n").split("\n")) {
140
+ if (!line.trim()) {
141
+ // A blank line ends a pasted block — this is how pastes actually look,
142
+ // and it is what makes the filter work on a MULTI-LINE trace whose
143
+ // continuation lines carry no marker of their own.
144
+ inPaste = false;
145
+ kept.push(line);
146
+ continue;
147
+ }
148
+ if (OUTPUT_HEAD.test(line)) {
149
+ inPaste = true;
150
+ continue;
151
+ }
152
+ if (inPaste) {
153
+ continue;
154
+ }
155
+ kept.push(line);
156
+ }
157
+ return kept.join("\n");
158
+ }
159
+
160
+
99
161
  export function classify(prompt: string): Classification {
100
- const de = (prompt.match(DE_MARKERS) ?? []).length;
101
- const en = (prompt.match(EN_MARKERS) ?? []).length;
162
+ const text = instructionText(prompt);
163
+ const de = (text.match(DE_MARKERS) ?? []).length;
164
+ const en = (text.match(EN_MARKERS) ?? []).length;
102
165
  if (de < MIN_MARKERS && en < MIN_MARKERS) {
103
166
  return { language: "und", de_markers: de, en_markers: en };
104
167
  }
@@ -110,9 +173,97 @@ const LANGUAGE_NAME: Record<Exclude<Verdict, "und">, string> = {
110
173
  en: "English",
111
174
  };
112
175
 
176
+ /**
177
+ * How a pin came to be — the provenance the roadmap's Phase 4 asks for.
178
+ *
179
+ * `prompt` means the user's own words carried the markers. `system-locale`
180
+ * means they did not and the environment answered instead. The distinction is
181
+ * load-bearing rather than decorative: a locale pin is the one a user may
182
+ * legitimately want to contradict, and the notice says which it is so that
183
+ * contradicting it does not require guessing where it came from.
184
+ */
185
+ export type PinSource = "prompt" | "system-locale";
186
+
187
+ /**
188
+ * The env vars that carry a POSIX locale, most specific first.
189
+ *
190
+ * `LC_ALL` overrides everything by definition; `LC_MESSAGES` governs the
191
+ * language of messages specifically, which is exactly this question; `LANG` is
192
+ * the fallback for both. `LANGUAGE` is deliberately last — it is a GNU
193
+ * extension holding a colon-separated PREFERENCE LIST, so it answers a
194
+ * different question and only its first entry is comparable.
195
+ */
196
+ const LOCALE_ENV_KEYS = ["LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"] as const;
197
+
198
+ /**
199
+ * Map an environment locale onto the classifier's verdict space.
200
+ *
201
+ * Returns `und` for a locale the classifier cannot mirror, for `C`/`POSIX`
202
+ * (which name no human language), and for an unset environment. Only the
203
+ * primary subtag is read: `de_AT.UTF-8`, `de-CH`, and `de` are one answer.
204
+ */
205
+ export function systemLocaleVerdict(env: NodeJS.ProcessEnv = process.env): Verdict {
206
+ for (const key of LOCALE_ENV_KEYS) {
207
+ const raw = (env[key] ?? "").trim();
208
+ if (!raw) {
209
+ continue;
210
+ }
211
+ // `LANGUAGE` may hold `de:en:fr`; the leading entry is the preference.
212
+ const first = raw.split(":")[0] ?? "";
213
+ // Strip the territory and codeset: `de_AT.UTF-8@euro` → `de`.
214
+ const primary = (first.split(/[._@-]/)[0] ?? "").toLowerCase();
215
+ if (primary === "c" || primary === "posix" || !primary) {
216
+ continue;
217
+ }
218
+ if (primary === "de") {
219
+ return "de";
220
+ }
221
+ if (primary === "en") {
222
+ return "en";
223
+ }
224
+ // A locale the classifier has no markers for. Stop rather than fall
225
+ // through to a less specific variable: the user HAS stated a language and
226
+ // it is neither of the two, so the honest answer is "no pin".
227
+ return "und";
228
+ }
229
+ return "und";
230
+ }
231
+
232
+ /**
233
+ * The one-line notice the dispatcher surfaces for this turn.
234
+ *
235
+ * A locale-sourced pin says so and says how to override it. A prompt-sourced
236
+ * pin keeps its original wording, because that line is what the conformance
237
+ * audit measured and a reworded pin is a changed instrument.
238
+ */
239
+ export function noticeText(language: Exclude<Verdict, "und">, source: PinSource): string {
240
+ const name = LANGUAGE_NAME[language];
241
+ if (source === "system-locale") {
242
+ return (
243
+ `Reply language for this turn: ${name} — the prompt carried no language ` +
244
+ `markers, so this fell back to the system locale. Write in another ` +
245
+ `language and the next turn follows the prompt instead.`
246
+ );
247
+ }
248
+ return `Reply language for this turn: ${name}.`;
249
+ }
250
+
113
251
  /** The context block handed back to the model for this turn. */
114
- export function pinText(language: Exclude<Verdict, "und">): string {
252
+ export function pinText(language: Exclude<Verdict, "und">, source: PinSource = "prompt"): string {
115
253
  const name = LANGUAGE_NAME[language];
254
+ if (source === "system-locale") {
255
+ return (
256
+ `<language-pin>\n` +
257
+ `No language markers were found in this turn's prompt and no language was ` +
258
+ `pinned yet, so the mirror target falls back to the environment's locale: ` +
259
+ `${name}. Use it for EVERY user-visible token this turn.\n\n` +
260
+ `This is the WEAKER of the two provenances — it reflects the machine, not ` +
261
+ `the user's own words. The first prompt that carries real markers replaces ` +
262
+ `it. Do not treat it as a standing preference, and do not argue with the ` +
263
+ `user about it if they write in something else.\n` +
264
+ `</language-pin>`
265
+ );
266
+ }
116
267
  return (
117
268
  `<language-pin>\n` +
118
269
  `The user submitted this turn's prompt in ${name}. That is the mirror target ` +
@@ -130,6 +281,7 @@ export function pinText(language: Exclude<Verdict, "und">): string {
130
281
 
131
282
  export interface PinState extends JsonObject {
132
283
  language: Verdict;
284
+ source: PinSource;
133
285
  detected_at: string;
134
286
  prompt_chars: number;
135
287
  de_markers: number;
@@ -157,13 +309,30 @@ export function nextState(
157
309
  prompt_chars: number,
158
310
  session_id: string,
159
311
  now: string,
312
+ locale: Verdict = "und",
160
313
  ): PinState | null {
161
314
  if (classification.language === "und") {
162
315
  // Terse continuation ("1", "ok", "weiter") — keep whatever was pinned.
163
- return null;
316
+ if (previous.language === "de" || previous.language === "en") {
317
+ return null;
318
+ }
319
+ // FIRST turn and nothing to keep: the locale is better than no pin at all.
320
+ if (locale === "und") {
321
+ return null;
322
+ }
323
+ return {
324
+ language: locale,
325
+ source: "system-locale",
326
+ detected_at: now,
327
+ prompt_chars,
328
+ de_markers: classification.de_markers,
329
+ en_markers: classification.en_markers,
330
+ session_id,
331
+ };
164
332
  }
165
333
  return {
166
334
  language: classification.language,
335
+ source: "prompt",
167
336
  detected_at: now,
168
337
  prompt_chars,
169
338
  de_markers: classification.de_markers,
@@ -182,7 +351,10 @@ function _extractPrompt(payload: JsonObject): string {
182
351
  return "";
183
352
  }
184
353
 
185
- export function run(stdin_text: string, options: { consumer_root: string }): number {
354
+ export function run(
355
+ stdin_text: string,
356
+ options: { consumer_root: string; env?: NodeJS.ProcessEnv },
357
+ ): number {
186
358
  let envelope: JsonObject = {};
187
359
  if (stdin_text.trim()) {
188
360
  try {
@@ -205,9 +377,25 @@ export function run(stdin_text: string, options: { consumer_root: string }): num
205
377
  const target = path.join(options.consumer_root, STATE_FILE);
206
378
  const previous = _loadState(target);
207
379
  const classification = classify(prompt);
208
- const next = nextState(previous, classification, prompt.length, session_id, new Date().toISOString());
380
+ const next = nextState(
381
+ previous,
382
+ classification,
383
+ prompt.length,
384
+ session_id,
385
+ new Date().toISOString(),
386
+ // Injectable so the suite can pin a locale instead of inheriting the
387
+ // machine's — a test whose verdict depends on the developer's `LANG` is a
388
+ // test that passes here and fails in CI for reasons unrelated to the diff.
389
+ systemLocaleVerdict(options.env ?? process.env),
390
+ );
209
391
 
210
392
  const effective = next?.language ?? (previous.language as Verdict | undefined);
393
+ // A state file written before `source` existed carries only what the old
394
+ // version could produce — a prompt reading. Absent is therefore `prompt`,
395
+ // never "unknown": treating it as unknown would reword the audited notice
396
+ // for every pre-existing session.
397
+ const effectiveSource: PinSource =
398
+ next?.source ?? (previous.source === "system-locale" ? "system-locale" : "prompt");
211
399
  if (next) {
212
400
  try {
213
401
  atomic_write_json(target, next);
@@ -218,8 +406,9 @@ export function run(stdin_text: string, options: { consumer_root: string }): num
218
406
 
219
407
  if (effective === "de" || effective === "en") {
220
408
  process.stdout.write(
221
- `${JSON.stringify({ decision: "warn", reason: `Reply language for this turn: ${LANGUAGE_NAME[effective]}.`, additional_context: pinText(effective) })}\n`,
409
+ `${JSON.stringify({ decision: "warn", reason: noticeText(effective, effectiveSource), additional_context: pinText(effective, effectiveSource) })}\n`,
222
410
  );
411
+ return EXIT_WARN;
223
412
  }
224
413
  return EXIT_ALLOW;
225
414
  }
@@ -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. */
@@ -66,6 +66,20 @@ function _isObject(v: unknown): v is JsonObject {
66
66
  }
67
67
 
68
68
  /** Return [tool_name, command_text] from a tool-event payload. */
69
+ /**
70
+ * Unwrap the dispatcher envelope.
71
+ *
72
+ * The dispatcher hands every concern `{event, platform, payload: <native>}`,
73
+ * but this hook read `tool_name` / `tool_response` off the TOP level. Fed a raw
74
+ * payload it worked; fed the real envelope it found nothing and returned ALLOW.
75
+ * Measured 2026-08-06: standalone 624 bytes of output, through the built
76
+ * dispatcher 0 bytes — the reminder had never fired in production.
77
+ */
78
+ function _unwrap(envelope: JsonObject): JsonObject {
79
+ const inner = envelope["payload"];
80
+ return _isObject(inner) ? inner : envelope;
81
+ }
82
+
69
83
  function _extractCommand(payload: JsonObject): [string | null, string | null] {
70
84
  const toolRaw = payload["tool_name"] ?? payload["toolName"] ?? payload["tool"];
71
85
  const tool = typeof toolRaw === "string" ? toolRaw : null;
@@ -124,12 +138,13 @@ export function main(): number {
124
138
  return EXIT_ALLOW;
125
139
  }
126
140
 
127
- const [tool, command] = _extractCommand(envelope);
141
+ const inner = _unwrap(envelope);
142
+ const [tool, command] = _extractCommand(inner);
128
143
  if (!_isPrCreate(tool, command)) {
129
144
  return EXIT_ALLOW;
130
145
  }
131
146
 
132
- const match = _PR_URL.exec(_toolOutput(envelope));
147
+ const match = _PR_URL.exec(_toolOutput(inner));
133
148
  if (match === null) {
134
149
  return EXIT_ALLOW; // create attempted but no URL produced (failed / dry-run) — do not nag
135
150
  }
@@ -767,11 +767,48 @@ function watch_pr_checks(branch: string): void {
767
767
  * 2026-08-03, 9.15.0). Same-branch commits are never ours to drop, so the
768
768
  * recovery is merge-and-retry, never force-push.
769
769
  */
770
+ /**
771
+ * True when git rejected the push because the remote ref moved ahead — the
772
+ * ONLY failure the merge-and-retry recovery below can repair.
773
+ *
774
+ * git's wording for this is stable across versions: the hint is `fetch first`
775
+ * / `Updates were rejected`, and the ref line carries `[rejected]` with
776
+ * `non-fast-forward` or `fetch first`.
777
+ */
778
+ export function _is_non_fast_forward(stderr: string, stdout: string): boolean {
779
+ const text = `${stderr}\n${stdout}`;
780
+ return (
781
+ /\[rejected\]/i.test(text) &&
782
+ /(non-fast-forward|fetch first|behind its remote counterpart)/i.test(text)
783
+ );
784
+ }
785
+
770
786
  function push_release_branch(branch: string): void {
771
- const first = run(['git', 'push', '-u', REMOTE, branch], { check: false });
787
+ const first = run(['git', 'push', '-u', REMOTE, branch], { check: false, capture: true });
772
788
  if (first.returncode === 0) {
789
+ process.stdout.write(first.stdout);
790
+ process.stderr.write(first.stderr);
773
791
  return;
774
792
  }
793
+
794
+ // A push can fail for many reasons — a pre-push hook refusing the commit,
795
+ // no credentials, a protected branch. Only ONE of them is repaired by
796
+ // fetch+merge+retry, and the recovery's first step (`git fetch <branch>`)
797
+ // exits 128 for a branch that was never pushed. Taking that path
798
+ // unconditionally replaced every real error with
799
+ // `couldn't find remote ref <branch>` — measured 2026-08-06 on 9.26.0,
800
+ // where a pre-push gate rejection surfaced as a git-fetch crash and cost
801
+ // an hour of diagnosis pointed at the wrong layer.
802
+ if (!_is_non_fast_forward(first.stderr, first.stdout)) {
803
+ process.stdout.write(first.stdout);
804
+ process.stderr.write(first.stderr);
805
+ die(
806
+ `push of ${branch} failed (exit ${first.returncode}) and the remote ref did not move — ` +
807
+ 'this is not the fetch-and-retry case. The push output above is the real error ' +
808
+ '(a pre-push gate, credentials, or branch protection); fix that and re-run.',
809
+ );
810
+ }
811
+
775
812
  process.stdout.write(`↻ ${REMOTE}/${branch} moved — integrating and retrying push\n`);
776
813
  run(['git', 'fetch', REMOTE, branch]);
777
814
  run(['git', 'merge', '--no-edit', `${REMOTE}/${branch}`]);