@event4u/agent-config 14.20.0 → 14.21.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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +243 -0
- package/README.md +1 -1
- package/agents/templates/.ai-council.yml.example +10 -0
- package/dist/agent-src/commands/analyze/inbox.md +1 -1
- package/dist/agent-src/commands/analyze/{reference-repo.md → repo.md} +247 -32
- package/dist/agent-src/commands/analyze/roadmap-repos.md +129 -0
- package/dist/agent-src/commands/analyze.md +12 -7
- package/dist/agent-src/commands/feature/plan.md +55 -16
- package/dist/agent-src/commands/feature/refactor.md +8 -3
- package/dist/agent-src/commands/humanize.md +48 -4
- package/dist/agent-src/contexts/execution/contract-decision-sheet.md +22 -1
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +4 -1
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +35 -2
- package/dist/agent-src/contexts/execution/terminal-states.md +4 -1
- package/dist/agent-src/rules/evaluator-independence.md +4 -4
- package/dist/agent-src/rules/external-reference-deep-dive.md +2 -2
- package/dist/agent-src/skills/competitive-positioning/SKILL.md +2 -2
- package/dist/agent-src/skills/humanizer/references/anti-aiisms.md +27 -11
- package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +1 -1
- package/dist/agent-src/skills/markitdown/SKILL.md +1 -1
- package/dist/agent-src/skills/repomix-packer/SKILL.md +1 -1
- package/dist/agent-src/skills/technical-specification/SKILL.md +4 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/features.md +5 -0
- package/dist/agent-src/templates/roadmaps.md +15 -0
- package/dist/cli-delegate/{chunk-KPBWFOPZ.js → chunk-2FTMENJL.js} +194 -37
- package/dist/cli-delegate/{chunk-PI2GU3DP.js → chunk-2TAQ2K4V.js} +4 -1
- package/dist/cli-delegate/{chunk-C6IPZRDA.js → chunk-643Z7FIC.js} +1 -1
- package/dist/cli-delegate/{chunk-NUMQHLS5.js → chunk-J3RFNKG4.js} +21 -2
- package/dist/cli-delegate/cmd_conformance.js +17 -3
- package/dist/cli-delegate/cmd_doctor.js +3 -3
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +56 -25
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +7 -6
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +6 -5
- package/dist/hooks/dispatch.js +479 -241
- package/dist/install/install.mjs +4 -1
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +313 -63
- package/dist/scripts/ai_council/content_ceiling.js +63 -0
- package/dist/scripts/ai_council/seat_policy.js +106 -0
- package/dist/scripts/ai_council/transport_resolver.js +21 -0
- package/llms.txt +14 -14
- package/package.json +1 -1
- package/src/config/gate-violation-baselines.json +3 -3
- package/src/config/release-gate-locality.yml +4 -3
- package/src/scripts/_lib/blocked_by_marker.ts +189 -0
- package/src/scripts/_lib/host_capability.ts +38 -1
- package/src/scripts/_lib/release_highlights.ts +19 -211
- package/src/scripts/_lib/release_material.ts +27 -77
- package/src/scripts/_lib/structured_ask.ts +117 -0
- package/src/scripts/_lib/tty_prompt.ts +76 -0
- package/src/scripts/ai_council/api_transport.ts +163 -0
- package/src/scripts/ai_council/clients.ts +58 -63
- package/src/scripts/ai_council/config.ts +13 -0
- package/src/scripts/ai_council/content_ceiling.ts +79 -0
- package/src/scripts/ai_council/jury_aggregate.ts +178 -0
- package/src/scripts/ai_council/seat_policy.ts +137 -0
- package/src/scripts/ai_council/status_surface.ts +79 -0
- package/src/scripts/ai_council/transport_resolver.ts +59 -1
- package/src/scripts/ai_tells_rules.ts +446 -5
- package/src/scripts/ask_block_census.ts +499 -0
- package/src/scripts/bench_humanizer_eval.ts +264 -40
- package/src/scripts/bench_prose_tells_fp.ts +198 -0
- package/src/scripts/check_branch_work_committed.ts +234 -0
- package/src/scripts/check_pack_size.ts +1 -1
- package/src/scripts/check_release_highlights.ts +0 -157
- package/src/scripts/council_cli.ts +18 -27
- package/src/scripts/detect_ai_tells.ts +142 -20
- package/src/scripts/harvest_reference_tokens.ts +698 -0
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +31 -1
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/hooks/delegation_nudge_hook.ts +45 -1
- package/src/scripts/hooks/one_question_per_ask_hook.ts +191 -0
- package/src/scripts/install-hooks.sh +52 -0
- package/src/scripts/lint_roadmap_blockers.ts +51 -1
- package/src/scripts/migrate_command_suggestions.ts +1 -1
- package/src/scripts/probe_unblocked_ask.ts +135 -2
- package/src/scripts/release.ts +26 -71
- package/src/scripts/release_drill.ts +6 -10
- package/src/scripts/release_env.ts +1 -0
- package/src/scripts/release_publication.ts +34 -29
- package/src/scripts/self_review_gate.ts +409 -15
|
@@ -37,9 +37,38 @@ const ROOT = process.cwd();
|
|
|
37
37
|
const FIXTURES = join(ROOT, "tests", "fixtures", "ai-tells");
|
|
38
38
|
const REPORT_DIR = join(ROOT, "internal", "bench", "reports");
|
|
39
39
|
const LENGTH_TOLERANCE = 0.25;
|
|
40
|
+
const SPLITS_FILE = join(FIXTURES, "SPLITS.json");
|
|
41
|
+
|
|
42
|
+
export type Split = "tune" | "holdout";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Tune / holdout membership, declared in `tests/fixtures/ai-tells/SPLITS.json`.
|
|
46
|
+
*
|
|
47
|
+
* Before this existed `loadPairs()` returned one array, so a rule tuned against
|
|
48
|
+
* the twenty pairs was measured against the same twenty pairs and overfitting
|
|
49
|
+
* was excluded by construction — of the measurement, not of the rule. Every
|
|
50
|
+
* figure this bench prints now names the half it came from, and a number that
|
|
51
|
+
* mixes the halves is not produced at all.
|
|
52
|
+
*/
|
|
53
|
+
export function loadSplits(file: string = SPLITS_FILE): Record<Split, string[]> {
|
|
54
|
+
const raw = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>;
|
|
55
|
+
const pick = (k: Split): string[] => {
|
|
56
|
+
const v = raw[k];
|
|
57
|
+
if (!Array.isArray(v)) throw new Error(`SPLITS.json: "${k}" is not an array`);
|
|
58
|
+
return v as string[];
|
|
59
|
+
};
|
|
60
|
+
return { tune: pick("tune"), holdout: pick("holdout") };
|
|
61
|
+
}
|
|
40
62
|
|
|
41
63
|
interface PairResult {
|
|
42
64
|
name: string;
|
|
65
|
+
split: Split;
|
|
66
|
+
/**
|
|
67
|
+
* Per rule id, how many occurrences the humanize pass removed (before minus
|
|
68
|
+
* after, positive only). Without this the judge returns one bit per pair and
|
|
69
|
+
* a family landed in Phase 2 cannot be tied to any effect at all.
|
|
70
|
+
*/
|
|
71
|
+
families_removed: Record<string, number>;
|
|
43
72
|
language: "en" | "de";
|
|
44
73
|
words_before: number;
|
|
45
74
|
words_after: number;
|
|
@@ -50,6 +79,23 @@ interface PairResult {
|
|
|
50
79
|
judge?: { preferred: "after" | "before"; order: "after-first" | "before-first" };
|
|
51
80
|
}
|
|
52
81
|
|
|
82
|
+
/** before minus after, per rule, keeping only what the pass actually removed. */
|
|
83
|
+
export function familyDelta(before: TellReport, after: TellReport): Record<string, number> {
|
|
84
|
+
const out: Record<string, number> = {};
|
|
85
|
+
for (const [id, n] of Object.entries(before.per_pattern)) {
|
|
86
|
+
const removed = n - (after.per_pattern[id] ?? 0);
|
|
87
|
+
if (removed > 0) out[id] = removed;
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface JudgedAggregate {
|
|
93
|
+
judge_model?: string;
|
|
94
|
+
judged_pairs: number;
|
|
95
|
+
prefers_after: number;
|
|
96
|
+
prefers_before: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
53
99
|
function slim(r: TellReport) {
|
|
54
100
|
return {
|
|
55
101
|
hard_total: r.hard_total,
|
|
@@ -63,8 +109,23 @@ function coin(seed: number): boolean {
|
|
|
63
109
|
return ((seed * 1103515245 + 12345) >>> 16) % 2 === 0;
|
|
64
110
|
}
|
|
65
111
|
|
|
66
|
-
|
|
67
|
-
|
|
112
|
+
export interface LoadedPair {
|
|
113
|
+
name: string;
|
|
114
|
+
split: Split;
|
|
115
|
+
language: "en" | "de";
|
|
116
|
+
before: string;
|
|
117
|
+
after: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function loadPairs(splits: Record<Split, string[]> = loadSplits()): LoadedPair[] {
|
|
121
|
+
const assigned = new Map<string, Split>();
|
|
122
|
+
for (const s of ["tune", "holdout"] as const) {
|
|
123
|
+
for (const key of splits[s]) {
|
|
124
|
+
if (assigned.has(key)) throw new Error(`SPLITS.json: ${key} is in both halves`);
|
|
125
|
+
assigned.set(key, s);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const out: LoadedPair[] = [];
|
|
68
129
|
for (const language of ["en", "de"] as const) {
|
|
69
130
|
const dir = join(FIXTURES, language);
|
|
70
131
|
let names: string[] = [];
|
|
@@ -77,8 +138,15 @@ function loadPairs(): Array<{ name: string; language: "en" | "de"; before: strin
|
|
|
77
138
|
continue;
|
|
78
139
|
}
|
|
79
140
|
for (const name of names) {
|
|
141
|
+
const split = assigned.get(`${language}/${name}`);
|
|
142
|
+
if (split === undefined) {
|
|
143
|
+
// Silence here would let a new pair join the tune half by default,
|
|
144
|
+
// which is the failure the split exists to prevent.
|
|
145
|
+
throw new Error(`SPLITS.json assigns no half to ${language}/${name}`);
|
|
146
|
+
}
|
|
80
147
|
out.push({
|
|
81
148
|
name,
|
|
149
|
+
split,
|
|
82
150
|
language,
|
|
83
151
|
before: readFileSync(join(dir, `${name}.before.md`), "utf8"),
|
|
84
152
|
after: readFileSync(join(dir, `${name}.after.md`), "utf8"),
|
|
@@ -111,6 +179,18 @@ async function judgePair(
|
|
|
111
179
|
async function main(): Promise<void> {
|
|
112
180
|
const runJudge = process.argv.includes("--judge");
|
|
113
181
|
const confirmSpend = process.argv.includes("--confirm-spend");
|
|
182
|
+
const excluded = new Set<string>();
|
|
183
|
+
for (let i = 0; i < process.argv.length; i += 1) {
|
|
184
|
+
if (process.argv[i] === "--disable-family") {
|
|
185
|
+
const id = process.argv[i + 1];
|
|
186
|
+
if (id === undefined || id.startsWith("--")) {
|
|
187
|
+
console.error("--disable-family needs a rule id");
|
|
188
|
+
process.exit(2);
|
|
189
|
+
}
|
|
190
|
+
excluded.add(id);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const opts = { exclude: excluded };
|
|
114
194
|
const pairs = loadPairs();
|
|
115
195
|
if (pairs.length < 20) {
|
|
116
196
|
console.error(`corpus too small: ${pairs.length} pairs (< 20)`);
|
|
@@ -123,8 +203,8 @@ async function main(): Promise<void> {
|
|
|
123
203
|
// never an interactive prompt. The objective-only default path is free.
|
|
124
204
|
if (runJudge && !confirmSpend) {
|
|
125
205
|
const controlledCount = pairs.filter((p) => {
|
|
126
|
-
const b = analyzeText(p.before, p.language).words;
|
|
127
|
-
const a = analyzeText(p.after, p.language).words;
|
|
206
|
+
const b = analyzeText(p.before, p.language, opts).words;
|
|
207
|
+
const a = analyzeText(p.after, p.language, opts).words;
|
|
128
208
|
return Math.abs(1 - a / b) <= LENGTH_TOLERANCE;
|
|
129
209
|
}).length;
|
|
130
210
|
process.stderr.write(
|
|
@@ -138,12 +218,14 @@ async function main(): Promise<void> {
|
|
|
138
218
|
const results: PairResult[] = [];
|
|
139
219
|
for (let i = 0; i < pairs.length; i++) {
|
|
140
220
|
const p = pairs[i]!;
|
|
141
|
-
const before = analyzeText(p.before, p.language);
|
|
142
|
-
const after = analyzeText(p.after, p.language);
|
|
221
|
+
const before = analyzeText(p.before, p.language, opts);
|
|
222
|
+
const after = analyzeText(p.after, p.language, opts);
|
|
143
223
|
const ratio = after.words / before.words;
|
|
144
224
|
const controlled = Math.abs(1 - ratio) <= LENGTH_TOLERANCE;
|
|
145
225
|
const row: PairResult = {
|
|
146
226
|
name: p.name,
|
|
227
|
+
split: p.split,
|
|
228
|
+
families_removed: familyDelta(before, after),
|
|
147
229
|
language: p.language,
|
|
148
230
|
words_before: before.words,
|
|
149
231
|
words_after: after.words,
|
|
@@ -161,31 +243,116 @@ async function main(): Promise<void> {
|
|
|
161
243
|
|
|
162
244
|
const mean = (xs: number[]) =>
|
|
163
245
|
Math.round((xs.reduce((s, x) => s + x, 0) / Math.max(xs.length, 1)) * 100) / 100;
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
246
|
+
/**
|
|
247
|
+
* A density is `null` when the text sits under the detector's word floor, so
|
|
248
|
+
* the rate was never computed. Averaging those as zero would report "measured
|
|
249
|
+
* clean" for text nobody measured; they are dropped and the surviving
|
|
250
|
+
* denominator is published beside the mean.
|
|
251
|
+
*/
|
|
252
|
+
const meanDefined = (xs: Array<number | null>): { value: number | null; n: number } => {
|
|
253
|
+
const defined = xs.filter((x): x is number => x !== null);
|
|
254
|
+
return { value: defined.length === 0 ? null : mean(defined), n: defined.length };
|
|
255
|
+
};
|
|
256
|
+
const show = (m: { value: number | null; n: number }): string =>
|
|
257
|
+
m.value === null ? `not evaluated (n=0)` : `${m.value} (n=${m.n})`;
|
|
258
|
+
const aggregateOver = (rows: PairResult[]) => {
|
|
259
|
+
const judgedRows = rows.filter((r) => r.judge);
|
|
260
|
+
return {
|
|
261
|
+
pairs: rows.length,
|
|
262
|
+
length_controlled_pairs: rows.filter((r) => r.length_controlled).length,
|
|
263
|
+
mean_hard_before: mean(rows.map((r) => r.before.hard_total)),
|
|
264
|
+
mean_hard_after: mean(rows.map((r) => r.after.hard_total)),
|
|
265
|
+
mean_cluster_before: meanDefined(rows.map((r) => r.before.cluster_score_per_500)),
|
|
266
|
+
mean_cluster_after: meanDefined(rows.map((r) => r.after.cluster_score_per_500)),
|
|
267
|
+
mean_dash_before: meanDefined(rows.map((r) => r.before.dash_density_per_500)),
|
|
268
|
+
mean_dash_after: meanDefined(rows.map((r) => r.after.dash_density_per_500)),
|
|
269
|
+
judged_pairs: judgedRows.length,
|
|
270
|
+
prefers_after: judgedRows.filter((r) => r.judge!.preferred === "after").length,
|
|
271
|
+
prefers_before: judgedRows.filter((r) => r.judge!.preferred === "before").length,
|
|
272
|
+
judge_model: undefined as string | undefined,
|
|
273
|
+
};
|
|
274
|
+
};
|
|
275
|
+
/**
|
|
276
|
+
* The last recorded judged run, when this one is objective-only.
|
|
277
|
+
*
|
|
278
|
+
* An objective-only re-run used to overwrite the canonical report with a
|
|
279
|
+
* "Not run" placeholder, which deleted the only evidence backing
|
|
280
|
+
* `claim:humanizer-tell-reduction` — a free re-run silently unbacking a
|
|
281
|
+
* claim is a worse failure than a stale number, because nothing announces it.
|
|
282
|
+
* The carried-forward block is labelled with its own date and is never
|
|
283
|
+
* presented as a measurement of the current register.
|
|
284
|
+
*/
|
|
285
|
+
const priorJudged = ((): { generated: string; agg: JudgedAggregate } | null => {
|
|
286
|
+
let best: { generated: string; agg: JudgedAggregate } | null = null;
|
|
287
|
+
let names: string[] = [];
|
|
288
|
+
try {
|
|
289
|
+
names = readdirSync(REPORT_DIR).filter((n) => n.endsWith("-humanizer-v1.json"));
|
|
290
|
+
} catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
for (const n of names.sort()) {
|
|
294
|
+
try {
|
|
295
|
+
const doc = JSON.parse(readFileSync(join(REPORT_DIR, n), "utf8")) as {
|
|
296
|
+
generated?: string;
|
|
297
|
+
aggregate?: JudgedAggregate;
|
|
298
|
+
};
|
|
299
|
+
if ((doc.aggregate?.judged_pairs ?? 0) > 0 && doc.generated !== undefined) {
|
|
300
|
+
best = { generated: doc.generated, agg: doc.aggregate as JudgedAggregate };
|
|
301
|
+
}
|
|
302
|
+
} catch {
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return best;
|
|
307
|
+
})();
|
|
308
|
+
|
|
309
|
+
const agg = aggregateOver(results);
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Attribution — which families the preference could have tracked.
|
|
313
|
+
*
|
|
314
|
+
* `pairs_removed` is deterministic and free. `pairs_removed_and_preferred`
|
|
315
|
+
* is the crossing with the blind judge and exists only on a `--judge` run;
|
|
316
|
+
* it is a CO-OCCURRENCE, never an isolated effect, because the pass removes
|
|
317
|
+
* several families at once from the same pair. What it rules out is the
|
|
318
|
+
* opposite: a family that appears in no preferred pair did not carry the
|
|
319
|
+
* preference, and that is the half a binary verdict could not say at all.
|
|
320
|
+
*/
|
|
321
|
+
const attribution = (() => {
|
|
322
|
+
const rows = new Map<string, { pairs_removed: number; pairs_removed_and_preferred: number }>();
|
|
323
|
+
for (const r of results) {
|
|
324
|
+
for (const id of Object.keys(r.families_removed)) {
|
|
325
|
+
const row = rows.get(id) ?? { pairs_removed: 0, pairs_removed_and_preferred: 0 };
|
|
326
|
+
row.pairs_removed += 1;
|
|
327
|
+
if (r.judge?.preferred === "after") row.pairs_removed_and_preferred += 1;
|
|
328
|
+
rows.set(id, row);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return [...rows.entries()]
|
|
332
|
+
.map(([id, v]) => ({ id, ...v }))
|
|
333
|
+
.sort((a, b) => b.pairs_removed - a.pairs_removed || a.id.localeCompare(b.id));
|
|
334
|
+
})();
|
|
335
|
+
const bySplit = {
|
|
336
|
+
tune: aggregateOver(results.filter((r) => r.split === "tune")),
|
|
337
|
+
holdout: aggregateOver(results.filter((r) => r.split === "holdout")),
|
|
177
338
|
};
|
|
178
|
-
const judged = results.filter((r) => r.judge);
|
|
179
|
-
agg.judged_pairs = judged.length;
|
|
180
|
-
agg.prefers_after = judged.filter((r) => r.judge!.preferred === "after").length;
|
|
181
|
-
agg.prefers_before = judged.filter((r) => r.judge!.preferred === "before").length;
|
|
182
339
|
if (runJudge) {
|
|
183
340
|
const { DEFAULT_ANTHROPIC_MODEL } = await import("./ai_council/clients.js");
|
|
184
341
|
agg.judge_model = DEFAULT_ANTHROPIC_MODEL as string;
|
|
342
|
+
bySplit.tune.judge_model = agg.judge_model;
|
|
343
|
+
bySplit.holdout.judge_model = agg.judge_model;
|
|
185
344
|
}
|
|
186
345
|
|
|
187
346
|
const iso = new Date().toISOString().replace(/\.\d+Z$/, "Z").replace(/:/g, "-");
|
|
188
|
-
const payload = {
|
|
347
|
+
const payload = {
|
|
348
|
+
generated: iso,
|
|
349
|
+
corpus: "tests/fixtures/ai-tells",
|
|
350
|
+
aggregate: agg,
|
|
351
|
+
by_split: bySplit,
|
|
352
|
+
disabled_families: [...excluded].sort(),
|
|
353
|
+
attribution,
|
|
354
|
+
results,
|
|
355
|
+
};
|
|
189
356
|
|
|
190
357
|
const md = [
|
|
191
358
|
"# Humanizer paired eval — v1",
|
|
@@ -195,19 +362,59 @@ async function main(): Promise<void> {
|
|
|
195
362
|
"",
|
|
196
363
|
"## Objective — AI-tell reduction (deterministic)",
|
|
197
364
|
"",
|
|
198
|
-
"
|
|
199
|
-
"
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
365
|
+
"Every figure names the split it came from. `tune` is the half a rule may be",
|
|
366
|
+
"looked at while it is being written; `holdout` is scored and never read",
|
|
367
|
+
"during authoring, so a gain that appears only in `tune` is overfitting and",
|
|
368
|
+
"says so on its face. Split membership: `tests/fixtures/ai-tells/SPLITS.json`.",
|
|
369
|
+
"",
|
|
370
|
+
`| Metric (mean) | tune before (n=${bySplit.tune.pairs}) | tune after | holdout before (n=${bySplit.holdout.pairs}) | holdout after | both before (n=${agg.pairs}) | both after |`,
|
|
371
|
+
"|---|---|---|---|---|---|---|",
|
|
372
|
+
`| Hard hits | ${bySplit.tune.mean_hard_before} | ${bySplit.tune.mean_hard_after} | ` +
|
|
373
|
+
`${bySplit.holdout.mean_hard_before} | ${bySplit.holdout.mean_hard_after} | ` +
|
|
374
|
+
`${agg.mean_hard_before} | ${agg.mean_hard_after} |`,
|
|
375
|
+
`| Cluster score /500w | ${show(bySplit.tune.mean_cluster_before)} | ${show(bySplit.tune.mean_cluster_after)} | ` +
|
|
376
|
+
`${show(bySplit.holdout.mean_cluster_before)} | ${show(bySplit.holdout.mean_cluster_after)} | ` +
|
|
377
|
+
`${show(agg.mean_cluster_before)} | ${show(agg.mean_cluster_after)} |`,
|
|
378
|
+
`| Dash density /500w | ${show(bySplit.tune.mean_dash_before)} | ${show(bySplit.tune.mean_dash_after)} | ` +
|
|
379
|
+
`${show(bySplit.holdout.mean_dash_before)} | ${show(bySplit.holdout.mean_dash_after)} | ` +
|
|
380
|
+
`${show(agg.mean_dash_before)} | ${show(agg.mean_dash_after)} |`,
|
|
203
381
|
"",
|
|
204
382
|
"## Blind preference (length-controlled)",
|
|
205
383
|
"",
|
|
206
384
|
agg.judged_pairs > 0
|
|
207
385
|
? `Judge ${agg.judge_model}: prefers the humanized text in **${agg.prefers_after}/${agg.judged_pairs}** ` +
|
|
208
|
-
`pairs
|
|
386
|
+
`pairs across both halves — **${bySplit.tune.prefers_after}/${bySplit.tune.judged_pairs}** on tune, ` +
|
|
387
|
+
`**${bySplit.holdout.prefers_after}/${bySplit.holdout.judged_pairs}** on holdout — ` +
|
|
388
|
+
`(randomized A/B order, deterministic seed). An honest null here keeps the detector as a ` +
|
|
209
389
|
`hygiene gate; the claim ledger only carries what this table shows.`
|
|
210
|
-
:
|
|
390
|
+
: priorJudged !== null
|
|
391
|
+
? `_Not re-run — this invocation is objective-only._ Carried forward from the judged run of ` +
|
|
392
|
+
`**${priorJudged.generated}**: judge ${priorJudged.agg.judge_model ?? "unrecorded"} ` +
|
|
393
|
+
`prefers the humanized text in **${priorJudged.agg.prefers_after}/${priorJudged.agg.judged_pairs}** ` +
|
|
394
|
+
`length-controlled pairs (randomized A/B order, deterministic seed). That figure was measured ` +
|
|
395
|
+
`against the register and the length-controlled set as they stood on that date; the objective ` +
|
|
396
|
+
`table above is newer. It is reproduced so a free re-run cannot silently unback the claim it ` +
|
|
397
|
+
`supports, and it is NOT a measurement of the current register.`
|
|
398
|
+
: "_Not run (objective-only invocation, and no earlier judged run on record)._",
|
|
399
|
+
"",
|
|
400
|
+
"## Attribution — which families the preference tracked",
|
|
401
|
+
"",
|
|
402
|
+
excluded.size > 0
|
|
403
|
+
? `Families disabled for this run: ${[...excluded].sort().map((x) => `\`${x}\``).join(", ")}. ` +
|
|
404
|
+
"They were excluded from the scan, not merely from this table, so every figure above moved with them."
|
|
405
|
+
: "No family disabled. Re-run with `--disable-family <rule-id>` to see what a family was carrying.",
|
|
406
|
+
"",
|
|
407
|
+
"| Family | Pairs it was removed from | …and the judge preferred the humanized text |",
|
|
408
|
+
"|---|---|---|",
|
|
409
|
+
...attribution.map(
|
|
410
|
+
(a) => `| \`${a.id}\` | ${a.pairs_removed} | ${agg.judged_pairs > 0 ? a.pairs_removed_and_preferred : "not judged"} |`,
|
|
411
|
+
),
|
|
412
|
+
"",
|
|
413
|
+
"The right-hand column is a **co-occurrence, never an isolated effect**: the",
|
|
414
|
+
"pass removes several families from the same pair, so a high number does not",
|
|
415
|
+
"attribute the preference to that family. What the table does establish is the",
|
|
416
|
+
"negative — a family removed in no preferred pair did not carry the preference —",
|
|
417
|
+
"and that is what a binary verdict could not say at all.",
|
|
211
418
|
"",
|
|
212
419
|
"## Scope note",
|
|
213
420
|
"",
|
|
@@ -215,14 +422,20 @@ async function main(): Promise<void> {
|
|
|
215
422
|
'It never measures third-party "AI detector" outcomes — that claim class is banned',
|
|
216
423
|
"(unfalsifiable from our side; see roadmap non-goals).",
|
|
217
424
|
"",
|
|
218
|
-
"## Open question — real-draft lift is unmeasured",
|
|
425
|
+
"## Open question — real-draft lift is unmeasured, and stays that way this round",
|
|
219
426
|
"",
|
|
220
427
|
"The `before` fixtures were **deliberately tell-seeded**, so a perfect score",
|
|
221
428
|
"measures seeded-tell removal on a self-constructed corpus — NOT that real",
|
|
222
|
-
"ghostwriter drafts get better. Real-world lift
|
|
223
|
-
"
|
|
224
|
-
"
|
|
225
|
-
"
|
|
429
|
+
"ghostwriter drafts get better. Real-world lift is **unmeasured**.",
|
|
430
|
+
"",
|
|
431
|
+
"Collecting it was declined for this round. Retaining real drafts, and",
|
|
432
|
+
"retaining metrics derived from real drafts, both create a new retention",
|
|
433
|
+
"practice beyond the fixture-only data-handling floor this package records,",
|
|
434
|
+
"and neither was authorized. That is a decision about this round: **future",
|
|
435
|
+
"authorization is neither granted nor refused**, and the owner-facing question",
|
|
436
|
+
"is unchanged. The claim ledger stays scoped to \"on the fixture corpus\"",
|
|
437
|
+
"accordingly, and widening it needs an authorization AND a measurement, not",
|
|
438
|
+
"either one alone.",
|
|
226
439
|
"",
|
|
227
440
|
].join("\n");
|
|
228
441
|
|
|
@@ -234,7 +447,18 @@ async function main(): Promise<void> {
|
|
|
234
447
|
process.stdout.write(md + "\n");
|
|
235
448
|
}
|
|
236
449
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
450
|
+
/**
|
|
451
|
+
* Guarded, because this module is now IMPORTED — `loadPairs`, `loadSplits` and
|
|
452
|
+
* `familyDelta` are the split and attribution surface the suite tests. Without
|
|
453
|
+
* the guard an import ran the whole bench as a side effect and overwrote the
|
|
454
|
+
* canonical report, so the test suite silently wrote tracked files.
|
|
455
|
+
*/
|
|
456
|
+
const invokedDirectly =
|
|
457
|
+
process.argv[1] !== undefined &&
|
|
458
|
+
import.meta.url.endsWith(process.argv[1].split("/").pop() ?? "");
|
|
459
|
+
if (invokedDirectly) {
|
|
460
|
+
main().catch((err) => {
|
|
461
|
+
console.error(String(err));
|
|
462
|
+
process.exit(2);
|
|
463
|
+
});
|
|
464
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* bench_prose_tells_fp — per-rule false-positive count for the prose-tell
|
|
4
|
+
* registry, measured over a clean corpus.
|
|
5
|
+
*
|
|
6
|
+
* The seeded corpus in `tests/fixtures/ai-tells/{en,de}/` can only answer
|
|
7
|
+
* "does the rule fire on text written to make it fire". This bench answers the
|
|
8
|
+
* other half: how often each rule in `ai_tells_rules.ts` fires on prose that
|
|
9
|
+
* was never written for it. Pre-registration:
|
|
10
|
+
* `internal/bench/corpora/prose-tells-fp-PREREG.md` — read it before reading a
|
|
11
|
+
* number from this tool.
|
|
12
|
+
*
|
|
13
|
+
* Counting is PER FILE, not per hit: a rule matching six lines of one file is
|
|
14
|
+
* one false positive, because the unit a writer experiences is "this rule
|
|
15
|
+
* flagged this file". Reported alongside, never instead: the count of rules
|
|
16
|
+
* with a non-zero M1, and how many clean files the shipped thresholds reject
|
|
17
|
+
* outright.
|
|
18
|
+
*
|
|
19
|
+
* Every run prints a corpus SHA-256 over the sorted `<path>:<sha256>` list. A
|
|
20
|
+
* number quoted without its corpus hash is not comparable to any other number.
|
|
21
|
+
*
|
|
22
|
+
* Usage:
|
|
23
|
+
* npx tsx src/scripts/bench_prose_tells_fp.ts # table + pin
|
|
24
|
+
* npx tsx src/scripts/bench_prose_tells_fp.ts --json
|
|
25
|
+
* npx tsx src/scripts/bench_prose_tells_fp.ts --gate tell-x tell-y
|
|
26
|
+
*
|
|
27
|
+
* `--gate <rule-id...>` is the Phase-2 promotion gate: exit 1 when any named
|
|
28
|
+
* rule has a non-zero clean-corpus count, naming the files that hit. Promotion
|
|
29
|
+
* of a family is refused while its count is above zero.
|
|
30
|
+
*
|
|
31
|
+
* Exit codes: 0 report-only or gate clean · 1 gate failed · 2 usage / no corpus.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { createHash } from "node:crypto";
|
|
35
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
36
|
+
import { join, relative } from "node:path";
|
|
37
|
+
import { fileURLToPath } from "node:url";
|
|
38
|
+
import { analyzeText, exceedsThresholds } from "./detect_ai_tells.js";
|
|
39
|
+
import {
|
|
40
|
+
ALL_TELL_RULES,
|
|
41
|
+
DEFAULT_MAX_CLUSTER_SCORE,
|
|
42
|
+
DEFAULT_MAX_DASH_DENSITY,
|
|
43
|
+
DEFAULT_MAX_HARD,
|
|
44
|
+
} from "./ai_tells_rules.js";
|
|
45
|
+
|
|
46
|
+
const REPO = join(fileURLToPath(import.meta.url), "..", "..", "..");
|
|
47
|
+
export const CLEAN_DIR = join(REPO, "tests", "fixtures", "ai-tells", "clean");
|
|
48
|
+
|
|
49
|
+
const THRESHOLDS = {
|
|
50
|
+
maxHard: DEFAULT_MAX_HARD,
|
|
51
|
+
maxScore: DEFAULT_MAX_CLUSTER_SCORE,
|
|
52
|
+
maxDashDensity: DEFAULT_MAX_DASH_DENSITY,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export interface CleanFile {
|
|
56
|
+
path: string;
|
|
57
|
+
language: "en" | "de";
|
|
58
|
+
text: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Every file in the clean corpus, sorted, with its language from the folder. */
|
|
62
|
+
export function loadCleanCorpus(dir: string = CLEAN_DIR): CleanFile[] {
|
|
63
|
+
const out: CleanFile[] = [];
|
|
64
|
+
for (const language of ["en", "de"] as const) {
|
|
65
|
+
let names: string[] = [];
|
|
66
|
+
try {
|
|
67
|
+
names = readdirSync(join(dir, language)).filter((f) => f.endsWith(".md")).sort();
|
|
68
|
+
} catch {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
for (const name of names) {
|
|
72
|
+
const full = join(dir, language, name);
|
|
73
|
+
out.push({
|
|
74
|
+
path: relative(REPO, full),
|
|
75
|
+
language,
|
|
76
|
+
text: readFileSync(full, "utf8"),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** SHA-256 over the sorted `<relative path>:<sha256 of contents>` list. */
|
|
84
|
+
export function corpusPin(files: CleanFile[]): string {
|
|
85
|
+
const lines = files
|
|
86
|
+
.map((f) => `${f.path}:${createHash("sha256").update(f.text).digest("hex")}`)
|
|
87
|
+
.sort();
|
|
88
|
+
return createHash("sha256").update(lines.join("\n")).digest("hex");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface RuleFp {
|
|
92
|
+
id: string;
|
|
93
|
+
language: string;
|
|
94
|
+
severity: string;
|
|
95
|
+
/** M1 — number of clean files on which the rule produced at least one match. */
|
|
96
|
+
files: number;
|
|
97
|
+
/** Which files, so a non-zero count is actionable rather than a number. */
|
|
98
|
+
hits: string[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface FpReport {
|
|
102
|
+
corpus_pin: string;
|
|
103
|
+
files: number;
|
|
104
|
+
per_rule: RuleFp[];
|
|
105
|
+
rules_with_findings: number;
|
|
106
|
+
/** Clean files the shipped thresholds would reject outright. */
|
|
107
|
+
rejected_files: Array<{ path: string; reasons: string[] }>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function measure(files: CleanFile[]): FpReport {
|
|
111
|
+
const byRule = new Map<string, string[]>();
|
|
112
|
+
const rejected: Array<{ path: string; reasons: string[] }> = [];
|
|
113
|
+
|
|
114
|
+
for (const f of files) {
|
|
115
|
+
const report = analyzeText(f.text, f.language);
|
|
116
|
+
for (const [id, count] of Object.entries(report.per_pattern)) {
|
|
117
|
+
if (count > 0) byRule.set(id, [...(byRule.get(id) ?? []), f.path]);
|
|
118
|
+
}
|
|
119
|
+
const reasons = exceedsThresholds(report, THRESHOLDS);
|
|
120
|
+
if (reasons.length > 0) rejected.push({ path: f.path, reasons });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const perRule: RuleFp[] = ALL_TELL_RULES.map((r) => ({
|
|
124
|
+
id: r.id,
|
|
125
|
+
language: r.language,
|
|
126
|
+
severity: r.severity,
|
|
127
|
+
files: (byRule.get(r.id) ?? []).length,
|
|
128
|
+
hits: byRule.get(r.id) ?? [],
|
|
129
|
+
})).sort((a, b) => b.files - a.files || a.id.localeCompare(b.id));
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
corpus_pin: corpusPin(files),
|
|
133
|
+
files: files.length,
|
|
134
|
+
per_rule: perRule,
|
|
135
|
+
rules_with_findings: perRule.filter((r) => r.files > 0).length,
|
|
136
|
+
rejected_files: rejected,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function render(r: FpReport): string {
|
|
141
|
+
const lines: string[] = [];
|
|
142
|
+
lines.push(`prose-tell false-positive count — ${r.files} clean files`);
|
|
143
|
+
lines.push(`corpus pin: ${r.corpus_pin}`);
|
|
144
|
+
lines.push("");
|
|
145
|
+
lines.push("| Rule | Lang | Severity | M1 (files) |");
|
|
146
|
+
lines.push("|---|---|---|---|");
|
|
147
|
+
for (const rule of r.per_rule) {
|
|
148
|
+
lines.push(`| ${rule.id} | ${rule.language} | ${rule.severity} | ${rule.files} |`);
|
|
149
|
+
}
|
|
150
|
+
lines.push("");
|
|
151
|
+
lines.push(`rules with a non-zero M1: ${r.rules_with_findings} / ${r.per_rule.length}`);
|
|
152
|
+
lines.push(`clean files the shipped thresholds reject: ${r.rejected_files.length} / ${r.files}`);
|
|
153
|
+
for (const f of r.rejected_files) lines.push(` ❌ ${f.path} — ${f.reasons.join("; ")}`);
|
|
154
|
+
for (const rule of r.per_rule.filter((x) => x.files > 0)) {
|
|
155
|
+
lines.push(` · ${rule.id} hit: ${rule.hits.join(", ")}`);
|
|
156
|
+
}
|
|
157
|
+
return lines.join("\n");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function main(): void {
|
|
161
|
+
const args = process.argv.slice(2);
|
|
162
|
+
const json = args.includes("--json");
|
|
163
|
+
const gateAt = args.indexOf("--gate");
|
|
164
|
+
const gated = gateAt === -1 ? [] : args.slice(gateAt + 1).filter((a) => !a.startsWith("--"));
|
|
165
|
+
|
|
166
|
+
const files = loadCleanCorpus();
|
|
167
|
+
if (files.length === 0) {
|
|
168
|
+
console.error(`no clean corpus at ${CLEAN_DIR} — nothing to measure`);
|
|
169
|
+
process.exit(2);
|
|
170
|
+
}
|
|
171
|
+
const report = measure(files);
|
|
172
|
+
process.stdout.write((json ? JSON.stringify(report, null, 2) : render(report)) + "\n");
|
|
173
|
+
|
|
174
|
+
if (gated.length > 0) {
|
|
175
|
+
const known = new Set(ALL_TELL_RULES.map((r) => r.id));
|
|
176
|
+
const unknown = gated.filter((g) => !known.has(g));
|
|
177
|
+
if (unknown.length > 0) {
|
|
178
|
+
console.error(`--gate names no such rule: ${unknown.join(", ")}`);
|
|
179
|
+
process.exit(2);
|
|
180
|
+
}
|
|
181
|
+
const failing = report.per_rule.filter((r) => gated.includes(r.id) && r.files > 0);
|
|
182
|
+
if (failing.length > 0) {
|
|
183
|
+
for (const f of failing) {
|
|
184
|
+
console.error(
|
|
185
|
+
`promotion refused: ${f.id} has ${f.files} clean-corpus false positive(s) — ${f.hits.join(", ")}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
process.stdout.write(`promotion gate clean for: ${gated.join(", ")}\n`);
|
|
191
|
+
}
|
|
192
|
+
process.exit(0);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const invokedDirectly =
|
|
196
|
+
process.argv[1] !== undefined &&
|
|
197
|
+
import.meta.url.endsWith(process.argv[1].split("/").pop() ?? "");
|
|
198
|
+
if (invokedDirectly) main();
|