atris 3.68.9 → 3.69.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.
@@ -8,7 +8,7 @@ const { spawn, spawnSync } = require('child_process');
8
8
  const { hasFlag, readFlag, readIntFlag } = require('../lib/arg-parser');
9
9
  const { memberProcessPrompt } = require('../lib/member-context');
10
10
  const { memberIdentityLine, missionObjectiveLine } = require('../lib/member-identity');
11
- const { formatToolStep, appendProgress, reportProgress } = require('../lib/member-progress');
11
+ const { formatToolStep, formatCodexEvent, appendProgress, reportProgress, startedLine, newRunToken, runMarker } = require('../lib/member-progress');
12
12
  const {
13
13
  compactErrorPayload,
14
14
  compactSuccessPayload,
@@ -28,6 +28,7 @@ const {
28
28
  resolveClaudeRunnerBin,
29
29
  resolveClaudeRunnerCommandTemplate,
30
30
  buildRunnerCommand,
31
+ codexJsonEventsCommand,
31
32
  RUNNER_PROFILE_DEFS,
32
33
  RUNNER_PROFILE_ALIASES,
33
34
  RUNNER_PROFILE_NAMES,
@@ -6509,9 +6510,16 @@ function writeReceipt(mission, result, root = process.cwd()) {
6509
6510
  const receiptPath = path.join(paths.runsDir, `mission-${mission.id}-${safeTime}.json`);
6510
6511
  const relativeReceiptPath = path.relative(root, receiptPath);
6511
6512
  const finalResult = normalizeMissionReceiptResult(mission, result, relativeReceiptPath);
6513
+ // tree_hash covers only doctrine files; git_tree pins the code the run's
6514
+ // checks saw, so a later reader can tell whether the code has moved since.
6515
+ // With unsaved changes the checks saw more than any commit holds, so no
6516
+ // code tree is recorded and clean is false.
6517
+ const codeState = require('../lib/member-autoland').gitCodeState(root);
6512
6518
  const receipt = {
6513
6519
  schema: 'atris.mission_receipt.v1',
6514
6520
  tree_hash: treeHashFor(root),
6521
+ clean: codeState.clean,
6522
+ git_tree: codeState.clean ? codeState.tree : null,
6515
6523
  mission_id: mission.id,
6516
6524
  objective: mission.objective,
6517
6525
  owner: mission.owner,
@@ -9181,11 +9189,20 @@ function spawnGenericRunnerTick(mission, opts) {
9181
9189
  if (dispatchDepth.tooDeep) return Promise.resolve(tooDeepRunnerResult(dispatchDepth));
9182
9190
  return new Promise((resolve) => {
9183
9191
  let promptFile = null;
9192
+ let lastMessageFile = null;
9184
9193
  let cmd = '';
9185
9194
  let briefId = null;
9186
9195
  try {
9187
9196
  promptFile = writeRunnerPromptFile(cwd, mission.id, prompt);
9188
9197
  cmd = buildRunnerCommand({ promptFile, model, ...(mission.roster_effort ? { effort: mission.roster_effort } : {}) });
9198
+ // A codex that prints JSONL events gets live step lines; its final
9199
+ // answer comes from the last-message file, the same text stdout held.
9200
+ const lastFile = promptFile.replace(/\.md$/, '.last.txt');
9201
+ const eventsCmd = codexJsonEventsCommand(cmd, lastFile);
9202
+ if (eventsCmd) {
9203
+ cmd = eventsCmd;
9204
+ lastMessageFile = lastFile;
9205
+ }
9189
9206
  const engine = canonicalEngineName(mission.runner);
9190
9207
  if (engine) {
9191
9208
  const record = appendBriefRecord(cwd, {
@@ -9232,13 +9249,31 @@ function spawnGenericRunnerTick(mission, opts) {
9232
9249
  env: { ...gitGuard.env, ATRIS_DISPATCH_DEPTH: String(dispatchDepth.depth + 1) },
9233
9250
  });
9234
9251
  updateMissionLockOwner(missionLock, proc.pid, { phase: 'tick' });
9252
+ const progressMember = mission.owner || 'mission';
9253
+ const seenItemIds = new Set();
9254
+ if (lastMessageFile) appendProgress(progressMember, `started: ${mission.objective || ''}`, mission.id);
9235
9255
  let stdout = '';
9256
+ let eventBuf = '';
9257
+ let lastAgentMessage = '';
9236
9258
  let stderr = '';
9237
9259
  let timedOut = false;
9238
9260
  let aborted = false;
9239
9261
 
9240
9262
  const cleanupPrompt = () => {
9241
9263
  try { if (promptFile) fs.unlinkSync(promptFile); } catch {}
9264
+ try { if (lastMessageFile) fs.unlinkSync(lastMessageFile); } catch {}
9265
+ };
9266
+ const readCodexEvent = (line) => {
9267
+ let ev;
9268
+ try { ev = JSON.parse(line); } catch { return; }
9269
+ const item = ev && ev.item;
9270
+ if (ev.type === 'item.completed' && item && item.type === 'agent_message' && typeof item.text === 'string') {
9271
+ lastAgentMessage = item.text;
9272
+ }
9273
+ const step = formatCodexEvent(ev, cwd);
9274
+ if (!step || (item && item.id && seenItemIds.has(item.id))) return;
9275
+ if (item && item.id) seenItemIds.add(item.id);
9276
+ reportProgress(progressMember, step, mission.id);
9242
9277
  };
9243
9278
  const kill = () => {
9244
9279
  const killGroup = (sig) => {
@@ -9254,18 +9289,36 @@ function spawnGenericRunnerTick(mission, opts) {
9254
9289
  else signal.addEventListener('abort', onAbort, { once: true });
9255
9290
  }
9256
9291
 
9257
- proc.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
9292
+ proc.stdout.on('data', (chunk) => {
9293
+ const text = chunk.toString();
9294
+ stdout += text;
9295
+ if (!lastMessageFile) return;
9296
+ eventBuf += text;
9297
+ let nl;
9298
+ while ((nl = eventBuf.indexOf('\n')) !== -1) {
9299
+ const line = eventBuf.slice(0, nl).trim();
9300
+ eventBuf = eventBuf.slice(nl + 1);
9301
+ if (line) readCodexEvent(line);
9302
+ }
9303
+ });
9258
9304
  proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
9259
9305
 
9260
9306
  proc.on('close', (code) => {
9261
9307
  clearTimeout(timer);
9308
+ if (lastMessageFile && eventBuf.trim()) readCodexEvent(eventBuf.trim());
9309
+ let finalText = String(stdout || '').trim();
9310
+ if (lastMessageFile) {
9311
+ let lastMessage = '';
9312
+ try { lastMessage = fs.readFileSync(lastMessageFile, 'utf8'); } catch {}
9313
+ finalText = String(lastMessage || lastAgentMessage || '').trim();
9314
+ }
9262
9315
  cleanupPrompt();
9263
9316
  gitGuard.cleanup();
9264
9317
  if (signal) signal.removeEventListener?.('abort', onAbort);
9265
9318
  updateMissionLockOwner(missionLock, missionLock?.driverPid);
9266
- const finalText = String(stdout || '').trim();
9267
9319
  const errStr = String(stderr || '').slice(-2000);
9268
9320
  const ok = code === 0 && !timedOut && !aborted;
9321
+ if (lastMessageFile) appendProgress(progressMember, `finished: ${ok ? 'ok' : 'failed'}`, mission.id);
9269
9322
  const authExpired = !ok && missionRunnerAuthExpired(errStr, finalText);
9270
9323
  resolve({
9271
9324
  ok,
@@ -9355,8 +9408,10 @@ function spawnClaudeTick(mission, opts) {
9355
9408
  });
9356
9409
  return;
9357
9410
  }
9411
+ const runToken = newRunToken();
9412
+ const runOwner = mission.owner || 'mission';
9358
9413
  const args = [
9359
- '-p', prompt,
9414
+ '-p', `${runMarker(runOwner, runToken)}\n${prompt}`,
9360
9415
  '--output-format', 'stream-json',
9361
9416
  '--verbose',
9362
9417
  '--permission-mode', 'bypassPermissions',
@@ -9380,7 +9435,7 @@ function spawnClaudeTick(mission, opts) {
9380
9435
  updateMissionLockOwner(missionLock, proc.pid, { phase: 'tick' });
9381
9436
  const progressMember = mission.owner || 'mission';
9382
9437
  const seenToolIds = new Set();
9383
- appendProgress(progressMember, `started: ${mission.objective || ''}`, mission.id);
9438
+ appendProgress(progressMember, startedLine(mission.objective, { pid: proc.pid, engine: 'claude', run: runToken }), mission.id);
9384
9439
 
9385
9440
  let stdoutBuf = '';
9386
9441
  let observedSessionIds = new Set();
@@ -9532,10 +9587,13 @@ function engineVerifierResultFromRun(engineResult, engine = 'codex') {
9532
9587
  }
9533
9588
 
9534
9589
  function pickCrossEngineVerifier(mission, cwd = process.cwd(), preferredEngine = '') {
9535
- const explicit = canonicalEngineName(preferredEngine || mission?.referee_engine || '');
9536
- if (explicit) return explicit;
9590
+ // The reviewer must not be the builder: a builder grading itself is no review.
9591
+ // A named reviewer is used unless it is the builder's own engine.
9537
9592
  const workerEngine = canonicalEngineName(mission?.runner) || 'claude';
9538
- const candidates = workerEngine === 'codex' ? ['claude', 'fable', 'codex'] : ['codex', 'claude'];
9593
+ const explicit = canonicalEngineName(preferredEngine || mission?.referee_engine || '');
9594
+ if (explicit && explicit !== workerEngine) return explicit;
9595
+ const candidates = (workerEngine === 'codex' ? ['claude', 'fable'] : ['codex', 'claude'])
9596
+ .filter((candidate) => candidate !== workerEngine);
9539
9597
  try {
9540
9598
  const engines = engineRegistryView(cwd);
9541
9599
  for (const candidate of candidates) {
@@ -10704,12 +10762,22 @@ async function verifyMissionRunTickPhase(context) {
10704
10762
  || (!currentTick.storedVerifier && !process.env.NODE_TEST_CONTEXT)
10705
10763
  );
10706
10764
  if (shouldRunReferee) {
10707
- const refereeResult = await runEngineVerifier(currentTick.tickRuntimeMission, {
10765
+ // Pin the code before the referee looks; a referee that changes the
10766
+ // code or its unsaved state has not reviewed what would merge.
10767
+ const { gitCodeState, codeStatus } = require('../lib/member-autoland');
10768
+ const beforeReview = gitCodeState(cwd);
10769
+ const reviewed = await runEngineVerifier(currentTick.tickRuntimeMission, {
10708
10770
  cwd,
10709
10771
  signal: controller.signal,
10710
10772
  tickIndex: currentTick.tickIdx,
10711
10773
  refereeEngine: context.refereeEngine,
10712
10774
  });
10775
+ const afterReview = gitCodeState(cwd);
10776
+ const codeMoved = beforeReview.tree !== afterReview.tree
10777
+ || codeStatus(beforeReview) !== codeStatus(afterReview)
10778
+ || beforeReview.hidden !== afterReview.hidden;
10779
+ const refereeResult = codeMoved ? { ...reviewed, passed: false, reason: 'code changed during review' } : reviewed;
10780
+ result.reviewed_git_tree = beforeReview.clean ? beforeReview.tree : null;
10713
10781
  result.referee_result = refereeResult;
10714
10782
  if (!verifierResult || !refereeResult.passed) {
10715
10783
  verifierResult = verifierResult
package/commands/sync.js CHANGED
@@ -3,6 +3,7 @@ const path = require('path');
3
3
  const os = require('os');
4
4
  const { ensureWikiScaffold } = require('../lib/wiki');
5
5
  const { upsertAtrisClaudeBootBlock } = require('../lib/claude-boot-block');
6
+ const { ensureRunIgnore } = require('../lib/gitignore');
6
7
  const {
7
8
  upsertAgentVoiceCard,
8
9
  upsertClaudeVoiceHook,
@@ -492,6 +493,10 @@ function syncAtris(options = {}) {
492
493
  dryRun,
493
494
  force,
494
495
  });
496
+ const bizRunIgnore = ensureRunIgnore(process.cwd(), { dryRun });
497
+ if (bizRunIgnore.changed) {
498
+ console.log(`${dryRun ? 'Would update' : '✓ Updated'} .gitignore (atris run output ignored)`);
499
+ }
495
500
  exportTeamAfterUpdate(process.cwd(), { dryRun });
496
501
  if (!yes) {
497
502
  console.log('\nPlan only. Re-run with --yes to write these files.');
@@ -730,6 +735,14 @@ Key behaviors:
730
735
  applyClaudeUpdate(content, 'create CLAUDE.md with atris block', 'created CLAUDE.md with atris block');
731
736
  }
732
737
 
738
+ // Member run output must stay out of git or member self-merge never fires
739
+ // on a dirty tree. Append-only; plan-only mode prints instead of writing.
740
+ const runIgnore = ensureRunIgnore(process.cwd(), { dryRun });
741
+ if (runIgnore.changed) {
742
+ console.log(`${dryRun ? 'Would update' : '✓ Updated'} .gitignore (atris run output ignored)`);
743
+ updated++;
744
+ }
745
+
733
746
  if (dryRun) {
734
747
  console.log(`\nDry run: ${updated} file(s) would update, ${skipped} unchanged; no changes made.`);
735
748
  if (!yes) {
@@ -1396,6 +1396,7 @@ function evaluateAutoAccept(task, options = {}) {
1396
1396
  module.exports = {
1397
1397
  AGENT_CERTIFICATION_REVIEW_PASSES,
1398
1398
  DENIED_TAGS,
1399
+ deniedTagOf,
1399
1400
  candidatePolicyGate,
1400
1401
  declaredProtectedLane,
1401
1402
  sniffedProtectedLane,
@@ -182,11 +182,12 @@ const ENGINE_SEED_META = Object.freeze({
182
182
  // bake the effort into the model.
183
183
  models: Object.freeze([
184
184
  'gemini-3.8-flash',
185
+ // Matches `agy models` on agy 1.3.4, verified 2026-10-13.
186
+ 'gemini-4-argon-high',
187
+ 'gemini-4-argon-medium',
185
188
  'gemini-3.8-flash-high',
186
- 'gemini-3.7-flash-high',
187
- 'gemini-3.1-pro-high',
188
- 'claude-sonnet-4-6',
189
- 'claude-opus-4-6-thinking',
189
+ 'claude-opus-5-5-high',
190
+ 'claude-sonnet-5-5-high',
190
191
  'gpt-oss-120b-medium',
191
192
  ]),
192
193
  duty: 'errands',
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ // Member runs drop receipts and status files under atris/runs/ and
4
+ // atris/status/. A project that never ignores them stays dirty forever and
5
+ // member self-merge never fires, so init and update pin these entries in the
6
+ // project .gitignore. Git repos only: a plain folder gets no new file.
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ const RUN_IGNORE_COMMENT = '# atris run output';
12
+ const RUN_IGNORE_ENTRIES = ['atris/runs/', 'atris/status/'];
13
+
14
+ // .git can be a directory or a worktree pointer file; either counts.
15
+ function isGitRepo(root) {
16
+ return fs.existsSync(path.join(root, '.git'));
17
+ }
18
+
19
+ // What the project .gitignore still needs, or null when it needs nothing
20
+ // (not a git repo, or every entry already present). Pure read; callers decide
21
+ // whether to write (update's plan-only mode prints the change instead).
22
+ function planRunIgnore(root) {
23
+ if (!isGitRepo(root)) return null;
24
+ const file = path.join(root, '.gitignore');
25
+ const exists = fs.existsSync(file);
26
+ const content = exists ? fs.readFileSync(file, 'utf8') : '';
27
+ const lines = content.split(/\r?\n/).map((line) => line.trim());
28
+ const missing = RUN_IGNORE_ENTRIES.filter((entry) => !lines.includes(entry));
29
+ if (missing.length === 0) return null;
30
+ const additions = lines.includes(RUN_IGNORE_COMMENT) ? missing : [RUN_IGNORE_COMMENT, ...missing];
31
+ const gap = content === '' ? '' : content.endsWith('\n') ? '\n' : '\n\n';
32
+ return { file, created: !exists, missing, block: `${gap}${additions.join('\n')}\n` };
33
+ }
34
+
35
+ // Append-only write: existing lines, their order, and the rest of the file
36
+ // are never touched. Best-effort: an unreadable or unwritable .gitignore must
37
+ // not fail init or update, it just reports nothing changed.
38
+ function ensureRunIgnore(root, { dryRun = false } = {}) {
39
+ let plan = null;
40
+ try {
41
+ plan = planRunIgnore(root);
42
+ } catch {
43
+ return { changed: false };
44
+ }
45
+ if (!plan) return { changed: false };
46
+ if (!dryRun) {
47
+ try {
48
+ fs.appendFileSync(plan.file, plan.block);
49
+ } catch {
50
+ return { changed: false };
51
+ }
52
+ }
53
+ return { changed: true, ...plan };
54
+ }
55
+
56
+ module.exports = { ensureRunIgnore };
@@ -0,0 +1,350 @@
1
+ 'use strict';
2
+
3
+ // After an ignited member run ends with its check passing, hand the branch to
4
+ // GitHub auto-merge. Every local gate fails closed: the branch stays put and
5
+ // one plain line says which gate held it. GitHub then waits for the branch to
6
+ // be up to date and CI green; this never merges, rebases, or polls itself.
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { spawnSync } = require('child_process');
11
+ const {
12
+ deniedTagOf,
13
+ declaredProtectedLane,
14
+ sniffedProtectedLane,
15
+ } = require('./auto-accept-certified');
16
+ const { isDecisionHoldTag, isDecisionTask } = require('./task-decision');
17
+
18
+ // Git replacement refs can make a local check read different code than the
19
+ // commit that gets pushed; every git and gh call here ignores them.
20
+ function run(cmd, args, cwd) {
21
+ const env = { ...process.env, GIT_NO_REPLACE_OBJECTS: '1' };
22
+ const res = spawnSync(cmd, args, { cwd, encoding: 'utf8', timeout: 120000, env });
23
+ return {
24
+ ok: res.status === 0,
25
+ stdout: String(res.stdout || '').trim(),
26
+ stderr: String(res.stderr || res.error?.message || '').trim(),
27
+ };
28
+ }
29
+
30
+ // The policy lives in the main checkout, not the mission's worktree copy.
31
+ function mainCheckoutRoot(cwd) {
32
+ const common = run('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd);
33
+ if (!common.ok || !common.stdout) return null;
34
+ return path.dirname(common.stdout);
35
+ }
36
+
37
+ function policyOn(cwd) {
38
+ const root = mainCheckoutRoot(cwd);
39
+ if (!root) return false;
40
+ try {
41
+ const policy = JSON.parse(fs.readFileSync(path.join(root, '.atris', 'policy', 'autoland.json'), 'utf8'));
42
+ return Boolean(policy) && typeof policy === 'object' && policy.enabled === true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ const TAG_KEYS = new Set(['tag', 'tags', 'todo_tags', 'lane']);
49
+ const METADATA_DEPTH_LIMIT = 6;
50
+ const METADATA_TEXT_CAP = 64 * 1024;
51
+
52
+ // Every string anywhere in metadata, and the ones anywhere below a tag key.
53
+ // Metadata too deep or too large to read whole returns null, which holds.
54
+ function metadataStrings(metadata) {
55
+ const texts = [];
56
+ const tags = [];
57
+ let size = 0;
58
+ const walk = (value, depth, tagged) => {
59
+ if (typeof value === 'string') {
60
+ size += value.length;
61
+ texts.push(value);
62
+ if (tagged) tags.push(value);
63
+ return size <= METADATA_TEXT_CAP;
64
+ }
65
+ if (!value || typeof value !== 'object') return true;
66
+ if (depth >= METADATA_DEPTH_LIMIT) return false;
67
+ if (Array.isArray(value)) return value.every((item) => walk(item, depth + 1, tagged));
68
+ return Object.entries(value).every(([key, item]) => walk(item, depth + 1, tagged || TAG_KEYS.has(key)));
69
+ };
70
+ return walk(metadata, 0, false) ? { texts, tags } : null;
71
+ }
72
+
73
+ // A row's protected lane, or null. Tags are its own tag and lane, every
74
+ // string under a tag key anywhere in metadata, and '#tag' words in the title.
75
+ // Text is the title, objective, and every string in metadata.
76
+ function protectedLane(row) {
77
+ if (!row || typeof row !== 'object') return 'unreadable';
78
+ // Metadata is a plain object or absent; anything else, including stored
79
+ // text that would not parse, cannot be checked.
80
+ const meta = row.metadata;
81
+ if (row.metadata_unreadable || (meta !== undefined && meta !== null && (typeof meta !== 'object' || Array.isArray(meta)))) {
82
+ return 'metadata could not be read';
83
+ }
84
+ const found = metadataStrings(meta);
85
+ if (!found) return 'metadata too large to check';
86
+ const title = String(row.title || '');
87
+ const tags = [
88
+ row.tag,
89
+ row.lane,
90
+ ...(Array.isArray(row.tags) ? row.tags : []),
91
+ ...found.tags,
92
+ ...(title.match(/#[A-Za-z0-9_-]+/g) || []).map((t) => t.slice(1)),
93
+ ];
94
+ // Decision rows wait for a person; the same rule member go's picker uses.
95
+ if (isDecisionTask(row)) return 'needs a human decision';
96
+ for (const tag of tags) {
97
+ const text = String(tag ?? '');
98
+ if ([text, ...text.split(/[\s,]+/)].some(isDecisionHoldTag)) return 'needs a human decision';
99
+ const denied = deniedTagOf({ tag: text });
100
+ if (denied) return denied;
101
+ }
102
+ const objective = [title, typeof row.objective === 'string' ? row.objective : '', ...found.texts].filter(Boolean).join('\n');
103
+ const view = { title, objective, metadata: {} };
104
+ const declared = declaredProtectedLane(view);
105
+ if (declared) return `declared "${declared.phrase}"`;
106
+ const sniffed = sniffedProtectedLane(view);
107
+ if (sniffed) return sniffed.lane;
108
+ return null;
109
+ }
110
+
111
+ // Every task this mission points at, or null when a link is malformed.
112
+ function linkedTaskIds(mission) {
113
+ const ids = [];
114
+ if (mission.task_ids !== undefined) {
115
+ if (!Array.isArray(mission.task_ids)) return null;
116
+ for (const id of mission.task_ids) {
117
+ if (typeof id !== 'string' || !id.trim()) return null;
118
+ ids.push(id.trim());
119
+ }
120
+ }
121
+ const xp = mission.xp_task;
122
+ if (xp !== undefined && xp !== null && (typeof xp !== 'object' || Array.isArray(xp))) return null;
123
+ for (const value of [mission.task_id, mission.task_ref, mission.current_task_id, xp?.task_id, xp?.ref]) {
124
+ if (value === undefined || value === null || value === '') continue;
125
+ if (typeof value !== 'string' || !value.trim()) return null;
126
+ ids.push(value.trim());
127
+ }
128
+ return [...new Set(ids)];
129
+ }
130
+
131
+ function defaultLoadTask(id) {
132
+ try {
133
+ const taskDb = require('./task-db');
134
+ return taskDb.getTask(taskDb.open(), id) || null;
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+
140
+ const RECEIPT_STAMP_RE = /^\d{4}-\d{2}-\d{2}T[0-9-]+Z\.json$/;
141
+
142
+ // The newest run receipt this mission wrote in its own folder, or null.
143
+ function latestRunReceipt(missionId, cwd) {
144
+ if (typeof missionId !== 'string' || !/^[A-Za-z0-9._-]+$/.test(missionId)) return null;
145
+ const dir = path.join(cwd, 'atris', 'runs');
146
+ const prefix = `mission-${missionId}-`;
147
+ let names;
148
+ try {
149
+ names = fs.readdirSync(dir);
150
+ } catch {
151
+ return null;
152
+ }
153
+ const latest = names
154
+ .filter((name) => name.startsWith(prefix) && RECEIPT_STAMP_RE.test(name.slice(prefix.length)))
155
+ .sort((a, b) => (a.slice(prefix.length) < b.slice(prefix.length) ? -1 : 1))
156
+ .pop();
157
+ if (!latest) return null;
158
+ try {
159
+ const receipt = JSON.parse(fs.readFileSync(path.join(dir, latest), 'utf8'));
160
+ if (!receipt || typeof receipt !== 'object' || receipt.mission_id !== missionId) return null;
161
+ return receipt;
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
167
+ // Review must cover the final code of the final run. The newest receipt for
168
+ // this mission must be a finished run's summary, recorded on a clean folder
169
+ // whose code tree is the one checked out now, with nothing newer in the
170
+ // mission's own state and no commit made after it. Every tick in that run
171
+ // must have passed its check and a verdict from a reviewer engine other than
172
+ // the member and every engine that did the work.
173
+ function reviewHold(mission, workerEngine, cwd, pin = {}) {
174
+ const receipt = latestRunReceipt(mission.id, cwd);
175
+ if (!receipt) return 'no run receipt for this mission could be read';
176
+ const result = receipt.result && typeof receipt.result === 'object' ? receipt.result : {};
177
+ if (result.kind !== 'mission_run_summary') return 'the latest run has not finished';
178
+ const receiptAt = Date.parse(receipt.at || '');
179
+ if (!Number.isFinite(receiptAt)) return 'the latest run receipt has no time';
180
+ if (receipt.clean !== true) return 'the latest run ended with unsaved changes';
181
+ const head = run('git', ['rev-parse', 'HEAD^{tree}'], cwd);
182
+ if (!head.ok || !head.stdout || receipt.git_tree !== head.stdout) {
183
+ return 'the latest review was not recorded on the code that is checked out now';
184
+ }
185
+ const commitTime = run('git', ['log', '-1', '--format=%ct', 'HEAD'], cwd);
186
+ const committedAt = Number(commitTime.stdout) * 1000;
187
+ if (!commitTime.ok || !Number.isFinite(committedAt) || committedAt <= 0 || committedAt > receiptAt) {
188
+ return 'the code was committed after the latest review';
189
+ }
190
+
191
+ const ticks = Array.isArray(result.ticks) ? result.ticks : [];
192
+ if (!ticks.length || result.tick_count !== ticks.length) return 'no reviewed work is recorded on the latest run';
193
+ const lastTick = ticks[ticks.length - 1] || {};
194
+ const lastTickAt = Date.parse(mission.last_tick_at || '');
195
+ if (!Number.isFinite(lastTickAt) || lastTickAt > receiptAt) return 'the mission ran again after the latest receipt';
196
+ if (mission.last_tick_status !== 'ran') return 'the mission\'s last step did not finish cleanly';
197
+ if (mission.last_tick_index !== undefined && mission.last_tick_index !== lastTick.tick_index) {
198
+ return 'the mission ran again after the latest receipt';
199
+ }
200
+
201
+ const worker = String(workerEngine || mission.runner || '').trim().toLowerCase();
202
+ if (!worker || worker === 'auto') return 'the engine that did the work is unknown';
203
+ const self = new Set([mission.owner, worker, ...ticks.map((tick) => tick && tick.engine_id)]
204
+ .map((v) => String(v || '').trim().toLowerCase()).filter(Boolean));
205
+ for (const tick of ticks) {
206
+ if (!tick || typeof tick !== 'object' || tick.status !== 'ran' || tick.verifier_passed !== true) {
207
+ return 'a step on the latest run did not pass its check';
208
+ }
209
+ const referee = tick.referee_result;
210
+ if (!referee || typeof referee !== 'object' || referee.mode !== 'engine' || referee.passed !== true) {
211
+ return 'a reviewer on the latest run did not pass it';
212
+ }
213
+ const reviewer = String(referee.engine || '').trim().toLowerCase();
214
+ if (!reviewer || self.has(reviewer)) return 'the latest run was reviewed by the same engine that did the work';
215
+ }
216
+ if (lastTick.reviewed_git_tree !== head.stdout) return 'the review did not see the code that is checked out now';
217
+ pin.tree = head.stdout;
218
+ return null;
219
+ }
220
+
221
+ // The checked-out code as git sees it with config tricks switched off: every
222
+ // untracked file listed, submodules compared, no file monitor, and no index
223
+ // entry marked assume-unchanged or skip-worktree (anything but an 'H' tag).
224
+ function gitCodeState(cwd) {
225
+ const status = run('git', [
226
+ '-c', 'status.showUntrackedFiles=all', '-c', 'core.fsmonitor=false',
227
+ 'status', '--porcelain', '--untracked-files=all', '--ignore-submodules=none',
228
+ ], cwd);
229
+ const index = run('git', ['-c', 'core.fsmonitor=false', 'ls-files', '-v'], cwd);
230
+ const hidden = !index.ok || index.stdout.split('\n').some((line) => line && !line.startsWith('H '));
231
+ const tree = run('git', ['rev-parse', 'HEAD^{tree}'], cwd);
232
+ return {
233
+ clean: status.ok && !status.stdout && !hidden,
234
+ hidden,
235
+ status: status.ok ? status.stdout : null,
236
+ tree: tree.ok && tree.stdout ? tree.stdout : null,
237
+ };
238
+ }
239
+
240
+ // New files Atris writes for itself while a run is going (receipts, state,
241
+ // logs). Changed tracked files always count.
242
+ const BOOKKEEPING = /^\?\? (?:\.atris\/|atris\/(?:runs|status|logs)\/|atris\/team\/[^/]+\/logs\/)/;
243
+
244
+ function codeStatus(state) {
245
+ if (!state || state.status == null) return null;
246
+ return state.status.split('\n').filter((line) => line && !BOOKKEEPING.test(line)).join('\n');
247
+ }
248
+
249
+ function unsavedHold(cwd) {
250
+ if (!gitCodeState(cwd).clean) return 'unsaved changes; the check may not match what would merge';
251
+ return null;
252
+ }
253
+
254
+ function held(gate, line) {
255
+ return { merged: false, auto_merge: false, gate, line: `left the branch for you: ${line}` };
256
+ }
257
+
258
+ function autolandMemberRun({ mission, cwd, summary = '', workerEngine = '', loadTask = defaultLoadTask } = {}) {
259
+ if (!mission || typeof mission !== 'object') return held('mission', 'the mission could not be read');
260
+ if (mission.verifier_result?.passed !== true) return held('check', 'the check did not pass');
261
+ if (!policyOn(cwd)) return held('policy', 'self-landing is not switched on in the main checkout');
262
+
263
+ const missionLane = protectedLane({ ...mission, title: mission.objective });
264
+ if (missionLane) return held('protected', `this mission sits in a protected area (${missionLane})`);
265
+ const taskIds = linkedTaskIds(mission);
266
+ if (!taskIds) return held('protected', 'the mission\'s linked tasks are not a clean list of ids');
267
+ for (const id of taskIds) {
268
+ let task = null;
269
+ try {
270
+ task = loadTask(id, cwd);
271
+ } catch {
272
+ task = null;
273
+ }
274
+ if (!task || typeof task !== 'object') return held('protected', `a linked task (${id}) could not be read`);
275
+ const lane = protectedLane(task);
276
+ if (lane) return held('protected', `a linked task sits in a protected area (${lane})`);
277
+ }
278
+
279
+ const unsaved = unsavedHold(cwd);
280
+ if (unsaved) return held('unsaved', unsaved);
281
+ const pin = {};
282
+ const reviewGap = reviewHold(mission, workerEngine, cwd, pin);
283
+ if (reviewGap) return held('review', reviewGap);
284
+ // Pin the commit the review passed on. Only this commit is pushed and
285
+ // merged, so a commit made after this point never rides along.
286
+ const headRead = run('git', ['rev-parse', 'HEAD'], cwd);
287
+ const headSha = headRead.ok ? headRead.stdout : '';
288
+ if (!/^[0-9a-f]{40,64}$/.test(headSha)) return held('branch', 'the latest commit could not be read');
289
+ const pinnedTree = () => {
290
+ const tree = run('git', ['rev-parse', `${headSha}^{tree}`], cwd);
291
+ return tree.ok && tree.stdout === pin.tree;
292
+ };
293
+ if (!pin.tree || !pinnedTree()) return held('review', 'the code moved while the review was being checked');
294
+
295
+ const branchRead = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
296
+ const branch = String(mission.worktree?.branch || branchRead.stdout || '').trim();
297
+ if (!branch || branch === 'HEAD' || branch === 'master' || branch === 'main') {
298
+ return held('branch', 'the work is not on its own branch');
299
+ }
300
+ const remoteHead = run('git', ['ls-remote', '--symref', 'origin', 'HEAD'], cwd);
301
+ const base = (remoteHead.stdout.match(/^ref: refs\/heads\/(\S+)\s+HEAD$/m) || [])[1] || '';
302
+ if (!remoteHead.ok || !base) return held('base', 'the main branch on GitHub could not be read');
303
+ if (branch === base) return held('branch', 'the work is not on its own branch');
304
+
305
+ if (!pinnedTree()) return held('review', 'the reviewed commit no longer holds the reviewed code');
306
+ const push = run('git', ['push', 'origin', `${headSha}:refs/heads/${branch}`], cwd);
307
+ if (!push.ok) return held('push', `the branch could not be pushed (${push.stderr.split('\n')[0]})`);
308
+
309
+ // Find the open pull request from exactly this branch into the main
310
+ // branch, or open one. Merge is always addressed by number, never by a
311
+ // branch name gh might read as a number, and pinned to the pushed commit.
312
+ const listed = run('gh', [
313
+ 'pr', 'list', '--head', branch, '--base', base, '--state', 'open',
314
+ '--json', 'number,headRefName,baseRefName,headRefOid,isCrossRepository',
315
+ ], cwd);
316
+ if (!listed.ok) return held('pull request', `open pull requests could not be read (${listed.stderr.split('\n')[0]})`);
317
+ let rows;
318
+ try {
319
+ rows = JSON.parse(listed.stdout || '[]');
320
+ } catch {
321
+ rows = null;
322
+ }
323
+ if (!Array.isArray(rows)) return held('pull request', 'open pull requests could not be read');
324
+ const matches = rows.filter((pr) => pr && pr.headRefName === branch && pr.baseRefName === base
325
+ && pr.isCrossRepository === false && Number.isInteger(pr.number) && pr.number > 0);
326
+ if (matches.length > 1) return held('pull request', 'more than one pull request is open for this branch');
327
+ let number = matches.length ? matches[0].number : 0;
328
+ let url = '';
329
+ if (!number) {
330
+ const title = String(mission.objective || branch).replace(/\s+/g, ' ').trim().slice(0, 70);
331
+ const body = String(summary || '').trim() || `Receipt: ${mission.receipt_path || 'none recorded'}`;
332
+ const created = run('gh', ['pr', 'create', '--head', branch, '--base', base, '--title', title, '--body', body], cwd);
333
+ url = created.ok ? created.stdout.split('\n').pop().trim() : '';
334
+ number = Number((url.match(/\/pull\/(\d+)$/) || [])[1] || 0);
335
+ if (!created.ok || !number) {
336
+ return held('pull request', `the pull request could not be opened (${(created.stderr || url).split('\n')[0]})`);
337
+ }
338
+ }
339
+ const label = url || `pull request #${number}`;
340
+
341
+ const merge = run('gh', ['pr', 'merge', String(number), '--auto', '--merge', '--match-head-commit', headSha], cwd);
342
+ if (!merge.ok) return held('auto-merge', `auto-merge could not be switched on for ${label}`);
343
+ return { merged: false, auto_merge: true, gate: null, url: label, line: `merges on its own once checks pass: ${label}` };
344
+ }
345
+
346
+ module.exports = {
347
+ autolandMemberRun,
348
+ codeStatus,
349
+ gitCodeState,
350
+ };