atris 3.74.11 → 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.
@@ -124,6 +124,7 @@ If new evidence changes the task, stop and add a task note instead of forcing co
124
124
  7. **Anti-slop aware**: Read `atris/policies/ANTISLOP.md` before writing. No sparkles, no filler, no purple prose.
125
125
  8. **Stay in scope**: Only touch files listed in the task. If you need to change something outside scope, stop and flag it. That's a new task.
126
126
  9. **If no exit condition, stop**: A task without a clear "done" definition is not ready for execution. Send it back to navigator.
127
+ 10. **Make exactly what was asked**: Match the existing output shape, names, and style. Add no fields, flags, or features the job did not ask for; name them as a next step instead.
127
128
 
128
129
  ---
129
130
 
@@ -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',
@@ -976,9 +977,22 @@ function inferMemberRunVerifier(missionText, args = [], cwd = process.cwd()) {
976
977
  if (testFileMatch && fs.existsSync(path.join(cwd, testFileMatch[1]))) {
977
978
  return `node --test ${testFileMatch[1]}`;
978
979
  }
980
+ // A job that names the project's own test command ("keep npm test green")
981
+ // chose that check; it is not a silent broad-suite default.
982
+ const npmMatch = text.match(/\bnpm\s+(run\s+)?test(?![\w:-]|\.\w)/i);
983
+ if (npmMatch && projectHasTestScript(cwd)) return npmMatch[1] ? 'npm run test' : 'npm test';
979
984
  return '';
980
985
  }
981
986
 
