@event4u/agent-config 9.25.0 → 9.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +87 -0
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/conformance.md +2 -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/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 +9 -3
- package/dist/agent-src/rules/session-canary.md +2 -0
- 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 +3 -2
- package/dist/agent-src/skills/recursive-verification/SKILL.md +13 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +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 +94 -23
- 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 +355 -63
- 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/gate-coverage.yml +29 -1
- package/src/config/gate-violation-baselines.json +13 -7
- package/src/scripts/_cli/cmd_route_audit.ts +4 -1
- package/src/scripts/_cli/cmd_route_explain.ts +9 -1
- package/src/scripts/ai-video/stitch.sh +27 -9
- package/src/scripts/before_complete_hook.ts +23 -4
- 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_gate_coverage.ts +67 -0
- package/src/scripts/check_hook_bundle_freshness.ts +183 -0
- package/src/scripts/conformance_scan.ts +47 -17
- package/src/scripts/dispatch_r2_reviewer.ts +31 -2
- package/src/scripts/git_authorization_hook.ts +75 -10
- package/src/scripts/hook_manifest.yaml +19 -0
- package/src/scripts/hooks/block_no_verify.ts +3 -1
- package/src/scripts/hooks/block_unauthorized_git.ts +133 -16
- 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 +40 -6
- package/src/scripts/language_mirror_hook.ts +200 -11
- package/src/scripts/lint_abstraction_thresholds.ts +41 -8
- package/src/scripts/lint_agents_layout.ts +2 -0
- package/src/scripts/lint_hook_manifest.ts +29 -0
- 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/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
|
@@ -33,15 +33,35 @@
|
|
|
33
33
|
* context does not otherwise contain — which language the human wrote in.
|
|
34
34
|
*
|
|
35
35
|
* State: `agents/state/language-mirror.json`
|
|
36
|
-
* { "language": "de"|"en"|"und", "
|
|
37
|
-
* "
|
|
38
|
-
* "session_id": str }
|
|
36
|
+
* { "language": "de"|"en"|"und", "source": "prompt"|"system-locale",
|
|
37
|
+
* "detected_at": iso8601, "prompt_chars": int, "de_markers": int,
|
|
38
|
+
* "en_markers": int, "session_id": str }
|
|
39
39
|
*
|
|
40
40
|
* Undetermined prompts (a bare "1", "ok", a pasted URL) leave a previous pin
|
|
41
41
|
* UNTOUCHED rather than clearing it — a short continuation does not change the
|
|
42
42
|
* conversation's language, and clearing on every terse turn would reproduce the
|
|
43
43
|
* exact drift this hook removes.
|
|
44
44
|
*
|
|
45
|
+
* FIRST TURN OF A SESSION — the system-locale fallback.
|
|
46
|
+
*
|
|
47
|
+
* The keep-previous-pin rule has nothing to keep on the very first prompt. If
|
|
48
|
+
* that prompt is terse ("weiter", "1", a pasted URL) the verdict is `und`, no
|
|
49
|
+
* pin is written, and the turn runs with no pin at all — which is the drift
|
|
50
|
+
* this hook exists to remove, occurring precisely where the conversation has
|
|
51
|
+
* the least other evidence. So when there is no previous pin AND the prompt is
|
|
52
|
+
* undetermined, the environment's locale supplies a starting language, recorded
|
|
53
|
+
* as `source: "system-locale"` so the weaker provenance is legible rather than
|
|
54
|
+
* indistinguishable from a read of the user's own words.
|
|
55
|
+
*
|
|
56
|
+
* A locale pin is a floor, never a ceiling: the FIRST prompt carrying real
|
|
57
|
+
* markers replaces it, because `source: "prompt"` always wins. That is what
|
|
58
|
+
* makes "any later explicit statement by the user overrides it" true without a
|
|
59
|
+
* second mechanism — the ordinary path already outranks the fallback.
|
|
60
|
+
*
|
|
61
|
+
* Locales outside the classifier's two languages stay `und` on purpose. Mapping
|
|
62
|
+
* `fr_FR` to English because the classifier has no French would pin a language
|
|
63
|
+
* the user never wrote, which is worse than carrying no pin.
|
|
64
|
+
*
|
|
45
65
|
* Never blocks (`fail_closed: false`, `severity: advisory`). Exit 0 always.
|
|
46
66
|
*/
|
|
47
67
|
import fs from "node:fs";
|
|
@@ -52,6 +72,13 @@ import { atomic_write_json } from "./hooks/state_io.js";
|
|
|
52
72
|
import { readHookStdin } from "./hooks/hook_stdin.js";
|
|
53
73
|
|
|
54
74
|
const EXIT_ALLOW = 0;
|
|
75
|
+
// Severity is taken from the EXIT CODE, not from the `decision` field in the
|
|
76
|
+
// stdout payload. This hook shipped returning EXIT_ALLOW while writing
|
|
77
|
+
// {"decision":"warn", …}, so the dispatcher reduced it to `allow`, emitted
|
|
78
|
+
// nothing, and the pin reached the model on no path — a state write with no
|
|
79
|
+
// delivery, for the audit's largest failure class (626 turns). Round 2, found
|
|
80
|
+
// by tracing the delivery rather than re-reading the unit tests.
|
|
81
|
+
const EXIT_WARN = 2;
|
|
55
82
|
|
|
56
83
|
export const STATE_FILE = path.join("agents", "state", "language-mirror.json");
|
|
57
84
|
|
|
@@ -68,7 +95,7 @@ function _isObject(v: unknown): v is JsonObject {
|
|
|
68
95
|
* ("fixe die ci", "mach weiter") where content words carry no signal.
|
|
69
96
|
*/
|
|
70
97
|
const DE_MARKERS =
|
|
71
|
-
/\b(der|die|das|den|dem|und|ist|sind|nicht|kein|keine|mach|mache|bitte|warum|wieso|weshalb|weiter|kannst|sollst|soll|musst|muss|hast|habe|haben|wir|ich|du|dann|noch|auch|schon|wenn|aber|alle|mit|ohne|damit|dass|wurde|werden|gibt|nochmal|erstell|erstelle|erstelle|fehler|regeln|arbeite|analysiere|fixe|nimm|lege|prüfe|pruefe|schau|zeig|mir|dir|sich|eine|einen|einem|einer|vom|zum|zur|beim|im
|
|
98
|
+
/\b(der|die|das|den|dem|und|ist|sind|nicht|kein|keine|mach|mache|bitte|warum|wieso|weshalb|weiter|kannst|sollst|soll|musst|muss|hast|habe|haben|wir|ich|du|dann|noch|auch|schon|wenn|aber|alle|mit|ohne|damit|dass|wurde|werden|gibt|nochmal|erstell|erstelle|erstelle|fehler|regeln|arbeite|analysiere|fixe|nimm|lege|prüfe|pruefe|schau|zeig|mir|dir|sich|eine|einen|einem|einer|vom|zum|zur|beim|im)\b|[äöüÄÖÜß]/gi;
|
|
72
99
|
|
|
73
100
|
/**
|
|
74
101
|
* English function words that a German sentence would not contain. Chosen to
|
|
@@ -96,9 +123,45 @@ export interface Classification {
|
|
|
96
123
|
* Below `MIN_MARKERS` on both sides the verdict is `und` (undetermined) and the
|
|
97
124
|
* caller keeps any previous pin.
|
|
98
125
|
*/
|
|
126
|
+
/**
|
|
127
|
+
* Strip the parts of a prompt that are PASTED rather than written.
|
|
128
|
+
*
|
|
129
|
+
* Without this the classifier reproduced the very defect it exists to fix: one
|
|
130
|
+
* German instruction plus a pasted English stack trace scored `en=18 / de=2`
|
|
131
|
+
* and pinned English — then asserted emphatically that English was the mirror
|
|
132
|
+
* target. The maintainer's most common prompt shape is exactly that.
|
|
133
|
+
*/
|
|
134
|
+
export function instructionText(prompt: string): string {
|
|
135
|
+
const OUTPUT_HEAD =
|
|
136
|
+
/^\s*(To\s+\S+|remote:|error:|fatal:|hint:|warning:|npm ERR!|Error:|Traceback|Exception|[A-Za-z]*Error\b|at\s+\S+:\d+|\s{4,}\S|[-+]{3}\s|@@\s|\$\s|>\s|\||\d+\s*\|)/i;
|
|
137
|
+
const kept: string[] = [];
|
|
138
|
+
let inPaste = false;
|
|
139
|
+
for (const line of prompt.replace(/```[\s\S]*?```/g, "\n\n").split("\n")) {
|
|
140
|
+
if (!line.trim()) {
|
|
141
|
+
// A blank line ends a pasted block — this is how pastes actually look,
|
|
142
|
+
// and it is what makes the filter work on a MULTI-LINE trace whose
|
|
143
|
+
// continuation lines carry no marker of their own.
|
|
144
|
+
inPaste = false;
|
|
145
|
+
kept.push(line);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (OUTPUT_HEAD.test(line)) {
|
|
149
|
+
inPaste = true;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (inPaste) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
kept.push(line);
|
|
156
|
+
}
|
|
157
|
+
return kept.join("\n");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
99
161
|
export function classify(prompt: string): Classification {
|
|
100
|
-
const
|
|
101
|
-
const
|
|
162
|
+
const text = instructionText(prompt);
|
|
163
|
+
const de = (text.match(DE_MARKERS) ?? []).length;
|
|
164
|
+
const en = (text.match(EN_MARKERS) ?? []).length;
|
|
102
165
|
if (de < MIN_MARKERS && en < MIN_MARKERS) {
|
|
103
166
|
return { language: "und", de_markers: de, en_markers: en };
|
|
104
167
|
}
|
|
@@ -110,9 +173,97 @@ const LANGUAGE_NAME: Record<Exclude<Verdict, "und">, string> = {
|
|
|
110
173
|
en: "English",
|
|
111
174
|
};
|
|
112
175
|
|
|
176
|
+
/**
|
|
177
|
+
* How a pin came to be — the provenance the roadmap's Phase 4 asks for.
|
|
178
|
+
*
|
|
179
|
+
* `prompt` means the user's own words carried the markers. `system-locale`
|
|
180
|
+
* means they did not and the environment answered instead. The distinction is
|
|
181
|
+
* load-bearing rather than decorative: a locale pin is the one a user may
|
|
182
|
+
* legitimately want to contradict, and the notice says which it is so that
|
|
183
|
+
* contradicting it does not require guessing where it came from.
|
|
184
|
+
*/
|
|
185
|
+
export type PinSource = "prompt" | "system-locale";
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The env vars that carry a POSIX locale, most specific first.
|
|
189
|
+
*
|
|
190
|
+
* `LC_ALL` overrides everything by definition; `LC_MESSAGES` governs the
|
|
191
|
+
* language of messages specifically, which is exactly this question; `LANG` is
|
|
192
|
+
* the fallback for both. `LANGUAGE` is deliberately last — it is a GNU
|
|
193
|
+
* extension holding a colon-separated PREFERENCE LIST, so it answers a
|
|
194
|
+
* different question and only its first entry is comparable.
|
|
195
|
+
*/
|
|
196
|
+
const LOCALE_ENV_KEYS = ["LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"] as const;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Map an environment locale onto the classifier's verdict space.
|
|
200
|
+
*
|
|
201
|
+
* Returns `und` for a locale the classifier cannot mirror, for `C`/`POSIX`
|
|
202
|
+
* (which name no human language), and for an unset environment. Only the
|
|
203
|
+
* primary subtag is read: `de_AT.UTF-8`, `de-CH`, and `de` are one answer.
|
|
204
|
+
*/
|
|
205
|
+
export function systemLocaleVerdict(env: NodeJS.ProcessEnv = process.env): Verdict {
|
|
206
|
+
for (const key of LOCALE_ENV_KEYS) {
|
|
207
|
+
const raw = (env[key] ?? "").trim();
|
|
208
|
+
if (!raw) {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
// `LANGUAGE` may hold `de:en:fr`; the leading entry is the preference.
|
|
212
|
+
const first = raw.split(":")[0] ?? "";
|
|
213
|
+
// Strip the territory and codeset: `de_AT.UTF-8@euro` → `de`.
|
|
214
|
+
const primary = (first.split(/[._@-]/)[0] ?? "").toLowerCase();
|
|
215
|
+
if (primary === "c" || primary === "posix" || !primary) {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (primary === "de") {
|
|
219
|
+
return "de";
|
|
220
|
+
}
|
|
221
|
+
if (primary === "en") {
|
|
222
|
+
return "en";
|
|
223
|
+
}
|
|
224
|
+
// A locale the classifier has no markers for. Stop rather than fall
|
|
225
|
+
// through to a less specific variable: the user HAS stated a language and
|
|
226
|
+
// it is neither of the two, so the honest answer is "no pin".
|
|
227
|
+
return "und";
|
|
228
|
+
}
|
|
229
|
+
return "und";
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The one-line notice the dispatcher surfaces for this turn.
|
|
234
|
+
*
|
|
235
|
+
* A locale-sourced pin says so and says how to override it. A prompt-sourced
|
|
236
|
+
* pin keeps its original wording, because that line is what the conformance
|
|
237
|
+
* audit measured and a reworded pin is a changed instrument.
|
|
238
|
+
*/
|
|
239
|
+
export function noticeText(language: Exclude<Verdict, "und">, source: PinSource): string {
|
|
240
|
+
const name = LANGUAGE_NAME[language];
|
|
241
|
+
if (source === "system-locale") {
|
|
242
|
+
return (
|
|
243
|
+
`Reply language for this turn: ${name} — the prompt carried no language ` +
|
|
244
|
+
`markers, so this fell back to the system locale. Write in another ` +
|
|
245
|
+
`language and the next turn follows the prompt instead.`
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
return `Reply language for this turn: ${name}.`;
|
|
249
|
+
}
|
|
250
|
+
|
|
113
251
|
/** The context block handed back to the model for this turn. */
|
|
114
|
-
export function pinText(language: Exclude<Verdict, "und"
|
|
252
|
+
export function pinText(language: Exclude<Verdict, "und">, source: PinSource = "prompt"): string {
|
|
115
253
|
const name = LANGUAGE_NAME[language];
|
|
254
|
+
if (source === "system-locale") {
|
|
255
|
+
return (
|
|
256
|
+
`<language-pin>\n` +
|
|
257
|
+
`No language markers were found in this turn's prompt and no language was ` +
|
|
258
|
+
`pinned yet, so the mirror target falls back to the environment's locale: ` +
|
|
259
|
+
`${name}. Use it for EVERY user-visible token this turn.\n\n` +
|
|
260
|
+
`This is the WEAKER of the two provenances — it reflects the machine, not ` +
|
|
261
|
+
`the user's own words. The first prompt that carries real markers replaces ` +
|
|
262
|
+
`it. Do not treat it as a standing preference, and do not argue with the ` +
|
|
263
|
+
`user about it if they write in something else.\n` +
|
|
264
|
+
`</language-pin>`
|
|
265
|
+
);
|
|
266
|
+
}
|
|
116
267
|
return (
|
|
117
268
|
`<language-pin>\n` +
|
|
118
269
|
`The user submitted this turn's prompt in ${name}. That is the mirror target ` +
|
|
@@ -130,6 +281,7 @@ export function pinText(language: Exclude<Verdict, "und">): string {
|
|
|
130
281
|
|
|
131
282
|
export interface PinState extends JsonObject {
|
|
132
283
|
language: Verdict;
|
|
284
|
+
source: PinSource;
|
|
133
285
|
detected_at: string;
|
|
134
286
|
prompt_chars: number;
|
|
135
287
|
de_markers: number;
|
|
@@ -157,13 +309,30 @@ export function nextState(
|
|
|
157
309
|
prompt_chars: number,
|
|
158
310
|
session_id: string,
|
|
159
311
|
now: string,
|
|
312
|
+
locale: Verdict = "und",
|
|
160
313
|
): PinState | null {
|
|
161
314
|
if (classification.language === "und") {
|
|
162
315
|
// Terse continuation ("1", "ok", "weiter") — keep whatever was pinned.
|
|
163
|
-
|
|
316
|
+
if (previous.language === "de" || previous.language === "en") {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
// FIRST turn and nothing to keep: the locale is better than no pin at all.
|
|
320
|
+
if (locale === "und") {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
language: locale,
|
|
325
|
+
source: "system-locale",
|
|
326
|
+
detected_at: now,
|
|
327
|
+
prompt_chars,
|
|
328
|
+
de_markers: classification.de_markers,
|
|
329
|
+
en_markers: classification.en_markers,
|
|
330
|
+
session_id,
|
|
331
|
+
};
|
|
164
332
|
}
|
|
165
333
|
return {
|
|
166
334
|
language: classification.language,
|
|
335
|
+
source: "prompt",
|
|
167
336
|
detected_at: now,
|
|
168
337
|
prompt_chars,
|
|
169
338
|
de_markers: classification.de_markers,
|
|
@@ -182,7 +351,10 @@ function _extractPrompt(payload: JsonObject): string {
|
|
|
182
351
|
return "";
|
|
183
352
|
}
|
|
184
353
|
|
|
185
|
-
export function run(
|
|
354
|
+
export function run(
|
|
355
|
+
stdin_text: string,
|
|
356
|
+
options: { consumer_root: string; env?: NodeJS.ProcessEnv },
|
|
357
|
+
): number {
|
|
186
358
|
let envelope: JsonObject = {};
|
|
187
359
|
if (stdin_text.trim()) {
|
|
188
360
|
try {
|
|
@@ -205,9 +377,25 @@ export function run(stdin_text: string, options: { consumer_root: string }): num
|
|
|
205
377
|
const target = path.join(options.consumer_root, STATE_FILE);
|
|
206
378
|
const previous = _loadState(target);
|
|
207
379
|
const classification = classify(prompt);
|
|
208
|
-
const next = nextState(
|
|
380
|
+
const next = nextState(
|
|
381
|
+
previous,
|
|
382
|
+
classification,
|
|
383
|
+
prompt.length,
|
|
384
|
+
session_id,
|
|
385
|
+
new Date().toISOString(),
|
|
386
|
+
// Injectable so the suite can pin a locale instead of inheriting the
|
|
387
|
+
// machine's — a test whose verdict depends on the developer's `LANG` is a
|
|
388
|
+
// test that passes here and fails in CI for reasons unrelated to the diff.
|
|
389
|
+
systemLocaleVerdict(options.env ?? process.env),
|
|
390
|
+
);
|
|
209
391
|
|
|
210
392
|
const effective = next?.language ?? (previous.language as Verdict | undefined);
|
|
393
|
+
// A state file written before `source` existed carries only what the old
|
|
394
|
+
// version could produce — a prompt reading. Absent is therefore `prompt`,
|
|
395
|
+
// never "unknown": treating it as unknown would reword the audited notice
|
|
396
|
+
// for every pre-existing session.
|
|
397
|
+
const effectiveSource: PinSource =
|
|
398
|
+
next?.source ?? (previous.source === "system-locale" ? "system-locale" : "prompt");
|
|
211
399
|
if (next) {
|
|
212
400
|
try {
|
|
213
401
|
atomic_write_json(target, next);
|
|
@@ -218,8 +406,9 @@ export function run(stdin_text: string, options: { consumer_root: string }): num
|
|
|
218
406
|
|
|
219
407
|
if (effective === "de" || effective === "en") {
|
|
220
408
|
process.stdout.write(
|
|
221
|
-
`${JSON.stringify({ decision: "warn", reason:
|
|
409
|
+
`${JSON.stringify({ decision: "warn", reason: noticeText(effective, effectiveSource), additional_context: pinText(effective, effectiveSource) })}\n`,
|
|
222
410
|
);
|
|
411
|
+
return EXIT_WARN;
|
|
223
412
|
}
|
|
224
413
|
return EXIT_ALLOW;
|
|
225
414
|
}
|
|
@@ -16,10 +16,18 @@
|
|
|
16
16
|
* `abstraction-thresholds` (the canon citation). A bare number is drift.
|
|
17
17
|
* A heading is its own block; a threshold-stating heading is satisfied
|
|
18
18
|
* when the block immediately below it carries the citation.
|
|
19
|
-
* 2. PINNING —
|
|
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',
|
|
@@ -148,6 +148,35 @@ function _check_concerns(manifest: YamlObject, errors: string[]): Set<string> {
|
|
|
148
148
|
if (!_isFile(path.join(REPO_ROOT, script))) {
|
|
149
149
|
errors.push(`concerns.${name}: script not found at '${script}'`);
|
|
150
150
|
}
|
|
151
|
+
// `tools:` — the optional per-concern tool filter the dispatcher applies
|
|
152
|
+
// in-process (`_concern_matches_tool`). Validated because the failure mode
|
|
153
|
+
// is silent: the dispatcher fails toward RUNNING the concern on anything
|
|
154
|
+
// malformed, so a typo (`tool:`, a bare string, `[]`) would look like a
|
|
155
|
+
// working filter while filtering nothing. An unvalidated key is worse than
|
|
156
|
+
// no key.
|
|
157
|
+
if ("tools" in spec) {
|
|
158
|
+
const tools = spec["tools"];
|
|
159
|
+
if (!Array.isArray(tools)) {
|
|
160
|
+
errors.push(
|
|
161
|
+
`concerns.${name}: 'tools' must be a list of tool names ` +
|
|
162
|
+
`(got ${pyTypeName(tools)}) — omit the key for "every event"`,
|
|
163
|
+
);
|
|
164
|
+
} else if (tools.length === 0) {
|
|
165
|
+
errors.push(
|
|
166
|
+
`concerns.${name}: 'tools' is an empty list — omit the key, or use ` +
|
|
167
|
+
`["*"], rather than a filter that reads as "no tools"`,
|
|
168
|
+
);
|
|
169
|
+
} else {
|
|
170
|
+
for (const t of tools) {
|
|
171
|
+
if (typeof t !== "string" || t.trim() === "") {
|
|
172
|
+
errors.push(
|
|
173
|
+
`concerns.${name}: 'tools' entries must be non-empty strings, ` +
|
|
174
|
+
`got ${pyTypeName(t)}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
151
180
|
names.add(name);
|
|
152
181
|
}
|
|
153
182
|
return names;
|
|
@@ -29,6 +29,20 @@ import fs from 'node:fs';
|
|
|
29
29
|
import path from 'node:path';
|
|
30
30
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
31
31
|
|
|
32
|
+
// This hook is the first in the tree to import from `src/shared/`, and its
|
|
33
|
+
// contract is fail-open — but the `try/catch` that delivers that lives in
|
|
34
|
+
// `main()`, so an import-time throw would escape it. The concern is real and
|
|
35
|
+
// the answer is measured rather than assumed: esbuild INLINES both modules into
|
|
36
|
+
// `dist/hooks/dispatch.js` (verified by building the bundle to a scratch path
|
|
37
|
+
// and grepping for `consentVerdict` and `withheld-machine-inferred`), so the
|
|
38
|
+
// installed path performs no runtime module resolution at all. Both modules are
|
|
39
|
+
// also pure declarations with zero top-level side effects, so there is nothing
|
|
40
|
+
// at import time that can throw. If either fact stops holding — a dynamic
|
|
41
|
+
// import, a module-level read — the fail-open guarantee weakens and this
|
|
42
|
+
// import has to move inside the guard.
|
|
43
|
+
import type { SettingsClass } from '../shared/settingsClasses.js';
|
|
44
|
+
import { consentVerdict, type ConsentVerdict } from '../shared/settingsConsent.js';
|
|
45
|
+
|
|
32
46
|
import {
|
|
33
47
|
buildSidecar,
|
|
34
48
|
LESSONS_NAME,
|
|
@@ -40,18 +54,54 @@ const SETTINGS_FILE = '.agent-settings.yml';
|
|
|
40
54
|
/** Wall-clock budget for the whole aggregation (ms). */
|
|
41
55
|
export const BUDGET_MS = 2000;
|
|
42
56
|
|
|
57
|
+
/** The key this hook is gated on. */
|
|
58
|
+
export const LEARN_KEY = 'memory.learn_on_session_end';
|
|
59
|
+
|
|
43
60
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
61
|
+
* The key's class, pinned rather than parsed.
|
|
62
|
+
*
|
|
63
|
+
* `classOfPath` would give the same answer, but only after reading and parsing
|
|
64
|
+
* `docs/contracts/settings-classes.md` — a multi-hundred-row markdown file — on
|
|
65
|
+
* every session teardown, which is exactly the cost the 2 s budget above exists
|
|
66
|
+
* to avoid. The drift risk a hardcoded constant carries is neutralised the
|
|
67
|
+
* cheap way instead: `tests/scripts/memory_learn_hook.test.ts` asserts the
|
|
68
|
+
* contract still classifies this key `B`, so a reclassification reds CI rather
|
|
69
|
+
* than silently unbinding the gate.
|
|
47
70
|
*/
|
|
48
|
-
export
|
|
71
|
+
export const LEARN_KEY_CLASS: SettingsClass = 'B';
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Whether `memory.learn_on_session_end` reads as enabled, or `undefined` when
|
|
75
|
+
* the file or the key is absent (same hand-rolled mini-parser pattern as the
|
|
76
|
+
* sibling hooks — no YAML dependency in the hook hot path).
|
|
77
|
+
*
|
|
78
|
+
* Normalised strictly: the literal `true` and nothing else enables the hook. A
|
|
79
|
+
* crude parser must not let `yes` / `1` / `maybe` read as a permission, and
|
|
80
|
+
* `isConservativeDefault` would treat any non-empty string as permissive — so
|
|
81
|
+
* the normalisation happens here, before the consent check, not inside it.
|
|
82
|
+
*
|
|
83
|
+
* WHAT THE TRI-STATE DOES AND DOES NOT SAY. `undefined` means "no answer was
|
|
84
|
+
* found" — the key is absent, the file is absent, OR the file could not be read
|
|
85
|
+
* at all (permissions, a directory at the path, an I/O error). `false` means
|
|
86
|
+
* EITHER a deliberate `false` OR a present-but-malformed scalar. So there are
|
|
87
|
+
* two collapses, not one: a user who writes `learn_on_session_end: yes` and a
|
|
88
|
+
* user whose settings file is unreadable both get a silent, permanent no-op
|
|
89
|
+
* with no diagnostic.
|
|
90
|
+
*
|
|
91
|
+
* The fail-safe direction is deliberate in both cases — a hook that writes
|
|
92
|
+
* files must not be enabled by a value nobody typed on purpose, nor by a file
|
|
93
|
+
* nobody could read. The ambiguity is the cost, and it is stated here rather
|
|
94
|
+
* than implied by a "raw scalar" this function does not actually return.
|
|
95
|
+
* Diagnosing either shape belongs to `settings:check`, not to a fail-open
|
|
96
|
+
* teardown hook.
|
|
97
|
+
*/
|
|
98
|
+
export function readLearnValue(root: string): boolean | undefined {
|
|
49
99
|
const p = path.join(root, SETTINGS_FILE);
|
|
50
100
|
let text: string;
|
|
51
101
|
try {
|
|
52
102
|
text = fs.readFileSync(p, 'utf8');
|
|
53
103
|
} catch {
|
|
54
|
-
return
|
|
104
|
+
return undefined;
|
|
55
105
|
}
|
|
56
106
|
let inMemory = false;
|
|
57
107
|
for (const rawLine of text.split('\n')) {
|
|
@@ -69,7 +119,60 @@ export function enabled(root: string): boolean {
|
|
|
69
119
|
if (m) return m[1] === 'true';
|
|
70
120
|
}
|
|
71
121
|
}
|
|
72
|
-
return
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The consent verdict for running the aggregation.
|
|
127
|
+
*
|
|
128
|
+
* This is the first production caller of `consentVerdict` — Phase 5 step 4 of
|
|
129
|
+
* `road-to-zero-ceremony-settings` asks the consent-gated action to verify the
|
|
130
|
+
* recorded DECISION rather than read the bare value, and this session_end
|
|
131
|
+
* concern is the only action in the tree that a class-B key actually gates.
|
|
132
|
+
*
|
|
133
|
+
* `handEdited: true` is a fact about the path, not a shortcut: the file read
|
|
134
|
+
* here is the PROJECT-LOCAL `.agent-settings.yml`, which
|
|
135
|
+
* `docs/contracts/settings-classes.md` names as a file only a human writes —
|
|
136
|
+
* `settings:set` and the GUI both write the user-global file instead. So on
|
|
137
|
+
* this path a permissive value IS the recorded decision and the verdict is
|
|
138
|
+
* `granted`.
|
|
139
|
+
*
|
|
140
|
+
* WHAT THIS CHANGES TODAY: nothing observable. Stated plainly because the
|
|
141
|
+
* opposite was claimed first and it was wrong. With `cls` a constant,
|
|
142
|
+
* `handEdited` a constant `true`, and the value already reduced to a boolean by
|
|
143
|
+
* `readLearnValue`, every discriminating input to `consentVerdict` is fixed, so
|
|
144
|
+
* `enabled()` returns exactly what the previous `=== 'true'` read returned —
|
|
145
|
+
* including for `yes` / `1` / `on`, which that read already rejected. The
|
|
146
|
+
* normalisation is not new and this call did not narrow it.
|
|
147
|
+
*
|
|
148
|
+
* WHAT IT DOES BUY, which is smaller than it sounds and worth being exact
|
|
149
|
+
* about: the obligation is expressed in code instead of prose, so
|
|
150
|
+
* `consentVerdict` has a real caller and stops being a library nobody invokes;
|
|
151
|
+
* the class binding is asserted against the contract by a test, so a
|
|
152
|
+
* reclassification reds CI (it does NOT re-route the hook at runtime — the
|
|
153
|
+
* class is a hardcoded constant); and the day this hook learns to read the
|
|
154
|
+
* user-global file, the sidecar check is already the thing standing in front of
|
|
155
|
+
* it rather than a change someone must remember to make.
|
|
156
|
+
*
|
|
157
|
+
* KNOWN GAP, recorded rather than fixed here: the hook reads only the
|
|
158
|
+
* project-local file, so a user who enables this in the GUI — which writes the
|
|
159
|
+
* user-global file plus the provenance sidecar — never triggers it. Reading the
|
|
160
|
+
* user-global file is where the sidecar's `auto-detected`-refusing power would
|
|
161
|
+
* actually discriminate, but it is also a behaviour change (a hook that writes
|
|
162
|
+
* files would start firing for users it does not fire for today), so it belongs
|
|
163
|
+
* to a decision rather than to this step. Filed in the roadmap.
|
|
164
|
+
*/
|
|
165
|
+
export function learnConsent(root: string): ConsentVerdict {
|
|
166
|
+
return consentVerdict({
|
|
167
|
+
cls: LEARN_KEY_CLASS,
|
|
168
|
+
value: readLearnValue(root) ?? false,
|
|
169
|
+
handEdited: true,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** `true` only when the recorded decision permits the aggregation. */
|
|
174
|
+
export function enabled(root: string): boolean {
|
|
175
|
+
return learnConsent(root) === 'granted';
|
|
73
176
|
}
|
|
74
177
|
|
|
75
178
|
/** Run the aggregation. Returns the one-line marker to emit, or null. */
|
|
@@ -66,6 +66,20 @@ function _isObject(v: unknown): v is JsonObject {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/** Return [tool_name, command_text] from a tool-event payload. */
|
|
69
|
+
/**
|
|
70
|
+
* Unwrap the dispatcher envelope.
|
|
71
|
+
*
|
|
72
|
+
* The dispatcher hands every concern `{event, platform, payload: <native>}`,
|
|
73
|
+
* but this hook read `tool_name` / `tool_response` off the TOP level. Fed a raw
|
|
74
|
+
* payload it worked; fed the real envelope it found nothing and returned ALLOW.
|
|
75
|
+
* Measured 2026-08-06: standalone 624 bytes of output, through the built
|
|
76
|
+
* dispatcher 0 bytes — the reminder had never fired in production.
|
|
77
|
+
*/
|
|
78
|
+
function _unwrap(envelope: JsonObject): JsonObject {
|
|
79
|
+
const inner = envelope["payload"];
|
|
80
|
+
return _isObject(inner) ? inner : envelope;
|
|
81
|
+
}
|
|
82
|
+
|
|
69
83
|
function _extractCommand(payload: JsonObject): [string | null, string | null] {
|
|
70
84
|
const toolRaw = payload["tool_name"] ?? payload["toolName"] ?? payload["tool"];
|
|
71
85
|
const tool = typeof toolRaw === "string" ? toolRaw : null;
|
|
@@ -124,12 +138,13 @@ export function main(): number {
|
|
|
124
138
|
return EXIT_ALLOW;
|
|
125
139
|
}
|
|
126
140
|
|
|
127
|
-
const
|
|
141
|
+
const inner = _unwrap(envelope);
|
|
142
|
+
const [tool, command] = _extractCommand(inner);
|
|
128
143
|
if (!_isPrCreate(tool, command)) {
|
|
129
144
|
return EXIT_ALLOW;
|
|
130
145
|
}
|
|
131
146
|
|
|
132
|
-
const match = _PR_URL.exec(_toolOutput(
|
|
147
|
+
const match = _PR_URL.exec(_toolOutput(inner));
|
|
133
148
|
if (match === null) {
|
|
134
149
|
return EXIT_ALLOW; // create attempted but no URL produced (failed / dry-run) — do not nag
|
|
135
150
|
}
|
package/src/scripts/release.ts
CHANGED
|
@@ -767,11 +767,48 @@ function watch_pr_checks(branch: string): void {
|
|
|
767
767
|
* 2026-08-03, 9.15.0). Same-branch commits are never ours to drop, so the
|
|
768
768
|
* recovery is merge-and-retry, never force-push.
|
|
769
769
|
*/
|
|
770
|
+
/**
|
|
771
|
+
* True when git rejected the push because the remote ref moved ahead — the
|
|
772
|
+
* ONLY failure the merge-and-retry recovery below can repair.
|
|
773
|
+
*
|
|
774
|
+
* git's wording for this is stable across versions: the hint is `fetch first`
|
|
775
|
+
* / `Updates were rejected`, and the ref line carries `[rejected]` with
|
|
776
|
+
* `non-fast-forward` or `fetch first`.
|
|
777
|
+
*/
|
|
778
|
+
export function _is_non_fast_forward(stderr: string, stdout: string): boolean {
|
|
779
|
+
const text = `${stderr}\n${stdout}`;
|
|
780
|
+
return (
|
|
781
|
+
/\[rejected\]/i.test(text) &&
|
|
782
|
+
/(non-fast-forward|fetch first|behind its remote counterpart)/i.test(text)
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
|
|
770
786
|
function push_release_branch(branch: string): void {
|
|
771
|
-
const first = run(['git', 'push', '-u', REMOTE, branch], { check: false });
|
|
787
|
+
const first = run(['git', 'push', '-u', REMOTE, branch], { check: false, capture: true });
|
|
772
788
|
if (first.returncode === 0) {
|
|
789
|
+
process.stdout.write(first.stdout);
|
|
790
|
+
process.stderr.write(first.stderr);
|
|
773
791
|
return;
|
|
774
792
|
}
|
|
793
|
+
|
|
794
|
+
// A push can fail for many reasons — a pre-push hook refusing the commit,
|
|
795
|
+
// no credentials, a protected branch. Only ONE of them is repaired by
|
|
796
|
+
// fetch+merge+retry, and the recovery's first step (`git fetch <branch>`)
|
|
797
|
+
// exits 128 for a branch that was never pushed. Taking that path
|
|
798
|
+
// unconditionally replaced every real error with
|
|
799
|
+
// `couldn't find remote ref <branch>` — measured 2026-08-06 on 9.26.0,
|
|
800
|
+
// where a pre-push gate rejection surfaced as a git-fetch crash and cost
|
|
801
|
+
// an hour of diagnosis pointed at the wrong layer.
|
|
802
|
+
if (!_is_non_fast_forward(first.stderr, first.stdout)) {
|
|
803
|
+
process.stdout.write(first.stdout);
|
|
804
|
+
process.stderr.write(first.stderr);
|
|
805
|
+
die(
|
|
806
|
+
`push of ${branch} failed (exit ${first.returncode}) and the remote ref did not move — ` +
|
|
807
|
+
'this is not the fetch-and-retry case. The push output above is the real error ' +
|
|
808
|
+
'(a pre-push gate, credentials, or branch protection); fix that and re-run.',
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
|
|
775
812
|
process.stdout.write(`↻ ${REMOTE}/${branch} moved — integrating and retrying push\n`);
|
|
776
813
|
run(['git', 'fetch', REMOTE, branch]);
|
|
777
814
|
run(['git', 'merge', '--no-edit', `${REMOTE}/${branch}`]);
|