atris 3.62.0 → 3.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/atris/features/README.md +8 -0
  2. package/atris/skills/README.md +1 -0
  3. package/atris/skills/commerce/SKILL.md +120 -0
  4. package/atris/skills/x-search/SKILL.md +3 -1
  5. package/ax +4 -3
  6. package/bin/atris.js +22 -2
  7. package/cli/atris_code.py +25 -1
  8. package/commands/auth.js +79 -10
  9. package/commands/autoland.js +11 -1
  10. package/commands/browse.js +2 -2
  11. package/commands/business.js +3 -3
  12. package/commands/buy.js +242 -0
  13. package/commands/deck.js +2 -2
  14. package/commands/engine.js +122 -13
  15. package/commands/feedback.js +2 -2
  16. package/commands/fork.js +2 -2
  17. package/commands/gm.js +2 -1
  18. package/commands/improve.js +142 -8
  19. package/commands/integrations.js +4 -3
  20. package/commands/loops.js +2 -1
  21. package/commands/member.js +82 -15
  22. package/commands/mission.js +4 -1
  23. package/commands/pack-craft.js +2 -2
  24. package/commands/publish.js +2 -2
  25. package/commands/radar.js +3 -2
  26. package/commands/router.js +2 -2
  27. package/commands/task.js +91 -2
  28. package/commands/transactions.js +115 -0
  29. package/commands/truth.js +2 -2
  30. package/commands/x-search.js +81 -10
  31. package/commands/youtube.js +24 -1
  32. package/lib/autoland.js +3 -2
  33. package/lib/ax-auto-lane.js +2 -2
  34. package/lib/brief-rules.js +11 -0
  35. package/lib/chat-log-scan.js +2 -1
  36. package/lib/ci-runner.js +3 -2
  37. package/lib/codex-flight.js +2 -1
  38. package/lib/commerce.js +90 -0
  39. package/lib/deck-history.js +2 -2
  40. package/lib/deck-review.js +3 -3
  41. package/lib/design-api.js +2 -2
  42. package/lib/engine-registry.js +102 -18
  43. package/lib/first-minute.js +2 -2
  44. package/lib/fleet.js +8 -36
  45. package/lib/git-spawn.js +5 -2
  46. package/lib/ignored-cache.js +51 -0
  47. package/lib/known-commands.js +1 -1
  48. package/lib/manifest.js +2 -2
  49. package/lib/map-refs.js +70 -4
  50. package/lib/member-engine.js +15 -1
  51. package/lib/member-scaffold.js +2 -0
  52. package/lib/member-type.js +27 -0
  53. package/lib/pack-capabilities.js +2 -1
  54. package/lib/permission-grants.js +2 -2
  55. package/lib/pulse.js +5 -5
  56. package/lib/roster-runs.js +13 -2
  57. package/lib/runner-command.js +54 -3
  58. package/lib/sandbox-pids.js +113 -0
  59. package/lib/secret-gateway.js +3 -13
  60. package/lib/state-home.js +106 -0
  61. package/lib/task-db.js +4 -3
  62. package/lib/task-prune.js +462 -0
  63. package/lib/x-post-check.js +497 -0
  64. package/package.json +1 -1
  65. package/scripts/det/data/xsearch-answer.json +24 -0
  66. package/scripts/det/data/xsearch-live-2026-09-29.json +93 -0
  67. package/scripts/det/xsearch-bench.js +307 -0
  68. package/scripts/det/ytnotes +122 -16
  69. package/scripts/det/ytrail-bench.js +215 -0
  70. package/scripts/det/ytrail-eval.js +128 -56
  71. package/utils/auth.js +3 -3
  72. package/utils/update-check.js +2 -2
@@ -18,10 +18,13 @@
18
18
  * fallback, and scorecard writes can all be faked in tests.
19
19
  */
20
20
 
21
+ const crypto = require('crypto');
21
22
  const fs = require('fs');
22
23
  const os = require('os');
23
24
  const path = require('path');
24
25
  const { spawnSync } = require('child_process');
26
+ const gitSpawn = require('../lib/git-spawn');
27
+ const ignoredCache = require('../lib/ignored-cache');
25
28
  const { apiRequestJson, getApiBaseUrl } = require('../utils/api');
26
29
  const { loadCredentials } = require('../utils/auth');
27
30
  const pulse = require('../lib/pulse');
@@ -1085,8 +1088,9 @@ function parseRevisionsArgs(argv = []) {
1085
1088
  return opts;
1086
1089
  }
1087
1090
 