987
+ function projectHasTestScript(cwd) {
988
+ try {
989
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
990
+ return Boolean(pkg && pkg.scripts && pkg.scripts.test);
991
+ } catch {
992
+ return false;
993
+ }
994
+ }
995
+
982
996
  function buildMemberRunStartArgs(owner, missionText, args = [], cwd = process.cwd()) {
983
997
  const resolvedVerifier = inferMemberRunVerifier(missionText, args, cwd);
984
998
  const startArgs = [
@@ -1023,7 +1037,10 @@ function buildMemberRunStartArgs(owner, missionText, args = [], cwd = process.cw
1023
1037
  // Default to an isolated worktree only when there is a git repo to cut it
1024
1038
  // from. A plain (non-git) workspace would fail every mission start with
1025
1039
  // "fatal: not a git repository", degrade to the shared checkout instead.
1026
- if (!hasFlag(args, '--shared-checkout') && !hasFlag(args, '--no-worktree') && insideGitRepo(cwd)) {
1040
+ // A job that says "do not commit" wants the change in the person's own
1041
+ // files; a side checkout would hide uncommitted work where they never see it.
1042
+ const forbidsCommit = !hasFlag(args, '--worktree') && require('../lib/member-save-work').jobForbidsCommit(missionText);
1043
+ if (!forbidsCommit && !hasFlag(args, '--shared-checkout') && !hasFlag(args, '--no-worktree') && insideGitRepo(cwd)) {
1027
1044
  startArgs.push('--worktree');
1028
1045
  }
1029
1046
  startArgs.push('--json');
@@ -1048,9 +1065,21 @@ function startMemberRunMission(name, missionText, args = []) {
1048
1065
  }
1049
1066
 
1050
1067
  // An ignited run asks a second engine to review the work by default, so every
1051
- // run ends with a verdict. --no-referee skips it; an explicit --referee is kept.
1052
- function memberRunWantsReferee(runArgs, ignite) {
1053
- 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);
1054
1083
  }
1055
1084
 
1056
1085
  function memberRunCli(args, cwd, { live = false, missionId = '' } = {}) {
@@ -1377,7 +1406,10 @@ function memberRunWith(name, args, { quiet = false, closeTaskIds = [] } = {}) {
1377
1406
  ['--mission', '--mission-id', '--minutes', '--hours', '--industry', '--domain', '--value', '--outcome', '--truth', '--proof', '--base', '--yes-lock', '--yes-lock-parent'],
1378
1407
  ['--worktree', '--shared-checkout', '--no-worktree', '--choose-work', '--auto', '--ignite', '--wait', '--sync']);
1379
1408
  if (ignite && !hasFlag(runArgs, '--complete-on-pass')) runArgs.push('--complete-on-pass');
1380
- 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');
1381
1413
  const budgetSeconds = memberRunBudgetSeconds(args);
1382
1414
  if (!readFlag(runArgs, '--max-ticks', '')) {
1383
1415
  runArgs.push('--max-ticks', budgetSeconds ? String(Math.max(4, Math.ceil(budgetSeconds / 300))) : '1');
@@ -1434,6 +1466,17 @@ function memberRunWith(name, args, { quiet = false, closeTaskIds = [] } = {}) {
1434
1466
  result.ask = `${name} is waiting on you: ${payload.human_asks[0]}`;
1435
1467
  result.next_command = payload.next_command || 'atris decide';
1436
1468
  }
1469
+ // Passed but never committed: save the loose work on the member's branch so
1470
+ // the branch holds it. Only in the member's own side checkout, never the main one.
1471
+ const worktreePath = finalMission.worktree?.path || mission.worktree?.path || '';
1472
+ if (ok && started && worktreePath && finalMission.verifier_result?.passed === true) {
1473
+ result.saved_work = require('../lib/member-save-work').saveLeftoverMemberWork({
1474
+ worktreePath, member: name, objective: finalMission.objective || mission.objective,
1475
+ });
1476
+ if (result.saved_work.saved && !asJson && !quiet) {
1477
+ console.log(`saved ${result.saved_work.files.length} file(s) ${name} left unsaved onto its branch`);
1478
+ }
1479
+ }
1437
1480
  // A finished ignited run whose check passed hands its branch to GitHub
1438
1481
  // auto-merge, only past the local gates; any held gate leaves the branch.
1439
1482
  if (ignite && ok && started && !reviewAsk && finalMission.verifier_result?.passed === true) {
@@ -9946,13 +9989,24 @@ async function memberChat(name, ...rest) {
9946
9989
 
9947
9990
  const IDEA_ARCHETYPE_RULES = [
9948
9991
  {
9949
- slugs: ['auto-improver', 'improver'],
9950
- pattern: /\b(repeat|recurring|broken\s+test|flaky|error|regression|pain|fix\s+bug|self[- ]improv|crash)\b/i,
9992
+ // A job that opens by asking where or which, or forbids source edits, is
9993
+ // a read-and-answer job even when it mentions a bug.
9994
+ slugs: ['navigator'],
9995
+ // Wins wherever it matches: the question shape decides the whole job.
9996
+ leading: true,
9997
+ pattern: /^(?![\s\S]*(?<!not\s|n't\s)\b(fix|repair|implement|update|change|edit|add|rename|modify)\b)\s*(find|locate|which|count|trace|identify)\b|\bdo\s+not\s+(edit|change|modify)\s+(any\s+)?source\b/i,
9998
+ reason: 'reads the code and answers where and which questions',
9999
+ },
10000
+ {
10001
+ // The shipped `improver` proposes team and workflow changes; it is not a
10002
+ // code fixer, so fix jobs go to auto-improver or fall back to the builder.
10003
+ slugs: ['auto-improver'],
10004
+ pattern: /\b(repeat|recurring|broken|flaky|errors?|regressions?|pain|fix(es|ed|ing)?|bugs?|fail(s|ing|ed|ure)?|self[- ]improv|crash(es|ed|ing)?)\b/i,
9951
10005
  reason: 'handles recurring errors, regressions, and test fixes',
9952
10006
  },
9953
10007
  {
9954
10008
  slugs: ['validator', 'orb'],
9955
- pattern: /\b(review|audit|validate|verify|check\s+diff|quality|slop|security)\b/i,
10009
+ pattern: /\b(code\s+review|review|audit|validate|verify|check\s+diff|quality|slop|security)\b/i,
9956
10010
  reason: 'reviews changes and blocks weak work before merge',
9957
10011
  },
9958
10012
  {
@@ -9988,8 +10042,10 @@ const ARCHETYPE_FOR_JOB = { build: 'executor', review: 'validator', search: 'res
9988
10042
  const EXPERIMENT_STATUSES = new Set(['experimental', 'one-off']);
9989
10043
 
9990
10044
  function ideaRuleFor(slug, record, text) {
9991
- const own = IDEA_ARCHETYPE_RULES.find((rule) => rule.slugs.includes(slug));
9992
- if (own && own.pattern.test(text)) return { rule: own, own: true };
10045
+ const ownRules = IDEA_ARCHETYPE_RULES.filter((rule) => rule.slugs.includes(slug));
10046
+ const matched = ownRules.find((rule) => rule.pattern.test(text));
10047
+ if (matched) return { rule: matched, own: true };
10048
+ const own = ownRules[0];
9993
10049
  const { autoJobForMember } = require('../lib/member-engine');
9994
10050
  const archetype = ARCHETYPE_FOR_JOB[autoJobForMember(slug, record.role)];
9995
10051
  const rule = IDEA_ARCHETYPE_RULES.find((r) => r.slugs.includes(archetype)) || null;
@@ -10000,7 +10056,15 @@ function ideaRuleFor(slug, record, text) {
10000
10056
  // specific jobs to the general build job, so "where is the code" is a
10001
10057
  // navigator job even though "code" is also a build word.
10002
10058
  function ideaJobRule(text) {
10003
- return IDEA_ARCHETYPE_RULES.find((rule) => rule.pattern.test(text)) || null;
10059
+ // The job word that comes first names the job: "review the bug fix" is a
10060
+ // review, "fix the review page" is a fix. Ties keep list order.
10061
+ let best = null;
10062
+ for (const rule of IDEA_ARCHETYPE_RULES) {
10063
+ const match = rule.pattern.exec(text);
10064
+ const index = match ? (rule.leading ? -1 : match.index) : null;
10065
+ if (match && (!best || index < best.index)) best = { rule, index };
10066
+ }
10067
+ return best ? best.rule : null;
10004
10068
  }
10005
10069
 
10006
10070
  // 3 = named in the idea, 2 = the job is the member's own, 1 = the member
@@ -10059,7 +10123,12 @@ function pickMemberForIdea(ideaText, root = process.cwd(), preferredMember = '')
10059
10123
  .toLowerCase()
10060
10124
  .split(/[^a-z0-9]+/)
10061
10125
  .filter((w) => w.length >= 4);
10062
- const jobRule = ideaJobRule(text);
10126
+ let jobRule = ideaJobRule(text);
10127
+ // A job nobody on this team owns goes to the builder rule, not to whoever
10128
+ // shares the most words with it.
10129
+ if (jobRule && !members.some((m) => ideaRuleFor(memberSlugFromRecord(m), m, text).rule === jobRule)) {
10130
+ jobRule = IDEA_ARCHETYPE_RULES.find((rule) => rule.slugs.includes('executor')) || jobRule;
10131
+ }
10063
10132
 
10064
10133
  let best = null;
10065
10134
  for (const record of members) {
@@ -10073,6 +10142,9 @@ function pickMemberForIdea(ideaText, root = process.cwd(), preferredMember = '')
10073
10142
 
10074
10143
  const memberRule = ideaRuleFor(slug, record, text);
10075
10144
  const fit = ideaJobFit(named, memberRule, jobRule);
10145
+ // A reviewer never patches, so it only takes review jobs unless named.
10146
+ const reviewRule = IDEA_ARCHETYPE_RULES.find((rule) => rule.slugs.includes('validator'));
10147
+ const effectiveTier = !named && memberRule.rule === reviewRule && jobRule !== reviewRule ? Math.max(tier, 1) : tier;
10076
10148
  if (fit > 0 && memberRule.rule) {
10077
10149
  score += 30;
10078
10150
  matchedReason = memberRule.rule.reason;
@@ -10105,7 +10177,7 @@ function pickMemberForIdea(ideaText, root = process.cwd(), preferredMember = '')
10105
10177
  role: record.role || 'Team Member',
10106
10178
  reason: matchedReason,
10107
10179
  score,
10108
- tier,
10180
+ tier: effectiveTier,
10109
10181
  fit,
10110
10182
  };
10111
10183
  if (outranksDispatch(candidate, best)) best = candidate;
@@ -10438,7 +10510,7 @@ function resolveMemberEngineSeat(memberSlug, root = process.cwd()) {
10438
10510
 
10439
10511
  function buildMemberSubagentShell(owner, role, engineId, objective, root = process.cwd()) {
10440
10512
  const cleanObjective = compactSentence(objective || `Advance ${owner} roadmap`, 160);
10441
- const verify = inferMemberRunVerifier(owner, cleanObjective, root);
10513
+ const verify = inferMemberRunVerifier(cleanObjective, [], root);
10442
10514
  return {
10443
10515
  role: `${owner} (${role || 'Team Member'})`,
10444
10516
  member: owner,
@@ -12068,6 +12140,7 @@ module.exports = {
12068
12140
  wakeBootLines,
12069
12141
  buildMemberRunStartArgs,
12070
12142
  inferMemberRunVerifier,
12143
+ buildMemberSubagentShell,
12071
12144
  pickMemberForIdea,
12072
12145
  appendMemberGoalLog,
12073
12146
  appendProjectLog,
@@ -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',
@@ -2620,7 +2621,8 @@ const MISSION_BARE_COMMAND_WORDS = new Set(['go', 'keep', 'going', 'do', 'it', '
2620
2621
  function missionObjectiveIsJunk(value) {
2621
2622
  const objective = String(value || '').trim();
2622
2623
  if (!objective || objective.startsWith('-')) return true;
2623
- if (/[\u0000-\u001f\u007f-\u009f\ufffd]/u.test(objective)) return true;
2624
+ // Line breaks and tabs are normal in a pasted bug report; other control bytes are junk.
2625
+ if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\ufffd]/u.test(objective)) return true;
2624
2626
  if (objective.replace(/\s/g, '').length < 8) return true;
2625
2627
  const words = objective.toLowerCase().match(/[a-z]+/g) || [];
2626
2628
  return words.length > 0
@@ -6949,6 +6951,39 @@ function missionProtectedTags(mission, root = process.cwd()) {
6949
6951
  return tags;
6950
6952
  }
6951
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
+
6952
6987
  function inspectMissionTickProtectedDiff(mission, before, root = process.cwd()) {
6953
6988
  const after = gitWorktreeSnapshot(root);
6954
6989
  if (before?.reason === 'not-git-worktree' && after?.reason === 'not-git-worktree') {
@@ -9152,6 +9187,31 @@ function readBoundedContextFile(filePath, maxChars = 1600) {
9152
9187
  }
9153
9188
  }
9154
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
+
9155
9215
  function buildPreloadedMemberContextLines(mission, root = process.cwd()) {
9156
9216
  const owner = String(mission?.owner || '').trim();
9157
9217
  const baseRoot = mission?.worktree_root || mission?.worktree?.path || root;
@@ -9281,7 +9341,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
9281
9341
  ``,
9282
9342
  `## Your task`,
9283
9343
  `Do ONE increment of work toward the stop condition. ONE. No more.`,
9284
- `- ${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.`,
9285
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.`,
9286
9346
  `- Pick the smallest concrete action that moves the mission forward.`,
9287
9347
  `- Before acting, state your single next move in one sentence.`,
@@ -9289,7 +9349,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
9289
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.`,
9290
9350
  `- After that one verify step, STOP. Never start a second slice in one tick.`,
9291
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.`,
9292
- `- 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.`,
9293
9353
  ``,
9294
9354
  `## No claimable work? Work the backstop ladder`,
9295
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:`,
@@ -9304,6 +9364,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
9304
9364
  ``,
9305
9365
  `## Landing rule`,
9306
9366
  `Safe-lane work (internal code, tests, docs, and proofs) must be committed and pushed or landed WITHOUT asking a human. Protected lanes (external sends, payments, credentials, auth/CSP/iframe/sandbox surfaces, and deletes of user data) are never self-landed: file a decision card instead.`,
9367
+ `The objective's own limits win over this rule: if it says do not commit, touch only certain files, or leave something alone, do exactly that.`,
9307
9368
  `After landing a ladder-sourced slice, append a judge card to atris/status/for-you.md in this SHIPPED format:`,
9308
9369
  missionShippedCard(),
9309
9370
  ``,
@@ -9684,6 +9745,7 @@ function spawnGenericRunnerTick(mission, opts) {
9684
9745
  result: finalText,
9685
9746
  summary: usefulClaudeReceiptSummary(finalText || errStr, ok ? 'no-text' : 'error'),
9686
9747
  receipt_text: cappedClaudeReceiptText(finalText || errStr),
9748
+ learned: receiptLearnedLine(finalText),
9687
9749
  duration_total_ms: Date.now() - startedAt,
9688
9750
  num_turns: null,
9689
9751
  stop_reason: null,
@@ -9773,6 +9835,7 @@ function spawnClaudeTick(mission, opts) {
9773
9835
  ];
9774
9836
  if (model) args.push('--model', model);
9775
9837
  if (mission.roster_effort) args.push('--effort', mission.roster_effort);
9838
+ args.push(...claudeRunnerMcpArgs(mission, cwd));
9776
9839
  if (sessionMode === 'set') args.push('--session-id', sessionId);
9777
9840
  else if (sessionMode === 'resume') args.push('--resume', sessionId);
9778
9841
 
@@ -9879,6 +9942,7 @@ function spawnClaudeTick(mission, opts) {
9879
9942
  result: finalText,
9880
9943
  summary: usefulClaudeReceiptSummary(finalText, ok ? 'no-text' : 'error'),
9881
9944
  receipt_text: cappedClaudeReceiptText(finalText),
9945
+ learned: receiptLearnedLine(finalText),
9882
9946
  api_equivalent_estimate: costEstimate,
9883
9947
  duration_api_ms: durationApiMs,
9884
9948
  duration_total_ms: Date.now() - startedAt,
@@ -10122,6 +10186,7 @@ async function parseAndValidateMissionRunPhase(args) {
10122
10186
  const skipClaude = hasFlag(args, '--no-claude');
10123
10187
  const verifyEach = !hasFlag(args, '--no-verify');
10124
10188
  const refereeEach = hasFlag(args, '--referee') && !hasFlag(args, '--no-referee');
10189
+ const refereeIfCheckEdited = hasFlag(args, '--referee-if-check-edited') && !hasFlag(args, '--no-referee');
10125
10190
  const refereeEngine = readFlag(args, '--referee-engine', '');
10126
10191
  const completeOnPass = hasFlag(args, '--complete-on-pass');
10127
10192
  const skipDrain = hasFlag(args, '--no-drain');
@@ -10159,6 +10224,7 @@ async function parseAndValidateMissionRunPhase(args) {
10159
10224
  skipClaude,
10160
10225
  verifyEach,
10161
10226
  refereeEach,
10227
+ refereeIfCheckEdited,
10162
10228
  refereeEngine,
10163
10229
  completeOnPass,
10164
10230
  skipDrain,
@@ -10993,6 +11059,7 @@ async function executeMissionRunTicksPhase(context) {
10993
11059
  verifier: verifierResult ? (verifierResult.passed ? 'passed' : 'failed') : 'not_run',
10994
11060
  summary: tickSummaryText ? tickSummaryText.slice(0, 240) : undefined,
10995
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),
10996
11063
  feedback: verifierResult && !verifierResult.passed ? String(mission.last_check_feedback || '').slice(0, 240) : undefined,
10997
11064
  receipt: receiptPath,
10998
11065
  }, cwd);
@@ -11190,6 +11257,7 @@ async function verifyMissionRunTickPhase(context) {
11190
11257
  && (
11191
11258
  !currentTick.frozen.verifier
11192
11259
  || context.refereeEach
11260
+ || (context.refereeIfCheckEdited && tickTouchedCheckDefinition(currentTick.frozen.verifier, currentTick.tickWorktreeBefore, cwd))
11193
11261
  || (!currentTick.storedVerifier && !process.env.NODE_TEST_CONTEXT)
11194
11262
  );
11195
11263
  if (shouldRunReferee) {
@@ -12858,6 +12926,9 @@ function missionCommand(args) {
12858
12926
  }
12859
12927
 
12860
12928
  module.exports = {
12929
+ receiptLearnedLine,
12930
+ tickTouchedCheckDefinition,
12931
+ claudeRunnerMcpArgs,
12861
12932
  crystallizeMemberSkillLesson,
12862
12933
  listSiblingWorkspaceMissionHints,
12863
12934
  missionProtectedLaneHold,
@@ -446,7 +446,14 @@ function landLocally({ headSha, branch, cwd, pinnedTree }) {
446
446
  function autolandMemberRun({ mission, cwd, summary = '', workerEngine = '', loadTask = defaultLoadTask, closeTaskIds = [] } = {}) {
447
447
  if (!mission || typeof mission !== 'object') return held('mission', 'the mission could not be read');
448
448
  if (mission.verifier_result?.passed !== true) return held('check', 'the check did not pass');
449
- if (!policyOn(cwd)) return held('policy', 'self-landing is not switched on in the main checkout');
449
+ if (!policyOn(cwd)) {
450
+ // Say where the work is and the one command that brings it in; "self-landing
451
+ // is off" alone left people thinking the fix was already in their files.
452
+ const branch = String(mission.worktree?.branch || '').trim();
453
+ const gate = held('policy', 'self-landing is not switched on in the main checkout');
454
+ if (!branch || branch === mainCheckoutBranch(cwd)) return gate;
455
+ return { ...gate, branch, line: `your files are not changed yet: the work is saved on branch ${branch}. bring it in: git merge ${branch}` };
456
+ }
450
457
 
451
458
  const missionLane = protectedLane({ ...mission, title: mission.objective });
452
459
  if (missionLane) return held('protected', `this mission sits in a protected area (${missionLane})`);
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+
7
+ // A member that passed its check but never committed leaves its work loose in
8
+ // a side checkout that cleanup later deletes (member lab 2026-10-24: navigator
9
+ // and validator wrote the right files, said done, committed nothing). Commit
10
+ // what is left so the branch holds the work. Atris's own state stays out.
11
+ function git(args, cwd) {
12
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
13
+ return { ok: result.status === 0, stdout: String(result.stdout || '').trim() };
14
+ }
15
+
16
+ function jobForbidsCommit(text) {
17
+ return /\b(do\s+not|don't|never|without|no)\s+(?:(?:create|make|add|any|new|a|an|more)\s+){0,3}commit/i.test(String(text || ''));
18
+ }
19
+
20
+ // Files that look like secrets never ride an automatic commit.
21
+ const SECRET_PATH = /(^|\/)(\.(aws|ssh|gnupg|docker|kube)\/|\.env(\.[^/]*)?$|\.npmrc$|\.netrc$|credentials?$|[^/]*credentials?[^/]*\.(json|ya?ml|txt)$|secrets?\.(json|ya?ml|txt|env)$|[^/]*\.(pem|key|p12|pfx)$|id_(rsa|ed25519|ecdsa)[^/]*$|service[-_]account[^/]*\.json$)/i;
22
+
23
+ function saveLeftoverMemberWork({ worktreePath, member = 'member', objective = '' } = {}) {
24
+ if (!worktreePath) return { saved: false, reason: 'no_worktree' };
25
+ // The job's own words win: "do not commit" means leave the changes loose.
26
+ if (jobForbidsCommit(objective)) return { saved: false, reason: 'job_forbids_commit' };
27
+ if (!git(['rev-parse', '--is-inside-work-tree'], worktreePath).ok) return { saved: false, reason: 'not_git' };
28
+ // Only a linked side checkout: its git dir differs from the shared one. The
29
+ // person's main checkout is never committed into.
30
+ // Plain --git-dir/--git-common-dir work on old git too; resolve both against
31
+ // the checkout, since either may come back relative.
32
+ const gitDirOut = git(['rev-parse', '--git-dir'], worktreePath).stdout;
33
+ const commonDirOut = git(['rev-parse', '--git-common-dir'], worktreePath).stdout;
34
+ if (!gitDirOut || !commonDirOut) return { saved: false, reason: 'not_side_checkout' };
35
+ const real = (dir) => { try { return fs.realpathSync(path.resolve(worktreePath, dir)); } catch { return ''; } };
36
+ const gitDir = real(gitDirOut);
37
+ const commonDir = real(commonDirOut);
38
+ if (!gitDir || !commonDir || gitDir === commonDir) return { saved: false, reason: 'not_side_checkout' };
39
+ const add = git(['add', '-A', '--', '.', ':(exclude).atris', ':(exclude).agent-worktrees'], worktreePath);
40
+ if (!add.ok) return { saved: false, reason: 'add_failed' };
41
+ git(['reset', '-q', '--', '.atris', '.agent-worktrees'], worktreePath);
42
+ const stagedAll = git(['diff', '--cached', '--name-only'], worktreePath).stdout.split('\n').filter(Boolean);
43
+ const skipped = stagedAll.filter((file) => SECRET_PATH.test(file));
44
+ if (skipped.length) git(['reset', '-q', '--', ...skipped], worktreePath);
45
+ const staged = stagedAll.filter((file) => !SECRET_PATH.test(file));
46
+ if (!staged.length) return { saved: false, reason: 'nothing_left', ...(skipped.length ? { skipped } : {}) };
47
+ const title = String(objective || '').split('\n').map((line) => line.trim()).find(Boolean) || 'finished work';
48
+ const message = `${member}: ${title.slice(0, 72)}\n\nSaved by atris after the check passed; the member left these changes uncommitted.`;
49
+ const commit = git(['commit', '-q', '-m', message], worktreePath);
50
+ if (!commit.ok) return { saved: false, reason: 'commit_failed', files: staged };
51
+ return { saved: true, files: staged, ...(skipped.length ? { skipped } : {}), sha: git(['rev-parse', 'HEAD'], worktreePath).stdout };
52
+ }
53
+
54
+ module.exports = { jobForbidsCommit, saveLeftoverMemberWork };
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.11",
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",