@event4u/agent-config 9.24.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 (97) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +136 -0
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/conformance.md +182 -0
  5. package/dist/agent-src/commands/analyze/inbox.md +33 -2
  6. package/dist/agent-src/commands/review/changes.md +6 -5
  7. package/dist/agent-src/commands/roadmap/next.md +194 -0
  8. package/dist/agent-src/commands/roadmap.md +12 -4
  9. package/dist/agent-src/contexts/execution/subagent-routing.md +4 -1
  10. package/dist/agent-src/rules/decision-revisit-gate.md +2 -0
  11. package/dist/agent-src/rules/delegation-policy.md +3 -2
  12. package/dist/agent-src/rules/design-fidelity.md +17 -1
  13. package/dist/agent-src/rules/design-review-after-ui-write.md +116 -0
  14. package/dist/agent-src/rules/evaluator-independence.md +111 -0
  15. package/dist/agent-src/rules/session-canary.md +15 -1
  16. package/dist/agent-src/rules/settings-ask-protocol.md +176 -0
  17. package/dist/agent-src/rules/token-budget-discipline.md +6 -3
  18. package/dist/agent-src/rules/ui-audit-gate.md +3 -0
  19. package/dist/agent-src/rules/user-interaction.md +23 -0
  20. package/dist/agent-src/skills/finishing-a-development-branch/SKILL.md +1 -1
  21. package/dist/agent-src/skills/recursive-verification/SKILL.md +13 -0
  22. package/dist/agent-src/skills/rule-writing/SKILL.md +45 -0
  23. package/dist/agent-src/skills/skill-writing/SKILL.md +114 -0
  24. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  25. package/dist/cli/registry.js +1 -0
  26. package/dist/cli/registry.js.map +1 -1
  27. package/dist/cli-delegate/cmd_route_audit.js +4 -1
  28. package/dist/cli-delegate/cmd_route_explain.js +4 -1
  29. package/dist/discovery/deprecation-report.md +1 -1
  30. package/dist/discovery/discovery-manifest.json +146 -28
  31. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  32. package/dist/discovery/discovery-manifest.summary.md +5 -5
  33. package/dist/discovery/orphan-report.md +1 -1
  34. package/dist/discovery/packs.json +16 -11
  35. package/dist/discovery/trust-report.md +3 -3
  36. package/dist/discovery/workspaces.json +12 -7
  37. package/dist/hooks/dispatch.js +1501 -511
  38. package/dist/mcp/registry-manifest.json +3 -3
  39. package/dist/mcp/server.json +2 -2
  40. package/dist/router.json +1 -1
  41. package/dist/server/nicknameResolver.js +116 -0
  42. package/dist/server/nicknameResolver.js.map +1 -0
  43. package/dist/server/routes/ping.js +29 -11
  44. package/dist/server/routes/ping.js.map +1 -1
  45. package/dist/shared/interactiveContext.js +73 -0
  46. package/dist/shared/interactiveContext.js.map +1 -0
  47. package/dist/shared/nicknamePrefill.js +59 -0
  48. package/dist/shared/nicknamePrefill.js.map +1 -0
  49. package/dist/shared/settingsAsks.js +128 -0
  50. package/dist/shared/settingsAsks.js.map +1 -0
  51. package/dist/shared/settingsConsent.js +93 -0
  52. package/dist/shared/settingsConsent.js.map +1 -0
  53. package/package.json +1 -1
  54. package/src/config/agents-paths.yml +15 -0
  55. package/src/config/evaluator-budgets.json +68 -68
  56. package/src/config/gate-coverage.yml +131 -5
  57. package/src/config/gate-violation-baselines.json +11 -0
  58. package/src/scripts/_cli/cmd_route_audit.ts +4 -1
  59. package/src/scripts/_cli/cmd_route_explain.ts +9 -1
  60. package/src/scripts/_dispatch.bash +12 -0
  61. package/src/scripts/ai-video/stitch.sh +27 -9
  62. package/src/scripts/before_complete_hook.ts +146 -5
  63. package/src/scripts/bench_ab_v2_run.ts +272 -16
  64. package/src/scripts/bench_ab_v2_stats.ts +156 -3
  65. package/src/scripts/check_completion_review.ts +80 -3
  66. package/src/scripts/check_corpus_staleness.ts +692 -0
  67. package/src/scripts/check_depth_budget.ts +295 -0
  68. package/src/scripts/check_gate_coverage.ts +67 -0
  69. package/src/scripts/check_hook_bundle_freshness.ts +183 -0
  70. package/src/scripts/conformance_scan.ts +422 -0
  71. package/src/scripts/dispatch_r2_reviewer.ts +31 -2
  72. package/src/scripts/git_authorization_hook.ts +328 -0
  73. package/src/scripts/hook_manifest.yaml +69 -9
  74. package/src/scripts/hooks/block_no_verify.ts +3 -1
  75. package/src/scripts/hooks/block_unauthorized_git.ts +371 -0
  76. package/src/scripts/hooks/concern_registry.ts +8 -0
  77. package/src/scripts/hooks/design_slop_hook.ts +13 -1
  78. package/src/scripts/hooks/dispatch_hook.ts +97 -2
  79. package/src/scripts/hooks/evidence_independence.ts +368 -0
  80. package/src/scripts/language_mirror_hook.ts +456 -0
  81. package/src/scripts/lint_abstraction_thresholds.ts +41 -8
  82. package/src/scripts/lint_agents_layout.ts +2 -0
  83. package/src/scripts/lint_decision_impact.ts +246 -0
  84. package/src/scripts/lint_example_fences.ts +609 -0
  85. package/src/scripts/lint_hook_manifest.ts +29 -0
  86. package/src/scripts/lint_rule_interactions.ts +90 -0
  87. package/src/scripts/lint_rule_migration_ledger.ts +480 -0
  88. package/src/scripts/lint_skill_descriptions.ts +86 -2
  89. package/src/scripts/memory_learn_hook.ts +109 -6
  90. package/src/scripts/pr_url_reminder_hook.ts +17 -2
  91. package/src/scripts/release.ts +38 -1
  92. package/src/scripts/schemas/review-findings.schema.json +99 -0
  93. package/src/scripts/schemas/rule.schema.json +10 -0
  94. package/src/shared/interactiveContext.ts +98 -0
  95. package/src/shared/nicknamePrefill.ts +96 -0
  96. package/src/shared/settingsAsks.ts +176 -0
  97. package/src/shared/settingsConsent.ts +136 -0
