atris 3.74.12 → 3.74.13

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.
@@ -785,6 +785,7 @@ const MEMBER_RUN_START_BOOLEAN_FLAGS = [
785
785
  '--json',
786
786
  '--no-verify',
787
787
  '--referee',
788
+ '--referee-if-check-edited',
788
789
  '--no-referee',
789
790
  '--worktree',
790
791
  '--shared-checkout',
@@ -1064,9 +1065,21 @@ function startMemberRunMission(name, missionText, args = []) {
1064
1065
  }
1065
1066
 
1066
1067
  // An ignited run asks a second engine to review the work by default, so every
1067
- // run ends with a verdict. --no-referee skips it; an explicit --referee is kept.
1068
- function memberRunWantsReferee(runArgs, ignite) {
1069
- return Boolean(ignite) && !hasFlag(runArgs, '--referee') && !hasFlag(runArgs, '--no-referee');
1068
+ // run ends with a verdict. A real test command is already that verdict: the
1069
+ // second engine only reran the same commands and cost about a minute a run
1070
+ // (member lab 2026-10-24), so it stays for runs whose check is not a test
1071
+ // runner (whitespace, syntax, or file-exists checks). --no-referee skips it; an explicit --referee is kept.
1072
+ function memberRunHasRealCheck(verifier) {
1073
+ const command = String(verifier || '').trim();
1074
+ // A plain test runner only: chaining (|| true, ; , &&, pipes, subshells) or a
1075
+ // pass-with-no-tests switch can make any run "pass", so those keep the review.
1076
+ if (/\|\||&&|[;|`\r\n]|\$\(|pass-?with-?no-?tests|--no-tests|--exit-zero/i.test(command)) return false;
1077
+ return /^((npm|pnpm|yarn)\s+(run\s+)?test\b|node\s+--test\b|npx\s+(jest|vitest|mocha)\b|pytest\b|python3?\s+-m\s+pytest\b|go\s+test\b|cargo\s+test\b)/i.test(command);
1078
+ }
1079
+
1080
+ function memberRunWantsReferee(runArgs, ignite, verifier = '') {
1081
+ return Boolean(ignite) && !hasFlag(runArgs, '--referee') && !hasFlag(runArgs, '--no-referee')
1082
+ && !memberRunHasRealCheck(verifier);
1070
1083
  }
1071
1084
 
1072
1085
  function memberRunCli(args, cwd, { live = false, missionId = '' } = {}) {
@@ -1393,7 +1406,10 @@ function memberRunWith(name, args, { quiet = false, closeTaskIds = [] } = {}) {
1393
1406
  ['--mission', '--mission-id', '--minutes', '--hours', '--industry', '--domain', '--value', '--outcome', '--truth', '--proof', '--base', '--yes-lock', '--yes-lock-parent'],
1394
1407
  ['--worktree', '--shared-checkout', '--no-worktree', '--choose-work', '--auto', '--ignite', '--wait', '--sync']);
1395
1408
  if (ignite && !hasFlag(runArgs, '--complete-on-pass')) runArgs.push('--complete-on-pass');
1396
- if (memberRunWantsReferee(runArgs, ignite)) runArgs.push('--referee');
1409
+ if (memberRunWantsReferee(runArgs, ignite, mission.verifier)) runArgs.push('--referee');
1410
+ // Skipped because a real test is the check: review anyway if the worker
1411
+ // edits what that test command runs.
1412
+ else if (ignite && memberRunHasRealCheck(mission.verifier) && !hasFlag(runArgs, '--no-referee')) runArgs.push('--referee-if-check-edited');
1397
1413
  const budgetSeconds = memberRunBudgetSeconds(args);
1398
1414
  if (!readFlag(runArgs, '--max-ticks', '')) {
1399
1415
  runArgs.push('--max-ticks', budgetSeconds ? String(Math.max(4, Math.ceil(budgetSeconds / 300))) : '1');
@@ -788,6 +788,7 @@ const MISSION_RUN_BOOLEAN_FLAGS = [
788
788
  '--no-claude',
789
789
  '--no-verify',
790
790
  '--referee',
791
+ '--referee-if-check-edited',
791
792
  '--no-referee',
792
793
  '--complete-on-pass',
793
794
  '--no-drain',
@@ -6950,6 +6951,39 @@ function missionProtectedTags(mission, root = process.cwd()) {
6950
6951
  return tags;
6951
6952
  }
6952
6953
 
6954
+ // True when the tick changed what its check command runs: package.json (which
6955
+ // defines npm test) or a file the command names. A passing test the worker
6956
+ // rewrote is not evidence on its own.
6957
+ function tickTouchedCheckDefinition(verifier, before, root = process.cwd()) {
6958
+ const run = (args) => spawnSync('git', args, { cwd: root, encoding: 'utf8' });
6959
+ const head = String(before?.head || '').trim();
6960
+ const changed = [
6961
+ // No starting commit to compare against: every tracked file counts.
6962
+ ...String(run(head ? ['diff', '--name-only', head] : ['ls-files']).stdout || '').split('\n'),
6963
+ ...String(run(['diff', '--name-only', 'HEAD']).stdout || '').split('\n'),
6964
+ ...String(run(['ls-files', '--others', '--exclude-standard']).stdout || '').split('\n'),
6965
+ ].map((file) => file.trim()).filter(Boolean);
6966
+ // npm test runs whatever package.json says, so its paths and folders count too.
6967
+ let checkText = String(verifier || '');
6968
+ if (/^(npm|pnpm|yarn)\s/i.test(checkText.trim())) {
6969
+ try { checkText += ` ${JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))?.scripts?.test || ''}`; } catch {}
6970
+ }
6971
+ // Runners that find tests by name (node --test, jest, pytest, go test) never
6972
+ // list them, so any changed file shaped like a test counts as well.
6973
+ const testShaped = /(^|\/)(tests?|__tests__|specs?|checks?)\/|\.(test|spec)\.[cm]?[jt]sx?$|_test\.(go|py)$|(^|\/)test_[^/]*\.py$|(^|\/)conftest\.py$/i;
6974
+ return changed.some((file) => path.basename(file) === 'package.json'
6975
+ || testShaped.test(file)
6976
+ || checkText.includes(file)
6977
+ || (path.dirname(file) !== '.' && checkText.includes(`${path.dirname(file)}/`)));
6978
+ }
6979
+
6980
+ // The worker's `learned:` line from its receipt, so the lesson reaches the
6981
+ // member log (and the next worker's preloaded memory) without a hand edit.
6982
+ function receiptLearnedLine(text) {
6983
+ const match = String(text || '').match(/^[\s*-]*learned:\s*(.+)$/im);
6984
+ return match ? match[1].trim().slice(0, 240) : undefined;
6985
+ }
6986
+
6953
6987
  function inspectMissionTickProtectedDiff(mission, before, root = process.cwd()) {
6954
6988
  const after = gitWorktreeSnapshot(root);
6955
6989
  if (before?.reason === 'not-git-worktree' && after?.reason === 'not-git-worktree') {
@@ -9153,6 +9187,31 @@ function readBoundedContextFile(filePath, maxChars = 1600) {
9153
9187
  }
9154
9188
  }
9155
9189
 
9190
+ // Code members never call the account-wide tool servers (mail, calendar,
9191
+ // docs), yet loading them cost about 5s per worker start in the member lab.
9192
+ // They keep the project's own .mcp.json; ATRIS_MEMBER_KEEP_MCP=1 keeps all.
9193
+ const CODE_MEMBERS = new Set(['executor', 'builder', 'codex-executor', 'auto-improver', 'navigator', 'validator', 'architect']);
9194
+
9195
+ function claudeRunnerMcpArgs(mission, cwd = process.cwd(), env = process.env) {
9196
+ if (env.ATRIS_MEMBER_KEEP_MCP === '1') return [];
9197
+ if (!CODE_MEMBERS.has(String(mission?.owner || '').trim().toLowerCase())) return [];
9198
+ const args = ['--strict-mcp-config'];
9199
+ const projectConfig = path.join(cwd, '.mcp.json');
9200
+ if (fs.existsSync(projectConfig)) args.push('--mcp-config', projectConfig);
9201
+ // Servers added for this folder with Claude's local scope live in
9202
+ // ~/.claude.json under the folder's path; keep those too.
9203
+ try {
9204
+ const home = env.HOME || os.homedir();
9205
+ const projects = JSON.parse(fs.readFileSync(path.join(home, '.claude.json'), 'utf8'))?.projects || {};
9206
+ // A side checkout's servers are usually registered under the main checkout.
9207
+ const common = spawnSync('git', ['rev-parse', '--git-common-dir'], { cwd, encoding: 'utf8' });
9208
+ const mainRoot = common.status === 0 ? path.dirname(path.resolve(cwd, String(common.stdout || '').trim())) : '';
9209
+ const local = { ...(mainRoot ? projects[mainRoot]?.mcpServers : null), ...projects[path.resolve(cwd)]?.mcpServers };
9210
+ if (Object.keys(local).length) args.push('--mcp-config', JSON.stringify({ mcpServers: local }));
9211
+ } catch {}
9212
+ return args;
9213
+ }
9214
+
9156
9215
  function buildPreloadedMemberContextLines(mission, root = process.cwd()) {
9157
9216
  const owner = String(mission?.owner || '').trim();
9158
9217
  const baseRoot = mission?.worktree_root || mission?.worktree?.path || root;
@@ -9282,7 +9341,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
9282
9341
  ``,
9283
9342
  `## Your task`,
9284
9343
  `Do ONE increment of work toward the stop condition. ONE. No more.`,
9285
- `- ${memberIdentityLine(mission.owner)} Read atris/team/${mission.owner}/MEMBER.md (and SOUL.md if present) before acting, work in that identity, inside its scope and stop rules. After your work, append what you did and what you learned to atris/team/${mission.owner}/logs/<today's date>.md.`,
9344
+ `- ${memberIdentityLine(mission.owner)} Your member files are preloaded above; open atris/team/${mission.owner}/MEMBER.md only if that copy is marked truncated. Work in that identity, inside its scope and stop rules. Atris writes your member log from your receipt, so end the receipt with one \`learned: <one line>\` line instead of editing the log.`,
9286
9345
  `- FIRST: inspect current mission/task state before acting. Read the relevant files, run \`atris mission status ${mission.id}\`, \`git status\`, or \`atris task list\` as needed so you know what's already done.`,
9287
9346
  `- Pick the smallest concrete action that moves the mission forward.`,
9288
9347
  `- Before acting, state your single next move in one sentence.`,
@@ -9290,7 +9349,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
9290
9349
  `- The tick is not done until the verify step passes. If a frozen verifier exists, the harness runs it, but that does not replace the real-surface proof below. If no frozen verifier exists, finish by actually running the surface you changed on this computer and paste the real command output into your receipt.`,
9291
9350
  `- After that one verify step, STOP. Never start a second slice in one tick.`,
9292
9351
  `- Write the tick summary in operator language: what changed and what it buys or costs in plain words, with no flags, task ids, or code identifiers.`,
9293
- `- Propose the next needle-mover: include a \`next_push: <one concrete feature or optimization to build next>\` line in your receipt and member log so the team compounds exponentially.`,
9352
+ `- Propose the next needle-mover: include a \`next_push: <one concrete feature or optimization to build next>\` line in your receipt so the team compounds exponentially.`,
9294
9353
  ``,
9295
9354
  `## No claimable work? Work the backstop ladder`,
9296
9355
  `If the mission and task state offer no claimable work, inspect these rungs in order. Pick the FIRST rung with real fuel and do ONE bounded slice:`,
@@ -9686,6 +9745,7 @@ function spawnGenericRunnerTick(mission, opts) {
9686
9745
  result: finalText,
9687
9746
  summary: usefulClaudeReceiptSummary(finalText || errStr, ok ? 'no-text' : 'error'),
9688
9747
  receipt_text: cappedClaudeReceiptText(finalText || errStr),
9748
+ learned: receiptLearnedLine(finalText),
9689
9749
  duration_total_ms: Date.now() - startedAt,
9690
9750
  num_turns: null,
9691
9751
  stop_reason: null,
@@ -9775,6 +9835,7 @@ function spawnClaudeTick(mission, opts) {
9775
9835
  ];
9776
9836
  if (model) args.push('--model', model);
9777
9837
  if (mission.roster_effort) args.push('--effort', mission.roster_effort);
9838
+ args.push(...claudeRunnerMcpArgs(mission, cwd));
9778
9839
  if (sessionMode === 'set') args.push('--session-id', sessionId);
9779
9840
  else if (sessionMode === 'resume') args.push('--resume', sessionId);
9780
9841
 
@@ -9881,6 +9942,7 @@ function spawnClaudeTick(mission, opts) {
9881
9942
  result: finalText,
9882
9943
  summary: usefulClaudeReceiptSummary(finalText, ok ? 'no-text' : 'error'),
9883
9944
  receipt_text: cappedClaudeReceiptText(finalText),
9945
+ learned: receiptLearnedLine(finalText),
9884
9946
  api_equivalent_estimate: costEstimate,
9885
9947
  duration_api_ms: durationApiMs,
9886
9948
  duration_total_ms: Date.now() - startedAt,
@@ -10124,6 +10186,7 @@ async function parseAndValidateMissionRunPhase(args) {
10124
10186
  const skipClaude = hasFlag(args, '--no-claude');
10125
10187
  const verifyEach = !hasFlag(args, '--no-verify');
10126
10188
  const refereeEach = hasFlag(args, '--referee') && !hasFlag(args, '--no-referee');
10189
+ const refereeIfCheckEdited = hasFlag(args, '--referee-if-check-edited') && !hasFlag(args, '--no-referee');
10127
10190
  const refereeEngine = readFlag(args, '--referee-engine', '');
10128
10191
  const completeOnPass = hasFlag(args, '--complete-on-pass');
10129
10192
  const skipDrain = hasFlag(args, '--no-drain');
@@ -10161,6 +10224,7 @@ async function parseAndValidateMissionRunPhase(args) {
10161
10224
  skipClaude,
10162
10225
  verifyEach,
10163
10226
  refereeEach,
10227
+ refereeIfCheckEdited,
10164
10228
  refereeEngine,
10165
10229
  completeOnPass,
10166
10230
  skipDrain,
@@ -10995,6 +11059,7 @@ async function executeMissionRunTicksPhase(context) {
10995
11059
  verifier: verifierResult ? (verifierResult.passed ? 'passed' : 'failed') : 'not_run',
10996
11060
  summary: tickSummaryText ? tickSummaryText.slice(0, 240) : undefined,
10997
11061
  next_push: result.next_push ? String(result.next_push).slice(0, 180) : undefined,
11062
+ learned: result?.claude?.learned || receiptLearnedLine(result?.claude?.result || result?.claude?.receipt_text),
10998
11063
  feedback: verifierResult && !verifierResult.passed ? String(mission.last_check_feedback || '').slice(0, 240) : undefined,
10999
11064
  receipt: receiptPath,
11000
11065
  }, cwd);
@@ -11192,6 +11257,7 @@ async function verifyMissionRunTickPhase(context) {
11192
11257
  && (
11193
11258
  !currentTick.frozen.verifier
11194
11259
  || context.refereeEach
11260
+ || (context.refereeIfCheckEdited && tickTouchedCheckDefinition(currentTick.frozen.verifier, currentTick.tickWorktreeBefore, cwd))
11195
11261
  || (!currentTick.storedVerifier && !process.env.NODE_TEST_CONTEXT)
11196
11262
  );
11197
11263
  if (shouldRunReferee) {
@@ -12860,6 +12926,9 @@ function missionCommand(args) {
12860
12926
  }
12861
12927
 
12862
12928
  module.exports = {
12929
+ receiptLearnedLine,
12930
+ tickTouchedCheckDefinition,
12931
+ claudeRunnerMcpArgs,
12863
12932
  crystallizeMemberSkillLesson,
12864
12933
  listSiblingWorkspaceMissionHints,
12865
12934
  missionProtectedLaneHold,
package/lib/zen-apply.js CHANGED
@@ -31,6 +31,7 @@ const {
31
31
  sideCopyLines,
32
32
  scheduledCopies,
33
33
  runningCopies,
34
+ shortPlace,
34
35
  untrackedFiles,
35
36
  ABANDONED_DAYS,
36
37
  abandonedDaysFromEnv,
@@ -81,6 +82,13 @@ function identityEnv(root) {
81
82
  return env;
82
83
  }
83
84
 
85
+ // Where zen put your copy of a clashing note, said from the repo root so the
86
+ // line fits the pane, or from home when it sits outside the repo.
87
+ function savedPlace(root, file) {
88
+ const rel = path.relative(root, file);
89
+ return rel && !rel.startsWith('..') && !path.isAbsolute(rel) ? rel : shortPlace(file);
90
+ }
91
+
84
92
  function archiveStashes(root, tag) {
85
93
  const shas = (gitOut(['stash', 'list', '--format=%H'], root) || '').split('\n').filter(Boolean);
86
94
  if (!shas.length) return null;
@@ -444,7 +452,7 @@ function moveKeepingNotes(root, args, target, tag, from = 'HEAD') {
444
452
  for (const p of clashed) {
445
453
  const mine = path.join(dir, 'mine', p);
446
454
  if (isJournal(p) && joinJournal(path.join(root, p), mine)) notes.push(`joined your ${p} onto the new one`);
447
- else if (!(fromCommit && lineRefsOnly(root, fromCommit, p, mine))) notes.push(`kept the new ${p}; your copy is in ${mine}`);
455
+ else if (!(fromCommit && lineRefsOnly(root, fromCommit, p, mine))) notes.push(`kept the new ${p}; your copy is in ${savedPlace(root, mine)}`);
448
456
  }
449
457
  for (const p of missed) {
450
458
  const mine = path.join(dir, 'mine', p);
@@ -452,9 +460,9 @@ function moveKeepingNotes(root, args, target, tag, from = 'HEAD') {
452
460
  if (to && fromCommit && followMove(root, fromCommit, p, to, mine)) {
453
461
  notes.push(`${p} moved to ${to}, and your edits went with it`);
454
462
  } else if (to) {
455
- notes.push(`${p} moved to ${to} and your edits clash there; your copy is in ${fs.existsSync(mine) ? mine : patchFile}`);
463
+ notes.push(`${p} moved to ${to} and your edits clash there; your copy is in ${savedPlace(root, fs.existsSync(mine) ? mine : patchFile)}`);
456
464
  } else {
457
- notes.push(`${p} no longer fits the new changes; your copy is in ${fs.existsSync(mine) ? mine : patchFile}`);
465
+ notes.push(`${p} no longer fits the new changes; your copy is in ${savedPlace(root, fs.existsSync(mine) ? mine : patchFile)}`);
458
466
  }
459
467
  }
460
468
  }
@@ -464,7 +472,7 @@ function moveKeepingNotes(root, args, target, tag, from = 'HEAD') {
464
472
  // A copy that matches the incoming file loses nothing, so say nothing.
465
473
  if (sameBytes(saved, path.join(root, p))) fs.rmSync(saved, { force: true });
466
474
  else if (isJournal(p) && joinJournal(path.join(root, p), saved)) notes.push(`joined your ${p} onto the new one`);
467
- else notes.push(`kept the new ${p}; your copy is in ${saved}`);
475
+ else notes.push(`kept the new ${p}; your copy is in ${savedPlace(root, saved)}`);
468
476
  } else {
469
477
  fs.mkdirSync(path.dirname(path.join(root, p)), { recursive: true });
470
478
  fs.copyFileSync(saved, path.join(root, p));
package/lib/zen.js CHANGED
@@ -646,4 +646,5 @@ module.exports = {
646
646
  readTeamState,
647
647
  scheduledCopies,
648
648
  runningCopies,
649
+ shortPlace,
649
650
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.74.12",
3
+ "version": "3.74.13",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "mcpName": "io.github.atrislabs/atris",
6
6
  "main": "bin/atris.js",