@event4u/agent-config 9.21.0 → 9.23.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 +1 -1
- package/CHANGELOG.md +69 -221
- package/README.md +1 -1
- package/dist/agent-src/commands/optimize/deep.md +13 -0
- package/dist/agent-src/commands/worktree/cleanup.md +17 -0
- package/dist/agent-src/commands/worktree/status.md +6 -0
- package/dist/agent-src/contexts/execution/cheap-question-mechanics.md +12 -2
- package/dist/agent-src/contexts/execution/mandated-lines.md +180 -0
- package/dist/agent-src/rules/context-hygiene.md +17 -0
- package/dist/agent-src/rules/downstream-changes.md +26 -0
- package/dist/agent-src/rules/think-before-action.md +25 -0
- package/dist/agent-src/rules/token-budget-discipline.md +46 -3
- package/dist/agent-src/rules/token-efficiency.md +13 -0
- package/dist/agent-src/rules/ui-audit-gate.md +33 -2
- package/dist/agent-src/skills/adversarial-review/SKILL.md +18 -4
- package/dist/agent-src/skills/decision-review/SKILL.md +49 -0
- package/dist/agent-src/skills/judge-artifact-completeness/SKILL.md +5 -1
- package/dist/agent-src/skills/judge-bug-hunter/SKILL.md +16 -0
- package/dist/agent-src/skills/judge-synthesis/SKILL.md +14 -1
- package/dist/agent-src/skills/rule-writing/SKILL.md +33 -0
- package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +31 -0
- package/dist/agent-src/skills/skill-reviewer/SKILL.md +1 -1
- package/dist/agent-src/skills/worktree-lifecycle/SKILL.md +22 -0
- 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/cli-delegate/chunk-DVRS2KVR.js +4602 -0
- package/dist/cli-delegate/{chunk-ELMFOQPW.js → chunk-SJQMJI7M.js} +8 -4597
- package/dist/cli-delegate/cmd_export.js +2 -1
- package/dist/cli-delegate/cmd_prune.js +2 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -1
- package/dist/cli-delegate/cmd_settings_set.js +3370 -0
- package/dist/cli-delegate/cmd_sync.js +2 -1
- package/dist/cli-delegate/cmd_uninstall.js +2 -1
- package/dist/cli-delegate/cmd_validate.js +2 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +20 -20
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +79 -1
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/scripts/condense.js +47 -2
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/routes/settings.js +82 -0
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/shared/settingsClasses.js +191 -0
- package/dist/shared/settingsClasses.js.map +1 -0
- package/docs/contracts/settings-classes.md +342 -0
- package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +23 -2
- package/docs/guidelines/agent-infra/false-green.md +187 -0
- package/docs/guidelines/agent-infra/gate-authoring.md +177 -0
- package/package.json +3 -1
- package/src/config/ci-local-parity.yml +12 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +54 -12
- package/src/config/gate-violation-baselines.json +6 -0
- package/src/config/pack-size-budget.json +4 -3
- package/src/scripts/_cli/cmd_settings_set.ts +509 -0
- package/src/scripts/_dispatch.bash +16 -0
- package/src/scripts/_lib/bench_ab_activation.ts +368 -0
- package/src/scripts/_lib/gate_ledger.ts +295 -0
- package/src/scripts/_lib/gate_result.ts +101 -0
- package/src/scripts/_lib/gate_self_test.ts +129 -0
- package/src/scripts/_lib/measured_render.ts +97 -0
- package/src/scripts/_lib/ratchet_base_ref.ts +373 -0
- package/src/scripts/bench_ab_task_runner.ts +18 -0
- package/src/scripts/bench_ab_v2_run.ts +147 -6
- package/src/scripts/bench_ab_v2_stats.ts +74 -2
- package/src/scripts/check_gate_completeness.ts +244 -0
- package/src/scripts/check_gate_coverage.ts +35 -6
- package/src/scripts/check_references.ts +125 -15
- package/src/scripts/check_review_dispositions.ts +14 -1
- package/src/scripts/check_suppression_hygiene.ts +503 -0
- package/src/scripts/condense.ts +47 -2
- package/src/scripts/hook_manifest.yaml +23 -0
- package/src/scripts/hooks/dispatch_hook.ts +65 -1
- package/src/scripts/hooks/host_semantics.ts +140 -0
- package/src/scripts/lint_framework_leakage.ts +147 -15
- package/src/scripts/lint_mandated_lines.ts +339 -0
- package/src/scripts/lint_roadmap_complexity.ts +102 -11
- package/src/scripts/lint_roadmap_family_cap.ts +191 -0
- package/src/scripts/lint_settings_classes.ts +397 -0
- package/src/scripts/lint_token_budget_discipline.ts +109 -1
- package/src/scripts/report_imperative_density.ts +229 -0
- package/src/scripts/verify_physical_move.ts +10 -1
- package/src/scripts/worktree_cleanup_check.ts +319 -4
- package/src/server/io/yamlIO.ts +173 -0
- package/src/shared/settingsClasses.ts +235 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shrink-only, enforced against the base ref instead of against a count.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. Every ratchet in this tree is shrink-only *by convention*
|
|
5
|
+
* plus a count comparison — and a count comparison permits
|
|
6
|
+
* swap-one-out-add-one-in. Removing a real entry and adding a fresh suppression
|
|
7
|
+
* in the same change keeps the number flat, passes the gate, and moves the
|
|
8
|
+
* estate backwards. The count cannot see it, because the count is the same.
|
|
9
|
+
*
|
|
10
|
+
* The fix is to compare the entry *sets*, and to read the "before" side from
|
|
11
|
+
* the base ref rather than from a checked-in number that the same commit can
|
|
12
|
+
* edit. `git show <baseRef>:<path>` is the only reading of the baseline that
|
|
13
|
+
* the change under review cannot rewrite.
|
|
14
|
+
*
|
|
15
|
+
* **Renames are read from git.** Moving a file must not read as growth: an
|
|
16
|
+
* entry keyed on a path that git reports as a rename of a path present at base
|
|
17
|
+
* is the same entry, relocated. Without this, the first legitimate refactor
|
|
18
|
+
* that moves an allowlisted file blocks itself, the gate gets a reputation for
|
|
19
|
+
* false reds, and someone turns it off — the gate-fatigue failure this
|
|
20
|
+
* repository has already recorded.
|
|
21
|
+
*
|
|
22
|
+
* **A deliberate reset is reported, never silent.** A real tooling change can
|
|
23
|
+
* legitimately re-baseline. That path exists ({@link RATCHET_RESET_KEY}) and
|
|
24
|
+
* costs a visible one-line diff carrying a reason; what it does not do is let
|
|
25
|
+
* the reset pass unannounced.
|
|
26
|
+
*/
|
|
27
|
+
import { spawnSync } from 'node:child_process';
|
|
28
|
+
import * as fs from 'node:fs';
|
|
29
|
+
import * as path from 'node:path';
|
|
30
|
+
import process from 'node:process';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Opt-in key that marks a baseline as deliberately re-based.
|
|
34
|
+
*
|
|
35
|
+
* Present with a non-empty string reason ⇒ growth is reported rather than
|
|
36
|
+
* thrown. The key lives in the baseline JSON itself so it shows up in the diff
|
|
37
|
+
* of the very commit that performs the reset.
|
|
38
|
+
*/
|
|
39
|
+
export const RATCHET_RESET_KEY = '__ratchet_reset__';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Find a usable "before" ref, in descending order of trustworthiness.
|
|
43
|
+
*
|
|
44
|
+
* This ladder exists because the obvious answer — `origin/main` — is the one
|
|
45
|
+
* that is NOT available where it matters most. `actions/checkout` performs a
|
|
46
|
+
* shallow PR-merge fetch, so a PR build has no `origin/main` remote-tracking
|
|
47
|
+
* ref, and this repository has already recorded that `git fetch origin <base>`
|
|
48
|
+
* can race with the configured auth extraheader on some runners. The merge
|
|
49
|
+
* commit's first parent IS the base on exactly those builds, and it needs no
|
|
50
|
+
* network at all.
|
|
51
|
+
*
|
|
52
|
+
* Returns `null` when nothing resolves; callers decide, and the honest default
|
|
53
|
+
* is to fail rather than to compare against an assumed-empty base.
|
|
54
|
+
*/
|
|
55
|
+
export function resolveBaseRef(
|
|
56
|
+
repoRoot: string,
|
|
57
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
58
|
+
git: (args: readonly string[], cwd: string) => { ok: boolean; stdout: string; stderr: string } = runGit,
|
|
59
|
+
): string | null {
|
|
60
|
+
const resolves = (ref: string): boolean =>
|
|
61
|
+
git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], repoRoot).ok;
|
|
62
|
+
|
|
63
|
+
const explicit = env['RATCHET_BASE_REF'];
|
|
64
|
+
if (explicit !== undefined && explicit !== '' && resolves(explicit)) {
|
|
65
|
+
return explicit;
|
|
66
|
+
}
|
|
67
|
+
const ghBase = env['GITHUB_BASE_REF'];
|
|
68
|
+
if (ghBase !== undefined && ghBase !== '') {
|
|
69
|
+
for (const candidate of [`origin/${ghBase}`, ghBase]) {
|
|
70
|
+
if (resolves(candidate)) {
|
|
71
|
+
return candidate;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// A PR-merge checkout: HEAD is the synthetic merge, HEAD^1 is the base tip.
|
|
76
|
+
//
|
|
77
|
+
// Gated on GITHUB_ACTIONS because "HEAD has two parents" is NOT specific to
|
|
78
|
+
// a PR-merge checkout: a feature branch that merged main back in has a merge
|
|
79
|
+
// commit at its tip too, and taking HEAD^1 there compares the branch against
|
|
80
|
+
// its own previous commit — a base so recent that every real change looks
|
|
81
|
+
// like growth. Observed on the first run of this resolver.
|
|
82
|
+
if (env['GITHUB_ACTIONS'] === 'true') {
|
|
83
|
+
const parents = git(['rev-list', '--parents', '-n', '1', 'HEAD'], repoRoot);
|
|
84
|
+
if (parents.ok && parents.stdout.trim().split(/\s+/).length >= 3 && resolves('HEAD^1')) {
|
|
85
|
+
return 'HEAD^1';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const candidate of ['origin/main', 'main']) {
|
|
89
|
+
if (resolves(candidate)) {
|
|
90
|
+
return candidate;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Raised when the working copy carries entries the base ref does not. */
|
|
97
|
+
export class RatchetGrowthError extends Error {
|
|
98
|
+
readonly baselinePath: string;
|
|
99
|
+
readonly added: readonly string[];
|
|
100
|
+
|
|
101
|
+
constructor(baselinePath: string, baseRef: string, added: readonly string[]) {
|
|
102
|
+
const shown = added.slice(0, 20);
|
|
103
|
+
const more = added.length - shown.length;
|
|
104
|
+
super(
|
|
105
|
+
`${baselinePath}: ${String(added.length)} entry(ies) present in the working copy and ` +
|
|
106
|
+
`absent at ${baseRef} — this baseline is shrink-only, and a flat count is not ` +
|
|
107
|
+
'evidence of that (removing one entry while adding another keeps the number ' +
|
|
108
|
+
`identical). Added: ${shown.join(', ')}${more > 0 ? ` … and ${String(more)} more` : ''}. ` +
|
|
109
|
+
`If this is a deliberate re-baseline, add "${RATCHET_RESET_KEY}": "<reason>" to the ` +
|
|
110
|
+
'file in the same commit so the reset is reviewable instead of silent.',
|
|
111
|
+
);
|
|
112
|
+
this.name = 'RatchetGrowthError';
|
|
113
|
+
this.baselinePath = baselinePath;
|
|
114
|
+
this.added = added;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Raised when the base ref cannot be read, so no honest comparison exists. */
|
|
119
|
+
export class BaseRefUnavailableError extends Error {
|
|
120
|
+
constructor(baseRef: string, detail: string) {
|
|
121
|
+
super(
|
|
122
|
+
`cannot read the baseline at ${baseRef}: ${detail}. A ratchet that cannot see its ` +
|
|
123
|
+
'"before" has not verified anything — fetch the base ref (CI needs more than a ' +
|
|
124
|
+
'depth-1 clone) rather than treating the missing side as empty.',
|
|
125
|
+
);
|
|
126
|
+
this.name = 'BaseRefUnavailableError';
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type RatchetVerdict = 'ok' | 'growth' | 'reset' | 'new_baseline';
|
|
131
|
+
|
|
132
|
+
export interface RatchetComparison {
|
|
133
|
+
verdict: RatchetVerdict;
|
|
134
|
+
/** Entries in the working copy that are absent at the base ref. */
|
|
135
|
+
added: string[];
|
|
136
|
+
/** Entries at the base ref that are absent in the working copy — the wanted direction. */
|
|
137
|
+
removed: string[];
|
|
138
|
+
/** Entries counted as unchanged because git reports the path as a rename. */
|
|
139
|
+
renamed: Array<{ from: string; to: string }>;
|
|
140
|
+
baseCount: number;
|
|
141
|
+
headCount: number;
|
|
142
|
+
/** Present only when the baseline carries {@link RATCHET_RESET_KEY}. */
|
|
143
|
+
resetReason?: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface RatchetBaseRefOptions {
|
|
147
|
+
/** Repo-relative path to the baseline file. */
|
|
148
|
+
baselinePath: string;
|
|
149
|
+
/** Ref to read the "before" side from, e.g. `origin/main`. */
|
|
150
|
+
baseRef: string;
|
|
151
|
+
/** Absolute repo root. */
|
|
152
|
+
repoRoot: string;
|
|
153
|
+
/**
|
|
154
|
+
* Extract the comparable entry keys from a parsed baseline.
|
|
155
|
+
*
|
|
156
|
+
* Defaults to {@link defaultEntriesOf}, which covers the two shapes this
|
|
157
|
+
* repo actually uses: a JSON array of strings, and a JSON object keyed by
|
|
158
|
+
* entry. A baseline with a nested shape passes its own extractor.
|
|
159
|
+
*/
|
|
160
|
+
entriesOf?: (parsed: unknown) => string[];
|
|
161
|
+
/**
|
|
162
|
+
* Accept a baseline that does not exist at `baseRef` as genuinely new.
|
|
163
|
+
*
|
|
164
|
+
* Off by default: absent-at-base and mistyped-path look identical, and the
|
|
165
|
+
* silent-pass consequence of guessing wrong is the failure this module is
|
|
166
|
+
* built against. Turn it on for exactly the one change that introduces the
|
|
167
|
+
* baseline, then remove it.
|
|
168
|
+
*/
|
|
169
|
+
allowNewBaseline?: boolean;
|
|
170
|
+
/** Injection seam for tests; defaults to a real `git` invocation. */
|
|
171
|
+
git?: (args: readonly string[], cwd: string) => { ok: boolean; stdout: string; stderr: string };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Run git, capturing output without throwing on a non-zero exit. */
|
|
175
|
+
function runGit(args: readonly string[], cwd: string): { ok: boolean; stdout: string; stderr: string } {
|
|
176
|
+
const res = spawnSync('git', [...args], { cwd, encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024 });
|
|
177
|
+
if (res.error) {
|
|
178
|
+
return { ok: false, stdout: '', stderr: res.error.message };
|
|
179
|
+
}
|
|
180
|
+
return { ok: res.status === 0, stdout: res.stdout ?? '', stderr: res.stderr ?? '' };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Entry keys for the two baseline shapes in this repository.
|
|
185
|
+
*
|
|
186
|
+
* - array of strings → the strings themselves;
|
|
187
|
+
* - array of objects → each object's `path`, `id`, `key`, `entry`, or `file`
|
|
188
|
+
* field, whichever it carries first, falling back to its stable JSON form;
|
|
189
|
+
* - object → its own keys.
|
|
190
|
+
*
|
|
191
|
+
* {@link RATCHET_RESET_KEY} is never an entry.
|
|
192
|
+
*/
|
|
193
|
+
export function defaultEntriesOf(parsed: unknown): string[] {
|
|
194
|
+
if (Array.isArray(parsed)) {
|
|
195
|
+
return parsed.map((item) => {
|
|
196
|
+
if (typeof item === 'string') {
|
|
197
|
+
return item;
|
|
198
|
+
}
|
|
199
|
+
if (item !== null && typeof item === 'object') {
|
|
200
|
+
const rec = item as Record<string, unknown>;
|
|
201
|
+
for (const field of ['path', 'id', 'key', 'entry', 'file']) {
|
|
202
|
+
const v = rec[field];
|
|
203
|
+
if (typeof v === 'string' && v !== '') {
|
|
204
|
+
return v;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return JSON.stringify(item);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
212
|
+
return Object.keys(parsed as Record<string, unknown>).filter((k) => k !== RATCHET_RESET_KEY);
|
|
213
|
+
}
|
|
214
|
+
return [];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function resetReasonOf(parsed: unknown): string | undefined {
|
|
218
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
219
|
+
const v = (parsed as Record<string, unknown>)[RATCHET_RESET_KEY];
|
|
220
|
+
if (typeof v === 'string' && v.trim() !== '') {
|
|
221
|
+
return v.trim();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Map every renamed path at HEAD back to its name at `baseRef`.
|
|
229
|
+
*
|
|
230
|
+
* `-M` is what makes a moved allowlist entry read as the same entry rather
|
|
231
|
+
* than as one deletion plus one addition.
|
|
232
|
+
*/
|
|
233
|
+
function renameMap(
|
|
234
|
+
baseRef: string,
|
|
235
|
+
repoRoot: string,
|
|
236
|
+
git: NonNullable<RatchetBaseRefOptions['git']>,
|
|
237
|
+
): Map<string, string> {
|
|
238
|
+
const out = new Map<string, string>();
|
|
239
|
+
const res = git(['diff', '--name-status', '-M', `${baseRef}...HEAD`], repoRoot);
|
|
240
|
+
if (!res.ok) {
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
for (const line of res.stdout.split('\n')) {
|
|
244
|
+
const parts = line.split('\t');
|
|
245
|
+
const status = parts[0];
|
|
246
|
+
if (status === undefined || !status.startsWith('R')) {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const from = parts[1];
|
|
250
|
+
const to = parts[2];
|
|
251
|
+
if (from !== undefined && to !== undefined) {
|
|
252
|
+
out.set(to, from);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Compare a baseline's entry set against the same file at `baseRef`.
|
|
260
|
+
*
|
|
261
|
+
* @throws {BaseRefUnavailableError} when the base ref cannot be read.
|
|
262
|
+
*/
|
|
263
|
+
export function compareToBaseRef(opts: RatchetBaseRefOptions): RatchetComparison {
|
|
264
|
+
const git = opts.git ?? runGit;
|
|
265
|
+
const entriesOf = opts.entriesOf ?? defaultEntriesOf;
|
|
266
|
+
const absolute = path.join(opts.repoRoot, opts.baselinePath);
|
|
267
|
+
|
|
268
|
+
let headRaw: string;
|
|
269
|
+
try {
|
|
270
|
+
headRaw = fs.readFileSync(absolute, 'utf-8');
|
|
271
|
+
} catch (e) {
|
|
272
|
+
throw new BaseRefUnavailableError(opts.baseRef, `working-copy baseline unreadable: ${String(e)}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const refCheck = git(['rev-parse', '--verify', '--quiet', `${opts.baseRef}^{commit}`], opts.repoRoot);
|
|
276
|
+
if (!refCheck.ok) {
|
|
277
|
+
throw new BaseRefUnavailableError(opts.baseRef, 'ref does not resolve in this clone');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// A baseline absent at base is EITHER a genuinely new ratchet OR a typo in
|
|
281
|
+
// `baselinePath`. Those two are indistinguishable from here, and treating
|
|
282
|
+
// the missing side as an empty set would make a mistyped path pass forever
|
|
283
|
+
// while comparing against nothing — the dead-scan-root shape, one level up.
|
|
284
|
+
// So it is fail-closed, and introducing a new baseline is an explicit
|
|
285
|
+
// opt-in the reviewer sees at the call site.
|
|
286
|
+
const show = git(['show', `${opts.baseRef}:${opts.baselinePath}`], opts.repoRoot);
|
|
287
|
+
if (!show.ok && opts.allowNewBaseline !== true) {
|
|
288
|
+
throw new BaseRefUnavailableError(
|
|
289
|
+
opts.baseRef,
|
|
290
|
+
`no such file at that ref (${opts.baselinePath}). If the baseline is genuinely new in ` +
|
|
291
|
+
'this change, pass allowNewBaseline; if it is not, the path is wrong and this gate ' +
|
|
292
|
+
'would otherwise compare against an empty set forever',
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
const baseEntries = show.ok ? entriesOf(JSON.parse(show.stdout)) : [];
|
|
296
|
+
|
|
297
|
+
const headParsed: unknown = JSON.parse(headRaw);
|
|
298
|
+
const headEntries = entriesOf(headParsed);
|
|
299
|
+
|
|
300
|
+
const renames = renameMap(opts.baseRef, opts.repoRoot, git);
|
|
301
|
+
const baseSet = new Set(baseEntries);
|
|
302
|
+
const headSet = new Set(headEntries);
|
|
303
|
+
|
|
304
|
+
const added: string[] = [];
|
|
305
|
+
const renamed: Array<{ from: string; to: string }> = [];
|
|
306
|
+
for (const entry of headEntries) {
|
|
307
|
+
if (baseSet.has(entry)) {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const previous = renames.get(entry);
|
|
311
|
+
if (previous !== undefined && baseSet.has(previous)) {
|
|
312
|
+
renamed.push({ from: previous, to: entry });
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
added.push(entry);
|
|
316
|
+
}
|
|
317
|
+
const removed = baseEntries.filter((e) => !headSet.has(e) && !renamed.some((r) => r.from === e));
|
|
318
|
+
|
|
319
|
+
const resetReason = resetReasonOf(headParsed);
|
|
320
|
+
let verdict: RatchetVerdict;
|
|
321
|
+
if (!show.ok) {
|
|
322
|
+
verdict = 'new_baseline';
|
|
323
|
+
} else if (added.length === 0) {
|
|
324
|
+
verdict = 'ok';
|
|
325
|
+
} else {
|
|
326
|
+
verdict = resetReason !== undefined ? 'reset' : 'growth';
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const comparison: RatchetComparison = {
|
|
330
|
+
verdict,
|
|
331
|
+
added,
|
|
332
|
+
removed,
|
|
333
|
+
renamed,
|
|
334
|
+
baseCount: baseEntries.length,
|
|
335
|
+
headCount: headEntries.length,
|
|
336
|
+
};
|
|
337
|
+
if (resetReason !== undefined) {
|
|
338
|
+
comparison.resetReason = resetReason;
|
|
339
|
+
}
|
|
340
|
+
return comparison;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Assert a baseline gained no entries relative to `baseRef`.
|
|
345
|
+
*
|
|
346
|
+
* @throws {RatchetGrowthError} on growth without a declared reset.
|
|
347
|
+
* @throws {BaseRefUnavailableError} when the base ref cannot be read.
|
|
348
|
+
*/
|
|
349
|
+
export function assertNoNewEntries(opts: RatchetBaseRefOptions): RatchetComparison {
|
|
350
|
+
const comparison = compareToBaseRef(opts);
|
|
351
|
+
if (comparison.verdict === 'growth') {
|
|
352
|
+
throw new RatchetGrowthError(opts.baselinePath, opts.baseRef, comparison.added);
|
|
353
|
+
}
|
|
354
|
+
return comparison;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* One-line human summary of a comparison, for a gate's green path.
|
|
359
|
+
*
|
|
360
|
+
* A reset is stated out loud, because a re-baseline that reads like a normal
|
|
361
|
+
* pass is the same silence this module removes.
|
|
362
|
+
*/
|
|
363
|
+
export function describeComparison(baselinePath: string, c: RatchetComparison): string {
|
|
364
|
+
const base = `${baselinePath}: ${String(c.headCount)} entry(ies), ${String(c.baseCount)} at base`;
|
|
365
|
+
const moved = c.renamed.length > 0 ? `, ${String(c.renamed.length)} renamed` : '';
|
|
366
|
+
if (c.verdict === 'new_baseline') {
|
|
367
|
+
return `${base}${moved} — NEW BASELINE (absent at base; nothing to ratchet against yet)`;
|
|
368
|
+
}
|
|
369
|
+
if (c.verdict === 'reset') {
|
|
370
|
+
return `${base}${moved} — RE-BASELINED (+${String(c.added.length)}): ${c.resetReason ?? ''}`;
|
|
371
|
+
}
|
|
372
|
+
return `${base}${moved}, ${String(c.removed.length)} removed since base`;
|
|
373
|
+
}
|
|
@@ -194,6 +194,12 @@ interface RunResult {
|
|
|
194
194
|
tokens?: number;
|
|
195
195
|
/** Empty object `{}` on dry-run (key absent in Python); breakdown otherwise. */
|
|
196
196
|
tokens_breakdown?: TokensBreakdown | Record<string, never>;
|
|
197
|
+
/**
|
|
198
|
+
* Model ids the envelope actually billed (`modelUsage` keys) — the only
|
|
199
|
+
* place a run states which model answered it. Empty when the envelope
|
|
200
|
+
* omits the block. Feeds the delta-#3 model-id check.
|
|
201
|
+
*/
|
|
202
|
+
models_seen?: string[];
|
|
197
203
|
errored?: boolean;
|
|
198
204
|
num_turns?: number;
|
|
199
205
|
subtype?: string;
|
|
@@ -295,6 +301,7 @@ export function run_live(
|
|
|
295
301
|
const returncode = result.status ?? -1;
|
|
296
302
|
let transcript: string = stdout;
|
|
297
303
|
let tokens = 0;
|
|
304
|
+
let modelsSeen: string[] = [];
|
|
298
305
|
let isError = false;
|
|
299
306
|
let errReason = 'ok';
|
|
300
307
|
let numTurns = 0;
|
|
@@ -337,6 +344,16 @@ export function run_live(
|
|
|
337
344
|
breakdown.output_tokens +
|
|
338
345
|
breakdown.cache_read_input_tokens +
|
|
339
346
|
breakdown.cache_creation_input_tokens;
|
|
347
|
+
// `modelUsage` is keyed by the model ids the run actually billed. Record
|
|
348
|
+
// them unconditionally: the delta-#3 check needs the id even on a run
|
|
349
|
+
// whose top-level `usage` was fine, and a mid-sweep model swap is exactly
|
|
350
|
+
// the case where the totals look ordinary.
|
|
351
|
+
modelsSeen = Object.keys(
|
|
352
|
+
(_isPlainObj(obj['modelUsage']) ? (obj['modelUsage'] as Record<string, unknown>) : {}) as Record<
|
|
353
|
+
string,
|
|
354
|
+
unknown
|
|
355
|
+
>,
|
|
356
|
+
);
|
|
340
357
|
// The top-level `usage` block is zeroed on a budget-capped / errored run
|
|
341
358
|
// (and unreliable even on some completions). `modelUsage` carries the
|
|
342
359
|
// authoritative per-model counts — sum it as the fallback so token deltas
|
|
@@ -387,6 +404,7 @@ export function run_live(
|
|
|
387
404
|
wall_time_seconds: _pyRound(duration, 3),
|
|
388
405
|
tokens,
|
|
389
406
|
tokens_breakdown: breakdown,
|
|
407
|
+
models_seen: modelsSeen,
|
|
390
408
|
errored: isError || returncode !== 0,
|
|
391
409
|
num_turns: numTurns,
|
|
392
410
|
subtype,
|