atris 3.15.57 → 3.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/AGENTS.md +2 -2
  2. package/GETTING_STARTED.md +1 -1
  3. package/PERSONA.md +4 -4
  4. package/README.md +12 -11
  5. package/atris/skills/copy-editor/SKILL.md +30 -4
  6. package/atris/skills/improve/SKILL.md +18 -20
  7. package/atris/wiki/concepts/agent-activation-contract.md +5 -3
  8. package/atris/wiki/concepts/workspace-initialization-contract.md +4 -4
  9. package/atris/wiki/index.md +1 -0
  10. package/ax +522 -73
  11. package/bin/atris.js +78 -44
  12. package/commands/align.js +0 -14
  13. package/commands/apps.js +102 -1
  14. package/commands/autopilot.js +628 -31
  15. package/commands/brain.js +219 -34
  16. package/commands/brainstorm.js +0 -829
  17. package/commands/compile.js +569 -0
  18. package/commands/computer.js +0 -60
  19. package/commands/improve.js +501 -0
  20. package/commands/integrations.js +233 -71
  21. package/commands/lesson.js +44 -0
  22. package/commands/member.js +4498 -226
  23. package/commands/mission.js +302 -27
  24. package/commands/now.js +89 -1
  25. package/commands/probe.js +366 -0
  26. package/commands/radar.js +181 -56
  27. package/commands/recap.js +203 -0
  28. package/commands/skill.js +6 -2
  29. package/commands/soul.js +0 -4
  30. package/commands/task.js +5587 -499
  31. package/commands/terminal.js +14 -10
  32. package/commands/wiki.js +87 -1
  33. package/commands/workflow.js +288 -73
  34. package/commands/worktree.js +52 -15
  35. package/commands/xp.js +6 -65
  36. package/lib/auto-accept-certified.js +294 -0
  37. package/lib/file-ops.js +0 -184
  38. package/lib/member-alive.js +232 -0
  39. package/lib/policy-lessons.js +280 -0
  40. package/lib/receipt-evidence.js +64 -0
  41. package/lib/state-detection.js +75 -1
  42. package/lib/task-db.js +568 -16
  43. package/lib/task-proof.js +43 -0
  44. package/package.json +1 -1
  45. package/utils/auth.js +13 -4
  46. package/commands/research.js +0 -52
  47. package/lib/section-merge.js +0 -196
@@ -191,7 +191,7 @@ function createMissionXpTask(mission, root = process.cwd(), asJson = false) {
191
191
  taskDb.noteTask(db, {
192
192
  id: task.id,
193
193
  actor: process.env.ATRIS_AGENT_ID || mission.owner || 'mission-lead',
194
- content: `Mission goal loop XP bridge for ${mission.id}. Proof goes through task ready; AgentXP lands only after human accept.`,
194
+ content: `Mission goal loop XP bridge for ${mission.id}. Proof goes through task current-step; AgentXP lands only after human accept.`,
195
195
  });
196
196
  }
197
197
  const { outPath } = writeMissionTaskProjection(taskDb, db, workspaceRoot);
@@ -262,6 +262,36 @@ function listMissions(root = process.cwd()) {
262
262
  .sort((a, b) => String(b.updated_at || b.created_at || '').localeCompare(String(a.updated_at || a.created_at || '')));
263
263
  }
264
264
 
