atris 3.30.12 → 3.31.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 (58) hide show
  1. package/AGENTS.md +16 -3
  2. package/FOR_AGENTS.md +81 -0
  3. package/README.md +3 -1
  4. package/atris/atris.md +51 -19
  5. package/atris/skills/README.md +1 -0
  6. package/atris/skills/blocks/SKILL.md +134 -0
  7. package/atris/skills/clawhub/philosophy-of-work/SKILL.md +56 -0
  8. package/atris/skills/youtube/SKILL.md +31 -11
  9. package/atris.md +7 -0
  10. package/ax +95 -3
  11. package/bin/atris.js +126 -153
  12. package/commands/autoland.js +319 -0
  13. package/commands/autopilot.js +94 -4
  14. package/commands/business.js +1 -1
  15. package/commands/clean.js +72 -9
  16. package/commands/codex-goal.js +72 -22
  17. package/commands/computer.js +48 -3
  18. package/commands/gm.js +1 -1
  19. package/commands/harvest.js +179 -0
  20. package/commands/init.js +1 -1
  21. package/commands/land.js +442 -0
  22. package/commands/loop-front.js +122 -4
  23. package/commands/member.js +519 -19
  24. package/commands/mission.js +3330 -290
  25. package/commands/play.js +1 -1
  26. package/commands/pulse.js +65 -7
  27. package/commands/run.js +3 -3
  28. package/commands/strings.js +301 -0
  29. package/commands/task.js +575 -102
  30. package/commands/truth.js +170 -0
  31. package/commands/xp.js +32 -8
  32. package/commands/youtube.js +72 -5
  33. package/decks/README.md +6 -12
  34. package/lib/auto-accept-certified.js +10 -0
  35. package/lib/autoland.js +283 -0
  36. package/lib/context-gatherer.js +0 -8
  37. package/lib/mission-artifact.js +504 -0
  38. package/lib/mission-room.js +846 -0
  39. package/lib/next-moves.js +212 -6
  40. package/lib/pulse.js +74 -1
  41. package/lib/runner-command.js +20 -8
  42. package/lib/runs-prune.js +242 -0
  43. package/lib/task-proof.js +1 -1
  44. package/package.json +3 -3
  45. package/decks/atris-seed-pitch-v3.json +0 -118
  46. package/decks/atris-seed-pitch-v4-skeleton.json +0 -106
  47. package/decks/atris-seed-pitch-v5.json +0 -109
  48. package/decks/atris-seed-pitch-v6.json +0 -137
  49. package/decks/atris-seed-pitch-v7.json +0 -133
  50. package/decks/mark-pincus-narrative.json +0 -102
  51. package/decks/mark-pincus-sourcery.json +0 -94
  52. package/decks/yash-applied-compute-detailed.json +0 -150
  53. package/decks/yash-applied-compute-generalist.json +0 -82
  54. package/decks/yash-applied-compute-narrative.json +0 -54
  55. package/lib/ax-chat-input.js +0 -164
  56. package/lib/ax-goal.js +0 -307
  57. package/lib/ax-prefs.js +0 -63
  58. package/lib/ax-shimmer.js +0 -63
@@ -15,11 +15,27 @@ const {
15
15
  normalizeOwnerSlug,
16
16
  resolveFunctionalOwner,
17
17
  } = require('../lib/functional-owner');
18
+ const {
19
+ buildMissionRoom,
20
+ writeMissionRoomReceipt,
21
+ missionRoomLines,
22
+ } = require('../lib/mission-room');
23
+ const {
24
+ missionArtifactPaths,
25
+ writeMissionArtifact,
26
+ } = require('../lib/mission-artifact');
27
+ const {
28
+ pruneRuns,
29
+ runsPruneLines,
30
+ formatBytes,
31
+ } = require('../lib/runs-prune');
18
32
 
19
33
  const VALID_STATUSES = new Set(['planning', 'running', 'ready', 'paused', 'blocked', 'stopped', 'complete']);
20
34
  const TERMINAL_STATUSES = new Set(['stopped', 'complete']);
21
35
  const GOAL_LOOP_STATUSES = new Set(['planning', 'running', 'ready']);
22
36
  const STATUS_ALIASES = new Set(['active']);
37
+ const DEFAULT_LONG_RUN_VERIFIER = 'git diff --check';
38
+ const SLEEP_LENGTH_BUDGET_SECONDS = 3600;
23
39
 
