atris 3.79.0 → 3.80.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/atris/atris.md +66 -167
- package/atris/skills/security-audit/SKILL.md +77 -0
- package/atris/team/brainstormer/MEMBER.md +2 -2
- package/atris.md +66 -167
- package/bin/atris.js +6 -0
- package/commands/auth.js +2 -2
- package/commands/business.js +44 -1984
- package/commands/computer.js +165 -2412
- package/commands/member.js +189 -966
- package/commands/mission.js +229 -1409
- package/commands/pack.js +112 -1476
- package/commands/security-review.js +129 -0
- package/commands/site-deploy.js +5 -1
- package/commands/task.js +137 -1359
- package/lib/business-core.js +2022 -0
- package/lib/computer-core.js +2447 -0
- package/lib/engine-ask.js +1 -0
- package/lib/handoff-wake.js +8 -6
- package/lib/known-commands.js +1 -1
- package/lib/member-autoland.js +21 -2
- package/lib/member-board.js +12 -10
- package/lib/member-core.js +1014 -0
- package/lib/member-up.js +46 -3
- package/lib/mission-core.js +1559 -0
- package/lib/pack-core.js +1588 -0
- package/lib/security-audit-classes.js +318 -0
- package/lib/security-audit.js +1549 -0
- package/lib/task-core.js +1412 -0
- package/lib/task-decision.js +66 -0
- package/lib/task-explanation.js +20 -0
- package/lib/yes-lock.js +1 -1
- package/package.json +9 -2
- package/utils/auth.js +18 -4
|
@@ -0,0 +1,1559 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const readline = require('readline');
|
|
7
|
+
const { hasFlag, readFlag } = require('./arg-parser');
|
|
8
|
+
const {
|
|
9
|
+
compactErrorPayload,
|
|
10
|
+
printCliJson,
|
|
11
|
+
} = require('./cli-json');
|
|
12
|
+
const {
|
|
13
|
+
resolveClaudeRunnerModel,
|
|
14
|
+
resolvePinnedRunnerModel,
|
|
15
|
+
runnerModelFor,
|
|
16
|
+
RUNNER_PROFILE_DEFS,
|
|
17
|
+
RUNNER_PROFILE_ALIASES,
|
|
18
|
+
RUNNER_PROFILE_NAMES,
|
|
19
|
+
} = require('./runner-command');
|
|
20
|
+
const {
|
|
21
|
+
resolveEngineForRoleWithPreference,
|
|
22
|
+
engineFailureHealthStatus,
|
|
23
|
+
engineStallReason,
|
|
24
|
+
recordEngineRunHealth,
|
|
25
|
+
setEngineHealth,
|
|
26
|
+
} = require('./engine-registry');
|
|
27
|
+
const { appendRosterRun } = require('./roster-runs');
|
|
28
|
+
const {
|
|
29
|
+
FUNCTIONAL_MEMBER_TOPICS,
|
|
30
|
+
listWorkspaceMemberSlugs,
|
|
31
|
+
normalizeOwnerSlug,
|
|
32
|
+
} = require('./functional-owner');
|
|
33
|
+
const { hasAgentJargon } = require('../commands/autoland');
|
|
34
|
+
const {
|
|
35
|
+
displayNumber,
|
|
36
|
+
meaningfulWords,
|
|
37
|
+
recordMatchesRef,
|
|
38
|
+
shortRecordLabel,
|
|
39
|
+
shortRecordRef,
|
|
40
|
+
} = require('./short-name');
|
|
41
|
+
const { readJson, writeJson } = require('./json-file');
|
|
42
|
+
|
|
43
|
+
const VALID_STATUSES = new Set(['planning', 'running', 'ready', 'paused', 'blocked', 'stopped', 'complete']);
|
|
44
|
+
const TERMINAL_STATUSES = new Set(['stopped', 'complete']);
|
|
45
|
+
const GOAL_LOOP_STATUSES = new Set(['planning', 'running', 'ready']);
|
|
46
|
+
const STATUS_ALIASES = new Set(['active']);
|
|
47
|
+
const CODEX_NATIVE_GOAL_SLOT_STATUSES = new Set(['active', 'paused', 'usage_limited']);
|
|
48
|
+
const CODEX_NATIVE_GOAL_REPLACE_STATUSES = new Set(['active', 'paused', 'usage_limited']);
|
|
49
|
+
const CODEX_NATIVE_GOAL_CLOSED_STATUSES = new Set(['complete', 'completed', 'achieved']);
|
|
50
|
+
const DEFAULT_LONG_RUN_VERIFIER = 'git diff --check';
|
|
51
|
+
const SLEEP_LENGTH_BUDGET_SECONDS = 3600;
|
|
52
|
+
const HUMAN_BLOCKING_PAUSE_REASONS = new Set(['auth-required', 'model-unavailable', 'rate-limit-exceeded-wall']);
|
|
53
|
+
// Claude prints its logged-out message on stdout ("Invalid API key · Please
|
|
54
|
+
// run /login"), not stderr, so the check has to read the captured result text
|
|
55
|
+
// too or a dead login masquerades as a generic claude-error.
|
|
56
|
+
const MISSION_RUNNER_AUTH_EXPIRED_RE = /not authenticated|please log in|login required|auth(?:entication)? expired|invalid (?:api|connection) key|run \/login/i;
|
|
57
|
+
|
|
58
|
+
function missionRunnerAuthExpired(...texts) {
|
|
59
|
+
return texts.some((text) => Boolean(text) && MISSION_RUNNER_AUTH_EXPIRED_RE.test(String(text)));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const MISSION_BUDGET_TIERS = Object.freeze({
|
|
63
|
+
quick: Object.freeze({ max_ticks: 4, requested_seconds: 15 * 60 }),
|
|
64
|
+
long: Object.freeze({ max_ticks: 12, requested_seconds: 60 * 60 }),
|
|
65
|
+
deep: Object.freeze({ max_ticks: 30, requested_seconds: 180 * 60 }),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
function stampIso() {
|
|
69
|
+
return new Date().toISOString();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function todayName() {
|
|
73
|
+
const now = new Date();
|
|
74
|
+
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function slugify(value) {
|
|
78
|
+
return String(value || 'mission')
|
|
79
|
+
.toLowerCase()
|
|
80
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
81
|
+
.replace(/^-+|-+$/g, '')
|
|
82
|
+
.slice(0, 48) || 'mission';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function shortHash(value) {
|
|
86
|
+
return crypto.createHash('sha1').update(String(value || '')).digest('hex').slice(0, 8);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function missionId(objective) {
|
|
90
|
+
return `mission-${todayName()}-${slugify(objective).slice(0, 28)}-${shortHash(`${objective}:${Date.now()}`)}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function destinationHash(destination) {
|
|
94
|
+
return crypto.createHash('sha256').update(String(destination || '')).digest('hex');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function missionDrive(destination) {
|
|
98
|
+
const value = String(destination || '').trim();
|
|
99
|
+
if (!value) return null;
|
|
100
|
+
return {
|
|
101
|
+
schema: 'atris.mission.drive.v1',
|
|
102
|
+
destination: value,
|
|
103
|
+
destination_hash: destinationHash(value),
|
|
104
|
+
autonomy_level: 'L1',
|
|
105
|
+
route_version: 1,
|
|
106
|
+
position: 0,
|
|
107
|
+
legs: [],
|
|
108
|
+
hard_gates: [],
|
|
109
|
+
budget: null,
|
|
110
|
+
stop_reason: null,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function proposeMissionDestinationChange(mission, destination) {
|
|
115
|
+
const value = String(destination || '').trim();
|
|
116
|
+
if (!mission?.drive || !value || value === mission.drive.destination) return mission;
|
|
117
|
+
return {
|
|
118
|
+
...mission,
|
|
119
|
+
drive: {
|
|
120
|
+
...mission.drive,
|
|
121
|
+
pending_destination_proposal: {
|
|
122
|
+
destination: value,
|
|
123
|
+
destination_hash: destinationHash(value),
|
|
124
|
+
reason: 'destination changes require operator approval',
|
|
125
|
+
operator_gated: true,
|
|
126
|
+
proposed_at: stampIso(),
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const SUPPORTED_MISSION_HARD_GATES = new Set([
|
|
133
|
+
'money_movement',
|
|
134
|
+
'outbound_communication',
|
|
135
|
+
'destructive_change',
|
|
136
|
+
'irreversible_change',
|
|
137
|
+
'secret_access',
|
|
138
|
+
'destination_change',
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
function missionRouteLegId(leg, index) {
|
|
142
|
+
return String(leg?.id || leg?.key || `leg-${index + 1}`).trim();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function missionRouteHardGates(proposal) {
|
|
146
|
+
const gates = [];
|
|
147
|
+
const add = (value) => {
|
|
148
|
+
const values = Array.isArray(value) ? value : value ? [value] : [];
|
|
149
|
+
for (const entry of values) {
|
|
150
|
+
const gate = String(entry || '').trim().toLowerCase().replace(/[\s-]+/g, '_');
|
|
151
|
+
if (gate && !gates.includes(gate)) gates.push(gate);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
add(proposal?.hard_gates);
|
|
155
|
+
for (const leg of proposal?.legs || []) {
|
|
156
|
+
add(leg?.hard_gates);
|
|
157
|
+
add(leg?.hard_gate);
|
|
158
|
+
}
|
|
159
|
+
return gates;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function missionRouteScopePresent(leg) {
|
|
163
|
+
const files = Array.isArray(leg?.files) ? leg.files : leg?.files ? [leg.files] : [];
|
|
164
|
+
const surfaces = Array.isArray(leg?.surface) ? leg.surface : leg?.surface ? [leg.surface] : [];
|
|
165
|
+
return [...files, ...surfaces].some((value) => String(value || '').trim());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function missionRouteCycle(legs) {
|
|
169
|
+
const ids = legs.map(missionRouteLegId);
|
|
170
|
+
const dependencies = new Map(legs.map((leg, index) => [
|
|
171
|
+
ids[index],
|
|
172
|
+
Array.isArray(leg.dependencies) ? leg.dependencies.map((value) => String(value || '').trim()).filter(Boolean) : [],
|
|
173
|
+
]));
|
|
174
|
+
const visiting = new Set();
|
|
175
|
+
const visited = new Set();
|
|
176
|
+
const stack = [];
|
|
177
|
+
|
|
178
|
+
function visit(id) {
|
|
179
|
+
if (visiting.has(id)) {
|
|
180
|
+
const start = stack.indexOf(id);
|
|
181
|
+
return [...stack.slice(start), id];
|
|
182
|
+
}
|
|
183
|
+
if (visited.has(id)) return null;
|
|
184
|
+
visiting.add(id);
|
|
185
|
+
stack.push(id);
|
|
186
|
+
for (const dependency of dependencies.get(id) || []) {
|
|
187
|
+
const cycle = visit(dependency);
|
|
188
|
+
if (cycle) return cycle;
|
|
189
|
+
}
|
|
190
|
+
stack.pop();
|
|
191
|
+
visiting.delete(id);
|
|
192
|
+
visited.add(id);
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const id of ids) {
|
|
197
|
+
const cycle = visit(id);
|
|
198
|
+
if (cycle) return cycle;
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function validateMissionRouteProposal(proposal) {
|
|
204
|
+
const legs = proposal?.legs;
|
|
205
|
+
if (!Array.isArray(legs) || legs.length === 0) {
|
|
206
|
+
return { code: 'missing_legs', reason: 'route proposal must contain at least one leg' };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const ids = legs.map(missionRouteLegId);
|
|
210
|
+
const idSet = new Set(ids);
|
|
211
|
+
if (idSet.size !== ids.length) {
|
|
212
|
+
return { code: 'duplicate_leg_id', reason: 'route proposal contains duplicate leg ids' };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
for (let index = 0; index < legs.length; index += 1) {
|
|
216
|
+
const leg = legs[index];
|
|
217
|
+
const id = ids[index];
|
|
218
|
+
if (!leg || typeof leg !== 'object' || Array.isArray(leg)) {
|
|
219
|
+
return { code: 'invalid_leg', reason: `route leg ${id} must be an object` };
|
|
220
|
+
}
|
|
221
|
+
if (!String(leg.objective || '').trim()) {
|
|
222
|
+
return { code: 'missing_objective', reason: `route leg ${id} is missing an objective` };
|
|
223
|
+
}
|
|
224
|
+
if (!String(leg.verifier || '').trim()) {
|
|
225
|
+
return { code: 'missing_verifier', reason: `route leg ${id} is missing a verifier command` };
|
|
226
|
+
}
|
|
227
|
+
if (!String(leg.stop_condition || leg.terminal_condition || leg.terminal_state || '').trim()) {
|
|
228
|
+
return { code: 'missing_stop_condition', reason: `route leg ${id} is missing a stop or terminal condition` };
|
|
229
|
+
}
|
|
230
|
+
if (!missionRouteScopePresent(leg)) {
|
|
231
|
+
return { code: 'missing_scope', reason: `route leg ${id} has no files or surface scope` };
|
|
232
|
+
}
|
|
233
|
+
if (leg.dependencies !== undefined && !Array.isArray(leg.dependencies)) {
|
|
234
|
+
return { code: 'invalid_dependencies', reason: `route leg ${id} dependencies must be a list` };
|
|
235
|
+
}
|
|
236
|
+
for (const dependency of leg.dependencies || []) {
|
|
237
|
+
const dependencyId = String(dependency || '').trim();
|
|
238
|
+
if (!idSet.has(dependencyId)) {
|
|
239
|
+
return { code: 'unknown_dependency', reason: `route leg ${id} depends on unknown leg ${dependencyId || '(empty)'}` };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const unsupportedGate = missionRouteHardGates(proposal)
|
|
245
|
+
.find((gate) => !SUPPORTED_MISSION_HARD_GATES.has(gate));
|
|
246
|
+
if (unsupportedGate) {
|
|
247
|
+
return { code: 'unsupported_hard_gate', reason: `route uses unsupported hard gate ${unsupportedGate}` };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const cycle = missionRouteCycle(legs);
|
|
251
|
+
if (cycle) {
|
|
252
|
+
return { code: 'dependency_cycle', reason: `route dependencies form a cycle: ${cycle.join(' -> ')}` };
|
|
253
|
+
}
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function readMissionRouteProposal(args, asJson) {
|
|
258
|
+
const source = readFlag(args, '--file', '');
|
|
259
|
+
let raw = '';
|
|
260
|
+
try {
|
|
261
|
+
raw = source && source !== '-'
|
|
262
|
+
? fs.readFileSync(path.resolve(process.cwd(), source), 'utf8')
|
|
263
|
+
: fs.readFileSync(0, 'utf8');
|
|
264
|
+
} catch (error) {
|
|
265
|
+
exitMissionError(`Could not read route proposal: ${error.message}`, 1, asJson);
|
|
266
|
+
}
|
|
267
|
+
if (!String(raw || '').trim()) {
|
|
268
|
+
exitMissionError('Route proposal JSON is required on stdin or with --file <path>.', 1, asJson);
|
|
269
|
+
}
|
|
270
|
+
let parsed;
|
|
271
|
+
try {
|
|
272
|
+
parsed = JSON.parse(raw);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
exitMissionError(`Route proposal is not valid JSON: ${error.message}`, 1, asJson);
|
|
275
|
+
}
|
|
276
|
+
if (Array.isArray(parsed)) return { legs: parsed, hard_gates: [] };
|
|
277
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
278
|
+
exitMissionError('Route proposal must be a list of legs or an object with a legs list.', 1, asJson);
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
...parsed,
|
|
282
|
+
legs: parsed.legs,
|
|
283
|
+
hard_gates: parsed.hard_gates ?? [],
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const MISSION_NATIVE_RUNNER_NAMES = Object.freeze(['manual', 'claude', 'atris2', 'codex_goal', 'caller_session', 'current_agent', 'drill']);
|
|
288
|
+
const MISSION_NATIVE_RUNNER_SET = new Set(MISSION_NATIVE_RUNNER_NAMES);
|
|
289
|
+
const MISSION_AUTO_RUNNER = 'auto';
|
|
290
|
+
|
|
291
|
+
function canonicalEngineName(name) {
|
|
292
|
+
const trimmed = String(name || '').trim();
|
|
293
|
+
if (!trimmed) return '';
|
|
294
|
+
if (RUNNER_PROFILE_DEFS[trimmed]) return trimmed;
|
|
295
|
+
if (RUNNER_PROFILE_ALIASES[trimmed]) return RUNNER_PROFILE_ALIASES[trimmed];
|
|
296
|
+
return '';
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function knownMissionRunnerText() {
|
|
300
|
+
return `Known runners: ${[...MISSION_NATIVE_RUNNER_NAMES, MISSION_AUTO_RUNNER].join(', ')}. Known engines: ${RUNNER_PROFILE_NAMES.join(', ')}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function resolveMissionRunnerSelection(value, options = {}) {
|
|
304
|
+
const raw = String(value || '').trim();
|
|
305
|
+
const asJson = options.asJson === true;
|
|
306
|
+
const engineOnly = options.engineOnly === true;
|
|
307
|
+
if (!raw) {
|
|
308
|
+
exitMissionError(`${options.label || 'runner'} is required. ${knownMissionRunnerText()}.`, 2, asJson);
|
|
309
|
+
}
|
|
310
|
+
const runner = raw.toLowerCase();
|
|
311
|
+
if (runner === MISSION_AUTO_RUNNER) {
|
|
312
|
+
return { requested: raw, runner, engine: null, kind: 'auto' };
|
|
313
|
+
}
|
|
314
|
+
if (!engineOnly && MISSION_NATIVE_RUNNER_SET.has(runner)) {
|
|
315
|
+
return { requested: raw, runner, engine: null, kind: 'runner' };
|
|
316
|
+
}
|
|
317
|
+
const engine = canonicalEngineName(raw);
|
|
318
|
+
if (engine) {
|
|
319
|
+
return { requested: raw, runner: engine, engine, kind: 'engine' };
|
|
320
|
+
}
|
|
321
|
+
const noun = engineOnly ? 'engine' : 'runner';
|
|
322
|
+
exitMissionError(`Unknown ${noun} "${raw}". ${knownMissionRunnerText()}.`, 2, asJson);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// The roster line's effort and time cap for this tick, when it sets them.
|
|
326
|
+
function rosterTickPins(engine) {
|
|
327
|
+
return {
|
|
328
|
+
...(engine && engine.roster_effort ? { roster_effort: engine.roster_effort } : {}),
|
|
329
|
+
...(engine && engine.roster_max_seconds ? { roster_max_seconds: engine.roster_max_seconds } : {}),
|
|
330
|
+
...(engine && engine.roster_prep ? { roster_prep: engine.roster_prep } : {}),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// A tick whose roster worker asks for prep ("prep: search") runs the same
|
|
335
|
+
// prep pass fleet runs before the worker starts, and the worker's prompt
|
|
336
|
+
// gets the brief. No prep line means the prompt goes out as it was.
|
|
337
|
+
async function missionTickPrep(root, { mission, runtimeMission, job, prompt, ask } = {}) {
|
|
338
|
+
const prepJob = String(runtimeMission && runtimeMission.roster_prep || '').trim();
|
|
339
|
+
if (!prepJob) return { prompt, prep: null };
|
|
340
|
+
const { runPrepPass, withPrepBrief } = require('./roster-prep');
|
|
341
|
+
const prep = await runPrepPass({
|
|
342
|
+
prepJob,
|
|
343
|
+
forJob: job || 'build',
|
|
344
|
+
task: { display_id: (mission && mission.id) || '', title: (mission && (mission.objective || mission.slug)) || '' },
|
|
345
|
+
prompt,
|
|
346
|
+
root,
|
|
347
|
+
record: { member: (mission && mission.owner) || '' },
|
|
348
|
+
...(ask ? { ask } : {}),
|
|
349
|
+
});
|
|
350
|
+
return { prompt: withPrepBrief(prompt, prep), prep };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// The reason a member log shows for a tick. A failed engine tick names its
|
|
354
|
+
// engine beside the reason, so a codex run that timed out stops counting as
|
|
355
|
+
// a claude timeout in the repeat-problem scan. The reason itself is left as
|
|
356
|
+
// is because engine health and backoff match on it.
|
|
357
|
+
function memberLogTickReason(result) {
|
|
358
|
+
const reason = result && result.reason;
|
|
359
|
+
if (!reason || !result.engine_id || result.status !== 'errored') return reason;
|
|
360
|
+
return `${reason} (engine: ${result.engine_id})`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function resolveMissionTickRunner(mission, root = process.cwd(), options = {}) {
|
|
364
|
+
if (String(mission && mission.runner || '').trim().toLowerCase() !== MISSION_AUTO_RUNNER) {
|
|
365
|
+
// A named engine still gets the roster's time limit for that engine.
|
|
366
|
+
const runner = String(mission && mission.runner || '').trim().toLowerCase();
|
|
367
|
+
const pin = runner && !(Number(mission && mission.roster_max_seconds) > 0)
|
|
368
|
+
? require('./engine-registry').rosterPinFor('executor', runner, root, options)
|
|
369
|
+
: {};
|
|
370
|
+
const limited = pin.roster_max_seconds ? { ...mission, roster_max_seconds: pin.roster_max_seconds } : mission;
|
|
371
|
+
return { mission: limited, engine_id: null, requested_engine: null, engine_fallback_reason: null };
|
|
372
|
+
}
|
|
373
|
+
// A mission owned by a team member runs on that member's roster pick. An
|
|
374
|
+
// explicit preferred engine (or a run's --engine) still wins, and with no
|
|
375
|
+
// roster line behind the member, routing stays exactly as it was.
|
|
376
|
+
if (!String(mission.preferred_engine || '').trim() && mission.owner) {
|
|
377
|
+
const { memberRosterEngine } = require('./member-engine');
|
|
378
|
+
const member = memberRosterEngine(mission.owner, root, options);
|
|
379
|
+
if (member) {
|
|
380
|
+
if (process.env.ATRIS_ROUTER_EXPLAIN !== '0') console.error(member.reason);
|
|
381
|
+
return {
|
|
382
|
+
mission: {
|
|
383
|
+
...mission,
|
|
384
|
+
runner: member.engine.id,
|
|
385
|
+
runner_kind: 'engine',
|
|
386
|
+
...(!mission.model && member.model ? { model: member.model } : {}),
|
|
387
|
+
...rosterTickPins(member.engine),
|
|
388
|
+
},
|
|
389
|
+
engine_id: member.engine.id,
|
|
390
|
+
requested_engine: null,
|
|
391
|
+
engine_fallback_reason: null,
|
|
392
|
+
member_engine_reason: member.reason,
|
|
393
|
+
roster_job: member.job || 'build',
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const resolved = resolveEngineForRoleWithPreference('executor', root, mission.preferred_engine, options);
|
|
398
|
+
return {
|
|
399
|
+
mission: resolved.engine ? {
|
|
400
|
+
...mission,
|
|
401
|
+
runner: resolved.engine.id,
|
|
402
|
+
runner_kind: 'engine',
|
|
403
|
+
...(!mission.model && resolved.engine.roster_model ? { model: resolved.engine.roster_model } : {}),
|
|
404
|
+
...rosterTickPins(resolved.engine),
|
|
405
|
+
} : mission,
|
|
406
|
+
engine_id: resolved.engine ? resolved.engine.id : null,
|
|
407
|
+
requested_engine: resolved.requested_engine,
|
|
408
|
+
engine_fallback_reason: resolved.engine_fallback_reason,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// A stalled tick benches its engine for the cooldown, so the next tick's
|
|
413
|
+
// roster pick is the backup; no retry inside the tick is needed.
|
|
414
|
+
function recordMissionEngineTickOutcome(engineId, result, root = process.cwd()) {
|
|
415
|
+
if (!engineId) return null;
|
|
416
|
+
if (result && result.status === 'ran') return setEngineHealth(engineId, 'ready', root);
|
|
417
|
+
if (!engineFailureHealthStatus(result)) return null;
|
|
418
|
+
return recordEngineRunHealth(engineId, result, root);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// One run-record line per engine tick: how long the worker ran, and how it
|
|
422
|
+
// ended. A tick that ran and passed (or had no verifier) landed; a failing
|
|
423
|
+
// verifier or a real error failed; a time cap or dropped connection stalled;
|
|
424
|
+
// a closed credit window is credit out. Never throws into the tick.
|
|
425
|
+
function missionTickRunOutcome(result, verifierResult, maxSeconds) {
|
|
426
|
+
if (result && result.status === 'ran') {
|
|
427
|
+
const review = verifierResult?.referee;
|
|
428
|
+
if (verifierResult?.passed === false && verifierResult.status === 0 && !verifierResult.timed_out
|
|
429
|
+
&& review?.mode === 'engine-unavailable' && review.reason !== 'code changed during review') {
|
|
430
|
+
return { outcome: 'review unavailable', detail: review.timed_out
|
|
431
|
+
? 'the build check passed, but the second review timed out'
|
|
432
|
+
: 'the build check passed, but the second review was unavailable' };
|
|
433
|
+
}
|
|
434
|
+
return verifierResult && verifierResult.passed === false
|
|
435
|
+
? { outcome: 'failed', detail: 'the verifier failed' }
|
|
436
|
+
: { outcome: 'landed' };
|
|
437
|
+
}
|
|
438
|
+
if (engineFailureHealthStatus(result) === 'credit_out') return { outcome: 'credit out', detail: 'the engine hit its usage limit' };
|
|
439
|
+
const stall = engineStallReason({ ...result, ...(maxSeconds ? { max_seconds: maxSeconds } : {}) });
|
|
440
|
+
if (stall) return { outcome: 'stalled', detail: stall.text };
|
|
441
|
+
if (/wall-exceeded/.test(String(result && result.reason || ''))) return { outcome: 'failed', detail: 'the mission ran out of time' };
|
|
442
|
+
const summary = String(result && result.claude && result.claude.summary || '').split('\n')[0].trim();
|
|
443
|
+
return { outcome: 'failed', detail: summary && summary !== 'error' ? summary : String(result && result.reason || 'the tick failed').replace(/-/g, ' ') };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function recordMissionTickRosterRun(root, { mission, runtimeMission, engineId, job, result, verifierResult, startedAt, endedMs, usage, prep = null } = {}) {
|
|
447
|
+
if (!engineId) return null;
|
|
448
|
+
try {
|
|
449
|
+
const { engineRunsView } = require('./roster-models');
|
|
450
|
+
const { prepRecordFields } = require('./roster-prep');
|
|
451
|
+
const run = runtimeMission || mission || {};
|
|
452
|
+
const startedMs = Date.parse(startedAt);
|
|
453
|
+
const maxSeconds = Number(run.roster_max_seconds) || 0;
|
|
454
|
+
return appendRosterRun(root, {
|
|
455
|
+
at: startedAt,
|
|
456
|
+
job: job || 'build',
|
|
457
|
+
member: (mission && mission.owner) || '',
|
|
458
|
+
engine: engineId,
|
|
459
|
+
model: run.model || engineRunsView(engineId).model || '',
|
|
460
|
+
effort: run.roster_effort || '',
|
|
461
|
+
max_seconds: maxSeconds,
|
|
462
|
+
seconds: Number.isFinite(startedMs) ? Math.max(0, (endedMs - startedMs) / 1000) : undefined,
|
|
463
|
+
task: (mission && mission.id) || '',
|
|
464
|
+
source: 'mission',
|
|
465
|
+
...prepRecordFields(prep),
|
|
466
|
+
...(usage || {}),
|
|
467
|
+
...missionTickRunOutcome(result, verifierResult, maxSeconds),
|
|
468
|
+
});
|
|
469
|
+
} catch {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function runnerModelPatch(runner, model) {
|
|
475
|
+
const explicitModel = String(model || '').trim();
|
|
476
|
+
if (explicitModel) return { model: explicitModel };
|
|
477
|
+
if (String(runner || '').trim().toLowerCase() === 'atris2') return { model: 'atris:fast' };
|
|
478
|
+
return {};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function missionRunRuntimeView(mission, runnerOverride = null, modelOverride = '') {
|
|
482
|
+
if (!runnerOverride) return mission;
|
|
483
|
+
const next = {
|
|
484
|
+
...mission,
|
|
485
|
+
runner: runnerOverride.runner,
|
|
486
|
+
runner_kind: runnerOverride.kind,
|
|
487
|
+
run_runner_override: {
|
|
488
|
+
requested: runnerOverride.requested,
|
|
489
|
+
runner: runnerOverride.runner,
|
|
490
|
+
stored_runner: mission.runner || null,
|
|
491
|
+
stored_model: mission.model || null,
|
|
492
|
+
},
|
|
493
|
+
};
|
|
494
|
+
delete next.model;
|
|
495
|
+
Object.assign(next, runnerModelPatch(runnerOverride.runner, modelOverride));
|
|
496
|
+
if (next.model) next.run_runner_override.model = next.model;
|
|
497
|
+
return next;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function applyMissionRunnerProfile(runner) {
|
|
501
|
+
const runnerName = String(runner || '').trim().toLowerCase();
|
|
502
|
+
// `manual` is the legacy name for a directly-run Claude mission. Do not let
|
|
503
|
+
// the host process's runner profile silently turn it into another engine.
|
|
504
|
+
const engine = canonicalEngineName(runnerName) || (runnerName === 'manual' ? 'claude' : '');
|
|
505
|
+
if (!engine) return () => {};
|
|
506
|
+
const previous = process.env.ATRIS_RUNNER_PROFILE;
|
|
507
|
+
process.env.ATRIS_RUNNER_PROFILE = engine;
|
|
508
|
+
return () => {
|
|
509
|
+
if (previous === undefined) delete process.env.ATRIS_RUNNER_PROFILE;
|
|
510
|
+
else process.env.ATRIS_RUNNER_PROFILE = previous;
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Claude-family engines share the claude CLI, so they keep the resolved
|
|
515
|
+
// default model pin. Every other engine's CLI rejects a claude model name, so
|
|
516
|
+
// it only ever takes an explicit pin (the mission's model or the roster
|
|
517
|
+
// line's); '' lets the engine ride its own default.
|
|
518
|
+
// A mission often still carries the claude model it was created with; an
|
|
519
|
+
// engine that runs only its own models (codex, grok) drops it.
|
|
520
|
+
const CLAUDE_FAMILY_RUNNER_ENGINES = new Set(['claude', 'fable', 'haiku']);
|
|
521
|
+
|
|
522
|
+
function resolveMissionTickRunnerModel(mission) {
|
|
523
|
+
const engine = canonicalEngineName(mission && mission.runner);
|
|
524
|
+
if (!engine || CLAUDE_FAMILY_RUNNER_ENGINES.has(engine)) {
|
|
525
|
+
return resolveClaudeRunnerModel(mission);
|
|
526
|
+
}
|
|
527
|
+
return runnerModelFor(engine, resolvePinnedRunnerModel(mission));
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function exitMissionError(message, code = 1, asJson = false) {
|
|
531
|
+
if (asJson) {
|
|
532
|
+
printCliJson(
|
|
533
|
+
{
|
|
534
|
+
ok: false,
|
|
535
|
+
error: message,
|
|
536
|
+
reason: 'mission_error',
|
|
537
|
+
detail: message,
|
|
538
|
+
selected_ref: null,
|
|
539
|
+
next_command: null,
|
|
540
|
+
},
|
|
541
|
+
compactErrorPayload({ reason: 'mission_error', detail: message }),
|
|
542
|
+
process.argv.slice(2),
|
|
543
|
+
);
|
|
544
|
+
} else {
|
|
545
|
+
console.error(message);
|
|
546
|
+
}
|
|
547
|
+
process.exit(code);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Write-time warnings judge only what a machine can truly judge: identifiers,
|
|
551
|
+
// flags, and task ids. Whether a why is present is a judgment call, the tick
|
|
552
|
+
// prompt demands it, the review pass judges it. A warning that cries wolf on
|
|
553
|
+
// plain sentences teaches agents to ignore it (golden-path papercut). The
|
|
554
|
+
// strict operatorReady bar stays at the digest surface, where under-showing
|
|
555
|
+
// is cheap. Boundary pinned by the marker-free fixture in mission-status tests.
|
|
556
|
+
function warnIfSummaryNeedsOperatorWhy(summary) {
|
|
557
|
+
const text = String(summary || '').trim();
|
|
558
|
+
if (!text || !hasAgentJargon(text)) return null;
|
|
559
|
+
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.';
|
|
560
|
+
console.error(warning);
|
|
561
|
+
return warning;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function warnIfTaskTitleNeedsOperatorWhy(title) {
|
|
565
|
+
const text = String(title || '').trim();
|
|
566
|
+
if (!text || !hasAgentJargon(text)) return null;
|
|
567
|
+
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.';
|
|
568
|
+
console.error(warning);
|
|
569
|
+
return warning;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function readPositiveIntegerFlag(args, name, fallback = null, options = {}) {
|
|
573
|
+
const raw = readFlag(args, name, '');
|
|
574
|
+
if (!raw) return fallback;
|
|
575
|
+
const value = Number(raw);
|
|
576
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
577
|
+
exitMissionError(`${name} must be a positive integer`, 2, options.json);
|
|
578
|
+
}
|
|
579
|
+
return value;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function readNonNegativeIntegerFlag(args, name, fallback = null) {
|
|
583
|
+
const raw = readFlag(args, name, '');
|
|
584
|
+
if (!raw) return fallback;
|
|
585
|
+
const value = Number(raw);
|
|
586
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
587
|
+
throw new Error(`${name} must be a non-negative integer.`);
|
|
588
|
+
}
|
|
589
|
+
return value;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function readRepeatedFlag(args, name) {
|
|
593
|
+
const values = [];
|
|
594
|
+
const prefix = `${name}=`;
|
|
595
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
596
|
+
const arg = String(args[i]);
|
|
597
|
+
if (arg === name && args[i + 1] && !String(args[i + 1]).startsWith('--')) {
|
|
598
|
+
values.push(readFlag([name, args[i + 1]], name, ''));
|
|
599
|
+
i += 1;
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
if (arg.startsWith(prefix)) values.push(readFlag([arg], name, ''));
|
|
603
|
+
}
|
|
604
|
+
return values.filter(Boolean);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function codexNativeGoalOptionsFromArgs(args) {
|
|
608
|
+
const nativeGoalStatus = readFlag(args, '--native-goal-status', readFlag(args, '--visible-goal-status', ''));
|
|
609
|
+
const nativeGoalObjective = readFlag(args, '--native-goal-objective', readFlag(args, '--visible-goal-objective', ''));
|
|
610
|
+
return {
|
|
611
|
+
...(nativeGoalStatus ? { nativeGoalStatus } : {}),
|
|
612
|
+
...(nativeGoalObjective ? { nativeGoalObjective } : {}),
|
|
613
|
+
...(hasFlag(args, '--manual-ack') ? { manualAck: true } : {}),
|
|
614
|
+
...(hasFlag(args, '--allow-native-goal-supersede') || hasFlag(args, '--supersede-paused-native-goal') ? { allowNativeGoalSupersede: true } : {}),
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function lintMissionVerifier(command) {
|
|
619
|
+
const text = String(command || '').trim();
|
|
620
|
+
if (!text) return null;
|
|
621
|
+
const compact = text.replace(/\s+/g, ' ');
|
|
622
|
+
const staticNumericTest = /^test \d+ -(?:eq|ne|gt|ge|lt|le) \d+$/.test(compact)
|
|
623
|
+
|| /^\[ \d+ -(?:eq|ne|gt|ge|lt|le) \d+ \]$/.test(compact);
|
|
624
|
+
if (!staticNumericTest) return null;
|
|
625
|
+
return 'looks like shell substitution expanded before Atris received it; quote dynamic verifiers with single quotes';
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function assertMissionVerifier(command, asJson = false) {
|
|
629
|
+
const issue = lintMissionVerifier(command);
|
|
630
|
+
if (!issue) return;
|
|
631
|
+
exitMissionError(`Invalid --verify: ${issue}. Example: --verify 'test $(wc -l < atris/learnings.jsonl) -ge 478'`, 2, asJson);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function stripKnownFlags(args, valueNames, booleanNames = []) {
|
|
635
|
+
const valueSet = new Set(valueNames);
|
|
636
|
+
const booleanSet = new Set(['--full', '--verbose', ...booleanNames]);
|
|
637
|
+
const out = [];
|
|
638
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
639
|
+
const arg = String(args[i]);
|
|
640
|
+
const key = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg;
|
|
641
|
+
if (booleanSet.has(key)) continue;
|
|
642
|
+
if (valueSet.has(key)) {
|
|
643
|
+
if (!arg.includes('=') && args[i + 1] && !String(args[i + 1]).startsWith('--')) i += 1;
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
out.push(args[i]);
|
|
647
|
+
}
|
|
648
|
+
return out;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function wantsJson(args) {
|
|
652
|
+
return hasFlag(args, '--json');
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function printJsonOrText(payload, lines, asJson) {
|
|
656
|
+
if (asJson) {
|
|
657
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
for (const line of lines) console.log(line);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const MISSION_RUN_VALUE_FLAGS = [
|
|
664
|
+
'--slots',
|
|
665
|
+
'--pick',
|
|
666
|
+
'--max-ticks',
|
|
667
|
+
'--max-idle-ticks',
|
|
668
|
+
'--max-wall',
|
|
669
|
+
'--minutes',
|
|
670
|
+
'--hours',
|
|
671
|
+
'--cadence',
|
|
672
|
+
'--owner',
|
|
673
|
+
'--runner',
|
|
674
|
+
'--lane',
|
|
675
|
+
'--verify',
|
|
676
|
+
'--stop',
|
|
677
|
+
'--model',
|
|
678
|
+
'--engine',
|
|
679
|
+
'--referee-engine',
|
|
680
|
+
'--budget',
|
|
681
|
+
'--campaign',
|
|
682
|
+
'--reserve',
|
|
683
|
+
'--repo',
|
|
684
|
+
'--native-goal-status',
|
|
685
|
+
'--native-goal-objective',
|
|
686
|
+
'--visible-goal-status',
|
|
687
|
+
'--visible-goal-objective',
|
|
688
|
+
];
|
|
689
|
+
const MISSION_RUN_BOOLEAN_FLAGS = [
|
|
690
|
+
'--json',
|
|
691
|
+
'--full',
|
|
692
|
+
'--verbose',
|
|
693
|
+
'--due',
|
|
694
|
+
'--detach',
|
|
695
|
+
'--headless',
|
|
696
|
+
'--self-drive',
|
|
697
|
+
'--fleet',
|
|
698
|
+
'--dry-run',
|
|
699
|
+
'--land',
|
|
700
|
+
'--no-claude',
|
|
701
|
+
'--no-verify',
|
|
702
|
+
'--referee',
|
|
703
|
+
'--referee-if-check-edited',
|
|
704
|
+
'--no-referee',
|
|
705
|
+
'--complete-on-pass',
|
|
706
|
+
'--no-drain',
|
|
707
|
+
'--create-next',
|
|
708
|
+
'--spend-full-budget',
|
|
709
|
+
'--use-whole-budget',
|
|
710
|
+
'--stop-when-done',
|
|
711
|
+
'--preflight',
|
|
712
|
+
'--no-preflight',
|
|
713
|
+
'--room-preflight',
|
|
714
|
+
'--no-room-preflight',
|
|
715
|
+
'--room-auto-run',
|
|
716
|
+
'--no-room-auto-run',
|
|
717
|
+
'--manual-ack',
|
|
718
|
+
'--allow-native-goal-supersede',
|
|
719
|
+
'--supersede-paused-native-goal',
|
|
720
|
+
'--take-goal-slot',
|
|
721
|
+
'--always-on',
|
|
722
|
+
'--xp-task',
|
|
723
|
+
'--agent-xp',
|
|
724
|
+
'--cloud',
|
|
725
|
+
];
|
|
726
|
+
const DEFAULT_MISSION_RUN_OWNER_SLUGS = new Set(
|
|
727
|
+
FUNCTIONAL_MEMBER_TOPICS.map(topic => normalizeOwnerSlug(topic.owner)),
|
|
728
|
+
);
|
|
729
|
+
|
|
730
|
+
function missionRunInputRequired(asJson = false, owner = '') {
|
|
731
|
+
const defaultOwner = normalizeOwnerSlug(owner || process.env.ATRIS_AGENT_ID || 'mission-lead') || 'mission-lead';
|
|
732
|
+
const error = 'mission run needs an interactive terminal; use atris mission run --due --headless or atris mission run <explicit-mission-id>.';
|
|
733
|
+
const payload = {
|
|
734
|
+
ok: false,
|
|
735
|
+
action: 'mission_input_required',
|
|
736
|
+
error,
|
|
737
|
+
prompt: 'What mission should Atris run?',
|
|
738
|
+
owner: defaultOwner,
|
|
739
|
+
owner_prompt: 'Which team member should own it?',
|
|
740
|
+
example: `atris mission run "make onboarding magical" --owner ${defaultOwner}`,
|
|
741
|
+
};
|
|
742
|
+
if (asJson) {
|
|
743
|
+
console.log(JSON.stringify(payload));
|
|
744
|
+
} else {
|
|
745
|
+
console.error(error);
|
|
746
|
+
}
|
|
747
|
+
process.exit(1);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function askLine(rl, question) {
|
|
751
|
+
return new Promise((resolve) => rl.question(question, (answer) => resolve(String(answer || '').trim())));
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function removeValueFlag(args, name) {
|
|
755
|
+
const out = [];
|
|
756
|
+
const prefix = `${name}=`;
|
|
757
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
758
|
+
const arg = String(args[i]);
|
|
759
|
+
if (arg === name) {
|
|
760
|
+
if (args[i + 1] && !String(args[i + 1]).startsWith('--')) i += 1;
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
if (arg.startsWith(prefix)) continue;
|
|
764
|
+
out.push(args[i]);
|
|
765
|
+
}
|
|
766
|
+
return out;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function missionRunOwnerRef(ref, root = process.cwd()) {
|
|
770
|
+
const owner = normalizeOwnerSlug(ref);
|
|
771
|
+
if (!owner || /\s/.test(String(ref || ''))) return null;
|
|
772
|
+
if (listWorkspaceMemberSlugs(root).has(owner)) return owner;
|
|
773
|
+
if (DEFAULT_MISSION_RUN_OWNER_SLUGS.has(owner)) return owner;
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function missionRunArgsWithOwner(args, owner) {
|
|
778
|
+
return [...removeValueFlag(args, '--owner'), '--owner', owner];
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function missionRunInputFromArgs(args, root = process.cwd()) {
|
|
782
|
+
const positionals = stripKnownFlags(args, MISSION_RUN_VALUE_FLAGS, MISSION_RUN_BOOLEAN_FLAGS);
|
|
783
|
+
const explicitOwner = Boolean(readFlag(args, '--owner', ''));
|
|
784
|
+
if (!explicitOwner && positionals.length > 0) {
|
|
785
|
+
const owner = missionRunOwnerRef(positionals[0], root);
|
|
786
|
+
if (owner) {
|
|
787
|
+
return {
|
|
788
|
+
ref: positionals.slice(1).join(' ').trim(),
|
|
789
|
+
args: missionRunArgsWithOwner(args, owner),
|
|
790
|
+
owner,
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return {
|
|
795
|
+
ref: positionals.join(' ').trim(),
|
|
796
|
+
args,
|
|
797
|
+
owner: readFlag(args, '--owner', ''),
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async function promptMissionRunInput(args) {
|
|
802
|
+
if (!process.stdin.isTTY) missionRunInputRequired(wantsJson(args), readFlag(args, '--owner', ''));
|
|
803
|
+
const defaultOwner = readFlag(args, '--owner', process.env.ATRIS_AGENT_ID || 'mission-lead');
|
|
804
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
805
|
+
try {
|
|
806
|
+
const objective = await askLine(rl, 'Mission: ');
|
|
807
|
+
if (!objective) missionRunInputRequired(false);
|
|
808
|
+
const owner = await askLine(rl, `Team member [${defaultOwner}]: `);
|
|
809
|
+
return {
|
|
810
|
+
objective,
|
|
811
|
+
args: [...removeValueFlag(args, '--owner'), '--owner', owner || defaultOwner],
|
|
812
|
+
};
|
|
813
|
+
} finally {
|
|
814
|
+
rl.close();
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function loadTaskDb(asJson = false) {
|
|
819
|
+
try {
|
|
820
|
+
return require('./task-db');
|
|
821
|
+
} catch (error) {
|
|
822
|
+
const message = error && error.message ? error.message : String(error);
|
|
823
|
+
if (error?.code === 'ERR_UNKNOWN_BUILTIN_MODULE' || /node:sqlite/.test(message)) {
|
|
824
|
+
exitMissionError('AgentXP mission tasks require Node 22+ with node:sqlite.', 2, asJson);
|
|
825
|
+
}
|
|
826
|
+
throw error;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function writeMissionTaskProjection(taskDb, db, workspaceRoot) {
|
|
831
|
+
const projection = taskDb.taskProjection(db, { workspaceRoot, limit: 500 });
|
|
832
|
+
const outPath = path.join(workspaceRoot, '.atris', 'state', 'tasks.projection.json');
|
|
833
|
+
writeJson(outPath, projection);
|
|
834
|
+
return { projection, outPath };
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function missionTaskRef(task) {
|
|
838
|
+
return task?.display_id || task?.legacy_ref || String(task?.id || '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase().slice(0, 8);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function missionXpTaskTitle(objective) {
|
|
842
|
+
const prefix = 'Mission XP: ';
|
|
843
|
+
const normalized = String(objective || '').replace(/\s+/g, ' ').trim();
|
|
844
|
+
const boundary = normalized.search(/:|[.!?](?=\s|$)/);
|
|
845
|
+
const firstClause = (boundary > 0 ? normalized.slice(0, boundary) : normalized) || 'mission work';
|
|
846
|
+
const maxClauseLength = 64 - prefix.length;
|
|
847
|
+
if (firstClause.length <= maxClauseLength) return `${prefix}${firstClause}`;
|
|
848
|
+
const clipped = firstClause.slice(0, maxClauseLength + 1);
|
|
849
|
+
const wordBoundary = clipped.lastIndexOf(' ');
|
|
850
|
+
const shortClause = clipped.slice(0, wordBoundary > 0 ? wordBoundary : maxClauseLength).trim();
|
|
851
|
+
return `${prefix}${shortClause || 'mission work'}`;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function normalizeMissionTaskMatchText(text) {
|
|
855
|
+
return String(text || '')
|
|
856
|
+
.replace(/^Mission XP:\s*/i, '')
|
|
857
|
+
.toLowerCase()
|
|
858
|
+
.replace(/\s+/g, ' ')
|
|
859
|
+
.trim();
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function missionObjectiveTaskMatch(objective, title) {
|
|
863
|
+
const want = normalizeMissionTaskMatchText(objective);
|
|
864
|
+
const have = normalizeMissionTaskMatchText(title);
|
|
865
|
+
if (!want || !have) return false;
|
|
866
|
+
if (want === have) return true;
|
|
867
|
+
// First clause of a long objective often matches the claimed task title.
|
|
868
|
+
const wantClause = want.split(/:|[.!?](?=\s|$)/)[0].trim();
|
|
869
|
+
const haveClause = have.split(/:|[.!?](?=\s|$)/)[0].trim();
|
|
870
|
+
if (wantClause && haveClause && (wantClause === haveClause || want.startsWith(haveClause) || have.startsWith(wantClause))) {
|
|
871
|
+
return true;
|
|
872
|
+
}
|
|
873
|
+
return false;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function taskRowToMissionXpBinding(task, ownerResolution, outPath, { inserted = false, reused = true } = {}) {
|
|
877
|
+
return {
|
|
878
|
+
task_id: task.id,
|
|
879
|
+
ref: missionTaskRef(task) || task.id,
|
|
880
|
+
title: task.title,
|
|
881
|
+
operator_title_warning: warnIfTaskTitleNeedsOperatorWhy(task.title),
|
|
882
|
+
status: task.status || 'claimed',
|
|
883
|
+
assigned_to: task.claimed_by || ownerResolution.owner,
|
|
884
|
+
owner_resolution: ownerResolution.reason,
|
|
885
|
+
executed_by: ownerResolution.executed_by ? normalizeOwnerSlug(ownerResolution.executed_by) : null,
|
|
886
|
+
inserted,
|
|
887
|
+
reused,
|
|
888
|
+
projection_path: outPath,
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function findOpenTaskMatchingMissionObjective(mission, root = process.cwd(), asJson = false) {
|
|
893
|
+
const taskDb = loadTaskDb(asJson);
|
|
894
|
+
const db = taskDb.open();
|
|
895
|
+
const workspaceRoot = taskDb.workspaceRoot(root);
|
|
896
|
+
const rows = taskDb.withTaskDisplayRefs(taskDb.listTasks(db, { workspaceRoot }));
|
|
897
|
+
const open = rows.filter((row) => row && ['open', 'claimed', 'review'].includes(row.status));
|
|
898
|
+
const explicitIds = Array.isArray(mission.task_ids) ? mission.task_ids.filter(Boolean) : [];
|
|
899
|
+
for (const id of explicitIds) {
|
|
900
|
+
const hit = open.find((row) => row.id === id || row.display_id === id || missionTaskRef(row) === id);
|
|
901
|
+
if (hit) return hit;
|
|
902
|
+
}
|
|
903
|
+
return open.find((row) => missionObjectiveTaskMatch(mission.objective, row.title)) || null;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function statePaths(root = process.cwd()) {
|
|
907
|
+
const stateDir = path.join(root, '.atris', 'state');
|
|
908
|
+
return {
|
|
909
|
+
stateDir,
|
|
910
|
+
missionsJsonl: path.join(stateDir, 'missions.jsonl'),
|
|
911
|
+
eventsJsonl: path.join(stateDir, 'mission_events.jsonl'),
|
|
912
|
+
codexGoalJson: path.join(stateDir, 'codex_goal.json'),
|
|
913
|
+
codexGoalRequestJson: path.join(stateDir, 'codex_goal_request.json'),
|
|
914
|
+
codexGoalStatus: path.join(root, 'atris', 'status', 'codex-goal.md'),
|
|
915
|
+
atrisGoalJson: path.join(stateDir, 'atris_goal.json'),
|
|
916
|
+
atrisGoalStatus: path.join(root, 'atris', 'status', 'atris-goal.md'),
|
|
917
|
+
statusNow: path.join(root, 'atris', 'status', 'now.md'),
|
|
918
|
+
statusForYou: path.join(root, 'atris', 'status', 'for-you.md'),
|
|
919
|
+
runsDir: path.join(root, 'atris', 'runs'),
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function readBusinessBinding(root = process.cwd()) {
|
|
924
|
+
const file = path.join(root, '.atris', 'business.json');
|
|
925
|
+
const parsed = readJson(file);
|
|
926
|
+
if (parsed == null) return null;
|
|
927
|
+
return {
|
|
928
|
+
business_id: parsed.business_id || '',
|
|
929
|
+
workspace_id: parsed.workspace_id || '',
|
|
930
|
+
slug: parsed.slug || '',
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function businessIdForAtris2Mission(mission, cwd = process.cwd()) {
|
|
935
|
+
return mission?.business_id || readBusinessBinding(cwd)?.business_id || null;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function readJsonLines(file) {
|
|
939
|
+
if (!fs.existsSync(file)) return [];
|
|
940
|
+
return fs.readFileSync(file, 'utf8')
|
|
941
|
+
.split(/\r?\n/)
|
|
942
|
+
.map((line) => line.trim())
|
|
943
|
+
.filter(Boolean)
|
|
944
|
+
.map((line) => {
|
|
945
|
+
try {
|
|
946
|
+
return JSON.parse(line);
|
|
947
|
+
} catch {
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
})
|
|
951
|
+
.filter(Boolean);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function missionLabel(mission) {
|
|
955
|
+
const displayHandle = String(mission && mission.display_handle || '').trim();
|
|
956
|
+
if (displayHandle) {
|
|
957
|
+
const words = meaningfulWords(mission && (mission.objective || mission.slug || mission.id), 3);
|
|
958
|
+
return `${displayHandle}${words.length ? ` ${words.join(' ')}` : ''}`;
|
|
959
|
+
}
|
|
960
|
+
return shortRecordLabel(mission, mission && (mission.objective || mission.slug || mission.id));
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function missionRef(mission) {
|
|
964
|
+
const displayHandle = String(mission && mission.display_handle || '').trim();
|
|
965
|
+
if (displayHandle) return displayHandle;
|
|
966
|
+
return shortRecordRef(mission);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function missionDisplayText(mission, value) {
|
|
970
|
+
const text = String(value || '');
|
|
971
|
+
const id = String(mission && mission.id || '').trim();
|
|
972
|
+
if (!id) return text;
|
|
973
|
+
return text.split(id).join(missionRef(mission));
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function missionNumberWinner(records = []) {
|
|
977
|
+
return records.reduce((winner, candidate) => {
|
|
978
|
+
if (!winner) return candidate;
|
|
979
|
+
const winnerTerminal = TERMINAL_STATUSES.has(winner.status);
|
|
980
|
+
const candidateTerminal = TERMINAL_STATUSES.has(candidate.status);
|
|
981
|
+
if (winnerTerminal !== candidateTerminal) return candidateTerminal ? winner : candidate;
|
|
982
|
+
const winnerUpdated = String(winner.updated_at || winner.created_at || '');
|
|
983
|
+
const candidateUpdated = String(candidate.updated_at || candidate.created_at || '');
|
|
984
|
+
if (candidateUpdated !== winnerUpdated) return candidateUpdated > winnerUpdated ? candidate : winner;
|
|
985
|
+
return String(candidate.id || '').localeCompare(String(winner.id || '')) < 0 ? candidate : winner;
|
|
986
|
+
}, null);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const MISSION_REF_SUFFIX_MIN_LENGTH = 6;
|
|
990
|
+
|
|
991
|
+
function uniqueMissionSuffix(mission, missions) {
|
|
992
|
+
const id = String(mission && mission.id || '').trim();
|
|
993
|
+
if (!id) return 'unknown';
|
|
994
|
+
const minimum = Math.min(MISSION_REF_SUFFIX_MIN_LENGTH, id.length);
|
|
995
|
+
for (let length = Math.min(8, id.length); length <= id.length; length += 1) {
|
|
996
|
+
const suffix = id.slice(-Math.max(length, minimum));
|
|
997
|
+
if (missions.filter((candidate) => String(candidate.id || '').endsWith(suffix)).length === 1) return suffix;
|
|
998
|
+
}
|
|
999
|
+
return id;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function missionNumberDisplayIndex(missions, canonicalMissions = missions) {
|
|
1003
|
+
const recordsByNumber = new Map();
|
|
1004
|
+
for (const mission of canonicalMissions) {
|
|
1005
|
+
const number = displayNumber(mission.n);
|
|
1006
|
+
if (!number) continue;
|
|
1007
|
+
const records = recordsByNumber.get(number) || [];
|
|
1008
|
+
records.push(mission);
|
|
1009
|
+
recordsByNumber.set(number, records);
|
|
1010
|
+
}
|
|
1011
|
+
const winnerIds = new Map();
|
|
1012
|
+
for (const [number, records] of recordsByNumber) {
|
|
1013
|
+
if (records.length > 1) winnerIds.set(number, missionNumberWinner(records)?.id);
|
|
1014
|
+
}
|
|
1015
|
+
return missions.map((mission) => {
|
|
1016
|
+
const number = displayNumber(mission.n);
|
|
1017
|
+
if (!number || !winnerIds.has(number) || winnerIds.get(number) === mission.id) return mission;
|
|
1018
|
+
return { ...mission, display_handle: uniqueMissionSuffix(mission, canonicalMissions) };
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// A ref that "looks like" an id/suffix/number is a single token with no
|
|
1023
|
+
// whitespace matching a hex/id/numeric shape. Those must resolve or error,
|
|
1024
|
+
// never silently fall through to starting a new mission (a mistyped id/n
|
|
1025
|
+
// creating a junk mission is the worst failure mode here).
|
|
1026
|
+
function missionRefLooksLikeHandle(ref) {
|
|
1027
|
+
const raw = String(ref || '').trim();
|
|
1028
|
+
if (!raw || /\s/.test(raw)) return false;
|
|
1029
|
+
const withoutHash = raw.startsWith('#') ? raw.slice(1) : raw;
|
|
1030
|
+
if (/^\d+$/.test(withoutHash)) return true;
|
|
1031
|
+
if (/^mission-/.test(raw)) return true;
|
|
1032
|
+
if (/^[0-9a-f]{6,}$/i.test(withoutHash)) return true;
|
|
1033
|
+
return false;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function missionMatchesRef(mission, ref) {
|
|
1037
|
+
return mission && (recordMatchesRef(mission, ref) || mission.slug === String(ref || '').trim());
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Suffix/prefix id matches and slug-prefix matches, gated to unique hits of
|
|
1041
|
+
// at least MISSION_REF_SUFFIX_MIN_LENGTH chars so a short fragment can't
|
|
1042
|
+
// silently grab the wrong mission.
|
|
1043
|
+
function missionsMatchingHandleFragment(missions, ref) {
|
|
1044
|
+
const raw = String(ref || '').trim();
|
|
1045
|
+
if (!raw || raw.length < MISSION_REF_SUFFIX_MIN_LENGTH) return [];
|
|
1046
|
+
const lower = raw.toLowerCase();
|
|
1047
|
+
const idHits = missions.filter((mission) => {
|
|
1048
|
+
const id = String(mission.id || '').toLowerCase();
|
|
1049
|
+
return id.startsWith(lower) || id.endsWith(lower);
|
|
1050
|
+
});
|
|
1051
|
+
if (idHits.length) return idHits;
|
|
1052
|
+
return missions.filter((mission) => String(mission.slug || '').toLowerCase().startsWith(lower));
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function nearestMissionCandidates(missions, ref, limit = 5) {
|
|
1056
|
+
const raw = String(ref || '').trim().toLowerCase();
|
|
1057
|
+
return missions
|
|
1058
|
+
.map((mission) => {
|
|
1059
|
+
const id = String(mission.id || '').toLowerCase();
|
|
1060
|
+
const slug = String(mission.slug || '').toLowerCase();
|
|
1061
|
+
let score = 0;
|
|
1062
|
+
if (raw && (id.includes(raw) || slug.includes(raw))) score = 2;
|
|
1063
|
+
else if (raw && raw.length >= 3 && (id.slice(-6).includes(raw.slice(0, 3)) || slug.includes(raw.slice(0, 3)))) score = 1;
|
|
1064
|
+
return { mission, score };
|
|
1065
|
+
})
|
|
1066
|
+
.filter((entry) => entry.score > 0)
|
|
1067
|
+
.sort((a, b) => b.score - a.score)
|
|
1068
|
+
.slice(0, limit)
|
|
1069
|
+
.map((entry) => entry.mission);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// Sibling folders looked at for a hint. A missing-id error waited 26 seconds
|
|
1073
|
+
// on a temp folder holding 276,000 entries, so the scan is capped and never
|
|
1074
|
+
// walks the system temp folder.
|
|
1075
|
+
const SIBLING_HINT_SCAN_LIMIT = 2000;
|
|
1076
|
+
|
|
1077
|
+
function missionMatchesStatusFilter(mission, statusFilter) {
|
|
1078
|
+
if (statusFilter === 'active') return !TERMINAL_STATUSES.has(mission.status);
|
|
1079
|
+
return mission.status === statusFilter;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function appendJsonLine(file, payload) {
|
|
1083
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1084
|
+
fs.appendFileSync(file, JSON.stringify(payload) + '\n', 'utf8');
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function resolveMemberWorkspaceRoot(root = process.cwd()) {
|
|
1088
|
+
const resolved = path.resolve(root || process.cwd());
|
|
1089
|
+
const agentWorktreesMarker = `${path.sep}.agent-worktrees${path.sep}`;
|
|
1090
|
+
const agentIdx = resolved.indexOf(agentWorktreesMarker);
|
|
1091
|
+
if (agentIdx > 0) {
|
|
1092
|
+
const parentDir = resolved.slice(0, agentIdx);
|
|
1093
|
+
const after = resolved.slice(agentIdx + agentWorktreesMarker.length);
|
|
1094
|
+
const repoName = after.split(path.sep)[0];
|
|
1095
|
+
if (repoName) {
|
|
1096
|
+
const primaryRoot = path.join(parentDir, repoName);
|
|
1097
|
+
if (fs.existsSync(path.join(primaryRoot, 'atris'))) return primaryRoot;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
const marker = `${path.sep}.atris${path.sep}worktrees${path.sep}`;
|
|
1101
|
+
const idx = resolved.indexOf(marker);
|
|
1102
|
+
if (idx > 0) {
|
|
1103
|
+
const parentRoot = resolved.slice(0, idx);
|
|
1104
|
+
if (fs.existsSync(path.join(parentRoot, 'atris'))) return parentRoot;
|
|
1105
|
+
}
|
|
1106
|
+
return resolved;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function appendEvent(type, mission, payload = {}, root = process.cwd()) {
|
|
1110
|
+
const paths = statePaths(root);
|
|
1111
|
+
const event = {
|
|
1112
|
+
schema: 'atris.mission_event.v1',
|
|
1113
|
+
type,
|
|
1114
|
+
mission_id: mission.id,
|
|
1115
|
+
at: stampIso(),
|
|
1116
|
+
actor: process.env.ATRIS_AGENT_ID || process.env.USER || null,
|
|
1117
|
+
payload,
|
|
1118
|
+
};
|
|
1119
|
+
appendJsonLine(paths.eventsJsonl, event);
|
|
1120
|
+
const parentRoot = resolveMemberWorkspaceRoot(root);
|
|
1121
|
+
if (type === 'mission_tick' && parentRoot !== path.resolve(root || process.cwd())) {
|
|
1122
|
+
try {
|
|
1123
|
+
appendJsonLine(statePaths(parentRoot).eventsJsonl, event);
|
|
1124
|
+
} catch {}
|
|
1125
|
+
}
|
|
1126
|
+
return event;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function memberDir(owner, root = process.cwd()) {
|
|
1130
|
+
if (!owner || !/^[a-zA-Z0-9._-]+$/.test(owner)) return null;
|
|
1131
|
+
const parentRoot = resolveMemberWorkspaceRoot(root);
|
|
1132
|
+
const candidates = parentRoot !== path.resolve(root || process.cwd())
|
|
1133
|
+
? [parentRoot, root]
|
|
1134
|
+
: [root];
|
|
1135
|
+
for (const base of candidates) {
|
|
1136
|
+
const dir = path.join(base, 'atris', 'team', owner);
|
|
1137
|
+
if (fs.existsSync(path.join(dir, 'MEMBER.md'))) return dir;
|
|
1138
|
+
}
|
|
1139
|
+
return null;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function appendMemberLog(owner, title, fields = {}, root = process.cwd()) {
|
|
1143
|
+
const dir = memberDir(owner, root);
|
|
1144
|
+
if (!dir) return null;
|
|
1145
|
+
const logsDir = path.join(dir, 'logs');
|
|
1146
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
1147
|
+
const logPath = path.join(logsDir, `${todayName()}.md`);
|
|
1148
|
+
const stamp = new Date().toTimeString().slice(0, 5);
|
|
1149
|
+
const rows = [
|
|
1150
|
+
`## ${stamp} · ${title}`,
|
|
1151
|
+
`- member: ${owner}`,
|
|
1152
|
+
...Object.entries(fields)
|
|
1153
|
+
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
|
1154
|
+
.map(([key, value]) => `- ${key}: ${String(value).replace(/\n/g, ' ')}`),
|
|
1155
|
+
'',
|
|
1156
|
+
];
|
|
1157
|
+
let lead = '';
|
|
1158
|
+
try {
|
|
1159
|
+
const size = fs.statSync(logPath).size;
|
|
1160
|
+
if (size > 0) {
|
|
1161
|
+
const fd = fs.openSync(logPath, 'r');
|
|
1162
|
+
const last = Buffer.alloc(1);
|
|
1163
|
+
fs.readSync(fd, last, 0, 1, size - 1);
|
|
1164
|
+
fs.closeSync(fd);
|
|
1165
|
+
if (last[0] !== 0x0a) lead = '\n';
|
|
1166
|
+
}
|
|
1167
|
+
} catch {}
|
|
1168
|
+
fs.appendFileSync(logPath, lead + rows.join('\n'), 'utf8');
|
|
1169
|
+
return logPath;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// Shorten at a word boundary so a lesson never ends mid-word.
|
|
1173
|
+
function clipWords(text, max) {
|
|
1174
|
+
if (text.length <= max) return text;
|
|
1175
|
+
const cut = text.slice(0, max + 1);
|
|
1176
|
+
const space = cut.lastIndexOf(' ');
|
|
1177
|
+
return `${(space > max * 0.6 ? cut.slice(0, space) : text.slice(0, max)).replace(/[\s,;:.-]+$/, '')}...`;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function crystallizeMemberSkillLesson(owner, entry = {}, root = process.cwd()) {
|
|
1181
|
+
const dir = memberDir(owner, root);
|
|
1182
|
+
if (!dir) return null;
|
|
1183
|
+
const summary = String(entry.summary || entry.objective || '').replace(/\s+/g, ' ').trim();
|
|
1184
|
+
if (!summary) return null;
|
|
1185
|
+
const skillDir = path.join(dir, 'skills', 'auto-learned');
|
|
1186
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
1187
|
+
const skillPath = path.join(skillDir, 'SKILL.md');
|
|
1188
|
+
let lines = null;
|
|
1189
|
+
if (fs.existsSync(skillPath)) {
|
|
1190
|
+
try {
|
|
1191
|
+
lines = fs.readFileSync(skillPath, 'utf8').split(/\r?\n/);
|
|
1192
|
+
} catch {}
|
|
1193
|
+
}
|
|
1194
|
+
const bulletAt = (lines || []).map((line, i) => (line.trim().startsWith('- **') ? i : -1)).filter((i) => i >= 0);
|
|
1195
|
+
const existingBullets = bulletAt.map((i) => lines[i].trim());
|
|
1196
|
+
const verifierText = String(entry.verifier || '').replace(/\s+/g, ' ').trim();
|
|
1197
|
+
const nextPushText = String(entry.next_push || '').replace(/\s+/g, ' ').trim();
|
|
1198
|
+
const layerLabel = String(entry.layer || 'capabilities').trim();
|
|
1199
|
+
const parts = [`- **${todayName()} (${layerLabel})**: ${clipWords(summary, 240)}`];
|
|
1200
|
+
// A cut-off command cannot be run, so a long one is left out, never clipped.
|
|
1201
|
+
if (verifierText && verifierText.length <= 300) parts.push(`Verify: \`${verifierText}\``);
|
|
1202
|
+
if (nextPushText) parts.push(`Next push: ${clipWords(nextPushText, 200)}`);
|
|
1203
|
+
const newBullet = parts.join(' -> ');
|
|
1204
|
+
const deduped = [
|
|
1205
|
+
newBullet,
|
|
1206
|
+
...existingBullets.filter((b) => !b.includes(summary.slice(0, 80))),
|
|
1207
|
+
].slice(0, 5);
|
|
1208
|
+
|
|
1209
|
+
let content;
|
|
1210
|
+
if (bulletAt.length) {
|
|
1211
|
+
// Keep whatever a person or reviewer wrote around the lessons; only the
|
|
1212
|
+
// lesson list itself is rewritten.
|
|
1213
|
+
const first = bulletAt[0];
|
|
1214
|
+
const last = bulletAt[bulletAt.length - 1];
|
|
1215
|
+
content = [...lines.slice(0, first), ...deduped, ...lines.slice(last + 1)].join('\n');
|
|
1216
|
+
} else {
|
|
1217
|
+
content = [
|
|
1218
|
+
'---',
|
|
1219
|
+
'name: auto-learned',
|
|
1220
|
+
`description: Verified patterns crystallized from passing runs for ${owner}. Bounded to 5 newest patterns so context never bloats.`,
|
|
1221
|
+
'---',
|
|
1222
|
+
'',
|
|
1223
|
+
'# Auto-Learned Verified Patterns',
|
|
1224
|
+
'',
|
|
1225
|
+
...deduped,
|
|
1226
|
+
'',
|
|
1227
|
+
].join('\n');
|
|
1228
|
+
}
|
|
1229
|
+
fs.writeFileSync(skillPath, content, 'utf8');
|
|
1230
|
+
return skillPath;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
function autoQueueMemberNextPush(owner, nextPush, parentObjective = '', root = process.cwd(), { promote = false } = {}) {
|
|
1234
|
+
const cleanPush = String(nextPush || '').replace(/\s+/g, ' ').trim();
|
|
1235
|
+
if (!cleanPush) return null;
|
|
1236
|
+
const dir = memberDir(owner, root);
|
|
1237
|
+
if (!dir) return null;
|
|
1238
|
+
const goalsPath = path.join(dir, 'goals.json');
|
|
1239
|
+
let store = { schema_version: 1, member: owner, updated_at: stampIso(), goals: [] };
|
|
1240
|
+
if (fs.existsSync(goalsPath)) {
|
|
1241
|
+
try {
|
|
1242
|
+
const parsed = JSON.parse(fs.readFileSync(goalsPath, 'utf8'));
|
|
1243
|
+
if (parsed && Array.isArray(parsed.goals)) store = parsed;
|
|
1244
|
+
} catch {}
|
|
1245
|
+
}
|
|
1246
|
+
const now = stampIso();
|
|
1247
|
+
const activeIdx = store.goals.findIndex((g) => g && g.status === 'active');
|
|
1248
|
+
if (activeIdx >= 0) {
|
|
1249
|
+
const prev = store.goals[activeIdx];
|
|
1250
|
+
store.goals[activeIdx] = {
|
|
1251
|
+
...prev,
|
|
1252
|
+
...(promote ? { previous_title: prev.title, title: cleanPush } : {}),
|
|
1253
|
+
next_push: cleanPush,
|
|
1254
|
+
updated_at: now,
|
|
1255
|
+
};
|
|
1256
|
+
} else {
|
|
1257
|
+
store.goals.push({
|
|
1258
|
+
id: `goal_${Date.now().toString(36)}`,
|
|
1259
|
+
member: owner,
|
|
1260
|
+
title: cleanPush,
|
|
1261
|
+
why: parentObjective ? `Compounds after verified win on: ${parentObjective}` : 'Self-proposed next optimization from verified run.',
|
|
1262
|
+
metric: 'verified_slices_shipped',
|
|
1263
|
+
target: '1',
|
|
1264
|
+
horizon: '7d',
|
|
1265
|
+
acceptance: ['Passes test check and cross-model referee'],
|
|
1266
|
+
next_push: cleanPush,
|
|
1267
|
+
status: 'active',
|
|
1268
|
+
created_at: now,
|
|
1269
|
+
updated_at: now,
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
store.updated_at = now;
|
|
1273
|
+
fs.writeFileSync(goalsPath, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
|
|
1274
|
+
return goalsPath;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
const DEFAULT_MEMBER_BATON_RELAY = {
|
|
1278
|
+
researcher: 'navigator',
|
|
1279
|
+
'customer-lead': 'navigator',
|
|
1280
|
+
navigator: 'executor',
|
|
1281
|
+
architect: 'executor',
|
|
1282
|
+
executor: 'validator',
|
|
1283
|
+
'auto-improver': 'validator',
|
|
1284
|
+
};
|
|
1285
|
+
|
|
1286
|
+
function recordMemberHandoff(fromMember, toMember, sliceSummary, root = process.cwd(), extra = {}) {
|
|
1287
|
+
const from = String(fromMember || '').trim().toLowerCase();
|
|
1288
|
+
const to = String(toMember || DEFAULT_MEMBER_BATON_RELAY[from] || '').trim().toLowerCase();
|
|
1289
|
+
const cleanSummary = String(sliceSummary || '').replace(/\s+/g, ' ').trim();
|
|
1290
|
+
if (!from || !to || !cleanSummary) return null;
|
|
1291
|
+
const parentRoot = resolveMemberWorkspaceRoot(root);
|
|
1292
|
+
if (!memberDir(to, parentRoot)) return null;
|
|
1293
|
+
const now = stampIso();
|
|
1294
|
+
const handoff = {
|
|
1295
|
+
id: `handoff_${Date.now().toString(36)}`,
|
|
1296
|
+
from,
|
|
1297
|
+
to,
|
|
1298
|
+
summary: cleanSummary.slice(0, 180),
|
|
1299
|
+
status: extra.status || 'open',
|
|
1300
|
+
next_command: `atris member go ${to}`,
|
|
1301
|
+
created_at: now,
|
|
1302
|
+
};
|
|
1303
|
+
const handoffsFile = path.join(parentRoot, '.atris', 'state', 'member-handoffs.jsonl');
|
|
1304
|
+
try {
|
|
1305
|
+
withHandoffsLock(handoffsFile, () => fs.appendFileSync(handoffsFile, `${JSON.stringify(handoff)}\n`, 'utf8'));
|
|
1306
|
+
} catch {
|
|
1307
|
+
return null;
|
|
1308
|
+
}
|
|
1309
|
+
try {
|
|
1310
|
+
autoQueueMemberNextPush(to, `${from} handoff: ${cleanSummary}`, `Baton pass from ${from}`, parentRoot);
|
|
1311
|
+
} catch {}
|
|
1312
|
+
return handoff;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// Every write to member-handoffs.jsonl (append or whole-file rewrite) holds
|
|
1316
|
+
// one lock file beside it, so concurrent handoffs never lose a row.
|
|
1317
|
+
// (Codex review round 3 on #1483.)
|
|
1318
|
+
function withHandoffsLock(handoffsFile, fn) {
|
|
1319
|
+
fs.mkdirSync(path.dirname(handoffsFile), { recursive: true });
|
|
1320
|
+
const lockFile = path.join(path.dirname(handoffsFile), 'member-handoffs.lock');
|
|
1321
|
+
return require('./handoff-wake').withFileLock(lockFile, fn, { staleMs: 60 * 1000, waitMs: 30 * 1000 });
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
function rewriteHandoffs(handoffsFile, lines) {
|
|
1325
|
+
const tmp = `${handoffsFile}.${process.pid}.tmp`;
|
|
1326
|
+
fs.writeFileSync(tmp, `${lines.join('\n')}\n`, 'utf8');
|
|
1327
|
+
fs.renameSync(tmp, handoffsFile);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// Merge `patch` into the newest handoff row with this id. Returns the
|
|
1331
|
+
// updated row, or null when it is not on file.
|
|
1332
|
+
function updateMemberHandoff(handoffId, patch, root = process.cwd()) {
|
|
1333
|
+
const id = String(handoffId || '');
|
|
1334
|
+
if (!id || !patch) return null;
|
|
1335
|
+
const handoffsFile = path.join(resolveMemberWorkspaceRoot(root), '.atris', 'state', 'member-handoffs.jsonl');
|
|
1336
|
+
try {
|
|
1337
|
+
return withHandoffsLock(handoffsFile, () => {
|
|
1338
|
+
const lines = fs.readFileSync(handoffsFile, 'utf8').split(/\r?\n/).filter((l) => l.trim());
|
|
1339
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
1340
|
+
let row = null;
|
|
1341
|
+
try { row = JSON.parse(lines[i]); } catch {}
|
|
1342
|
+
if (!row || row.id !== id) continue;
|
|
1343
|
+
const updated = { ...row, ...patch };
|
|
1344
|
+
lines[i] = JSON.stringify(updated);
|
|
1345
|
+
rewriteHandoffs(handoffsFile, lines);
|
|
1346
|
+
return updated;
|
|
1347
|
+
}
|
|
1348
|
+
return null;
|
|
1349
|
+
});
|
|
1350
|
+
} catch {
|
|
1351
|
+
return null;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function closeMemberHandoffsForReceiver(receiverMember, root = process.cwd()) {
|
|
1356
|
+
const receiver = String(receiverMember || '').trim().toLowerCase();
|
|
1357
|
+
if (!receiver) return 0;
|
|
1358
|
+
const parentRoot = resolveMemberWorkspaceRoot(root);
|
|
1359
|
+
const handoffsFile = path.join(parentRoot, '.atris', 'state', 'member-handoffs.jsonl');
|
|
1360
|
+
if (!fs.existsSync(handoffsFile)) return 0;
|
|
1361
|
+
try {
|
|
1362
|
+
return withHandoffsLock(handoffsFile, () => closeHandoffRows(handoffsFile, receiver));
|
|
1363
|
+
} catch {
|
|
1364
|
+
return 0;
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function closeHandoffRows(handoffsFile, receiver) {
|
|
1369
|
+
const rawLines = fs.readFileSync(handoffsFile, 'utf8').split(/\r?\n/).filter((l) => l.trim());
|
|
1370
|
+
let closedCount = 0;
|
|
1371
|
+
const now = stampIso();
|
|
1372
|
+
const updatedLines = rawLines.map((line) => {
|
|
1373
|
+
try {
|
|
1374
|
+
const row = JSON.parse(line);
|
|
1375
|
+
if (row && String(row.to || '').toLowerCase() === receiver && (row.status || 'open') === 'open') {
|
|
1376
|
+
closedCount++;
|
|
1377
|
+
return JSON.stringify({ ...row, status: 'closed', closed_at: now });
|
|
1378
|
+
}
|
|
1379
|
+
} catch {}
|
|
1380
|
+
return line;
|
|
1381
|
+
});
|
|
1382
|
+
if (closedCount > 0) rewriteHandoffs(handoffsFile, updatedLines);
|
|
1383
|
+
return closedCount;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function memberMissionFile(owner, root = process.cwd()) {
|
|
1387
|
+
const dir = memberDir(owner, root);
|
|
1388
|
+
if (!dir) return null;
|
|
1389
|
+
return path.join(dir, 'MISSION.md');
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
function missingOwnerMemberWarning(owner, root = process.cwd()) {
|
|
1393
|
+
if (!owner || fs.existsSync(path.join(root, 'atris', 'team', owner))) return null;
|
|
1394
|
+
const teamDir = path.join(root, 'atris', 'team');
|
|
1395
|
+
const hasKnownMembers = fs.existsSync(teamDir)
|
|
1396
|
+
&& fs.readdirSync(teamDir, { withFileTypes: true }).some((entry) => (
|
|
1397
|
+
entry.isDirectory() && fs.existsSync(path.join(teamDir, entry.name, 'MEMBER.md'))
|
|
1398
|
+
));
|
|
1399
|
+
if (!hasKnownMembers) return null;
|
|
1400
|
+
return {
|
|
1401
|
+
code: 'missing_owner_member',
|
|
1402
|
+
message: `owner "${owner}" has no atris/team/${owner}/ member. Create it (atris member create ${owner}) or pick an existing member (ls atris/team/).`,
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
function ensureMemberMissionFile(owner, root = process.cwd(), objective = '') {
|
|
1407
|
+
const missionPath = memberMissionFile(owner, root);
|
|
1408
|
+
if (!missionPath || fs.existsSync(missionPath)) return missionPath;
|
|
1409
|
+
const purpose = String(objective || '').trim() || 'Define why this member exists and how it chooses goals.';
|
|
1410
|
+
const content = [
|
|
1411
|
+
'# Mission',
|
|
1412
|
+
'',
|
|
1413
|
+
'<!-- Human-authored purpose file. Keep this durable; runtime state belongs in .atris/state/*.jsonl and now.md. -->',
|
|
1414
|
+
'',
|
|
1415
|
+
'## North Star',
|
|
1416
|
+
'',
|
|
1417
|
+
purpose,
|
|
1418
|
+
'',
|
|
1419
|
+
'## How To Choose Goals',
|
|
1420
|
+
'',
|
|
1421
|
+
'- Read MEMBER.md, MISSION.md, current goals, now.md, and recent logs.',
|
|
1422
|
+
'- Choose one useful bounded goal toward the mission.',
|
|
1423
|
+
'- Verify the work, write the receipt, and update the log.',
|
|
1424
|
+
'- Ask the human when vision, taste, risk, or uncertainty matters.',
|
|
1425
|
+
'',
|
|
1426
|
+
].join('\n');
|
|
1427
|
+
fs.writeFileSync(missionPath, content, 'utf8');
|
|
1428
|
+
return missionPath;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
function removeLegacyGeneratedMissionViews(dir) {
|
|
1432
|
+
for (const name of ['missions.md', 'missions.json']) {
|
|
1433
|
+
const legacyPath = path.join(dir, name);
|
|
1434
|
+
if (!fs.existsSync(legacyPath)) continue;
|
|
1435
|
+
let text = '';
|
|
1436
|
+
try {
|
|
1437
|
+
text = fs.readFileSync(legacyPath, 'utf8');
|
|
1438
|
+
} catch {}
|
|
1439
|
+
const looksGenerated = name.endsWith('.json')
|
|
1440
|
+
? text.includes('"schema": "atris.member_missions.v1"')
|
|
1441
|
+
: text.includes('Generated from local Mission state');
|
|
1442
|
+
if (looksGenerated) fs.unlinkSync(legacyPath);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
module.exports = {
|
|
1447
|
+
VALID_STATUSES,
|
|
1448
|
+
TERMINAL_STATUSES,
|
|
1449
|
+
GOAL_LOOP_STATUSES,
|
|
1450
|
+
STATUS_ALIASES,
|
|
1451
|
+
CODEX_NATIVE_GOAL_SLOT_STATUSES,
|
|
1452
|
+
CODEX_NATIVE_GOAL_REPLACE_STATUSES,
|
|
1453
|
+
CODEX_NATIVE_GOAL_CLOSED_STATUSES,
|
|
1454
|
+
DEFAULT_LONG_RUN_VERIFIER,
|
|
1455
|
+
SLEEP_LENGTH_BUDGET_SECONDS,
|
|
1456
|
+
HUMAN_BLOCKING_PAUSE_REASONS,
|
|
1457
|
+
MISSION_RUNNER_AUTH_EXPIRED_RE,
|
|
1458
|
+
missionRunnerAuthExpired,
|
|
1459
|
+
MISSION_BUDGET_TIERS,
|
|
1460
|
+
stampIso,
|
|
1461
|
+
todayName,
|
|
1462
|
+
slugify,
|
|
1463
|
+
shortHash,
|
|
1464
|
+
missionId,
|
|
1465
|
+
destinationHash,
|
|
1466
|
+
missionDrive,
|
|
1467
|
+
proposeMissionDestinationChange,
|
|
1468
|
+
SUPPORTED_MISSION_HARD_GATES,
|
|
1469
|
+
missionRouteLegId,
|
|
1470
|
+
missionRouteHardGates,
|
|
1471
|
+
missionRouteScopePresent,
|
|
1472
|
+
missionRouteCycle,
|
|
1473
|
+
validateMissionRouteProposal,
|
|
1474
|
+
readMissionRouteProposal,
|
|
1475
|
+
MISSION_NATIVE_RUNNER_NAMES,
|
|
1476
|
+
MISSION_NATIVE_RUNNER_SET,
|
|
1477
|
+
MISSION_AUTO_RUNNER,
|
|
1478
|
+
canonicalEngineName,
|
|
1479
|
+
knownMissionRunnerText,
|
|
1480
|
+
resolveMissionRunnerSelection,
|
|
1481
|
+
rosterTickPins,
|
|
1482
|
+
missionTickPrep,
|
|
1483
|
+
memberLogTickReason,
|
|
1484
|
+
resolveMissionTickRunner,
|
|
1485
|
+
recordMissionEngineTickOutcome,
|
|
1486
|
+
missionTickRunOutcome,
|
|
1487
|
+
recordMissionTickRosterRun,
|
|
1488
|
+
runnerModelPatch,
|
|
1489
|
+
missionRunRuntimeView,
|
|
1490
|
+
applyMissionRunnerProfile,
|
|
1491
|
+
CLAUDE_FAMILY_RUNNER_ENGINES,
|
|
1492
|
+
resolveMissionTickRunnerModel,
|
|
1493
|
+
exitMissionError,
|
|
1494
|
+
warnIfSummaryNeedsOperatorWhy,
|
|
1495
|
+
warnIfTaskTitleNeedsOperatorWhy,
|
|
1496
|
+
readPositiveIntegerFlag,
|
|
1497
|
+
readNonNegativeIntegerFlag,
|
|
1498
|
+
readRepeatedFlag,
|
|
1499
|
+
codexNativeGoalOptionsFromArgs,
|
|
1500
|
+
lintMissionVerifier,
|
|
1501
|
+
assertMissionVerifier,
|
|
1502
|
+
stripKnownFlags,
|
|
1503
|
+
wantsJson,
|
|
1504
|
+
printJsonOrText,
|
|
1505
|
+
MISSION_RUN_VALUE_FLAGS,
|
|
1506
|
+
MISSION_RUN_BOOLEAN_FLAGS,
|
|
1507
|
+
DEFAULT_MISSION_RUN_OWNER_SLUGS,
|
|
1508
|
+
missionRunInputRequired,
|
|
1509
|
+
askLine,
|
|
1510
|
+
removeValueFlag,
|
|
1511
|
+
missionRunOwnerRef,
|
|
1512
|
+
missionRunArgsWithOwner,
|
|
1513
|
+
missionRunInputFromArgs,
|
|
1514
|
+
promptMissionRunInput,
|
|
1515
|
+
loadTaskDb,
|
|
1516
|
+
writeMissionTaskProjection,
|
|
1517
|
+
missionTaskRef,
|
|
1518
|
+
missionXpTaskTitle,
|
|
1519
|
+
normalizeMissionTaskMatchText,
|
|
1520
|
+
missionObjectiveTaskMatch,
|
|
1521
|
+
taskRowToMissionXpBinding,
|
|
1522
|
+
findOpenTaskMatchingMissionObjective,
|
|
1523
|
+
statePaths,
|
|
1524
|
+
readBusinessBinding,
|
|
1525
|
+
businessIdForAtris2Mission,
|
|
1526
|
+
readJsonLines,
|
|
1527
|
+
missionLabel,
|
|
1528
|
+
missionRef,
|
|
1529
|
+
missionDisplayText,
|
|
1530
|
+
missionNumberWinner,
|
|
1531
|
+
MISSION_REF_SUFFIX_MIN_LENGTH,
|
|
1532
|
+
uniqueMissionSuffix,
|
|
1533
|
+
missionNumberDisplayIndex,
|
|
1534
|
+
missionRefLooksLikeHandle,
|
|
1535
|
+
missionMatchesRef,
|
|
1536
|
+
missionsMatchingHandleFragment,
|
|
1537
|
+
nearestMissionCandidates,
|
|
1538
|
+
SIBLING_HINT_SCAN_LIMIT,
|
|
1539
|
+
missionMatchesStatusFilter,
|
|
1540
|
+
appendJsonLine,
|
|
1541
|
+
resolveMemberWorkspaceRoot,
|
|
1542
|
+
appendEvent,
|
|
1543
|
+
memberDir,
|
|
1544
|
+
appendMemberLog,
|
|
1545
|
+
clipWords,
|
|
1546
|
+
crystallizeMemberSkillLesson,
|
|
1547
|
+
autoQueueMemberNextPush,
|
|
1548
|
+
DEFAULT_MEMBER_BATON_RELAY,
|
|
1549
|
+
recordMemberHandoff,
|
|
1550
|
+
withHandoffsLock,
|
|
1551
|
+
rewriteHandoffs,
|
|
1552
|
+
updateMemberHandoff,
|
|
1553
|
+
closeMemberHandoffsForReceiver,
|
|
1554
|
+
closeHandoffRows,
|
|
1555
|
+
memberMissionFile,
|
|
1556
|
+
missingOwnerMemberWarning,
|
|
1557
|
+
ensureMemberMissionFile,
|
|
1558
|
+
removeLegacyGeneratedMissionViews,
|
|
1559
|
+
};
|