atris 3.69.0 → 3.71.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.
Files changed (79) hide show
  1. package/README.md +40 -1
  2. package/atris/skills/README.md +1 -0
  3. package/atris/skills/campaigns/SKILL.md +81 -0
  4. package/atris/skills/meta/SKILL.md +52 -8
  5. package/ax +226 -42
  6. package/bin/atris.js +26 -6
  7. package/commands/autoland.js +176 -7
  8. package/commands/autopilot.js +71 -11
  9. package/commands/campaign.js +759 -0
  10. package/commands/compile.js +19 -8
  11. package/commands/doctor.js +58 -0
  12. package/commands/dream.js +18 -3
  13. package/commands/engine.js +147 -5
  14. package/commands/improve.js +119 -9
  15. package/commands/init.js +18 -1
  16. package/commands/interview.js +15 -3
  17. package/commands/land.js +22 -6
  18. package/commands/link.js +1 -0
  19. package/commands/member.js +433 -99
  20. package/commands/mission.js +332 -41
  21. package/commands/report.js +42 -8
  22. package/commands/run.js +9 -2
  23. package/commands/scout.js +9 -1
  24. package/commands/task.js +132 -16
  25. package/commands/team.js +212 -4
  26. package/commands/unknowns.js +15 -1
  27. package/commands/verify.js +46 -0
  28. package/commands/worktree.js +148 -18
  29. package/commands/write.js +12 -1
  30. package/commands/youtube.js +17 -2
  31. package/lib/accept-verify-gate.js +1 -0
  32. package/lib/auto-accept-certified.js +1 -0
  33. package/lib/autoland.js +13 -4
  34. package/lib/campaign-cadence.js +131 -0
  35. package/lib/campaign-core.js +330 -0
  36. package/lib/campaign-holdout.js +65 -0
  37. package/lib/campaign-loop.js +242 -0
  38. package/lib/campaign-missions.js +237 -0
  39. package/lib/campaign-new.js +251 -0
  40. package/lib/campaign-protect.js +75 -0
  41. package/lib/campaign-reading.js +147 -0
  42. package/lib/campaign-schema.js +196 -0
  43. package/lib/campaign-store.js +380 -0
  44. package/lib/campaign-sync.js +302 -0
  45. package/lib/campaign-trace.js +300 -0
  46. package/lib/conductor-artifacts.js +15 -1
  47. package/lib/daily-log.js +11 -1
  48. package/lib/dispatch-scout.js +18 -0
  49. package/lib/engine-ask.js +35 -4
  50. package/lib/engine-registry.js +2 -0
  51. package/lib/engine-resource.js +518 -0
  52. package/lib/engine-validate.js +189 -9
  53. package/lib/fleet.js +125 -16
  54. package/lib/init-commit.js +80 -0
  55. package/lib/intents.js +8 -0
  56. package/lib/known-commands.js +1 -1
  57. package/lib/land-green.js +58 -7
  58. package/lib/landing-proof.js +352 -0
  59. package/lib/member-autoland.js +479 -10
  60. package/lib/member-board.js +86 -10
  61. package/lib/member-map-fold.js +126 -0
  62. package/lib/member-map-inbox.js +80 -0
  63. package/lib/member-progress.js +48 -1
  64. package/lib/member-report.js +433 -0
  65. package/lib/member-scaffold.js +1 -2
  66. package/lib/member-self-next-job.js +97 -0
  67. package/lib/member-switches.js +7 -0
  68. package/lib/member-task-pick.js +41 -14
  69. package/lib/member-up.js +249 -16
  70. package/lib/roster-prep.js +25 -2
  71. package/lib/runner-command.js +5 -0
  72. package/lib/task-db.js +63 -11
  73. package/lib/task-explanation.js +7 -0
  74. package/lib/task-list-keeper.js +8 -1
  75. package/lib/task-receipt.js +6 -1
  76. package/mcp/atris-mcp/index.mjs +212 -10
  77. package/package.json +2 -1
  78. package/scripts/det/engine-reading.js +37 -0
  79. package/scripts/det/ytnotes +29 -5
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const os = require('os');
5
6
  const crypto = require('crypto');
6
7
  const readline = require('readline');
7
8
  const { spawn, spawnSync } = require('child_process');
