atris 3.31.0 → 3.32.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.
@@ -29,6 +29,7 @@ const {
29
29
  runsPruneLines,
30
30
  formatBytes,
31
31
  } = require('../lib/runs-prune');
32
+ const { operatorReady, hasAgentJargon } = require('./autoland');
32
33
 
33
34
  const VALID_STATUSES = new Set(['planning', 'running', 'ready', 'paused', 'blocked', 'stopped', 'complete']);
34
35
  const TERMINAL_STATUSES = new Set(['stopped', 'complete']);
@@ -90,6 +91,28 @@ function exitMissionError(message, code = 1, asJson = false) {
90
91
  process.exit(code);
91
92
  }
92
93
 
94
+ // Write-time warnings judge only what a machine can truly judge: identifiers,
95
+ // flags, and task ids. Whether a why is present is a judgment call — the tick
96
+ // prompt demands it, the review pass judges it. A warning that cries wolf on
97
+ // plain sentences teaches agents to ignore it (golden-path papercut). The
98
+ // strict operatorReady bar stays at the digest surface, where under-showing
99
+ // is cheap. Boundary pinned by the marker-free fixture in mission-status tests.
100
+ function warnIfSummaryNeedsOperatorWhy(summary) {
101
+ const text = String(summary || '').trim();
102
+ if (!text || !hasAgentJargon(text)) return null;
103
+ const warning = 'Warning: this tick summary contains flags, ids, or code identifiers. Rewrite it in words the operator can use; identifiers belong in the receipt body.';
104
+ console.error(warning);
105
+ return warning;
106
+ }
107
+
108
+ function warnIfTaskTitleNeedsOperatorWhy(title) {
109
+ const text = String(title || '').trim();
110
+ if (!text || !hasAgentJargon(text)) return null;
111
+ const warning = 'Warning: this task title contains flags, ids, or code identifiers. Rewrite it in words the operator can use; identifiers belong in the task body.';
112
+ console.error(warning);
113
+ return warning;
114
+ }
115
+
93
116
  function readPositiveIntegerFlag(args, name, fallback = null, options = {}) {
94
117
  const raw = readFlag(args, name, '');
95
118
  if (!raw) return fallback;
@@ -328,6 +351,7 @@ function createMissionXpTask(mission, root = process.cwd(), asJson = false) {
328
351
  const db = taskDb.open();
329
352
  const workspaceRoot = taskDb.workspaceRoot(root);
330
353
  const title = `Mission XP: ${mission.objective}`;
354
+ const operatorTitleWarning = warnIfTaskTitleNeedsOperatorWhy(title);
331
355
  const ownerResolution = resolveMissionOwner(mission, workspaceRoot);
332
356
  const owner = ownerResolution.owner;
333
357
  const metadata = {
@@ -371,6 +395,7 @@ function createMissionXpTask(mission, root = process.cwd(), asJson = false) {
371
395
  task_id: result.id,
372
396
  ref: missionTaskRef(task) || result.id,
373
397
  title,
398
+ operator_title_warning: operatorTitleWarning,
374
399
  status: task?.status || 'claimed',
375
400
  assigned_to: owner,
376
401
  owner_resolution: ownerResolution.reason,
@@ -499,10 +524,72 @@ function listWorktreeRollupMissions(root = process.cwd()) {
499
524
  return rolled;
500
525
  }
501
526
 
527
+ function missionMatchesRef(mission, ref) {
528
+ return mission && (mission.id === ref || mission.id.startsWith(ref) || mission.slug === ref);
529
+ }
530
+
531
+ function listSiblingWorkspaceMissionHints(ref, root = process.cwd(), limit = 5) {
532
+ if (!ref) return [];
533
+ const baseRoot = path.resolve(root);
534
+ const parent = path.dirname(baseRoot);
535
+ let here = baseRoot;
536
+ try {
537
+ here = fs.realpathSync(baseRoot);
538
+ } catch { /* keep raw path */ }
539
+ let entries = [];
540
+ try {
541
+ entries = fs.readdirSync(parent, { withFileTypes: true });
542
+ } catch {
543
+ return [];
544
+ }
545
+ const hints = [];
546
+ for (const entry of entries) {
547
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
548
+ const candidateRoot = path.join(parent, entry.name);
549
+ let candidateReal = candidateRoot;
550
+ try {
551
+ candidateReal = fs.realpathSync(candidateRoot);
552
+ } catch {
553
+ continue;
554
+ }
555
+ if (candidateReal === here) continue;
556
+ if (!fs.existsSync(path.join(candidateRoot, '.atris', 'state', 'missions.jsonl'))) continue;
557
+ const mission = listMissions(candidateRoot).find((row) => missionMatchesRef(row, ref));
558
+ if (!mission) continue;
559
+ hints.push({
560
+ id: mission.id,
561
+ status: mission.status,
562
+ objective: mission.objective,
563
+ workspace_root: candidateRoot,
564
+ command: `cd ${shellQuote(candidateRoot)} && atris mission status ${mission.id}`,
565
+ });
566
+ if (hints.length >= limit) break;
567
+ }
568
+ return hints;
569
+ }
570
+
571
+ function exitMissingMission(ref, code = 1, asJson = false) {
572
+ const error = `Mission "${ref}" not found.`;
573
+ const hints = listSiblingWorkspaceMissionHints(ref);
574
+ if (asJson) {
575
+ const payload = { ok: false, error };
576
+ if (hints.length) payload.workspace_hint = hints[0];
577
+ if (hints.length > 1) payload.workspace_hints = hints;
578
+ console.log(JSON.stringify(payload, null, 2));
579
+ } else {
580
+ console.error(error);
581
+ if (hints.length) {
582
+ console.error(`Workspace hint: mission ${hints[0].id} exists in ${hints[0].workspace_root}.`);
583
+ console.error(`Run: ${hints[0].command}`);
584
+ }
585
+ }
586
+ process.exit(code);
587
+ }
588
+
502
589
  function resolveMission(ref, root = process.cwd()) {
503
590
  const missions = listMissions(root);
504
591
  if (!ref) return missions.find((mission) => !TERMINAL_STATUSES.has(mission.status)) || missions[0] || null;
505
- return missions.find((mission) => mission.id === ref || mission.id.startsWith(ref) || mission.slug === ref) || null;
592
+ return missions.find((mission) => missionMatchesRef(mission, ref)) || null;
506
593
  }
507
594
 
508
595
  function missionMatchesStatusFilter(mission, statusFilter) {
@@ -2008,7 +2095,51 @@ function normalizeMissionOwner(value) {
2008
2095
  return String(value || '').trim().toLowerCase();
2009
2096
  }
2010
2097
 
2011
- function missionMemberTasteProfile(owner) {
2098
+ function readMissionMemberRole(owner, root = process.cwd()) {
2099
+ const dir = memberDir(owner, root);
2100
+ if (!dir) return '';
2101
+ try {
2102
+ const text = fs.readFileSync(path.join(dir, 'MEMBER.md'), 'utf8');
2103
+ const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
2104
+ if (!match) return '';
2105
+ const role = match[1].match(/^role:\s*(.+)$/im);
2106
+ return role ? role[1].replace(/^["']|["']$/g, '').trim() : '';
2107
+ } catch {
2108
+ return '';
2109
+ }
2110
+ }
2111
+
2112
+ function missionTasteProfileForRole(role) {
2113
+ const text = String(role || '').toLowerCase();
2114
+ if (!text) return null;
2115
+ if (/\b(security|risk|trust|privacy|compliance|safety|safe|guard|inspector)\b/.test(text)) {
2116
+ return {
2117
+ id: 'security_guard',
2118
+ name: 'Security / trust',
2119
+ bias: 'Prefer work that prevents unsafe behavior, privacy mistakes, or quiet trust breaks.',
2120
+ role_reason: 'Member role says guard risk before spending tokens.',
2121
+ };
2122
+ }
2123
+ if (/\b(demo|reliability|ux|usability|support|operator|launcher|customer|growth|onboarding)\b/.test(text)) {
2124
+ return {
2125
+ id: 'usability_operator',
2126
+ name: 'Usability / demo',
2127
+ bias: 'Prefer work that makes the product easier, faster, clearer, or more demo-ready.',
2128
+ role_reason: 'Member role says optimize usability before spending tokens.',
2129
+ };
2130
+ }
2131
+ if (/\b(technical|engineer|architect|research|improver|runtime|compiler|infra|backend|agent|model)\b/.test(text)) {
2132
+ return {
2133
+ id: 'technical_homerun',
2134
+ name: 'Technical homerun',
2135
+ bias: 'Prefer technical leaps, but require a usability or proof gate before spending serious tokens.',
2136
+ role_reason: 'Member role says chase validated technical homeruns.',
2137
+ };
2138
+ }
2139
+ return null;
2140
+ }
2141
+
2142
+ function missionOwnerTasteProfile(owner) {
2012
2143
  const key = normalizeMissionOwner(owner);
2013
2144
  if (/\b(security|sync-inspector|proof-inspector|validator)\b/.test(key)) {
2014
2145
  return {
@@ -2031,13 +2162,25 @@ function missionMemberTasteProfile(owner) {
2031
2162
  };
2032
2163
  }
2033
2164
 
2165
+ function missionMemberTasteProfile(owner, root = process.cwd()) {
2166
+ const role = readMissionMemberRole(owner, root);
2167
+ const roleProfile = missionTasteProfileForRole(role);
2168
+ const profile = roleProfile || missionOwnerTasteProfile(owner);
2169
+ return {
2170
+ ...profile,
2171
+ role: role || null,
2172
+ role_source: roleProfile ? `atris/team/${normalizeMissionOwner(owner)}/MEMBER.md` : null,
2173
+ role_reason: roleProfile?.role_reason || null,
2174
+ };
2175
+ }
2176
+
2034
2177
  function missionValueSignals(title) {
2035
2178
  const text = String(title || '').toLowerCase();
2036
2179
  const signals = [];
2037
2180
  const add = (id, label, pattern) => {
2038
2181
  if (pattern.test(text)) signals.push({ id, label });
2039
2182
  };
2040
- add('usability', 'simplifies use', /\b(simple|simplify|preview|clear|plain|feynman|demo|onboarding|ux|workflow|usable|understand)\b/);
2183
+ add('usability', 'simplifies use', /\b(simple|simplify|previews?|clear|plain|feynman|demos?|onboarding|ux|workflow|usable|understand)\b/);
2041
2184
  add('speed', 'speeds the process', /\b(speed|fast|faster|latency|token|waste|friction|shortcut|automation|auto)\b/);
2042
2185
  add('users_revenue', 'can help users or revenue', /\b(user|users|customer|revenue|payment|checkout|pricing|conversion|retention|demo)\b/);
2043
2186
  add('trust', 'protects trust', /\b(security|safe|safety|trust|permission|approval|auth|privacy|gmail|email|connector|leak|isolation)\b/);
@@ -2265,6 +2408,7 @@ function missionTasteMemoryReason(tasteMemory, signals) {
2265
2408
 
2266
2409
  function missionPreviewWhyNow(move, profile, signals, tasteMemory = null) {
2267
2410
  const ids = new Set((signals || []).map((signal) => signal.id));
2411
+ const roleReason = profile?.role_reason ? `${profile.role_reason} ` : '';
2268
2412
  let base = '';
2269
2413
  if (profile.id === 'technical_homerun') {
2270
2414
  base = ids.has('technical')
@@ -2282,7 +2426,7 @@ function missionPreviewWhyNow(move, profile, signals, tasteMemory = null) {
2282
2426
  base = move?.why || 'This needs a clear reason before it should spend serious tokens.';
2283
2427
  }
2284
2428
  const memoryReason = missionTasteMemoryReason(tasteMemory, signals);
2285
- return memoryReason ? `${base} Taste memory says: ${memoryReason}` : base;
2429
+ return memoryReason ? `${roleReason}${base} Taste memory says: ${memoryReason}` : `${roleReason}${base}`;
2286
2430
  }
2287
2431
 
2288
2432
  function missionPreviewRisk(signals) {
@@ -2301,7 +2445,7 @@ function missionPreviewValidation(signals) {
2301
2445
  }
2302
2446
 
2303
2447
  function missionValuePreview(move, mission, root = process.cwd()) {
2304
- const profile = missionMemberTasteProfile(mission?.owner);
2448
+ const profile = missionMemberTasteProfile(mission?.owner, root);
2305
2449
  const signals = missionValueSignals(move?.title);
2306
2450
  const tasteMemory = readMissionTasteMemory(root, mission?.owner);
2307
2451
  const score = missionValueScore(signals, profile, tasteMemory);
@@ -2821,7 +2965,7 @@ function attachMissionTask(args) {
2821
2965
  }
2822
2966
  let mission = resolveMission(ref);
2823
2967
  if (!mission) {
2824
- exitMissionError(`Mission "${ref}" not found.`, 1, asJson);
2968
+ exitMissingMission(ref, 1, asJson);
2825
2969
  }
2826
2970
 
2827
2971
  const lock = acquireMissionLock(mission.id, process.cwd(), { waitMs: 2000 });
@@ -2965,7 +3109,7 @@ function statusMission(args) {
2965
3109
  if (!ref && statusFilter) missions = missions.filter((mission) => missionMatchesStatusFilter(mission, statusFilter));
2966
3110
  if (!ref && limit) missions = missions.slice(0, limit);
2967
3111
  if (ref && !missions.length) {
2968
- exitMissionError(`Mission "${ref}" not found.`, 1, asJson);
3112
+ exitMissingMission(ref, 1, asJson);
2969
3113
  }
2970
3114
  const missionViews = missions.map(missionStatusView);
2971
3115
  // Member state renders are cwd-local writes; rolled-up missions stay read-only.
@@ -3147,7 +3291,7 @@ function missionReportTimeline(mission, root = process.cwd(), limit = 6) {
3147
3291
  return items.slice(Math.max(0, items.length - limit));
3148
3292
  }
3149
3293
 
3150
- function missionLandingTimeline(mission, root = process.cwd(), limit = 12) {
3294
+ function missionLandingTimeline(mission, root = process.cwd(), limit = 12, { kind = null, since = null } = {}) {
3151
3295
  const paths = statePaths(root);
3152
3296
  let files = [];
3153
3297
  try {
@@ -3158,6 +3302,8 @@ function missionLandingTimeline(mission, root = process.cwd(), limit = 12) {
3158
3302
  files = [];
3159
3303
  }
3160
3304
 
3305
+ const kindFilter = kind ? String(kind).trim() : null;
3306
+ const sinceFilter = since ? String(since).trim() : null;
3161
3307
  const items = [];
3162
3308
  const seen = new Set();
3163
3309
  for (const file of files) {
@@ -3168,6 +3314,9 @@ function missionLandingTimeline(mission, root = process.cwd(), limit = 12) {
3168
3314
  continue;
3169
3315
  }
3170
3316
  if (!receipt || receipt.mission_id !== mission.id) continue;
3317
+ const receiptKind = receipt.result && receipt.result.kind ? String(receipt.result.kind) : (receipt.kind ? String(receipt.kind) : '');
3318
+ if (kindFilter && receiptKind !== kindFilter) continue;
3319
+ if (sinceFilter && String(receipt.at || '') < sinceFilter) continue;
3171
3320
  const landing = receipt.result && receipt.result.landing;
3172
3321
  if (landing && landing.timeline_visible === false) continue;
3173
3322
  const changed = String(landing && landing.changed || '').trim();
@@ -3426,7 +3575,7 @@ function reportMission(args) {
3426
3575
  missions.sort((a, b) => String(b.updated_at || b.created_at || '').localeCompare(String(a.updated_at || a.created_at || '')));
3427
3576
  }
3428
3577
  if (ref && !missions.length) {
3429
- exitMissionError(`Mission "${ref}" not found.`, 1, asJson);
3578
+ exitMissingMission(ref, 1, asJson);
3430
3579
  }
3431
3580
  missions = missions.slice(0, limit);
3432
3581
  const reports = missions.map((mission) => missionReportFor(mission, mission.worktree_root || process.cwd()));
@@ -3458,14 +3607,16 @@ function timelineMission(args) {
3458
3607
  const all = hasFlag(args, '--all');
3459
3608
  const prunePreviewRequested = hasFlag(args, '--prune-preview');
3460
3609
  const outputPath = readFlag(args, '--output', '') || readFlag(args, '--out', '');
3461
- const ref = stripKnownFlags(args, ['--limit', '--output', '--out'], ['--json', '--write', '--all', '--prune-preview'])[0] || '';
3610
+ const kindFilter = readFlag(args, '--kind', '') || null;
3611
+ const sinceFilter = readFlag(args, '--since', '') || null;
3612
+ const ref = stripKnownFlags(args, ['--limit', '--output', '--out', '--kind', '--since'], ['--json', '--write', '--all', '--prune-preview'])[0] || '';
3462
3613
  const limit = all ? Number.MAX_SAFE_INTEGER : readPositiveIntegerFlag(args, '--limit', 12, { json: asJson });
3463
3614
  const missions = listMissions();
3464
3615
  const mission = ref
3465
3616
  ? resolveMission(ref)
3466
3617
  : (missions.find((row) => !TERMINAL_STATUSES.has(row.status)) || missions[0] || null);
3467
3618
  if (ref && !mission) {
3468
- exitMissionError(`Mission "${ref}" not found.`, 1, asJson);
3619
+ exitMissingMission(ref, 1, asJson);
3469
3620
  }
3470
3621
  if (!mission) {
3471
3622
  printJsonOrText(
@@ -3482,7 +3633,7 @@ function timelineMission(args) {
3482
3633
  return;
3483
3634
  }
3484
3635
  const root = mission.worktree_root || process.cwd();
3485
- const timelineResult = missionLandingTimeline(mission, root, limit);
3636
+ const timelineResult = missionLandingTimeline(mission, root, limit, { kind: kindFilter, since: sinceFilter });
3486
3637
  const timeline = timelineResult.items;
3487
3638
  const currentLanding = missionTimelineCurrentLanding(timeline);
3488
3639
  const timelineItemDisplay = (item) => ({
@@ -3652,8 +3803,14 @@ function timelineMission(args) {
3652
3803
  label: 'Filters',
3653
3804
  active_label: 'Active filter',
3654
3805
  active: all ? 'full_history' : 'latest',
3806
+ mission_label: 'Mission',
3807
+ mission: mission.id,
3655
3808
  limit_label: 'Limit',
3656
3809
  limit: timelineResult.meta.limit,
3810
+ kind_label: 'Kind',
3811
+ kind: kindFilter,
3812
+ since_label: 'Since',
3813
+ since: sinceFilter,
3657
3814
  shown_count: timelineResult.meta.shown_count,
3658
3815
  total_count: timelineResult.meta.total_count,
3659
3816
  hidden_count: timelineResult.meta.hidden_count,
@@ -3930,7 +4087,7 @@ function watchMission(args) {
3930
4087
  return listMissions().filter((mission) => !HEARTBEAT_TERMINAL_STATUSES.has(mission.status));
3931
4088
  };
3932
4089
  if (ref && !loadTargets().length) {
3933
- exitMissionError(`Mission "${ref}" not found.`, 1, false);
4090
+ exitMissingMission(ref, 1, false);
3934
4091
  }
3935
4092
  const stamp = () => new Date().toTimeString().slice(0, 8);
3936
4093
  const shortId = (mission) => mission.id.length > 20 ? `…${mission.id.slice(-8)}` : mission.id;
@@ -4225,6 +4382,15 @@ const MISSION_RUN_DEFAULTS = {
4225
4382
  backoff: { initialMs: 30_000, maxMs: 10 * 60_000, factor: 2, jitter: 0.3 },
4226
4383
  };
4227
4384
 
4385
+ // Claude sessions accumulate context across resumed ticks; an always-on
4386
+ // mission would grow without bound. Continuity lives on disk (receipts, logs,
4387
+ // now.md), so a healthy session is disposable: rotate to a fresh one every N
4388
+ // ran ticks. Failure-path rotation (stale lock) stays separate below.
4389
+ const CLAUDE_SESSION_CONTEXT_ROTATE_TICKS = Math.max(
4390
+ 1,
4391
+ Number(process.env.ATRIS_CLAUDE_SESSION_ROTATE_TICKS) || 8,
4392
+ );
4393
+
4228
4394
  function runnerUsesCallerSession(runner) {
4229
4395
  return new Set(['codex_goal', 'caller_session', 'current_agent']).has(String(runner || '').trim().toLowerCase());
4230
4396
  }
@@ -4328,6 +4494,16 @@ function missionIsRunnable(mission) {
4328
4494
  && !missionHasHumanAsks(mission);
4329
4495
  }
4330
4496
 
4497
+ // Fresh goal selection: moving work outranks review-parked work. A ready
4498
+ // mission stays selectable (its next action is native-goal completion /
4499
+ // review), but it must never beat a running or planning mission on recency.
4500
+ const GOAL_SELECTION_STATUS_RANK = { running: 0, planning: 1, ready: 2 };
4501
+
4502
+ function missionGoalSelectionRank(mission) {
4503
+ const rank = GOAL_SELECTION_STATUS_RANK[String(mission?.status || '')];
4504
+ return Number.isFinite(rank) ? rank : 3;
4505
+ }
4506
+
4331
4507
  function missionSortTime(mission) {
4332
4508
  return Date.parse(mission?.updated_at || mission?.created_at || '') || 0;
4333
4509
  }
@@ -4382,6 +4558,10 @@ function selectCodexGoalMission(root = process.cwd(), options = {}, now = new Da
4382
4558
  }
4383
4559
 
4384
4560
  candidates.sort((a, b) => {
4561
+ const aRank = missionGoalSelectionRank(a);
4562
+ const bRank = missionGoalSelectionRank(b);
4563
+ if (aRank !== bRank) return aRank - bRank;
4564
+
4385
4565
  const aCaller = runnerUsesCallerSession(a.runner) ? 1 : 0;
4386
4566
  const bCaller = runnerUsesCallerSession(b.runner) ? 1 : 0;
4387
4567
  if (aCaller !== bCaller) return bCaller - aCaller;
@@ -4409,6 +4589,10 @@ function selectAtrisGoalMission(root = process.cwd(), options = {}, now = new Da
4409
4589
  if (exact) return { mission: exact, reason: 'selected' };
4410
4590
  }
4411
4591
  runnable.sort((a, b) => {
4592
+ const aRank = missionGoalSelectionRank(a);
4593
+ const bRank = missionGoalSelectionRank(b);
4594
+ if (aRank !== bRank) return aRank - bRank;
4595
+
4412
4596
  const aDue = effectiveMissionVerifier(a) && missionDueAt(a, now) ? 1 : 0;
4413
4597
  const bDue = effectiveMissionVerifier(b) && missionDueAt(b, now) ? 1 : 0;
4414
4598
  if (aDue !== bDue) return bDue - aDue;
@@ -4435,6 +4619,42 @@ function codexNativeGoalAck(mission, objective = codexGoalObjective(mission)) {
4435
4619
  return ack;
4436
4620
  }
4437
4621
 
4622
+ function supersedeOtherCodexNativeGoalAcks(root, activeMission, activeAck) {
4623
+ const activeMissionId = String(activeMission?.id || '');
4624
+ if (!activeMissionId) return [];
4625
+ const supersededAt = stampIso();
4626
+ const superseded = [];
4627
+ for (const mission of listMissions(root)) {
4628
+ if (String(mission.id || '') === activeMissionId) continue;
4629
+ const priorAck = codexNativeGoalAck(mission);
4630
+ if (!priorAck) continue;
4631
+ const nextAck = {
4632
+ ...priorAck,
4633
+ status: 'superseded',
4634
+ superseded_at: supersededAt,
4635
+ superseded_by_mission_id: activeMissionId,
4636
+ superseded_by_objective: activeAck?.objective || activeMission.objective,
4637
+ };
4638
+ const { mission: saved } = saveMission(
4639
+ { ...mission, native_goal_ack: nextAck },
4640
+ root,
4641
+ 'mission_native_goal_ack_superseded',
4642
+ {
4643
+ superseded_by_mission_id: activeMissionId,
4644
+ superseded_by_objective: activeAck?.objective || activeMission.objective,
4645
+ previous_ack: priorAck,
4646
+ },
4647
+ );
4648
+ superseded.push({
4649
+ mission_id: saved.id,
4650
+ objective: saved.objective,
4651
+ previous_ack: priorAck,
4652
+ native_goal_ack: nextAck,
4653
+ });
4654
+ }
4655
+ return superseded;
4656
+ }
4657
+
4438
4658
  function codexRuntimeGoalStateFromOptions(options = {}) {
4439
4659
  const status = String(options.nativeGoalStatus || options.visibleGoalStatus || '').trim().toLowerCase();
4440
4660
  const objective = String(options.nativeGoalObjective || options.visibleGoalObjective || '').trim();
@@ -5315,9 +5535,30 @@ function probeClaudeBinary() {
5315
5535
  return { ok: true };
5316
5536
  }
5317
5537
 
5318
- function buildTickPrompt(mission, tickIndex, maxTicks, frozen) {
5538
+ // Pull unread operator pings off the mission and mark them consumed, so the
5539
+ // next tick's prompt carries them exactly once. Pings are how a human talks to
5540
+ // an always-on member mid-run: atris member ping <name> "<msg>".
5541
+ function consumeMissionPings(mission, cwd) {
5542
+ const pending = (Array.isArray(mission.pings) ? mission.pings : []).filter((p) => p && !p.consumed_at);
5543
+ if (!pending.length) return { mission, pings: [] };
5544
+ const consumedAt = stampIso();
5545
+ const pings = (mission.pings || []).map((p) => (p && !p.consumed_at ? { ...p, consumed_at: consumedAt } : p));
5546
+ const saved = saveMission({ ...mission, pings }, cwd, 'mission_pings_consumed', { count: pending.length }).mission;
5547
+ return { mission: saved, pings: pending };
5548
+ }
5549
+
5550
+ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = []) {
5551
+ const pingLines = pings.length
5552
+ ? [
5553
+ ``,
5554
+ `## Operator pings (read these first)`,
5555
+ `Your operator sent ${pings.length === 1 ? 'a message' : 'messages'} mid-run. Treat them as fresh direction for this tick (they do not change the frozen verifier or lane):`,
5556
+ ...pings.map((p) => `- [${p.at}] ${p.from || 'operator'}: ${p.text}`),
5557
+ ]
5558
+ : [];
5319
5559
  const lines = [
5320
5560
  `# Mission Tick ${tickIndex}/${maxTicks}`,
5561
+ ...pingLines,
5321
5562
  ``,
5322
5563
  `**Objective:** ${mission.objective}`,
5323
5564
  `**Owner:** ${mission.owner}`,
@@ -5336,6 +5577,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen) {
5336
5577
  `- Pick the smallest concrete action that moves the mission forward.`,
5337
5578
  `- Edit / run / research as needed for the lane.`,
5338
5579
  `- After your work, the harness runs the frozen verifier — make sure it'll pass.`,
5580
+ `- Write the tick summary in operator language: what changed and what it buys or costs in plain words, with no flags, task ids, or code identifiers.`,
5339
5581
  `- If you can't make progress this tick, say why explicitly. Don't fake it.`,
5340
5582
  ``,
5341
5583
  `## Constraints`,
@@ -5405,7 +5647,10 @@ function spawnClaudeTick(mission, opts) {
5405
5647
  else if (sessionMode === 'resume') args.push('--resume', sessionId);
5406
5648
 
5407
5649
  const startedAt = Date.now();
5408
- const proc = spawn(resolveClaudeRunnerBin(), args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
5650
+ // detached: the runner spawns its own children; killing only the direct
5651
+ // child on timeout leaves them holding the session lock, and the next
5652
+ // tick's resume fails with "Session ID ... is already in use".
5653
+ const proc = spawn(resolveClaudeRunnerBin(), args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
5409
5654
 
5410
5655
  let stdoutBuf = '';
5411
5656
  let observedSessionIds = new Set();
@@ -5422,8 +5667,11 @@ function spawnClaudeTick(mission, opts) {
5422
5667
  let aborted = false;
5423
5668
 
5424
5669
  const kill = (reason) => {
5425
- try { proc.kill('SIGTERM'); } catch {}
5426
- setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} }, 3000).unref();
5670
+ const killGroup = (sig) => {
5671
+ try { process.kill(-proc.pid, sig); } catch { try { proc.kill(sig); } catch {} }
5672
+ };
5673
+ killGroup('SIGTERM');
5674
+ setTimeout(() => killGroup('SIGKILL'), 3000).unref();
5427
5675
  };
5428
5676
  const timer = setTimeout(() => { timedOut = true; kill('timeout'); }, timeoutMs);
5429
5677
  const onAbort = () => { aborted = true; kill('aborted'); };
@@ -5715,7 +5963,9 @@ async function runMission(args) {
5715
5963
  claude: { skipped: true, reason: callerSessionRunner ? 'runner-uses-caller-session' : 'no-claude-mode' },
5716
5964
  };
5717
5965
  } else if (atris2Runner) {
5718
- const prompt = buildTickPrompt(mission, tickIdx, effectiveMaxTicks, frozen);
5966
+ const pingDrain = consumeMissionPings(mission, cwd);
5967
+ mission = pingDrain.mission;
5968
+ const prompt = buildTickPrompt(mission, tickIdx, effectiveMaxTicks, frozen, pingDrain.pings);
5719
5969
  const { runAtris2Turn } = require('./probe');
5720
5970
  const businessId = businessIdForAtris2Mission(mission, cwd);
5721
5971
  const turn = await runAtris2Turn({
@@ -5745,7 +5995,9 @@ async function runMission(args) {
5745
5995
  } else {
5746
5996
  const sessionMode = sessionId ? 'resume' : 'set';
5747
5997
  const useId = sessionId || pendingSessionId;
5748
- const prompt = buildTickPrompt(mission, tickIdx, effectiveMaxTicks, frozen);
5998
+ const pingDrain = consumeMissionPings(mission, cwd);
5999
+ mission = pingDrain.mission;
6000
+ const prompt = buildTickPrompt(mission, tickIdx, effectiveMaxTicks, frozen, pingDrain.pings);
5749
6001
  const claudeResult = await spawnClaudeTick(mission, {
5750
6002
  sessionMode, sessionId: useId, cwd, signal: controller.signal,
5751
6003
  timeoutMs: MISSION_RUN_DEFAULTS.claudeTimeoutMs, prompt,
@@ -5766,10 +6018,11 @@ async function runMission(args) {
5766
6018
  aborted: claudeResult.aborted,
5767
6019
  };
5768
6020
  if (claudeResult.rate_limit_info) {
5769
- lastRateLimit = claudeResult.rate_limit_info;
5770
- if (lastRateLimit.status && lastRateLimit.status !== 'allowed') {
5771
- // throttled / overage
5772
- }
6021
+ // Claude reports the five-hour window's resetsAt on every turn, even
6022
+ // when status is "allowed". Only a non-allowed status is a cooldown;
6023
+ // treating an allowed resetsAt as one pauses every timed run after
6024
+ // tick 1 with rate-limit-exceeded-wall.
6025
+ lastRateLimit = claudeResult.rate_limit_info.status === 'allowed' ? null : claudeResult.rate_limit_info;
5773
6026
  }
5774
6027
  if (claudeResult.aborted) { pauseReason = 'aborted-during-claude'; break; }
5775
6028
  if (claudeResult.authExpired) { pauseReason = 'auth-required'; break; }
@@ -5778,6 +6031,20 @@ async function runMission(args) {
5778
6031
  const deadModel = detectUnavailableModel(claudeResult.summary || claudeResult.receipt_text);
5779
6032
  let reason = claudeResult.timedOut ? 'claude-timeout' : 'claude-error';
5780
6033
  if (deadModel) { reason = 'model-unavailable'; result.model_unavailable = deadModel; }
6034
+ // A killed tick (usually claude-timeout) can leave the session lock
6035
+ // held, so the next resume fails with "already in use". Session
6036
+ // continuity is disposable — mission state lives on disk (receipts,
6037
+ // logs, now.md) — so rotate to a fresh id instead of grinding the
6038
+ // repeated-error breaker on a stale lock.
6039
+ if (/session id .* is already in use/i.test(claudeResult.stderr || '')) {
6040
+ reason = 'claude-session-busy';
6041
+ pendingSessionId = crypto.randomUUID();
6042
+ sessionId = null;
6043
+ mission = saveMission(
6044
+ { ...mission, claude_session_id: null, pending_session_id: pendingSessionId },
6045
+ cwd, 'mission_session_rotated', { reason: 'session-lock-busy', session_id: pendingSessionId },
6046
+ ).mission;
6047
+ }
5781
6048
  result = { ...result, status: 'errored', reason };
5782
6049
  } else {
5783
6050
  // Promote pending session id ONLY if claude confirmed the exact UUID we requested.
@@ -5865,6 +6132,11 @@ async function runMission(args) {
5865
6132
  } else if (result.status === 'ran' && mission.always_on) {
5866
6133
  nextAction = nextCandidateTickAction(mission);
5867
6134
  }
6135
+ // Context hygiene: count ran claude ticks on this session; at the
6136
+ // rotation threshold, drop the session so the next tick starts fresh.
6137
+ const claudeRanTick = Boolean(result.claude) && result.status === 'ran';
6138
+ const claudeSessionTicks = claudeRanTick ? Number(mission.claude_session_ticks || 0) + 1 : Number(mission.claude_session_ticks || 0);
6139
+ const rotateSessionForContext = claudeRanTick && claudeSessionTicks >= CLAUDE_SESSION_CONTEXT_ROTATE_TICKS;
5868
6140
  mission = saveMission({
5869
6141
  ...mission,
5870
6142
  status: newStatus,
@@ -5879,9 +6151,23 @@ async function runMission(args) {
5879
6151
  verifier_result: verifierResult || mission.verifier_result || null,
5880
6152
  receipt_path: receiptPath,
5881
6153
  next_action: nextAction,
6154
+ claude_session_ticks: rotateSessionForContext ? 0 : claudeSessionTicks,
5882
6155
  }, cwd, 'mission_tick', {
5883
6156
  tick_index: tickIdx, status: result.status, reason: result.reason, receipt_path: receiptPath, layer: result.layer,
5884
6157
  }).mission;
6158
+ if (rotateSessionForContext) {
6159
+ // Same shape as the stale-lock rotation above: mint the fresh pending
6160
+ // id AND reset the loop locals, or the next tick resumes the old
6161
+ // session from its stale local and the rotation is a no-op.
6162
+ pendingSessionId = crypto.randomUUID();
6163
+ sessionId = null;
6164
+ mission = saveMission(
6165
+ { ...mission, claude_session_id: null, pending_session_id: pendingSessionId },
6166
+ cwd, 'mission_session_rotated', {
6167
+ reason: 'context-refresh', after_ticks: claudeSessionTicks, session_id: pendingSessionId,
6168
+ },
6169
+ ).mission;
6170
+ }
5885
6171
  appendMemberLog(mission.owner, `Mission run tick ${tickIdx}`, {
5886
6172
  mission: mission.objective,
5887
6173
  state: mission.status,
@@ -5904,7 +6190,10 @@ async function runMission(args) {
5904
6190
  if (result.status === 'ran') {
5905
6191
  ranTicks++;
5906
6192
  backoffAttempt = 0;
5907
- } else if (result.status === 'errored') {
6193
+ } else if (result.status === 'errored' && result.reason !== 'claude-session-busy') {
6194
+ // A rotated session is already healed — the next tick starts on a
6195
+ // fresh id, so backing off just burns wall clock. If rotation itself
6196
+ // keeps failing, the repeated-error breaker below still stops the run.
5908
6197
  backoffAttempt++;
5909
6198
  }
5910
6199
 
@@ -5924,7 +6213,7 @@ async function runMission(args) {
5924
6213
 
5925
6214
  // Sleep until next tick
5926
6215
  let sleepMs = 0;
5927
- if (result.status === 'errored') {
6216
+ if (result.status === 'errored' && result.reason !== 'claude-session-busy') {
5928
6217
  sleepMs = computeBackoff(MISSION_RUN_DEFAULTS.backoff, backoffAttempt);
5929
6218
  } else if (cadenceSeconds > 0) {
5930
6219
  sleepMs = cadenceSeconds * 1000;
@@ -6009,10 +6298,12 @@ function tickMission(args) {
6009
6298
  const verifyOverride = readFlag(args, '--verify', '');
6010
6299
  const completeOnPass = hasFlag(args, '--complete-on-pass');
6011
6300
  const summary = readFlag(args, '--summary', '');
6301
+ const operatorSummaryWarning = warnIfSummaryNeedsOperatorWhy(summary);
6012
6302
  const ref = stripKnownFlags(args, ['--summary', '--verify'], ['--json', '--complete-on-pass'])[0] || '';
6013
6303
  let mission = resolveMission(ref);
6014
6304
  if (!mission) {
6015
- exitMissionError(ref ? `Mission "${ref}" not found.` : 'No mission found. Run: atris mission start "..."', 1, asJson);
6305
+ if (ref) exitMissingMission(ref, 1, asJson);
6306
+ exitMissionError('No mission found. Run: atris mission start "..."', 1, asJson);
6016
6307
  }
6017
6308
 
6018
6309
  // Same per-mission flock that `mission run` uses. Without it, a tick could
@@ -6154,7 +6445,7 @@ function tickMission(args) {
6154
6445
  const atrisGoalState = refreshAtrisGoalController(process.cwd(), { missionId: outputMission.id });
6155
6446
  const codexGoalState = refreshCodexGoalController(process.cwd());
6156
6447
  printJsonOrText(
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 },
6448
+ { 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, operator_summary_warning: operatorSummaryWarning },
6158
6449
  [
6159
6450
  ...missionTickResultLines(outputMission, tickIdx, receiptPath, verifierResult, summary),
6160
6451
  ...(continuationGoal?.mission ? [`Next goal: ${continuationGoal.mission.objective}`] : []),
@@ -6223,7 +6514,7 @@ function completeMission(args) {
6223
6514
  }
6224
6515
  const mission = resolveMission(ref);
6225
6516
  if (!mission) {
6226
- exitMissionError(`Mission "${ref}" not found.`, 1, asJson);
6517
+ exitMissingMission(ref, 1, asJson);
6227
6518
  }
6228
6519
  const gate = missionCompletionGate(mission, proof, root);
6229
6520
  if (!gate.ok && !force) {
@@ -6299,7 +6590,7 @@ function stopMission(args) {
6299
6590
  }
6300
6591
  const mission = resolveMission(ref);
6301
6592
  if (!mission) {
6302
- exitMissionError(`Mission "${ref}" not found.`, 1, asJson);
6593
+ exitMissingMission(ref, 1, asJson);
6303
6594
  }
6304
6595
  const status = pause ? 'paused' : 'stopped';
6305
6596
  // Full stops abandon work, so leave evidence: snapshot the worktree against
@@ -6415,7 +6706,7 @@ function ackMissionGoal(args) {
6415
6706
  const status = String(readFlag(args, '--status', 'active') || 'active').trim().toLowerCase();
6416
6707
  let mission = resolveMission(ref);
6417
6708
  if (!mission) {
6418
- exitMissionError(`Mission "${ref}" not found.`, 1, asJson);
6709
+ exitMissingMission(ref, 1, asJson);
6419
6710
  }
6420
6711
  if (runtime !== 'codex' || status !== 'active') {
6421
6712
  exitMissionError('Native goal ack requires --runtime codex --status active.', 2, asJson);
@@ -6449,6 +6740,7 @@ function ackMissionGoal(args) {
6449
6740
  native_goal_ack: ack,
6450
6741
  next_action: codexGoalNextCommand({ ...mission, native_goal_ack: ack }),
6451
6742
  };
6743
+ const supersededNativeGoalAcks = supersedeOtherCodexNativeGoalAcks(process.cwd(), nextMission, ack);
6452
6744
  const { mission: saved } = saveMission(nextMission, process.cwd(), 'mission_native_goal_ack', { ack });
6453
6745
  const payload = refreshCodexGoalController(process.cwd());
6454
6746
  printJsonOrText(
@@ -6457,6 +6749,7 @@ function ackMissionGoal(args) {
6457
6749
  action: 'native_goal_acknowledged',
6458
6750
  mission: saved,
6459
6751
  native_goal_ack: ack,
6752
+ superseded_native_goal_acks: supersededNativeGoalAcks,
6460
6753
  codex_goal_state: payload,
6461
6754
  next_command: payload.goal?.next_command || `atris mission tick ${saved.id} --summary "<what changed>"`,
6462
6755
  },
@@ -6838,6 +7131,64 @@ function pruneRunsMission(args) {
6838
7131
  if (result.errors.length) process.exitCode = 1;
6839
7132
  }
6840
7133
 
7134
+ // Locate a mission whose state may live in a sibling worktree (--worktree
7135
+ // missions keep their jsonl there). Returns the mission plus the root whose
7136
+ // state file owns it, so writes land where the runner reads.
7137
+ function findMissionAcrossWorktrees(ref, root = process.cwd()) {
7138
+ const local = resolveMission(ref, root);
7139
+ if (local) return { mission: local, root };
7140
+ for (const rolled of listWorktreeRollupMissions(root)) {
7141
+ if (rolled.id === ref || rolled.id.startsWith(ref) || rolled.slug === ref) {
7142
+ const { worktree_root, worktree_branch, ...mission } = rolled;
7143
+ return { mission, root: worktree_root };
7144
+ }
7145
+ }
7146
+ return null;
7147
+ }
7148
+
7149
+ // atris mission ping <id> "<message>" — leave a note the mission's next tick
7150
+ // reads (and consumes) as operator direction. This is how you talk to an
7151
+ // always-on member mid-run without stopping it.
7152
+ function pingMission(args) {
7153
+ const asJson = args.includes('--json');
7154
+ const rest = args.filter((a) => a !== '--json');
7155
+ let from = process.env.USER || 'operator';
7156
+ const fromIdx = rest.indexOf('--from');
7157
+ if (fromIdx !== -1) {
7158
+ from = rest[fromIdx + 1] || from;
7159
+ rest.splice(fromIdx, 2);
7160
+ }
7161
+ const [ref, ...textParts] = rest;
7162
+ const text = textParts.join(' ').trim();
7163
+ if (!ref || !text) {
7164
+ console.error('usage: atris mission ping <id> "<message>" [--from <name>]');
7165
+ process.exit(2);
7166
+ }
7167
+ const found = findMissionAcrossWorktrees(ref);
7168
+ if (!found) {
7169
+ exitMissingMission(ref, 1, asJson);
7170
+ }
7171
+ const { mission, root } = found;
7172
+ if (TERMINAL_STATUSES.has(mission.status)) {
7173
+ console.error(`Mission ${mission.id} is ${mission.status}; pings only reach live missions.`);
7174
+ process.exit(1);
7175
+ }
7176
+ const ping = { at: stampIso(), from, text };
7177
+ const saved = saveMission(
7178
+ { ...mission, pings: [...(Array.isArray(mission.pings) ? mission.pings : []), ping] },
7179
+ root,
7180
+ 'mission_ping',
7181
+ { from, text: text.slice(0, 200) },
7182
+ ).mission;
7183
+ const pending = (saved.pings || []).filter((p) => p && !p.consumed_at).length;
7184
+ if (asJson) {
7185
+ console.log(JSON.stringify({ ok: true, action: 'mission_ping', mission_id: saved.id, pending_pings: pending, ping }));
7186
+ } else {
7187
+ console.log(`pinged ${saved.id} — the next tick reads it (${pending} unread).`);
7188
+ }
7189
+ return saved;
7190
+ }
7191
+
6841
7192
  function missionCommand(args) {
6842
7193
  const subcommand = args[0] || 'status';
6843
7194
  const rest = args.slice(1);
@@ -6882,6 +7233,8 @@ function missionCommand(args) {
6882
7233
  case 'goal-loop':
6883
7234
  case 'codex-goal-loop':
6884
7235
  return goalLoopMission(rest);
7236
+ case 'ping':
7237
+ return pingMission(rest);
6885
7238
  case 'tick':
6886
7239
  return tickMission(rest);
6887
7240
  case 'run':
@@ -6906,9 +7259,12 @@ module.exports = {
6906
7259
  missionCommand,
6907
7260
  missionHeartbeatLines,
6908
7261
  listMissions,
7262
+ listWorktreeRollupMissions,
7263
+ pingMission,
6909
7264
  loadMissionMap,
6910
7265
  renderMissionStatus,
6911
7266
  selectDueMission,
7267
+ selectAtrisGoalMission,
6912
7268
  selectCodexGoalMission,
6913
7269
  usefulClaudeReceiptSummary,
6914
7270
  cappedClaudeReceiptText,