atris 3.58.7 → 3.59.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 (95) hide show
  1. package/AGENTS.md +3 -2
  2. package/GETTING_STARTED.md +2 -2
  3. package/PERSONA.md +11 -11
  4. package/README.md +1 -1
  5. package/atris/GEMINI.md +7 -6
  6. package/atris/PERSONA.md +4 -4
  7. package/atris/features/README.md +77 -6
  8. package/atris/skills/README.md +44 -15
  9. package/atris/skills/aeo/SKILL.md +10 -10
  10. package/atris/skills/atris/SKILL.md +1 -1
  11. package/atris/skills/engines/SKILL.md +69 -50
  12. package/atris/skills/meta/SKILL.md +4 -4
  13. package/bin/atris.js +8 -6
  14. package/commands/aeo.js +9 -12
  15. package/commands/analytics.js +8 -11
  16. package/commands/autopilot.js +79 -8
  17. package/commands/avail.js +1 -1
  18. package/commands/brain.js +3 -1
  19. package/commands/business.js +13 -8
  20. package/commands/clean.js +17 -0
  21. package/commands/close.js +2 -1
  22. package/commands/computer.js +15 -10
  23. package/commands/context-sync.js +3 -3
  24. package/commands/doc-health.js +55 -2
  25. package/commands/engine.js +384 -4
  26. package/commands/experiments.js +13 -4
  27. package/commands/host.js +53 -0
  28. package/commands/improve.js +1 -1
  29. package/commands/integrations.js +2 -5
  30. package/commands/learn.js +1 -1
  31. package/commands/member.js +231 -53
  32. package/commands/mission.js +129 -19
  33. package/commands/one-lap.js +58 -3
  34. package/commands/pack.js +2 -2
  35. package/commands/probe.js +11 -5
  36. package/commands/pull.js +1 -1
  37. package/commands/push.js +1 -1
  38. package/commands/rainmaker.js +3 -16
  39. package/commands/run.js +4 -1
  40. package/commands/scout.js +1 -1
  41. package/commands/search.js +5 -3
  42. package/commands/spaceship.js +1 -1
  43. package/commands/stream.js +27 -2
  44. package/commands/study.js +15 -9
  45. package/commands/sync.js +1 -1
  46. package/commands/task.js +13 -2
  47. package/commands/unknowns.js +5 -0
  48. package/commands/verify.js +12 -4
  49. package/commands/visualize.js +20 -3
  50. package/commands/workflow.js +23 -7
  51. package/lib/codex-flight.js +28 -5
  52. package/lib/dispatch-scout.js +31 -22
  53. package/lib/engine-registry.js +1482 -13
  54. package/lib/feedback-triage.js +2 -0
  55. package/lib/fleet.js +111 -12
  56. package/lib/host.js +336 -0
  57. package/lib/improve-vitals-html.js +3 -2
  58. package/lib/known-commands.js +1 -1
  59. package/lib/lesson-contradiction.js +2 -2
  60. package/lib/map-refs.js +226 -0
  61. package/lib/member-engine.js +253 -0
  62. package/lib/mission-room.js +4 -4
  63. package/lib/noninteractive.js +15 -4
  64. package/lib/policy-lessons.js +1 -1
  65. package/lib/receipt-block.js +137 -28
  66. package/lib/roster-markdown.js +206 -0
  67. package/lib/roster-models.js +179 -0
  68. package/lib/runner-command.js +95 -15
  69. package/lib/scorecard.js +4 -4
  70. package/lib/self-drive.js +9 -1
  71. package/lib/sleep-sync.js +11 -0
  72. package/lib/state-detection.js +28 -9
  73. package/lib/task-db.js +3 -2
  74. package/lib/todo-fallback.js +1 -1
  75. package/lib/wish-audit.js +10 -6
  76. package/lib/wish-delegate.js +29 -5
  77. package/mcp/atris-mcp/index.mjs +77 -24
  78. package/package.json +2 -5
  79. package/scripts/det/det.js +2 -2
  80. package/scripts/det/pr-description.js +2 -2
  81. package/scripts/det/test.js +2 -2
  82. package/templates/members/host/MEMBER.md +23 -0
  83. package/templates/members/host/MISSION.md +11 -0
  84. package/templates/members/host/SOUL.md +7 -0
  85. package/templates/members/host/context/why.md +3 -0
  86. package/templates/members/host/goals.json +5 -0
  87. package/templates/members/host/goals.md +3 -0
  88. package/templates/members/host/skills/host/SKILL.md +13 -0
  89. package/utils/api.js +1 -1
  90. package/utils/auth.js +17 -7
  91. package/utils/backend-root.js +31 -0
  92. package/utils/config.js +5 -4
  93. package/utils/owner-identity.js +61 -0
  94. package/atris/policies/LESSONS.md +0 -13
  95. package/atris/skills/autopilot/atris-autopilot.md +0 -71
