@event4u/agent-config 9.1.0 → 9.2.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 (54) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +22 -251
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/implement-ticket.md +2 -0
  5. package/dist/agent-src/commands/review/changes.md +22 -0
  6. package/dist/agent-src/contexts/execution/subagent-modes-detail.md +41 -6
  7. package/dist/agent-src/contexts/execution/verify-budget.md +15 -0
  8. package/dist/agent-src/rules/cross-source-consistency.md +92 -0
  9. package/dist/agent-src/rules/delegation-policy.md +1 -1
  10. package/dist/agent-src/skills/estimate-ticket/SKILL.md +2 -0
  11. package/dist/agent-src/skills/feature-planning/SKILL.md +1 -1
  12. package/dist/agent-src/skills/po-discovery/SKILL.md +2 -0
  13. package/dist/agent-src/skills/refine-ticket/SKILL.md +7 -0
  14. package/dist/agent-src/skills/roadmap-writing/SKILL.md +14 -0
  15. package/dist/agent-src/skills/subagent-orchestration/SKILL.md +26 -5
  16. package/dist/agent-src/skills/subagent-orchestration/evals/evals.json +18 -0
  17. package/dist/agent-src/skills/subagent-orchestration/prompts/README.md +3 -2
  18. package/dist/agent-src/skills/subagent-orchestration/prompts/adversarial-verification-council.md +79 -0
  19. package/dist/agent-src/skills/subagent-orchestration/schemas/adversarial-findings.json +56 -0
  20. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  21. package/dist/discovery/deprecation-report.md +1 -1
  22. package/dist/discovery/discovery-manifest.json +42 -19
  23. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  24. package/dist/discovery/discovery-manifest.summary.md +4 -4
  25. package/dist/discovery/orphan-report.md +1 -1
  26. package/dist/discovery/packs.json +10 -8
  27. package/dist/discovery/trust-report.md +3 -3
  28. package/dist/discovery/workspaces.json +8 -6
  29. package/dist/install/install.mjs +9 -0
  30. package/dist/mcp/registry-manifest.json +2 -2
  31. package/dist/router.json +1 -1
  32. package/dist/server/schemas/settings.js +5 -0
  33. package/dist/server/schemas/settings.js.map +1 -1
  34. package/docs/CLAIMS.md +8 -1
  35. package/docs/architecture.md +2 -2
  36. package/docs/archive/CHANGELOG-pre-9.2.0.md +276 -0
  37. package/docs/benchmark.md +24 -0
  38. package/docs/decisions/ADR-122-adversarial-verification-council.md +136 -0
  39. package/docs/decisions/INDEX.md +1 -0
  40. package/docs/design/adversarial-council-eval.md +106 -0
  41. package/docs/getting-started-by-role.md +1 -1
  42. package/docs/getting-started.md +1 -1
  43. package/docs/governance-advantage.md +1 -1
  44. package/docs/guidelines/agent-infra/cross-source-consistency-mechanics.md +109 -0
  45. package/docs/proof.md +2 -2
  46. package/package.json +1 -1
  47. package/src/config/agent-settings.template.yml +42 -0
  48. package/src/scripts/_lib/adversarial_council_gate.test.ts +112 -0
  49. package/src/scripts/_lib/adversarial_council_gate.ts +98 -0
  50. package/src/scripts/_lib/adversarial_reconcile.test.ts +197 -0
  51. package/src/scripts/_lib/adversarial_reconcile.ts +187 -0
  52. package/src/scripts/_lib/verify_budget.ts +4 -1
  53. package/src/scripts/schemas/agent-settings.schema.json +11 -0
  54. package/src/server/schemas/settings.ts +9 -0
