atris 3.58.6 → 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/youtube/SKILL.md +11 -11
- package/bin/atris.js +56 -3
- package/commands/auth.js +8 -2
- 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 +6 -1
- 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 +11 -6
- package/commands/youtube.js +76 -8
- 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 +43 -8
- package/utils/auth.js +62 -7
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* atris design, brand design-system commands against api.atris.ai.
|
|
5
|
+
*
|
|
6
|
+
* atris design extract <url> [--json] [--sections colors,typography]
|
|
7
|
+
* atris design check <url> --against <brand-url> [--json]
|
|
8
|
+
* atris design search "<words>" [--limit n] [--json]
|
|
9
|
+
*
|
|
10
|
+
* Auth: developer key as `Authorization: Bearer atris_...`, resolved by
|
|
11
|
+
* lib/design-api.js (ATRIS_API_KEY, then the atris login token, then
|
|
12
|
+
* ~/.atris/design-api-key).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
resolveDesignKey,
|
|
17
|
+
designRequest,
|
|
18
|
+
pollDesignJob,
|
|
19
|
+
billingOf,
|
|
20
|
+
creditLine,
|
|
21
|
+
} = require('../lib/design-api');
|
|
22
|
+
|
|
23
|
+
const NO_KEY = 'no api key found. set ATRIS_API_KEY or run: atris login (or save a key in ~/.atris/design-api-key)';
|
|
24
|
+
|
|
25
|
+
function showDesignHelp() {
|
|
26
|
+
console.log('usage: atris design extract <url> [--json] [--sections colors,typography]');
|
|
27
|
+
console.log(' atris design check <url> --against <brand-url> [--json]');
|
|
28
|
+
console.log(' atris design search "<words>" [--limit n] [--json]');
|
|
29
|
+
console.log('pull a site\'s design system, score a page against a brand, or search extracted brands.');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function wantsHelp(args) {
|
|
33
|
+
return args.includes('--help') || args.includes('-h') || args[0] === 'help';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readFlag(args, name) {
|
|
37
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
38
|
+
const a = String(args[i]);
|
|
39
|
+
if (a === name) {
|
|
40
|
+
const v = args[i + 1];
|
|
41
|
+
if (v === undefined || String(v).startsWith('--')) return { error: `${name} needs a value` };
|
|
42
|
+
args.splice(i, 2);
|
|
43
|
+
return { value: String(v) };
|
|
44
|
+
}
|
|
45
|
+
if (a.startsWith(`${name}=`)) {
|
|
46
|
+
args.splice(i, 1);
|
|
47
|
+
return { value: a.slice(name.length + 1) };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function positionals(args) {
|
|
54
|
+
return args.filter((a) => !String(a).startsWith('--'));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function unknownFlags(args, allowed) {
|
|
58
|
+
return args.filter((a) => String(a).startsWith('--') && !allowed.includes(a));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeUrl(url) {
|
|
62
|
+
return /^https?:\/\//i.test(url) ? url : `https://${url}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isTerminal(job) {
|
|
66
|
+
const s = String(job && job.status || '').toLowerCase();
|
|
67
|
+
return s === 'completed' || s === 'failed' || s === 'error' || s === 'succeeded';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// One-line spinner on TTY only; off TTY stays silent so --json and pipes stay clean.
|
|
71
|
+
function makeSpinner(label, io) {
|
|
72
|
+
const write = io.write;
|
|
73
|
+
if (!io.tty || typeof write !== 'function') return { stop() {} };
|
|
74
|
+
const start = Date.now();
|
|
75
|
+
const frames = ['-', '\\', '|', '/'];
|
|
76
|
+
let n = 0;
|
|
77
|
+
const timer = setInterval(() => {
|
|
78
|
+
const secs = Math.floor((Date.now() - start) / 1000);
|
|
79
|
+
write(`\r ${frames[n++ % frames.length]} ${label} ${secs}s`);
|
|
80
|
+
}, 250);
|
|
81
|
+
if (timer.unref) timer.unref();
|
|
82
|
+
return {
|
|
83
|
+
stop() {
|
|
84
|
+
clearInterval(timer);
|
|
85
|
+
write('\r\x1b[2K');
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function uniqueHexes(colors = {}, palette = []) {
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const key of ['primary', 'secondary', 'accent']) {
|
|
93
|
+
const hex = colors[key];
|
|
94
|
+
if (hex && !out.includes(hex)) out.push(hex);
|
|
95
|
+
}
|
|
96
|
+
for (const p of Array.isArray(palette) ? palette : []) {
|
|
97
|
+
const hex = p && typeof p === 'object' ? p.hex : p;
|
|
98
|
+
if (hex && !out.includes(hex)) out.push(hex);
|
|
99
|
+
if (out.length >= 6) break;
|
|
100
|
+
}
|
|
101
|
+
return out.slice(0, 6);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function fontFamily(value) {
|
|
105
|
+
if (!value) return null;
|
|
106
|
+
if (typeof value === 'string') return value;
|
|
107
|
+
if (typeof value === 'object') return value.family || null;
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Short readable card for an extraction job.
|
|
112
|
+
function extractionCard(job = {}) {
|
|
113
|
+
const ds = job.result && job.result.design_system ? job.result.design_system : {};
|
|
114
|
+
const profile = ds.profile || {};
|
|
115
|
+
const colors = ds.colors || {};
|
|
116
|
+
const typo = ds.typography || {};
|
|
117
|
+
const lines = [];
|
|
118
|
+
lines.push('');
|
|
119
|
+
lines.push(` ${profile.brand_name || job.source_url || 'unknown brand'}`);
|
|
120
|
+
if (job.source_url) lines.push(` ${job.source_url}`);
|
|
121
|
+
lines.push('');
|
|
122
|
+
const hexes = uniqueHexes(colors, colors.palette);
|
|
123
|
+
if (hexes.length) lines.push(` colors ${hexes.join(' ')}`);
|
|
124
|
+
const heading = fontFamily(typo.heading);
|
|
125
|
+
const body = fontFamily(typo.body);
|
|
126
|
+
if (heading) lines.push(` heading ${heading}`);
|
|
127
|
+
if (body) lines.push(` body ${body}`);
|
|
128
|
+
if (profile.one_line_positioning) lines.push(` line ${profile.one_line_positioning}`);
|
|
129
|
+
lines.push('');
|
|
130
|
+
lines.push(` ${creditLine(job)}`);
|
|
131
|
+
lines.push('');
|
|
132
|
+
return lines.join('\n');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Short readable card for an adherence job.
|
|
136
|
+
function adherenceCard(job = {}) {
|
|
137
|
+
const result = job.result && typeof job.result === 'object' ? job.result : {};
|
|
138
|
+
const lines = [];
|
|
139
|
+
lines.push('');
|
|
140
|
+
const score = Number(result.score);
|
|
141
|
+
lines.push(` score ${Number.isFinite(score) ? score.toFixed(2) : '?'}`);
|
|
142
|
+
if (job.source_url) lines.push(` source ${job.source_url}`);
|
|
143
|
+
if (job.reference_url) lines.push(` against ${job.reference_url}`);
|
|
144
|
+
lines.push('');
|
|
145
|
+
for (const key of ['fixes', 'recommendations']) {
|
|
146
|
+
const items = Array.isArray(result[key]) ? result[key] : [];
|
|
147
|
+
if (!items.length) continue;
|
|
148
|
+
lines.push(` ${key}:`);
|
|
149
|
+
for (const item of items.slice(0, 5)) {
|
|
150
|
+
const text = typeof item === 'string' ? item : (item && (item.title || item.detail || item.message)) || JSON.stringify(item);
|
|
151
|
+
lines.push(` - ${String(text).replace(/\s+/g, ' ').trim()}`);
|
|
152
|
+
}
|
|
153
|
+
lines.push('');
|
|
154
|
+
}
|
|
155
|
+
lines.push(` ${creditLine(job)}`);
|
|
156
|
+
lines.push('');
|
|
157
|
+
return lines.join('\n');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Short readable card for a search response.
|
|
161
|
+
function searchCard(data = {}, query = '') {
|
|
162
|
+
const results = Array.isArray(data.results) ? data.results : [];
|
|
163
|
+
const lines = [];
|
|
164
|
+
lines.push('');
|
|
165
|
+
lines.push(` ${results.length} result${results.length === 1 ? '' : 's'} for "${query}"`);
|
|
166
|
+
lines.push('');
|
|
167
|
+
for (const r of results) {
|
|
168
|
+
const name = r.brand_name || r.source_url || 'unknown';
|
|
169
|
+
lines.push(` ${name}${r.source_url ? ` ${r.source_url}` : ''}`);
|
|
170
|
+
const palette = Array.isArray(r.palette) ? r.palette.slice(0, 6).join(' ') : '';
|
|
171
|
+
if (palette) lines.push(` ${palette}`);
|
|
172
|
+
}
|
|
173
|
+
lines.push('');
|
|
174
|
+
lines.push(` ${creditLine(data)}`);
|
|
175
|
+
lines.push('');
|
|
176
|
+
return lines.join('\n');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function designExtract(args, ctx) {
|
|
180
|
+
const json = args.includes('--json');
|
|
181
|
+
const sections = readFlag(args, '--sections');
|
|
182
|
+
if (sections.error) { ctx.err(sections.error); return 1; }
|
|
183
|
+
const bad = unknownFlags(args, ['--json']);
|
|
184
|
+
if (bad.length) { ctx.err(`unknown flag for design extract: ${bad.join(' ')}`); return 1; }
|
|
185
|
+
const url = positionals(args)[0];
|
|
186
|
+
if (!url) { ctx.err('usage: atris design extract <url> [--json] [--sections colors,typography]'); return 1; }
|
|
187
|
+
const target = normalizeUrl(url);
|
|
188
|
+
|
|
189
|
+
const body = { url: target };
|
|
190
|
+
if (sections.value) body.sections = sections.value;
|
|
191
|
+
const first = await ctx.request('/design/extractions', { method: 'POST', body, key: ctx.key });
|
|
192
|
+
if (!first.ok) {
|
|
193
|
+
ctx.err(`design extract failed (${first.status}): ${first.error}`);
|
|
194
|
+
return 1;
|
|
195
|
+
}
|
|
196
|
+
let job = first.data || {};
|
|
197
|
+
const pollMs = ctx.pollMs;
|
|
198
|
+
if (!isTerminal(job) && job.id) {
|
|
199
|
+
const qs = sections.value ? `?sections=${encodeURIComponent(sections.value)}` : '';
|
|
200
|
+
const spinner = makeSpinner(`extracting ${target}`, ctx.io);
|
|
201
|
+
let failures = 0;
|
|
202
|
+
const polled = await pollDesignJob(async () => {
|
|
203
|
+
const res = await ctx.request(`/design/extractions/${job.id}${qs}`, { key: ctx.key });
|
|
204
|
+
if (!res.ok) {
|
|
205
|
+
failures += 1;
|
|
206
|
+
if (failures >= 3) return { status: 'failed', error: res.error };
|
|
207
|
+
return { status: 'polling' };
|
|
208
|
+
}
|
|
209
|
+
failures = 0;
|
|
210
|
+
return res.data;
|
|
211
|
+
}, { intervalMs: pollMs, timeoutMs: ctx.maxWaitMs, sleep: ctx.sleep });
|
|
212
|
+
spinner.stop();
|
|
213
|
+
if (polled.timedOut) {
|
|
214
|
+
ctx.err(`still running after ${Math.round(ctx.maxWaitMs / 1000)}s. job id: ${job.id}`);
|
|
215
|
+
return 1;
|
|
216
|
+
}
|
|
217
|
+
job = polled.job || job;
|
|
218
|
+
}
|
|
219
|
+
if (json) { ctx.out(JSON.stringify(job, null, 2)); return job.status === 'completed' ? 0 : 1; }
|
|
220
|
+
if (!isTerminal(job) || job.status !== 'completed') {
|
|
221
|
+
ctx.err(`extraction did not finish: ${job.error || job.status || 'unknown'}`);
|
|
222
|
+
return 1;
|
|
223
|
+
}
|
|
224
|
+
ctx.out(extractionCard(job));
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function designCheck(args, ctx) {
|
|
229
|
+
const json = args.includes('--json');
|
|
230
|
+
const against = readFlag(args, '--against');
|
|
231
|
+
if (against.error) { ctx.err(against.error); return 1; }
|
|
232
|
+
const bad = unknownFlags(args, ['--json']);
|
|
233
|
+
if (bad.length) { ctx.err(`unknown flag for design check: ${bad.join(' ')}`); return 1; }
|
|
234
|
+
const url = positionals(args)[0];
|
|
235
|
+
if (!url || !against.value) {
|
|
236
|
+
ctx.err('usage: atris design check <url> --against <brand-url> [--json]');
|
|
237
|
+
return 1;
|
|
238
|
+
}
|
|
239
|
+
const source = normalizeUrl(url);
|
|
240
|
+
const reference = normalizeUrl(against.value);
|
|
241
|
+
|
|
242
|
+
const first = await ctx.request('/design/adherence', {
|
|
243
|
+
method: 'POST',
|
|
244
|
+
body: { source_url: source, reference_url: reference },
|
|
245
|
+
key: ctx.key,
|
|
246
|
+
});
|
|
247
|
+
if (!first.ok) {
|
|
248
|
+
ctx.err(`design check failed (${first.status}): ${first.error}`);
|
|
249
|
+
return 1;
|
|
250
|
+
}
|
|
251
|
+
let job = first.data || {};
|
|
252
|
+
if (!isTerminal(job) && job.id) {
|
|
253
|
+
const spinner = makeSpinner(`checking ${source}`, ctx.io);
|
|
254
|
+
let failures = 0;
|
|
255
|
+
const polled = await pollDesignJob(async () => {
|
|
256
|
+
const res = await ctx.request(`/design/adherence/${job.id}`, { key: ctx.key });
|
|
257
|
+
if (!res.ok) {
|
|
258
|
+
failures += 1;
|
|
259
|
+
if (failures >= 3) return { status: 'failed', error: res.error };
|
|
260
|
+
return { status: 'polling' };
|
|
261
|
+
}
|
|
262
|
+
failures = 0;
|
|
263
|
+
return res.data;
|
|
264
|
+
}, { intervalMs: ctx.pollMs, timeoutMs: ctx.maxWaitMs, sleep: ctx.sleep });
|
|
265
|
+
spinner.stop();
|
|
266
|
+
if (polled.timedOut) {
|
|
267
|
+
ctx.err(`still running after ${Math.round(ctx.maxWaitMs / 1000)}s. job id: ${job.id}`);
|
|
268
|
+
return 1;
|
|
269
|
+
}
|
|
270
|
+
job = polled.job || job;
|
|
271
|
+
}
|
|
272
|
+
if (json) { ctx.out(JSON.stringify(job, null, 2)); return job.status === 'completed' ? 0 : 1; }
|
|
273
|
+
if (job.status !== 'completed') {
|
|
274
|
+
ctx.err(`check did not finish: ${job.error || job.status || 'unknown'}`);
|
|
275
|
+
return 1;
|
|
276
|
+
}
|
|
277
|
+
ctx.out(adherenceCard(job));
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function designSearch(args, ctx) {
|
|
282
|
+
const json = args.includes('--json');
|
|
283
|
+
const limitFlag = readFlag(args, '--limit');
|
|
284
|
+
if (limitFlag.error) { ctx.err(limitFlag.error); return 1; }
|
|
285
|
+
const bad = unknownFlags(args, ['--json']);
|
|
286
|
+
if (bad.length) { ctx.err(`unknown flag for design search: ${bad.join(' ')}`); return 1; }
|
|
287
|
+
const query = positionals(args).join(' ').trim();
|
|
288
|
+
if (!query) { ctx.err('usage: atris design search "<words>" [--limit n] [--json]'); return 1; }
|
|
289
|
+
let limit;
|
|
290
|
+
if (limitFlag.value != null) {
|
|
291
|
+
limit = parseInt(limitFlag.value, 10);
|
|
292
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
293
|
+
ctx.err(`invalid --limit value: "${limitFlag.value}". expected a positive integer.`);
|
|
294
|
+
return 1;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const body = { query };
|
|
299
|
+
if (limit != null) body.limit = limit;
|
|
300
|
+
const res = await ctx.request('/design/search', { method: 'POST', body, key: ctx.key });
|
|
301
|
+
if (!res.ok) {
|
|
302
|
+
ctx.err(`design search failed (${res.status}): ${res.error}`);
|
|
303
|
+
return 1;
|
|
304
|
+
}
|
|
305
|
+
const data = res.data || {};
|
|
306
|
+
if (json) { ctx.out(JSON.stringify(data, null, 2)); return 0; }
|
|
307
|
+
ctx.out(searchCard(data, query));
|
|
308
|
+
return 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function run(args = [], deps = {}) {
|
|
312
|
+
const rest = args.slice();
|
|
313
|
+
if (rest.length === 0 || wantsHelp(rest)) {
|
|
314
|
+
showDesignHelp();
|
|
315
|
+
return rest.length === 0 ? 1 : 0;
|
|
316
|
+
}
|
|
317
|
+
const sub = rest.shift();
|
|
318
|
+
|
|
319
|
+
const io = deps.io || {
|
|
320
|
+
out: (s) => process.stdout.write(`${s}\n`),
|
|
321
|
+
err: (s) => process.stderr.write(`${s}\n`),
|
|
322
|
+
write: (s) => process.stdout.write(s),
|
|
323
|
+
tty: Boolean(process.stdout.isTTY),
|
|
324
|
+
};
|
|
325
|
+
const ctx = {
|
|
326
|
+
out: deps.out || io.out,
|
|
327
|
+
err: deps.err || io.err,
|
|
328
|
+
io,
|
|
329
|
+
key: deps.key !== undefined ? deps.key : resolveDesignKey(process.env, deps),
|
|
330
|
+
request: deps.request || designRequest,
|
|
331
|
+
sleep: deps.sleep,
|
|
332
|
+
pollMs: deps.pollMs != null
|
|
333
|
+
? deps.pollMs
|
|
334
|
+
: Number(process.env.ATRIS_DESIGN_POLL_MS) || undefined,
|
|
335
|
+
maxWaitMs: deps.maxWaitMs != null
|
|
336
|
+
? deps.maxWaitMs
|
|
337
|
+
: Number(process.env.ATRIS_DESIGN_TIMEOUT_MS) || 3 * 60 * 1000,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
if (!['extract', 'check', 'search'].includes(sub)) {
|
|
341
|
+
ctx.err(`unknown design subcommand: ${sub}`);
|
|
342
|
+
showDesignHelp();
|
|
343
|
+
return 1;
|
|
344
|
+
}
|
|
345
|
+
if (!ctx.key) {
|
|
346
|
+
ctx.err(NO_KEY);
|
|
347
|
+
return 1;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
if (sub === 'extract') return await designExtract(rest, ctx);
|
|
352
|
+
if (sub === 'check') return await designCheck(rest, ctx);
|
|
353
|
+
return await designSearch(rest, ctx);
|
|
354
|
+
} catch (error) {
|
|
355
|
+
ctx.err(`design ${sub} failed: ${(error && error.message) || error}`);
|
|
356
|
+
return 1;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
module.exports = {
|
|
361
|
+
run,
|
|
362
|
+
};
|
|
@@ -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 };
|