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.
- package/README.md +3 -3
- package/atris/CLAUDE.md +8 -0
- package/atris.md +8 -0
- package/ax +94 -4
- package/bin/atris.js +148 -73
- package/commands/autoland.js +70 -10
- package/commands/autopilot-front.js +273 -0
- package/commands/init.js +21 -0
- package/commands/member.js +32 -0
- package/commands/mission.js +386 -30
- package/commands/run-front.js +144 -0
- package/commands/run.js +9 -6
- package/commands/sign.js +90 -0
- package/commands/task.js +207 -6
- package/commands/truth.js +2 -1
- package/lib/autoland.js +133 -25
- package/lib/operator-next.js +7 -0
- package/lib/pulse.js +4 -3
- package/lib/runner-command.js +32 -13
- package/lib/task-db.js +19 -2
- package/package.json +2 -2
package/commands/mission.js
CHANGED
|
@@ -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) =>
|
|
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
|
|
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|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5426
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
5770
|
-
|
|
5771
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|