@@ -0,0 +1,109 @@
1
+ # Cross-Source Consistency — Mechanics
2
+
3
+ > The discrepancy taxonomy, the scan procedure, the confidence-tiered noise control, worked examples, and the precedence table for the `cross-source-consistency` rule
4
+
5
+ _Companion to `src/rules/cross-source-consistency.md` (P4 pattern of `road-to-kernel-and-router.md`). The Iron Law, the fire/not-fire scope, and the subordination clauses stay in the rule; this file carries the taxonomy detail, the scan procedure, and the examples._
6
+
7
+ ## The discrepancy taxonomy
8
+
9
+ | # | Class | Definition | Canonical example |
10
+ |---|---|---|---|
11
+ | a | **text ↔ image** | The ticket/spec text contradicts an attached mockup, screenshot, or diagram. | Text: "show birthdays **today**". Mockup: a birthday from two days ago. |
12
+ | b | **silent-but-needed** | The spec is silent on a behavior that is clearly required for the feature to be correct. | Spec: weekend-shifting only. Clearly also needed: public-holiday shifting — but never specified. |
13
+ | c | **spec ↔ codebase/reality** | The spec contradicts what the code, DB, API, or a real source actually does. | Spec: "the endpoint returns `dueDate`". The DTO has no such field. |
14
+ | d | **intra-ticket** | Acceptance criteria, description, and comments contradict each other. | Description: real-time. AC: "nightly batch is fine." |
15
+
16
+ Type (a) and (d) are **contradictions between present sources** — the agent
17
+ must not pick one silently. Type (b) is a **scope decision** — the inferred
18
+ behavior may be right, but adding it is a scope expansion the user owns. Type
19
+ (c) routes the *verification* to `source-discovery` (read the real source); this
20
+ rule only owns *surfacing the conflict and asking*.
21
+
22
+ ## The scan procedure
23
+
24
+ Run this whenever planning, refining, estimating, roadmapping, or implementing
25
+ from a ticket/spec that carries a second source:
26
+
27
+ 1. **Enumerate the sources.** Ticket text, every attachment (mockup / screenshot
28
+ / diagram), the AC block, description, comments, and — for implementation —
29
+ the relevant codebase/DB/API surface. If an attachment cannot be read,
30
+ say so; do not assume it agrees.
31
+ 2. **Compare pairwise for type (a), (c), (d).** For each pair, ask: do they state
32
+ the same thing about the same requirement? A mismatch is a discrepancy.
33
+ 3. **Scan for type (b) — silence.** List the behaviors the feature obviously
34
+ needs but the spec never mentions (weekend/holiday shifts, empty states,
35
+ error paths, permission edges, pagination, timezone). Each unstated-but-needed
36
+ behavior is a candidate to surface — not to implement.
37
+ 4. **Classify confidence.** High = the sources plainly disagree, or the missing
38
+ behavior is clearly load-bearing. Low = the "conflict" is a plausible
39
+ re-reading, or the missing behavior may be intentionally out of scope.
40
+ 5. **Decide per the setting** (below), then **batch** every surviving discrepancy
41
+ into ONE question folded into this turn's single `ask-when-uncertain` prompt.
42
+
43
+ ## Confidence-tiered noise control (`consistency.cross_source`)
44
+
45
+ The rule fires only under a real trade-off (it must pass the
46
+ `no-cheap-questions` Pre-Send Self-Check). The setting tunes how aggressively:
47
+
48
+ | Value | High-confidence discrepancy | Low-confidence discrepancy |
49
+ |---|---|---|
50
+ | `on` (default) | Surface + ask before proceeding. | Surface + ask (batched into the same question). |
51
+ | `auto` | Surface + ask. | State as an explicit assumption and proceed (no question). |
52
+ | `off` | No cross-source checking — legacy behavior. | No cross-source checking. |
53
+
54
+ Batching is the anti-noise mechanism: N discrepancies for one artefact become
55
+ one numbered-options block, never N prompts. Plain vagueness (a single unclear
56
+ source, no second source to compare) is **not** a discrepancy — it stays with
57
+ `ask-when-uncertain` so the two disciplines never double-fire on the same input.
58
+
59
+ ## Worked examples
60
+
61
+ ### Example 1 — text ↔ image (type a)
62
+
63
+ Ticket: *"The tile shows employees who have their birthday **today**."* Attached
64
+ mockup: a card with a birthday dated **two days ago**.
65
+
66
+ - Wrong: implement "today" (or implement "recent, like the mockup") and move on.
67
+ Both are silent guesses on a real contradiction — a violation even if the
68
+ chosen reading turns out correct.
69
+ - Right: *"The ticket text says 'today' but the mockup shows a birthday from two
70
+ days ago. Which is authoritative — (1) strictly today, (2) a recent window as
71
+ the mockup implies, (3) something else?"* Then implement the confirmed reading;
72
+ `design-fidelity` governs building it 1:1 afterwards.
73
+
74
+ ### Example 2 — silent-but-needed (type b)
75
+
76
+ Ticket update: *"If a birthday falls on the weekend, show it on the next
77
+ workday."* The spec never mentions public holidays.
78
+
79
+ - Wrong: also shift birthdays that fall on a public holiday, because it is
80
+ "obviously" consistent with the weekend rule — a silent scope expansion.
81
+ - Right: *"The spec covers weekend-shifting but is silent on public holidays.
82
+ Should birthdays on a public holiday also shift to the next workday? (1) yes,
83
+ same as weekends, (2) no, holidays are out of scope, (3) only regional
84
+ holidays."* Obvious-to-the-agent ≠ in-scope; `scope-control` owns the
85
+ expansion permission.
86
+
87
+ ## Precedence & subordination
88
+
89
+ | Neighbour rule | Relationship |
90
+ |---|---|
91
+ | `ask-when-uncertain` | **Parent ask discipline.** This rule folds its discrepancy into that rule's single question. `ask-when-uncertain` fires on *missing* info; this adds *conflicting present* sources. Never a second question block. |
92
+ | `scope-control` | **Permission authority** for the type-(b) silent-scope-expansion. This rule surfaces the expansion; `scope-control` gates it. |
93
+ | `design-fidelity` | **Fires after** this rule. This rule resolves a text↔image conflict at decision time; `design-fidelity` then enforces build-time 1:1 fidelity to the resolved design. |
94
+ | `source-discovery` / `source-discovery-gate` | **Owns verification** for type (c). This rule surfaces the spec↔code conflict; `source-discovery` provides the evidence procedure. |
95
+ | `no-cheap-questions` | **Floor.** A discrepancy ask must carry a real trade-off; batch, never nag. |
96
+ | `active-remediation` | **Sibling shape.** Its never-silently-ignore ladder is the response model this rule applies to source discrepancies rather than code issues. |
97
+
98
+ ## Failure modes
99
+
100
+ - **Silent-correct-guess.** Picking the right reading of a contradiction without
101
+ asking. Correct output, wrong process — still a violation.
102
+ - **Double-firing.** Emitting a discrepancy question AND a separate
103
+ `ask-when-uncertain` / `improve-before-implement` question about the same
104
+ underlying uncertainty. Fold into one turn's one question.
105
+ - **Noise creep.** Surfacing plausible re-readings as if they were hard
106
+ contradictions. Use the confidence tier; `auto` states low-confidence as an
107
+ assumption instead of asking.
108
+ - **Silent scope expansion.** Implementing an inferred-but-unspecified behavior
109
+ because it seems obvious. It is a scope decision the user owns.
package/docs/proof.md CHANGED
@@ -41,7 +41,7 @@ evidence pointer, or `task check-claims` fails the build.
41
41
  | The whole layer is compiled into host agents with zero runtime daemon. | qual | `docs/contracts/no-runtime-boundary.md#file-first, no-runtime suite` | ✅ |
