atris 3.43.0 → 3.45.1
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/atris/skills/design/SKILL.md +7 -1
- package/atris/skills/engines/SKILL.md +44 -13
- package/atris/team/customer-lead/MEMBER.md +45 -0
- package/atris/team/customer-lead/SOUL.md +33 -0
- package/atris/team/customer-lead/START_HERE.md +7 -0
- package/atris/team/customer-lead/skills/customer-commitments/SKILL.md +45 -0
- package/atris/team/improver/MEMBER.md +33 -0
- package/bin/atris.js +36 -3
- package/commands/aeo.js +5 -2
- package/commands/align.js +5 -2
- package/commands/autoland.js +15 -1
- package/commands/caretaker.js +303 -0
- package/commands/clean.js +76 -0
- package/commands/computer.js +5 -2
- package/commands/engine-watch.js +212 -0
- package/commands/engine.js +99 -11
- package/commands/founder.js +304 -0
- package/commands/human-missions.js +844 -0
- package/commands/improve.js +29 -6
- package/commands/init.js +16 -7
- package/commands/mission.js +124 -69
- package/commands/pull.js +5 -2
- package/commands/push.js +5 -2
- package/commands/slop.js +34 -3
- package/commands/task.js +51 -4
- package/commands/team.js +329 -13
- package/commands/terminal.js +5 -2
- package/commands/verify.js +99 -6
- package/commands/workflow.js +10 -3
- package/commands/worktree.js +119 -4
- package/lib/auto-accept-certified.js +302 -0
- package/lib/cloud-mission.js +59 -2
- package/lib/conductor-artifacts.js +1 -1
- package/lib/dispatch-scout.js +386 -0
- package/lib/engine-ask.js +645 -0
- package/lib/engine-job-lifecycle.js +65 -0
- package/lib/engine-receipt-sweep.js +98 -0
- package/lib/engine-registry.js +2 -2
- package/lib/engine-validate.js +382 -0
- package/lib/fleet.js +459 -106
- package/lib/known-commands.js +2 -2
- package/lib/member-alive.js +2 -2
- package/lib/policy-lessons.js +70 -0
- package/lib/receipt-evidence.js +56 -1
- package/lib/runner-command.js +1 -1
- package/lib/secret-gateway.js +588 -0
- package/lib/team-presence.js +13 -1
- package/lib/voice-gate.js +6 -0
- package/lib/wish-audit.js +5 -205
- package/lib/wish-delegate.js +5 -2
- package/package.json +6 -1
- package/utils/auth.js +56 -9
package/commands/team.js
CHANGED
|
@@ -6,6 +6,7 @@ const path = require('path');
|
|
|
6
6
|
const { canonicalEngineName } = require('../lib/engine-registry');
|
|
7
7
|
const taskDb = require('../lib/task-db');
|
|
8
8
|
const { buildTeamPresence, DEFAULT_FRESHNESS_WINDOW_MS, renderTeamPresence } = require('../lib/team-presence');
|
|
9
|
+
const { readEngineRegistry } = require('./engine');
|
|
9
10
|
const { listMissions, listWorktreeRollupMissions } = require('./mission');
|
|
10
11
|
const { collectSnapshot, collectStreamEvents, repoRoot } = require('./stream');
|
|
11
12
|
|
|
@@ -42,6 +43,7 @@ function collectTeamPresence(deps = {}) {
|
|
|
42
43
|
return buildTeamPresence({
|
|
43
44
|
nowMs,
|
|
44
45
|
freshnessWindowMs,
|
|
46
|
+
operator: deps.operator || process.env.USER || process.env.USERNAME || '',
|
|
45
47
|
stream,
|
|
46
48
|
streamEvents,
|
|
47
49
|
missions: collectMissions(root, deps),
|
|
@@ -88,8 +90,92 @@ function missionEngine(mission) {
|
|
|
88
90
|
return canonicalEngineName(mission?.runner) || canonicalEngineName(mission?.engine);
|
|
89
91
|
}
|
|
90
92
|
|
|
93
|
+
function formatEngineModel(engineId, engineRoster) {
|
|
94
|
+
const id = String(engineId || '').trim();
|
|
95
|
+
if (!id) return '-';
|
|
96
|
+
const entry = (Array.isArray(engineRoster) ? engineRoster : []).find((row) => row.id === id);
|
|
97
|
+
if (!entry) return id;
|
|
98
|
+
const models = Array.isArray(entry.models) ? entry.models.filter(Boolean) : [];
|
|
99
|
+
if (!models.length) return id;
|
|
100
|
+
return `${id} (${models.join(', ')})`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isTemplateMember(member) {
|
|
104
|
+
const name = String(member?.name || '').trim();
|
|
105
|
+
if (name === '<name>') return true;
|
|
106
|
+
const dir = String(member?.dir || '').trim();
|
|
107
|
+
return dir.includes('<') || dir.includes('>');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function readMemberNow(member, root) {
|
|
111
|
+
const nowPath = member?.dir
|
|
112
|
+
? path.join(member.dir, 'now.md')
|
|
113
|
+
: path.join(root, 'atris', 'team', String(member?.name || '').trim(), 'now.md');
|
|
114
|
+
let text = '';
|
|
115
|
+
try { text = fs.readFileSync(nowPath, 'utf8'); } catch { return '-'; }
|
|
116
|
+
for (const line of text.split(/\r?\n/)) {
|
|
117
|
+
const trimmed = String(line || '').trim();
|
|
118
|
+
if (!trimmed || /^#+\s/.test(trimmed) || /^<!--/.test(trimmed) || /-->$/.test(trimmed)) continue;
|
|
119
|
+
if (/^---+$/.test(trimmed)) continue;
|
|
120
|
+
const content = trimmed
|
|
121
|
+
.replace(/^[-*]\s+/, '')
|
|
122
|
+
.replace(/^\[[ xX]\]\s+/, '')
|
|
123
|
+
.trim();
|
|
124
|
+
if (!content || /^[A-Za-z_-]+:\s/.test(content)) continue;
|
|
125
|
+
return content;
|
|
126
|
+
}
|
|
127
|
+
return '-';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function memberFrontmatterEngine(member) {
|
|
131
|
+
return String(member?.frontmatter?.engine || '').trim();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function memberAlwaysOn(member) {
|
|
135
|
+
const raw = member?.frontmatter?.alwayson;
|
|
136
|
+
if (raw === true) return true;
|
|
137
|
+
return String(raw || '').trim().toLowerCase() === 'true';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function memberFocus(rawNow, { awake, alwaysOn }) {
|
|
141
|
+
let focus = rawNow;
|
|
142
|
+
if (alwaysOn && rawNow === '-') focus = 'always on';
|
|
143
|
+
if (awake) focus = focus === '-' ? 'always on (live)' : `${focus} (live)`;
|
|
144
|
+
return focus;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function memberIsActive({ frontmatterEngine, awake }) {
|
|
148
|
+
// A stale focus line in now.md does not make a member active — only an
|
|
149
|
+
// assigned engine or live presence does.
|
|
150
|
+
return Boolean(frontmatterEngine) || awake;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function clipCell(text, max) {
|
|
154
|
+
const value = String(text || '').trim();
|
|
155
|
+
if (!max || value.length <= max) return value;
|
|
156
|
+
if (max <= 0) return '';
|
|
157
|
+
return value.slice(0, max);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function rosterStatus(entry) {
|
|
161
|
+
if (entry.status === 'awake') return 'live';
|
|
162
|
+
if (String(entry.engine || '').trim()) return 'assigned';
|
|
163
|
+
return 'idle';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function escapeHtml(text) {
|
|
167
|
+
return String(text || '')
|
|
168
|
+
.replace(/&/g, '&')
|
|
169
|
+
.replace(/</g, '<')
|
|
170
|
+
.replace(/>/g, '>')
|
|
171
|
+
.replace(/"/g, '"');
|
|
172
|
+
}
|
|
173
|
+
|
|
91
174
|
function collectTeamRoster(deps = {}) {
|
|
92
175
|
const root = deps.root || repoRoot(deps.cwd || process.cwd());
|
|
176
|
+
const presence = deps.presence || collectTeamPresence(deps);
|
|
177
|
+
const awake = new Set(presence.members.map((member) => String(member.name || '').trim().toLowerCase()));
|
|
178
|
+
const engineRoster = deps.engineRoster || readEngineRegistry(root, { persist: false }).engines;
|
|
93
179
|
const engineByOwner = new Map();
|
|
94
180
|
for (const mission of collectMissions(root, deps)) {
|
|
95
181
|
if (!ROSTER_LIVE_MISSION_STATUSES.has(String(mission?.status || '').toLowerCase())) continue;
|
|
@@ -99,21 +185,233 @@ function collectTeamRoster(deps = {}) {
|
|
|
99
185
|
if (owner && engine && !engineByOwner.has(owner)) engineByOwner.set(owner, engine);
|
|
100
186
|
}
|
|
101
187
|
return collectMembers(root, deps)
|
|
188
|
+
.filter((member) => !isTemplateMember(member))
|
|
102
189
|
.map((member) => {
|
|
103
190
|
const name = String(member?.name || '').trim().toLowerCase();
|
|
104
|
-
|
|
191
|
+
const missionEngine = engineByOwner.get(name) || '';
|
|
192
|
+
const frontmatterEngine = memberFrontmatterEngine(member);
|
|
193
|
+
const alwaysOn = memberAlwaysOn(member);
|
|
194
|
+
const isAwake = awake.has(name);
|
|
195
|
+
const rawNow = readMemberNow(member, root);
|
|
196
|
+
const active = memberIsActive({ frontmatterEngine, awake: isAwake, rawNow });
|
|
197
|
+
const focus = memberFocus(rawNow, { awake: isAwake, alwaysOn });
|
|
198
|
+
return {
|
|
199
|
+
name,
|
|
200
|
+
role: plainRole(member),
|
|
201
|
+
engine: frontmatterEngine,
|
|
202
|
+
mission_engine: missionEngine,
|
|
203
|
+
engine_model: formatEngineModel(missionEngine, engineRoster),
|
|
204
|
+
status: isAwake ? 'awake' : 'idle',
|
|
205
|
+
now: rawNow,
|
|
206
|
+
focus,
|
|
207
|
+
active,
|
|
208
|
+
};
|
|
105
209
|
})
|
|
106
210
|
.filter((entry) => entry.name)
|
|
107
211
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
108
212
|
}
|
|
109
213
|
|
|
110
|
-
function
|
|
111
|
-
|
|
214
|
+
function wrapCommaNames(names, width = 80) {
|
|
215
|
+
const lines = [];
|
|
216
|
+
let line = '';
|
|
217
|
+
for (const name of names) {
|
|
218
|
+
const candidate = line ? `${line}, ${name}` : name;
|
|
219
|
+
if (candidate.length > width && line) {
|
|
220
|
+
lines.push(line);
|
|
221
|
+
line = name;
|
|
222
|
+
} else {
|
|
223
|
+
line = candidate;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (line) lines.push(line);
|
|
227
|
+
return lines.join('\n');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function renderTeamRoster(rosterRows, deps = {}) {
|
|
231
|
+
if (!rosterRows.length) {
|
|
112
232
|
return 'no team members yet. create one with: atris member create <name> --role="..."';
|
|
113
233
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
234
|
+
const activeRows = rosterRows.filter((entry) => entry.active);
|
|
235
|
+
const restRows = rosterRows.filter((entry) => !entry.active);
|
|
236
|
+
const termWidth = deps.termWidth || process.stdout.columns || 80;
|
|
237
|
+
const memberW = Math.max(6, ...activeRows.map((entry) => entry.name.length));
|
|
238
|
+
const engineW = Math.max(6, ...activeRows.map((entry) => (entry.engine || '-').length));
|
|
239
|
+
const statusW = 8;
|
|
240
|
+
const sep = 3;
|
|
241
|
+
const focusW = Math.max(8, termWidth - memberW - engineW - statusW - sep * 3);
|
|
242
|
+
const lines = ['active team:'];
|
|
243
|
+
if (activeRows.length) {
|
|
244
|
+
for (const entry of activeRows) {
|
|
245
|
+
const engine = entry.engine || '-';
|
|
246
|
+
const status = rosterStatus(entry);
|
|
247
|
+
const focus = clipCell(entry.focus || '-', focusW);
|
|
248
|
+
lines.push(
|
|
249
|
+
`${clipCell(entry.name, memberW).padEnd(memberW)} | ${clipCell(engine, engineW).padEnd(engineW)} | ${status.padEnd(statusW)} | ${focus}`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
} else {
|
|
253
|
+
lines.push('(none)');
|
|
254
|
+
}
|
|
255
|
+
lines.push('');
|
|
256
|
+
lines.push('rest of the team:');
|
|
257
|
+
if (restRows.length) {
|
|
258
|
+
lines.push(wrapCommaNames(restRows.map((entry) => entry.name), termWidth));
|
|
259
|
+
} else {
|
|
260
|
+
lines.push('(none)');
|
|
261
|
+
}
|
|
262
|
+
return lines.join('\n');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function renderTeamRosterHtml(rosterRows, meta = {}) {
|
|
266
|
+
const activeRows = rosterRows.filter((entry) => entry.active);
|
|
267
|
+
const restRows = rosterRows.filter((entry) => !entry.active);
|
|
268
|
+
const generatedAt = meta.generatedAt || new Date().toISOString();
|
|
269
|
+
const workspace = meta.workspace || process.cwd();
|
|
270
|
+
|
|
271
|
+
const statusDot = (entry) => {
|
|
272
|
+
const status = rosterStatus(entry);
|
|
273
|
+
if (status === 'live') return '<span class="dot dot-live" title="live"></span><span class="status-label">live</span>';
|
|
274
|
+
if (status === 'assigned') return '<span class="dot dot-assigned" title="assigned"></span><span class="status-label">assigned</span>';
|
|
275
|
+
return '<span class="dot dot-idle" title="idle"></span><span class="status-label">idle</span>';
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const activeRowsHtml = activeRows.length
|
|
279
|
+
? activeRows.map((entry) => `
|
|
280
|
+
<tr>
|
|
281
|
+
<td class="col-member">${escapeHtml(entry.name)}</td>
|
|
282
|
+
<td class="col-engine">${escapeHtml(entry.engine || '-')}</td>
|
|
283
|
+
<td class="col-status">${statusDot(entry)}</td>
|
|
284
|
+
<td class="col-focus">${escapeHtml(entry.focus || '-')}</td>
|
|
285
|
+
</tr>`).join('')
|
|
286
|
+
: '<tr><td colspan="4" class="empty">(none)</td></tr>';
|
|
287
|
+
|
|
288
|
+
const restChipsHtml = restRows.length
|
|
289
|
+
? restRows.map((entry) => `<span class="chip">${escapeHtml(entry.name)}</span>`).join('')
|
|
290
|
+
: '<span class="empty">(none)</span>';
|
|
291
|
+
|
|
292
|
+
return `<!DOCTYPE html>
|
|
293
|
+
<html lang="en">
|
|
294
|
+
<head>
|
|
295
|
+
<meta charset="utf-8">
|
|
296
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
297
|
+
<title>Team board</title>
|
|
298
|
+
<style>
|
|
299
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
300
|
+
body {
|
|
301
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial,
|
|
302
|
+
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
|
303
|
+
font-size: 14px;
|
|
304
|
+
line-height: 1.5;
|
|
305
|
+
color: #1c1917;
|
|
306
|
+
background: #fafaf9;
|
|
307
|
+
padding: 16px;
|
|
308
|
+
}
|
|
309
|
+
.board { max-width: 1200px; margin: 0 auto; }
|
|
310
|
+
h2 {
|
|
311
|
+
font-size: 16px;
|
|
312
|
+
font-weight: 600;
|
|
313
|
+
margin-bottom: 8px;
|
|
314
|
+
padding-bottom: 4px;
|
|
315
|
+
border-bottom: 2px solid #f59e0b;
|
|
316
|
+
}
|
|
317
|
+
section { margin-bottom: 16px; }
|
|
318
|
+
table {
|
|
319
|
+
width: 100%;
|
|
320
|
+
border-collapse: collapse;
|
|
321
|
+
background: #fff;
|
|
322
|
+
border-radius: 4px;
|
|
323
|
+
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
|
324
|
+
}
|
|
325
|
+
th, td {
|
|
326
|
+
text-align: left;
|
|
327
|
+
vertical-align: middle;
|
|
328
|
+
padding: 8px 12px;
|
|
329
|
+
border-bottom: 1px solid #f5f5f4;
|
|
330
|
+
}
|
|
331
|
+
th {
|
|
332
|
+
font-size: 12px;
|
|
333
|
+
font-weight: 600;
|
|
334
|
+
color: #78716c;
|
|
335
|
+
text-transform: uppercase;
|
|
336
|
+
letter-spacing: 0.04em;
|
|
337
|
+
}
|
|
338
|
+
tr { min-height: 44px; }
|
|
339
|
+
tr:last-child td { border-bottom: none; }
|
|
340
|
+
.col-member { white-space: nowrap; }
|
|
341
|
+
.col-engine { white-space: nowrap; }
|
|
342
|
+
.col-status { white-space: nowrap; }
|
|
343
|
+
.col-focus { word-wrap: break-word; overflow-wrap: break-word; }
|
|
344
|
+
.dot {
|
|
345
|
+
display: inline-block;
|
|
346
|
+
width: 8px;
|
|
347
|
+
height: 8px;
|
|
348
|
+
border-radius: 50%;
|
|
349
|
+
margin-right: 4px;
|
|
350
|
+
vertical-align: middle;
|
|
351
|
+
}
|
|
352
|
+
.dot-live { background: #22c55e; }
|
|
353
|
+
.dot-assigned { background: #f59e0b; }
|
|
354
|
+
.dot-idle { background: #a8a29e; }
|
|
355
|
+
.status-label { font-size: 14px; vertical-align: middle; }
|
|
356
|
+
.chip-grid {
|
|
357
|
+
display: flex;
|
|
358
|
+
flex-wrap: wrap;
|
|
359
|
+
gap: 4px;
|
|
360
|
+
}
|
|
361
|
+
.chip {
|
|
362
|
+
display: inline-block;
|
|
363
|
+
background: #fff;
|
|
364
|
+
border: 1px solid #e7e5e4;
|
|
365
|
+
border-radius: 4px;
|
|
366
|
+
padding: 4px 8px;
|
|
367
|
+
font-size: 14px;
|
|
368
|
+
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
|
369
|
+
}
|
|
370
|
+
.empty { color: #78716c; font-style: italic; }
|
|
371
|
+
footer {
|
|
372
|
+
margin-top: 16px;
|
|
373
|
+
font-size: 12px;
|
|
374
|
+
color: #78716c;
|
|
375
|
+
}
|
|
376
|
+
</style>
|
|
377
|
+
</head>
|
|
378
|
+
<body>
|
|
379
|
+
<div class="board">
|
|
380
|
+
<section>
|
|
381
|
+
<h2>Active team</h2>
|
|
382
|
+
<table>
|
|
383
|
+
<thead>
|
|
384
|
+
<tr>
|
|
385
|
+
<th>Member</th>
|
|
386
|
+
<th>Engine</th>
|
|
387
|
+
<th>Status</th>
|
|
388
|
+
<th>Focus</th>
|
|
389
|
+
</tr>
|
|
390
|
+
</thead>
|
|
391
|
+
<tbody>${activeRowsHtml}
|
|
392
|
+
</tbody>
|
|
393
|
+
</table>
|
|
394
|
+
</section>
|
|
395
|
+
<section>
|
|
396
|
+
<h2>Rest of the team</h2>
|
|
397
|
+
<div class="chip-grid">${restChipsHtml}</div>
|
|
398
|
+
</section>
|
|
399
|
+
<footer>generated ${escapeHtml(generatedAt)} · ${escapeHtml(workspace)}</footer>
|
|
400
|
+
</div>
|
|
401
|
+
</body>
|
|
402
|
+
</html>`;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function writeTeamBoardHtml(rosterRows, deps = {}) {
|
|
406
|
+
const workspace = deps.cwd || process.cwd();
|
|
407
|
+
const outPath = path.join(workspace, 'atris', 'team', 'team-board.html');
|
|
408
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
409
|
+
const html = renderTeamRosterHtml(rosterRows, {
|
|
410
|
+
workspace,
|
|
411
|
+
generatedAt: deps.generatedAt || new Date().toISOString(),
|
|
412
|
+
});
|
|
413
|
+
fs.writeFileSync(outPath, html, 'utf8');
|
|
414
|
+
return outPath;
|
|
117
415
|
}
|
|
118
416
|
|
|
119
417
|
// The pruning pass keeps the team lean like a real company: it flags members
|
|
@@ -187,11 +485,11 @@ function renderTeamPrune(report, days = DEFAULT_PRUNE_DAYS) {
|
|
|
187
485
|
|
|
188
486
|
function helpText() {
|
|
189
487
|
return [
|
|
190
|
-
'atris team -
|
|
488
|
+
'atris team - active members and the rest of the roster',
|
|
191
489
|
'atris team presence - show who is awake and what they are doing',
|
|
192
490
|
'atris team prune - flag members with no recent activity; deletes nothing',
|
|
193
491
|
'',
|
|
194
|
-
'usage: atris team [roster|presence] [--json]',
|
|
492
|
+
'usage: atris team [roster|presence] [--json] [--html]',
|
|
195
493
|
'usage: atris team prune [--days N] [--json]',
|
|
196
494
|
].join('\n');
|
|
197
495
|
}
|
|
@@ -223,16 +521,28 @@ function teamCommand(args = [], deps = {}) {
|
|
|
223
521
|
return 0;
|
|
224
522
|
}
|
|
225
523
|
const rosterArgs = args.filter((arg) => arg !== 'roster');
|
|
226
|
-
|
|
524
|
+
const rosterFlags = new Set(['--json', '--html']);
|
|
525
|
+
if (args[0] !== 'presence' && rosterArgs.every((arg) => rosterFlags.has(arg))) {
|
|
526
|
+
const html = rosterArgs.includes('--html');
|
|
527
|
+
const json = rosterArgs.includes('--json');
|
|
528
|
+
if (html && json) {
|
|
529
|
+
(deps.error || process.stderr.write.bind(process.stderr))('usage: atris team [--json] [--html] (not both)\n');
|
|
530
|
+
return 2;
|
|
531
|
+
}
|
|
227
532
|
const roster = deps.roster || collectTeamRoster(deps);
|
|
228
|
-
|
|
533
|
+
if (html) {
|
|
534
|
+
const outPath = writeTeamBoardHtml(roster, deps);
|
|
535
|
+
(deps.write || process.stdout.write.bind(process.stdout))(`${outPath}\n`);
|
|
536
|
+
return 0;
|
|
537
|
+
}
|
|
538
|
+
const output = json
|
|
229
539
|
? JSON.stringify(roster, null, 2)
|
|
230
|
-
: renderTeamRoster(roster);
|
|
540
|
+
: renderTeamRoster(roster, deps);
|
|
231
541
|
(deps.write || process.stdout.write.bind(process.stdout))(`${output}\n`);
|
|
232
542
|
return 0;
|
|
233
543
|
}
|
|
234
544
|
if (args[0] !== 'presence' || args.some((arg, index) => index > 0 && arg !== '--json')) {
|
|
235
|
-
(deps.error || process.stderr.write.bind(process.stderr))('usage: atris team [roster|presence|prune] [--json]\n');
|
|
545
|
+
(deps.error || process.stderr.write.bind(process.stderr))('usage: atris team [roster|presence|prune] [--json] [--html]\n');
|
|
236
546
|
return 2;
|
|
237
547
|
}
|
|
238
548
|
const presence = deps.presence || collectTeamPresence(deps);
|
|
@@ -243,4 +553,10 @@ function teamCommand(args = [], deps = {}) {
|
|
|
243
553
|
return 0;
|
|
244
554
|
}
|
|
245
555
|
|
|
246
|
-
module.exports = {
|
|
556
|
+
module.exports = {
|
|
557
|
+
collectTeamPrune,
|
|
558
|
+
collectTeamRoster,
|
|
559
|
+
renderTeamPrune,
|
|
560
|
+
renderTeamRoster,
|
|
561
|
+
teamCommand,
|
|
562
|
+
};
|
package/commands/terminal.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
const fs = require('fs');
|
|
26
26
|
const path = require('path');
|
|
27
|
-
const { loadCredentials } = require('../utils/auth');
|
|
27
|
+
const { loadCredentials, abortOnAuthFailure } = require('../utils/auth');
|
|
28
28
|
const { apiRequestJson } = require('../utils/api');
|
|
29
29
|
const { loadBusinesses, saveBusinesses } = require('./business');
|
|
30
30
|
|
|
@@ -34,15 +34,18 @@ function sleep(ms) {
|
|
|
34
34
|
|
|
35
35
|
async function ensureAwake(token, businessId, maxWaitSec = 90) {
|
|
36
36
|
const status = await apiRequestJson(`/business/${businessId}/ai-computer/status`, { method: 'GET', token });
|
|
37
|
+
abortOnAuthFailure(status);
|
|
37
38
|
if (status.ok && status.data && status.data.status === 'running' && status.data.endpoint) {
|
|
38
39
|
return true;
|
|
39
40
|
}
|
|
40
41
|
process.stdout.write(' Waking EC2 computer... ');
|
|
41
|
-
await apiRequestJson(`/business/${businessId}/ai-computer/wake`, { method: 'POST', token });
|
|
42
|
+
const wake = await apiRequestJson(`/business/${businessId}/ai-computer/wake`, { method: 'POST', token });
|
|
43
|
+
abortOnAuthFailure(wake, true);
|
|
42
44
|
const start = Date.now();
|
|
43
45
|
while (Date.now() - start < maxWaitSec * 1000) {
|
|
44
46
|
await sleep(3000);
|
|
45
47
|
const s = await apiRequestJson(`/business/${businessId}/ai-computer/status`, { method: 'GET', token });
|
|
48
|
+
abortOnAuthFailure(s, true);
|
|
46
49
|
if (s.ok && s.data && s.data.status === 'running' && s.data.endpoint) {
|
|
47
50
|
const elapsed = Math.floor((Date.now() - start) / 1000);
|
|
48
51
|
console.log(`awake (${elapsed}s)`);
|
package/commands/verify.js
CHANGED
|
@@ -84,6 +84,7 @@ function verifyWorkspace(cwd, atrisDir) {
|
|
|
84
84
|
console.log('✗ MAP.md — Issues found');
|
|
85
85
|
mapResult.issues.forEach(issue => console.log(` • ${issue}`));
|
|
86
86
|
}
|
|
87
|
+
(mapResult.notes || []).forEach(note => console.log(` ○ ${note}`));
|
|
87
88
|
console.log('');
|
|
88
89
|
|
|
89
90
|
// Test status
|
|
@@ -215,7 +216,7 @@ function verifyTask(cwd, atrisDir, taskId) {
|
|
|
215
216
|
*/
|
|
216
217
|
function verifyMap(cwd, atrisDir) {
|
|
217
218
|
const mapFile = path.join(atrisDir, 'MAP.md');
|
|
218
|
-
const result = { valid: false, issues: [], stats: null };
|
|
219
|
+
const result = { valid: false, issues: [], notes: [], stats: null };
|
|
219
220
|
|
|
220
221
|
if (!fs.existsSync(mapFile)) {
|
|
221
222
|
result.issues.push('MAP.md does not exist');
|
|
@@ -236,6 +237,22 @@ function verifyMap(cwd, atrisDir) {
|
|
|
236
237
|
return result;
|
|
237
238
|
}
|
|
238
239
|
|
|
240
|
+
// Let the repo's own map validator have the final say, when it ships one.
|
|
241
|
+
// A missing python3 is a skip, not a failure — the CLI has no python dependency.
|
|
242
|
+
const validator = path.join(cwd, 'scripts', 'validate_map.py');
|
|
243
|
+
if (fs.existsSync(validator)) {
|
|
244
|
+
const proc = spawnSync('python3', [validator], { cwd, encoding: 'utf8', env: process.env });
|
|
245
|
+
if (proc.error) {
|
|
246
|
+
const reason = proc.error.code === 'ENOENT' ? 'python3 not available' : proc.error.message;
|
|
247
|
+
result.notes.push(`Skipped scripts/validate_map.py — ${reason}`);
|
|
248
|
+
} else if ((proc.status ?? 0) !== 0) {
|
|
249
|
+
const detail = firstOutputLine(proc.stderr) || firstOutputLine(proc.stdout) || `exit ${proc.status}`;
|
|
250
|
+
result.issues.push(`scripts/validate_map.py failed: ${detail}`);
|
|
251
|
+
} else {
|
|
252
|
+
result.notes.push('scripts/validate_map.py passed');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
239
256
|
// Count refs
|
|
240
257
|
const fileRefs = (content.match(/`[^`]+\.(js|ts|py|go|rs|rb|java|md|json)`/g) || []).length;
|
|
241
258
|
const lineRefs = (content.match(/:\d+`?/g) || []).length;
|
|
@@ -250,6 +267,11 @@ function verifyMap(cwd, atrisDir) {
|
|
|
250
267
|
return result;
|
|
251
268
|
}
|
|
252
269
|
|
|
270
|
+
function firstOutputLine(text) {
|
|
271
|
+
if (!text) return '';
|
|
272
|
+
return String(text).split('\n').map(line => line.trim()).find(line => line.length > 0) || '';
|
|
273
|
+
}
|
|
274
|
+
|
|
253
275
|
/**
|
|
254
276
|
* Run project tests
|
|
255
277
|
*/
|
|
@@ -296,6 +318,61 @@ function runTests(cwd) {
|
|
|
296
318
|
return result;
|
|
297
319
|
}
|
|
298
320
|
|
|
321
|
+
// Backtick-quoted spans, plus bare tokens that carry at least one slash.
|
|
322
|
+
const MAP_PATH_PATTERN = /`([^`\n]+)`|((?:[A-Za-z0-9_.-]+\/)+[A-Za-z0-9_.-]*)/g;
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Normalize a path-looking token from MAP.md into a repo-relative path.
|
|
326
|
+
* Returns null for anything that is not usable as a path.
|
|
327
|
+
*/
|
|
328
|
+
function normalizeMapPath(raw) {
|
|
329
|
+
if (!raw) return null;
|
|
330
|
+
let value = String(raw).trim();
|
|
331
|
+
if (!value || /\s/.test(value) || value.includes('://')) return null;
|
|
332
|
+
value = value.replace(/^\.\//, '').replace(/^\/+/, '');
|
|
333
|
+
// Sentence punctuation trailing a path in prose, never a trailing slash.
|
|
334
|
+
value = value.replace(/[),.;:]+$/, '');
|
|
335
|
+
if (!value || value === '/' || value.split('/').includes('..')) return null;
|
|
336
|
+
return value;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Split MAP.md into the exact file paths it names and the directory prefixes it covers.
|
|
341
|
+
* A compact map documents areas, so `backend/routers/` stands in for every file beneath it.
|
|
342
|
+
*/
|
|
343
|
+
function mapCoverage(mapContent, isDirectory = () => false) {
|
|
344
|
+
const files = new Set();
|
|
345
|
+
const dirSet = new Set();
|
|
346
|
+
const seen = new Set();
|
|
347
|
+
const pattern = new RegExp(MAP_PATH_PATTERN.source, 'g');
|
|
348
|
+
let match;
|
|
349
|
+
|
|
350
|
+
while ((match = pattern.exec(mapContent)) !== null) {
|
|
351
|
+
const value = normalizeMapPath(match[1] || match[2]);
|
|
352
|
+
if (!value || seen.has(value)) continue;
|
|
353
|
+
seen.add(value);
|
|
354
|
+
|
|
355
|
+
if (value.endsWith('/')) {
|
|
356
|
+
dirSet.add(value);
|
|
357
|
+
} else {
|
|
358
|
+
files.add(value);
|
|
359
|
+
if (isDirectory(value)) dirSet.add(`${value}/`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return { files, dirs: [...dirSet] };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* A file is documented when the map names it outright or covers the area it lives in.
|
|
368
|
+
*/
|
|
369
|
+
function isPathCovered(file, coverage) {
|
|
370
|
+
const target = normalizeMapPath(file);
|
|
371
|
+
if (!target || !coverage) return false;
|
|
372
|
+
if (coverage.files.has(target)) return true;
|
|
373
|
+
return coverage.dirs.some((dir) => target.startsWith(dir) && target.length > dir.length);
|
|
374
|
+
}
|
|
375
|
+
|
|
299
376
|
/**
|
|
300
377
|
* Check if recent git changes are documented in MAP.md
|
|
301
378
|
*/
|
|
@@ -332,12 +409,25 @@ function checkDocsVsChanges(cwd, atrisDir) {
|
|
|
332
409
|
significantExtensions.some(ext => f.endsWith(ext))
|
|
333
410
|
);
|
|
334
411
|
|
|
412
|
+
if (significantChanges.length === 0) {
|
|
413
|
+
return result;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const isDirectory = (candidate) => {
|
|
417
|
+
try {
|
|
418
|
+
return fs.statSync(path.join(cwd, candidate)).isDirectory();
|
|
419
|
+
} catch {
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
const coverage = mapCoverage(mapContent, isDirectory);
|
|
424
|
+
|
|
335
425
|
for (const file of significantChanges) {
|
|
336
426
|
const basename = path.basename(file);
|
|
337
|
-
if (
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
}
|
|
427
|
+
if (mapContent.includes(basename) || mapContent.includes(file)) continue;
|
|
428
|
+
if (isPathCovered(file, coverage)) continue;
|
|
429
|
+
result.upToDate = false;
|
|
430
|
+
result.issues.push(`${file} changed and its area is not in MAP.md`);
|
|
341
431
|
}
|
|
342
432
|
|
|
343
433
|
// Limit reported issues
|
|
@@ -631,5 +721,8 @@ module.exports = {
|
|
|
631
721
|
verifyRubric,
|
|
632
722
|
verifyArtifact,
|
|
633
723
|
findTaskInContent,
|
|
634
|
-
escapeRegExp
|
|
724
|
+
escapeRegExp,
|
|
725
|
+
mapCoverage,
|
|
726
|
+
isPathCovered,
|
|
727
|
+
normalizeMapPath
|
|
635
728
|
};
|
package/commands/workflow.js
CHANGED
|
@@ -305,13 +305,20 @@ async function runAtris2Local(userInput, atris2Mode) {
|
|
|
305
305
|
};
|
|
306
306
|
|
|
307
307
|
if (businessSlug) {
|
|
308
|
-
const {
|
|
308
|
+
const { ensureValidCredentials } = require('../utils/auth');
|
|
309
|
+
const { apiRequestJson } = require('../utils/api');
|
|
309
310
|
const { resolveBusiness, ensureAwake } = require('./terminal');
|
|
310
|
-
const
|
|
311
|
-
if (
|
|
311
|
+
const ensured = await ensureValidCredentials(apiRequestJson);
|
|
312
|
+
if (ensured.error === 'not_logged_in' || !ensured.credentials?.token) {
|
|
312
313
|
console.error('Not logged in. Run: atris login');
|
|
313
314
|
process.exit(1);
|
|
314
315
|
}
|
|
316
|
+
if (ensured.error) {
|
|
317
|
+
console.error(`Authentication failed: ${ensured.detail || ensured.error}. Run: atris login`);
|
|
318
|
+
console.error('Check with: atris whoami');
|
|
319
|
+
process.exit(1);
|
|
320
|
+
}
|
|
321
|
+
const creds = ensured.credentials;
|
|
315
322
|
const biz = await resolveBusiness(creds.token, businessSlug);
|
|
316
323
|
if (!biz || !biz.workspaceId) {
|
|
317
324
|
console.error(`Business "${businessSlug}" not found or has no workspace.`);
|