@@ -22,8 +22,8 @@ function detectLessonContradictions(cwd) {
22
22
  ? fs.readFileSync(lessonsMdPath, 'utf8')
23
23
  : '';
24
24
 
25
- // Format: - **[YYYY-MM-DD] slug**, pass|fail, prose
26
- const lessonRegex = /^-\s+\*\*\[(\d{4}-\d{2}-\d{2})\]\s+([a-z0-9-]+)\*\*\s*\u2014\s*(pass|fail)\s*\u2014\s*(\[resolved\])?\s*(.+)$/m;
25
+ // Format: - **[YYYY-MM-DD] slug** - pass|fail - prose
26
+ const lessonRegex = /^-\s+\*\*\[(\d{4}-\d{2}-\d{2})\]\s+([a-z0-9-]+)\*\*\s*[-\u2014]\s*(pass|fail)\s*[-\u2014]\s*(\[resolved\])?\s*(.+)$/m;
27
27
  const lines = lessonsMdContent.split('\n');
28
28
 
29
29
  const lessonsBySlug = new Map(); // slug -> [{ date, outcome, lineNum, resolved }]
@@ -0,0 +1,226 @@
1
+ 'use strict';
2
+
3
+ // Checks the `path:N` line refs in a map document against the code they point at.
4
+ // A ref may carry a symbol: an identifier written right after it (`path:N` `name`,
5
+ // `path:N` (`name`), or a bare camelCase name) or right before it (`name` (`path:N`)).
6
+ // A name elsewhere in the sentence never binds: map sentences often name a helper
7
+ // the ref does not point at, and binding it would move the ref to the wrong place.
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const WINDOW = 3;
13
+ const IDENT = /^[A-Za-z_$][\w$]*$/;
14
+ const REF = /^(?:\.\/)?([^\s:`<>*|]+):(\d+)(?:-(\d+))?((?:,\d+(?:-\d+)?)*)(?:#\S*)?$/;
15
+
16
+ function escape(text) {
17
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
18
+ }
19
+
20
+ function definitionPatterns(symbol) {
21
+ const s = escape(symbol);
22
+ return [
23
+ new RegExp(`\\bfunction\\s*\\*?\\s*${s}\\s*\\(`),
24
+ new RegExp(`\\b(?:const|let|var)\\s+${s}\\b`),
25
+ new RegExp(`\\bclass\\s+${s}\\b`),
26
+ new RegExp(`\\bdef\\s+${s}\\b`),
27
+ new RegExp(`^\\s*(?:async\\s+)?(?:static\\s+)?(?:get\\s+|set\\s+)?\\*?${s}\\s*\\([^)]*\\)\\s*\\{`),
28
+ new RegExp(`^\\s*['"]?${s}['"]?\\s*:(?!:)`),
29
+ new RegExp(`\\bcase\\s+['"\`]?${s}['"\`]?\\s*:`),
30
+ new RegExp(`\\bexports\\.${s}\\s*=`),
31
+ ];
32
+ }
33
+
34
+ function tokenPattern(symbol, flags = '') {
35
+ return new RegExp(`(?<![\\w$])${escape(symbol)}(?![\\w$])`, flags);
36
+ }
37
+
38
+ function isPathLike(file) {
39
+ return !file.includes('://') && (file.includes('/') || /\.[a-z0-9]+$/i.test(file));
40
+ }
41
+
42
+ // Find every ref in the map, with its map line, column, and optional symbol.
43
+ // Code fences are scanned too: the map keeps prose notes inside its search block.
44
+ function parseRefs(mapText) {
45
+ const refs = [];
46
+ mapText.split(/\r?\n/).forEach((line, index) => {
47
+ const spans = [...line.matchAll(/`([^`\r\n]+)`/g)].map(match => ({
48
+ text: match[1], start: match.index, end: match.index + match[0].length,
49
+ }));
50
+ spans.forEach((span, i) => {
51
+ const match = span.text.trim().match(REF);
52
+ if (!match || !isPathLike(match[1])) return;
53
+ const ref = {
54
+ map_line: index + 1, column: span.start, raw: span.text, path: match[1],
55
+ start: Number(match[2]), end: match[3] ? Number(match[3]) : null,
56
+ extra: match[4] ? match[4].slice(1).split(',').map(part => part.split('-').map(Number)) : [],
57
+ symbols: [],
58
+ };
59
+ if (!ref.extra.length) ref.symbols = symbolsFor(line, spans, i);
60
+ ref.symbol = ref.symbols[0] || null;
61
+ refs.push(ref);
62
+ });
63
+ });
64
+ return refs;
65
+ }
66
+
67
+ // Symbols bind only when written right next to the ref, so a backticked word
68
+ // later in the sentence never passes for the thing the ref points at.
69
+ function symbolsFor(line, spans, i) {
70
+ const clean = text => text.trim().replace(/\(\)$/, '');
71
+ const found = [];
72
+ const after = spans[i + 1];
73
+ if (after && /^\s*\(?\s*$/.test(line.slice(spans[i].end, after.start)) && IDENT.test(clean(after.text))) {
74
+ found.push(clean(after.text));
75
+ }
76
+ // An unquoted name counts only when it is shaped like code (camelCase or snake_case).
77
+ const bare = line.slice(spans[i].end).match(/^ ([A-Za-z_$][\w$]*)(?:\(\))?(?=[\s),;:.]|$)/);
78
+ if (bare && /[a-z][A-Z]|_/.test(bare[1])) found.push(bare[1]);
79
+ const before = spans[i - 1];
80
+ if (before && /^\s*\(\s*$/.test(line.slice(before.end, spans[i].start)) && IDENT.test(clean(before.text))) {
81
+ found.push(clean(before.text));
82
+ }
83
+ return [...new Set(found)];
84
+ }
85
+
86
+ // Line numbers (1-based) of every line holding the symbol, found in one pass over the text.
87
+ function lineNumbersOf(file, symbol) {
88
+ const found = [];
89
+ for (const match of file.text.matchAll(tokenPattern(symbol, 'g'))) {
90
+ let low = 0;
91
+ let high = file.starts.length - 1;
92
+ while (low < high) {
93
+ const mid = (low + high + 1) >> 1;
94
+ if (file.starts[mid] <= match.index) low = mid; else high = mid - 1;
95
+ }
96
+ if (found[found.length - 1] !== low + 1) found.push(low + 1);
97
+ }
98
+ return found;
99
+ }
100
+
101
+ function classify(ref, file) {
102
+ const { lines } = file;
103
+ const last = ref.end || ref.start;
104
+ const numbers = [ref.start, last, ...ref.extra.flat()];
105
+ const inBounds = numbers.every(n => n >= 1 && n <= lines.length);
106
+ if (!ref.symbol) return inBounds ? { status: 'ok' } : { status: 'missing', reason: 'line out of range' };
107
+ const low = Math.max(1, ref.start - WINDOW);
108
+ const high = Math.min(lines.length, last + WINDOW);
109
+ // A range that runs past the end of the file is wrong even when its start is right.
110
+ if (inBounds) for (const symbol of ref.symbols) {
111
+ const near = tokenPattern(symbol);
112
+ for (let n = low; n <= high; n++) if (near.test(lines[n - 1])) return { status: 'ok', symbol };
113
+ }
114
+ const uses = lineNumbersOf(file, ref.symbol);
115
+ const patterns = definitionPatterns(ref.symbol);
116
+ const defs = uses.filter(n => patterns.some(pattern => pattern.test(lines[n - 1])));
117
+ const candidates = defs.length ? defs : uses;
118
+ if (!candidates.length) return { status: 'missing', reason: `${ref.symbol} is not in the file` };
119
+ if (candidates.length > 1) return { status: 'ambiguous', lines: candidates.slice(0, 5) };
120
+ const line = candidates[0];
121
+ // Keep the range width but never write an end past the last line.
122
+ return { status: 'moved', line, ...(ref.end ? { end_line: Math.min(lines.length, line + ref.end - ref.start) } : {}) };
123
+ }
124
+
125
+ function checkMapRefs(root, mapText) {
126
+ const cache = new Map();
127
+ const linesOf = file => {
128
+ if (!cache.has(file)) {
129
+ const full = path.resolve(root, file);
130
+ let text = null;
131
+ try {
132
+ if (fs.statSync(full).isFile()) text = fs.readFileSync(full, 'utf8');
133
+ } catch (error) {
134
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error;
135
+ }
136
+ if (text === null) cache.set(file, null);
137
+ else {
138
+ const raw = text.split('\n');
139
+ const starts = [0];
140
+ for (let i = 0; i < raw.length - 1; i++) starts.push(starts[i] + raw[i].length + 1);
141
+ const lines = raw.map(line => (line.endsWith('\r') ? line.slice(0, -1) : line));
142
+ cache.set(file, { text, lines, starts });
143
+ }
144
+ }
145
+ return cache.get(file);
146
+ };
147
+ const refs = parseRefs(mapText).map(ref => {
148
+ const file = linesOf(ref.path);
149
+ return { ...ref, ...(file ? classify(ref, file) : { status: 'missing-file' }) };
150
+ });
151
+ return summarize(refs);
152
+ }
153
+
154
+ function summarize(refs) {
155
+ const count = status => refs.filter(ref => ref.status === status).length;
156
+ const summary = {
157
+ total: refs.length, ok: count('ok'), moved: count('moved'), ambiguous: count('ambiguous'),
158
+ missing: count('missing'), missing_file: count('missing-file'),
159
+ };
160
+ return { ...summary, score: refs.length ? summary.ok / refs.length : 1, refs };
161
+ }
162
+
163
+ function newRefText(ref) {
164
+ const lineText = ref.end ? `${ref.line}-${ref.end_line}` : `${ref.line}`;
165
+ return ref.raw.replace(`:${ref.start}${ref.end ? `-${ref.end}` : ''}`, `:${lineText}`);
166
+ }
167
+
168
+ // Rewrite only refs whose symbol sits unambiguously on another line.
169
+ function fixMapRefs(root, mapFile = 'atris/MAP.md') {
170
+ const file = path.resolve(root, mapFile);
171
+ const text = fs.readFileSync(file, 'utf8');
172
+ const result = checkMapRefs(root, text);
173
+ const moved = result.refs.filter(ref => ref.status === 'moved');
174
+ const lines = text.split('\n');
175
+ const changes = [];
176
+ for (const ref of [...moved].sort((a, b) => b.map_line - a.map_line || b.column - a.column)) {
177
+ const replacement = newRefText(ref);
178
+ const line = lines[ref.map_line - 1];
179
+ lines[ref.map_line - 1] = line.slice(0, ref.column + 1) + replacement + line.slice(ref.column + 1 + ref.raw.length);
180
+ changes.unshift({ map_line: ref.map_line, from: ref.raw, to: replacement, symbol: ref.symbol });
181
+ }
182
+ if (changes.length) fs.writeFileSync(file, lines.join('\n'));
183
+ const left = result.refs.filter(ref => ref.status !== 'ok' && ref.status !== 'moved');
184
+ return { changes, left };
185
+ }
186
+
187
+ // The short routing map loads at boot; exact line refs live in the notes file.
188
+ // Both are checked and fixed, so moving a ref out of the boot map never hides it.
189
+ const MAP_REF_DOCS = ['atris/MAP.md', 'atris/refs/MAP-NOTES.md'];
190
+
191
+ function existingMapDocs(root) {
192
+ return MAP_REF_DOCS.filter(doc => {
193
+ try { return fs.statSync(path.resolve(root, doc)).isFile(); } catch (error) {
194
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
195
+ throw error;
196
+ }
197
+ });
198
+ }
199
+
200
+ // mapText is the already-read atris/MAP.md, so boot does not read it twice.
201
+ function checkMapDocs(root, mapText) {
202
+ const refs = existingMapDocs(root).flatMap(doc => {
203
+ const text = doc === MAP_REF_DOCS[0] && typeof mapText === 'string'
204
+ ? mapText : fs.readFileSync(path.resolve(root, doc), 'utf8');
205
+ return checkMapRefs(root, text).refs.map(ref => ({ doc, ...ref }));
206
+ });
207
+ return summarize(refs);
208
+ }
209
+
210
+ function fixMapDocs(root) {
211
+ const changes = [];
212
+ const left = [];
213
+ for (const doc of existingMapDocs(root)) {
214
+ const result = fixMapRefs(root, doc);
215
+ changes.push(...result.changes.map(change => ({ doc, ...change })));
216
+ left.push(...result.left.map(ref => ({ doc, ...ref })));
217
+ }
218
+ return { changes, left };
219
+ }
220
+
221
+ // Repo-relative path of the notes file when this workspace has one, else null.
222
+ function mapNotesPath(root) {
223
+ return existingMapDocs(root).includes(MAP_REF_DOCS[1]) ? MAP_REF_DOCS[1] : null;
224
+ }
225
+
226
+ module.exports = { MAP_REF_DOCS, existingMapDocs, mapNotesPath, checkMapRefs, checkMapDocs, fixMapRefs, fixMapDocs };
@@ -0,0 +1,253 @@
1
+ 'use strict';
2
+
3
+ // Which engine a team member runs on. Every member in atris/team/*/MEMBER.md
4
+ // gets a job on its own: navigators, researchers, and scouts search;
5
+ // validators, reviewers, judges, critics, and verifiers review; everyone else
6
+ // builds. A line under "## team" in ROSTER.md overrides that, either with a
7
+ // job ("researcher: search", "fixer: small build") or with engine or model
8
+ // words ("judge: opus 5.5"). The job's pick then comes from the roster.
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const {
13
+ ENGINE_JOBS,
14
+ ENGINE_ROLES,
15
+ inferJobKind,
16
+ mainCheckoutRoot,
17
+ readEngineRegistry,
18
+ readRosterState,
19
+ resolveEngineForRoleRanked,
20
+ rosterChoiceForPick,
21
+ rosterDecided,
22
+ ROSTER_LAYERS,
23
+ rosterJobKey,
24
+ rosterJobLabel,
25
+ rosterJobRole,
26
+ rosterModelLabel,
27
+ rosterPickFromValue,
28
+ } = require('./engine-registry');
29
+
30
+ const SEARCH_WORDS = Object.freeze(['navigator', 'researcher', 'research', 'search', 'scout']);
31
+ const REVIEW_WORDS = Object.freeze(['validator', 'reviewer', 'review', 'judge', 'critic', 'verifier']);
32
+
33
+ function memberCardFile(root, name) {
34
+ return path.join(root, 'atris', 'team', name, 'MEMBER.md');
35
+ }
36
+
37
+ // This folder's card, else the main checkout's when this is a git worktree:
38
+ // the same fallback the roster file uses, so a card not yet committed still
39
+ // counts from a worktree. '' when neither has one.
40
+ function findMemberCard(root, name) {
41
+ const own = memberCardFile(root, name);
42
+ if (fs.existsSync(own)) return own;
43
+ const main = mainCheckoutRoot(root);
44
+ const shared = main ? memberCardFile(main, name) : '';
45
+ return shared && fs.existsSync(shared) ? shared : '';
46
+ }
47
+
48
+ // name and role from the MEMBER.md frontmatter; found is false when there is
49
+ // no card for this name.
50
+ function readMemberCard(root, name) {
51
+ let text = '';
52
+ try {
53
+ text = fs.readFileSync(findMemberCard(root, name), 'utf8');
54
+ } catch {
55
+ return { name, role: '', found: false };
56
+ }
57
+ const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
58
+ const field = (key) => {
59
+ const match = block ? new RegExp(`^${key}:\\s*(.*)$`, 'm').exec(block[1]) : null;
60
+ return match ? match[1].trim().replace(/^["']|["']$/g, '') : '';
61
+ };
62
+ return { name: field('name') || name, role: field('role'), found: true };
63
+ }
64
+
65
+ function listTeamMembers(root = process.cwd()) {
66
+ const dir = path.join(root, 'atris', 'team');
67
+ let names = [];
68
+ try {
69
+ names = fs.readdirSync(dir, { withFileTypes: true })
70
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.') && !entry.name.startsWith('_'))
71
+ .map((entry) => entry.name)
72
+ .filter((name) => fs.existsSync(memberCardFile(root, name)));
73
+ } catch {
74
+ return [];
75
+ }
76
+ return names.sort();
77
+ }
78
+
79
+ function words(text) {
80
+ return String(text || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
81
+ }
82
+
83
+ function kindFromWords(list) {
84
+ if (list.some((word) => SEARCH_WORDS.includes(word))) return 'search';
85
+ if (list.some((word) => REVIEW_WORDS.includes(word))) return 'review';
86
+ return '';
87
+ }
88
+
89
+ // The job a member gets with no team line: its name decides first, then its
90
+ // role title, else build.
91
+ function autoJobForMember(name, role = '') {
92
+ return kindFromWords(words(name)) || kindFromWords(words(role)) || 'build';
93
+ }
94
+
95
+ // This session's team line wins, then this project's, then the
96
+ // all-projects one.
97
+ function teamLineFor(state, member) {
98
+ const key = String(member || '').trim().toLowerCase();
99
+ const find = (layer) => (layer && Array.isArray(layer.team) ? layer.team.filter((line) => line.member === key)[0] : null);
100
+ for (const scope of ROSTER_LAYERS) {
101
+ const line = find(state[scope]);
102
+ if (line) return { ...line, scope };
103
+ }
104
+ return null;
105
+ }
106
+
107
+ // What a team line value means: a job (built-in name, a job the roster
108
+ // already has, or a name that says its kind) or a direct engine pick for the
109
+ // member's own kind of work. error is a plain sentence for a line that means
110
+ // neither.
111
+ function interpretTeamValue(value, state, memberKind, options = {}) {
112
+ const text = String(value || '').trim();
113
+ if (!text) return { error: 'names no job or engine' };
114
+ const key = rosterJobKey(text);
115
+ if (key && ENGINE_ROLES.includes(key)) return { type: 'job', key };
116
+ const saved = (layer) => Boolean(key && layer && layer.picks && layer.picks[key]);
117
+ if (saved(state.session) || saved(state.project) || saved(state.machine)) return { type: 'job', key };
118
+ try {
119
+ return { type: 'pick', pick: rosterPickFromValue(text, ENGINE_JOBS[memberKind], options) };
120
+ } catch (error) {
121
+ if (key && inferJobKind(text)) return { type: 'job', key };
122
+ return { error: error.message };
123
+ }
124
+ }
125
+
126
+ function engineText(engine) {
127
+ if (!engine) return 'no ready engine';
128
+ return `${engine.id}${engine.roster_model ? ` ${rosterModelLabel(engine.roster_model)}` : ''}`;
129
+ }
130
+
131
+ // The engine one member runs on, with a plain reason ("researcher does
132
+ // search: claude haiku"). roster_backed is true when a roster line decided
133
+ // (a job pick or a team line); when false the router decided, and callers
134
+ // keep whatever they did before the roster existed.
135
+ function resolveEngineForMember(memberName, root = process.cwd(), options = {}) {
136
+ const name = String(memberName || '').trim();
137
+ const card = readMemberCard(root, name);
138
+ const state = options.rosterState || readRosterState(root, options);
139
+ const autoKind = autoJobForMember(name, card.role);
140
+ const line = teamLineFor(state, name);
141
+ const read = line ? interpretTeamValue(line.value, state, autoKind, options) : null;
142
+ const override = read && !read.error ? read : null;
143
+ const yearless = override && override.type === 'pick' && override.pick.until_needs_year;
144
+ const pickOptions = {
145
+ ...options,
146
+ sessionRosterPicks: state.session ? state.session.picks : {},
147
+ projectRosterPicks: state.project.picks,
148
+ machineRosterPicks: state.machine.picks,
149
+ };
150
+ const base = {
151
+ member: name,
152
+ title: card.role || '',
153
+ found: card.found || Boolean(line),
154
+ source: override ? 'file' : 'auto',
155
+ file: override ? line.file : null,
156
+ line: override ? line.line : null,
157
+ ...(read && read.error ? { warning: read.error } : {}),
158
+ ...(yearless ? { until_warning: `has no year in its until date, so it counts as expired; write until ${yearless}` } : {}),
159
+ };
160
+
161
+ if (override && override.type === 'pick') {
162
+ const role = ENGINE_JOBS[autoKind];
163
+ const registry = readEngineRegistry(root, { persist: false });
164
+ const choice = rosterChoiceForPick(override.pick, role, registry, { label: autoKind, source: line.scope, now: options.now });
165
+ if (choice) {
166
+ return {
167
+ ...base,
168
+ job: autoKind,
169
+ job_key: role,
170
+ role,
171
+ engine: choice.engine,
172
+ model: choice.engine.roster_model || '',
173
+ pick_source: 'team',
174
+ roster_backed: true,
175
+ reason: `${name} does ${autoKind}: ${engineText(choice.engine)}`,
176
+ };
177
+ }
178
+ }
179
+
180
+ const jobKey = override && override.type === 'job' ? override.key : ENGINE_JOBS[autoKind];
181
+ const role = rosterJobRole(jobKey, root, pickOptions) || ENGINE_JOBS[autoKind];
182
+ const custom = !ENGINE_ROLES.includes(jobKey);
183
+ const resolved = resolveEngineForRoleRanked(role, root, { ...pickOptions, ...(custom ? { job: jobKey } : {}) });
184
+ const job = rosterJobLabel(jobKey);
185
+ const fromRoster = rosterDecided(resolved.source);
186
+ return {
187
+ ...base,
188
+ job,
189
+ job_key: jobKey,
190
+ role,
191
+ engine: resolved.engine || null,
192
+ model: resolved.engine && resolved.engine.roster_model ? resolved.engine.roster_model : '',
193
+ pick_source: resolved.source,
194
+ roster_backed: fromRoster || Boolean(override),
195
+ reason: `${name} does ${job}: ${engineText(resolved.engine)}`,
196
+ };
197
+ }
198
+
199
+ // The member's engine only when the roster decided it and the member is on
200
+ // the team (a MEMBER.md or a team line). null means keep the old routing.
201
+ // Never throws: a broken roster must not stop a run.
202
+ function memberRosterEngine(memberName, root = process.cwd(), options = {}) {
203
+ const name = String(memberName || '').trim();
204
+ if (!name || name.includes('/') || name.includes('\\')) return null;
205
+ try {
206
+ const picked = resolveEngineForMember(name, root, options);
207
+ if (!picked.found && options.requireMember !== false) return null;
208
+ return picked.roster_backed && picked.engine ? picked : null;
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ // One row per member for `atris engine roster`, plus a warning for every
215
+ // team line that could not be used.
216
+ function teamRosterView(root = process.cwd(), options = {}) {
217
+ const state = options.rosterState || readRosterState(root, options);
218
+ const names = new Set(listTeamMembers(root));
219
+ const warnings = [];
220
+ for (const layer of [state.session, state.project, state.machine]) {
221
+ for (const line of (layer && layer.team) || []) {
222
+ if (findMemberCard(root, line.member)) names.add(line.member);
223
+ else if (layer.scope === 'project') warnings.push({ file: line.file, line: line.line, text: line.text, message: `there is no team member named ${line.member}, so the line does nothing` });
224
+ }
225
+ }
226
+ const rows = [...names].sort().map((name) => {
227
+ const picked = resolveEngineForMember(name, root, { ...options, rosterState: state });
228
+ const line = teamLineFor(state, name);
229
+ if (picked.warning && line) {
230
+ warnings.push({ file: line.file, line: line.line, text: line.text, message: `${picked.warning}, so ${name} picks automatically` });
231
+ }
232
+ if (picked.until_warning && line) warnings.push({ file: line.file, line: line.line, text: line.text, message: picked.until_warning });
233
+ return {
234
+ member: name,
235
+ job: picked.job,
236
+ engine: picked.engine ? picked.engine.id : null,
237
+ model: picked.model || null,
238
+ effort: picked.engine && picked.engine.roster_effort ? picked.engine.roster_effort : null,
239
+ max_seconds: picked.engine && picked.engine.roster_max_seconds ? picked.engine.roster_max_seconds : null,
240
+ source: picked.source,
241
+ file: picked.file,
242
+ reason: picked.reason,
243
+ };
244
+ });
245
+ return { rows, warnings };
246
+ }
247
+
248
+ module.exports = {
249
+ autoJobForMember,
250
+ resolveEngineForMember,
251
+ memberRosterEngine,
252
+ teamRosterView,
253
+ };
@@ -400,9 +400,9 @@ function defaultThinkingBody() {
400
400
  '',
401
401
  'Team logs say what happened.',
402
402
  '',
403
- 'This file says how Keshav thinks.',
403
+ 'This file says how the operator thinks.',
404
404
  '',
405
- '## How Keshav Thinks',
405
+ '## How the Operator Thinks',
406
406
  '',
407
407
  '- Starts from messy truth, then wants it clarified into a mission he can approve or reject.',
408
408
  '- Values plain English, no jargon, and proof over motion.',
@@ -411,7 +411,7 @@ function defaultThinkingBody() {
411
411
  '## Approval Rules',
412
412
  '',
413
413
  '- Atris can clarify, propose, run bounded proof steps, and write receipts.',
414
- '- Keshav keeps taste, judgment, priority, budget, customer calls, and final accept.',
414
+ '- The operator keeps taste, judgment, priority, budget, customer calls, and final accept.',
415
415
  '- Real-world side effects wait for approval unless the mission explicitly grants permission.',
416
416
  '',
417
417
  '## Proof Standards',
@@ -457,7 +457,7 @@ function writeThinkingMemory(room, options = {}) {
457
457
  path: relativePath,
458
458
  updated: true,
459
459
  latest_signal: signal,
460
- purpose: 'how Keshav thinks, decides, approves, rejects, prioritizes, and explains.',
460
+ purpose: 'how the operator thinks, decides, approves, rejects, prioritizes, and explains.',
461
461
  };
462
462
  }
463
463
 
@@ -1,5 +1,17 @@
1
1
  'use strict';
2
2
 
3
+ /**
4
+ * The one place that reads the two env names. Both mean the same thing; any
5
+ * value other than empty, 0, or false turns prompts off.
6
+ */
7
+ const NON_INTERACTIVE_ENV_NAMES = ['ATRIS_NONINTERACTIVE', 'ATRIS_NO_INTERACTIVE'];
8
+ function envForcesNonInteractive(env = process.env) {
9
+ return NON_INTERACTIVE_ENV_NAMES.some((name) => {
10
+ const value = String(env[name] || '').trim().toLowerCase();
11
+ return value !== '' && value !== '0' && value !== 'false';
12
+ });
13
+ }
14
+
3
15
  /**
4
16
  * Shared headless/agent guard. When true, commands must not open a prompt.
5
17
  * Covers: ATRIS_NONINTERACTIVE / ATRIS_NO_INTERACTIVE, --yes/-y, --json.
@@ -9,8 +21,7 @@
9
21
  */
10
22
  function isForcedNonInteractive(args = process.argv.slice(2)) {
11
23
  const list = Array.isArray(args) ? args : [];
12
- if (process.env.ATRIS_NONINTERACTIVE === '1') return true;
13
- if (process.env.ATRIS_NO_INTERACTIVE === '1') return true;
24
+ if (envForcesNonInteractive()) return true;
14
25
  if (list.includes('--yes') || list.includes('-y')) return true;
15
26
  if (list.includes('--json')) return true;
16
27
  return false;
@@ -48,8 +59,7 @@ function hasYesFlag(args = []) {
48
59
  function refuseHeadlessUnless(args, { allowYes = true, allowOnce = false, usage = '' } = {}) {
49
60
  const list = Array.isArray(args) ? args : [];
50
61
  const headless = Boolean(
51
- process.env.ATRIS_NONINTERACTIVE === '1'
52
- || process.env.ATRIS_NO_INTERACTIVE === '1'
62
+ envForcesNonInteractive()
53
63
  || !process.stdin.isTTY
54
64
  || !process.stdout.isTTY
55
65
  );
@@ -77,6 +87,7 @@ function rejectUnsupportedJson(commandName, args = process.argv.slice(2)) {
77
87
  }
78
88
 
79
89
  module.exports = {
90
+ envForcesNonInteractive,
80
91
  isForcedNonInteractive,
81
92
  isNonInteractive,
82
93
  wantsJson,
@@ -366,7 +366,7 @@ function syncLessonsMd(root, mined) {
366
366
  const today = (mined.mined_at || new Date().toISOString()).split('T')[0];
367
367
  const lines = (mined.lessons || []).map((lesson) => ({
368
368
  id: lesson.id,
369
- line: `- **[${today}] policy-${lesson.id}** \u2014 ${lesson.status || 'pass'} \u2014 ${lesson.lesson} (mined from ${mined.sources.career_xp_receipts} receipts / ${mined.sources.task_episodes} episodes / ${mined.sources.scorecards} scorecards)`,
369
+ line: `- **[${today}] policy-${lesson.id}** - ${lesson.status || 'pass'} - ${String(lesson.lesson).replace(/\s*\u2014\s*/g, ', ')} (mined from ${mined.sources.career_xp_receipts} receipts / ${mined.sources.task_episodes} episodes / ${mined.sources.scorecards} scorecards)`,
370
370
  }));
371
371
  if (!lines.length) return { path: lessonsPath, written: [] };
372
372