@@ -115,6 +116,8 @@ const {
115
116
 
116
117
  const VALID_STATUSES = new Set(['planning', 'running', 'ready', 'paused', 'blocked', 'stopped', 'complete']);
117
118
  const TERMINAL_STATUSES = new Set(['stopped', 'complete']);
119
+ // Campaign hooks load on first use, so missions without a campaign never open its store.
120
+ const campaignMissions = () => require('../lib/campaign-missions');
118
121
  const GOAL_LOOP_STATUSES = new Set(['planning', 'running', 'ready']);
119
122
  const STATUS_ALIASES = new Set(['active']);
120
123
  const CODEX_NATIVE_GOAL_SLOT_STATUSES = new Set(['active', 'paused', 'usage_limited']);
@@ -425,16 +428,41 @@ async function missionTickPrep(root, { mission, runtimeMission, job, prompt, ask
425
428
 
426
429
  // A tick's time limit: the time left on the mission, capped by the roster
427
430
  // line's "max 20 min" when it sets one, else by the default tick cap.
428
- function missionTickTimeoutMs(mission, remainingMs) {
429
- const cap = Number(mission && mission.roster_max_seconds) > 0
431
+ function missionTickCapMs(mission) {
432
+ return Number(mission && mission.roster_max_seconds) > 0
430
433
  ? Number(mission.roster_max_seconds) * 1000
431
434
  : MISSION_RUN_DEFAULTS.claudeTimeoutMs;
432
- return clampTimeoutMs(remainingMs, cap);
435
+ }
436
+
437
+ function missionTickTimeoutMs(mission, remainingMs) {
438
+ return clampTimeoutMs(remainingMs, missionTickCapMs(mission));
439
+ }
440
+
441
+ // True when the time left on the mission, not the tick's own cap, set the
442
+ // tick's limit, so a kill there means the mission clock ran out.
443
+ function missionTickWallBound(mission, remainingMs) {
444
+ return remainingMs <= missionTickCapMs(mission);
445
+ }
446
+
447
+ // The reason a member log shows for a tick. A failed engine tick names its
448
+ // engine beside the reason, so a codex run that timed out stops counting as
449
+ // a claude timeout in the repeat-problem scan. The reason itself is left as
450
+ // is because engine health and backoff match on it.
451
+ function memberLogTickReason(result) {
452
+ const reason = result && result.reason;
453
+ if (!reason || !result.engine_id || result.status !== 'errored') return reason;
454
+ return `${reason} (engine: ${result.engine_id})`;
433
455
  }
434
456
 
435
457
  function resolveMissionTickRunner(mission, root = process.cwd(), options = {}) {
436
458
  if (String(mission && mission.runner || '').trim().toLowerCase() !== MISSION_AUTO_RUNNER) {
437
- return { mission, engine_id: null, requested_engine: null, engine_fallback_reason: null };
459
+ // A named engine still gets the roster's time limit for that engine.
460
+ const runner = String(mission && mission.runner || '').trim().toLowerCase();
461
+ const pin = runner && !(Number(mission && mission.roster_max_seconds) > 0)
462
+ ? require('../lib/engine-registry').rosterPinFor('executor', runner, root, options)
463
+ : {};
464
+ const limited = pin.roster_max_seconds ? { ...mission, roster_max_seconds: pin.roster_max_seconds } : mission;
465
+ return { mission: limited, engine_id: null, requested_engine: null, engine_fallback_reason: null };
438
466
  }
439
467
  // A mission owned by a team member runs on that member's roster pick. An
440
468
  // explicit preferred engine (or a run's --engine) still wins, and with no
@@ -736,6 +764,8 @@ const MISSION_RUN_VALUE_FLAGS = [
736
764
  '--engine',
737
765
  '--referee-engine',
738
766
  '--budget',
767
+ '--campaign',
768
+ '--reserve',
739
769
  '--repo',
740
770
  '--native-goal-status',
741
771
  '--native-goal-objective',
@@ -1472,14 +1502,24 @@ function nearestMissionCandidates(missions, ref, limit = 5) {
1472
1502
  .map((entry) => entry.mission);
1473
1503
  }
1474
1504
 
1475
- function listSiblingWorkspaceMissionHints(ref, root = process.cwd(), limit = 5) {
1505
+ // Sibling folders looked at for a hint. A missing-id error waited 26 seconds
1506
+ // on a temp folder holding 276,000 entries, so the scan is capped and never
1507
+ // walks the system temp folder.
1508
+ const SIBLING_HINT_SCAN_LIMIT = 2000;
1509
+
1510
+ function listSiblingWorkspaceMissionHints(ref, root = process.cwd(), limit = 5, { scanLimit = SIBLING_HINT_SCAN_LIMIT, tmpRoot = os.tmpdir() } = {}) {
1476
1511
  if (!ref) return [];
1477
1512
  const baseRoot = path.resolve(root);
1478
1513
  const parent = path.dirname(baseRoot);
1479
- let here = baseRoot;
1480
- try {
1481
- here = fs.realpathSync(baseRoot);
1482
- } catch { /* keep raw path */ }
1514
+ const real = (target) => {
1515
+ try {
1516
+ return fs.realpathSync(target);
1517
+ } catch {
1518
+ return target;
1519
+ }
1520
+ };
1521
+ const here = real(baseRoot);
1522
+ if (real(parent) === real(tmpRoot)) return [];
1483
1523
  let entries = [];
1484
1524
  try {
1485
1525
  entries = fs.readdirSync(parent, { withFileTypes: true });
@@ -1487,17 +1527,11 @@ function listSiblingWorkspaceMissionHints(ref, root = process.cwd(), limit = 5)
1487
1527
  return [];
1488
1528
  }
1489
1529
  const hints = [];
1490
- for (const entry of entries) {
1530
+ for (const entry of entries.slice(0, scanLimit)) {
1491
1531
  if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
1492
1532
  const candidateRoot = path.join(parent, entry.name);
1493
- let candidateReal = candidateRoot;
1494
- try {
1495
- candidateReal = fs.realpathSync(candidateRoot);
1496
- } catch {
1497
- continue;
1498
- }
1499
- if (candidateReal === here) continue;
1500
1533
  if (!fs.existsSync(path.join(candidateRoot, '.atris', 'state', 'missions.jsonl'))) continue;
1534
+ if (real(candidateRoot) === here) continue;
1501
1535
  const mission = listMissions(candidateRoot).find((row) => missionMatchesRef(row, ref));
1502
1536
  if (!mission) continue;
1503
1537
  hints.push({
@@ -1690,6 +1724,7 @@ function saveMission(mission, root = process.cwd(), eventType = 'mission_updated
1690
1724
  // this file several times, so unbounded snapshot history taxes everything.
1691
1725
  try { compactMissionLedger(paths.missionsJsonl); } catch {}
1692
1726
  const event = appendEvent(eventType, next, payload, root);
1727
+ if (next.campaign_id) campaignMissions().recordCampaignMissionEnded(root, existing, next, TERMINAL_STATUSES);
1693
1728
  renderMissionStatus(root);
1694
1729
  renderMemberMissionState(next.owner, root);
1695
1730
  return { mission: next, event };
@@ -1723,7 +1758,18 @@ function appendMemberLog(owner, title, fields = {}, root = process.cwd()) {
1723
1758
  .map(([key, value]) => `- ${key}: ${String(value).replace(/\n/g, ' ')}`),
1724
1759
  '',
1725
1760
  ];
1726
- fs.appendFileSync(logPath, rows.join('\n'), 'utf8');
1761
+ let lead = '';
1762
+ try {
1763
+ const size = fs.statSync(logPath).size;
1764
+ if (size > 0) {
1765
+ const fd = fs.openSync(logPath, 'r');
1766
+ const last = Buffer.alloc(1);
1767
+ fs.readSync(fd, last, 0, 1, size - 1);
1768
+ fs.closeSync(fd);
1769
+ if (last[0] !== 0x0a) lead = '\n';
1770
+ }
1771
+ } catch {}
1772
+ fs.appendFileSync(logPath, lead + rows.join('\n'), 'utf8');
1727
1773
  return logPath;
1728
1774
  }
1729
1775
 
@@ -3152,6 +3198,9 @@ function missionFromArgs(args) {
3152
3198
  '--base',
3153
3199
  '--task',
3154
3200
  '--ask',
3201
+ '--campaign',
3202
+ '--reserve',
3203
+ '--mission-id',
3155
3204
  '--model',
3156
3205
  '--destination',
3157
3206
  '--native-goal-status',
@@ -3168,6 +3217,7 @@ function missionFromArgs(args) {
3168
3217
  const budgetContract = inferRunObjectiveBudgetContract(objective, args);
3169
3218
  const maxTicksOverride = readPositiveIntegerFlag(args, '--max-ticks', null, { json: wantsJson(args) });
3170
3219
  const requestedOwner = readFlag(args, '--owner', process.env.ATRIS_AGENT_ID || 'mission-lead');
3220
+ const campaignId = readFlag(args, '--campaign', '');
3171
3221
  const cadence = readFlag(args, '--cadence', readFlag(args, '--loop', 'manual')) || 'manual';
3172
3222
  const engineFlag = readFlag(args, '--engine', '');
3173
3223
  const runnerSelection = engineFlag
@@ -3195,7 +3245,13 @@ function missionFromArgs(args) {
3195
3245
  const alwaysOn = hasFlag(args, '--always-on');
3196
3246
  const xpTaskEnabled = hasFlag(args, '--xp-task') || hasFlag(args, '--agent-xp');
3197
3247
  const businessBinding = readBusinessBinding(process.cwd());
3198
- const id = missionId(objective);
3248
+ // A campaign proposes a mission under an id before it runs; --mission-id
3249
+ // keeps that id so the campaign and the mission name the same thing.
3250
+ const givenId = readFlag(args, '--mission-id', '');
3251
+ if (givenId && !/^[a-z0-9][a-z0-9-]{2,120}$/.test(givenId)) {
3252
+ exitMissionError('--mission-id takes lowercase letters, digits, and dashes', 2, wantsJson(args));
3253
+ }
3254
+ const id = givenId || missionId(objective);
3199
3255
  const mission = {
3200
3256
  schema: 'atris.mission.v1',
3201
3257
  id,
@@ -3221,6 +3277,7 @@ function missionFromArgs(args) {
3221
3277
  stop_condition: stopCondition,
3222
3278
  task_ids: taskIds,
3223
3279
  human_asks: humanAsks,
3280
+ ...(campaignId ? { campaign_id: campaignId } : {}),
3224
3281
  ...(drive ? { drive } : {}),
3225
3282
  next_action: verifier ? 'run verifier with `atris mission tick <id> --verify`' : 'define verifier or run next task',
3226
3283
  receipt_path: null,
@@ -3910,6 +3967,30 @@ function readTasteReviewHistory(root, limit = 4) {
3910
3967
  return { accepted, revised };
3911
3968
  }
3912
3969
 
3970
+ // Why the mission's own task was last sent back, or '' when it was not. Ticks
3971
+ // run in a worktree, so fall back to the main checkout's task projection.
3972
+ function readBoundTaskTurnDown(mission, root) {
3973
+ const bound = mission?.xp_task || mission?.selected_target_task || null;
3974
+ if (!bound) return '';
3975
+ const keys = new Set([bound.task_id, bound.id, bound.ref, bound.task_ref].filter(Boolean).map(String));
3976
+ if (!keys.size) return '';
3977
+ const roots = [root];
3978
+ const common = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: root, encoding: 'utf8' });
3979
+ if (common.status === 0 && String(common.stdout || '').trim()) roots.push(path.dirname(String(common.stdout).trim()));
3980
+ for (const dir of roots) {
3981
+ const projection = readJson(path.join(dir, '.atris', 'state', 'tasks.projection.json'));
3982
+ const tasks = Array.isArray(projection?.tasks) ? projection.tasks : [];
3983
+ const task = tasks.find((row) => row && [row.id, row.display_id, row.legacy_ref].some((key) => key && keys.has(String(key))));
3984
+ if (!task) continue;
3985
+ const metadata = task.metadata && typeof task.metadata === 'object' ? task.metadata : {};
3986
+ if (metadata.approval_status && metadata.approval_status !== 'revise') return '';
3987
+ const events = (Array.isArray(task.events) ? task.events : []).filter((event) => event?.event_type === 'revision_requested');
3988
+ const note = metadata.human_revision_note || events[events.length - 1]?.payload?.note || '';
3989
+ return missionTasteSnippet(String(note), 600);
3990
+ }
3991
+ return '';
3992
+ }
3993
+
3913
3994
  function missionTasteMemorySignals(text) {
3914
3995
  const definitions = [
3915
3996
  ['plain_language', 'the operator asks for plain English', /\b(plain|jargon|feynman|understand|simple|clarify|clarity)\b/i],
@@ -4310,6 +4391,7 @@ function seedMissionRunContinuation(parent, root = process.cwd(), proof = '') {
4310
4391
  ...mission,
4311
4392
  started_from: 'mission_run_continuation',
4312
4393
  parent_mission_id: parent.id,
4394
+ ...(parent.campaign_id && campaignMissions().checkCampaignStart(root, parent.campaign_id, { reserveUsd: 0 }).ok ? { campaign_id: parent.campaign_id } : {}),
4313
4395
  parent_objective: parent.objective,
4314
4396
  continue_on_complete: false,
4315
4397
  continuation_policy: 'choose_next_mission',
@@ -4326,6 +4408,7 @@ function seedMissionRunContinuation(parent, root = process.cwd(), proof = '') {
4326
4408
  parent_objective: parent.objective,
4327
4409
  proof: proof || null,
4328
4410
  });
4411
+ if (saved.campaign_id) campaignMissions().recordCampaignMissionStarted(root, saved, 0);
4329
4412
  const worktreeBaseline = captureMissionWorktreeBaseline(saved, root);
4330
4413
  const seededAt = stampIso();
4331
4414
  const { mission: updatedParent } = saveMission({
@@ -4411,7 +4494,7 @@ function startMission(args, options = {}) {
4411
4494
  const asJson = wantsJson(args);
4412
4495
  const firstArg = String(args[0] || '').trim().toLowerCase();
4413
4496
  if (args.length === 1 && (firstArg === '--help' || firstArg === '-h' || firstArg === 'help')) {
4414
- console.log('Usage: atris mission start "<objective>" --owner <member> --verify "..." [--destination "..."] [--no-verify] [--always-on] [--budget quick|long|deep] [--runner manual|claude|atris2|codex_goal|auto]');
4497
+ console.log('Usage: atris mission start "<objective>" --owner <member> --verify "..." [--destination "..."] [--no-verify] [--always-on] [--budget quick|long|deep] [--runner manual|claude|atris2|codex_goal|auto] [--campaign <id> [--reserve <dollars>]]');
4415
4498
  console.log('Run `atris mission --help` for the full option list.');
4416
4499
  process.exit(0);
4417
4500
  }
@@ -4498,6 +4581,18 @@ function startMission(args, options = {}) {
4498
4581
  asJson,
4499
4582
  );
4500
4583
  }
4584
+ // --campaign: the campaign must be open and able to hold this mission's
4585
+ // reserve. Checked before any state exists, so a refusal leaves nothing behind.
4586
+ let campaignReserve = 0;
4587
+ if (mission.campaign_id) {
4588
+ const check = campaignMissions().checkCampaignStart(process.cwd(), mission.campaign_id, { missionId: mission.id, reserveUsd: readFlag(args, '--reserve', '') });
4589
+ if (!check.ok) exitMissionError(`mission start refused: ${check.error}`, 2, asJson);
4590
+ try {
4591
+ campaignMissions().reserveCampaignMission(process.cwd(), mission.campaign_id, mission.id, check.reserve, mission.owner || 'mission-lead');
4592
+ } catch (error) {
4593
+ exitMissionError(`mission start refused: ${error.message}`, 2, asJson);
4594
+ }
4595
+ }
4501
4596
  // --worktree: bind the mission to its own isolated checkout. We chdir before
4502
4597
  // any state writes so the mission record, baseline sidecar, receipts, and
4503
4598
  // member files all land inside the worktree, ticks run there, and the main
@@ -4589,6 +4684,7 @@ function startMission(args, options = {}) {
4589
4684
  const warnings = [missingVerifierWarning(mission), missingOwnerMemberWarning(mission.owner)].filter(Boolean);
4590
4685
  ensureMemberMissionFile(mission.owner, process.cwd(), mission.objective);
4591
4686
  const { mission: saved } = saveMission(assignMissionNumber(mission, process.cwd()), process.cwd(), 'mission_started', { objective: mission.objective });
4687
+ if (saved.campaign_id) campaignMissions().recordCampaignMissionStarted(process.cwd(), saved, campaignReserve);
4592
4688
  const goalSlotHandoff = hasFlag(args, '--take-goal-slot') && isCodexGoalMission(saved)
4593
4689
  ? takeCodexGoalSlotForMission(saved, process.cwd())
4594
4690
  : null;
@@ -4709,6 +4805,8 @@ async function startMissionFromRunObjective(objective, args) {
4709
4805
  ];
4710
4806
  if (verifier) startArgs.push('--verify', verifier);
4711
4807
  if (budgetTier) startArgs.push('--budget', budgetTier.name);
4808
+ const campaignFlag = readFlag(args, '--campaign', '');
4809
+ if (campaignFlag) startArgs.push('--campaign', campaignFlag);
4712
4810
  if (maxTicksOverride) startArgs.push('--max-ticks', String(maxTicksOverride));
4713
4811
  const model = readFlag(args, '--model', '');
4714
4812
  if (model) startArgs.push('--model', model);
@@ -4716,6 +4814,16 @@ async function startMissionFromRunObjective(objective, args) {
4716
4814
  if (hasFlag(args, '--xp-task') || hasFlag(args, '--agent-xp') || missionRunPreflight?.task_spine_required) startArgs.push('--xp-task');
4717
4815
 
4718
4816
  const mission = markMissionRunContinuation(missionFromArgs(startArgs));
4817
+ let campaignReserve = 0;
4818
+ if (mission.campaign_id) {
4819
+ const check = campaignMissions().checkCampaignStart(process.cwd(), mission.campaign_id, { missionId: mission.id, reserveUsd: readFlag(args, '--reserve', '') });
4820
+ if (!check.ok) exitMissionError(`mission start refused: ${check.error}`, 2, wantsJson(args));
4821
+ try {
4822
+ campaignMissions().reserveCampaignMission(process.cwd(), mission.campaign_id, mission.id, check.reserve, mission.owner || 'mission-lead');
4823
+ } catch (error) {
4824
+ exitMissionError(`mission start refused: ${error.message}`, 2, wantsJson(args));
4825
+ }
4826
+ }
4719
4827
  if (landOwnerResolution) {
4720
4828
  mission.owner = runOwner;
4721
4829
  mission.owner_resolution = landOwnerResolution.reason || mission.owner_resolution;
@@ -4799,6 +4907,7 @@ async function startMissionFromRunObjective(objective, args) {
4799
4907
  const warnings = [missingVerifierWarning(mission), missingOwnerMemberWarning(mission.owner)].filter(Boolean);
4800
4908
  ensureMemberMissionFile(mission.owner, process.cwd(), mission.objective);
4801
4909
  const { mission: saved } = saveMission(assignMissionNumber(mission, process.cwd()), process.cwd(), 'mission_started', { objective: mission.objective, source: 'mission_run_objective' });
4910
+ if (saved.campaign_id) campaignMissions().recordCampaignMissionStarted(process.cwd(), saved, campaignReserve);
4802
4911
  if (landRun) {
4803
4912
  const memberState = renderMemberMissionState(saved.owner);
4804
4913
  const logPath = appendMemberLog(saved.owner, 'Mission landing flight dispatched', {
@@ -6556,7 +6665,7 @@ function runVerifier(command, root = process.cwd()) {
6556
6665
  const resolvedCommand = resolveVerifierCommand(command);
6557
6666
  const { envWithNodeDir } = require('../lib/spawn-env');
6558
6667
  const timeoutMs = missionVerifierTimeoutMs();
6559
- const startedAt = Date.now();
6668
+ const startedAt = process.hrtime.bigint();
6560
6669
  const result = spawnSync(resolvedCommand, {
6561
6670
  cwd: root,
6562
6671
  shell: true,
@@ -6564,11 +6673,15 @@ function runVerifier(command, root = process.cwd()) {
6564
6673
  timeout: timeoutMs,
6565
6674
  env: envWithNodeDir(process.env),
6566
6675
  });
6567
- const elapsedMs = Date.now() - startedAt;
6568
6676
  // A verifier that blows its window is not a failing check: the code may be
6569
6677
  // fine and the suite merely slow (or starved by a concurrent flight). Report
6570
6678
  // it as a distinct timed-out state so operators do not read slow as broken.
6571
6679
  const timedOut = result.error ? result.error.code === 'ETIMEDOUT' : false;
6680
+ // Steady clock, not wall time: the wall clock can slew, and a kill at the
6681
+ // window once read as 927ms of a 1000ms window. A timed-out run used the
6682
+ // whole window by definition.
6683
+ const measuredMs = Number((process.hrtime.bigint() - startedAt) / 1000000n);
6684
+ const elapsedMs = timedOut ? Math.max(measuredMs, timeoutMs) : measuredMs;
6572
6685
  return {
6573
6686
  command,
6574
6687
  resolved_command: resolvedCommand === command ? null : resolvedCommand,
@@ -6588,17 +6701,29 @@ const CHECK_FEEDBACK_TRIM_MARKER = 'output trimmed';
6588
6701
 
6589
6702
  function extractCheckFeedback(verifierResult) {
6590
6703
  if (!verifierResult || verifierResult.passed !== false) return '';
6591
- const lines = [verifierResult.output, verifierResult.stdout, verifierResult.stderr]
6704
+ // A reviewer turn-down goes first: when the tests passed, it is the only
6705
+ // real reason the try failed, and passing test names must not crowd it out.
6706
+ const referee = verifierResult.referee && verifierResult.referee.passed === false
6707
+ ? verifierResult.referee
6708
+ : null;
6709
+ const refereeLines = referee
6710
+ ? [referee.reason, referee.output].filter((value) => String(value || '').trim())
6711
+ : [];
6712
+ const lines = [...new Set([...refereeLines, verifierResult.output, verifierResult.stdout, verifierResult.stderr]
6592
6713
  .filter((value) => String(value || '').trim())
6593
6714
  .flatMap((value) => String(value).split(/\r?\n/))
6594
6715
  .map((line) => line.trim())
6595
- .filter(Boolean);
6716
+ .filter(Boolean))];
6596
6717
  if (!lines.length) return '';
6597
6718
 
6719
+ const refereeSet = new Set(refereeLines.flatMap((value) => String(value).split(/\r?\n/)).map((line) => line.trim()).filter(Boolean));
6720
+ const passingLine = /^(?:✔|ℹ )/;
6598
6721
  const usefulPattern = /\b(?:error|fail|expected|assert)\w*\b|(?:^|\s|\()[^:\s()]+:\d+(?::\d+)?(?:\b|$)/i;
6599
- const useful = lines.filter((line) => usefulPattern.test(line));
6600
- const context = lines.filter((line) => !usefulPattern.test(line));
6601
- const feedback = [...useful, ...context].join('\n');
6722
+ const rest = lines.filter((line) => !refereeSet.has(line));
6723
+ const useful = rest.filter((line) => !passingLine.test(line) && usefulPattern.test(line));
6724
+ const context = rest.filter((line) => !passingLine.test(line) && !usefulPattern.test(line));
6725
+ const passing = rest.filter((line) => passingLine.test(line));
6726
+ const feedback = [...lines.filter((line) => refereeSet.has(line)), ...useful, ...context, ...passing].join('\n');
6602
6727
  if (feedback.length <= CHECK_FEEDBACK_LIMIT) return feedback;
6603
6728
 
6604
6729
  const contentLimit = CHECK_FEEDBACK_LIMIT - CHECK_FEEDBACK_TRIM_MARKER.length - 1;
@@ -6837,7 +6962,10 @@ function worktreeReceipt(before, after, { verifier = '', baseline = null } = {})
6837
6962
  const MISSION_RUN_DEFAULTS = {
6838
6963
  maxTicks: 4,
6839
6964
  maxWallSeconds: 3600,
6840
- claudeTimeoutMs: 10 * 60 * 1000,
6965
+ // Sized from real runs: claude ticks that landed 10/08-10/14 ran a median
6966
+ // 4 min, but 15 of 92 needed more than 10 and the slowest 5% took 17+.
6967
+ // A 10 min cap killed 12 runs at exactly 10:00; 20 min fits 89 of 92.
6968
+ claudeTimeoutMs: 20 * 60 * 1000,
6841
6969
  backoff: { initialMs: 30_000, maxMs: 10 * 60_000, factor: 2, jitter: 0.3 },
6842
6970
  };
6843
6971
 
@@ -8824,6 +8952,45 @@ function consumeMissionPings(mission, cwd) {
8824
8952
  return { mission: saved, pings: pending };
8825
8953
  }
8826
8954
 
8955
+ // A one-tick job has no next prompt. Deliver notes that arrived while the
8956
+ // worker ran before checking its work, within the same wall budget. Only a
8957
+ // successful worker response acknowledges a batch; failed delivery can retry.
8958
+ async function deliverLateMissionPings(mission, cwd, firstResult, { run, remainingMs, prompt }) {
8959
+ let result = firstResult;
8960
+ let delivered = 0;
8961
+ while (result.ok) {
8962
+ const latest = resolveMission(mission.id, cwd) || mission;
8963
+ const pending = pendingMissionPings(latest);
8964
+ if (!pending.length || TERMINAL_STATUSES.has(latest.status)) break;
8965
+ if (remainingMs() <= 0) {
8966
+ return { ...result, ok: false, timedOut: true, error: 'unread notes at the wall limit' };
8967
+ }
8968
+ const next = await run([
8969
+ prompt,
8970
+ '',
8971
+ '## Notes received before this job finished',
8972
+ 'This is the same work step. Read and apply these notes before returning the final receipt. Do not start a second slice.',
8973
+ ...pending.map((p) => `- [${p.at}] ${p.from || 'operator'}: ${p.text}`),
8974
+ '',
8975
+ 'Previous work receipt:',
8976
+ result.receipt_text || result.result || '',
8977
+ ].join('\n'));
8978
+ if (!next.ok) return next;
8979
+ // Re-read so notes sent during delivery survive the acknowledgement.
8980
+ const fresh = resolveMission(mission.id, cwd) || latest;
8981
+ const keys = new Set(pending.map((p) => JSON.stringify([p.at, p.from, p.text])));
8982
+ const consumedAt = stampIso();
8983
+ saveMission({
8984
+ ...fresh,
8985
+ pings: (fresh.pings || []).map((p) => p && !p.consumed_at && keys.has(JSON.stringify([p.at, p.from, p.text]))
8986
+ ? { ...p, consumed_at: consumedAt } : p),
8987
+ }, cwd, 'mission_pings_consumed', { count: pending.length });
8988
+ delivered += pending.length;
8989
+ result = { ...next, operator_notes_read: delivered };
8990
+ }
8991
+ return result;
8992
+ }
8993
+
8827
8994
  function readBoundedContextFile(filePath, maxChars = 1600) {
8828
8995
  try {
8829
8996
  if (!fs.existsSync(filePath)) return '';
@@ -8938,6 +9105,16 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
8938
9105
  checkFeedback,
8939
9106
  ]
8940
9107
  : [];
9108
+ let turnDown = '';
9109
+ try { turnDown = readBoundTaskTurnDown(mission, mission.worktree_root || mission.worktree?.path || cwd); } catch {}
9110
+ const turnDownLines = turnDown
9111
+ ? [
9112
+ ``,
9113
+ `## why the last try was turned down`,
9114
+ `the reviewer sent this task back. fix this before anything else.`,
9115
+ turnDown,
9116
+ ]
9117
+ : [];
8941
9118
  const preloadedContextLines = buildPreloadedMemberContextLines(mission, cwd);
8942
9119
  const lines = [
8943
9120
  `# Mission Tick ${tickIndex}/${maxTicks}`,
@@ -8953,6 +9130,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
8953
9130
  `**Last tick:** ${mission.last_tick_at || 'never'}`,
8954
9131
  ...missionBudgetPromptLines(mission),
8955
9132
  ...checkFeedbackLines,
9133
+ ...turnDownLines,
8956
9134
  ...(sharedProcess ? ['', sharedProcess] : []),
8957
9135
  ...preloadedContextLines,
8958
9136
  ``,
@@ -9183,6 +9361,22 @@ function tooDeepRunnerResult(depth) {
9183
9361
  };
9184
9362
  }
9185
9363
 
9364
+ // Machine memory and load at both ends of a tick, like a dispatch, so a
9365
+ // killed tick still says whether the machine was short on memory. The
9366
+ // returned end writes once, whichever of close or error comes first.
9367
+ function startTickResource(cwd, engine, mission) {
9368
+ const { recordEngineResource } = require('../lib/engine-resource');
9369
+ const resource = { engine: engine || 'runner', task: mission.task_id || mission.id || '', run_id: crypto.randomUUID() };
9370
+ recordEngineResource(cwd, { phase: 'start', ...resource });
9371
+ const startedAt = process.hrtime.bigint();
9372
+ let ended = false;
9373
+ return ({ signal = null, timedOut = false, error = '' } = {}) => {
9374
+ if (ended) return;
9375
+ ended = true;
9376
+ recordEngineResource(cwd, { phase: 'end', ...resource, signal, timed_out: timedOut, error, duration_ms: Math.round(Number(process.hrtime.bigint() - startedAt) / 1e6) });
9377
+ };
9378
+ }
9379
+
9186
9380
  function spawnGenericRunnerTick(mission, opts) {
9187
9381
  const { sessionId, cwd, signal, timeoutMs, prompt, model, missionLock } = opts;
9188
9382
  const dispatchDepth = missionDispatchDepth();
@@ -9241,7 +9435,17 @@ function spawnGenericRunnerTick(mission, opts) {
9241
9435
  }
9242
9436
 
9243
9437
  const startedAt = Date.now();
9244
- const guardedCommand = `PATH=${shellQuote(gitGuard.pathPrefix)}:$PATH; export PATH; ${cmd}`;
9438
+ const endResource = startTickResource(cwd, canonicalEngineName(mission.runner), mission);
9439
+ const progressMember = mission.owner || 'mission';
9440
+ // A codex run opens its shell script with the run marker as a no-op, so
9441
+ // member stop can prove the shell leading this group is this run. Only
9442
+ // names member stop accepts go into the shell line.
9443
+ const runToken = lastMessageFile && /^[A-Za-z0-9._-]+$/.test(progressMember) ? newRunToken() : '';
9444
+ const markerPrefix = runToken ? `: ${runMarker(progressMember, runToken)}; ` : '';
9445
+ // The trailing exit keeps a shell like dash from replacing itself with
9446
+ // codex, which would take the marker out of the process list.
9447
+ const markerSuffix = runToken ? '; exit $?' : '';
9448
+ const guardedCommand = `${markerPrefix}PATH=${shellQuote(gitGuard.pathPrefix)}:$PATH; export PATH; ${cmd}${markerSuffix}`;
9245
9449
  const proc = spawn('sh', ['-lc', guardedCommand], {
9246
9450
  cwd,
9247
9451
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -9249,9 +9453,11 @@ function spawnGenericRunnerTick(mission, opts) {
9249
9453
  env: { ...gitGuard.env, ATRIS_DISPATCH_DEPTH: String(dispatchDepth.depth + 1) },
9250
9454
  });
9251
9455
  updateMissionLockOwner(missionLock, proc.pid, { phase: 'tick' });
9252
- const progressMember = mission.owner || 'mission';
9253
9456
  const seenItemIds = new Set();
9254
- if (lastMessageFile) appendProgress(progressMember, `started: ${mission.objective || ''}`, mission.id);
9457
+ if (lastMessageFile) {
9458
+ const tag = runToken ? { pid: proc.pid, engine: 'codex', run: runToken } : {};
9459
+ appendProgress(progressMember, startedLine(mission.objective, tag), mission.id);
9460
+ }
9255
9461
  let stdout = '';
9256
9462
  let eventBuf = '';
9257
9463
  let lastAgentMessage = '';
@@ -9303,8 +9509,10 @@ function spawnGenericRunnerTick(mission, opts) {
9303
9509
  });
9304
9510
  proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
9305
9511
 
9306
- proc.on('close', (code) => {
9512
+ proc.on('close', (code, closeSignal) => {
9307
9513
  clearTimeout(timer);
9514
+ // The shell returns 128 + 9 when its engine child was force-killed.
9515
+ endResource({ signal: closeSignal || (code === 137 ? 'SIGKILL' : null), timedOut, error: code === 0 ? '' : stderr.slice(-2000) });
9308
9516
  if (lastMessageFile && eventBuf.trim()) readCodexEvent(eventBuf.trim());
9309
9517
  let finalText = String(stdout || '').trim();
9310
9518
  if (lastMessageFile) {
@@ -9342,6 +9550,7 @@ function spawnGenericRunnerTick(mission, opts) {
9342
9550
 
9343
9551
  proc.on('error', (e) => {
9344
9552
  clearTimeout(timer);
9553
+ endResource({ timedOut, error: e.message });
9345
9554
  cleanupPrompt();
9346
9555
  gitGuard.cleanup();
9347
9556
  if (signal) signal.removeEventListener?.('abort', onAbort);
@@ -9426,6 +9635,7 @@ function spawnClaudeTick(mission, opts) {
9426
9635
  // detached: the runner spawns its own children; killing only the direct
9427
9636
  // child on timeout leaves them holding the session lock, and the next
9428
9637
  // tick's resume fails with "Session ID ... is already in use".
9638
+ const endResource = startTickResource(cwd, 'claude', mission);
9429
9639
  const proc = spawn(resolveClaudeRunnerBin(), args, {
9430
9640
  cwd,
9431
9641
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -9504,8 +9714,9 @@ function spawnClaudeTick(mission, opts) {
9504
9714
 
9505
9715
  proc.stderr.on('data', (c) => { stderr += c.toString(); });
9506
9716
 
9507
- proc.on('close', (code) => {
9717
+ proc.on('close', (code, closeSignal) => {
9508
9718
  clearTimeout(timer);
9719
+ endResource({ signal: closeSignal, timedOut, error: code === 0 ? '' : stderr.slice(-2000) });
9509
9720
  gitGuard.cleanup();
9510
9721
  if (signal) signal.removeEventListener?.('abort', onAbort);
9511
9722
  updateMissionLockOwner(missionLock, missionLock?.driverPid);
@@ -9538,6 +9749,7 @@ function spawnClaudeTick(mission, opts) {
9538
9749
 
9539
9750
  proc.on('error', (e) => {
9540
9751
  clearTimeout(timer);
9752
+ endResource({ timedOut, error: e.message });
9541
9753
  gitGuard.cleanup();
9542
9754
  updateMissionLockOwner(missionLock, missionLock?.driverPid);
9543
9755
  resolve({ ok: false, error: e.message, sessionIds: [], aborted, timedOut, authExpired: false });
@@ -9548,14 +9760,42 @@ function spawnClaudeTick(mission, opts) {
9548
9760
  const ENGINE_VERIFY_TIMEOUT_MS = 5 * 60 * 1000;
9549
9761
  const ENGINE_VERIFY_OUTPUT_LIMIT = 4000;
9550
9762
 
9551
- function buildEngineVerifyPrompt(mission, tickIndex) {
9763
+ // The job's own done-looks-like line, so the reviewer judges the work against
9764
+ // what the job promised instead of the mission headline alone. Only a line the
9765
+ // job wrote down counts: a derived fallback ("it clears review") hands the
9766
+ // reviewer a target that says nothing. Any lookup failure means no line.
9767
+ function missionDoneLooksLike(mission, root = process.cwd()) {
9768
+ try {
9769
+ const task = findOpenTaskMatchingMissionObjective(mission, root, true);
9770
+ if (!task) return '';
9771
+ const explanation = require('../lib/task-explanation').taskExplanation(task);
9772
+ return explanation.sources.done_looks_like === 'explicit' ? explanation.done_looks_like : '';
9773
+ } catch {
9774
+ return '';
9775
+ }
9776
+ }
9777
+
9778
+ function buildEngineVerifyPrompt(mission, tickIndex, doneLooksLike = '', { timeoutMs = ENGINE_VERIFY_TIMEOUT_MS, checkPassed = '' } = {}) {
9779
+ const doneLine = String(doneLooksLike || '').replace(/\s+/g, ' ').trim();
9780
+ const minutes = Math.max(1, Math.round(timeoutMs / 60000));
9552
9781
  return [
9553
9782
  `# Verify Mission Tick ${tickIndex}`,
9554
9783
  ``,
9555
9784
  `This is a bounded verify-only pass for mission: ${mission.objective}`,
9785
+ ...(doneLine ? [
9786
+ `The job says done looks like: ${doneLine}`,
9787
+ `Compare the work against that line. If the work does not show it, the verdict is FAIL even when the checks pass.`,
9788
+ `Split that line into each separate thing it promises and check every one. List each part as met or missing.`,
9789
+ `Work that does only some of the parts is not done. If any part is missing, end with 'VERDICT: FAIL; missing: <that part>'.`,
9790
+ ] : []),
9556
9791
  `Read the diff this tick produced. Do not edit files and do not start another work slice.`,
9557
9792
  `ACTUALLY RUN the changed surface on this computer using real commands and real exit codes.`,
9558
9793
  `Paste the commands and their real output. Do not accept a receipt claim or static inspection as execution proof.`,
9794
+ // Reviews that outran the clock were 11 of 25 turn-downs (10/11 to 10/14).
9795
+ `You have ${minutes} minutes. Leave time to write the verdict; running out of time counts as a fail.`,
9796
+ ...(checkPassed ? [`The mission check \`${checkPassed}\` already passed on this exact code. Do not run it again.`] : []),
9797
+ `Prove the change with the narrowest real run: the changed command, or one test file or one named test.`,
9798
+ `Do not run whole test suites or large test files; on a busy machine they outlast your time.`,
9559
9799
  `End your reply with a final line exactly 'VERDICT: PASS' or 'VERDICT: FAIL'.`,
9560
9800
  ].join('\n');
9561
9801
  }
@@ -9617,7 +9857,10 @@ async function runEngineVerifier(mission, options = {}) {
9617
9857
  cwd,
9618
9858
  signal: options.signal,
9619
9859
  timeoutMs: ENGINE_VERIFY_TIMEOUT_MS,
9620
- prompt: buildEngineVerifyPrompt(mission, options.tickIndex || 1),
9860
+ prompt: buildEngineVerifyPrompt(mission, options.tickIndex || 1, missionDoneLooksLike(mission, cwd), {
9861
+ timeoutMs: ENGINE_VERIFY_TIMEOUT_MS,
9862
+ checkPassed: options.checkPassed || '',
9863
+ }),
9621
9864
  model: resolveMissionTickRunnerModel(verifyMission),
9622
9865
  });
9623
9866
  } catch (error) {
@@ -9671,6 +9914,20 @@ function cappedClaudeReceiptText(text, limit = 4000) {
9671
9914
  return clean.slice(0, limit - 16).trimEnd() + '\n...[truncated]';
9672
9915
  }
9673
9916
 
9917
+ // The receipt keeps a capped copy of the runner's report. When the report is
9918
+ // longer than that, the whole text goes to its own file beside the receipts
9919
+ // so nothing the member wrote is lost.
9920
+ function saveFullRunnerReport(mission, text, root = process.cwd()) {
9921
+ const clean = String(text || '').trim();
9922
+ if (!clean || cappedClaudeReceiptText(clean) === clean) return null;
9923
+ const runsDir = statePaths(root).runsDir;
9924
+ fs.mkdirSync(runsDir, { recursive: true });
9925
+ const safeTime = stampIso().replace(/[:.]/g, '-');
9926
+ const reportPath = path.join(runsDir, `mission-${mission.id}-${safeTime}-report.md`);
9927
+ fs.writeFileSync(reportPath, clean + '\n', 'utf8');
9928
+ return path.relative(root, reportPath);
9929
+ }
9930
+
9674
9931
  async function parseAndValidateMissionRunPhase(args) {
9675
9932
  const asJson = wantsJson(args);
9676
9933
  const context = {
@@ -10062,6 +10319,9 @@ async function executeMissionRunTicksPhase(context) {
10062
10319
  let cycleTickLimit = effectiveMaxTicks;
10063
10320
  while (true) {
10064
10321
  while (ticks.length < cycleTickLimit) {
10322
+ // A campaign that is done, paused, or out of money stops its missions.
10323
+ const campaignStop = mission.campaign_id ? campaignMissions().campaignGate(cwd, mission) : null;
10324
+ if (campaignStop) { pauseReason = campaignStop; break; }
10065
10325
  // Re-read before measuring the wall. Detached full-budget drivers use
10066
10326
  // the mission contract as their wall so process startup drift cannot
10067
10327
  // shorten the promised work window.
@@ -10201,8 +10461,8 @@ async function executeMissionRunTicksPhase(context) {
10201
10461
  }, Math.max(1, Math.floor(remainingWall * 1000)));
10202
10462
  let turn;
10203
10463
  try {
10204
- turn = await runAtris2Turn({
10205
- prompt,
10464
+ const runTurn = (direction) => runAtris2Turn({
10465
+ prompt: direction,
10206
10466
  model: tickRuntimeMission.model || 'atris:fast',
10207
10467
  business: businessId,
10208
10468
  maxTurns: 16,
@@ -10213,6 +10473,16 @@ async function executeMissionRunTicksPhase(context) {
10213
10473
  localEnv: atris2GitGuard.env,
10214
10474
  localPathPrefix: atris2GitGuard.pathPrefix,
10215
10475
  });
10476
+ turn = await runTurn(prompt);
10477
+ turn = await deliverLateMissionPings(mission, cwd, { ...turn, receipt_text: turn.text }, {
10478
+ prompt,
10479
+ remainingMs: () => (maxWallSeconds * 1000) - (Date.now() - startedAt),
10480
+ run: async (direction) => {
10481
+ const next = await runTurn(direction);
10482
+ return { ...next, receipt_text: next.text };
10483
+ },
10484
+ });
10485
+ mission = resolveMission(mission.id, cwd) || mission;
10216
10486
  } finally {
10217
10487
  atris2GitGuard.cleanup();
10218
10488
  clearTimeout(tickTimer);
@@ -10228,6 +10498,7 @@ async function executeMissionRunTicksPhase(context) {
10228
10498
  error: turn.error,
10229
10499
  backend_unavailable: isTransientAtris2BackendError(turn.error) || undefined,
10230
10500
  receipt_text: String(turn.text || '').slice(0, 4000),
10501
+ operator_notes_read: turn.operator_notes_read,
10231
10502
  };
10232
10503
  tickWorkerRun = { endedMs: Date.now(), usage: {} };
10233
10504
  if (controller.signal.aborted) { pauseReason = 'aborted-during-atris2'; break; }
@@ -10261,14 +10532,14 @@ async function executeMissionRunTicksPhase(context) {
10261
10532
  const restoreTickRunnerProfile = tickEngineId ? applyMissionRunnerProfile(tickEngineId) : () => {};
10262
10533
  let claudeResult;
10263
10534
  let sessionBusyRetried = false;
10264
- const runClaudeSession = () => spawnClaudeTick(tickRuntimeMission, {
10535
+ const runClaudeSession = (direction = workerPrompt) => spawnClaudeTick(tickRuntimeMission, {
10265
10536
  sessionMode, sessionId: useId, cwd, signal: controller.signal,
10266
10537
  missionLock: lock,
10267
10538
  timeoutMs: missionTickTimeoutMs(
10268
10539
  tickRuntimeMission,
10269
10540
  (maxWallSeconds - ((Date.now() - startedAt) / 1000)) * 1000,
10270
10541
  ),
10271
- prompt: workerPrompt,
10542
+ prompt: direction,
10272
10543
  model: resolveMissionTickRunnerModel(tickRuntimeMission),
10273
10544
  });
10274
10545
  try {
@@ -10285,17 +10556,30 @@ async function executeMissionRunTicksPhase(context) {
10285
10556
  useId = pendingSessionId;
10286
10557
  claudeResult = await runClaudeSession();
10287
10558
  }
10559
+ claudeResult = await deliverLateMissionPings(mission, cwd, claudeResult, {
10560
+ prompt: workerPrompt,
10561
+ remainingMs: () => (maxWallSeconds * 1000) - (Date.now() - startedAt),
10562
+ run: (direction) => {
10563
+ // Claude needs to resume the session it just confirmed. Generic
10564
+ // runners receive the earlier receipt in the delivery prompt.
10565
+ if (claudeResult.sessionIds?.includes(useId)) sessionMode = 'resume';
10566
+ return runClaudeSession(direction);
10567
+ },
10568
+ });
10569
+ mission = resolveMission(mission.id, cwd) || mission;
10288
10570
  } finally {
10289
10571
  restoreTickRunnerProfile();
10290
10572
  }
10291
10573
  tickWorkerRun = { endedMs: Date.now(), usage: (claudeResult && claudeResult.run_usage) || {}, prep: tickPrep, startedAt: workerStartedAt };
10292
10574
  result.claude = {
10293
10575
  ok: claudeResult.ok,
10576
+ error: claudeResult.error,
10294
10577
  reason: claudeResult.reason,
10295
10578
  protected_lane_guard: claudeResult.protected_lane_guard || null,
10296
10579
  brief_id: claudeResult.brief_id || null,
10297
10580
  summary: claudeResult.summary,
10298
10581
  receipt_text: claudeResult.receipt_text,
10582
+ report_path: saveFullRunnerReport(mission, claudeResult.result, cwd),
10299
10583
  stop_reason: claudeResult.stop_reason,
10300
10584
  api_equivalent_estimate: claudeResult.api_equivalent_estimate,
10301
10585
  duration_total_ms: claudeResult.duration_total_ms,
@@ -10306,6 +10590,7 @@ async function executeMissionRunTicksPhase(context) {
10306
10590
  timed_out: claudeResult.timedOut,
10307
10591
  aborted: claudeResult.aborted,
10308
10592
  session_busy_retry: sessionBusyRetried || undefined,
10593
+ operator_notes_read: claudeResult.operator_notes_read,
10309
10594
  };
10310
10595
  if (claudeResult.rate_limit_info) {
10311
10596
  // Claude reports the five-hour window's resetsAt on every turn, even
@@ -10320,7 +10605,7 @@ async function executeMissionRunTicksPhase(context) {
10320
10605
 
10321
10606
  if (!claudeResult.ok) {
10322
10607
  const deadModel = detectUnavailableModel(claudeResult.summary || claudeResult.receipt_text);
10323
- const wallBoundTimeout = remainingWall * 1000 <= MISSION_RUN_DEFAULTS.claudeTimeoutMs;
10608
+ const wallBoundTimeout = missionTickWallBound(tickRuntimeMission, remainingWall * 1000);
10324
10609
  let reason = claudeResult.reason === 'too-deep'
10325
10610
  ? 'too-deep'
10326
10611
  : claudeResult.timedOut
@@ -10410,6 +10695,7 @@ async function executeMissionRunTicksPhase(context) {
10410
10695
  prep: tickWorkerRun.prep || null,
10411
10696
  });
10412
10697
  }
10698
+ if (mission.campaign_id && !cachedStep) campaignMissions().recordCampaignTick(cwd, mission, { ...((tickWorkerRun && tickWorkerRun.usage) || {}), ...(tickEngineId ? { engine: tickEngineId } : {}) });
10413
10699
  let receiptPath = cachedStep ? cachedStep.receipt_path : null;
10414
10700
 
10415
10701
  // Review-lane drain: always-on loops sweep the agent-safe review actions
@@ -10557,7 +10843,7 @@ async function executeMissionRunTicksPhase(context) {
10557
10843
  mission: mission.objective,
10558
10844
  state: mission.status,
10559
10845
  tick_status: result.status,
10560
- reason: result.reason,
10846
+ reason: memberLogTickReason(result),
10561
10847
  layer: result.layer || undefined,
10562
10848
  verifier: verifierResult ? (verifierResult.passed ? 'passed' : 'failed') : 'not_run',
10563
10849
  summary: tickSummaryText ? tickSummaryText.slice(0, 240) : undefined,
@@ -10771,6 +11057,7 @@ async function verifyMissionRunTickPhase(context) {
10771
11057
  signal: controller.signal,
10772
11058
  tickIndex: currentTick.tickIdx,
10773
11059
  refereeEngine: context.refereeEngine,
11060
+ checkPassed: verifierResult?.passed ? currentTick.frozen.verifier : '',
10774
11061
  });
10775
11062
  const afterReview = gitCodeState(cwd);
10776
11063
  const codeMoved = beforeReview.tree !== afterReview.tree
@@ -12415,6 +12702,7 @@ function missionCommand(args) {
12415
12702
  }
12416
12703
 
12417
12704
  module.exports = {
12705
+ listSiblingWorkspaceMissionHints,
12418
12706
  missionProtectedLaneHold,
12419
12707
  missionCommand,
12420
12708
  startMission,
@@ -12467,6 +12755,7 @@ module.exports = {
12467
12755
  missionVerifierCheckedText,
12468
12756
  missionVerifierHighLevelTestText,
12469
12757
  buildEngineVerifyPrompt,
12758
+ missionDoneLooksLike,
12470
12759
  engineVerifierResultFromRun,
12471
12760
  pickCrossEngineVerifier,
12472
12761
  runVerifier,
@@ -12477,6 +12766,8 @@ module.exports = {
12477
12766
  resolveMissionTickRunner,
12478
12767
  resolveMissionTickRunnerModel,
12479
12768
  missionTickTimeoutMs,
12769
+ missionTickWallBound,
12770
+ memberLogTickReason,
12480
12771
  engineFailureHealthStatus,
12481
12772
  recordMissionEngineTickOutcome,
12482
12773
  recordMissionTickRosterRun,