@nexus-cortex/cli 4.130.0 → 4.131.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.cortex/agents/autoresearch-agent.md +7 -1
  2. package/.cortex/agents/web-researcher.md +1 -2
  3. package/.cortex/permissions.prod.json +2 -1
  4. package/.env.defaults +172 -2
  5. package/bin/cortex.js +22 -8
  6. package/dist/agent-mode.js +103 -5
  7. package/dist/agent-mode.js.map +1 -1
  8. package/dist/commands/autoresearch/approachPlan.d.ts +30 -0
  9. package/dist/commands/autoresearch/approachPlan.d.ts.map +1 -0
  10. package/dist/commands/autoresearch/approachPlan.js +54 -0
  11. package/dist/commands/autoresearch/approachPlan.js.map +1 -0
  12. package/dist/commands/autoresearch/experiment.d.ts +5 -0
  13. package/dist/commands/autoresearch/experiment.d.ts.map +1 -1
  14. package/dist/commands/autoresearch/experiment.js +14 -0
  15. package/dist/commands/autoresearch/experiment.js.map +1 -1
  16. package/dist/commands/autoresearch/loop.d.ts +30 -0
  17. package/dist/commands/autoresearch/loop.d.ts.map +1 -1
  18. package/dist/commands/autoresearch/loop.js +382 -37
  19. package/dist/commands/autoresearch/loop.js.map +1 -1
  20. package/dist/commands/browser/index.d.ts +48 -0
  21. package/dist/commands/browser/index.d.ts.map +1 -0
  22. package/dist/commands/browser/index.js +116 -0
  23. package/dist/commands/browser/index.js.map +1 -0
  24. package/dist/commands/secrets/index.d.ts +3 -0
  25. package/dist/commands/secrets/index.d.ts.map +1 -0
  26. package/dist/commands/secrets/index.js +196 -0
  27. package/dist/commands/secrets/index.js.map +1 -0
  28. package/dist/commands/setup/index.d.ts +4 -0
  29. package/dist/commands/setup/index.d.ts.map +1 -1
  30. package/dist/commands/setup/index.js +144 -15
  31. package/dist/commands/setup/index.js.map +1 -1
  32. package/dist/index.js +52 -0
  33. package/dist/index.js.map +1 -1
  34. package/dist/lifecycle/toolsInstall.d.ts.map +1 -1
  35. package/dist/lifecycle/toolsInstall.js +2 -0
  36. package/dist/lifecycle/toolsInstall.js.map +1 -1
  37. package/dist/orchestrator/OrchestratorClient.d.ts +4 -2
  38. package/dist/orchestrator/OrchestratorClient.d.ts.map +1 -1
  39. package/dist/orchestrator/OrchestratorClient.js +14 -8
  40. package/dist/orchestrator/OrchestratorClient.js.map +1 -1
  41. package/dist/themes/colors.d.ts +6 -0
  42. package/dist/themes/colors.d.ts.map +1 -1
  43. package/dist/themes/colors.js +11 -0
  44. package/dist/themes/colors.js.map +1 -1
  45. package/package.json +2 -2
@@ -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. On accept: advance base to the winner + anchor the loop branch to it.
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 out = { arm: assign.arm, model: assign.model, strategy: assign.strategy };
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(goal));
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
- ...(assign.strategy ? { CORTEX_ARM_STRATEGY: assign.strategy } : {}),
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', goal, '--json'];
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. Commit candidate (no change → skip this arm).
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(['commit', '-q', '-m', `autoresearch loop r${r}${width > 1 ? `a${assign.arm}` : ''}: ${goal.slice(0, 60)}`], candDir);
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
- const expArgs = ['autoresearch', 'experiment',
222
- '--experiment-tag', tag,
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 results = await Promise.all(plan.arms.map((a) => runArm(a, r, goal)));
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
- const src = winner ?? acceptedArms[0] ?? verdictArms[0];
369
- const s = src?.res?.benchSummaries?.candidate?.holdout ?? src?.res?.benchSummaries?.candidate?.train;
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, stop, ...(width > 1 ? { width } : {}), history: rounds };
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
  };