@event4u/agent-config 9.2.0 → 9.4.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 (93) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +111 -0
  3. package/CONTRIBUTING.md +14 -0
  4. package/README.md +12 -4
  5. package/dist/agent-src/commands/contribution-precheck.md +100 -0
  6. package/dist/agent-src/commands/fix/route.md +49 -0
  7. package/dist/agent-src/commands/fix.md +4 -2
  8. package/dist/agent-src/commands/tdd/green.md +32 -0
  9. package/dist/agent-src/commands/tdd/red.md +33 -0
  10. package/dist/agent-src/commands/tdd/refactor.md +31 -0
  11. package/dist/agent-src/commands/tdd.md +50 -0
  12. package/dist/agent-src/commands/team.md +4 -0
  13. package/dist/agent-src/contexts/communication/rules-auto/guidelines-mechanics.md +1 -0
  14. package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +81 -2
  15. package/dist/agent-src/contexts/model-recommendations.md +6 -0
  16. package/dist/agent-src/rules/architecture.md +7 -0
  17. package/dist/agent-src/rules/token-efficiency.md +7 -0
  18. package/dist/agent-src/skills/adversarial-review/SKILL.md +5 -0
  19. package/dist/agent-src/skills/ai-council/SKILL.md +3 -0
  20. package/dist/agent-src/skills/build-buy-partner/SKILL.md +1 -0
  21. package/dist/agent-src/skills/decision-record/SKILL.md +35 -1
  22. package/dist/agent-src/skills/decision-record/evals/triggers.json +82 -12
  23. package/dist/agent-src/skills/decision-record/references/weighted-matrix.md +119 -0
  24. package/dist/agent-src/skills/evaluate-llm-feature/SKILL.md +142 -0
  25. package/dist/agent-src/skills/fe-design/SKILL.md +1 -0
  26. package/dist/agent-src/skills/judge-code-quality/SKILL.md +1 -0
  27. package/dist/agent-src/skills/judge-synthesis/SKILL.md +1 -0
  28. package/dist/agent-src/skills/learning-tutor/SKILL.md +215 -0
  29. package/dist/agent-src/skills/persona-improvement/SKILL.md +81 -0
  30. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +10 -0
  31. package/dist/agent-src/skills/rice-prioritization/SKILL.md +1 -0
  32. package/dist/agent-src/skills/stakeholder-tradeoff/SKILL.md +3 -1
  33. package/dist/agent-src/skills/subagent-orchestration/SKILL.md +14 -24
  34. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  35. package/dist/discovery/deprecation-report.md +1 -1
  36. package/dist/discovery/discovery-manifest.json +253 -30
  37. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  38. package/dist/discovery/discovery-manifest.summary.md +4 -4
  39. package/dist/discovery/orphan-report.md +1 -1
  40. package/dist/discovery/packs.json +19 -10
  41. package/dist/discovery/trust-report.md +3 -3
  42. package/dist/discovery/workspaces.json +15 -6
  43. package/dist/mcp/registry-manifest.json +3 -3
  44. package/docs/CLAIMS.md +16 -2
  45. package/docs/announcements/2026-07-honest-cost-sweep-launch.md +84 -0
  46. package/docs/anti-reskin-gate.md +60 -0
  47. package/docs/architecture.md +3 -3
  48. package/docs/catalog.md +303 -278
  49. package/docs/command-flows.md +10 -3
  50. package/docs/comparison.yaml +10 -0
  51. package/docs/contracts/adoption-signal-floor.md +17 -0
  52. package/docs/contracts/ai-council-config.md +25 -0
  53. package/docs/contracts/command-clusters.md +2 -1
  54. package/docs/contracts/mcp-tool-inventory.md +25 -22
  55. package/docs/contracts/tier-3-contrib-plugin.md +12 -4
  56. package/docs/decisions/ADR-122-adversarial-verification-council.md +1 -1
  57. package/docs/development.md +10 -1
  58. package/docs/featured-skills.md +2 -2
  59. package/docs/getting-started-by-role.md +1 -1
  60. package/docs/getting-started.md +1 -1
  61. package/docs/governance-advantage.md +1 -1
  62. package/docs/guidelines/component-oriented-and-oop-development.md +181 -0
  63. package/docs/proof.md +10 -6
  64. package/docs/self-review-gate.md +14 -0
  65. package/docs/setup/per-ide/antigravity.md +8 -3
  66. package/docs/us-vs-the-category.md +38 -0
  67. package/docs/wedge/production-validator/README.md +24 -0
  68. package/docs/wedge/production-validator/first-run-check.sh +42 -0
  69. package/package.json +3 -2
  70. package/src/scripts/_lib/catalog_score.ts +64 -0
  71. package/src/scripts/_lib/shingle_similarity.ts +146 -0
  72. package/src/scripts/ai_council/clients.ts +50 -1
  73. package/src/scripts/ai_council/config.ts +28 -3
  74. package/src/scripts/ai_council/low_impact.ts +16 -0
  75. package/src/scripts/ai_council/necessity.ts +11 -0
  76. package/src/scripts/ai_council/orchestrator.ts +78 -26
  77. package/src/scripts/ai_council/pricing.ts +59 -0
  78. package/src/scripts/ai_council/prompts.ts +7 -0
  79. package/src/scripts/ai_council/stance_tally.ts +42 -5
  80. package/src/scripts/build_catalog_index.ts +188 -0
  81. package/src/scripts/council_cli.ts +56 -1
  82. package/src/scripts/generate_index.ts +6 -3
  83. package/src/scripts/hook_manifest.yaml +17 -6
  84. package/src/scripts/install-hooks.sh +32 -17
  85. package/src/scripts/lint_originality.ts +464 -0
  86. package/src/scripts/mcp_server/catalog_tools.ts +114 -0
  87. package/src/scripts/mcp_server/consumer_tool_catalog.json +46 -0
  88. package/src/scripts/pr_url_reminder_hook.ts +177 -0
  89. package/src/scripts/schemas/flow.schema.json +17 -0
  90. package/src/scripts/schemas/skill.schema.json +166 -32
  91. package/src/scripts/self_review_gate.test.ts +48 -0
  92. package/src/scripts/self_review_gate.ts +73 -5
  93. package/src/scripts/validate_flow_teams.ts +127 -0
