atris 3.69.0 → 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.
package/README.md CHANGED
@@ -151,7 +151,28 @@ atris member board --watch
151
151
  tail -f ~/.atris/progress/executor.log
152
152
  ```
153
153
 
154
- The older words still work as aliases: `dispatch` and `go` do what run does, and `up` does what wake does. Run with a job, wake with a number or names, and `up` need the release after 3.68.9.
154
+ When a member finishes, ask for its result: what it changed, its receipt, and one number. Each member's copy of the repo stays until its work lands, then tidy puts it away.
155
+
156
+ ```bash
157
+ atris member result executor
158
+ atris member tidy --dry-run
159
+ ```
160
+
161
+ The older words still work as aliases: `dispatch` and `go` do what run does, and `up` does what wake does.
162
+
163
+ ### How member work gets checked and lands
164
+
165
+ Every member job ends with its own check, a command that must pass. Then a second engine reviews the change, and it is never the engine that did the work.
166
+
167
+ Work lands on its own only after you switch self-landing on, once. From then on Atris pushes the branch, opens a pull request, and turns on GitHub auto-merge, so GitHub merges it when the branch is up to date and CI passes.
168
+
169
+ ```bash
170
+ atris autoland on
171
+ ```
172
+
173
+ Anything else keeps the work on its branch, with one plain line saying why. That covers a failed check or review, unsaved changes, a clash with the main branch, and work that touches a protected area like payments, deploys, or credentials.
174
+
175
+ A job that fails three times in a row stops and waits for a person.
155
176
 
156
177
  ## Chat With Atris 2
157
178
 
@@ -51,8 +51,7 @@ function ensureMemberLog(memberDir, { name, role, description, source = 'cli' }
51
51
  description ? `- mission: ${description}` : '',
52
52
  `- source: ${source}`,
53
53
  '- status: ready_for_room',
54
- '',
55
- ].filter(Boolean).join('\n');
54
+ ].filter(Boolean).join('\n') + '\n';
56
55
  fs.writeFileSync(logPath, content, 'utf8');
57
56
  return logPath;
58
57
  }
@@ -915,6 +914,17 @@ function memberRunBudgetSeconds(args = []) {
915
914
  return null;
916
915
  }
917
916
 
917
+ // A job with no time given gets 15 minutes, or the roster's limit for this
918
+ // member's engine plus ten minutes for the review, whichever is longer.
919
+ function memberRunDefaultWallSeconds(name, cwd = process.cwd()) {
920
+ let cap = 0;
921
+ try {
922
+ const picked = require('../lib/member-engine').memberRosterEngine(name, cwd);
923
+ cap = Number(picked && picked.engine && picked.engine.roster_max_seconds) || 0;
924
+ } catch {}
925
+ return Math.max(900, cap ? cap + 600 : 0);
926
+ }
927
+
918
928
  function pushFlagValue(out, args, name) {
919
929
  const value = readFlag(args, name, '');
920
930
  if (value) out.push(name, String(value));
@@ -1040,7 +1050,7 @@ function memberRunCli(args, cwd, { live = false, missionId = '' } = {}) {
1040
1050
  : null;
1041
1051
  const receipt = receiptName ? readJson(path.join(runsDir, receiptName), null) : null;
1042
1052
  return { status: result.status, payload: receipt?.result?.kind === 'mission_run_summary'
1043
- ? { ran_ticks: receipt.result.ran_ticks } : null, stderr: '', errorReported: result.status !== 0 };
1053
+ ? { ran_ticks: receipt.result.ran_ticks, ticks: receipt.result.ticks } : null, stderr: '', errorReported: result.status !== 0 };
1044
1054
  }
1045
1055
  let payload = null;
1046
1056
  try {
@@ -1235,6 +1245,15 @@ function memberRun(name, ...args) {
1235
1245
  return memberRunWith(name, args);
1236
1246
  }
1237
1247
 
1248
+ // One plain line for why the reviewing engine refused the work.
1249
+ function reviewRefusalLine(refusal) {
1250
+ if (refusal.timed_out) return 'the reviewer ran out of time';
1251
+ const text = String(refusal.output || refusal.reason || '');
1252
+ const verdict = text.match(/VERDICT:\s*FAIL\s*[;:]\s*(.+)/i);
1253
+ const line = verdict ? verdict[1] : (text.split(/\r?\n/).find((l) => l.trim()) || refusal.reason || 'no reason given');
1254
+ return line.trim().slice(0, 200);
1255
+ }
1256
+
1238
1257
  function memberRunWith(name, args, { quiet = false } = {}) {
1239
1258
  if (!name || name === '--help' || name === '-h' || hasFlag(args, '--help') || hasFlag(args, '-h')) {
1240
1259
  console.log('usage: atris member run <name> ["mission text"] [--verify "<cmd>"] [--minutes N|--hours N] [--json]');
@@ -1338,7 +1357,7 @@ function memberRunWith(name, args, { quiet = false } = {}) {
1338
1357
  if (!readFlag(runArgs, '--max-ticks', '')) {
1339
1358
  runArgs.push('--max-ticks', budgetSeconds ? String(Math.max(4, Math.ceil(budgetSeconds / 300))) : '1');
1340
1359
  }
1341
- if (!readFlag(runArgs, '--max-wall', '')) runArgs.push('--max-wall', String(budgetSeconds || 900));
1360
+ if (!readFlag(runArgs, '--max-wall', '')) runArgs.push('--max-wall', String(budgetSeconds || memberRunDefaultWallSeconds(name, cwd)));
1342
1361
  const caller = callerRuntime(args);
1343
1362
  const runnerSwap = memberRunRunnerSwap(mission, args, caller);
1344
1363
  if (runnerSwap) runArgs.push('--engine', runnerSwap.to);
@@ -1351,7 +1370,12 @@ function memberRunWith(name, args, { quiet = false } = {}) {
1351
1370
  const waitingOnNativeGoal = caller === 'codex' && payload.requires_native_goal_start === true
1352
1371
  && memberRunUsesCallerSession(finalMission.runner);
1353
1372
  const noWorkStepExpected = hasFlag(runArgs, '--detach') || hasFlag(runArgs, '--dry-run');
1354
- const ok = (run.status === 0 && payload.ok !== false && (started || noWorkStepExpected)) || waitingOnNativeGoal;
1373
+ // The work ran but its check failed: the run failed, even though a work
1374
+ // step happened. Scripts and the caller read this from the exit code.
1375
+ const lastRanTick = [...(Array.isArray(payload.ticks) ? payload.ticks : [])].reverse().find((tick) => tick && tick.ran === true);
1376
+ const checkFailed = started && Boolean(String(finalMission.verifier || '').trim())
1377
+ && lastRanTick?.verifier_passed === false && finalMission.status !== 'complete';
1378
+ const ok = ((run.status === 0 && payload.ok !== false && (started || noWorkStepExpected)) || waitingOnNativeGoal) && !checkFailed;
1355
1379
  const reviewAsk = memberRunReviewAsk(name, payload, finalMission, cwd);
1356
1380
  const result = { ...payload, ok, started, state: finalMission.status, mission_id: mission.id,
1357
1381
  mission: finalMission, node_modules_linked: nodeModulesLinked, node_modules_source: nodeModulesSource,
@@ -1385,7 +1409,13 @@ function memberRunWith(name, args, { quiet = false } = {}) {
1385
1409
  result.autoland = require('../lib/member-autoland').autolandMemberRun({ mission: finalMission, cwd, summary, workerEngine });
1386
1410
  if (!asJson && !quiet) console.log(result.autoland.line);
1387
1411
  }
1388
- if (!ok) {
1412
+ if (checkFailed) {
1413
+ result.check_failed = true;
1414
+ const refusal = lastRanTick?.referee_result?.passed === false ? lastRanTick.referee_result : null;
1415
+ result.error = refusal
1416
+ ? `turned down in review: ${reviewRefusalLine(refusal)}`
1417
+ : `the work ran but its check failed: ${finalMission.verifier || 'completion check'}`;
1418
+ } else if (!ok) {
1389
1419
  result.error = result.ask || payload.detail || payload.error || payload.reason || payload.next_action
1390
1420
  || run.stderr || 'mission did not run a work step';
1391
1421
  }
@@ -4256,7 +4286,10 @@ function parseFrontmatter(content) {
4256
4286
  } else if (val.startsWith('[') && val.endsWith(']')) {
4257
4287
  result[currentKey] = val.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, ''));
4258
4288
  } else {
4259
- result[currentKey] = val.replace(/^["']|["']$/g, '');
4289
+ // Unwrap only a matching outer pair: `verify: node -e "process.exit(0)"`
4290
+ // must keep its closing quote or the check is a shell syntax error.
4291
+ const quoted = val.match(/^(["'])([\s\S]*)\1$/);
4292
+ result[currentKey] = quoted ? quoted[2] : val;
4260
4293
  }
4261
4294
  }
4262
4295
  }
@@ -10463,12 +10496,43 @@ function memberGoFinishTask(owner, task, result, thrown = null) {
10463
10496
  const ready = taskDb.readyTask(db, { id: task.id, actor: owner, proof });
10464
10497
  if (ready.ready !== false) return { id: task.id, title: task.title, outcome: 'review', receipt: receipt || null };
10465
10498
  }
10499
+ const ticks = Array.isArray(result && result.ticks) ? result.ticks : [];
10500
+ const lastReason = String((ticks[ticks.length - 1] || {}).reason || (ticks[ticks.length - 1] || {}).error || '');
10501
+ const outOfTime = /timeout|wall-exceeded/.test(lastReason);
10466
10502
  const why = thrown ? thrown.message
10503
+ : outOfTime ? 'ran out of time before the work finished'
10467
10504
  : (result && result.error) || (result && result.started ? 'the check did not pass' : 'the run did not start');
10468
10505
  const reason = String(why || 'the run did not finish').split(/\r?\n/)[0].trim().slice(0, 200);
10469
10506
  try { taskDb.noteTask(db, { id: task.id, actor: owner, content: `Released by ${owner}: ${reason}` }); } catch {}
10507
+ // Third failure in a row: hold the task for a person instead of letting
10508
+ // members pick it up again forever.
10509
+ let held = false;
10510
+ if (memberGoFailStreak(taskDb, db, task.id) >= MEMBER_GO_STRIKES) {
10511
+ try {
10512
+ held = taskDb.tagTask(db, { id: task.id, actor: owner, add: ['needs-human'] }).tagged === true;
10513
+ if (held) taskDb.noteTask(db, { id: task.id, actor: owner,
10514
+ content: `Held for a person: failed ${MEMBER_GO_STRIKES} times in a row. Last reason: ${reason}` });
10515
+ } catch {}
10516
+ }
10470
10517
  const released = taskDb.releaseTask(db, { id: task.id, actor: owner });
10471
- return { id: task.id, title: task.title, outcome: released.released ? 'released' : 'left_claimed', reason };
10518
+ const outcome = released.released ? (held ? 'needs_human' : 'released') : 'left_claimed';
10519
+ return { id: task.id, title: task.title, outcome, reason };
10520
+ }
10521
+
10522
+ const MEMBER_GO_STRIKES = 3;
10523
+
10524
+ // Failed runs on a task since its last pass, review, reopen, or a person
10525
+ // clearing its hold.
10526
+ function memberGoFailStreak(taskDb, db, taskId) {
10527
+ let streak = 0;
10528
+ for (const event of taskDb.listTaskEvents(db, { taskId })) {
10529
+ const type = event.event_type;
10530
+ const payload = event.payload || {};
10531
+ if (type === 'message' && /^Released by \S+: /.test(String(payload.content || ''))) streak += 1;
10532
+ else if (['proof_ready', 'reviewed', 'reopened'].includes(type)) streak = 0;
10533
+ else if (type === 'task_tags_updated' && (payload.removed || []).includes('needs-human')) streak = 0;
10534
+ }
10535
+ return streak;
10472
10536
  }
10473
10537
 
10474
10538
  async function memberSteerOrDispatch(...rawArgs) {
@@ -10651,6 +10715,7 @@ async function memberSteerOrDispatchWith({ pullTask = false } = {}, rawArgs = []
10651
10715
  ? { task, work_step: { summary: result?.work_step?.summary || null }, error: result?.error || null }
10652
10716
  : merged));
10653
10717
  else if (task.outcome === 'review') console.log(`Task "${task.title}" is ready for review.`);
10718
+ else if (task.outcome === 'needs_human') console.log(`Task "${task.title}" failed three times in a row and now waits for a person: ${task.reason}`);
10654
10719
  else console.log(`Task "${task.title}" went back on the list: ${task.reason}`);
10655
10720
  return merged;
10656
10721
  }
@@ -11239,7 +11304,7 @@ async function memberCommand(subcommand, ...args) {
11239
11304
  const HELP_AWARE_SUBCOMMANDS = new Set([
11240
11305
  'create', 'new', 'run', 'install', 'result', 'dispatch', 'route', 'feed', 'stream',
11241
11306
  'card', 'cards', 'go', 'work', 'up', 'steer', 'sleep', 'rest', '1on1', 'interview',
11242
- 'room', 'ui', 'day', 'standup', 'handoff', 'handoffs',
11307
+ 'room', 'ui', 'day', 'standup', 'handoff', 'handoffs', 'report',
11243
11308
  ]);
11244
11309
  const explicitHelp = subcommand === 'help' || subcommand === '--help' || subcommand === '-h'
11245
11310
  || (!HELP_AWARE_SUBCOMMANDS.has(subcommand) && (args[0] === '-h' || args[0] === '--help'));
@@ -11345,6 +11410,8 @@ async function memberCommand(subcommand, ...args) {
11345
11410
  return memberHeartbeat(args[0], ...args.slice(1));
11346
11411
  case 'improvements':
11347
11412
  return memberImprovements(args[0], ...args.slice(1));
11413
+ case 'report':
11414
+ return require('../lib/member-report').memberReportCommand(...args);
11348
11415
  case 'history':
11349
11416
  return memberHistory(args[0], ...args.slice(1));
11350
11417
  case 'supervisor':
@@ -11406,6 +11473,7 @@ async function memberCommand(subcommand, ...args) {
11406
11473
  console.log(' wake <name> Decide tick/wait/ask/stop, then run one bounded work step on the roster engine (--plan to only decide; --keep keeps it working)');
11407
11474
  console.log(' run "<job>" Pick the best member for the job and run it (same as dispatch "<job>" --ignite)');
11408
11475
  console.log(' run <name> ["..."] Start a budgeted member mission, or run its active Mission Runtime');
11476
+ console.log(' report [--since 2h] Short summary per member: merged, finished, turned down, done but not merged yet [--json]');
11409
11477
  console.log(' result <name> Newest run result: run record, receipt, artifacts, number [--since <iso>] [--json]');
11410
11478
  console.log(' dispatch "<idea>" Route an idea to the best specialist member and start work');
11411
11479
  console.log(' up [--max N] [--only a,b] Keep the team working: start free members on fitting tasks every minute (wake 5 / wake a b)');
@@ -11469,6 +11537,7 @@ async function memberCommand(subcommand, ...args) {
11469
11537
  }
11470
11538
 
11471
11539
  module.exports = {
11540
+ memberRunDefaultWallSeconds,
11472
11541
  memberRunWantsReferee,
11473
11542
  proposalPromptForGoal,
11474
11543
  memberGoFinishTask,
@@ -434,7 +434,13 @@ function missionTickTimeoutMs(mission, remainingMs) {
434
434
 
435
435
  function resolveMissionTickRunner(mission, root = process.cwd(), options = {}) {
436
436
  if (String(mission && mission.runner || '').trim().toLowerCase() !== MISSION_AUTO_RUNNER) {
437
- 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 };
438
444
  }
439
445
  // A mission owned by a team member runs on that member's roster pick. An
440
446
  // explicit preferred engine (or a run's --engine) still wins, and with no
@@ -1723,7 +1729,18 @@ function appendMemberLog(owner, title, fields = {}, root = process.cwd()) {
1723
1729
  .map(([key, value]) => `- ${key}: ${String(value).replace(/\n/g, ' ')}`),
1724
1730
  '',
1725
1731
  ];
1726
- 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');
1727
1744
  return logPath;
1728
1745
  }
1729
1746
 
@@ -3910,6 +3927,30 @@ function readTasteReviewHistory(root, limit = 4) {
3910
3927
  return { accepted, revised };
3911
3928
  }
3912
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
+
3913
3954
  function missionTasteMemorySignals(text) {
3914
3955
  const definitions = [
3915
3956
  ['plain_language', 'the operator asks for plain English', /\b(plain|jargon|feynman|understand|simple|clarify|clarity)\b/i],
@@ -8938,6 +8979,16 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
8938
8979
  checkFeedback,
8939
8980
  ]
8940
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
+ : [];
8941
8992
  const preloadedContextLines = buildPreloadedMemberContextLines(mission, cwd);
8942
8993
  const lines = [
8943
8994
  `# Mission Tick ${tickIndex}/${maxTicks}`,
