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.
- 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 +37 -4
- package/commands/autoland.js +15 -1
- package/commands/caretaker.js +303 -0
- package/commands/clean.js +76 -0
- 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/init.js +16 -7
- package/commands/lesson.js +178 -4
- package/commands/mission.js +124 -69
- package/commands/slop.js +34 -3
- package/commands/task.js +51 -4
- package/commands/team.js +329 -13
- package/commands/verify.js +99 -6
- 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 +383 -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 +374 -0
- package/lib/fleet.js +459 -106
- package/lib/known-commands.js +2 -2
- package/lib/lesson-ledger.js +84 -0
- 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/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
|
|
9501
|
-
//
|
|
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
|
-
|
|
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, '&')
|
|
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/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
|
};
|