265
+ // Cross-worktree rollup: missions started with --worktree keep their state inside
266
+ // that worktree, so a plain `mission status` from any single checkout is blind to
267
+ // them. Enumerate sibling git worktrees and surface their missions read-only.
268
+ function listWorktreeRollupMissions(root = process.cwd()) {
269
+ let entries = [];
270
+ try {
271
+ entries = require('./worktree').listWorktrees(root);
272
+ } catch {
273
+ return []; // not a git repo (or git unavailable): nothing to roll up
274
+ }
275
+ let here = root;
276
+ try {
277
+ here = fs.realpathSync(root);
278
+ } catch { /* keep raw path */ }
279
+ const rolled = [];
280
+ for (const entry of entries) {
281
+ let wtRoot;
282
+ try {
283
+ wtRoot = fs.realpathSync(entry.path);
284
+ } catch {
285
+ continue; // stale worktree record
286
+ }
287
+ if (wtRoot === here) continue;
288
+ for (const mission of listMissions(wtRoot)) {
289
+ rolled.push({ ...mission, worktree_root: entry.path, worktree_branch: entry.branch });
290
+ }
291
+ }
292
+ return rolled;
293
+ }
294
+
265
295
  function resolveMission(ref, root = process.cwd()) {
266
296
  const missions = listMissions(root);
267
297
  if (!ref) return missions.find((mission) => !TERMINAL_STATUSES.has(mission.status)) || missions[0] || null;
@@ -422,6 +452,13 @@ function renderMemberMissionState(owner, root = process.cwd()) {
422
452
  return { missionPath, nowPath };
423
453
  }
424
454
 
455
+ // One glanceable label for how a mission earned its completion: evidence
456
+ // source when the gate passed, an explicit marker when an operator forced it.
457
+ function completionGateLabel(gate) {
458
+ if (!gate) return null;
459
+ return gate.forced ? `forced override (${gate.source})` : gate.source;
460
+ }
461
+
425
462
  function renderMissionStatus(root = process.cwd()) {
426
463
  const paths = statePaths(root);
427
464
  const missions = listMissions(root);
@@ -443,6 +480,8 @@ function renderMissionStatus(root = process.cwd()) {
443
480
  lines.push(` - next: ${mission.next_action || 'tick or verify'}`);
444
481
  if (mission.xp_task?.ref) lines.push(` - AgentXP task: ${mission.xp_task.ref}`);
445
482
  if (mission.receipt_path) lines.push(` - proof: ${mission.receipt_path}`);
483
+ const gateLabel = completionGateLabel(mission.completion_gate);
484
+ if (gateLabel) lines.push(` - gate: ${gateLabel}`);
446
485
  }
447
486
  lines.push('');
448
487
  }
@@ -461,7 +500,8 @@ function missionXpTaskRefFromMission(mission) {
461
500
  function missionXpReadyAction(mission, receiptPath) {
462
501
  const ref = missionXpTaskRefFromMission(mission);
463
502
  if (!ref || !receiptPath) return null;
464
- return `queue AgentXP review: atris task ready ${ref} --proof "${receiptPath}"`;
503
+ const owner = mission.owner || process.env.ATRIS_AGENT_ID || 'mission-lead';
504
+ return `queue AgentXP review: atris task current-step --goal-id ${mission.id} --as ${owner} --proof "${receiptPath}" --json`;
465
505
  }
466
506
 
467
507
  function missionFromArgs(args) {
@@ -475,9 +515,9 @@ function missionFromArgs(args) {
475
515
  '--stop',
476
516
  '--task',
477
517
  '--ask',
478
- ], ['--json', '--always-on', '--xp-task', '--agent-xp']).join(' ').trim();
518
+ ], ['--json', '--always-on', '--xp-task', '--agent-xp', '--worktree']).join(' ').trim();
479
519
  if (!objective) {
480
- exitMissionError('Usage: atris mission start "<objective>" --owner <member> [--verify "..."] [--cadence manual]', 1, wantsJson(args));
520
+ exitMissionError('Usage: atris mission start "<objective>" --owner <member> [--verify "..."] [--cadence manual] [--worktree]', 1, wantsJson(args));
481
521
  }
482
522
  const owner = readFlag(args, '--owner', process.env.ATRIS_AGENT_ID || 'mission-lead');
483
523
  const cadence = readFlag(args, '--cadence', readFlag(args, '--loop', 'manual')) || 'manual';
