atris 3.58.6 → 3.58.8

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 (90) hide show
  1. package/GETTING_STARTED.md +2 -2
  2. package/PERSONA.md +11 -11
  3. package/README.md +9 -1
  4. package/atris/GEMINI.md +7 -6
  5. package/atris/PERSONA.md +4 -4
  6. package/atris/features/README.md +77 -6
  7. package/atris/policies/engineering-principles.md +129 -0
  8. package/atris/policies/genesis.md +112 -0
  9. package/atris/policies/product-design-principles.md +100 -0
  10. package/atris/skills/README.md +44 -15
  11. package/atris/skills/aeo/SKILL.md +10 -10
  12. package/atris/skills/atris/SKILL.md +1 -1
  13. package/atris/skills/design/SKILL.md +3 -1
  14. package/atris/skills/engines/SKILL.md +42 -50
  15. package/atris/skills/meta/SKILL.md +4 -4
  16. package/atris/skills/youtube/SKILL.md +11 -11
  17. package/bin/atris.js +60 -8
  18. package/commands/aeo.js +9 -12
  19. package/commands/analytics.js +8 -11
  20. package/commands/auth.js +8 -2
  21. package/commands/autopilot.js +4 -3
  22. package/commands/avail.js +1 -1
  23. package/commands/brain.js +4 -1
  24. package/commands/business.js +13 -8
  25. package/commands/close.js +2 -1
  26. package/commands/computer.js +15 -10
  27. package/commands/context-sync.js +3 -3
  28. package/commands/design.js +362 -0
  29. package/commands/doc-health.js +377 -0
  30. package/commands/drive.js +32 -0
  31. package/commands/experiments.js +13 -4
  32. package/commands/improve.js +68 -2
  33. package/commands/integrations.js +2 -5
  34. package/commands/land.js +144 -4
  35. package/commands/learn.js +7 -2
  36. package/commands/member.js +239 -58
  37. package/commands/mission.js +94 -18
  38. package/commands/pack.js +2 -2
  39. package/commands/probe.js +11 -5
  40. package/commands/pull.js +1 -1
  41. package/commands/pulse.js +38 -0
  42. package/commands/push.js +1 -1
  43. package/commands/rainmaker.js +3 -16
  44. package/commands/rsi.js +156 -0
  45. package/commands/spaceship.js +1 -1
  46. package/commands/stream.js +27 -2
  47. package/commands/study.js +15 -9
  48. package/commands/sync.js +1 -1
  49. package/commands/task.js +54 -3
  50. package/commands/workflow.js +17 -7
  51. package/commands/youtube.js +76 -8
  52. package/lib/apply-gate.js +22 -4
  53. package/lib/codex-flight.js +28 -5
  54. package/lib/daily-log.js +88 -0
  55. package/lib/design-api.js +130 -0
  56. package/lib/engine-ask.js +1 -1
  57. package/lib/first-minute.js +1 -6
  58. package/lib/fleet.js +4 -1
  59. package/lib/improve-vitals-html.js +3 -2
  60. package/lib/known-commands.js +3 -3
  61. package/lib/lesson-contradiction.js +2 -2
  62. package/lib/map-refs.js +183 -0
  63. package/lib/member-context.js +42 -0
  64. package/lib/mission-room.js +4 -4
  65. package/lib/noninteractive.js +15 -4
  66. package/lib/policy-lessons.js +1 -1
  67. package/lib/receipt-block.js +137 -28
  68. package/lib/rsi-record.js +335 -0
  69. package/lib/runner-command.js +1 -1
  70. package/lib/scorecard.js +4 -4
  71. package/lib/sleep-sync.js +11 -0
  72. package/lib/state-detection.js +36 -17
  73. package/lib/task-db.js +74 -53
  74. package/lib/task-list-keeper.js +192 -0
  75. package/lib/todo-fallback.js +10 -4
  76. package/lib/todo.js +22 -10
  77. package/lib/wish-delegate.js +19 -5
  78. package/mcp/atris-mcp/index.mjs +227 -0
  79. package/package.json +4 -2
  80. package/scripts/det/det.js +2 -2
  81. package/scripts/det/pr-description.js +2 -2
  82. package/scripts/det/test.js +2 -2
  83. package/scripts/det/ytnotes +43 -8
  84. package/utils/api.js +1 -1
  85. package/utils/auth.js +79 -14
  86. package/utils/backend-root.js +31 -0
  87. package/utils/config.js +5 -4
  88. package/utils/owner-identity.js +61 -0
  89. package/atris/policies/LESSONS.md +0 -13
  90. package/atris/skills/autopilot/atris-autopilot.md +0 -71
