atris 3.68.8 → 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,
@@ -83,6 +84,23 @@ function ensureRealDirectory(dir) {
83
84
  fs.mkdirSync(dir, { recursive: true });
84
85
  }
85
86
 
87
+ // A hook script the project already has is never replaced. Projects harden
88
+ // their hooks (nonce checks, queues, proof before pop); a sync must not
89
+ // downgrade them to the shipped copy.
90
+ function isProtectedHook(destPath) {
91
+ return path.basename(path.dirname(destPath)) === 'hooks' && fs.existsSync(destPath);
92
+ }
93
+
94
+ // A member retired to team/_archived/<name>-<date>/ must never come back.
95
+ function isArchivedMember(teamDir, name) {
96
+ const archiveDir = path.join(teamDir, '_archived');
97
+ try {
98
+ return fs.readdirSync(archiveDir).some(entry => entry === name || (entry.startsWith(`${name}-`) && /^\d{4}-\d{2}-\d{2}$/.test(entry.slice(name.length + 1))));
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
86
104
  /**
87
105
  * Sync the canonical skill set from atris-cli/atris/skills/* into a
88
106
  * workspace's atris/skills/* (plus ensure .claude/skills/ symlinks).
@@ -128,7 +146,9 @@ function syncPackageSkills(targetAtrisDir, opts = {}) {
128
146
  } else {
129
147
  const srcContent = fs.readFileSync(srcPath, 'utf8');
130
148
  const destContent = fs.existsSync(destPath) ? fs.readFileSync(destPath, 'utf8') : '';
131
- if (srcContent !== destContent) {
149
+ if (srcContent !== destContent && isProtectedHook(destPath)) {
150
+ if (verbose) console.log(`- Kept atris/skills/${skillName}/${relPath} (differs from shipped hook, not replaced)`);
151
+ } else if (srcContent !== destContent) {
132
152
  if (!dryRun) {
133
153
  fs.writeFileSync(destPath, srcContent);
134
154
  if (entry.endsWith('.sh')) fs.chmodSync(destPath, 0o755);
@@ -348,6 +368,12 @@ function syncWorkspaceTemplate(targetRoot, bizMeta, options = {}) {
348
368
  try { templateContent = fs.readFileSync(templatePath, 'utf-8'); } catch { continue; }
349
369
  const finalContent = _substituteParams(templateContent, params);
350
370
 
371
+ const memberMatch = targetRelPath.split(path.sep).join('/').match(/^team\/([^/_][^/]*)\//);
372
+ if (memberMatch && isArchivedMember(path.join(targetAtrisDir, 'team'), memberMatch[1])) {
373
+ skipped++;
374
+ continue;
375
+ }
376
+
351
377
  if (!fs.existsSync(targetPath)) {
352
378
  addedList.push(targetRelPath); added++;
353
379
  if (!dryRun) {
@@ -358,7 +384,7 @@ function syncWorkspaceTemplate(targetRoot, bizMeta, options = {}) {
358
384
  const existing = fs.readFileSync(targetPath, 'utf-8');
359
385
  if (existing === finalContent) {
360
386
  skipped++;
361
- } else if (force) {
387
+ } else if (force && !memberMatch) {
362
388
  updatedList.push(targetRelPath); updated++;
363
389
  if (!dryRun) fs.writeFileSync(targetPath, finalContent);
364
390
  } else {
@@ -467,6 +493,10 @@ function syncAtris(options = {}) {
467
493
  dryRun,
468
494
  force,
469
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
+ }
470
500
  exportTeamAfterUpdate(process.cwd(), { dryRun });
471
501
  if (!yes) {
472
502
  console.log('\nPlan only. Re-run with --yes to write these files.');
@@ -565,6 +595,17 @@ function syncAtris(options = {}) {
565
595
  return;
566
596
  }
567
597
 
598
+ // Team members belong to the project. Update never adds a member (init
599
+ // does that, and a missing member may have been retired to
600
+ // team/_archived/) and never replaces one the project has changed.
601
+ if (target.startsWith('team/')) {
602
+ if (fs.existsSync(targetFile)) {
603
+ console.log(`- Kept ${target} (newer template exists in the package, yours not replaced)`);
604
+ }
605
+ skipped++;
606
+ return;
607
+ }
608
+
568
609
  if (!dryRun) {
569
610
  fs.mkdirSync(path.dirname(targetFile), { recursive: true });
570
611
  fs.copyFileSync(sourceFile, targetFile);
@@ -694,6 +735,14 @@ Key behaviors:
694
735
  applyClaudeUpdate(content, 'create CLAUDE.md with atris block', 'created CLAUDE.md with atris block');
695
736
  }
696
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
+
697
746
  if (dryRun) {
698
747
  console.log(`\nDry run: ${updated} file(s) would update, ${skipped} unchanged; no changes made.`);
699
748
  if (!yes) {
@@ -728,7 +777,9 @@ function syncRecursiveCount(src, dest, label, silent) {
728
777
  } else {
729
778
  const srcBuf = fs.readFileSync(srcPath);
730
779
  const destBuf = fs.existsSync(destPath) ? fs.readFileSync(destPath) : Buffer.alloc(0);
731
- if (!srcBuf.equals(destBuf)) {
780
+ if (!srcBuf.equals(destBuf) && isProtectedHook(destPath)) {
781
+ if (!silent) console.log(`- Kept ${label}/${entry} (differs from shipped hook, not replaced)`);
782
+ } else if (!srcBuf.equals(destBuf)) {
732
783
  fs.writeFileSync(destPath, srcBuf);
733
784
  if (entry.endsWith('.sh')) {
734
785
  fs.chmodSync(destPath, 0o755);
@@ -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 };