42
42
  | On a 12-fixture option-decision corpus (3 arms × 2 providers, blind rubric judge claude-opus-4-8, pre-registered hypotheses), famous-figure identity framing added nothing beyond the underlying method text (method 5.04 vs figure 4.88, Δ=0.17, sign-test p=0.607), and provider diversity moved judged quality ~15× more than persona identity (provider Δ=2.58 vs identity Δ=0.17); the whole persona layer lifted only +0.08 over bare prompts. Honest null — persona panel-mode stays CUT, evidence-closed. | quant | `internal/bench/reports/persona-placebo.json#honest-null` | ✅ |
43
43
  | On the live end-to-end retrieval benchmark (9 tasks × 3 arms × 3 seeds on claude-haiku, fixture store with keyword-overlapping confusers), the memory retrieval substrate scored precision@5 = 100% (9/9) with 100% poisoned-entry rejection, and the retrieval-on arm passed 27/27 model-scored tasks vs 2/27 with retrieval off and 4/27 with a placebo injection. Known limit stays published: mean tie-set 4.1 means top-k ties break by store order, not relevance (the ADR-116/FTS5 signal). | quant | `internal/bench/reports/second-brain-retrieval.json#retrieval-on` | ✅ |
44
- | 104 governed rules. | quant | `src/scripts/check_artefact_count_messaging.ts#Artefact-count messaging gate` | ✅ |
44
+ | 105 governed rules. | quant | `src/scripts/check_artefact_count_messaging.ts#Artefact-count messaging gate` | ✅ |
45
45
  | On a deterministic multi-session recall corpus, the memory substrate produces a measured, placebo-controlled recall lift — memory-on 27/27 vs no-memory 10/27 and vs equal-byte placebo 9/27 (claude-haiku-4-5, n=9 tasks x 3 seeds, sign test p=0.031 for BOTH pairings). Scoped honestly: this is the context-value upper bound (perfect retrieval on a one-fact-per-task corpus), not retrieval precision under a large store. | quant | `internal/bench/reports/second-brain-delta.json` | ✅ |
46
46
  | Removing the perfect-retrieval assumption, the substrate's REAL keyword retrieval recalls the needed decision into the top-5 under keyword-overlapping confusers (precision@5 9/9) and the model disambiguates it from the co-injected confusers — retrieval-on 27/27 vs no-memory 5/27 and vs equal-count placebo 5/27 (claude-haiku-4-5, 9 tasks x 3 seeds, sign test p=0.008 both). Named limit: retrieval RECALLS but does not RANK (mean tie-set 3.3, ties broken by store order, not relevance) — the discrimination gap that motivates the SQLite-FTS5 activation path (ADR-116) at larger scale. | quant | `internal/bench/reports/second-brain-retrieval.json` | ✅ |
47
47
  | Every artifact the package ships — source AND the condensed projection that reaches consumers — is machine-scanned in CI for hidden-Unicode, mixed-script-confusable, and instruction-smuggling payloads (the rules-file-backdoor class); a finding blocks the release before `npm publish`, not just the merge. | qual | `.github/workflows/publish-npm.yml#lint_agent_security` | ✅ |
@@ -56,7 +56,7 @@ guidelines, personas) are **generated from source and CI-drift-checked**:
56
56
  fails the build on any count-shaped prose mention that drifts from the
