@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.
Files changed (100) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +243 -0
  5. package/README.md +1 -1
  6. package/agents/templates/.ai-council.yml.example +10 -0
  7. package/dist/agent-src/commands/analyze/inbox.md +1 -1
  8. package/dist/agent-src/commands/analyze/{reference-repo.md → repo.md} +247 -32
  9. package/dist/agent-src/commands/analyze/roadmap-repos.md +129 -0
  10. package/dist/agent-src/commands/analyze.md +12 -7
  11. package/dist/agent-src/commands/feature/plan.md +55 -16
  12. package/dist/agent-src/commands/feature/refactor.md +8 -3
  13. package/dist/agent-src/commands/humanize.md +48 -4
  14. package/dist/agent-src/contexts/execution/contract-decision-sheet.md +22 -1
  15. package/dist/agent-src/contexts/execution/host-capability-manifest.md +4 -1
  16. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +35 -2
  17. package/dist/agent-src/contexts/execution/terminal-states.md +4 -1
  18. package/dist/agent-src/rules/evaluator-independence.md +4 -4
  19. package/dist/agent-src/rules/external-reference-deep-dive.md +2 -2
  20. package/dist/agent-src/skills/competitive-positioning/SKILL.md +2 -2
  21. package/dist/agent-src/skills/humanizer/references/anti-aiisms.md +27 -11
  22. package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +1 -1
  23. package/dist/agent-src/skills/markitdown/SKILL.md +1 -1
  24. package/dist/agent-src/skills/repomix-packer/SKILL.md +1 -1
  25. package/dist/agent-src/skills/technical-specification/SKILL.md +4 -0
  26. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  27. package/dist/agent-src/templates/features.md +5 -0
  28. package/dist/agent-src/templates/roadmaps.md +15 -0
  29. package/dist/cli-delegate/{chunk-KPBWFOPZ.js → chunk-2FTMENJL.js} +194 -37
  30. package/dist/cli-delegate/{chunk-PI2GU3DP.js → chunk-2TAQ2K4V.js} +4 -1
  31. package/dist/cli-delegate/{chunk-C6IPZRDA.js → chunk-643Z7FIC.js} +1 -1
  32. package/dist/cli-delegate/{chunk-NUMQHLS5.js → chunk-J3RFNKG4.js} +21 -2
  33. package/dist/cli-delegate/cmd_conformance.js +17 -3
  34. package/dist/cli-delegate/cmd_doctor.js +3 -3
  35. package/dist/cli-delegate/cmd_export.js +2 -2
  36. package/dist/cli-delegate/cmd_handoff.js +1 -1
  37. package/dist/cli-delegate/cmd_prune.js +2 -2
  38. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  39. package/dist/cli-delegate/cmd_sync.js +2 -2
  40. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  41. package/dist/cli-delegate/cmd_validate.js +2 -2
  42. package/dist/discovery/deprecation-report.md +1 -1
  43. package/dist/discovery/discovery-manifest.json +56 -25
  44. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  45. package/dist/discovery/discovery-manifest.summary.md +3 -3
  46. package/dist/discovery/dormancy-report.md +1 -1
  47. package/dist/discovery/orphan-report.md +1 -1
  48. package/dist/discovery/packs.json +7 -6
  49. package/dist/discovery/trust-report.md +2 -2
  50. package/dist/discovery/workspaces.json +6 -5
  51. package/dist/hooks/dispatch.js +479 -241
  52. package/dist/install/install.mjs +4 -1
  53. package/dist/mcp/registry-manifest.json +2 -2
  54. package/dist/mcp/server.json +2 -2
  55. package/dist/mcp/server.mjs +313 -63
  56. package/dist/scripts/ai_council/content_ceiling.js +63 -0
  57. package/dist/scripts/ai_council/seat_policy.js +106 -0
  58. package/dist/scripts/ai_council/transport_resolver.js +21 -0
  59. package/llms.txt +14 -14
  60. package/package.json +1 -1
  61. package/src/config/gate-violation-baselines.json +3 -3
  62. package/src/config/release-gate-locality.yml +4 -3
  63. package/src/scripts/_lib/blocked_by_marker.ts +189 -0
  64. package/src/scripts/_lib/host_capability.ts +38 -1
  65. package/src/scripts/_lib/release_highlights.ts +19 -211
  66. package/src/scripts/_lib/release_material.ts +27 -77
  67. package/src/scripts/_lib/structured_ask.ts +117 -0
  68. package/src/scripts/_lib/tty_prompt.ts +76 -0
  69. package/src/scripts/ai_council/api_transport.ts +163 -0
  70. package/src/scripts/ai_council/clients.ts +58 -63
  71. package/src/scripts/ai_council/config.ts +13 -0
  72. package/src/scripts/ai_council/content_ceiling.ts +79 -0
  73. package/src/scripts/ai_council/jury_aggregate.ts +178 -0
  74. package/src/scripts/ai_council/seat_policy.ts +137 -0
  75. package/src/scripts/ai_council/status_surface.ts +79 -0
  76. package/src/scripts/ai_council/transport_resolver.ts +59 -1
  77. package/src/scripts/ai_tells_rules.ts +446 -5
  78. package/src/scripts/ask_block_census.ts +499 -0
  79. package/src/scripts/bench_humanizer_eval.ts +264 -40
  80. package/src/scripts/bench_prose_tells_fp.ts +198 -0
  81. package/src/scripts/check_branch_work_committed.ts +234 -0
  82. package/src/scripts/check_pack_size.ts +1 -1
  83. package/src/scripts/check_release_highlights.ts +0 -157
  84. package/src/scripts/council_cli.ts +18 -27
  85. package/src/scripts/detect_ai_tells.ts +142 -20
  86. package/src/scripts/harvest_reference_tokens.ts +698 -0
  87. package/src/scripts/hook_manifest.json +1 -1
  88. package/src/scripts/hook_manifest.yaml +31 -1
  89. package/src/scripts/hooks/concern_registry.ts +2 -0
  90. package/src/scripts/hooks/delegation_nudge_hook.ts +45 -1
  91. package/src/scripts/hooks/one_question_per_ask_hook.ts +191 -0
  92. package/src/scripts/install-hooks.sh +52 -0
  93. package/src/scripts/lint_roadmap_blockers.ts +51 -1
  94. package/src/scripts/migrate_command_suggestions.ts +1 -1
  95. package/src/scripts/probe_unblocked_ask.ts +135 -2
  96. package/src/scripts/release.ts +26 -71
  97. package/src/scripts/release_drill.ts +6 -10
  98. package/src/scripts/release_env.ts +1 -0
  99. package/src/scripts/release_publication.ts +34 -29
  100. 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
