atris 3.42.0 → 3.44.0

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 (45) 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 +37 -4
  9. package/commands/autoland.js +15 -1
  10. package/commands/caretaker.js +303 -0
  11. package/commands/clean.js +76 -0
  12. package/commands/engine-watch.js +212 -0
  13. package/commands/engine.js +99 -11
  14. package/commands/founder.js +304 -0
  15. package/commands/human-missions.js +844 -0
  16. package/commands/init.js +16 -7
  17. package/commands/lesson.js +178 -4
  18. package/commands/mission.js +124 -69
  19. package/commands/slop.js +34 -3
  20. package/commands/task.js +51 -4
  21. package/commands/team.js +329 -13
  22. package/commands/verify.js +99 -6
  23. package/commands/worktree.js +119 -4
  24. package/lib/auto-accept-certified.js +302 -0
  25. package/lib/cloud-mission.js +59 -2
  26. package/lib/conductor-artifacts.js +1 -1
  27. package/lib/dispatch-scout.js +383 -0
  28. package/lib/engine-ask.js +645 -0
  29. package/lib/engine-job-lifecycle.js +65 -0
  30. package/lib/engine-receipt-sweep.js +98 -0
  31. package/lib/engine-registry.js +2 -2
  32. package/lib/engine-validate.js +374 -0
  33. package/lib/fleet.js +459 -106
  34. package/lib/known-commands.js +2 -2
  35. package/lib/lesson-ledger.js +84 -0
  36. package/lib/member-alive.js +2 -2
  37. package/lib/policy-lessons.js +70 -0
  38. package/lib/receipt-evidence.js +56 -1
  39. package/lib/runner-command.js +1 -1
  40. package/lib/secret-gateway.js +588 -0
  41. package/lib/team-presence.js +13 -1
  42. package/lib/voice-gate.js +6 -0
  43. package/lib/wish-audit.js +5 -205
  44. package/lib/wish-delegate.js +5 -2
  45. package/package.json +6 -1
package/commands/task.js CHANGED
@@ -11,6 +11,7 @@ const os = require('os');
11
11
  const { hasFlag } = require('../lib/arg-parser');
12
12
  const { taskProofState } = require('../lib/task-proof');
