atris 3.68.2 → 3.68.4

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.
@@ -103,6 +103,8 @@ const {
103
103
  normalizeHumanAsks,
104
104
  openHumanAsks,
105
105
  } = require('../lib/mission-human-asks');
106
+ const { missionLinkedTaskGate } = require('../lib/mission-task-gate');
107
+ const { memberApprovalAsks, runBlockingAsks } = require('../lib/member-approval');
106
108
  const {
107
109
  inspectMissionProtectedDiff,
108
110
  prepareMissionGitGuard,
@@ -730,6 +732,7 @@ const MISSION_RUN_VALUE_FLAGS = [
730
732
  '--stop',
731
733
  '--model',
732
734
  '--engine',
735
+ '--referee-engine',
733
736
  '--budget',
734
737
  '--repo',
735
738
  '--native-goal-status',
@@ -750,6 +753,8 @@ const MISSION_RUN_BOOLEAN_FLAGS = [
750
753
  '--land',
751
754
  '--no-claude',
752
755
  '--no-verify',
756
+ '--referee',
757
+ '--no-referee',
753
758
  '--complete-on-pass',
754
759
  '--no-drain',
755
760
  '--create-next',
@@ -1621,6 +1626,28 @@ function appendJsonLine(file, payload) {
1621
1626
  fs.appendFileSync(file, JSON.stringify(payload) + '\n', 'utf8');
1622
1627
  }
1623
1628
 
1629
+ function resolveMemberWorkspaceRoot(root = process.cwd()) {
1630
+ const resolved = path.resolve(root || process.cwd());
1631
+ const agentWorktreesMarker = `${path.sep}.agent-worktrees${path.sep}`;
1632
+ const agentIdx = resolved.indexOf(agentWorktreesMarker);
1633
+ if (agentIdx > 0) {
1634
+ const parentDir = resolved.slice(0, agentIdx);
1635
+ const after = resolved.slice(agentIdx + agentWorktreesMarker.length);
1636
+ const repoName = after.split(path.sep)[0];
1637
+ if (repoName) {
1638
+ const primaryRoot = path.join(parentDir, repoName);
1639
+ if (fs.existsSync(path.join(primaryRoot, 'atris'))) return primaryRoot;
1640
+ }
1641
+ }
1642
+ const marker = `${path.sep}.atris${path.sep}worktrees${path.sep}`;
1643
+ const idx = resolved.indexOf(marker);
1644
+ if (idx > 0) {
1645
+ const parentRoot = resolved.slice(0, idx);
1646
+ if (fs.existsSync(path.join(parentRoot, 'atris'))) return parentRoot;
1647
+ }
1648
+ return resolved;
1649
+ }
1650
+
1624
1651
  function appendEvent(type, mission, payload = {}, root = process.cwd()) {
1625
1652
  const paths = statePaths(root);
1626
1653
  const event = {
@@ -1632,6 +1659,12 @@ function appendEvent(type, mission, payload = {}, root = process.cwd()) {
1632
1659
  payload,
1633
1660
  };
1634
1661
  appendJsonLine(paths.eventsJsonl, event);
1662
+ const parentRoot = resolveMemberWorkspaceRoot(root);
1663
+ if (type === 'mission_tick' && parentRoot !== path.resolve(root || process.cwd())) {
1664
+ try {
1665
+ appendJsonLine(statePaths(parentRoot).eventsJsonl, event);
1666
+ } catch {}
1667
+ }
1635
1668
  return event;
1636
1669
  }
1637
1670
 
@@ -1662,9 +1695,15 @@ function saveMission(mission, root = process.cwd(), eventType = 'mission_updated
1662
1695
 
1663
1696
  function memberDir(owner, root = process.cwd()) {
1664
1697
  if (!owner || !/^[a-zA-Z0-9._-]+$/.test(owner)) return null;
1665
- const dir = path.join(root, 'atris', 'team', owner);
1666
- if (!fs.existsSync(path.join(dir, 'MEMBER.md'))) return null;
1667
- return dir;
1698
+ const parentRoot = resolveMemberWorkspaceRoot(root);
1699
+ const candidates = parentRoot !== path.resolve(root || process.cwd())
1700
+ ? [parentRoot, root]
1701
+ : [root];
1702
+ for (const base of candidates) {
1703
+ const dir = path.join(base, 'atris', 'team', owner);
1704
+ if (fs.existsSync(path.join(dir, 'MEMBER.md'))) return dir;
1705
+ }
1706
+ return null;
1668
1707
  }
1669
1708
 
1670
1709
  function appendMemberLog(owner, title, fields = {}, root = process.cwd()) {
@@ -1686,6 +1725,158 @@ function appendMemberLog(owner, title, fields = {}, root = process.cwd()) {
1686
1725
  return logPath;
1687
1726
  }
1688
1727
 
1728
+ function crystallizeMemberSkillLesson(owner, entry = {}, root = process.cwd()) {
1729
+ const dir = memberDir(owner, root);
1730
+ if (!dir) return null;
1731
+ const summary = String(entry.summary || entry.objective || '').replace(/\s+/g, ' ').trim();
1732
+ if (!summary) return null;
1733
+ const skillDir = path.join(dir, 'skills', 'auto-learned');
1734
+ fs.mkdirSync(skillDir, { recursive: true });
1735
+ const skillPath = path.join(skillDir, 'SKILL.md');
1736
+ const existingBullets = [];
1737
+ if (fs.existsSync(skillPath)) {
1738
+ try {
1739
+ const raw = fs.readFileSync(skillPath, 'utf8');
1740
+ for (const line of raw.split(/\r?\n/)) {
1741
+ if (line.trim().startsWith('- **')) existingBullets.push(line.trim());
1742
+ }
1743
+ } catch {}
1744
+ }
1745
+ const verifierText = String(entry.verifier || '').replace(/\s+/g, ' ').trim();
1746
+ const nextPushText = String(entry.next_push || '').replace(/\s+/g, ' ').trim();
1747
+ const layerLabel = String(entry.layer || 'capabilities').trim();
1748
+ const parts = [`- **${todayName()} (${layerLabel})**: ${summary.slice(0, 140)}`];
1749
+ if (verifierText) parts.push(`Verify: \`${verifierText.slice(0, 80)}\``);
1750
+ if (nextPushText) parts.push(`Next push: ${nextPushText.slice(0, 120)}`);
1751
+ const newBullet = parts.join(' -> ');
1752
+ const deduped = [
1753
+ newBullet,
1754
+ ...existingBullets.filter((b) => !b.includes(summary.slice(0, 80))),
1755
+ ].slice(0, 5);
1756
+
1757
+ const content = [
1758
+ '---',
1759
+ 'name: auto-learned',
1760
+ `description: Verified patterns crystallized from passing runs for ${owner}. Bounded to 5 newest patterns so context never bloats.`,
1761
+ '---',
1762
+ '',
1763
+ '# Auto-Learned Verified Patterns',
1764
+ '',
1765
+ ...deduped,
1766
+ '',
1767
+ ].join('\n');
1768
+ fs.writeFileSync(skillPath, content, 'utf8');
1769
+ return skillPath;
1770
+ }
1771
+
1772
+ function autoQueueMemberNextPush(owner, nextPush, parentObjective = '', root = process.cwd(), { promote = false } = {}) {
1773
+ const cleanPush = String(nextPush || '').replace(/\s+/g, ' ').trim();
1774
+ if (!cleanPush) return null;
1775
+ const dir = memberDir(owner, root);
1776
+ if (!dir) return null;
1777
+ const goalsPath = path.join(dir, 'goals.json');
1778
+ let store = { schema_version: 1, member: owner, updated_at: stampIso(), goals: [] };
1779
+ if (fs.existsSync(goalsPath)) {
1780
+ try {
1781
+ const parsed = JSON.parse(fs.readFileSync(goalsPath, 'utf8'));
1782
+ if (parsed && Array.isArray(parsed.goals)) store = parsed;
1783
+ } catch {}
1784
+ }
1785
+ const now = stampIso();
1786
+ const activeIdx = store.goals.findIndex((g) => g && g.status === 'active');
1787
+ if (activeIdx >= 0) {
1788
+ const prev = store.goals[activeIdx];
1789
+ store.goals[activeIdx] = {
1790
+ ...prev,
1791
+ ...(promote ? { previous_title: prev.title, title: cleanPush } : {}),
1792
+ next_push: cleanPush,
1793
+ updated_at: now,
1794
+ };
1795
+ } else {
1796
+ store.goals.push({
1797
+ id: `goal_${Date.now().toString(36)}`,
1798
+ member: owner,
1799
+ title: cleanPush,
1800
+ why: parentObjective ? `Compounds after verified win on: ${parentObjective}` : 'Self-proposed next optimization from verified run.',
1801
+ metric: 'verified_slices_shipped',
1802
+ target: '1',
1803
+ horizon: '7d',
1804
+ acceptance: ['Passes test check and cross-model referee'],
1805
+ next_push: cleanPush,
1806
+ status: 'active',
1807
+ created_at: now,
1808
+ updated_at: now,
1809
+ });
1810
+ }
1811
+ store.updated_at = now;
1812
+ fs.writeFileSync(goalsPath, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
1813
+ return goalsPath;
1814
+ }
1815
+
1816
+ const DEFAULT_MEMBER_BATON_RELAY = {
1817
+ researcher: 'navigator',
1818
+ 'customer-lead': 'navigator',
1819
+ navigator: 'executor',
1820
+ architect: 'executor',
1821
+ executor: 'validator',
1822
+ 'auto-improver': 'validator',
1823
+ };
1824
+
1825
+ function recordMemberHandoff(fromMember, toMember, sliceSummary, root = process.cwd(), extra = {}) {
1826
+ const from = String(fromMember || '').trim().toLowerCase();
1827
+ const to = String(toMember || DEFAULT_MEMBER_BATON_RELAY[from] || '').trim().toLowerCase();
1828
+ const cleanSummary = String(sliceSummary || '').replace(/\s+/g, ' ').trim();
1829
+ if (!from || !to || !cleanSummary) return null;
1830
+ const parentRoot = resolveMemberWorkspaceRoot(root);
1831
+ if (!memberDir(to, parentRoot)) return null;
1832
+ const now = stampIso();
1833
+ const handoff = {
1834
+ id: `handoff_${Date.now().toString(36)}`,
1835
+ from,
1836
+ to,
1837
+ summary: cleanSummary.slice(0, 180),
1838
+ status: extra.status || 'open',
1839
+ next_command: `atris member go ${to}`,
1840
+ created_at: now,
1841
+ };
1842
+ try {
1843
+ const handoffsFile = path.join(parentRoot, '.atris', 'state', 'member-handoffs.jsonl');
1844
+ fs.mkdirSync(path.dirname(handoffsFile), { recursive: true });
1845
+ fs.appendFileSync(handoffsFile, `${JSON.stringify(handoff)}\n`, 'utf8');
1846
+ autoQueueMemberNextPush(to, `${from} handoff: ${cleanSummary}`, `Baton pass from ${from}`, parentRoot);
1847
+ } catch {}
1848
+ return handoff;
1849
+ }
1850
+
1851
+ function closeMemberHandoffsForReceiver(receiverMember, root = process.cwd()) {
1852
+ const receiver = String(receiverMember || '').trim().toLowerCase();
1853
+ if (!receiver) return 0;
1854
+ const parentRoot = resolveMemberWorkspaceRoot(root);
1855
+ const handoffsFile = path.join(parentRoot, '.atris', 'state', 'member-handoffs.jsonl');
1856
+ if (!fs.existsSync(handoffsFile)) return 0;
1857
+ try {
1858
+ const rawLines = fs.readFileSync(handoffsFile, 'utf8').split(/\r?\n/).filter((l) => l.trim());
1859
+ let closedCount = 0;
1860
+ const now = stampIso();
1861
+ const updatedLines = rawLines.map((line) => {
1862
+ try {
1863
+ const row = JSON.parse(line);
1864
+ if (row && String(row.to || '').toLowerCase() === receiver && (row.status || 'open') === 'open') {
1865
+ closedCount++;
1866
+ return JSON.stringify({ ...row, status: 'closed', closed_at: now });
1867
+ }
1868
+ } catch {}
1869
+ return line;
1870
+ });
1871
+ if (closedCount > 0) {
1872
+ fs.writeFileSync(handoffsFile, `${updatedLines.join('\n')}\n`, 'utf8');
1873
+ }
1874
+ return closedCount;
1875
+ } catch {
1876
+ return 0;
1877
+ }
1878
+ }
1879
+
1689
1880
  function memberMissionFile(owner, root = process.cwd()) {
1690
1881
  const dir = memberDir(owner, root);
1691
1882
  if (!dir) return null;
@@ -2017,6 +2208,10 @@ function missionVerifierCheckedText(verifierResult, mission, guard = null) {
2017
2208
  ? 'Engine verify passed after running the changed surface on this computer.'
2018
2209
  : 'VERIFY FAILED: the engine verify pass did not end with VERDICT: PASS.';
2019
2210
  }
2211
+ // A passing command plus a failing referee must name the referee, not the command.
2212
+ if (verifierResult.referee?.passed === false && verifierResult.status === 0) {
2213
+ return missionVerifierCheckedText(verifierResult.referee, mission, guard);
2214
+ }
2020
2215
  const command = verifierResult.command || mission.verifier || 'configured verifier';
2021
2216
  if (!verifierResult.passed && verifierResult.timed_out) {
2022
2217
  return `VERIFY TIMED OUT: the check ran past its ${verifierTimeoutSeconds(verifierResult)}s window (took ${verifierElapsedSeconds(verifierResult)}s); the code is not failing, the suite is slow. Rerun with more room.`;
@@ -2051,6 +2246,9 @@ function missionVerifierHighLevelTestText(verifierResult, mission, guard = null)
2051
2246
  ? 'The verify engine inspected the tick diff and reported real changed-surface command output.'
2052
2247
  : 'The verify engine did not provide a passing changed-surface verdict.';
2053
2248
  }
2249
+ if (verifierResult.referee?.passed === false && verifierResult.status === 0) {
2250
+ return missionVerifierHighLevelTestText(verifierResult.referee, mission, guard);
2251
+ }
2054
2252
  const command = verifierResult.command || mission.verifier || 'configured verifier';
2055
2253
  if (!verifierResult.passed && verifierResult.timed_out) {
2056
2254
  return `Verifier command timed out after ${verifierElapsedSeconds(verifierResult)}s against a ${verifierTimeoutSeconds(verifierResult)}s window: ${command}. No pass/fail verdict was reached; the suite outran the window rather than the code failing.`;
@@ -2940,6 +3138,7 @@ function missionFromArgs(args) {
2940
3138
  '--cadence',
2941
3139
  '--loop',
2942
3140
  '--runner',
3141
+ '--engine',
2943
3142
  '--lane',
2944
3143
  '--verify',
2945
3144
  '--stop',
@@ -2968,7 +3167,10 @@ function missionFromArgs(args) {
2968
3167
  const maxTicksOverride = readPositiveIntegerFlag(args, '--max-ticks', null, { json: wantsJson(args) });
2969
3168
  const requestedOwner = readFlag(args, '--owner', process.env.ATRIS_AGENT_ID || 'mission-lead');
2970
3169
  const cadence = readFlag(args, '--cadence', readFlag(args, '--loop', 'manual')) || 'manual';
2971
- const runnerSelection = resolveMissionRunnerSelection(readFlag(args, '--runner', 'manual'), { asJson: wantsJson(args), label: 'runner' });
3170
+ const engineFlag = readFlag(args, '--engine', '');
3171
+ const runnerSelection = engineFlag
3172
+ ? resolveMissionRunnerSelection(engineFlag, { asJson: wantsJson(args), engineOnly: true, label: 'engine' })
3173
+ : resolveMissionRunnerSelection(readFlag(args, '--runner', 'manual'), { asJson: wantsJson(args), label: 'runner' });
2972
3174
  const runner = runnerSelection.runner;
2973
3175
  const model = readFlag(args, '--model', '') || (String(runner).toLowerCase() === 'atris2' ? 'atris:fast' : '');
2974
3176
  const lane = readFlag(args, '--lane', 'workspace');
@@ -2985,7 +3187,8 @@ function missionFromArgs(args) {
2985
3187
  assertMissionVerifier(verifier, wantsJson(args));
2986
3188
  const stopCondition = readFlag(args, '--stop', budgetStopCondition(budgetContract) || (verifier ? 'verifier passes and no human asks remain' : 'human marks complete with proof'));
2987
3189
  const taskIds = readRepeatedFlag(args, '--task');
2988
- const humanAsks = readRepeatedFlag(args, '--ask');
3190
+ // MEMBER.md approval-required becomes an open human ask the owner must clear.
3191
+ const humanAsks = [...readRepeatedFlag(args, '--ask'), ...memberApprovalAsks(owner, process.cwd(), objective)];
2989
3192
  const drive = missionDrive(readFlag(args, '--destination', ''));
2990
3193
  const alwaysOn = hasFlag(args, '--always-on');
2991
3194
  const xpTaskEnabled = hasFlag(args, '--xp-task') || hasFlag(args, '--agent-xp');
@@ -8612,6 +8815,101 @@ function consumeMissionPings(mission, cwd) {
8612
8815
  return { mission: saved, pings: pending };
8613
8816
  }
8614
8817
 
8818
+ function readBoundedContextFile(filePath, maxChars = 1600) {
8819
+ try {
8820
+ if (!fs.existsSync(filePath)) return '';
8821
+ // Frontmatter is machine config (skills, permissions); the prompt only
8822
+ // needs the body.
8823
+ const raw = fs.readFileSync(filePath, 'utf8')
8824
+ .replace(/^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/, '')
8825
+ .replace(/<!--[\s\S]*?-->/g, '')
8826
+ .trim();
8827
+ if (!raw) return '';
8828
+ return raw.length > maxChars ? `${raw.slice(0, maxChars).trimEnd()}\n...[truncated]` : raw;
8829
+ } catch {
8830
+ return '';
8831
+ }
8832
+ }
8833
+
8834
+ function buildPreloadedMemberContextLines(mission, root = process.cwd()) {
8835
+ const owner = String(mission?.owner || '').trim();
8836
+ const baseRoot = mission?.worktree_root || mission?.worktree?.path || root;
8837
+ const sections = [];
8838
+ if (owner && /^[a-zA-Z0-9._-]+$/.test(owner)) {
8839
+ const teamMemberDir = memberDir(owner, baseRoot) || path.join(baseRoot, 'atris', 'team', owner);
8840
+ const memberMd = readBoundedContextFile(path.join(teamMemberDir, 'MEMBER.md'), 1800);
8841
+ const soulMd = readBoundedContextFile(path.join(teamMemberDir, 'SOUL.md'), 1200);
8842
+ const missionMd = readBoundedContextFile(path.join(teamMemberDir, 'MISSION.md'), 800);
8843
+ if (memberMd) {
8844
+ sections.push(`### Member Identity (atris/team/${owner}/MEMBER.md)`, memberMd);
8845
+ }
8846
+ if (soulMd) {
8847
+ sections.push(`### Member Soul (atris/team/${owner}/SOUL.md)`, soulMd);
8848
+ }
8849
+ if (missionMd) {
8850
+ sections.push(`### Member Standing Mission (atris/team/${owner}/MISSION.md)`, missionMd);
8851
+ }
8852
+ try {
8853
+ const skillsDir = path.join(teamMemberDir, 'skills');
8854
+ if (fs.existsSync(skillsDir)) {
8855
+ const skillSnippets = fs.readdirSync(skillsDir)
8856
+ .sort()
8857
+ .slice(0, 3)
8858
+ .map((slug) => {
8859
+ const skillText = readBoundedContextFile(path.join(skillsDir, slug, 'SKILL.md'), 500);
8860
+ return skillText ? `#### Skill: ${slug}\n${skillText}` : '';
8861
+ })
8862
+ .filter(Boolean);
8863
+ if (skillSnippets.length) {
8864
+ sections.push(`### Unlocked Member Skills (atris/team/${owner}/skills/)`, skillSnippets.join('\n\n'));
8865
+ }
8866
+ }
8867
+ } catch {}
8868
+ try {
8869
+ const logsDir = path.join(teamMemberDir, 'logs');
8870
+ if (fs.existsSync(logsDir)) {
8871
+ const latestLog = fs.readdirSync(logsDir)
8872
+ .filter((name) => name.endsWith('.md'))
8873
+ .sort()
8874
+ .pop();
8875
+ if (latestLog) {
8876
+ const rawLog = fs.readFileSync(path.join(logsDir, latestLog), 'utf8').trim();
8877
+ if (rawLog) {
8878
+ const tail = rawLog.length > 500 ? `...[earlier entries]\n${rawLog.slice(-500).trimStart()}` : rawLog;
8879
+ sections.push(`### Recent Member Memory (atris/team/${owner}/logs/${latestLog})`, tail);
8880
+ }
8881
+ }
8882
+ }
8883
+ } catch {}
8884
+ }
8885
+ const boundTask = mission?.xp_task || mission?.selected_target_task || null;
8886
+ if (boundTask && (boundTask.title || boundTask.ref || boundTask.task_id)) {
8887
+ const taskRef = boundTask.ref || boundTask.task_ref || boundTask.task_id;
8888
+ const taskTitle = boundTask.title ? `: ${boundTask.title}` : '';
8889
+ const taskStatus = boundTask.status ? ` (${boundTask.status})` : '';
8890
+ sections.push(`### Bound Task Context`, `- Active task: ${taskRef}${taskTitle}${taskStatus}`);
8891
+ }
8892
+ try {
8893
+ const tasteHistory = readTasteReviewHistory(baseRoot);
8894
+ const tasteLines = [];
8895
+ if (tasteHistory?.accepted?.length) {
8896
+ tasteLines.push(...tasteHistory.accepted.slice(0, 2).map((line) => `- Accepted: ${line}`));
8897
+ }
8898
+ if (tasteHistory?.revised?.length) {
8899
+ tasteLines.push(...tasteHistory.revised.slice(0, 2).map((line) => `- Sent back: ${line}`));
8900
+ }
8901
+ if (tasteLines.length) {
8902
+ sections.push(`### Operator Taste & Review Notes`, tasteLines.join('\n'));
8903
+ }
8904
+ } catch {}
8905
+ if (!sections.length) return [];
8906
+ return [
8907
+ ``,
8908
+ `## Preloaded Member & Task Context`,
8909
+ ...sections,
8910
+ ];
8911
+ }
8912
+
8615
8913
  function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd = process.cwd()) {
8616
8914
  const sharedProcess = memberProcessPrompt(cwd);
8617
8915
  const pingLines = pings.length
@@ -8631,6 +8929,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
8631
8929
  checkFeedback,
8632
8930
  ]
8633
8931
  : [];
8932
+ const preloadedContextLines = buildPreloadedMemberContextLines(mission, cwd);
8634
8933
  const lines = [
8635
8934
  `# Mission Tick ${tickIndex}/${maxTicks}`,
8636
8935
  ...pingLines,
@@ -8646,6 +8945,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
8646
8945
  ...missionBudgetPromptLines(mission),
8647
8946
  ...checkFeedbackLines,
8648
8947
  ...(sharedProcess ? ['', sharedProcess] : []),
8948
+ ...preloadedContextLines,
8649
8949
  ``,
8650
8950
  `## Your task`,
8651
8951
  `Do ONE increment of work toward the stop condition. ONE. No more.`,
@@ -8657,6 +8957,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
8657
8957
  `- 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.`,
8658
8958
  `- After that one verify step, STOP. Never start a second slice in one tick.`,
8659
8959
  `- 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.`,
8960
+ `- 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.`,
8660
8961
  ``,
8661
8962
  `## No claimable work? Work the backstop ladder`,
8662
8963
  `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:`,
@@ -9217,10 +9518,25 @@ function engineVerifierResultFromRun(engineResult, engine = 'codex') {
9217
9518
  };
9218
9519
  }
9219
9520
 
9521
+ function pickCrossEngineVerifier(mission, cwd = process.cwd(), preferredEngine = '') {
9522
+ const explicit = canonicalEngineName(preferredEngine || mission?.referee_engine || '');
9523
+ if (explicit) return explicit;
9524
+ const workerEngine = canonicalEngineName(mission?.runner) || 'claude';
9525
+ const candidates = workerEngine === 'codex' ? ['claude', 'fable', 'codex'] : ['codex', 'claude'];
9526
+ try {
9527
+ const engines = engineRegistryView(cwd);
9528
+ for (const candidate of candidates) {
9529
+ const entry = engines.find((e) => e.id === candidate);
9530
+ if (entry && entry.installed && entry.health?.status === 'ready') return candidate;
9531
+ }
9532
+ } catch {}
9533
+ return workerEngine === 'codex' ? 'claude' : 'codex';
9534
+ }
9535
+
9220
9536
  async function runEngineVerifier(mission, options = {}) {
9221
9537
  const cwd = options.cwd || process.cwd();
9222
- const engine = 'codex';
9223
- const verifyMission = { ...mission, runner: engine, runner_kind: 'engine' };
9538
+ const engine = pickCrossEngineVerifier(mission, cwd, options.refereeEngine);
9539
+ const verifyMission = { ...mission, runner: engine, runner_kind: 'engine', model: '' };
9224
9540
  const restoreRunnerProfile = applyMissionRunnerProfile(engine);
9225
9541
  let engineResult;
9226
9542
  try {
@@ -9261,6 +9577,22 @@ function usefulClaudeReceiptSummary(text, fallback = 'no-text') {
9261
9577
  return fallback;
9262
9578
  }
9263
9579
 
9580
+ function extractNextPushFromReceiptText(text) {
9581
+ const raw = String(text || '');
9582
+ if (!raw.trim()) return null;
9583
+ const explicitMatch = raw.match(/^\s*(?:[-*]\s*)?(?:next_push|next push|next needle[- ]mover)\s*:\s*(.+)$/im);
9584
+ if (explicitMatch && explicitMatch[1]) {
9585
+ const cleaned = explicitMatch[1].replace(/`/g, '').replace(/\s+/g, ' ').trim();
9586
+ if (cleaned && !/^<one concrete/i.test(cleaned)) return cleaned.slice(0, 180);
9587
+ }
9588
+ const numberedMatch = raw.match(/^\s*(?:\(3\)|3[.)])\s*(?:next(?:\s+tick)?\s*:\s*)?(.+)$/im);
9589
+ if (numberedMatch && numberedMatch[1]) {
9590
+ const cleaned = numberedMatch[1].replace(/`/g, '').replace(/\s+/g, ' ').trim();
9591
+ if (cleaned && !/^layer\s*:/i.test(cleaned)) return cleaned.slice(0, 180);
9592
+ }
9593
+ return null;
9594
+ }
9595
+
9264
9596
  function cappedClaudeReceiptText(text, limit = 4000) {
9265
9597
  const clean = String(text || '').trim();
9266
9598
  if (!clean) return '';
@@ -9316,6 +9648,8 @@ async function parseAndValidateMissionRunPhase(args) {
9316
9648
  const selfDrive = dueMode || headlessOnly || hasFlag(args, '--self-drive');
9317
9649
  const skipClaude = hasFlag(args, '--no-claude');
9318
9650
  const verifyEach = !hasFlag(args, '--no-verify');
9651
+ const refereeEach = hasFlag(args, '--referee') && !hasFlag(args, '--no-referee');
9652
+ const refereeEngine = readFlag(args, '--referee-engine', '');
9319
9653
  const completeOnPass = hasFlag(args, '--complete-on-pass');
9320
9654
  const skipDrain = hasFlag(args, '--no-drain');
9321
9655
  const createNext = hasFlag(args, '--create-next');
@@ -9351,6 +9685,8 @@ async function parseAndValidateMissionRunPhase(args) {
9351
9685
  selfDrive,
9352
9686
  skipClaude,
9353
9687
  verifyEach,
9688
+ refereeEach,
9689
+ refereeEngine,
9354
9690
  completeOnPass,
9355
9691
  skipDrain,
9356
9692
  createNext,
@@ -9376,7 +9712,6 @@ async function resolveMissionRunPhase(context) {
9376
9712
  const {
9377
9713
  args,
9378
9714
  asJson,
9379
- cwd,
9380
9715
  dueMode,
9381
9716
  headlessOnly,
9382
9717
  budgetTier,
@@ -9389,7 +9724,7 @@ async function resolveMissionRunPhase(context) {
9389
9724
  ref,
9390
9725
  runArgs,
9391
9726
  } = context;
9392
- let { maxTicks, maxWallSeconds } = context;
9727
+ let { cwd, maxTicks, maxWallSeconds } = context;
9393
9728
 
9394
9729
  if (!dueMode && !ref) {
9395
9730
  if (asJson || !process.stdin.isTTY || !process.stderr.isTTY) {
@@ -9428,6 +9763,13 @@ async function resolveMissionRunPhase(context) {
9428
9763
  if (!mission) {
9429
9764
  exitMissionError('Usage: atris mission run <id|objective> [--max-ticks 4] [--max-wall 3600]', 1, asJson);
9430
9765
  }
9766
+ const worktreeRoot = mission.worktree_root || mission.worktree?.path || '';
9767
+ if (worktreeRoot && fs.existsSync(worktreeRoot) && path.resolve(worktreeRoot) !== path.resolve(cwd)) {
9768
+ cwd = path.resolve(worktreeRoot);
9769
+ context.cwd = cwd;
9770
+ process.chdir(cwd);
9771
+ mission = resolveMission(mission.id, cwd) || mission;
9772
+ }
9431
9773
  if (!maxTicksFlag && !budgetTier && Number(mission.max_ticks) > 0) {
9432
9774
  maxTicks = Math.max(1, Number(mission.max_ticks));
9433
9775
  }
@@ -9452,6 +9794,30 @@ async function resolveMissionRunPhase(context) {
9452
9794
  console.error(`Mission ${mission.id} is ${mission.status}; nothing to run.`);
9453
9795
  process.exit(0);
9454
9796
  }
9797
+ // An explicit run must respect open human asks the same way auto-selection
9798
+ // does (missionIsRunnable), instead of ticking straight past them.
9799
+ const blockingAsks = runBlockingAsks(mission.human_asks);
9800
+ if (blockingAsks.length) {
9801
+ detachedDriverLifecycle?.setExitReason('human_asks_open');
9802
+ const lines = [
9803
+ `Mission ${mission.id} is waiting on ${blockingAsks.length} human ask${blockingAsks.length === 1 ? '' : 's'}; not ticking.`,
9804
+ ...blockingAsks.map((ask) => ` - ${ask.text}${ask.answered_at ? ' (answered no)' : ''}`),
9805
+ 'Answer with: atris decide (then atris decide <n> y), and re-run.',
9806
+ ];
9807
+ if (asJson) {
9808
+ printJsonOrText({
9809
+ ok: false,
9810
+ action: 'run_blocked',
9811
+ reason: 'human_asks_open',
9812
+ mission_id: mission.id,
9813
+ human_asks: blockingAsks.map((ask) => ask.text),
9814
+ next_command: 'atris decide',
9815
+ }, [], true);
9816
+ } else {
9817
+ for (const line of lines) console.error(line);
9818
+ }
9819
+ process.exit(2);
9820
+ }
9455
9821
  if (hasFlag(args, '--detach')) {
9456
9822
  detachMissionRun(args, mission, cwd, asJson);
9457
9823
  context.handled = true;
@@ -9671,6 +10037,7 @@ async function executeMissionRunTicksPhase(context) {
9671
10037
  const tickEngineId = engineBackedTick ? (tickSelection.engine_id || inferredEngineId) : null;
9672
10038
  if (engineBackedTick) {
9673
10039
  result.engine_id = tickEngineId;
10040
+ if (tickRuntimeMission.model) result.model = tickRuntimeMission.model;
9674
10041
  result.requested_engine = tickSelection.requested_engine;
9675
10042
  result.engine_fallback_reason = tickSelection.engine_fallback_reason;
9676
10043
  }
@@ -9945,6 +10312,7 @@ async function executeMissionRunTicksPhase(context) {
9945
10312
  tickSkipWorker,
9946
10313
  tickIdx,
9947
10314
  frozen,
10315
+ storedVerifier,
9948
10316
  };
9949
10317
  let verifierResult = null;
9950
10318
  if (cachedStep) {
@@ -9986,12 +10354,15 @@ async function executeMissionRunTicksPhase(context) {
9986
10354
 
9987
10355
  // Layer classification needs the receipt text AND the worktree receipt, so it
9988
10356
  // runs here, after both exist, covering the claude and atris2 branches alike.
10357
+ const tickReceiptText = result.atris2?.receipt_text || result.claude?.receipt_text || result.drill?.receipt_text || '';
9989
10358
  if (!(cachedStep && cachedStep.tick && cachedStep.tick.layer)) {
9990
- const tickReceiptText = result.atris2?.receipt_text || result.claude?.receipt_text || result.drill?.receipt_text || '';
9991
10359
  const layerInfo = extractLayerFromReceiptText(tickReceiptText, tickWorktree?.new_since_baseline_sample);
9992
10360
  result.layer = layerInfo.layer;
9993
10361
  result.layer_source = layerInfo.source;
9994
10362
  }
10363
+ if (!result.next_push) {
10364
+ result.next_push = extractNextPushFromReceiptText(tickReceiptText);
10365
+ }
9995
10366
 
9996
10367
  // Persist tick to mission state + write structured receipt
9997
10368
  const finishedAt = stampIso();
@@ -10017,9 +10388,14 @@ async function executeMissionRunTicksPhase(context) {
10017
10388
  // reopen closed rooms, mission 32 complete@19:23:33 → ready@19:23:48).
10018
10389
  const latestOnDisk = loadMissionMap(cwd).get(mission.id) || mission;
10019
10390
  const alreadyTerminal = TERMINAL_STATUSES.has(String(latestOnDisk.status || ''));
10391
+ // A passing verifier alone never completes a mission whose linked task
10392
+ // is not accepted: park it ready and name the accept command instead.
10393
+ const acceptGate = (verifierResult?.passed && completeOnPass && !fullBudgetMode && !mission.always_on && !xpReadyAction)
10394
+ ? missionLinkedTaskGate(mission, cwd)
10395
+ : { ok: true };
10020
10396
  let newStatus = (verifierResult?.passed && mission.always_on) ? 'ready' :
10021
10397
  (verifierResult?.passed && xpReadyAction) ? 'ready' :
10022
- (verifierResult?.passed && completeOnPass && !fullBudgetMode) ? 'complete' :
10398
+ (verifierResult?.passed && completeOnPass && !fullBudgetMode && acceptGate.ok) ? 'complete' :
10023
10399
  (verifierResult?.passed && fullBudgetMode) ? 'running' :
10024
10400
  (verifierResult?.passed ? 'ready' :
10025
10401
  (verifierResult ? 'blocked' :
@@ -10033,6 +10409,8 @@ async function executeMissionRunTicksPhase(context) {
10033
10409
  nextAction = nextCandidateTickAction(mission);
10034
10410
  } else if (verifierResult?.passed && xpReadyAction) {
10035
10411
  nextAction = xpReadyAction;
10412
+ } else if (verifierResult?.passed && completeOnPass && !fullBudgetMode && !acceptGate.ok) {
10413
+ nextAction = missionAcceptNextAction(mission, acceptGate);
10036
10414
  } else if (verifierResult?.passed && completeOnPass && !fullBudgetMode) {
10037
10415
  nextAction = 'mission complete';
10038
10416
  } else if (verifierResult?.passed && fullBudgetMode) {
@@ -10070,6 +10448,7 @@ async function executeMissionRunTicksPhase(context) {
10070
10448
  last_tick_layer: result.layer,
10071
10449
  last_tick_layer_source: result.layer_source,
10072
10450
  error_streak_count: errorStreakCount,
10451
+ last_next_push: result.next_push || latestOnDisk.last_next_push || null,
10073
10452
  verifier_result: verifierResult || (verifyEach && result.protected_lane_guard && result.protected_lane_guard.allowed === false ? null : latestOnDisk.verifier_result) || null,
10074
10453
  last_check_feedback: verifierResult
10075
10454
  ? extractCheckFeedback(verifierResult)
@@ -10078,7 +10457,16 @@ async function executeMissionRunTicksPhase(context) {
10078
10457
  next_action: nextAction,
10079
10458
  claude_session_ticks: rotateSessionForContext ? 0 : claudeSessionTicks,
10080
10459
  }, cwd, 'mission_tick', {
10081
- tick_index: tickIdx, status: result.status, reason: result.reason, receipt_path: receiptPath, layer: result.layer,
10460
+ tick_index: tickIdx,
10461
+ status: result.status,
10462
+ reason: result.reason,
10463
+ receipt_path: receiptPath,
10464
+ layer: result.layer,
10465
+ next_push: result.next_push || undefined,
10466
+ engine: result.engine_id || runtimeMission.runner || mission.runner || null,
10467
+ task_type: 'executor',
10468
+ verified_passed: verifierResult && typeof verifierResult.passed === 'boolean' ? verifierResult.passed : undefined,
10469
+ duration_ms: Math.max(0, (Date.parse(finishedAt) || 0) - (Date.parse(tickStart) || 0)),
10082
10470
  }).mission;
10083
10471
  if (rotateSessionForContext) {
10084
10472
  // Same shape as the stale-lock rotation above: mint the fresh pending
@@ -10093,6 +10481,7 @@ async function executeMissionRunTicksPhase(context) {
10093
10481
  },
10094
10482
  ).mission;
10095
10483
  }
10484
+ const tickSummaryText = String(result?.claude?.summary || '').replace(/\s+/g, ' ').trim();
10096
10485
  appendMemberLog(mission.owner, `Mission run tick ${tickIdx}`, {
10097
10486
  mission: mission.objective,
10098
10487
  state: mission.status,
@@ -10100,8 +10489,39 @@ async function executeMissionRunTicksPhase(context) {
10100
10489
  reason: result.reason,
10101
10490
  layer: result.layer || undefined,
10102
10491
  verifier: verifierResult ? (verifierResult.passed ? 'passed' : 'failed') : 'not_run',
10492
+ summary: tickSummaryText ? tickSummaryText.slice(0, 240) : undefined,
10493
+ next_push: result.next_push ? String(result.next_push).slice(0, 180) : undefined,
10494
+ feedback: verifierResult && !verifierResult.passed ? String(mission.last_check_feedback || '').slice(0, 240) : undefined,
10103
10495
  receipt: receiptPath,
10104
- });
10496
+ }, cwd);
10497
+ if (verifierResult && verifierResult.passed === true && result.status === 'ran') {
10498
+ try {
10499
+ crystallizeMemberSkillLesson(mission.owner, {
10500
+ objective: mission.objective,
10501
+ summary: tickSummaryText || mission.objective,
10502
+ verifier: frozen.verifier || mission.verifier || '',
10503
+ next_push: result.next_push || '',
10504
+ layer: result.layer || 'capabilities',
10505
+ }, cwd);
10506
+ } catch {}
10507
+ if (result.next_push) {
10508
+ try {
10509
+ autoQueueMemberNextPush(mission.owner, result.next_push, mission.objective, cwd, {
10510
+ promote: newStatus === 'complete',
10511
+ });
10512
+ } catch {}
10513
+ }
10514
+ if (mission.owner) {
10515
+ try {
10516
+ closeMemberHandoffsForReceiver(mission.owner, cwd);
10517
+ } catch {}
10518
+ }
10519
+ if (newStatus === 'complete' && mission.owner) {
10520
+ try {
10521
+ recordMemberHandoff(mission.owner, '', tickSummaryText || mission.objective, cwd);
10522
+ } catch {}
10523
+ }
10524
+ }
10105
10525
  if (newStatus === 'complete') {
10106
10526
  continuationGoal = seedMissionRunContinuation(mission, cwd, receiptPath);
10107
10527
  if (continuationGoal?.parent) mission = continuationGoal.parent;
@@ -10262,12 +10682,37 @@ async function verifyMissionRunTickPhase(context) {
10262
10682
  if (result.status === 'ran' && verifyEach) {
10263
10683
  if (currentTick.frozen.verifier) {
10264
10684
  verifierResult = runVerifier(currentTick.frozen.verifier);
10265
- } else if (!currentTick.tickSkipWorker) {
10266
- verifierResult = await runEngineVerifier(currentTick.tickRuntimeMission, {
10685
+ }
10686
+ const shouldRunReferee = !currentTick.tickSkipWorker
10687
+ && (!verifierResult || verifierResult.passed)
10688
+ && (
10689
+ !currentTick.frozen.verifier
10690
+ || context.refereeEach
10691
+ || (!currentTick.storedVerifier && !process.env.NODE_TEST_CONTEXT)
10692
+ );
10693
+ if (shouldRunReferee) {
10694
+ const refereeResult = await runEngineVerifier(currentTick.tickRuntimeMission, {
10267
10695
  cwd,
10268
10696
  signal: controller.signal,
10269
10697
  tickIndex: currentTick.tickIdx,
10698
+ refereeEngine: context.refereeEngine,
10270
10699
  });
10700
+ result.referee_result = refereeResult;
10701
+ if (!verifierResult || !refereeResult.passed) {
10702
+ verifierResult = verifierResult
10703
+ ? {
10704
+ ...verifierResult,
10705
+ passed: Boolean(verifierResult.passed && refereeResult.passed),
10706
+ referee: refereeResult,
10707
+ output: [verifierResult.stdout, verifierResult.stderr, refereeResult.output].filter(Boolean).join('\n'),
10708
+ }
10709
+ : refereeResult;
10710
+ } else {
10711
+ verifierResult = {
10712
+ ...verifierResult,
10713
+ referee: refereeResult,
10714
+ };
10715
+ }
10271
10716
  }
10272
10717
  if (verifierResult) result.verifier_passed = verifierResult.passed;
10273
10718
  }
@@ -10601,10 +11046,14 @@ function tickMission(args) {
10601
11046
  const xpReadyAction = missionXpReadyAction(mission, receiptPath);
10602
11047
  const budgetRemainingSeconds = missionFullBudgetRemainingSeconds(mission);
10603
11048
  const budgetOpen = budgetRemainingSeconds > 0;
10604
- status = (completeOnPass && !mission.always_on && !xpReadyAction && !budgetOpen) ? 'complete'
11049
+ const acceptGate = (completeOnPass && !mission.always_on && !xpReadyAction && !budgetOpen)
11050
+ ? missionLinkedTaskGate(mission, process.cwd())
11051
+ : { ok: true };
11052
+ status = (completeOnPass && !mission.always_on && !xpReadyAction && !budgetOpen && acceptGate.ok) ? 'complete'
10605
11053
  : (budgetOpen && !mission.always_on && !xpReadyAction ? 'running' : 'ready');
10606
11054
  nextAction = mission.always_on ? nextCandidateTickAction(mission) :
10607
11055
  (xpReadyAction
11056
+ || (!acceptGate.ok ? missionAcceptNextAction(mission, acceptGate) : null)
10608
11057
  || (budgetOpen
10609
11058
  ? `proof passed with ${durationLabel(budgetRemainingSeconds)} left on the budget; pick the next useful move, then: atris mission tick ${mission.id} --verify --summary "<what changed>"`
10610
11059
  : (completeOnPass ? 'mission complete' : `review proof then run: atris mission complete ${mission.id} --proof "${receiptPath}"`)));
@@ -10747,6 +11196,38 @@ function receiptShowsPass(receipt) {
10747
11196
  // verifier passed. Mirrors the task plane's proof-only accept guard so the
10748
11197
  // final transition consumes the receipts instead of trusting free text.
10749
11198
  function missionCompletionGate(mission, proof, root = process.cwd()) {
11199
+ const base = missionVerifierCompletionGate(mission, proof, root);
11200
+ if (!base.ok) {
11201
+ // The check not passing is the first thing to fix, but a linked task that
11202
+ // is not accepted would refuse the next try too: say so now.
11203
+ const pending = missionLinkedTaskGate(mission, root);
11204
+ if (pending.ok) return base;
11205
+ const how = pending.accept_command ? ` (${pending.accept_command})` : '';
11206
+ return { ...base, linked_task_note: `Also, ${pending.reason}${how}` };
11207
+ }
11208
+ // Done means an accepted task plus a receipt: a passing verifier is not
11209
+ // enough when the mission is linked to task(s) a human has not accepted.
11210
+ const linked = missionLinkedTaskGate(mission, root);
11211
+ if (!linked.ok) {
11212
+ return {
11213
+ ok: false,
11214
+ source: 'linked_task',
11215
+ reason: linked.reason,
11216
+ task_id: linked.task_id,
11217
+ task_ref: linked.task_ref,
11218
+ accept_command: linked.accept_command,
11219
+ archived_only: linked.archived_only === true,
11220
+ };
11221
+ }
11222
+ return linked.tasks.length ? { ...base, linked_tasks: linked.tasks } : base;
11223
+ }
11224
+
11225
+ function missionAcceptNextAction(mission, gate) {
11226
+ const how = gate.accept_command || (gate.archived_only ? 'link a live task and accept it' : 'attach a receipt to it');
11227
+ return `verifier passed but ${gate.reason}; accept it (${how}), then: atris mission complete ${mission.id} --proof "<receipt>"`;
11228
+ }
11229
+
11230
+ function missionVerifierCompletionGate(mission, proof, root = process.cwd()) {
10750
11231
  const remainingSeconds = missionFullBudgetRemainingSeconds(mission);
10751
11232
  if (remainingSeconds > 0) {
10752
11233
  return {
@@ -10789,10 +11270,19 @@ function completeMission(args) {
10789
11270
  }
10790
11271
  const gate = missionCompletionGate(mission, proof, root);
10791
11272
  if (!gate.ok && !force) {
10792
- exitMissionError(`[mission complete] ${gate.reason}. Run: atris mission tick ${mission.id} --verify (or override as operator with --force)`, 2, asJson);
11273
+ const fix = gate.source === 'linked_task'
11274
+ ? (gate.accept_command
11275
+ ? `Accept it first: ${gate.accept_command}, then re-run mission complete`
11276
+ : (gate.archived_only ? 'Link a live task to the mission and accept it first' : 'Attach a receipt to it first'))
11277
+ : `Run: atris mission tick ${mission.id} --verify`;
11278
+ const also = gate.linked_task_note ? ` ${gate.linked_task_note}.` : '';
11279
+ exitMissionError(`[mission complete] ${gate.reason}. ${fix}.${also} (or override as operator with --force)`, 2, asJson);
11280
+ }
11281
+ if (!gate.ok && force && gate.source === 'linked_task') {
11282
+ console.error(`[mission complete] WARNING --force overrides the accepted-task gate: ${gate.reason}. Recorded as completion_gate.forced.`);
10793
11283
  }
10794
11284
  const baselineSummary = pruneMissionWorktreeBaseline(mission, root);
10795
- const completionGate = { ...gate, forced: force && !gate.ok };
11285
+ const completionGate = { ...gate, forced: force && !gate.ok, ...(force && !gate.ok ? { forced_reason: gate.reason } : {}) };
10796
11286
  const baseNext = {
10797
11287
  ...mission,
10798
11288
  status: 'complete',
@@ -11849,6 +12339,7 @@ module.exports = {
11849
12339
  startMission,
11850
12340
  resolveMission,
11851
12341
  spawnMissionDriver,
12342
+ missionDriverHealth,
11852
12343
  missionRunnerNeedsLiveSession,
11853
12344
  completeMission,
11854
12345
  inspectMission,
@@ -11874,6 +12365,9 @@ module.exports = {
11874
12365
  selectCodexGoalMission,
11875
12366
  codexGoalNextCommand,
11876
12367
  usefulClaudeReceiptSummary,
12368
+ autoQueueMemberNextPush,
12369
+ recordMemberHandoff,
12370
+ closeMemberHandoffsForReceiver,
11877
12371
  cappedClaudeReceiptText,
11878
12372
  extractLayerFromReceiptText,
11879
12373
  classifyPathsByLayer,
@@ -11893,6 +12387,7 @@ module.exports = {
11893
12387
  missionVerifierHighLevelTestText,
11894
12388
  buildEngineVerifyPrompt,
11895
12389
  engineVerifierResultFromRun,
12390
+ pickCrossEngineVerifier,
11896
12391
  runVerifier,
11897
12392
  missionFullBudgetRemainingSeconds,
11898
12393
  missionBudgetContinuationText,