- function loadPairs(): Array<{ name: string; language: "en" | "de"; before: string; after: string }> {
67
- const out: Array<{ name: string; language: "en" | "de"; before: string; after: string }> = [];
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
- const agg = {
165
- pairs: results.length,
166
- length_controlled_pairs: results.filter((r) => r.length_controlled).length,
167
- mean_hard_before: mean(results.map((r) => r.before.hard_total)),
168
- mean_hard_after: mean(results.map((r) => r.after.hard_total)),
169
- mean_cluster_before: mean(results.map((r) => r.before.cluster_score_per_500)),
170
- mean_cluster_after: mean(results.map((r) => r.after.cluster_score_per_500)),
171
- mean_dash_before: mean(results.map((r) => r.before.dash_density_per_500)),
172
- mean_dash_after: mean(results.map((r) => r.after.dash_density_per_500)),
173
- judge_model: undefined as string | undefined,
174
- judged_pairs: 0,
175
- prefers_after: 0,
176
- prefers_before: 0,
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 = { generated: iso, corpus: "tests/fixtures/ai-tells", aggregate: agg, results };
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
- "| Metric (mean) | Before | After |",
199
- "|---|---|---|",
200
- `| Hard hits | ${agg.mean_hard_before} | ${agg.mean_hard_after} |`,
201
- `| Cluster score /500w | ${agg.mean_cluster_before} | ${agg.mean_cluster_after} |`,
202
- `| Dash density /500w | ${agg.mean_dash_before} | ${agg.mean_dash_after} |`,
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 (randomized A/B order, deterministic seed). An honest null here keeps the detector as a ` +
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
- : "_Not run (objective-only invocation)._",
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 stays **unmeasured** until",
223
- "write-engine step 4b has processed real `/ghostwriter:write` drafts and those",
224
- "have been paired-evaluated (the `road-to-humanizer-hardening` live-usage",
225
- "blocker). The claim ledger is scoped to \"on the fixture corpus\" accordingly.",
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
- main().catch((err) => {
238
- console.error(String(err));
239
- process.exit(2);
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();