24
40
  function stampIso() {
25
41
  return new Date().toISOString();
@@ -84,6 +100,16 @@ function readPositiveIntegerFlag(args, name, fallback = null, options = {}) {
84
100
  return value;
85
101
  }
86
102
 
103
+ function readNonNegativeIntegerFlag(args, name, fallback = null) {
104
+ const raw = readFlag(args, name, '');
105
+ if (!raw) return fallback;
106
+ const value = Number(raw);
107
+ if (!Number.isInteger(value) || value < 0) {
108
+ throw new Error(`${name} must be a non-negative integer.`);
109
+ }
110
+ return value;
111
+ }
112
+
87
113
  function readRepeatedFlag(args, name) {
88
114
  const values = [];
89
115
  const prefix = `${name}=`;
@@ -147,6 +173,8 @@ function printJsonOrText(payload, lines, asJson) {
147
173
  const MISSION_RUN_VALUE_FLAGS = [
148
174
  '--max-ticks',
149
175
  '--max-wall',
176
+ '--minutes',
177
+ '--hours',
150
178
  '--cadence',
151
179
  '--owner',
152
180
  '--runner',
@@ -154,6 +182,10 @@ const MISSION_RUN_VALUE_FLAGS = [
154
182
  '--verify',
155
183
  '--stop',
156
184
  '--model',
185
+ '--native-goal-status',
186
+ '--native-goal-objective',
187
+ '--visible-goal-status',
188
+ '--visible-goal-objective',
157
189
  ];
158
190
  const MISSION_RUN_BOOLEAN_FLAGS = [
159
191
  '--json',
@@ -162,6 +194,16 @@ const MISSION_RUN_BOOLEAN_FLAGS = [
162
194
  '--no-verify',
163
195
  '--complete-on-pass',
164
196
  '--no-drain',
197
+ '--create-next',
198
+ '--spend-full-budget',
199
+ '--use-whole-budget',
200
+ '--stop-when-done',
201
+ '--room-preflight',
202
+ '--no-room-preflight',
203
+ '--room-auto-run',
204
+ '--no-room-auto-run',
205
+ '--allow-native-goal-supersede',
206
+ '--supersede-paused-native-goal',
165
207
  '--always-on',
166
208
  '--xp-task',
167
209
  '--agent-xp',
@@ -321,7 +363,7 @@ function createMissionXpTask(mission, root = process.cwd(), asJson = false) {
321
363
  taskDb.noteTask(db, {
322
364
  id: task.id,
323
365
  actor: process.env.ATRIS_AGENT_ID || owner,
324
- content: `Mission goal loop XP bridge for ${mission.id}. Proof goes through task current-step; AgentXP lands only after human accept.`,
366
+ content: `Mission goal loop XP bridge for ${mission.id}. Proof goes through task current-step; AgentXP is awarded only after human approval.`,
325
367
  });
326
368
  }
327
369
  const { outPath } = writeMissionTaskProjection(taskDb, db, workspaceRoot);
@@ -345,6 +387,7 @@ function statePaths(root = process.cwd()) {
345
387
  missionsJsonl: path.join(stateDir, 'missions.jsonl'),
346
388
  eventsJsonl: path.join(stateDir, 'mission_events.jsonl'),
347
389
  codexGoalJson: path.join(stateDir, 'codex_goal.json'),
390
+ codexGoalRequestJson: path.join(stateDir, 'codex_goal_request.json'),
348
391
  codexGoalStatus: path.join(root, 'atris', 'status', 'codex-goal.md'),
349
392
  atrisGoalJson: path.join(stateDir, 'atris_goal.json'),
350
393
  atrisGoalStatus: path.join(root, 'atris', 'status', 'atris-goal.md'),
@@ -404,9 +447,21 @@ function terminalNextAction(status) {
404
447
 
405
448
  function normalizeMissionState(mission) {
406
449
  if (!mission) return mission;
450
+ let normalized = mission;
407
451
  const nextAction = terminalNextAction(mission.status);
408
- if (!nextAction || mission.next_action === nextAction) return mission;
409
- return { ...mission, next_action: nextAction };
452
+ if (nextAction && mission.next_action !== nextAction) {
453
+ normalized = { ...normalized, next_action: nextAction };
454
+ }
455
+ const effectiveVerifier = effectiveMissionVerifier(normalized);
456
+ const explicitVerifier = String(normalized.verifier || '').trim();
457
+ if (effectiveVerifier && effectiveVerifier !== explicitVerifier) {
458
+ return { ...normalized, effective_verifier: effectiveVerifier };
459
+ }
460
+ if (Object.prototype.hasOwnProperty.call(normalized, 'effective_verifier')) {
461
+ const { effective_verifier, ...withoutDerivedVerifier } = normalized;
462
+ return withoutDerivedVerifier;
463
+ }
464
+ return normalized;
410
465
  }
411
466
 
412
467
  function listMissions(root = process.cwd()) {
@@ -618,6 +673,7 @@ function completionGateLabel(gate) {
618
673
  function missionCompletionReceipt(mission, proof, xpNextCommand = null) {
619
674
  const gate = mission.completion_gate || {};
620
675
  const gateLabel = completionGateLabel(gate) || 'completion gate';
676
+ const happened = `${mission.objective} is complete.`;
621
677
  const checked = gate.source === 'receipt' && gate.receipt_path
622
678
  ? `I checked the passing verifier receipt ${gate.receipt_path}.`
623
679
  : gate.source === 'mission_state'
@@ -626,21 +682,23 @@ function missionCompletionReceipt(mission, proof, xpNextCommand = null) {
626
682
  ? 'No verifier was configured, so completion used the no-verifier gate.'
627
683
  : `I checked the ${gateLabel} completion gate.`;
628
684
  const tested = mission.verifier_result?.passed
629
- ? `Verifier passed: ${mission.verifier_result.command || mission.verifier || 'configured verifier'}.`
685
+ ? missionVerifierHighLevelTestText(mission.verifier_result, mission)
630
686
  : mission.verifier
631
687
  ? `Completion proof is attached for verifier: ${mission.verifier}.`
632
688
  : 'No verifier command was recorded for this mission.';
633
689
  const landing = {
634
- happened: `Mission completed: ${mission.objective}.`,
690
+ happened,
691
+ reason: missionHumanReasonText(mission, happened),
635
692
  checked,
636
693
  tested,
637
- saved: `Saved complete mission ${mission.id}${proof ? ` with proof ${proof}` : ''}.`,
694
+ saved: proof ? `Proof saved at ${proof}.` : 'Proof saved in mission state.',
638
695
  decision: xpNextCommand
639
- ? `Queue AgentXP next: ${xpNextCommand}`
640
- : 'Mission is complete; start or resume the next mission if more work remains.',
696
+ ? 'Ready for human review; accept in Atris if the proof looks right.'
697
+ : 'Pick the next customer-facing move.',
641
698
  };
642
699
  const result = {
643
700
  changed: landing.happened,
701
+ reason: landing.reason,
644
702
  checked: landing.checked,
645
703
  tested: landing.tested,
646
704
  saved: landing.saved,
@@ -652,12 +710,657 @@ function missionCompletionReceipt(mission, proof, xpNextCommand = null) {
652
710
  function missionResultLines(completion) {
653
711
  const landing = completion?.landing || {};
654
712
  const result = completion?.result || {};
655
- const lines = ['Result:'];
656
- if (landing.happened) lines.push(` What happened: ${landing.happened}`);
713
+ const lines = ['Landing:'];
714
+ if (landing.happened) lines.push(` Changed: ${landing.happened}`);
715
+ if (landing.reason) lines.push(` Why it matters: ${landing.reason}`);
716
+ if (landing.artifact) lines.push(` Artifact: ${landing.artifact}`);
657
717
  if (landing.checked) lines.push(` How I checked: ${landing.checked}`);
658
718
  if (landing.tested) lines.push(` What I tested: ${landing.tested}`);
659
- if (result.saved) lines.push(` Saved: ${result.saved}`);
660
- if (landing.decision) lines.push(` Decision: ${landing.decision}`);
719
+ if (result.saved) lines.push(` Proof: ${result.saved}`);
720
+ if (landing.decision) lines.push(` Next: ${landing.decision}`);
721
+ return lines;
722
+ }
723
+
724
+ function missionSelfImprovementSeedAction(mission, root = process.cwd()) {
725
+ try {
726
+ const moves = require('../lib/next-moves');
727
+ const seed = moves.nextMoves(root, 3).find((move) => (
728
+ move
729
+ && move.source === 'mission'
730
+ && (!mission?.id || !move.ref || move.ref === mission.id)
731
+ && move.title !== mission.objective
732
+ ));
733
+ return seed ? `${seed.title}.` : '';
734
+ } catch {
735
+ return '';
736
+ }
737
+ }
738
+
739
+ function missionHumanNextAction(mission, root = process.cwd(), options = {}) {
740
+ if (!mission) return 'Pick the next customer-facing move.';
741
+ if (mission.goal_chain?.pause_ready) return 'Mission feels good; review proof, then complete, revise, or choose the next goal.';
742
+ if (mission.status === 'ready' && /^queue AgentXP review:/i.test(mission.next_action || '')) {
743
+ return 'Ready for human review; accept in Atris if the proof looks right.';
744
+ }
745
+ if (mission.status === 'ready' && mission.always_on) {
746
+ return options.allowSelfImprovementSeed
747
+ ? (missionSelfImprovementSeedAction(mission, root) || 'Run the next proof step.')
748
+ : 'Run the next proof step.';
749
+ }
750
+ if (mission.status === 'ready') return 'Review the proof, then complete the mission.';
751
+ if (mission.status === 'complete') return 'Pick the next customer-facing move.';
752
+ if (mission.status === 'blocked') return 'Fix the verifier failure or revise the mission.';
753
+ return 'Keep running the mission.';
754
+ }
755
+
756
+ function missionLandingStepSummary(summary) {
757
+ const clean = String(summary || '').replace(/\s+/g, ' ').trim();
758
+ if (!clean) return '';
759
+ const withoutLabel = clean
760
+ .replace(/^(?:landing|changed|summary|result|product proof|proof):\s*/i, '')
761
+ .trim();
762
+ if (!withoutLabel) return '';
763
+ const plainVerified = missionPlainVerifiedSummary(withoutLabel);
764
+ if (plainVerified) return plainVerified;
765
+ const beforeChecks = withoutLabel.split(/\s+(?:checks?|verified|proof):\s+/i)[0] || withoutLabel;
766
+ const clipped = missionLandingSentenceClip(beforeChecks, 220);
767
+ return clipped ? `${clipped}.` : '';
768
+ }
769
+
770
+ function missionHumanReasonText(mission, changed = '') {
771
+ const objective = String(mission?.objective || '').trim();
772
+ const text = `${objective} ${changed}`.toLowerCase();
773
+ if (/\b(human|plain|language|landing|proof|receipt|understand|readable)\b/.test(text)) {
774
+ return 'It makes the result understandable before a human accepts or rejects it.';
775
+ }
776
+ if (/\b(update|install|runner|autopilot|mission run|heartbeat)\b/.test(text)) {
777
+ return 'It proves the workflow works in the place people actually use it.';
778
+ }
779
+ return 'It turns the mission into a concrete result a human can accept, reject, or run again.';
780
+ }
781
+
782
+ function missionPlainVerifiedSummary(text) {
783
+ const clean = String(text || '').replace(/\s+/g, ' ').trim();
784
+ const shipped = clean.match(/^Verified\s+(.+?)\s+behavior\s+already\s+shipped\s+on\s+master\s*:/i);
785
+ if (!shipped) return '';
786
+ const topic = shipped[1].toLowerCase();
787
+ if (topic.includes('npm auto-update')) {
788
+ return 'Verified npm auto-update works for installed npm packages on master.';
789
+ }
790
+ if (topic.includes('runner-agnostic heartbeat')) {
791
+ return 'Verified autopilot and mission run use the same runner setup on master.';
792
+ }
793
+ return `Verified ${shipped[1]} is already working on master.`;
794
+ }
795
+
796
+ function missionLandingSentenceClip(text, max) {
797
+ const clean = String(text || '').replace(/\s+/g, ' ').trim();
798
+ if (!clean) return '';
799
+ if (clean.length <= max) return clean.replace(/[.!?:;,]+$/g, '').trim();
800
+ const sentence = clean.match(/^(.{24,220}?[.!?])\s+/);
801
+ const base = sentence ? sentence[1] : clean.slice(0, max);
802
+ return base
803
+ .replace(/\s+\S*$/, '')
804
+ .replace(/[.!?:;,]+$/g, '')
805
+ .replace(/\b(?:and|or|but|with|to|for|from|by|through|via|using|including|include|includes|plus|then|both|the|a|an|of|in|on|at|as)$/i, '')
806
+ .trim();
807
+ }
808
+
809
+ function missionLastStepSummary(ticks = []) {
810
+ for (let index = ticks.length - 1; index >= 0; index -= 1) {
811
+ const tick = ticks[index] || {};
812
+ const summary = missionLandingStepSummary(tick.summary || tick.claude?.summary || tick.atris2?.receipt_text || '');
813
+ if (summary) return summary;
814
+ }
815
+ return '';
816
+ }
817
+
818
+ function missionVerifierCheckedText(verifierResult, mission) {
819
+ if (!verifierResult) return 'Tick recorded; no verifier was run.';
820
+ const command = verifierResult.command || mission.verifier || 'configured verifier';
821
+ if (verifierResult.passed) {
822
+ if (/^git\s+diff\s+--check\b/i.test(command)) return 'I ran the diff cleanliness check.';
823
+ if (/\bnode\s+--test\b/i.test(command)) return 'I ran the behavior checks.';
824
+ return `Verifier passed: ${command}.`;
825
+ }
826
+ if (/^git\s+diff\s+--check\b/i.test(command)) return 'Diff cleanliness check failed.';
827
+ if (/\bnode\s+--test\b/i.test(command)) return 'Behavior checks failed.';
828
+ return `Verifier failed: ${command}.`;
829
+ }
830
+
831
+ function missionVerifierHighLevelTestText(verifierResult, mission) {
832
+ if (!verifierResult) return 'No automated verifier ran for this receipt; judge it from the receipt, changed files, and next action.';
833
+ const command = verifierResult.command || mission.verifier || 'configured verifier';
834
+ const outcome = verifierResult.passed ? 'passed' : 'failed';
835
+ if (/^git\s+diff\s+--check\b/i.test(command)) {
836
+ return `Diff cleanliness check ${outcome}: no whitespace or patch-format issues in the changed files.`;
837
+ }
838
+ if (/\bnode\s+--test\b/i.test(command) && /\btest\/mission-status\.test\.js\b/i.test(command)) {
839
+ return `Mission behavior checks ${outcome}: mission start, tick, completion, timeline landing, goal-chain, next-mission, and human-accept boundaries were exercised.`;
840
+ }
841
+ if (/\bnode\s+--test\b/i.test(command)) {
842
+ return `Automated behavior checks ${outcome}: the touched code path was exercised by Node tests.`;
843
+ }
844
+ return `Verifier command ${outcome}: ${command}.`;
845
+ }
846
+
847
+ function missionFallbackChangedText(mission, status, tickIndex, { ranTicks = null, effectiveMaxTicks = null } = {}) {
848
+ if (mission?.always_on && (status === 'ready' || status === 'running')) {
849
+ return 'Recorded a proof heartbeat for this always-on mission.';
850
+ }
851
+ if (status === 'ready') return `${mission.objective} is ready for review.`;
852
+ if (status === 'complete') return `${mission.objective} is complete.`;
853
+ if (status === 'blocked') return `${mission.objective} is blocked.`;
854
+ if (ranTicks != null && effectiveMaxTicks != null) {
855
+ return ranTicks > 0
856
+ ? `${mission.objective} ran ${ranTicks}/${effectiveMaxTicks} tick(s).`
857
+ : `${mission.objective} did not run a tick.`;
858
+ }
859
+ return `${mission.objective} recorded tick ${tickIndex}.`;
860
+ }
861
+
862
+ function missionTickResultLines(mission, tickIndex, receiptPath, verifierResult = null, stepSummary = '') {
863
+ return missionLandingLines(missionReceiptLanding(mission, {
864
+ tick: { tick_index: tickIndex },
865
+ summary: stepSummary,
866
+ verifier_result: verifierResult,
867
+ }, receiptPath));
868
+ }
869
+
870
+ function missionReceiptSummaryText(result) {
871
+ const tick = result?.tick || {};
872
+ return tick.summary
873
+ || tick.atris2?.receipt_text
874
+ || tick.claude?.receipt_text
875
+ || result?.summary
876
+ || result?.reason
877
+ || '';
878
+ }
879
+
880
+ function missionReceiptTickIndex(mission, result) {
881
+ const value = Number(result?.tick?.tick_index || mission?.last_tick_index || 0);
882
+ return Number.isFinite(value) && value > 0 ? value : 1;
883
+ }
884
+
885
+ function missionReceiptStatus(mission, result) {
886
+ const tickStatus = String(result?.tick?.status || '').trim();
887
+ if (tickStatus === 'blocked') return 'blocked';
888
+ if (result?.verifier_result?.passed === false) return 'blocked';
889
+ if (result?.verifier_result?.passed === true) return 'proof_ready';
890
+ return String(mission?.status || 'running');
891
+ }
892
+
893
+ function missionReceiptNextText(mission, result) {
894
+ if (result?.next) return String(result.next);
895
+ if (result?.landing?.next) return String(result.landing.next);
896
+ if (result?.verifier_result?.passed === true) {
897
+ return mission?.always_on ? 'Run the next proof step.' : 'Review the proof, then complete the mission.';
898
+ }
899
+ if (result?.verifier_result) return 'Fix the verifier failure or revise the mission.';
900
+ return missionHumanNextAction(mission);
901
+ }
902
+
903
+ function missionReceiptLanding(mission, result, receiptPath = '') {
904
+ const verifierResult = result?.verifier_result || null;
905
+ const status = missionReceiptStatus(mission, result);
906
+ const summary = missionReceiptSummaryText(result);
907
+ const changed = missionLandingStepSummary(summary)
908
+ || missionFallbackChangedText(mission, status, missionReceiptTickIndex(mission, result));
909
+ const checked = missionVerifierCheckedText(verifierResult, mission);
910
+ const tested = missionVerifierHighLevelTestText(verifierResult, mission);
911
+ const reason = missionHumanReasonText(mission, changed);
912
+ return {
913
+ schema: 'atris.result_landing.v1',
914
+ status,
915
+ changed,
916
+ reason,
917
+ checked,
918
+ tested,
919
+ proof: receiptPath ? `Receipt saved at ${receiptPath}.` : 'Receipt saved in mission run history.',
920
+ next: missionReceiptNextText(mission, result),
921
+ timeline_visible: !missionLandingChangedIsGenericTick(mission, changed),
922
+ };
923
+ }
924
+
925
+ function missionLandingLines(landing) {
926
+ if (!landing) return [];
927
+ return [
928
+ 'Landing:',
929
+ ` Changed: ${landing.changed || landing.happened || 'Landing recorded.'}`,
930
+ ` Why it matters: ${landing.reason || landing.why || 'This makes the work easier to judge.'}`,
931
+ ` How I checked: ${landing.checked || 'No check recorded.'}`,
932
+ ` What I tested: ${landing.tested || 'No test summary recorded.'}`,
933
+ ` Proof: ${landing.proof || landing.saved || 'No proof path recorded.'}`,
934
+ ` Next: ${landing.next || landing.decision || 'Pick the next useful move.'}`,
935
+ ];
936
+ }
937
+
938
+ function missionStatusLandingLines(landing) {
939
+ const lines = missionLandingLines(landing);
940
+ if (!lines.length) return [];
941
+ return [
942
+ ' last landing:',
943
+ ...lines.slice(1).map((line) => ` ${line.trim()}`),
944
+ ];
945
+ }
946
+
947
+ function missionLastLanding(mission, root = process.cwd()) {
948
+ const receipt = readMissionReceipt(mission?.receipt_path, mission?.worktree_root || root);
949
+ const landing = receipt?.result?.landing;
950
+ if (!landing || typeof landing !== 'object') return null;
951
+ return {
952
+ ...landing,
953
+ receipt_path: mission.receipt_path || null,
954
+ };
955
+ }
956
+
957
+ function missionLandingChangedIsGenericTick(mission, changed) {
958
+ const text = String(changed || '').trim();
959
+ const objective = String(mission?.objective || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
960
+ if (!objective) return false;
961
+ return new RegExp(`^${objective} recorded tick \\d+\\.$`).test(text);
962
+ }
963
+
964
+ function normalizeMissionReceiptResult(mission, result, receiptPath = '') {
965
+ const object = result && typeof result === 'object' && !Array.isArray(result)
966
+ ? { ...result }
967
+ : { value: result };
968
+ if (!object.landing) {
969
+ object.landing = missionReceiptLanding(mission, object, receiptPath);
970
+ }
971
+ if (object.verifier_result && !('passed' in object)) {
972
+ object.passed = !!object.verifier_result.passed;
973
+ }
974
+ return object;
975
+ }
976
+
977
+ function missionRunStartNextLine(mission, nextCommand, warnings = []) {
978
+ if (warnings.length) return 'Add a verifier before completion, then run the first proof tick.';
979
+ if (isCodexGoalMission(mission) && !codexNativeGoalAck(mission)) {
980
+ return 'Start the visible goal, then continue this mission.';
981
+ }
982
+ if (/attach-task/.test(nextCommand || '')) return 'Attach task context, then continue this mission.';
983
+ return 'Run the first proof tick.';
984
+ }
985
+
986
+ function missionRunTakeoffLines(mission, { warnings = [], nextCommand = '' } = {}) {
987
+ const checked = mission.verifier
988
+ ? `Verifier configured: ${mission.verifier}.`
989
+ : 'No verifier was recorded for this mission.';
990
+ return [
991
+ 'Takeoff:',
992
+ ` Goal: ${mission.objective}`,
993
+ ` Done when: ${mission.stop_condition || 'the mission has proof or a human decision'}.`,
994
+ ...(missionBudgetLine(mission) ? [` Budget: ${missionBudgetLine(mission)}`] : []),
995
+ ...missionGoalChainLines(mission),
996
+ ' Proof: Mission state saved in .atris/state/missions.jsonl.',
997
+ ` Check: ${checked}`,
998
+ ` Next: ${missionRunStartNextLine(mission, nextCommand, warnings)}`,
999
+ ];
1000
+ }
1001
+
1002
+ function missionRunPreflightSignals(text) {
1003
+ const lower = String(text || '').toLowerCase().replace(/\s+/g, ' ');
1004
+ return /\b(messy|shower|overnight|nonstop|forever|goal\s+after\s+goal|self[-\s]?improve|figure\s+out|think\s+through|thinkwell|what\s+to\s+do\s+next|keep\s+going|tell\s+me|right\s+mission\s+input|finish[-\s]+line)\b/i.test(lower);
1005
+ }
1006
+
1007
+ function shouldMissionRunRoomPreflight(objective, args = []) {
1008
+ if (hasFlag(args, '--no-room-preflight')) return false;
1009
+ if (hasFlag(args, '--room-preflight')) return true;
1010
+ return missionRunPreflightSignals(objective) || missionRunTrustedRoomSignals(objective);
1011
+ }
1012
+
1013
+ function missionRunTrustedRoomSignals(text) {
1014
+ const lower = String(text || '').toLowerCase().replace(/\s+/g, ' ');
1015
+ return /\b(one[-\s]?message|autonomy|autonomous|self[-\s]?improve|improve\s+(atris|this|it)|keep\s+going|work\s+on\s+this|next\s+useful|goes?\s+off|no\s+junk|junk\s+state)\b/i.test(lower);
1016
+ }
1017
+
1018
+ function shouldMissionRunTrustedRoom(rawObjective, args = []) {
1019
+ if (hasFlag(args, '--no-room-auto-run')) return false;
1020
+ if (hasFlag(args, '--room-auto-run')) return true;
1021
+ return missionRunTrustedRoomSignals(rawObjective);
1022
+ }
1023
+
1024
+ function missionRunConcreteTitle(value) {
1025
+ const title = String(value || '').replace(/\s+/g, ' ').trim();
1026
+ if (!title) return '';
1027
+ if (/^(go|go go|go go go|keep going|do it|start|run|continue)$/i.test(title)) return '';
1028
+ if (title.length < 8) return '';
1029
+ return title;
1030
+ }
1031
+
1032
+ function selectMissionRunUsefulTarget(rawObjective, root = process.cwd()) {
1033
+ try {
1034
+ const moves = require('../lib/next-moves');
1035
+ const rawTitle = missionRunConcreteTitle(rawObjective);
1036
+ return moves.nextMoves(root, 8)
1037
+ .filter((move) => missionRunConcreteTitle(move?.title))
1038
+ .filter((move) => !rawTitle || String(move.title).trim() !== rawTitle)
1039
+ .find((move) => move.source === 'task')
1040
+ || moves.nextMoves(root, 8).find((move) => missionRunConcreteTitle(move?.title))
1041
+ || null;
1042
+ } catch {
1043
+ return null;
1044
+ }
1045
+ }
1046
+
1047
+ function missionRunPreflightSentence(text, max = 140) {
1048
+ const clean = String(text || '').replace(/\s+/g, ' ').trim();
1049
+ if (clean.length <= max) return clean;
1050
+ return `${clean.slice(0, max - 3).replace(/\s+\S*$/, '').trimEnd()}...`;
1051
+ }
1052
+
1053
+ function missionRunPreflightObjective(rawObjective, room, owner) {
1054
+ const name = room?.name || 'Mission Room';
1055
+ const task = room?.task_plan_preview?.task || room?.truth_snapshot || rawObjective;
1056
+ return `${name} with ${owner}: turn "${missionRunPreflightSentence(task)}" into one visible goal, task spine, proof receipt, and next action.`;
1057
+ }
1058
+
1059
+ function missionRunTrustedObjective(rawObjective, room, target) {
1060
+ const targetTitle = missionRunConcreteTitle(target?.title);
1061
+ if (targetTitle) return targetTitle;
1062
+ if (missionRunTrustedRoomSignals(rawObjective)) {
1063
+ return 'Improve Atris one-message autonomy without creating junk mission state';
1064
+ }
1065
+ return missionRunPreflightObjective(rawObjective, room, room?.owner || 'mission-lead');
1066
+ }
1067
+
1068
+ function buildMissionRunRoomPreflight(rawObjective, args = [], options = {}) {
1069
+ if (!shouldMissionRunRoomPreflight(rawObjective, args)) return null;
1070
+ const root = options.root || process.cwd();
1071
+ const owner = options.owner || readFlag(args, '--owner', process.env.ATRIS_AGENT_ID || 'mission-lead');
1072
+ const trustedRun = options.allowTrustedRun !== false && shouldMissionRunTrustedRoom(rawObjective, args);
1073
+ const selectedTarget = trustedRun ? selectMissionRunUsefulTarget(rawObjective, root) : null;
1074
+ const ownerResolution = resolveFunctionalOwner({
1075
+ requestedOwner: owner,
1076
+ title: selectedTarget?.title || rawObjective,
1077
+ tag: readFlag(args, '--lane', 'workspace'),
1078
+ goal: selectedTarget?.title || rawObjective,
1079
+ root,
1080
+ fallbackOwners: ['mission-lead', 'task-planner', 'architect', 'validator'],
1081
+ });
1082
+ const room = buildMissionRoom(rawObjective, {
1083
+ root,
1084
+ owner: ownerResolution.owner,
1085
+ ownerResolution,
1086
+ trustedRun,
1087
+ selectedTarget,
1088
+ verifier: trustedRun ? DEFAULT_LONG_RUN_VERIFIER : '',
1089
+ });
1090
+ const written = writeMissionRoomReceipt(room, { root });
1091
+ const shapedObjective = trustedRun
1092
+ ? missionRunTrustedObjective(rawObjective, written.room, selectedTarget)
1093
+ : missionRunPreflightObjective(rawObjective, written.room, ownerResolution.owner);
1094
+ return {
1095
+ schema: 'atris.mission_run_preflight.v1',
1096
+ source: 'mission_room',
1097
+ raw_objective: rawObjective,
1098
+ shaped_objective: shapedObjective,
1099
+ visible_goal_objective: shapedObjective,
1100
+ room_name: written.room.name,
1101
+ room_receipt_path: written.relativePath,
1102
+ owner: ownerResolution.owner,
1103
+ owner_resolution: ownerResolution.reason,
1104
+ trusted_run: trustedRun,
1105
+ selected_target: selectedTarget ? {
1106
+ title: selectedTarget.title,
1107
+ source: selectedTarget.source,
1108
+ task_id: selectedTarget.task_id || null,
1109
+ ref: selectedTarget.ref || null,
1110
+ why: selectedTarget.why || '',
1111
+ } : null,
1112
+ task_spine_required: !selectedTarget,
1113
+ next_action: selectedTarget
1114
+ ? 'run one proof tick for the selected existing task'
1115
+ : 'attach task spine, then run one proof tick',
1116
+ };
1117
+ }
1118
+
1119
+ function missionRunTaskLabel(task) {
1120
+ const ref = task?.ref || task?.display_id || task?.id || 'task';
1121
+ return [ref, task?.title].filter(Boolean).join(' ');
1122
+ }
1123
+
1124
+ function missionRunSelectedTaskTarget(preflight) {
1125
+ const target = preflight?.selected_target;
1126
+ if (!target || target.source !== 'task') return null;
1127
+ const taskId = String(target.task_id || '').trim();
1128
+ const ref = String(target.ref || taskId || '').trim();
1129
+ if (!taskId && !ref) return null;
1130
+ return {
1131
+ task_id: taskId || ref,
1132
+ task_ref: ref || taskId,
1133
+ title: target.title || '',
1134
+ };
1135
+ }
1136
+
1137
+ function attachSelectedTargetTaskSpine(mission) {
1138
+ const selected = missionRunSelectedTaskTarget(mission?.mission_run_preflight);
1139
+ if (!selected) return mission;
1140
+ const taskIds = Array.from(new Set([...(mission.task_ids || []), selected.task_id].filter(Boolean)));
1141
+ return {
1142
+ ...mission,
1143
+ task_ids: taskIds,
1144
+ current_task_id: selected.task_id,
1145
+ task_ref: selected.task_ref,
1146
+ task_scope_ref: selected.task_ref || selected.task_id,
1147
+ selected_target_task: selected,
1148
+ next_action: `work selected task then run: atris task current-step --goal-id ${selected.task_ref || selected.task_id} --as ${mission.owner} --proof "<proof>" --json`,
1149
+ };
1150
+ }
1151
+
1152
+ function missionRunCreatedNextChangedText(createdNext) {
1153
+ const createdTask = createdNext?.ok ? createdNext.task : null;
1154
+ if (createdTask) {
1155
+ return `Created and claimed next task: ${missionRunTaskLabel(createdTask)}.`;
1156
+ }
1157
+ const activeTask = createdNext?.reason === 'active_task' ? createdNext.move : null;
1158
+ if (activeTask) {
1159
+ return `Kept active task: ${missionRunTaskLabel(activeTask)}. No duplicate was created.`;
1160
+ }
1161
+ return null;
1162
+ }
1163
+
1164
+ function missionRunCreatedNextLine(createdNext, continuationGoal, mission) {
1165
+ const createdTask = createdNext?.ok ? createdNext.task : null;
1166
+ if (createdTask) {
1167
+ return `Created next task: ${missionRunTaskLabel(createdTask)}.`;
1168
+ }
1169
+ const activeTask = createdNext?.reason === 'active_task' ? createdNext.move : null;
1170
+ if (activeTask) {
1171
+ return `Continue active task: ${missionRunTaskLabel(activeTask)}.`;
1172
+ }
1173
+ return continuationGoal?.mission
1174
+ ? `Next mission: ${continuationGoal.mission.objective}.`
1175
+ : missionHumanNextAction(mission, process.cwd(), { allowSelfImprovementSeed: true });
1176
+ }
1177
+
1178
+ function missionRunTimelineCommand(mission) {
1179
+ return `atris mission timeline ${mission.id} --limit 5`;
1180
+ }
1181
+
1182
+ function missionRunExportCommand(mission) {
1183
+ return `atris mission timeline ${mission.id} --all --write`;
1184
+ }
1185
+
1186
+ function missionRunPrunePreviewCommand(mission) {
1187
+ return `atris mission timeline ${mission.id} --prune-preview`;
1188
+ }
1189
+
1190
+ function missionRunChangedText(mission, ranTicks, effectiveMaxTicks, ticks = [], createdNext = null) {
1191
+ const stepChanged = missionLastStepSummary(ticks);
1192
+ const createdNextChanged = missionRunCreatedNextChangedText(createdNext);
1193
+ return createdNextChanged || stepChanged || missionFallbackChangedText(mission, mission.status, null, { ranTicks, effectiveMaxTicks });
1194
+ }
1195
+
1196
+ function missionBudgetLine(mission) {
1197
+ const contract = mission?.budget_contract;
1198
+ if (!contract) return null;
1199
+ return `${contract.plain_language} Limit: ${contract.budget_label}.`;
1200
+ }
1201
+
1202
+ function missionSpendsFullBudget(mission) {
1203
+ return mission?.budget_contract?.policy === 'spend_full_budget';
1204
+ }
1205
+
1206
+ function missionFullBudgetRemainingSeconds(mission, nowMs = Date.now()) {
1207
+ if (!missionSpendsFullBudget(mission)) return 0;
1208
+ const budgetSeconds = Number(mission?.budget_contract?.requested_seconds || mission?.max_wall_seconds || 0);
1209
+ if (!Number.isFinite(budgetSeconds) || budgetSeconds <= 0) return 0;
1210
+ const startedMs = Date.parse(mission.started_at || mission.created_at || mission.updated_at || '');
1211
+ if (!Number.isFinite(startedMs)) return 0;
1212
+ return Math.max(0, Math.ceil((startedMs + budgetSeconds * 1000 - nowMs) / 1000));
1213
+ }
1214
+
1215
+ function missionGoalChainIntent(text) {
1216
+ const lower = String(text || '').toLowerCase().replace(/\s+/g, ' ');
1217
+ return /\b(3\s*(?:or|-)?\s*4|three\s+or\s+four|multiple|child|subgoals?|goal\s+after\s+goal|keeps?\s+goaling|mission\s+feeling\s+good|feels?\s+good|validated\s+and\s+i\s+can\s+understand)\b/.test(lower)
1218
+ || /\bset\s+a\s+goal\b.{0,160}\b(accomplish|complete|finish|prove)\w*\b.{0,200}\bnext\s+(?:useful\s+)?goal\b/.test(lower);
1219
+ }
1220
+
1221
+ function missionGoalChainTargetCount(text) {
1222
+ const lower = String(text || '').toLowerCase();
1223
+ if (/\b3\s*(?:or|-)?\s*4\b|\bthree\s+or\s+four\b/.test(lower)) return 4;
1224
+ const explicit = lower.match(/\b([3-4])\s+(?:goals?|child\s+goals?|subgoals?)\b/);
1225
+ if (explicit) return Number(explicit[1]);
1226
+ return 4;
1227
+ }
1228
+
1229
+ function buildMissionGoalChain(objective, options = {}) {
1230
+ const targetCount = Math.max(3, Math.min(4, Number(options.targetCount || missionGoalChainTargetCount(objective)) || 4));
1231
+ const baseGoals = [
1232
+ {
1233
+ title: 'Find the novel mission',
1234
+ done_when: 'A concrete mission is named with why it matters now.',
1235
+ validation: 'Plain-English reason plus the source signal that made it worth doing.',
1236
+ },
1237
+ {
1238
+ title: 'Split it into child goals',
1239
+ done_when: 'The mission has a 3-4 step path with a clear first move.',
1240
+ validation: 'Each child goal says what proof would make it done.',
1241
+ },
1242
+ {
1243
+ title: 'Run the smallest proof',
1244
+ done_when: 'One real artifact, code change, status output, or receipt exists.',
1245
+ validation: 'A verifier, receipt, or inspectable output proves the work happened.',
1246
+ },
1247
+ {
1248
+ title: 'Explain the pause or next goal',
1249
+ done_when: 'The result says what changed, how it was checked, and whether to accept, revise, or continue.',
1250
+ validation: 'The operator can understand the mission state without reading logs.',
1251
+ },
1252
+ ].slice(0, targetCount).map((goal, index) => ({
1253
+ order: index + 1,
1254
+ status: 'pending',
1255
+ ...goal,
1256
+ }));
1257
+
1258
+ return {
1259
+ schema: 'atris.mission_goal_chain.v1',
1260
+ mode: 'validated_child_goals',
1261
+ status: 'planned',
1262
+ target_count: targetCount,
1263
+ done_count: 0,
1264
+ current_goal_order: 1,
1265
+ plain_language: 'One mission can be reached by several small goals, each with proof.',
1266
+ pause_rule: 'Pause when the chain has proof strong enough to understand, accept, revise, or choose the next mission.',
1267
+ validation_rule: 'Every child goal must leave a receipt, verifier result, artifact, or explicit stop reason.',
1268
+ goals: baseGoals,
1269
+ };
1270
+ }
1271
+
1272
+ function missionGoalChainCounts(goalChain) {
1273
+ const goals = Array.isArray(goalChain?.goals) ? goalChain.goals : [];
1274
+ const done = goals.filter((goal) => goal?.status === 'done').length;
1275
+ return { done, total: goals.length };
1276
+ }
1277
+
1278
+ function missionGoalChainPendingGoal(goalChain) {
1279
+ const goals = Array.isArray(goalChain?.goals) ? goalChain.goals : [];
1280
+ return goals.find((goal) => goal?.status !== 'done' && goal?.status !== 'blocked') || null;
1281
+ }
1282
+
1283
+ function missionGoalChainNextAction(goalChain) {
1284
+ const goal = missionGoalChainPendingGoal(goalChain);
1285
+ if (!goal) return 'continue child-goal chain';
1286
+ return `continue child goal ${goal.order}: ${goal.title}`;
1287
+ }
1288
+
1289
+ function advanceMissionGoalChain(goalChain, summary, verifierResult = null) {
1290
+ if (!goalChain || !Array.isArray(goalChain.goals) || !goalChain.goals.length) return goalChain || null;
1291
+ const cleanSummary = String(summary || '').replace(/\s+/g, ' ').trim();
1292
+ if (!cleanSummary && !verifierResult) return goalChain;
1293
+
1294
+ const goals = goalChain.goals.map((goal) => ({ ...goal }));
1295
+ const nextIndex = goals.findIndex((goal) => goal.status !== 'done');
1296
+ if (nextIndex === -1) return goalChain;
1297
+
1298
+ const validationResult = verifierResult
1299
+ ? (verifierResult.passed ? 'Verifier passed.' : 'Verifier failed; this goal needs repair.')
1300
+ : 'Summary receipt recorded.';
1301
+ goals[nextIndex] = {
1302
+ ...goals[nextIndex],
1303
+ status: verifierResult && !verifierResult.passed ? 'blocked' : 'done',
1304
+ completed_at: stampIso(),
1305
+ result: cleanSummary.slice(0, 240) || validationResult,
1306
+ validation_result: validationResult,
1307
+ };
1308
+
1309
+ const counts = missionGoalChainCounts({ goals });
1310
+ const blocked = goals.some((goal) => goal.status === 'blocked');
1311
+ const nextPending = goals.find((goal) => goal.status !== 'done' && goal.status !== 'blocked');
1312
+ return {
1313
+ ...goalChain,
1314
+ goals,
1315
+ done_count: counts.done,
1316
+ current_goal_order: nextPending ? nextPending.order : null,
1317
+ status: blocked ? 'blocked' : counts.done >= counts.total ? 'validated' : 'running',
1318
+ pause_ready: !blocked && counts.done >= counts.total,
1319
+ };
1320
+ }
1321
+
1322
+ function missionGoalChainLines(mission) {
1323
+ const chain = mission?.goal_chain;
1324
+ if (!chain || !Array.isArray(chain.goals) || !chain.goals.length) return [];
1325
+ const counts = missionGoalChainCounts(chain);
1326
+ const lines = [
1327
+ ` goal chain: ${counts.done}/${counts.total} done; ${chain.pause_rule}`,
1328
+ ];
1329
+ for (const goal of chain.goals) {
1330
+ const mark = goal.status === 'done' ? '[x]' : goal.status === 'blocked' ? '[!]' : '[ ]';
1331
+ lines.push(` ${mark} ${goal.order}. ${goal.title} -> ${goal.validation}`);
1332
+ }
1333
+ return lines;
1334
+ }
1335
+
1336
+ function missionRunSummaryLines(mission, ranTicks, effectiveMaxTicks, finalReceipt, pauseReason = null, continuationGoal = null, ticks = [], createdNext = null) {
1337
+ const changed = missionRunChangedText(mission, ranTicks, effectiveMaxTicks, ticks, createdNext);
1338
+ const reason = missionHumanReasonText(mission, changed);
1339
+ const verifier = mission.verifier_result;
1340
+ const checked = verifier
1341
+ ? missionVerifierCheckedText(verifier, mission)
1342
+ : pauseReason
1343
+ ? `Run paused: ${pauseReason}.`
1344
+ : 'Run recorded; no verifier was run.';
1345
+ const tested = verifier
1346
+ ? missionVerifierHighLevelTestText(verifier, mission)
1347
+ : mission.verifier
1348
+ ? `Verifier was configured but not completed: ${mission.verifier}.`
1349
+ : 'No verifier command was recorded for this mission.';
1350
+ const nextLine = missionRunCreatedNextLine(createdNext, continuationGoal, mission);
1351
+ const lines = [
1352
+ 'Landing:',
1353
+ ` Changed: ${changed}`,
1354
+ ` Why it matters: ${reason}`,
1355
+ ...(missionBudgetLine(mission) ? [` Budget: ${missionBudgetLine(mission)}`] : []),
1356
+ ` How I checked: ${checked}`,
1357
+ ` What I tested: ${tested}`,
1358
+ ` Proof: Summary receipt saved at ${finalReceipt}.`,
1359
+ ` Timeline: ${missionRunTimelineCommand(mission)}`,
1360
+ ` Export: ${missionRunExportCommand(mission)}`,
1361
+ ` Prune preview: ${missionRunPrunePreviewCommand(mission)}`,
1362
+ ` Next: ${nextLine}`,
1363
+ ];
661
1364
  return lines;
662
1365
  }
663
1366
 
@@ -763,6 +1466,7 @@ function missionTaskSpine(mission) {
763
1466
  const taskRef = mission.xp_task?.ref
764
1467
  || mission.task_ref
765
1468
  || (taskId ? String(taskId) : null);
1469
+ const taskScopeRef = mission.task_scope_ref || mission.id;
766
1470
  const owner = ownerResolution.owner;
767
1471
  return {
768
1472
  schema: 'atris.mission_task_spine.v1',
@@ -778,7 +1482,7 @@ function missionTaskSpine(mission) {
778
1482
  task_ref: taskRef,
779
1483
  has_task: Boolean(taskId || taskRef),
780
1484
  current_step_command: taskId || taskRef
781
- ? `atris task current-step --goal-id ${mission.id} --as ${owner} --proof "<proof>" --json`
1485
+ ? `atris task current-step --goal-id ${taskScopeRef} --as ${owner} --proof "<proof>" --json`
782
1486
  : null,
783
1487
  ensure_task_command: taskId || taskRef
784
1488
  ? null
@@ -804,6 +1508,7 @@ function missionStatusView(mission) {
804
1508
  current_task_id: taskSpine.current_task_id,
805
1509
  task_ref: taskSpine.task_ref,
806
1510
  task_spine: taskSpine,
1511
+ last_landing: missionLastLanding(mission),
807
1512
  };
808
1513
  }
809
1514
 
@@ -816,13 +1521,22 @@ function missionFromArgs(args) {
816
1521
  '--lane',
817
1522
  '--verify',
818
1523
  '--stop',
1524
+ '--max-wall',
1525
+ '--minutes',
1526
+ '--hours',
1527
+ '--base',
819
1528
  '--task',
820
1529
  '--ask',
821
1530
  '--model',
822
- ], ['--json', '--always-on', '--xp-task', '--agent-xp', '--worktree']).join(' ').trim();
1531
+ '--native-goal-status',
1532
+ '--native-goal-objective',
1533
+ '--visible-goal-status',
1534
+ '--visible-goal-objective',
1535
+ ], ['--json', '--always-on', '--xp-task', '--agent-xp', '--worktree', '--spend-full-budget', '--use-whole-budget', '--stop-when-done', '--allow-native-goal-supersede', '--supersede-paused-native-goal']).join(' ').trim();
823
1536
  if (!objective) {
824
1537
  exitMissionError('Usage: atris mission start "<objective>" --owner <member> [--verify "..."] [--cadence manual] [--worktree]', 1, wantsJson(args));
825
1538
  }
1539
+ const budgetContract = inferRunObjectiveBudgetContract(objective, args);
826
1540
  const requestedOwner = readFlag(args, '--owner', process.env.ATRIS_AGENT_ID || 'mission-lead');
827
1541
  const cadence = readFlag(args, '--cadence', readFlag(args, '--loop', 'manual')) || 'manual';
828
1542
  const runner = readFlag(args, '--runner', 'manual');
@@ -839,7 +1553,7 @@ function missionFromArgs(args) {
839
1553
  const owner = ownerResolution.owner;
840
1554
  const verifier = readFlag(args, '--verify', '');
841
1555
  assertMissionVerifier(verifier, wantsJson(args));
842
- const stopCondition = readFlag(args, '--stop', verifier ? 'verifier passes and no human asks remain' : 'human marks complete with proof');
1556
+ const stopCondition = readFlag(args, '--stop', budgetStopCondition(budgetContract) || (verifier ? 'verifier passes and no human asks remain' : 'human marks complete with proof'));
843
1557
  const taskIds = readRepeatedFlag(args, '--task');
844
1558
  const humanAsks = readRepeatedFlag(args, '--ask');
845
1559
  const alwaysOn = hasFlag(args, '--always-on');
@@ -874,12 +1588,16 @@ function missionFromArgs(args) {
874
1588
  created_at: stampIso(),
875
1589
  updated_at: stampIso(),
876
1590
  };
1591
+ if (budgetContract) {
1592
+ mission.budget_contract = budgetContract;
1593
+ if (budgetContract.requested_seconds) mission.max_wall_seconds = budgetContract.requested_seconds;
1594
+ }
877
1595
  if (alwaysOn) mission.next_action = nextCandidateTickAction(mission);
878
1596
  return mission;
879
1597
  }
880
1598
 
881
1599
  function missingVerifierWarning(mission) {
882
- if (String(mission.verifier || '').trim()) return null;
1600
+ if (effectiveMissionVerifier(mission)) return null;
883
1601
  return {
884
1602
  code: 'missing_verifier',
885
1603
  message: 'Mission has no verifier; it cannot complete automatically and future runs will report unverified worktree side effects.',
@@ -919,56 +1637,843 @@ function inferRunObjectiveVerifier(objective, root = process.cwd()) {
919
1637
  return missionRunSmokeVerifier();
920
1638
  }
921
1639
 
922
- function markMissionRunContinuation(mission) {
1640
+ function durationSecondsFromText(text) {
1641
+ const match = String(text || '').match(/\b(\d+(?:\.\d+)?)[-\s]*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)\b/i);
1642
+ if (!match) return null;
1643
+ const value = Number(match[1]);
1644
+ if (!Number.isFinite(value) || value <= 0) return null;
1645
+ const unit = match[2].toLowerCase();
1646
+ if (/^d/.test(unit)) return Math.round(value * 86400);
1647
+ if (/^h/.test(unit)) return Math.round(value * 3600);
1648
+ if (/^m(?!s)/.test(unit)) return Math.round(value * 60);
1649
+ return Math.round(value);
1650
+ }
1651
+
1652
+ function durationLabel(seconds, fallback = 'the requested time') {
1653
+ const value = Number(seconds);
1654
+ if (!Number.isFinite(value) || value <= 0) return fallback;
1655
+ if (value % 86400 === 0) {
1656
+ const days = value / 86400;
1657
+ return `${days} day${days === 1 ? '' : 's'}`;
1658
+ }
1659
+ if (value % 3600 === 0) {
1660
+ const hours = value / 3600;
1661
+ return `${hours} hour${hours === 1 ? '' : 's'}`;
1662
+ }
1663
+ if (value % 60 === 0) {
1664
+ const minutes = value / 60;
1665
+ return `${minutes} minute${minutes === 1 ? '' : 's'}`;
1666
+ }
1667
+ return `${value} second${value === 1 ? '' : 's'}`;
1668
+ }
1669
+
1670
+ function wantsFullBudget(text, args = []) {
1671
+ if (hasFlag(args, '--spend-full-budget') || hasFlag(args, '--use-whole-budget')) return true;
1672
+ if (hasFlag(args, '--hours') || hasFlag(args, '--minutes')) return true;
1673
+ const compact = String(text || '').toLowerCase().replace(/\s+/g, ' ');
1674
+ const duration = '\\d+(?:\\.\\d+)?[-\\s]*(?:s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)';
1675
+ return /\b(use|spend)\s+(the\s+)?(whole|full|entire)\s+(time|budget|window)\b/.test(compact)
1676
+ || /\b(use|spend)\s+(the\s+)?(whole|full|entire)\s+\d/.test(compact)
1677
+ || new RegExp(`\\b(?:spend|use)\\s+${duration}\\b`).test(compact)
1678
+ || new RegExp(`(?:^|\\b)(?:run|work|think|research|investigate|brainstorm|plan|map|audit|explore|study|analyze|build)\\s+(?:[^.!?;]{0,80}\\s+)?for\\s+${duration}\\b`).test(compact)
1679
+ || new RegExp(`^\\s*for\\s+${duration}\\b`).test(compact)
1680
+ || /\bfor\s+the\s+(whole|full|entire)\s+/.test(compact)
1681
+ || /\bkeep\s+going\s+until\s+(time|the\s+time|budget|the\s+budget)\s+(is\s+)?(up|done|spent|used)\b/.test(compact)
1682
+ || /\b(run|work|stop|continue)\s+(until|till)\s+(the\s+)?(time|budget)\s+(is\s+)?(up|done|spent|used)\b/.test(compact);
1683
+ }
1684
+
1685
+ function sleepLengthBudgetIntent(requestedSeconds, text = '') {
1686
+ const seconds = Number(requestedSeconds);
1687
+ return /\b(overnight|while\s+i\s+(?:sleep|am\s+sleeping|['’]?m\s+sleeping)|sleep(?:ing)?\s+run)\b/i.test(text)
1688
+ || (Number.isFinite(seconds) && seconds >= SLEEP_LENGTH_BUDGET_SECONDS);
1689
+ }
1690
+
1691
+ function inferRunObjectiveBudgetContract(objective, args = []) {
1692
+ const text = `${objective || ''} ${Array.isArray(args) ? args.join(' ') : ''}`;
1693
+ const explicitHours = Number(readFlag(args, '--hours', ''));
1694
+ const explicitMinutes = Number(readFlag(args, '--minutes', ''));
1695
+ const explicitMaxWall = Number(readFlag(args, '--max-wall', ''));
1696
+ const explicitSeconds = Number.isFinite(explicitHours) && explicitHours > 0
1697
+ ? Math.round(explicitHours * 3600)
1698
+ : (Number.isFinite(explicitMinutes) && explicitMinutes > 0
1699
+ ? Math.round(explicitMinutes * 60)
1700
+ : (Number.isFinite(explicitMaxWall) && explicitMaxWall > 0 ? Math.round(explicitMaxWall) : null));
1701
+ const requestedSeconds = explicitSeconds || durationSecondsFromText(text);
1702
+ const overnight = /\bovernight\b/i.test(text);
1703
+ if (!requestedSeconds && !overnight) return null;
1704
+ const spendBudget = !hasFlag(args, '--stop-when-done')
1705
+ && (wantsFullBudget(text, args) || sleepLengthBudgetIntent(requestedSeconds, text));
1706
+ const policy = spendBudget
1707
+ ? 'spend_full_budget'
1708
+ : 'stop_when_done';
1709
+ const budgetLabel = requestedSeconds
1710
+ ? durationLabel(requestedSeconds)
1711
+ : 'the overnight window';
1712
+ const plainLanguage = policy === 'spend_full_budget'
1713
+ ? 'Use the whole time.'
1714
+ : 'Finish early if solved.';
1715
+ const stopRule = policy === 'spend_full_budget'
1716
+ ? `Use the whole ${budgetLabel}; keep picking the next useful move until time is up, unless blocked or unsafe.`
1717
+ : `Use up to ${budgetLabel}; stop early when the mission is done, proven, or blocked.`;
923
1718
  return {
924
- ...mission,
925
- started_from: 'mission_run_objective',
926
- continue_on_complete: true,
927
- continuation_policy: 'decide_and_start_next_useful_mission',
1719
+ schema: 'atris.mission_budget_contract.v1',
1720
+ requested_seconds: requestedSeconds,
1721
+ budget_label: budgetLabel,
1722
+ policy,
1723
+ plain_language: plainLanguage,
1724
+ stop_rule: stopRule,
928
1725
  };
929
1726
  }
930
1727
 
931
- function continuationObjective(parent) {
932
- return `Decide and start the next useful mission after: ${parent.objective}`;
1728
+ function budgetStopCondition(contract) {
1729
+ if (!contract) return '';
1730
+ if (contract.policy === 'spend_full_budget') {
1731
+ return `run for ${contract.budget_label}; use the whole time unless blocked or unsafe`;
1732
+ }
1733
+ return `run for ${contract.budget_label}, or stop early when proof is strong enough`;
933
1734
  }
934
1735
 
935
- function findActiveContinuationMission(parent, root = process.cwd()) {
936
- return listMissions(root).find((mission) => (
937
- mission.parent_mission_id === parent.id
938
- && mission.started_from === 'mission_run_continuation'
939
- && !TERMINAL_STATUSES.has(mission.status)
940
- )) || null;
1736
+ function inferRunObjectiveLoopOptions(objective, args = []) {
1737
+ const text = `${objective || ''} ${Array.isArray(args) ? args.join(' ') : ''}`;
1738
+ const hoursMatch = text.match(/\b(\d+(?:\.\d+)?)\s*(h|hr|hrs|hour|hours)\b/i);
1739
+ const requestedHours = hoursMatch ? Number(hoursMatch[1]) : null;
1740
+ const wantsLongRun = /\b(overnight|nonstop|forever|goal\s+after\s+goal|self[-\s]?improve)\b/i.test(text)
1741
+ || (Number.isFinite(requestedHours) && requestedHours > 0);
1742
+ if (!wantsLongRun) return { wantsLongRun: false, requestedHours: null, cadence: '' };
1743
+ return {
1744
+ wantsLongRun: true,
1745
+ requestedHours: Number.isFinite(requestedHours) && requestedHours > 0 ? requestedHours : null,
1746
+ cadence: readFlag(args, '--cadence', '13m'),
1747
+ };
941
1748
  }
942
1749
 
943
- function findActiveMissionRunContinuation(root = process.cwd(), excludeId = '') {
944
- const excluded = String(excludeId || '');
945
- const candidates = listMissions(root)
946
- .filter((mission) => (
947
- mission.id !== excluded
948
- && mission.started_from === 'mission_run_continuation'
949
- && mission.continuation_policy === 'choose_next_mission'
950
- && !TERMINAL_STATUSES.has(mission.status)
951
- ));
952
- candidates.sort((a, b) => missionSortTime(b) - missionSortTime(a));
953
- return candidates[0] || null;
1750
+ function missionLongRunIntent(mission) {
1751
+ const text = `${mission?.objective || ''} ${mission?.stop_condition || ''}`;
1752
+ return Boolean(mission?.overnight_loop)
1753
+ || /\b(overnight|nonstop|forever|goal\s+after\s+goal|self[-\s]?improve)\b/i.test(text);
954
1754
  }
955
1755
 
956
- function completeActiveContinuationForStartedMission(nextMission, root = process.cwd()) {
957
- const continuation = findActiveMissionRunContinuation(root, nextMission?.id);
958
- if (!continuation || !nextMission) return null;
959
- if (continuation.objective === nextMission.objective) return null;
1756
+ function missionChoosesNextMission(mission) {
1757
+ return mission?.started_from === 'mission_run_continuation'
1758
+ && mission?.continuation_policy === 'choose_next_mission';
1759
+ }
960
1760
 
961
- const proof = `Started next mission ${nextMission.id}: ${nextMission.objective}`;
962
- const completionGate = { ok: true, source: 'mission_run_continuation', forced: false };
963
- const baseNext = {
964
- ...continuation,
965
- status: 'complete',
966
- completed_at: stampIso(),
967
- proof,
968
- completion_gate: completionGate,
969
- continued_by_mission_id: nextMission.id,
970
- continued_by_objective: nextMission.objective,
971
- next_action: 'mission complete',
1761
+ function isConcreteContinuationTarget(title) {
1762
+ const text = String(title || '').trim();
1763
+ if (!text) return false;
1764
+ if (/^decide and start the next useful mission after:/i.test(text)) return false;
1765
+ if (/<next useful mission>/i.test(text)) return false;
1766
+ if (/^create the next proof-backed self-improvement task$/i.test(text)) return false;
1767
+ return true;
1768
+ }
1769
+
1770
+ function continuationTargetKey(title) {
1771
+ return String(title || '')
1772
+ .replace(/^mission xp:\s*/i, '')
1773
+ .replace(/\s+/g, ' ')
1774
+ .trim()
1775
+ .toLowerCase();
1776
+ }
1777
+
1778
+ function handledContinuationTargetKeys(root, moves) {
1779
+ const keys = new Set();
1780
+ const add = (title) => {
1781
+ const key = continuationTargetKey(title);
1782
+ if (key) keys.add(key);
1783
+ };
1784
+ try {
1785
+ for (const title of moves.readHandledTaskTitles(root)) add(title);
1786
+ } catch {
1787
+ // Best-effort guard; mission state below still catches completed missions.
1788
+ }
1789
+ try {
1790
+ for (const row of listMissions(root)) {
1791
+ add(row.objective);
1792
+ }
1793
+ } catch {
1794
+ // If mission state is unreadable, fall back to the remaining explicit filters.
1795
+ }
1796
+ return keys;
1797
+ }
1798
+
1799
+ function readTaskProjectionForMission(root = process.cwd()) {
1800
+ const file = path.join(root, '.atris', 'state', 'tasks.projection.json');
1801
+ try {
1802
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
1803
+ return Array.isArray(parsed?.tasks) ? parsed.tasks : [];
1804
+ } catch {
1805
+ return [];
1806
+ }
1807
+ }
1808
+
1809
+ function taskTags(task) {
1810
+ return [
1811
+ task?.tag,
1812
+ ...(Array.isArray(task?.tags) ? task.tags : []),
1813
+ ...(Array.isArray(task?.metadata?.tags) ? task.metadata.tags : []),
1814
+ ]
1815
+ .filter(Boolean)
1816
+ .map((tag) => String(tag).trim().toLowerCase())
1817
+ .filter(Boolean);
1818
+ }
1819
+
1820
+ function certifiedReviewNextTaskCandidates(root = process.cwd()) {
1821
+ return readTaskProjectionForMission(root)
1822
+ .filter((task) => String(task?.status || '').toLowerCase() === 'review')
1823
+ .filter((task) => task?.review?.agent_certified === true || task?.metadata?.agent_certified === true)
1824
+ .map((task) => ({
1825
+ title: task?.review?.next_task || task?.metadata?.latest_agent_next_task || '',
1826
+ why: `certified review ${task.display_id || task.id || ''} suggested this next task`.trim(),
1827
+ source: 'certified_review_next_task',
1828
+ ref: task.display_id || task.id || null,
1829
+ weight: 95,
1830
+ }))
1831
+ .filter((move) => String(move.title || '').trim());
1832
+ }
1833
+
1834
+ function endgameBacklogCandidates(root = process.cwd()) {
1835
+ return readTaskProjectionForMission(root)
1836
+ .filter((task) => String(task?.status || '').toLowerCase() === 'open')
1837
+ .filter((task) => taskTags(task).includes('endgame'))
1838
+ .map((task) => ({
1839
+ title: String(task.title || '').trim(),
1840
+ why: `open endgame backlog task ${task.display_id || task.id || ''}`.trim(),
1841
+ source: 'endgame_backlog',
1842
+ ref: task.display_id || task.id || null,
1843
+ weight: 85,
1844
+ }))
1845
+ .filter((move) => move.title);
1846
+ }
1847
+
1848
+ function cleanWikiNextIngestTitle(value) {
1849
+ return String(value || '')
1850
+ .replace(/`([^`]+)`/g, '$1')
1851
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
1852
+ .replace(/^[-*]\s*/, '')
1853
+ .replace(/^next[- ]?ingests?\s*:\s*/i, '')
1854
+ .replace(/^next source\s*:\s*/i, '')
1855
+ .replace(/\s+/g, ' ')
1856
+ .trim()
1857
+ .replace(/[.!?:;,]+$/g, '');
1858
+ }
1859
+
1860
+ function wikiStatusNextIngestCandidates(root = process.cwd()) {
1861
+ const file = path.join(root, 'atris', 'wiki', 'STATUS.md');
1862
+ let text = '';
1863
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
1864
+ const out = [];
1865
+ let inSection = false;
1866
+ for (const rawLine of text.split(/\r?\n/)) {
1867
+ const line = rawLine.trim();
1868
+ if (/^#+\s+next[- ]?ingests?\b/i.test(line)) {
1869
+ inSection = true;
1870
+ continue;
1871
+ }
1872
+ if (inSection && /^#+\s+/.test(line)) inSection = false;
1873
+ const direct = line.match(/^[-*]?\s*next[- ]?ingests?\s*:\s*(.+)$/i)
1874
+ || line.match(/^[-*]?\s*next source\s*:\s*(.+)$/i);
1875
+ if (direct) {
1876
+ const title = cleanWikiNextIngestTitle(direct[1]);
1877
+ if (title) out.push(title);
1878
+ continue;
1879
+ }
1880
+ if (inSection && /^[-*]\s+/.test(line)) {
1881
+ const title = cleanWikiNextIngestTitle(line);
1882
+ if (title) out.push(title);
1883
+ }
1884
+ }
1885
+ return out.map((title) => ({
1886
+ title: /^ingest\b/i.test(title) ? title : `Ingest ${title}`,
1887
+ why: 'wiki STATUS listed this as a next ingest',
1888
+ source: 'wiki_status_next_ingest',
1889
+ ref: 'atris/wiki/STATUS.md',
1890
+ weight: 70,
1891
+ }));
1892
+ }
1893
+
1894
+ function missionExtraNextCandidates(root = process.cwd()) {
1895
+ return [
1896
+ ...certifiedReviewNextTaskCandidates(root),
1897
+ ...endgameBacklogCandidates(root),
1898
+ ...wikiStatusNextIngestCandidates(root),
1899
+ ];
1900
+ }
1901
+
1902
+ function uniqueMissionCandidates(candidates) {
1903
+ const seen = new Set();
1904
+ const out = [];
1905
+ for (const candidate of candidates) {
1906
+ const key = continuationTargetKey(candidate?.title);
1907
+ if (!key || seen.has(key)) continue;
1908
+ seen.add(key);
1909
+ out.push(candidate);
1910
+ }
1911
+ return out;
1912
+ }
1913
+
1914
+ function trustedNextMissionSource(source) {
1915
+ return new Set([
1916
+ 'certified_review_next_task',
1917
+ 'endgame_backlog',
1918
+ 'wiki_status_next_ingest',
1919
+ 'mission_report',
1920
+ ]).has(String(source || ''));
1921
+ }
1922
+
1923
+ function rejectNextMissionCandidate(move, context) {
1924
+ const title = String(move?.title || '').trim();
1925
+ if (!title) return 'empty title';
1926
+ if (context.moves.isGenericInboxPlaceholder(title)) return 'placeholder';
1927
+ if (!isConcreteContinuationTarget(title)) return 'not a concrete mission';
1928
+ const key = continuationTargetKey(title);
1929
+ if (context.handledTargets.has(key)) return 'already handled';
1930
+ if (context.currentObjective && title === context.currentObjective) return 'same as current objective';
1931
+ if (context.parentObjective && title === context.parentObjective) return 'same as parent objective';
1932
+ if (context.mission?.id && move?.ref === context.mission.id && title === context.currentObjective) return 'same as current mission';
1933
+ const preview = missionValuePreview(move, context.mission, context.root);
1934
+ const score = Number(preview?.score?.total || 0);
1935
+ move.value_preview = preview;
1936
+ if (score <= 0 && !trustedNextMissionSource(move.source)) return 'zero value score';
1937
+ return '';
1938
+ }
1939
+
1940
+ function nearMissPreview(nearMisses) {
1941
+ if (!nearMisses.length) return null;
1942
+ return {
1943
+ schema: 'atris.next_mission_stop_preview.v1',
1944
+ stop_reason: 'no concrete follow-up mission found in Atris state',
1945
+ near_misses: nearMisses.slice(0, 3),
1946
+ feynman: {
1947
+ what: 'Atris found possible next moves, but none passed the mission filter.',
1948
+ why_now: 'Stopping is safer than spending tokens on fake or low-value work.',
1949
+ risk: 'A human may want to promote one near-miss manually.',
1950
+ validation: 'Review the near-miss reasons, then add a concrete next task if one should run.',
1951
+ },
1952
+ };
1953
+ }
1954
+
1955
+ function chooseNextMissionAnalysis(mission, root = process.cwd()) {
1956
+ try {
1957
+ const moves = require('../lib/next-moves');
1958
+ const currentObjective = String(mission?.objective || '').trim();
1959
+ const parentObjective = String(mission?.parent_objective || '').trim();
1960
+ const handledTargets = handledContinuationTargetKeys(root, moves);
1961
+ const latestTarget = moves.latestSuggestedTarget(root);
1962
+ const reportCandidates = latestTarget
1963
+ ? [{
1964
+ title: latestTarget,
1965
+ why: 'latest proof timeline suggested this follow-up mission',
1966
+ source: 'mission_report',
1967
+ weight: 75,
1968
+ }]
1969
+ : [];
1970
+ const context = { mission, root, moves, currentObjective, parentObjective, handledTargets };
1971
+ const nearMisses = [];
1972
+ const candidates = uniqueMissionCandidates([
1973
+ ...missionExtraNextCandidates(root),
1974
+ ...reportCandidates,
1975
+ ...moves.nextMoves(root, 8),
1976
+ ])
1977
+ .filter((move) => {
1978
+ const reason = rejectNextMissionCandidate(move, context);
1979
+ if (reason) {
1980
+ nearMisses.push({
1981
+ title: String(move?.title || '').trim() || '(empty)',
1982
+ source: move?.source || null,
1983
+ ref: move?.ref || null,
1984
+ reason,
1985
+ });
1986
+ return false;
1987
+ }
1988
+ return true;
1989
+ })
1990
+ .sort((a, b) => {
1991
+ const aScore = Number(a.value_preview?.score?.total || 0);
1992
+ const bScore = Number(b.value_preview?.score?.total || 0);
1993
+ if (aScore !== bScore) return bScore - aScore;
1994
+ return Number(b.weight || 0) - Number(a.weight || 0);
1995
+ });
1996
+ return { target: candidates[0] || null, near_misses: nearMisses.slice(0, 3) };
1997
+ } catch {
1998
+ // Fall through to an explicit stop command; never emit the old placeholder.
1999
+ }
2000
+ return { target: null, near_misses: [] };
2001
+ }
2002
+
2003
+ function chooseNextMissionTarget(mission, root = process.cwd()) {
2004
+ return chooseNextMissionAnalysis(mission, root).target;
2005
+ }
2006
+
2007
+ function normalizeMissionOwner(value) {
2008
+ return String(value || '').trim().toLowerCase();
2009
+ }
2010
+
2011
+ function missionMemberTasteProfile(owner) {
2012
+ const key = normalizeMissionOwner(owner);
2013
+ if (/\b(security|sync-inspector|proof-inspector|validator)\b/.test(key)) {
2014
+ return {
2015
+ id: 'security_guard',
2016
+ name: 'Security / trust',
2017
+ bias: 'Prefer work that prevents unsafe behavior, privacy mistakes, or quiet trust breaks.',
2018
+ };
2019
+ }
2020
+ if (/\b(researcher|architect|improver|auto-improver|problem-solver|objective-generator)\b/.test(key)) {
2021
+ return {
2022
+ id: 'technical_homerun',
2023
+ name: 'Technical homerun',
2024
+ bias: 'Prefer technical leaps, but require a usability or proof gate before spending serious tokens.',
2025
+ };
2026
+ }
2027
+ return {
2028
+ id: 'usability_operator',
2029
+ name: 'Usability / demo',
2030
+ bias: 'Prefer work that makes the product easier, faster, clearer, or more demo-ready.',
2031
+ };
2032
+ }
2033
+
2034
+ function missionValueSignals(title) {
2035
+ const text = String(title || '').toLowerCase();
2036
+ const signals = [];
2037
+ const add = (id, label, pattern) => {
2038
+ if (pattern.test(text)) signals.push({ id, label });
2039
+ };
2040
+ add('usability', 'simplifies use', /\b(simple|simplify|preview|clear|plain|feynman|demo|onboarding|ux|workflow|usable|understand)\b/);
2041
+ add('speed', 'speeds the process', /\b(speed|fast|faster|latency|token|waste|friction|shortcut|automation|auto)\b/);
2042
+ add('users_revenue', 'can help users or revenue', /\b(user|users|customer|revenue|payment|checkout|pricing|conversion|retention|demo)\b/);
2043
+ add('trust', 'protects trust', /\b(security|safe|safety|trust|permission|approval|auth|privacy|gmail|email|connector|leak|isolation)\b/);
2044
+ add('technical', 'technical advancement', /\b(agent|ax|connector|isolation|research|benchmark|model|compiler|runtime|architecture|rl|experiment)\b/);
2045
+ add('freshness', 'keeps workflow current', /\b(up[- ]?to[- ]?date|fresh|sync|stale|current|latest)\b/);
2046
+ return signals;
2047
+ }
2048
+
2049
+ function missionTasteSnippet(value, max = 180) {
2050
+ const text = String(value || '')
2051
+ .replace(/<!--[\s\S]*?-->/g, ' ')
2052
+ .replace(/[`*_#>]+/g, '')
2053
+ .replace(/^[-\d.()[\]\s]+/g, '')
2054
+ .replace(/\s+/g, ' ')
2055
+ .trim();
2056
+ if (!text) return '';
2057
+ return text.length > max ? `${text.slice(0, max - 3).trim()}...` : text;
2058
+ }
2059
+
2060
+ function missionTasteSnippets(text, limit = 4) {
2061
+ const tastePattern = /\b(plain|jargon|feynman|understand|simple|clarify|clarity|proof|receipt|verify|verified|checked|tested|accept|approval|runway|revenue|cash|user|customer|demo|technical|homerun|security|trust|privacy|logs|working memory|recent|speed|token|waste|simplify)\b/i;
2062
+ const snippets = [];
2063
+ for (const line of String(text || '').split(/\r?\n/)) {
2064
+ const snippet = missionTasteSnippet(line);
2065
+ if (!snippet || !tastePattern.test(snippet)) continue;
2066
+ snippets.push(snippet);
2067
+ }
2068
+ return snippets.slice(-limit);
2069
+ }
2070
+
2071
+ function readTextFile(root, relativePath) {
2072
+ const file = path.join(root, ...relativePath.split('/'));
2073
+ try {
2074
+ return { path: relativePath, present: true, text: fs.readFileSync(file, 'utf8') };
2075
+ } catch {
2076
+ return { path: relativePath, present: false, text: '' };
2077
+ }
2078
+ }
2079
+
2080
+ function recentMarkdownFiles(dir, limit = 2) {
2081
+ const files = [];
2082
+ try {
2083
+ for (const entry of fs.readdirSync(dir)) {
2084
+ const full = path.join(dir, entry);
2085
+ const stat = fs.statSync(full);
2086
+ if (stat.isDirectory()) {
2087
+ for (const child of recentMarkdownFiles(full, limit * 4)) files.push(child);
2088
+ } else if (/\.md$/i.test(entry)) {
2089
+ files.push(full);
2090
+ }
2091
+ }
2092
+ } catch {
2093
+ return [];
2094
+ }
2095
+ return files
2096
+ .sort((a, b) => {
2097
+ const aBase = path.basename(a);
2098
+ const bBase = path.basename(b);
2099
+ if (aBase !== bBase) return aBase.localeCompare(bBase);
2100
+ return a.localeCompare(b);
2101
+ })
2102
+ .slice(-limit);
2103
+ }
2104
+
2105
+ function readRecentTasteLogs(root, owner, limit = 3) {
2106
+ const logFiles = [
2107
+ ...recentMarkdownFiles(path.join(root, 'atris', 'logs'), 2),
2108
+ ...recentMarkdownFiles(path.join(root, 'atris', 'team', owner || '', 'logs'), 2),
2109
+ ];
2110
+ const seen = new Set();
2111
+ return logFiles
2112
+ .filter((file) => {
2113
+ const key = path.resolve(file);
2114
+ if (seen.has(key)) return false;
2115
+ seen.add(key);
2116
+ return true;
2117
+ })
2118
+ .slice(-limit)
2119
+ .map((file) => {
2120
+ let text = '';
2121
+ try { text = fs.readFileSync(file, 'utf8'); } catch { text = ''; }
2122
+ return {
2123
+ path: path.relative(root, file),
2124
+ snippets: missionTasteSnippets(text, 3),
2125
+ };
2126
+ })
2127
+ .filter((entry) => entry.snippets.length);
2128
+ }
2129
+
2130
+ function readTasteReviewHistory(root, limit = 4) {
2131
+ const file = path.join(root, '.atris', 'state', 'tasks.projection.json');
2132
+ let projection = null;
2133
+ try { projection = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { projection = null; }
2134
+ const tasks = Array.isArray(projection?.tasks) ? projection.tasks : [];
2135
+ const accepted = [];
2136
+ const revised = [];
2137
+ const sorted = tasks
2138
+ .filter(Boolean)
2139
+ .sort((a, b) => Number(b.updated_at || 0) - Number(a.updated_at || 0));
2140
+ for (const task of sorted) {
2141
+ const title = missionTasteSnippet(task.title, 120);
2142
+ for (const event of Array.isArray(task.events) ? task.events : []) {
2143
+ const type = String(event?.event_type || '');
2144
+ const payload = event?.payload || {};
2145
+ if ((type === 'accepted' || (type === 'reviewed' && Number(payload.reward || 0) > 0)) && accepted.length < limit) {
2146
+ accepted.push(missionTasteSnippet(`${title}: ${payload.proof || payload.note || 'accepted'}`));
2147
+ }
2148
+ if (type === 'revision_requested' && revised.length < limit) {
2149
+ revised.push(missionTasteSnippet(`${title}: ${payload.note || 'revision requested'}`));
2150
+ }
2151
+ }
2152
+ }
2153
+ return { accepted, revised };
2154
+ }
2155
+
2156
+ function missionTasteMemorySignals(text) {
2157
+ const definitions = [
2158
+ ['plain_language', 'Keshav asks for plain English', /\b(plain|jargon|feynman|understand|simple|clarify|clarity)\b/i],
2159
+ ['proof_gate', 'Keshav wants proof before accept', /\b(proof|receipt|verify|verified|checked|tested|accept|approval)\b/i],
2160
+ ['runway_revenue', 'runway pushes user or revenue work', /\b(runway|revenue|cash|user|users|customer|demo|adoption|retention)\b/i],
2161
+ ['technical_ambition', 'technical advancement still matters', /\b(technical|homerun|research|runtime|architecture|model|benchmark|agent)\b/i],
2162
+ ['trust_boundary', 'trust and approval boundaries matter', /\b(security|safe|safety|trust|privacy|approval|permission|gmail|email|connector)\b/i],
2163
+ ['working_memory', 'recent logs are working memory', /\b(logs?|working memory|recent|today|now)\b/i],
2164
+ ['speed_token', 'avoid wasted time and tokens', /\b(speed|fast|faster|token|waste|friction|simplify)\b/i],
2165
+ ];
2166
+ return definitions
2167
+ .filter(([, , pattern]) => pattern.test(text))
2168
+ .map(([id, label]) => ({ id, label }));
2169
+ }
2170
+
2171
+ function readMissionTasteMemory(root = process.cwd(), owner = '') {
2172
+ const safeOwner = normalizeMissionOwner(owner || process.env.ATRIS_AGENT_ID || 'mission-lead') || 'mission-lead';
2173
+ const thinking = readTextFile(root, 'atris/thinking.md');
2174
+ const memberMission = readTextFile(root, `atris/team/${safeOwner}/MISSION.md`);
2175
+ const recentLogs = readRecentTasteLogs(root, safeOwner);
2176
+ const taskHistory = readTasteReviewHistory(root);
2177
+ const thinkingSnippets = missionTasteSnippets(thinking.text, 5);
2178
+ const memberMissionSnippets = missionTasteSnippets(memberMission.text, 5);
2179
+ const allText = [
2180
+ ...thinkingSnippets,
2181
+ ...memberMissionSnippets,
2182
+ ...recentLogs.flatMap((entry) => entry.snippets),
2183
+ ...taskHistory.accepted,
2184
+ ...taskHistory.revised,
2185
+ ].join('\n');
2186
+ const signals = missionTasteMemorySignals(allText);
2187
+ return {
2188
+ schema: 'atris.mission_taste_memory.v1',
2189
+ owner: safeOwner,
2190
+ sources: {
2191
+ thinking_md: {
2192
+ path: thinking.path,
2193
+ present: thinking.present,
2194
+ snippets: thinkingSnippets,
2195
+ },
2196
+ member_mission: {
2197
+ path: memberMission.path,
2198
+ present: memberMission.present,
2199
+ snippets: memberMissionSnippets,
2200
+ },
2201
+ recent_logs: recentLogs,
2202
+ task_history: taskHistory,
2203
+ },
2204
+ signals,
2205
+ };
2206
+ }
2207
+
2208
+ function missionTasteMemoryBoost(signals, tasteMemory, profile) {
2209
+ const candidateIds = new Set((signals || []).map((signal) => signal.id));
2210
+ const memoryIds = new Set((tasteMemory?.signals || []).map((signal) => signal.id));
2211
+ const hasCandidate = (id) => candidateIds.has(id) ? 1 : 0;
2212
+ const hasMemory = (id) => memoryIds.has(id);
2213
+ let boost = 0;
2214
+ if (hasMemory('plain_language')) boost += hasCandidate('usability') * 2;
2215
+ if (hasMemory('proof_gate')) boost += (hasCandidate('trust') || hasCandidate('usability') || hasCandidate('technical')) ? 1 : 0;
2216
+ if (hasMemory('runway_revenue')) boost += hasCandidate('users_revenue') * 3;
2217
+ if (hasMemory('technical_ambition')) boost += profile.id === 'technical_homerun' ? hasCandidate('technical') * 2 : hasCandidate('technical');
2218
+ if (hasMemory('trust_boundary')) boost += hasCandidate('trust') * 2;
2219
+ if (hasMemory('working_memory')) boost += (hasCandidate('freshness') || hasCandidate('speed')) ? 1 : 0;
2220
+ if (hasMemory('speed_token')) boost += hasCandidate('speed') * 2;
2221
+ return boost;
2222
+ }
2223
+
2224
+ function missionValueScore(signals, profile, tasteMemory = null) {
2225
+ const ids = new Set((signals || []).map((signal) => signal.id));
2226
+ const has = (id) => ids.has(id) ? 1 : 0;
2227
+ const base = has('usability') + has('speed') + has('users_revenue') + has('trust') + has('freshness');
2228
+ let profileBoost = 0;
2229
+ if (profile.id === 'technical_homerun') profileBoost = (has('technical') * 2) + has('trust') + has('speed');
2230
+ else if (profile.id === 'security_guard') profileBoost = (has('trust') * 3) + has('freshness');
2231
+ else profileBoost = (has('usability') * 2) + has('speed') + has('users_revenue');
2232
+ const memoryBoost = missionTasteMemoryBoost(signals, tasteMemory, profile);
2233
+ return {
2234
+ total: base + profileBoost + memoryBoost,
2235
+ signals: Array.from(ids),
2236
+ memory_boost: memoryBoost,
2237
+ };
2238
+ }
2239
+
2240
+ function missionPlainTaskPreview(title) {
2241
+ const text = String(title || '').replace(/\s+/g, ' ').trim();
2242
+ if (!text) return 'Do one concrete useful thing.';
2243
+ if (/ax\b/i.test(text) && /gmail/i.test(text) && /turn isolation/i.test(text)) {
2244
+ return 'Make ax safer with Gmail: keep each chat request separate, and show a clear preview or receipt before Gmail actions.';
2245
+ }
2246
+ const cleaned = text
2247
+ .replace(/^mission xp:\s*/i, '')
2248
+ .replace(/^ship\s+/i, '')
2249
+ .replace(/\bturn isolation\b/ig, 'separate chat requests')
2250
+ .replace(/\breceipt previews?\b/ig, 'clear before/after receipts')
2251
+ .replace(/\bconnector\b/ig, 'connected-app path');
2252
+ return `${cleaned.charAt(0).toUpperCase()}${cleaned.slice(1)}`.replace(/[.!?:;,]+$/g, '') + '.';
2253
+ }
2254
+
2255
+ function missionTasteMemoryReason(tasteMemory, signals) {
2256
+ const candidateIds = new Set((signals || []).map((signal) => signal.id));
2257
+ const memoryIds = new Set((tasteMemory?.signals || []).map((signal) => signal.id));
2258
+ if (memoryIds.has('runway_revenue') && candidateIds.has('users_revenue')) return 'Keshav has been steering toward user/revenue proof because runway is tight.';
2259
+ if (memoryIds.has('plain_language') && candidateIds.has('usability')) return 'Keshav keeps asking for plain-English, understandable work.';
2260
+ if (memoryIds.has('proof_gate') && (candidateIds.has('trust') || candidateIds.has('technical'))) return 'Keshav wants proof and receipts before serious work is accepted.';
2261
+ if (memoryIds.has('working_memory')) return 'Recent logs are being used as working memory for what matters now.';
2262
+ if (memoryIds.has('technical_ambition') && candidateIds.has('technical')) return 'The member memory still values technical advancement when it has proof.';
2263
+ return '';
2264
+ }
2265
+
2266
+ function missionPreviewWhyNow(move, profile, signals, tasteMemory = null) {
2267
+ const ids = new Set((signals || []).map((signal) => signal.id));
2268
+ let base = '';
2269
+ if (profile.id === 'technical_homerun') {
2270
+ base = ids.has('technical')
2271
+ ? 'This is a technical bet; run it only because it can make the product stronger without making the user think harder.'
2272
+ : 'This is not a technical homerun on its face, so it needs clear usability proof before it should win.';
2273
+ } else if (profile.id === 'security_guard') {
2274
+ base = ids.has('trust')
2275
+ ? 'This matters because trust failures are expensive and should be blocked before users feel them.'
2276
+ : 'This is not mainly security work, so it should only run if the current trust queue is quiet.';
2277
+ } else if (ids.has('users_revenue')) {
2278
+ base = 'This matters now because it can help demos, users, retention, or revenue.';
2279
+ } else if (ids.has('usability') || ids.has('speed')) {
2280
+ base = 'This matters now because it can make the product easier or faster to use.';
2281
+ } else {
2282
+ base = move?.why || 'This needs a clear reason before it should spend serious tokens.';
2283
+ }
2284
+ const memoryReason = missionTasteMemoryReason(tasteMemory, signals);
2285
+ return memoryReason ? `${base} Taste memory says: ${memoryReason}` : base;
2286
+ }
2287
+
2288
+ function missionPreviewRisk(signals) {
2289
+ const ids = new Set((signals || []).map((signal) => signal.id));
2290
+ if (ids.has('technical') && !ids.has('usability')) return 'Risk: it becomes clever infrastructure that does not make the product easier.';
2291
+ if (ids.has('trust')) return 'Risk: changing connected-tool behavior can hide or create approval/privacy mistakes.';
2292
+ return 'Risk: it adds complexity without a visible product win.';
2293
+ }
2294
+
2295
+ function missionPreviewValidation(signals) {
2296
+ const ids = new Set((signals || []).map((signal) => signal.id));
2297
+ if (ids.has('trust')) return 'Validate with a before/after receipt and a test proving unsafe state does not carry across turns.';
2298
+ if (ids.has('users_revenue')) return 'Validate with a customer-visible proof: demo path, signup/payment path, or retention signal.';
2299
+ if (ids.has('technical')) return 'Validate with a small benchmark, regression test, or artifact that proves the technical bet helped.';
2300
+ return 'Validate with a simple before/after check that a human can understand.';
2301
+ }
2302
+
2303
+ function missionValuePreview(move, mission, root = process.cwd()) {
2304
+ const profile = missionMemberTasteProfile(mission?.owner);
2305
+ const signals = missionValueSignals(move?.title);
2306
+ const tasteMemory = readMissionTasteMemory(root, mission?.owner);
2307
+ const score = missionValueScore(signals, profile, tasteMemory);
2308
+ return {
2309
+ schema: 'atris.mission_value_preview.v1',
2310
+ member: mission?.owner || null,
2311
+ profile,
2312
+ candidate: {
2313
+ title: move?.title || '',
2314
+ source: move?.source || null,
2315
+ ref: move?.ref || null,
2316
+ },
2317
+ feynman: {
2318
+ what: missionPlainTaskPreview(move?.title),
2319
+ why_now: missionPreviewWhyNow(move, profile, signals, tasteMemory),
2320
+ risk: missionPreviewRisk(signals),
2321
+ validation: missionPreviewValidation(signals),
2322
+ taste: missionTasteMemoryReason(tasteMemory, signals) || 'No live taste memory matched this candidate yet.',
2323
+ },
2324
+ value_signals: signals,
2325
+ taste_memory: tasteMemory,
2326
+ score,
2327
+ };
2328
+ }
2329
+
2330
+ function resumableActiveMissions(mission, root = process.cwd()) {
2331
+ const selfId = String(mission?.id || '');
2332
+ const parentId = String(mission?.parent_mission_id || '');
2333
+ try {
2334
+ // Only planning missions resume: ready missions wait for human review, and
2335
+ // running missions already have an actor.
2336
+ return listMissions(root).filter((row) => {
2337
+ if (!row || row.status !== 'planning') return false;
2338
+ if (row.id === selfId || row.id === parentId) return false;
2339
+ if (String(row.started_from || '') === 'mission_run_continuation') return false;
2340
+ if (!isConcreteContinuationTarget(row.objective)) return false;
2341
+ return true;
2342
+ });
2343
+ } catch {
2344
+ return [];
2345
+ }
2346
+ }
2347
+
2348
+ function chooseResumeMissionPlan(mission, nearMisses, root = process.cwd()) {
2349
+ const ranked = resumableActiveMissions(mission, root)
2350
+ .map((row) => ({
2351
+ row,
2352
+ preview: missionValuePreview({ title: row.objective, source: 'resume_active_mission', ref: row.id }, mission, root),
2353
+ }))
2354
+ .sort((a, b) => {
2355
+ const aScore = Number(a.preview?.score?.total || 0);
2356
+ const bScore = Number(b.preview?.score?.total || 0);
2357
+ if (aScore !== bScore) return bScore - aScore;
2358
+ return String(b.row.updated_at || '').localeCompare(String(a.row.updated_at || ''));
2359
+ });
2360
+ const best = ranked[0];
2361
+ if (!best) return null;
2362
+ return {
2363
+ target: {
2364
+ title: best.row.objective,
2365
+ source: 'resume_active_mission',
2366
+ ref: best.row.id,
2367
+ value_preview: best.preview,
2368
+ },
2369
+ command: `atris mission run ${best.row.id} --json`,
2370
+ preview: {
2371
+ schema: 'atris.next_mission_resume_preview.v1',
2372
+ resume_mission_id: best.row.id,
2373
+ resume_objective: best.row.objective,
2374
+ resume_status: best.row.status,
2375
+ resume_owner: best.row.owner || null,
2376
+ near_misses: nearMisses.slice(0, 3),
2377
+ feynman: {
2378
+ what: 'Every new candidate is already tracked, and one active mission is still in planning.',
2379
+ why_now: 'Resuming chosen work beats stopping when real work is already on the board.',
2380
+ risk: 'The resumed mission may deserve a fresher objective; stop it if it no longer matters.',
2381
+ validation: 'Run the resume command, then check the mission landing and receipt.',
2382
+ },
2383
+ },
2384
+ };
2385
+ }
2386
+
2387
+ function chooseNextMissionPlan(mission, root = process.cwd()) {
2388
+ const owner = mission?.owner || process.env.ATRIS_AGENT_ID || 'mission-lead';
2389
+ const analysis = chooseNextMissionAnalysis(mission, root);
2390
+ const target = analysis.target;
2391
+ if (target?.title) {
2392
+ return {
2393
+ target,
2394
+ command: `atris mission run ${shellQuote(target.title)} --owner ${owner}`,
2395
+ preview: target.value_preview || missionValuePreview(target, mission, root),
2396
+ };
2397
+ }
2398
+ const resume = chooseResumeMissionPlan(mission, analysis.near_misses || [], root);
2399
+ if (resume) return resume;
2400
+ const missionId = mission?.id || '<mission-id>';
2401
+ return {
2402
+ target: null,
2403
+ command: `atris mission stop ${missionId} --reason ${shellQuote('no concrete follow-up mission found in Atris state')} --json`,
2404
+ preview: nearMissPreview(analysis.near_misses || []),
2405
+ };
2406
+ }
2407
+
2408
+ function chooseNextMissionCommand(mission, root = process.cwd()) {
2409
+ return chooseNextMissionPlan(mission, root).command;
2410
+ }
2411
+
2412
+ function chooseNextMissionPreview(mission, root = process.cwd()) {
2413
+ return chooseNextMissionPlan(mission, root).preview;
2414
+ }
2415
+
2416
+ function effectiveMissionVerifier(mission) {
2417
+ const explicit = String(mission?.verifier || '').trim();
2418
+ if (explicit) return explicit;
2419
+ if (missionChoosesNextMission(mission)) return '';
2420
+ const runner = String(mission?.runner || '').trim().toLowerCase();
2421
+ if (missionLongRunIntent(mission) && (!runner || runner === 'codex_goal')) {
2422
+ return DEFAULT_LONG_RUN_VERIFIER;
2423
+ }
2424
+ return '';
2425
+ }
2426
+
2427
+ function markMissionRunContinuation(mission) {
2428
+ return {
2429
+ ...mission,
2430
+ started_from: 'mission_run_objective',
2431
+ continue_on_complete: true,
2432
+ continuation_policy: 'decide_and_start_next_useful_mission',
2433
+ };
2434
+ }
2435
+
2436
+ function continuationObjective(parent) {
2437
+ return `Decide and start the next useful mission after: ${parent.objective}`;
2438
+ }
2439
+
2440
+ function findActiveContinuationMission(parent, root = process.cwd()) {
2441
+ return listMissions(root).find((mission) => (
2442
+ mission.parent_mission_id === parent.id
2443
+ && mission.started_from === 'mission_run_continuation'
2444
+ && !TERMINAL_STATUSES.has(mission.status)
2445
+ )) || null;
2446
+ }
2447
+
2448
+ function findActiveMissionRunContinuation(root = process.cwd(), excludeId = '') {
2449
+ const excluded = String(excludeId || '');
2450
+ const candidates = listMissions(root)
2451
+ .filter((mission) => (
2452
+ mission.id !== excluded
2453
+ && mission.started_from === 'mission_run_continuation'
2454
+ && mission.continuation_policy === 'choose_next_mission'
2455
+ && !TERMINAL_STATUSES.has(mission.status)
2456
+ ));
2457
+ candidates.sort((a, b) => missionSortTime(b) - missionSortTime(a));
2458
+ return candidates[0] || null;
2459
+ }
2460
+
2461
+ function completeActiveContinuationForStartedMission(nextMission, root = process.cwd()) {
2462
+ const continuation = findActiveMissionRunContinuation(root, nextMission?.id);
2463
+ if (!continuation || !nextMission) return null;
2464
+ if (continuation.objective === nextMission.objective) return null;
2465
+
2466
+ const proof = `Started next mission ${nextMission.id}: ${nextMission.objective}`;
2467
+ const completionGate = { ok: true, source: 'mission_run_continuation', forced: false };
2468
+ const baseNext = {
2469
+ ...continuation,
2470
+ status: 'complete',
2471
+ completed_at: stampIso(),
2472
+ proof,
2473
+ completion_gate: completionGate,
2474
+ continued_by_mission_id: nextMission.id,
2475
+ continued_by_objective: nextMission.objective,
2476
+ next_action: 'mission complete',
972
2477
  };
973
2478
  const completion = missionCompletionReceipt(baseNext, proof);
974
2479
  const { mission: saved } = saveMission({
@@ -995,14 +2500,33 @@ function completeActiveContinuationForStartedMission(nextMission, root = process
995
2500
  };
996
2501
  }
997
2502
 
2503
+ function missionCanSeedContinuation(parent) {
2504
+ if (!parent) return false;
2505
+ if (parent.status === 'complete') return true;
2506
+ return GOAL_LOOP_STATUSES.has(String(parent.status || '')) && missionTaskHumanAcceptWaiting(parent);
2507
+ }
2508
+
998
2509
  function seedMissionRunContinuation(parent, root = process.cwd(), proof = '') {
999
- if (!parent || parent.status !== 'complete') return null;
2510
+ if (!missionCanSeedContinuation(parent)) return null;
1000
2511
  if (parent.continue_on_complete !== true) return null;
1001
- if (parent.continuation_seeded_mission_id) return {
1002
- inserted: false,
1003
- reason: 'already_seeded',
1004
- mission_id: parent.continuation_seeded_mission_id,
1005
- };
2512
+ if (parent.continuation_seeded_mission_id) {
2513
+ const seeded = resolveMission(parent.continuation_seeded_mission_id, root);
2514
+ if (seeded && TERMINAL_STATUSES.has(String(seeded.status || '')) && seeded.continued_by_mission_id) {
2515
+ return {
2516
+ inserted: false,
2517
+ reason: 'already_continued',
2518
+ mission_id: parent.continuation_seeded_mission_id,
2519
+ mission: null,
2520
+ continued_by_mission_id: seeded.continued_by_mission_id,
2521
+ };
2522
+ }
2523
+ return {
2524
+ inserted: false,
2525
+ reason: 'already_seeded',
2526
+ mission_id: parent.continuation_seeded_mission_id,
2527
+ mission: seeded || null,
2528
+ };
2529
+ }
1006
2530
 
1007
2531
  const existing = findActiveContinuationMission(parent, root);
1008
2532
  if (existing) return { inserted: false, reason: 'active_continuation_exists', mission: existing };
@@ -1032,8 +2556,11 @@ function seedMissionRunContinuation(parent, root = process.cwd(), proof = '') {
1032
2556
  continue_on_complete: false,
1033
2557
  continuation_policy: 'choose_next_mission',
1034
2558
  parent_proof: proof || parent.receipt_path || null,
1035
- next_action: `decide next mission, then run: atris mission run "<next useful mission>" --owner ${owner}`,
2559
+ next_action: '',
1036
2560
  };
2561
+ const nextPlan = chooseNextMissionPlan(nextMission, root);
2562
+ nextMission.next_action = `decide next mission, then run: ${nextPlan.command}`;
2563
+ nextMission.next_action_preview = nextPlan.preview;
1037
2564
 
1038
2565
  ensureMemberMissionFile(nextMission.owner, root, nextMission.objective);
1039
2566
  const { mission: saved } = saveMission(nextMission, root, 'mission_continuation_started', {
@@ -1064,6 +2591,36 @@ function seedMissionRunContinuation(parent, root = process.cwd(), proof = '') {
1064
2591
  };
1065
2592
  }
1066
2593
 
2594
+ function seedNextMoveContinuationGoal(root = process.cwd()) {
2595
+ const candidates = listMissions(root)
2596
+ .filter((mission) => runnerUsesCallerSession(mission.runner))
2597
+ .filter((mission) => mission.continue_on_complete === true)
2598
+ .filter((mission) => missionCanSeedContinuation(mission));
2599
+ candidates.sort((a, b) => missionSortTime(b) - missionSortTime(a));
2600
+ for (const parent of candidates) {
2601
+ const seeded = seedMissionRunContinuation(parent, root, parent.receipt_path || 'agent-certified task waiting for human accept');
2602
+ if (seeded?.mission && missionSelectableForCodexGoal(seeded.mission)) return { ...seeded, parent: seeded.parent || parent };
2603
+ }
2604
+ return null;
2605
+ }
2606
+
2607
+ // Continuation runs started from inside an existing agent worktree must build
2608
+ // on that work, not restart clean from origin/master. Returns the current HEAD
2609
+ // sha (a sha survives normalizeTargetRef; an unpushed branch name would be
2610
+ // rewritten to a nonexistent origin/ ref) when cwd is an agent worktree.
2611
+ function inheritedWorktreeBase(cwd) {
2612
+ try {
2613
+ const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
2614
+ const root = String(top.stdout || '').trim();
2615
+ if (top.status !== 0 || !root) return '';
2616
+ if (!fs.existsSync(path.join(root, '.atris', 'agent-worktree.json'))) return '';
2617
+ const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' });
2618
+ return head.status === 0 ? String(head.stdout || '').trim() : '';
2619
+ } catch {
2620
+ return '';
2621
+ }
2622
+ }
2623
+
1067
2624
  function startMission(args) {
1068
2625
  const asJson = wantsJson(args);
1069
2626
  const mission = missionFromArgs(args);
@@ -1075,7 +2632,8 @@ function startMission(args) {
1075
2632
  let created;
1076
2633
  try {
1077
2634
  const { createAgentWorktree } = require('./worktree');
1078
- created = createAgentWorktree({ member: mission.owner, task: mission.objective });
2635
+ const base = readFlag(args, '--base', '') || inheritedWorktreeBase(process.cwd());
2636
+ created = createAgentWorktree({ member: mission.owner, task: mission.objective, ...(base ? { base } : {}) });
1079
2637
  } catch (e) {
1080
2638
  exitMissionError(`[mission start] worktree creation failed: ${e.message}`, 2, asJson);
1081
2639
  }
@@ -1119,35 +2677,88 @@ function startMission(args) {
1119
2677
 
1120
2678
  function startMissionFromRunObjective(objective, args) {
1121
2679
  const asJson = wantsJson(args);
1122
- const verifier = readFlag(args, '--verify', inferRunObjectiveVerifier(objective));
2680
+ const rawObjective = String(objective || '').trim();
2681
+ const inferredLoop = inferRunObjectiveLoopOptions(rawObjective, args);
2682
+ const budgetContract = inferRunObjectiveBudgetContract(rawObjective, args);
2683
+ const inferredOwner = inferredLoop.wantsLongRun && /\bself[-\s]?improve\b/i.test(rawObjective)
2684
+ ? 'auto-improver'
2685
+ : (process.env.ATRIS_AGENT_ID || 'mission-lead');
2686
+ const preflightOwner = readFlag(args, '--owner', inferredOwner);
2687
+ const missionRunPreflight = buildMissionRunRoomPreflight(rawObjective, args, {
2688
+ root: process.cwd(),
2689
+ owner: preflightOwner,
2690
+ allowTrustedRun: !inferredLoop.wantsLongRun,
2691
+ });
2692
+ const missionObjective = missionRunPreflight?.shaped_objective || rawObjective;
2693
+ const verifier = readFlag(
2694
+ args,
2695
+ '--verify',
2696
+ inferRunObjectiveVerifier(missionObjective)
2697
+ || inferRunObjectiveVerifier(rawObjective)
2698
+ || (missionRunPreflight?.trusted_run ? DEFAULT_LONG_RUN_VERIFIER : '')
2699
+ || (inferredLoop.wantsLongRun ? DEFAULT_LONG_RUN_VERIFIER : ''),
2700
+ );
1123
2701
  const stopCondition = readFlag(
1124
2702
  args,
1125
2703
  '--stop',
1126
- verifier ? 'verifier passes and visible goal lands' : 'visible goal lands and proof is ready',
2704
+ budgetStopCondition(budgetContract) || (inferredLoop.wantsLongRun
2705
+ ? `run for ${inferredLoop.requestedHours || 'the requested overnight window'} hour${inferredLoop.requestedHours === 1 ? '' : 's'}, or stop when proof is ready`
2706
+ : (verifier ? 'verifier passes and visible goal lands' : 'visible goal lands and proof is ready')),
1127
2707
  );
1128
2708
  const startArgs = [
1129
- objective,
2709
+ missionObjective,
1130
2710
  '--owner',
1131
- readFlag(args, '--owner', process.env.ATRIS_AGENT_ID || 'mission-lead'),
2711
+ readFlag(args, '--owner', inferredOwner),
1132
2712
  '--runner',
1133
2713
  readFlag(args, '--runner', 'codex_goal'),
1134
2714
  '--lane',
1135
2715
  readFlag(args, '--lane', 'workspace'),
1136
2716
  '--cadence',
1137
- readFlag(args, '--cadence', 'manual'),
2717
+ readFlag(args, '--cadence', inferredLoop.wantsLongRun ? inferredLoop.cadence : 'manual'),
1138
2718
  '--stop',
1139
2719
  stopCondition,
1140
2720
  ];
1141
2721
  if (verifier) startArgs.push('--verify', verifier);
1142
2722
  const model = readFlag(args, '--model', '');
1143
2723
  if (model) startArgs.push('--model', model);
1144
- if (hasFlag(args, '--always-on')) startArgs.push('--always-on');
1145
- if (hasFlag(args, '--xp-task') || hasFlag(args, '--agent-xp')) startArgs.push('--xp-task');
2724
+ if (hasFlag(args, '--always-on') || inferredLoop.wantsLongRun) startArgs.push('--always-on');
2725
+ if (hasFlag(args, '--xp-task') || hasFlag(args, '--agent-xp') || missionRunPreflight?.task_spine_required) startArgs.push('--xp-task');
1146
2726
 
1147
2727
  const mission = markMissionRunContinuation(missionFromArgs(startArgs));
2728
+ if (missionGoalChainIntent(rawObjective) || missionGoalChainIntent(missionObjective)) {
2729
+ mission.goal_chain = buildMissionGoalChain(missionObjective || rawObjective);
2730
+ mission.next_action = missionGoalChainNextAction(mission.goal_chain);
2731
+ }
2732
+ if (missionRunPreflight) {
2733
+ mission.mission_run_preflight = missionRunPreflight;
2734
+ mission.raw_objective = rawObjective;
2735
+ }
2736
+ const selectedTargetTask = missionRunSelectedTaskTarget(missionRunPreflight);
2737
+ if (selectedTargetTask) {
2738
+ Object.assign(mission, attachSelectedTargetTaskSpine(mission));
2739
+ }
2740
+ if (budgetContract) {
2741
+ mission.budget_contract = budgetContract;
2742
+ if (budgetContract.requested_seconds) mission.max_wall_seconds = budgetContract.requested_seconds;
2743
+ }
2744
+ if (inferredLoop.wantsLongRun) {
2745
+ mission.overnight_loop = {
2746
+ requested_hours: inferredLoop.requestedHours,
2747
+ cadence: mission.cadence,
2748
+ install_command: inferredLoop.requestedHours
2749
+ ? `atris loop start --overnight --cadence ${mission.cadence} --hours ${inferredLoop.requestedHours}`
2750
+ : `atris loop start --overnight --cadence ${mission.cadence}`,
2751
+ };
2752
+ }
2753
+ if (mission.xp_task_enabled && !selectedTargetTask) {
2754
+ const xpTask = createMissionXpTask(mission, process.cwd(), asJson);
2755
+ mission.xp_task = xpTask;
2756
+ mission.task_ids = Array.from(new Set([...(mission.task_ids || []), xpTask.task_id]));
2757
+ }
1148
2758
  const warnings = [missingVerifierWarning(mission)].filter(Boolean);
1149
2759
  ensureMemberMissionFile(mission.owner, process.cwd(), mission.objective);
1150
2760
  const { mission: saved } = saveMission(mission, process.cwd(), 'mission_started', { objective: mission.objective, source: 'mission_run_objective' });
2761
+ const directGoalRequest = writeDirectRunCodexGoalRequest(saved, process.cwd());
1151
2762
  const memberState = renderMemberMissionState(saved.owner);
1152
2763
  const logPath = appendMemberLog(saved.owner, 'Mission started from run', {
1153
2764
  mission: saved.objective,
@@ -1158,16 +2769,26 @@ function startMissionFromRunObjective(objective, args) {
1158
2769
  });
1159
2770
  const worktreeBaseline = captureMissionWorktreeBaseline(saved, process.cwd());
1160
2771
  const completedContinuationGoal = completeActiveContinuationForStartedMission(saved, process.cwd());
2772
+ const nativeGoalStatus = readFlag(args, '--native-goal-status', readFlag(args, '--visible-goal-status', ''));
2773
+ const nativeGoalObjective = readFlag(args, '--native-goal-objective', readFlag(args, '--visible-goal-objective', ''));
2774
+ const nativeGoalOptions = {
2775
+ ...(nativeGoalStatus ? { nativeGoalStatus } : {}),
2776
+ ...(nativeGoalObjective ? { nativeGoalObjective } : {}),
2777
+ ...(hasFlag(args, '--allow-native-goal-supersede') || hasFlag(args, '--supersede-paused-native-goal') ? { allowNativeGoalSupersede: true } : {}),
2778
+ };
1161
2779
  const atrisGoalState = refreshAtrisGoalController(process.cwd(), { missionId: saved.id });
1162
2780
  const codexGoalState = runnerUsesCallerSession(saved.runner)
1163
- ? refreshCodexGoalController(process.cwd(), { missionId: saved.id })
2781
+ ? refreshCodexGoalController(process.cwd(), { missionId: saved.id, ...nativeGoalOptions })
1164
2782
  : null;
1165
- const nativeGoal = codexGoalState?.goal?.requires_native_goal_start ? codexGoalState.goal.native_goal_action : null;
2783
+ const nativeGoal = codexGoalState?.native_goal_action
2784
+ || (codexGoalState?.goal?.requires_native_goal_start ? codexGoalState.goal.native_goal_action : null);
2785
+ const nextCommand = codexGoalState?.next_command || codexGoalState?.goal?.next_command || atrisGoalState.goal?.next_command || `atris mission tick ${saved.id} --summary "<what changed>"`;
1166
2786
  printJsonOrText(
1167
2787
  {
1168
2788
  ok: true,
1169
2789
  action: 'mission_run_started',
1170
2790
  mission: saved,
2791
+ budget_contract: saved.budget_contract || null,
1171
2792
  warnings,
1172
2793
  state_path: statePaths().missionsJsonl,
1173
2794
  member_state: memberState,
@@ -1178,21 +2799,16 @@ function startMissionFromRunObjective(objective, args) {
1178
2799
  dirty_hash: worktreeBaseline.dirty_hash,
1179
2800
  } : null,
1180
2801
  completed_continuation_goal: completedContinuationGoal,
2802
+ direct_goal_request: directGoalRequest,
1181
2803
  atris_goal_state: atrisGoalState,
1182
2804
  codex_goal_state: codexGoalState,
1183
- requires_native_goal_start: codexGoalState?.goal?.requires_native_goal_start === true,
2805
+ requires_native_goal_start: codexGoalState?.requires_native_goal_start === true || codexGoalState?.goal?.requires_native_goal_start === true,
2806
+ requires_native_goal_replace: codexGoalState?.requires_native_goal_replace === true || codexGoalState?.goal?.requires_native_goal_replace === true,
1184
2807
  native_goal_action: nativeGoal,
1185
- native_goal_ack_command: codexGoalState?.goal?.native_goal_ack_command || null,
1186
- next_command: codexGoalState?.goal?.next_command || atrisGoalState.goal?.next_command || `atris mission tick ${saved.id} --summary "<what changed>"`,
2808
+ native_goal_ack_command: codexGoalState?.goal?.native_goal_ack_command || codexGoalState?.active_goal_conflict?.commands?.ack_new_mission || null,
2809
+ next_command: nextCommand,
1187
2810
  },
1188
- [
1189
- `Started mission: ${saved.objective}`,
1190
- `Owner: ${saved.owner}`,
1191
- `Runner: ${saved.runner}`,
1192
- ...(atrisGoalState.goal ? [`Atris goal: ${atrisGoalState.goal.objective}`] : []),
1193
- ...(codexGoalState?.goal ? [`Codex goal: ${codexGoalState.goal.objective}`] : []),
1194
- ...warnings.map((warning) => `Warning: ${warning.message}`),
1195
- ],
2811
+ missionRunTakeoffLines(saved, { warnings, nextCommand }),
1196
2812
  asJson,
1197
2813
  );
1198
2814
  }
@@ -1203,60 +2819,128 @@ function attachMissionTask(args) {
1203
2819
  if (!ref) {
1204
2820
  exitMissionError('Usage: atris mission attach-task <id> [--json]', 1, asJson);
1205
2821
  }
1206
- const mission = resolveMission(ref);
2822
+ let mission = resolveMission(ref);
1207
2823
  if (!mission) {
1208
2824
  exitMissionError(`Mission "${ref}" not found.`, 1, asJson);
1209
2825
  }
1210
- if (TERMINAL_STATUSES.has(mission.status)) {
1211
- exitMissionError(`Mission "${ref}" is ${mission.status}; task spines attach only to active missions.`, 2, asJson);
2826
+
2827
+ const lock = acquireMissionLock(mission.id, process.cwd(), { waitMs: 2000 });
2828
+ if (!lock.ok) {
2829
+ exitMissionError(`[mission attach-task] lock busy (held by pid ${lock.holder?.pid || '?'} since ${lock.holder?.started_at || '?'}). Exit.`, 3, asJson);
1212
2830
  }
1213
2831
 
1214
- const existingSpine = missionTaskSpine(mission);
1215
- if (existingSpine?.has_task) {
1216
- const view = missionStatusView(mission);
2832
+ try {
2833
+ mission = resolveMission(mission.id) || mission;
2834
+ if (TERMINAL_STATUSES.has(mission.status)) {
2835
+ releaseMissionLock(lock);
2836
+ exitMissionError(`Mission "${ref}" is ${mission.status}; task spines attach only to active missions.`, 2, asJson);
2837
+ }
2838
+
2839
+ const existingSpine = missionTaskSpine(mission);
2840
+ if (existingSpine?.has_task) {
2841
+ let currentMission = mission;
2842
+ if (missionChoosesNextMission(currentMission)) {
2843
+ const nextPlan = chooseNextMissionPlan(currentMission);
2844
+ const nextAction = `decide next mission, then run: ${nextPlan.command}`;
2845
+ const currentPreviewTitle = currentMission.next_action_preview?.candidate?.title || null;
2846
+ const nextPreviewTitle = nextPlan.preview?.candidate?.title || null;
2847
+ const previewChanged = Boolean(currentMission.next_action_preview) !== Boolean(nextPlan.preview)
2848
+ || currentPreviewTitle !== nextPreviewTitle;
2849
+ if (currentMission.next_action !== nextAction || previewChanged) {
2850
+ currentMission = saveMission(
2851
+ {
2852
+ ...currentMission,
2853
+ next_action: nextAction,
2854
+ next_action_preview: nextPlan.preview,
2855
+ },
2856
+ process.cwd(),
2857
+ 'mission_next_action_refreshed',
2858
+ {
2859
+ next_command: nextPlan.command,
2860
+ target: nextPreviewTitle,
2861
+ },
2862
+ ).mission;
2863
+ }
2864
+ }
2865
+ const view = missionStatusView(currentMission);
2866
+ printJsonOrText(
2867
+ { ok: true, action: 'mission_task_spine_exists', mission: view, task_spine: view.task_spine },
2868
+ [
2869
+ `Mission task spine already exists: ${mission.objective}`,
2870
+ `Task: ${view.task_spine.task_ref}`,
2871
+ `Next: ${view.task_spine.current_step_command}`,
2872
+ ],
2873
+ asJson,
2874
+ );
2875
+ return;
2876
+ }
2877
+
2878
+ const ownership = applyMissionOwnerResolution(mission, process.cwd());
2879
+ const baseMission = ownership.mission;
2880
+ const selectedTargetMission = attachSelectedTargetTaskSpine(baseMission);
2881
+ if (missionTaskSpine(selectedTargetMission)?.has_task) {
2882
+ const { mission: saved } = saveMission(
2883
+ selectedTargetMission,
2884
+ process.cwd(),
2885
+ 'mission_selected_task_spine_attached',
2886
+ {
2887
+ task_id: selectedTargetMission.current_task_id || selectedTargetMission.task_ids?.[0] || null,
2888
+ task_ref: selectedTargetMission.task_ref || null,
2889
+ },
2890
+ );
2891
+ const memberState = renderMemberMissionState(saved.owner);
2892
+ const logPath = appendMemberLog(saved.owner, 'Mission selected task spine attached', {
2893
+ mission: saved.objective,
2894
+ task: saved.task_ref || saved.current_task_id,
2895
+ });
2896
+ const view = missionStatusView(saved);
2897
+ printJsonOrText(
2898
+ { ok: true, action: 'mission_selected_task_spine_attached', mission: view, task_spine: view.task_spine, member_state: memberState, log_path: logPath },
2899
+ [
2900
+ `Attached selected task spine: ${saved.objective}`,
2901
+ `Task: ${view.task_spine.task_ref}`,
2902
+ `Next: ${view.task_spine.current_step_command}`,
2903
+ ],
2904
+ asJson,
2905
+ );
2906
+ return;
2907
+ }
2908
+ const xpTask = createMissionXpTask(baseMission, process.cwd(), asJson);
2909
+ const nextMission = {
2910
+ ...baseMission,
2911
+ xp_task_enabled: true,
2912
+ xp_task: xpTask,
2913
+ task_ids: Array.from(new Set([...(baseMission.task_ids || []), xpTask.task_id])),
2914
+ };
2915
+ if (nextMission.status === 'ready' && nextMission.receipt_path) {
2916
+ nextMission.next_action = missionXpReadyAction(nextMission, nextMission.receipt_path) || nextMission.next_action;
2917
+ } else if (missionChoosesNextMission(nextMission)) {
2918
+ const nextPlan = chooseNextMissionPlan(nextMission);
2919
+ nextMission.next_action = `decide next mission, then run: ${nextPlan.command}`;
2920
+ nextMission.next_action_preview = nextPlan.preview;
2921
+ } else if (!nextMission.verifier && !nextMission.always_on) {
2922
+ nextMission.next_action = `work task then run: atris task current-step --goal-id ${nextMission.id} --as ${nextMission.owner} --proof "<proof>" --json`;
2923
+ }
2924
+
2925
+ const { mission: saved } = saveMission(nextMission, process.cwd(), 'mission_task_spine_attached', { task_id: xpTask.task_id, task_ref: xpTask.ref });
2926
+ const memberState = renderMemberMissionState(saved.owner);
2927
+ const logPath = appendMemberLog(saved.owner, 'Mission task spine attached', {
2928
+ mission: saved.objective,
2929
+ task: xpTask.ref,
2930
+ });
2931
+ const view = missionStatusView(saved);
1217
2932
  printJsonOrText(
1218
- { ok: true, action: 'mission_task_spine_exists', mission: view, task_spine: view.task_spine },
2933
+ { ok: true, action: 'mission_task_spine_attached', mission: view, task: xpTask, task_spine: view.task_spine, member_state: memberState, log_path: logPath },
1219
2934
  [
1220
- `Mission task spine already exists: ${mission.objective}`,
1221
- `Task: ${view.task_spine.task_ref}`,
2935
+ `Attached task spine: ${saved.objective}`,
2936
+ `Task: ${xpTask.ref}`,
1222
2937
  `Next: ${view.task_spine.current_step_command}`,
1223
2938
  ],
1224
2939
  asJson,
1225
2940
  );
1226
- return;
1227
- }
1228
-
1229
- const ownership = applyMissionOwnerResolution(mission, process.cwd());
1230
- const baseMission = ownership.mission;
1231
- const xpTask = createMissionXpTask(baseMission, process.cwd(), asJson);
1232
- const nextMission = {
1233
- ...baseMission,
1234
- xp_task_enabled: true,
1235
- xp_task: xpTask,
1236
- task_ids: Array.from(new Set([...(baseMission.task_ids || []), xpTask.task_id])),
1237
- };
1238
- if (nextMission.status === 'ready' && nextMission.receipt_path) {
1239
- nextMission.next_action = missionXpReadyAction(nextMission, nextMission.receipt_path) || nextMission.next_action;
1240
- } else if (!nextMission.verifier && !nextMission.always_on) {
1241
- nextMission.next_action = `work task then run: atris task current-step --goal-id ${nextMission.id} --as ${nextMission.owner} --proof "<proof>" --json`;
2941
+ } finally {
2942
+ releaseMissionLock(lock);
1242
2943
  }
1243
-
1244
- const { mission: saved } = saveMission(nextMission, process.cwd(), 'mission_task_spine_attached', { task_id: xpTask.task_id, task_ref: xpTask.ref });
1245
- const memberState = renderMemberMissionState(saved.owner);
1246
- const logPath = appendMemberLog(saved.owner, 'Mission task spine attached', {
1247
- mission: saved.objective,
1248
- task: xpTask.ref,
1249
- });
1250
- const view = missionStatusView(saved);
1251
- printJsonOrText(
1252
- { ok: true, action: 'mission_task_spine_attached', mission: view, task: xpTask, task_spine: view.task_spine, member_state: memberState, log_path: logPath },
1253
- [
1254
- `Attached task spine: ${saved.objective}`,
1255
- `Task: ${xpTask.ref}`,
1256
- `Next: ${view.task_spine.current_step_command}`,
1257
- ],
1258
- asJson,
1259
- );
1260
2944
  }
1261
2945
 
1262
2946
  function statusMission(args) {
@@ -1304,14 +2988,17 @@ function statusMission(args) {
1304
2988
  ` id: ${mission.id}`,
1305
2989
  ` owner: ${mission.owner}`,
1306
2990
  ...(mission.executed_by ? [` executed_by: ${mission.executed_by}`] : []),
1307
- ` state: ${mission.status}`,
1308
- ...missionHeartbeatLines(mission),
1309
- ...(mission.worktree_root ? [` worktree: ${mission.worktree_root}`] : []),
1310
- ` next: ${mission.next_action || 'tick or verify'}`,
1311
- ...(mission.task_spine?.task_ref ? [` task: ${mission.task_spine.task_ref}`] : []),
2991
+ ` state: ${mission.status}`,
2992
+ ...missionHeartbeatLines(mission),
2993
+ ...(mission.worktree_root ? [` worktree: ${mission.worktree_root}`] : []),
2994
+ ` next: ${mission.next_action || 'tick or verify'}`,
2995
+ ...(mission.next_action_preview?.feynman?.what ? [` preview: ${mission.next_action_preview.feynman.what}`] : []),
2996
+ ...missionGoalChainLines(mission),
2997
+ ...(mission.task_spine?.task_ref ? [` task: ${mission.task_spine.task_ref}`] : []),
1312
2998
  ...(mission.task_spine?.current_step_command ? [` task next: ${mission.task_spine.current_step_command}`] : []),
1313
2999
  ...(!mission.task_spine?.has_task && mission.task_spine?.ensure_task_command ? [` task setup: ${mission.task_spine.ensure_task_command}`] : []),
1314
3000
  ...(mission.receipt_path ? [` proof: ${mission.receipt_path}`] : []),
3001
+ ...missionStatusLandingLines(mission.last_landing),
1315
3002
  ...(completionGateLabel(mission.completion_gate) ? [` gate: ${completionGateLabel(mission.completion_gate)}`] : []),
1316
3003
  ])
1317
3004
  : ['No missions yet. Run: atris mission start "..." --owner <member>'],
@@ -1350,11 +3037,20 @@ function missionWorkerSummary(mission, receipt) {
1350
3037
  if (mission && mission.worker_summary) return mission.worker_summary;
1351
3038
  const tick = receipt && receipt.result && (receipt.result.tick || (Array.isArray(receipt.result.ticks) ? receipt.result.ticks[receipt.result.ticks.length - 1] : null));
1352
3039
  if (!tick) return mission && mission.last_tick_reason ? `Last tick: ${mission.last_tick_reason}` : 'No worker receipt yet.';
3040
+ return missionTickReportSummary(tick);
3041
+ }
3042
+
3043
+ function missionTickReportSummary(tick) {
3044
+ if (!tick) return 'Worker tick recorded.';
1353
3045
  if (tick.atris2) {
1354
3046
  return firstUsefulLine(tick.atris2.receipt_text, tick.atris2.ok ? 'Remote worker ran and returned a response.' : 'Remote worker failed.');
1355
3047
  }
1356
3048
  if (tick.claude) {
1357
3049
  if (tick.claude.skipped) {
3050
+ const skipReason = tick.claude.reason || tick.reason || '';
3051
+ if (!tick.summary && ['runner-uses-caller-session', 'orchestrator-is-caller-session'].includes(skipReason)) {
3052
+ return 'Goal handed to the active Codex session.';
3053
+ }
1358
3054
  return tick.summary || `Worker step skipped: ${tick.claude.reason || tick.reason || 'not needed'}.`;
1359
3055
  }
1360
3056
  return tick.claude.summary || firstUsefulLine(tick.claude.receipt_text, tick.claude.ok ? 'Worker ran and returned a response.' : 'Worker failed.');
@@ -1363,6 +3059,330 @@ function missionWorkerSummary(mission, receipt) {
1363
3059
  return tick.reason ? `Worker tick: ${tick.reason}` : 'Worker tick recorded.';
1364
3060
  }
1365
3061
 
3062
+ function missionReceiptTicks(receipt) {
3063
+ const result = receipt && receipt.result;
3064
+ if (!result) return [];
3065
+ if (result.tick) return [result.tick];
3066
+ if (Array.isArray(result.ticks)) return result.ticks.filter(Boolean);
3067
+ return [];
3068
+ }
3069
+
3070
+ function missionTickAt(receipt, tick) {
3071
+ return tick.finished_at || tick.started_at || receipt.at || '';
3072
+ }
3073
+
3074
+ function missionTimelineTitle(tick, summary) {
3075
+ const tickIndex = Number(tick && tick.tick_index);
3076
+ const prefix = Number.isInteger(tickIndex) && tickIndex > 0 ? `Goal ${tickIndex}` : 'Goal';
3077
+ const text = String(summary || '').trim();
3078
+ const explicitGoal = text.match(/^(Goal\s+\d+)\s*:\s*(.*)$/i);
3079
+ if (explicitGoal) {
3080
+ return missionTimelineTitleLine(`${explicitGoal[1].replace(/\s+/g, ' ')}: ${explicitGoal[2].trim()}`);
3081
+ }
3082
+ return missionTimelineTitleLine(`${prefix}: ${text.replace(/^Goal\s*:\s*/i, '').trim()}`);
3083
+ }
3084
+
3085
+ function missionTimelineTitleLine(title) {
3086
+ const text = String(title || '').replace(/\s+/g, ' ').trim();
3087
+ const firstSentence = text.match(/^(.+?[.!?])(?:\s+|$)/);
3088
+ const candidate = firstSentence ? firstSentence[1].trim() : text;
3089
+ if (candidate.length <= 180) return candidate;
3090
+ return `${candidate.slice(0, 177).trimEnd()}...`;
3091
+ }
3092
+
3093
+ function missionTimelineSummaryIsUseful(summary) {
3094
+ const text = String(summary || '').trim();
3095
+ if (!text) return false;
3096
+ return ![
3097
+ 'Goal handed to the active Codex session.',
3098
+ 'Worker tick recorded.',
3099
+ ].includes(text) && !/^Worker step skipped:/i.test(text);
3100
+ }
3101
+
3102
+ function missionTimelineTickIndex(tick) {
3103
+ const tickIndex = Number(tick && tick.tick_index);
3104
+ return Number.isInteger(tickIndex) && tickIndex > 0 ? tickIndex : null;
3105
+ }
3106
+
3107
+ function missionReportTimeline(mission, root = process.cwd(), limit = 6) {
3108
+ const paths = statePaths(root);
3109
+ let files = [];
3110
+ try {
3111
+ files = fs.readdirSync(paths.runsDir)
3112
+ .filter((file) => file.startsWith('mission-') && file.endsWith('.json'))
3113
+ .map((file) => path.join(paths.runsDir, file));
3114
+ } catch {
3115
+ files = [];
3116
+ }
3117
+
3118
+ const items = [];
3119
+ const seen = new Set();
3120
+ for (const file of files) {
3121
+ let receipt = null;
3122
+ try {
3123
+ receipt = JSON.parse(fs.readFileSync(file, 'utf8'));
3124
+ } catch {
3125
+ continue;
3126
+ }
3127
+ if (!receipt || receipt.mission_id !== mission.id) continue;
3128
+ const receiptPath = path.relative(root, file);
3129
+ for (const tick of missionReceiptTicks(receipt)) {
3130
+ const summary = missionTickReportSummary(tick);
3131
+ if (!missionTimelineSummaryIsUseful(summary)) continue;
3132
+ const at = missionTickAt(receipt, tick);
3133
+ const key = `${tick.tick_index || ''}:${at}:${summary}`;
3134
+ if (seen.has(key)) continue;
3135
+ seen.add(key);
3136
+ items.push({
3137
+ at,
3138
+ tick_index: missionTimelineTickIndex(tick),
3139
+ title: missionTimelineTitle(tick, summary),
3140
+ summary,
3141
+ receipt_path: receiptPath,
3142
+ });
3143
+ }
3144
+ }
3145
+
3146
+ items.sort((a, b) => String(a.at || '').localeCompare(String(b.at || '')));
3147
+ return items.slice(Math.max(0, items.length - limit));
3148
+ }
3149
+
3150
+ function missionLandingTimeline(mission, root = process.cwd(), limit = 12) {
3151
+ const paths = statePaths(root);
3152
+ let files = [];
3153
+ try {
3154
+ files = fs.readdirSync(paths.runsDir)
3155
+ .filter((file) => file.startsWith('mission-') && file.endsWith('.json'))
3156
+ .map((file) => path.join(paths.runsDir, file));
3157
+ } catch {
3158
+ files = [];
3159
+ }
3160
+
3161
+ const items = [];
3162
+ const seen = new Set();
3163
+ for (const file of files) {
3164
+ let receipt = null;
3165
+ try {
3166
+ receipt = JSON.parse(fs.readFileSync(file, 'utf8'));
3167
+ } catch {
3168
+ continue;
3169
+ }
3170
+ if (!receipt || receipt.mission_id !== mission.id) continue;
3171
+ const landing = receipt.result && receipt.result.landing;
3172
+ if (landing && landing.timeline_visible === false) continue;
3173
+ const changed = String(landing && landing.changed || '').trim();
3174
+ const next = String(landing && landing.next || '').trim();
3175
+ if (!changed && !next) continue;
3176
+ const receiptPath = path.relative(root, file);
3177
+ const key = `${receipt.at || ''}:${changed}:${next}`;
3178
+ if (seen.has(key)) continue;
3179
+ seen.add(key);
3180
+ items.push({
3181
+ at: receipt.at || '',
3182
+ changed,
3183
+ next,
3184
+ receipt_path: receiptPath,
3185
+ created_next: receipt.result.created_next || null,
3186
+ });
3187
+ }
3188
+ items.sort((a, b) => String(a.at || '').localeCompare(String(b.at || '')));
3189
+ const normalizedLimit = Number.isFinite(limit) && limit > 0 ? limit : items.length;
3190
+ const shown = items.slice(Math.max(0, items.length - normalizedLimit));
3191
+ return {
3192
+ items: shown,
3193
+ meta: {
3194
+ shown_count: shown.length,
3195
+ total_count: items.length,
3196
+ hidden_count: Math.max(0, items.length - shown.length),
3197
+ truncated: shown.length < items.length,
3198
+ limit: normalizedLimit === Number.MAX_SAFE_INTEGER ? null : normalizedLimit,
3199
+ },
3200
+ };
3201
+ }
3202
+
3203
+ function formatInteger(value) {
3204
+ return String(Math.max(0, Number(value || 0))).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
3205
+ }
3206
+
3207
+ function missionTimelinePruneSummaryMarkdown(prunePreview) {
3208
+ if (!prunePreview) return [];
3209
+ if (prunePreview.error) {
3210
+ return [
3211
+ '',
3212
+ '## Latest prune dry-run',
3213
+ '',
3214
+ `- Not available: ${prunePreview.error}`,
3215
+ ];
3216
+ }
3217
+ return [
3218
+ '',
3219
+ '## Latest prune dry-run',
3220
+ '',
3221
+ `- Policy: keep newest ${formatInteger(prunePreview.policy?.keep_newest)}; keep ${formatInteger(prunePreview.policy?.keep_days)} days`,
3222
+ `- Total run files: ${formatInteger(prunePreview.total_files)}`,
3223
+ `- Total run bytes: ${formatInteger(prunePreview.total_bytes)} (${formatBytes(prunePreview.total_bytes)})`,
3224
+ `- Referenced files kept: ${formatInteger(prunePreview.referenced_files)}`,
3225
+ `- Recent files kept: ${formatInteger(prunePreview.recent_files)}`,
3226
+ `- Would prune: ${formatInteger(prunePreview.prune_count)} files / ${formatInteger(prunePreview.prune_bytes)} bytes (${formatBytes(prunePreview.prune_bytes)})`,
3227
+ `- Deleted files: ${formatInteger(prunePreview.deleted_count)}`,
3228
+ ];
3229
+ }
3230
+
3231
+ function missionTimelinePruneSummaryLine(prunePreview) {
3232
+ if (!prunePreview) return null;
3233
+ if (prunePreview.error) return `Prune dry-run: not available (${prunePreview.error}).`;
3234
+ return `Prune dry-run: ${formatInteger(prunePreview.prune_count)} files / ${formatBytes(prunePreview.prune_bytes)} would prune; ${formatInteger(prunePreview.deleted_count)} deleted.`;
3235
+ }
3236
+
3237
+ function missionTimelinePruneSummaryObject(prunePreview) {
3238
+ if (!prunePreview) return null;
3239
+ if (prunePreview.error) {
3240
+ return {
3241
+ ok: false,
3242
+ error: prunePreview.error,
3243
+ text: missionTimelinePruneSummaryLine(prunePreview),
3244
+ };
3245
+ }
3246
+ return {
3247
+ ok: true,
3248
+ text: missionTimelinePruneSummaryLine(prunePreview),
3249
+ total_files: prunePreview.total_files,
3250
+ total_bytes: prunePreview.total_bytes,
3251
+ referenced_files: prunePreview.referenced_files,
3252
+ recent_files: prunePreview.recent_files,
3253
+ prune_count: prunePreview.prune_count,
3254
+ prune_bytes: prunePreview.prune_bytes,
3255
+ prune_bytes_text: formatBytes(prunePreview.prune_bytes),
3256
+ deleted_count: prunePreview.deleted_count,
3257
+ };
3258
+ }
3259
+
3260
+ function missionTimelineCurrentLanding(timeline) {
3261
+ if (!timeline.length) return null;
3262
+ const latest = timeline[timeline.length - 1];
3263
+ return {
3264
+ at: latest.at || '',
3265
+ changed: latest.changed || '',
3266
+ next: latest.next || '',
3267
+ receipt_path: latest.receipt_path || '',
3268
+ };
3269
+ }
3270
+
3271
+ function missionTimelineEmptyStateDisplay(mission, timelineLength = 0) {
3272
+ const hasMission = Boolean(mission);
3273
+ const isEmpty = !timelineLength;
3274
+ return {
3275
+ label: 'Empty state',
3276
+ is_empty: isEmpty,
3277
+ has_mission: hasMission,
3278
+ title: isEmpty ? (hasMission ? 'No timeline items yet.' : 'No missions yet.') : null,
3279
+ message: isEmpty
3280
+ ? (hasMission ? 'Run the mission once to create the first timeline item.' : 'Start a mission to create the first timeline item.')
3281
+ : null,
3282
+ action_label: isEmpty ? (hasMission ? 'Run mission' : 'Start mission') : null,
3283
+ command: isEmpty ? (hasMission ? `atris mission run ${mission.id} --create-next` : 'atris mission start "..." --owner <member>') : null,
3284
+ };
3285
+ }
3286
+
3287
+ function missionTimelineNextMove(mission, landing) {
3288
+ const latestNext = landing ? String(landing.next || '').trim() : '';
3289
+ return latestNext || `atris mission run ${mission.id} --create-next`;
3290
+ }
3291
+
3292
+ function missionTimelineCountLine(meta) {
3293
+ if (!meta) return null;
3294
+ const shown = Number(meta.shown_count || 0);
3295
+ const total = Number(meta.total_count || 0);
3296
+ if (meta.truncated) {
3297
+ return `Showing latest ${formatInteger(shown)} of ${formatInteger(total)} ${total === 1 ? 'item' : 'items'}.`;
3298
+ }
3299
+ return `Showing ${formatInteger(shown)} ${shown === 1 ? 'item' : 'items'}.`;
3300
+ }
3301
+
3302
+ function missionTimelineCurrentLandingLines(landing) {
3303
+ if (!landing) return [];
3304
+ return [
3305
+ 'Current landing:',
3306
+ ` Changed: ${landing.changed || 'Landing recorded.'}`,
3307
+ ...(landing.next ? [` Next: ${landing.next}`] : []),
3308
+ ` Proof: ${landing.receipt_path}`,
3309
+ '',
3310
+ ];
3311
+ }
3312
+
3313
+ function missionTimelineMarkdown(mission, timeline, { prunePreview = null, generatedAt = stampIso(), timelineMeta = null } = {}) {
3314
+ const latestLanding = timeline.length ? timeline[timeline.length - 1] : null;
3315
+ const nextMove = missionTimelineNextMove(mission, latestLanding);
3316
+ const lines = [
3317
+ `# Mission timeline: ${mission.objective}`,
3318
+ '',
3319
+ `Mission: ${mission.id}`,
3320
+ `Status: ${mission.status}`,
3321
+ `Generated at: ${generatedAt}`,
3322
+ ...(timelineMeta ? [missionTimelineCountLine(timelineMeta)] : []),
3323
+ '',
3324
+ '## Operator commands',
3325
+ '',
3326
+ `- Timeline: \`${missionRunTimelineCommand(mission)}\``,
3327
+ `- Export: \`${missionRunExportCommand(mission)}\``,
3328
+ `- Prune preview: \`${missionRunPrunePreviewCommand(mission)}\``,
3329
+ '',
3330
+ ];
3331
+ if (latestLanding) {
3332
+ lines.push(
3333
+ '## Current landing',
3334
+ '',
3335
+ `Changed: ${latestLanding.changed || 'Landing recorded.'}`,
3336
+ ...(latestLanding.next ? [`Next: ${latestLanding.next}`] : []),
3337
+ `Proof: ${latestLanding.receipt_path}`,
3338
+ '',
3339
+ );
3340
+ }
3341
+ if (!timeline.length) {
3342
+ lines.push('No landing receipts yet.');
3343
+ lines.push(
3344
+ '',
3345
+ '## Next move',
3346
+ '',
3347
+ nextMove,
3348
+ );
3349
+ lines.push(
3350
+ '',
3351
+ '## Keep it concise',
3352
+ '',
3353
+ '- Dry run: `atris mission prune-runs --days 14 --keep-newest 200`',
3354
+ '- Apply only after review: add `--apply`.',
3355
+ );
3356
+ lines.push(...missionTimelinePruneSummaryMarkdown(prunePreview));
3357
+ lines.push('');
3358
+ return lines.join('\n');
3359
+ }
3360
+ timeline.forEach((item, index) => {
3361
+ if (index === 0) lines.push('## Full history', '');
3362
+ lines.push(`${index + 1}. ${item.changed || 'Landing recorded.'}`);
3363
+ if (item.next) lines.push(` - Next: ${item.next}`);
3364
+ lines.push(` - Proof: ${item.receipt_path}`);
3365
+ });
3366
+ lines.push('', '## Next move', '', nextMove);
3367
+ lines.push(
3368
+ '',
3369
+ '## Keep it concise',
3370
+ '',
3371
+ '- Dry run: `atris mission prune-runs --days 14 --keep-newest 200`',
3372
+ '- Apply only after review: add `--apply`.',
3373
+ );
3374
+ lines.push(...missionTimelinePruneSummaryMarkdown(prunePreview));
3375
+ lines.push('');
3376
+ return lines.join('\n');
3377
+ }
3378
+
3379
+ function defaultMissionTimelinePath(root, mission) {
3380
+ const safeId = String(mission.id || 'mission')
3381
+ .replace(/[^a-zA-Z0-9._-]+/g, '-')
3382
+ .replace(/^-+|-+$/g, '') || 'mission';
3383
+ return path.join(root, 'atris', 'reports', `${safeId}-timeline.md`);
3384
+ }
3385
+
1366
3386
  function missionReportFor(mission, root = process.cwd()) {
1367
3387
  const verifierReceiptPath = mission.receipt_path || null;
1368
3388
  const receipt = readMissionReceipt(verifierReceiptPath, root);
@@ -1377,48 +3397,523 @@ function missionReportFor(mission, root = process.cwd()) {
1377
3397
  operator_outcome: operatorOutcome,
1378
3398
  worker: mission.worker || missionWorkerLabel(mission),
1379
3399
  worker_summary: missionWorkerSummary(mission, receipt),
3400
+ timeline: missionReportTimeline(mission, root),
1380
3401
  worker_receipt_path: workerReceiptPath,
1381
3402
  verifier_receipt_path: verifierReceiptPath,
1382
3403
  operator_next: mission.operator_next || mission.next_action || 'Review the mission state.',
1383
3404
  };
1384
3405
  }
1385
3406
 
1386
- function reportMission(args) {
1387
- const asJson = wantsJson(args);
1388
- const localOnly = hasFlag(args, '--local');
1389
- const ref = stripKnownFlags(args, ['--limit'], ['--json', '--local'])[0] || '';
1390
- const limit = readPositiveIntegerFlag(args, '--limit', ref ? 1 : 3, { json: asJson });
1391
- let missions = ref ? [resolveMission(ref)].filter(Boolean) : listMissions();
1392
- if (!ref && !localOnly) {
3407
+ function missionReportNextText(nextAction) {
3408
+ return String(nextAction || 'Review the mission state.')
3409
+ .replace(/^next move:\s*run\s+/i, '')
3410
+ .trim();
3411
+ }
3412
+
3413
+ function reportMission(args) {
3414
+ const asJson = wantsJson(args);
3415
+ const localOnly = hasFlag(args, '--local');
3416
+ const ref = stripKnownFlags(args, ['--limit'], ['--json', '--local'])[0] || '';
3417
+ const limit = readPositiveIntegerFlag(args, '--limit', ref ? 1 : 3, { json: asJson });
3418
+ let missions = ref ? [resolveMission(ref)].filter(Boolean) : listMissions();
3419
+ if (!ref && !localOnly) {
3420
+ const seen = new Set(missions.map((mission) => mission.id));
3421
+ for (const rolled of listWorktreeRollupMissions()) {
3422
+ if (seen.has(rolled.id)) continue;
3423
+ seen.add(rolled.id);
3424
+ missions.push(rolled);
3425
+ }
3426
+ missions.sort((a, b) => String(b.updated_at || b.created_at || '').localeCompare(String(a.updated_at || a.created_at || '')));
3427
+ }
3428
+ if (ref && !missions.length) {
3429
+ exitMissionError(`Mission "${ref}" not found.`, 1, asJson);
3430
+ }
3431
+ missions = missions.slice(0, limit);
3432
+ const reports = missions.map((mission) => missionReportFor(mission, mission.worktree_root || process.cwd()));
3433
+ printJsonOrText(
3434
+ { ok: true, action: 'mission_report', reports },
3435
+ reports.length
3436
+ ? reports.flatMap((report) => [
3437
+ `Mission: ${report.objective}`,
3438
+ ` state: ${report.status}`,
3439
+ ` What happened: ${report.operator_outcome}`,
3440
+ ` Worker: ${report.worker}`,
3441
+ ` Worker summary: ${report.worker_summary}`,
3442
+ ...(report.timeline && report.timeline.length ? [
3443
+ ' Timeline:',
3444
+ ...report.timeline.map((item) => ` - ${item.title}`),
3445
+ ] : []),
3446
+ ...(report.worker_receipt_path ? [` Worker receipt: ${report.worker_receipt_path}`] : []),
3447
+ ...(report.verifier_receipt_path ? [` Verifier receipt: ${report.verifier_receipt_path}`] : []),
3448
+ ` Next: ${missionReportNextText(report.operator_next)}`,
3449
+ ])
3450
+ : ['No missions yet. Run: atris mission start "..." --owner <member>'],
3451
+ asJson,
3452
+ );
3453
+ }
3454
+
3455
+ function timelineMission(args) {
3456
+ const asJson = wantsJson(args);
3457
+ const write = hasFlag(args, '--write');
3458
+ const all = hasFlag(args, '--all');
3459
+ const prunePreviewRequested = hasFlag(args, '--prune-preview');
3460
+ const outputPath = readFlag(args, '--output', '') || readFlag(args, '--out', '');
3461
+ const ref = stripKnownFlags(args, ['--limit', '--output', '--out'], ['--json', '--write', '--all', '--prune-preview'])[0] || '';
3462
+ const limit = all ? Number.MAX_SAFE_INTEGER : readPositiveIntegerFlag(args, '--limit', 12, { json: asJson });
3463
+ const missions = listMissions();
3464
+ const mission = ref
3465
+ ? resolveMission(ref)
3466
+ : (missions.find((row) => !TERMINAL_STATUSES.has(row.status)) || missions[0] || null);
3467
+ if (ref && !mission) {
3468
+ exitMissionError(`Mission "${ref}" not found.`, 1, asJson);
3469
+ }
3470
+ if (!mission) {
3471
+ printJsonOrText(
3472
+ {
3473
+ ok: true,
3474
+ action: 'mission_timeline',
3475
+ mission: null,
3476
+ timeline: [],
3477
+ empty_state_display: missionTimelineEmptyStateDisplay(null, 0),
3478
+ },
3479
+ ['No missions yet. Run: atris mission start "..." --owner <member>'],
3480
+ asJson,
3481
+ );
3482
+ return;
3483
+ }
3484
+ const root = mission.worktree_root || process.cwd();
3485
+ const timelineResult = missionLandingTimeline(mission, root, limit);
3486
+ const timeline = timelineResult.items;
3487
+ const currentLanding = missionTimelineCurrentLanding(timeline);
3488
+ const timelineItemDisplay = (item) => ({
3489
+ changed_label: 'Changed',
3490
+ changed: item.changed || 'Landing recorded.',
3491
+ next_label: 'Next',
3492
+ next: item.next || null,
3493
+ proof_label: 'Proof',
3494
+ proof: item.receipt_path || null,
3495
+ });
3496
+ const currentLandingDisplay = currentLanding ? {
3497
+ label: 'Current landing',
3498
+ ...timelineItemDisplay(currentLanding),
3499
+ } : null;
3500
+ const historyWithoutCurrent = currentLanding ? timeline.slice(0, -1) : timeline;
3501
+ const historyWithoutCurrentDisplay = historyWithoutCurrent.map((item, index) => ({
3502
+ index: index + 1,
3503
+ label: `History item ${index + 1}`,
3504
+ ...timelineItemDisplay(item),
3505
+ }));
3506
+ const timelineDisplay = timeline.map((item, index) => ({
3507
+ index: index + 1,
3508
+ label: `Timeline item ${index + 1}`,
3509
+ at_label: 'When',
3510
+ at: item.at || null,
3511
+ ...timelineItemDisplay(item),
3512
+ }));
3513
+ const nextMove = missionTimelineNextMove(mission, currentLanding);
3514
+ const generatedAt = stampIso();
3515
+ let artifactPath = null;
3516
+ let prunePreview = null;
3517
+ if (write || prunePreviewRequested) {
3518
+ try {
3519
+ prunePreview = pruneRuns(root, { keepNewest: 200, keepDays: 14 });
3520
+ } catch (error) {
3521
+ prunePreview = { error: error.message || String(error) };
3522
+ }
3523
+ }
3524
+ if (write) {
3525
+ const outPath = outputPath
3526
+ ? path.resolve(root, outputPath)
3527
+ : defaultMissionTimelinePath(root, mission);
3528
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
3529
+ fs.writeFileSync(outPath, missionTimelineMarkdown(mission, timeline, { prunePreview, generatedAt, timelineMeta: timelineResult.meta }), 'utf8');
3530
+ artifactPath = path.relative(root, outPath);
3531
+ }
3532
+ const pruneSummary = missionTimelinePruneSummaryObject(prunePreview);
3533
+ const pruneDisplay = {
3534
+ label: 'Prune preview',
3535
+ command: missionRunPrunePreviewCommand(mission),
3536
+ available: Boolean(pruneSummary),
3537
+ ok: pruneSummary?.ok ?? null,
3538
+ summary: pruneSummary?.text || null,
3539
+ would_prune_label: 'Would prune',
3540
+ prune_count: pruneSummary?.prune_count ?? null,
3541
+ prune_bytes_text: pruneSummary?.prune_bytes_text || null,
3542
+ deleted_label: 'Deleted',
3543
+ deleted_count: pruneSummary?.deleted_count ?? null,
3544
+ };
3545
+ const payload = {
3546
+ ok: true,
3547
+ action: 'mission_timeline',
3548
+ generated_at: generatedAt,
3549
+ generated: {
3550
+ label: 'Generated at',
3551
+ at: generatedAt,
3552
+ },
3553
+ schema_display: {
3554
+ label: 'Schema',
3555
+ name: 'atris.mission_timeline',
3556
+ version: 1,
3557
+ primary_objects: [
3558
+ 'display',
3559
+ 'summary_display',
3560
+ 'navigation_display',
3561
+ 'filter_display',
3562
+ 'mission_display',
3563
+ 'current_landing_display',
3564
+ 'history_without_current_display',
3565
+ 'timeline_display',
3566
+ 'timeline_meta_display',
3567
+ 'empty_state_display',
3568
+ 'status_display',
3569
+ 'actions_display',
3570
+ 'proof_display',
3571
+ 'receipt_display',
3572
+ 'export_display',
3573
+ 'prune_display',
3574
+ 'artifact_display',
3575
+ ],
3576
+ },
3577
+ summary_display: {
3578
+ label: 'Summary',
3579
+ title: `Mission timeline: ${mission.objective}`,
3580
+ count: missionTimelineCountLine(timelineResult.meta),
3581
+ latest_label: 'Latest',
3582
+ latest: currentLanding?.changed || null,
3583
+ proof_label: 'Proof',
3584
+ proof: currentLanding?.receipt_path || null,
3585
+ next_label: 'Next',
3586
+ next: nextMove,
3587
+ },
3588
+ display: {
3589
+ title: `Mission timeline: ${mission.objective}`,
3590
+ generated: `Generated at: ${generatedAt}`,
3591
+ count: missionTimelineCountLine(timelineResult.meta),
3592
+ current_landing_label: 'Current landing',
3593
+ history_label: 'History',
3594
+ next_label: 'Next',
3595
+ },
3596
+ status_display: {
3597
+ label: 'Status',
3598
+ mission_status_label: 'Mission status',
3599
+ mission_status: mission.status,
3600
+ history_status_label: 'History status',
3601
+ history_status: timelineResult.meta.truncated ? 'Compact history' : 'Full history',
3602
+ count: missionTimelineCountLine(timelineResult.meta),
3603
+ truncated: Boolean(timelineResult.meta.truncated),
3604
+ hidden_count: timelineResult.meta.hidden_count,
3605
+ },
3606
+ empty_state_display: missionTimelineEmptyStateDisplay(mission, timeline.length),
3607
+ mission: {
3608
+ id: mission.id,
3609
+ objective: mission.objective,
3610
+ status: mission.status,
3611
+ },
3612
+ mission_labels: {
3613
+ mission: 'Mission',
3614
+ objective: 'Objective',
3615
+ status: 'Status',
3616
+ },
3617
+ mission_display: {
3618
+ label: 'Mission',
3619
+ title: mission.objective,
3620
+ id: mission.id,
3621
+ status: mission.status,
3622
+ },
3623
+ operator_commands: {
3624
+ timeline: missionRunTimelineCommand(mission),
3625
+ export: missionRunExportCommand(mission),
3626
+ prune_preview: missionRunPrunePreviewCommand(mission),
3627
+ },
3628
+ commands: {
3629
+ timeline: missionRunTimelineCommand(mission),
3630
+ export: missionRunExportCommand(mission),
3631
+ prune_preview: missionRunPrunePreviewCommand(mission),
3632
+ },
3633
+ actions_display: {
3634
+ label: 'Actions',
3635
+ items: [
3636
+ { label: 'Timeline', command: missionRunTimelineCommand(mission) },
3637
+ { label: 'Export', command: missionRunExportCommand(mission) },
3638
+ { label: 'Prune preview', command: missionRunPrunePreviewCommand(mission) },
3639
+ ],
3640
+ },
3641
+ navigation_display: {
3642
+ label: 'Navigation',
3643
+ current_label: 'Current view',
3644
+ current: 'timeline',
3645
+ items: [
3646
+ { key: 'timeline', label: 'Timeline', command: missionRunTimelineCommand(mission), active: true },
3647
+ { key: 'export', label: 'Full history', command: missionRunExportCommand(mission), active: false },
3648
+ { key: 'prune_preview', label: 'Prune preview', command: missionRunPrunePreviewCommand(mission), active: false },
3649
+ ],
3650
+ },
3651
+ filter_display: {
3652
+ label: 'Filters',
3653
+ active_label: 'Active filter',
3654
+ active: all ? 'full_history' : 'latest',
3655
+ limit_label: 'Limit',
3656
+ limit: timelineResult.meta.limit,
3657
+ shown_count: timelineResult.meta.shown_count,
3658
+ total_count: timelineResult.meta.total_count,
3659
+ hidden_count: timelineResult.meta.hidden_count,
3660
+ truncated_label: 'Truncated',
3661
+ truncated: Boolean(timelineResult.meta.truncated),
3662
+ items: [
3663
+ { key: 'latest', label: 'Latest', command: missionRunTimelineCommand(mission), active: !all },
3664
+ { key: 'full_history', label: 'Full history', command: `atris mission timeline ${mission.id} --all`, active: Boolean(all) },
3665
+ ],
3666
+ },
3667
+ current_landing: currentLanding,
3668
+ current_landing_display: currentLandingDisplay,
3669
+ current_landing_label: 'Current landing',
3670
+ history_without_current: historyWithoutCurrent,
3671
+ history_without_current_display: historyWithoutCurrentDisplay,
3672
+ history_without_current_count: historyWithoutCurrent.length,
3673
+ has_history_without_current: historyWithoutCurrent.length > 0,
3674
+ history_label: 'History',
3675
+ labels: {
3676
+ current_landing: 'Current landing',
3677
+ history: 'History',
3678
+ },
3679
+ counts: {
3680
+ timeline: timeline.length,
3681
+ history_without_current: historyWithoutCurrent.length,
3682
+ total: timelineResult.meta.total_count,
3683
+ hidden: timelineResult.meta.hidden_count,
3684
+ shown: timelineResult.meta.shown_count,
3685
+ },
3686
+ booleans: {
3687
+ has_current_landing: Boolean(currentLanding),
3688
+ has_history_without_current: historyWithoutCurrent.length > 0,
3689
+ truncated: Boolean(timelineResult.meta.truncated),
3690
+ all: Boolean(all),
3691
+ },
3692
+ next_move: nextMove,
3693
+ next: {
3694
+ label: 'Next',
3695
+ move: nextMove,
3696
+ has_move: Boolean(nextMove),
3697
+ },
3698
+ timeline,
3699
+ timeline_display: timelineDisplay,
3700
+ timeline_meta: timelineResult.meta,
3701
+ timeline_meta_display: {
3702
+ label: 'Timeline metadata',
3703
+ shown_label: 'Shown',
3704
+ shown_count: timelineResult.meta.shown_count,
3705
+ total_label: 'Total',
3706
+ total_count: timelineResult.meta.total_count,
3707
+ hidden_label: 'Hidden',
3708
+ hidden_count: timelineResult.meta.hidden_count,
3709
+ limit_label: 'Limit',
3710
+ limit: timelineResult.meta.limit,
3711
+ truncated_label: 'Truncated',
3712
+ truncated: Boolean(timelineResult.meta.truncated),
3713
+ },
3714
+ all,
3715
+ prune_summary: pruneSummary,
3716
+ prune_display: pruneDisplay,
3717
+ prune_preview: prunePreview,
3718
+ artifact: {
3719
+ path: artifactPath,
3720
+ written: Boolean(artifactPath),
3721
+ format: artifactPath ? 'markdown' : null,
3722
+ },
3723
+ artifact_display: {
3724
+ label: 'Artifact',
3725
+ path_label: 'Path',
3726
+ path: artifactPath,
3727
+ written_label: 'Written',
3728
+ written: Boolean(artifactPath),
3729
+ format_label: 'Format',
3730
+ format: artifactPath ? 'markdown' : null,
3731
+ },
3732
+ export_display: {
3733
+ label: 'Export',
3734
+ command: missionRunExportCommand(mission),
3735
+ report_label: 'Saved report',
3736
+ report_path: artifactPath,
3737
+ report_written: Boolean(artifactPath),
3738
+ report_format: artifactPath ? 'markdown' : null,
3739
+ },
3740
+ receipt_display: {
3741
+ label: 'Receipts',
3742
+ latest_label: 'Latest receipt',
3743
+ latest_path: currentLanding?.receipt_path || null,
3744
+ has_latest: Boolean(currentLanding?.receipt_path),
3745
+ count_label: 'Receipts',
3746
+ count: timeline.filter((item) => item.receipt_path).length,
3747
+ items: timeline.map((item, index) => ({
3748
+ index: index + 1,
3749
+ label: `Receipt ${index + 1}`,
3750
+ path: item.receipt_path || null,
3751
+ at: item.at || null,
3752
+ current: Boolean(currentLanding?.receipt_path && item.receipt_path === currentLanding.receipt_path),
3753
+ })),
3754
+ },
3755
+ proof_display: {
3756
+ label: 'Proof',
3757
+ latest_receipt_label: 'Latest receipt',
3758
+ latest_receipt_path: currentLanding?.receipt_path || null,
3759
+ report_label: 'Saved report',
3760
+ report_path: artifactPath,
3761
+ report_written: Boolean(artifactPath),
3762
+ report_format: artifactPath ? 'markdown' : null,
3763
+ },
3764
+ artifact_path: artifactPath,
3765
+ };
3766
+ const lines = timeline.length
3767
+ ? [
3768
+ `Mission timeline: ${mission.objective}`,
3769
+ `Generated at: ${generatedAt}`,
3770
+ missionTimelineCountLine(timelineResult.meta),
3771
+ ...missionTimelineCurrentLandingLines(currentLanding),
3772
+ ...(historyWithoutCurrent.length ? ['History:'] : []),
3773
+ ...historyWithoutCurrent.flatMap((item, index) => [
3774
+ ` ${index + 1}. ${item.changed || 'Landing recorded.'}`,
3775
+ ...(item.next ? [` Next: ${item.next}`] : []),
3776
+ ` Proof: ${item.receipt_path}`,
3777
+ ]),
3778
+ ]
3779
+ : [
3780
+ `Mission timeline: ${mission.objective}`,
3781
+ `Generated at: ${generatedAt}`,
3782
+ missionTimelineCountLine(timelineResult.meta),
3783
+ ' No landing receipts yet.',
3784
+ ` Next: atris mission run ${mission.id} --create-next`,
3785
+ ];
3786
+ if (timelineResult.meta.truncated) lines.push(`Full history: ${missionRunExportCommand(mission)}`);
3787
+ if (artifactPath) lines.push(`Saved: ${artifactPath}`);
3788
+ const pruneLine = missionTimelinePruneSummaryLine(prunePreview);
3789
+ if (pruneLine) lines.push(pruneLine);
3790
+ printJsonOrText(payload, lines, asJson);
3791
+ }
3792
+
3793
+ function missionReceiptHealth(mission, root = process.cwd()) {
3794
+ const receiptPath = String(mission?.receipt_path || '').trim();
3795
+ if (!receiptPath) return { ok: false, reason: 'missing_receipt_path' };
3796
+ const file = path.isAbsolute(receiptPath) ? receiptPath : path.join(root, receiptPath);
3797
+ let receipt = null;
3798
+ try {
3799
+ receipt = JSON.parse(fs.readFileSync(file, 'utf8'));
3800
+ } catch {
3801
+ return { ok: false, reason: 'receipt_not_found', receipt_path: receiptPath };
3802
+ }
3803
+ const missionVerifierPassedState = mission?.verifier_result?.passed === true;
3804
+ const receiptPassed = receipt?.result?.passed === true
3805
+ || receipt?.result?.verifier_result?.passed === true;
3806
+ if (!missionVerifierPassedState || !receiptPassed) {
3807
+ return { ok: false, reason: 'verifier_not_passed', receipt_path: receiptPath };
3808
+ }
3809
+ return { ok: true, reason: 'verifier_passed', receipt_path: receiptPath };
3810
+ }
3811
+
3812
+ function collectMissionDoctorFindings(root = process.cwd(), options = {}) {
3813
+ const localOnly = options.localOnly === true;
3814
+ let missions = listMissions(root);
3815
+ if (!localOnly) {
1393
3816
  const seen = new Set(missions.map((mission) => mission.id));
1394
- for (const rolled of listWorktreeRollupMissions()) {
3817
+ for (const rolled of listWorktreeRollupMissions(root)) {
1395
3818
  if (seen.has(rolled.id)) continue;
1396
3819
  seen.add(rolled.id);
1397
3820
  missions.push(rolled);
1398
3821
  }
1399
- missions.sort((a, b) => String(b.updated_at || b.created_at || '').localeCompare(String(a.updated_at || a.created_at || '')));
1400
3822
  }
1401
- if (ref && !missions.length) {
1402
- exitMissionError(`Mission "${ref}" not found.`, 1, asJson);
3823
+ missions = missions.map(missionStatusView);
3824
+ const findings = [];
3825
+ const add = (mission, code, message, severity = 'high', extra = {}) => {
3826
+ findings.push({
3827
+ severity,
3828
+ code,
3829
+ mission_id: mission.id,
3830
+ owner: mission.owner,
3831
+ status: mission.status,
3832
+ objective: mission.objective,
3833
+ message,
3834
+ next: extra.next || `atris mission status ${mission.id}`,
3835
+ ...extra,
3836
+ });
3837
+ };
3838
+
3839
+ for (const mission of missions) {
3840
+ const status = String(mission.status || '');
3841
+ const active = !TERMINAL_STATUSES.has(status);
3842
+ const objective = String(mission.objective || '').trim();
3843
+ if (active && !effectiveMissionVerifier(mission)) {
3844
+ add(
3845
+ mission,
3846
+ 'missing_verifier',
3847
+ 'Mission has no verifier, so it cannot prove done work.',
3848
+ 'high',
3849
+ { next: `atris mission start "${objective}" --owner ${mission.owner} --verify "<cmd>"` },
3850
+ );
3851
+ }
3852
+ if (active && /^(?:--)?help$/i.test(objective)) {
3853
+ add(
3854
+ mission,
3855
+ 'accidental_help_mission',
3856
+ 'Looks like a help flag became a mission.',
3857
+ 'medium',
3858
+ { next: `atris mission stop ${mission.id} --reason "accidental help mission"` },
3859
+ );
3860
+ }
3861
+ if (status === 'ready') {
3862
+ const receiptRoot = mission.worktree_root || root;
3863
+ const health = missionReceiptHealth(mission, receiptRoot);
3864
+ if (!health.ok) {
3865
+ add(
3866
+ mission,
3867
+ 'stale_ready_receipt',
3868
+ `Ready mission does not have a fresh passing receipt (${health.reason}).`,
3869
+ 'high',
3870
+ {
3871
+ receipt_path: health.receipt_path || mission.receipt_path || null,
3872
+ receipt_reason: health.reason,
3873
+ next: `atris mission tick ${mission.id} --verify`,
3874
+ },
3875
+ );
3876
+ }
3877
+ }
3878
+ if (mission.always_on === true && status === 'blocked') {
3879
+ add(
3880
+ mission,
3881
+ 'blocked_always_on_loop',
3882
+ 'Always-on mission is blocked and will not keep improving.',
3883
+ 'high',
3884
+ { next: `atris mission run ${mission.id} --max-ticks 1` },
3885
+ );
3886
+ }
1403
3887
  }
1404
- missions = missions.slice(0, limit);
1405
- const reports = missions.map((mission) => missionReportFor(mission, mission.worktree_root || process.cwd()));
1406
- printJsonOrText(
1407
- { ok: true, action: 'mission_report', reports },
1408
- reports.length
1409
- ? reports.flatMap((report) => [
1410
- `Mission: ${report.objective}`,
1411
- ` state: ${report.status}`,
1412
- ` What happened: ${report.operator_outcome}`,
1413
- ` Worker: ${report.worker}`,
1414
- ` Worker summary: ${report.worker_summary}`,
1415
- ...(report.worker_receipt_path ? [` Worker receipt: ${report.worker_receipt_path}`] : []),
1416
- ...(report.verifier_receipt_path ? [` Verifier receipt: ${report.verifier_receipt_path}`] : []),
1417
- ` Next: ${report.operator_next}`,
1418
- ])
1419
- : ['No missions yet. Run: atris mission start "..." --owner <member>'],
1420
- asJson,
1421
- );
3888
+
3889
+ findings.sort((a, b) => {
3890
+ const rank = { high: 0, medium: 1, low: 2 };
3891
+ return (rank[a.severity] ?? 9) - (rank[b.severity] ?? 9)
3892
+ || String(a.code).localeCompare(String(b.code))
3893
+ || String(a.objective).localeCompare(String(b.objective));
3894
+ });
3895
+ return { missions, findings };
3896
+ }
3897
+
3898
+ function doctorMission(args) {
3899
+ const asJson = wantsJson(args);
3900
+ const localOnly = hasFlag(args, '--local');
3901
+ const { missions, findings } = collectMissionDoctorFindings(process.cwd(), { localOnly });
3902
+ const payload = {
3903
+ ok: findings.length === 0,
3904
+ action: 'mission_doctor',
3905
+ checked_count: missions.length,
3906
+ finding_count: findings.length,
3907
+ findings,
3908
+ };
3909
+ const lines = findings.length
3910
+ ? [
3911
+ `Mission doctor: ${findings.length} problem(s) across ${missions.length} mission(s)`,
3912
+ ...findings.map((finding) => `${finding.severity.toUpperCase()} ${finding.code}: ${finding.objective} -> ${finding.next}`),
3913
+ ]
3914
+ : [`Mission doctor: clean (${missions.length} mission(s) checked)`];
3915
+ printJsonOrText(payload, lines, asJson);
3916
+ if (findings.length) process.exitCode = 1;
1422
3917
  }
1423
3918
 
1424
3919
  // `atris mission watch [id]` — read-only live heartbeat. Prints a line per tick as it
@@ -1488,11 +3983,8 @@ function writeReceipt(mission, result, root = process.cwd()) {
1488
3983
  fs.mkdirSync(paths.runsDir, { recursive: true });
1489
3984
  const safeTime = stampIso().replace(/[:.]/g, '-');
1490
3985
  const receiptPath = path.join(paths.runsDir, `mission-${mission.id}-${safeTime}.json`);
1491
- // Back-compat: legacy consumers read receipt.result.passed (verifier-only shape).
1492
- // New shape nests verifier under result.verifier_result, so mirror .passed at top.
1493
- const finalResult = (result && typeof result === 'object' && result.verifier_result && !('passed' in result))
1494
- ? { ...result, passed: !!result.verifier_result.passed }
1495
- : result;
3986
+ const relativeReceiptPath = path.relative(root, receiptPath);
3987
+ const finalResult = normalizeMissionReceiptResult(mission, result, relativeReceiptPath);
1496
3988
  fs.writeFileSync(receiptPath, JSON.stringify({
1497
3989
  schema: 'atris.mission_receipt.v1',
1498
3990
  mission_id: mission.id,
@@ -1502,7 +3994,7 @@ function writeReceipt(mission, result, root = process.cwd()) {
1502
3994
  verifier: mission.verifier || null,
1503
3995
  result: finalResult,
1504
3996
  }, null, 2) + '\n', 'utf8');
1505
- return path.relative(root, receiptPath);
3997
+ return relativeReceiptPath;
1506
3998
  }
1507
3999
 
1508
4000
  function shellQuote(value) {
@@ -1746,6 +4238,10 @@ function nextCandidateTickAction(mission) {
1746
4238
  return `next move: run atris mission run ${mission.id}${completeFlag}`;
1747
4239
  }
1748
4240
 
4241
+ function nextCandidateRunCommand(mission) {
4242
+ return `atris mission run ${mission.id}`;
4243
+ }
4244
+
1749
4245
  function missionVerifierPassed(mission) {
1750
4246
  return (mission && mission.verifier_result && mission.verifier_result.passed) === true;
1751
4247
  }
@@ -1788,7 +4284,7 @@ function missionHeartbeatLines(mission, now = new Date()) {
1788
4284
  const lastTickAt = mission.last_tick_at ? Date.parse(mission.last_tick_at) : NaN;
1789
4285
  if (Number.isFinite(lastTickAt)) {
1790
4286
  const age = formatDurationShort((now.getTime() - lastTickAt) / 1000);
1791
- const verifier = mission.verifier
4287
+ const verifier = effectiveMissionVerifier(mission)
1792
4288
  ? (mission.verifier_result ? (mission.verifier_result.passed ? 'verifier passed' : 'verifier failed') : 'verifier not run')
1793
4289
  : 'no verifier';
1794
4290
  const tickIdx = mission.last_tick_index != null ? `#${mission.last_tick_index}, ` : '';
@@ -1809,6 +4305,23 @@ function missionHasHumanAsks(mission) {
1809
4305
  && mission.human_asks.some((ask) => String(ask || '').trim());
1810
4306
  }
1811
4307
 
4308
+ function missionTaskHumanAcceptWaiting(mission) {
4309
+ const taskId = missionTaskSpine(mission)?.task_id;
4310
+ if (!taskId) return false;
4311
+ try {
4312
+ const taskDb = require('../lib/task-db');
4313
+ const db = taskDb.open();
4314
+ const task = taskDb.getTask(db, taskId);
4315
+ if (!task) return false;
4316
+ const metadata = task.metadata || {};
4317
+ return task.status === 'review'
4318
+ && metadata.approval_status === 'pending'
4319
+ && metadata.agent_certified === true;
4320
+ } catch {
4321
+ return false;
4322
+ }
4323
+ }
4324
+
1812
4325
  function missionIsRunnable(mission) {
1813
4326
  return mission
1814
4327
  && GOAL_LOOP_STATUSES.has(String(mission.status || ''))
@@ -1822,7 +4335,8 @@ function missionSortTime(mission) {
1822
4335
  function selectDueMission(root = process.cwd(), now = new Date()) {
1823
4336
  const candidates = listMissions(root)
1824
4337
  .filter((mission) => missionSelectableForLoop(mission, now))
1825
- .filter((mission) => mission.verifier)
4338
+ .filter((mission) => !missionTaskHumanAcceptWaiting(mission))
4339
+ .filter((mission) => effectiveMissionVerifier(mission) || callerSessionMissionReadyForDue(mission))
1826
4340
  .filter((mission) => mission.always_on || !missionVerifierPassed(mission))
1827
4341
  .filter((mission) => missionDueAt(mission, now));
1828
4342
 
@@ -1838,8 +4352,16 @@ function selectDueMission(root = process.cwd(), now = new Date()) {
1838
4352
  return candidates[0] || null;
1839
4353
  }
1840
4354
 
4355
+ function callerSessionMissionReadyForDue(mission) {
4356
+ return runnerUsesCallerSession(mission?.runner)
4357
+ && mission?.always_on === true
4358
+ && Boolean(codexNativeGoalAck(mission))
4359
+ && missionTaskSpine(mission)?.has_task === true;
4360
+ }
4361
+
1841
4362
  function missionSelectableForCodexGoal(mission, now = new Date()) {
1842
4363
  if (!missionIsRunnable(mission)) return false;
4364
+ if (missionTaskHumanAcceptWaiting(mission)) return false;
1843
4365
  if (mission.always_on && missionVerifierPassed(mission) && !missionDueAt(mission, now)) {
1844
4366
  return parseCadenceSeconds(mission.cadence) > 0;
1845
4367
  }
@@ -1854,7 +4376,7 @@ function selectCodexGoalMission(root = process.cwd(), options = {}, now = new Da
1854
4376
  if (requestedId) {
1855
4377
  const exact = candidates.find((mission) => mission.id === requestedId || mission.id.startsWith(requestedId));
1856
4378
  if (exact) {
1857
- const due = exact.verifier && missionDueAt(exact, now);
4379
+ const due = effectiveMissionVerifier(exact) && missionDueAt(exact, now);
1858
4380
  return { mission: exact, reason: due ? 'due' : 'selected' };
1859
4381
  }
1860
4382
  }
@@ -1864,8 +4386,8 @@ function selectCodexGoalMission(root = process.cwd(), options = {}, now = new Da
1864
4386
  const bCaller = runnerUsesCallerSession(b.runner) ? 1 : 0;
1865
4387
  if (aCaller !== bCaller) return bCaller - aCaller;
1866
4388
 
1867
- const aVerifier = a.verifier ? 1 : 0;
1868
- const bVerifier = b.verifier ? 1 : 0;
4389
+ const aVerifier = effectiveMissionVerifier(a) ? 1 : 0;
4390
+ const bVerifier = effectiveMissionVerifier(b) ? 1 : 0;
1869
4391
  if (aVerifier !== bVerifier) return bVerifier - aVerifier;
1870
4392
 
1871
4393
  const aTime = missionSortTime(a);
@@ -1875,7 +4397,7 @@ function selectCodexGoalMission(root = process.cwd(), options = {}, now = new Da
1875
4397
 
1876
4398
  const mission = candidates[0] || null;
1877
4399
  if (!mission) return null;
1878
- const due = mission.verifier && missionDueAt(mission, now);
4400
+ const due = effectiveMissionVerifier(mission) && missionDueAt(mission, now);
1879
4401
  return { mission, reason: due ? 'due' : 'active' };
1880
4402
  }
1881
4403
 
@@ -1887,8 +4409,8 @@ function selectAtrisGoalMission(root = process.cwd(), options = {}, now = new Da
1887
4409
  if (exact) return { mission: exact, reason: 'selected' };
1888
4410
  }
1889
4411
  runnable.sort((a, b) => {
1890
- const aDue = a.verifier && missionDueAt(a, now) ? 1 : 0;
1891
- const bDue = b.verifier && missionDueAt(b, now) ? 1 : 0;
4412
+ const aDue = effectiveMissionVerifier(a) && missionDueAt(a, now) ? 1 : 0;
4413
+ const bDue = effectiveMissionVerifier(b) && missionDueAt(b, now) ? 1 : 0;
1892
4414
  if (aDue !== bDue) return bDue - aDue;
1893
4415
 
1894
4416
  const aTime = missionSortTime(a);
@@ -1897,7 +4419,7 @@ function selectAtrisGoalMission(root = process.cwd(), options = {}, now = new Da
1897
4419
  });
1898
4420
  const mission = runnable[0] || null;
1899
4421
  if (!mission) return null;
1900
- const due = mission.verifier && missionDueAt(mission, now);
4422
+ const due = effectiveMissionVerifier(mission) && missionDueAt(mission, now);
1901
4423
  return { mission, reason: due ? 'due' : 'active' };
1902
4424
  }
1903
4425
 
@@ -1913,10 +4435,234 @@ function codexNativeGoalAck(mission, objective = codexGoalObjective(mission)) {
1913
4435
  return ack;
1914
4436
  }
1915
4437
 
4438
+ function codexRuntimeGoalStateFromOptions(options = {}) {
4439
+ const status = String(options.nativeGoalStatus || options.visibleGoalStatus || '').trim().toLowerCase();
4440
+ const objective = String(options.nativeGoalObjective || options.visibleGoalObjective || '').trim();
4441
+ if (!status && !objective) return null;
4442
+ return {
4443
+ status: status || null,
4444
+ objective: objective || null,
4445
+ };
4446
+ }
4447
+
4448
+ function codexRuntimeStateBlocksMissionSlot(runtimeGoalState, mission) {
4449
+ if (!runtimeGoalState) return true;
4450
+ if (runtimeGoalState.status && !new Set(['active', 'paused']).has(runtimeGoalState.status)) return false;
4451
+ if (!runtimeGoalState.objective) return new Set(['active', 'paused']).has(runtimeGoalState.status);
4452
+ return runtimeGoalState.objective === codexGoalObjective(mission);
4453
+ }
4454
+
1916
4455
  function codexGoalAckCommand(mission, objective = codexGoalObjective(mission)) {
1917
4456
  return `atris mission goal ack ${mission.id} --runtime codex --status active --objective ${shellQuote(objective)} --json`;
1918
4457
  }
1919
4458
 
4459
+ function codexNativeGoalReplaceAction(newMission, activeMission, runtimeGoalState = null, commands = {}) {
4460
+ const fromObjective = codexGoalObjective(activeMission);
4461
+ const toObjective = codexGoalObjective(newMission);
4462
+ return {
4463
+ runtime: 'codex',
4464
+ tool: 'replace_goal',
4465
+ available: false,
4466
+ blocked_by: 'codex_runtime_missing_replace_goal_tool',
4467
+ args: {
4468
+ objective: toObjective,
4469
+ from_objective: fromObjective,
4470
+ to_objective: toObjective,
4471
+ from_mission_id: activeMission.id,
4472
+ to_mission_id: newMission.id,
4473
+ current_status: runtimeGoalState?.status || null,
4474
+ },
4475
+ after_success: {
4476
+ ack_new_mission: commands.ack_new_mission || codexGoalAckCommand(newMission, toObjective),
4477
+ },
4478
+ fallback: {
4479
+ reason: 'This Codex runtime exposes get_goal/create_goal/update_goal but not replace_goal or resume_goal.',
4480
+ commands,
4481
+ },
4482
+ };
4483
+ }
4484
+
4485
+ function codexNativeGoalRuntimeReplaceAction(newMission, runtimeGoalState = null, commands = {}) {
4486
+ const toObjective = codexGoalObjective(newMission);
4487
+ const fromObjective = runtimeGoalState?.objective || null;
4488
+ const ackNewMission = commands.ack_new_mission || codexGoalAckCommand(newMission, toObjective);
4489
+ const completeCurrentGoal = 'update_goal({ status: "complete" })';
4490
+ const createNewGoal = `create_goal({ objective: ${JSON.stringify(toObjective)} })`;
4491
+ const supersedeApproved = commands.allow_native_goal_supersede === true;
4492
+ return {
4493
+ runtime: 'codex',
4494
+ tool: 'replace_goal',
4495
+ available: false,
4496
+ blocked_by: 'codex_runtime_missing_replace_goal_tool',
4497
+ args: {
4498
+ objective: toObjective,
4499
+ from_objective: fromObjective,
4500
+ to_objective: toObjective,
4501
+ from_mission_id: null,
4502
+ to_mission_id: newMission.id,
4503
+ current_status: runtimeGoalState?.status || null,
4504
+ },
4505
+ after_success: {
4506
+ ack_new_mission: ackNewMission,
4507
+ },
4508
+ fallback: {
4509
+ reason: 'This Codex runtime exposes get_goal/create_goal/update_goal but not replace_goal or resume_goal.',
4510
+ automatic: supersedeApproved,
4511
+ approved: supersedeApproved,
4512
+ executable_now: supersedeApproved,
4513
+ blocked_by: supersedeApproved ? null : 'native_goal_cancel_or_supersede_tool_missing',
4514
+ safe_when: 'Use only when a mission handoff proves the paused goal is intentionally superseded; update_goal complete otherwise misrepresents abandoned work as finished.',
4515
+ sequence_name: 'complete_paused_goal_then_create_new_goal',
4516
+ sequence: [
4517
+ completeCurrentGoal,
4518
+ createNewGoal,
4519
+ ackNewMission,
4520
+ ],
4521
+ commands: {
4522
+ ...commands,
4523
+ complete_current_goal: completeCurrentGoal,
4524
+ create_new_goal: createNewGoal,
4525
+ ack_new_mission: ackNewMission,
4526
+ },
4527
+ },
4528
+ };
4529
+ }
4530
+
4531
+ function codexRuntimeGoalNeedsReplace(runtimeGoalState, objective) {
4532
+ if (!runtimeGoalState) return false;
4533
+ if (runtimeGoalState.status !== 'paused') return false;
4534
+ if (!runtimeGoalState.objective) return true;
4535
+ return runtimeGoalState.objective !== objective;
4536
+ }
4537
+
4538
+ function writeDirectRunCodexGoalRequest(mission, root = process.cwd()) {
4539
+ if (!isCodexGoalMission(mission)) return null;
4540
+ const request = {
4541
+ schema: 'atris.codex_goal_direct_run_request.v1',
4542
+ source: 'mission_run_objective',
4543
+ mission_id: mission.id,
4544
+ objective: codexGoalObjective(mission),
4545
+ mission_run_preflight: mission.mission_run_preflight || null,
4546
+ requested_at: stampIso(),
4547
+ };
4548
+ const paths = statePaths(root);
4549
+ fs.mkdirSync(path.dirname(paths.codexGoalRequestJson), { recursive: true });
4550
+ fs.writeFileSync(paths.codexGoalRequestJson, JSON.stringify(request, null, 2) + '\n', 'utf8');
4551
+ return request;
4552
+ }
4553
+
4554
+ function readDirectRunCodexGoalRequest(root = process.cwd(), now = new Date()) {
4555
+ const file = statePaths(root).codexGoalRequestJson;
4556
+ let request = null;
4557
+ try {
4558
+ request = JSON.parse(fs.readFileSync(file, 'utf8'));
4559
+ } catch {
4560
+ return null;
4561
+ }
4562
+ const missionId = String(request?.mission_id || '').trim();
4563
+ if (!missionId) return null;
4564
+ const mission = resolveMission(missionId, root);
4565
+ if (!mission || !isCodexGoalMission(mission)) return null;
4566
+ if (!missionSelectableForCodexGoal(mission, now)) return null;
4567
+ return { request, mission };
4568
+ }
4569
+
4570
+ function clearDirectRunCodexGoalRequestForMission(missionId, root = process.cwd()) {
4571
+ const file = statePaths(root).codexGoalRequestJson;
4572
+ let request = null;
4573
+ try {
4574
+ request = JSON.parse(fs.readFileSync(file, 'utf8'));
4575
+ } catch {
4576
+ return false;
4577
+ }
4578
+ if (String(request?.mission_id || '') !== String(missionId || '')) return false;
4579
+ try {
4580
+ fs.rmSync(file, { force: true });
4581
+ return true;
4582
+ } catch {
4583
+ return false;
4584
+ }
4585
+ }
4586
+
4587
+ function activeCodexVisibleGoalOwner(root = process.cwd(), excludeId = '', now = new Date(), runtimeGoalState = null) {
4588
+ const excluded = String(excludeId || '');
4589
+ const candidates = listMissions(root)
4590
+ .filter((mission) => mission.id !== excluded)
4591
+ .filter((mission) => isCodexGoalMission(mission))
4592
+ .filter((mission) => missionSelectableForCodexGoal(mission, now))
4593
+ .filter((mission) => Boolean(codexNativeGoalAck(mission)))
4594
+ .filter((mission) => codexRuntimeStateBlocksMissionSlot(runtimeGoalState, mission));
4595
+ candidates.sort((a, b) => {
4596
+ const aAck = Date.parse(a.native_goal_ack?.acknowledged_at || '') || 0;
4597
+ const bAck = Date.parse(b.native_goal_ack?.acknowledged_at || '') || 0;
4598
+ if (aAck !== bAck) return bAck - aAck;
4599
+ return missionSortTime(b) - missionSortTime(a);
4600
+ });
4601
+ return candidates[0] || null;
4602
+ }
4603
+
4604
+ function codexGoalActiveConflictPayload(newMission, activeMission, request = null, heartbeatMode = false, runtimeGoalState = null) {
4605
+ const newObjective = codexGoalObjective(newMission);
4606
+ const pausedConflict = runtimeGoalState?.status === 'paused';
4607
+ const holdOldMission = `atris mission pause ${activeMission.id} --reason ${shellQuote(`visible goal replaced by ${newMission.id}`)} --json`;
4608
+ const ackNewMission = codexGoalAckCommand(newMission, newObjective);
4609
+ const completeOldMission = `atris mission complete ${activeMission.id} --proof "<proof>" --json`;
4610
+ const refreshAfterResume = `atris mission goal --native-goal-status active --native-goal-objective ${shellQuote(codexGoalObjective(activeMission))} --json`;
4611
+ const nextCommand = pausedConflict
4612
+ ? `Resume the paused Codex goal, then run ${refreshAfterResume}`
4613
+ : `${holdOldMission} && ${ackNewMission}`;
4614
+ const commands = {
4615
+ hold_old_mission: holdOldMission,
4616
+ complete_old_mission: completeOldMission,
4617
+ refresh_after_resume: refreshAfterResume,
4618
+ ack_new_mission: ackNewMission,
4619
+ };
4620
+ const nativeGoalAction = codexNativeGoalReplaceAction(newMission, activeMission, runtimeGoalState, commands);
4621
+ const nativeGoalResolution = {
4622
+ action: 'replace_visible_goal',
4623
+ automatic_decision: true,
4624
+ executable_now: false,
4625
+ blocked_by: nativeGoalAction.blocked_by,
4626
+ required_tool: nativeGoalAction.tool,
4627
+ from_mission_id: activeMission.id,
4628
+ to_mission_id: newMission.id,
4629
+ };
4630
+ const conflict = {
4631
+ schema: 'atris.codex_active_goal_conflict.v1',
4632
+ message: pausedConflict
4633
+ ? `new mission created, but old mission ${activeMission.id} is paused and still occupies the visible slot.`
4634
+ : `new mission created, but old mission ${activeMission.id} still owns the visible slot.`,
4635
+ source: request?.source || 'mission_run_objective',
4636
+ status: pausedConflict ? 'paused_goal_conflict' : 'active_goal_conflict',
4637
+ new_mission_id: newMission.id,
4638
+ new_objective: newObjective,
4639
+ active_mission_id: activeMission.id,
4640
+ active_objective: codexGoalObjective(activeMission),
4641
+ next_command: nextCommand,
4642
+ native_goal_action: nativeGoalAction,
4643
+ native_goal_resolution: nativeGoalResolution,
4644
+ commands,
4645
+ new_mission: missionStatusView(newMission),
4646
+ active_mission: missionStatusView(activeMission),
4647
+ request,
4648
+ runtime_goal_state: runtimeGoalState,
4649
+ };
4650
+ return {
4651
+ ok: true,
4652
+ action: pausedConflict ? 'paused_goal_conflict' : 'active_goal_conflict',
4653
+ active_goal_conflict: conflict,
4654
+ mission: conflict.new_mission,
4655
+ conflicting_mission: conflict.active_mission,
4656
+ goal: null,
4657
+ heartbeat: heartbeatMode ? { heavy_work_performed: false, next_heavy_command: nextCommand } : undefined,
4658
+ requires_native_goal_start: true,
4659
+ requires_native_goal_replace: true,
4660
+ native_goal_action: nativeGoalAction,
4661
+ native_goal_resolution: nativeGoalResolution,
4662
+ next_command: nextCommand,
4663
+ };
4664
+ }
4665
+
1920
4666
  function codexNativeGoalAction(mission, objective = codexGoalObjective(mission)) {
1921
4667
  return {
1922
4668
  runtime: 'codex',
@@ -1929,6 +4675,16 @@ function codexNativeGoalStartInstruction(mission, objective = codexGoalObjective
1929
4675
  return `Call native Codex create_goal({ objective: ${JSON.stringify(objective)} }), then run ${codexGoalAckCommand(mission, objective)}`;
1930
4676
  }
1931
4677
 
4678
+ function codexNativeGoalReplaceInstruction(mission, runtimeGoalState = null, objective = codexGoalObjective(mission), options = {}) {
4679
+ const fromObjective = runtimeGoalState?.objective
4680
+ ? ` from paused objective ${JSON.stringify(runtimeGoalState.objective)}`
4681
+ : '';
4682
+ if (options.allowNativeGoalSupersede === true) {
4683
+ return `Supersede approved: run update_goal({ status: "complete" }), then create_goal({ objective: ${JSON.stringify(objective)} }), then run ${codexGoalAckCommand(mission, objective)}. Atris records the old paused goal as superseded.`;
4684
+ }
4685
+ return `Native Codex replace_goal is required${fromObjective} to ${JSON.stringify(objective)}, then run ${codexGoalAckCommand(mission, objective)}; this runtime currently lacks replace_goal. Fallback is update_goal({ status: "complete" }) -> create_goal({ objective: ${JSON.stringify(objective)} }) -> mission goal ack only after handoff proof says the paused goal is intentionally superseded.`;
4686
+ }
4687
+
1932
4688
  function codexNativeGoalBlockPayload(mission) {
1933
4689
  const objective = codexGoalObjective(mission);
1934
4690
  return {
@@ -1968,15 +4724,22 @@ function codexGoalNextCommand(mission) {
1968
4724
  if (taskSpine && !taskSpine.has_task && taskSpine.ensure_task_command) {
1969
4725
  return taskSpine.ensure_task_command;
1970
4726
  }
4727
+ if (missionChoosesNextMission(mission)) {
4728
+ return chooseNextMissionCommand(mission);
4729
+ }
4730
+ if (mission.status === 'ready' && mission.always_on) {
4731
+ return nextCandidateRunCommand(mission);
4732
+ }
1971
4733
  if (mission.status === 'ready') {
1972
4734
  const xpAction = missionXpReadyAction(mission, mission.receipt_path);
1973
4735
  if (xpAction) return xpAction.replace(/^queue AgentXP review: /, '');
1974
4736
  }
1975
- if (mission.verifier && missionDueAt(mission)) {
4737
+ const verifier = effectiveMissionVerifier(mission);
4738
+ if (verifier && missionDueAt(mission)) {
1976
4739
  const completeFlag = mission.always_on ? '' : ' --complete-on-pass';
1977
4740
  return `atris mission run --due --max-ticks 1${completeFlag}`;
1978
4741
  }
1979
- if (mission.verifier) {
4742
+ if (verifier) {
1980
4743
  return `atris mission tick ${mission.id} --verify --summary "<what changed>"`;
1981
4744
  }
1982
4745
  return `atris mission tick ${mission.id} --summary "<what changed>"`;
@@ -2070,6 +4833,15 @@ function writeCodexGoalState(payload, root = process.cwd()) {
2070
4833
  lines.push(`- visible goal complete: ${state.goal.visible_goal.operations.complete_after_proof}`);
2071
4834
  }
2072
4835
  lines.push(`- platform write blocked: ${state.goal.codex_tool_contract.blocked_without_platform_goal_write}`);
4836
+ } else if (state.active_goal_conflict) {
4837
+ lines.push(`- conflict: ${state.active_goal_conflict.message}`);
4838
+ lines.push(`- new mission: ${state.active_goal_conflict.new_mission_id}`);
4839
+ lines.push(`- active mission: ${state.active_goal_conflict.active_mission_id}`);
4840
+ if (state.active_goal_conflict.native_goal_action) {
4841
+ lines.push(`- native goal action: ${state.active_goal_conflict.native_goal_action.tool}`);
4842
+ lines.push(`- native goal executable now: ${state.active_goal_conflict.native_goal_action.available !== false}`);
4843
+ }
4844
+ lines.push(`- next: ${state.active_goal_conflict.next_command}`);
2073
4845
  } else {
2074
4846
  lines.push('- mission: none');
2075
4847
  }
@@ -2085,7 +4857,34 @@ function writeCodexGoalState(payload, root = process.cwd()) {
2085
4857
 
2086
4858
  function buildCodexGoalPayload(root = process.cwd(), options = {}) {
2087
4859
  const heartbeatMode = options.heartbeat === true;
2088
- const selected = selectCodexGoalMission(root, options);
4860
+ const runtimeGoalState = codexRuntimeGoalStateFromOptions(options);
4861
+ const directRequest = options.missionId
4862
+ ? (() => {
4863
+ const mission = resolveMission(options.missionId, root);
4864
+ return mission && isCodexGoalMission(mission) && missionSelectableForCodexGoal(mission)
4865
+ ? { mission, request: null }
4866
+ : null;
4867
+ })()
4868
+ : readDirectRunCodexGoalRequest(root);
4869
+ const activeGoalOwner = directRequest
4870
+ ? activeCodexVisibleGoalOwner(root, directRequest.mission.id, new Date(), runtimeGoalState)
4871
+ : null;
4872
+ if (directRequest && activeGoalOwner) {
4873
+ return codexGoalActiveConflictPayload(directRequest.mission, activeGoalOwner, directRequest.request, heartbeatMode, runtimeGoalState);
4874
+ }
4875
+ let selected = directRequest
4876
+ ? { mission: directRequest.mission, reason: 'direct_run', direct_goal_request: directRequest.request }
4877
+ : selectCodexGoalMission(root, options);
4878
+ if (!selected && !directRequest) {
4879
+ const continuation = seedNextMoveContinuationGoal(root);
4880
+ if (continuation?.mission) {
4881
+ selected = {
4882
+ mission: continuation.mission,
4883
+ reason: continuation.inserted ? 'next_move_continuation_seeded' : 'next_move_continuation_active',
4884
+ seeded_continuation_goal: continuation,
4885
+ };
4886
+ }
4887
+ }
2089
4888
  if (!selected) {
2090
4889
  const heartbeat = heartbeatMode ? codexGoalHeartbeat(null, null) : undefined;
2091
4890
  return {
@@ -2096,11 +4895,20 @@ function buildCodexGoalPayload(root = process.cwd(), options = {}) {
2096
4895
  };
2097
4896
  }
2098
4897
 
2099
- const { mission, reason } = selected;
4898
+ const { mission, reason, direct_goal_request: directGoalRequest, seeded_continuation_goal: seededContinuationGoal } = selected;
2100
4899
  const taskSpine = missionTaskSpine(mission);
2101
4900
  const missionView = missionStatusView(mission);
2102
4901
  const objective = codexGoalObjective(mission);
2103
4902
  const ack = codexNativeGoalAck(mission, objective);
4903
+ const runtimeNeedsReplace = !ack && codexRuntimeGoalNeedsReplace(runtimeGoalState, objective);
4904
+ const nativeGoalAction = ack
4905
+ ? null
4906
+ : runtimeNeedsReplace
4907
+ ? codexNativeGoalRuntimeReplaceAction(mission, runtimeGoalState, {
4908
+ ack_new_mission: codexGoalAckCommand(mission, objective),
4909
+ allow_native_goal_supersede: options.allowNativeGoalSupersede === true,
4910
+ })
4911
+ : codexNativeGoalAction(mission, objective);
2104
4912
  const goal = {
2105
4913
  objective,
2106
4914
  mission_id: mission.id,
@@ -2110,14 +4918,23 @@ function buildCodexGoalPayload(root = process.cwd(), options = {}) {
2110
4918
  executed_by: taskSpine?.executed_by || mission.executed_by || null,
2111
4919
  task_spine: taskSpine,
2112
4920
  reason,
2113
- next_command: codexGoalNextCommand(mission),
4921
+ next_command: runtimeNeedsReplace
4922
+ ? codexNativeGoalReplaceInstruction(mission, runtimeGoalState, objective, {
4923
+ allowNativeGoalSupersede: options.allowNativeGoalSupersede === true,
4924
+ })
4925
+ : codexGoalNextCommand(mission),
2114
4926
  replace_after: 'After proof or verifier pass, run atris mission goal --json again and replace the Codex /goal with the returned objective.',
2115
4927
  visible_goal: codexVisibleGoalBridge(mission, objective),
2116
4928
  codex_tool_contract: codexGoalToolContract(mission),
2117
4929
  requires_native_goal_start: !ack,
2118
- native_goal_action: ack ? null : codexNativeGoalAction(mission, objective),
4930
+ requires_native_goal_replace: runtimeNeedsReplace,
4931
+ native_goal_action: nativeGoalAction,
2119
4932
  native_goal_ack_command: codexGoalAckCommand(mission, objective),
2120
4933
  native_goal_ack: ack,
4934
+ direct_goal_request: directGoalRequest || null,
4935
+ seeded_continuation_goal: seededContinuationGoal || null,
4936
+ next_action_preview: missionChoosesNextMission(mission) ? chooseNextMissionPreview(mission, root) : (mission.next_action_preview || null),
4937
+ runtime_goal_state: runtimeGoalState,
2121
4938
  };
2122
4939
  const heartbeat = heartbeatMode ? codexGoalHeartbeat(goal, mission) : undefined;
2123
4940
  return {
@@ -2127,7 +4944,9 @@ function buildCodexGoalPayload(root = process.cwd(), options = {}) {
2127
4944
  mission: missionView,
2128
4945
  heartbeat,
2129
4946
  requires_native_goal_start: goal.requires_native_goal_start,
4947
+ requires_native_goal_replace: goal.requires_native_goal_replace,
2130
4948
  native_goal_action: goal.native_goal_action,
4949
+ runtime_goal_state: runtimeGoalState,
2131
4950
  };
2132
4951
  }
2133
4952
 
@@ -2341,6 +5160,7 @@ function goalLoopNextCommandPlan(goal) {
2341
5160
 
2342
5161
  function shouldRunGoalLoopCommand(heartbeat, plan) {
2343
5162
  if (!heartbeat?.goal) return false;
5163
+ if (heartbeat.goal.requires_native_goal_start === true) return false;
2344
5164
  if (plan && plan.run_when_due_only === false) return true;
2345
5165
  return heartbeat.heartbeat?.due === true;
2346
5166
  }
@@ -2390,6 +5210,19 @@ function computeBackoff(policy, attempt) {
2390
5210
  return Math.min(policy.maxMs, Math.round(base + jitter));
2391
5211
  }
2392
5212
 
5213
+ function missionBudgetPromptLines(mission) {
5214
+ const contract = mission?.budget_contract;
5215
+ if (!contract) return [];
5216
+ return [
5217
+ ``,
5218
+ `## Budget`,
5219
+ `Plain rule: ${contract.plain_language}`,
5220
+ `Limit: ${contract.budget_label}`,
5221
+ `Stop rule: ${contract.stop_rule}`,
5222
+ `Before acting, state the current bottleneck, the next useful move, and what proof or stop reason will make the tick honest.`,
5223
+ ];
5224
+ }
5225
+
2393
5226
  function consecutiveVerifierFails(ticks) {
2394
5227
  let n = 0;
2395
5228
  for (let i = ticks.length - 1; i >= 0; i--) {
@@ -2432,22 +5265,36 @@ function isWithinActiveHours(activeHours, now = new Date()) {
2432
5265
  return cur >= start || cur < end;
2433
5266
  }
2434
5267
 
2435
- function acquireMissionLock(missionId, root = process.cwd()) {
5268
+ function sleepSync(ms) {
5269
+ const waitMs = Math.max(0, Number(ms) || 0);
5270
+ if (!waitMs) return;
5271
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, waitMs);
5272
+ }
5273
+
5274
+ function acquireMissionLock(missionId, root = process.cwd(), options = {}) {
2436
5275
  const dir = path.join(root, '.atris', 'state');
2437
5276
  fs.mkdirSync(dir, { recursive: true });
2438
5277
  const lockFile = path.join(dir, `mission-${missionId}.lock`);
5278
+ const waitMs = Math.max(0, Number(options.waitMs) || 0);
5279
+ const deadline = waitMs ? Date.now() + waitMs : 0;
2439
5280
  let fd;
2440
- try {
2441
- fd = fs.openSync(lockFile, 'wx');
2442
- fs.writeSync(fd, JSON.stringify({ pid: process.pid, started_at: stampIso(), mission_id: missionId }));
2443
- return { ok: true, lockFile, fd };
2444
- } catch (e) {
2445
- if (e.code === 'EEXIST') {
2446
- let info = {};
2447
- try { info = JSON.parse(fs.readFileSync(lockFile, 'utf8') || '{}'); } catch {}
2448
- return { ok: false, lockFile, busy: true, holder: info };
5281
+ while (true) {
5282
+ try {
5283
+ fd = fs.openSync(lockFile, 'wx');
5284
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, started_at: stampIso(), mission_id: missionId }));
5285
+ return { ok: true, lockFile, fd };
5286
+ } catch (e) {
5287
+ if (e.code === 'EEXIST') {
5288
+ let info = {};
5289
+ try { info = JSON.parse(fs.readFileSync(lockFile, 'utf8') || '{}'); } catch {}
5290
+ if (deadline && Date.now() < deadline) {
5291
+ sleepSync(Math.min(25, deadline - Date.now()));
5292
+ continue;
5293
+ }
5294
+ return { ok: false, lockFile, busy: true, holder: info };
5295
+ }
5296
+ return { ok: false, lockFile, error: e.message };
2449
5297
  }
2450
- return { ok: false, lockFile, error: e.message };
2451
5298
  }
2452
5299
  }
2453
5300
 
@@ -2480,6 +5327,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen) {
2480
5327
  `**Verifier (frozen):** ${frozen.verifier || '(none — receipt only)'}`,
2481
5328
  `**Last status:** ${mission.status}`,
2482
5329
  `**Last tick:** ${mission.last_tick_at || 'never'}`,
5330
+ ...missionBudgetPromptLines(mission),
2483
5331
  ``,
2484
5332
  `## Your task`,
2485
5333
  `Do ONE increment of work toward the stop condition. ONE. No more.`,
@@ -2686,9 +5534,11 @@ async function runMission(args) {
2686
5534
  const verifyEach = !hasFlag(args, '--no-verify');
2687
5535
  const completeOnPass = hasFlag(args, '--complete-on-pass');
2688
5536
  const skipDrain = hasFlag(args, '--no-drain');
5537
+ const createNext = hasFlag(args, '--create-next');
2689
5538
  const maxTicksFlag = readFlag(args, '--max-ticks', '');
2690
5539
  const maxTicks = Math.max(1, Number(maxTicksFlag) || MISSION_RUN_DEFAULTS.maxTicks);
2691
- const maxWallSeconds = Math.max(60, Number(readFlag(args, '--max-wall', '')) || MISSION_RUN_DEFAULTS.maxWallSeconds);
5540
+ const maxWallFlag = readFlag(args, '--max-wall', '');
5541
+ let maxWallSeconds = Math.max(60, Number(maxWallFlag) || MISSION_RUN_DEFAULTS.maxWallSeconds);
2692
5542
  const cadenceOverride = readFlag(args, '--cadence', '');
2693
5543
  const input = missionRunInputFromArgs(args);
2694
5544
  const ref = input.ref;
@@ -2719,6 +5569,9 @@ async function runMission(args) {
2719
5569
  if (!mission) {
2720
5570
  exitMissionError(ref ? `Mission "${ref}" not found.` : 'Usage: atris mission run <id|objective> [--max-ticks 4] [--max-wall 3600]', 1, asJson);
2721
5571
  }
5572
+ if (!maxWallFlag && Number(mission.budget_contract?.requested_seconds) > 0) {
5573
+ maxWallSeconds = Math.max(60, Number(mission.budget_contract.requested_seconds));
5574
+ }
2722
5575
  if (['complete', 'stopped'].includes(mission.status)) {
2723
5576
  if (asJson) {
2724
5577
  printJsonOrText(
@@ -2779,6 +5632,7 @@ async function runMission(args) {
2779
5632
  mission = saveMission({
2780
5633
  ...mission,
2781
5634
  status: 'running',
5635
+ paused_at: null,
2782
5636
  resumed_at: stampIso(),
2783
5637
  stop_reason: null,
2784
5638
  next_action: `running: atris mission run ${mission.id}`,
@@ -2792,7 +5646,7 @@ async function runMission(args) {
2792
5646
 
2793
5647
  // Freeze run-start contract (verifier, lane). Stored on receipts, not the mission record.
2794
5648
  const frozen = {
2795
- verifier: mission.verifier || '',
5649
+ verifier: effectiveMissionVerifier(mission),
2796
5650
  lane: mission.lane || 'workspace',
2797
5651
  started_at: stampIso(),
2798
5652
  };
@@ -2820,7 +5674,9 @@ async function runMission(args) {
2820
5674
  : atris2Runner
2821
5675
  ? `atris2 (${mission.model || 'atris:fast'})`
2822
5676
  : (sessionId || `pending=${pendingSessionId}`);
2823
- console.error(`[mission run] ${mission.id}\n objective: ${mission.objective}\n lane: ${frozen.lane}\n cadence: ${cadence} (${cadenceSeconds}s)\n max_ticks: ${effectiveMaxTicks}, max_wall: ${maxWallSeconds}s\n session: ${sessionLabel}`);
5677
+ if (!asJson) {
5678
+ console.error(`[mission run] ${mission.id}\n objective: ${mission.objective}\n lane: ${frozen.lane}\n cadence: ${cadence} (${cadenceSeconds}s)\n max_ticks: ${effectiveMaxTicks}, max_wall: ${maxWallSeconds}s\n session: ${sessionLabel}`);
5679
+ }
2824
5680
 
2825
5681
  while (ticks.length < effectiveMaxTicks) {
2826
5682
  const elapsedSec = (Date.now() - startedAt) / 1000;
@@ -2831,7 +5687,7 @@ async function runMission(args) {
2831
5687
  // Re-read mission, detect mutation of frozen fields
2832
5688
  mission = resolveMission(mission.id) || mission;
2833
5689
  if (['complete', 'stopped', 'paused'].includes(mission.status)) { pauseReason = mission.status; break; }
2834
- if (mission.verifier !== frozen.verifier) { pauseReason = 'verifier-mutated'; break; }
5690
+ if (effectiveMissionVerifier(mission) !== frozen.verifier) { pauseReason = 'verifier-mutated'; break; }
2835
5691
  if ((mission.lane || 'workspace') !== frozen.lane) { pauseReason = 'lane-mutated'; break; }
2836
5692
 
2837
5693
  const tickIdx = Number(mission.last_tick_index || 0) + 1;
@@ -2984,9 +5840,12 @@ async function runMission(args) {
2984
5840
  });
2985
5841
 
2986
5842
  const xpReadyAction = missionXpReadyAction(mission, receiptPath);
5843
+ const budgetRemainingSeconds = missionFullBudgetRemainingSeconds(mission);
5844
+ const fullBudgetMode = budgetRemainingSeconds > 0;
2987
5845
  const newStatus = (verifierResult?.passed && mission.always_on) ? 'ready' :
2988
5846
  (verifierResult?.passed && xpReadyAction) ? 'ready' :
2989
- (verifierResult?.passed && completeOnPass) ? 'complete' :
5847
+ (verifierResult?.passed && completeOnPass && !fullBudgetMode) ? 'complete' :
5848
+ (verifierResult?.passed && fullBudgetMode) ? 'running' :
2990
5849
  (verifierResult?.passed ? 'ready' :
2991
5850
  (verifierResult ? 'blocked' :
2992
5851
  (result.status === 'ran' ? 'running' : mission.status)));
@@ -2995,16 +5854,22 @@ async function runMission(args) {
2995
5854
  nextAction = nextCandidateTickAction(mission);
2996
5855
  } else if (verifierResult?.passed && xpReadyAction) {
2997
5856
  nextAction = xpReadyAction;
2998
- } else if (verifierResult?.passed && completeOnPass) {
5857
+ } else if (verifierResult?.passed && completeOnPass && !fullBudgetMode) {
2999
5858
  nextAction = 'mission complete';
5859
+ } else if (verifierResult?.passed && fullBudgetMode) {
5860
+ nextAction = `proof passed with ${durationLabel(budgetRemainingSeconds)} left on the budget; pick the next useful move, then: atris mission tick ${mission.id} --verify --summary "<what changed>"`;
3000
5861
  } else if (verifierResult?.passed) {
3001
5862
  nextAction = `review proof then run: atris mission complete ${mission.id} --proof "${receiptPath}"`;
3002
5863
  } else if (verifierResult) {
3003
5864
  nextAction = 'fix verifier failure or revise mission';
5865
+ } else if (result.status === 'ran' && mission.always_on) {
5866
+ nextAction = nextCandidateTickAction(mission);
3004
5867
  }
3005
5868
  mission = saveMission({
3006
5869
  ...mission,
3007
5870
  status: newStatus,
5871
+ paused_at: null,
5872
+ stop_reason: null,
3008
5873
  last_tick_at: finishedAt,
3009
5874
  last_tick_status: result.status,
3010
5875
  last_tick_reason: result.reason,
@@ -3032,7 +5897,9 @@ async function runMission(args) {
3032
5897
  }
3033
5898
  refreshCodexGoalController(cwd);
3034
5899
 
3035
- console.error(`[tick ${tickIdx}] status=${result.status} reason=${result.reason} verifier=${verifierResult ? (verifierResult.passed ? 'pass' : 'fail') : 'skip'} -> ${receiptPath || '-'}`);
5900
+ if (!asJson) {
5901
+ console.error(`[tick ${tickIdx}] status=${result.status} reason=${result.reason} verifier=${verifierResult ? (verifierResult.passed ? 'pass' : 'fail') : 'skip'} -> ${receiptPath || '-'}`);
5902
+ }
3036
5903
 
3037
5904
  if (result.status === 'ran') {
3038
5905
  ranTicks++;
@@ -3041,7 +5908,8 @@ async function runMission(args) {
3041
5908
  backoffAttempt++;
3042
5909
  }
3043
5910
 
3044
- if (newStatus === 'complete' || (newStatus === 'ready' && !mission.always_on)) break;
5911
+ if (callerSessionRunner && result.status === 'ran') break;
5912
+ if (newStatus === 'complete' || (newStatus === 'ready' && !mission.always_on && !fullBudgetMode)) break;
3045
5913
  if (consecutiveVerifierFails(ticks) >= 2) { pauseReason = 'consecutive-verifier-fails'; break; }
3046
5914
  // A retired/inaccessible model is deterministic: the id is fixed for the run, so
3047
5915
  // every remaining tick (and every future cron firing) fails identically. Backoff
@@ -3092,6 +5960,17 @@ async function runMission(args) {
3092
5960
  }
3093
5961
 
3094
5962
  const summaryWorktree = worktreeReceipt(runWorktreeBefore, gitWorktreeSnapshot(cwd), { verifier: frozen.verifier, baseline: runWorktreeBaseline });
5963
+ const createdNext = createNext
5964
+ ? require('./loop-front').createNextLoopTask(['--as', mission.owner || 'auto-improver', '--json'], cwd, { print: false })
5965
+ : null;
5966
+ const landingSummary = {
5967
+ changed: missionRunChangedText(mission, ranTicks, effectiveMaxTicks, ticks, createdNext),
5968
+ timeline_command: missionRunTimelineCommand(mission),
5969
+ export_command: missionRunExportCommand(mission),
5970
+ prune_preview_command: missionRunPrunePreviewCommand(mission),
5971
+ next: missionRunCreatedNextLine(createdNext, continuationGoal, mission),
5972
+ };
5973
+ landingSummary.reason = missionHumanReasonText(mission, landingSummary.changed);
3095
5974
  const finalReceipt = writeReceipt(mission, {
3096
5975
  kind: 'mission_run_summary',
3097
5976
  frozen,
@@ -3102,23 +5981,17 @@ async function runMission(args) {
3102
5981
  session_id: sessionId,
3103
5982
  pending_session_id: mission.pending_session_id || null,
3104
5983
  elapsed_seconds: (Date.now() - startedAt) / 1000,
5984
+ budget_contract: mission.budget_contract || null,
3105
5985
  worktree: summaryWorktree,
5986
+ created_next: createdNext,
5987
+ landing: landingSummary,
3106
5988
  });
3107
5989
  const atrisGoalState = refreshAtrisGoalController(cwd, { missionId: mission.id });
3108
5990
  const codexGoalState = refreshCodexGoalController(cwd);
3109
5991
 
3110
5992
  printJsonOrText(
3111
- { ok: true, action: 'mission_run', mission, ran_ticks: ranTicks, tick_count: ticks.length, ticks, pause_reason: pauseReason, session_id: sessionId, summary_receipt: finalReceipt, worktree: summaryWorktree, atris_goal_state: atrisGoalState, codex_goal_state: codexGoalState, continuation_goal: continuationGoal },
3112
- [
3113
- `Ran mission ${mission.id}`,
3114
- ` objective: ${mission.objective}`,
3115
- ` ran_ticks: ${ranTicks}/${effectiveMaxTicks} (skipped/errored: ${ticks.length - ranTicks})`,
3116
- ` final state: ${mission.status}`,
3117
- pauseReason ? ` pause: ${pauseReason}` : null,
3118
- ` session: ${sessionId || '(none)'}`,
3119
- ` summary receipt: ${finalReceipt}`,
3120
- continuationGoal?.mission ? ` next goal: ${continuationGoal.mission.objective}` : null,
3121
- ].filter(Boolean),
5993
+ { ok: true, action: 'mission_run', mission, ran_ticks: ranTicks, tick_count: ticks.length, ticks, pause_reason: pauseReason, session_id: sessionId, summary_receipt: finalReceipt, budget_contract: mission.budget_contract || null, worktree: summaryWorktree, atris_goal_state: atrisGoalState, codex_goal_state: codexGoalState, continuation_goal: continuationGoal, created_next: createdNext },
5994
+ missionRunSummaryLines(mission, ranTicks, effectiveMaxTicks, finalReceipt, pauseReason, continuationGoal, ticks, createdNext),
3122
5995
  asJson,
3123
5996
  );
3124
5997
  } finally {
@@ -3133,9 +6006,10 @@ async function runMission(args) {
3133
6006
  function tickMission(args) {
3134
6007
  const asJson = wantsJson(args);
3135
6008
  const verify = hasFlag(args, '--verify');
6009
+ const verifyOverride = readFlag(args, '--verify', '');
3136
6010
  const completeOnPass = hasFlag(args, '--complete-on-pass');
3137
6011
  const summary = readFlag(args, '--summary', '');
3138
- const ref = stripKnownFlags(args, ['--summary'], ['--json', '--verify', '--complete-on-pass'])[0] || '';
6012
+ const ref = stripKnownFlags(args, ['--summary', '--verify'], ['--json', '--complete-on-pass'])[0] || '';
3139
6013
  let mission = resolveMission(ref);
3140
6014
  if (!mission) {
3141
6015
  exitMissionError(ref ? `Mission "${ref}" not found.` : 'No mission found. Run: atris mission start "..."', 1, asJson);
@@ -3171,11 +6045,17 @@ function tickMission(args) {
3171
6045
  const tickWorktreeBefore = gitWorktreeSnapshot(cwd);
3172
6046
  const worktreeBaseline = loadMissionWorktreeBaseline(mission.id, cwd);
3173
6047
 
6048
+ const effectiveVerifier = effectiveMissionVerifier(mission);
6049
+ const verifierCommand = verify
6050
+ ? String(verifyOverride || effectiveVerifier || '').trim()
6051
+ : '';
6052
+ if (verifierCommand) assertMissionVerifier(verifierCommand, asJson);
6053
+
3174
6054
  let verifierResult = null;
3175
- if (verify && mission.verifier) {
3176
- verifierResult = runVerifier(mission.verifier);
6055
+ if (verify && verifierCommand) {
6056
+ verifierResult = runVerifier(verifierCommand);
3177
6057
  }
3178
- const tickWorktree = worktreeReceipt(tickWorktreeBefore, gitWorktreeSnapshot(cwd), { verifier: mission.verifier, baseline: worktreeBaseline });
6058
+ const tickWorktree = worktreeReceipt(tickWorktreeBefore, gitWorktreeSnapshot(cwd), { verifier: verifierCommand || effectiveVerifier, baseline: worktreeBaseline });
3179
6059
 
3180
6060
  // Same layer classification as the run-tick path; manual ticks carry their
3181
6061
  // receipt text in --summary.
@@ -3198,7 +6078,7 @@ function tickMission(args) {
3198
6078
  kind: 'mission_tick',
3199
6079
  tick: tickRecord,
3200
6080
  frozen: {
3201
- verifier: mission.verifier || '',
6081
+ verifier: verifierCommand || effectiveVerifier || '',
3202
6082
  lane: mission.lane || 'workspace',
3203
6083
  started_at: tickStart,
3204
6084
  },
@@ -3207,21 +6087,40 @@ function tickMission(args) {
3207
6087
  worktree: tickWorktree,
3208
6088
  });
3209
6089
 
3210
- let status = 'running';
3211
- let nextAction = mission.verifier ? `run verifier: ${mission.verifier}` : 'attach task, verifier, or proof';
3212
- if (verifierResult?.passed) {
6090
+ let status = 'running';
6091
+ let nextAction = (verifierCommand || effectiveVerifier)
6092
+ ? `run verifier: ${verifierCommand || effectiveVerifier}`
6093
+ : (mission.always_on && missionTaskSpine(mission)?.has_task
6094
+ ? nextCandidateTickAction(mission)
6095
+ : 'attach task, verifier, or proof');
6096
+ const nextGoalChain = advanceMissionGoalChain(mission.goal_chain, summary, verifierResult);
6097
+ if (verifierResult?.passed && nextGoalChain && !nextGoalChain.pause_ready) {
6098
+ status = 'running';
6099
+ nextAction = missionGoalChainNextAction(nextGoalChain);
6100
+ } else if (verifierResult?.passed) {
3213
6101
  const xpReadyAction = missionXpReadyAction(mission, receiptPath);
3214
- status = (completeOnPass && !mission.always_on && !xpReadyAction) ? 'complete' : 'ready';
6102
+ const budgetRemainingSeconds = missionFullBudgetRemainingSeconds(mission);
6103
+ const budgetOpen = budgetRemainingSeconds > 0;
6104
+ status = (completeOnPass && !mission.always_on && !xpReadyAction && !budgetOpen) ? 'complete'
6105
+ : (budgetOpen && !mission.always_on && !xpReadyAction ? 'running' : 'ready');
3215
6106
  nextAction = mission.always_on ? nextCandidateTickAction(mission) :
3216
- (xpReadyAction || (completeOnPass ? 'mission complete' : `review proof then run: atris mission complete ${mission.id} --proof "${receiptPath}"`));
3217
- } else if (verifierResult) {
3218
- status = 'blocked';
3219
- nextAction = 'fix verifier failure or revise mission';
3220
- }
3221
- const clearsPauseState = !['paused', 'stopped'].includes(status);
3222
- const nextMission = {
3223
- ...mission,
3224
- status,
6107
+ (xpReadyAction
6108
+ || (budgetOpen
6109
+ ? `proof passed with ${durationLabel(budgetRemainingSeconds)} left on the budget; pick the next useful move, then: atris mission tick ${mission.id} --verify --summary "<what changed>"`
6110
+ : (completeOnPass ? 'mission complete' : `review proof then run: atris mission complete ${mission.id} --proof "${receiptPath}"`)));
6111
+ } else if (verifierResult) {
6112
+ status = 'blocked';
6113
+ nextAction = 'fix verifier failure or revise mission';
6114
+ } else if (nextGoalChain?.pause_ready) {
6115
+ status = 'ready';
6116
+ nextAction = `mission feels good; review proof then run: atris mission complete ${mission.id} --proof "${receiptPath}"`;
6117
+ } else if (nextGoalChain) {
6118
+ nextAction = missionGoalChainNextAction(nextGoalChain);
6119
+ }
6120
+ const clearsPauseState = !['paused', 'stopped'].includes(status);
6121
+ const nextMission = {
6122
+ ...mission,
6123
+ status,
3225
6124
  paused_at: clearsPauseState ? null : mission.paused_at || null,
3226
6125
  stop_reason: clearsPauseState ? null : mission.stop_reason || null,
3227
6126
  resumed_at: clearsPauseState && mission.status === 'paused' ? tickRecord.finished_at : mission.resumed_at || null,
@@ -3230,11 +6129,12 @@ function tickMission(args) {
3230
6129
  last_tick_status: tickRecord.status,
3231
6130
  last_tick_reason: tickRecord.reason,
3232
6131
  last_tick_index: tickIdx,
3233
- last_tick_layer: tickRecord.layer,
3234
- last_tick_layer_source: tickRecord.layer_source,
3235
- verifier_result: verifierResult || mission.verifier_result || null,
3236
- next_action: nextAction,
3237
- };
6132
+ last_tick_layer: tickRecord.layer,
6133
+ last_tick_layer_source: tickRecord.layer_source,
6134
+ verifier_result: verifierResult || mission.verifier_result || null,
6135
+ ...(nextGoalChain ? { goal_chain: nextGoalChain } : {}),
6136
+ next_action: nextAction,
6137
+ };
3238
6138
  const { mission: saved } = saveMission(nextMission, cwd, 'mission_tick', {
3239
6139
  tick_index: tickIdx, verify, verifier_result: verifierResult, receipt_path: receiptPath, layer: tickRecord.layer,
3240
6140
  });
@@ -3256,11 +6156,7 @@ function tickMission(args) {
3256
6156
  printJsonOrText(
3257
6157
  { ok: true, action: 'mission_tick', mission: outputMission, tick: tickRecord, verifier_result: verifierResult, receipt_path: receiptPath, log_path: logPath, atris_goal_state: atrisGoalState, codex_goal_state: codexGoalState, continuation_goal: continuationGoal },
3258
6158
  [
3259
- `Ticked mission: ${outputMission.objective}`,
3260
- `State: ${outputMission.status}`,
3261
- `Tick: ${tickIdx}`,
3262
- `Next: ${outputMission.next_action}`,
3263
- ...(receiptPath ? [`Receipt: ${receiptPath}`] : []),
6159
+ ...missionTickResultLines(outputMission, tickIdx, receiptPath, verifierResult, summary),
3264
6160
  ...(continuationGoal?.mission ? [`Next goal: ${continuationGoal.mission.objective}`] : []),
3265
6161
  ],
3266
6162
  asJson,
@@ -3294,7 +6190,14 @@ function receiptShowsPass(receipt) {
3294
6190
  // verifier passed. Mirrors the task plane's proof-only accept guard so the
3295
6191
  // final transition consumes the receipts instead of trusting free text.
3296
6192
  function missionCompletionGate(mission, proof, root = process.cwd()) {
3297
- if (!String(mission.verifier || '').trim()) return { ok: true, source: 'no_verifier' };
6193
+ const remainingSeconds = missionFullBudgetRemainingSeconds(mission);
6194
+ if (remainingSeconds > 0) {
6195
+ return {
6196
+ ok: false,
6197
+ source: 'budget_contract',
6198
+ reason: `full-budget mission still has ${durationLabel(remainingSeconds)} left; keep picking the next useful move or use --force if blocked/unsafe`,
6199
+ };
6200
+ }
3298
6201
  const receipt = readReceiptProof(proof, root);
3299
6202
  if (receipt) {
3300
6203
  if (receipt.mission_id !== mission.id) {
@@ -3305,6 +6208,7 @@ function missionCompletionGate(mission, proof, root = process.cwd()) {
3305
6208
  : { ok: false, source: 'receipt', reason: 'proof receipt does not show a passing verifier' };
3306
6209
  }
3307
6210
  if (mission.verifier_result?.passed === true) return { ok: true, source: 'mission_state' };
6211
+ if (!effectiveMissionVerifier(mission)) return { ok: true, source: 'no_verifier' };
3308
6212
  return { ok: false, source: 'mission_state', reason: 'verifier has not passed for this mission and proof is not a passing receipt' };
3309
6213
  }
3310
6214
 
@@ -3313,6 +6217,7 @@ function completeMission(args) {
3313
6217
  const force = hasFlag(args, '--force');
3314
6218
  const proof = readFlag(args, '--proof', '');
3315
6219
  const ref = stripKnownFlags(args, ['--proof'], ['--json', '--force'])[0] || '';
6220
+ const root = process.cwd();
3316
6221
  if (!ref || !proof) {
3317
6222
  exitMissionError('Usage: atris mission complete <id> --proof "..."', 1, asJson);
3318
6223
  }
@@ -3320,11 +6225,11 @@ function completeMission(args) {
3320
6225
  if (!mission) {
3321
6226
  exitMissionError(`Mission "${ref}" not found.`, 1, asJson);
3322
6227
  }
3323
- const gate = missionCompletionGate(mission, proof, process.cwd());
6228
+ const gate = missionCompletionGate(mission, proof, root);
3324
6229
  if (!gate.ok && !force) {
3325
6230
  exitMissionError(`[mission complete] ${gate.reason}. Run: atris mission tick ${mission.id} --verify (or override as operator with --force)`, 2, asJson);
3326
6231
  }
3327
- const baselineSummary = pruneMissionWorktreeBaseline(mission, process.cwd());
6232
+ const baselineSummary = pruneMissionWorktreeBaseline(mission, root);
3328
6233
  const completionGate = { ...gate, forced: force && !gate.ok };
3329
6234
  const baseNext = {
3330
6235
  ...mission,
@@ -3337,17 +6242,31 @@ function completeMission(args) {
3337
6242
  };
3338
6243
  const xpNextCommand = missionXpReadyAction(baseNext, proof);
3339
6244
  const completion = missionCompletionReceipt(baseNext, proof, xpNextCommand);
6245
+ const artifactPreview = missionArtifactPaths(baseNext, root);
6246
+ completion.landing.artifact = `Open timeline at ${artifactPreview.relativeIndexHtml}.`;
6247
+ completion.result.artifact = artifactPreview.relativeIndexHtml;
3340
6248
  const next = {
3341
6249
  ...baseNext,
3342
6250
  landing: completion.landing,
3343
6251
  result: completion.result,
3344
6252
  };
3345
- const { mission: saved } = saveMission(next, process.cwd(), 'mission_completed', { proof, completion_gate: next.completion_gate });
3346
- const continuationGoal = seedMissionRunContinuation(saved, process.cwd(), proof);
6253
+ const { mission: saved } = saveMission(next, root, 'mission_completed', { proof, completion_gate: next.completion_gate });
6254
+ const continuationGoal = seedMissionRunContinuation(saved, root, proof);
3347
6255
  const outputMission = continuationGoal?.parent || saved;
3348
- const logPath = appendMemberLog(outputMission.owner, 'Mission completed', { mission: outputMission.objective, proof });
3349
- const atrisGoalState = refreshAtrisGoalController(process.cwd(), { missionId: continuationGoal?.mission?.id || outputMission.id });
3350
- const codexGoalState = refreshCodexGoalController(process.cwd());
6256
+ const artifact = writeMissionArtifact(outputMission, {
6257
+ root,
6258
+ proof,
6259
+ completion,
6260
+ xpNextCommand,
6261
+ continuationGoal,
6262
+ });
6263
+ const logPath = appendMemberLog(outputMission.owner, 'Mission completed', {
6264
+ mission: outputMission.objective,
6265
+ proof,
6266
+ artifact: artifact.index_html,
6267
+ }, root);
6268
+ const atrisGoalState = refreshAtrisGoalController(root, { missionId: continuationGoal?.mission?.id || outputMission.id });
6269
+ const codexGoalState = refreshCodexGoalController(root);
3351
6270
  printJsonOrText(
3352
6271
  {
3353
6272
  ok: true,
@@ -3355,6 +6274,7 @@ function completeMission(args) {
3355
6274
  mission: outputMission,
3356
6275
  landing: completion.landing,
3357
6276
  result: completion.result,
6277
+ artifact,
3358
6278
  log_path: logPath,
3359
6279
  atris_goal_state: atrisGoalState,
3360
6280
  codex_goal_state: codexGoalState,
@@ -3362,10 +6282,7 @@ function completeMission(args) {
3362
6282
  continuation_goal: continuationGoal,
3363
6283
  },
3364
6284
  [
3365
- `Completed mission: ${outputMission.objective}`,
3366
6285
  ...missionResultLines(completion),
3367
- `Proof: ${proof}`,
3368
- ...(xpNextCommand ? [`AgentXP: ${xpNextCommand}`] : []),
3369
6286
  ...(continuationGoal?.mission ? [`Next goal: ${continuationGoal.mission.objective}`] : []),
3370
6287
  ],
3371
6288
  asJson,
@@ -3408,9 +6325,10 @@ function stopMission(args) {
3408
6325
  next_action: status === 'paused' ? `resume with: atris mission tick ${mission.id}` : 'mission stopped',
3409
6326
  };
3410
6327
  const { mission: saved } = saveMission(next, process.cwd(), pause ? 'mission_paused' : 'mission_stopped', { reason, receipt_path: receiptPath });
6328
+ const directGoalRequestCleared = pause ? false : clearDirectRunCodexGoalRequestForMission(saved.id, process.cwd());
3411
6329
  const logPath = appendMemberLog(saved.owner, pause ? 'Mission paused' : 'Mission stopped', { mission: saved.objective, reason });
3412
6330
  printJsonOrText(
3413
- { ok: true, action: pause ? 'mission_paused' : 'mission_stopped', mission: saved, receipt_path: receiptPath, log_path: logPath },
6331
+ { ok: true, action: pause ? 'mission_paused' : 'mission_stopped', mission: saved, receipt_path: receiptPath, log_path: logPath, direct_goal_request_cleared: directGoalRequestCleared },
3414
6332
  [
3415
6333
  `${pause ? 'Paused' : 'Stopped'} mission: ${saved.objective}`,
3416
6334
  `Reason: ${reason}`,
@@ -3446,7 +6364,26 @@ function goalMission(args) {
3446
6364
  return;
3447
6365
  }
3448
6366
  const heartbeatMode = hasFlag(args, '--heartbeat');
3449
- const payload = refreshCodexGoalController(process.cwd(), { heartbeat: heartbeatMode });
6367
+ const nativeGoalStatus = readFlag(args, '--native-goal-status', readFlag(args, '--visible-goal-status', ''));
6368
+ const nativeGoalObjective = readFlag(args, '--native-goal-objective', readFlag(args, '--visible-goal-objective', ''));
6369
+ const allowNativeGoalSupersede = hasFlag(args, '--allow-native-goal-supersede') || hasFlag(args, '--supersede-paused-native-goal');
6370
+ const payload = refreshCodexGoalController(process.cwd(), {
6371
+ heartbeat: heartbeatMode,
6372
+ ...(nativeGoalStatus ? { nativeGoalStatus } : {}),
6373
+ ...(nativeGoalObjective ? { nativeGoalObjective } : {}),
6374
+ ...(allowNativeGoalSupersede ? { allowNativeGoalSupersede: true } : {}),
6375
+ });
6376
+ if (payload.active_goal_conflict) {
6377
+ printJsonOrText(
6378
+ payload,
6379
+ [
6380
+ payload.active_goal_conflict.message,
6381
+ `Next: ${payload.active_goal_conflict.next_command}`,
6382
+ ],
6383
+ asJson,
6384
+ );
6385
+ return;
6386
+ }
3450
6387
  if (!payload.goal) {
3451
6388
  printJsonOrText(
3452
6389
  payload,
@@ -3476,49 +6413,62 @@ function ackMissionGoal(args) {
3476
6413
  }
3477
6414
  const runtime = String(readFlag(args, '--runtime', 'codex') || 'codex').trim().toLowerCase();
3478
6415
  const status = String(readFlag(args, '--status', 'active') || 'active').trim().toLowerCase();
3479
- const mission = resolveMission(ref);
6416
+ let mission = resolveMission(ref);
3480
6417
  if (!mission) {
3481
6418
  exitMissionError(`Mission "${ref}" not found.`, 1, asJson);
3482
6419
  }
3483
- if (!isCodexGoalMission(mission)) {
3484
- exitMissionError(`Mission "${ref}" uses runner=${mission.runner || 'manual'}; native Codex goal ack is only for runner=codex_goal.`, 2, asJson);
3485
- }
3486
6420
  if (runtime !== 'codex' || status !== 'active') {
3487
6421
  exitMissionError('Native goal ack requires --runtime codex --status active.', 2, asJson);
3488
6422
  }
3489
- const expectedObjective = codexGoalObjective(mission);
3490
- const objective = readFlag(args, '--objective', expectedObjective);
3491
- if (objective !== expectedObjective) {
3492
- exitMissionError(`Native goal objective mismatch. Expected: ${expectedObjective}`, 2, asJson);
6423
+
6424
+ const lock = acquireMissionLock(mission.id, process.cwd(), { waitMs: 2000 });
6425
+ if (!lock.ok) {
6426
+ exitMissionError(`[mission goal ack] lock busy (held by pid ${lock.holder?.pid || '?'} since ${lock.holder?.started_at || '?'}). Exit.`, 3, asJson);
3493
6427
  }
3494
- const ack = {
3495
- runtime: 'codex',
3496
- status: 'active',
3497
- objective,
3498
- acknowledged_at: stampIso(),
3499
- };
3500
- const nextMission = {
3501
- ...mission,
3502
- native_goal_ack: ack,
3503
- next_action: codexGoalNextCommand({ ...mission, native_goal_ack: ack }),
3504
- };
3505
- const { mission: saved } = saveMission(nextMission, process.cwd(), 'mission_native_goal_ack', { ack });
3506
- const payload = refreshCodexGoalController(process.cwd());
3507
- printJsonOrText(
3508
- {
3509
- ok: true,
3510
- action: 'native_goal_acknowledged',
3511
- mission: saved,
6428
+
6429
+ try {
6430
+ mission = resolveMission(mission.id) || mission;
6431
+ if (!isCodexGoalMission(mission)) {
6432
+ releaseMissionLock(lock);
6433
+ exitMissionError(`Mission "${ref}" uses runner=${mission.runner || 'manual'}; native Codex goal ack is only for runner=codex_goal.`, 2, asJson);
6434
+ }
6435
+ const expectedObjective = codexGoalObjective(mission);
6436
+ const objective = readFlag(args, '--objective', expectedObjective);
6437
+ if (objective !== expectedObjective) {
6438
+ releaseMissionLock(lock);
6439
+ exitMissionError(`Native goal objective mismatch. Expected: ${expectedObjective}`, 2, asJson);
6440
+ }
6441
+ const ack = {
6442
+ runtime: 'codex',
6443
+ status: 'active',
6444
+ objective,
6445
+ acknowledged_at: stampIso(),
6446
+ };
6447
+ const nextMission = {
6448
+ ...mission,
3512
6449
  native_goal_ack: ack,
3513
- codex_goal_state: payload,
3514
- next_command: payload.goal?.next_command || `atris mission tick ${saved.id} --summary "<what changed>"`,
3515
- },
3516
- [
3517
- `Native goal active: ${saved.objective}`,
3518
- `Next: ${payload.goal?.next_command || saved.next_action}`,
3519
- ],
3520
- asJson,
3521
- );
6450
+ next_action: codexGoalNextCommand({ ...mission, native_goal_ack: ack }),
6451
+ };
6452
+ const { mission: saved } = saveMission(nextMission, process.cwd(), 'mission_native_goal_ack', { ack });
6453
+ const payload = refreshCodexGoalController(process.cwd());
6454
+ printJsonOrText(
6455
+ {
6456
+ ok: true,
6457
+ action: 'native_goal_acknowledged',
6458
+ mission: saved,
6459
+ native_goal_ack: ack,
6460
+ codex_goal_state: payload,
6461
+ next_command: payload.goal?.next_command || `atris mission tick ${saved.id} --summary "<what changed>"`,
6462
+ },
6463
+ [
6464
+ `Native goal active: ${saved.objective}`,
6465
+ `Next: ${payload.goal?.next_command || saved.next_action}`,
6466
+ ],
6467
+ asJson,
6468
+ );
6469
+ } finally {
6470
+ releaseMissionLock(lock);
6471
+ }
3522
6472
  }
3523
6473
 
3524
6474
  async function goalLoopMission(args) {
@@ -3612,19 +6562,29 @@ atris mission - durable goal + loop + owner + proof state
3612
6562
  default model atris:fast; runner codex_goal publishes the goal for a live
3613
6563
  Codex session to pull via atris mission goal)
3614
6564
  atris mission status [id] [--status <state>] [--limit <n>] [--local] [--json]
6565
+ atris mission doctor [--local] [--json] Flag no-verifier missions, help missions, stale ready receipts, and blocked always-on loops
3615
6566
  atris mission attach-task <id> [--json] Create the missing task spine for an existing active mission
3616
6567
  atris mission report [id] [--limit <n>] [--local] [--json] Plain outcome, worker receipt, verifier receipt, and next move
6568
+ atris mission timeline [id] [--limit <n>] [--all] [--prune-preview] [--write] [--json] Saved landing Changed/Next lines from mission receipts
3617
6569
  atris mission watch [id] [--interval <s>] [--idle-every <s>] Live heartbeat: prints a line per tick as it lands
3618
6570
  atris mission layers [--mission <id-substr>] [--since <date>] [--json] Per-layer growth curve across tick receipts
3619
6571
  (rolls up sibling git-worktree missions; --local scopes to this checkout)
3620
- atris mission goal [--runtime codex|atris] [--heartbeat] [--json]
6572
+ atris mission room "<messy input>" [--owner <member>] [--room-auto-run] [--json] Create a Mission Room card and shareable receipt from messy intent
6573
+ atris mission prune-runs [--apply] [--days <n>] [--keep-newest <n>] [--json] Compress old run receipts into a manifest and prune unreferenced clutter
6574
+ atris mission goal [--runtime codex|atris] [--heartbeat] [--native-goal-status active|paused] [--native-goal-objective "..."] [--allow-native-goal-supersede] [--json]
3621
6575
  atris mission goal ack <id> --runtime codex --status active --objective "<objective>" --json
3622
6576
  atris mission goal-loop [--max-wall 28800] [--max-iterations 32] [--no-claude] [--json]
3623
- atris mission tick <id> [--verify] [--complete-on-pass] [--summary "..."] [--json]
6577
+ atris mission tick <id> [--verify ["cmd"]] [--complete-on-pass] [--summary "..."] [--json]
3624
6578
  atris mission "<objective>" [--owner <member>] Shortcut for: atris mission run "<objective>"
3625
6579
  atris mission run ["objective"|<member> ["objective"]|id|--due] [--owner <member>] [--max-ticks 4] [--max-wall 3600] [--cadence "15m"]
3626
- [--no-claude] [--no-verify] [--complete-on-pass] [--no-drain] [--json]
6580
+ [--native-goal-status active|paused] [--native-goal-objective "..."] [--allow-native-goal-supersede]
6581
+ [--spend-full-budget|--use-whole-budget|--stop-when-done] [--room-preflight|--no-room-preflight]
6582
+ [--room-auto-run|--no-room-auto-run]
6583
+ [--no-claude] [--no-verify] [--complete-on-pass] [--no-drain] [--create-next] [--json]
3627
6584
  (bare/member-only run prompts; one-word fuzzy intent starts a new visible-goal mission; --due runs the saved queue)
6585
+ (short time like "20 minutes" means finish early; long/sleep time like "5 hours" keeps using the budget; --stop-when-done overrides)
6586
+ (messy, shower, and overnight requests preflight through Mission Room before the visible goal is written)
6587
+ (--room-auto-run makes trusted self-improvement asks preview through Mission Room, select real work, then start one bounded goal)
3628
6588
  (mission-run completions seed the next visible goal: decide and start the next useful mission)
3629
6589
  atris mission complete <id> --proof "..."
3630
6590
  atris mission stop <id> [--pause] [--reason "..."]
@@ -3633,7 +6593,7 @@ Autonomy recipe:
3633
6593
  1. Pick an owner member: atris member create <member> (if missing)
3634
6594
  2. Start a current-agent mission with a verifier:
3635
6595
  atris mission start "ship one proof" --owner <member> --runner codex_goal --lane code --verify "npm test" --stop "verifier passes" --xp-task
3636
- 3. Codex sessions: atris mission goal --json, create the native goal, then run atris mission goal ack <id> --runtime codex --status active --objective "<objective>" --json
6596
+ 3. Codex sessions: read native get_goal, then pass its status into atris mission goal --native-goal-status <status> --native-goal-objective "<objective>" --json
3637
6597
  Overnight controller: atris mission goal --heartbeat --json
3638
6598
  Bounded overnight runner: atris mission goal-loop --max-wall 28800 --no-claude --json
3639
6599
  4. Do one bounded step, then record it:
@@ -3814,6 +6774,70 @@ function layersMission(args) {
3814
6774
  printJsonOrText({ ok: true, since: sinceRaw || null, total, tagged, untagged, by_layer: byLayer, by_source: bySource, dominant: tagged ? dominant : null, skewed }, lines, asJson);
3815
6775
  }
3816
6776
 
6777
+ function roomMission(args) {
6778
+ const asJson = wantsJson(args);
6779
+ const explicitOwner = readFlag(args, '--owner', '');
6780
+ const input = stripKnownFlags(args, ['--owner'], ['--json', '--room-auto-run', '--no-room-auto-run']).join(' ').trim();
6781
+ if (!input) {
6782
+ exitMissionError('Usage: atris mission room "<messy input>" [--owner <member>] [--room-auto-run] [--json]', 1, asJson);
6783
+ }
6784
+ const requestedOwner = explicitOwner || process.env.ATRIS_AGENT_ID || '';
6785
+ const ownerResolution = resolveFunctionalOwner({
6786
+ requestedOwner,
6787
+ title: input,
6788
+ note: input,
6789
+ root: process.cwd(),
6790
+ fallbackOwners: ['mission-lead', 'task-planner', 'architect', 'validator'],
6791
+ });
6792
+ const owner = ownerResolution.owner || requestedOwner || 'mission-lead';
6793
+
6794
+ let room;
6795
+ try {
6796
+ room = buildMissionRoom(input, {
6797
+ owner,
6798
+ root: process.cwd(),
6799
+ ownerResolution,
6800
+ trustedRun: hasFlag(args, '--room-auto-run') && !hasFlag(args, '--no-room-auto-run'),
6801
+ verifier: DEFAULT_LONG_RUN_VERIFIER,
6802
+ });
6803
+ } catch (error) {
6804
+ exitMissionError(error.message || String(error), 1, asJson);
6805
+ }
6806
+ const { receipt, relativePath, room: persistedRoom } = writeMissionRoomReceipt(room, { root: process.cwd() });
6807
+ const payload = {
6808
+ ok: true,
6809
+ action: 'mission_room_created',
6810
+ room: persistedRoom,
6811
+ receipt_path: relativePath,
6812
+ receipt,
6813
+ };
6814
+ printJsonOrText(payload, missionRoomLines(persistedRoom, relativePath), asJson);
6815
+ }
6816
+
6817
+ function pruneRunsMission(args) {
6818
+ const asJson = wantsJson(args);
6819
+ let keepNewest;
6820
+ let keepDays;
6821
+ try {
6822
+ keepNewest = readNonNegativeIntegerFlag(args, '--keep-newest', 200);
6823
+ keepDays = readNonNegativeIntegerFlag(args, '--days', 14);
6824
+ } catch (error) {
6825
+ exitMissionError(error.message || String(error), 1, asJson);
6826
+ }
6827
+ const result = pruneRuns(process.cwd(), {
6828
+ apply: hasFlag(args, '--apply'),
6829
+ archive: !hasFlag(args, '--no-archive'),
6830
+ keepNewest,
6831
+ keepDays,
6832
+ });
6833
+ printJsonOrText(
6834
+ { ok: !result.errors.length, ...result },
6835
+ runsPruneLines(result),
6836
+ asJson,
6837
+ );
6838
+ if (result.errors.length) process.exitCode = 1;
6839
+ }
6840
+
3817
6841
  function missionCommand(args) {
3818
6842
  const subcommand = args[0] || 'status';
3819
6843
  const rest = args.slice(1);
@@ -3825,7 +6849,13 @@ function missionCommand(args) {
3825
6849
  case 'status':
3826
6850
  case 'list':
3827
6851
  case 'ls':
6852
+ case 'show':
6853
+ case 'info':
6854
+ case 'view':
3828
6855
  return statusMission(rest);
6856
+ case 'doctor':
6857
+ case 'check':
6858
+ return doctorMission(rest);
3829
6859
  case 'attach-task':
3830
6860
  case 'ensure-task':
3831
6861
  case 'task-spine':
@@ -3833,10 +6863,19 @@ function missionCommand(args) {
3833
6863
  case 'report':
3834
6864
  case 'debrief':
3835
6865
  return reportMission(rest);
6866
+ case 'timeline':
6867
+ case 'landings':
6868
+ return timelineMission(rest);
3836
6869
  case 'watch':
3837
6870
  return watchMission(rest);
3838
6871
  case 'layers':
3839
6872
  return layersMission(rest);
6873
+ case 'room':
6874
+ return roomMission(rest);
6875
+ case 'prune-runs':
6876
+ case 'runs-prune':
6877
+ case 'clean-runs':
6878
+ return pruneRunsMission(rest);
3840
6879
  case 'goal':
3841
6880
  case 'codex-goal':
3842
6881
  return goalMission(rest);
@@ -3875,6 +6914,7 @@ module.exports = {
3875
6914
  cappedClaudeReceiptText,
3876
6915
  extractLayerFromReceiptText,
3877
6916
  classifyPathsByLayer,
6917
+ collectMissionDoctorFindings,
3878
6918
  resolveClaudeRunnerModel,
3879
6919
  resolveClaudeRunnerBin,
3880
6920
  businessIdForAtris2Mission,