@event4u/agent-config 14.13.0 → 14.14.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 +116 -387
- package/dist/agent-src/commands/analyze/inbox.md +36 -0
- package/dist/agent-src/rules/code-comment-discipline.md +6 -0
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +6 -6
- package/dist/agent-src/scripts/guarded_baseline.ts +22 -7
- package/dist/agent-src/scripts/stubs_due.ts +118 -2
- package/dist/agent-src/scripts/update_roadmap_progress.ts +8 -8
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/cmd_conformance.js +38 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +4 -4
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +1438 -711
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +112 -17
- package/docs/guidelines/agent-infra/guarded-baseline.md +1 -1
- package/docs/guidelines/code-clarity.md +18 -0
- package/package.json +1 -1
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +6 -6
- package/src/agent-src/scripts/guarded_baseline.ts +22 -7
- package/src/agent-src/scripts/stubs_due.ts +118 -2
- package/src/agent-src/scripts/update_roadmap_progress.ts +8 -8
- package/src/config/estate-count-budget.json +20 -1
- package/src/config/gate-coverage.yml +150 -0
- package/src/config/gate-violation-baselines.json +8 -3
- package/src/config/preamble-payload-budget.json +19 -11
- package/src/config/rule-stub-ceilings.json +14 -7
- package/src/scripts/_dispatch.bash +0 -4
- package/src/scripts/_lib/activation_ladder.ts +47 -3
- package/src/scripts/_lib/activation_receipt_producer.ts +341 -0
- package/src/scripts/_lib/adr_frontmatter.ts +26 -0
- package/src/scripts/_lib/candidate_pair_delta.ts +349 -0
- package/src/scripts/_lib/carrier_status.ts +118 -0
- package/src/scripts/_lib/cascade_stage_enumeration.ts +92 -0
- package/src/scripts/_lib/corpus_manifest.ts +726 -0
- package/src/scripts/_lib/delivery_arm_experiment.ts +18 -2
- package/src/scripts/_lib/estate_carrier_cases.ts +72 -0
- package/src/scripts/_lib/evaluation_cascade.ts +452 -0
- package/src/scripts/_lib/evolution_roi.ts +562 -0
- package/src/scripts/_lib/harness_evolution_guards.ts +10 -0
- package/src/scripts/_lib/lexical_shortlist.ts +215 -0
- package/src/scripts/_lib/llm_candidate_proposer.ts +461 -0
- package/src/scripts/_lib/llm_proposer_transport.ts +153 -0
- package/src/scripts/_lib/pathology_archive.ts +393 -0
- package/src/scripts/_lib/promotion_capability.ts +315 -0
- package/src/scripts/_lib/promotion_evidence.ts +414 -0
- package/src/scripts/_lib/promotion_review.ts +294 -0
- package/src/scripts/_lib/prompt_shape.ts +92 -0
- package/src/scripts/_lib/release_highlights.ts +70 -1
- package/src/scripts/_lib/release_material.ts +125 -2
- package/src/scripts/_lib/routing_corpus.ts +307 -0
- package/src/scripts/_lib/routing_index_input.ts +104 -0
- package/src/scripts/_lib/rule_injection.ts +22 -1
- package/src/scripts/_lib/semantic_noop.ts +167 -0
- package/src/scripts/activation_receipt.ts +258 -0
- package/src/scripts/ai_council/cross_exam.ts +128 -0
- package/src/scripts/ai_council/explain_route.ts +157 -0
- package/src/scripts/ai_council/information_gain.ts +293 -0
- package/src/scripts/ai_council/leakage_corpus.ts +385 -0
- package/src/scripts/ai_council/leakage_patterns.ts +453 -0
- package/src/scripts/ai_council/minority_retention.ts +171 -0
- package/src/scripts/ai_council/recouncil_savings.ts +299 -0
- package/src/scripts/ai_council/replay_route.ts +223 -0
- package/src/scripts/ai_council/reviewer_assignment.ts +279 -0
- package/src/scripts/ai_council/routing_training_row.ts +197 -0
- package/src/scripts/ai_council/synthesis_strategy.ts +159 -0
- package/src/scripts/check_adr_frontmatter.ts +4 -9
- package/src/scripts/check_estate_count.ts +205 -24
- package/src/scripts/check_release_highlights.ts +96 -13
- package/src/scripts/check_roadmap_trackable.ts +17 -2
- package/src/scripts/corpus_manifest.ts +235 -0
- package/src/scripts/evolution_lab.ts +181 -8
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +28 -6
- package/src/scripts/hooks/comment_discipline_hook.ts +146 -0
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +9 -1
- package/src/scripts/language_mirror_hook.ts +14 -2
- package/src/scripts/lint_carrier_integrity.ts +820 -0
- package/src/scripts/lint_code_comments.ts +643 -0
- package/src/scripts/lint_plan_risk_register.ts +39 -19
- package/src/scripts/lint_promotion_paths.ts +779 -0
- package/src/scripts/llm_propose.ts +256 -0
- package/src/scripts/measure_delivery_sets.ts +361 -0
- package/src/scripts/measure_routing_signal.ts +307 -0
- package/src/scripts/model_rule_injection.ts +19 -3
- package/src/scripts/probe_council_retention.ts +495 -0
- package/src/scripts/release.ts +21 -37
- package/src/scripts/release_drill.ts +143 -0
- package/src/scripts/release_env.ts +35 -0
- package/src/scripts/release_publication.ts +84 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The version-pinned pattern list for the provider-recognition leakage bench's
|
|
3
|
+
* PATTERN-STRIPPED arm.
|
|
4
|
+
*
|
|
5
|
+
* `road-to-inbox-harvest-2026-08-e-council-topology-evidence`,
|
|
6
|
+
* `blocker: leakage-bench-needs-assembler-and-design-forks`, todo item 3.
|
|
7
|
+
*
|
|
8
|
+
* ── Why this file exists as its own change ──────────────────────────────────
|
|
9
|
+
* The AI council settled the anonymisation protocol on 2026-08-31 (Option C,
|
|
10
|
+
* two arms, 2/2 convergent) and its condition 1 requires the pattern list to be
|
|
11
|
+
* "pre-registered and version-pinned" before the stripped arm runs.
|
|
12
|
+
* `internal/bench/council-provider-leakage/PREREG-anonymisation-and-sampling.md`
|
|
13
|
+
* § Fork 3 DEFERRED the list on an explicit ground: writing it in the same
|
|
14
|
+
* change that settled the protocol "would put an unreviewed floor into the
|
|
15
|
+
* pre-registration under cover of the council's verdict". This module is that
|
|
16
|
+
* separate change. It carries the list, its replacement semantics, and the
|
|
17
|
+
* span-level transformation log condition 2 requires — and nothing else.
|
|
18
|
+
*
|
|
19
|
+
* ── What this module does NOT do ────────────────────────────────────────────
|
|
20
|
+
* It does NOT anonymise in any strong sense, and the label is load-bearing:
|
|
21
|
+
* condition 6 of the verdict says the second arm is called `pattern-stripped`
|
|
22
|
+
* and NEVER `identifier-free`. Regexes miss identifiers. `ARM_LABEL` is the
|
|
23
|
+
* only name this module will answer to, and `FORBIDDEN_ARM_LABELS` names the
|
|
24
|
+
* ones a caller may not substitute; a test asserts neither appears in the
|
|
25
|
+
* rendered output of anything here.
|
|
26
|
+
*
|
|
27
|
+
* It does NOT dispatch, prompt, score, or write. No `main()`, no transport, no
|
|
28
|
+
* filesystem access.
|
|
29
|
+
*
|
|
30
|
+
* ── The residue this design cannot remove, stated here rather than discovered ─
|
|
31
|
+
* Replacement is family-INVARIANT: every rule writes the same placeholder
|
|
32
|
+
* whatever family matched, so the placeholder text itself cannot tell a rater
|
|
33
|
+
* which provider wrote the body. What it cannot make invariant is the COUNT and
|
|
34
|
+
* the POSITION of the placeholders. A family that self-identifies four times
|
|
35
|
+
* and one that never does are distinguishable by `[REDACTED-…]` density alone,
|
|
36
|
+
* and that density is created by the stripping rather than removed by it.
|
|
37
|
+
*
|
|
38
|
+
* This is not a defect to fix here — suppressing it would mean equalising the
|
|
39
|
+
* marker count across families, which is a second transformation with its own
|
|
40
|
+
* distortion. It is a bound on what the arm may claim, and it belongs beside
|
|
41
|
+
* the claim limit both council seats insisted on: the RAW − STRIPPED delta
|
|
42
|
+
* estimates the effect of THESE registered transformations, never "label
|
|
43
|
+
* leakage" in general.
|
|
44
|
+
*
|
|
45
|
+
* ── Freeze semantics ────────────────────────────────────────────────────────
|
|
46
|
+
* Condition 1 makes the list a floor ONCE AN ARM HAS RUN; it does not forbid
|
|
47
|
+
* authoring it. No arm has run at the commit that lands this file
|
|
48
|
+
* (`collectGuesses` and `scoreRecognition` still have zero production callers),
|
|
49
|
+
* so the list is pinned-and-amendable today and frozen the moment the first
|
|
50
|
+
* rater call is made. `PATTERN_LIST_DIGEST` exists so that transition is
|
|
51
|
+
* observable: a test asserts the live list hashes to the recorded digest, so
|
|
52
|
+
* any edit — before or after the freeze — is a loud diff rather than a silent
|
|
53
|
+
* one.
|
|
54
|
+
*/
|
|
55
|
+
import * as crypto from 'node:crypto';
|
|
56
|
+
|
|
57
|
+
/** The version id of this list. Bump on ANY change to `LEAKAGE_PATTERNS`. */
|
|
58
|
+
export const LEAKAGE_PATTERN_LIST_VERSION = 'leakage-patterns-v1-2026-08-31';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The only name the stripped arm may carry. Verdict condition 6: regexes miss
|
|
62
|
+
* identifiers, so a name promising their absence would overstate the result.
|
|
63
|
+
*/
|
|
64
|
+
export const ARM_LABEL = 'pattern-stripped';
|
|
65
|
+
|
|
66
|
+
/** Names condition 6 forbids for the stripped arm. Asserted, not documented. */
|
|
67
|
+
export const FORBIDDEN_ARM_LABELS: readonly string[] = [
|
|
68
|
+
'identifier-free',
|
|
69
|
+
'anonymised',
|
|
70
|
+
'anonymized',
|
|
71
|
+
'de-identified',
|
|
72
|
+
'deidentified',
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* What a rule targets. The four categories are the verdict's own enumeration —
|
|
77
|
+
* "provider names, vendor names, model ids and first-person
|
|
78
|
+
* self-identification" — and the union is closed so a fifth cannot be added
|
|
79
|
+
* without a version bump.
|
|
80
|
+
*/
|
|
81
|
+
export type PatternCategory =
|
|
82
|
+
| 'provider-name'
|
|
83
|
+
| 'vendor-name'
|
|
84
|
+
| 'model-id'
|
|
85
|
+
| 'first-person-self-identification';
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The placeholder written in place of a match. ONE token per category, and the
|
|
89
|
+
* SAME token whatever family matched — see the residue note in the header.
|
|
90
|
+
*/
|
|
91
|
+
export const CATEGORY_PLACEHOLDER: Readonly<Record<PatternCategory, string>> = {
|
|
92
|
+
'provider-name': '[REDACTED-PROVIDER]',
|
|
93
|
+
'vendor-name': '[REDACTED-VENDOR]',
|
|
94
|
+
'model-id': '[REDACTED-MODEL]',
|
|
95
|
+
'first-person-self-identification': '[REDACTED-SELF-ID]',
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* How likely this rule is to remove material that is NOT an identifier.
|
|
100
|
+
* Recorded per rule because the council's claim limit turns on exactly this:
|
|
101
|
+
* a rule that also removes stylistic material changes what the delta estimates.
|
|
102
|
+
*/
|
|
103
|
+
export type FalsePositiveRisk = 'low' | 'moderate';
|
|
104
|
+
|
|
105
|
+
export interface PatternRule {
|
|
106
|
+
/** Stable id. Appears in the transformation log; never renumbered. */
|
|
107
|
+
readonly id: string;
|
|
108
|
+
readonly category: PatternCategory;
|
|
109
|
+
/** Why this token identifies a family. One clause, checkable by a reader. */
|
|
110
|
+
readonly rationale: string;
|
|
111
|
+
readonly falsePositiveRisk: FalsePositiveRisk;
|
|
112
|
+
/** Source of the pattern. Recompiled per call so lastIndex never leaks. */
|
|
113
|
+
readonly source: string;
|
|
114
|
+
/** Flags. Always contains `g` and `i`; asserted by a test. */
|
|
115
|
+
readonly flags: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The list.
|
|
120
|
+
*
|
|
121
|
+
* ── Two admission rules, applied to every candidate ─────────────────────────
|
|
122
|
+
* (1) A bare token that is also ordinary English is EXCLUDED, and excluded
|
|
123
|
+
* deliberately rather than forgotten: `meta`, `grok`, `bard`, `gemini` (a
|
|
124
|
+
* zodiac sign and a common noun in other registers) and `mistral` (a wind)
|
|
125
|
+
* all appear in prose that has nothing to do with a vendor. Stripping them
|
|
126
|
+
* unconditionally would delete ordinary sentences, and the deletions would
|
|
127
|
+
* not be family-uniform, so the arm would measure the regex rather than the
|
|
128
|
+
* style. Each is admitted ONLY inside an identifying context — a vendor
|
|
129
|
+
* possessive, a model-id shape, or a self-identification frame.
|
|
130
|
+
* (2) A token whose only role is to name a vendor or a model is admitted bare.
|
|
131
|
+
* `anthropic`, `openai`, `claude`, `chatgpt` and `codex` carry no ordinary
|
|
132
|
+
* English sense in this corpus's register.
|
|
133
|
+
*
|
|
134
|
+
* ── Order is part of the pinned artefact ───────────────────────────────────
|
|
135
|
+
* Three tiers, longest frame first, and the reason is span attribution rather
|
|
136
|
+
* than aesthetics: `applyLeakagePatterns` resolves overlap by FIRST-CLAIM-WINS
|
|
137
|
+
* in declaration order, so whichever tier is declared first decides both what
|
|
138
|
+
* is removed and which category the audit log attributes it to.
|
|
139
|
+
*
|
|
140
|
+
* 1. self-identification frames — "I am Claude", "trained by Anthropic".
|
|
141
|
+
* Declared first so the whole frame is one `[REDACTED-SELF-ID]` span. Put
|
|
142
|
+
* last, `product-claude` would claim `Claude` on its own, leaving a
|
|
143
|
+
* dangling "I am " and logging a `provider-name` removal where the thing
|
|
144
|
+
* actually removed was a self-identification.
|
|
145
|
+
* 2. model ids — `claude-sonnet-4-5`, `gpt-4o`. Ahead of the bare vendor
|
|
146
|
+
* tokens they contain, so the id is consumed whole rather than leaving
|
|
147
|
+
* `[REDACTED-PROVIDER]-sonnet-4-5` behind.
|
|
148
|
+
* 3. bare vendor and product tokens — the residue neither tier above framed.
|
|
149
|
+
*
|
|
150
|
+
* A test pins each tier boundary by asserting the span's `patternId`, not only
|
|
151
|
+
* the output text: two orderings can produce the same string and disagree about
|
|
152
|
+
* what the log says was removed, and the log is what condition 2 asks for.
|
|
153
|
+
*/
|
|
154
|
+
export const LEAKAGE_PATTERNS: readonly PatternRule[] = [
|
|
155
|
+
// ── first-person self-identification, FIRST: longest frames ────────────
|
|
156
|
+
{
|
|
157
|
+
id: 'selfid-i-am-assistant',
|
|
158
|
+
category: 'first-person-self-identification',
|
|
159
|
+
rationale: 'Direct self-naming: "I am Claude", "I\u0027m ChatGPT", "This is Gemini".',
|
|
160
|
+
falsePositiveRisk: 'low',
|
|
161
|
+
source: '\\b(?:I\\s+am|I\u0027m|this\\s+is)\\s+(?:an?\\s+)?(?:claude|chatgpt|gpt(?:[\\w.-]*[\\w])?|gemini|llama|codex|grok|mistral)\\b',
|
|
162
|
+
flags: 'gi',
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
id: 'selfid-trained-by',
|
|
166
|
+
category: 'first-person-self-identification',
|
|
167
|
+
rationale: 'Attribution frames: "trained by X", "developed by X", "made by X", "created by X".',
|
|
168
|
+
falsePositiveRisk: 'low',
|
|
169
|
+
source: '\\b(?:trained|developed|built|made|created)\\s+by\\s+(?:anthropic|open\\s?ai|google|meta|mistral|deepseek|xai|cohere)\\b',
|
|
170
|
+
flags: 'gi',
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: 'selfid-as-an-ai-by',
|
|
174
|
+
category: 'first-person-self-identification',
|
|
175
|
+
rationale: 'The "As an AI assistant developed by …" frame, including the truncated "As an AI model from …".',
|
|
176
|
+
falsePositiveRisk: 'low',
|
|
177
|
+
source: '\\bas\\s+an?\\s+(?:AI|language)\\s+(?:model|assistant)\\s+(?:developed|created|trained|made)?\\s*(?:by|from)\\s+[A-Z][\\w.\\s]{0,20}?(?=[,.;:]|\\s+I\\b)',
|
|
178
|
+
flags: 'g',
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: 'selfid-my-training',
|
|
182
|
+
category: 'first-person-self-identification',
|
|
183
|
+
rationale: 'First-person model-self talk that names the house: "my training data at Anthropic".',
|
|
184
|
+
falsePositiveRisk: 'moderate',
|
|
185
|
+
source: '\\bmy\\s+(?:training|creators?|developers?|makers?)\\b(?:[^.\\n]{0,40}?\\b(?:anthropic|open\\s?ai|google|meta)\\b)',
|
|
186
|
+
flags: 'gi',
|
|
187
|
+
},
|
|
188
|
+
// ── model ids, second: they contain the bare vendor tokens below ───────
|
|
189
|
+
{
|
|
190
|
+
id: 'model-claude-family',
|
|
191
|
+
category: 'model-id',
|
|
192
|
+
rationale: 'Anthropic model ids: claude-<line>-<version>, incl. dotted and bare line names.',
|
|
193
|
+
falsePositiveRisk: 'low',
|
|
194
|
+
source: 'claude[-\\s](?:sonnet|opus|haiku|instant)(?:[-\\s.\\d]*\\d)?',
|
|
195
|
+
flags: 'gi',
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
id: 'model-gpt-family',
|
|
199
|
+
category: 'model-id',
|
|
200
|
+
rationale: 'OpenAI model ids: gpt-4, gpt-4o, gpt-5, and the o-series reasoning ids.',
|
|
201
|
+
falsePositiveRisk: 'low',
|
|
202
|
+
source: '\\bgpt[-\\s]?[0-9](?:[\\w.-]*[\\w])?|\\bo[1-9]\\b(?=[-\\s](?:mini|preview|pro))',
|
|
203
|
+
flags: 'gi',
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
id: 'model-codex-family',
|
|
207
|
+
category: 'model-id',
|
|
208
|
+
rationale: 'The codex-* seat ids this repository configures for the openai member.',
|
|
209
|
+
falsePositiveRisk: 'low',
|
|
210
|
+
source: '\\bcodex(?:[-\\s][\\w.]+)?',
|
|
211
|
+
flags: 'gi',
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
id: 'model-gemini-family',
|
|
215
|
+
category: 'model-id',
|
|
216
|
+
rationale: 'Google model ids in id shape only — a bare "gemini" is excluded by admission rule 1.',
|
|
217
|
+
falsePositiveRisk: 'low',
|
|
218
|
+
source: '\\bgemini[-\\s][\\d](?:[\\w.-]*[\\w])?|\\bgemini[-\\s](?:pro|flash|ultra|nano)\\b',
|
|
219
|
+
flags: 'gi',
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
id: 'model-open-weight-family',
|
|
223
|
+
category: 'model-id',
|
|
224
|
+
rationale: 'Open-weight ids that name their house: llama-N, mixtral-*, deepseek-*, qwen-*.',
|
|
225
|
+
falsePositiveRisk: 'low',
|
|
226
|
+
source: '\\b(?:llama|mixtral|mistral|deepseek|qwen|grok)[-\\s]?[\\d](?:[\\w.-]*[\\w])?',
|
|
227
|
+
flags: 'gi',
|
|
228
|
+
},
|
|
229
|
+
// ── vendor and product names admitted bare (admission rule 2) ───────────
|
|
230
|
+
{
|
|
231
|
+
id: 'vendor-anthropic',
|
|
232
|
+
category: 'vendor-name',
|
|
233
|
+
rationale: 'Company name; no ordinary-English sense.',
|
|
234
|
+
falsePositiveRisk: 'low',
|
|
235
|
+
source: '\\banthropic(?:\u0027s)?\\b',
|
|
236
|
+
flags: 'gi',
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
id: 'vendor-openai',
|
|
240
|
+
category: 'vendor-name',
|
|
241
|
+
rationale: 'Company name, hyphenated and spaced spellings included.',
|
|
242
|
+
falsePositiveRisk: 'low',
|
|
243
|
+
source: '\\bopen[-\\s]?ai(?:\u0027s)?\\b',
|
|
244
|
+
flags: 'gi',
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
id: 'vendor-possessive-ambiguous',
|
|
248
|
+
category: 'vendor-name',
|
|
249
|
+
rationale: 'Ambiguous tokens admitted ONLY in a vendor possessive/attributive frame (admission rule 1).',
|
|
250
|
+
falsePositiveRisk: 'moderate',
|
|
251
|
+
source: '\\b(?:google|meta|mistral\\s+ai|deepseek|xai|x\\.ai|cohere)(?:\u0027s)?(?=\\s+(?:model|models|api|team|research|assistant|policy|guidelines|training))',
|
|
252
|
+
flags: 'gi',
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
id: 'product-claude',
|
|
256
|
+
category: 'provider-name',
|
|
257
|
+
rationale: 'Assistant product name; the single strongest bare identifier in this corpus.',
|
|
258
|
+
falsePositiveRisk: 'low',
|
|
259
|
+
source: '\\bclaude(?:\u0027s)?\\b',
|
|
260
|
+
flags: 'gi',
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
id: 'product-chatgpt',
|
|
264
|
+
category: 'provider-name',
|
|
265
|
+
rationale: 'Assistant product name; no ordinary-English sense.',
|
|
266
|
+
falsePositiveRisk: 'low',
|
|
267
|
+
source: '\\bchat[-\\s]?gpt(?:\u0027s)?\\b',
|
|
268
|
+
flags: 'gi',
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
id: 'product-bare-gpt',
|
|
272
|
+
category: 'provider-name',
|
|
273
|
+
rationale: 'Bare "GPT" outside a model id; kept separate so its removals are countable on their own.',
|
|
274
|
+
falsePositiveRisk: 'moderate',
|
|
275
|
+
source: '\\bgpt\\b',
|
|
276
|
+
flags: 'gi',
|
|
277
|
+
},
|
|
278
|
+
];
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* One removal, with enough information to audit it after the fact. Offsets
|
|
282
|
+
* index the ORIGINAL text, so `original.slice(start, end) === matched` — the
|
|
283
|
+
* property a span-level log has to have to be checkable, and a test asserts it.
|
|
284
|
+
*/
|
|
285
|
+
export interface TransformationSpan {
|
|
286
|
+
readonly patternId: string;
|
|
287
|
+
readonly category: PatternCategory;
|
|
288
|
+
/** Inclusive start offset in the ORIGINAL text. */
|
|
289
|
+
readonly start: number;
|
|
290
|
+
/** Exclusive end offset in the ORIGINAL text. */
|
|
291
|
+
readonly end: number;
|
|
292
|
+
readonly matched: string;
|
|
293
|
+
readonly replacement: string;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export interface StripResult {
|
|
297
|
+
/** The transformed body. Never labelled `identifier-free` — see `ARM_LABEL`. */
|
|
298
|
+
readonly text: string;
|
|
299
|
+
/**
|
|
300
|
+
* Every removal, ascending by `start` and NON-OVERLAPPING. Empty means the
|
|
301
|
+
* body carried no registered identifier — which is a RESULT, not a failure.
|
|
302
|
+
*/
|
|
303
|
+
readonly spans: readonly TransformationSpan[];
|
|
304
|
+
/** Echoed so a persisted log is self-describing without its writer. */
|
|
305
|
+
readonly patternListVersion: string;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Ascending, non-overlapping claim over the original text. */
|
|
309
|
+
interface Claim {
|
|
310
|
+
readonly start: number;
|
|
311
|
+
readonly end: number;
|
|
312
|
+
readonly rule: PatternRule;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Apply the pinned list to one body.
|
|
317
|
+
*
|
|
318
|
+
* Rules are applied in declaration order and a later rule may not claim a
|
|
319
|
+
* region an earlier rule already claimed — which is how `claude-sonnet-4-5`
|
|
320
|
+
* survives as ONE model-id span instead of being cut into a vendor token plus
|
|
321
|
+
* debris. Overlap is resolved by first-claim-wins rather than by longest-match,
|
|
322
|
+
* because the declaration order is itself part of the pinned list and a
|
|
323
|
+
* length heuristic would make the outcome depend on the input.
|
|
324
|
+
*/
|
|
325
|
+
export function applyLeakagePatterns(original: string): StripResult {
|
|
326
|
+
const claims: Claim[] = [];
|
|
327
|
+
|
|
328
|
+
for (const rule of LEAKAGE_PATTERNS) {
|
|
329
|
+
// Recompiled per rule per call so a shared `g` regex cannot carry
|
|
330
|
+
// `lastIndex` across calls.
|
|
331
|
+
//
|
|
332
|
+
// HONEST GAP, recorded because the sabotage run showed it: hoisting
|
|
333
|
+
// this into a module-level cache does NOT turn any test red. The loop
|
|
334
|
+
// below always runs to `exec` returning null, which resets `lastIndex`
|
|
335
|
+
// to 0 on the way out, so today a cached regex is indistinguishable
|
|
336
|
+
// from a fresh one. The recompile is defensive against a future early
|
|
337
|
+
// exit from this loop, and it is UNPROVEN rather than proven.
|
|
338
|
+
const re = new RegExp(rule.source, rule.flags);
|
|
339
|
+
let match: RegExpExecArray | null = re.exec(original);
|
|
340
|
+
while (match !== null) {
|
|
341
|
+
const start = match.index;
|
|
342
|
+
const end = start + match[0].length;
|
|
343
|
+
if (match[0].length === 0) {
|
|
344
|
+
re.lastIndex += 1;
|
|
345
|
+
match = re.exec(original);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const overlaps = claims.some((c) => start < c.end && c.start < end);
|
|
349
|
+
if (!overlaps) claims.push({ start, end, rule });
|
|
350
|
+
match = re.exec(original);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
claims.sort((a, b) => (a.start === b.start ? a.end - b.end : a.start - b.start));
|
|
355
|
+
|
|
356
|
+
const spans: TransformationSpan[] = [];
|
|
357
|
+
let out = '';
|
|
358
|
+
let cursor = 0;
|
|
359
|
+
for (const claim of claims) {
|
|
360
|
+
const replacement = CATEGORY_PLACEHOLDER[claim.rule.category];
|
|
361
|
+
out += original.slice(cursor, claim.start) + replacement;
|
|
362
|
+
spans.push({
|
|
363
|
+
patternId: claim.rule.id,
|
|
364
|
+
category: claim.rule.category,
|
|
365
|
+
start: claim.start,
|
|
366
|
+
end: claim.end,
|
|
367
|
+
matched: original.slice(claim.start, claim.end),
|
|
368
|
+
replacement,
|
|
369
|
+
});
|
|
370
|
+
cursor = claim.end;
|
|
371
|
+
}
|
|
372
|
+
out += original.slice(cursor);
|
|
373
|
+
|
|
374
|
+
return { text: out, spans, patternListVersion: LEAKAGE_PATTERN_LIST_VERSION };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** One item's transformation log — what condition 2 asks be retained. */
|
|
378
|
+
export interface ItemTransformationLog {
|
|
379
|
+
readonly itemId: string;
|
|
380
|
+
readonly spans: readonly TransformationSpan[];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Build an `AssembleOptions.anonymise` seam that ALSO records a span log.
|
|
385
|
+
*
|
|
386
|
+
* The seam's type is `(text: string) => string` and cannot return a log, so the
|
|
387
|
+
* log is accumulated into the array the caller owns. Ordering is the assembler's
|
|
388
|
+
* item order; ids are attached by `attachLogIds` because the seam is called
|
|
389
|
+
* before the item id exists. Keeping the two steps apart is deliberate: a seam
|
|
390
|
+
* that invented an id would make the log's ids disagree with the corpus's.
|
|
391
|
+
*/
|
|
392
|
+
export function makeRecordingAnonymiser(sink: TransformationSpan[][]): (text: string) => string {
|
|
393
|
+
return (text: string): string => {
|
|
394
|
+
const result = applyLeakagePatterns(text);
|
|
395
|
+
sink.push([...result.spans]);
|
|
396
|
+
return result.text;
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Zip a recorded span sink onto the ids the assembler produced, in order. */
|
|
401
|
+
export function attachLogIds(
|
|
402
|
+
sink: readonly (readonly TransformationSpan[])[],
|
|
403
|
+
itemIds: readonly string[],
|
|
404
|
+
): readonly ItemTransformationLog[] {
|
|
405
|
+
if (sink.length !== itemIds.length) {
|
|
406
|
+
throw new Error(
|
|
407
|
+
`transformation log length ${sink.length} does not match item count ${itemIds.length}; ` +
|
|
408
|
+
'the log cannot be attributed and must not be published',
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
return itemIds.map((itemId, i) => ({ itemId, spans: [...(sink[i] ?? [])] }));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Canonical serialisation of the list — the input to `PATTERN_LIST_DIGEST`.
|
|
416
|
+
* Every field a rule carries is included, so a rationale edit is as visible as
|
|
417
|
+
* a regex edit: the freeze condition 1 states is about the registered artefact,
|
|
418
|
+
* not only about its matching behaviour.
|
|
419
|
+
*/
|
|
420
|
+
export function canonicalPatternListSource(): string {
|
|
421
|
+
return JSON.stringify(
|
|
422
|
+
{
|
|
423
|
+
version: LEAKAGE_PATTERN_LIST_VERSION,
|
|
424
|
+
armLabel: ARM_LABEL,
|
|
425
|
+
placeholders: CATEGORY_PLACEHOLDER,
|
|
426
|
+
rules: LEAKAGE_PATTERNS.map((r) => ({
|
|
427
|
+
id: r.id,
|
|
428
|
+
category: r.category,
|
|
429
|
+
rationale: r.rationale,
|
|
430
|
+
falsePositiveRisk: r.falsePositiveRisk,
|
|
431
|
+
source: r.source,
|
|
432
|
+
flags: r.flags,
|
|
433
|
+
})),
|
|
434
|
+
},
|
|
435
|
+
null,
|
|
436
|
+
0,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** sha256 of `canonicalPatternListSource()`. */
|
|
441
|
+
export function patternListDigest(): string {
|
|
442
|
+
return crypto.createHash('sha256').update(canonicalPatternListSource(), 'utf8').digest('hex');
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* The recorded digest of the pinned list.
|
|
447
|
+
*
|
|
448
|
+
* A test asserts `patternListDigest() === PATTERN_LIST_DIGEST`, so editing a
|
|
449
|
+
* rule without editing this constant is a RED test rather than a quiet change.
|
|
450
|
+
* That is the whole enforcement of condition 1 available before a run exists:
|
|
451
|
+
* it cannot stop an edit, and it makes one impossible to make silently.
|
|
452
|
+
*/
|
|
453
|
+
export const PATTERN_LIST_DIGEST = '10045caaec23a1bd7053a629f9e8043cb6a3066b44e349ceba6890b058976da6';
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Majority laundering — the permanent fixture gate for step 5.3.
|
|
3
|
+
*
|
|
4
|
+
* `road-to-inbox-harvest-2026-08-e-council-topology-evidence` step 5.3:
|
|
5
|
+
* *"seed one correct minority against several plausible-but-wrong majority
|
|
6
|
+
* answers; the synthesizer must justify accepting or rejecting the minority"*,
|
|
7
|
+
* verified by *"the fixture is permanent, and a synthesizer that silently drops
|
|
8
|
+
* the minority fails it"*.
|
|
9
|
+
*
|
|
10
|
+
* ## What this decides, and what it deliberately does not
|
|
11
|
+
*
|
|
12
|
+
* It decides the SHAPE of the disposition, not its correctness. Three questions,
|
|
13
|
+
* all answerable from text:
|
|
14
|
+
*
|
|
15
|
+
* 1. Does the synthesis NAME the minority position (an anchor phrase)?
|
|
16
|
+
* 2. Does it state a DISPOSITION — accept or reject?
|
|
17
|
+
* 3. Does it attach a REASON to that disposition?
|
|
18
|
+
*
|
|
19
|
+
* Accepting and rejecting are BOTH passes. The failure 5.3 names is the silent
|
|
20
|
+
* drop: the majority answer reported as the verdict with the minority never
|
|
21
|
+
* appearing, so a reader cannot tell whether it was weighed or never seen.
|
|
22
|
+
*
|
|
23
|
+
* It does NOT decide whether the synthesizer reached the right verdict. That
|
|
24
|
+
* needs the benchmark, which is gated behind `blocker: phase-2-benchmark-cost`,
|
|
25
|
+
* and pretending otherwise here would be the overstatement this file's own
|
|
26
|
+
* § Prevented items exists to catch. A synthesis that rejects the correct
|
|
27
|
+
* minority with a stated reason PASSES this gate and is wrong — which is the
|
|
28
|
+
* honest scope, said out loud rather than implied.
|
|
29
|
+
*
|
|
30
|
+
* Pure, offline, no model call: the "synthesizers" its tests exercise are
|
|
31
|
+
* scripted strings.
|
|
32
|
+
*/
|
|
33
|
+
import * as fs from 'node:fs';
|
|
34
|
+
|
|
35
|
+
/** The minority half of a laundering fixture. */
|
|
36
|
+
export interface MinorityPosition {
|
|
37
|
+
readonly member: string;
|
|
38
|
+
readonly answer: string;
|
|
39
|
+
/** Distinctive phrases; naming none of them is not engaging with the minority. */
|
|
40
|
+
readonly anchors: readonly string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One plausible-but-wrong majority answer. */
|
|
44
|
+
export interface MajorityPosition {
|
|
45
|
+
readonly member: string;
|
|
46
|
+
readonly answer: string;
|
|
47
|
+
readonly why_wrong: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface LaunderingFixture {
|
|
51
|
+
readonly id: string;
|
|
52
|
+
readonly permanent: boolean;
|
|
53
|
+
readonly question: string;
|
|
54
|
+
/** Which side is correct. `'minority'` is the whole point of the fixture. */
|
|
55
|
+
readonly ground_truth: 'minority' | 'majority';
|
|
56
|
+
readonly minority: MinorityPosition;
|
|
57
|
+
readonly majority: readonly MajorityPosition[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Why a synthesis failed. Empty means it stated a disposition with a reason. */
|
|
61
|
+
export type RetentionFailure =
|
|
62
|
+
/** No anchor appears — the minority is not in the text at all. */
|
|
63
|
+
| 'minority-silently-dropped'
|
|
64
|
+
/** The minority is named but neither accepted nor rejected. */
|
|
65
|
+
| 'no-disposition'
|
|
66
|
+
/** A disposition is stated with nothing attached to it. */
|
|
67
|
+
| 'unjustified-disposition';
|
|
68
|
+
|
|
69
|
+
export interface RetentionVerdict {
|
|
70
|
+
readonly passed: boolean;
|
|
71
|
+
readonly failures: readonly RetentionFailure[];
|
|
72
|
+
/** Anchors actually found, for the failure message. */
|
|
73
|
+
readonly anchorsFound: readonly string[];
|
|
74
|
+
/** `'accept' | 'reject' | null` — what the text says was done with the minority. */
|
|
75
|
+
readonly disposition: 'accept' | 'reject' | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Disposition vocabulary.
|
|
80
|
+
*
|
|
81
|
+
* Deliberately small and explicit rather than a general parser: a wide matcher
|
|
82
|
+
* would pass a synthesis that merely uses the word "however", which is the
|
|
83
|
+
* hedge a laundering synthesis produces most often.
|
|
84
|
+
*/
|
|
85
|
+
const ACCEPT_RE =
|
|
86
|
+
/\b(accept(?:ed|ing|s)?|adopt(?:ed|ing|s)?|the minority is correct|minority is right|side with the minority|upheld)\b/i;
|
|
87
|
+
const REJECT_RE =
|
|
88
|
+
/\b(reject(?:ed|ing|s)?|set aside|discount(?:ed|ing|s)?|overrul(?:e|ed|ing)|not persuasive|declin(?:e|ed|ing) to adopt|dismiss(?:ed|ing|es)?)\b/i;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Justification markers. A disposition with no reason attached is the second
|
|
92
|
+
* failure mode 5.3 implies but does not spell out: *"justify accepting or
|
|
93
|
+
* rejecting"* is two obligations, and a bare "the minority is rejected" clears
|
|
94
|
+
* only the first.
|
|
95
|
+
*/
|
|
96
|
+
const REASON_RE = /\b(because|since|on the grounds that|the reason is|as it|reasoning:|rationale:|given that)\b/i;
|
|
97
|
+
|
|
98
|
+
/** Tolerant containment: `eager load` matches `eager loading`, case-insensitively. */
|
|
99
|
+
export function anchorPresent(text: string, anchor: string): boolean {
|
|
100
|
+
const words = anchor
|
|
101
|
+
.trim()
|
|
102
|
+
.split(/\s+/)
|
|
103
|
+
.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
104
|
+
// `\w*` after each word absorbs an inflection; `\s+` between them tolerates
|
|
105
|
+
// whatever whitespace the renderer emitted.
|
|
106
|
+
return new RegExp(`\\b${words.join('\\w*\\s+')}\\w*`, 'i').test(text);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Audit one synthesis against one fixture. */
|
|
110
|
+
export function auditMinorityRetention(synthesis: string, fixture: LaunderingFixture): RetentionVerdict {
|
|
111
|
+
const anchorsFound = fixture.minority.anchors.filter((a) => anchorPresent(synthesis, a));
|
|
112
|
+
const failures: RetentionFailure[] = [];
|
|
113
|
+
|
|
114
|
+
if (anchorsFound.length === 0) {
|
|
115
|
+
// Nothing further is decidable: with the minority absent there is no
|
|
116
|
+
// disposition to grade, and reporting three failures for one defect
|
|
117
|
+
// would put noise in a message whose value is that it is short.
|
|
118
|
+
return {
|
|
119
|
+
passed: false,
|
|
120
|
+
failures: ['minority-silently-dropped'],
|
|
121
|
+
anchorsFound,
|
|
122
|
+
disposition: null,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const accepted = ACCEPT_RE.test(synthesis);
|
|
127
|
+
const rejected = REJECT_RE.test(synthesis);
|
|
128
|
+
const disposition: 'accept' | 'reject' | null = accepted ? 'accept' : rejected ? 'reject' : null;
|
|
129
|
+
|
|
130
|
+
if (disposition === null) failures.push('no-disposition');
|
|
131
|
+
else if (!REASON_RE.test(synthesis)) failures.push('unjustified-disposition');
|
|
132
|
+
|
|
133
|
+
return { passed: failures.length === 0, failures, anchorsFound, disposition };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Human-readable failure text. */
|
|
137
|
+
export function renderRetentionVerdict(v: RetentionVerdict, fixture: LaunderingFixture): string {
|
|
138
|
+
if (v.passed) {
|
|
139
|
+
return (
|
|
140
|
+
`synthesis PASSES ${fixture.id}: minority (${fixture.minority.member}) ` +
|
|
141
|
+
`${v.disposition === null ? 'addressed' : v.disposition + 'ed'} with a stated reason ` +
|
|
142
|
+
`[anchors: ${v.anchorsFound.join(', ')}]\n`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const why: Record<RetentionFailure, string> = {
|
|
146
|
+
'minority-silently-dropped':
|
|
147
|
+
`no anchor of the minority position appears (${fixture.minority.anchors.join(' | ')}) — ` +
|
|
148
|
+
'the majority answer was reported as the verdict and the minority never appears',
|
|
149
|
+
'no-disposition': 'the minority is named but neither accepted nor rejected',
|
|
150
|
+
'unjustified-disposition': 'a disposition is stated with no reason attached to it',
|
|
151
|
+
};
|
|
152
|
+
return (
|
|
153
|
+
`synthesis FAILS ${fixture.id}:\n` +
|
|
154
|
+
v.failures.map((f) => ` - ${f}: ${why[f]}\n`).join('') +
|
|
155
|
+
` ground truth: the ${fixture.ground_truth} is correct\n`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Load and validate the frozen fixture. Throws on a fixture that is not permanent. */
|
|
160
|
+
export function loadLaunderingFixture(absPath: string): LaunderingFixture {
|
|
161
|
+
const raw = JSON.parse(fs.readFileSync(absPath, 'utf8')) as Record<string, unknown>;
|
|
162
|
+
if (raw['permanent'] !== true) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`${absPath}: step 5.3 requires a PERMANENT fixture; this file does not declare permanent: true`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
if (raw['ground_truth'] !== 'minority' && raw['ground_truth'] !== 'majority') {
|
|
168
|
+
throw new Error(`${absPath}: ground_truth must be "minority" or "majority"`);
|
|
169
|
+
}
|
|
170
|
+
return raw as unknown as LaunderingFixture;
|
|
171
|
+
}
|