@@ -8953,6 +9004,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
8953
9004
  `**Last tick:** ${mission.last_tick_at || 'never'}`,
8954
9005
  ...missionBudgetPromptLines(mission),
8955
9006
  ...checkFeedbackLines,
9007
+ ...turnDownLines,
8956
9008
  ...(sharedProcess ? ['', sharedProcess] : []),
8957
9009
  ...preloadedContextLines,
8958
9010
  ``,
@@ -9241,7 +9293,16 @@ function spawnGenericRunnerTick(mission, opts) {
9241
9293
  }
9242
9294
 
9243
9295
  const startedAt = Date.now();
9244
- 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}`;
9245
9306
  const proc = spawn('sh', ['-lc', guardedCommand], {
9246
9307
  cwd,
9247
9308
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -9249,9 +9310,11 @@ function spawnGenericRunnerTick(mission, opts) {
9249
9310
  env: { ...gitGuard.env, ATRIS_DISPATCH_DEPTH: String(dispatchDepth.depth + 1) },
9250
9311
  });
9251
9312
  updateMissionLockOwner(missionLock, proc.pid, { phase: 'tick' });
9252
- const progressMember = mission.owner || 'mission';
9253
9313
  const seenItemIds = new Set();
9254
- if (lastMessageFile) appendProgress(progressMember, `started: ${mission.objective || ''}`, mission.id);
9314
+ if (lastMessageFile) {
9315
+ const tag = runToken ? { pid: proc.pid, engine: 'codex', run: runToken } : {};
9316
+ appendProgress(progressMember, startedLine(mission.objective, tag), mission.id);
9317
+ }
9255
9318
  let stdout = '';
9256
9319
  let eventBuf = '';
9257
9320
  let lastAgentMessage = '';
@@ -9671,6 +9734,20 @@ function cappedClaudeReceiptText(text, limit = 4000) {
9671
9734
  return clean.slice(0, limit - 16).trimEnd() + '\n...[truncated]';
9672
9735
  }
9673
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
+
9674
9751
  async function parseAndValidateMissionRunPhase(args) {
9675
9752
  const asJson = wantsJson(args);
9676
9753
  const context = {
@@ -10296,6 +10373,7 @@ async function executeMissionRunTicksPhase(context) {
10296
10373
  brief_id: claudeResult.brief_id || null,
10297
10374
  summary: claudeResult.summary,
10298
10375
  receipt_text: claudeResult.receipt_text,
10376
+ report_path: saveFullRunnerReport(mission, claudeResult.result, cwd),
10299
10377
  stop_reason: claudeResult.stop_reason,
10300
10378
  api_equivalent_estimate: claudeResult.api_equivalent_estimate,
10301
10379
  duration_total_ms: claudeResult.duration_total_ms,
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) {
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
  });