@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +136 -0
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/conformance.md +182 -0
- package/dist/agent-src/commands/analyze/inbox.md +33 -2
- package/dist/agent-src/commands/review/changes.md +6 -5
- package/dist/agent-src/commands/roadmap/next.md +194 -0
- package/dist/agent-src/commands/roadmap.md +12 -4
- package/dist/agent-src/contexts/execution/subagent-routing.md +4 -1
- package/dist/agent-src/rules/decision-revisit-gate.md +2 -0
- package/dist/agent-src/rules/delegation-policy.md +3 -2
- package/dist/agent-src/rules/design-fidelity.md +17 -1
- package/dist/agent-src/rules/design-review-after-ui-write.md +116 -0
- package/dist/agent-src/rules/evaluator-independence.md +111 -0
- package/dist/agent-src/rules/session-canary.md +15 -1
- package/dist/agent-src/rules/settings-ask-protocol.md +176 -0
- package/dist/agent-src/rules/token-budget-discipline.md +6 -3
- package/dist/agent-src/rules/ui-audit-gate.md +3 -0
- package/dist/agent-src/rules/user-interaction.md +23 -0
- package/dist/agent-src/skills/finishing-a-development-branch/SKILL.md +1 -1
- package/dist/agent-src/skills/recursive-verification/SKILL.md +13 -0
- package/dist/agent-src/skills/rule-writing/SKILL.md +45 -0
- package/dist/agent-src/skills/skill-writing/SKILL.md +114 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/cmd_route_audit.js +4 -1
- package/dist/cli-delegate/cmd_route_explain.js +4 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +146 -28
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +5 -5
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +16 -11
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +12 -7
- package/dist/hooks/dispatch.js +1501 -511
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/router.json +1 -1
- package/dist/server/nicknameResolver.js +116 -0
- package/dist/server/nicknameResolver.js.map +1 -0
- package/dist/server/routes/ping.js +29 -11
- package/dist/server/routes/ping.js.map +1 -1
- package/dist/shared/interactiveContext.js +73 -0
- package/dist/shared/interactiveContext.js.map +1 -0
- package/dist/shared/nicknamePrefill.js +59 -0
- package/dist/shared/nicknamePrefill.js.map +1 -0
- package/dist/shared/settingsAsks.js +128 -0
- package/dist/shared/settingsAsks.js.map +1 -0
- package/dist/shared/settingsConsent.js +93 -0
- package/dist/shared/settingsConsent.js.map +1 -0
- package/package.json +1 -1
- package/src/config/agents-paths.yml +15 -0
- package/src/config/evaluator-budgets.json +68 -68
- package/src/config/gate-coverage.yml +131 -5
- package/src/config/gate-violation-baselines.json +11 -0
- package/src/scripts/_cli/cmd_route_audit.ts +4 -1
- package/src/scripts/_cli/cmd_route_explain.ts +9 -1
- package/src/scripts/_dispatch.bash +12 -0
- package/src/scripts/ai-video/stitch.sh +27 -9
- package/src/scripts/before_complete_hook.ts +146 -5
- package/src/scripts/bench_ab_v2_run.ts +272 -16
- package/src/scripts/bench_ab_v2_stats.ts +156 -3
- package/src/scripts/check_completion_review.ts +80 -3
- package/src/scripts/check_corpus_staleness.ts +692 -0
- package/src/scripts/check_depth_budget.ts +295 -0
- package/src/scripts/check_gate_coverage.ts +67 -0
- package/src/scripts/check_hook_bundle_freshness.ts +183 -0
- package/src/scripts/conformance_scan.ts +422 -0
- package/src/scripts/dispatch_r2_reviewer.ts +31 -2
- package/src/scripts/git_authorization_hook.ts +328 -0
- package/src/scripts/hook_manifest.yaml +69 -9
- package/src/scripts/hooks/block_no_verify.ts +3 -1
- package/src/scripts/hooks/block_unauthorized_git.ts +371 -0
- package/src/scripts/hooks/concern_registry.ts +8 -0
- package/src/scripts/hooks/design_slop_hook.ts +13 -1
- package/src/scripts/hooks/dispatch_hook.ts +97 -2
- package/src/scripts/hooks/evidence_independence.ts +368 -0
- package/src/scripts/language_mirror_hook.ts +456 -0
- package/src/scripts/lint_abstraction_thresholds.ts +41 -8
- package/src/scripts/lint_agents_layout.ts +2 -0
- package/src/scripts/lint_decision_impact.ts +246 -0
- package/src/scripts/lint_example_fences.ts +609 -0
- package/src/scripts/lint_hook_manifest.ts +29 -0
- package/src/scripts/lint_rule_interactions.ts +90 -0
- package/src/scripts/lint_rule_migration_ledger.ts +480 -0
- package/src/scripts/lint_skill_descriptions.ts +86 -2
- package/src/scripts/memory_learn_hook.ts +109 -6
- package/src/scripts/pr_url_reminder_hook.ts +17 -2
- package/src/scripts/release.ts +38 -1
- package/src/scripts/schemas/review-findings.schema.json +99 -0
- package/src/scripts/schemas/rule.schema.json +10 -0
- package/src/shared/interactiveContext.ts +98 -0
- package/src/shared/nicknamePrefill.ts +96 -0
- package/src/shared/settingsAsks.ts +176 -0
- 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 —
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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.
|
|
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 +
|
|
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',
|