atris 3.68.9 → 3.70.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.
@@ -8,7 +8,7 @@ const { spawn, spawnSync } = require('child_process');
8
8
  const { hasFlag, readFlag, readIntFlag } = require('../lib/arg-parser');
9
9
  const { memberProcessPrompt } = require('../lib/member-context');
10
10
  const { memberIdentityLine, missionObjectiveLine } = require('../lib/member-identity');
11
- const { formatToolStep, appendProgress, reportProgress } = require('../lib/member-progress');
11
+ const { formatToolStep, formatCodexEvent, appendProgress, reportProgress, startedLine, newRunToken, runMarker } = require('../lib/member-progress');
12
12
  const {
13
13
  compactErrorPayload,
14
14
  compactSuccessPayload,
@@ -28,6 +28,7 @@ const {
28
28
  resolveClaudeRunnerBin,
29
29
  resolveClaudeRunnerCommandTemplate,
30
30
  buildRunnerCommand,
31
+ codexJsonEventsCommand,
31
32
  RUNNER_PROFILE_DEFS,
32
33
  RUNNER_PROFILE_ALIASES,
33
34
  RUNNER_PROFILE_NAMES,
@@ -433,7 +434,13 @@ function missionTickTimeoutMs(mission, remainingMs) {
433
434
 
434
435
  function resolveMissionTickRunner(mission, root = process.cwd(), options = {}) {
435
436
  if (String(mission && mission.runner || '').trim().toLowerCase() !== MISSION_AUTO_RUNNER) {
436
- return { mission, engine_id: null, requested_engine: null, engine_fallback_reason: null };
437
+ // A named engine still gets the roster's time limit for that engine.
438
+ const runner = String(mission && mission.runner || '').trim().toLowerCase();
439
+ const pin = runner && !(Number(mission && mission.roster_max_seconds) > 0)
440
+ ? require('../lib/engine-registry').rosterPinFor('executor', runner, root, options)
441
+ : {};
442
+ const limited = pin.roster_max_seconds ? { ...mission, roster_max_seconds: pin.roster_max_seconds } : mission;
443
+ return { mission: limited, engine_id: null, requested_engine: null, engine_fallback_reason: null };
437
444
  }
438
445
  // A mission owned by a team member runs on that member's roster pick. An
439
446
  // explicit preferred engine (or a run's --engine) still wins, and with no
@@ -1722,7 +1729,18 @@ function appendMemberLog(owner, title, fields = {}, root = process.cwd()) {
1722
1729
  .map(([key, value]) => `- ${key}: ${String(value).replace(/\n/g, ' ')}`),
1723
1730
  '',
1724
1731
  ];
1725
- fs.appendFileSync(logPath, rows.join('\n'), 'utf8');
1732
+ let lead = '';
1733
+ try {
1734
+ const size = fs.statSync(logPath).size;
1735
+ if (size > 0) {
1736
+ const fd = fs.openSync(logPath, 'r');
1737
+ const last = Buffer.alloc(1);
1738
+ fs.readSync(fd, last, 0, 1, size - 1);
1739
+ fs.closeSync(fd);
1740
+ if (last[0] !== 0x0a) lead = '\n';
1741
+ }
1742
+ } catch {}
1743
+ fs.appendFileSync(logPath, lead + rows.join('\n'), 'utf8');
1726
1744
  return logPath;
1727
1745
  }
1728
1746
 
@@ -3909,6 +3927,30 @@ function readTasteReviewHistory(root, limit = 4) {
3909
3927
  return { accepted, revised };
3910
3928
  }
3911
3929
 
3930
+ // Why the mission's own task was last sent back, or '' when it was not. Ticks
3931
+ // run in a worktree, so fall back to the main checkout's task projection.
3932
+ function readBoundTaskTurnDown(mission, root) {
3933
+ const bound = mission?.xp_task || mission?.selected_target_task || null;
3934
+ if (!bound) return '';
3935
+ const keys = new Set([bound.task_id, bound.id, bound.ref, bound.task_ref].filter(Boolean).map(String));
3936
+ if (!keys.size) return '';
3937
+ const roots = [root];
3938
+ const common = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: root, encoding: 'utf8' });
3939
+ if (common.status === 0 && String(common.stdout || '').trim()) roots.push(path.dirname(String(common.stdout).trim()));
3940
+ for (const dir of roots) {
3941
+ const projection = readJson(path.join(dir, '.atris', 'state', 'tasks.projection.json'));
3942
+ const tasks = Array.isArray(projection?.tasks) ? projection.tasks : [];
3943
+ const task = tasks.find((row) => row && [row.id, row.display_id, row.legacy_ref].some((key) => key && keys.has(String(key))));
3944
+ if (!task) continue;
3945
+ const metadata = task.metadata && typeof task.metadata === 'object' ? task.metadata : {};
3946
+ if (metadata.approval_status && metadata.approval_status !== 'revise') return '';
3947
+ const events = (Array.isArray(task.events) ? task.events : []).filter((event) => event?.event_type === 'revision_requested');
3948
+ const note = metadata.human_revision_note || events[events.length - 1]?.payload?.note || '';
3949
+ return missionTasteSnippet(String(note), 600);
3950
+ }
3951
+ return '';
3952
+ }
3953
+
3912
3954
  function missionTasteMemorySignals(text) {
3913
3955
  const definitions = [
3914
3956
  ['plain_language', 'the operator asks for plain English', /\b(plain|jargon|feynman|understand|simple|clarify|clarity)\b/i],
@@ -6509,9 +6551,16 @@ function writeReceipt(mission, result, root = process.cwd()) {
6509
6551
  const receiptPath = path.join(paths.runsDir, `mission-${mission.id}-${safeTime}.json`);
6510
6552
  const relativeReceiptPath = path.relative(root, receiptPath);
6511
6553
  const finalResult = normalizeMissionReceiptResult(mission, result, relativeReceiptPath);
6554
+ // tree_hash covers only doctrine files; git_tree pins the code the run's
6555
+ // checks saw, so a later reader can tell whether the code has moved since.
6556
+ // With unsaved changes the checks saw more than any commit holds, so no
6557
+ // code tree is recorded and clean is false.
6558
+ const codeState = require('../lib/member-autoland').gitCodeState(root);
6512
6559
  const receipt = {
6513
6560
  schema: 'atris.mission_receipt.v1',
6514
6561
  tree_hash: treeHashFor(root),
6562
+ clean: codeState.clean,
6563
+ git_tree: codeState.clean ? codeState.tree : null,
6515
6564
  mission_id: mission.id,
6516
6565
  objective: mission.objective,
6517
6566
  owner: mission.owner,
@@ -8930,6 +8979,16 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
8930
8979
  checkFeedback,
8931
8980
  ]
8932
8981
  : [];
8982
+ let turnDown = '';
8983
+ try { turnDown = readBoundTaskTurnDown(mission, mission.worktree_root || mission.worktree?.path || cwd); } catch {}
8984
+ const turnDownLines = turnDown
8985
+ ? [
8986
+ ``,
8987
+ `## why the last try was turned down`,
8988
+ `the reviewer sent this task back. fix this before anything else.`,
8989
+ turnDown,
8990
+ ]
8991
+ : [];
8933
8992
  const preloadedContextLines = buildPreloadedMemberContextLines(mission, cwd);
8934
8993
  const lines = [
8935
8994
  `# Mission Tick ${tickIndex}/${maxTicks}`,
@@ -8945,6 +9004,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
8945
9004
  `**Last tick:** ${mission.last_tick_at || 'never'}`,
8946
9005
  ...missionBudgetPromptLines(mission),
8947
9006
  ...checkFeedbackLines,
9007
+ ...turnDownLines,
8948
9008
  ...(sharedProcess ? ['', sharedProcess] : []),
8949
9009
  ...preloadedContextLines,
8950
9010
  ``,
@@ -9181,11 +9241,20 @@ function spawnGenericRunnerTick(mission, opts) {
9181
9241
  if (dispatchDepth.tooDeep) return Promise.resolve(tooDeepRunnerResult(dispatchDepth));
9182
9242
  return new Promise((resolve) => {
9183
9243
  let promptFile = null;
9244
+ let lastMessageFile = null;
9184
9245
  let cmd = '';
9185
9246
  let briefId = null;
9186
9247
  try {
9187
9248
  promptFile = writeRunnerPromptFile(cwd, mission.id, prompt);
9188
9249
  cmd = buildRunnerCommand({ promptFile, model, ...(mission.roster_effort ? { effort: mission.roster_effort } : {}) });
9250
+ // A codex that prints JSONL events gets live step lines; its final
9251
+ // answer comes from the last-message file, the same text stdout held.
9252
+ const lastFile = promptFile.replace(/\.md$/, '.last.txt');
9253
+ const eventsCmd = codexJsonEventsCommand(cmd, lastFile);
9254
+ if (eventsCmd) {
9255
+ cmd = eventsCmd;
9256
+ lastMessageFile = lastFile;
9257
+ }
9189
9258
  const engine = canonicalEngineName(mission.runner);
9190
9259
  if (engine) {
9191
9260
  const record = appendBriefRecord(cwd, {
@@ -9224,7 +9293,16 @@ function spawnGenericRunnerTick(mission, opts) {
9224
9293
  }
9225
9294
 
9226
9295
  const startedAt = Date.now();
9227
- const guardedCommand = `PATH=${shellQuote(gitGuard.pathPrefix)}:$PATH; export PATH; ${cmd}`;
9296
+ const progressMember = mission.owner || 'mission';
9297
+ // A codex run opens its shell script with the run marker as a no-op, so
9298
+ // member stop can prove the shell leading this group is this run. Only
9299
+ // names member stop accepts go into the shell line.
9300
+ const runToken = lastMessageFile && /^[A-Za-z0-9._-]+$/.test(progressMember) ? newRunToken() : '';
9301
+ const markerPrefix = runToken ? `: ${runMarker(progressMember, runToken)}; ` : '';
9302
+ // The trailing exit keeps a shell like dash from replacing itself with
9303
+ // codex, which would take the marker out of the process list.
9304
+ const markerSuffix = runToken ? '; exit $?' : '';
9305
+ const guardedCommand = `${markerPrefix}PATH=${shellQuote(gitGuard.pathPrefix)}:$PATH; export PATH; ${cmd}${markerSuffix}`;
9228
9306
  const proc = spawn('sh', ['-lc', guardedCommand], {
9229
9307
  cwd,
9230
9308
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -9232,13 +9310,33 @@ function spawnGenericRunnerTick(mission, opts) {
9232
9310
  env: { ...gitGuard.env, ATRIS_DISPATCH_DEPTH: String(dispatchDepth.depth + 1) },
9233
9311
  });
9234
9312
  updateMissionLockOwner(missionLock, proc.pid, { phase: 'tick' });
9313
+ const seenItemIds = new Set();
9314
+ if (lastMessageFile) {
9315
+ const tag = runToken ? { pid: proc.pid, engine: 'codex', run: runToken } : {};
9316
+ appendProgress(progressMember, startedLine(mission.objective, tag), mission.id);
9317
+ }
9235
9318
  let stdout = '';
9319
+ let eventBuf = '';
9320
+ let lastAgentMessage = '';
9236
9321
  let stderr = '';
9237
9322
  let timedOut = false;
9238
9323
  let aborted = false;
9239
9324
 
9240
9325
  const cleanupPrompt = () => {
9241
9326
  try { if (promptFile) fs.unlinkSync(promptFile); } catch {}
9327
+ try { if (lastMessageFile) fs.unlinkSync(lastMessageFile); } catch {}
9328
+ };
9329
+ const readCodexEvent = (line) => {
9330
+ let ev;
9331
+ try { ev = JSON.parse(line); } catch { return; }
9332
+ const item = ev && ev.item;
9333
+ if (ev.type === 'item.completed' && item && item.type === 'agent_message' && typeof item.text === 'string') {
9334
+ lastAgentMessage = item.text;
9335
+ }
9336
+ const step = formatCodexEvent(ev, cwd);
9337
+ if (!step || (item && item.id && seenItemIds.has(item.id))) return;
9338
+ if (item && item.id) seenItemIds.add(item.id);
9339
+ reportProgress(progressMember, step, mission.id);
9242
9340
  };
9243
9341
  const kill = () => {
9244
9342
  const killGroup = (sig) => {
@@ -9254,18 +9352,36 @@ function spawnGenericRunnerTick(mission, opts) {
9254
9352
  else signal.addEventListener('abort', onAbort, { once: true });
9255
9353
  }
9256
9354
 
9257
- proc.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
9355
+ proc.stdout.on('data', (chunk) => {
9356
+ const text = chunk.toString();
9357
+ stdout += text;
9358
+ if (!lastMessageFile) return;
9359
+ eventBuf += text;
9360
+ let nl;
9361
+ while ((nl = eventBuf.indexOf('\n')) !== -1) {
9362
+ const line = eventBuf.slice(0, nl).trim();
9363
+ eventBuf = eventBuf.slice(nl + 1);
9364
+ if (line) readCodexEvent(line);
9365
+ }
9366
+ });
9258
9367
  proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
9259
9368
 
9260
9369
  proc.on('close', (code) => {
9261
9370
  clearTimeout(timer);
9371
+ if (lastMessageFile && eventBuf.trim()) readCodexEvent(eventBuf.trim());
9372
+ let finalText = String(stdout || '').trim();
9373
+ if (lastMessageFile) {
9374
+ let lastMessage = '';
9375
+ try { lastMessage = fs.readFileSync(lastMessageFile, 'utf8'); } catch {}
9376
+ finalText = String(lastMessage || lastAgentMessage || '').trim();
9377
+ }
9262
9378
  cleanupPrompt();
9263
9379
  gitGuard.cleanup();
9264
9380
  if (signal) signal.removeEventListener?.('abort', onAbort);
9265
9381
  updateMissionLockOwner(missionLock, missionLock?.driverPid);
9266
- const finalText = String(stdout || '').trim();
9267
9382
  const errStr = String(stderr || '').slice(-2000);
9268
9383
  const ok = code === 0 && !timedOut && !aborted;
9384
+ if (lastMessageFile) appendProgress(progressMember, `finished: ${ok ? 'ok' : 'failed'}`, mission.id);
9269
9385
  const authExpired = !ok && missionRunnerAuthExpired(errStr, finalText);
9270
9386
  resolve({
9271
9387
  ok,
@@ -9355,8 +9471,10 @@ function spawnClaudeTick(mission, opts) {
9355
9471
  });
9356
9472
  return;
9357
9473
  }
9474
+ const runToken = newRunToken();
9475
+ const runOwner = mission.owner || 'mission';
9358
9476
  const args = [
9359
- '-p', prompt,
9477
+ '-p', `${runMarker(runOwner, runToken)}\n${prompt}`,
9360
9478
  '--output-format', 'stream-json',
9361
9479
  '--verbose',
9362
9480
  '--permission-mode', 'bypassPermissions',
@@ -9380,7 +9498,7 @@ function spawnClaudeTick(mission, opts) {
9380
9498
  updateMissionLockOwner(missionLock, proc.pid, { phase: 'tick' });
9381
9499
  const progressMember = mission.owner || 'mission';
9382
9500
  const seenToolIds = new Set();
9383
- appendProgress(progressMember, `started: ${mission.objective || ''}`, mission.id);
9501
+ appendProgress(progressMember, startedLine(mission.objective, { pid: proc.pid, engine: 'claude', run: runToken }), mission.id);
9384
9502
 
9385
9503
  let stdoutBuf = '';
9386
9504
  let observedSessionIds = new Set();
@@ -9532,10 +9650,13 @@ function engineVerifierResultFromRun(engineResult, engine = 'codex') {
9532
9650
  }
9533
9651
 
9534
9652
  function pickCrossEngineVerifier(mission, cwd = process.cwd(), preferredEngine = '') {
9535
- const explicit = canonicalEngineName(preferredEngine || mission?.referee_engine || '');
9536
- if (explicit) return explicit;
9653
+ // The reviewer must not be the builder: a builder grading itself is no review.
9654
+ // A named reviewer is used unless it is the builder's own engine.
9537
9655
  const workerEngine = canonicalEngineName(mission?.runner) || 'claude';
9538
- const candidates = workerEngine === 'codex' ? ['claude', 'fable', 'codex'] : ['codex', 'claude'];
9656
+ const explicit = canonicalEngineName(preferredEngine || mission?.referee_engine || '');
9657
+ if (explicit && explicit !== workerEngine) return explicit;
9658
+ const candidates = (workerEngine === 'codex' ? ['claude', 'fable'] : ['codex', 'claude'])
9659
+ .filter((candidate) => candidate !== workerEngine);
9539
9660
  try {
9540
9661
  const engines = engineRegistryView(cwd);
9541
9662
  for (const candidate of candidates) {
@@ -9613,6 +9734,20 @@ function cappedClaudeReceiptText(text, limit = 4000) {
9613
9734
  return clean.slice(0, limit - 16).trimEnd() + '\n...[truncated]';
9614
9735
  }
9615
9736
 
9737
+ // The receipt keeps a capped copy of the runner's report. When the report is
9738
+ // longer than that, the whole text goes to its own file beside the receipts
9739
+ // so nothing the member wrote is lost.
9740
+ function saveFullRunnerReport(mission, text, root = process.cwd()) {
9741
+ const clean = String(text || '').trim();
9742
+ if (!clean || cappedClaudeReceiptText(clean) === clean) return null;
9743
+ const runsDir = statePaths(root).runsDir;
9744
+ fs.mkdirSync(runsDir, { recursive: true });
9745
+ const safeTime = stampIso().replace(/[:.]/g, '-');
9746
+ const reportPath = path.join(runsDir, `mission-${mission.id}-${safeTime}-report.md`);
9747
+ fs.writeFileSync(reportPath, clean + '\n', 'utf8');
9748
+ return path.relative(root, reportPath);
9749
+ }
9750
+
9616
9751
  async function parseAndValidateMissionRunPhase(args) {
9617
9752
  const asJson = wantsJson(args);
9618
9753
  const context = {
@@ -10238,6 +10373,7 @@ async function executeMissionRunTicksPhase(context) {
10238
10373
  brief_id: claudeResult.brief_id || null,
10239
10374
  summary: claudeResult.summary,
10240
10375
  receipt_text: claudeResult.receipt_text,
10376
+ report_path: saveFullRunnerReport(mission, claudeResult.result, cwd),
10241
10377
  stop_reason: claudeResult.stop_reason,
10242
10378
  api_equivalent_estimate: claudeResult.api_equivalent_estimate,
10243
10379
  duration_total_ms: claudeResult.duration_total_ms,
@@ -10704,12 +10840,22 @@ async function verifyMissionRunTickPhase(context) {
10704
10840
  || (!currentTick.storedVerifier && !process.env.NODE_TEST_CONTEXT)
10705
10841
  );
10706
10842
  if (shouldRunReferee) {
10707
- const refereeResult = await runEngineVerifier(currentTick.tickRuntimeMission, {
10843
+ // Pin the code before the referee looks; a referee that changes the
10844
+ // code or its unsaved state has not reviewed what would merge.
10845
+ const { gitCodeState, codeStatus } = require('../lib/member-autoland');
10846
+ const beforeReview = gitCodeState(cwd);
10847
+ const reviewed = await runEngineVerifier(currentTick.tickRuntimeMission, {
10708
10848
  cwd,
10709
10849
  signal: controller.signal,
10710
10850
  tickIndex: currentTick.tickIdx,
10711
10851
  refereeEngine: context.refereeEngine,
10712
10852
  });
10853
+ const afterReview = gitCodeState(cwd);
10854
+ const codeMoved = beforeReview.tree !== afterReview.tree
10855
+ || codeStatus(beforeReview) !== codeStatus(afterReview)
10856
+ || beforeReview.hidden !== afterReview.hidden;
10857
+ const refereeResult = codeMoved ? { ...reviewed, passed: false, reason: 'code changed during review' } : reviewed;
10858
+ result.reviewed_git_tree = beforeReview.clean ? beforeReview.tree : null;
10713
10859
  result.referee_result = refereeResult;
10714
10860
  if (!verifierResult || !refereeResult.passed) {
10715
10861
  verifierResult = verifierResult
package/commands/sync.js CHANGED
@@ -3,6 +3,7 @@ const path = require('path');
3
3
  const os = require('os');
4
4
  const { ensureWikiScaffold } = require('../lib/wiki');
5
5
  const { upsertAtrisClaudeBootBlock } = require('../lib/claude-boot-block');
6
+ const { ensureRunIgnore } = require('../lib/gitignore');
6
7
  const {
7
8
  upsertAgentVoiceCard,
8
9
  upsertClaudeVoiceHook,
@@ -492,6 +493,10 @@ function syncAtris(options = {}) {
492
493
  dryRun,
493
494
  force,
494
495
  });
496
+ const bizRunIgnore = ensureRunIgnore(process.cwd(), { dryRun });
497
+ if (bizRunIgnore.changed) {
498
+ console.log(`${dryRun ? 'Would update' : '✓ Updated'} .gitignore (atris run output ignored)`);
499
+ }
495
500
  exportTeamAfterUpdate(process.cwd(), { dryRun });
496
501
  if (!yes) {
497
502
  console.log('\nPlan only. Re-run with --yes to write these files.');
@@ -730,6 +735,14 @@ Key behaviors:
730
735
  applyClaudeUpdate(content, 'create CLAUDE.md with atris block', 'created CLAUDE.md with atris block');
731
736
  }
732
737
 
738
+ // Member run output must stay out of git or member self-merge never fires
739
+ // on a dirty tree. Append-only; plan-only mode prints instead of writing.
740
+ const runIgnore = ensureRunIgnore(process.cwd(), { dryRun });
741
+ if (runIgnore.changed) {
742
+ console.log(`${dryRun ? 'Would update' : '✓ Updated'} .gitignore (atris run output ignored)`);
743
+ updated++;
744
+ }
745
+
733
746
  if (dryRun) {
734
747
  console.log(`\nDry run: ${updated} file(s) would update, ${skipped} unchanged; no changes made.`);
735
748
  if (!yes) {
package/commands/task.js CHANGED
@@ -253,6 +253,7 @@ atris task - durable local task state (SQLite, gitignored)
253
253
  atris task archive <id> --reason "..." [--from-failed]
254
254
  Sweep off-roadmap/duplicate work as archived (not failed);
255
255
  --from-failed opts in to relabel a fail-closed row (never done)
256
+ atris task reopen <id> [--reason "..."] Send a done, failed, or archived task back to open
256
257
  atris task clear-done [--before <days>] [--dry-run] [--json] Archive completed rows, oldest first
257
258
  atris task prune-test-junk [--yes] [--vacuum] [--json]
258
259
  Count rows test runs left from temp folders; --yes backs up the db, then removes them
@@ -6243,6 +6244,27 @@ function cmdAdd(args) {
6243
6244
  const taskDb = getTaskDb();
6244
6245
  const db = taskDb.open();
6245
6246
  const ws = taskDb.workspaceRoot();
6247
+ // Filing a job that is already open or claimed hands back that job instead
6248
+ // of making a copy. Work already handed in for review can be filed again.
6249
+ const existing = taskDb.findUnfinishedTaskByTitle(db, { workspaceRoot: ws, title });
6250
+ if (existing) {
6251
+ const { projection, outPath } = writeDefaultProjection(taskDb, db);
6252
+ const task = compactTaskFromProjection(projection, existing.id);
6253
+ if (wantsJson(args)) {
6254
+ printJson({
6255
+ ok: true,
6256
+ action: 'reused',
6257
+ task_id: existing.id,
6258
+ inserted: false,
6259
+ operator_title_warning: null,
6260
+ projection_path: outPath,
6261
+ task,
6262
+ });
6263
+ return;
6264
+ }
6265
+ console.log(`${taskRef(task)}\t${existing.title}\t(already on the list, reused)`);
6266
+ return;
6267
+ }
6246
6268
  const operatorTitleWarning = warnIfTaskTitleNeedsOperatorWhy(title);
6247
6269
  // Generation throttle, the named root cause is generation > human-review rate. An AGENT cannot keep
6248
6270
  // minting tasks while a wall of certified-but-unaccepted work waits; that treadmill is what accept-group
@@ -9738,6 +9760,45 @@ function cmdArchive(args) {
9738
9760
  }
9739
9761
  }
9740
9762
 
9763
+ // Send a done, failed, or archived task back to open so it can be claimed
9764
+ // again. Review sign-offs are cleared; the old status and reason are kept.
9765
+ function cmdReopen(args) {
9766
+ const pos = positional(args);
9767
+ const id = pos[0];
9768
+ if (!id) failTask('atris task reopen', 'missing_id', 'task id required');
9769
+ const reason = textFlag(args, ['--reason']) || 'reopened';
9770
+ const taskDb = getTaskDb();
9771
+ const db = taskDb.open();
9772
+ const taskId = requireTaskId(taskDb, db, id, 'atris task reopen');
9773
+ const actor = String(flag(args, '--as') || DEFAULT_OWNER);
9774
+ const result = taskDb.reopenTask(db, { id: taskId, actor, reason });
9775
+ if (!result.reopened) {
9776
+ const detail = `reopen failed: ${taskId} ${result.reason}`;
9777
+ if (wantsJson(args)) {
9778
+ printJson({ ok: false, command: 'atris task reopen', reason: result.reason, task_id: taskId, detail });
9779
+ process.exit(1);
9780
+ }
9781
+ console.error(detail);
9782
+ process.exit(1);
9783
+ }
9784
+ const reopenedFrom = result.row && result.row.metadata && result.row.metadata.reopened_from || null;
9785
+ const { projection, outPath } = writeDefaultProjection(taskDb, db);
9786
+ const task = compactTaskFromProjection(projection, taskId);
9787
+ if (wantsJson(args)) {
9788
+ printJson({
9789
+ ok: true,
9790
+ action: 'reopened',
9791
+ task_id: taskId,
9792
+ reopened_from: reopenedFrom,
9793
+ reason,
9794
+ projection_path: outPath,
9795
+ task,
9796
+ });
9797
+ return;
9798
+ }
9799
+ console.log(`reopened ${taskRef(task)}${reopenedFrom ? ` (was ${reopenedFrom})` : ''}: ${reason}`);
9800
+ }
9801
+
9741
9802
  function taskCompletionTime(row) {
9742
9803
  const doneAt = Number(row && row.done_at);
9743
9804
  if (Number.isFinite(doneAt) && doneAt > 0) return doneAt;
@@ -13475,6 +13536,7 @@ async function runTaskCommand(args) {
13475
13536
  case 'finish': return cmdFinish(rest);
13476
13537
  case 'fail': return cmdDone([...rest, '--failed']);
13477
13538
  case 'archive': return cmdArchive(rest);
13539
+ case 'reopen': return cmdReopen(rest);
13478
13540
  case 'clear-done': return cmdClearDone(rest);
13479
13541
  case 'prune-test-junk': return cmdPruneTestJunk(rest);
13480
13542
  case 'keep': return cmdKeep(rest);
@@ -13521,7 +13583,7 @@ const MUTATING_TASK_COMMANDS = new Set([
13521
13583
  'continue-work', 'continue', 'chat', 'note', 'say', 'retitle', 'tag', 'tags', 'step',
13522
13584
  'ready', 'result', 'accept', 'landing', 'land-review', 'auto-accept-certified',
13523
13585
  'auto-accept', 'sweep', 'audit', 'certify-verified', 'accept-group', 'revise',
13524
- 'done', 'finish', 'fail', 'archive', 'clear-done', 'reap-mission-blockers', 'reap-blockers', 'relabel-archived', 'review', 'import',
13586
+ 'done', 'finish', 'fail', 'archive', 'reopen', 'clear-done', 'reap-mission-blockers', 'reap-blockers', 'relabel-archived', 'review', 'import',
13525
13587
  'setup', 'review-lane-act', 'review-act', 'act-review', 'review-lane-loop',
13526
13588
  'review-loop', 'loop-review', 'review-lane-run', 'review-run', 'run-review',
13527
13589
  ]);
@@ -159,6 +159,26 @@ function branchName(member, task, now = new Date()) {
159
159
  return `codex/${slugify(member, 'member', 24)}-${slugify(task, 'task', 36)}-${stamp(now)}`;
160
160
  }
161
161
 
162
+ // The newest earlier run of this same member+job, if its commits never reached
163
+ // the base, so a retry continues that work instead of redoing it. Only the
164
+ // newest run counts: once it landed or did nothing, anything older is stale.
165
+ function lastRunBranch(root, owner, task, base, skip) {
166
+ const prefix = `codex/${slugify(owner, 'member', 24)}-${slugify(task, 'task', 36)}-`;
167
+ const listed = runGit(['for-each-ref', '--format=%(refname:short)', `refs/heads/${prefix}*`], { cwd: root, check: false });
168
+ if (listed.status !== 0) return null;
169
+ // Only exact runs of this job (stamp plus optional same-second number), so
170
+ // "fix-login" never matches "fix-login-page".
171
+ const runs = listed.stdout.split('\n').map((name) => name.trim())
172
+ .map((name) => ({ name, run: name.slice(prefix.length).match(/^(\d{8}-\d{6})(?:-(\d+))?$/) }))
173
+ .filter(({ name, run }) => run && name !== skip)
174
+ .sort((a, b) => b.run[1].localeCompare(a.run[1]) || Number(b.run[2] || 1) - Number(a.run[2] || 1));
175
+ if (!runs.length) return null;
176
+ const newest = runs[0].name;
177
+ const cherry = runGit(['cherry', base, newest], { cwd: root, check: false });
178
+ if (cherry.status !== 0) return null;
179
+ return cherry.stdout.split('\n').some((line) => line.startsWith('+')) ? newest : null;
180
+ }
181
+
162
182
  function defaultWorktreePath(root, member, task, now = new Date()) {
163
183
  const name = `${slugify(member, 'member', 24)}-${slugify(task, 'task', 36)}-${stamp(now)}`;
164
184
  return path.join(path.dirname(root), '.agent-worktrees', path.basename(root), name);
@@ -529,8 +549,18 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
529
549
  reapedBeforeStart = cleaned.removed;
530
550
  cachePrunedBeforeStart = cleaned.cachePruned;
531
551
  } catch {}
532
- const branch = branchOverride || branchName(owner, task, now);
533
- const target = path.resolve(pathOverride || defaultWorktreePath(root, owner, task, now));
552
+ let branch = branchOverride || branchName(owner, task, now);
553
+ let target = path.resolve(pathOverride || defaultWorktreePath(root, owner, task, now));
554
+ // Two runs of the same job in the same second get the same name; number
555
+ // the later one instead of failing it.
556
+ if (!pathOverride && !branchOverride) {
557
+ const baseBranch = branch;
558
+ const baseTarget = target;
559
+ for (let n = 2; fs.existsSync(target) && n < 100; n += 1) {
560
+ branch = `${baseBranch}-${n}`;
561
+ target = `${baseTarget}-${n}`;
562
+ }
563
+ }
534
564
  // Agent/member worktrees always cut from the mainline: honoring the
535
565
  // launcher checkout's upstream let a dispatch inherit another session's
536
566
  // in-progress feature branch (2026-07-09: an auth fix arrived carrying 13
@@ -540,9 +570,15 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
540
570
  if (fs.existsSync(target)) throw new Error(`worktree path already exists: ${target}`);
541
571
  fs.mkdirSync(path.dirname(target), { recursive: true });
542
572
  const staleBase = refreshRemoteRef(root, checkoutBase);
573
+ // A retry of the same job starts from the last run's unlanded commits; it
574
+ // still ships to the mainline.
575
+ const resumedFrom = (!pathOverride && !branchOverride)
576
+ ? lastRunBranch(root, owner, task, checkoutBase, branch)
577
+ : null;
578
+ const startPoint = resumedFrom || checkoutBase;
543
579
  const ref = `refs/heads/${branch}`;
544
580
  const existed = runGit(['show-ref', '--verify', '--quiet', ref], { cwd: root, check: false }).status !== 1;
545
- const baseCommit = runGit(['rev-parse', '--verify', `${checkoutBase}^{commit}`], { cwd: root, check: false });
581
+ const baseCommit = runGit(['rev-parse', '--verify', `${startPoint}^{commit}`], { cwd: root, check: false });
546
582
  const startCommit = baseCommit.status === 0 ? baseCommit.stdout.trim() : '';
547
583
  // With no local copy the start fails on its own; only a real cut gets the warning.
548
584
  if (staleBase && startCommit) {
@@ -562,7 +598,7 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
562
598
  if (!fs.existsSync(target)) runGit(['update-ref', '-d', ref, startCommit], { cwd: root });
563
599
  };
564
600
  for (let attempt = 0; attempt < 3; attempt += 1) {
565
- const added = runGit(['worktree', 'add', '-b', branch, target, checkoutBase], { cwd: root, check: false });
601
+ const added = runGit(['worktree', 'add', '-b', branch, target, startPoint], { cwd: root, check: false });
566
602
  if (added.status === 0) break;
567
603
  // Git can create the branch before failing to write its tracking config.
568
604
  // Drop only that untouched ref, never an existing branch or a checkout.
@@ -587,6 +623,7 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
587
623
  branch,
588
624
  base: shipBase,
589
625
  checkout_base: checkoutBase,
626
+ resumed_from: resumedFrom,
590
627
  workspace_root: root,
591
628
  created_at: now.toISOString(),
592
629
  }, null, 2) + '\n',
@@ -596,7 +633,7 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
596
633
  discardFailedStart();
597
634
  throw error;
598
635
  }
599
- return { path: target, branch, base: shipBase, checkoutBase, owner, reapedBeforeStart, cachePrunedBeforeStart };
636
+ return { path: target, branch, base: shipBase, checkoutBase, resumedFrom, owner, reapedBeforeStart, cachePrunedBeforeStart };
600
637
  }
601
638
 
602
639
  function startWorktree(args) {
@@ -1396,6 +1396,7 @@ function evaluateAutoAccept(task, options = {}) {
1396
1396
  module.exports = {
1397
1397
  AGENT_CERTIFICATION_REVIEW_PASSES,
1398
1398
  DENIED_TAGS,
1399
+ deniedTagOf,
1399
1400
  candidatePolicyGate,
1400
1401
  declaredProtectedLane,
1401
1402
  sniffedProtectedLane,
package/lib/engine-ask.js CHANGED
@@ -415,6 +415,7 @@ function runAskProcess(invocation, {
415
415
  killProcess = process.kill.bind(process),
416
416
  signal = null,
417
417
  onOutputChunk = null,
418
+ env = null,
418
419
  } = {}) {
419
420
  return new Promise((resolve) => {
420
421
  const startedAt = Date.now();
@@ -502,7 +503,7 @@ function runAskProcess(invocation, {
502
503
  try {
503
504
  child = spawnProcess(invocation.bin, invocation.args, {
504
505
  cwd,
505
- env: buildAskSpawnEnv(),
506
+ env: buildAskSpawnEnv(env || invocation.env || process.env),
506
507
  detached: process.platform !== 'win32',
507
508
  stdio: ['ignore', 'pipe', 'pipe'],
508
509
  });
@@ -182,11 +182,12 @@ const ENGINE_SEED_META = Object.freeze({
182
182
  // bake the effort into the model.
183
183
  models: Object.freeze([
184
184
  'gemini-3.8-flash',
185
+ // Matches `agy models` on agy 1.3.4, verified 2026-10-13.
186
+ 'gemini-4-argon-high',
187
+ 'gemini-4-argon-medium',
185
188
  'gemini-3.8-flash-high',
186
- 'gemini-3.7-flash-high',
187
- 'gemini-3.1-pro-high',
188
- 'claude-sonnet-4-6',
189
- 'claude-opus-4-6-thinking',
189
+ 'claude-opus-5-5-high',
190
+ 'claude-sonnet-5-5-high',
190
191
  'gpt-oss-120b-medium',
191
192
  ]),
192
193
  duty: 'errands',