@@ -2,8 +2,9 @@
2
2
 
3
3
  Antigravity (Google's agentic IDE) reads the Anthropic-shaped
4
4
  markdown skill bundle from its user-scope anchor `~/.agents/`. The
5
- package deploys via the universal skill convention; project-scope
6
- bridge is not yet wired (Phase 2.4 anchor).
5
+ package deploys the universal skill bundle at global scope, the same
6
+ projection its other bundle hosts get (codex, continue, roocode and
7
+ the rest) — global-only by design, not a pending gap.
7
8
 
8
9
  ## Prerequisites
9
10
 
@@ -25,7 +26,11 @@ Populates:
25
26
  - `~/.agents/personas/` — review-lens personas
26
27
 
27
28
  (Project-scope `--tools=antigravity` is rejected with exit code 1
28
- — Antigravity has no documented project-discovery convention yet.)
29
+ — every bundle host is global-only; the package writes the shared
30
+ bundle to a user-scope anchor, not into the project. Antigravity does
31
+ have a native project `.agents/` convention (`skills/`, `agents.md`,
32
+ `workflows/`), but a project-scope projection is not part of the
33
+ shipped surface.)
29
34
 
30
35
  ## How to use
31
36
 
@@ -0,0 +1,38 @@
1
+ # Us vs. the category — the honest version
2
+
3
+ <!-- claim:positioning-honest-nulls -->**The only agent layer that publishes
4
+ the runs where it changed nothing.**<!-- /claim --> That line is deliberately
5
+ falsifiable: find a comparable agent layer publishing its own honest-null
6
+ benchmark runs and it updates — that is the point. The category's growth
7
+ playbook is headline numbers nobody can re-run; ours is a claims ledger CI
8
+ refuses to let drift.
9
+
10
+ This page is the adoption-context frame around the CI-locked comparison table.
11
+ The table itself lives on the proof page — every "our evidence" pointer is
12
+ resolved by `task check-comparison` on every build, the category column only
13
+ ever describes what is publicly observable, and no competitor is named or
14
+ counter-claimed:
15
+
16
+ **→ [The comparison table, CI-checked on every build](proof.md#4-what-is-checkable--us-vs-the-category)**
17
+
18
+ ## What the rows mean for an adopter
19
+
20
+ - **No runtime.** Nothing to keep alive, nothing that writes state behind
21
+ you — the whole layer compiles into your host agent's own config surface.
22
+ - **Published nulls.** The benchmark page keeps the runs where the package
23
+ changed *nothing* — including the persona-theater question: swapping the
24
+ advisor identities was measured at ~zero effect while the provider choice
25
+ mattered ~15× more, so persona identity was published as a placebo
26
+ (<!-- claim:persona-identity-placebo-null -->the persona-identity A/B is a
27
+ backed honest null<!-- /claim -->) instead of shipped as a feature. A
28
+ council implementation with falsifiable verdicts — including about itself.
29
+ - **A wedge, not a funnel trap.** The 30-second entry is one read-only
30
+ subagent with a scoped, published eval behind its promise
31
+ ([what it catches](wedge/production-validator/README.md)) — the platform is
32
+ the second date, not the first.
33
+
34
+ ## Verify it yourself
35
+
36
+ Every command on the [proof page § Verify it yourself](proof.md#5-verify-it-yourself)
37
+ runs on a fresh checkout. If a claim here does not resolve, CI is red before
38
+ you ever read it.
@@ -7,6 +7,14 @@ system — not just green tests over hollow code.
7
7
 
8
8
  One file. No install of anything else. No runtime, no daemon, no account.
9
9
 
10
+ <!-- claim:wedge-hollow-detection -->On its published eval it returned the
11
+ correct verdict on both fixtures — `NOT READY` with the exact `file:line` on a
12
+ planted hollow implementation, `READY` with zero spurious findings on the clean
13
+ control — at ~45k fewer tokens per task than the same check run inline
14
+ ([evidence](../../../internal/bench/orchestration/pv-a3-results.md)). Two
15
+ planted fixtures on a Claude Code host — a scoped, reproducible promise, not a
16
+ hit-rate.<!-- /claim -->
17
+
10
18
  ## What it does
11
19
 
12
20
  Before you call a feature done, `@production-validator` greps the shipped path
@@ -46,6 +54,22 @@ production-validator
46
54
  Final gate: NOT READY — refund() is a stub; validate against the real provider.
47
55
  ```
48
56
 
57
+ ## Optional: record your first run (local-only, opt-in)
58
+
59
+ Nothing is measured unless you run it. After your first verdict, you can keep a
60
+ private local record — one aggregate line, written into YOUR repo, no network:
61
+
62
+ ```bash
63
+ bash <(curl -fsSL https://raw.githubusercontent.com/event4u-app/agent-config/main/docs/wedge/production-validator/first-run-check.sh) ready
64
+ ```
65
+
66
+ Outcomes: `ready` · `not-ready` · `abandoned`. The log
67
+ (`.claude/wedge-first-run.local.log`) never leaves your machine — the package
68
+ runs zero telemetry by default
69
+ ([contract](../../contracts/adoption-signal-floor.md)). The same outcome
70
+ vocabulary feeds the proctored install-friction study
71
+ (`agents/recruit-sessions/_install-friction-runbook.md`).
72
+
49
73
  ## Why this one
50
74
 
51
75
  It carries an anti-hallucination discipline (Evidence-First): "done" is a claim
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env bash
2
+ # Opt-in, local-only first-run check for the production-validator wedge.
3
+ #
4
+ # Default-off by construction: nothing runs unless YOU invoke this script.
5
+ # It writes ONE aggregate line to a local log in your repo and never opens a
6
+ # network connection — consistent with the package's no-telemetry posture
7
+ # (docs/contracts/adoption-signal-floor.md). Delete the log any time.
8
+ #
9
+ # Usage (after your first @production-validator verdict):
10
+ # bash first-run-check.sh ready # first verdict was READY
11
+ # bash first-run-check.sh not-ready # first verdict was NOT READY
12
+ # bash first-run-check.sh abandoned # you gave up before a verdict
13
+ #
14
+ # The same outcome vocabulary feeds the B9 install-friction study
15
+ # (agents/recruit-sessions/_install-friction-runbook.md) when a session is
16
+ # proctored; unproctored users simply keep a private local record.
17
+ set -euo pipefail
18
+
19
+ OUTCOME="${1:-}"
20
+ case "$OUTCOME" in
21
+ ready|not-ready|abandoned) ;;
22
+ *)
23
+ echo "usage: bash first-run-check.sh <ready|not-ready|abandoned>" >&2
24
+ exit 2
25
+ ;;
26
+ esac
27
+
28
+ AGENT_FILE=".claude/agents/production-validator.md"
29
+ LOG_FILE=".claude/wedge-first-run.local.log"
30
+
31
+ if [ ! -f "$AGENT_FILE" ]; then
32
+ echo "NOT INSTALLED — $AGENT_FILE missing. Install first:" >&2
33
+ echo " mkdir -p .claude/agents && curl -fsSL https://raw.githubusercontent.com/event4u-app/agent-config/main/docs/wedge/production-validator/production-validator.md -o $AGENT_FILE" >&2
34
+ exit 1
35
+ fi
36
+
37
+ # One aggregate line: date + outcome. No user, host, repo, or content data.
38
+ printf '%s wedge=production-validator first_run=%s\n' \
39
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$OUTCOME" >> "$LOG_FILE"
40
+
41
+ echo "Recorded locally in $LOG_FILE (never leaves this machine)."
42
+ echo "Add it to .gitignore if you don't want it committed."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
- "version": "9.2.0",
3
+ "version": "9.4.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,
@@ -70,7 +70,8 @@
70
70
  "lint:ts": "eslint 'src/**/*.ts'",
71
71
  "test:ts": "vitest run",
72
72
  "prepack": "npm run build && npm run build:discovery && npm run build:mcp-manifest && node src/scripts/prepack-check.mjs",
73
- "prepublishOnly": "./scripts-run src/scripts/check_release_includes_discovery"
73
+ "prepublishOnly": "./scripts-run src/scripts/check_release_includes_discovery",
74
+ "prepare": "[ -d .git ] && bash src/scripts/install-hooks.sh || true"
74
75
  },
75
76
  "publishConfig": {
76
77
  "access": "public",
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Catalog search scoring — a thin field-boosted wrapper over the shared
3
+ * `LexicalIndex` (BM25 + trigram prefilter).
4
+ *
5
+ * DECISION (road-to-persona-library-harvest.md Phase 2.3): the roadmap sketched
6
+ * porting the upstream Hermes `_score` (token overlap + name/description
7
+ * boosts). We do NOT — `_lib/lexical_index.ts` already ships a calibrated BM25
8
+ * core (ADR-061-sanctioned, dependency-free) with a coverage² multi-term
9
+ * weighting and a trigram candidate prefilter. Maintaining a second lexical
10
+ * scorer would be duplication. Instead we reuse `LexicalIndex` and get the
11
+ * field boost by REPEATING the name (×3) and description (×2) terms in each
12
+ * doc's indexed text, so a query term hitting the name outranks the same term
13
+ * buried in a tag. No new scorer.
14
+ */
15
+ import type { CatalogIndexEntry } from '../build_catalog_index.js';
16
+ import { LexicalIndex } from './lexical_index.js';
17
+
18
+ export interface CatalogFilter {
19
+ cls?: CatalogIndexEntry['cls'];
20
+ /** Match against the entry's tags (packs/domain/tier). */
21
+ pack?: string;
22
+ limit?: number;
23
+ }
24
+
25
+ export interface CatalogHit {
26
+ entry: CatalogIndexEntry;
27
+ score: number;
28
+ }
29
+
30
+ /** Field-boosted searchable text: name ×3, description ×2, tags ×1. */
31
+ function _docText(e: CatalogIndexEntry): string {
32
+ return [e.name, e.name, e.name, e.description, e.description, ...e.tags].join(' ');
33
+ }
34
+
35
+ function _filter(entries: readonly CatalogIndexEntry[], f: CatalogFilter): CatalogIndexEntry[] {
36
+ return entries.filter((e) => {
37
+ if (f.cls && e.cls !== f.cls) return false;
38
+ if (f.pack && !e.tags.includes(f.pack)) return false;
39
+ return true;
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Rank catalog entries against a free-text query. The index is built over the
45
+ * (filtered) candidate set so IDF reflects the searched scope. Empty or
46
+ * whitespace query → no hits (a search tool must not dump the catalog).
47
+ */
48
+ export function searchCatalog(
49
+ entries: readonly CatalogIndexEntry[],
50
+ query: string,
51
+ filter: CatalogFilter = {},
52
+ ): CatalogHit[] {
53
+ const q = query.trim();
54
+ if (q.length === 0) return [];
55
+ const candidates = _filter(entries, filter);
56
+ if (candidates.length === 0) return [];
57
+
58
+ const index = new LexicalIndex(candidates.map((e) => ({ id: e.id, text: _docText(e) })));
59
+ const byId = new Map(candidates.map((e) => [e.id, e]));
60
+ const limit = filter.limit && filter.limit > 0 ? filter.limit : 8;
61
+
62
+ const ranked = index.rank([q]).filter((r) => r.score > 0);
63
+ return ranked.slice(0, limit).map((r) => ({ entry: byId.get(r.id) as CatalogIndexEntry, score: r.score }));
64
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Entity-neutralized shingle-overlap primitive — the anti-reskin engine behind
3
+ * `lint_originality.ts`.
4
+ *
5
+ * Deterministic, no embeddings, no network. This is a DIFFERENT primitive from
6
+ * `text_similarity.ts` (token-set Jaccard, dedup) and `audit_skill_overlap.ts`
7
+ * (keyword-cosine, advisory) — both of which a find-replace re-skin
8
+ * ("Laravel" → "Symfony", "Vietnam" → "Korea") can defeat, because the swapped
9
+ * proper nouns shift the token/keyword vectors enough to drop the score.
10
+ *
11
+ * The defence is two-fold:
12
+ * 1. NEUTRALIZE entities — replace framework / vendor / language / region /
13
+ * currency proper nouns with a single placeholder BEFORE comparison, so a
14
+ * find-replace re-skin collapses to the original text.
15
+ * 2. SHINGLE — compare k-word shingles (default k = 8), not a bag of tokens,
16
+ * so re-skins that only swap scattered nouns still share long verbatim
17
+ * runs, and unrelated files that happen to share vocabulary do not.
18
+ *
19
+ * Overlap is CONTAINMENT (`|a∩b| / min(|a|,|b|)`), not Jaccard: a small file
20
+ * copied verbatim into a large padded one must still score high — the exact
21
+ * shape of an embedded re-skin.
22
+ *
23
+ * Deliberately does NOT touch `text_similarity.ts` or `memory_signal.ts`
24
+ * (byte-faithful parity contracts). This module is a new, additional layer.
25
+ */
26
+
27
+ /**
28
+ * Proper-noun families that a find-replace re-skin swaps. Neutralized to a
29
+ * single placeholder so the swap does not move the score. Word-boundary
30
+ * matched, case-insensitive. **Extend via PR, never inline at a call site** —
31
+ * the list is the calibration surface, and a scattered entity list drifts.
32
+ *
33
+ * Seed the list from OUR corpus (the frameworks / vendors / regions the suite
34
+ * actually names), not from any external source.
35
+ */
36
+ export const ENTITY_TERMS: readonly string[] = [
37
+ // Frameworks / libraries
38
+ 'laravel', 'symfony', 'react', 'vue', 'preact', 'angular', 'svelte', 'nextjs',
39
+ 'next', 'nuxt', 'livewire', 'flux', 'inertia', 'blade', 'tailwind', 'eloquent',
40
+ 'doctrine', 'pest', 'phpunit', 'vitest', 'jest', 'playwright', 'express',
41
+ 'fastify', 'django', 'flask', 'rails', 'spring',
42
+ // Languages
43
+ 'php', 'typescript', 'javascript', 'python', 'ruby', 'golang', 'rust', 'java',
44
+ 'kotlin', 'swift', 'csharp', 'elixir',
45
+ // Vendors / platforms / services
46
+ 'aws', 'gcp', 'azure', 'hetzner', 'cloudflare', 'vercel', 'netlify', 'stripe',
47
+ 'paddle', 'paypal', 'github', 'gitlab', 'bitbucket', 'linear', 'jira',
48
+ 'confluence', 'slack', 'sentry', 'datadog', 'grafana', 'prometheus', 'redis',
49
+ 'postgres', 'postgresql', 'mysql', 'mariadb', 'sqlite', 'mongodb', 'kafka',
50
+ 'rabbitmq', 'terraform', 'terragrunt', 'kubernetes', 'docker', 'ansible',
51
+ 'openai', 'anthropic', 'gemini', 'claude',
52
+ // Cloud regions / locales that re-skins swap
53
+ 'us-east-1', 'us-west-2', 'eu-west-1', 'eu-central-1', 'ap-southeast-1',
54
+ // Currency codes / symbols
55
+ 'usd', 'eur', 'gbp', 'chf', 'jpy',
56
+ ];
57
+
58
+ const _ENTITY_RE = new RegExp(
59
+ `\\b(?:${ENTITY_TERMS.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\b`,
60
+ 'gi',
61
+ );
62
+
63
+ /** Placeholder every entity family collapses to. Distinct token so it shingles. */
64
+ export const ENTITY_PLACEHOLDER = 'entterm';
65
+
66
+ /** Word token — mirrors the upstream re-skin gate's word regex so a calibrated
67
+ * threshold ports across corpora. Digits, `+ . # _ -` kept (e.g. `c#`, `9.3.0`). */
68
+ const _WORD_RE = /[a-z0-9][a-z0-9+.#_-]*/g;
69
+
70
+ /**
71
+ * Lowercase, strip frontmatter + markdown syntax, then replace entity proper
72
+ * nouns with `ENTITY_PLACEHOLDER`. `extra` is an optional caller-supplied regex
73
+ * (global) of additional terms to neutralize for one call (e.g. a corpus-
74
+ * specific product name); it never mutates the shared list.
75
+ */
76
+ export function neutralizeEntities(text: string, extra?: RegExp): string {
77
+ let t = text;
78
+ // Leading YAML frontmatter.
79
+ t = t.replace(/^---\n[\s\S]*?\n---\n?/, '');
80
+ // Fenced code blocks (``` or ~~~).
81
+ t = t.replace(/^([`~]{3,}).*$[\s\S]*?^\1[ \t]*$/gm, ' ');
82
+ // HTML comments.
83
+ t = t.replace(/<!--[\s\S]*?-->/g, ' ');
84
+ // Inline code spans.
85
+ t = t.replace(/`[^`]*`/g, ' ');
86
+ // Links / images: keep the visible label, drop the target.
87
+ t = t.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1');
88
+ t = t.toLowerCase();
89
+ // Heading / emphasis / list / blockquote / table punctuation → spaces, so
90
+ // the same prose scores identically regardless of markdown decoration.
91
+ t = t.replace(/[#*_>|~]+/g, ' ');
92
+ if (extra) {
93
+ t = t.replace(extra, ENTITY_PLACEHOLDER);
94
+ }
95
+ t = t.replace(_ENTITY_RE, ENTITY_PLACEHOLDER);
96
+ return t;
97
+ }
98
+
99
+ /** Ordered word tokens of the neutralized text (no set — shingles need order). */
100
+ export function words(text: string): string[] {
101
+ return text.match(_WORD_RE) ?? [];
102
+ }
103
+
104
+ /**
105
+ * Word-level k-shingles over the neutralized token stream. A file with fewer
106
+ * than `k` tokens yields the empty set (guarded downstream → score 0, never
107
+ * NaN). Shingles join on a single space so they are comparable across files.
108
+ */
109
+ export function shingles(text: string, k = 8): Set<string> {
110
+ const toks = words(neutralizeEntities(text));
111
+ const out = new Set<string>();
112
+ if (toks.length < k) {
113
+ return out;
114
+ }
115
+ for (let i = 0; i + k <= toks.length; i++) {
116
+ out.add(toks.slice(i, i + k).join(' '));
117
+ }
118
+ return out;
119
+ }
120
+
121
+ /**
122
+ * Containment overlap in percent: `100 * |a∩b| / min(|a|,|b|)`.
123
+ *
124
+ * Containment (not Jaccard) so a small file copied verbatim into a large padded
125
+ * one still scores high. Two empty shingle sets → 0 (a file too short to
126
+ * shingle carries no originality signal — never flag it). One empty → 0.
127
+ */
128
+ export function overlapPercent(a: Set<string>, b: Set<string>): number {
129
+ const min = Math.min(a.size, b.size);
130
+ if (min === 0) {
131
+ return 0;
132
+ }
133
+ const [small, large] = a.size <= b.size ? [a, b] : [b, a];
134
+ let shared = 0;
135
+ for (const s of small) {
136
+ if (large.has(s)) {
137
+ shared++;
138
+ }
139
+ }
140
+ return (100 * shared) / min;
141
+ }
142
+
143
+ /** Convenience: shingle both texts and score. */
144
+ export function shingleOverlap(a: string, b: string, k = 8): number {
145
+ return overlapPercent(shingles(a, k), shingles(b, k));
146
+ }
@@ -281,6 +281,27 @@ export abstract class ExternalAIClient {
281
281
  user_prompt: string,
282
282
  max_tokens?: number,
283
283
  ): CouncilResponse;
284
+
285
+ /**
286
+ * Cache-aware variant (A3 cross-round read unlock): the caller splits the
287
+ * user prompt into a byte-stable prefix (system + artefact — identical
288
+ * across rounds) and a volatile suffix (per-round critiques, stance
289
+ * contract). Clients with native prompt-cache support override this and
290
+ * place the cache breakpoint on the stable block only, so round N+1 READS
291
+ * the prefix instead of re-writing it. Default: plain concatenation —
292
+ * byte-identical to `ask()` for every transport without cache support.
293
+ */
294
+ ask_split(
295
+ system_prompt: string,
296
+ stable_prompt: string,
297
+ volatile_suffix: string,
298
+ max_tokens?: number,
299
+ ): CouncilResponse {
300
+ const user_prompt = volatile_suffix
301
+ ? `${stable_prompt}${volatile_suffix}`
302
+ : stable_prompt;
303
+ return this.ask(system_prompt, user_prompt, max_tokens);
304
+ }
284
305
  }
285
306
 
286
307
  /** Shared ctor-options shape for the API clients. */
@@ -395,6 +416,27 @@ export class AnthropicClient extends ExternalAIClient {
395
416
  user_prompt: string,
396
417
  max_tokens: number = DEFAULT_MAX_TOKENS,
397
418
  ): CouncilResponse {
419
+ return this._ask_impl(system_prompt, user_prompt, '', max_tokens);
420
+ }
421
+
422
+ override ask_split(
423
+ system_prompt: string,
424
+ stable_prompt: string,
425
+ volatile_suffix: string,
426
+ max_tokens: number = DEFAULT_MAX_TOKENS,
427
+ ): CouncilResponse {
428
+ return this._ask_impl(system_prompt, stable_prompt, volatile_suffix, max_tokens);
429
+ }
430
+
431
+ private _ask_impl(
432
+ system_prompt: string,
433
+ stable_prompt: string,
434
+ volatile_suffix: string,
435
+ max_tokens: number,
436
+ ): CouncilResponse {
437
+ const user_prompt = volatile_suffix
438
+ ? `${stable_prompt}${volatile_suffix}`
439
+ : stable_prompt;
398
440
  const t0 = _nowMs();
399
441
  let response: unknown;
400
442
  try {
@@ -426,12 +468,19 @@ export class AnthropicClient extends ExternalAIClient {
426
468
  messages: [
427
469
  {
428
470
  role: 'user',
471
+ // Breakpoint on the STABLE block only — the
472
+ // volatile suffix (round critiques) rides after
473
+ // it, so round N+1 reads the cached prefix
474
+ // instead of re-writing it (A3 read unlock).
429
475
  content: [
430
476
  {
431
477
  type: 'text',
432
- text: user_prompt,
478
+ text: stable_prompt,
433
479
  cache_control: { type: 'ephemeral' },
434
480
  },
481
+ ...(volatile_suffix
482
+ ? [{ type: 'text', text: volatile_suffix }]
483
+ : []),
435
484
  ],
436
485
  },
437
486
  ],
@@ -437,6 +437,13 @@ export interface NecessityClassifierConfig {
437
437
  export interface ModelDowngradeConfig {
438
438
  readonly enabled: boolean;
439
439
  readonly auto_apply: boolean;
440
+ /**
441
+ * Per-run escape hatch (roadmap A3): member name → model id. A member
442
+ * listed here is pinned to that model for the run — the size classifier
443
+ * and the cache-coupling gate are both skipped for it. Set it in
444
+ * `.ai-council.yml` when a specific artefact needs a specific tier.
445
+ */
446
+ readonly model_tier_override: Readonly<Record<string, string>>;
440
447
  }
441
448
 
442
449
  /** Pre-flight cost-disclosure toggle (Phase 8). */
@@ -908,11 +915,27 @@ function _build_model_downgrade(d: Dict): ModelDowngradeConfig {
908
915
  if (!_isBool(enabled)) {
909
916
  throw new CouncilConfigError('`model_downgrade.enabled` must be a bool.');
910
917
  }
911
- const auto_apply = _get(d, 'auto_apply', false);
918
+ // A3 (2026-07-20): auto_apply default flipped to TRUE — auto-downgrade is
919
+ // the default and `auto_apply: false` (or `enabled: false`) is the opt-out.
920
+ // The cache-coupling gate in council_cli._size_fit_gate keeps it honest.
921
+ const auto_apply = _get(d, 'auto_apply', true);
912
922
  if (!_isBool(auto_apply)) {
913
923
  throw new CouncilConfigError('`model_downgrade.auto_apply` must be a bool.');
914
924
  }
915
- return { enabled: Boolean(enabled), auto_apply: Boolean(auto_apply) };
925
+ const override_raw = _get(d, 'model_tier_override', {});
926
+ if (!_isDict(override_raw)) {
927
+ throw new CouncilConfigError('`model_downgrade.model_tier_override` must be a mapping.');
928
+ }
929
+ const model_tier_override: Record<string, string> = {};
930
+ for (const [k, v] of Object.entries(override_raw as Dict)) {
931
+ if (!_isStr(v) || (v as string).length === 0) {
932
+ throw new CouncilConfigError(
933
+ `\`model_downgrade.model_tier_override.${k}\` must be a non-empty string model id.`,
934
+ );
935
+ }
936
+ model_tier_override[k] = v as string;
937
+ }
938
+ return { enabled: Boolean(enabled), auto_apply: Boolean(auto_apply), model_tier_override };
916
939
  }
917
940
 
918
941
  function _build_cost_disclosure(d: Dict, scope: string): CostDisclosureConfig {
@@ -1308,7 +1331,8 @@ function _build_lens_overrides(d: Dict): LensOverridesConfig {
1308
1331
  `\`lenses.${lens_name}.model_downgrade.enabled\` must be a bool.`,
1309
1332
  );
1310
1333
  }
1311
- const md_auto = _get(md_block, 'auto_apply', false);
1334
+ // Lens overrides inherit the A3 auto-by-default posture.
1335
+ const md_auto = _get(md_block, 'auto_apply', true);
1312
1336
  if (!_isBool(md_auto)) {
1313
1337
  throw new CouncilConfigError(
1314
1338
  `\`lenses.${lens_name}.model_downgrade.auto_apply\` must be a bool.`,
@@ -1317,6 +1341,7 @@ function _build_lens_overrides(d: Dict): LensOverridesConfig {
1317
1341
  md_overrides.set(lens_name, {
1318
1342
  enabled: Boolean(md_enabled),
1319
1343
  auto_apply: Boolean(md_auto),
1344
+ model_tier_override: {},
1320
1345
  });
1321
1346
  }
1322
1347
  const cd_block = lens_cfg.cost_disclosure;
@@ -598,6 +598,10 @@ export function resolve_low_impact(
598
598
  clients: ReadonlyMap<string, ExternalAIClient> | Record<string, ExternalAIClient>,
599
599
  price_table: unknown = null,
600
600
  now: (() => Date) | null = null,
601
+ model_downgrade: {
602
+ enabled: boolean;
603
+ model_tier_override?: Readonly<Record<string, string>>;
604
+ } | null = null,
601
605
  ): FastPathResolution {
602
606
  if (!plan.is_resolvable) {
603
607
  return new FastPathResolution({
@@ -618,6 +622,18 @@ export function resolve_low_impact(
618
622
 
619
623
  for (const member of plan.members) {
620
624
  const client = getClient(member.name);
625
+ // A3: the fast-path is one-shot (no same-model cache reads to lose),
626
+ // so a configured ladder downgrades to its CHEAPEST rung outright —
627
+ // low-impact questions are the definition of small/low-stakes. The
628
+ // per-run model_tier_override pin wins; enabled: false opts out.
629
+ if (client && model_downgrade?.enabled) {
630
+ const pinned = model_downgrade.model_tier_override?.[member.name];
631
+ const cheapest = member.model_ladder.length > 0 ? member.model_ladder[0] : null;
632
+ const target = pinned && pinned.length > 0 ? pinned : cheapest;
633
+ if (target && target !== client.model) {
634
+ client.model = target;
635
+ }
636
+ }
621
637
  if (client === undefined || client === null) {
622
638
  answers.push(
623
639
  new MemberAnswer({
@@ -402,6 +402,17 @@ function _length_tier(text: string): LengthTier {
402
402
  * @param lens active lens; `debate` lens disables downgrade suggestions to
403
403
  * keep dissent quality high.
404
404
  * @returns A {@link SizeFitVerdict}.
405
+ *
406
+ * SIZE DEFINITION (A3 precondition, verified 2026-07-20): "small" is measured
407
+ * in Unicode code points (`_pyLen`, Python-`len()` parity) against the
408
+ * character thresholds `_SHORT_PROMPT_MAX`/`_MEDIUM_PROMPT_MAX` (200/800) plus
409
+ * a complexity-trigger heuristic — NOT bytes and NOT tokens. A token-based
410
+ * classifier was considered and rejected: it would add a tokenizer dependency
411
+ * (or a paid pre-flight count_tokens call) on the sequential-dispatch path
412
+ * (orchestrator.ts § "Members are called sequentially"), where every
413
+ * per-member pre-flight adds wall-clock latency before the first real call.
414
+ * The chars/4 heuristic in `pricing.estimate_input_tokens` remains the shared
415
+ * token approximation where cost math needs one.
405
416
  */
406
417
  export function classify_size_fit(
407
418
  prompt: string,