@@ -527,12 +567,27 @@ function missingVerifierWarning(mission) {
527
567
  function startMission(args) {
528
568
  const asJson = wantsJson(args);
529
569
  const mission = missionFromArgs(args);
570
+ // --worktree: bind the mission to its own isolated checkout. We chdir before
571
+ // any state writes so the mission record, baseline sidecar, receipts, and
572
+ // member files all land inside the worktree — ticks run there, and the main
573
+ // checkout's dirt never reaches the mission baseline.
574
+ if (hasFlag(args, '--worktree')) {
575
+ let created;
576
+ try {
577
+ const { createAgentWorktree } = require('./worktree');
578
+ created = createAgentWorktree({ member: mission.owner, task: mission.objective });
579
+ } catch (e) {
580
+ exitMissionError(`[mission start] worktree creation failed: ${e.message}`, 2, asJson);
581
+ }
582
+ mission.worktree = { path: created.path, branch: created.branch, base: created.base };
583
+ process.chdir(created.path);
584
+ }
530
585
  if (mission.xp_task_enabled) {
531
586
  const xpTask = createMissionXpTask(mission, process.cwd(), asJson);
532
587
  mission.xp_task = xpTask;
533
588
  mission.task_ids = Array.from(new Set([...(mission.task_ids || []), xpTask.task_id]));
534
589
  if (!mission.verifier && !mission.always_on) {
535
- mission.next_action = `work task then run: atris task ready ${xpTask.ref} --proof "<proof>"`;
590
+ mission.next_action = `work task then run: atris task current-step --goal-id ${mission.id} --as ${mission.owner} --proof "<proof>" --json`;
536
591
  }
537
592
  }
538
593
  const warnings = [missingVerifierWarning(mission)].filter(Boolean);
@@ -546,15 +601,17 @@ function startMission(args) {
546
601
  lane: saved.lane,
547
602
  verifier: saved.verifier,
548
603
  });
604
+ const worktreeBaseline = captureMissionWorktreeBaseline(saved, process.cwd());
549
605
  printJsonOrText(
550
- { ok: true, action: 'mission_started', mission: saved, warnings, state_path: statePaths().missionsJsonl, member_state: memberState, log_path: logPath },
606
+ { ok: true, action: 'mission_started', mission: saved, warnings, state_path: statePaths().missionsJsonl, member_state: memberState, log_path: logPath, worktree_baseline: worktreeBaseline ? { path: path.relative(process.cwd(), missionBaselinePath(saved.id)), dirty_count: worktreeBaseline.dirty_count, dirty_hash: worktreeBaseline.dirty_hash } : null },
551
607
  [
552
608
  `Started mission: ${saved.objective}`,
553
609
  `Owner: ${saved.owner}`,
554
610
  `State: ${saved.status}`,
611
+ ...(saved.worktree ? [`Worktree: ${saved.worktree.path}`, `Branch: ${saved.worktree.branch}`] : []),
555
612
  ...warnings.map((warning) => `Warning: ${warning.message}`),
556
613
  ...(saved.xp_task ? [`AgentXP task: ${saved.xp_task.ref}`] : []),
557
- `Next: atris mission tick ${saved.id}`,
614
+ ...(saved.worktree ? [`Next: cd ${saved.worktree.path} && atris mission tick ${saved.id}`] : [`Next: atris mission tick ${saved.id}`]),
558
615
  ],
559
616
  asJson,
560
617
  );
@@ -562,19 +619,30 @@ function startMission(args) {
562
619
 
563
620
  function statusMission(args) {
564
621
  const asJson = wantsJson(args);
565
- const ref = stripKnownFlags(args, ['--status', '--limit'], ['--json'])[0] || '';
622
+ const localOnly = hasFlag(args, '--local');
623
+ const ref = stripKnownFlags(args, ['--status', '--limit'], ['--json', '--local'])[0] || '';
566
624
  const statusFilter = readFlag(args, '--status', '');
567
625
  if (statusFilter && !VALID_STATUSES.has(statusFilter) && !STATUS_ALIASES.has(statusFilter)) {
568
626
  exitMissionError(`Invalid --status: ${statusFilter}`, 2, asJson);
569
627
  }
570
628
  const limit = readPositiveIntegerFlag(args, '--limit', null, { json: asJson });
571
629
  let missions = ref ? [resolveMission(ref)].filter(Boolean) : listMissions();
630
+ if (!ref && !localOnly) {
631
+ const seen = new Set(missions.map((mission) => mission.id));
632
+ for (const rolled of listWorktreeRollupMissions()) {
633
+ if (seen.has(rolled.id)) continue;
634
+ seen.add(rolled.id);
635
+ missions.push(rolled);
636
+ }
637
+ missions.sort((a, b) => String(b.updated_at || b.created_at || '').localeCompare(String(a.updated_at || a.created_at || '')));
638
+ }
572
639
  if (!ref && statusFilter) missions = missions.filter((mission) => missionMatchesStatusFilter(mission, statusFilter));
573
640
  if (!ref && limit) missions = missions.slice(0, limit);
574
641
  if (ref && !missions.length) {
575
642
  exitMissionError(`Mission "${ref}" not found.`, 1, asJson);
576
643
  }
577
- for (const owner of new Set(missions.map((mission) => mission.owner).filter(Boolean))) {
644
+ // Member state renders are cwd-local writes; rolled-up missions stay read-only.
645
+ for (const owner of new Set(missions.filter((mission) => !mission.worktree_root).map((mission) => mission.owner).filter(Boolean))) {
578
646
  renderMemberMissionState(owner);
579
647
  }
580
648
  const payload = {
@@ -593,8 +661,10 @@ function statusMission(args) {
593
661
  ` id: ${mission.id}`,
594
662
  ` owner: ${mission.owner}`,
595
663
  ` state: ${mission.status}`,
664
+ ...(mission.worktree_root ? [` worktree: ${mission.worktree_root}`] : []),
596
665
  ` next: ${mission.next_action || 'tick or verify'}`,
597
666
  ...(mission.receipt_path ? [` proof: ${mission.receipt_path}`] : []),
667
+ ...(completionGateLabel(mission.completion_gate) ? [` gate: ${completionGateLabel(mission.completion_gate)}`] : []),
598
668
  ])
599
669
  : ['No missions yet. Run: atris mission start "..." --owner <member>'],
600
670
  asJson,
@@ -657,6 +727,38 @@ function runVerifier(command, root = process.cwd()) {
657
727
  };
658
728
  }
659
729
 
730
+ const REVIEW_LANE_DRAIN_TIMEOUT_MS = 120000;
731
+
732
+ // Bounded agent-side review sweep, recorded on the tick. Failures never break
733
+ // the mission loop; they surface in the tick record instead.
734
+ function runReviewLaneDrain(root = process.cwd()) {
735
+ const cliPath = path.resolve(__dirname, '..', 'bin', 'atris.js');
736
+ const res = spawnSync(process.execPath, [cliPath, 'task', 'review-lane-run', '--json'], {
737
+ cwd: root,
738
+ encoding: 'utf8',
739
+ timeout: REVIEW_LANE_DRAIN_TIMEOUT_MS,
740
+ });
741
+ let receipt = null;
742
+ try {
743
+ receipt = JSON.parse(res.stdout);
744
+ } catch { /* fall through to error shape */ }
745
+ if (!receipt) {
746
+ return {
747
+ ok: false,
748
+ error: 'review_lane_run_unparseable',
749
+ status: res.status ?? null,
750
+ stderr: String(res.stderr || '').slice(-400),
751
+ };
752
+ }
753
+ return {
754
+ ok: receipt.ok === true,
755
+ run_count: receipt.run_count ?? null,
756
+ total_acted_count: receipt.total_acted_count ?? 0,
757
+ stopped_reason: receipt.stopped_reason || null,
758
+ receipt_path: receipt.receipt_path || null,
759
+ };
760
+ }
761
+
660
762
  function gitWorktreeSnapshot(root = process.cwd()) {
661
763
  const inside = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
662
764
  cwd: root,
@@ -689,7 +791,77 @@ function gitWorktreeSnapshot(root = process.cwd()) {
689
791
  };
690
792
  }
691
793
 
692
- function worktreeReceipt(before, after, { verifier = '' } = {}) {
794
+ // Porcelain v1 entries are "XY path" or "XY old -> new"; baselines compare by
795
+ // post-rename path so a status-letter change alone never counts as new dirt.
796
+ function porcelainEntryPath(line) {
797
+ const trimmed = String(line || '').slice(3);
798
+ const arrow = trimmed.indexOf(' -> ');
799
+ return arrow >= 0 ? trimmed.slice(arrow + 4) : trimmed;
800
+ }
801
+
802
+ const LOOP_EXHAUST_PREFIXES = ['.atris/', 'atris/runs/', 'atris/status/'];
803
+
804
+ function isLoopExhaustPath(entryPath) {
805
+ return LOOP_EXHAUST_PREFIXES.some((prefix) => String(entryPath).startsWith(prefix));
806
+ }
807
+
808
+ function missionBaselinePath(missionId, root = process.cwd()) {
809
+ return path.join(root, '.atris', 'state', 'mission-baselines', `${missionId}.json`);
810
+ }
811
+
812
+ // Write-once sidecar capturing the dirt the mission inherited. Receipts subtract
813
+ // these paths so pre-existing workspace noise stops flagging unverified_dirty.
814
+ // Stored outside missions.jsonl because that log re-appends the full mission
815
+ // record on every save. Captured after start bookkeeping so the mission's own
816
+ // state writes land inside the baseline, not in new_since_baseline.
817
+ function captureMissionWorktreeBaseline(mission, root = process.cwd()) {
818
+ const snapshot = gitWorktreeSnapshot(root);
819
+ if (!snapshot.available) return null;
820
+ const baselineFile = missionBaselinePath(mission.id, root);
821
+ const paths = new Set((snapshot.entries || []).map(porcelainEntryPath));
822
+ // The sidecar itself and the per-tick lock are mission bookkeeping; without
823
+ // these the mission would flag its own state files as unverified dirt.
824
+ paths.add(path.relative(root, baselineFile));
825
+ paths.add(path.relative(root, path.join(root, '.atris', 'state', `mission-${mission.id}.lock`)));
826
+ const baseline = {
827
+ schema: 'atris.mission_worktree_baseline.v1',
828
+ mission_id: mission.id,
829
+ captured_at: stampIso(),
830
+ dirty_count: snapshot.dirty_count,
831
+ dirty_hash: snapshot.dirty_hash,
832
+ paths: Array.from(paths).sort(),
833
+ };
834
+ fs.mkdirSync(path.dirname(baselineFile), { recursive: true });
835
+ fs.writeFileSync(baselineFile, JSON.stringify(baseline, null, 2) + '\n', 'utf8');
836
+ return baseline;
837
+ }
838
+
839
+ function loadMissionWorktreeBaseline(missionId, root = process.cwd()) {
840
+ try {
841
+ const baseline = JSON.parse(fs.readFileSync(missionBaselinePath(missionId, root), 'utf8'));
842
+ return Array.isArray(baseline?.paths) ? baseline : null;
843
+ } catch {
844
+ return null;
845
+ }
846
+ }
847
+
848
+ // Closed missions no longer tick, so the sidecar is dead weight; prune it and
849
+ // fold a compact audit summary into the mission record (full path lists stay
850
+ // out of missions.jsonl, which re-appends the whole record on every save).
851
+ // Paused missions keep their sidecar — resume ticks still subtract it.
852
+ function pruneMissionWorktreeBaseline(mission, root = process.cwd()) {
853
+ const baseline = loadMissionWorktreeBaseline(mission.id, root);
854
+ try { fs.rmSync(missionBaselinePath(mission.id, root), { force: true }); } catch {}
855
+ if (!baseline) return null;
856
+ return {
857
+ captured_at: baseline.captured_at,
858
+ dirty_count: baseline.dirty_count,
859
+ dirty_hash: baseline.dirty_hash,
860
+ path_count: baseline.paths.length,
861
+ };
862
+ }
863
+
864
+ function worktreeReceipt(before, after, { verifier = '', baseline = null } = {}) {
693
865
  if (!before?.available || !after?.available) {
694
866
  return {
695
867
  available: false,
@@ -703,12 +875,27 @@ function worktreeReceipt(before, after, { verifier = '' } = {}) {
703
875
  const clearedDirty = (before.entries || []).filter((entry) => !afterSet.has(entry));
704
876
  const changed = before.dirty_hash !== after.dirty_hash;
705
877
  const hasVerifier = !!String(verifier || '').trim();
878
+ // Baseline = dirt the mission inherited (mission-start sidecar when present,
879
+ // tick-start snapshot for legacy missions). Only paths dirtied beyond that
880
+ // baseline count toward the unverified signal. Loop exhaust the mission
881
+ // writes about itself (state plane, receipts, rendered status) is not work
882
+ // product, so it never counts — otherwise every multi-tick mission in a repo
883
+ // that doesn't gitignore those dirs would flag its own bookkeeping.
884
+ const baselinePaths = baseline
885
+ ? new Set(baseline.paths)
886
+ : new Set((before.entries || []).map(porcelainEntryPath));
887
+ const newSinceBaseline = Array.from(new Set((after.entries || []).map(porcelainEntryPath)))
888
+ .filter((entryPath) => !baselinePaths.has(entryPath) && !isLoopExhaustPath(entryPath));
706
889
  return {
707
890
  available: true,
708
891
  before_dirty_count: before.dirty_count,
709
892
  after_dirty_count: after.dirty_count,
710
893
  changed,
711
- unverified_dirty: !hasVerifier && after.dirty_count > 0,
894
+ baseline_source: baseline ? 'mission_start' : 'tick_start',
895
+ baseline_dirty_count: baseline ? baseline.dirty_count : before.dirty_count,
896
+ new_since_baseline_count: newSinceBaseline.length,
897
+ new_since_baseline_sample: newSinceBaseline.slice(0, 25),
898
+ unverified_dirty: !hasVerifier && newSinceBaseline.length > 0,
712
899
  unverified_change: !hasVerifier && changed,
713
900
  new_dirty_count: newDirty.length,
714
901
  cleared_dirty_count: clearedDirty.length,
@@ -739,7 +926,8 @@ function runnerUsesCallerSession(runner) {
739
926
  }
740
927
 
741
928
  function nextCandidateTickAction(mission) {
742
- return `next move: run atris mission run ${mission.id} --complete-on-pass`;
929
+ const completeFlag = mission.always_on ? '' : ' --complete-on-pass';
930
+ return `next move: run atris mission run ${mission.id}${completeFlag}`;
743
931
  }
744
932
 
745
933
  function missionVerifierPassed(mission) {
@@ -803,9 +991,17 @@ function selectDueMission(root = process.cwd(), now = new Date()) {
803
991
  return candidates[0] || null;
804
992
  }
805
993
 
994
+ function missionSelectableForCodexGoal(mission, now = new Date()) {
995
+ if (!missionIsRunnable(mission)) return false;
996
+ if (mission.always_on && missionVerifierPassed(mission) && !missionDueAt(mission, now)) {
997
+ return parseCadenceSeconds(mission.cadence) > 0;
998
+ }
999
+ return true;
1000
+ }
1001
+
806
1002
  function selectCodexGoalMission(root = process.cwd(), now = new Date()) {
807
1003
  const candidates = listMissions(root)
808
- .filter((mission) => missionSelectableForLoop(mission, now));
1004
+ .filter((mission) => missionSelectableForCodexGoal(mission, now));
809
1005
 
810
1006
  candidates.sort((a, b) => {
811
1007
  const aCaller = runnerUsesCallerSession(a.runner) ? 1 : 0;
@@ -837,7 +1033,8 @@ function codexGoalNextCommand(mission) {
837
1033
  if (xpAction) return xpAction.replace(/^queue AgentXP review: /, '');
838
1034
  }
839
1035
  if (mission.verifier && missionDueAt(mission)) {
840
- return 'atris mission run --due --max-ticks 1 --complete-on-pass';
1036
+ const completeFlag = mission.always_on ? '' : ' --complete-on-pass';
1037
+ return `atris mission run --due --max-ticks 1${completeFlag}`;
841
1038
  }
842
1039
  if (mission.verifier) {
843
1040
  return `atris mission tick ${mission.id} --verify --summary "<what changed>"`;
@@ -1243,11 +1440,12 @@ async function runMission(args) {
1243
1440
  const skipClaude = hasFlag(args, '--no-claude');
1244
1441
  const verifyEach = !hasFlag(args, '--no-verify');
1245
1442
  const completeOnPass = hasFlag(args, '--complete-on-pass');
1443
+ const skipDrain = hasFlag(args, '--no-drain');
1246
1444
  const maxTicksFlag = readFlag(args, '--max-ticks', '');
1247
1445
  const maxTicks = Math.max(1, Number(maxTicksFlag) || MISSION_RUN_DEFAULTS.maxTicks);
1248
1446
  const maxWallSeconds = Math.max(60, Number(readFlag(args, '--max-wall', '')) || MISSION_RUN_DEFAULTS.maxWallSeconds);
1249
1447
  const cadenceOverride = readFlag(args, '--cadence', '');
1250
- const ref = stripKnownFlags(args, ['--max-ticks', '--max-wall', '--cadence'], ['--json', '--due', '--no-claude', '--no-verify', '--complete-on-pass'])[0] || '';
1448
+ const ref = stripKnownFlags(args, ['--max-ticks', '--max-wall', '--cadence'], ['--json', '--due', '--no-claude', '--no-verify', '--complete-on-pass', '--no-drain'])[0] || '';
1251
1449
 
1252
1450
  let mission = dueMode && !ref ? selectDueMission() : resolveMission(ref);
1253
1451
  if (!mission && dueMode && !ref) {
@@ -1334,6 +1532,7 @@ async function runMission(args) {
1334
1532
  started_at: stampIso(),
1335
1533
  };
1336
1534
  const runWorktreeBefore = gitWorktreeSnapshot(cwd);
1535
+ const runWorktreeBaseline = loadMissionWorktreeBaseline(mission.id, cwd);
1337
1536
  const cadence = cadenceOverride || mission.cadence || 'manual';
1338
1537
  let cadenceSeconds = parseCadenceSeconds(cadence);
1339
1538
  // cadence=manual|once: exactly 1 tick unless user explicitly raised --max-ticks
@@ -1450,7 +1649,16 @@ async function runMission(args) {
1450
1649
  verifierResult = runVerifier(frozen.verifier);
1451
1650
  result.verifier_passed = verifierResult.passed;
1452
1651
  }
1453
- const tickWorktree = worktreeReceipt(tickWorktreeBefore, gitWorktreeSnapshot(cwd), { verifier: frozen.verifier });
1652
+
1653
+ // Review-lane drain: always-on loops sweep the agent-safe review actions
1654
+ // each tick so proof-backed work reaches certified on cadence with zero
1655
+ // human turns. Human accept stays the only path to Done; --no-drain opts out.
1656
+ if (mission.always_on && result.status === 'ran') {
1657
+ result.review_lane = skipDrain
1658
+ ? { skipped: true, reason: 'no-drain-flag' }
1659
+ : runReviewLaneDrain(cwd);
1660
+ }
1661
+ const tickWorktree = worktreeReceipt(tickWorktreeBefore, gitWorktreeSnapshot(cwd), { verifier: frozen.verifier, baseline: runWorktreeBaseline });
1454
1662
 
1455
1663
  // Persist tick to mission state + write structured receipt
1456
1664
  const finishedAt = stampIso();
@@ -1553,7 +1761,7 @@ async function runMission(args) {
1553
1761
  }, cwd, 'mission_run_paused', { reason: pauseReason }).mission;
1554
1762
  }
1555
1763
 
1556
- const summaryWorktree = worktreeReceipt(runWorktreeBefore, gitWorktreeSnapshot(cwd), { verifier: frozen.verifier });
1764
+ const summaryWorktree = worktreeReceipt(runWorktreeBefore, gitWorktreeSnapshot(cwd), { verifier: frozen.verifier, baseline: runWorktreeBaseline });
1557
1765
  const finalReceipt = writeReceipt(mission, {
1558
1766
  kind: 'mission_run_summary',
1559
1767
  frozen,
@@ -1628,12 +1836,13 @@ function tickMission(args) {
1628
1836
  const lastTickIndex = Number(mission.last_tick_index || 0);
1629
1837
  const tickIdx = lastTickIndex + 1;
1630
1838
  const tickWorktreeBefore = gitWorktreeSnapshot(cwd);
1839
+ const worktreeBaseline = loadMissionWorktreeBaseline(mission.id, cwd);
1631
1840
 
1632
1841
  let verifierResult = null;
1633
1842
  if (verify && mission.verifier) {
1634
1843
  verifierResult = runVerifier(mission.verifier);
1635
1844
  }
1636
- const tickWorktree = worktreeReceipt(tickWorktreeBefore, gitWorktreeSnapshot(cwd), { verifier: mission.verifier });
1845
+ const tickWorktree = worktreeReceipt(tickWorktreeBefore, gitWorktreeSnapshot(cwd), { verifier: mission.verifier, baseline: worktreeBaseline });
1637
1846
 
1638
1847
  const tickRecord = {
1639
1848
  status: 'ran',
@@ -1710,10 +1919,49 @@ function tickMission(args) {
1710
1919
  }
1711
1920
  }
1712
1921
 
1922
+ // Proof receipts are JSON files written by writeReceipt(); anything else
1923
+ // (free text, command strings, missing paths) reads as null and falls back
1924
+ // to durable mission state.
1925
+ function readReceiptProof(proof, root = process.cwd()) {
1926
+ try {
1927
+ const parsed = JSON.parse(fs.readFileSync(path.resolve(root, String(proof || '')), 'utf8'));
1928
+ return parsed?.schema === 'atris.mission_receipt.v1' ? parsed : null;
1929
+ } catch {
1930
+ return null;
1931
+ }
1932
+ }
1933
+
1934
+ function receiptShowsPass(receipt) {
1935
+ const result = receipt?.result;
1936
+ return result?.passed === true
1937
+ || result?.verifier_result?.passed === true
1938
+ || result?.tick?.verifier_passed === true;
1939
+ }
1940
+
1941
+ // Terminal gate: a verifier mission may only complete on real evidence — a
1942
+ // passing receipt belonging to this mission, or durable state showing the
1943
+ // verifier passed. Mirrors the task plane's proof-only accept guard so the
1944
+ // final transition consumes the receipts instead of trusting free text.
1945
+ function missionCompletionGate(mission, proof, root = process.cwd()) {
1946
+ if (!String(mission.verifier || '').trim()) return { ok: true, source: 'no_verifier' };
1947
+ const receipt = readReceiptProof(proof, root);
1948
+ if (receipt) {
1949
+ if (receipt.mission_id !== mission.id) {
1950
+ return { ok: false, source: 'receipt', reason: `proof receipt belongs to mission ${receipt.mission_id}, not ${mission.id}` };
1951
+ }
1952
+ return receiptShowsPass(receipt)
1953
+ ? { ok: true, source: 'receipt', receipt_path: String(proof) }
1954
+ : { ok: false, source: 'receipt', reason: 'proof receipt does not show a passing verifier' };
1955
+ }
1956
+ if (mission.verifier_result?.passed === true) return { ok: true, source: 'mission_state' };
1957
+ return { ok: false, source: 'mission_state', reason: 'verifier has not passed for this mission and proof is not a passing receipt' };
1958
+ }
1959
+
1713
1960
  function completeMission(args) {
1714
1961
  const asJson = wantsJson(args);
1962
+ const force = hasFlag(args, '--force');
1715
1963
  const proof = readFlag(args, '--proof', '');
1716
- const ref = stripKnownFlags(args, ['--proof'], ['--json'])[0] || '';
1964
+ const ref = stripKnownFlags(args, ['--proof'], ['--json', '--force'])[0] || '';
1717
1965
  if (!ref || !proof) {
1718
1966
  exitMissionError('Usage: atris mission complete <id> --proof "..."', 1, asJson);
1719
1967
  }
@@ -1721,14 +1969,21 @@ function completeMission(args) {
1721
1969
  if (!mission) {
1722
1970
  exitMissionError(`Mission "${ref}" not found.`, 1, asJson);
1723
1971
  }
1972
+ const gate = missionCompletionGate(mission, proof, process.cwd());
1973
+ if (!gate.ok && !force) {
1974
+ exitMissionError(`[mission complete] ${gate.reason}. Run: atris mission tick ${mission.id} --verify (or override as operator with --force)`, 2, asJson);
1975
+ }
1976
+ const baselineSummary = pruneMissionWorktreeBaseline(mission, process.cwd());
1724
1977
  const next = {
1725
1978
  ...mission,
1726
1979
  status: 'complete',
1727
1980
  completed_at: stampIso(),
1728
1981
  proof,
1982
+ completion_gate: { ...gate, forced: force && !gate.ok },
1983
+ worktree_baseline: baselineSummary || mission.worktree_baseline || null,
1729
1984
  next_action: 'mission complete',
1730
1985
  };
1731
- const { mission: saved } = saveMission(next, process.cwd(), 'mission_completed', { proof });
1986
+ const { mission: saved } = saveMission(next, process.cwd(), 'mission_completed', { proof, completion_gate: next.completion_gate });
1732
1987
  const logPath = appendMemberLog(saved.owner, 'Mission completed', { mission: saved.objective, proof });
1733
1988
  const codexGoalState = refreshCodexGoalController(process.cwd());
1734
1989
  const xpNextCommand = missionXpReadyAction(saved, proof);
@@ -1752,19 +2007,37 @@ function stopMission(args) {
1752
2007
  exitMissionError(`Mission "${ref}" not found.`, 1, asJson);
1753
2008
  }
1754
2009
  const status = pause ? 'paused' : 'stopped';
2010
+ // Full stops abandon work, so leave evidence: snapshot the worktree against
2011
+ // the mission baseline (what did this mission leave dirty?) before pruning it.
2012
+ let receiptPath = null;
2013
+ if (!pause) {
2014
+ const snapshot = gitWorktreeSnapshot(process.cwd());
2015
+ const worktree = worktreeReceipt(snapshot, snapshot, {
2016
+ verifier: mission.verifier,
2017
+ baseline: loadMissionWorktreeBaseline(mission.id, process.cwd()),
2018
+ });
2019
+ receiptPath = writeReceipt(mission, { kind: 'mission_stop', reason, worktree });
2020
+ }
2021
+ const baselineSummary = pause ? null : pruneMissionWorktreeBaseline(mission, process.cwd());
1755
2022
  const next = {
1756
2023
  ...mission,
1757
2024
  status,
1758
2025
  stopped_at: status === 'stopped' ? stampIso() : mission.stopped_at || null,
1759
2026
  paused_at: status === 'paused' ? stampIso() : mission.paused_at || null,
1760
2027
  stop_reason: reason,
2028
+ receipt_path: receiptPath || mission.receipt_path || null,
2029
+ worktree_baseline: baselineSummary || mission.worktree_baseline || null,
1761
2030
  next_action: status === 'paused' ? `resume with: atris mission tick ${mission.id}` : 'mission stopped',
1762
2031
  };
1763
- const { mission: saved } = saveMission(next, process.cwd(), pause ? 'mission_paused' : 'mission_stopped', { reason });
2032
+ const { mission: saved } = saveMission(next, process.cwd(), pause ? 'mission_paused' : 'mission_stopped', { reason, receipt_path: receiptPath });
1764
2033
  const logPath = appendMemberLog(saved.owner, pause ? 'Mission paused' : 'Mission stopped', { mission: saved.objective, reason });
1765
2034
  printJsonOrText(
1766
- { ok: true, action: pause ? 'mission_paused' : 'mission_stopped', mission: saved, log_path: logPath },
1767
- [`${pause ? 'Paused' : 'Stopped'} mission: ${saved.objective}`, `Reason: ${reason}`],
2035
+ { ok: true, action: pause ? 'mission_paused' : 'mission_stopped', mission: saved, receipt_path: receiptPath, log_path: logPath },
2036
+ [
2037
+ `${pause ? 'Paused' : 'Stopped'} mission: ${saved.objective}`,
2038
+ `Reason: ${reason}`,
2039
+ ...(receiptPath ? [`Receipt: ${receiptPath}`] : []),
2040
+ ],
1768
2041
  asJson,
1769
2042
  );
1770
2043
  }
@@ -1866,13 +2139,15 @@ function help() {
1866
2139
  console.log(`
1867
2140
  atris mission - durable goal + loop + owner + proof state
1868
2141
 
1869
- atris mission start "<objective>" --owner <member> [--verify "..."] [--always-on] [--xp-task]
1870
- atris mission status [id] [--status <state>] [--limit <n>] [--json]
2142
+ atris mission start "<objective>" --owner <member> [--verify "..."] [--always-on] [--xp-task] [--worktree]
2143
+ atris mission status [id] [--status <state>] [--limit <n>] [--local] [--json]
2144
+ (rolls up sibling git-worktree missions; --local scopes to this checkout)
1871
2145
  atris mission goal [--heartbeat] [--json]
1872
2146
  atris mission goal-loop [--max-wall 28800] [--max-iterations 32] [--no-claude] [--json]
1873
2147
  atris mission tick <id> [--verify] [--complete-on-pass] [--summary "..."] [--json]
1874
2148
  atris mission run <id|--due> [--max-ticks 4] [--max-wall 3600] [--cadence "15m"]
1875
- [--no-claude] [--no-verify] [--complete-on-pass] [--json]
2149
+ [--no-claude] [--no-verify] [--complete-on-pass] [--no-drain] [--json]
2150
+ (always-on runs drain the review lane each tick; --no-drain skips)
1876
2151
  atris mission complete <id> --proof "..."
1877
2152
  atris mission stop <id> [--pause] [--reason "..."]
1878
2153
 
@@ -1886,7 +2161,7 @@ Autonomy recipe:
1886
2161
  4. Do one bounded step, then record it:
1887
2162
  atris mission tick <id> --verify --summary "what changed"
1888
2163
  5. Close or continue from the receipt:
1889
- atris task ready <xp_task_ref> --proof "<receipt_path>" (if --xp-task)
2164
+ atris task current-step --goal-id <mission_id> --as <owner> --proof "<receipt_path>" --json (if --xp-task)
1890
2165
  atris task accept <xp_task_ref> --reward <n> (human accept mints AgentXP)
1891
2166
  atris mission complete <id> --proof "<receipt_path>"
1892
2167
  repeat status -> step -> tick for current-agent work