@@ -0,0 +1,377 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { resolveWorkspaceRoot } = require('../lib/mission-root');
6
+ const { readText } = require('./brain');
7
+ const { checkMapRefs, fixMapRefs } = require('../lib/map-refs');
8
+
9
+ const BOOT_FILES = [
10
+ 'CLAUDE.md', 'AGENTS.md', 'atris/atris.md', 'atris/MAP.md',
11
+ 'atris/TODO.md', 'atris/now.md', 'atris/PERSONA.md',
12
+ 'atris/brain/STATUS.md', 'atris/brain/self_improvement_ledger.md',
13
+ 'atris/wiki/index.md', 'atris/skills/atris/SKILL.md',
14
+ ];
15
+ const DAY = 86400000;
16
+ const SCAFFOLD_FOLDERS = new Set(['_archive', '_archived', '_templates', '_template', '_drafts']);
17
+ const STOP_WORDS = new Set('the and for are was were where what which who how does did can could should would this that these those with from into about find have has had there here when why'.split(' '));
18
+ const DEFAULT_QUESTIONS = 'atris/doc-health/questions.jsonl';
19
+
20
+ function stat(file) {
21
+ try { return fs.statSync(file); } catch { return null; }
22
+ }
23
+
24
+ function entries(dir) {
25
+ try { return fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
26
+ }
27
+
28
+ function folders(root, base) {
29
+ return entries(path.join(root, base)).filter(entry => entry.isDirectory())
30
+ .map(entry => entry.name).sort();
31
+ }
32
+
33
+ function backtickedPaths(text) {
34
+ return [...text.matchAll(/`([^`\r\n]+)`/g)]
35
+ .map(match => match[1].trim().replace(/#.*$/, '').replace(/:\d+(?:-\d+)?$/, '').replace(/^\.\//, ''))
36
+ .filter(file => !/\s|[<>*|]/.test(file) && !file.includes('://')
37
+ && (file.includes('/') || /\.[a-z0-9]+$/i.test(file)));
38
+ }
39
+
40
+ function folderCoverage(text, base, names) {
41
+ const mentions = new Set([...text.matchAll(new RegExp(`${base}/([a-zA-Z0-9_.-]+)`, 'g'))]
42
+ .map(match => match[1]));
43
+ const mentioned = names.filter(name => mentions.has(name));
44
+ return { total: names.length, mentioned: mentioned.length, missing: names.filter(name => !mentions.has(name)) };
45
+ }
46
+
47
+ function collectMap(root, text, featureNames, memberNames) {
48
+ let rows = 0;
49
+ const paths = new Set();
50
+ const lines = text.split(/\r?\n/);
51
+ let inTable = false;
52
+ let fence = null;
53
+ for (let i = 0; i < lines.length; i++) {
54
+ const line = lines[i].trim();
55
+ const marker = line.match(/^(`{3,}|~{3,})/);
56
+ if (marker) {
57
+ if (!fence) fence = marker[1][0];
58
+ else if (fence === marker[1][0]) fence = null;
59
+ inTable = false;
60
+ continue;
61
+ }
62
+ if (fence) continue;
63
+ const cells = line.split(/(?<!\\)\|/);
64
+ const nextIsSeparator = /^\s*\|?\s*:?-{3,}:?\s*\|(?:\s*:?-{3,}:?\s*\|?)+\s*$/.test(lines[i + 1] || '');
65
+ if (cells.length < 2 || !(line.startsWith('|') || inTable || nextIsSeparator)) {
66
+ inTable = false;
67
+ continue;
68
+ }
69
+ inTable = true;
70
+ if (cells[0] === '') cells.shift();
71
+ const found = backtickedPaths(cells[1] || '');
72
+ if (!found.length) continue;
73
+ rows++;
74
+ for (const file of found) paths.add(file);
75
+ }
76
+ const files = [...paths].map(file => ({ path: file, exists: fs.existsSync(path.resolve(root, file)) }));
77
+ const existing = files.filter(file => file.exists).length;
78
+ const pathScore = files.length ? existing / files.length : 0;
79
+ const { refs: all, ...refs } = checkMapRefs(root, text);
80
+ refs.flagged = all.filter(ref => ref.status !== 'ok').map(ref => ({
81
+ map_line: ref.map_line, ref: ref.raw, symbol: ref.symbol, status: ref.status,
82
+ ...(ref.line ? { line: ref.line } : {}), ...(ref.lines ? { lines: ref.lines } : {}),
83
+ ...(ref.reason ? { reason: ref.reason } : {}),
84
+ }));
85
+ return {
86
+ rows, paths: files.length, existing, files,
87
+ // A path that exists but a line ref that points at the wrong code is still a wrong map.
88
+ path_score: pathScore, refs, score: pathScore * refs.score,
89
+ features: folderCoverage(text, 'atris/features', featureNames),
90
+ members: folderCoverage(text, 'atris/team', memberNames),
91
+ };
92
+ }
93
+
94
+ function collectLookups(root, mapText, questionsPath) {
95
+ const filename = path.resolve(root, questionsPath);
96
+ const result = {
97
+ path: questionsPath, missing: !stat(filename)?.isFile(),
98
+ questions: [], invalid_lines: [], one_hop: 0, two_hops: 0, unresolved: 0, score: null,
99
+ };
100
+ if (result.missing) {
101
+ result.message = `create ${questionsPath} with one object per line:\n{"q":"where is the map","expect":"atris/MAP.md"}`;
102
+ return result;
103
+ }
104
+ const mapLines = mapText.split(/\r?\n/);
105
+ // Most healthy workspaces resolve every question in the map. Read second-hop
106
+ // documents only when needed, sharing each read across unresolved questions.
107
+ let docs;
108
+ const viaDocument = expected => {
109
+ if (!docs) docs = [...new Set(backtickedPaths(mapText).filter(file => file.endsWith('.md')))]
110
+ .filter(file => path.resolve(root, file) !== path.join(root, 'atris', 'MAP.md'))
111
+ .map(file => ({ path: file }));
112
+ for (const doc of docs) {
113
+ if (doc.text === undefined) doc.text = readText(path.resolve(root, doc.path));
114
+ if (doc.text.includes(expected)) return doc.path;
115
+ }
116
+ return null;
117
+ };
118
+ readText(filename).split(/\r?\n/).forEach((line, index) => {
119
+ if (!line.trim()) return;
120
+ let question;
121
+ try { question = JSON.parse(line); } catch { /* Report bad input without failing the command. */ }
122
+ if (!question || typeof question.q !== 'string' || !question.q.trim()
123
+ || typeof question.expect !== 'string' || !question.expect.trim()) {
124
+ result.invalid_lines.push(index + 1);
125
+ return;
126
+ }
127
+ const keywords = (question.q.toLowerCase().match(/[\p{L}\p{N}]+/gu) || [])
128
+ .filter(word => word.length >= 3 && !STOP_WORDS.has(word));
129
+ const oneHop = mapLines.some(row => row.includes(question.expect)
130
+ && keywords.some(word => row.toLowerCase().includes(word)));
131
+ const via = oneHop ? null : viaDocument(question.expect);
132
+ const hops = oneHop ? 1 : via ? 2 : null;
133
+ result.questions.push({ q: question.q, expect: question.expect, hops, via });
134
+ });
135
+ result.one_hop = result.questions.filter(question => question.hops === 1).length;
136
+ result.two_hops = result.questions.filter(question => question.hops === 2).length;
137
+ result.unresolved = result.questions.filter(question => question.hops === null).length;
138
+ if (result.questions.length) result.score = result.one_hop / result.questions.length;
139
+ return result;
140
+ }
141
+
142
+ function field(text, label) {
143
+ // Accept both plain and bold metadata labels used in feature idea files.
144
+ const match = text.match(new RegExp(`^[ \\t]*(?:>[ \\t]*)?(?:[-*] )?(?:\\*\\*)?${label}[ \\t]*(?:\\*\\*)?:[ \\t]*(?:\\*\\*)?([^\\r\\n]*)`, 'im'));
145
+ return match ? match[1].replace(/\*\*/g, '').trim() : '';
146
+ }
147
+
148
+ function newestLog(dir, freshAfter = Infinity) {
149
+ let newest = null;
150
+ for (const entry of entries(dir)) {
151
+ const file = path.join(dir, entry.name);
152
+ // Do not follow symlinks into another tree or a recursive loop.
153
+ const candidate = entry.isDirectory() ? newestLog(file, freshAfter)
154
+ : entry.isFile() ? { file, mtime: stat(file)?.mtimeMs } : null;
155
+ if (candidate && Number.isFinite(candidate.mtime) && (!newest || candidate.mtime > newest.mtime)) newest = candidate;
156
+ if (newest && newest.mtime >= freshAfter) return newest;
157
+ }
158
+ return newest;
159
+ }
160
+
161
+ function freshness(items, thresholdDays) {
162
+ const flagged = items.filter(item => item.stale);
163
+ return {
164
+ total: items.length, flagged: flagged.length, threshold_days: thresholdDays,
165
+ score: items.length ? (items.length - flagged.length) / items.length : 1,
166
+ items,
167
+ oldest: [...flagged].sort((a, b) => (b.age_days ?? Infinity) - (a.age_days ?? Infinity)
168
+ || a.name.localeCompare(b.name)).slice(0, 10),
169
+ };
170
+ }
171
+
172
+ function inactiveMember(text) {
173
+ const frontmatter = text.match(/^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
174
+ return Boolean(frontmatter && /^status:[ \t]*(['"]?)(?:retired|parked|archived)\1[ \t]*(?:#.*)?$/im.test(frontmatter[1]));
175
+ }
176
+
177
+ function collectStaleness(root, featureNames, memberNames, now, scoreOnly = false) {
178
+ const features = featureNames.filter(name => stat(path.join(root, 'atris/features', name, 'idea.md'))?.isFile())
179
+ .map(name => {
180
+ const file = `atris/features/${name}/idea.md`;
181
+ const text = readText(path.join(root, file));
182
+ const date = field(text, 'Last Updated') || field(text, 'Created');
183
+ const written = date ? Date.parse(date) : NaN;
184
+ // Real activity counts: the newer of the written date and the newest file in the folder.
185
+ const newest = newestLog(path.join(root, 'atris/features', name));
186
+ const activity = newest && Number.isFinite(newest.mtime) ? newest.mtime : NaN;
187
+ const timestamp = Number.isFinite(written) && Number.isFinite(activity) ? Math.max(written, activity) : (Number.isFinite(written) ? written : activity);
188
+ const age = Number.isFinite(timestamp) ? (now - timestamp) / DAY : null;
189
+ const status = field(text, 'Status');
190
+ const exempt = /complete|shipped|live|archived|parked|retired|superseded/i.test(status);
191
+ return { name, path: file, date: date || null, status, age_days: age === null ? null : Math.floor(age), stale: age !== null && age > 60 && !exempt };
192
+ });
193
+ const members = memberNames.filter(name => stat(path.join(root, 'atris/team', name, 'MEMBER.md'))?.isFile())
194
+ .filter(name => !inactiveMember(readText(path.join(root, 'atris/team', name, 'MEMBER.md'))))
195
+ .map(name => {
196
+ const newest = newestLog(path.join(root, 'atris/team', name, 'logs'), scoreOnly ? now - 30 * DAY : Infinity);
197
+ const age = newest ? (now - newest.mtime) / DAY : null;
198
+ return {
199
+ name, path: `atris/team/${name}`, newest_log: newest ? path.relative(root, newest.file) : null,
200
+ last_activity: newest ? new Date(newest.mtime).toISOString() : null,
201
+ age_days: age === null ? null : Math.floor(age), stale: !newest || age > 30,
202
+ };
203
+ });
204
+ return {
205
+ features: freshness(features, 60), members: freshness(members, 30),
206
+ member_age_basis: 'newest log file modification time',
207
+ };
208
+ }
209
+
210
+ function nearDuplicates(names) {
211
+ const groups = new Map();
212
+ for (const name of names) {
213
+ if (name.length < 5) continue;
214
+ const prefix = name.slice(0, 5);
215
+ if (!groups.has(prefix)) groups.set(prefix, []);
216
+ groups.get(prefix).push(name);
217
+ }
218
+ return [...groups.values()].filter(group => group.length > 1).map(group => {
219
+ let prefix = group[0];
220
+ while (!group.every(name => name.startsWith(prefix))) prefix = prefix.slice(0, -1);
221
+ return { prefix, names: group };
222
+ });
223
+ }
224
+
225
+ function overallScore(boot, map, lookups, staleness) {
226
+ const round = value => Math.round(value * 100) / 100;
227
+ const part = (score, max) => ({ points: round((score ?? 0) * max), max });
228
+ const parts = {
229
+ lookup_hops: part(lookups.score, 30), map_coverage: part(map.score, 25),
230
+ boot_load: part(Math.min(1, Math.max(0, (200000 - boot.total_chars) / 120000)), 20),
231
+ feature_freshness: part(staleness.features.score, 15), member_freshness: part(staleness.members.score, 10),
232
+ };
233
+ return {
234
+ parts, total: round(Object.values(parts).reduce((sum, value) => sum + value.points, 0)), max: 100,
235
+ lookup_skipped: lookups.score === null,
236
+ };
237
+ }
238
+
239
+ function collectDocHealth({ cwd = process.cwd(), questions = DEFAULT_QUESTIONS, now = Date.now() } = {}) {
240
+ return measureDocHealth(resolveWorkspaceRoot(cwd), { questions, now });
241
+ }
242
+
243
+ // Boot already knows its root. Keep this path in-process, without asking git
244
+ // to resolve the workspace or launching another CLI. A recent log is enough
245
+ // for the score; only the detailed report needs its exact newest timestamp.
246
+ function computeDocHealth(root) {
247
+ const payload = measureDocHealth(root, { scoreOnly: true });
248
+ if (!payload.ok) return payload;
249
+ const { ok, boot_load, lookup_hops, overall } = payload;
250
+ return { ok, boot_load, lookup_hops, overall };
251
+ }
252
+
253
+ function measureDocHealth(root, { questions = DEFAULT_QUESTIONS, now = Date.now(), scoreOnly = false } = {}) {
254
+ if (!stat(path.join(root, 'atris'))?.isDirectory()) {
255
+ return { ok: false, action: 'doc-health', root, message: 'no atris/ folder in this workspace.' };
256
+ }
257
+ const files = BOOT_FILES.map(file => {
258
+ const missing = !stat(path.join(root, file))?.isFile();
259
+ const chars = missing ? 0 : readText(path.join(root, file)).length;
260
+ return { path: file, missing, chars, approximate_tokens: chars / 4, oversized: chars > 20000 };
261
+ });
262
+ const total_chars = files.reduce((sum, file) => sum + file.chars, 0);
263
+ const boot_load = { files, total_chars, approximate_tokens: total_chars / 4, token_estimate: 'chars divided by 4' };
264
+ const mapText = readText(path.join(root, 'atris', 'MAP.md'));
265
+ // Exact scaffolding folder names are skipped. A sibling like _archive-active is still real work.
266
+ const featureNames = folders(root, 'atris/features').filter(name => !SCAFFOLD_FOLDERS.has(name));
267
+ const memberNames = folders(root, 'atris/team').filter(name => !SCAFFOLD_FOLDERS.has(name));
268
+ const map_coverage = collectMap(root, mapText, featureNames, memberNames);
269
+ const lookup_hops = collectLookups(root, mapText, questions);
270
+ const staleness = collectStaleness(root, featureNames, memberNames, now, scoreOnly);
271
+ return {
272
+ ok: true, action: 'doc-health', root, boot_load, map_coverage, lookup_hops, staleness,
273
+ near_duplicates: nearDuplicates(featureNames),
274
+ overall: overallScore(boot_load, map_coverage, lookup_hops, staleness),
275
+ };
276
+ }
277
+
278
+ function table(headers, rows) {
279
+ const cells = [headers, ...rows].map(row => row.map(String));
280
+ const widths = headers.map((_, i) => Math.max(...cells.map(row => row[i].length)));
281
+ return cells.map(row => row.map((cell, i) => cell.padEnd(widths[i])).join(' ').trimEnd());
282
+ }
283
+
284
+ function refsLine(refs) {
285
+ const gone = refs.missing + refs.missing_file;
286
+ return `map refs: ${refs.ok} right, ${refs.moved} moved, ${refs.ambiguous} unclear, ${gone} gone`;
287
+ }
288
+
289
+ function describeLeft(ref) {
290
+ const where = `MAP line ${ref.map_line}: ${ref.raw}${ref.symbol ? ` (${ref.symbol})` : ''}`;
291
+ if (ref.status === 'ambiguous') return `${where} found on lines ${ref.lines.join(', ')}`;
292
+ if (ref.status === 'missing-file') return `${where} file is gone`;
293
+ return `${where} ${ref.reason}`;
294
+ }
295
+
296
+ function fixRefsCommand(root, asJson) {
297
+ const { changes, left } = fixMapRefs(root);
298
+ if (asJson) {
299
+ console.log(JSON.stringify({ ok: true, action: 'doc-health fix-refs', changes, left }, null, 2));
300
+ return 0;
301
+ }
302
+ const lines = [changes.length ? `map refs: moved ${changes.length}` : 'map refs: nothing to move.'];
303
+ for (const change of changes) lines.push(` MAP line ${change.map_line}: ${change.from} -> ${change.to} (${change.symbol})`);
304
+ if (left.length) {
305
+ lines.push(`left for a human: ${left.length}`);
306
+ for (const ref of left) lines.push(` ${describeLeft(ref)}`);
307
+ }
308
+ console.log(lines.join('\n'));
309
+ return 0;
310
+ }
311
+
312
+ function renderDocHealth(payload) {
313
+ if (!payload.ok) return payload.message;
314
+ const { boot_load: boot, map_coverage: map, lookup_hops: lookup, staleness, overall } = payload;
315
+ const lines = [
316
+ `document health: ${overall.total}/100`, '', 'score',
317
+ ...table(['part', 'points', 'max'], Object.entries(overall.parts).map(([name, part]) => [name.replace(/_/g, ' '), part.points, part.max])),
318
+ '', 'boot load', 'approximate tokens = chars divided by 4',
319
+ ...table(['file', 'chars', 'tokens', 'status'], boot.files.map(file => [file.path, file.chars, file.approximate_tokens, file.missing ? 'missing' : file.oversized ? 'over 20,000 chars' : 'ok'])
320
+ .concat([['total', boot.total_chars, boot.approximate_tokens, '']])),
321
+ '', 'map coverage',
322
+ ...table(['measure', 'count', 'total'], [
323
+ ['routing rows', map.rows, map.rows], ['existing paths', map.existing, map.paths],
324
+ ['features mentioned', map.features.mentioned, map.features.total], ['members mentioned', map.members.mentioned, map.members.total],
325
+ ]),
326
+ refsLine(map.refs),
327
+ ...(map.refs.moved ? ['run atris doc-health --fix-refs to update the moved ones.'] : []),
328
+ '', 'lookup hops',
329
+ ];
330
+ if (lookup.missing) {
331
+ lines.push('skipped: question file is missing.', lookup.message);
332
+ } else {
333
+ lines.push(...table(['question', 'hops'], lookup.questions.map(question => [question.q, question.hops ?? 'unresolved'])));
334
+ if (lookup.score !== null) lines.push(`one-hop share: ${Math.round(lookup.score * 100)}%`);
335
+ if (lookup.invalid_lines.length) lines.push(`invalid question lines skipped: ${lookup.invalid_lines.join(', ')}`);
336
+ if (!lookup.questions.length) lines.push('skipped: no valid questions.');
337
+ }
338
+ if (overall.lookup_skipped) lines.push('lookup score: null; contributes 0 of 30 points.');
339
+ lines.push('', 'staleness',
340
+ ...table(['kind', 'flagged', 'total'], ['features', 'members'].map(kind => [kind, staleness[kind].flagged, staleness[kind].total])),
341
+ 'features: older than 60 days and still active.',
342
+ 'members: no logs or newest log older than 30 days.',
343
+ `log age: ${staleness.member_age_basis}.`);
344
+ for (const kind of ['features', 'members']) {
345
+ lines.push(`${kind}: oldest flagged (up to ten)`);
346
+ if (!staleness[kind].oldest.length) lines.push('none');
347
+ else lines.push(...table(['name', 'age in days'], staleness[kind].oldest.map(item => [item.name, item.age_days ?? 'no logs'])));
348
+ }
349
+ lines.push('', 'near duplicates: shared prefix of at least 5 chars');
350
+ if (!payload.near_duplicates.length) lines.push('none');
351
+ for (const group of payload.near_duplicates) lines.push(`${group.prefix}: ${group.names.join(', ')}`);
352
+ return lines.join('\n');
353
+ }
354
+
355
+ function docHealthCommand(args = [], options = {}) {
356
+ if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
357
+ console.log('usage: atris doc-health [--json] [--questions <path>] [--fix-refs]');
358
+ console.log(' --fix-refs rewrite map line refs whose code moved; list the ones a human must fix');
359
+ return 0;
360
+ }
361
+ if (args.includes('--fix-refs')) {
362
+ const root = resolveWorkspaceRoot(options.cwd || process.cwd());
363
+ if (!stat(path.join(root, 'atris', 'MAP.md'))?.isFile()) {
364
+ console.log('no atris/MAP.md in this workspace.');
365
+ return 1;
366
+ }
367
+ return fixRefsCommand(root, args.includes('--json'));
368
+ }
369
+ const index = args.indexOf('--questions');
370
+ const questions = index >= 0 && args[index + 1] && !args[index + 1].startsWith('--') ? args[index + 1]
371
+ : args.find(arg => arg.startsWith('--questions='))?.slice('--questions='.length);
372
+ const payload = collectDocHealth({ ...options, ...(questions ? { questions } : {}) });
373
+ console.log(args.includes('--json') ? JSON.stringify(payload, null, 2) : renderDocHealth(payload));
374
+ return payload.ok ? 0 : 1;
375
+ }
376
+
377
+ module.exports = { collectDocHealth, computeDocHealth, docHealthCommand };
package/commands/drive.js CHANGED
@@ -9,6 +9,7 @@
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
11
  const { spawnSync } = require('child_process');
12
+ const rsi = require('../lib/rsi-record');
12
13
 
13
14
  const BIN = path.join(__dirname, '..', 'bin', 'atris.js');
14
15
 
@@ -73,9 +74,31 @@ async function driveCommand(argv) {
73
74
  return 0;
74
75
  }
75
76
 
77
+ // Dream-RSI: one drive run is one bounded improvement attempt. Recorded
78
+ // only when the workspace has the recorder; a recording failure never
79
+ // changes the run or its exit code.
80
+ const rsiLog = (m) => process.stderr.write(`rsi: ${m}\n`);
81
+ const rsiAttempt = dryRun ? null : rsi.beginAttempt(cwd, { lane: rsi.IMPROVE_LANE, engine: 'claude', log: rsiLog });
82
+ const rsiBefore = rsiAttempt ? rsi.gitSnapshot(cwd) : null;
83
+ const rsiT0 = Date.now();
84
+ const rsiFinish = (outcome) => {
85
+ if (!rsiAttempt) return;
86
+ const delta = rsi.gitDelta(cwd, rsiBefore);
87
+ rsi.finishAttempt(cwd, rsiAttempt, {
88
+ commits: delta.commits,
89
+ files: delta.files,
90
+ elapsed_s: Math.round((Date.now() - rsiT0) / 100) / 10,
91
+ engine_calls: 1,
92
+ verify: 'skipped',
93
+ ...outcome,
94
+ }, { log: rsiLog });
95
+ };
96
+
97
+ try {
76
98
  const doctor = runAtris(['mission', 'doctor', '--json'], cwd);
77
99
  const report = parseJsonLoose(doctor.stdout);
78
100
  if (!report || !Array.isArray(report.findings)) {
101
+ rsiFinish({ status: 'failed', reason: 'mission doctor returned no parseable findings' });
79
102
  console.error('drive: mission doctor returned no parseable findings.');
80
103
  if (doctor.stderr) console.error(doctor.stderr.slice(0, 500));
81
104
  return 1;
@@ -167,6 +190,11 @@ async function driveCommand(argv) {
167
190
  };
168
191
  if (!dryRun) appendState(cwd, record);
169
192
 
193
+ rsiFinish({
194
+ status: fixed.length ? 'shipped' : 'nothing',
195
+ reason: `${fixed.length} auto-fixed, ${disengagements.length} still need a human`.slice(0, 200),
196
+ });
197
+
170
198
  if (json) { console.log(JSON.stringify({ ok: true, ...record }, null, 2)); return 0; }
171
199
 
172
200
  console.log(`drive tick: ${report.checked_count} missions checked, ${report.findings.length} findings`);
@@ -182,6 +210,10 @@ async function driveCommand(argv) {
182
210
  }
183
211
  console.log(` next: atris drive status · fix the ✋ list · re-run atris drive`);
184
212
  return disengagements.length > 0 ? 0 : 0;
213
+ } catch (err) {
214
+ rsiFinish({ status: 'failed', reason: String(err && err.message ? err.message : err).slice(0, 200) });
215
+ throw err;
216
+ }
185
217
  }
186
218
 
187
219
  module.exports = { driveCommand };
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { spawnSync } = require('child_process');
4
+ const { BACKEND_ROOT_HINT, resolveBackendRoot } = require('../utils/backend-root');
4
5
  const { runTaskOnce } = require('./autopilot');
5
6
  const {
6
7
  appendResultsRow,
@@ -391,7 +392,7 @@ function parseRunOptions(args) {
391
392
  const options = {
392
393
  dryRun: false,
393
394
  verbose: false,
394
- backendPath: path.resolve(process.cwd(), '../atrisos-backend'),
395
+ backendPath: null,
395
396
  interventions: [],
396
397
  };
397
398
 
@@ -527,8 +528,10 @@ function buildBenchmarkArtifact(name, packDir, options) {
527
528
  'atris/features/endstate/contract.md',
528
529
  'atris/features/endstate/artifact-schema.json',
529
530
  path.relative(cwd, path.join(packDir, 'program.md')),
530
- '../atrisos-backend/atris/MAP.md',
531
- '../atrisos-backend/atris/TODO.md',
531
+ ...(backendPath ? [
532
+ path.relative(cwd, path.join(backendPath, 'atris', 'MAP.md')),
533
+ path.relative(cwd, path.join(backendPath, 'atris', 'TODO.md')),
534
+ ] : []),
532
535
  ],
533
536
  contextNote: [
534
537
  `Project Endstate track: ${track}`,
@@ -555,7 +558,7 @@ function buildBenchmarkArtifact(name, packDir, options) {
555
558
  ? []
556
559
  : [
557
560
  ...collectChangedFiles(cwd, beforeCli, afterCli),
558
- ...collectChangedFiles(backendPath, beforeBackend, afterBackend, '../atrisos-backend/'),
561
+ ...(backendPath ? collectChangedFiles(backendPath, beforeBackend, afterBackend, `${path.relative(cwd, backendPath)}/`) : []),
559
562
  ];
560
563
  const wikiAfter = readTextIfExists(path.join(cwd, 'atris', 'wiki', 'STATUS.md'));
561
564
 
@@ -637,6 +640,12 @@ function experimentsRun(name, ...args) {
637
640
  return;
638
641
  }
639
642
 
643
+ if (!options.backendPath) options.backendPath = resolveBackendRoot();
644
+ if (!options.backendPath) {
645
+ console.error(BACKEND_ROOT_HINT);
646
+ process.exit(1);
647
+ }
648
+
640
649
  const schemaPath = path.join(process.cwd(), 'atris', 'features', 'endstate', 'artifact-schema.json');
641
650
  const contractPath = path.join(process.cwd(), 'atris', 'features', 'endstate', 'contract.md');
642
651
  if (!fs.existsSync(schemaPath) || !fs.existsSync(contractPath)) {
@@ -30,13 +30,14 @@ const close = require('./close');
30
30
  const { readUsage } = require('../lib/usage');
31
31
  const { knownCommands } = require('../lib/known-commands');
32
32
  const { treeHashFor } = require('../lib/tree-hash');
33
+ const rsi = require('../lib/rsi-record');
33
34
 
34
35
  /**
35
36
  * Expand a leading `~` to the real home directory for LOCAL filesystem
36
37
  * writes. The path sent to the backend is left untouched, a remote tick
37
38
  * may target a server-side `~/...` workspace the server expands itself, but
38
39
  * local scorecard/journal writes must never create a literal `~` directory.
39
- * (Surfaced by a live plan tick whose `~/arena/...` arg wrote junk locally.)
40
+ * (Surfaced by a live plan tick whose home-relative path wrote junk locally.)
40
41
  */
41
42
  function expandHome(p) {
42
43
  if (typeof p === 'string' && (p === '~' || p.startsWith('~/'))) {
@@ -735,6 +736,68 @@ function runLocalFallback(opts = {}) {
735
736
  };
736
737
  }
737
738
 
739
+ // --- Dream-RSI attempt recording -----------------------------------------
740
+ // A shipping tick (mode full, not dry-run) is one bounded attempt at
741
+ // improving the workspace. When the workspace has the recorder
742
+ // (backend/scripts/rsi/record.py), wrap the tick in open -> finish so the
743
+ // attempt lands in .atris/state/rsi/attempts.jsonl. Recording never changes
744
+ // the tick's result or exit code.
745
+
746
+ function improveAttemptOutcome(result, { workspace, before, elapsedMs }) {
747
+ const s = (result && result.summary) || {};
748
+ const delta = rsi.gitDelta(workspace, before);
749
+ const files = [...new Set([...(Array.isArray(s.files) ? s.files : []), ...delta.files])].slice(0, 200);
750
+ const landed = files.length > 0 || delta.commits > 0 || Boolean(s.shipped);
751
+ const verify = s.verify === true ? 'pass' : s.verify === false ? 'fail' : 'skipped';
752
+ let status;
753
+ if (!result || !result.ok || s.error || verify === 'fail') status = 'failed';
754
+ else if (landed) status = 'shipped';
755
+ else status = 'nothing';
756
+ const reason = status === 'failed'
757
+ ? String(result.error || s.error || 'tick failed').slice(-200)
758
+ : String(s.shipped || result.reason || 'nothing to do').slice(0, 200);
759
+ return {
760
+ status,
761
+ verify,
762
+ commits: delta.commits,
763
+ files,
764
+ elapsed_s: Math.round(elapsedMs / 100) / 10,
765
+ engine_calls: 1,
766
+ reason,
767
+ };
768
+ }
769
+
770
+ async function runImprove(opts = {}, deps = {}) {
771
+ const workspace = opts.workspace || process.cwd();
772
+ const log = deps.log || (() => {});
773
+ // Local receipt writes expand a leading ~; the recorder check must too, or
774
+ // a `~/...` workspace would never see its own backend/scripts/rsi/record.py.
775
+ const rsiRoot = expandHome(workspace);
776
+ const shippingTick = (opts.mode || 'full') === 'full' && !opts.dryRun;
777
+ const attempt = shippingTick
778
+ ? rsi.beginAttempt(rsiRoot, { lane: rsi.IMPROVE_LANE, engine: rsi.engineFromModel(opts.model) || 'claude', log })
779
+ : null;
780
+ if (!attempt) return runImproveCore(opts, deps);
781
+ const startedMs = Date.now();
782
+ const before = rsi.gitSnapshot(rsiRoot);
783
+ try {
784
+ const result = await runImproveCore(opts, deps);
785
+ rsi.finishAttempt(rsiRoot, attempt, improveAttemptOutcome(result, { workspace: rsiRoot, before, elapsedMs: Date.now() - startedMs }), { log });
786
+ return result;
787
+ } catch (err) {
788
+ rsi.finishAttempt(rsiRoot, attempt, {
789
+ status: 'failed',
790
+ verify: 'skipped',
791
+ commits: 0,
792
+ files: [],
793
+ elapsed_s: Math.round((Date.now() - startedMs) / 100) / 10,
794
+ engine_calls: 1,
795
+ reason: String(err && err.message || err).slice(-200),
796
+ }, { log });
797
+ throw err;
798
+ }
799
+ }
800
+
738
801
  /**
739
802
  * Run one improvement tick. Dependency-injected so tests can fake the
740
803
  * network (apiRequestJson), auth (loadCredentials), the local fallback,
@@ -743,7 +806,7 @@ function runLocalFallback(opts = {}) {
743
806
  * Returns a structured result:
744
807
  * { ok, source: 'api'|'local'|'none', reason, summary?, scorecardPath?, local?, apiResult?, error? }
745
808
  */
746
- async function runImprove(opts = {}, deps = {}) {
809
+ async function runImproveCore(opts = {}, deps = {}) {
747
810
  const apiFn = deps.apiRequestJson || apiRequestJson;
748
811
  const loadCreds = deps.loadCredentials || loadCredentials;
749
812
  const localFn = deps.runLocalFallback || runLocalFallback;
@@ -989,6 +1052,8 @@ function isRevisionSignalFile(file) {
989
1052
  const REVISION_WINDOW_MS = REVISION_WINDOW_HOURS * 60 * 60 * 1000;
990
1053
  const AGENT_TRAILER_MARKERS = [
991
1054
  'atris-builder[bot]',
1055
+ 'night@atris.ai',
1056
+ 'devin',
992
1057
  'claude',
993
1058
  'cursor',
994
1059
  'codex',
@@ -1406,6 +1471,7 @@ async function run(argv = [], deps = {}) {
1406
1471
  module.exports = {
1407
1472
  run,
1408
1473
  runImprove,
1474
+ runImproveCore,
1409
1475
  parseImproveArgs,
1410
1476
  buildImprovePayload,
1411
1477
  summarizeImproveResponse,
@@ -35,6 +35,7 @@ const os = require('os');
35
35
  const path = require('path');
36
36
  const readline = require('readline');
37
37
  const { spawnSync } = require('child_process');
38
+ const { sleepSync } = require('../lib/sleep-sync');
38
39
 
39
40
  const CALENDAR_CACHE_PATH = path.join(os.homedir(), '.atris', 'calendar-events-cache.json');
40
41
  const GMAIL_CONNECT_POLL_MS = 3000;
@@ -1812,10 +1813,6 @@ function writeImessageLookupCache(cache) {
1812
1813
  return cachePath;
1813
1814
  }
1814
1815
 
1815
- function sleepMs(ms) {
1816
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
1817
- }
1818
-
1819
1816
  function readLatestOutgoingImessage(handle, sinceMs) {
1820
1817
  const chatDb = path.join(os.homedir(), 'Library', 'Messages', 'chat.db');
1821
1818
  const sinceUnix = Math.max(0, Math.floor(Number(sinceMs || Date.now()) / 1000) - 5);
@@ -1873,7 +1870,7 @@ function imessageVerifyLatestOutgoing(handle, sinceMs, options = {}) {
1873
1870
  && !(latest.is_sent || latest.is_delivered || latest.is_finished)
1874
1871
  && Date.now() - startedAt < timeoutMs
1875
1872
  ) {
1876
- sleepMs(intervalMs);
1873
+ sleepSync(intervalMs);
1877
1874
  latest = readLatestOutgoingImessage(handle, sinceMs);
1878
1875
  }
1879
1876
  return {