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.
Files changed (52) hide show
  1. package/atris/skills/design/SKILL.md +7 -1
  2. package/atris/skills/engines/SKILL.md +44 -13
  3. package/atris/team/customer-lead/MEMBER.md +45 -0
  4. package/atris/team/customer-lead/SOUL.md +33 -0
  5. package/atris/team/customer-lead/START_HERE.md +7 -0
  6. package/atris/team/customer-lead/skills/customer-commitments/SKILL.md +45 -0
  7. package/atris/team/improver/MEMBER.md +33 -0
  8. package/bin/atris.js +36 -3
  9. package/commands/aeo.js +5 -2
  10. package/commands/align.js +5 -2
  11. package/commands/autoland.js +15 -1
  12. package/commands/caretaker.js +303 -0
  13. package/commands/clean.js +76 -0
  14. package/commands/computer.js +5 -2
  15. package/commands/engine-watch.js +212 -0
  16. package/commands/engine.js +99 -11
  17. package/commands/founder.js +304 -0
  18. package/commands/human-missions.js +844 -0
  19. package/commands/improve.js +29 -6
  20. package/commands/init.js +16 -7
  21. package/commands/mission.js +124 -69
  22. package/commands/pull.js +5 -2
  23. package/commands/push.js +5 -2
  24. package/commands/slop.js +34 -3
  25. package/commands/task.js +51 -4
  26. package/commands/team.js +329 -13
  27. package/commands/terminal.js +5 -2
  28. package/commands/verify.js +99 -6
  29. package/commands/workflow.js +10 -3
  30. package/commands/worktree.js +119 -4
  31. package/lib/auto-accept-certified.js +302 -0
  32. package/lib/cloud-mission.js +59 -2
  33. package/lib/conductor-artifacts.js +1 -1
  34. package/lib/dispatch-scout.js +386 -0
  35. package/lib/engine-ask.js +645 -0
  36. package/lib/engine-job-lifecycle.js +65 -0
  37. package/lib/engine-receipt-sweep.js +98 -0
  38. package/lib/engine-registry.js +2 -2
  39. package/lib/engine-validate.js +382 -0
  40. package/lib/fleet.js +459 -106
  41. package/lib/known-commands.js +2 -2
  42. package/lib/member-alive.js +2 -2
  43. package/lib/policy-lessons.js +70 -0
  44. package/lib/receipt-evidence.js +56 -1
  45. package/lib/runner-command.js +1 -1
  46. package/lib/secret-gateway.js +588 -0
  47. package/lib/team-presence.js +13 -1
  48. package/lib/voice-gate.js +6 -0
  49. package/lib/wish-audit.js +5 -205
  50. package/lib/wish-delegate.js +5 -2
  51. package/package.json +6 -1
  52. 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, '&amp;')
169
+ .replace(/</g, '&lt;')
170
+ .replace(/>/g, '&gt;')
171
+ .replace(/"/g, '&quot;');
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
- return { name, role: plainRole(member), engine: engineByOwner.get(name) || '' };
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 renderTeamRoster(roster) {
111
- if (!roster.length) {
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
- return roster
115
- .map((entry) => `${entry.name} - ${entry.role}${entry.engine ? `, on ${entry.engine}` : ''}.`)
116
- .join('\n');
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 - one team view: every member, their role, and any engine running their work',
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
- if (args[0] !== 'presence' && rosterArgs.every((arg) => arg === '--json')) {
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
- const output = rosterArgs.includes('--json')
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 = { collectMissions, collectTasks, collectTeamPresence, collectTeamPrune, collectTeamRoster, renderTeamPrune, renderTeamRoster, teamCommand };
556
+ module.exports = {
557
+ collectTeamPrune,
558
+ collectTeamRoster,
559
+ renderTeamPrune,
560
+ renderTeamRoster,
561
+ teamCommand,
562
+ };
@@ -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)`);
@@ -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 (!mapContent.includes(basename) && !mapContent.includes(file)) {
338
- result.upToDate = false;
339
- result.issues.push(`${file} changed but not in MAP.md`);
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
  };
@@ -305,13 +305,20 @@ async function runAtris2Local(userInput, atris2Mode) {
305
305
  };
306
306
 
307
307
  if (businessSlug) {
308
- const { loadCredentials } = require('../utils/auth');
308
+ const { ensureValidCredentials } = require('../utils/auth');
309
+ const { apiRequestJson } = require('../utils/api');
309
310
  const { resolveBusiness, ensureAwake } = require('./terminal');
310
- const creds = loadCredentials();
311
- if (!creds || !creds.token) {
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.`);