57
57
  source count — or on two different numbers for the same artefact kind.
58
58
 
59
- We also publish our **debt**: 4 claim(s) are logged as
59
+ We also publish our **debt**: 5 claim(s) are logged as
60
60
  `unbacked` inventory in the ledger — not yet bound, and therefore not
61
61
  allowed to carry a marker in public prose. Hiding them would be the
62
62
  opposite of the point.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
- "version": "9.1.0",
3
+ "version": "9.2.0",
4
4
  "description": "Universal AI Agent OS — audited skills, governance rules, commands, and templates for AI coding tools (Claude Code, Cursor, Windsurf, Copilot).",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -518,6 +518,34 @@ design:
518
518
  # autonomy setting, roadmap, or standing instruction lifts it).
519
519
  fidelity_mode: strict
520
520
 
521
+ # --- Cross-source consistency ---
522
+ #
523
+ # When the agent plans, refines, roadmaps, or implements from multiple
524
+ # sources — ticket text, an attached image / mockup / screenshot, the spec,
525
+ # the codebase — it actively checks them AGAINST each other and STOPS to ask
526
+ # before proceeding on a detected discrepancy, instead of silently guessing
527
+ # (even a correct guess). Consumed by the `cross-source-consistency` rule.
528
+ # Catches: (a) ticket text vs an attached image/mockup, (b) a spec silent on
529
+ # a clearly-needed behavior (weekend/holiday shifting, empty/error states),
530
+ # (c) spec vs the codebase, (d) intra-ticket contradiction (AC vs description).
531
+ #
532
+ # The discipline is deliberately subordinate to `ask-when-uncertain` (it folds
533
+ # a detected discrepancy into the same turn's single question — never a second
534
+ # prompt) and defers to `scope-control` for the permission to expand scope.
535
+ consistency:
536
+ # on | auto | off (default: on)
537
+ #
538
+ # on = default. Detect discrepancies across sources and surface ONE
539
+ # numbered-options question before proceeding. A silent guess on a
540
+ # real contradiction is a rule violation, even when the guess is
541
+ # correct. An inferred-but-unspecified behavior is a scope expansion:
542
+ # ask before implementing it.
543
+ # auto = detect + surface only HIGH-confidence contradictions; low-confidence
544
+ # ones are stated as an assumption and proceed (noise control).
545
+ # off = no cross-source checking (legacy: the agent may proceed on its own
546
+ # reading of whichever source it happened to read).
547
+ cross_source: "on"
548
+
521
549
  code_style:
522
550
  # minimal | full (default: minimal)
523
551
  #
@@ -604,6 +632,20 @@ subagents:
604
632
  # Integer, default 3. Set to 1 to serialize. Hard cap enforced by runtime.
605
633
  max_parallel: 3
606
634
 
635
+ # Opt-in adversarial-verification-council (subagent-orchestration Mode 9,
636
+ # ADR-122): off | ask | on
637
+ #
638
+ # off (default) = never runs. ask = offer it on an explicit high-risk change.
639
+ # on = auto-run on high-risk changes (security, tenant, migration, public API).
640
+ #
641
+ # A panel of distinct-model skeptics red-teams a real, already-verified change
642
+ # for defect FINDING coverage (not a go/no-go decision) and returns an advisory
643
+ # findings-by-severity verdict. ADVISORY ONLY — it NEVER auto-gates the change
644
+ # (Hard Floor). The registered claim + high-risk tier use cross-vendor skeptics.
645
+ # Stays default-off until the adversarial-council-finding-coverage claim is
646
+ # backed by the two-stage residual-detection benchmark (prove-or-drop).
647
+ adversarial_council: off
648
+
607
649
  # --- Git worktrees ---
608
650
  #
