@event4u/agent-config 7.3.0 → 7.5.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/.claude-plugin/marketplace.json +4 -1
- package/CHANGELOG.md +81 -0
- package/README.md +2 -2
- package/dist/agent-src/commands/fix/pr-comments.md +42 -13
- package/dist/agent-src/commands/review-changes.md +14 -7
- package/dist/agent-src/rules/artifact-engagement-recording.md +9 -0
- package/dist/agent-src/rules/cli-output-handling.md +18 -0
- package/dist/agent-src/rules/domain-safety-pii.md +4 -3
- package/dist/agent-src/rules/icon-consistency.md +1 -0
- package/dist/agent-src/rules/legal-safety-floor.md +2 -2
- package/dist/agent-src/rules/untrusted-input-defense.md +8 -5
- package/dist/agent-src/skills/adr-create/SKILL.md +5 -1
- package/dist/agent-src/skills/adversarial-review/SKILL.md +1 -1
- package/dist/agent-src/skills/blade-ui/SKILL.md +4 -0
- package/dist/agent-src/skills/brand-to-tokens/SKILL.md +2 -1
- package/dist/agent-src/skills/code-review/SKILL.md +4 -0
- package/dist/agent-src/skills/context-authoring/SKILL.md +1 -1
- package/dist/agent-src/skills/design-intelligence/SKILL.md +40 -0
- package/dist/agent-src/skills/design-review/SKILL.md +23 -11
- package/dist/agent-src/skills/design-system-capture/SKILL.md +41 -0
- package/dist/agent-src/skills/design-system-capture/reference/design-system-json.md +62 -0
- package/dist/agent-src/skills/design-tokens/SKILL.md +1 -0
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +6 -0
- package/dist/agent-src/skills/flux/SKILL.md +4 -0
- package/dist/agent-src/skills/iconography/SKILL.md +1 -0
- package/dist/agent-src/skills/image-editing/evals/triggers.json +13 -13
- package/dist/agent-src/skills/judge-artifact-completeness/SKILL.md +136 -0
- package/dist/agent-src/skills/judge-artifact-completeness/calibration/fixtures.json +149 -0
- package/dist/agent-src/skills/judge-artifact-completeness/evals/triggers.json +17 -0
- package/dist/agent-src/skills/judge-artifact-completeness/rubrics/architecture-score.json +73 -0
- package/dist/agent-src/skills/judge-artifact-completeness/rubrics/pr-review-score.json +65 -0
- package/dist/agent-src/skills/judge-artifact-completeness/rubrics/roadmap-score.json +73 -0
- package/dist/agent-src/skills/judge-artifact-completeness/rubrics/ticket-quality-score.json +73 -0
- package/dist/agent-src/skills/judge-injection-defense/SKILL.md +99 -0
- package/dist/agent-src/skills/judge-injection-defense/calibration/fixtures.json +24 -0
- package/dist/agent-src/skills/judge-injection-defense/evals/triggers.json +17 -0
- package/dist/agent-src/skills/judge-injection-defense/fixtures/behavioral-fixtures.json +63 -0
- package/dist/agent-src/skills/judge-injection-defense/fixtures/coverage-report.md +53 -0
- package/dist/agent-src/skills/judge-injection-defense/fixtures/perturbation-taxonomy.json +110 -0
- package/dist/agent-src/skills/judge-synthesis/SKILL.md +134 -0
- package/dist/agent-src/skills/judge-synthesis/evals/triggers.json +17 -0
- package/dist/agent-src/skills/logo-generation/SKILL.md +1 -1
- package/dist/agent-src/skills/markitdown/SKILL.md +1 -1
- package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +18 -0
- package/dist/agent-src/skills/react-shadcn-ui/reference/registry.md +59 -0
- package/dist/agent-src/skills/refine-ticket/SKILL.md +5 -1
- package/dist/agent-src/skills/release-comms/SKILL.md +1 -1
- package/dist/agent-src/skills/roadmap-management/SKILL.md +4 -0
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
- package/dist/agent-src/skills/script-writing/SKILL.md +1 -1
- package/dist/agent-src/skills/sql-writing/SKILL.md +1 -1
- package/dist/agent-src/skills/systematic-debugging/SKILL.md +1 -1
- package/dist/agent-src/skills/tailwind-engineer/SKILL.md +4 -0
- package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +1 -1
- package/dist/agent-src/skills/token-optimizer/SKILL.md +1 -1
- package/dist/agent-src/skills/typography-system/SKILL.md +2 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +109 -43
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +8 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +7 -4
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/router.json +1 -1
- package/dist/server/schemas/settings.js +6 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/architecture.md +2 -2
- package/docs/catalog.md +28 -20
- package/docs/command-flows.md +1 -1
- package/docs/enforcement-by-host.md +9 -0
- package/docs/governance-advantage.md +72 -0
- package/docs/guidelines/design-antipatterns.md +26 -0
- package/docs/guidelines/design-canon.md +62 -0
- package/package.json +2 -1
- package/src/config/agent-settings.template.yml +21 -0
- package/src/scripts/_cli/cmd_analyze_session.ts +347 -0
- package/src/scripts/_dispatch.bash +8 -0
- package/src/scripts/_lib/token_count.ts +110 -35
- package/src/scripts/audit_initial_context.ts +37 -12
- package/src/scripts/bench_quality_run.ts +253 -0
- package/src/scripts/bench_rule_load_latency.ts +178 -0
- package/src/scripts/check_kernel_prefix_stability.ts +0 -0
- package/src/scripts/check_quality_regression.ts +244 -0
- package/src/scripts/check_token_quality_golden.ts +253 -0
- package/src/scripts/check_token_regression.ts +266 -0
- package/src/scripts/design_slop_rules.test.ts +193 -0
- package/src/scripts/design_slop_rules.ts +502 -0
- package/src/scripts/external_sources_denylist.json +7 -1
- package/src/scripts/hook_manifest.yaml +26 -3
- package/src/scripts/hooks/design_slop_hook.ts +181 -0
- package/src/scripts/hooks/rtk_wrap_hook.ts +228 -0
- package/src/scripts/lint_agent_security.ts +1 -0
- package/src/scripts/lint_confusables.ts +197 -0
- package/src/scripts/lint_design_slop.ts +245 -0
- package/src/scripts/probe_host_compliance.ts +145 -0
|
@@ -1,41 +1,80 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Real-tokenizer counting for the budget tooling
|
|
2
|
+
* Real-tokenizer counting for the budget tooling
|
|
3
|
+
* (roadmap 0B.1 → token-saving Phase 0, "real tokenizer into the bench path").
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* `char != token`. Every budget in this suite is historically in characters;
|
|
6
|
+
* this helper reports a token count *alongside* chars so chars stay the cheap
|
|
7
|
+
* proxy and tokens become the truth where a real tokenizer is available.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
* characters; this helper adds a token count *alongside* chars so chars
|
|
10
|
-
* stay the cheap proxy and tokens become the truth where a real
|
|
11
|
-
* tokenizer is available.
|
|
9
|
+
* Design — optional dependency, graceful proxy fallback, **synchronous** API:
|
|
12
10
|
*
|
|
13
|
-
*
|
|
11
|
+
* - **GPT** — uses `js-tiktoken` (encoding `cl100k_base`) when the dependency
|
|
12
|
+
* is present, producing EXACT BPE counts flagged `exact: true`. `js-tiktoken`
|
|
13
|
+
* is a build/measurement-time **devDependency** (consumed by the bench /
|
|
14
|
+
* audit tooling, never shipped to a consumer runtime). When it is absent —
|
|
15
|
+
* e.g. a consumer that installed without devDependencies — the module falls
|
|
16
|
+
* back to the documented `chars / 4` proxy flagged `exact: false`. The
|
|
17
|
+
* optional load is a synchronous `createRequire(...)` inside a try/catch so
|
|
18
|
+
* the public `measure()` / `gpt_tokens()` API stays synchronous and never
|
|
19
|
+
* hard-fails on a missing dependency (no mandatory install, no network).
|
|
20
|
+
* - **Claude** — no offline Claude tokenizer exists; documented `chars / 3.6`
|
|
21
|
+
* proxy, always `exact: false`. (Anthropic's exact count is API-only.)
|
|
14
22
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* `chars / 4` proxy applies, flagged `exact: false`
|
|
18
|
-
* (`TIKTOKEN_AVAILABLE` is always `false` here — matching the
|
|
19
|
-
* Python module's behaviour when tiktoken is absent).
|
|
20
|
-
* - **Claude** — no offline tokenizer; documented `chars / 3.6` proxy
|
|
21
|
-
* flagged `exact: false`.
|
|
23
|
+
* Both proxies are intentionally conservative ratios drawn from English-prose +
|
|
24
|
+
* markdown samples; they are estimates, never gates.
|
|
22
25
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
26
|
+
* The `*_proxy` helpers (`gpt_tokens_proxy`, `measure_proxy`,
|
|
27
|
+
* `method_note_proxy`) expose the fallback math directly — they are used by the
|
|
28
|
+
* fallback regression guard in the test suite and by any report that wants to
|
|
29
|
+
* record both the exact and the proxy count to flag the delta.
|
|
25
30
|
*
|
|
26
|
-
* Parity notes: "chars" counts Unicode code points (
|
|
27
|
-
*
|
|
31
|
+
* Parity notes: "chars" counts Unicode code points (`len(str)` parity), not
|
|
32
|
+
* UTF-16 units; proxy rounding is banker's rounding.
|
|
28
33
|
*/
|
|
29
34
|
|
|
35
|
+
import { createRequire } from "node:module";
|
|
36
|
+
|
|
30
37
|
// Proxy ratios (chars per token) for the no-tokenizer fallback. Tuned for
|
|
31
38
|
// English markdown rule/skill prose; deliberately conservative.
|
|
32
39
|
const _GPT_CHARS_PER_TOKEN = 4.0;
|
|
33
40
|
const _CLAUDE_CHARS_PER_TOKEN = 3.6;
|
|
34
41
|
|
|
35
|
-
|
|
42
|
+
// Roadmap (token-saving Phase 0) mandates the `cl100k_base` encoding. The
|
|
43
|
+
// dependency also bundles `o200k_base` (GPT-4o / GPT-4.1 family); switching the
|
|
44
|
+
// GPT count basis is a one-line change to this constant. The exact-vs-proxy
|
|
45
|
+
// delta is recorded in every report via `method_note()`.
|
|
46
|
+
const _TIKTOKEN_ENCODING = "cl100k_base";
|
|
47
|
+
|
|
48
|
+
/** Minimal structural type for the js-tiktoken encoder we rely on. */
|
|
49
|
+
interface _Encoder {
|
|
50
|
+
encode(text: string): number[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Load the optional `js-tiktoken` encoder synchronously, returning `null` when
|
|
55
|
+
* the dependency is absent or fails to initialise (graceful proxy fallback).
|
|
56
|
+
* `createRequire` resolves the CJS build so the call stays synchronous even
|
|
57
|
+
* though this module is ESM.
|
|
58
|
+
*/
|
|
59
|
+
function _loadEncoder(): _Encoder | null {
|
|
60
|
+
try {
|
|
61
|
+
const require = createRequire(import.meta.url);
|
|
62
|
+
const mod = require("js-tiktoken") as {
|
|
63
|
+
getEncoding(name: string): _Encoder;
|
|
64
|
+
};
|
|
65
|
+
return mod.getEncoding(_TIKTOKEN_ENCODING);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Resolved once at module load. js-tiktoken's `getEncoding` is synchronous with
|
|
72
|
+
// bundled rank tables, so eager init is cheap and keeps `TIKTOKEN_AVAILABLE` a
|
|
73
|
+
// plain const (the historical API shape).
|
|
74
|
+
const _ENCODER: _Encoder | null = _loadEncoder();
|
|
36
75
|
|
|
37
|
-
|
|
38
|
-
export const TIKTOKEN_AVAILABLE =
|
|
76
|
+
/** True when the real tokenizer is available (exact GPT counts), else proxy. */
|
|
77
|
+
export const TIKTOKEN_AVAILABLE = _ENCODER !== null;
|
|
39
78
|
|
|
40
79
|
/** A single token measurement and whether it is exact or a proxy. */
|
|
41
80
|
export class TokenCount {
|
|
@@ -49,7 +88,7 @@ export class TokenCount {
|
|
|
49
88
|
}
|
|
50
89
|
}
|
|
51
90
|
|
|
52
|
-
/** Count Unicode code points —
|
|
91
|
+
/** Count Unicode code points — `len(str)` parity. */
|
|
53
92
|
function _len(text: string): number {
|
|
54
93
|
let count = 0;
|
|
55
94
|
// for..of iterates code points, so astral chars count once.
|
|
@@ -57,7 +96,7 @@ function _len(text: string): number {
|
|
|
57
96
|
return count;
|
|
58
97
|
}
|
|
59
98
|
|
|
60
|
-
/**
|
|
99
|
+
/** Banker's rounding (round half to even) — proxy-path parity. */
|
|
61
100
|
function _python_round(x: number): number {
|
|
62
101
|
const floor = Math.floor(x);
|
|
63
102
|
const diff = x - floor;
|
|
@@ -66,17 +105,31 @@ function _python_round(x: number): number {
|
|
|
66
105
|
return floor % 2 === 0 ? floor : floor + 1;
|
|
67
106
|
}
|
|
68
107
|
|
|
69
|
-
/** Render a float like Python
|
|
108
|
+
/** Render a float like a Python f-string (4.0 → "4.0", 3.6 → "3.6"). */
|
|
70
109
|
function _py_float_repr(x: number): string {
|
|
71
110
|
return Number.isInteger(x) ? `${x}.0` : `${x}`;
|
|
72
111
|
}
|
|
73
112
|
|
|
74
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* GPT token count — EXACT via js-tiktoken (`cl100k_base`) when available,
|
|
115
|
+
* otherwise the documented `chars / 4` proxy.
|
|
116
|
+
*/
|
|
75
117
|
export function gpt_tokens(text: string): TokenCount {
|
|
118
|
+
if (_ENCODER !== null) {
|
|
119
|
+
return new TokenCount(_ENCODER.encode(text).length, true);
|
|
120
|
+
}
|
|
121
|
+
return gpt_tokens_proxy(text);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* GPT proxy count (`chars / 4`, banker's rounding) — the fallback path,
|
|
126
|
+
* exposed directly for the fallback regression guard and delta reporting.
|
|
127
|
+
*/
|
|
128
|
+
export function gpt_tokens_proxy(text: string): TokenCount {
|
|
76
129
|
return new TokenCount(_python_round(_len(text) / _GPT_CHARS_PER_TOKEN), false);
|
|
77
130
|
}
|
|
78
131
|
|
|
79
|
-
/** Claude token count — documented offline proxy (no local tokenizer). */
|
|
132
|
+
/** Claude token count — documented offline proxy (no local Claude tokenizer). */
|
|
80
133
|
export function claude_tokens(text: string): TokenCount {
|
|
81
134
|
return new TokenCount(
|
|
82
135
|
_python_round(_len(text) / _CLAUDE_CHARS_PER_TOKEN),
|
|
@@ -95,9 +148,9 @@ export interface Measure {
|
|
|
95
148
|
/**
|
|
96
149
|
* Return chars + per-model token counts for one text blob.
|
|
97
150
|
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
151
|
+
* `tokens_gpt` is exact (js-tiktoken) when available; `tokens_gpt_exact` tells
|
|
152
|
+
* the report consumer which path produced it. `tokens_claude` is always the
|
|
153
|
+
* proxy (no offline Claude tokenizer).
|
|
101
154
|
*/
|
|
102
155
|
export function measure(text: string): Measure {
|
|
103
156
|
const g = gpt_tokens(text);
|
|
@@ -111,6 +164,31 @@ export function measure(text: string): Measure {
|
|
|
111
164
|
};
|
|
112
165
|
}
|
|
113
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Proxy-only measure (forces the chars-based estimate for GPT regardless of
|
|
169
|
+
* tokenizer availability). Use to record both the exact and the proxy count
|
|
170
|
+
* and flag the delta, and as the fallback regression guard.
|
|
171
|
+
*/
|
|
172
|
+
export function measure_proxy(text: string): Measure {
|
|
173
|
+
const g = gpt_tokens_proxy(text);
|
|
174
|
+
const c = claude_tokens(text);
|
|
175
|
+
return {
|
|
176
|
+
chars: _len(text),
|
|
177
|
+
tokens_gpt: g.tokens,
|
|
178
|
+
tokens_gpt_exact: g.exact,
|
|
179
|
+
tokens_claude: c.tokens,
|
|
180
|
+
tokens_claude_exact: c.exact,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Proxy-path provenance string (the fallback note). */
|
|
185
|
+
export function method_note_proxy(): string {
|
|
186
|
+
return (
|
|
187
|
+
`tokens_gpt: proxy (chars/${_py_float_repr(_GPT_CHARS_PER_TOKEN)}, tiktoken not installed); ` +
|
|
188
|
+
`tokens_claude: proxy (chars/${_py_float_repr(_CLAUDE_CHARS_PER_TOKEN)})`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
114
192
|
/** One-line provenance of how token counts were produced (for reports). */
|
|
115
193
|
export function method_note(): string {
|
|
116
194
|
if (TIKTOKEN_AVAILABLE) {
|
|
@@ -119,8 +197,5 @@ export function method_note(): string {
|
|
|
119
197
|
`tokens_claude: proxy (chars/${_py_float_repr(_CLAUDE_CHARS_PER_TOKEN)})`
|
|
120
198
|
);
|
|
121
199
|
}
|
|
122
|
-
return (
|
|
123
|
-
`tokens_gpt: proxy (chars/${_py_float_repr(_GPT_CHARS_PER_TOKEN)}, tiktoken not installed); ` +
|
|
124
|
-
`tokens_claude: proxy (chars/${_py_float_repr(_CLAUDE_CHARS_PER_TOKEN)})`
|
|
125
|
-
);
|
|
200
|
+
return method_note_proxy();
|
|
126
201
|
}
|
|
@@ -67,13 +67,41 @@ const MCP_SERVER_NAME = 'agent-config';
|
|
|
67
67
|
const MCP_OVERSUBSCRIPTION_TOOL_CAP = 25;
|
|
68
68
|
|
|
69
69
|
// Initial-token budget per surface (null = advisory only, no gate).
|
|
70
|
-
|
|
70
|
+
//
|
|
71
|
+
// PROVISIONAL caps (token-saving Phase 8): regression caps at the current
|
|
72
|
+
// surface + ~15% headroom — NOT the quality elbow. The context-rot quality
|
|
73
|
+
// elbow (the roadmap's intended threshold) needs the Phase 0 live validation
|
|
74
|
+
// run, which is operator/cost-gated; until then these caps stop SILENT growth
|
|
75
|
+
// of the always-loaded token surface. Re-anchor with evidence once the elbow is
|
|
76
|
+
// measured. `rules.gpt` stays advisory here — the always-RULES surface is gated
|
|
77
|
+
// (char-based, extended size) by `check_always_budget`; a token cap would be
|
|
78
|
+
// redundant.
|
|
79
|
+
export const BUDGETS: Record<string, number | null> = {
|
|
71
80
|
'rules.gpt': null,
|
|
72
|
-
'skill_catalog.gpt':
|
|
73
|
-
'command_catalog.gpt':
|
|
74
|
-
'mcp_schemas.gpt':
|
|
81
|
+
'skill_catalog.gpt': 12_500, // always-scanned skill descriptions (skills_core_source, 258); current ~10,999
|
|
82
|
+
'command_catalog.gpt': 5_800, // always-scanned command catalog; current ~5,005
|
|
83
|
+
'mcp_schemas.gpt': 3_500, // per-connected-client MCP tool schemas; current ~2,942
|
|
75
84
|
};
|
|
76
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Pure budget check: return one breach line per surface whose measured token
|
|
88
|
+
* count exceeds its cap. A `null`/absent cap is advisory (never a breach).
|
|
89
|
+
* Exported so the gate's pass/fail logic is unit-testable (token-saving Phase 8).
|
|
90
|
+
*/
|
|
91
|
+
export function evaluate_budgets(
|
|
92
|
+
checks: Record<string, number>,
|
|
93
|
+
budgets: Record<string, number | null>,
|
|
94
|
+
): string[] {
|
|
95
|
+
const breaches: string[] = [];
|
|
96
|
+
for (const [key, val] of Object.entries(checks)) {
|
|
97
|
+
const cap = budgets[key];
|
|
98
|
+
if (cap !== null && cap !== undefined && val > cap) {
|
|
99
|
+
breaches.push(`${key} ${val} > budget ${cap}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return breaches;
|
|
103
|
+
}
|
|
104
|
+
|
|
77
105
|
type Measure = token_count.Measure & { files?: number; entries?: number };
|
|
78
106
|
|
|
79
107
|
function _isDir(p: string): boolean {
|
|
@@ -645,7 +673,6 @@ export function main(argv: string[] | null = null): number {
|
|
|
645
673
|
const data = build();
|
|
646
674
|
|
|
647
675
|
if (args.fail_if_over_budget) {
|
|
648
|
-
const breaches: string[] = [];
|
|
649
676
|
const rfVals = Object.values(data.rule_footprint as Record<string, Measure>);
|
|
650
677
|
const rf = rfVals.length > 0 ? (rfVals[0] as Measure) : ({} as Measure);
|
|
651
678
|
const dc = data.description_catalog as Record<string, Measure>;
|
|
@@ -653,16 +680,14 @@ export function main(argv: string[] | null = null): number {
|
|
|
653
680
|
const mcp_gpt = Object.values(mcp).reduce((acc, s) => acc + s.tokens_gpt, 0);
|
|
654
681
|
const checks: Record<string, number> = {
|
|
655
682
|
'rules.gpt': rf.tokens_gpt ?? 0,
|
|
656
|
-
|
|
683
|
+
// The always-SCANNED skill descriptions (258 source skills) — the
|
|
684
|
+
// surface the host reads for selection — not the larger per-tool
|
|
685
|
+
// projected catalog (skills_projected, 412 entries). Phase 8.
|
|
686
|
+
'skill_catalog.gpt': (dc.skills_core_source as Measure).tokens_gpt,
|
|
657
687
|
'command_catalog.gpt': (dc.commands_core_source as Measure).tokens_gpt,
|
|
658
688
|
'mcp_schemas.gpt': mcp_gpt,
|
|
659
689
|
};
|
|
660
|
-
|
|
661
|
-
const cap = BUDGETS[key];
|
|
662
|
-
if (cap !== null && cap !== undefined && val > cap) {
|
|
663
|
-
breaches.push(`${key} ${val} > budget ${cap}`);
|
|
664
|
-
}
|
|
665
|
-
}
|
|
690
|
+
const breaches = evaluate_budgets(checks, BUDGETS);
|
|
666
691
|
if (breaches.length > 0) {
|
|
667
692
|
process.stdout.write('❌ initial-context budget: ' + breaches.join('; ') + '\n');
|
|
668
693
|
return 1;
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Thin-vs-eager quality-run producer (token-saving Phase 0 EXIT).
|
|
4
|
+
*
|
|
5
|
+
* The missing RUNNER that produces `internal/bench/reports/quality-run.json` —
|
|
6
|
+
* the report the `check_quality_regression` gate consumes. For each labelled
|
|
7
|
+
* golden task it generates the agent's answer under the THIN rule context
|
|
8
|
+
* (kernel bodies + non-kernel pointers) and the EAGER context (all rule bodies),
|
|
9
|
+
* then judges the pair with the length-controlled paired judge
|
|
10
|
+
* (`evaluatePair`: both orders → reject-on-flip).
|
|
11
|
+
*
|
|
12
|
+
* Model calls (answer generation + judging) go through the proven council
|
|
13
|
+
* `AnthropicClient` (synchronous curl → Anthropic Messages API) — so this is
|
|
14
|
+
* **API-gated**: a real run needs an Anthropic key and costs money proportional
|
|
15
|
+
* to (golden tasks × 2 arms + 2 judge calls) × the rule-context size. Use
|
|
16
|
+
* `--dry-run` (deterministic mock, no API) to prove the pipeline + see the
|
|
17
|
+
* output shape first, and `--limit N` to bound a live run.
|
|
18
|
+
*
|
|
19
|
+
* CLI:
|
|
20
|
+
* ./scripts-run src/scripts/bench_quality_run --dry-run # mock, no API
|
|
21
|
+
* ./scripts-run src/scripts/bench_quality_run --limit 5 # live, first 5 tasks
|
|
22
|
+
* ./scripts-run src/scripts/bench_quality_run --model claude-sonnet-4-5
|
|
23
|
+
* Then gate on it: ./scripts-run src/scripts/check_quality_regression
|
|
24
|
+
*
|
|
25
|
+
* Exit codes: 0 wrote report · 1 error.
|
|
26
|
+
*/
|
|
27
|
+
import { createRequire } from 'node:module';
|
|
28
|
+
import * as fs from 'node:fs';
|
|
29
|
+
import * as path from 'node:path';
|
|
30
|
+
import process from 'node:process';
|
|
31
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
32
|
+
|
|
33
|
+
import yaml from 'js-yaml';
|
|
34
|
+
|
|
35
|
+
import { build_thin, RULES_SOURCE } from './project_thin_rules.js';
|
|
36
|
+
import { evaluatePair, type JudgeFn, type JudgeVerdict, type PairResult } from './check_quality_regression.js';
|
|
37
|
+
|
|
38
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
39
|
+
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
40
|
+
const GOLDEN = path.join(REPO_ROOT, 'internal/bench/corpora/token-quality-golden.yaml');
|
|
41
|
+
const OUT = path.join(REPO_ROOT, 'internal/bench/reports/quality-run.json');
|
|
42
|
+
const DEFAULT_THRESHOLD = 0.48;
|
|
43
|
+
|
|
44
|
+
export interface GoldenTask {
|
|
45
|
+
id: string;
|
|
46
|
+
rubric: string;
|
|
47
|
+
prompt: string;
|
|
48
|
+
must_include: string[];
|
|
49
|
+
must_not: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Load the labelled golden tasks (stubs are skipped — they have no real rubric). */
|
|
53
|
+
export function load_golden(file = GOLDEN): GoldenTask[] {
|
|
54
|
+
const doc = yaml.load(fs.readFileSync(file, 'utf-8')) as { tasks?: unknown[] };
|
|
55
|
+
const tasks = Array.isArray(doc?.tasks) ? doc.tasks : [];
|
|
56
|
+
const out: GoldenTask[] = [];
|
|
57
|
+
for (const t of tasks as Array<Record<string, unknown>>) {
|
|
58
|
+
if (t.label_status !== 'labelled') continue;
|
|
59
|
+
const exp = (t.expected ?? {}) as Record<string, unknown>;
|
|
60
|
+
out.push({
|
|
61
|
+
id: String(t.id),
|
|
62
|
+
rubric: String(exp.rubric ?? ''),
|
|
63
|
+
prompt: String(t.prompt ?? ''),
|
|
64
|
+
must_include: Array.isArray(exp.must_include) ? exp.must_include.map(String) : [],
|
|
65
|
+
must_not: Array.isArray(exp.must_not) ? exp.must_not.map(String) : [],
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Assemble the always-loaded rule context for each arm (eager bodies vs thin pointers). */
|
|
72
|
+
export function assemble_contexts(rulesDir = RULES_SOURCE): { eager: string; thin: string } {
|
|
73
|
+
const thinMap = build_thin(rulesDir);
|
|
74
|
+
const thin = [...thinMap.values()].join('\n\n');
|
|
75
|
+
const eager = fs
|
|
76
|
+
.readdirSync(rulesDir)
|
|
77
|
+
.filter((n) => n.endsWith('.md'))
|
|
78
|
+
.sort()
|
|
79
|
+
.map((n) => fs.readFileSync(path.join(rulesDir, n), 'utf-8'))
|
|
80
|
+
.join('\n\n');
|
|
81
|
+
return { eager, thin };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A model that answers a task under a given rule context. */
|
|
85
|
+
export type AnswerFn = (armContext: string, task: GoldenTask) => string;
|
|
86
|
+
|
|
87
|
+
/** A judge that, given the FULL task (rubric + anchors) + two presented answers,
|
|
88
|
+
* returns the order-relative verdict. Wrapped into evaluatePair's narrow JudgeFn. */
|
|
89
|
+
export type ModelJudgeFn = (task: GoldenTask, first: string, second: string) => JudgeVerdict;
|
|
90
|
+
|
|
91
|
+
/** Build the judge user-prompt — length-controlled (ignore verbosity). */
|
|
92
|
+
export function judge_prompt(task: GoldenTask, first: string, second: string): string {
|
|
93
|
+
return (
|
|
94
|
+
`Rubric: ${task.rubric}\n` +
|
|
95
|
+
(task.must_include.length ? `Must satisfy: ${task.must_include.join('; ')}\n` : '') +
|
|
96
|
+
(task.must_not.length ? `Must avoid: ${task.must_not.join('; ')}\n` : '') +
|
|
97
|
+
`\nTwo answers to the same request. Judge ONLY which better satisfies the rubric —\n` +
|
|
98
|
+
`IGNORE length and verbosity (a longer answer is not better).\n\n` +
|
|
99
|
+
`--- ANSWER A ---\n${first}\n\n--- ANSWER B ---\n${second}\n\n` +
|
|
100
|
+
`Reply with exactly one line: "VERDICT: A", "VERDICT: B", or "VERDICT: TIE".`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Parse a judge reply into the order-relative verdict (default tie = inconclusive). */
|
|
105
|
+
export function parse_verdict(reply: string): JudgeVerdict {
|
|
106
|
+
const m = /VERDICT:\s*(A|B|TIE)/i.exec(reply);
|
|
107
|
+
if (!m) return 'tie';
|
|
108
|
+
const v = (m[1] as string).toUpperCase();
|
|
109
|
+
return v === 'A' ? 'first' : v === 'B' ? 'second' : 'tie';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Run every task through both arms + the paired judge. Pure given the injected fns. */
|
|
113
|
+
export function run_golden_judge(
|
|
114
|
+
tasks: GoldenTask[],
|
|
115
|
+
contexts: { eager: string; thin: string },
|
|
116
|
+
answer: AnswerFn,
|
|
117
|
+
judge: ModelJudgeFn,
|
|
118
|
+
): PairResult[] {
|
|
119
|
+
return tasks.map((task) => {
|
|
120
|
+
const thinAnswer = answer(contexts.thin, task);
|
|
121
|
+
const eagerAnswer = answer(contexts.eager, task);
|
|
122
|
+
// evaluatePair calls its JudgeFn with the narrow {id,rubric} ctx; capture
|
|
123
|
+
// the full task here so the model judge gets the rubric + anchors.
|
|
124
|
+
const judgeFn: JudgeFn = (_ctx, first, second) => judge(task, first, second);
|
|
125
|
+
return evaluatePair(task, thinAnswer, eagerAnswer, judgeFn);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Mock model (--dry-run): deterministic, no API ───────────────────────────
|
|
130
|
+
function mock_answer(_ctx: string, task: GoldenTask): string {
|
|
131
|
+
return `Answer to ${task.id}: ${(task.must_include[0] ?? 'addresses the request')}.`;
|
|
132
|
+
}
|
|
133
|
+
const mock_judge: ModelJudgeFn = () => 'tie'; // dry-run can't truly compare → all ties
|
|
134
|
+
|
|
135
|
+
interface Args {
|
|
136
|
+
dryRun: boolean;
|
|
137
|
+
model: string;
|
|
138
|
+
limit: number | null;
|
|
139
|
+
output: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parse_args(argv: string[]): Args | number {
|
|
143
|
+
const a: Args = { dryRun: false, model: 'claude-sonnet-4-5', limit: null, output: OUT };
|
|
144
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
145
|
+
const arg = argv[i];
|
|
146
|
+
if (arg === '--dry-run') a.dryRun = true;
|
|
147
|
+
else if (arg === '--model') a.model = String(argv[(i += 1)] ?? a.model);
|
|
148
|
+
else if (arg === '--output') a.output = String(argv[(i += 1)] ?? a.output);
|
|
149
|
+
else if (arg === '--limit') {
|
|
150
|
+
const v = Number(argv[(i += 1)]);
|
|
151
|
+
if (!Number.isInteger(v) || v < 1) {
|
|
152
|
+
process.stderr.write('error: --limit must be a positive integer\n');
|
|
153
|
+
return 1;
|
|
154
|
+
}
|
|
155
|
+
a.limit = v;
|
|
156
|
+
} else if (arg === '-h' || arg === '--help') {
|
|
157
|
+
process.stdout.write('usage: bench_quality_run [--dry-run] [--model M] [--limit N] [--output PATH]\n');
|
|
158
|
+
return 0;
|
|
159
|
+
} else {
|
|
160
|
+
process.stderr.write(`error: unknown argument: ${arg}\n`);
|
|
161
|
+
return 1;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return a;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function main(argv: string[]): number {
|
|
168
|
+
const parsed = parse_args(argv);
|
|
169
|
+
if (typeof parsed === 'number') return parsed;
|
|
170
|
+
const args = parsed;
|
|
171
|
+
|
|
172
|
+
let tasks = load_golden();
|
|
173
|
+
if (tasks.length === 0) {
|
|
174
|
+
process.stderr.write('error: no labelled golden tasks — fill internal/bench/corpora/token-quality-golden.yaml\n');
|
|
175
|
+
return 1;
|
|
176
|
+
}
|
|
177
|
+
if (args.limit !== null) tasks = tasks.slice(0, args.limit);
|
|
178
|
+
const contexts = assemble_contexts();
|
|
179
|
+
|
|
180
|
+
let answer: AnswerFn;
|
|
181
|
+
let judge: ModelJudgeFn;
|
|
182
|
+
let modelLabel: string;
|
|
183
|
+
|
|
184
|
+
if (args.dryRun) {
|
|
185
|
+
answer = mock_answer;
|
|
186
|
+
judge = mock_judge;
|
|
187
|
+
modelLabel = 'dry-run-mock';
|
|
188
|
+
process.stdout.write(`⚠️ --dry-run: deterministic mock, no API. Output shape only.\n`);
|
|
189
|
+
} else {
|
|
190
|
+
// Live path — reuse the proven council AnthropicClient (sync curl). API-gated.
|
|
191
|
+
let client: { ask(system: string, user: string): { text: string } };
|
|
192
|
+
try {
|
|
193
|
+
// Lazy import so --dry-run + tests never touch the council key path.
|
|
194
|
+
const require = createRequire(import.meta.url);
|
|
195
|
+
const clients = require('./ai_council/clients.js') as {
|
|
196
|
+
AnthropicClient: new (o: { model: string; api_key: string }) => { ask(s: string, u: string): { text: string } };
|
|
197
|
+
load_anthropic_key: () => string;
|
|
198
|
+
};
|
|
199
|
+
client = new clients.AnthropicClient({ model: args.model, api_key: clients.load_anthropic_key() });
|
|
200
|
+
} catch (e) {
|
|
201
|
+
process.stderr.write(
|
|
202
|
+
`error: live model unavailable (${(e as Error).message}). ` +
|
|
203
|
+
`Set an Anthropic key (see install-anthropic-key) or use --dry-run.\n`,
|
|
204
|
+
);
|
|
205
|
+
return 1;
|
|
206
|
+
}
|
|
207
|
+
const SYS =
|
|
208
|
+
'You are an AI coding agent governed by the following always-loaded package rules. ' +
|
|
209
|
+
'Answer the user request as that agent would, honouring these rules:\n\n';
|
|
210
|
+
answer = (ctx, task) => client.ask(SYS + ctx, task.prompt).text;
|
|
211
|
+
judge = (task, first, second) =>
|
|
212
|
+
parse_verdict(
|
|
213
|
+
client.ask('You are a strict, length-neutral answer judge.', judge_prompt(task, first, second)).text,
|
|
214
|
+
);
|
|
215
|
+
modelLabel = args.model;
|
|
216
|
+
process.stdout.write(
|
|
217
|
+
`Live run: ${tasks.length} tasks × 2 arms + 2 judge calls each via ${args.model}. This costs API $.\n`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let results: PairResult[];
|
|
222
|
+
try {
|
|
223
|
+
results = run_golden_judge(tasks, contexts, answer, judge);
|
|
224
|
+
} catch (e) {
|
|
225
|
+
process.stderr.write(`error: run failed: ${(e as Error).message}\n`);
|
|
226
|
+
return 1;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const payload = {
|
|
230
|
+
generated_by: 'bench_quality_run',
|
|
231
|
+
judge_model: modelLabel,
|
|
232
|
+
dry_run: args.dryRun,
|
|
233
|
+
threshold: DEFAULT_THRESHOLD,
|
|
234
|
+
results,
|
|
235
|
+
};
|
|
236
|
+
fs.writeFileSync(args.output, JSON.stringify(payload, null, 2) + '\n');
|
|
237
|
+
const thinWins = results.filter((r) => r.winner === 'thin').length;
|
|
238
|
+
const eagerWins = results.filter((r) => r.winner === 'eager').length;
|
|
239
|
+
process.stdout.write(
|
|
240
|
+
`→ wrote ${path.relative(REPO_ROOT, args.output)} (${results.length} pairs; ` +
|
|
241
|
+
`thin ${thinWins} / eager ${eagerWins}). Gate: \`check_quality_regression\`.\n`,
|
|
242
|
+
);
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const _IS_MAIN =
|
|
247
|
+
process.argv[1] !== undefined &&
|
|
248
|
+
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
|
249
|
+
if (_IS_MAIN) {
|
|
250
|
+
process.exit(main(process.argv.slice(2)));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export { main, mock_answer };
|