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.
- package/AGENTS.md +3 -2
- package/GETTING_STARTED.md +2 -2
- package/PERSONA.md +11 -11
- package/README.md +1 -1
- package/atris/GEMINI.md +7 -6
- package/atris/PERSONA.md +4 -4
- package/atris/features/README.md +77 -6
- package/atris/skills/README.md +44 -15
- package/atris/skills/aeo/SKILL.md +10 -10
- package/atris/skills/atris/SKILL.md +1 -1
- package/atris/skills/engines/SKILL.md +69 -50
- package/atris/skills/meta/SKILL.md +4 -4
- package/bin/atris.js +8 -6
- package/commands/aeo.js +9 -12
- package/commands/analytics.js +8 -11
- package/commands/autopilot.js +79 -8
- package/commands/avail.js +1 -1
- package/commands/brain.js +3 -1
- package/commands/business.js +13 -8
- package/commands/clean.js +17 -0
- package/commands/close.js +2 -1
- package/commands/computer.js +15 -10
- package/commands/context-sync.js +3 -3
- package/commands/doc-health.js +55 -2
- package/commands/engine.js +384 -4
- package/commands/experiments.js +13 -4
- package/commands/host.js +53 -0
- package/commands/improve.js +1 -1
- package/commands/integrations.js +2 -5
- package/commands/learn.js +1 -1
- package/commands/member.js +231 -53
- package/commands/mission.js +129 -19
- package/commands/one-lap.js +58 -3
- package/commands/pack.js +2 -2
- package/commands/probe.js +11 -5
- package/commands/pull.js +1 -1
- package/commands/push.js +1 -1
- package/commands/rainmaker.js +3 -16
- package/commands/run.js +4 -1
- package/commands/scout.js +1 -1
- package/commands/search.js +5 -3
- package/commands/spaceship.js +1 -1
- package/commands/stream.js +27 -2
- package/commands/study.js +15 -9
- package/commands/sync.js +1 -1
- package/commands/task.js +13 -2
- package/commands/unknowns.js +5 -0
- package/commands/verify.js +12 -4
- package/commands/visualize.js +20 -3
- package/commands/workflow.js +23 -7
- package/lib/codex-flight.js +28 -5
- package/lib/dispatch-scout.js +31 -22
- package/lib/engine-registry.js +1482 -13
- package/lib/feedback-triage.js +2 -0
- package/lib/fleet.js +111 -12
- package/lib/host.js +336 -0
- package/lib/improve-vitals-html.js +3 -2
- package/lib/known-commands.js +1 -1
- package/lib/lesson-contradiction.js +2 -2
- package/lib/map-refs.js +226 -0
- package/lib/member-engine.js +253 -0
- package/lib/mission-room.js +4 -4
- package/lib/noninteractive.js +15 -4
- package/lib/policy-lessons.js +1 -1
- package/lib/receipt-block.js +137 -28
- package/lib/roster-markdown.js +206 -0
- package/lib/roster-models.js +179 -0
- package/lib/runner-command.js +95 -15
- package/lib/scorecard.js +4 -4
- package/lib/self-drive.js +9 -1
- package/lib/sleep-sync.js +11 -0
- package/lib/state-detection.js +28 -9
- package/lib/task-db.js +3 -2
- package/lib/todo-fallback.js +1 -1
- package/lib/wish-audit.js +10 -6
- package/lib/wish-delegate.js +29 -5
- package/mcp/atris-mcp/index.mjs +77 -24
- package/package.json +2 -5
- package/scripts/det/det.js +2 -2
- package/scripts/det/pr-description.js +2 -2
- package/scripts/det/test.js +2 -2
- package/templates/members/host/MEMBER.md +23 -0
- package/templates/members/host/MISSION.md +11 -0
- package/templates/members/host/SOUL.md +7 -0
- package/templates/members/host/context/why.md +3 -0
- package/templates/members/host/goals.json +5 -0
- package/templates/members/host/goals.md +3 -0
- package/templates/members/host/skills/host/SKILL.md +13 -0
- package/utils/api.js +1 -1
- package/utils/auth.js +17 -7
- package/utils/backend-root.js +31 -0
- package/utils/config.js +5 -4
- package/utils/owner-identity.js +61 -0
- package/atris/policies/LESSONS.md +0 -13
- 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
|
|
26
|
-
const lessonRegex = /^-\s+\*\*\[(\d{4}-\d{2}-\d{2})\]\s+([a-z0-9-]+)\*\*\s
|
|
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 }]
|
package/lib/map-refs.js
ADDED
|
@@ -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
|
+
};
|
package/lib/mission-room.js
CHANGED
|
@@ -400,9 +400,9 @@ function defaultThinkingBody() {
|
|
|
400
400
|
'',
|
|
401
401
|
'Team logs say what happened.',
|
|
402
402
|
'',
|
|
403
|
-
'This file says how
|
|
403
|
+
'This file says how the operator thinks.',
|
|
404
404
|
'',
|
|
405
|
-
'## How
|
|
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
|
-
'-
|
|
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
|
|
460
|
+
purpose: 'how the operator thinks, decides, approves, rejects, prioritizes, and explains.',
|
|
461
461
|
};
|
|
462
462
|
}
|
|
463
463
|
|
package/lib/noninteractive.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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,
|
package/lib/policy-lessons.js
CHANGED
|
@@ -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}**
|
|
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
|
|