@nexus-cortex/cli 4.129.1 → 4.131.1
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/.cortex/agents/autoresearch-agent.md +7 -1
- package/.cortex/agents/web-researcher.md +1 -2
- package/.cortex/permissions.prod.json +2 -1
- package/.env.defaults +168 -2
- package/bin/cortex.js +22 -8
- package/dist/agent-mode.js +103 -5
- package/dist/agent-mode.js.map +1 -1
- package/dist/commands/autoresearch/approachPlan.d.ts +30 -0
- package/dist/commands/autoresearch/approachPlan.d.ts.map +1 -0
- package/dist/commands/autoresearch/approachPlan.js +54 -0
- package/dist/commands/autoresearch/approachPlan.js.map +1 -0
- package/dist/commands/autoresearch/experiment.d.ts +5 -0
- package/dist/commands/autoresearch/experiment.d.ts.map +1 -1
- package/dist/commands/autoresearch/experiment.js +14 -0
- package/dist/commands/autoresearch/experiment.js.map +1 -1
- package/dist/commands/autoresearch/loop.d.ts +30 -0
- package/dist/commands/autoresearch/loop.d.ts.map +1 -1
- package/dist/commands/autoresearch/loop.js +382 -37
- package/dist/commands/autoresearch/loop.js.map +1 -1
- package/dist/commands/browser/index.d.ts +48 -0
- package/dist/commands/browser/index.d.ts.map +1 -0
- package/dist/commands/browser/index.js +116 -0
- package/dist/commands/browser/index.js.map +1 -0
- package/dist/commands/canon/pipeline.js +2 -2
- package/dist/commands/canon/pipeline.js.map +1 -1
- package/dist/commands/secrets/index.d.ts +3 -0
- package/dist/commands/secrets/index.d.ts.map +1 -0
- package/dist/commands/secrets/index.js +196 -0
- package/dist/commands/secrets/index.js.map +1 -0
- package/dist/commands/setup/index.d.ts +4 -0
- package/dist/commands/setup/index.d.ts.map +1 -1
- package/dist/commands/setup/index.js +144 -15
- package/dist/commands/setup/index.js.map +1 -1
- package/dist/index.js +62 -10
- package/dist/index.js.map +1 -1
- package/dist/lifecycle/toolsInstall.d.ts.map +1 -1
- package/dist/lifecycle/toolsInstall.js +2 -0
- package/dist/lifecycle/toolsInstall.js.map +1 -1
- package/dist/orchestrator/OrchestratorClient.d.ts +4 -2
- package/dist/orchestrator/OrchestratorClient.d.ts.map +1 -1
- package/dist/orchestrator/OrchestratorClient.js +14 -8
- package/dist/orchestrator/OrchestratorClient.js.map +1 -1
- package/dist/themes/colors.d.ts +6 -0
- package/dist/themes/colors.d.ts.map +1 -1
- package/dist/themes/colors.js +11 -0
- package/dist/themes/colors.js.map +1 -1
- package/package.json +3 -3
|
@@ -13,16 +13,49 @@
|
|
|
13
13
|
* --missing-provider-key-policy semantics).
|
|
14
14
|
* 3. Mutate each arm: --fixer-cmd (any transformer; sees CORTEX_ARM_INDEX/MODEL/STRATEGY
|
|
15
15
|
* in its environment), else the LLM `cortex autoresearch fix --model <arm model>`.
|
|
16
|
-
* 4. Commit each candidate (no change → that arm is skipped).
|
|
16
|
+
* 4. Commit each candidate (no change → that arm is skipped). EVAL GUARD (core EvalGuard.ts):
|
|
17
|
+
* the task-sets were sealed into a private copy at loop start and every experiment
|
|
18
|
+
* grades against it; in-repo task-sets are hidden from the Fixer's worktree; a
|
|
19
|
+
* candidate that touches a protected path (the task-sets, --protect) or strays outside
|
|
20
|
+
* --editable is refused unbenched (skipped: 'eval-guard').
|
|
17
21
|
* 5. Run `cortex autoresearch experiment` per arm (base vs candidate, FWER-adjusted with
|
|
18
22
|
* --n-family = the number of arms launched) and read the verdicts.
|
|
19
23
|
* 6. ACCEPT = mergeEligible when a holdout is given (keep + FWER + holdout-verified); with
|
|
20
24
|
* no holdout, accept = keep-on-train (logged as UNVERIFIED — provide --holdout-set for
|
|
21
25
|
* the overfitting guard). The WINNER is the gate-accepted arm with the highest effect;
|
|
22
26
|
* with --require-judge the judge reviews accepted arms in effect order until one is
|
|
23
|
-
* approved.
|
|
27
|
+
* approved. CONFIRM (default on, --no-confirm to skip): the selected winner is
|
|
28
|
+
* re-benched base vs candidate on FRESH train runs in a separate store (no pooling with
|
|
29
|
+
* the screening records) with a new gate seed — choosing the best of N noisy arms
|
|
30
|
+
* inflates its effect (winner's curse) and FWER does not remove that bias. The fresh
|
|
31
|
+
* run is judged by core decideConfirmation (--confirm-mode, default 'pooled': the
|
|
32
|
+
* screening and confirmation p-values Stouffer-combined at the gate's own bar, plus a
|
|
33
|
+
* fresh effect > 0 — protects against lucky winners without demanding significance
|
|
34
|
+
* twice). One confirmation per round. A winner that passes screening but fails
|
|
35
|
+
* confirmation is a NEAR-MISS: its commit is kept under
|
|
36
|
+
* refs/autoresearch/near-miss/<branch>/r<round>-a<arm> and a row goes to
|
|
37
|
+
* <store>/.cortex/autoresearch-near-misses.jsonl, so it can be re-tested with more
|
|
38
|
+
* runs. On accept: advance base to the winner + anchor the loop branch to it.
|
|
24
39
|
* Losing/rejected arms are dropped.
|
|
40
|
+
* GUARDS (B4, core AutoResearchGuards, --guards block|warn|off, default block): a gain on
|
|
41
|
+
* the aggregate must not hide a loss in one task family. A candidate is blocked when a
|
|
42
|
+
* family's loss is material AND evident (estimate < -margin and its one-sided upper
|
|
43
|
+
* bound < 0, alpha split across families) — per STEP (vs its base, --guard-margin 2)
|
|
44
|
+
* and as a running TOTAL from the round-0 base (Σ of merged steps' family effects,
|
|
45
|
+
* --guard-total-margin 5). The confirmation run's own family effects are checked too
|
|
46
|
+
* and are what the running total records.
|
|
47
|
+
* APPROACHES + ATTEMPTS (B6, approachPlan.ts): with --width > 1 each arm's prompt names a
|
|
48
|
+
* different approach from a rotating queue; the Fixer may declare known flaws in
|
|
49
|
+
* .autoresearch-flaws (read + removed before commit; such an arm is benched, never
|
|
50
|
+
* merged); every arm that does not merge is kept under refs/autoresearch/attempts/…
|
|
51
|
+
* with a row in <store>/.cortex/autoresearch-attempts.jsonl saying why.
|
|
52
|
+
* RUNS: every experiment reports a run plan (core planRuns: the smallest effect its runs
|
|
53
|
+
* can detect, and the runs per arm needed for --target-effect). The loop warns when
|
|
54
|
+
* underpowered; --auto-runs raises the runs for later rounds (capped by --max-runs).
|
|
25
55
|
* 7. Stop on: success metric met, max rounds, max consecutive stale rounds, or a dry backlog.
|
|
56
|
+
* The success metric reads TRAIN scores (the confirmation's when it ran), never the
|
|
57
|
+
* holdout: holdout results only gate the merge, so repeated rounds cannot steer the
|
|
58
|
+
* loop toward the holdout (adaptive overfitting).
|
|
26
59
|
*
|
|
27
60
|
* GIT SAFETY: all work happens in throwaway worktrees off a dedicated loop branch
|
|
28
61
|
* (`autoresearch/loop-*`). The user's branch and working tree are never touched. Accepted
|
|
@@ -30,13 +63,14 @@
|
|
|
30
63
|
* worktree removal; the operator merges the loop branch when satisfied.
|
|
31
64
|
*/
|
|
32
65
|
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
|
33
|
-
import { mkdtempSync } from 'node:fs';
|
|
66
|
+
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
34
67
|
import { tmpdir } from 'node:os';
|
|
35
68
|
import { join, resolve } from 'node:path';
|
|
36
|
-
import { ResearchBacklog } from '@nexus-cortex/core';
|
|
69
|
+
import { ResearchBacklog, checkCandidateChanges, decideConfirmation, describeViolations, protectGlobFor, sealTaskSet, taskIdsIn, stepGuards, totalGuards, recordDrift, describeGuardBlocks, } from '@nexus-cortex/core';
|
|
37
70
|
import { ThemeManager } from '../../themes/ThemeManager.js';
|
|
38
71
|
import { resolveRepoDir, resolveTaskSet } from './repoResolve.js';
|
|
39
72
|
import { planArms, parseMissingKeyPolicy } from './armPlan.js';
|
|
73
|
+
import { DEFAULT_APPROACHES, FLAWS_FILE, approachesForRound, fixerPrompt, parseApproaches } from './approachPlan.js';
|
|
40
74
|
/** Default judge rubric used when --require-judge is set without an explicit
|
|
41
75
|
* --judge-rubric / --judge-rubric-file. Tuned for the autoresearch loop: the
|
|
42
76
|
* statistical gate already proved a measurable, holdout-verified gain, so the
|
|
@@ -121,6 +155,74 @@ export async function autoResearchLoop(options) {
|
|
|
121
155
|
const successMetric = options.successMetric
|
|
122
156
|
? { taskId: options.successMetric.split(':')[0], threshold: Number(options.successMetric.split(':')[1]) }
|
|
123
157
|
: undefined;
|
|
158
|
+
const splitList = (s) => s?.split(',').map((x) => x.trim()).filter(Boolean);
|
|
159
|
+
// ---- Eval guard: seal the task-sets, hide in-repo copies, protect paths ------
|
|
160
|
+
const sealDir = mkdtempSync(join(tmpdir(), 'arseal-'));
|
|
161
|
+
let trainSet;
|
|
162
|
+
let holdoutSet;
|
|
163
|
+
try {
|
|
164
|
+
trainSet = sealTaskSet(resolveTaskSet(options.taskSet, repo), sealDir, 'train', repo);
|
|
165
|
+
holdoutSet = options.holdoutSet ? sealTaskSet(resolveTaskSet(options.holdoutSet, repo), sealDir, 'holdout', repo) : undefined;
|
|
166
|
+
}
|
|
167
|
+
catch (e) {
|
|
168
|
+
console.error(theme.colors.error(`Error: cannot read task-set: ${e.message}`));
|
|
169
|
+
process.exit(1);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const sealedSets = [trainSet, holdoutSet].filter((x) => !!x);
|
|
173
|
+
const hiddenPaths = sealedSets.map((x) => x.inRepo).filter((x) => !!x);
|
|
174
|
+
const guard = {
|
|
175
|
+
protect: [...sealedSets.filter((x) => x.inRepo).map((x) => protectGlobFor(x.inRepo, x.isDir)), ...(splitList(options.protect) ?? [])],
|
|
176
|
+
editable: splitList(options.editable) ?? [],
|
|
177
|
+
};
|
|
178
|
+
emit({
|
|
179
|
+
event: 'eval_seal',
|
|
180
|
+
train: { source: trainSet.source, sha256: trainSet.sha256, inRepo: trainSet.inRepo ?? null },
|
|
181
|
+
...(holdoutSet ? { holdout: { source: holdoutSet.source, sha256: holdoutSet.sha256, inRepo: holdoutSet.inRepo ?? null } } : {}),
|
|
182
|
+
protect: guard.protect, editable: guard.editable,
|
|
183
|
+
});
|
|
184
|
+
if (holdoutSet?.inRepo) {
|
|
185
|
+
const msg = `eval-guard: the holdout set is inside the repo (${holdoutSet.inRepo}) — it is hidden from the Fixer's worktree and protected, but still readable from git history; keep it outside the repo for real secrecy`;
|
|
186
|
+
log(msg);
|
|
187
|
+
emit({ event: 'warning', message: msg });
|
|
188
|
+
}
|
|
189
|
+
if (successMetric && !taskIdsIn(trainSet.sealed).has(successMetric.taskId)) {
|
|
190
|
+
const msg = `--success-metric task '${successMetric.taskId}' is not in the train set — the success stop reads train scores only (never the holdout), so it will never fire`;
|
|
191
|
+
log(msg);
|
|
192
|
+
emit({ event: 'warning', message: msg });
|
|
193
|
+
}
|
|
194
|
+
// ---- Confirmation + run planning ------------------------------------------
|
|
195
|
+
const confirmMode = (options.confirmMode ?? 'pooled');
|
|
196
|
+
if (!['pooled', 'direction', 'strict'].includes(confirmMode)) {
|
|
197
|
+
console.error(theme.colors.error(`Error: --confirm-mode must be pooled | direction | strict (got '${options.confirmMode}')`));
|
|
198
|
+
process.exit(1);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const confirmLossMargin = options.confirmLossMargin != null ? Number(options.confirmLossMargin) : undefined;
|
|
202
|
+
const maxRuns = Number(options.maxRuns ?? '12');
|
|
203
|
+
let runsPerArm = Number(options.runs ?? '3');
|
|
204
|
+
const nearMissLog = join(store, '.cortex', 'autoresearch-near-misses.jsonl');
|
|
205
|
+
const guardMode = options.guards ?? 'block';
|
|
206
|
+
if (!['block', 'warn', 'off'].includes(guardMode)) {
|
|
207
|
+
console.error(theme.colors.error(`Error: --guards must be block | warn | off (got '${options.guards}')`));
|
|
208
|
+
process.exit(1);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const guardOpts = {
|
|
212
|
+
margin: options.guardMargin != null ? Number(options.guardMargin) : undefined,
|
|
213
|
+
totalMargin: options.guardTotalMargin != null ? Number(options.guardTotalMargin) : undefined,
|
|
214
|
+
};
|
|
215
|
+
// B6: distinct approaches per arm (rotating queue); losing attempts are kept.
|
|
216
|
+
const approaches = options.approaches === false ? []
|
|
217
|
+
: typeof options.approaches === 'string' && options.approaches.trim() ? parseApproaches(options.approaches)
|
|
218
|
+
: []; // filled below once the width is known
|
|
219
|
+
const attemptsLog = join(store, '.cortex', 'autoresearch-attempts.jsonl');
|
|
220
|
+
const attempts = [];
|
|
221
|
+
/** Per-family drift from the round-0 base: Σ of merged steps' family effects. */
|
|
222
|
+
const drift = new Map();
|
|
223
|
+
/** Step + total guard verdicts for one measurement; [] when guards are off. */
|
|
224
|
+
const guardCheck = (families) => guardMode === 'off' || !families?.length ? [] : [...stepGuards(families, guardOpts), ...totalGuards(drift, families, guardOpts)];
|
|
225
|
+
const nearMisses = [];
|
|
124
226
|
// ---- Multi-provider arm plan (width 1 = classic single-arm loop) ------------
|
|
125
227
|
let policy;
|
|
126
228
|
try {
|
|
@@ -131,7 +233,6 @@ export async function autoResearchLoop(options) {
|
|
|
131
233
|
process.exit(1);
|
|
132
234
|
return;
|
|
133
235
|
}
|
|
134
|
-
const splitList = (s) => s?.split(',').map((x) => x.trim()).filter(Boolean);
|
|
135
236
|
const plan = planArms({
|
|
136
237
|
width: Number(options.width ?? '1'),
|
|
137
238
|
baseModel: options.model,
|
|
@@ -141,6 +242,11 @@ export async function autoResearchLoop(options) {
|
|
|
141
242
|
policy,
|
|
142
243
|
});
|
|
143
244
|
const width = plan.arms.length;
|
|
245
|
+
if (options.approaches !== false && !approaches.length && width > 1)
|
|
246
|
+
approaches.push(...DEFAULT_APPROACHES);
|
|
247
|
+
if (approaches.length && width > approaches.length) {
|
|
248
|
+
log(`approaches: width ${width} > ${approaches.length} approaches — some arms in a round share one`);
|
|
249
|
+
}
|
|
144
250
|
if (width > 1 || plan.notes.length) {
|
|
145
251
|
emit({ event: 'arm_plan', width, arms: plan.arms, notes: plan.notes });
|
|
146
252
|
for (const n of plan.notes)
|
|
@@ -170,21 +276,161 @@ export async function autoResearchLoop(options) {
|
|
|
170
276
|
let stop = 'max-rounds';
|
|
171
277
|
if (!json)
|
|
172
278
|
console.log(`\n ${theme.colors.highlight('Auto-research loop')} repo=${repo} branch=${branch} base=${baseRef.slice(0, 8)} width=${width} max=${maxRounds}\n`);
|
|
279
|
+
/** `cortex autoresearch experiment` args for base vs one candidate, graded against the
|
|
280
|
+
* SEALED task-sets. `holdout:false` = train only (the confirmation run). */
|
|
281
|
+
const experimentArgs = (a) => {
|
|
282
|
+
const args = ['autoresearch', 'experiment',
|
|
283
|
+
'--experiment-tag', a.tag,
|
|
284
|
+
'--base-dir', baseDir, '--candidate-dir', a.candDir,
|
|
285
|
+
'--base-ref', git(['rev-parse', '--short', 'HEAD'], baseDir), '--candidate-ref', a.candRef,
|
|
286
|
+
'--task-set', trainSet.sealed, '--no-build',
|
|
287
|
+
'--n-family', String(a.nFamily),
|
|
288
|
+
'--cortex-dir', a.store, '--runs', String(runsPerArm), '--json'];
|
|
289
|
+
if (options.targetEffect)
|
|
290
|
+
args.push('--target-effect', options.targetEffect);
|
|
291
|
+
if (options.power)
|
|
292
|
+
args.push('--power', options.power);
|
|
293
|
+
if (a.holdout && holdoutSet)
|
|
294
|
+
args.push('--holdout-set', holdoutSet.sealed);
|
|
295
|
+
if (a.seed != null)
|
|
296
|
+
args.push('--seed', String(a.seed));
|
|
297
|
+
if (options.model)
|
|
298
|
+
args.push('--model', options.model);
|
|
299
|
+
if (options.temperature)
|
|
300
|
+
args.push('--temperature', options.temperature);
|
|
301
|
+
if (a.strategy)
|
|
302
|
+
args.push('--strategy', a.strategy);
|
|
303
|
+
if (options.runCmd) {
|
|
304
|
+
args.push('--run-cmd', options.runCmd, '--accept-exit', options.acceptExit ?? '0');
|
|
305
|
+
if (options.buildCmd)
|
|
306
|
+
args.push('--build-cmd', options.buildCmd);
|
|
307
|
+
}
|
|
308
|
+
return args;
|
|
309
|
+
};
|
|
310
|
+
/** Re-bench the selected winner on FRESH train runs in a private store (so the gate sees
|
|
311
|
+
* only the new records) with a new seed; it must KEEP again. Never throws. */
|
|
312
|
+
const confirmWinner = async (w, r) => {
|
|
313
|
+
const confirmStore = mkdtempSync(join(tmpdir(), 'arconfirm-'));
|
|
314
|
+
try {
|
|
315
|
+
const args = experimentArgs({ tag: `${w.tag}-confirm`, candDir: w.candDir, candRef: w.candRef, store: confirmStore, nFamily: 1, holdout: false, strategy: w.strategy, seed: 7919 * r + w.arm });
|
|
316
|
+
emit({ event: 'confirm_start', round: r, arm: w.arm, candidateRef: w.candRef });
|
|
317
|
+
const exp = await spawnAsync('node', [self, ...args], { stdio: 'capture' });
|
|
318
|
+
let res;
|
|
319
|
+
try {
|
|
320
|
+
res = JSON.parse(exp.stdout);
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
res = undefined;
|
|
324
|
+
}
|
|
325
|
+
if (!res?.verdict) {
|
|
326
|
+
const reason = String(res?.error ?? ((exp.stderr || '').trim().split('\n').filter(Boolean).slice(-3).join(' ') || `exit ${exp.status}`)).slice(0, 500);
|
|
327
|
+
return { confirmed: false, mode: confirmMode, decision: 'no-verdict', reason, runs: runsPerArm };
|
|
328
|
+
}
|
|
329
|
+
const fresh = res.verdict;
|
|
330
|
+
const d = decideConfirmation(w.res.verdict, fresh, { mode: confirmMode, lossMargin: confirmLossMargin });
|
|
331
|
+
// The fresh run's own family effects must not show an evident family loss either.
|
|
332
|
+
const families = res.families;
|
|
333
|
+
const blocks = guardCheck(families).filter((g) => g.blocked);
|
|
334
|
+
const guardFail = guardMode === 'block' && blocks.length > 0;
|
|
335
|
+
return {
|
|
336
|
+
confirmed: d.confirmed && !guardFail, mode: d.mode, decision: fresh.decision, effect: fresh.effect, pValue: fresh.pValue,
|
|
337
|
+
ciLow: fresh.ciLow, combinedP: d.combinedP,
|
|
338
|
+
reason: guardFail ? `${d.reason}; GUARD on fresh run: ${describeGuardBlocks(blocks)}` : d.reason,
|
|
339
|
+
runs: runsPerArm, trainSummary: res.benchSummaries?.candidate?.train, families,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
finally {
|
|
343
|
+
rmSync(confirmStore, { recursive: true, force: true });
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
/** A winner that passed screening (and the judge) but not its confirmation: keep its
|
|
347
|
+
* commit under a ref and log a row, so it can be re-tested with more runs. */
|
|
348
|
+
const recordNearMiss = (w, r, c) => {
|
|
349
|
+
const ref = `refs/autoresearch/near-miss/${branch}/r${r}-a${w.arm}`;
|
|
350
|
+
try {
|
|
351
|
+
git(['update-ref', ref, w.candRef]);
|
|
352
|
+
}
|
|
353
|
+
catch { /* best effort; the row still records the sha */ }
|
|
354
|
+
const sha = (() => { try {
|
|
355
|
+
return git(['rev-parse', w.candRef]);
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
return w.candRef;
|
|
359
|
+
} })();
|
|
360
|
+
const recommended = Math.max(runsPerArm * 2, w.res?.runPlan?.requiredRunsPerArm ?? 0);
|
|
361
|
+
const row = {
|
|
362
|
+
ts: new Date().toISOString(), branch, round: r, arm: w.arm, model: w.model ?? null,
|
|
363
|
+
ref, candidateSha: sha, baseRef,
|
|
364
|
+
screening: { decision: w.res?.verdict?.decision, effect: w.res?.verdict?.effect, pValue: w.res?.verdict?.pValue, ciLow: w.res?.verdict?.ciLow, ciHigh: w.res?.verdict?.ciHigh, alphaAdjusted: w.res?.verdict?.alphaAdjusted, mergeEligible: !!w.res?.mergeEligible },
|
|
365
|
+
confirmation: { mode: c.mode, decision: c.decision, effect: c.effect, pValue: c.pValue, ciLow: c.ciLow, combinedP: c.combinedP, reason: c.reason, runs: c.runs },
|
|
366
|
+
runPlan: w.res?.runPlan ?? null,
|
|
367
|
+
retest: `cortex autoresearch experiment --base-ref ${baseRef} --candidate-ref ${ref} --runs ${recommended} … (check out both refs as worktrees; graded against the same task-set)`,
|
|
368
|
+
};
|
|
369
|
+
try {
|
|
370
|
+
mkdirSync(join(store, '.cortex'), { recursive: true });
|
|
371
|
+
appendFileSync(nearMissLog, JSON.stringify(row) + '\n');
|
|
372
|
+
}
|
|
373
|
+
catch (e) {
|
|
374
|
+
log(`near-miss log write failed: ${e.message}`);
|
|
375
|
+
}
|
|
376
|
+
nearMisses.push({ round: r, arm: w.arm, ref, candidateSha: sha, effect: w.res?.verdict?.effect, confirmEffect: c.effect });
|
|
377
|
+
emit({ event: 'near_miss', round: r, arm: w.arm, ref, candidateSha: sha, log: nearMissLog });
|
|
378
|
+
log(`r${r}: near-miss kept → ${ref} (row in ${nearMissLog})`);
|
|
379
|
+
};
|
|
380
|
+
/** Keep a non-merged arm's commit under refs/autoresearch/attempts/… and log why. */
|
|
381
|
+
const recordAttempt = (a, r, status) => {
|
|
382
|
+
const ref = `refs/autoresearch/attempts/${branch}/r${r}-a${a.arm}`;
|
|
383
|
+
try {
|
|
384
|
+
git(['update-ref', ref, a.candRef]);
|
|
385
|
+
}
|
|
386
|
+
catch { /* best effort */ }
|
|
387
|
+
let shortstat = '';
|
|
388
|
+
try {
|
|
389
|
+
shortstat = git(['diff', '--shortstat', `${a.candRef}~1`, a.candRef]);
|
|
390
|
+
}
|
|
391
|
+
catch { /* */ }
|
|
392
|
+
const row = {
|
|
393
|
+
ts: new Date().toISOString(), branch, round: r, arm: a.arm, model: a.model ?? null, approach: a.approach ?? null,
|
|
394
|
+
status, ref, candidateRef: a.candRef, baseRef, diff: shortstat,
|
|
395
|
+
...(a.res?.verdict ? { verdict: { decision: a.res.verdict.decision, effect: a.res.verdict.effect, pValue: a.res.verdict.pValue, mergeEligible: !!a.res.mergeEligible } } : {}),
|
|
396
|
+
...(a.flaws ? { flaws: a.flaws } : {}),
|
|
397
|
+
...(a.violations ? { violations: a.violations } : {}),
|
|
398
|
+
...(a.guardBlocks ? { guardBlocks: a.guardBlocks } : {}),
|
|
399
|
+
...(a.reason ? { reason: a.reason } : {}),
|
|
400
|
+
};
|
|
401
|
+
try {
|
|
402
|
+
mkdirSync(join(store, '.cortex'), { recursive: true });
|
|
403
|
+
appendFileSync(attemptsLog, JSON.stringify(row) + '\n');
|
|
404
|
+
}
|
|
405
|
+
catch (e) {
|
|
406
|
+
log(`attempts log write failed: ${e.message}`);
|
|
407
|
+
}
|
|
408
|
+
attempts.push({ round: r, arm: a.arm, status, ref });
|
|
409
|
+
emit({ event: 'attempt_kept', round: r, arm: a.arm, status, ref });
|
|
410
|
+
};
|
|
173
411
|
/** Run one arm: worktree → fixer → commit → experiment. Never throws. */
|
|
174
|
-
const runArm = async (assign, r, goal) => {
|
|
175
|
-
const
|
|
412
|
+
const runArm = async (assign, r, goal, approach) => {
|
|
413
|
+
const strategy = [assign.strategy, approach && `approach:${approach.slug}`].filter(Boolean).join('+') || undefined;
|
|
414
|
+
const out = { arm: assign.arm, model: assign.model, strategy, ...(approach ? { approach: approach.slug } : {}) };
|
|
415
|
+
const prompt = fixerPrompt(goal, approach, width);
|
|
176
416
|
const candDir = addWorktree(baseRef);
|
|
177
417
|
out.candDir = candDir;
|
|
418
|
+
// Hide in-repo task-sets from the Fixer (restored to the base version before commit).
|
|
419
|
+
const hidden = hiddenPaths.filter((p) => existsSync(join(candDir, p)));
|
|
420
|
+
for (const p of hidden)
|
|
421
|
+
rmSync(join(candDir, p), { recursive: true, force: true });
|
|
178
422
|
// 3. Mutate (fixer). Single-arm keeps today's interactive stdio; parallel arms are quiet.
|
|
179
423
|
const fixerStdio = json || width > 1 ? 'ignore' : 'inherit';
|
|
180
424
|
emit({ event: 'fix', round: r, arm: assign.arm, model: assign.model, fixer: options.fixerCmd ? 'command' : 'llm' });
|
|
181
425
|
if (options.fixerCmd) {
|
|
182
|
-
const cmd = options.fixerCmd.replace(/\{prompt\}/g, shQuote(
|
|
426
|
+
const cmd = options.fixerCmd.replace(/\{prompt\}/g, shQuote(prompt));
|
|
183
427
|
const env = {
|
|
184
428
|
...process.env,
|
|
185
429
|
CORTEX_ARM_INDEX: String(assign.arm),
|
|
430
|
+
CORTEX_FLAWS_FILE: join(candDir, FLAWS_FILE),
|
|
186
431
|
...(assign.model ? { CORTEX_ARM_MODEL: assign.model } : {}),
|
|
187
|
-
...(
|
|
432
|
+
...(strategy ? { CORTEX_ARM_STRATEGY: strategy } : {}),
|
|
433
|
+
...(approach ? { CORTEX_ARM_APPROACH: approach.slug } : {}),
|
|
188
434
|
};
|
|
189
435
|
const f = width > 1
|
|
190
436
|
? await spawnAsync('sh', ['-c', cmd], { cwd: candDir, env, stdio: fixerStdio })
|
|
@@ -196,7 +442,7 @@ export async function autoResearchLoop(options) {
|
|
|
196
442
|
}
|
|
197
443
|
}
|
|
198
444
|
else {
|
|
199
|
-
const fixArgs = [self, 'autoresearch', 'fix', '--cwd', candDir, '--prompt',
|
|
445
|
+
const fixArgs = [self, 'autoresearch', 'fix', '--cwd', candDir, '--prompt', prompt, '--json'];
|
|
200
446
|
if (assign.model)
|
|
201
447
|
fixArgs.push('--model', assign.model);
|
|
202
448
|
const f = await spawnAsync('node', fixArgs, { stdio: fixerStdio });
|
|
@@ -206,38 +452,52 @@ export async function autoResearchLoop(options) {
|
|
|
206
452
|
return out;
|
|
207
453
|
}
|
|
208
454
|
}
|
|
209
|
-
// 4.
|
|
455
|
+
// 4. Restore the hidden task-sets, then commit the candidate (no change → skip this arm)
|
|
456
|
+
// unless the eval guard refuses it.
|
|
457
|
+
// A hidden task-set that exists again after the Fixer ran was WRITTEN by it (it could not
|
|
458
|
+
// see the original) — tampering, refused below like any protected-path change.
|
|
459
|
+
const recreated = hiddenPaths
|
|
460
|
+
.filter((p) => existsSync(join(candDir, p)))
|
|
461
|
+
.map((path) => ({ path, rule: 'protected' }));
|
|
462
|
+
for (const p of hiddenPaths)
|
|
463
|
+
rmSync(join(candDir, p), { recursive: true, force: true });
|
|
464
|
+
if (hidden.length)
|
|
465
|
+
git(['checkout', 'HEAD', '--', ...hidden], candDir);
|
|
466
|
+
// Declared flaws (B6): read and remove the Fixer's flaws file before commit.
|
|
467
|
+
const flawsPath = join(candDir, FLAWS_FILE);
|
|
468
|
+
if (existsSync(flawsPath)) {
|
|
469
|
+
try {
|
|
470
|
+
const lines = readFileSync(flawsPath, 'utf8').split('\n').map((l) => l.replace(/^[-*\s]+/, '').trim()).filter(Boolean);
|
|
471
|
+
if (lines.length)
|
|
472
|
+
out.flaws = lines.slice(0, 20).map((l) => l.slice(0, 300));
|
|
473
|
+
}
|
|
474
|
+
catch { /* unreadable → treated as no declaration */ }
|
|
475
|
+
rmSync(flawsPath, { force: true });
|
|
476
|
+
}
|
|
210
477
|
git(['add', '-A'], candDir);
|
|
211
478
|
const dirty = spawnSync('git', ['-C', candDir, 'diff', '--cached', '--quiet']).status !== 0;
|
|
212
|
-
if (!dirty) {
|
|
479
|
+
if (!dirty && !recreated.length) {
|
|
213
480
|
out.skipped = 'no-change';
|
|
214
481
|
return out;
|
|
215
482
|
}
|
|
216
|
-
git(['
|
|
483
|
+
const changed = git(['diff', '--cached', '--name-only', '--no-renames'], candDir).split('\n').filter(Boolean);
|
|
484
|
+
// Commit before the guard so a refused candidate is kept as evidence (attempts ref).
|
|
485
|
+
git(['commit', '-q', '--allow-empty', '-m', `autoresearch loop r${r}${width > 1 ? `a${assign.arm}` : ''}: ${goal.slice(0, 60)}`], candDir);
|
|
217
486
|
out.candRef = git(['rev-parse', '--short', 'HEAD'], candDir);
|
|
487
|
+
const checked = checkCandidateChanges(changed, guard);
|
|
488
|
+
const verdict = { ok: checked.ok && !recreated.length, violations: [...recreated, ...checked.violations] };
|
|
489
|
+
if (!verdict.ok) {
|
|
490
|
+
out.skipped = 'eval-guard';
|
|
491
|
+
out.violations = verdict.violations;
|
|
492
|
+
out.reason = `refused unbenched: ${describeViolations(verdict.violations)}`;
|
|
493
|
+
emit({ event: 'eval_guard', round: r, arm: assign.arm, model: assign.model, violations: verdict.violations });
|
|
494
|
+
return out;
|
|
495
|
+
}
|
|
218
496
|
// 5. Experiment base vs candidate (subprocess → JSON verdict). FWER-adjusted with the
|
|
219
497
|
// real family width so N parallel arms don't inflate the false-keep rate.
|
|
220
498
|
const tag = width > 1 ? `loop-r${r}a${assign.arm}` : `loop-r${r}`;
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
'--base-dir', baseDir, '--candidate-dir', candDir,
|
|
224
|
-
'--base-ref', git(['rev-parse', '--short', 'HEAD'], baseDir), '--candidate-ref', out.candRef,
|
|
225
|
-
'--task-set', resolveTaskSet(options.taskSet, repo), '--no-build',
|
|
226
|
-
'--n-family', String(width),
|
|
227
|
-
'--cortex-dir', store, '--runs', options.runs ?? '3', '--json'];
|
|
228
|
-
if (options.holdoutSet)
|
|
229
|
-
expArgs.push('--holdout-set', resolveTaskSet(options.holdoutSet, repo));
|
|
230
|
-
if (options.model)
|
|
231
|
-
expArgs.push('--model', options.model);
|
|
232
|
-
if (options.temperature)
|
|
233
|
-
expArgs.push('--temperature', options.temperature);
|
|
234
|
-
if (assign.strategy)
|
|
235
|
-
expArgs.push('--strategy', assign.strategy);
|
|
236
|
-
if (options.runCmd) {
|
|
237
|
-
expArgs.push('--run-cmd', options.runCmd, '--accept-exit', options.acceptExit ?? '0');
|
|
238
|
-
if (options.buildCmd)
|
|
239
|
-
expArgs.push('--build-cmd', options.buildCmd);
|
|
240
|
-
}
|
|
499
|
+
out.tag = tag;
|
|
500
|
+
const expArgs = experimentArgs({ tag, candDir, candRef: out.candRef, store, nFamily: width, holdout: true, strategy });
|
|
241
501
|
emit({ event: 'experiment', round: r, arm: assign.arm, model: assign.model, candidateRef: out.candRef });
|
|
242
502
|
const exp = await spawnAsync('node', [self, ...expArgs], { stdio: 'capture' });
|
|
243
503
|
try {
|
|
@@ -265,6 +525,20 @@ export async function autoResearchLoop(options) {
|
|
|
265
525
|
}
|
|
266
526
|
const keep = out.res.verdict?.decision === 'keep';
|
|
267
527
|
out.gateAccepted = options.holdoutSet ? !!out.res.mergeEligible : keep;
|
|
528
|
+
// B4 family guards (step + running total from the round-0 base).
|
|
529
|
+
const blocks = guardCheck(out.res.families).filter((g) => g.blocked);
|
|
530
|
+
if (blocks.length) {
|
|
531
|
+
out.guardBlocks = blocks;
|
|
532
|
+
emit({ event: 'guard', round: r, arm: assign.arm, mode: guardMode, blocks });
|
|
533
|
+
log(`r${r}${width > 1 ? ` a${assign.arm}` : ''}: family guard ${guardMode === 'block' ? 'BLOCKS' : 'warns'}: ${describeGuardBlocks(blocks)}`);
|
|
534
|
+
if (guardMode === 'block')
|
|
535
|
+
out.gateAccepted = false;
|
|
536
|
+
}
|
|
537
|
+
if (out.flaws?.length) {
|
|
538
|
+
emit({ event: 'flaws', round: r, arm: assign.arm, flaws: out.flaws, gateWouldAccept: out.gateAccepted });
|
|
539
|
+
log(`r${r}${width > 1 ? ` a${assign.arm}` : ''}: Fixer declared ${out.flaws.length} flaw(s) — held for review, not merged`);
|
|
540
|
+
out.gateAccepted = false;
|
|
541
|
+
}
|
|
268
542
|
emit({ event: 'gate', round: r, arm: assign.arm, model: assign.model, decision: out.res.verdict?.decision, effect: out.res.verdict?.effect, mergeEligible: !!out.res.mergeEligible, accepted: out.gateAccepted });
|
|
269
543
|
return out;
|
|
270
544
|
};
|
|
@@ -286,7 +560,8 @@ export async function autoResearchLoop(options) {
|
|
|
286
560
|
}
|
|
287
561
|
// 2-5. Fan out the arms (width 1 = the classic single-candidate round).
|
|
288
562
|
log(`r${r}: fixing${width > 1 ? ` ×${width} arms` : ''} → ${goal.slice(0, 80)}`);
|
|
289
|
-
const
|
|
563
|
+
const roundApproaches = approachesForRound(approaches, r, plan.arms.length);
|
|
564
|
+
const results = await Promise.all(plan.arms.map((a, i) => runArm(a, r, goal, roundApproaches[i])));
|
|
290
565
|
for (const a of results.filter((x) => x.skipped)) {
|
|
291
566
|
log(`r${r}${width > 1 ? ` a${a.arm}` : ''}: ${a.skipped}${a.reason ? ` (${a.reason})` : ''} → skip`);
|
|
292
567
|
}
|
|
@@ -308,6 +583,13 @@ export async function autoResearchLoop(options) {
|
|
|
308
583
|
emit({ event: 'round_done', round: r, merged: false, skipped: 'no-verdict', reason: failedNoVerdict[0].reason, exitCode: failedNoVerdict[0].exitCode ?? null });
|
|
309
584
|
}
|
|
310
585
|
else {
|
|
586
|
+
const guarded = results.filter((a) => a.skipped === 'eval-guard');
|
|
587
|
+
if (guarded.length) {
|
|
588
|
+
rounds.push({
|
|
589
|
+
round: r, accepted: false, skipped: 'eval-guard', reason: guarded[0].reason,
|
|
590
|
+
...(width > 1 ? { arms: results.map(armHistory) } : { violations: guarded[0].violations }),
|
|
591
|
+
});
|
|
592
|
+
}
|
|
311
593
|
emit({ event: 'round_done', round: r, merged: false, skipped: first.skipped });
|
|
312
594
|
}
|
|
313
595
|
for (const a of results) {
|
|
@@ -328,6 +610,7 @@ export async function autoResearchLoop(options) {
|
|
|
328
610
|
// Fail-closed: a judge that errors or returns no parseable verdict blocks
|
|
329
611
|
// that candidate (approve defaults to false).
|
|
330
612
|
let winner;
|
|
613
|
+
const judgeRejected = new Set();
|
|
331
614
|
if (options.requireJudge) {
|
|
332
615
|
for (const cand of acceptedArms) {
|
|
333
616
|
const baseShort = git(['rev-parse', '--short', 'HEAD'], baseDir);
|
|
@@ -358,15 +641,34 @@ export async function autoResearchLoop(options) {
|
|
|
358
641
|
winner = cand;
|
|
359
642
|
break;
|
|
360
643
|
}
|
|
644
|
+
judgeRejected.add(cand.arm);
|
|
361
645
|
}
|
|
362
646
|
}
|
|
363
647
|
else {
|
|
364
648
|
winner = acceptedArms[0];
|
|
365
649
|
}
|
|
650
|
+
// 5c. Confirmation on fresh data (default on). ONE confirmation per round: the
|
|
651
|
+
// selected winner only, so the confirmation is a single pre-registered test.
|
|
652
|
+
let confirm;
|
|
653
|
+
let notConfirmedArm;
|
|
654
|
+
if (winner && options.confirm !== false) {
|
|
655
|
+
log(`r${r}${width > 1 ? ` a${winner.arm}` : ''}: confirming on fresh train runs`);
|
|
656
|
+
confirm = await confirmWinner(winner, r);
|
|
657
|
+
const ok = confirm.confirmed;
|
|
658
|
+
emit({ event: 'confirm', round: r, arm: winner.arm, mode: confirm.mode, decision: confirm.decision, effect: confirm.effect, pValue: confirm.pValue, ...(confirm.combinedP != null ? { combinedP: confirm.combinedP } : {}), reason: confirm.reason, confirmed: ok });
|
|
659
|
+
log(`r${r}: confirm ${ok ? theme.colors.success('CONFIRMED') : theme.colors.error('NOT CONFIRMED')} (${confirm.reason})`);
|
|
660
|
+
if (!ok) {
|
|
661
|
+
recordNearMiss(winner, r, confirm);
|
|
662
|
+
notConfirmedArm = winner.arm;
|
|
663
|
+
winner = undefined;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
366
666
|
const accept = !!winner;
|
|
667
|
+
// Success metric = TRAIN score (the confirmation's when it ran), never the holdout.
|
|
367
668
|
const candScore = (() => {
|
|
368
|
-
|
|
369
|
-
|
|
669
|
+
if (!winner)
|
|
670
|
+
return undefined;
|
|
671
|
+
const s = confirm?.trainSummary ?? winner.res?.benchSummaries?.candidate?.train;
|
|
370
672
|
const t = s?.tasks?.find((x) => x.taskId === successMetric?.taskId);
|
|
371
673
|
return t?.meanScore;
|
|
372
674
|
})();
|
|
@@ -374,9 +676,42 @@ export async function autoResearchLoop(options) {
|
|
|
374
676
|
rounds.push({
|
|
375
677
|
round: r, candRef: lead.candRef, decision: lead.res?.verdict?.decision, effect: lead.res?.verdict?.effect,
|
|
376
678
|
mergeEligible: lead.res?.mergeEligible, accepted: accept, candScore,
|
|
679
|
+
...(confirm ? { confirm: { confirmed: confirm.confirmed, mode: confirm.mode, decision: confirm.decision, effect: confirm.effect, pValue: confirm.pValue, ...(confirm.combinedP != null ? { combinedP: confirm.combinedP } : {}), reason: confirm.reason, runs: confirm.runs } } : {}),
|
|
680
|
+
...(lead.res?.runPlan ? { runPlan: lead.res.runPlan } : {}),
|
|
681
|
+
...(lead.guardBlocks ? { guardBlocks: lead.guardBlocks } : {}),
|
|
377
682
|
...(width > 1 ? { winnerArm: winner?.arm, arms: results.map(armHistory) } : {}),
|
|
378
683
|
});
|
|
379
684
|
log(`r${r}: ${lead.res?.verdict?.decision?.toUpperCase()} effect ${lead.res?.verdict?.effect} ${accept ? theme.colors.success('ACCEPT') : theme.colors.error('reject')}${options.holdoutSet ? '' : ' (train-only, UNVERIFIED — pass --holdout-set)'}${width > 1 && winner ? ` (arm ${winner.arm}${winner.model ? ` · ${winner.model}` : ''})` : ''}`);
|
|
685
|
+
// Run planning: warn when the screening runs cannot see --target-effect; with
|
|
686
|
+
// --auto-runs, raise the runs for LATER rounds (never mid-round) up to --max-runs.
|
|
687
|
+
const roundPlan = lead.res?.runPlan;
|
|
688
|
+
if (roundPlan && !roundPlan.adequate) {
|
|
689
|
+
const msg = `r${r}: underpowered — ${roundPlan.runsPerArm} runs/arm detect ≥ ${roundPlan.detectableEffect} pts at power ${roundPlan.power}; +${roundPlan.targetEffect} needs ${roundPlan.requiredRunsPerArm}/arm`;
|
|
690
|
+
log(msg);
|
|
691
|
+
emit({ event: 'power_warning', round: r, runPlan: roundPlan });
|
|
692
|
+
if (options.autoRuns && runsPerArm < maxRuns) {
|
|
693
|
+
const next = Math.min(maxRuns, roundPlan.requiredRunsPerArm);
|
|
694
|
+
if (next > runsPerArm) {
|
|
695
|
+
log(`r${r}: --auto-runs → ${next} runs/arm from round ${r + 1}${next === maxRuns && roundPlan.requiredRunsPerArm > maxRuns ? ` (capped by --max-runs; ${roundPlan.requiredRunsPerArm} needed)` : ''}`);
|
|
696
|
+
emit({ event: 'runs_changed', round: r, from: runsPerArm, to: next, required: roundPlan.requiredRunsPerArm });
|
|
697
|
+
runsPerArm = next;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
// B6: keep every arm that did not merge (its commit under a ref + a row with why).
|
|
702
|
+
for (const a of results) {
|
|
703
|
+
if (!a.candRef || a === winner)
|
|
704
|
+
continue;
|
|
705
|
+
const status = a.skipped === 'eval-guard' ? 'eval-guard'
|
|
706
|
+
: a.skipped === 'no-verdict' ? 'no-verdict'
|
|
707
|
+
: a.flaws?.length ? 'declared-flaws'
|
|
708
|
+
: a.guardBlocks?.length && guardMode === 'block' ? 'guard-blocked'
|
|
709
|
+
: a.arm === notConfirmedArm ? 'not-confirmed'
|
|
710
|
+
: judgeRejected.has(a.arm) ? 'judge-rejected'
|
|
711
|
+
: !a.gateAccepted ? `gate-${a.res?.verdict?.decision ?? 'none'}`
|
|
712
|
+
: 'outranked';
|
|
713
|
+
recordAttempt(a, r, status);
|
|
714
|
+
}
|
|
380
715
|
// 6. Advance or discard.
|
|
381
716
|
if (accept && winner) {
|
|
382
717
|
git(['branch', '-f', branch, winner.candRef]); // anchor the accepted commit
|
|
@@ -388,6 +723,8 @@ export async function autoResearchLoop(options) {
|
|
|
388
723
|
baseDir = winner.candDir;
|
|
389
724
|
baseRef = winner.candRef;
|
|
390
725
|
stale = 0;
|
|
726
|
+
// The drift ledger records the unbiased measurement: the confirmation's when it ran.
|
|
727
|
+
recordDrift(drift, confirm?.families ?? winner.res?.families ?? []);
|
|
391
728
|
if (defId) {
|
|
392
729
|
if (options.holdoutSet)
|
|
393
730
|
backlog.markVerified(defId, `loop-r${r}`);
|
|
@@ -424,10 +761,12 @@ export async function autoResearchLoop(options) {
|
|
|
424
761
|
catch { /* */ }
|
|
425
762
|
for (const w of worktrees)
|
|
426
763
|
removeWorktree(w);
|
|
764
|
+
rmSync(sealDir, { recursive: true, force: true });
|
|
427
765
|
}
|
|
428
766
|
emit({ event: 'stop', reason: stop });
|
|
429
767
|
const failedArms = rounds.filter(r => r.failed).map(r => ({ round: r.round, reason: r.reason, exitCode: r.exitCode }));
|
|
430
|
-
const out = { repo, branch, finalRef: baseRef, rounds: rounds.length, merges: rounds.filter(r => r.accepted).length, failures: failedArms.length, failedArms,
|
|
768
|
+
const out = { repo, branch, finalRef: baseRef, rounds: rounds.length, merges: rounds.filter(r => r.accepted).length, failures: failedArms.length, failedArms, nearMisses, attempts, runsPerArm,
|
|
769
|
+
drift: Object.fromEntries([...drift].map(([f, d]) => [f, { effect: d.effect, se: Math.round(Math.sqrt(d.variance) * 100) / 100, steps: d.steps }])), stop, ...(width > 1 ? { width } : {}), history: rounds };
|
|
431
770
|
if (json) {
|
|
432
771
|
console.log(JSON.stringify(out, null, 2));
|
|
433
772
|
return;
|
|
@@ -435,6 +774,8 @@ export async function autoResearchLoop(options) {
|
|
|
435
774
|
console.log();
|
|
436
775
|
console.log(` ${theme.colors.highlight('Loop done')} stop=${stop} rounds=${rounds.length} merges=${out.merges}`);
|
|
437
776
|
console.log(theme.colors.muted(` Result on branch ${branch} → ${baseRef.slice(0, 8)}. Merge it when satisfied: git -C ${repo} merge ${branch}`));
|
|
777
|
+
if (nearMisses.length)
|
|
778
|
+
console.log(theme.colors.muted(` ${nearMisses.length} near-miss(es) kept for re-testing: refs/autoresearch/near-miss/${branch}/* (rows: ${nearMissLog})`));
|
|
438
779
|
console.log();
|
|
439
780
|
}
|
|
440
781
|
/** Compact per-arm history entry for the round record / final JSON. */
|
|
@@ -445,6 +786,10 @@ function armHistory(a) {
|
|
|
445
786
|
...(a.candRef ? { candRef: a.candRef } : {}),
|
|
446
787
|
...(a.skipped ? { skipped: a.skipped } : {}),
|
|
447
788
|
...(a.reason ? { reason: a.reason } : {}),
|
|
789
|
+
...(a.violations ? { violations: a.violations } : {}),
|
|
790
|
+
...(a.guardBlocks ? { guardBlocks: a.guardBlocks } : {}),
|
|
791
|
+
...(a.approach ? { approach: a.approach } : {}),
|
|
792
|
+
...(a.flaws ? { flaws: a.flaws } : {}),
|
|
448
793
|
...(a.res ? { decision: a.res.verdict?.decision, effect: a.res.verdict?.effect, mergeEligible: a.res.mergeEligible } : {}),
|
|
449
794
|
...(a.gateAccepted != null ? { gateAccepted: a.gateAccepted } : {}),
|
|
450
795
|
};
|