@event4u/agent-config 9.30.0 → 9.31.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 +31 -314
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/{chunk-IYTGIG2Y.js → chunk-2WCLVDWC.js} +1 -1
- package/dist/cli-delegate/{chunk-66OVWGB5.js → chunk-AG47LAGQ.js} +1 -1
- package/dist/cli-delegate/{chunk-MP3JT3IU.js → chunk-DS6BLDMG.js} +1 -1
- package/dist/cli-delegate/{chunk-O3CO5BFB.js → chunk-EJITJBJX.js} +12 -1
- package/dist/cli-delegate/{chunk-EQY4FLJB.js → chunk-ITIQSGOQ.js} +3 -3
- package/dist/cli-delegate/{chunk-LCFJIQ4J.js → chunk-JEJABGKB.js} +110 -6
- package/dist/cli-delegate/{chunk-BTJNRUX3.js → chunk-QEEB4LHO.js} +1 -1
- package/dist/cli-delegate/{chunk-DDUNNFYX.js → chunk-UVKNIJH2.js} +2 -2
- package/dist/cli-delegate/cmd_analyze_session.js +1 -1
- package/dist/cli-delegate/cmd_brand_status.js +1 -1
- package/dist/cli-delegate/cmd_conformance.js +98 -36
- package/dist/cli-delegate/cmd_doctor.js +3 -3
- package/dist/cli-delegate/cmd_explain.js +2 -2
- package/dist/cli-delegate/cmd_export.js +4 -4
- package/dist/cli-delegate/cmd_handoff.js +2 -2
- package/dist/cli-delegate/cmd_mcp_available.js +1 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_packs_active.js +2 -2
- package/dist/cli-delegate/cmd_prune.js +4 -4
- package/dist/cli-delegate/cmd_route_audit.js +2 -2
- package/dist/cli-delegate/cmd_route_explain.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +2 -2
- package/dist/cli-delegate/cmd_settings_get.js +2 -55
- package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +4 -4
- package/dist/cli-delegate/cmd_uninstall.js +4 -4
- package/dist/cli-delegate/cmd_update.js +1 -1
- package/dist/cli-delegate/cmd_upgrade.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +4 -4
- package/dist/cli-delegate/cmd_versions.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +1 -1
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +1 -1
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +1 -1
- package/dist/hooks/dispatch.js +347 -174
- package/dist/install/install.mjs +121 -16
- package/dist/mcp/registry-manifest.json +1 -1
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +310 -138
- package/dist/scripts/_lib/agent_settings.js +111 -2
- package/dist/scripts/_lib/agent_settings.js.map +1 -1
- package/dist/server/schemas/settings.js +2 -2
- package/dist/server/schemas/settings.js.map +1 -1
- package/package.json +1 -1
- package/src/config/pack-size-budget.json +3 -2
- package/src/scripts/_cli/explain_last/inputs.ts +13 -0
- package/src/scripts/_lib/agent_settings.ts +119 -2
- package/src/scripts/_lib/carrier_divergence.ts +47 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +296 -88
- package/src/scripts/language_mirror_hook.ts +69 -16
- package/src/scripts/measure_turn_end_gate.ts +129 -51
- package/src/scripts/report_carrier_divergence.ts +72 -24
- package/src/scripts/report_conformance_funnel.ts +11 -4
- package/src/server/schemas/settings.ts +2 -2
|
@@ -72,7 +72,7 @@ import fs from "node:fs";
|
|
|
72
72
|
import path from "node:path";
|
|
73
73
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
74
74
|
|
|
75
|
-
import { atomic_write_json } from "./hooks/state_io.js";
|
|
75
|
+
import { atomic_write_json, atomic_write_text, is_replay_mode } from "./hooks/state_io.js";
|
|
76
76
|
import { readHookStdin } from "./hooks/hook_stdin.js";
|
|
77
77
|
import { humanAuthoredLead, isSyntheticPrompt } from "./_lib/prompt_shape.js";
|
|
78
78
|
|
|
@@ -92,7 +92,21 @@ export const STATE_FILE = path.join("agents", "state", "language-mirror.json");
|
|
|
92
92
|
* re-emit that follows it (round-5 § 6.1). Its existence — not a counter — is
|
|
93
93
|
* what bounds the re-injection to ONE per compaction event.
|
|
94
94
|
*/
|
|
95
|
-
export const
|
|
95
|
+
export const PIN_LOST_DIR = path.join("agents", "state", "language-mirror.pin-lost");
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Per-SESSION marker path. R2 finding 10: a single shared marker made the
|
|
99
|
+
* mechanism cross-talk between concurrent sessions — the normal shape for this
|
|
100
|
+
* repo's worktree workflow. Session B's next `post_tool_use` consumed the marker
|
|
101
|
+
* session A's compaction had set, so A never got the one re-emit that is the
|
|
102
|
+
* whole point of the mechanism, and B got a pin notice derived from whichever
|
|
103
|
+
* session last wrote the shared state. An empty id degrades to a literal
|
|
104
|
+
* bucket rather than colliding silently.
|
|
105
|
+
*/
|
|
106
|
+
export function pinLostMarker(session_id: string): string {
|
|
107
|
+
const safe = (session_id || "unknown-session").replace(/[^A-Za-z0-9._-]/g, "_");
|
|
108
|
+
return path.join(PIN_LOST_DIR, `${safe}.marker`);
|
|
109
|
+
}
|
|
96
110
|
|
|
97
111
|
type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
|
|
98
112
|
type JsonObject = { [k: string]: JsonValue };
|
|
@@ -387,29 +401,68 @@ function _extractPrompt(payload: JsonObject): string {
|
|
|
387
401
|
return "";
|
|
388
402
|
}
|
|
389
403
|
|
|
390
|
-
/**
|
|
391
|
-
|
|
404
|
+
/** The envelope's session id, or a literal bucket when the host sends none. */
|
|
405
|
+
function _sessionId(envelope: JsonObject): string {
|
|
406
|
+
const v = envelope["session_id"];
|
|
407
|
+
return typeof v === "string" && v.trim() !== "" ? v : "unknown-session";
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Record that compaction is about to drop the context copy of the pin.
|
|
412
|
+
*
|
|
413
|
+
* R2 finding 14: this used to write with a raw `mkdirSync` + `writeFileSync`,
|
|
414
|
+
* bypassing both conventions the surrounding code follows — the module's own
|
|
415
|
+
* atomic writer, and the `is_replay_mode()` guard that keeps a replayed event
|
|
416
|
+
* from mutating live state. A replayed compaction wrote the marker for real.
|
|
417
|
+
*/
|
|
418
|
+
/**
|
|
419
|
+
* The pre-R2 layout wrote a single FILE at the path that is now a DIRECTORY.
|
|
420
|
+
* A consumer upgrading with a leftover legacy marker — compaction happened, no
|
|
421
|
+
* `post_tool_use` followed — hits `EEXIST` on the mkdir and `ENOTDIR` on the
|
|
422
|
+
* write (reproduced against Node), which the catch below swallows as
|
|
423
|
+
* "observability only". The failure would then be silent AND permanent for that
|
|
424
|
+
* root: `existsSync` always false, so the mechanism is dead with no diagnostic.
|
|
425
|
+
* One unlink at the moment of collision, and only when the path is a file.
|
|
426
|
+
*/
|
|
427
|
+
function _migrateLegacyMarker(consumer_root: string): void {
|
|
428
|
+
const legacy = path.join(consumer_root, PIN_LOST_DIR);
|
|
429
|
+
try {
|
|
430
|
+
if (fs.statSync(legacy).isFile()) {
|
|
431
|
+
fs.rmSync(legacy, { force: true });
|
|
432
|
+
}
|
|
433
|
+
} catch {
|
|
434
|
+
// Absent, already a directory, or unreadable — nothing to migrate.
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function _setPinLost(consumer_root: string, session_id: string): void {
|
|
439
|
+
if (is_replay_mode()) return;
|
|
440
|
+
_migrateLegacyMarker(consumer_root);
|
|
392
441
|
try {
|
|
393
|
-
|
|
394
|
-
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
395
|
-
fs.writeFileSync(target, new Date().toISOString());
|
|
442
|
+
atomic_write_text(path.join(consumer_root, pinLostMarker(session_id)), new Date().toISOString());
|
|
396
443
|
} catch {
|
|
397
444
|
// Observability only — a failed marker write costs one un-restored pin,
|
|
398
445
|
// never a broken turn.
|
|
399
446
|
}
|
|
400
447
|
}
|
|
401
448
|
|
|
402
|
-
export function _pinLost(consumer_root: string): boolean {
|
|
449
|
+
export function _pinLost(consumer_root: string, session_id: string): boolean {
|
|
403
450
|
try {
|
|
404
|
-
return fs.existsSync(path.join(consumer_root,
|
|
451
|
+
return fs.existsSync(path.join(consumer_root, pinLostMarker(session_id)));
|
|
405
452
|
} catch {
|
|
406
453
|
return false;
|
|
407
454
|
}
|
|
408
455
|
}
|
|
409
456
|
|
|
410
|
-
function _clearPinLost(consumer_root: string): void {
|
|
457
|
+
function _clearPinLost(consumer_root: string, session_id: string): void {
|
|
458
|
+
// R2 round 2, finding 8: the replay guard covered only the WRITE. A replayed
|
|
459
|
+
// `post_tool_use` therefore deleted a live marker it was forbidden to create,
|
|
460
|
+
// and the real session lost the single re-emit this whole mechanism exists to
|
|
461
|
+
// deliver — replay destroying live state while blocked from writing it, which
|
|
462
|
+
// is worse than either direction alone.
|
|
463
|
+
if (is_replay_mode()) return;
|
|
411
464
|
try {
|
|
412
|
-
fs.rmSync(path.join(consumer_root,
|
|
465
|
+
fs.rmSync(path.join(consumer_root, pinLostMarker(session_id)), { force: true });
|
|
413
466
|
} catch {
|
|
414
467
|
// If it cannot be cleared the next tool call re-emits once more. One
|
|
415
468
|
// duplicate line is the failure mode; a loop is not, because the marker
|
|
@@ -422,8 +475,8 @@ function _clearPinLost(consumer_root: string): void {
|
|
|
422
475
|
* and only when a pin actually exists — an absent or undetermined pin is not
|
|
423
476
|
* an obligation, and inventing one here would be worse than the gap.
|
|
424
477
|
*/
|
|
425
|
-
export function _reEmitAfterCompaction(consumer_root: string): number {
|
|
426
|
-
if (!_pinLost(consumer_root)) {
|
|
478
|
+
export function _reEmitAfterCompaction(consumer_root: string, session_id: string): number {
|
|
479
|
+
if (!_pinLost(consumer_root, session_id)) {
|
|
427
480
|
return EXIT_ALLOW;
|
|
428
481
|
}
|
|
429
482
|
const previous = _loadState(path.join(consumer_root, STATE_FILE));
|
|
@@ -431,7 +484,7 @@ export function _reEmitAfterCompaction(consumer_root: string): number {
|
|
|
431
484
|
const source: PinSource = previous.source === "system-locale" ? "system-locale" : "prompt";
|
|
432
485
|
// Clear FIRST: if the write below throws, the marker must not survive to
|
|
433
486
|
// re-fire on every subsequent tool call — that is the shape § 6.2 refuses.
|
|
434
|
-
_clearPinLost(consumer_root);
|
|
487
|
+
_clearPinLost(consumer_root, session_id);
|
|
435
488
|
if (language !== "de" && language !== "en") {
|
|
436
489
|
return EXIT_ALLOW;
|
|
437
490
|
}
|
|
@@ -479,11 +532,11 @@ export function run(
|
|
|
479
532
|
// and this marker is the entire difference between the two.
|
|
480
533
|
const event = typeof envelope["event"] === "string" ? envelope["event"] : "";
|
|
481
534
|
if (event === "pre_compact") {
|
|
482
|
-
_setPinLost(options.consumer_root);
|
|
535
|
+
_setPinLost(options.consumer_root, _sessionId(envelope));
|
|
483
536
|
return EXIT_ALLOW;
|
|
484
537
|
}
|
|
485
538
|
if (event === "post_tool_use") {
|
|
486
|
-
return _reEmitAfterCompaction(options.consumer_root);
|
|
539
|
+
return _reEmitAfterCompaction(options.consumer_root, _sessionId(envelope));
|
|
487
540
|
}
|
|
488
541
|
|
|
489
542
|
const prompt = _extractPrompt(payload);
|
|
@@ -11,37 +11,61 @@
|
|
|
11
11
|
*
|
|
12
12
|
* What it measures, and what it deliberately does NOT:
|
|
13
13
|
*
|
|
14
|
-
* · FIRE RATE of each detector over
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
14
|
+
* · FIRE RATE of each detector PER TURN, over the last assistant text of the
|
|
15
|
+
* turn — the population the shipped gate inspects. R2 finding 1: the first
|
|
16
|
+
* version scored every assistant ENTRY and published the result as a
|
|
17
|
+
* per-turn rate, which inflated the population ~8.6x and scored a
|
|
18
|
+
* closing-paragraph detector over mid-turn prose.
|
|
19
|
+
* · The independent scanner's own language total, printed for CONTEXT only.
|
|
20
|
+
* It is not an agreement figure and no overlap is claimed from it: the two
|
|
21
|
+
* instruments read different spans (its first prose line; this one strips
|
|
22
|
+
* code/quotes/paths from the reply and then scores lead-first via the same
|
|
23
|
+
* classifier), and this script does no per-turn matching between them.
|
|
24
|
+
* R2 finding 7 removed the per-session min/max arithmetic that used to
|
|
25
|
+
* report disjoint findings of equal count as full agreement.
|
|
20
26
|
* · It does NOT compute recall against the 20 promissory occurrences the
|
|
21
27
|
* round-5 audit reported. Those came from subagent transcript READING;
|
|
22
28
|
* `conformance_scan.ts` states in its own header that promissory
|
|
23
29
|
* closings are "deliberately NOT scanned", so there is no machine-
|
|
24
30
|
* readable ground truth to score against. Claiming a recall figure here
|
|
25
31
|
* would invent one.
|
|
32
|
+
* · KNOWN RESIDUAL, R2 finding 8, not fixed here: this script re-derives the
|
|
33
|
+
* language pin with `classify` per user entry, while the gate reads the pin
|
|
34
|
+
* `language_mirror_hook` persisted — which also applies a system-locale
|
|
35
|
+
* fallback and a keep-previous rule. Where those diverge, a turn can move
|
|
36
|
+
* between "pinned de", "pinned en" and "no obligation", so detector B's
|
|
37
|
+
* rate here is close to but not identical with the gate's. Reaching parity
|
|
38
|
+
* means reusing `nextState`, which is a larger change than a measurement
|
|
39
|
+
* correction and would itself need re-measuring.
|
|
26
40
|
*
|
|
27
41
|
* Usage:
|
|
28
42
|
* ./scripts-run src/scripts/measure_turn_end_gate --store <dir> [--limit N]
|
|
29
43
|
*/
|
|
30
44
|
import * as fs from 'node:fs';
|
|
31
45
|
import * as path from 'node:path';
|
|
46
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
32
47
|
|
|
33
|
-
import { classify } from './language_mirror_hook.js';
|
|
34
|
-
import { assistantText,
|
|
48
|
+
import { classify, isSyntheticPrompt } from './language_mirror_hook.js';
|
|
49
|
+
import { assistantText, scanSession, userText } from './conformance_scan.js';
|
|
35
50
|
import { detectLanguage, detectPromissory } from './hooks/turn_end_gate_hook.js';
|
|
36
51
|
|
|
37
52
|
interface Counts {
|
|
38
|
-
|
|
53
|
+
/**
|
|
54
|
+
* TURNS, not entries. R2 finding 1: the first version incremented a counter
|
|
55
|
+
* named `assistant_turns` on every assistant entry and ran both detectors on
|
|
56
|
+
* each one, while the gate only ever evaluates the LAST assistant text of a
|
|
57
|
+
* turn. A turn with k prose entries contributed k samples to the instrument
|
|
58
|
+
* and 1 to the gate, so the published "18.0% of turns" was a per-ENTRY rate
|
|
59
|
+
* wearing a per-turn label — and detector A, a closing-paragraph detector by
|
|
60
|
+
* construction, was being scored over mid-turn prose.
|
|
61
|
+
*/
|
|
62
|
+
turns: number;
|
|
63
|
+
/** Assistant entries seen — kept only to show the ratio the old bug hid. */
|
|
64
|
+
assistant_entries: number;
|
|
39
65
|
promissory_fires: number;
|
|
40
66
|
language_fires: number;
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
/** Detector B fired where the scanner saw nothing — candidate false positives. */
|
|
44
|
-
language_only: number;
|
|
67
|
+
/** The independent scanner's own per-session language total, for context. */
|
|
68
|
+
scanner_language: number;
|
|
45
69
|
sessions: number;
|
|
46
70
|
}
|
|
47
71
|
|
|
@@ -68,11 +92,11 @@ function sessionFiles(store: string, limit: number): string[] {
|
|
|
68
92
|
|
|
69
93
|
export function measure(store: string, limit: number): Counts {
|
|
70
94
|
const c: Counts = {
|
|
71
|
-
|
|
95
|
+
turns: 0,
|
|
96
|
+
assistant_entries: 0,
|
|
72
97
|
promissory_fires: 0,
|
|
73
98
|
language_fires: 0,
|
|
74
|
-
|
|
75
|
-
language_only: 0,
|
|
99
|
+
scanner_language: 0,
|
|
76
100
|
sessions: 0,
|
|
77
101
|
};
|
|
78
102
|
|
|
@@ -86,20 +110,36 @@ export function measure(store: string, limit: number): Counts {
|
|
|
86
110
|
c.sessions += 1;
|
|
87
111
|
const sessionId = path.basename(file).slice(0, 8);
|
|
88
112
|
|
|
89
|
-
// The scanner's
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
113
|
+
// The independent scanner's own language total for this session,
|
|
114
|
+
// reported side by side and NOT as agreement — see the header for why
|
|
115
|
+
// no overlap is derivable from two totals (R2 finding 7).
|
|
116
|
+
//
|
|
117
|
+
// A throw is surfaced, not swallowed to zero: swallowing silently
|
|
118
|
+
// reclassified a whole session's fires as scanner-disagreements.
|
|
93
119
|
try {
|
|
94
|
-
|
|
120
|
+
c.scanner_language += scanSession(sessionId, lines).violations.filter(
|
|
95
121
|
(v) => v.check === 'language-pin',
|
|
96
122
|
).length;
|
|
97
|
-
} catch {
|
|
98
|
-
|
|
123
|
+
} catch (err) {
|
|
124
|
+
process.stderr.write(
|
|
125
|
+
`measure-turn-end-gate: scanSession failed for ${sessionId} — excluded from the scanner total: ${String(err)}\n`,
|
|
126
|
+
);
|
|
99
127
|
}
|
|
100
128
|
|
|
101
129
|
let pinned: 'de' | 'en' | 'und' = 'und';
|
|
102
|
-
|
|
130
|
+
// The LAST assistant text since the last genuine user prompt. This is
|
|
131
|
+
// the population the gate inspects; anything else is a different
|
|
132
|
+
// measurement wearing the same label (R2 finding 1).
|
|
133
|
+
let pendingReply: string | null = null;
|
|
134
|
+
let pendingPin: 'de' | 'en' | 'und' = 'und';
|
|
135
|
+
|
|
136
|
+
const scoreTurn = (): void => {
|
|
137
|
+
if (pendingReply === null) return;
|
|
138
|
+
c.turns += 1;
|
|
139
|
+
if (detectPromissory(pendingReply) !== null) c.promissory_fires += 1;
|
|
140
|
+
if (detectLanguage(pendingReply, pendingPin) !== null) c.language_fires += 1;
|
|
141
|
+
pendingReply = null;
|
|
142
|
+
};
|
|
103
143
|
|
|
104
144
|
for (const raw of lines) {
|
|
105
145
|
let entry: Record<string, unknown>;
|
|
@@ -111,10 +151,14 @@ export function measure(store: string, limit: number): Counts {
|
|
|
111
151
|
|
|
112
152
|
const u = userText(entry);
|
|
113
153
|
if (u !== null) {
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
154
|
+
// `isSyntheticPrompt`, the SAME filter the gate uses for the
|
|
155
|
+
// ordinal — not `isInjectedBody`. R2 round 2, finding 10: the
|
|
156
|
+
// two filters classify differently, and any entry they disagree
|
|
157
|
+
// about shifts this instrument's turn boundary away from the
|
|
158
|
+
// gate's own. Population parity is the entire point of this
|
|
159
|
+
// correction, so the filter has to be the same one.
|
|
160
|
+
if (!isSyntheticPrompt(u)) {
|
|
161
|
+
scoreTurn(); // the previous turn ends here
|
|
118
162
|
const verdict = classify(u);
|
|
119
163
|
if (verdict.language !== 'und') pinned = verdict.language;
|
|
120
164
|
}
|
|
@@ -123,39 +167,38 @@ export function measure(store: string, limit: number): Counts {
|
|
|
123
167
|
|
|
124
168
|
const a = assistantText(entry);
|
|
125
169
|
if (a === null || a.trim() === '') continue;
|
|
126
|
-
c.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
c.language_fires += 1;
|
|
131
|
-
myLanguage += 1;
|
|
132
|
-
}
|
|
170
|
+
c.assistant_entries += 1;
|
|
171
|
+
// Overwrite: only the last one in the turn is the gate's input.
|
|
172
|
+
pendingReply = a;
|
|
173
|
+
pendingPin = pinned;
|
|
133
174
|
}
|
|
134
|
-
|
|
135
|
-
const agree = Math.min(myLanguage, scannerLanguage);
|
|
136
|
-
c.language_agree += agree;
|
|
137
|
-
c.language_only += Math.max(0, myLanguage - scannerLanguage);
|
|
175
|
+
scoreTurn(); // the transcript's final turn
|
|
138
176
|
}
|
|
139
177
|
|
|
140
178
|
return c;
|
|
141
179
|
}
|
|
142
180
|
|
|
143
181
|
export function render(c: Counts): string {
|
|
144
|
-
const pct = (n: number): string =>
|
|
145
|
-
c.assistant_turns === 0 ? '0.0' : ((100 * n) / c.assistant_turns).toFixed(1);
|
|
182
|
+
const pct = (n: number): string => (c.turns === 0 ? '0.0' : ((100 * n) / c.turns).toFixed(1));
|
|
146
183
|
return [
|
|
147
|
-
`measure-turn-end-gate · ${c.sessions} sessions · ${c.
|
|
184
|
+
`measure-turn-end-gate · ${c.sessions} sessions · ${c.turns} turns · ${c.assistant_entries} assistant entries`,
|
|
148
185
|
'',
|
|
149
186
|
` detector A (promissory) fires on ${c.promissory_fires} turns (${pct(c.promissory_fires)}%)`,
|
|
150
187
|
` detector B (language) fires on ${c.language_fires} turns (${pct(c.language_fires)}%)`,
|
|
151
|
-
` of which the independent scanner also flagged: ${c.language_agree}`,
|
|
152
|
-
` fired where the scanner saw nothing: ${c.language_only}`,
|
|
153
188
|
'',
|
|
154
|
-
'
|
|
155
|
-
' the
|
|
156
|
-
'
|
|
157
|
-
'
|
|
158
|
-
'
|
|
189
|
+
' Rates are per TURN, over the last assistant text of each turn — the same',
|
|
190
|
+
' population the shipped gate inspects. The entry count is printed beside',
|
|
191
|
+
' the turn count because the first version of this script scored every',
|
|
192
|
+
' assistant ENTRY and labelled the result per-turn; the gap between the two',
|
|
193
|
+
' numbers is the size of that error.',
|
|
194
|
+
'',
|
|
195
|
+
` For context only, the independent scanner's own language total: ${c.scanner_language}`,
|
|
196
|
+
' NOT an agreement figure. The two instruments read different spans (its',
|
|
197
|
+
' first prose line vs. this stripped, lead-first-scored reply) and this',
|
|
198
|
+
' script does no per-turn matching between them, so no overlap can be claimed from these',
|
|
199
|
+
' two totals — the previous version claimed one from per-session min/max',
|
|
200
|
+
' arithmetic, which reports disjoint findings of equal count as full',
|
|
201
|
+
' agreement.',
|
|
159
202
|
'',
|
|
160
203
|
' No recall figure for detector A: promissory closings are not machine-',
|
|
161
204
|
' scanned anywhere in this tree, so there is no ground truth to score.',
|
|
@@ -170,7 +213,18 @@ export function main(argv: string[] = process.argv.slice(2)): number {
|
|
|
170
213
|
store = argv[i + 1]!;
|
|
171
214
|
i += 1;
|
|
172
215
|
} else if (argv[i] === '--limit' && argv[i + 1] !== undefined) {
|
|
173
|
-
|
|
216
|
+
// R2 finding 15: an unvalidated parseInt made `--limit abc` yield
|
|
217
|
+
// NaN, `slice(0, NaN)` return nothing, and the script exit 2 with
|
|
218
|
+
// "no .jsonl sessions under <store>" — a real input error reported
|
|
219
|
+
// as a missing corpus.
|
|
220
|
+
const parsed = Number.parseInt(argv[i + 1]!, 10);
|
|
221
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
222
|
+
process.stderr.write(
|
|
223
|
+
`measure-turn-end-gate: --limit must be a positive integer, got ${JSON.stringify(argv[i + 1])}\n`,
|
|
224
|
+
);
|
|
225
|
+
return 2;
|
|
226
|
+
}
|
|
227
|
+
limit = parsed;
|
|
174
228
|
i += 1;
|
|
175
229
|
}
|
|
176
230
|
}
|
|
@@ -189,6 +243,30 @@ export function main(argv: string[] = process.argv.slice(2)): number {
|
|
|
189
243
|
return 0;
|
|
190
244
|
}
|
|
191
245
|
|
|
192
|
-
|
|
246
|
+
// Bundle-safety, same guard as the sibling hook. R2 finding 15: a substring
|
|
247
|
+
// test on argv[1] auto-runs the module when esbuild inlines it into a bundle,
|
|
248
|
+
// where every module shares the bundle's `import.meta.url`.
|
|
249
|
+
declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
|
|
250
|
+
function _isCliEntry(): boolean {
|
|
251
|
+
if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
if (process.argv[1] === undefined) {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
258
|
+
if (import.meta.url === argvUrl) {
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
263
|
+
const argv = fs.realpathSync(path.resolve(process.argv[1]));
|
|
264
|
+
return here === argv;
|
|
265
|
+
} catch {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (_isCliEntry()) {
|
|
193
271
|
process.exit(main());
|
|
194
272
|
}
|
|
@@ -35,16 +35,30 @@
|
|
|
35
35
|
* ignored, which is the failure `check-rule-invariants` already paid for once.
|
|
36
36
|
* The point is that the condition becomes VISIBLE, not that it becomes illegal.
|
|
37
37
|
*
|
|
38
|
-
* THE
|
|
39
|
-
*
|
|
40
|
-
* body-diff the two carriers deliver different
|
|
38
|
+
* THE FOUR CLASSES, AND WHICH ONE A READER MUST ACT ON
|
|
39
|
+
* ---------------------------------------------------
|
|
40
|
+
* body-diff the two carriers deliver different PROSE. This is the class
|
|
41
41
|
* round 5's contradiction lived in, and the only one that
|
|
42
|
-
* needs a decision.
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
42
|
+
* needs a decision. What the host does about it: NOTHING —
|
|
43
|
+
* both copies load at launch with the same priority and no
|
|
44
|
+
* precedence marker between them, so the divergence is
|
|
45
|
+
* binding-undefined rather than resolved in either
|
|
46
|
+
* direction. Cited, not inferred:
|
|
47
|
+
* `agents/evidence/analysis/claude-code-rules-dir-contract.md`
|
|
48
|
+
* (host 2.1.226, first-party observation plus the host's own
|
|
49
|
+
* documentation). The project copy is the NEWER text, which
|
|
50
|
+
* is a fact about recency and not a precedence rule — the
|
|
51
|
+
* earlier wording of this header called it a win, and a
|
|
52
|
+
* reader acting on that would have believed the host resolves
|
|
53
|
+
* something it does not.
|
|
54
|
+
* frontmatter-only the prose is byte-identical and only the metadata block
|
|
55
|
+
* differs. The host delivers the prose without that block, so
|
|
56
|
+
* nothing a reader must act on reaches the model differently.
|
|
57
|
+
* Split out because it was, measured, the ENTIRE content of
|
|
58
|
+
* the `body-diff` class: 109 of 109 on 2026-08-10. Left inside
|
|
59
|
+
* body-diff it manufactured the report's only actionable
|
|
60
|
+
* class out of a metadata difference — the same mistake the
|
|
61
|
+
* `unreadable` branch below already refuses to make.
|
|
48
62
|
* provenance-only identical after removing the two installer ownership keys.
|
|
49
63
|
* Structural, expected, and decided against closing —
|
|
50
64
|
* `agents/settings/contexts/dedup-reachability-refusal.md`.
|
|
@@ -69,7 +83,7 @@ import * as path from 'node:path';
|
|
|
69
83
|
import process from 'node:process';
|
|
70
84
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
71
85
|
|
|
72
|
-
import { comparePair, type PairVerdict } from './_lib/carrier_divergence.js';
|
|
86
|
+
import { comparePair, type PairVerdict, proseEqual } from './_lib/carrier_divergence.js';
|
|
73
87
|
|
|
74
88
|
const _HERE = fileURLToPath(import.meta.url);
|
|
75
89
|
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
@@ -103,6 +117,13 @@ export interface CarrierDivergence {
|
|
|
103
117
|
shared: number;
|
|
104
118
|
identical: string[];
|
|
105
119
|
provenanceOnly: string[];
|
|
120
|
+
/**
|
|
121
|
+
* Prose byte-identical, metadata block different. A real byte difference — so
|
|
122
|
+
* `comparePair` still calls it `body-diff` and the dedup predicate is
|
|
123
|
+
* unchanged — but not a difference in what the host delivers, so it is not
|
|
124
|
+
* something a reader has to act on.
|
|
125
|
+
*/
|
|
126
|
+
frontmatterOnly: string[];
|
|
106
127
|
bodyDiff: string[];
|
|
107
128
|
projectOnly: string[];
|
|
108
129
|
globalOnly: string[];
|
|
@@ -171,6 +192,7 @@ export function compareCarriers(
|
|
|
171
192
|
shared: 0,
|
|
172
193
|
identical: [],
|
|
173
194
|
provenanceOnly: [],
|
|
195
|
+
frontmatterOnly: [],
|
|
174
196
|
bodyDiff: [],
|
|
175
197
|
projectOnly: [],
|
|
176
198
|
globalOnly: [],
|
|
@@ -196,11 +218,12 @@ export function compareCarriers(
|
|
|
196
218
|
}
|
|
197
219
|
out.shared += 1;
|
|
198
220
|
let verdict: PairVerdict;
|
|
221
|
+
let proseSame: boolean;
|
|
199
222
|
try {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
);
|
|
223
|
+
const projectCopy = fs.readFileSync(path.join(projectDir, rule));
|
|
224
|
+
const globalCopy = fs.readFileSync(path.join(globalDir, rule));
|
|
225
|
+
verdict = comparePair(projectCopy, globalCopy);
|
|
226
|
+
proseSame = proseEqual(projectCopy.toString('utf-8'), globalCopy.toString('utf-8'));
|
|
204
227
|
} catch {
|
|
205
228
|
// An unreadable copy is not a body difference and must not be
|
|
206
229
|
// reported as one — a permission error would otherwise manufacture
|
|
@@ -213,6 +236,7 @@ export function compareCarriers(
|
|
|
213
236
|
}
|
|
214
237
|
if (verdict === 'identical') out.identical.push(rule);
|
|
215
238
|
else if (verdict === 'provenance-only') out.provenanceOnly.push(rule);
|
|
239
|
+
else if (proseSame) out.frontmatterOnly.push(rule);
|
|
216
240
|
else out.bodyDiff.push(rule);
|
|
217
241
|
}
|
|
218
242
|
return out;
|
|
@@ -244,7 +268,8 @@ export function render(d: CarrierDivergence): string {
|
|
|
244
268
|
lines.push(` shared rule names ${d.shared}`);
|
|
245
269
|
lines.push(` byte-identical ${d.identical.length}`);
|
|
246
270
|
lines.push(` differ ONLY in the install stamp ${d.provenanceOnly.length}`);
|
|
247
|
-
lines.push(` differ in
|
|
271
|
+
lines.push(` differ ONLY in frontmatter ${d.frontmatterOnly.length}`);
|
|
272
|
+
lines.push(` differ in PROSE ${d.bodyDiff.length}`);
|
|
248
273
|
if (d.unreadable.length > 0) {
|
|
249
274
|
lines.push(` unreadable on one side ${d.unreadable.length} (${d.unreadable.join(', ')})`);
|
|
250
275
|
lines.push(' Not counted as shared and NOT a body difference — a dangling entry is');
|
|
@@ -255,16 +280,34 @@ export function render(d: CarrierDivergence): string {
|
|
|
255
280
|
lines.push('');
|
|
256
281
|
|
|
257
282
|
if (d.bodyDiff.length > 0) {
|
|
258
|
-
lines.push('
|
|
259
|
-
lines.push(' Both copies reach the model,
|
|
260
|
-
lines.push('
|
|
261
|
-
lines.push('
|
|
262
|
-
lines.push('
|
|
263
|
-
lines.push('
|
|
283
|
+
lines.push(' PROSE DIVERGENCE — the two carriers deliver different governed text for');
|
|
284
|
+
lines.push(' these rules. Both copies reach the model, and the host resolves NOTHING:');
|
|
285
|
+
lines.push(' rules without a `paths` key load at launch with the same priority as');
|
|
286
|
+
lines.push(' CLAUDE.md, with no precedence marker between the layers, so a claim one');
|
|
287
|
+
lines.push(' copy retracts can be re-asserted by the other and which text binds is');
|
|
288
|
+
lines.push(' UNDEFINED. Cited, not inferred — agents/evidence/analysis/');
|
|
289
|
+
lines.push(' claude-code-rules-dir-contract.md (host 2.1.226, the host\'s own docs plus');
|
|
290
|
+
lines.push(' a first-party observation). The project copy is the NEWER text, which is a');
|
|
291
|
+
lines.push(' fact about recency and NOT a host precedence rule: close the divergence by');
|
|
292
|
+
lines.push(' reinstalling the global copy, or decide which text binds — do not assume');
|
|
293
|
+
lines.push(' the newer one already won.');
|
|
264
294
|
for (const r of d.bodyDiff) lines.push(` - ${r}`);
|
|
265
295
|
lines.push('');
|
|
266
296
|
}
|
|
267
297
|
|
|
298
|
+
if (d.frontmatterOnly.length > 0) {
|
|
299
|
+
lines.push(` ${String(d.frontmatterOnly.length)} pair(s) differ ONLY in the frontmatter block — prose byte-identical.`);
|
|
300
|
+
lines.push(' Not a governed-text difference and nothing to act on: the host delivers the');
|
|
301
|
+
lines.push(' prose without that block, and none of the keys in it is one this host reads');
|
|
302
|
+
lines.push(' (claude-code-rules-dir-contract.md: it reads `paths`, and agent-config\'s own');
|
|
303
|
+
lines.push(' vocabulary is not read at all). Two writers, two frontmatter policies —');
|
|
304
|
+
lines.push(' `generate-tools` emits `paths` where a rule is path-scoped and nothing');
|
|
305
|
+
lines.push(' otherwise; `install.ts` writes the full vocabulary plus its ownership stamp.');
|
|
306
|
+
lines.push(' Reported as a count on purpose: naming every pair would bury the class');
|
|
307
|
+
lines.push(' above, which is the one a reader must act on.');
|
|
308
|
+
lines.push('');
|
|
309
|
+
}
|
|
310
|
+
|
|
268
311
|
if (d.provenanceOnly.length > 0) {
|
|
269
312
|
lines.push(` ${String(d.provenanceOnly.length)} pair(s) differ only by the installer's package:/source_path: stamp.`);
|
|
270
313
|
lines.push(' Not a content difference and NOT a defect: install.ts stamps every installed');
|
|
@@ -302,9 +345,14 @@ export function render(d: CarrierDivergence): string {
|
|
|
302
345
|
|
|
303
346
|
if (d.bodyDiff.length === 0) {
|
|
304
347
|
lines.push('');
|
|
305
|
-
lines.push(' No
|
|
306
|
-
lines.push('
|
|
307
|
-
lines.push('
|
|
348
|
+
lines.push(' No prose divergence — no rule\'s governed text differs between the carriers.');
|
|
349
|
+
lines.push(' The condition is transient — it returns whenever the checkout moves ahead of');
|
|
350
|
+
lines.push(' the installed release — so this is a reading of right now, not a property of');
|
|
351
|
+
lines.push(' the repo. Re-run it, do not cite it. That holds doubly here: the reading also');
|
|
352
|
+
lines.push(' depends on WHICH GENERATION of the emitter produced the project tree (a');
|
|
353
|
+
lines.push(' frontmatter-carrying symlink tree and a frontmatter-less real-file tree');
|
|
354
|
+
lines.push(' classify the same commit differently), so a number from another checkout is');
|
|
355
|
+
lines.push(' not comparable to this one.');
|
|
308
356
|
}
|
|
309
357
|
return lines.join('\n');
|
|
310
358
|
}
|
|
@@ -146,15 +146,22 @@ export function render(r: FunnelReport): string {
|
|
|
146
146
|
lines.push(' substituted with dist/, which would answer a different question.');
|
|
147
147
|
} else {
|
|
148
148
|
lines.push(
|
|
149
|
-
` Divergence: ${d.shared} shared · ${d.bodyDiff.length}
|
|
149
|
+
` Divergence: ${d.shared} shared · ${d.bodyDiff.length} prose-diff · ` +
|
|
150
|
+
`${d.frontmatterOnly.length} frontmatter-only · ` +
|
|
150
151
|
`${d.provenanceOnly.length} provenance-only · ${d.projectOnly.length} project-only · ` +
|
|
151
152
|
`${d.globalOnly.length} global-only` +
|
|
152
153
|
(d.manualOnlyGlobal.length > 0 ? ` (${d.manualOnlyGlobal.length} by design, ADR-004 manual)` : ''),
|
|
153
154
|
);
|
|
154
155
|
if (d.bodyDiff.length > 0) {
|
|
155
|
-
lines.push(`
|
|
156
|
-
lines.push('
|
|
157
|
-
lines.push('
|
|
156
|
+
lines.push(` PROSE DIVERGENCE — both copies reach the model: ${d.bodyDiff.join(', ')}`);
|
|
157
|
+
lines.push(' The host resolves nothing: both layers load at launch at the same');
|
|
158
|
+
lines.push(' priority with no precedence marker, so which text binds is UNDEFINED');
|
|
159
|
+
lines.push(' (claude-code-rules-dir-contract.md). The project copy is merely the');
|
|
160
|
+
lines.push(' newer one — recency, not precedence.');
|
|
161
|
+
}
|
|
162
|
+
if (d.frontmatterOnly.length > 0) {
|
|
163
|
+
lines.push(` ${String(d.frontmatterOnly.length)} pair(s) differ only in frontmatter — prose byte-identical, nothing`);
|
|
164
|
+
lines.push(' to act on; the host delivers the prose without that block.');
|
|
158
165
|
}
|
|
159
166
|
if (d.unreadable.length > 0) {
|
|
160
167
|
lines.push(` Unreadable on one side (${d.unreadable.length}) — a broken install, not a disagreement.`);
|
|
@@ -525,10 +525,10 @@ export const settingsSchema = z.object({
|
|
|
525
525
|
'Stop-slot turn-end gate (road-to-conformance-round5 Phase 3). Default off, and it is the only concern that can REFUSE a turn-end rather than a tool call — so it soaks behind an explicit opt-in before it binds. Round 5 measured the reason: both blocking carriers reached zero violations, neither advisory carrier did, and 19 wrong-language replies survived a pin that had fired 26-35 seconds earlier. A refused turn continues in the SAME turn and is never refused twice.',
|
|
526
526
|
),
|
|
527
527
|
promissory: z.boolean().default(true).describe(
|
|
528
|
-
'Detector A of the turn-end gate: refuse a turn whose closing paragraph promises work not yet performed while asking the user nothing (20 measured occurrences). Inert while hooks.turn_end_gate.enabled is false.',
|
|
528
|
+
'Detector A of the turn-end gate: refuse a turn whose closing paragraph promises work not yet performed while asking the user nothing (20 measured occurrences). No fire rate is quoted here on purpose: the measured rate moved twice while the instrument itself was being corrected, so a number in a shipped description would be a moving target. Re-derive it with `measure-turn-end-gate --store <transcript dir>` against your own corpus before arming the gate. Inert while hooks.turn_end_gate.enabled is false.',
|
|
529
529
|
),
|
|
530
530
|
language: z.boolean().default(true).describe(
|
|
531
|
-
'Detector B of the turn-end gate: refuse a turn whose
|
|
531
|
+
'Detector B of the turn-end gate: refuse a turn whose reply is not in the pinned language (19 measured occurrences). Fenced code, inline code, quoted tool output, tables, URLs and paths are stripped first, and the remainder is then scored LEAD-FIRST by the same classifier that sets the pin — so the verdict usually comes from the reply opening, not from all of it. Sharing that classifier is deliberate: it stops the pin and the detector disagreeing about a turn. Accepted opt-in spellings for the switches below include YAML true/false and yes/no/on/off. Inert while hooks.turn_end_gate.enabled is false.',
|
|
532
532
|
),
|
|
533
533
|
}).default({}),
|
|
534
534
|
}),
|