atris 3.78.0 → 3.80.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.
@@ -115,318 +115,122 @@ const {
115
115
  prepareMissionGitGuard,
116
116
  unreadableMissionGuard,
117
117
  } = require('../lib/mission-protected-lane');
118
-
119
- const VALID_STATUSES = new Set(['planning', 'running', 'ready', 'paused', 'blocked', 'stopped', 'complete']);
120
- const TERMINAL_STATUSES = new Set(['stopped', 'complete']);
121
118
  // Campaign hooks load on first use, so missions without a campaign never open its store.
122
119
  const campaignMissions = () => require('../lib/campaign-missions');
123
- const GOAL_LOOP_STATUSES = new Set(['planning', 'running', 'ready']);
124
- const STATUS_ALIASES = new Set(['active']);
125
- const CODEX_NATIVE_GOAL_SLOT_STATUSES = new Set(['active', 'paused', 'usage_limited']);
126
- const CODEX_NATIVE_GOAL_REPLACE_STATUSES = new Set(['active', 'paused', 'usage_limited']);
127
- const CODEX_NATIVE_GOAL_CLOSED_STATUSES = new Set(['complete', 'completed', 'achieved']);
128
- const DEFAULT_LONG_RUN_VERIFIER = 'git diff --check';
129
- const SLEEP_LENGTH_BUDGET_SECONDS = 3600;
130
- const HUMAN_BLOCKING_PAUSE_REASONS = new Set(['auth-required', 'model-unavailable', 'rate-limit-exceeded-wall']);
131
- // Claude prints its logged-out message on stdout ("Invalid API key · Please
132
- // run /login"), not stderr, so the check has to read the captured result text
133
- // too or a dead login masquerades as a generic claude-error.
134
- const MISSION_RUNNER_AUTH_EXPIRED_RE = /not authenticated|please log in|login required|auth(?:entication)? expired|invalid (?:api|connection) key|run \/login/i;
135
-
136
- function missionRunnerAuthExpired(...texts) {
137
- return texts.some((text) => Boolean(text) && MISSION_RUNNER_AUTH_EXPIRED_RE.test(String(text)));
138
- }
139
-
140
- const MISSION_BUDGET_TIERS = Object.freeze({
141
- quick: Object.freeze({ max_ticks: 4, requested_seconds: 15 * 60 }),
142
- long: Object.freeze({ max_ticks: 12, requested_seconds: 60 * 60 }),
143
- deep: Object.freeze({ max_ticks: 30, requested_seconds: 180 * 60 }),
144
- });
145
-
146
- function stampIso() {
147
- return new Date().toISOString();
148
- }
149
-
150
- function todayName() {
151
- const now = new Date();
152
- return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
153
- }
154
-
155
- function slugify(value) {
156
- return String(value || 'mission')
157
- .toLowerCase()
158
- .replace(/[^a-z0-9]+/g, '-')
159
- .replace(/^-+|-+$/g, '')
160
- .slice(0, 48) || 'mission';
161
- }
162
-
163
- function shortHash(value) {
164
- return crypto.createHash('sha1').update(String(value || '')).digest('hex').slice(0, 8);
165
- }
166
-
167
- function missionId(objective) {
168
- return `mission-${todayName()}-${slugify(objective).slice(0, 28)}-${shortHash(`${objective}:${Date.now()}`)}`;
169
- }
170
-
171
- function destinationHash(destination) {
172
- return crypto.createHash('sha256').update(String(destination || '')).digest('hex');
173
- }
174
-
175
- function missionDrive(destination) {
176
- const value = String(destination || '').trim();
177
- if (!value) return null;
178
- return {
179
- schema: 'atris.mission.drive.v1',
180
- destination: value,
181
- destination_hash: destinationHash(value),
182
- autonomy_level: 'L1',
183
- route_version: 1,
184
- position: 0,
185
- legs: [],
186
- hard_gates: [],
187
- budget: null,
188
- stop_reason: null,
189
- };
190
- }
191
-
192
- function proposeMissionDestinationChange(mission, destination) {
193
- const value = String(destination || '').trim();
194
- if (!mission?.drive || !value || value === mission.drive.destination) return mission;
195
- return {
196
- ...mission,
197
- drive: {
198
- ...mission.drive,
199
- pending_destination_proposal: {
200
- destination: value,
201
- destination_hash: destinationHash(value),
202
- reason: 'destination changes require operator approval',
203
- operator_gated: true,
204
- proposed_at: stampIso(),
205
- },
206
- },
207
- };
208
- }
209
-
210
- const SUPPORTED_MISSION_HARD_GATES = new Set([
211
- 'money_movement',
212
- 'outbound_communication',
213
- 'destructive_change',
214
- 'irreversible_change',
215
- 'secret_access',
216
- 'destination_change',
217
- ]);
218
-
219
- function missionRouteLegId(leg, index) {
220
- return String(leg?.id || leg?.key || `leg-${index + 1}`).trim();
221
- }
222
-
223
- function missionRouteHardGates(proposal) {
224
- const gates = [];
225
- const add = (value) => {
226
- const values = Array.isArray(value) ? value : value ? [value] : [];
227
- for (const entry of values) {
228
- const gate = String(entry || '').trim().toLowerCase().replace(/[\s-]+/g, '_');
229
- if (gate && !gates.includes(gate)) gates.push(gate);
230
- }
231
- };
232
- add(proposal?.hard_gates);
233
- for (const leg of proposal?.legs || []) {
234
- add(leg?.hard_gates);
235
- add(leg?.hard_gate);
236
- }
237
- return gates;
238
- }
239
-
240
- function missionRouteScopePresent(leg) {
241
- const files = Array.isArray(leg?.files) ? leg.files : leg?.files ? [leg.files] : [];
242
- const surfaces = Array.isArray(leg?.surface) ? leg.surface : leg?.surface ? [leg.surface] : [];
243
- return [...files, ...surfaces].some((value) => String(value || '').trim());
244
- }
245
-
246
- function missionRouteCycle(legs) {
247
- const ids = legs.map(missionRouteLegId);
248
- const dependencies = new Map(legs.map((leg, index) => [
249
- ids[index],
250
- Array.isArray(leg.dependencies) ? leg.dependencies.map((value) => String(value || '').trim()).filter(Boolean) : [],
251
- ]));
252
- const visiting = new Set();
253
- const visited = new Set();
254
- const stack = [];
255
-
256
- function visit(id) {
257
- if (visiting.has(id)) {
258
- const start = stack.indexOf(id);
259
- return [...stack.slice(start), id];
260
- }
261
- if (visited.has(id)) return null;
262
- visiting.add(id);
263
- stack.push(id);
264
- for (const dependency of dependencies.get(id) || []) {
265
- const cycle = visit(dependency);
266
- if (cycle) return cycle;
267
- }
268
- stack.pop();
269
- visiting.delete(id);
270
- visited.add(id);
271
- return null;
272
- }
273
-
274
- for (const id of ids) {
275
- const cycle = visit(id);
276
- if (cycle) return cycle;
277
- }
278
- return null;
279
- }
280
-
281
- function validateMissionRouteProposal(proposal) {
282
- const legs = proposal?.legs;
283
- if (!Array.isArray(legs) || legs.length === 0) {
284
- return { code: 'missing_legs', reason: 'route proposal must contain at least one leg' };
285
- }
286
-
287
- const ids = legs.map(missionRouteLegId);
288
- const idSet = new Set(ids);
289
- if (idSet.size !== ids.length) {
290
- return { code: 'duplicate_leg_id', reason: 'route proposal contains duplicate leg ids' };
291
- }
292
-
293
- for (let index = 0; index < legs.length; index += 1) {
294
- const leg = legs[index];
295
- const id = ids[index];
296
- if (!leg || typeof leg !== 'object' || Array.isArray(leg)) {
297
- return { code: 'invalid_leg', reason: `route leg ${id} must be an object` };
298
- }
299
- if (!String(leg.objective || '').trim()) {
300
- return { code: 'missing_objective', reason: `route leg ${id} is missing an objective` };
301
- }
302
- if (!String(leg.verifier || '').trim()) {
303
- return { code: 'missing_verifier', reason: `route leg ${id} is missing a verifier command` };
304
- }
305
- if (!String(leg.stop_condition || leg.terminal_condition || leg.terminal_state || '').trim()) {
306
- return { code: 'missing_stop_condition', reason: `route leg ${id} is missing a stop or terminal condition` };
307
- }
308
- if (!missionRouteScopePresent(leg)) {
309
- return { code: 'missing_scope', reason: `route leg ${id} has no files or surface scope` };
310
- }
311
- if (leg.dependencies !== undefined && !Array.isArray(leg.dependencies)) {
312
- return { code: 'invalid_dependencies', reason: `route leg ${id} dependencies must be a list` };
313
- }
314
- for (const dependency of leg.dependencies || []) {
315
- const dependencyId = String(dependency || '').trim();
316
- if (!idSet.has(dependencyId)) {
317
- return { code: 'unknown_dependency', reason: `route leg ${id} depends on unknown leg ${dependencyId || '(empty)'}` };
318
- }
319
- }
320
- }
321
-
322
- const unsupportedGate = missionRouteHardGates(proposal)
323
- .find((gate) => !SUPPORTED_MISSION_HARD_GATES.has(gate));
324
- if (unsupportedGate) {
325
- return { code: 'unsupported_hard_gate', reason: `route uses unsupported hard gate ${unsupportedGate}` };
326
- }
327
-
328
- const cycle = missionRouteCycle(legs);
329
- if (cycle) {
330
- return { code: 'dependency_cycle', reason: `route dependencies form a cycle: ${cycle.join(' -> ')}` };
331
- }
332
- return null;
333
- }
334
-
335
- function readMissionRouteProposal(args, asJson) {
336
- const source = readFlag(args, '--file', '');
337
- let raw = '';
338
- try {
339
- raw = source && source !== '-'
340
- ? fs.readFileSync(path.resolve(process.cwd(), source), 'utf8')
341
- : fs.readFileSync(0, 'utf8');
342
- } catch (error) {
343
- exitMissionError(`Could not read route proposal: ${error.message}`, 1, asJson);
344
- }
345
- if (!String(raw || '').trim()) {
346
- exitMissionError('Route proposal JSON is required on stdin or with --file <path>.', 1, asJson);
347
- }
348
- let parsed;
349
- try {
350
- parsed = JSON.parse(raw);
351
- } catch (error) {
352
- exitMissionError(`Route proposal is not valid JSON: ${error.message}`, 1, asJson);
353
- }
354
- if (Array.isArray(parsed)) return { legs: parsed, hard_gates: [] };
355
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
356
- exitMissionError('Route proposal must be a list of legs or an object with a legs list.', 1, asJson);
357
- }
358
- return {
359
- ...parsed,
360
- legs: parsed.legs,
361
- hard_gates: parsed.hard_gates ?? [],
362
- };
363
- }
364
-
365
- const MISSION_NATIVE_RUNNER_NAMES = Object.freeze(['manual', 'claude', 'atris2', 'codex_goal', 'caller_session', 'current_agent', 'drill']);
366
- const MISSION_NATIVE_RUNNER_SET = new Set(MISSION_NATIVE_RUNNER_NAMES);
367
- const MISSION_AUTO_RUNNER = 'auto';
368
-
369
- function canonicalEngineName(name) {
370
- const trimmed = String(name || '').trim();
371
- if (!trimmed) return '';
372
- if (RUNNER_PROFILE_DEFS[trimmed]) return trimmed;
373
- if (RUNNER_PROFILE_ALIASES[trimmed]) return RUNNER_PROFILE_ALIASES[trimmed];
374
- return '';
375
- }
376
-
377
- function knownMissionRunnerText() {
378
- return `Known runners: ${[...MISSION_NATIVE_RUNNER_NAMES, MISSION_AUTO_RUNNER].join(', ')}. Known engines: ${RUNNER_PROFILE_NAMES.join(', ')}`;
379
- }
380
-
381
- function resolveMissionRunnerSelection(value, options = {}) {
382
- const raw = String(value || '').trim();
383
- const asJson = options.asJson === true;
384
- const engineOnly = options.engineOnly === true;
385
- if (!raw) {
386
- exitMissionError(`${options.label || 'runner'} is required. ${knownMissionRunnerText()}.`, 2, asJson);
387
- }
388
- const runner = raw.toLowerCase();
389
- if (runner === MISSION_AUTO_RUNNER) {
390
- return { requested: raw, runner, engine: null, kind: 'auto' };
391
- }
392
- if (!engineOnly && MISSION_NATIVE_RUNNER_SET.has(runner)) {
393
- return { requested: raw, runner, engine: null, kind: 'runner' };
394
- }
395
- const engine = canonicalEngineName(raw);
396
- if (engine) {
397
- return { requested: raw, runner: engine, engine, kind: 'engine' };
398
- }
399
- const noun = engineOnly ? 'engine' : 'runner';
400
- exitMissionError(`Unknown ${noun} "${raw}". ${knownMissionRunnerText()}.`, 2, asJson);
401
- }
402
-
403
- // The roster line's effort and time cap for this tick, when it sets them.
404
- function rosterTickPins(engine) {
405
- return {
406
- ...(engine && engine.roster_effort ? { roster_effort: engine.roster_effort } : {}),
407
- ...(engine && engine.roster_max_seconds ? { roster_max_seconds: engine.roster_max_seconds } : {}),
408
- ...(engine && engine.roster_prep ? { roster_prep: engine.roster_prep } : {}),
409
- };
410
- }
411
-
412
- // A tick whose roster worker asks for prep ("prep: search") runs the same
413
- // prep pass fleet runs before the worker starts, and the worker's prompt
414
- // gets the brief. No prep line means the prompt goes out as it was.
415
- async function missionTickPrep(root, { mission, runtimeMission, job, prompt, ask } = {}) {
416
- const prepJob = String(runtimeMission && runtimeMission.roster_prep || '').trim();
417
- if (!prepJob) return { prompt, prep: null };
418
- const { runPrepPass, withPrepBrief } = require('../lib/roster-prep');
419
- const prep = await runPrepPass({
420
- prepJob,
421
- forJob: job || 'build',
422
- task: { display_id: (mission && mission.id) || '', title: (mission && (mission.objective || mission.slug)) || '' },
423
- prompt,
424
- root,
425
- record: { member: (mission && mission.owner) || '' },
426
- ...(ask ? { ask } : {}),
427
- });
428
- return { prompt: withPrepBrief(prompt, prep), prep };
429
- }
120
+ const {
121
+ VALID_STATUSES,
122
+ TERMINAL_STATUSES,
123
+ GOAL_LOOP_STATUSES,
124
+ STATUS_ALIASES,
125
+ CODEX_NATIVE_GOAL_SLOT_STATUSES,
126
+ CODEX_NATIVE_GOAL_REPLACE_STATUSES,
127
+ CODEX_NATIVE_GOAL_CLOSED_STATUSES,
128
+ DEFAULT_LONG_RUN_VERIFIER,
129
+ SLEEP_LENGTH_BUDGET_SECONDS,
130
+ HUMAN_BLOCKING_PAUSE_REASONS,
131
+ MISSION_RUNNER_AUTH_EXPIRED_RE,
132
+ missionRunnerAuthExpired,
133
+ MISSION_BUDGET_TIERS,
134
+ stampIso,
135
+ todayName,
136
+ slugify,
137
+ shortHash,
138
+ missionId,
139
+ destinationHash,
140
+ missionDrive,
141
+ proposeMissionDestinationChange,
142
+ SUPPORTED_MISSION_HARD_GATES,
143
+ missionRouteLegId,
144
+ missionRouteHardGates,
145
+ missionRouteScopePresent,
146
+ missionRouteCycle,
147
+ validateMissionRouteProposal,
148
+ readMissionRouteProposal,
149
+ MISSION_NATIVE_RUNNER_NAMES,
150
+ MISSION_NATIVE_RUNNER_SET,
151
+ MISSION_AUTO_RUNNER,
152
+ canonicalEngineName,
153
+ knownMissionRunnerText,
154
+ resolveMissionRunnerSelection,
155
+ rosterTickPins,
156
+ missionTickPrep,
157
+ memberLogTickReason,
158
+ resolveMissionTickRunner,
159
+ recordMissionEngineTickOutcome,
160
+ missionTickRunOutcome,
161
+ recordMissionTickRosterRun,
162
+ runnerModelPatch,
163
+ missionRunRuntimeView,
164
+ applyMissionRunnerProfile,
165
+ CLAUDE_FAMILY_RUNNER_ENGINES,
166
+ resolveMissionTickRunnerModel,
167
+ exitMissionError,
168
+ warnIfSummaryNeedsOperatorWhy,
169
+ warnIfTaskTitleNeedsOperatorWhy,
170
+ readPositiveIntegerFlag,
171
+ readNonNegativeIntegerFlag,
172
+ readRepeatedFlag,
173
+ codexNativeGoalOptionsFromArgs,
174
+ lintMissionVerifier,
175
+ assertMissionVerifier,
176
+ stripKnownFlags,
177
+ wantsJson,
178
+ printJsonOrText,
179
+ MISSION_RUN_VALUE_FLAGS,
180
+ MISSION_RUN_BOOLEAN_FLAGS,
181
+ DEFAULT_MISSION_RUN_OWNER_SLUGS,
182
+ missionRunInputRequired,
183
+ askLine,
184
+ removeValueFlag,
185
+ missionRunOwnerRef,
186
+ missionRunArgsWithOwner,
187
+ missionRunInputFromArgs,
188
+ promptMissionRunInput,
189
+ loadTaskDb,
190
+ writeMissionTaskProjection,
191
+ missionTaskRef,
192
+ missionXpTaskTitle,
193
+ normalizeMissionTaskMatchText,
194
+ missionObjectiveTaskMatch,
195
+ taskRowToMissionXpBinding,
196
+ findOpenTaskMatchingMissionObjective,
197
+ statePaths,
198
+ readBusinessBinding,
199
+ businessIdForAtris2Mission,
200
+ readJsonLines,
201
+ missionLabel,
202
+ missionRef,
203
+ missionDisplayText,
204
+ missionNumberWinner,
205
+ MISSION_REF_SUFFIX_MIN_LENGTH,
206
+ uniqueMissionSuffix,
207
+ missionNumberDisplayIndex,
208
+ missionRefLooksLikeHandle,
209
+ missionMatchesRef,
210
+ missionsMatchingHandleFragment,
211
+ nearestMissionCandidates,
212
+ SIBLING_HINT_SCAN_LIMIT,
213
+ missionMatchesStatusFilter,
214
+ appendJsonLine,
215
+ resolveMemberWorkspaceRoot,
216
+ appendEvent,
217
+ memberDir,
218
+ appendMemberLog,
219
+ clipWords,
220
+ crystallizeMemberSkillLesson,
221
+ autoQueueMemberNextPush,
222
+ DEFAULT_MEMBER_BATON_RELAY,
223
+ recordMemberHandoff,
224
+ withHandoffsLock,
225
+ rewriteHandoffs,
226
+ updateMemberHandoff,
227
+ closeMemberHandoffsForReceiver,
228
+ closeHandoffRows,
229
+ memberMissionFile,
230
+ missingOwnerMemberWarning,
231
+ ensureMemberMissionFile,
232
+ removeLegacyGeneratedMissionViews,
233
+ } = require('../lib/mission-core');
430
234
 
