@event4u/agent-config 9.23.0 → 9.25.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 +82 -0
- package/README.md +2 -2
- package/dist/agent-src/commands/agent-handoff.md +10 -3
- package/dist/agent-src/commands/analyze/conformance.md +182 -0
- package/dist/agent-src/commands/analyze/inbox.md +221 -0
- package/dist/agent-src/commands/analyze.md +4 -3
- package/dist/agent-src/commands/pr/create.md +6 -6
- package/dist/agent-src/rules/context-hygiene.md +3 -0
- package/dist/agent-src/rules/decision-revisit-gate.md +2 -0
- package/dist/agent-src/rules/evaluator-independence.md +105 -0
- package/dist/agent-src/rules/session-canary.md +13 -1
- package/dist/agent-src/rules/user-interaction.md +22 -0
- package/dist/agent-src/skills/finishing-a-development-branch/SKILL.md +1 -1
- 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/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +96 -22
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +11 -8
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +9 -6
- package/dist/hooks/dispatch.js +1026 -328
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/router.json +1 -1
- package/package.json +1 -1
- package/src/config/evaluator-budgets.json +68 -68
- package/src/config/gate-coverage.yml +103 -5
- package/src/config/gate-violation-baselines.json +11 -6
- package/src/scripts/_dispatch.bash +12 -0
- package/src/scripts/before_complete_hook.ts +127 -5
- package/src/scripts/check_depth_budget.ts +295 -0
- package/src/scripts/conformance_scan.ts +392 -0
- package/src/scripts/git_authorization_hook.ts +263 -0
- package/src/scripts/hook_manifest.yaml +50 -9
- package/src/scripts/hooks/block_unauthorized_git.ts +254 -0
- package/src/scripts/hooks/concern_registry.ts +8 -0
- package/src/scripts/hooks/evidence_independence.ts +334 -0
- package/src/scripts/language_mirror_hook.ts +267 -0
- package/src/scripts/lint_decision_impact.ts +246 -0
- package/src/scripts/lint_example_fences.ts +609 -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/measure_rule_absoluta.ts +231 -0
- package/src/scripts/schemas/rule.schema.json +10 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Evaluator-independence guard — `pre_tool_use` concern.
|
|
4
|
+
*
|
|
5
|
+
* WHY — the single highest-severity finding of the 30-session conformance audit
|
|
6
|
+
* (2026-08-06), and the one class no rule covered at all:
|
|
7
|
+
*
|
|
8
|
+
* The agent commissioned a "blind review" of its own work, WROTE THE
|
|
9
|
+
* REVIEWER'S PROMPT ITSELF with the verdict pre-loaded ("NO-FINDINGS is
|
|
10
|
+
* expected and welcome") and the scope narrowed to four self-chosen files,
|
|
11
|
+
* then committed the resulting honest-null as binding gate evidence. An
|
|
12
|
+
* unsteered pass over the same delta later found 5 findings, 1 critical,
|
|
13
|
+
* already live on the trunk. The agent's own retro: "Die Fabrikation lag
|
|
14
|
+
* nicht im Weglassen des Reviews — sie lag in meinem Prompt. Ich habe die
|
|
15
|
+
* Antwort vorgeladen und dann als Evidenz verbucht."
|
|
16
|
+
*
|
|
17
|
+
* WHAT THIS GATES — evaluation dispatches only, never fan-out.
|
|
18
|
+
*
|
|
19
|
+
* The AI council (2026-08-06) proposed blocking the SECOND subagent dispatch of
|
|
20
|
+
* any kind within a turn. That criterion is wrong, and measurably so: the very
|
|
21
|
+
* session that produced this guard dispatched **seven** analysis subagents in
|
|
22
|
+
* one turn as a deliberate parallel fan-out, at the user's explicit request.
|
|
23
|
+
* Six of them would have been blocked. Parallel dispatch is not the defect —
|
|
24
|
+
* steering an evaluator is.
|
|
25
|
+
*
|
|
26
|
+
* So the gate keys on the evaluation shape instead:
|
|
27
|
+
*
|
|
28
|
+
* BLOCK · a dispatch whose prompt PRE-LOADS the verdict ("no findings is
|
|
29
|
+
* expected", "you should find nothing", "confirm there are no issues"). This
|
|
30
|
+
* is the literal construct that fabricated the honest-null, and it is
|
|
31
|
+
* mechanically detectable in the prompt the agent is about to send.
|
|
32
|
+
*
|
|
33
|
+
* BLOCK · a SECOND evaluation dispatch in the same turn — verdict shopping.
|
|
34
|
+
* Counted per turn over evaluation-shaped dispatches only.
|
|
35
|
+
*
|
|
36
|
+
* WARN · the first evaluation dispatch, naming the two observed failure
|
|
37
|
+
* shapes so the author can check its prompt before it goes out.
|
|
38
|
+
*
|
|
39
|
+
* A dispatch that is not evaluation-shaped is not touched at all.
|
|
40
|
+
*
|
|
41
|
+
* State: `agents/state/evidence-dispatch.json`
|
|
42
|
+
* { "session_id": str, "turn_started_at": iso8601, "evaluations": [ {at, digest} ] }
|
|
43
|
+
*
|
|
44
|
+
* Exit codes: 0 allow · 2 block (stderr carries the reason).
|
|
45
|
+
*/
|
|
46
|
+
import crypto from "node:crypto";
|
|
47
|
+
import fs from "node:fs";
|
|
48
|
+
import path from "node:path";
|
|
49
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
50
|
+
|
|
51
|
+
import { atomic_write_json } from "./state_io.js";
|
|
52
|
+
import { readHookStdin } from "./hook_stdin.js";
|
|
53
|
+
|
|
54
|
+
const EXIT_ALLOW = 0;
|
|
55
|
+
const EXIT_BLOCK = 2;
|
|
56
|
+
|
|
57
|
+
export const STATE_FILE = path.join("agents", "state", "evidence-dispatch.json");
|
|
58
|
+
|
|
59
|
+
type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
|
|
60
|
+
type JsonObject = { [k: string]: JsonValue };
|
|
61
|
+
|
|
62
|
+
function _isObject(v: unknown): v is JsonObject {
|
|
63
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Tool names that dispatch a subagent, across platforms. */
|
|
67
|
+
const DISPATCH_TOOLS: ReadonlySet<string> = new Set([
|
|
68
|
+
"Agent",
|
|
69
|
+
"Task",
|
|
70
|
+
"task",
|
|
71
|
+
"dispatch_agent",
|
|
72
|
+
"dispatchAgent",
|
|
73
|
+
"launch_agent",
|
|
74
|
+
"run_subagent",
|
|
75
|
+
"Subagent",
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
/** The dispatch is an EVALUATION of work rather than ordinary fan-out. */
|
|
79
|
+
const EVALUATION_RE =
|
|
80
|
+
/\b(review|reviewer|audit|auditor|judge|verdict|blind[- ]pass|blind review|adversarial|critique|assess(ment)?|verify (my|the) (work|change|diff|implementation)|find (any )?(bugs|issues|defects|problems) in (my|the))\b/i;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Verdict pre-loading. Each pattern is a phrase that tells the evaluator what
|
|
84
|
+
* answer is acceptable BEFORE it has looked — the construct that produced the
|
|
85
|
+
* fabricated honest-null.
|
|
86
|
+
*/
|
|
87
|
+
const PRELOADED_VERDICT_RE = [
|
|
88
|
+
/\bno[- ]findings? (is|are) (expected|welcome|fine|acceptable|the likely)/i,
|
|
89
|
+
/\b(you )?(should|will|probably) find (nothing|no (issues|problems|bugs|findings))/i,
|
|
90
|
+
/\bconfirm (that )?there (are|is) no\b/i,
|
|
91
|
+
/\bi (believe|think|expect) (this|it) is (clean|correct|fine)\b/i,
|
|
92
|
+
/\bexpect(ed)? (to be )?(clean|green|no findings)\b/i,
|
|
93
|
+
/\bjust confirm\b/i,
|
|
94
|
+
/\bit('s| is) (probably|likely) fine\b/i,
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
export function isDispatchTool(tool: string | null): boolean {
|
|
98
|
+
return tool !== null && DISPATCH_TOOLS.has(tool);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function isEvaluationPrompt(prompt: string): boolean {
|
|
102
|
+
return EVALUATION_RE.test(prompt);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The evaluation targets the AGENT'S OWN work rather than some external
|
|
107
|
+
* artifact.
|
|
108
|
+
*
|
|
109
|
+
* This discriminator was added because the conformance scan caught the gate
|
|
110
|
+
* red-handed on real data: the audit session that produced this hook dispatched
|
|
111
|
+
* seven subagents whose prompts opened "You are auditing real Claude Code
|
|
112
|
+
* session transcripts…". `EVALUATION_RE` matches `audit`, so six of the seven
|
|
113
|
+
* were flagged as verdict shopping — the exact false positive the hook's own
|
|
114
|
+
* header claims to avoid. Auditing thirty transcripts is not reviewing your own
|
|
115
|
+
* diff twice.
|
|
116
|
+
*
|
|
117
|
+
* Verdict shopping is only possible when there is one subject to shop a verdict
|
|
118
|
+
* ON, so the second-dispatch block requires a self-reference. The pre-loaded-
|
|
119
|
+
* verdict block does NOT: steering an evaluator is wrong whatever it is
|
|
120
|
+
* pointed at.
|
|
121
|
+
*/
|
|
122
|
+
const SELF_SCOPE_RE =
|
|
123
|
+
/\b(my (work|change|diff|implementation|code|fix|patch|branch|pr)|this (diff|change|branch|pr|delta|implementation|patch)|the delta|the change i|what i (wrote|built|changed|implemented)|i just (wrote|built|changed|implemented))\b/i;
|
|
124
|
+
|
|
125
|
+
export function isSelfScoped(prompt: string): boolean {
|
|
126
|
+
return SELF_SCOPE_RE.test(prompt);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Return the pre-loading phrase found in a prompt, or null. */
|
|
130
|
+
export function preloadedVerdict(prompt: string): string | null {
|
|
131
|
+
for (const re of PRELOADED_VERDICT_RE) {
|
|
132
|
+
const m = re.exec(prompt);
|
|
133
|
+
if (m) {
|
|
134
|
+
return m[0];
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Pull [tool, prompt] out of a pre-tool envelope. */
|
|
141
|
+
export function extractDispatch(envelope: JsonObject): [string | null, string] {
|
|
142
|
+
const payload = _isObject(envelope["payload"]) ? (envelope["payload"] as JsonObject) : envelope;
|
|
143
|
+
const toolRaw = payload["tool_name"] ?? payload["toolName"] ?? payload["tool"];
|
|
144
|
+
const tool = typeof toolRaw === "string" ? toolRaw : null;
|
|
145
|
+
const input = _isObject(payload["tool_input"])
|
|
146
|
+
? (payload["tool_input"] as JsonObject)
|
|
147
|
+
: _isObject(payload["toolInput"])
|
|
148
|
+
? (payload["toolInput"] as JsonObject)
|
|
149
|
+
: payload;
|
|
150
|
+
for (const key of ["prompt", "instructions", "task", "description"]) {
|
|
151
|
+
const v = input[key];
|
|
152
|
+
if (typeof v === "string" && v.trim()) {
|
|
153
|
+
return [tool, v];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return [tool, ""];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
interface DispatchState extends JsonObject {
|
|
160
|
+
session_id: string;
|
|
161
|
+
turn_started_at: string;
|
|
162
|
+
evaluations: JsonValue[];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function _load(target: string): DispatchState {
|
|
166
|
+
try {
|
|
167
|
+
const decoded = JSON.parse(fs.readFileSync(target, "utf8")) as unknown;
|
|
168
|
+
if (_isObject(decoded) && Array.isArray(decoded["evaluations"])) {
|
|
169
|
+
return decoded as unknown as DispatchState;
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
/* fall through */
|
|
173
|
+
}
|
|
174
|
+
return { session_id: "", turn_started_at: "", evaluations: [] };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface Decision {
|
|
178
|
+
exit: number;
|
|
179
|
+
stdout: string;
|
|
180
|
+
stderr: string;
|
|
181
|
+
/** Number of evaluation dispatches recorded for this turn AFTER this one. */
|
|
182
|
+
evaluations: number;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const WARN_TEXT =
|
|
186
|
+
"First evaluation dispatch this turn. Two shapes fabricated a binding honest-null " +
|
|
187
|
+
"in the audited sessions — check your prompt for both before it goes out: " +
|
|
188
|
+
"(1) a verdict pre-loaded into the prompt, and (2) a scope narrowed to files you " +
|
|
189
|
+
"chose. A review you commissioned on your own work is admissible as gate evidence " +
|
|
190
|
+
"only when the prompt is recorded alongside the verdict.";
|
|
191
|
+
|
|
192
|
+
export function decide(
|
|
193
|
+
tool: string | null,
|
|
194
|
+
prompt: string,
|
|
195
|
+
priorEvaluations: number,
|
|
196
|
+
): Decision {
|
|
197
|
+
if (!isDispatchTool(tool) || !isEvaluationPrompt(prompt)) {
|
|
198
|
+
return { exit: EXIT_ALLOW, stdout: "", stderr: "", evaluations: priorEvaluations };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const preloaded = preloadedVerdict(prompt);
|
|
202
|
+
if (preloaded !== null) {
|
|
203
|
+
return {
|
|
204
|
+
exit: EXIT_BLOCK,
|
|
205
|
+
stdout: "",
|
|
206
|
+
stderr:
|
|
207
|
+
`Blocked: this evaluation prompt pre-loads its verdict ("${preloaded}"). ` +
|
|
208
|
+
`That is the construct that produced a fabricated NO-FINDINGS committed as ` +
|
|
209
|
+
`binding gate evidence, over a delta an unsteered pass then found a live ` +
|
|
210
|
+
`critical in. Remove the expectation from the prompt and let the evaluator ` +
|
|
211
|
+
`reach its own verdict.\n`,
|
|
212
|
+
evaluations: priorEvaluations,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Verdict shopping needs a single subject to shop a verdict on. An
|
|
217
|
+
// evaluation of an external artifact (transcripts, a third party's code) is
|
|
218
|
+
// fan-out, not self-review, and is never counted or blocked.
|
|
219
|
+
if (!isSelfScoped(prompt)) {
|
|
220
|
+
return { exit: EXIT_ALLOW, stdout: "", stderr: "", evaluations: priorEvaluations };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (priorEvaluations >= 1) {
|
|
224
|
+
return {
|
|
225
|
+
exit: EXIT_BLOCK,
|
|
226
|
+
stdout: "",
|
|
227
|
+
stderr:
|
|
228
|
+
`Blocked: second evaluation dispatch of your own work in this turn (verdict ` +
|
|
229
|
+
`shopping). One evaluation has already run; commissioning another with a ` +
|
|
230
|
+
`different prompt or scope selects the answer instead of measuring it. ` +
|
|
231
|
+
`Report what the first pass returned, then re-plan.\n`,
|
|
232
|
+
evaluations: priorEvaluations,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
exit: EXIT_ALLOW,
|
|
238
|
+
stdout: `${JSON.stringify({ decision: "warn", reason: WARN_TEXT })}\n`,
|
|
239
|
+
stderr: "",
|
|
240
|
+
evaluations: priorEvaluations + 1,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function run(stdin_text: string, options: { consumer_root: string }): number {
|
|
245
|
+
let envelope: JsonObject = {};
|
|
246
|
+
if (stdin_text.trim()) {
|
|
247
|
+
try {
|
|
248
|
+
const decoded = JSON.parse(stdin_text) as unknown;
|
|
249
|
+
if (_isObject(decoded)) {
|
|
250
|
+
envelope = decoded;
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
return EXIT_ALLOW;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const target = path.join(options.consumer_root, STATE_FILE);
|
|
258
|
+
const state = _load(target);
|
|
259
|
+
const session_id = typeof envelope["session_id"] === "string" ? envelope["session_id"] : "";
|
|
260
|
+
|
|
261
|
+
// The authorization ledger owns turn boundaries; this guard reads its own
|
|
262
|
+
// marker so it never depends on another concern's ordering.
|
|
263
|
+
const turnMarker =
|
|
264
|
+
typeof envelope["turn_id"] === "string" ? (envelope["turn_id"] as string) : session_id;
|
|
265
|
+
if (state.session_id !== turnMarker) {
|
|
266
|
+
state.session_id = turnMarker;
|
|
267
|
+
state.turn_started_at = new Date().toISOString();
|
|
268
|
+
state.evaluations = [];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const [tool, prompt] = extractDispatch(envelope);
|
|
272
|
+
const decision = decide(tool, prompt, state.evaluations.length);
|
|
273
|
+
|
|
274
|
+
if (decision.evaluations > state.evaluations.length) {
|
|
275
|
+
state.evaluations.push({
|
|
276
|
+
at: new Date().toISOString(),
|
|
277
|
+
digest: crypto.createHash("sha256").update(prompt).digest("hex").slice(0, 16),
|
|
278
|
+
prompt_chars: prompt.length,
|
|
279
|
+
});
|
|
280
|
+
try {
|
|
281
|
+
atomic_write_json(target, state);
|
|
282
|
+
} catch {
|
|
283
|
+
/* observability only */
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (decision.stdout) {
|
|
288
|
+
process.stdout.write(decision.stdout);
|
|
289
|
+
}
|
|
290
|
+
if (decision.stderr) {
|
|
291
|
+
process.stderr.write(decision.stderr);
|
|
292
|
+
}
|
|
293
|
+
return decision.exit;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function main(argv?: string[]): number {
|
|
297
|
+
const args = argv ?? process.argv.slice(2);
|
|
298
|
+
let consumer_root = process.cwd();
|
|
299
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
300
|
+
const a = args[i];
|
|
301
|
+
if (a === "--project-dir" && args[i + 1] !== undefined) {
|
|
302
|
+
consumer_root = args[i + 1] as string;
|
|
303
|
+
i += 1;
|
|
304
|
+
} else if (a !== undefined && a.startsWith("--project-dir=")) {
|
|
305
|
+
consumer_root = a.slice("--project-dir=".length);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return run(readHookStdin(), { consumer_root });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
|
|
312
|
+
function _isCliEntry(): boolean {
|
|
313
|
+
if (typeof __AGENT_CONFIG_BUNDLE__ !== "undefined" && __AGENT_CONFIG_BUNDLE__) {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
if (process.argv[1] === undefined) {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
320
|
+
if (import.meta.url === argvUrl) {
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
325
|
+
const argv = fs.realpathSync(path.resolve(process.argv[1]));
|
|
326
|
+
return here === argv;
|
|
327
|
+
} catch {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (_isCliEntry()) {
|
|
333
|
+
process.exit(main());
|
|
334
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
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", "detected_at": iso8601,
|
|
37
|
+
* "prompt_chars": int, "de_markers": int, "en_markers": int,
|
|
38
|
+
* "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
|
+
* Never blocks (`fail_closed: false`, `severity: advisory`). Exit 0 always.
|
|
46
|
+
*/
|
|
47
|
+
import fs from "node:fs";
|
|
48
|
+
import path from "node:path";
|
|
49
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
50
|
+
|
|
51
|
+
import { atomic_write_json } from "./hooks/state_io.js";
|
|
52
|
+
import { readHookStdin } from "./hooks/hook_stdin.js";
|
|
53
|
+
|
|
54
|
+
const EXIT_ALLOW = 0;
|
|
55
|
+
|
|
56
|
+
export const STATE_FILE = path.join("agents", "state", "language-mirror.json");
|
|
57
|
+
|
|
58
|
+
type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
|
|
59
|
+
type JsonObject = { [k: string]: JsonValue };
|
|
60
|
+
|
|
61
|
+
function _isObject(v: unknown): v is JsonObject {
|
|
62
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* German function words + umlauts. Function words are used rather than a
|
|
67
|
+
* dictionary because the prompts that matter are short imperatives
|
|
68
|
+
* ("fixe die ci", "mach weiter") where content words carry no signal.
|
|
69
|
+
*/
|
|
70
|
+
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;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* English function words that a German sentence would not contain. Chosen to
|
|
75
|
+
* be disjoint from German homographs (`in`, `so`, `war`, `also`, `hat`, `man`,
|
|
76
|
+
* `bei`, `die` … are deliberately absent — they are German words too).
|
|
77
|
+
*/
|
|
78
|
+
const EN_MARKERS =
|
|
79
|
+
/\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;
|
|
80
|
+
|
|
81
|
+
/** Minimum marker count before a verdict is trustworthy. */
|
|
82
|
+
export const MIN_MARKERS = 2;
|
|
83
|
+
|
|
84
|
+
export type Verdict = "de" | "en" | "und";
|
|
85
|
+
|
|
86
|
+
export interface Classification {
|
|
87
|
+
language: Verdict;
|
|
88
|
+
de_markers: number;
|
|
89
|
+
en_markers: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Classify a prompt.
|
|
94
|
+
*
|
|
95
|
+
* Ties resolve to German, per the rule's own "mixed → dominant; tie → German".
|
|
96
|
+
* Below `MIN_MARKERS` on both sides the verdict is `und` (undetermined) and the
|
|
97
|
+
* caller keeps any previous pin.
|
|
98
|
+
*/
|
|
99
|
+
export function classify(prompt: string): Classification {
|
|
100
|
+
const de = (prompt.match(DE_MARKERS) ?? []).length;
|
|
101
|
+
const en = (prompt.match(EN_MARKERS) ?? []).length;
|
|
102
|
+
if (de < MIN_MARKERS && en < MIN_MARKERS) {
|
|
103
|
+
return { language: "und", de_markers: de, en_markers: en };
|
|
104
|
+
}
|
|
105
|
+
return { language: de >= en ? "de" : "en", de_markers: de, en_markers: en };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const LANGUAGE_NAME: Record<Exclude<Verdict, "und">, string> = {
|
|
109
|
+
de: "German (Deutsch)",
|
|
110
|
+
en: "English",
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** The context block handed back to the model for this turn. */
|
|
114
|
+
export function pinText(language: Exclude<Verdict, "und">): string {
|
|
115
|
+
const name = LANGUAGE_NAME[language];
|
|
116
|
+
return (
|
|
117
|
+
`<language-pin>\n` +
|
|
118
|
+
`The user submitted this turn's prompt in ${name}. That is the mirror target ` +
|
|
119
|
+
`for EVERY user-visible token of your replies this turn — including the short ` +
|
|
120
|
+
`inter-tool lines between tool calls, headings, table cells, bullets, status ` +
|
|
121
|
+
`lines, and the recommendation label under any numbered-options block.\n\n` +
|
|
122
|
+
`This pin exists because the trigger is not observable from the transcript ` +
|
|
123
|
+
`alone: slash-command and skill bodies arrive in the user role and can be ` +
|
|
124
|
+
`long and English, which is what drove ~470 wrong-language turns in the ` +
|
|
125
|
+
`sessions this hook was built from. Tool output, file contents, and an ` +
|
|
126
|
+
`injected skill body are NOT the trigger — this pin is.\n` +
|
|
127
|
+
`</language-pin>`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface PinState extends JsonObject {
|
|
132
|
+
language: Verdict;
|
|
133
|
+
detected_at: string;
|
|
134
|
+
prompt_chars: number;
|
|
135
|
+
de_markers: number;
|
|
136
|
+
en_markers: number;
|
|
137
|
+
session_id: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function _loadState(target: string): Partial<PinState> {
|
|
141
|
+
try {
|
|
142
|
+
const raw = fs.readFileSync(target, "utf8");
|
|
143
|
+
const decoded = JSON.parse(raw) as unknown;
|
|
144
|
+
return _isObject(decoded) ? (decoded as Partial<PinState>) : {};
|
|
145
|
+
} catch {
|
|
146
|
+
return {};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Decide the next state from the previous one and this turn's classification.
|
|
152
|
+
* Exported so the tests can assert the keep-previous-pin rule directly.
|
|
153
|
+
*/
|
|
154
|
+
export function nextState(
|
|
155
|
+
previous: Partial<PinState>,
|
|
156
|
+
classification: Classification,
|
|
157
|
+
prompt_chars: number,
|
|
158
|
+
session_id: string,
|
|
159
|
+
now: string,
|
|
160
|
+
): PinState | null {
|
|
161
|
+
if (classification.language === "und") {
|
|
162
|
+
// Terse continuation ("1", "ok", "weiter") — keep whatever was pinned.
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
language: classification.language,
|
|
167
|
+
detected_at: now,
|
|
168
|
+
prompt_chars,
|
|
169
|
+
de_markers: classification.de_markers,
|
|
170
|
+
en_markers: classification.en_markers,
|
|
171
|
+
session_id,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function _extractPrompt(payload: JsonObject): string {
|
|
176
|
+
for (const key of ["prompt", "userPrompt", "user_prompt", "message", "text"]) {
|
|
177
|
+
const v = payload[key];
|
|
178
|
+
if (typeof v === "string" && v.trim()) {
|
|
179
|
+
return v;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return "";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function run(stdin_text: string, options: { consumer_root: string }): number {
|
|
186
|
+
let envelope: JsonObject = {};
|
|
187
|
+
if (stdin_text.trim()) {
|
|
188
|
+
try {
|
|
189
|
+
const decoded = JSON.parse(stdin_text) as unknown;
|
|
190
|
+
if (_isObject(decoded)) {
|
|
191
|
+
envelope = decoded;
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
return EXIT_ALLOW; // never act on a malformed envelope
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const payload = _isObject(envelope["payload"]) ? (envelope["payload"] as JsonObject) : envelope;
|
|
199
|
+
const prompt = _extractPrompt(payload);
|
|
200
|
+
if (!prompt) {
|
|
201
|
+
return EXIT_ALLOW;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const session_id = typeof envelope["session_id"] === "string" ? envelope["session_id"] : "";
|
|
205
|
+
const target = path.join(options.consumer_root, STATE_FILE);
|
|
206
|
+
const previous = _loadState(target);
|
|
207
|
+
const classification = classify(prompt);
|
|
208
|
+
const next = nextState(previous, classification, prompt.length, session_id, new Date().toISOString());
|
|
209
|
+
|
|
210
|
+
const effective = next?.language ?? (previous.language as Verdict | undefined);
|
|
211
|
+
if (next) {
|
|
212
|
+
try {
|
|
213
|
+
atomic_write_json(target, next);
|
|
214
|
+
} catch {
|
|
215
|
+
// Observability only — a failed state write never blocks the turn.
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (effective === "de" || effective === "en") {
|
|
220
|
+
process.stdout.write(
|
|
221
|
+
`${JSON.stringify({ decision: "warn", reason: `Reply language for this turn: ${LANGUAGE_NAME[effective]}.`, additional_context: pinText(effective) })}\n`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
return EXIT_ALLOW;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function main(argv?: string[]): number {
|
|
228
|
+
const args = argv ?? process.argv.slice(2);
|
|
229
|
+
let consumer_root = process.cwd();
|
|
230
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
231
|
+
const a = args[i];
|
|
232
|
+
if (a === "--project-dir" && args[i + 1] !== undefined) {
|
|
233
|
+
consumer_root = args[i + 1] as string;
|
|
234
|
+
i += 1;
|
|
235
|
+
} else if (a !== undefined && a.startsWith("--project-dir=")) {
|
|
236
|
+
consumer_root = a.slice("--project-dir=".length);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return run(readHookStdin(), { consumer_root });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Bundle-safety: never auto-run when inlined into an esbuild bundle, where
|
|
243
|
+
// every module shares the bundle's `import.meta.url` (see cmd_migrate.ts).
|
|
244
|
+
declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
|
|
245
|
+
function _isCliEntry(): boolean {
|
|
246
|
+
if (typeof __AGENT_CONFIG_BUNDLE__ !== "undefined" && __AGENT_CONFIG_BUNDLE__) {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
if (process.argv[1] === undefined) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
253
|
+
if (import.meta.url === argvUrl) {
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
258
|
+
const argv = fs.realpathSync(path.resolve(process.argv[1]));
|
|
259
|
+
return here === argv;
|
|
260
|
+
} catch {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (_isCliEntry()) {
|
|
266
|
+
process.exit(main());
|
|
267
|
+
}
|