13
13
  const {
14
+ candidatePolicyGate,
14
15
  evaluateAutoAccept,
15
16
  isAgentCertified,
16
17
  isAutoCertifyVerifyCommandAllowed,
@@ -443,6 +444,21 @@ function failTask(label, reason, detail, exitCode = 2) {
443
444
  process.exit(exitCode);
444
445
  }
445
446
 
447
+ function refuseCandidatePolicyGate(label, gate) {
448
+ if (jsonModeActive()) {
449
+ printJson({ ok: false, command: label, ...gate });
450
+ } else {
451
+ console.error(`${label}: ${gate.reason}: ${gate.message}`);
452
+ if (Array.isArray(gate.offenders)) {
453
+ gate.offenders.forEach((offender) => console.error(` ${offender}`));
454
+ }
455
+ if (Array.isArray(gate.lesson_ids) && gate.lesson_ids.length) {
456
+ console.error(` lessons: ${gate.lesson_ids.join(', ')}`);
457
+ }
458
+ }
459
+ process.exit(1);
460
+ }
461
+
446
462
  function proofFlagValue(args) {
447
463
  const proof = flag(args, '--proof');
448
464
  return typeof proof === 'string' ? proof.trim() : '';
@@ -5254,6 +5270,12 @@ function autoAcceptCertifiedSmallReviews(taskDb, db, projection) {
5254
5270
  results.push({ ...evaluation, action: 'queued', task_id: task?.id || item.id || null });
5255
5271
  continue;
5256
5272
  }
5273
+ const policyGate = candidatePolicyGate(task, { executeDetectors: true });
5274
+ if (!policyGate.ok) {
5275
+ results.push({ ...policyGate, action: 'queued', task_id: task?.id || item.id || null });
5276
+ continue;
5277
+ }
5278
+ evaluation.candidate_gate = policyGate.gate;
5257
5279
  const accepted = acceptReviewTask(taskDb, db, task.id, {
5258
5280
  actor: REVIEW_AUTO_ACCEPT_ACTOR,
5259
5281
  proof: evaluation.proof,
@@ -9436,6 +9458,16 @@ function cmdReady(args) {
9436
9458
  if (missionXpIssue) {
9437
9459
  failTask('atris task ready', MISSION_XP_END_TO_END_REASON, missionXpIssue);
9438
9460
  }
9461
+ const readyPolicyTask = {
9462
+ ...beforeTask,
9463
+ workspace_root: process.cwd(),
9464
+ metadata: {
9465
+ ...(beforeTask && beforeTask.metadata || {}),
9466
+ ...(resultFields.files ? { changed_files: resultFields.files } : {}),
9467
+ },
9468
+ };
9469
+ const readyPolicyGate = candidatePolicyGate(readyPolicyTask, { executeDetectors: true });
9470
+ if (!readyPolicyGate.ok) refuseCandidatePolicyGate('atris task ready', readyPolicyGate);
9439
9471
  const resultTrace = buildAutomaticResultTrace(taskDb, db, taskId, {
9440
9472
  actor,
9441
9473
  proof: String(proof),
@@ -9497,12 +9529,12 @@ function cmdReady(args) {
9497
9529
  handoff.codex_prompt = reviewChat.codex_prompt;
9498
9530
  handoff.verification_focus = reviewChat.verification_focus;
9499
9531
  }
9500
- // Mined policy lessons (atris lesson mine) coach the proof at submission
9501
- // time: evidence patterns that historically certify get suggested before
9502
- // the task stalls in the review lane. Advisory only — never blocks ready.
9532
+ // Mined proof lessons without runnable detectors remain coaching. Promoted,
9533
+ // path-scoped detector lessons already passed the hard gate above.
9503
9534
  const { readPolicyLessons, policyHintsForProof } = require('../lib/policy-lessons');
9504
9535
  const policyHints = policyHintsForProof(String(proof), readPolicyLessons(taskDb.workspaceRoot()), taskDb.workspaceRoot());
9505
9536
  if (policyHints.length) handoff.policy_hints = policyHints;
9537
+ if (readyPolicyGate.gate.advisories.length) handoff.policy_gate = readyPolicyGate.gate;
9506
9538
  if (wantsJson(args)) {
9507
9539
  printJson({
9508
9540
  ok: true,
@@ -9998,6 +10030,11 @@ function cmdCertifyVerified(args, options = {}) {
9998
10030
  results.push({ ref, action: 'verify_failed', reason: run.reason, verify });
9999
10031
  continue;
10000
10032
  }
10033
+ const policyGate = candidatePolicyGate(task, { verifyCache, executeDetectors: true });
10034
+ if (!policyGate.ok) {
10035
+ results.push({ ...policyGate, ref, action: 'skipped' });
10036
+ continue;
10037
+ }
10001
10038
  const builderProof = String(review.proof || metadata.latest_agent_proof || '').slice(0, 200);
10002
10039
  const readied = taskDb.readyTask(db, {
10003
10040
  id: task.id,
@@ -10036,7 +10073,12 @@ function cmdCertifyVerified(args, options = {}) {
10036
10073
  console.log('certify-verified: no review rows to consider.');
10037
10074
  } else {
10038
10075
  for (const r of results) {
10039
- console.log(`${r.action.padEnd(14)} ${r.ref}${r.verify ? ` \`${r.verify}\`` : ''}${r.reason ? ` (${r.reason})` : ''}`);
10076
+ const gateDetail = r.reason === 'slop_gate' && Array.isArray(r.offenders)
10077
+ ? ` [${r.offenders.join('; ')}]`
10078
+ : (r.reason === 'lesson_gate' && Array.isArray(r.lesson_ids)
10079
+ ? ` [lessons: ${r.lesson_ids.join(', ')}]`
10080
+ : '');
10081
+ console.log(`${r.action.padEnd(14)} ${r.ref}${r.verify ? ` \`${r.verify}\`` : ''}${r.reason ? ` (${r.reason})` : ''}${gateDetail}`);
10040
10082
  }
10041
10083
  console.log(`certified ${certified}; humans keep denied lanes and rows without a runnable check.`);
10042
10084
  }
@@ -10348,6 +10390,8 @@ function evaluateSweepAutoAccept(task, root) {
10348
10390
  if (denied) return { eligible: false, ref, reason: denied };
10349
10391
  const proof = autoAcceptSweepLatestProof(task);
10350
10392
  if (!proof) return { eligible: false, ref, reason: 'no_proof' };
10393
+ const policyGate = candidatePolicyGate(task, { executeDetectors: true });
10394
+ if (!policyGate.ok) return { ...policyGate, ref };
10351
10395
 
10352
10396
  // 1. An explicit, stored verifier (`atris task ready --verify`, or a prior
10353
10397
  // certify-verified stamp) is the strongest signal: re-run it live, right
@@ -10367,6 +10411,7 @@ function evaluateSweepAutoAccept(task, root) {
10367
10411
  policy: 'sweep_auto_accept_verified_command',
10368
10412
  proof,
10369
10413
  verify: storedVerify,
10414
+ candidate_gate: policyGate.gate,
10370
10415
  proved_by: [`${storedVerify} exited 0`],
10371
10416
  happened: autoAcceptSweepHappened(task),
10372
10417
  };
@@ -10382,6 +10427,7 @@ function evaluateSweepAutoAccept(task, root) {
10382
10427
  policy: 'sweep_auto_accept_verified',
10383
10428
  proof,
10384
10429
  evidence: verifier.evidence,
10430
+ candidate_gate: policyGate.gate,
10385
10431
  proved_by: verifier.proved_by,
10386
10432
  happened: autoAcceptSweepHappened(task),
10387
10433
  };
@@ -10405,6 +10451,7 @@ function evaluateSweepAutoAccept(task, root) {
10405
10451
  policy: 'sweep_auto_accept_verified_derived',
10406
10452
  proof,
10407
10453
  verify: derived,
10454
+ candidate_gate: policyGate.gate,
10408
10455
  proved_by: [`${derived} exited 0`],
10409
10456
  happened: autoAcceptSweepHappened(task),
10410
10457
  };
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
+ };
@@ -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
  };