@@ -0,0 +1,456 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Language-mirror pin — `user_prompt_submit` concern.
4
+ *
5
+ * Deterministic carrier for the `language-and-tone` Iron Law (mirror the
6
+ * language of the user's last CHAT MESSAGE in every user-visible token).
7
+ *
8
+ * WHY THIS EXISTS — the measured defect, not a general reminder.
9
+ *
10
+ * A conformance audit of 30 sessions (2026-08-06) found ~470 assistant turns
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:
14
+ *
15
+ * **47 skill- and slash-command bodies arrive in the `user` role** across 21
16
+ * of the 30 sessions. In the worst session the last user-role content before
17
+ * 136 consecutive English assistant turns is a 4,196-character English skill
18
+ * body (`de=0 / en=59`), while the maintainer's actual prompt — 451
19
+ * characters, `de=16 / en=0` — sits above it.
20
+ *
21
+ * So the most recent user-role content genuinely WAS English, and mirroring it
22
+ * was locally correct. The rule says the trigger is the user's last *chat
23
+ * message* and explicitly excludes tool output, but the transcript offers no
24
+ * way to tell an injected skill body from a typed prompt.
25
+ *
26
+ * `user_prompt_submit` is the one event a skill body never reaches — it fires
27
+ * on what the human actually submitted. Pinning the language there turns an
28
+ * unobservable distinction into a recorded fact.
29
+ *
30
+ * This is deliberately NOT "more prose into the same context": the AI council
31
+ * (2026-08-06) was right that a reminder cannot buy attention in a context that
32
+ * already carries the Iron Law in full. What it can do is carry a fact the
33
+ * context does not otherwise contain — which language the human wrote in.
34
+ *
35
+ * State: `agents/state/language-mirror.json`
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
+ *
40
+ * Undetermined prompts (a bare "1", "ok", a pasted URL) leave a previous pin
41
+ * UNTOUCHED rather than clearing it — a short continuation does not change the
42
+ * conversation's language, and clearing on every terse turn would reproduce the
43
+ * exact drift this hook removes.
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
+ *
65
+ * Never blocks (`fail_closed: false`, `severity: advisory`). Exit 0 always.
66
+ */
67
+ import fs from "node:fs";
68
+ import path from "node:path";
69
+ import { fileURLToPath, pathToFileURL } from "node:url";
70
+
71
+ import { atomic_write_json } from "./hooks/state_io.js";
72
+ import { readHookStdin } from "./hooks/hook_stdin.js";
73
+
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;
82
+
83
+ export const STATE_FILE = path.join("agents", "state", "language-mirror.json");
84
+
85
+ type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
86
+ type JsonObject = { [k: string]: JsonValue };
87
+
88
+ function _isObject(v: unknown): v is JsonObject {
89
+ return typeof v === "object" && v !== null && !Array.isArray(v);
90
+ }
91
+
92
+ /**
93
+ * German function words + umlauts. Function words are used rather than a
94
+ * dictionary because the prompts that matter are short imperatives
95
+ * ("fixe die ci", "mach weiter") where content words carry no signal.
96
+ */
97
+ const DE_MARKERS =
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;
99
+
100
+ /**
101
+ * English function words that a German sentence would not contain. Chosen to
102
+ * be disjoint from German homographs (`in`, `so`, `war`, `also`, `hat`, `man`,
103
+ * `bei`, `die` … are deliberately absent — they are German words too).
104
+ */
105
+ const EN_MARKERS =
106
+ /\b(the|and|is|are|was|were|not|please|why|because|should|would|could|there|these|those|with|from|about|which|that|this|will|have|has|been|they|them|their|your|our|what|when|where|make|need|want|does|doesn't|don't|it's|let's)\b/gi;
107
+
108
+ /** Minimum marker count before a verdict is trustworthy. */
109
+ export const MIN_MARKERS = 2;
110
+
111
+ export type Verdict = "de" | "en" | "und";
112
+
113
+ export interface Classification {
114
+ language: Verdict;
115
+ de_markers: number;
116
+ en_markers: number;
117
+ }
118
+
119
+ /**
120
+ * Classify a prompt.
121
+ *
122
+ * Ties resolve to German, per the rule's own "mixed → dominant; tie → German".
123
+ * Below `MIN_MARKERS` on both sides the verdict is `und` (undetermined) and the
124
+ * caller keeps any previous pin.
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
+
161
+ export function classify(prompt: string): Classification {
162
+ const text = instructionText(prompt);
163
+ const de = (text.match(DE_MARKERS) ?? []).length;
164
+ const en = (text.match(EN_MARKERS) ?? []).length;
165
+ if (de < MIN_MARKERS && en < MIN_MARKERS) {
166
+ return { language: "und", de_markers: de, en_markers: en };
167
+ }
168
+ return { language: de >= en ? "de" : "en", de_markers: de, en_markers: en };
169
+ }
170
+
171
+ const LANGUAGE_NAME: Record<Exclude<Verdict, "und">, string> = {
172
+ de: "German (Deutsch)",
173
+ en: "English",
174
+ };
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
+
251
+ /** The context block handed back to the model for this turn. */
252
+ export function pinText(language: Exclude<Verdict, "und">, source: PinSource = "prompt"): string {
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
+ }
267
+ return (
268
+ `<language-pin>\n` +
269
+ `The user submitted this turn's prompt in ${name}. That is the mirror target ` +
270
+ `for EVERY user-visible token of your replies this turn — including the short ` +
271
+ `inter-tool lines between tool calls, headings, table cells, bullets, status ` +
272
+ `lines, and the recommendation label under any numbered-options block.\n\n` +
273
+ `This pin exists because the trigger is not observable from the transcript ` +
274
+ `alone: slash-command and skill bodies arrive in the user role and can be ` +
275
+ `long and English, which is what drove ~470 wrong-language turns in the ` +
276
+ `sessions this hook was built from. Tool output, file contents, and an ` +
277
+ `injected skill body are NOT the trigger — this pin is.\n` +
278
+ `</language-pin>`
279
+ );
280
+ }
281
+
282
+ export interface PinState extends JsonObject {
283
+ language: Verdict;
284
+ source: PinSource;
285
+ detected_at: string;
286
+ prompt_chars: number;
287
+ de_markers: number;
288
+ en_markers: number;
289
+ session_id: string;
290
+ }
291
+
292
+ function _loadState(target: string): Partial<PinState> {
293
+ try {
294
+ const raw = fs.readFileSync(target, "utf8");
295
+ const decoded = JSON.parse(raw) as unknown;
296
+ return _isObject(decoded) ? (decoded as Partial<PinState>) : {};
297
+ } catch {
298
+ return {};
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Decide the next state from the previous one and this turn's classification.
304
+ * Exported so the tests can assert the keep-previous-pin rule directly.
305
+ */
306
+ export function nextState(
307
+ previous: Partial<PinState>,
308
+ classification: Classification,
309
+ prompt_chars: number,
310
+ session_id: string,
311
+ now: string,
312
+ locale: Verdict = "und",
313
+ ): PinState | null {
314
+ if (classification.language === "und") {
315
+ // Terse continuation ("1", "ok", "weiter") — keep whatever was pinned.
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
+ };
332
+ }
333
+ return {
334
+ language: classification.language,
335
+ source: "prompt",
336
+ detected_at: now,
337
+ prompt_chars,
338
+ de_markers: classification.de_markers,
339
+ en_markers: classification.en_markers,
340
+ session_id,
341
+ };
342
+ }
343
+
344
+ function _extractPrompt(payload: JsonObject): string {
345
+ for (const key of ["prompt", "userPrompt", "user_prompt", "message", "text"]) {
346
+ const v = payload[key];
347
+ if (typeof v === "string" && v.trim()) {
348
+ return v;
349
+ }
350
+ }
351
+ return "";
352
+ }
353
+
354
+ export function run(
355
+ stdin_text: string,
356
+ options: { consumer_root: string; env?: NodeJS.ProcessEnv },
357
+ ): number {
358
+ let envelope: JsonObject = {};
359
+ if (stdin_text.trim()) {
360
+ try {
361
+ const decoded = JSON.parse(stdin_text) as unknown;
362
+ if (_isObject(decoded)) {
363
+ envelope = decoded;
364
+ }
365
+ } catch {
366
+ return EXIT_ALLOW; // never act on a malformed envelope
367
+ }
368
+ }
369
+
370
+ const payload = _isObject(envelope["payload"]) ? (envelope["payload"] as JsonObject) : envelope;
371
+ const prompt = _extractPrompt(payload);
372
+ if (!prompt) {
373
+ return EXIT_ALLOW;
374
+ }
375
+
376
+ const session_id = typeof envelope["session_id"] === "string" ? envelope["session_id"] : "";
377
+ const target = path.join(options.consumer_root, STATE_FILE);
378
+ const previous = _loadState(target);
379
+ const classification = classify(prompt);
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
+ );
391
+
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");
399
+ if (next) {
400
+ try {
401
+ atomic_write_json(target, next);
402
+ } catch {
403
+ // Observability only — a failed state write never blocks the turn.
404
+ }
405
+ }
406
+
407
+ if (effective === "de" || effective === "en") {
408
+ process.stdout.write(
409
+ `${JSON.stringify({ decision: "warn", reason: noticeText(effective, effectiveSource), additional_context: pinText(effective, effectiveSource) })}\n`,
410
+ );
411
+ return EXIT_WARN;
412
+ }
413
+ return EXIT_ALLOW;
414
+ }
415
+
416
+ export function main(argv?: string[]): number {
417
+ const args = argv ?? process.argv.slice(2);
418
+ let consumer_root = process.cwd();
419
+ for (let i = 0; i < args.length; i += 1) {
420
+ const a = args[i];
421
+ if (a === "--project-dir" && args[i + 1] !== undefined) {
422
+ consumer_root = args[i + 1] as string;
423
+ i += 1;
424
+ } else if (a !== undefined && a.startsWith("--project-dir=")) {
425
+ consumer_root = a.slice("--project-dir=".length);
426
+ }
427
+ }
428
+ return run(readHookStdin(), { consumer_root });
429
+ }
430
+
431
+ // Bundle-safety: never auto-run when inlined into an esbuild bundle, where
432
+ // every module shares the bundle's `import.meta.url` (see cmd_migrate.ts).
433
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
434
+ function _isCliEntry(): boolean {
435
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== "undefined" && __AGENT_CONFIG_BUNDLE__) {
436
+ return false;
437
+ }
438
+ if (process.argv[1] === undefined) {
439
+ return false;
440
+ }
441
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
442
+ if (import.meta.url === argvUrl) {
443
+ return true;
444
+ }
445
+ try {
446
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
447
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
448
+ return here === argv;
449
+ } catch {
450
+ return false;
451
+ }
452
+ }
453
+
454
+ if (_isCliEntry()) {
455
+ process.exit(main());
456
+ }
@@ -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',