609
651
  # Controls how the `using-git-worktrees` skill and `subagent-orchestration`
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Adversarial council prove-or-drop gate — coverage (Phase 4).
3
+ *
4
+ * Locks the pre-registered dual-threshold semantics: BOTH relative (>=+25%) and
5
+ * absolute (>=+8pp) residual-recall lift, AND panel FP not worse than baseline
6
+ * within noise. Guards the base-rate-compression case the council flagged
7
+ * (high single-judge baseline → relative easy but absolute must still clear).
8
+ */
9
+ import { describe, expect, it } from 'vitest';
10
+
11
+ import {
12
+ ABSOLUTE_LIFT_THRESHOLD_PP,
13
+ RELATIVE_LIFT_THRESHOLD,
14
+ evaluateCouncilBench,
15
+ } from './adversarial_council_gate.js';
16
+
17
+ const CLEAN_FP = { single_judge_fp_rate: 0.05, panel_fp_rate: 0.05, fp_noise_margin: 0.03 };
18
+
19
+ describe('evaluateCouncilBench', () => {
20
+ it('exposes the locked thresholds', () => {
21
+ expect(RELATIVE_LIFT_THRESHOLD).toBe(0.25);
22
+ expect(ABSOLUTE_LIFT_THRESHOLD_PP).toBe(0.08);
23
+ });
24
+
25
+ it('backs the claim when both recall thresholds and FP pass', () => {
26
+ const v = evaluateCouncilBench({
27
+ single_judge_residual_recall: 0.5,
28
+ panel_residual_recall: 0.65, // +30% relative, +15pp absolute
29
+ ...CLEAN_FP,
30
+ });
31
+ expect(v.relative_pass).toBe(true);
32
+ expect(v.absolute_pass).toBe(true);
33
+ expect(v.fp_pass).toBe(true);
34
+ expect(v.verdict).toBe('backed');
35
+ });
36
+
37
+ it('honest-null when relative passes but absolute fails (base-rate compression)', () => {
38
+ // baseline 0.90; +0.05 → relative 5.5% (fails) — the high-baseline trap.
39
+ const v = evaluateCouncilBench({
40
+ single_judge_residual_recall: 0.9,
41
+ panel_residual_recall: 0.95,
42
+ ...CLEAN_FP,
43
+ });
44
+ expect(v.verdict).toBe('honest-null');
45
+ expect(v.reasons.join(' ')).toMatch(/relative lift/);
46
+ });
47
+
48
+ it('honest-null when absolute passes but relative fails', () => {
49
+ // baseline 0.6; +0.09 absolute (passes) but +15% relative (fails at 25%).
50
+ const v = evaluateCouncilBench({
51
+ single_judge_residual_recall: 0.6,
52
+ panel_residual_recall: 0.69,
53
+ ...CLEAN_FP,
54
+ });
55
+ expect(v.absolute_pass).toBe(true);
56
+ expect(v.relative_pass).toBe(false);
57
+ expect(v.verdict).toBe('honest-null');
58
+ });
59
+
60
+ it('honest-null when recall passes but FP regresses beyond noise', () => {
61
+ const v = evaluateCouncilBench({
62
+ single_judge_residual_recall: 0.5,
63
+ panel_residual_recall: 0.7,
64
+ single_judge_fp_rate: 0.05,
65
+ panel_fp_rate: 0.12, // +7pp > 3pp noise
66
+ fp_noise_margin: 0.03,
67
+ });
68
+ expect(v.relative_pass).toBe(true);
69
+ expect(v.absolute_pass).toBe(true);
70
+ expect(v.fp_pass).toBe(false);
71
+ expect(v.verdict).toBe('honest-null');
72
+ });
73
+
74
+ it('allows a within-noise FP increase', () => {
75
+ const v = evaluateCouncilBench({
76
+ single_judge_residual_recall: 0.5,
77
+ panel_residual_recall: 0.7,
78
+ single_judge_fp_rate: 0.05,
79
+ panel_fp_rate: 0.075, // +2.5pp <= 3pp noise
80
+ fp_noise_margin: 0.03,
81
+ });
82
+ expect(v.fp_pass).toBe(true);
83
+ expect(v.verdict).toBe('backed');
84
+ });
85
+
86
+ it('handles a zero baseline (infinite relative lift, absolute still gates)', () => {
87
+ const backed = evaluateCouncilBench({
88
+ single_judge_residual_recall: 0,
89
+ panel_residual_recall: 0.2,
90
+ ...CLEAN_FP,
91
+ });
92
+ expect(backed.relative_lift).toBe(Infinity);
93
+ expect(backed.verdict).toBe('backed'); // +20pp absolute clears
94
+
95
+ const nullAbs = evaluateCouncilBench({
96
+ single_judge_residual_recall: 0,
97
+ panel_residual_recall: 0.05, // +5pp < 8pp
98
+ ...CLEAN_FP,
99
+ });
100
+ expect(nullAbs.verdict).toBe('honest-null');
101
+ });
102
+
103
+ it('rejects out-of-range inputs', () => {
104
+ expect(() =>
105
+ evaluateCouncilBench({
106
+ single_judge_residual_recall: 1.5,
107
+ panel_residual_recall: 0.5,
108
+ ...CLEAN_FP,
109
+ }),
110
+ ).toThrow(/\[0,1\]/);
111
+ });
112
+ });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Adversarial verification council — prove-or-drop gate (Phase 4).
3
+ *
4
+ * Pure, deterministic. Encodes the pre-registered `adversarial-council-finding-
5
+ * coverage` claim (docs/CLAIMS.md, ADR-122) as a machine-checkable verdict on
6
+ * the two-stage residual-detection benchmark: does a cross-vendor skeptic panel
7
+ * out-find a single strong judge on the RESIDUAL defect pool (defects that
8
+ * survived stage 1), at a false-positive rate no worse than baseline on the
9
+ * controversial-but-correct control?
10
+ *
11
+ * Dual threshold, both required, to guard against base-rate compression when the
12
+ * single-judge residual recall is already high (the council's Round-1 critique):
13
+ * (a) relative residual-recall lift >= +25%
14
+ * (b) absolute residual-recall lift >= +8 percentage points
15
+ * AND panel FP not worse than single-judge FP within the noise margin.
16
+ *
17
+ * Thresholds are LOCKED at pre-registration (roadmap Phase 0). This module is the
18
+ * "countable logic in TS with tests" the anti-lesson mandates — never LLM-computed.
19
+ *
20
+ * Design: docs/design/adversarial-council-eval.md
21
+ */
22
+
23
+ /** Relative residual-recall lift threshold (fraction). */
24
+ export const RELATIVE_LIFT_THRESHOLD = 0.25;
25
+ /** Absolute residual-recall lift threshold (percentage points, as a fraction). */
26
+ export const ABSOLUTE_LIFT_THRESHOLD_PP = 0.08;
27
+
28
+ export interface CouncilBenchInputs {
29
+ /** Single-judge recall on the judge-passed residual pool (0..1). */
30
+ single_judge_residual_recall: number;
31
+ /** Panel recall on the same judge-passed residual pool (0..1). */
32
+ panel_residual_recall: number;
33
+ /** Single-judge false-positive rate on the controversial-clean control (0..1). */
34
+ single_judge_fp_rate: number;
35
+ /** Panel false-positive rate on the same control (0..1). */
36
+ panel_fp_rate: number;
37
+ /** Noise margin within which a higher panel FP still counts as "not worse" (0..1). */
38
+ fp_noise_margin: number;
39
+ }
40
+
41
+ export type CouncilVerdict = 'backed' | 'honest-null';
42
+
43
+ export interface CouncilBenchVerdict {
44
+ relative_lift: number;
45
+ absolute_lift_pp: number;
46
+ relative_pass: boolean;
47
+ absolute_pass: boolean;
48
+ fp_pass: boolean;
49
+ verdict: CouncilVerdict;
50
+ reasons: string[];
51
+ }
52
+
53
+ function assertUnit(name: string, v: number): void {
54
+ if (typeof v !== 'number' || Number.isNaN(v) || v < 0 || v > 1) {
55
+ throw new Error(`${name} must be a number in [0,1], got ${JSON.stringify(v)}`);
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Evaluate the pre-registered dual-threshold gate. Returns `backed` only when
61
+ * BOTH recall thresholds AND the FP constraint pass; otherwise `honest-null`
62
+ * (which keeps the surface default-off permanently, like recursive-verification).
63
+ */
64
+ export function evaluateCouncilBench(inputs: CouncilBenchInputs): CouncilBenchVerdict {
65
+ assertUnit('single_judge_residual_recall', inputs.single_judge_residual_recall);
66
+ assertUnit('panel_residual_recall', inputs.panel_residual_recall);
67
+ assertUnit('single_judge_fp_rate', inputs.single_judge_fp_rate);
68
+ assertUnit('panel_fp_rate', inputs.panel_fp_rate);
69
+ assertUnit('fp_noise_margin', inputs.fp_noise_margin);
70
+
71
+ const single = inputs.single_judge_residual_recall;
72
+ const panel = inputs.panel_residual_recall;
73
+ const absolute_lift_pp = panel - single;
74
+ // A zero baseline makes the ratio undefined — any positive panel recall is an
75
+ // infinite relative lift; no lift is zero. This never masks the absolute gate.
76
+ const relative_lift = single === 0 ? (panel > 0 ? Infinity : 0) : absolute_lift_pp / single;
77
+
78
+ const relative_pass = relative_lift >= RELATIVE_LIFT_THRESHOLD;
79
+ const absolute_pass = absolute_lift_pp >= ABSOLUTE_LIFT_THRESHOLD_PP;
80
+ const fp_pass = inputs.panel_fp_rate <= inputs.single_judge_fp_rate + inputs.fp_noise_margin;
81
+
82
+ const reasons: string[] = [];
83
+ if (!relative_pass) {
84
+ reasons.push(`relative lift ${(relative_lift * 100).toFixed(1)}% < ${RELATIVE_LIFT_THRESHOLD * 100}%`);
85
+ }
86
+ if (!absolute_pass) {
87
+ reasons.push(`absolute lift ${(absolute_lift_pp * 100).toFixed(1)}pp < ${ABSOLUTE_LIFT_THRESHOLD_PP * 100}pp`);
88
+ }
89
+ if (!fp_pass) {
90
+ reasons.push(
91
+ `panel FP ${(inputs.panel_fp_rate * 100).toFixed(1)}% > baseline ${(inputs.single_judge_fp_rate * 100).toFixed(1)}% + noise ${(inputs.fp_noise_margin * 100).toFixed(1)}%`,
92
+ );
93
+ }
94
+
95
+ const verdict: CouncilVerdict = relative_pass && absolute_pass && fp_pass ? 'backed' : 'honest-null';
96
+ if (verdict === 'backed') reasons.push('all thresholds met');
97
+ return { relative_lift, absolute_lift_pp, relative_pass, absolute_pass, fp_pass, verdict, reasons };
98
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Adversarial reconciliation core — deterministic coverage (Phase 1).
3
+ *
4
+ * Covers: dedup across skeptics, severity aggregation (strict-er wins),
5
+ * quorum-confidence math, false-positive suppression (demote, never drop),
6
+ * empty panel, single-finding, unanimous vs split, malformed-input rejection,
7
+ * and hand-rolled validation of every output against
8
+ * adversarial-findings.json (no jsonschema runtime dep — mirrors
9
+ * test_subagent_status_schema.py's approach).
10
+ */
11
+ import { readFileSync } from 'node:fs';
12
+ import { fileURLToPath } from 'node:url';
13
+ import path from 'node:path';
14
+
15
+ import { describe, expect, it } from 'vitest';
16
+
17
+ import {
18
+ type AdversarialFindings,
19
+ type RawFinding,
20
+ type SkepticReturn,
21
+ assertValidRawFinding,
22
+ findingKey,
23
+ isSuppressedFalsePositive,
24
+ reconcileFindings,
25
+ severityQuorum,
26
+ } from './adversarial_reconcile.js';
27
+
28
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
29
+ const SCHEMA_PATH = path.resolve(
30
+ HERE,
31
+ '../../skills/subagent-orchestration/schemas/adversarial-findings.json',
32
+ );
33
+
34
+ const SEVERITIES = new Set(['critical', 'high', 'medium', 'low']);
35
+ const CONFIDENCES = new Set(['high', 'medium', 'low']);
36
+
37
+ /** Minimal hand-rolled validator matching the schema's required keys + enums. */
38
+ function validateEnvelope(env: AdversarialFindings): void {
39
+ expect(env).toHaveProperty('panel');
40
+ expect(env.panel.models.length).toBeGreaterThanOrEqual(1);
41
+ expect(env.panel.skeptic_count).toBe(env.panel.models.length);
42
+ for (const bucket of [env.findings, env.false_positives_suppressed]) {
43
+ for (const f of bucket) {
44
+ for (const val of [f.id, f.severity, f.category, f.location, f.description, f.confidence]) {
45
+ expect(typeof val).toBe('string');
46
+ expect(val.length).toBeGreaterThan(0);
47
+ }
48
+ expect(SEVERITIES.has(f.severity)).toBe(true);
49
+ expect(CONFIDENCES.has(f.confidence)).toBe(true);
50
+ expect(Array.isArray(f.raised_by)).toBe(true);
51
+ expect(f.raised_by.length).toBeGreaterThanOrEqual(1);
52
+ expect(Array.isArray(f.refuted_by)).toBe(true);
53
+ }
54
+ }
55
+ }
56
+
57
+ function raw(over: Partial<RawFinding> = {}): RawFinding {
58
+ return {
59
+ severity: 'high',
60
+ category: 'correctness',
61
+ location: 'src/a.ts:42',
62
+ description: 'off-by-one in loop bound',
63
+ ...over,
64
+ };
65
+ }
66
+
67
+ describe('adversarial-findings.json schema', () => {
68
+ it('parses and declares the finding contract', () => {
69
+ const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8'));
70
+ expect(schema.required).toEqual(['panel', 'findings', 'false_positives_suppressed']);
71
+ expect(schema.additionalProperties).toBe(false);
72
+ expect(schema.definitions.finding.required).toContain('raised_by');
73
+ expect(schema.definitions.finding.required).toContain('confidence');
74
+ expect(schema.definitions.finding.properties.severity.enum).toEqual([
75
+ 'critical',
76
+ 'high',
77
+ 'medium',
78
+ 'low',
79
+ ]);
80
+ });
81
+ });
82
+
83
+ describe('findingKey', () => {
84
+ it('is location + normalized category', () => {
85
+ expect(findingKey({ location: 'src/a.ts:42 ', category: 'Security' })).toBe(
86
+ 'src/a.ts:42::security',
87
+ );
88
+ });
89
+ });
90
+
91
+ describe('assertValidRawFinding', () => {
92
+ it('rejects a bad severity', () => {
93
+ expect(() => assertValidRawFinding(raw({ severity: 'blocker' as never }))).toThrow(/severity/);
94
+ });
95
+ it('rejects an empty location', () => {
96
+ expect(() => assertValidRawFinding(raw({ location: ' ' }))).toThrow(/location/);
97
+ });
98
+ it('accepts a well-formed finding', () => {
99
+ expect(() => assertValidRawFinding(raw())).not.toThrow();
100
+ });
101
+ });
102
+
103
+ describe('severityQuorum', () => {
104
+ it('high when corroborated by a panel quorum', () => {
105
+ expect(severityQuorum(2, 0, 2)).toBe('high');
106
+ expect(severityQuorum(2, 0, 3)).toBe('high');
107
+ });
108
+ it('medium when corroborated below quorum', () => {
109
+ expect(severityQuorum(2, 0, 5)).toBe('medium');
110
+ });
111
+ it('medium for a lone raiser on a solo panel', () => {
112
+ expect(severityQuorum(1, 0, 1)).toBe('medium');
113
+ });
114
+ it('low for a single raiser on a multi-skeptic panel', () => {
115
+ expect(severityQuorum(1, 0, 3)).toBe('low');
116
+ });
117
+ });
118
+
119
+ describe('isSuppressedFalsePositive', () => {
120
+ it('suppresses a lone finding refuted by a strict majority of the rest', () => {
121
+ expect(isSuppressedFalsePositive(1, 1, 2)).toBe(true); // rest=1, refuted=1 > 0.5
122
+ expect(isSuppressedFalsePositive(1, 2, 3)).toBe(true); // rest=2, refuted=2 > 1
123
+ });
124
+ it('does not suppress when refutation is not a strict majority', () => {
125
+ expect(isSuppressedFalsePositive(1, 1, 3)).toBe(false); // rest=2, refuted=1 == 1, not > 1
126
+ });
127
+ it('never suppresses a corroborated finding', () => {
128
+ expect(isSuppressedFalsePositive(2, 5, 7)).toBe(false);
129
+ });
130
+ });
131
+
132
+ describe('reconcileFindings', () => {
133
+ it('handles an empty panel', () => {
134
+ const env = reconcileFindings([]);
135
+ expect(env.panel).toEqual({ models: [], skeptic_count: 0 });
136
+ expect(env.findings).toEqual([]);
137
+ expect(env.false_positives_suppressed).toEqual([]);
138
+ });
139
+
140
+ it('dedups the same defect across skeptics and records both as raisers', () => {
141
+ const env = reconcileFindings([
142
+ { model: 'anthropic:x', findings: [raw()] },
143
+ { model: 'openai:y', findings: [raw({ description: 'the loop reads one past the end of the array' })] },
144
+ ]);
145
+ expect(env.findings).toHaveLength(1);
146
+ expect(env.findings[0]!.raised_by.sort()).toEqual(['anthropic:x', 'openai:y']);
147
+ expect(env.findings[0]!.confidence).toBe('high'); // 2/2 = quorum
148
+ // Longest description wins.
149
+ expect(env.findings[0]!.description).toMatch(/one past the end/);
150
+ validateEnvelope(env);
151
+ });
152
+
153
+ it('aggregates severity strict-er-wins', () => {
154
+ const env = reconcileFindings([
155
+ { model: 'a', findings: [raw({ severity: 'medium' })] },
156
+ { model: 'b', findings: [raw({ severity: 'critical' })] },
157
+ ]);
158
+ expect(env.findings[0]!.severity).toBe('critical');
159
+ });
160
+
161
+ it('takes the union of distinct findings and orders by severity then key', () => {
162
+ const env = reconcileFindings([
163
+ { model: 'a', findings: [raw({ location: 'src/a.ts:1', severity: 'low', category: 'quality' })] },
164
+ { model: 'b', findings: [raw({ location: 'src/b.ts:9', severity: 'critical', category: 'security' })] },
165
+ ]);
166
+ expect(env.findings.map((f) => f.severity)).toEqual(['critical', 'low']);
167
+ expect(env.findings.map((f) => f.id)).toEqual(['avc-001', 'avc-002']);
168
+ validateEnvelope(env);
169
+ });
170
+
171
+ it('demotes a lone finding refuted by a majority into false_positives_suppressed', () => {
172
+ const loneKey = findingKey({ location: 'src/c.ts:5', category: 'security' });
173
+ const env = reconcileFindings([
174
+ { model: 'a', findings: [raw({ location: 'src/c.ts:5', category: 'security' })] },
175
+ { model: 'b', findings: [], refutes: [loneKey] },
176
+ ]);
177
+ expect(env.findings).toHaveLength(0);
178
+ expect(env.false_positives_suppressed).toHaveLength(1);
179
+ expect(env.false_positives_suppressed[0]!.refuted_by).toEqual(['b']);
180
+ validateEnvelope(env);
181
+ });
182
+
183
+ it('is deterministic across input order', () => {
184
+ const a: SkepticReturn = { model: 'a', findings: [raw({ location: 'src/z.ts:1', severity: 'high' })] };
185
+ const b: SkepticReturn = { model: 'b', findings: [raw({ location: 'src/a.ts:1', severity: 'high', category: 'security' })] };
186
+ const one = reconcileFindings([a, b]);
187
+ const two = reconcileFindings([b, a]);
188
+ expect(one.findings.map((f) => f.location)).toEqual(two.findings.map((f) => f.location));
189
+ expect(one.findings.map((f) => f.id)).toEqual(two.findings.map((f) => f.id));
190
+ });
191
+
192
+ it('rejects malformed findings from a skeptic', () => {
193
+ expect(() =>
194
+ reconcileFindings([{ model: 'a', findings: [raw({ severity: 'nope' as never })] }]),
195
+ ).toThrow(/severity/);
196
+ });
197
+ });