1088
- function gitLines(cwdRoot, gitArgs) {
1089
- const r = spawnSync('git', gitArgs, { cwd: cwdRoot, encoding: 'utf8' });
1091
+ // Through the module object so tests can count git spawns with a spy.
1092
+ function gitLines(cwdRoot, gitArgs, { input, maxBuffer } = {}) {
1093
+ const r = gitSpawn.runGit(gitArgs, { cwd: cwdRoot, check: false, input, maxBuffer });
1090
1094
  if (r.status !== 0) {
1091
1095
  const err = new Error(String(r.stderr || `git ${gitArgs[0]} exited ${r.status}`).trim());
1092
1096
  err.gitFailed = true;
@@ -1095,17 +1099,129 @@ function gitLines(cwdRoot, gitArgs) {
1095
1099
  return String(r.stdout || '');
1096
1100
  }
1097
1101
 
1102
+ // File names come back NUL-separated and never quoted, whatever core.quotePath
1103
+ // says, so a name reads the same fresh from git and from the saved cache.
1104
+ const NAME_ARGS = ['-c', 'core.quotePath=false'];
1105
+
1106
+ function splitNames(out) {
1107
+ return String(out || '').split('\0').filter(Boolean);
1108
+ }
1109
+
1098
1110
  /**
1099
1111
  * Files changed by one commit. Merge commits are attributed by their
1100
1112
  * first-parent diff (what the merge actually brought onto the mainline);
1101
1113
  * plain commits use diff-tree. Root commits list their initial files.
1114
+ * The rename choice is spelled out so a user's diff.renames setting cannot
1115
+ * change the lists: merges find renames (git diff's default), plain commits
1116
+ * do not (diff-tree's default).
1102
1117
  */
1103
1118
  function commitFiles(cwdRoot, commit) {
1104
1119
  const parents = commit.parents;
1105
1120
  const out = parents.length >= 2
1106
- ? gitLines(cwdRoot, ['diff', '--name-only', `${commit.hash}^1`, commit.hash])
1107
- : gitLines(cwdRoot, ['diff-tree', '--no-commit-id', '--name-only', '-r', '--root', commit.hash]);
1108
- return out.split('\n').map((line) => line.trim()).filter(Boolean);
1121
+ ? gitLines(cwdRoot, [...NAME_ARGS, 'diff', '--name-only', '--find-renames', '-z', `${commit.hash}^1`, commit.hash])
1122
+ : gitLines(cwdRoot, [...NAME_ARGS, 'diff-tree', '--no-commit-id', '--name-only', '--no-renames', '-z', '-r', '--root', commit.hash]);
1123
+ return splitNames(out);
1124
+ }
1125
+
1126
+ /**
1127
+ * Files changed by many plain commits in one git process: the same diff-tree
1128
+ * per-commit call above, fed every hash on stdin. --always prints the hash
1129
+ * even for an empty commit. Raw output frames every file as a ':'-led status
1130
+ * field followed by its path, so a path is never mistaken for a commit hash,
1131
+ * even a path spelled exactly like the next one.
1132
+ */
1133
+ function plainCommitFiles(cwdRoot, commits) {
1134
+ const found = new Map(commits.map((commit) => [commit.hash, []]));
1135
+ if (!commits.length) return found;
1136
+ const hashes = commits.map((commit) => commit.hash);
1137
+ const out = gitLines(cwdRoot, [...NAME_ARGS, 'diff-tree', '--stdin', '--always', '--raw', '--no-renames', '-z', '-r', '--root'], {
1138
+ input: `${hashes.join('\n')}\n`,
1139
+ // Many commits' lists in one reply; the per-commit calls each had 1MB.
1140
+ maxBuffer: Math.max(1, hashes.length) * 1024 * 1024,
1141
+ });
1142
+ const fields = splitNames(out);
1143
+ let current = null;
1144
+ for (let i = 0; i < fields.length; i++) {
1145
+ const field = fields[i];
1146
+ if (field.startsWith(':')) {
1147
+ // Status field; with --no-renames exactly one path follows it.
1148
+ if (current && i + 1 < fields.length) current.push(fields[i + 1]);
1149
+ i += 1;
1150
+ } else {
1151
+ current = found.get(field) || null;
1152
+ }
1153
+ }
1154
+ return found;
1155
+ }
1156
+
1157
+ // Where git keeps state shared by every checkout of this repo (a linked
1158
+ // worktree points at its main repo). null when it cannot be found on disk.
1159
+ function gitCommonDir(root) {
1160
+ if (process.env.GIT_DIR || process.env.GIT_COMMON_DIR) return null;
1161
+ let dir = path.resolve(root);
1162
+ for (;;) {
1163
+ const dotGit = path.join(dir, '.git');
1164
+ let stat = null;
1165
+ try { stat = fs.statSync(dotGit); } catch { stat = null; }
1166
+ if (stat && stat.isDirectory()) return dotGit;
1167
+ if (stat && stat.isFile()) {
1168
+ const match = fs.readFileSync(dotGit, 'utf8').match(/^gitdir:\s*(.+)$/m);
1169
+ if (!match) return null;
1170
+ const gitDir = path.resolve(dir, match[1].trim());
1171
+ try {
1172
+ return path.resolve(gitDir, fs.readFileSync(path.join(gitDir, 'commondir'), 'utf8').trim());
1173
+ } catch {
1174
+ return gitDir;
1175
+ }
1176
+ }
1177
+ const parent = path.dirname(dir);
1178
+ if (parent === dir) return null;
1179
+ dir = parent;
1180
+ }
1181
+ }
1182
+
1183
+ // A shallow clone cuts history at boundary commits, and those commits list
1184
+ // different files once the history is fetched. The saved lists are only good
1185
+ // for the shallow state they were read under; null means do not cache.
1186
+ function shallowStamp(root) {
1187
+ const common = gitCommonDir(root);
1188
+ if (!common) return null;
1189
+ try {
1190
+ return crypto.createHash('sha1').update(fs.readFileSync(path.join(common, 'shallow'))).digest('hex');
1191
+ } catch (error) {
1192
+ return error.code === 'ENOENT' ? 'full' : null;
1193
+ }
1194
+ }
1195
+
1196
+ // A commit's file list never changes, so it is saved by hash in the ignored
1197
+ // .atris/cache and the gauge (shown on every boot) only asks git about commits
1198
+ // it has not seen. Hashes this read used come first; older ones stay until the
1199
+ // cap, so a 7-day boot read and a 14-day report read do not evict each other.
1200
+ const REVISION_FILES_CACHE = 'revision-files.json';
1201
+ const REVISION_FILES_CACHE_MAX = 5000;
1202
+
1203
+ function loadRevisionFilesCache(root) {
1204
+ const shallow = ignoredCache.cacheEnabled(root) ? shallowStamp(root) : null;
1205
+ const cache = { root, shallow, entries: {}, used: {}, dirty: false, writable: Boolean(shallow) };
1206
+ if (!cache.writable) return cache;
1207
+ const parsed = ignoredCache.readCacheJson(root, REVISION_FILES_CACHE);
1208
+ if (parsed && parsed.version === 1 && parsed.shallow === shallow && parsed.entries && typeof parsed.entries === 'object') {
1209
+ cache.entries = parsed.entries;
1210
+ }
1211
+ return cache;
1212
+ }
1213
+
1214
+ function saveRevisionFilesCache(cache) {
1215
+ if (!cache.writable || !cache.dirty) return;
1216
+ const entries = { ...cache.used };
1217
+ let count = Object.keys(entries).length;
1218
+ for (const [hash, files] of Object.entries(cache.entries)) {
1219
+ if (count >= REVISION_FILES_CACHE_MAX) break;
1220
+ if (entries[hash] || !Array.isArray(files)) continue;
1221
+ entries[hash] = files;
1222
+ count += 1;
1223
+ }
1224
+ ignoredCache.writeCacheJson(cache.root, REVISION_FILES_CACHE, { version: 1, shallow: cache.shallow, entries });
1109
1225
  }
1110
1226
 
1111
1227
  /**
@@ -1150,10 +1266,27 @@ function collectRevisionSignals(root, options = {}) {
1150
1266
  // commits count as revision signals.
1151
1267
  const humans = commits.filter((c) => !c.isAgent && c.parents.length < 2);
1152
1268
 
1153
- const filesCache = new Map();
1269
+ // Every landing's files are needed, and a human commit's only when it falls
1270
+ // inside some landing's window. Plain commits missing from the cache are
1271
+ // read in one batch; merges keep their own first-parent diff.
1272
+ const cache = loadRevisionFilesCache(root);
1273
+ const needed = [...landings, ...humans.filter((human) => landings.some((landing) => human.ms > landing.ms && human.ms <= landing.ms + REVISION_WINDOW_MS))];
1274
+ const missing = [];
1275
+ for (const commit of needed) {
1276
+ const hit = cache.entries[commit.hash];
1277
+ if (Array.isArray(hit)) cache.used[commit.hash] = hit;
1278
+ else if (commit.parents.length < 2) missing.push(commit);
1279
+ }
1280
+ for (const [hash, files] of plainCommitFiles(root, missing)) {
1281
+ cache.used[hash] = files;
1282
+ cache.dirty = true;
1283
+ }
1154
1284
  const filesOf = (commit) => {
1155
- if (!filesCache.has(commit.hash)) filesCache.set(commit.hash, commitFiles(root, commit));
1156
- return filesCache.get(commit.hash);
1285
+ if (!cache.used[commit.hash]) {
1286
+ cache.used[commit.hash] = commitFiles(root, commit);
1287
+ cache.dirty = true;
1288
+ }
1289
+ return cache.used[commit.hash];
1157
1290
  };
1158
1291
 
1159
1292
  const revisions = [];
@@ -1177,6 +1310,7 @@ function collectRevisionSignals(root, options = {}) {
1177
1310
  });
1178
1311
  }
1179
1312
  }
1313
+ saveRevisionFilesCache(cache);
1180
1314
 
1181
1315
  return {
1182
1316
  schema: REVISIONS_SCHEMA,
@@ -33,11 +33,12 @@ const {
33
33
  const fs = require('fs');
34
34
  const os = require('os');
35
35
  const path = require('path');
36
+ const { stateHome } = require('../lib/state-home');
36
37
  const readline = require('readline');
37
38
  const { spawnSync } = require('child_process');
38
39
  const { sleepSync } = require('../lib/sleep-sync');
39
40
 
40
- const CALENDAR_CACHE_PATH = path.join(os.homedir(), '.atris', 'calendar-events-cache.json');
41
+ const CALENDAR_CACHE_PATH = path.join(stateHome(), '.atris', 'calendar-events-cache.json');
41
42
  const GMAIL_CONNECT_POLL_MS = 3000;
42
43
  const GMAIL_CONNECT_TIMEOUT_MS = 3 * 60 * 1000;
43
44
  const GMAIL_SEND_USAGE = 'usage: atris gmail send <to> <subject> <body...> [--body-file <path>] [--account <id>]';
@@ -182,7 +183,7 @@ function parseGmailVerdictsArgs(args = []) {
182
183
 
183
184
  function gmailAccountStatePath() {
184
185
  return process.env.ATRIS_GMAIL_ACCOUNT_FILE
185
- || path.join(os.homedir(), '.atris', 'gmail-account.json');
186
+ || path.join(stateHome(), '.atris', 'gmail-account.json');
186
187
  }
187
188
 
188
189
  function readGmailStickyAccount() {
@@ -1794,7 +1795,7 @@ function normalizeContactLabel(value) {
1794
1795
  }
1795
1796
 
1796
1797
  function imessageLookupCachePath() {
1797
- return path.join(os.homedir(), '.atris', 'cache', 'imessage-contacts.json');
1798
+ return path.join(stateHome(), '.atris', 'cache', 'imessage-contacts.json');
1798
1799
  }
1799
1800
 
1800
1801
  function readImessageLookupCache() {
package/commands/loops.js CHANGED
@@ -13,6 +13,7 @@
13
13
  const fs = require('fs');
14
14
  const os = require('os');
15
15
  const path = require('path');
16
+ const { stateHome } = require('../lib/state-home');
16
17
  const { execSync, spawnSync } = require('child_process');
17
18
 
18
19
  const MAX_LOG_AGE_DAYS = 14;
@@ -26,7 +27,7 @@ const SCAFFOLD_FILES = [
26
27
  ];
27
28
 
28
29
  function heartbeatDir() {
29
- return process.env.ATRIS_HEARTBEAT_DIR || path.join(os.homedir(), '.atris', 'heartbeat');
30
+ return process.env.ATRIS_HEARTBEAT_DIR || path.join(stateHome(), '.atris', 'heartbeat');
30
31
  }
31
32
 
32
33
  function readJson(filePath, fallback) {
@@ -7,6 +7,7 @@ const { loadCredentials } = require('../utils/auth');
7
7
  const { resolveBackendRoot } = require('../utils/backend-root');
8
8
  const { apiRequestJson } = require('../utils/api');
9
9
  const { runAliveTick } = require('../lib/member-alive');
10
+ const { memberRefusalReason } = require('../lib/member-type');
10
11
  const { defaultObjectiveRunner } = require('../lib/default-runner');
11
12
  const { readJson, writeJson } = require('../lib/json-file');
12
13
  const { sleepSync } = require('../lib/sleep-sync');
@@ -306,6 +307,28 @@ function nearestMemberSlug(name, slugs) {
306
307
  return bestScore <= Math.max(2, Math.floor(needle.length / 3)) ? best : null;
307
308
  }
308
309
 
310
+ // A person is never run as an agent. Reads the member's own type and returns
311
+ // the plain refusal sentence, or '' when the member can run.
312
+ function memberRefusalFor(name, memberFile) {
313
+ try {
314
+ return memberRefusalReason(name, parseFrontmatter(fs.readFileSync(memberFile, 'utf8')) || {});
315
+ } catch {
316
+ return '';
317
+ }
318
+ }
319
+
320
+ // Same as requireMemberDir, then stops with a non-zero exit for a human or
321
+ // odd-typed member. Every path that runs a member as an agent starts here.
322
+ function requireRunnableMember(name) {
323
+ const paths = requireMemberDir(name);
324
+ const reason = memberRefusalFor(name, paths.memberFile);
325
+ if (reason) {
326
+ console.error(reason);
327
+ process.exit(1);
328
+ }
329
+ return paths;
330
+ }
331
+
309
332
  function requireMemberDir(name) {
310
333
  if (!name) {
311
334
  console.error('Usage: atris member <goal|tick|review> <name> ...');
@@ -1077,7 +1100,7 @@ function memberRun(name, ...args) {
1077
1100
  }
1078
1101
 
1079
1102
  const asJson = hasFlag(args, '--json');
1080
- const paths = requireMemberDir(name);
1103
+ const paths = requireRunnableMember(name);
1081
1104
  // Parking only hides a member from the team views; a run by name still goes.
1082
1105
  const { isParkedText } = require('../lib/member-park');
1083
1106
  try {
@@ -4221,6 +4244,8 @@ name: ${name}
4221
4244
  role: ${role}
4222
4245
  description: ${description || `Handles ${role.toLowerCase()} tasks`}
4223
4246
  version: 1.0.0
4247
+ # ai or human; a human member is a person and Atris never acts as them
4248
+ type: ai
4224
4249
 
4225
4250
  skills: []
4226
4251
 
@@ -5418,7 +5443,12 @@ function isWikiMinerNoiseName(value) {
5418
5443
  const name = compactSentence(value || '', 120);
5419
5444
  if (!name) return true;
5420
5445
  if (/^[A-Z]{2,}$/.test(name)) return false;
5421
- return /^(?:i|me|my|mine|myself|we|us|our|ours|ourselves|you|your|yours|yourself|yourselves|he|him|his|himself|she|her|hers|herself|it|its|itself|they|them|their|theirs|themselves|this|that|these|those|the|wiki|mission)$/i.test(name);
5446
+ return /^(?:i|me|my|mine|myself|we|us|our|ours|ourselves|you|your|yours|yourself|yourselves|he|him|his|himself|she|her|hers|herself|it|its|itself|they|them|their|theirs|themselves|this|that|these|those|here|there|where|when|what|who|whom|which|why|how|everything|nothing|something|anything|everyone|anyone|someone|nobody|everybody|anybody|somebody|none|the|wiki|mission)$/i.test(name);
5447
+ }
5448
+
5449
+ function isWikiMinerSelfLoop(relationship) {
5450
+ const from = lowerCompact(relationship?.from);
5451
+ return from !== '' && from === lowerCompact(relationship?.to);
5422
5452
  }
5423
5453
 
5424
5454
  function normalizeWikiMinerExtraction(raw) {
@@ -5437,7 +5467,7 @@ function normalizeWikiMinerExtraction(raw) {
5437
5467
  to: compactSentence(relationship?.to || '', 120),
5438
5468
  type: allowedRelationTypes.has(String(relationship?.type || '').toLowerCase()) ? String(relationship.type).toLowerCase() : 'uses',
5439
5469
  }))
5440
- .filter((relationship) => !isWikiMinerNoiseName(relationship.from) && !isWikiMinerNoiseName(relationship.to));
5470
+ .filter((relationship) => !isWikiMinerNoiseName(relationship.from) && !isWikiMinerNoiseName(relationship.to) && !isWikiMinerSelfLoop(relationship));
5441
5471
  return { entities, relationships };
5442
5472
  }
5443
5473
 
@@ -5459,7 +5489,9 @@ function heuristicWikiMinerExtraction(pagePath, pageContent) {
5459
5489
  }
5460
5490
  const entities = [{ type: 'concept', name: title }];
5461
5491
  for (const name of [...candidates].slice(0, 12)) entities.push({ type: /agent|api|sdk|cli|hub|system/i.test(name) ? 'system' : 'concept', name });
5462
- const relationships = [...candidates].slice(0, 12).map((name) => ({ from: title, to: name, type: 'uses' }));
5492
+ const relationships = [...candidates].slice(0, 12)
5493
+ .map((name) => ({ from: title, to: name, type: 'uses' }))
5494
+ .filter((relationship) => !isWikiMinerSelfLoop(relationship));
5463
5495
  return { entities, relationships };
5464
5496
  }
5465
5497
 
@@ -5513,11 +5545,12 @@ function mergeWikiGraph(graph, extraction, sourcePath) {
5513
5545
 
5514
5546
  const relationshipMap = new Map();
5515
5547
  for (const relationship of graph.relationships || []) {
5516
- if (isWikiMinerNoiseName(relationship.from) || isWikiMinerNoiseName(relationship.to)) continue;
5548
+ if (isWikiMinerNoiseName(relationship.from) || isWikiMinerNoiseName(relationship.to) || isWikiMinerSelfLoop(relationship)) continue;
5517
5549
  const key = `${lowerCompact(relationship.from)}|${String(relationship.type || 'uses').toLowerCase()}|${lowerCompact(relationship.to)}`;
5518
5550
  relationshipMap.set(key, { ...relationship, sources: Array.isArray(relationship.sources) ? relationship.sources : [] });
5519
5551
  }
5520
5552
  for (const relationship of extraction.relationships || []) {
5553
+ if (isWikiMinerNoiseName(relationship.from) || isWikiMinerNoiseName(relationship.to) || isWikiMinerSelfLoop(relationship)) continue;
5521
5554
  const key = `${lowerCompact(relationship.from)}|${relationship.type}|${lowerCompact(relationship.to)}`;
5522
5555
  const existing = relationshipMap.get(key) || { from: relationship.from, to: relationship.to, type: relationship.type, sources: [] };
5523
5556
  if (sourcePath && !existing.sources.includes(sourcePath)) existing.sources.push(sourcePath);
@@ -5794,7 +5827,6 @@ async function callSupervisorLlm(receipts, logs) {
5794
5827
  const output = { isTTY: false, write() { return true; } };
5795
5828
  const result = await postTurn(supervisorPrompt(receipts, logs), {
5796
5829
  mode: process.env.ATRIS_SUPERVISOR_LLM_MODE || 'fast',
5797
- route: 'local',
5798
5830
  cwd: process.cwd(),
5799
5831
  output,
5800
5832
  color: false,
@@ -5880,11 +5912,21 @@ async function runSupervisorWake(name, paths, { execute = false } = {}) {
5880
5912
  analysis = normalizeSupervisorAnalysis(llm.analysis, { status: 'ok', llm_source: llm.source });
5881
5913
  if (!analysis) {
5882
5914
  reason = 'llm_json_parse_failed';
5883
- analysis = emptySupervisorRecommendations({ status: 'parse_error', llm_source: llm.source, llm_error: 'invalid_analysis_shape' });
5915
+ analysis = {
5916
+ ...fallbackSupervisorAnalysis(receipts),
5917
+ status: 'parse_error',
5918
+ llm_source: llm.source,
5919
+ llm_error: 'invalid_analysis_shape',
5920
+ };
5884
5921
  }
5885
5922
  } else if (llm?.error) {
5886
5923
  reason = llm.error === 'invalid_json' ? 'llm_json_parse_failed' : 'llm_analysis_failed';
5887
- analysis = emptySupervisorRecommendations({ status: llm.error === 'invalid_json' ? 'parse_error' : 'llm_error', llm_source: llm.source, llm_error: llm.error });
5924
+ analysis = {
5925
+ ...fallbackSupervisorAnalysis(receipts),
5926
+ status: llm.error === 'invalid_json' ? 'parse_error' : 'llm_error',
5927
+ llm_source: llm.source,
5928
+ llm_error: llm.error,
5929
+ };
5888
5930
  } else {
5889
5931
  reason = 'llm_not_configured';
5890
5932
  analysis = fallbackSupervisorAnalysis(receipts);
@@ -7326,7 +7368,7 @@ async function runGeneralistWake(name, paths, { execute = false, domainInput = {
7326
7368
  record: domain.ok && proof.status === 'ok',
7327
7369
  libraryBefore: crossDomainLibraryBefore,
7328
7370
  receiptPath,
7329
- proofPath,
7371
+ proofPath: execute ? proofPath : null,
7330
7372
  });
7331
7373
  proof.learning = {
7332
7374
  library_path: path.relative(root, learning.library_path),
@@ -7757,6 +7799,24 @@ function wakeRuleDirtyMemberScope(ctx) {
7757
7799
  };
7758
7800
  }
7759
7801
 
7802
+ // Rule: a goal pinned to a terminal mission must not mint another doomed tick.
7803
+ // The goal stays active until a human retires it or revives the mission, so the
7804
+ // wake surfaces that decision instead of proposing `member tick --goal` on a
7805
+ // dead spine. Unknown mission ids pass through: they may live in a worktree.
7806
+ function wakeRuleTerminalMissionGoal(ctx) {
7807
+ const mission = ctx.goalTerminalMission;
7808
+ if (!ctx.goal || !mission) return null;
7809
+ return {
7810
+ decision: 'ask',
7811
+ reason: `goal_mission_terminal:${mission.status}`,
7812
+ needs_user: true,
7813
+ ask: `Goal "${ctx.goal.title}" is pinned to mission ${mission.id} (${mission.status}). Retire the goal or revive the mission, then wake again.`,
7814
+ next_command: `atris member goal-from-mission ${ctx.name}`,
7815
+ goal: ctx.goal,
7816
+ current_experiment: ctx.current || null,
7817
+ };
7818
+ }
7819
+
7760
7820
  // Rule: nothing blocks the member, so take the safe next bounded step.
7761
7821
  function wakeRuleSafeBoundedTick(ctx) {
7762
7822
  return {
@@ -7777,6 +7837,7 @@ const WAKE_DECISION_RULES = [
7777
7837
  wakeRuleOpenReviewableProposal,
7778
7838
  wakeRuleScoredNonTickCandidate,
7779
7839
  wakeRuleDirtyMemberScope,
7840
+ wakeRuleTerminalMissionGoal,
7780
7841
  wakeRuleSafeBoundedTick,
7781
7842
  ];
7782
7843
 
@@ -7790,6 +7851,8 @@ function wakeDecision(name, paths, { force = false, runtimeKind = memberRuntimeK
7790
7851
  const steering = readSteeringMemory(paths, name);
7791
7852
  const state = loadMemberGoals(name, paths);
7792
7853
  const goal = activeGoal(state);
7854
+ const pinnedMission = goal?.mission_id ? memberRunMissionMap().get(String(goal.mission_id).trim()) || null : null;
7855
+ const goalTerminalMission = pinnedMission && MISSION_TERMINAL_STATUSES.has(lowerCompact(pinnedMission.status || '')) ? pinnedMission : null;
7793
7856
  const current = memberOpenExperiment(state);
7794
7857
  const generalistDecision = wakeRuleGeneralistDomainFile(name, { purpose, steering, state, goal, current });
7795
7858
  if (generalistDecision) return generalistDecision;
@@ -7835,7 +7898,7 @@ function wakeDecision(name, paths, { force = false, runtimeKind = memberRuntimeK
7835
7898
  next_command: wakeScores.selected.next_command,
7836
7899
  } : null;
7837
7900
 
7838
- const ctx = { name, force, purpose, nowFile, steering, state, goal, current, blocked, evidence, workspace, checks, wakeScores };
7901
+ const ctx = { name, force, purpose, nowFile, steering, state, goal, goalTerminalMission, current, blocked, evidence, workspace, checks, wakeScores };
7839
7902
  for (const rule of WAKE_DECISION_RULES) {
7840
7903
  const verdict = rule(ctx);
7841
7904
  if (verdict) return composeWakeDecision(ctx, verdict);
@@ -7863,6 +7926,8 @@ function wakeMemberEngine(name, root = process.cwd()) {
7863
7926
 
7864
7927
  async function runMemberWake(name, { execute = false, confirmed = false, force = false, domainInput = {} } = {}) {
7865
7928
  const paths = requireMemberDir(name);
7929
+ const refusal = memberRefusalFor(name, paths.memberFile);
7930
+ if (refusal) throw new Error(refusal);
7866
7931
  const runtimeKind = paths.runtimeKind || memberRuntimeKind(name);
7867
7932
  if (runtimeKind === 'auto-improver') {
7868
7933
  return runAutoImproverWake(name, paths, { execute, confirmed });
@@ -8176,6 +8241,7 @@ function clipText(value, max = 70) {
8176
8241
  }
8177
8242
 
8178
8243
  async function memberWake(name, ...args) {
8244
+ if (name) requireRunnableMember(name);
8179
8245
  const asJson = hasFlag(args, '--json');
8180
8246
  const execute = hasFlag(args, '--execute') && !hasFlag(args, '--dry-run');
8181
8247
  const confirmed = hasFlag(args, '--confirm-autonomy-policy');
@@ -8223,14 +8289,15 @@ function collectFleetActivationRows(root = process.cwd()) {
8223
8289
  const goalPlane = memberGoalPlaneStatus(slug, paths);
8224
8290
  const activeMission = memberLiveMission(slug, missionMap);
8225
8291
  const latestMission = memberLatestMission(slug, missionMap);
8226
- const skipped = !goalPlane.goal && !activeMission;
8292
+ const refusal = memberRefusalFor(slug, paths.memberFile);
8293
+ const skipped = Boolean(refusal) || (!goalPlane.goal && !activeMission);
8227
8294
  return {
8228
8295
  member: slug,
8229
8296
  goal: goalPlane.goal || null,
8230
8297
  active_mission: activeMission || null,
8231
8298
  latest_mission: latestMission || null,
8232
8299
  skipped,
8233
- skip_reason: skipped ? 'no_goal_and_no_active_mission' : null,
8300
+ skip_reason: refusal ? 'not_ai' : skipped ? 'no_goal_and_no_active_mission' : null,
8234
8301
  };
8235
8302
  })
8236
8303
  .sort((a, b) => a.member.localeCompare(b.member));
@@ -8263,7 +8330,7 @@ async function memberLoopAll(kind, ...args) {
8263
8330
  for (const row of rows) {
8264
8331
  if (row.skipped) {
8265
8332
  skipped.push(row);
8266
- if (!asJson) console.log(`Skip ${row.member}: no goal and no active mission.`);
8333
+ if (!asJson) console.log(`Skip ${row.member}: ${row.skip_reason === 'not_ai' ? 'not an AI member.' : 'no goal and no active mission.'}`);
8267
8334
  continue;
8268
8335
  }
8269
8336
  if (!asJson) console.log(`Activate ${row.member}: ${row.goal ? 'goal' : `mission ${row.active_mission?.id || 'active'}`}`);
@@ -8675,7 +8742,7 @@ function memberLoopRecordResult(name, paths, lease, run) {
8675
8742
  // Driver: the phases above do the work; this reads as setup -> gate -> run -> record.
8676
8743
  async function memberLoop(name, ...args) {
8677
8744
  if (name === '--all' || name === 'all') return memberLoopAll('loop', ...args);
8678
- requireMemberDir(name);
8745
+ requireRunnableMember(name);
8679
8746
  const asJson = hasFlag(args, '--json');
8680
8747
  const aliveMode = hasFlag(args, '--alive');
8681
8748
  const execute = hasFlag(args, '--execute');
@@ -8739,7 +8806,7 @@ async function memberLoop(name, ...args) {
8739
8806
  }
8740
8807
 
8741
8808
  async function memberTick(name, ...args) {
8742
- const paths = requireMemberDir(name);
8809
+ const paths = requireRunnableMember(name);
8743
8810
  const asJson = hasFlag(args, '--json');
8744
8811
  const force = hasFlag(args, '--force');
8745
8812
  const goalId = readFlag(args, '--goal', '');
@@ -22,6 +22,7 @@ const {
22
22
  const {
23
23
  resolveClaudeRunnerModel,
24
24
  resolvePinnedRunnerModel,
25
+ runnerModelFor,
25
26
  resolveClaudeRunnerBin,
26
27
  resolveClaudeRunnerCommandTemplate,
27
28
  buildRunnerCommand,
@@ -568,6 +569,8 @@ function applyMissionRunnerProfile(runner) {
568
569
  // default model pin. Every other engine's CLI rejects a claude model name, so
569
570
  // it only ever takes an explicit pin (the mission's model or the roster
570
571
  // line's); '' lets the engine ride its own default.
572
+ // A mission often still carries the claude model it was created with; an
573
+ // engine that runs only its own models (codex, grok) drops it.
571
574
  const CLAUDE_FAMILY_RUNNER_ENGINES = new Set(['claude', 'fable', 'haiku']);
572
575
 
573
576
  function resolveMissionTickRunnerModel(mission) {
@@ -575,7 +578,7 @@ function resolveMissionTickRunnerModel(mission) {
575
578
  if (!engine || CLAUDE_FAMILY_RUNNER_ENGINES.has(engine)) {
576
579
  return resolveClaudeRunnerModel(mission);
577
580
  }
578
- return resolvePinnedRunnerModel(mission);
581
+ return runnerModelFor(engine, resolvePinnedRunnerModel(mission));
579
582
  }
580
583
 
581
584
  function exitMissionError(message, code = 1, asJson = false) {
@@ -1,8 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  const fs = require('fs');
4
- const os = require('os');
5
4
  const path = require('path');
5
+ const { stateHome } = require('../lib/state-home');
6
6
 
7
7
  function slugify(value, fallback = 'atris-pack') {
8
8
  return String(value || fallback)
@@ -69,7 +69,7 @@ function isNonEmptyTarget(targetDir) {
69
69
  function defaultAuthor() {
70
70
  try {
71
71
  const creds = JSON.parse(
72
- fs.readFileSync(path.join(os.homedir(), '.atris', 'credentials.json'), 'utf8')
72
+ fs.readFileSync(path.join(stateHome(), '.atris', 'credentials.json'), 'utf8')
73
73
  );
74
74
  if (creds && typeof creds.email === 'string' && creds.email.trim()) return creds.email.trim();
75
75
  } catch {
@@ -1,6 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const os = require('os');
3
+ const { stateHome } = require('../lib/state-home');
4
4
  const { loadCredentials } = require('../utils/auth');
5
5
  const { apiRequestJson } = require('../utils/api');
6
6
 
@@ -104,7 +104,7 @@ async function publishAtris() {
104
104
  }
105
105
 
106
106
  // Save local copy
107
- const localDir = path.join(os.homedir(), '.atris', 'templates', name.toLowerCase().replace(/[^a-z0-9-]/g, '-'));
107
+ const localDir = path.join(stateHome(), '.atris', 'templates', name.toLowerCase().replace(/[^a-z0-9-]/g, '-'));
108
108
  fs.mkdirSync(localDir, { recursive: true });
109
109
 
110
110
  for (const f of files) {
package/commands/radar.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
+ const { stateHome } = require('../lib/state-home');
6
7
  const { execFileSync } = require('child_process');
7
8
  const { parseScopeFlag, pathUnderRoot } = require('../lib/cli-scope');
8
9
 
@@ -346,7 +347,7 @@ function loadBusinessCollaboration(root, deps, team = {}, scorecardCount = count
346
347
  const business = readJsonFile(path.join(root, '.atris', 'business.json'), deps, null);
347
348
  const runtime = readJsonFile(path.join(root, '.atris', 'state', 'runtime.json'), deps, null);
348
349
  const sync = readJsonFile(path.join(root, '.atris', 'state', '_sync.json'), deps, null);
349
- const cache = readJsonFile(path.join(deps.homeDir || os.homedir(), '.atris', 'businesses.json'), deps, {}) || {};
350
+ const cache = readJsonFile(path.join(deps.homeDir || stateHome(), '.atris', 'businesses.json'), deps, {}) || {};
350
351
  const slug = business?.slug || sync?.workspace_slug || null;
351
352
  const cacheEntry = slug && cache ? cache[slug] : null;
352
353
  const hasAtris = deps.exists(path.join(root, 'atris'));
@@ -804,7 +805,7 @@ function collectRadar(options = {}) {
804
805
  readlink: options.readlinkSync || fs.readlinkSync,
805
806
  readdir: options.readdirSync || fs.readdirSync,
806
807
  platform: options.platform || os.platform(),
807
- homeDir: options.homeDir || os.homedir(),
808
+ homeDir: options.homeDir || stateHome(),
808
809
  };
809
810
  const nowMs = options.nowMs || Date.now();
810
811
  const tasks = loadTasks(root, deps);
@@ -2,8 +2,8 @@
2
2
 
3
3
  const crypto = require('node:crypto');
4
4
  const fs = require('node:fs');
5
- const os = require('node:os');
6
5
  const path = require('node:path');
6
+ const { stateHome } = require('../lib/state-home');
7
7
  const { spawnSync } = require('node:child_process');
8
8
  const { loadOverrides, pickLane } = require('../lib/ax-auto-lane');
9
9
 
@@ -28,7 +28,7 @@ function readFirstNamedFlag(args, names) {
28
28
  }
29
29
 
30
30
  function resolveRouterPaths(args = [], env = process.env) {
31
- const home = env.HOME || os.homedir();
31
+ const home = stateHome(env);
32
32
  return {
33
33
  picks: path.resolve(readFirstNamedFlag(args, ['--picks', '--picks-path'])
34
34
  || env.ATRIS_ROUTER_PICKS_PATH