431
235
  // A tick's time limit: the time left on the mission, capped by the roster
432
236
  // line's "max 20 min" when it sets one, else by the default tick cap.
@@ -446,559 +250,6 @@ function missionTickWallBound(mission, remainingMs) {
446
250
  return remainingMs <= missionTickCapMs(mission);
447
251
  }
448
252
 
449
- // The reason a member log shows for a tick. A failed engine tick names its
450
- // engine beside the reason, so a codex run that timed out stops counting as
451
- // a claude timeout in the repeat-problem scan. The reason itself is left as
452
- // is because engine health and backoff match on it.
453
- function memberLogTickReason(result) {
454
- const reason = result && result.reason;
455
- if (!reason || !result.engine_id || result.status !== 'errored') return reason;
456
- return `${reason} (engine: ${result.engine_id})`;
457
- }
458
-
459
- function resolveMissionTickRunner(mission, root = process.cwd(), options = {}) {
460
- if (String(mission && mission.runner || '').trim().toLowerCase() !== MISSION_AUTO_RUNNER) {
461
- // A named engine still gets the roster's time limit for that engine.
462
- const runner = String(mission && mission.runner || '').trim().toLowerCase();
463
- const pin = runner && !(Number(mission && mission.roster_max_seconds) > 0)
464
- ? require('../lib/engine-registry').rosterPinFor('executor', runner, root, options)
465
- : {};
466
- const limited = pin.roster_max_seconds ? { ...mission, roster_max_seconds: pin.roster_max_seconds } : mission;
467
- return { mission: limited, engine_id: null, requested_engine: null, engine_fallback_reason: null };
468
- }
469
- // A mission owned by a team member runs on that member's roster pick. An
470
- // explicit preferred engine (or a run's --engine) still wins, and with no
471
- // roster line behind the member, routing stays exactly as it was.
472
- if (!String(mission.preferred_engine || '').trim() && mission.owner) {
473
- const { memberRosterEngine } = require('../lib/member-engine');
474
- const member = memberRosterEngine(mission.owner, root, options);
475
- if (member) {
476
- if (process.env.ATRIS_ROUTER_EXPLAIN !== '0') console.error(member.reason);
477
- return {
478
- mission: {
479
- ...mission,
480
- runner: member.engine.id,
481
- runner_kind: 'engine',
482
- ...(!mission.model && member.model ? { model: member.model } : {}),
483
- ...rosterTickPins(member.engine),
484
- },
485
- engine_id: member.engine.id,
486
- requested_engine: null,
487
- engine_fallback_reason: null,
488
- member_engine_reason: member.reason,
489
- roster_job: member.job || 'build',
490
- };
491
- }
492
- }
493
- const resolved = resolveEngineForRoleWithPreference('executor', root, mission.preferred_engine, options);
494
- return {
495
- mission: resolved.engine ? {
496
- ...mission,
497
- runner: resolved.engine.id,
498
- runner_kind: 'engine',
499
- ...(!mission.model && resolved.engine.roster_model ? { model: resolved.engine.roster_model } : {}),
500
- ...rosterTickPins(resolved.engine),
501
- } : mission,
502
- engine_id: resolved.engine ? resolved.engine.id : null,
503
- requested_engine: resolved.requested_engine,
504
- engine_fallback_reason: resolved.engine_fallback_reason,
505
- };
506
- }
507
-
508
- // A stalled tick benches its engine for the cooldown, so the next tick's
509
- // roster pick is the backup; no retry inside the tick is needed.
510
- function recordMissionEngineTickOutcome(engineId, result, root = process.cwd()) {
511
- if (!engineId) return null;
512
- if (result && result.status === 'ran') return setEngineHealth(engineId, 'ready', root);
513
- if (!engineFailureHealthStatus(result)) return null;
514
- return recordEngineRunHealth(engineId, result, root);
515
- }
516
-
517
- // One run-record line per engine tick: how long the worker ran, and how it
518
- // ended. A tick that ran and passed (or had no verifier) landed; a failing
519
- // verifier or a real error failed; a time cap or dropped connection stalled;
520
- // a closed credit window is credit out. Never throws into the tick.
521
- function missionTickRunOutcome(result, verifierResult, maxSeconds) {
522
- if (result && result.status === 'ran') {
523
- const review = verifierResult?.referee;
524
- if (verifierResult?.passed === false && verifierResult.status === 0 && !verifierResult.timed_out
525
- && review?.mode === 'engine-unavailable' && review.reason !== 'code changed during review') {
526
- return { outcome: 'review unavailable', detail: review.timed_out
527
- ? 'the build check passed, but the second review timed out'
528
- : 'the build check passed, but the second review was unavailable' };
529
- }
530
- return verifierResult && verifierResult.passed === false
531
- ? { outcome: 'failed', detail: 'the verifier failed' }
532
- : { outcome: 'landed' };
533
- }
534
- if (engineFailureHealthStatus(result) === 'credit_out') return { outcome: 'credit out', detail: 'the engine hit its usage limit' };
535
- const stall = engineStallReason({ ...result, ...(maxSeconds ? { max_seconds: maxSeconds } : {}) });
536
- if (stall) return { outcome: 'stalled', detail: stall.text };
537
- if (/wall-exceeded/.test(String(result && result.reason || ''))) return { outcome: 'failed', detail: 'the mission ran out of time' };
538
- const summary = String(result && result.claude && result.claude.summary || '').split('\n')[0].trim();
539
- return { outcome: 'failed', detail: summary && summary !== 'error' ? summary : String(result && result.reason || 'the tick failed').replace(/-/g, ' ') };
540
- }
541
-
542
- function recordMissionTickRosterRun(root, { mission, runtimeMission, engineId, job, result, verifierResult, startedAt, endedMs, usage, prep = null } = {}) {
543
- if (!engineId) return null;
544
- try {
545
- const { engineRunsView } = require('../lib/roster-models');
546
- const { prepRecordFields } = require('../lib/roster-prep');
547
- const run = runtimeMission || mission || {};
548
- const startedMs = Date.parse(startedAt);
549
- const maxSeconds = Number(run.roster_max_seconds) || 0;
550
- return appendRosterRun(root, {
551
- at: startedAt,
552
- job: job || 'build',
553
- member: (mission && mission.owner) || '',
554
- engine: engineId,
555
- model: run.model || engineRunsView(engineId).model || '',
556
- effort: run.roster_effort || '',
557
- max_seconds: maxSeconds,
558
- seconds: Number.isFinite(startedMs) ? Math.max(0, (endedMs - startedMs) / 1000) : undefined,
559
- task: (mission && mission.id) || '',
560
- source: 'mission',
561
- ...prepRecordFields(prep),
562
- ...(usage || {}),
563
- ...missionTickRunOutcome(result, verifierResult, maxSeconds),
564
- });
565
- } catch {
566
- return null;
567
- }
568
- }
569
-
570
- function runnerModelPatch(runner, model) {
571
- const explicitModel = String(model || '').trim();
572
- if (explicitModel) return { model: explicitModel };
573
- if (String(runner || '').trim().toLowerCase() === 'atris2') return { model: 'atris:fast' };
574
- return {};
575
- }
576
-
577
- function missionRunRuntimeView(mission, runnerOverride = null, modelOverride = '') {
578
- if (!runnerOverride) return mission;
579
- const next = {
580
- ...mission,
581
- runner: runnerOverride.runner,
582
- runner_kind: runnerOverride.kind,
583
- run_runner_override: {
584
- requested: runnerOverride.requested,
585
- runner: runnerOverride.runner,
586
- stored_runner: mission.runner || null,
587
- stored_model: mission.model || null,
588
- },
589
- };
590
- delete next.model;
591
- Object.assign(next, runnerModelPatch(runnerOverride.runner, modelOverride));
592
- if (next.model) next.run_runner_override.model = next.model;
593
- return next;
594
- }
595
-
596
- function applyMissionRunnerProfile(runner) {
597
- const runnerName = String(runner || '').trim().toLowerCase();
598
- // `manual` is the legacy name for a directly-run Claude mission. Do not let
599
- // the host process's runner profile silently turn it into another engine.
600
- const engine = canonicalEngineName(runnerName) || (runnerName === 'manual' ? 'claude' : '');
601
- if (!engine) return () => {};
602
- const previous = process.env.ATRIS_RUNNER_PROFILE;
603
- process.env.ATRIS_RUNNER_PROFILE = engine;
604
- return () => {
605
- if (previous === undefined) delete process.env.ATRIS_RUNNER_PROFILE;
606
- else process.env.ATRIS_RUNNER_PROFILE = previous;
607
- };
608
- }
609
-
610
- // Claude-family engines share the claude CLI, so they keep the resolved
611
- // default model pin. Every other engine's CLI rejects a claude model name, so
612
- // it only ever takes an explicit pin (the mission's model or the roster
613
- // line's); '' lets the engine ride its own default.
614
- // A mission often still carries the claude model it was created with; an
615
- // engine that runs only its own models (codex, grok) drops it.
616
- const CLAUDE_FAMILY_RUNNER_ENGINES = new Set(['claude', 'fable', 'haiku']);
617
-
618
- function resolveMissionTickRunnerModel(mission) {
619
- const engine = canonicalEngineName(mission && mission.runner);
620
- if (!engine || CLAUDE_FAMILY_RUNNER_ENGINES.has(engine)) {
621
- return resolveClaudeRunnerModel(mission);
622
- }
623
- return runnerModelFor(engine, resolvePinnedRunnerModel(mission));
624
- }
625
-
626
- function exitMissionError(message, code = 1, asJson = false) {
627
- if (asJson) {
628
- printCliJson(
629
- {
630
- ok: false,
631
- error: message,
632
- reason: 'mission_error',
633
- detail: message,
634
- selected_ref: null,
635
- next_command: null,
636
- },
637
- compactErrorPayload({ reason: 'mission_error', detail: message }),
638
- process.argv.slice(2),
639
- );
640
- } else {
641
- console.error(message);
642
- }
643
- process.exit(code);
644
- }
645
-
646
- // Write-time warnings judge only what a machine can truly judge: identifiers,
647
- // flags, and task ids. Whether a why is present is a judgment call, the tick
648
- // prompt demands it, the review pass judges it. A warning that cries wolf on
649
- // plain sentences teaches agents to ignore it (golden-path papercut). The
650
- // strict operatorReady bar stays at the digest surface, where under-showing
651
- // is cheap. Boundary pinned by the marker-free fixture in mission-status tests.
652
- function warnIfSummaryNeedsOperatorWhy(summary) {
653
- const text = String(summary || '').trim();
654
- if (!text || !hasAgentJargon(text)) return null;
655
- 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.';
656
- console.error(warning);
657
- return warning;
658
- }
659
-
660
- function warnIfTaskTitleNeedsOperatorWhy(title) {
661
- const text = String(title || '').trim();
662
- if (!text || !hasAgentJargon(text)) return null;
663
- 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.';
664
- console.error(warning);
665
- return warning;
666
- }
667
-
668
- function readPositiveIntegerFlag(args, name, fallback = null, options = {}) {
669
- const raw = readFlag(args, name, '');
670
- if (!raw) return fallback;
671
- const value = Number(raw);
672
- if (!Number.isInteger(value) || value < 1) {
673
- exitMissionError(`${name} must be a positive integer`, 2, options.json);
674
- }
675
- return value;
676
- }
677
-
678
- function readNonNegativeIntegerFlag(args, name, fallback = null) {
679
- const raw = readFlag(args, name, '');
680
- if (!raw) return fallback;
681
- const value = Number(raw);
682
- if (!Number.isInteger(value) || value < 0) {
683
- throw new Error(`${name} must be a non-negative integer.`);
684
- }
685
- return value;
686
- }
687
-
688
- function readRepeatedFlag(args, name) {
689
- const values = [];
690
- const prefix = `${name}=`;
691
- for (let i = 0; i < args.length; i += 1) {
692
- const arg = String(args[i]);
693
- if (arg === name && args[i + 1] && !String(args[i + 1]).startsWith('--')) {
694
- values.push(readFlag([name, args[i + 1]], name, ''));
695
- i += 1;
696
- continue;
697
- }
698
- if (arg.startsWith(prefix)) values.push(readFlag([arg], name, ''));
699
- }
700
- return values.filter(Boolean);
701
- }
702
-
703
- function codexNativeGoalOptionsFromArgs(args) {
704
- const nativeGoalStatus = readFlag(args, '--native-goal-status', readFlag(args, '--visible-goal-status', ''));
705
- const nativeGoalObjective = readFlag(args, '--native-goal-objective', readFlag(args, '--visible-goal-objective', ''));
706
- return {
707
- ...(nativeGoalStatus ? { nativeGoalStatus } : {}),
708
- ...(nativeGoalObjective ? { nativeGoalObjective } : {}),
709
- ...(hasFlag(args, '--manual-ack') ? { manualAck: true } : {}),
710
- ...(hasFlag(args, '--allow-native-goal-supersede') || hasFlag(args, '--supersede-paused-native-goal') ? { allowNativeGoalSupersede: true } : {}),
711
- };
712
- }
713
-
714
- function lintMissionVerifier(command) {
715
- const text = String(command || '').trim();
716
- if (!text) return null;
717
- const compact = text.replace(/\s+/g, ' ');
718
- const staticNumericTest = /^test \d+ -(?:eq|ne|gt|ge|lt|le) \d+$/.test(compact)
719
- || /^\[ \d+ -(?:eq|ne|gt|ge|lt|le) \d+ \]$/.test(compact);
720
- if (!staticNumericTest) return null;
721
- return 'looks like shell substitution expanded before Atris received it; quote dynamic verifiers with single quotes';
722
- }
723
-
724
- function assertMissionVerifier(command, asJson = false) {
725
- const issue = lintMissionVerifier(command);
726
- if (!issue) return;
727
- exitMissionError(`Invalid --verify: ${issue}. Example: --verify 'test $(wc -l < atris/learnings.jsonl) -ge 478'`, 2, asJson);
728
- }
729
-
730
- function stripKnownFlags(args, valueNames, booleanNames = []) {
731
- const valueSet = new Set(valueNames);
732
- const booleanSet = new Set(['--full', '--verbose', ...booleanNames]);
733
- const out = [];
734
- for (let i = 0; i < args.length; i += 1) {
735
- const arg = String(args[i]);
736
- const key = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg;
737
- if (booleanSet.has(key)) continue;
738
- if (valueSet.has(key)) {
739
- if (!arg.includes('=') && args[i + 1] && !String(args[i + 1]).startsWith('--')) i += 1;
740
- continue;
741
- }
742
- out.push(args[i]);
743
- }
744
- return out;
745
- }
746
-
747
- function wantsJson(args) {
748
- return hasFlag(args, '--json');
749
- }
750
-
751
- function printJsonOrText(payload, lines, asJson) {
752
- if (asJson) {
753
- console.log(JSON.stringify(payload, null, 2));
754
- return;
755
- }
756
- for (const line of lines) console.log(line);
757
- }
758
-
759
- const MISSION_RUN_VALUE_FLAGS = [
760
- '--slots',
761
- '--pick',
762
- '--max-ticks',
763
- '--max-idle-ticks',
764
- '--max-wall',
765
- '--minutes',
766
- '--hours',
767
- '--cadence',
768
- '--owner',
769
- '--runner',
770
- '--lane',
771
- '--verify',
772
- '--stop',
773
- '--model',
774
- '--engine',
775
- '--referee-engine',
776
- '--budget',
777
- '--campaign',
778
- '--reserve',
779
- '--repo',
780
- '--native-goal-status',
781
- '--native-goal-objective',
782
- '--visible-goal-status',
783
- '--visible-goal-objective',
784
- ];
785
- const MISSION_RUN_BOOLEAN_FLAGS = [
786
- '--json',
787
- '--full',
788
- '--verbose',
789
- '--due',
790
- '--detach',
791
- '--headless',
792
- '--self-drive',
793
- '--fleet',
794
- '--dry-run',
795
- '--land',
796
- '--no-claude',
797
- '--no-verify',
798
- '--referee',
799
- '--referee-if-check-edited',
800
- '--no-referee',
801
- '--complete-on-pass',
802
- '--no-drain',
803
- '--create-next',
804
- '--spend-full-budget',
805
- '--use-whole-budget',
806
- '--stop-when-done',
807
- '--preflight',
808
- '--no-preflight',
809
- '--room-preflight',
810
- '--no-room-preflight',
811
- '--room-auto-run',
812
- '--no-room-auto-run',
813
- '--manual-ack',
814
- '--allow-native-goal-supersede',
815
- '--supersede-paused-native-goal',
816
- '--take-goal-slot',
817
- '--always-on',
818
- '--xp-task',
819
- '--agent-xp',
820
- '--cloud',
821
- ];
822
- const DEFAULT_MISSION_RUN_OWNER_SLUGS = new Set(
823
- FUNCTIONAL_MEMBER_TOPICS.map(topic => normalizeOwnerSlug(topic.owner)),
824
- );
825
-
826
- function missionRunInputRequired(asJson = false, owner = '') {
827
- const defaultOwner = normalizeOwnerSlug(owner || process.env.ATRIS_AGENT_ID || 'mission-lead') || 'mission-lead';
828
- const error = 'mission run needs an interactive terminal; use atris mission run --due --headless or atris mission run <explicit-mission-id>.';
829
- const payload = {
830
- ok: false,
831
- action: 'mission_input_required',
832
- error,
833
- prompt: 'What mission should Atris run?',
834
- owner: defaultOwner,
835
- owner_prompt: 'Which team member should own it?',
836
- example: `atris mission run "make onboarding magical" --owner ${defaultOwner}`,
837
- };
838
- if (asJson) {
839
- console.log(JSON.stringify(payload));
840
- } else {
841
- console.error(error);
842
- }
843
- process.exit(1);
844
- }
845
-
846
- function askLine(rl, question) {
847
- return new Promise((resolve) => rl.question(question, (answer) => resolve(String(answer || '').trim())));
848
- }
849
-
850
- function removeValueFlag(args, name) {
851
- const out = [];
852
- const prefix = `${name}=`;
853
- for (let i = 0; i < args.length; i += 1) {
854
- const arg = String(args[i]);
855
- if (arg === name) {
856
- if (args[i + 1] && !String(args[i + 1]).startsWith('--')) i += 1;
857
- continue;
858
- }
859
- if (arg.startsWith(prefix)) continue;
860
- out.push(args[i]);
861
- }
862
- return out;
863
- }
864
-
865
- function missionRunOwnerRef(ref, root = process.cwd()) {
866
- const owner = normalizeOwnerSlug(ref);
867
- if (!owner || /\s/.test(String(ref || ''))) return null;
868
- if (listWorkspaceMemberSlugs(root).has(owner)) return owner;
869
- if (DEFAULT_MISSION_RUN_OWNER_SLUGS.has(owner)) return owner;
870
- return null;
871
- }
872
-
873
- function missionRunArgsWithOwner(args, owner) {
874
- return [...removeValueFlag(args, '--owner'), '--owner', owner];
875
- }
876
-
877
- function missionRunInputFromArgs(args, root = process.cwd()) {
878
- const positionals = stripKnownFlags(args, MISSION_RUN_VALUE_FLAGS, MISSION_RUN_BOOLEAN_FLAGS);
879
- const explicitOwner = Boolean(readFlag(args, '--owner', ''));
880
- if (!explicitOwner && positionals.length > 0) {
881
- const owner = missionRunOwnerRef(positionals[0], root);
882
- if (owner) {
883
- return {
884
- ref: positionals.slice(1).join(' ').trim(),
885
- args: missionRunArgsWithOwner(args, owner),
886
- owner,
887
- };
888
- }
889
- }
890
- return {
891
- ref: positionals.join(' ').trim(),
892
- args,
893
- owner: readFlag(args, '--owner', ''),
894
- };
895
- }
896
-
897
- async function promptMissionRunInput(args) {
898
- if (!process.stdin.isTTY) missionRunInputRequired(wantsJson(args), readFlag(args, '--owner', ''));
899
- const defaultOwner = readFlag(args, '--owner', process.env.ATRIS_AGENT_ID || 'mission-lead');
900
- const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
901
- try {
902
- const objective = await askLine(rl, 'Mission: ');
903
- if (!objective) missionRunInputRequired(false);
904
- const owner = await askLine(rl, `Team member [${defaultOwner}]: `);
905
- return {
906
- objective,
907
- args: [...removeValueFlag(args, '--owner'), '--owner', owner || defaultOwner],
908
- };
909
- } finally {
910
- rl.close();
911
- }
912
- }
913
-
914
- function loadTaskDb(asJson = false) {
915
- try {
916
- return require('../lib/task-db');
917
- } catch (error) {
918
- const message = error && error.message ? error.message : String(error);
919
- if (error?.code === 'ERR_UNKNOWN_BUILTIN_MODULE' || /node:sqlite/.test(message)) {
920
- exitMissionError('AgentXP mission tasks require Node 22+ with node:sqlite.', 2, asJson);
921
- }
922
- throw error;
923
- }
924
- }
925
-
926
- function writeMissionTaskProjection(taskDb, db, workspaceRoot) {
927
- const projection = taskDb.taskProjection(db, { workspaceRoot, limit: 500 });
928
- const outPath = path.join(workspaceRoot, '.atris', 'state', 'tasks.projection.json');
929
- writeJson(outPath, projection);
930
- return { projection, outPath };
931
- }
932
-
933
- function missionTaskRef(task) {
934
- return task?.display_id || task?.legacy_ref || String(task?.id || '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase().slice(0, 8);
935
- }
936
-
937
- function missionXpTaskTitle(objective) {
938
- const prefix = 'Mission XP: ';
939
- const normalized = String(objective || '').replace(/\s+/g, ' ').trim();
940
- const boundary = normalized.search(/:|[.!?](?=\s|$)/);
941
- const firstClause = (boundary > 0 ? normalized.slice(0, boundary) : normalized) || 'mission work';
942
- const maxClauseLength = 64 - prefix.length;
943
- if (firstClause.length <= maxClauseLength) return `${prefix}${firstClause}`;
944
- const clipped = firstClause.slice(0, maxClauseLength + 1);
945
- const wordBoundary = clipped.lastIndexOf(' ');
946
- const shortClause = clipped.slice(0, wordBoundary > 0 ? wordBoundary : maxClauseLength).trim();
947
- return `${prefix}${shortClause || 'mission work'}`;
948
- }
949
-
950
- function normalizeMissionTaskMatchText(text) {
951
- return String(text || '')
952
- .replace(/^Mission XP:\s*/i, '')
953
- .toLowerCase()
954
- .replace(/\s+/g, ' ')
955
- .trim();
956
- }
957
-
958
- function missionObjectiveTaskMatch(objective, title) {
959
- const want = normalizeMissionTaskMatchText(objective);
960
- const have = normalizeMissionTaskMatchText(title);
961
- if (!want || !have) return false;
962
- if (want === have) return true;
963
- // First clause of a long objective often matches the claimed task title.
964
- const wantClause = want.split(/:|[.!?](?=\s|$)/)[0].trim();
965
- const haveClause = have.split(/:|[.!?](?=\s|$)/)[0].trim();
966
- if (wantClause && haveClause && (wantClause === haveClause || want.startsWith(haveClause) || have.startsWith(wantClause))) {
967
- return true;
968
- }
969
- return false;
970
- }
971
-
972
- function taskRowToMissionXpBinding(task, ownerResolution, outPath, { inserted = false, reused = true } = {}) {
973
- return {
974
- task_id: task.id,
975
- ref: missionTaskRef(task) || task.id,
976
- title: task.title,
977
- operator_title_warning: warnIfTaskTitleNeedsOperatorWhy(task.title),
978
- status: task.status || 'claimed',
979
- assigned_to: task.claimed_by || ownerResolution.owner,
980
- owner_resolution: ownerResolution.reason,
981
- executed_by: ownerResolution.executed_by ? normalizeOwnerSlug(ownerResolution.executed_by) : null,
982
- inserted,
983
- reused,
984
- projection_path: outPath,
985
- };
986
- }
987
-
988
- function findOpenTaskMatchingMissionObjective(mission, root = process.cwd(), asJson = false) {
989
- const taskDb = loadTaskDb(asJson);
990
- const db = taskDb.open();
991
- const workspaceRoot = taskDb.workspaceRoot(root);
992
- const rows = taskDb.withTaskDisplayRefs(taskDb.listTasks(db, { workspaceRoot }));
993
- const open = rows.filter((row) => row && ['open', 'claimed', 'review'].includes(row.status));
994
- const explicitIds = Array.isArray(mission.task_ids) ? mission.task_ids.filter(Boolean) : [];
995
- for (const id of explicitIds) {
996
- const hit = open.find((row) => row.id === id || row.display_id === id || missionTaskRef(row) === id);
997
- if (hit) return hit;
998
- }
999
- return open.find((row) => missionObjectiveTaskMatch(mission.objective, row.title)) || null;
1000
- }
1001
-
1002
253
  function bindExistingMissionTask(mission, task, root = process.cwd(), asJson = false) {
1003
254
  const taskDb = loadTaskDb(asJson);
1004
255
  const db = taskDb.open();
@@ -1072,80 +323,10 @@ function createMissionXpTask(mission, root = process.cwd(), asJson = false) {
1072
323
  };
1073
324
  }
1074
325
 
1075
- function statePaths(root = process.cwd()) {
1076
- const stateDir = path.join(root, '.atris', 'state');
1077
- return {
1078
- stateDir,
1079
- missionsJsonl: path.join(stateDir, 'missions.jsonl'),
1080
- eventsJsonl: path.join(stateDir, 'mission_events.jsonl'),
1081
- codexGoalJson: path.join(stateDir, 'codex_goal.json'),
1082
- codexGoalRequestJson: path.join(stateDir, 'codex_goal_request.json'),
1083
- codexGoalStatus: path.join(root, 'atris', 'status', 'codex-goal.md'),
1084
- atrisGoalJson: path.join(stateDir, 'atris_goal.json'),
1085
- atrisGoalStatus: path.join(root, 'atris', 'status', 'atris-goal.md'),
1086
- statusNow: path.join(root, 'atris', 'status', 'now.md'),
1087
- statusForYou: path.join(root, 'atris', 'status', 'for-you.md'),
1088
- runsDir: path.join(root, 'atris', 'runs'),
1089
- };
1090
- }
1091
-
1092
- function readBusinessBinding(root = process.cwd()) {
1093
- const file = path.join(root, '.atris', 'business.json');
1094
- const parsed = readJson(file);
1095
- if (parsed == null) return null;
1096
- return {
1097
- business_id: parsed.business_id || '',
1098
- workspace_id: parsed.workspace_id || '',
1099
- slug: parsed.slug || '',
1100
- };
1101
- }
1102
-
1103
- function businessIdForAtris2Mission(mission, cwd = process.cwd()) {
1104
- return mission?.business_id || readBusinessBinding(cwd)?.business_id || null;
1105
- }
1106
-
1107
- function readJsonLines(file) {
1108
- if (!fs.existsSync(file)) return [];
1109
- return fs.readFileSync(file, 'utf8')
1110
- .split(/\r?\n/)
1111
- .map((line) => line.trim())
1112
- .filter(Boolean)
1113
- .map((line) => {
1114
- try {
1115
- return JSON.parse(line);
1116
- } catch {
1117
- return null;
1118
- }
1119
- })
1120
- .filter(Boolean);
1121
- }
1122
-
1123
326
  function nextMissionNumber(root = process.cwd()) {
1124
327
  return nextRecordNumber(canonicalMissionIndex(root));
1125
328
  }
1126
329
 
1127
- function missionLabel(mission) {
1128
- const displayHandle = String(mission && mission.display_handle || '').trim();
1129
- if (displayHandle) {
1130
- const words = meaningfulWords(mission && (mission.objective || mission.slug || mission.id), 3);
1131
- return `${displayHandle}${words.length ? ` ${words.join(' ')}` : ''}`;
1132
- }
1133
- return shortRecordLabel(mission, mission && (mission.objective || mission.slug || mission.id));
1134
- }
1135
-
1136
- function missionRef(mission) {
1137
- const displayHandle = String(mission && mission.display_handle || '').trim();
1138
- if (displayHandle) return displayHandle;
1139
- return shortRecordRef(mission);
1140
- }
1141
-
1142
- function missionDisplayText(mission, value) {
1143
- const text = String(value || '');
1144
- const id = String(mission && mission.id || '').trim();
1145
- if (!id) return text;
1146
- return text.split(id).join(missionRef(mission));
1147
- }
1148
-
1149
330
  function assignMissionNumber(mission, root = process.cwd()) {
1150
331
  if (!mission) return mission;
1151
332
  const existing = mission.id ? loadMissionMap(root).get(mission.id) : null;
@@ -1417,107 +598,6 @@ function canonicalMissionIndex(root = process.cwd()) {
1417
598
  return missions;
1418
599
  }
1419
600
 
1420
- function missionNumberWinner(records = []) {
1421
- return records.reduce((winner, candidate) => {
1422
- if (!winner) return candidate;
1423
- const winnerTerminal = TERMINAL_STATUSES.has(winner.status);
1424
- const candidateTerminal = TERMINAL_STATUSES.has(candidate.status);
1425
- if (winnerTerminal !== candidateTerminal) return candidateTerminal ? winner : candidate;
1426
- const winnerUpdated = String(winner.updated_at || winner.created_at || '');
1427
- const candidateUpdated = String(candidate.updated_at || candidate.created_at || '');
1428
- if (candidateUpdated !== winnerUpdated) return candidateUpdated > winnerUpdated ? candidate : winner;
1429
- return String(candidate.id || '').localeCompare(String(winner.id || '')) < 0 ? candidate : winner;
1430
- }, null);
1431
- }
1432
-
1433
- function uniqueMissionSuffix(mission, missions) {
1434
- const id = String(mission && mission.id || '').trim();
1435
- if (!id) return 'unknown';
1436
- const minimum = Math.min(MISSION_REF_SUFFIX_MIN_LENGTH, id.length);
1437
- for (let length = Math.min(8, id.length); length <= id.length; length += 1) {
1438
- const suffix = id.slice(-Math.max(length, minimum));
1439
- if (missions.filter((candidate) => String(candidate.id || '').endsWith(suffix)).length === 1) return suffix;
1440
- }
1441
- return id;
1442
- }
1443
-
1444
- function missionNumberDisplayIndex(missions, canonicalMissions = missions) {
1445
- const recordsByNumber = new Map();
1446
- for (const mission of canonicalMissions) {
1447
- const number = displayNumber(mission.n);
1448
- if (!number) continue;
1449
- const records = recordsByNumber.get(number) || [];
1450
- records.push(mission);
1451
- recordsByNumber.set(number, records);
1452
- }
1453
- const winnerIds = new Map();
1454
- for (const [number, records] of recordsByNumber) {
1455
- if (records.length > 1) winnerIds.set(number, missionNumberWinner(records)?.id);
1456
- }
1457
- return missions.map((mission) => {
1458
- const number = displayNumber(mission.n);
1459
- if (!number || !winnerIds.has(number) || winnerIds.get(number) === mission.id) return mission;
1460
- return { ...mission, display_handle: uniqueMissionSuffix(mission, canonicalMissions) };
1461
- });
1462
- }
1463
-
1464
- const MISSION_REF_SUFFIX_MIN_LENGTH = 6;
1465
-
1466
- // A ref that "looks like" an id/suffix/number is a single token with no
1467
- // whitespace matching a hex/id/numeric shape. Those must resolve or error,
1468
- // never silently fall through to starting a new mission (a mistyped id/n
1469
- // creating a junk mission is the worst failure mode here).
1470
- function missionRefLooksLikeHandle(ref) {
1471
- const raw = String(ref || '').trim();
1472
- if (!raw || /\s/.test(raw)) return false;
1473
- const withoutHash = raw.startsWith('#') ? raw.slice(1) : raw;
1474
- if (/^\d+$/.test(withoutHash)) return true;
1475
- if (/^mission-/.test(raw)) return true;
1476
- if (/^[0-9a-f]{6,}$/i.test(withoutHash)) return true;
1477
- return false;
1478
- }
1479
-
1480
- function missionMatchesRef(mission, ref) {
1481
- return mission && (recordMatchesRef(mission, ref) || mission.slug === String(ref || '').trim());
1482
- }
1483
-
1484
- // Suffix/prefix id matches and slug-prefix matches, gated to unique hits of
1485
- // at least MISSION_REF_SUFFIX_MIN_LENGTH chars so a short fragment can't
1486
- // silently grab the wrong mission.
1487
- function missionsMatchingHandleFragment(missions, ref) {
1488
- const raw = String(ref || '').trim();
1489
- if (!raw || raw.length < MISSION_REF_SUFFIX_MIN_LENGTH) return [];
1490
- const lower = raw.toLowerCase();
1491
- const idHits = missions.filter((mission) => {
1492
- const id = String(mission.id || '').toLowerCase();
1493
- return id.startsWith(lower) || id.endsWith(lower);
1494
- });
1495
- if (idHits.length) return idHits;
1496
- return missions.filter((mission) => String(mission.slug || '').toLowerCase().startsWith(lower));
1497
- }
1498
-
1499
- function nearestMissionCandidates(missions, ref, limit = 5) {
1500
- const raw = String(ref || '').trim().toLowerCase();
1501
- return missions
1502
- .map((mission) => {
1503
- const id = String(mission.id || '').toLowerCase();
1504
- const slug = String(mission.slug || '').toLowerCase();
1505
- let score = 0;
1506
- if (raw && (id.includes(raw) || slug.includes(raw))) score = 2;
1507
- else if (raw && raw.length >= 3 && (id.slice(-6).includes(raw.slice(0, 3)) || slug.includes(raw.slice(0, 3)))) score = 1;
1508
- return { mission, score };
1509
- })
1510
- .filter((entry) => entry.score > 0)
1511
- .sort((a, b) => b.score - a.score)
1512
- .slice(0, limit)
1513
- .map((entry) => entry.mission);
1514
- }
1515
-
1516
- // Sibling folders looked at for a hint. A missing-id error waited 26 seconds
1517
- // on a temp folder holding 276,000 entries, so the scan is capped and never
1518
- // walks the system temp folder.
1519
- const SIBLING_HINT_SCAN_LIMIT = 2000;
1520
-
1521
601
  function listSiblingWorkspaceMissionHints(ref, root = process.cwd(), limit = 5, { scanLimit = SIBLING_HINT_SCAN_LIMIT, tmpRoot = os.tmpdir() } = {}) {
1522
602
  if (!ref) return [];
1523
603
  const baseRoot = path.resolve(root);
@@ -1663,58 +743,6 @@ function resolveMission(ref, root = process.cwd()) {
1663
743
  return null;
1664
744
  }
1665
745
 
1666
- function missionMatchesStatusFilter(mission, statusFilter) {
1667
- if (statusFilter === 'active') return !TERMINAL_STATUSES.has(mission.status);
1668
- return mission.status === statusFilter;
1669
- }
1670
-
1671
- function appendJsonLine(file, payload) {
1672
- fs.mkdirSync(path.dirname(file), { recursive: true });
1673
- fs.appendFileSync(file, JSON.stringify(payload) + '\n', 'utf8');
1674
- }
1675
-
1676
- function resolveMemberWorkspaceRoot(root = process.cwd()) {
1677
- const resolved = path.resolve(root || process.cwd());
1678
- const agentWorktreesMarker = `${path.sep}.agent-worktrees${path.sep}`;
1679
- const agentIdx = resolved.indexOf(agentWorktreesMarker);
1680
- if (agentIdx > 0) {
1681
- const parentDir = resolved.slice(0, agentIdx);
1682
- const after = resolved.slice(agentIdx + agentWorktreesMarker.length);
1683
- const repoName = after.split(path.sep)[0];
1684
- if (repoName) {
1685
- const primaryRoot = path.join(parentDir, repoName);
1686
- if (fs.existsSync(path.join(primaryRoot, 'atris'))) return primaryRoot;
1687
- }
1688
- }
1689
- const marker = `${path.sep}.atris${path.sep}worktrees${path.sep}`;
1690
- const idx = resolved.indexOf(marker);
1691
- if (idx > 0) {
1692
- const parentRoot = resolved.slice(0, idx);
1693
- if (fs.existsSync(path.join(parentRoot, 'atris'))) return parentRoot;
1694
- }
1695
- return resolved;
1696
- }
1697
-
1698
- function appendEvent(type, mission, payload = {}, root = process.cwd()) {
1699
- const paths = statePaths(root);
1700
- const event = {
1701
- schema: 'atris.mission_event.v1',
1702
- type,
1703
- mission_id: mission.id,
1704
- at: stampIso(),
1705
- actor: process.env.ATRIS_AGENT_ID || process.env.USER || null,
1706
- payload,
1707
- };
1708
- appendJsonLine(paths.eventsJsonl, event);
1709
- const parentRoot = resolveMemberWorkspaceRoot(root);
1710
- if (type === 'mission_tick' && parentRoot !== path.resolve(root || process.cwd())) {
1711
- try {
1712
- appendJsonLine(statePaths(parentRoot).eventsJsonl, event);
1713
- } catch {}
1714
- }
1715
- return event;
1716
- }
1717
-
1718
746
  function saveMission(mission, root = process.cwd(), eventType = 'mission_updated', payload = {}) {
1719
747
  const paths = statePaths(root);
1720
748
  const existing = mission && mission.id ? loadMissionMap(root).get(mission.id) : null;
@@ -1741,323 +769,6 @@ function saveMission(mission, root = process.cwd(), eventType = 'mission_updated
1741
769
  return { mission: next, event };
1742
770
  }
1743
771
 
1744
- function memberDir(owner, root = process.cwd()) {
1745
- if (!owner || !/^[a-zA-Z0-9._-]+$/.test(owner)) return null;
1746
- const parentRoot = resolveMemberWorkspaceRoot(root);
1747
- const candidates = parentRoot !== path.resolve(root || process.cwd())
1748
- ? [parentRoot, root]
1749
- : [root];
1750
- for (const base of candidates) {
1751
- const dir = path.join(base, 'atris', 'team', owner);
1752
- if (fs.existsSync(path.join(dir, 'MEMBER.md'))) return dir;
1753
- }
1754
- return null;
1755
- }
1756
-
1757
- function appendMemberLog(owner, title, fields = {}, root = process.cwd()) {
1758
- const dir = memberDir(owner, root);
1759
- if (!dir) return null;
1760
- const logsDir = path.join(dir, 'logs');
1761
- fs.mkdirSync(logsDir, { recursive: true });
1762
- const logPath = path.join(logsDir, `${todayName()}.md`);
1763
- const stamp = new Date().toTimeString().slice(0, 5);
1764
- const rows = [
1765
- `## ${stamp} · ${title}`,
1766
- `- member: ${owner}`,
1767
- ...Object.entries(fields)
1768
- .filter(([, value]) => value !== undefined && value !== null && value !== '')
1769
- .map(([key, value]) => `- ${key}: ${String(value).replace(/\n/g, ' ')}`),
1770
- '',
1771
- ];
1772
- let lead = '';
1773
- try {
1774
- const size = fs.statSync(logPath).size;
1775
- if (size > 0) {
1776
- const fd = fs.openSync(logPath, 'r');
1777
- const last = Buffer.alloc(1);
1778
- fs.readSync(fd, last, 0, 1, size - 1);
1779
- fs.closeSync(fd);
1780
- if (last[0] !== 0x0a) lead = '\n';
1781
- }
1782
- } catch {}
1783
- fs.appendFileSync(logPath, lead + rows.join('\n'), 'utf8');
1784
- return logPath;
1785
- }
1786
-
1787
- // Shorten at a word boundary so a lesson never ends mid-word.
1788
- function clipWords(text, max) {
1789
- if (text.length <= max) return text;
1790
- const cut = text.slice(0, max + 1);
1791
- const space = cut.lastIndexOf(' ');
1792
- return `${(space > max * 0.6 ? cut.slice(0, space) : text.slice(0, max)).replace(/[\s,;:.-]+$/, '')}...`;
1793
- }
1794
-
1795
- function crystallizeMemberSkillLesson(owner, entry = {}, root = process.cwd()) {
1796
- const dir = memberDir(owner, root);
1797
- if (!dir) return null;
1798
- const summary = String(entry.summary || entry.objective || '').replace(/\s+/g, ' ').trim();
1799
- if (!summary) return null;
1800
- const skillDir = path.join(dir, 'skills', 'auto-learned');
1801
- fs.mkdirSync(skillDir, { recursive: true });
1802
- const skillPath = path.join(skillDir, 'SKILL.md');
1803
- let lines = null;
1804
- if (fs.existsSync(skillPath)) {
1805
- try {
1806
- lines = fs.readFileSync(skillPath, 'utf8').split(/\r?\n/);
1807
- } catch {}
1808
- }
1809
- const bulletAt = (lines || []).map((line, i) => (line.trim().startsWith('- **') ? i : -1)).filter((i) => i >= 0);
1810
- const existingBullets = bulletAt.map((i) => lines[i].trim());
1811
- const verifierText = String(entry.verifier || '').replace(/\s+/g, ' ').trim();
1812
- const nextPushText = String(entry.next_push || '').replace(/\s+/g, ' ').trim();
1813
- const layerLabel = String(entry.layer || 'capabilities').trim();
1814
- const parts = [`- **${todayName()} (${layerLabel})**: ${clipWords(summary, 240)}`];
1815
- // A cut-off command cannot be run, so a long one is left out, never clipped.
1816
- if (verifierText && verifierText.length <= 300) parts.push(`Verify: \`${verifierText}\``);
1817
- if (nextPushText) parts.push(`Next push: ${clipWords(nextPushText, 200)}`);
1818
- const newBullet = parts.join(' -> ');
1819
- const deduped = [
1820
- newBullet,
1821
- ...existingBullets.filter((b) => !b.includes(summary.slice(0, 80))),
1822
- ].slice(0, 5);
1823
-
1824
- let content;
1825
- if (bulletAt.length) {
1826
- // Keep whatever a person or reviewer wrote around the lessons; only the
1827
- // lesson list itself is rewritten.
1828
- const first = bulletAt[0];
1829
- const last = bulletAt[bulletAt.length - 1];
1830
- content = [...lines.slice(0, first), ...deduped, ...lines.slice(last + 1)].join('\n');
1831
- } else {
1832
- content = [
1833
- '---',
1834
- 'name: auto-learned',
1835
- `description: Verified patterns crystallized from passing runs for ${owner}. Bounded to 5 newest patterns so context never bloats.`,
1836
- '---',
1837
- '',
1838
- '# Auto-Learned Verified Patterns',
1839
- '',
1840
- ...deduped,
1841
- '',
1842
- ].join('\n');
1843
- }
1844
- fs.writeFileSync(skillPath, content, 'utf8');
1845
- return skillPath;
1846
- }
1847
-
1848
- function autoQueueMemberNextPush(owner, nextPush, parentObjective = '', root = process.cwd(), { promote = false } = {}) {
1849
- const cleanPush = String(nextPush || '').replace(/\s+/g, ' ').trim();
1850
- if (!cleanPush) return null;
1851
- const dir = memberDir(owner, root);
1852
- if (!dir) return null;
1853
- const goalsPath = path.join(dir, 'goals.json');
1854
- let store = { schema_version: 1, member: owner, updated_at: stampIso(), goals: [] };
1855
- if (fs.existsSync(goalsPath)) {
1856
- try {
1857
- const parsed = JSON.parse(fs.readFileSync(goalsPath, 'utf8'));
1858
- if (parsed && Array.isArray(parsed.goals)) store = parsed;
1859
- } catch {}
1860
- }
1861
- const now = stampIso();
1862
- const activeIdx = store.goals.findIndex((g) => g && g.status === 'active');
1863
- if (activeIdx >= 0) {
1864
- const prev = store.goals[activeIdx];
1865
- store.goals[activeIdx] = {
1866
- ...prev,
1867
- ...(promote ? { previous_title: prev.title, title: cleanPush } : {}),
1868
- next_push: cleanPush,
1869
- updated_at: now,
1870
- };
1871
- } else {
1872
- store.goals.push({
1873
- id: `goal_${Date.now().toString(36)}`,
1874
- member: owner,
1875
- title: cleanPush,
1876
- why: parentObjective ? `Compounds after verified win on: ${parentObjective}` : 'Self-proposed next optimization from verified run.',
1877
- metric: 'verified_slices_shipped',
1878
- target: '1',
1879
- horizon: '7d',
1880
- acceptance: ['Passes test check and cross-model referee'],
1881
- next_push: cleanPush,
1882
- status: 'active',
1883
- created_at: now,
1884
- updated_at: now,
1885
- });
1886
- }
1887
- store.updated_at = now;
1888
- fs.writeFileSync(goalsPath, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
1889
- return goalsPath;
1890
- }
1891
-
1892
- const DEFAULT_MEMBER_BATON_RELAY = {
1893
- researcher: 'navigator',
1894
- 'customer-lead': 'navigator',
1895
- navigator: 'executor',
1896
- architect: 'executor',
1897
- executor: 'validator',
1898
- 'auto-improver': 'validator',
1899
- };
1900
-
1901
- function recordMemberHandoff(fromMember, toMember, sliceSummary, root = process.cwd(), extra = {}) {
1902
- const from = String(fromMember || '').trim().toLowerCase();
1903
- const to = String(toMember || DEFAULT_MEMBER_BATON_RELAY[from] || '').trim().toLowerCase();
1904
- const cleanSummary = String(sliceSummary || '').replace(/\s+/g, ' ').trim();
1905
- if (!from || !to || !cleanSummary) return null;
1906
- const parentRoot = resolveMemberWorkspaceRoot(root);
1907
- if (!memberDir(to, parentRoot)) return null;
1908
- const now = stampIso();
1909
- const handoff = {
1910
- id: `handoff_${Date.now().toString(36)}`,
1911
- from,
1912
- to,
1913
- summary: cleanSummary.slice(0, 180),
1914
- status: extra.status || 'open',
1915
- next_command: `atris member go ${to}`,
1916
- created_at: now,
1917
- };
1918
- const handoffsFile = path.join(parentRoot, '.atris', 'state', 'member-handoffs.jsonl');
1919
- try {
1920
- withHandoffsLock(handoffsFile, () => fs.appendFileSync(handoffsFile, `${JSON.stringify(handoff)}\n`, 'utf8'));
1921
- } catch {
1922
- return null;
1923
- }
1924
- try {
1925
- autoQueueMemberNextPush(to, `${from} handoff: ${cleanSummary}`, `Baton pass from ${from}`, parentRoot);
1926
- } catch {}
1927
- return handoff;
1928
- }
1929
-
1930
- // Every write to member-handoffs.jsonl (append or whole-file rewrite) holds
1931
- // one lock file beside it, so concurrent handoffs never lose a row.
1932
- // (Codex review round 3 on #1483.)
1933
- function withHandoffsLock(handoffsFile, fn) {
1934
- fs.mkdirSync(path.dirname(handoffsFile), { recursive: true });
1935
- const lockFile = path.join(path.dirname(handoffsFile), 'member-handoffs.lock');
1936
- return require('../lib/handoff-wake').withFileLock(lockFile, fn, { staleMs: 60 * 1000, waitMs: 30 * 1000 });
1937
- }
1938
-
1939
- function rewriteHandoffs(handoffsFile, lines) {
1940
- const tmp = `${handoffsFile}.${process.pid}.tmp`;
1941
- fs.writeFileSync(tmp, `${lines.join('\n')}\n`, 'utf8');
1942
- fs.renameSync(tmp, handoffsFile);
1943
- }
1944
-
1945
- // Merge `patch` into the newest handoff row with this id. Returns the
1946
- // updated row, or null when it is not on file.
1947
- function updateMemberHandoff(handoffId, patch, root = process.cwd()) {
1948
- const id = String(handoffId || '');
1949
- if (!id || !patch) return null;
1950
- const handoffsFile = path.join(resolveMemberWorkspaceRoot(root), '.atris', 'state', 'member-handoffs.jsonl');
1951
- try {
1952
- return withHandoffsLock(handoffsFile, () => {
1953
- const lines = fs.readFileSync(handoffsFile, 'utf8').split(/\r?\n/).filter((l) => l.trim());
1954
- for (let i = lines.length - 1; i >= 0; i -= 1) {
1955
- let row = null;
1956
- try { row = JSON.parse(lines[i]); } catch {}
1957
- if (!row || row.id !== id) continue;
1958
- const updated = { ...row, ...patch };
1959
- lines[i] = JSON.stringify(updated);
1960
- rewriteHandoffs(handoffsFile, lines);
1961
- return updated;
1962
- }
1963
- return null;
1964
- });
1965
- } catch {
1966
- return null;
1967
- }
1968
- }
1969
-
1970
- function closeMemberHandoffsForReceiver(receiverMember, root = process.cwd()) {
1971
- const receiver = String(receiverMember || '').trim().toLowerCase();
1972
- if (!receiver) return 0;
1973
- const parentRoot = resolveMemberWorkspaceRoot(root);
1974
- const handoffsFile = path.join(parentRoot, '.atris', 'state', 'member-handoffs.jsonl');
1975
- if (!fs.existsSync(handoffsFile)) return 0;
1976
- try {
1977
- return withHandoffsLock(handoffsFile, () => closeHandoffRows(handoffsFile, receiver));
1978
- } catch {
1979
- return 0;
1980
- }
1981
- }
1982
-
1983
- function closeHandoffRows(handoffsFile, receiver) {
1984
- const rawLines = fs.readFileSync(handoffsFile, 'utf8').split(/\r?\n/).filter((l) => l.trim());
1985
- let closedCount = 0;
1986
- const now = stampIso();
1987
- const updatedLines = rawLines.map((line) => {
1988
- try {
1989
- const row = JSON.parse(line);
1990
- if (row && String(row.to || '').toLowerCase() === receiver && (row.status || 'open') === 'open') {
1991
- closedCount++;
1992
- return JSON.stringify({ ...row, status: 'closed', closed_at: now });
1993
- }
1994
- } catch {}
1995
- return line;
1996
- });
1997
- if (closedCount > 0) rewriteHandoffs(handoffsFile, updatedLines);
1998
- return closedCount;
1999
- }
2000
-
2001
- function memberMissionFile(owner, root = process.cwd()) {
2002
- const dir = memberDir(owner, root);
2003
- if (!dir) return null;
2004
- return path.join(dir, 'MISSION.md');
2005
- }
2006
-
2007
- function missingOwnerMemberWarning(owner, root = process.cwd()) {
2008
- if (!owner || fs.existsSync(path.join(root, 'atris', 'team', owner))) return null;
2009
- const teamDir = path.join(root, 'atris', 'team');
2010
- const hasKnownMembers = fs.existsSync(teamDir)
2011
- && fs.readdirSync(teamDir, { withFileTypes: true }).some((entry) => (
2012
- entry.isDirectory() && fs.existsSync(path.join(teamDir, entry.name, 'MEMBER.md'))
2013
- ));
2014
- if (!hasKnownMembers) return null;
2015
- return {
2016
- code: 'missing_owner_member',
2017
- message: `owner "${owner}" has no atris/team/${owner}/ member. Create it (atris member create ${owner}) or pick an existing member (ls atris/team/).`,
2018
- };
2019
- }
2020
-
2021
- function ensureMemberMissionFile(owner, root = process.cwd(), objective = '') {
2022
- const missionPath = memberMissionFile(owner, root);
2023
- if (!missionPath || fs.existsSync(missionPath)) return missionPath;
2024
- const purpose = String(objective || '').trim() || 'Define why this member exists and how it chooses goals.';
2025
- const content = [
2026
- '# Mission',
2027
- '',
2028
- '<!-- Human-authored purpose file. Keep this durable; runtime state belongs in .atris/state/*.jsonl and now.md. -->',
2029
- '',
2030
- '## North Star',
2031
- '',
2032
- purpose,
2033
- '',
2034
- '## How To Choose Goals',
2035
- '',
2036
- '- Read MEMBER.md, MISSION.md, current goals, now.md, and recent logs.',
2037
- '- Choose one useful bounded goal toward the mission.',
2038
- '- Verify the work, write the receipt, and update the log.',
2039
- '- Ask the human when vision, taste, risk, or uncertainty matters.',
2040
- '',
2041
- ].join('\n');
2042
- fs.writeFileSync(missionPath, content, 'utf8');
2043
- return missionPath;
2044
- }
2045
-
2046
- function removeLegacyGeneratedMissionViews(dir) {
2047
- for (const name of ['missions.md', 'missions.json']) {
2048
- const legacyPath = path.join(dir, name);
2049
- if (!fs.existsSync(legacyPath)) continue;
2050
- let text = '';
2051
- try {
2052
- text = fs.readFileSync(legacyPath, 'utf8');
2053
- } catch {}
2054
- const looksGenerated = name.endsWith('.json')
2055
- ? text.includes('"schema": "atris.member_missions.v1"')
2056
- : text.includes('Generated from local Mission state');
2057
- if (looksGenerated) fs.unlinkSync(legacyPath);
2058
- }
2059
- }
2060
-
2061
772
  // Metric-verified missions (wish --metric) render their target expression plus
2062
773
  // the last observed value, read from the most recent verifier JSON line when a
2063
774
  // tick has run the verifier (metric_verify.py prints {"value": ..., "proof_uri": ...}).
@@ -10003,8 +8714,91 @@ function missionDoneLooksLike(mission, root = process.cwd()) {
10003
8714
  }
10004
8715
  }
10005
8716
 
8717
+ function splitDoneLooksLikeClaims(doneLooksLike) {
8718
+ const line = String(doneLooksLike || '').replace(/\s+/g, ' ').trim().replace(/\.+$/, '').trim();
8719
+ if (!line) return [];
8720
+ const hasSerialJoin = /,\s+(?:and|then)\s+/i.test(line);
8721
+ const rawParts = line.split(/;\s*|\.\s+(?=[A-Za-z])|,\s+(?:and|then)\s+/i);
8722
+ const claims = [];
8723
+ for (const part of rawParts) {
8724
+ const trimmed = String(part || '').trim();
8725
+ if (!trimmed) continue;
8726
+ if (hasSerialJoin && trimmed.includes(', ')) {
8727
+ const sub = trimmed.split(/,\s+/).map((item) => item.trim()).filter(Boolean);
8728
+ if (sub.length > 1 && sub.every((item) => item.split(/\s+/).length >= 2)) {
8729
+ claims.push(...sub);
8730
+ continue;
8731
+ }
8732
+ }
8733
+ claims.push(trimmed);
8734
+ }
8735
+ return claims
8736
+ .map((claim) => claim.replace(/^(?:and|then)\s+/i, '').replace(/\.+$/, '').trim())
8737
+ .filter(Boolean);
8738
+ }
8739
+
8740
+ function collectVerifyEvidence(cwd, { diff = '', changedFiles = [], testOutput = '' } = {}) {
8741
+ let diffText = String(diff || '');
8742
+ const testText = String(testOutput || '');
8743
+ const files = new Set(
8744
+ (Array.isArray(changedFiles) ? changedFiles : [])
8745
+ .map((file) => String(file || '').trim())
8746
+ .filter(Boolean),
8747
+ );
8748
+ if (cwd && !diffText && !files.size) {
8749
+ try {
8750
+ const status = spawnSync('git', ['status', '--porcelain', '--untracked-files=all'], { cwd, encoding: 'utf8' });
8751
+ const statusLines = status.status === 0 ? String(status.stdout || '').split(/\r?\n/).filter(Boolean) : [];
8752
+ for (const line of statusLines) {
8753
+ const rel = line.slice(3).trim().replace(/^"(.*)"$/, '$1');
8754
+ if (!rel || /^(\.atris\/|atris\/(?:runs|status|logs)\/|atris\/team\/[^/]+\/logs\/)/.test(rel)) continue;
8755
+ files.add(rel);
8756
+ try {
8757
+ const full = path.join(cwd, rel);
8758
+ if (fs.statSync(full).isFile()) {
8759
+ diffText += `\n--- ${rel}\n${fs.readFileSync(full, 'utf8').slice(0, 8192)}`;
8760
+ }
8761
+ } catch {}
8762
+ }
8763
+ const patch = spawnSync('git', ['diff', 'HEAD'], { cwd, encoding: 'utf8' });
8764
+ if (patch.status === 0 && patch.stdout) diffText += `\n${patch.stdout}`;
8765
+ if (!files.size && !diffText.trim()) {
8766
+ const headShow = spawnSync('git', ['show', '--stat', '--patch', '--pretty=format:', 'HEAD'], { cwd, encoding: 'utf8' });
8767
+ if (headShow.status === 0 && headShow.stdout) diffText += `\n${headShow.stdout}`;
8768
+ }
8769
+ } catch {}
8770
+ }
8771
+ return { diffText, testText, files: [...files] };
8772
+ }
8773
+
8774
+ function missingDoneLooksLikeClaim(doneLooksLike, options = {}) {
8775
+ const reviewOutput = String(options.reviewOutput || '');
8776
+ const explicitMissing = reviewOutput.match(/VERDICT:\s*FAIL\s*[;:]\s*missing:\s*(.+)/i);
8777
+ if (explicitMissing) return explicitMissing[1].trim();
8778
+ const claims = splitDoneLooksLikeClaims(doneLooksLike);
8779
+ if (claims.length < 2) return '';
8780
+ const { diffText, testText, files } = collectVerifyEvidence(options.cwd, options);
8781
+ const haystack = [diffText, testText, ...files].filter(Boolean).join('\n');
8782
+ if (!haystack.trim()) return '';
8783
+ for (const claim of claims) {
8784
+ if (/\b(?:no|not|never|without|unchanged|untouched)\b/i.test(claim)) continue;
8785
+ const namedFiles = claim.match(/\b(?:[A-Za-z0-9_.-]+\/)+[A-Za-z0-9_.-]+\.[A-Za-z0-9]{1,8}\b|\b[A-Za-z0-9_-]+\.(?:txt|md|js|ts|tsx|jsx|json|py|sh|yml|yaml|sql|css|html|go|rs|csv)\b/g) || [];
8786
+ for (const file of namedFiles) {
8787
+ const base = path.basename(file);
8788
+ const touched = files.some((entry) => entry === file || path.basename(entry) === base)
8789
+ || diffText.includes(file)
8790
+ || diffText.includes(base)
8791
+ || testText.includes(file)
8792
+ || testText.includes(base);
8793
+ if (!touched) return claim;
8794
+ }
8795
+ }
8796
+ return '';
8797
+ }
8798
+
10006
8799
  function buildEngineVerifyPrompt(mission, tickIndex, doneLooksLike = '', { timeoutMs = ENGINE_VERIFY_TIMEOUT_MS, checkPassed = '' } = {}) {
10007
8800
  const doneLine = String(doneLooksLike || '').replace(/\s+/g, ' ').trim();
8801
+ const claims = splitDoneLooksLikeClaims(doneLine);
10008
8802
  const minutes = Math.max(1, Math.round(timeoutMs / 60000));
10009
8803
  return [
10010
8804
  `# Verify Mission Tick ${tickIndex}`,
@@ -10013,8 +8807,9 @@ function buildEngineVerifyPrompt(mission, tickIndex, doneLooksLike = '', { timeo
10013
8807
  ...(doneLine ? [
10014
8808
  `The job says done looks like: ${doneLine}`,
10015
8809
  `Compare the work against that line. If the work does not show it, the verdict is FAIL even when the checks pass.`,
10016
- `Split that line into each separate thing it promises and check every one. List each part as met or missing.`,
10017
- `Work that does only some of the parts is not done. If any part is missing, end with 'VERDICT: FAIL; missing: <that part>'.`,
8810
+ `Split that line into each separate thing it promises and check every one. List each part as met or missing:`,
8811
+ ...claims.map((claim, index) => `Claim ${index + 1}: ${claim}`),
8812
+ `Require evidence for each claim from the diff or test output. Work that does only some of the parts is not done. If any part is missing, end with 'VERDICT: FAIL; missing: <that part>'.`,
10018
8813
  ] : []),
10019
8814
  // Member lab: reviewers spent about half their time on the workspace boot
10020
8815
  // (atris atris.md, MAP, PERSONA, skills) before reading the change.
@@ -10027,11 +8822,11 @@ function buildEngineVerifyPrompt(mission, tickIndex, doneLooksLike = '', { timeo
10027
8822
  ...(checkPassed ? [`The mission check \`${checkPassed}\` already passed on this exact code. Do not run it again.`] : []),
10028
8823
  `Prove the change with the narrowest real run: the changed command, or one test file or one named test.`,
10029
8824
  `Do not run whole test suites or large test files; on a busy machine they outlast your time.`,
10030
- `End your reply with a final line exactly 'VERDICT: PASS' or 'VERDICT: FAIL'.`,
8825
+ `End your reply with a final line 'VERDICT: PASS' or 'VERDICT: FAIL' (or 'VERDICT: FAIL; missing: <that part>' when a part of done-looks-like is unproven).`,
10031
8826
  ].join('\n');
10032
8827
  }
10033
8828
 
10034
- function engineVerifierResultFromRun(engineResult, engine = 'codex') {
8829
+ function engineVerifierResultFromRun(engineResult, engine = 'codex', options = {}) {
10035
8830
  const output = String(
10036
8831
  engineResult?.result
10037
8832
  || engineResult?.receipt_text
@@ -10049,6 +8844,21 @@ function engineVerifierResultFromRun(engineResult, engine = 'codex') {
10049
8844
  };
10050
8845
  }
10051
8846
  const finalLine = output.trim().split(/\r?\n/).pop() || '';
8847
+ const missing = missingDoneLooksLikeClaim(options.doneLooksLike, {
8848
+ ...options,
8849
+ reviewOutput: output,
8850
+ });
8851
+ if (missing) {
8852
+ const failLine = `VERDICT: FAIL; missing: ${missing}`;
8853
+ return {
8854
+ passed: false,
8855
+ mode: 'engine',
8856
+ engine,
8857
+ missing_claim: missing,
8858
+ reason: `missing: ${missing}`,
8859
+ output: /VERDICT:\s*FAIL/i.test(finalLine) ? output : `${output.trim()}\n${failLine}`.trim(),
8860
+ };
8861
+ }
10052
8862
  return {
10053
8863
  passed: finalLine === 'VERDICT: PASS',
10054
8864
  mode: 'engine',
@@ -10133,6 +8943,7 @@ function engineVerifierLineup(mission, cwd, preferredEngine = '', options = {})
10133
8943
  async function runEngineVerifier(mission, options = {}) {
10134
8944
  const cwd = options.cwd || process.cwd();
10135
8945
  const healthRoot = engineHealthRoot(cwd);
8946
+ const doneLooksLike = options.doneLooksLike !== undefined ? options.doneLooksLike : missionDoneLooksLike(mission, cwd);
10136
8947
  const lineup = engineVerifierLineup(mission, cwd, options.refereeEngine, { healthRoot });
10137
8948
  let verdict = null;
10138
8949
  for (const { engine, model } of lineup) {
@@ -10146,7 +8957,7 @@ async function runEngineVerifier(mission, options = {}) {
10146
8957
  cwd,
10147
8958
  signal: options.signal,
10148
8959
  timeoutMs: options.timeoutMs || ENGINE_VERIFY_TIMEOUT_MS,
10149
- prompt: buildEngineVerifyPrompt(mission, options.tickIndex || 1, missionDoneLooksLike(mission, cwd), {
8960
+ prompt: buildEngineVerifyPrompt(mission, options.tickIndex || 1, doneLooksLike, {
10150
8961
  timeoutMs: ENGINE_VERIFY_TIMEOUT_MS,
10151
8962
  checkPassed: options.checkPassed || '',
10152
8963
  }),
@@ -10157,7 +8968,13 @@ async function runEngineVerifier(mission, options = {}) {
10157
8968
  } finally {
10158
8969
  restoreRunnerProfile();
10159
8970
  }
10160
- verdict = engineVerifierResultFromRun(engineResult, engine);
8971
+ verdict = engineVerifierResultFromRun(engineResult, engine, {
8972
+ doneLooksLike,
8973
+ cwd,
8974
+ diff: options.diff,
8975
+ changedFiles: options.changedFiles,
8976
+ testOutput: options.testOutput,
8977
+ });
10161
8978
  if (engineResult?.ok && !engineResult?.timedOut && !engineResult?.aborted) {
10162
8979
  try { setEngineHealth(engine, 'ready', healthRoot); } catch {}
10163
8980
  try { setEngineHealth(engine, 'ready', reviewerHealthRoot(engine)); } catch {}
@@ -11385,6 +10202,7 @@ async function verifyMissionRunTickPhase(context) {
11385
10202
  tickIndex: currentTick.tickIdx,
11386
10203
  refereeEngine: context.refereeEngine,
11387
10204
  checkPassed: verifierResult?.passed ? currentTick.frozen.verifier : '',
10205
+ testOutput: [verifierResult?.stdout, verifierResult?.stderr].filter(Boolean).join('\n'),
11388
10206
  });
11389
10207
  const afterReview = gitCodeState(cwd);
11390
10208
  const codeMoved = beforeReview.tree !== afterReview.tree
@@ -13103,6 +11921,8 @@ module.exports = {
13103
11921
  missionVerifierHighLevelTestText,
13104
11922
  buildEngineVerifyPrompt,
13105
11923
  missionDoneLooksLike,
11924
+ splitDoneLooksLikeClaims,
11925
+ missingDoneLooksLikeClaim,
13106
11926
  engineVerifierResultFromRun,
13107
11927
  pickCrossEngineVerifier,
13108
11928
  runVerifier,