atris 3.58.5 → 3.58.7
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/README.md +8 -0
- package/atris/policies/engineering-principles.md +129 -0
- package/atris/policies/genesis.md +112 -0
- package/atris/policies/product-design-principles.md +100 -0
- package/atris/skills/design/SKILL.md +3 -1
- package/atris/skills/engines/SKILL.md +3 -3
- package/atris/skills/x-search/SKILL.md +2 -2
- package/atris/skills/youtube/SKILL.md +44 -28
- package/bin/atris.js +56 -3
- package/commands/auth.js +58 -24
- package/commands/brain.js +1 -0
- package/commands/design.js +362 -0
- package/commands/doc-health.js +329 -0
- package/commands/drive.js +32 -0
- package/commands/improve.js +67 -1
- package/commands/land.js +144 -4
- package/commands/learn.js +211 -40
- package/commands/member.js +65 -11
- package/commands/mission.js +37 -7
- package/commands/pulse.js +38 -0
- package/commands/rsi.js +156 -0
- package/commands/task.js +41 -1
- package/commands/workflow.js +15 -14
- package/commands/x-search.js +9 -10
- package/commands/youtube.js +518 -107
- package/lib/apply-gate.js +22 -4
- package/lib/daily-log.js +88 -0
- package/lib/design-api.js +130 -0
- package/lib/engine-ask.js +1 -1
- package/lib/first-minute.js +1 -6
- package/lib/known-commands.js +3 -3
- package/lib/member-context.js +42 -0
- package/lib/rsi-record.js +335 -0
- package/lib/state-detection.js +8 -8
- package/lib/task-db.js +71 -51
- package/lib/task-list-keeper.js +192 -0
- package/lib/todo-fallback.js +9 -3
- package/lib/todo.js +22 -10
- package/mcp/atris-mcp/index.mjs +174 -0
- package/package.json +8 -3
- package/scripts/det/ytnotes +122 -10
- package/utils/auth.js +109 -13
|
@@ -0,0 +1,329 @@
|
|
|
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
|
+
|
|
8
|
+
const BOOT_FILES = [
|
|
9
|
+
'CLAUDE.md', 'AGENTS.md', 'atris/atris.md', 'atris/MAP.md',
|
|
10
|
+
'atris/TODO.md', 'atris/now.md', 'atris/PERSONA.md',
|
|
11
|
+
'atris/brain/STATUS.md', 'atris/brain/self_improvement_ledger.md',
|
|
12
|
+
'atris/wiki/index.md', 'atris/skills/atris/SKILL.md',
|
|
13
|
+
];
|
|
14
|
+
const DAY = 86400000;
|
|
15
|
+
const SCAFFOLD_FOLDERS = new Set(['_archive', '_archived', '_templates', '_template', '_drafts']);
|
|
16
|
+
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(' '));
|
|
17
|
+
const DEFAULT_QUESTIONS = 'atris/doc-health/questions.jsonl';
|
|
18
|
+
|
|
19
|
+
function stat(file) {
|
|
20
|
+
try { return fs.statSync(file); } catch { return null; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function entries(dir) {
|
|
24
|
+
try { return fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function folders(root, base) {
|
|
28
|
+
return entries(path.join(root, base)).filter(entry => entry.isDirectory())
|
|
29
|
+
.map(entry => entry.name).sort();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function backtickedPaths(text) {
|
|
33
|
+
return [...text.matchAll(/`([^`\r\n]+)`/g)]
|
|
34
|
+
.map(match => match[1].trim().replace(/#.*$/, '').replace(/:\d+(?:-\d+)?$/, '').replace(/^\.\//, ''))
|
|
35
|
+
.filter(file => !/\s|[<>*|]/.test(file) && !file.includes('://')
|
|
36
|
+
&& (file.includes('/') || /\.[a-z0-9]+$/i.test(file)));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function folderCoverage(text, base, names) {
|
|
40
|
+
const mentions = new Set([...text.matchAll(new RegExp(`${base}/([a-zA-Z0-9_.-]+)`, 'g'))]
|
|
41
|
+
.map(match => match[1]));
|
|
42
|
+
const mentioned = names.filter(name => mentions.has(name));
|
|
43
|
+
return { total: names.length, mentioned: mentioned.length, missing: names.filter(name => !mentions.has(name)) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function collectMap(root, text, featureNames, memberNames) {
|
|
47
|
+
let rows = 0;
|
|
48
|
+
const paths = new Set();
|
|
49
|
+
const lines = text.split(/\r?\n/);
|
|
50
|
+
let inTable = false;
|
|
51
|
+
let fence = null;
|
|
52
|
+
for (let i = 0; i < lines.length; i++) {
|
|
53
|
+
const line = lines[i].trim();
|
|
54
|
+
const marker = line.match(/^(`{3,}|~{3,})/);
|
|
55
|
+
if (marker) {
|
|
56
|
+
if (!fence) fence = marker[1][0];
|
|
57
|
+
else if (fence === marker[1][0]) fence = null;
|
|
58
|
+
inTable = false;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (fence) continue;
|
|
62
|
+
const cells = line.split(/(?<!\\)\|/);
|
|
63
|
+
const nextIsSeparator = /^\s*\|?\s*:?-{3,}:?\s*\|(?:\s*:?-{3,}:?\s*\|?)+\s*$/.test(lines[i + 1] || '');
|
|
64
|
+
if (cells.length < 2 || !(line.startsWith('|') || inTable || nextIsSeparator)) {
|
|
65
|
+
inTable = false;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
inTable = true;
|
|
69
|
+
if (cells[0] === '') cells.shift();
|
|
70
|
+
const found = backtickedPaths(cells[1] || '');
|
|
71
|
+
if (!found.length) continue;
|
|
72
|
+
rows++;
|
|
73
|
+
for (const file of found) paths.add(file);
|
|
74
|
+
}
|
|
75
|
+
const files = [...paths].map(file => ({ path: file, exists: fs.existsSync(path.resolve(root, file)) }));
|
|
76
|
+
const existing = files.filter(file => file.exists).length;
|
|
77
|
+
return {
|
|
78
|
+
rows, paths: files.length, existing, files,
|
|
79
|
+
score: files.length ? existing / files.length : 0,
|
|
80
|
+
features: folderCoverage(text, 'atris/features', featureNames),
|
|
81
|
+
members: folderCoverage(text, 'atris/team', memberNames),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function collectLookups(root, mapText, questionsPath) {
|
|
86
|
+
const filename = path.resolve(root, questionsPath);
|
|
87
|
+
const result = {
|
|
88
|
+
path: questionsPath, missing: !stat(filename)?.isFile(),
|
|
89
|
+
questions: [], invalid_lines: [], one_hop: 0, two_hops: 0, unresolved: 0, score: null,
|
|
90
|
+
};
|
|
91
|
+
if (result.missing) {
|
|
92
|
+
result.message = `create ${questionsPath} with one object per line:\n{"q":"where is the map","expect":"atris/MAP.md"}`;
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
const mapLines = mapText.split(/\r?\n/);
|
|
96
|
+
// Most healthy workspaces resolve every question in the map. Read second-hop
|
|
97
|
+
// documents only when needed, sharing each read across unresolved questions.
|
|
98
|
+
let docs;
|
|
99
|
+
const viaDocument = expected => {
|
|
100
|
+
if (!docs) docs = [...new Set(backtickedPaths(mapText).filter(file => file.endsWith('.md')))]
|
|
101
|
+
.filter(file => path.resolve(root, file) !== path.join(root, 'atris', 'MAP.md'))
|
|
102
|
+
.map(file => ({ path: file }));
|
|
103
|
+
for (const doc of docs) {
|
|
104
|
+
if (doc.text === undefined) doc.text = readText(path.resolve(root, doc.path));
|
|
105
|
+
if (doc.text.includes(expected)) return doc.path;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
};
|
|
109
|
+
readText(filename).split(/\r?\n/).forEach((line, index) => {
|
|
110
|
+
if (!line.trim()) return;
|
|
111
|
+
let question;
|
|
112
|
+
try { question = JSON.parse(line); } catch { /* Report bad input without failing the command. */ }
|
|
113
|
+
if (!question || typeof question.q !== 'string' || !question.q.trim()
|
|
114
|
+
|| typeof question.expect !== 'string' || !question.expect.trim()) {
|
|
115
|
+
result.invalid_lines.push(index + 1);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const keywords = (question.q.toLowerCase().match(/[\p{L}\p{N}]+/gu) || [])
|
|
119
|
+
.filter(word => word.length >= 3 && !STOP_WORDS.has(word));
|
|
120
|
+
const oneHop = mapLines.some(row => row.includes(question.expect)
|
|
121
|
+
&& keywords.some(word => row.toLowerCase().includes(word)));
|
|
122
|
+
const via = oneHop ? null : viaDocument(question.expect);
|
|
123
|
+
const hops = oneHop ? 1 : via ? 2 : null;
|
|
124
|
+
result.questions.push({ q: question.q, expect: question.expect, hops, via });
|
|
125
|
+
});
|
|
126
|
+
result.one_hop = result.questions.filter(question => question.hops === 1).length;
|
|
127
|
+
result.two_hops = result.questions.filter(question => question.hops === 2).length;
|
|
128
|
+
result.unresolved = result.questions.filter(question => question.hops === null).length;
|
|
129
|
+
if (result.questions.length) result.score = result.one_hop / result.questions.length;
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function field(text, label) {
|
|
134
|
+
// Accept both plain and bold metadata labels used in feature idea files.
|
|
135
|
+
const match = text.match(new RegExp(`^[ \\t]*(?:>[ \\t]*)?(?:[-*] )?(?:\\*\\*)?${label}[ \\t]*(?:\\*\\*)?:[ \\t]*(?:\\*\\*)?([^\\r\\n]*)`, 'im'));
|
|
136
|
+
return match ? match[1].replace(/\*\*/g, '').trim() : '';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function newestLog(dir, freshAfter = Infinity) {
|
|
140
|
+
let newest = null;
|
|
141
|
+
for (const entry of entries(dir)) {
|
|
142
|
+
const file = path.join(dir, entry.name);
|
|
143
|
+
// Do not follow symlinks into another tree or a recursive loop.
|
|
144
|
+
const candidate = entry.isDirectory() ? newestLog(file, freshAfter)
|
|
145
|
+
: entry.isFile() ? { file, mtime: stat(file)?.mtimeMs } : null;
|
|
146
|
+
if (candidate && Number.isFinite(candidate.mtime) && (!newest || candidate.mtime > newest.mtime)) newest = candidate;
|
|
147
|
+
if (newest && newest.mtime >= freshAfter) return newest;
|
|
148
|
+
}
|
|
149
|
+
return newest;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function freshness(items, thresholdDays) {
|
|
153
|
+
const flagged = items.filter(item => item.stale);
|
|
154
|
+
return {
|
|
155
|
+
total: items.length, flagged: flagged.length, threshold_days: thresholdDays,
|
|
156
|
+
score: items.length ? (items.length - flagged.length) / items.length : 1,
|
|
157
|
+
items,
|
|
158
|
+
oldest: [...flagged].sort((a, b) => (b.age_days ?? Infinity) - (a.age_days ?? Infinity)
|
|
159
|
+
|| a.name.localeCompare(b.name)).slice(0, 10),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function inactiveMember(text) {
|
|
164
|
+
const frontmatter = text.match(/^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
165
|
+
return Boolean(frontmatter && /^status:[ \t]*(['"]?)(?:retired|parked|archived)\1[ \t]*(?:#.*)?$/im.test(frontmatter[1]));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function collectStaleness(root, featureNames, memberNames, now, scoreOnly = false) {
|
|
169
|
+
const features = featureNames.filter(name => stat(path.join(root, 'atris/features', name, 'idea.md'))?.isFile())
|
|
170
|
+
.map(name => {
|
|
171
|
+
const file = `atris/features/${name}/idea.md`;
|
|
172
|
+
const text = readText(path.join(root, file));
|
|
173
|
+
const date = field(text, 'Last Updated') || field(text, 'Created');
|
|
174
|
+
const written = date ? Date.parse(date) : NaN;
|
|
175
|
+
// Real activity counts: the newer of the written date and the newest file in the folder.
|
|
176
|
+
const newest = newestLog(path.join(root, 'atris/features', name));
|
|
177
|
+
const activity = newest && Number.isFinite(newest.mtime) ? newest.mtime : NaN;
|
|
178
|
+
const timestamp = Number.isFinite(written) && Number.isFinite(activity) ? Math.max(written, activity) : (Number.isFinite(written) ? written : activity);
|
|
179
|
+
const age = Number.isFinite(timestamp) ? (now - timestamp) / DAY : null;
|
|
180
|
+
const status = field(text, 'Status');
|
|
181
|
+
const exempt = /complete|shipped|live|archived|parked|retired|superseded/i.test(status);
|
|
182
|
+
return { name, path: file, date: date || null, status, age_days: age === null ? null : Math.floor(age), stale: age !== null && age > 60 && !exempt };
|
|
183
|
+
});
|
|
184
|
+
const members = memberNames.filter(name => stat(path.join(root, 'atris/team', name, 'MEMBER.md'))?.isFile())
|
|
185
|
+
.filter(name => !inactiveMember(readText(path.join(root, 'atris/team', name, 'MEMBER.md'))))
|
|
186
|
+
.map(name => {
|
|
187
|
+
const newest = newestLog(path.join(root, 'atris/team', name, 'logs'), scoreOnly ? now - 30 * DAY : Infinity);
|
|
188
|
+
const age = newest ? (now - newest.mtime) / DAY : null;
|
|
189
|
+
return {
|
|
190
|
+
name, path: `atris/team/${name}`, newest_log: newest ? path.relative(root, newest.file) : null,
|
|
191
|
+
last_activity: newest ? new Date(newest.mtime).toISOString() : null,
|
|
192
|
+
age_days: age === null ? null : Math.floor(age), stale: !newest || age > 30,
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
return {
|
|
196
|
+
features: freshness(features, 60), members: freshness(members, 30),
|
|
197
|
+
member_age_basis: 'newest log file modification time',
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function nearDuplicates(names) {
|
|
202
|
+
const groups = new Map();
|
|
203
|
+
for (const name of names) {
|
|
204
|
+
if (name.length < 5) continue;
|
|
205
|
+
const prefix = name.slice(0, 5);
|
|
206
|
+
if (!groups.has(prefix)) groups.set(prefix, []);
|
|
207
|
+
groups.get(prefix).push(name);
|
|
208
|
+
}
|
|
209
|
+
return [...groups.values()].filter(group => group.length > 1).map(group => {
|
|
210
|
+
let prefix = group[0];
|
|
211
|
+
while (!group.every(name => name.startsWith(prefix))) prefix = prefix.slice(0, -1);
|
|
212
|
+
return { prefix, names: group };
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function overallScore(boot, map, lookups, staleness) {
|
|
217
|
+
const round = value => Math.round(value * 100) / 100;
|
|
218
|
+
const part = (score, max) => ({ points: round((score ?? 0) * max), max });
|
|
219
|
+
const parts = {
|
|
220
|
+
lookup_hops: part(lookups.score, 30), map_coverage: part(map.score, 25),
|
|
221
|
+
boot_load: part(Math.min(1, Math.max(0, (200000 - boot.total_chars) / 120000)), 20),
|
|
222
|
+
feature_freshness: part(staleness.features.score, 15), member_freshness: part(staleness.members.score, 10),
|
|
223
|
+
};
|
|
224
|
+
return {
|
|
225
|
+
parts, total: round(Object.values(parts).reduce((sum, value) => sum + value.points, 0)), max: 100,
|
|
226
|
+
lookup_skipped: lookups.score === null,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function collectDocHealth({ cwd = process.cwd(), questions = DEFAULT_QUESTIONS, now = Date.now() } = {}) {
|
|
231
|
+
return measureDocHealth(resolveWorkspaceRoot(cwd), { questions, now });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Boot already knows its root. Keep this path in-process, without asking git
|
|
235
|
+
// to resolve the workspace or launching another CLI. A recent log is enough
|
|
236
|
+
// for the score; only the detailed report needs its exact newest timestamp.
|
|
237
|
+
function computeDocHealth(root) {
|
|
238
|
+
const payload = measureDocHealth(root, { scoreOnly: true });
|
|
239
|
+
if (!payload.ok) return payload;
|
|
240
|
+
const { ok, boot_load, lookup_hops, overall } = payload;
|
|
241
|
+
return { ok, boot_load, lookup_hops, overall };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function measureDocHealth(root, { questions = DEFAULT_QUESTIONS, now = Date.now(), scoreOnly = false } = {}) {
|
|
245
|
+
if (!stat(path.join(root, 'atris'))?.isDirectory()) {
|
|
246
|
+
return { ok: false, action: 'doc-health', root, message: 'no atris/ folder in this workspace.' };
|
|
247
|
+
}
|
|
248
|
+
const files = BOOT_FILES.map(file => {
|
|
249
|
+
const missing = !stat(path.join(root, file))?.isFile();
|
|
250
|
+
const chars = missing ? 0 : readText(path.join(root, file)).length;
|
|
251
|
+
return { path: file, missing, chars, approximate_tokens: chars / 4, oversized: chars > 20000 };
|
|
252
|
+
});
|
|
253
|
+
const total_chars = files.reduce((sum, file) => sum + file.chars, 0);
|
|
254
|
+
const boot_load = { files, total_chars, approximate_tokens: total_chars / 4, token_estimate: 'chars divided by 4' };
|
|
255
|
+
const mapText = readText(path.join(root, 'atris', 'MAP.md'));
|
|
256
|
+
// Exact scaffolding folder names are skipped. A sibling like _archive-active is still real work.
|
|
257
|
+
const featureNames = folders(root, 'atris/features').filter(name => !SCAFFOLD_FOLDERS.has(name));
|
|
258
|
+
const memberNames = folders(root, 'atris/team').filter(name => !SCAFFOLD_FOLDERS.has(name));
|
|
259
|
+
const map_coverage = collectMap(root, mapText, featureNames, memberNames);
|
|
260
|
+
const lookup_hops = collectLookups(root, mapText, questions);
|
|
261
|
+
const staleness = collectStaleness(root, featureNames, memberNames, now, scoreOnly);
|
|
262
|
+
return {
|
|
263
|
+
ok: true, action: 'doc-health', root, boot_load, map_coverage, lookup_hops, staleness,
|
|
264
|
+
near_duplicates: nearDuplicates(featureNames),
|
|
265
|
+
overall: overallScore(boot_load, map_coverage, lookup_hops, staleness),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function table(headers, rows) {
|
|
270
|
+
const cells = [headers, ...rows].map(row => row.map(String));
|
|
271
|
+
const widths = headers.map((_, i) => Math.max(...cells.map(row => row[i].length)));
|
|
272
|
+
return cells.map(row => row.map((cell, i) => cell.padEnd(widths[i])).join(' ').trimEnd());
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function renderDocHealth(payload) {
|
|
276
|
+
if (!payload.ok) return payload.message;
|
|
277
|
+
const { boot_load: boot, map_coverage: map, lookup_hops: lookup, staleness, overall } = payload;
|
|
278
|
+
const lines = [
|
|
279
|
+
`document health: ${overall.total}/100`, '', 'score',
|
|
280
|
+
...table(['part', 'points', 'max'], Object.entries(overall.parts).map(([name, part]) => [name.replace(/_/g, ' '), part.points, part.max])),
|
|
281
|
+
'', 'boot load', 'approximate tokens = chars divided by 4',
|
|
282
|
+
...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'])
|
|
283
|
+
.concat([['total', boot.total_chars, boot.approximate_tokens, '']])),
|
|
284
|
+
'', 'map coverage',
|
|
285
|
+
...table(['measure', 'count', 'total'], [
|
|
286
|
+
['routing rows', map.rows, map.rows], ['existing paths', map.existing, map.paths],
|
|
287
|
+
['features mentioned', map.features.mentioned, map.features.total], ['members mentioned', map.members.mentioned, map.members.total],
|
|
288
|
+
]),
|
|
289
|
+
'', 'lookup hops',
|
|
290
|
+
];
|
|
291
|
+
if (lookup.missing) {
|
|
292
|
+
lines.push('skipped: question file is missing.', lookup.message);
|
|
293
|
+
} else {
|
|
294
|
+
lines.push(...table(['question', 'hops'], lookup.questions.map(question => [question.q, question.hops ?? 'unresolved'])));
|
|
295
|
+
if (lookup.score !== null) lines.push(`one-hop share: ${Math.round(lookup.score * 100)}%`);
|
|
296
|
+
if (lookup.invalid_lines.length) lines.push(`invalid question lines skipped: ${lookup.invalid_lines.join(', ')}`);
|
|
297
|
+
if (!lookup.questions.length) lines.push('skipped: no valid questions.');
|
|
298
|
+
}
|
|
299
|
+
if (overall.lookup_skipped) lines.push('lookup score: null; contributes 0 of 30 points.');
|
|
300
|
+
lines.push('', 'staleness',
|
|
301
|
+
...table(['kind', 'flagged', 'total'], ['features', 'members'].map(kind => [kind, staleness[kind].flagged, staleness[kind].total])),
|
|
302
|
+
'features: older than 60 days and still active.',
|
|
303
|
+
'members: no logs or newest log older than 30 days.',
|
|
304
|
+
`log age: ${staleness.member_age_basis}.`);
|
|
305
|
+
for (const kind of ['features', 'members']) {
|
|
306
|
+
lines.push(`${kind}: oldest flagged (up to ten)`);
|
|
307
|
+
if (!staleness[kind].oldest.length) lines.push('none');
|
|
308
|
+
else lines.push(...table(['name', 'age in days'], staleness[kind].oldest.map(item => [item.name, item.age_days ?? 'no logs'])));
|
|
309
|
+
}
|
|
310
|
+
lines.push('', 'near duplicates: shared prefix of at least 5 chars');
|
|
311
|
+
if (!payload.near_duplicates.length) lines.push('none');
|
|
312
|
+
for (const group of payload.near_duplicates) lines.push(`${group.prefix}: ${group.names.join(', ')}`);
|
|
313
|
+
return lines.join('\n');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function docHealthCommand(args = [], options = {}) {
|
|
317
|
+
if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
|
|
318
|
+
console.log('usage: atris doc-health [--json] [--questions <path>]');
|
|
319
|
+
return 0;
|
|
320
|
+
}
|
|
321
|
+
const index = args.indexOf('--questions');
|
|
322
|
+
const questions = index >= 0 && args[index + 1] && !args[index + 1].startsWith('--') ? args[index + 1]
|
|
323
|
+
: args.find(arg => arg.startsWith('--questions='))?.slice('--questions='.length);
|
|
324
|
+
const payload = collectDocHealth({ ...options, ...(questions ? { questions } : {}) });
|
|
325
|
+
console.log(args.includes('--json') ? JSON.stringify(payload, null, 2) : renderDocHealth(payload));
|
|
326
|
+
return payload.ok ? 0 : 1;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
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 };
|
package/commands/improve.js
CHANGED
|
@@ -30,6 +30,7 @@ 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
|
|
@@ -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
|
|
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,
|
package/commands/land.js
CHANGED
|
@@ -58,6 +58,102 @@ function worktreeWithinReapGrace(worktreePath, now = Date.now()) {
|
|
|
58
58
|
return typeof mtime === 'number' && now - mtime < WORKTREE_REAP_GRACE_MS;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
function canonicalPath(p) {
|
|
62
|
+
try {
|
|
63
|
+
return fs.realpathSync(p);
|
|
64
|
+
} catch {
|
|
65
|
+
return path.resolve(p);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function gitCommonDir(root) {
|
|
70
|
+
for (const args of [['rev-parse', '--path-format=absolute', '--git-common-dir'], ['rev-parse', '--git-common-dir']]) {
|
|
71
|
+
const res = runGit(args, { cwd: root, check: false });
|
|
72
|
+
if (res.status === 0 && res.stdout.trim()) return path.resolve(root, res.stdout.trim());
|
|
73
|
+
}
|
|
74
|
+
return '';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function headRefName(headFile) {
|
|
78
|
+
try {
|
|
79
|
+
const m = /^ref: refs\/heads\/(.+)$/.exec(fs.readFileSync(headFile, 'utf8').trim());
|
|
80
|
+
return m ? m[1] : '';
|
|
81
|
+
} catch {
|
|
82
|
+
return '';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Branch checkouts straight from .git/worktrees/<id>/HEAD plus the main
|
|
87
|
+
// checkout's HEAD — the live truth, not the board snapshot. A `worktree add`
|
|
88
|
+
// still in flight (or a half-registered entry) can leave a branch checked out
|
|
89
|
+
// in a directory the snapshot never saw, and git's own `branch -D` refusal
|
|
90
|
+
// only consults registrations whose gitdir link is already written.
|
|
91
|
+
// pendingFresh marks a fresh registration whose HEAD file does not exist yet:
|
|
92
|
+
// an add mid-flight that has not named its branch.
|
|
93
|
+
function liveCheckouts(root, now = Date.now()) {
|
|
94
|
+
const bound = new Map();
|
|
95
|
+
let pendingFresh = false;
|
|
96
|
+
const common = gitCommonDir(root);
|
|
97
|
+
if (!common) return { bound, pendingFresh };
|
|
98
|
+
if (path.basename(common) === '.git') {
|
|
99
|
+
const name = headRefName(path.join(common, 'HEAD'));
|
|
100
|
+
const mainPath = path.dirname(common);
|
|
101
|
+
if (name && fs.existsSync(mainPath)) bound.set(name, { path: mainPath, adminDir: common });
|
|
102
|
+
}
|
|
103
|
+
let ids = [];
|
|
104
|
+
try {
|
|
105
|
+
ids = fs.readdirSync(path.join(common, 'worktrees'));
|
|
106
|
+
} catch {
|
|
107
|
+
return { bound, pendingFresh };
|
|
108
|
+
}
|
|
109
|
+
for (const id of ids) {
|
|
110
|
+
const adminDir = path.join(common, 'worktrees', id);
|
|
111
|
+
let stat;
|
|
112
|
+
try {
|
|
113
|
+
stat = fs.statSync(adminDir);
|
|
114
|
+
} catch {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (!stat.isDirectory()) continue;
|
|
118
|
+
const headFile = path.join(adminDir, 'HEAD');
|
|
119
|
+
const name = headRefName(headFile);
|
|
120
|
+
if (!name) {
|
|
121
|
+
if (!fs.existsSync(headFile) && now - stat.mtimeMs < WORKTREE_REAP_GRACE_MS) pendingFresh = true;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
let wtPath = null;
|
|
125
|
+
try {
|
|
126
|
+
wtPath = path.dirname(fs.readFileSync(path.join(adminDir, 'gitdir'), 'utf8').trim());
|
|
127
|
+
} catch {
|
|
128
|
+
// gitdir unwritten or lost: cannot prove which directory is the
|
|
129
|
+
// checkout, so the branch stays claimed — fail safe, prune's job.
|
|
130
|
+
}
|
|
131
|
+
if (wtPath && !fs.existsSync(wtPath)) continue;
|
|
132
|
+
bound.set(name, { path: wtPath, adminDir });
|
|
133
|
+
}
|
|
134
|
+
return { bound, pendingFresh };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// The branch's own reflog records when `worktree add -b` (or `git branch`)
|
|
138
|
+
// created it. While an add is in flight a just-created target branch is very
|
|
139
|
+
// likely the checkout being wired up right now — keep it for this pass.
|
|
140
|
+
function branchCreatedWithinGrace(root, name, now = Date.now()) {
|
|
141
|
+
const res = runGit(['reflog', 'show', '--date=unix', name], { cwd: root, check: false });
|
|
142
|
+
if (res.status !== 0) return false;
|
|
143
|
+
const lines = res.stdout.split(/\r?\n/).filter(Boolean);
|
|
144
|
+
const m = /@\{(\d+)\}/.exec(lines[lines.length - 1] || '');
|
|
145
|
+
return Boolean(m) && now - Number(m[1]) * 1000 < WORKTREE_REAP_GRACE_MS;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// One receipt line for a branch kept because a live checkout claims it:
|
|
149
|
+
// fresh checkouts name the grace, older ones name where the branch is in use.
|
|
150
|
+
function checkoutKeepLine(name, bound, now) {
|
|
151
|
+
if (worktreeWithinReapGrace(bound.path || bound.adminDir, now)) {
|
|
152
|
+
return bound.path ? `${bound.path} (fresh_worktree_grace)` : `branch ${name} (fresh_worktree_grace)`;
|
|
153
|
+
}
|
|
154
|
+
return `branch ${name} (checked out in ${bound.path || 'a worktree'})`;
|
|
155
|
+
}
|
|
156
|
+
|
|
61
157
|
function listBranches(root, base = '') {
|
|
62
158
|
// With a base, ask git for ahead counts in the same single spawn
|
|
63
159
|
// (%(ahead-behind:) needs git >= 2.41; on failure we retry without it and
|
|
@@ -151,7 +247,12 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_S
|
|
|
151
247
|
const worktrees = [];
|
|
152
248
|
const all = listWorktrees(root);
|
|
153
249
|
for (const wt of all.slice(1)) {
|
|
154
|
-
const
|
|
250
|
+
const rawBranch = (wt.branch || '').replace(/^refs\/heads\//, '');
|
|
251
|
+
// 'detached' is a marker, not a name: left as-is it reads as a real branch
|
|
252
|
+
// name (hiding the worktree's own commits and, in reap, pushing the word
|
|
253
|
+
// "detached" into the delete list). Treat it as no branch so the worktree
|
|
254
|
+
// is asked directly what it holds.
|
|
255
|
+
const branch = rawBranch === 'detached' ? '' : rawBranch;
|
|
155
256
|
// light mode skips the full `git status` per worktree, the banner summary
|
|
156
257
|
// never reads dirty counts, only worktree mtimes for staleness.
|
|
157
258
|
const counts = (light ? null : statusCounts(wt.path)) || { staged: 0, unstaged: 0, untracked: 0 };
|
|
@@ -376,8 +477,25 @@ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOU
|
|
|
376
477
|
};
|
|
377
478
|
if (targetNames.size === 0 && worktreeTargets.length === 0) return receipt;
|
|
378
479
|
if (dryRun) {
|
|
480
|
+
// The preview must match the real pass: a branch still checked out in a
|
|
481
|
+
// worktree the board missed is kept, not listed as deleted. A checkout
|
|
482
|
+
// that is itself a removal target would be gone first, so it still reads
|
|
483
|
+
// as deletable here.
|
|
484
|
+
const live = liveCheckouts(root, now);
|
|
485
|
+
const targetPaths = new Set(worktreeTargets.map((w) => canonicalPath(w.path)));
|
|
379
486
|
receipt.removedWorktrees = worktreeTargets.map((w) => w.path);
|
|
380
|
-
receipt.deletedBranches = [...targetNames]
|
|
487
|
+
receipt.deletedBranches = [...targetNames].filter((name) => {
|
|
488
|
+
const bound = live.bound.get(name);
|
|
489
|
+
if (bound && !(bound.path && targetPaths.has(canonicalPath(bound.path)))) {
|
|
490
|
+
receipt.keptWorktrees.push(checkoutKeepLine(name, bound, now));
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
if (!bound && live.pendingFresh && branchCreatedWithinGrace(root, name, now)) {
|
|
494
|
+
receipt.keptWorktrees.push(`branch ${name} (fresh_worktree_grace)`);
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
return true;
|
|
498
|
+
});
|
|
381
499
|
return receipt;
|
|
382
500
|
}
|
|
383
501
|
|
|
@@ -406,9 +524,17 @@ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOU
|
|
|
406
524
|
(w) => targetNames.has(w.branch) || (includeDetached && w.state === 'detached')
|
|
407
525
|
);
|
|
408
526
|
for (const w of survivingWorktreeTargets) {
|
|
527
|
+
// The grace check ran at scan time, and a bundle build can sit between it
|
|
528
|
+
// and removal — an engine that booted mid-sweep refreshes the directory
|
|
529
|
+
// mtime in that gap. Stat once more at removal time, the cheapest place
|
|
530
|
+
// to never be wrong about freshness.
|
|
531
|
+
if (worktreeWithinReapGrace(w.path)) {
|
|
532
|
+
receipt.keptWorktrees.push(`${w.path} (fresh_worktree_grace)`);
|
|
533
|
+
if (w.branch) targetNames.delete(w.branch);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
409
536
|
// Salvage-then-remove, never keep-because-dirty: patches + untracked
|
|
410
|
-
// copies bank everything force-remove would destroy.
|
|
411
|
-
// grace was already applied when candidates were selected.
|
|
537
|
+
// copies bank everything force-remove would destroy.
|
|
412
538
|
if (w.dirty > 0 && !salvageWorktree(w, dir, receipt)) {
|
|
413
539
|
// could not fully back up what force-remove would destroy, keep it,
|
|
414
540
|
// and say why: a bare path in the receipt reads as an unexplained
|
|
@@ -430,7 +556,21 @@ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOU
|
|
|
430
556
|
}
|
|
431
557
|
}
|
|
432
558
|
|
|
559
|
+
// Re-scan after removals: worktrees just removed freed their branches, and
|
|
560
|
+
// anything still bound is checked out in a directory that exists — the
|
|
561
|
+
// board snapshot may have missed it entirely (a `worktree add` mid-flight),
|
|
562
|
+
// and git's own -D refusal only reads fully wired registrations.
|
|
563
|
+
const live = liveCheckouts(root, now);
|
|
433
564
|
for (const name of targetNames) {
|
|
565
|
+
const bound = live.bound.get(name);
|
|
566
|
+
if (bound) {
|
|
567
|
+
receipt.keptWorktrees.push(checkoutKeepLine(name, bound, now));
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
if (live.pendingFresh && branchCreatedWithinGrace(root, name, now)) {
|
|
571
|
+
receipt.keptWorktrees.push(`branch ${name} (fresh_worktree_grace)`);
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
434
574
|
const entry = board.branches.find((b) => b.name === name);
|
|
435
575
|
// the board is a snapshot; an agent may have committed since it was
|
|
436
576
|
// taken. A branch that moved is left alone, the next reap sees the
|