atris 3.69.0 → 3.71.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 (79) hide show
  1. package/README.md +40 -1
  2. package/atris/skills/README.md +1 -0
  3. package/atris/skills/campaigns/SKILL.md +81 -0
  4. package/atris/skills/meta/SKILL.md +52 -8
  5. package/ax +226 -42
  6. package/bin/atris.js +26 -6
  7. package/commands/autoland.js +176 -7
  8. package/commands/autopilot.js +71 -11
  9. package/commands/campaign.js +759 -0
  10. package/commands/compile.js +19 -8
  11. package/commands/doctor.js +58 -0
  12. package/commands/dream.js +18 -3
  13. package/commands/engine.js +147 -5
  14. package/commands/improve.js +119 -9
  15. package/commands/init.js +18 -1
  16. package/commands/interview.js +15 -3
  17. package/commands/land.js +22 -6
  18. package/commands/link.js +1 -0
  19. package/commands/member.js +433 -99
  20. package/commands/mission.js +332 -41
  21. package/commands/report.js +42 -8
  22. package/commands/run.js +9 -2
  23. package/commands/scout.js +9 -1
  24. package/commands/task.js +132 -16
  25. package/commands/team.js +212 -4
  26. package/commands/unknowns.js +15 -1
  27. package/commands/verify.js +46 -0
  28. package/commands/worktree.js +148 -18
  29. package/commands/write.js +12 -1
  30. package/commands/youtube.js +17 -2
  31. package/lib/accept-verify-gate.js +1 -0
  32. package/lib/auto-accept-certified.js +1 -0
  33. package/lib/autoland.js +13 -4
  34. package/lib/campaign-cadence.js +131 -0
  35. package/lib/campaign-core.js +330 -0
  36. package/lib/campaign-holdout.js +65 -0
  37. package/lib/campaign-loop.js +242 -0
  38. package/lib/campaign-missions.js +237 -0
  39. package/lib/campaign-new.js +251 -0
  40. package/lib/campaign-protect.js +75 -0
  41. package/lib/campaign-reading.js +147 -0
  42. package/lib/campaign-schema.js +196 -0
  43. package/lib/campaign-store.js +380 -0
  44. package/lib/campaign-sync.js +302 -0
  45. package/lib/campaign-trace.js +300 -0
  46. package/lib/conductor-artifacts.js +15 -1
  47. package/lib/daily-log.js +11 -1
  48. package/lib/dispatch-scout.js +18 -0
  49. package/lib/engine-ask.js +35 -4
  50. package/lib/engine-registry.js +2 -0
  51. package/lib/engine-resource.js +518 -0
  52. package/lib/engine-validate.js +189 -9
  53. package/lib/fleet.js +125 -16
  54. package/lib/init-commit.js +80 -0
  55. package/lib/intents.js +8 -0
  56. package/lib/known-commands.js +1 -1
  57. package/lib/land-green.js +58 -7
  58. package/lib/landing-proof.js +352 -0
  59. package/lib/member-autoland.js +479 -10
  60. package/lib/member-board.js +86 -10
  61. package/lib/member-map-fold.js +126 -0
  62. package/lib/member-map-inbox.js +80 -0
  63. package/lib/member-progress.js +48 -1
  64. package/lib/member-report.js +433 -0
  65. package/lib/member-scaffold.js +1 -2
  66. package/lib/member-self-next-job.js +97 -0
  67. package/lib/member-switches.js +7 -0
  68. package/lib/member-task-pick.js +41 -14
  69. package/lib/member-up.js +249 -16
  70. package/lib/roster-prep.js +25 -2
  71. package/lib/runner-command.js +5 -0
  72. package/lib/task-db.js +63 -11
  73. package/lib/task-explanation.js +7 -0
  74. package/lib/task-list-keeper.js +8 -1
  75. package/lib/task-receipt.js +6 -1
  76. package/mcp/atris-mcp/index.mjs +212 -10
  77. package/package.json +2 -1
  78. package/scripts/det/engine-reading.js +37 -0
  79. package/scripts/det/ytnotes +29 -5
@@ -0,0 +1,433 @@
1
+ 'use strict';
2
+
3
+ // `atris member report [--since <time>] [--json]`: a short per-member summary
4
+ // of what the team did. Four groups, newest first, one line per piece of work:
5
+ // merged diary "Task completed" with action merged, plus the PR link, and
6
+ // pull requests GitHub merged from a member's branch (codex/<member>-...)
7
+ // finished diary mission ticks whose check passed, accepted tasks, completed missions
8
+ // turned down tasks a person sent back (task db) and tasks set aside (diary), with the reason
9
+ // waiting tasks sitting in review for a person right now (task db)
10
+ // Diary headings are `## HH:MM · title` in local time and the file name holds
11
+ // the date, so every time here is local; nothing prints in UTC.
12
+ // Merged and finished lines also say how long the job took (first pickup to
13
+ // finish) and how many tries it needed (every run, failed or timed out too).
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { spawnSync } = require('child_process');
18
+
19
+ const DEFAULT_SINCE = '24h';
20
+ const PER_GROUP = 3;
21
+ // A job picked up before the window still gets its real start time.
22
+ const HISTORY_DAYS = 7;
23
+ const LOG_NAME_RE = /^(\d{4})-(\d{2})-(\d{2})\.md$/;
24
+ const HEADING_RE = /^##\s+(\d{1,2}):(\d{2})\s*(?:·|-|:)?\s*(.*)$/;
25
+ const FIELD_RE = /^-\s+([a-z_][a-z0-9_ ]*):\s*(.*)$/i;
26
+ const URL_RE = /https?:\/\/\S+/;
27
+ const GROUPS = [
28
+ ['merged', 'merged'],
29
+ ['finished', 'finished'],
30
+ ['turned_down', 'turned down'],
31
+ ['waiting', 'not merged yet'],
32
+ ];
33
+
34
+ function usage() {
35
+ return [
36
+ 'usage:',
37
+ ' atris member report [--since <time>] [--json]',
38
+ '',
39
+ 'a short summary of what each member got merged, finished, had turned down,',
40
+ 'and what is done but not merged yet.',
41
+ '',
42
+ 'options:',
43
+ ' --since <time> 2h, 30m, 3d, today, yesterday, a date, or an iso time (default: 24h)',
44
+ ' --json print the report as json',
45
+ ].join('\n');
46
+ }
47
+
48
+ function localMidnight(date) {
49
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate());
50
+ }
51
+
52
+ // Returns a Date, or null when the text is not a time we understand.
53
+ function parseSince(text, now = new Date()) {
54
+ const raw = String(text || '').trim().toLowerCase();
55
+ if (!raw) return null;
56
+ if (raw === 'today') return localMidnight(now);
57
+ if (raw === 'yesterday') {
58
+ const day = localMidnight(now);
59
+ day.setDate(day.getDate() - 1);
60
+ return day;
61
+ }
62
+ const relative = raw.match(/^(\d+)\s*(m|min|h|hr|d|day|days)$/);
63
+ if (relative) {
64
+ const unit = relative[2][0];
65
+ const ms = Number(relative[1]) * (unit === 'm' ? 60e3 : unit === 'h' ? 3600e3 : 86400e3);
66
+ return new Date(now.getTime() - ms);
67
+ }
68
+ const dateOnly = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/);
69
+ if (dateOnly) return new Date(Number(dateOnly[1]), Number(dateOnly[2]) - 1, Number(dateOnly[3]));
70
+ const ms = Date.parse(String(text).trim());
71
+ return Number.isFinite(ms) ? new Date(ms) : null;
72
+ }
73
+
74
+ function parseReportArgs(args = []) {
75
+ let since = DEFAULT_SINCE;
76
+ let json = false;
77
+ let help = false;
78
+ for (let index = 0; index < args.length; index += 1) {
79
+ const arg = String(args[index]);
80
+ if (arg === '--help' || arg === '-h') { help = true; continue; }
81
+ if (arg === '--json') { json = true; continue; }
82
+ if (arg === '--since') {
83
+ if (index + 1 >= args.length) throw new Error('--since needs a time, like 2h or yesterday');
84
+ since = String(args[index + 1]);
85
+ index += 1;
86
+ continue;
87
+ }
88
+ if (arg.startsWith('--since=')) { since = arg.slice('--since='.length); continue; }
89
+ throw new Error(`unknown option ${arg}\n${usage()}`);
90
+ }
91
+ return { help, since, json };
92
+ }
93
+
94
+ function parseDiary(text, year, month, day) {
95
+ const entries = [];
96
+ let current = null;
97
+ for (const line of String(text || '').split('\n')) {
98
+ const heading = line.match(HEADING_RE);
99
+ if (heading) {
100
+ const at = new Date(year, month - 1, day, Number(heading[1]), Number(heading[2]));
101
+ current = { at, title: heading[3].trim(), fields: {} };
102
+ entries.push(current);
103
+ continue;
104
+ }
105
+ const field = current && line.match(FIELD_RE);
106
+ if (field) {
107
+ const key = field[1].trim().toLowerCase();
108
+ if (!(key in current.fields)) current.fields[key] = field[2].trim();
109
+ }
110
+ }
111
+ return entries;
112
+ }
113
+
114
+ function memberNames(root) {
115
+ const teamDir = path.join(root, 'atris', 'team');
116
+ if (!fs.existsSync(teamDir)) return null;
117
+ return fs.readdirSync(teamDir, { withFileTypes: true })
118
+ .filter((entry) => entry.isDirectory() && !/^[._]/.test(entry.name))
119
+ .map((entry) => entry.name)
120
+ .sort();
121
+ }
122
+
123
+ function diaryEntriesSince(root, member, since) {
124
+ const logsDir = path.join(root, 'atris', 'team', member, 'logs');
125
+ if (!fs.existsSync(logsDir)) return [];
126
+ const sinceDay = localMidnight(since).getTime();
127
+ const entries = [];
128
+ for (const name of fs.readdirSync(logsDir).sort()) {
129
+ const match = name.match(LOG_NAME_RE);
130
+ if (!match) continue;
131
+ const [year, month, day] = match.slice(1).map(Number);
132
+ if (new Date(year, month - 1, day).getTime() < sinceDay) continue;
133
+ const text = fs.readFileSync(path.join(logsDir, name), 'utf8');
134
+ for (const entry of parseDiary(text, year, month, day)) {
135
+ if (entry.at.getTime() >= since.getTime()) entries.push(entry);
136
+ }
137
+ }
138
+ return entries;
139
+ }
140
+
141
+ // One diary entry becomes at most one summary item, or null when it is
142
+ // bookkeeping (claimed, started, notes, ticks that did not pass their check).
143
+ function classifyDiaryEntry(entry) {
144
+ const f = entry.fields;
145
+ const kind = entry.title.replace(/\s+\d+$/, '');
146
+ if (kind === 'Task completed' && f.action === 'merged') {
147
+ const link = (String(f.proof || '').match(URL_RE) || [''])[0].replace(/[).,]+$/, '');
148
+ return { group: 'merged', title: f.title, link: link || null };
149
+ }
150
+ if (kind === 'Task completed' || kind === 'Task accepted') return { group: 'finished', title: f.title };
151
+ if (kind === 'Mission completed') return { group: 'finished', title: f.mission || f.title };
152
+ if (kind === 'Mission run tick' && f.verifier === 'passed') {
153
+ return { group: 'finished', title: f.mission, detail: f.summary };
154
+ }
155
+ if (kind === 'Task archived' || kind === 'Task failed') {
156
+ return { group: 'turned_down', title: f.title, reason: f.proof || f.reason || null };
157
+ }
158
+ return null;
159
+ }
160
+
161
+ // Rows from the task db for this workspace: who sent work back and what is
162
+ // waiting on a person. Tasks with no team member behind them are skipped.
163
+ function taskItems(tasks, sinceMs, members) {
164
+ const known = new Set(members);
165
+ const items = [];
166
+ for (const task of tasks || []) {
167
+ const meta = task && task.metadata && typeof task.metadata === 'object' ? task.metadata : {};
168
+ const member = [meta.built_by, meta.assigned_to, task.claimed_by].find((name) => name && known.has(name));
169
+ if (!member) continue;
170
+ if (task.status !== 'review') continue;
171
+ if (meta.approval_status === 'revise') {
172
+ const at = Date.parse(meta.human_revision_at || '');
173
+ if (Number.isFinite(at) && at >= sinceMs) {
174
+ items.push({ member, at, group: 'turned_down', title: task.title, reason: meta.human_revision_note || null });
175
+ }
176
+ continue;
177
+ }
178
+ items.push({ member, at: Number(task.updated_at) || 0, group: 'waiting', title: task.title });
179
+ }
180
+ return items;
181
+ }
182
+
183
+ function jobKey(text) {
184
+ return String(text || '').trim().toLowerCase();
185
+ }
186
+
187
+ // Walks one member's diary oldest first and returns, for each entry that
188
+ // finishes a job, { took_minutes, tries }. A job opens at its first pickup and
189
+ // stays open through failed runs and re-pickups; a pickup after a passing run
190
+ // opens a new job. Finishes with no pickup in the diary get nothing.
191
+ function jobTimings(entries) {
192
+ const jobs = new Map();
193
+ const stamps = new Map();
194
+ for (const entry of [...entries].sort((a, b) => a.at - b.at)) {
195
+ const f = entry.fields;
196
+ const kind = entry.title.replace(/\s+\d+$/, '');
197
+ if (kind === 'Task claimed' || kind === 'Mission started') {
198
+ const key = jobKey(kind === 'Task claimed' ? f.title : f.mission);
199
+ const job = jobs.get(key);
200
+ if (key && (!job || job.done)) jobs.set(key, { startAt: entry.at.getTime(), tries: 0, done: false });
201
+ continue;
202
+ }
203
+ const finishKey = kind === 'Mission run tick' ? jobKey(f.mission)
204
+ : kind === 'Mission completed' ? jobKey(f.mission || f.title)
205
+ : kind === 'Task completed' || kind === 'Task accepted' ? jobKey(f.title) : null;
206
+ const job = finishKey && jobs.get(finishKey);
207
+ if (!job) continue;
208
+ if (kind === 'Mission run tick') {
209
+ if (job.done) continue;
210
+ job.tries += 1;
211
+ if (f.verifier !== 'passed') continue;
212
+ job.done = true;
213
+ }
214
+ const stamp = { took_minutes: Math.round((entry.at.getTime() - job.startAt) / 60e3) };
215
+ if (job.tries) stamp.tries = job.tries;
216
+ stamps.set(entry, stamp);
217
+ }
218
+ return stamps;
219
+ }
220
+
221
+ // Builder branches are `<engine>/<member>-<slug>`; the longest member name that
222
+ // fits wins, so `codex-executor-x` is codex-executor, not a member named codex.
223
+ function memberFromBranch(branch, members) {
224
+ const tail = String(branch || '').split('/').slice(1).join('/');
225
+ if (!tail) return null;
226
+ let best = null;
227
+ for (const name of members) {
228
+ if ((tail === name || tail.startsWith(`${name}-`)) && (!best || name.length > best.length)) best = name;
229
+ }
230
+ return best;
231
+ }
232
+
233
+ // Pull requests GitHub merged since the cutoff, credited to the member whose
234
+ // branch they came from. A merge nobody wrote down still counts.
235
+ function githubMergeItems(prs, sinceMs, members) {
236
+ const items = [];
237
+ for (const pr of prs || []) {
238
+ const at = Date.parse(pr && pr.mergedAt || '');
239
+ if (!Number.isFinite(at) || at < sinceMs) continue;
240
+ const member = memberFromBranch(pr.headRefName, members);
241
+ if (!member) continue;
242
+ items.push({ member, at, group: 'merged', title: pr.title, link: pr.url || null });
243
+ }
244
+ return items;
245
+ }
246
+
247
+ function sameTitle(a, b) {
248
+ return String(a || '').trim().toLowerCase() === String(b || '').trim().toLowerCase();
249
+ }
250
+
251
+ function summarize(items) {
252
+ const groups = { merged: [], finished: [], turned_down: [], waiting: [] };
253
+ const sorted = [...items].sort((a, b) => b.at - a.at);
254
+ for (const item of sorted) {
255
+ if (!item.title) continue;
256
+ // Newest word on a piece of work wins, and merged beats finished. A pull
257
+ // request a diary already logged shares its link, so it counts once.
258
+ const seenBefore = (seen) => sameTitle(seen.title, item.title) || (item.link && seen.link === item.link);
259
+ if (Object.values(groups).some((list) => list.some(seenBefore))) continue;
260
+ const { member, at, group, ...rest } = item;
261
+ groups[group].push(rest);
262
+ }
263
+ return groups;
264
+ }
265
+
266
+ function memberReport(root, { since = DEFAULT_SINCE, now = new Date(), tasks = [], merges = [] } = {}) {
267
+ const sinceAt = parseSince(since, now);
268
+ if (!sinceAt) throw new Error(`--since "${since}" is not a time; try 2h, yesterday, or 2026-10-14`);
269
+ const names = memberNames(root);
270
+ if (!names) throw new Error('no atris/team folder here; run this inside an atris workspace');
271
+ const fromTasks = taskItems(tasks, sinceAt.getTime(), names)
272
+ .concat(githubMergeItems(merges, sinceAt.getTime(), names));
273
+ const historyAt = new Date(sinceAt.getTime() - HISTORY_DAYS * 86400e3);
274
+ const members = names
275
+ .map((member) => {
276
+ const history = diaryEntriesSince(root, member, historyAt);
277
+ const timings = jobTimings(history);
278
+ const items = history
279
+ .filter((entry) => entry.at.getTime() >= sinceAt.getTime())
280
+ .map((entry) => {
281
+ const item = classifyDiaryEntry(entry);
282
+ if (!item) return null;
283
+ const timing = item.group === 'merged' || item.group === 'finished' ? timings.get(entry) : null;
284
+ return { member, at: entry.at.getTime(), ...item, ...timing };
285
+ })
286
+ .filter(Boolean)
287
+ .concat(fromTasks.filter((item) => item.member === member));
288
+ const groups = summarize(items);
289
+ const total = Object.values(groups).reduce((sum, list) => sum + list.length, 0);
290
+ return { member, total, ...groups };
291
+ })
292
+ .filter((row) => row.total > 0)
293
+ .sort((a, b) => b.total - a.total || a.member.localeCompare(b.member));
294
+ const totals = {};
295
+ for (const [key] of GROUPS) totals[key] = members.reduce((sum, row) => sum + row[key].length, 0);
296
+ return { ok: true, action: 'report', since: localStamp(sinceAt, now), totals, members };
297
+ }
298
+
299
+ function pad(n) {
300
+ return String(n).padStart(2, '0');
301
+ }
302
+
303
+ // "14:05" today, "yesterday 14:05", or "10-12 14:05"; always the local clock.
304
+ function localStamp(at, now) {
305
+ const time = `${pad(at.getHours())}:${pad(at.getMinutes())}`;
306
+ const days = Math.round((localMidnight(now) - localMidnight(at)) / 86400e3);
307
+ if (days === 0) return time;
308
+ if (days === 1) return `yesterday ${time}`;
309
+ return `${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${time}`;
310
+ }
311
+
312
+ function clip(text, max) {
313
+ const flat = String(text || '').replace(/\s+/g, ' ').trim();
314
+ return flat.length > max ? `${flat.slice(0, max - 3)}...` : flat;
315
+ }
316
+
317
+ // The first sentence of a tick summary says what changed in plain words; the
318
+ // mission text is the job description, so it is only the fallback.
319
+ function firstSentence(text) {
320
+ const flat = String(text || '').replace(/\s+/g, ' ').trim();
321
+ const match = flat.match(/^.*?[.!?](?=\s|$)/);
322
+ return match ? match[0] : flat;
323
+ }
324
+
325
+ // "took 3h 30m, 2 tries"; empty when the diary never showed the pickup.
326
+ function timingText(item) {
327
+ if (!Number.isFinite(item.took_minutes)) return '';
328
+ const minutes = item.took_minutes;
329
+ const days = Math.floor(minutes / 1440);
330
+ const hours = Math.floor((minutes % 1440) / 60);
331
+ const mins = minutes % 60;
332
+ const took = minutes < 1 ? 'under a minute'
333
+ : days ? `${days}d${hours ? ` ${hours}h` : ''}`
334
+ : hours ? `${hours}h${mins ? ` ${mins}m` : ''}` : `${mins}m`;
335
+ const tries = item.tries ? `, ${item.tries} ${item.tries === 1 ? 'try' : 'tries'}` : '';
336
+ return ` took ${took}${tries}`;
337
+ }
338
+
339
+ function itemLine(key, item) {
340
+ if (key === 'merged' || key === 'finished') return baseLine(key, item) + timingText(item);
341
+ return baseLine(key, item);
342
+ }
343
+
344
+ function baseLine(key, item) {
345
+ if (key === 'finished' && item.detail) return clip(firstSentence(item.detail), 90);
346
+ if (key === 'merged') return item.link ? `${clip(item.title, 70)} ${item.link}` : clip(item.title, 90);
347
+ if (key === 'turned_down') return item.reason ? `${clip(item.title, 50)}: ${clip(item.reason, 70)}` : clip(item.title, 90);
348
+ return clip(item.title, 90);
349
+ }
350
+
351
+ function formatReport(report) {
352
+ const lines = ['', `what the team did since ${report.since}`];
353
+ if (!report.members.length) {
354
+ lines.push('', ' nothing merged, finished, turned down, or done since then.', '');
355
+ return lines.join('\n');
356
+ }
357
+ lines.push(GROUPS.map(([key, label]) => `${report.totals[key]} ${label}`).join(', '));
358
+ for (const row of report.members) {
359
+ lines.push('', row.member);
360
+ for (const [key, label] of GROUPS) {
361
+ const list = row[key];
362
+ list.slice(0, PER_GROUP).forEach((item, index) => {
363
+ lines.push(` ${(index === 0 ? label : '').padEnd(15)}${itemLine(key, item)}`);
364
+ });
365
+ if (list.length > PER_GROUP) lines.push(` ${''.padEnd(15)}and ${list.length - PER_GROUP} more`);
366
+ }
367
+ }
368
+ lines.push('');
369
+ return lines.join('\n');
370
+ }
371
+
372
+ function loadTasks(root) {
373
+ try {
374
+ const taskDb = require('./task-db');
375
+ return taskDb.listTasks(taskDb.open(), { workspaceRoot: taskDb.workspaceRoot(root) });
376
+ } catch {
377
+ return [];
378
+ }
379
+ }
380
+
381
+ // Merged pull requests from GitHub for this checkout. No gh, no remote, or no
382
+ // login means an empty list: the report still prints what the diaries say.
383
+ function loadGithubMerges(root, since) {
384
+ const day = new Date(since.getTime() - 86400e3).toISOString().slice(0, 10);
385
+ const result = spawnSync('gh', [
386
+ 'pr', 'list', '--state', 'merged', '--search', `merged:>=${day}`, '--limit', '200',
387
+ '--json', 'title,headRefName,mergedAt,url',
388
+ ], { cwd: root, encoding: 'utf8', timeout: 20000 });
389
+ if (result.status !== 0) return [];
390
+ try {
391
+ const prs = JSON.parse(result.stdout);
392
+ return Array.isArray(prs) ? prs : [];
393
+ } catch {
394
+ return [];
395
+ }
396
+ }
397
+
398
+ function memberReportCommand(...args) {
399
+ let parsed;
400
+ try {
401
+ parsed = parseReportArgs(args);
402
+ } catch (error) {
403
+ console.error(error.message);
404
+ process.exitCode = 1;
405
+ return null;
406
+ }
407
+ if (parsed.help) {
408
+ console.log(usage());
409
+ return null;
410
+ }
411
+ let report;
412
+ try {
413
+ const root = process.cwd();
414
+ const sinceAt = parseSince(parsed.since);
415
+ const merges = sinceAt ? loadGithubMerges(root, sinceAt) : [];
416
+ report = memberReport(root, { since: parsed.since, tasks: loadTasks(root), merges });
417
+ } catch (error) {
418
+ console.error(error.message);
419
+ process.exitCode = 1;
420
+ return null;
421
+ }
422
+ console.log(parsed.json ? JSON.stringify(report, null, 2) : formatReport(report));
423
+ return report;
424
+ }
425
+
426
+ module.exports = {
427
+ formatReport,
428
+ githubMergeItems,
429
+ memberFromBranch,
430
+ memberReport,
431
+ memberReportCommand,
432
+ parseSince,
433
+ };
@@ -139,8 +139,7 @@ function initialMemberLog({ name, role, description, source, now }) {
139
139
  description ? `- mission: ${description}` : '',
140
140
  `- source: ${source}`,
141
141
  '- status: ready_for_room',
142
- '',
143
- ].filter(Boolean).join('\n');
142
+ ].filter(Boolean).join('\n') + '\n';
144
143
  }
145
144
 
146
145
  function ensureMemberBundle(memberDir, {
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+
3
+ // When a member's job list is empty, it writes its own next job from its
4
+ // standing mission (atris/team/<member>/MISSION.md) and keeps working. The
5
+ // job is a normal open task: the North Star is the goal, and the mission's
6
+ // `## Check` section names the command that proves a run. A mission with no
7
+ // runnable check writes nothing, so the member stays idle as before. At most
8
+ // one self-written job is open per member, and at most MAX_PER_DAY are
9
+ // written per member per local day, so an idle team cannot spin forever.
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const { skipReason, taskCheck } = require('./member-task-pick');
14
+
15
+ const MAX_PER_DAY = 12;
16
+ const TITLE_MAX = 120;
17
+
18
+ function oneLine(text) {
19
+ return String(text || '').replace(/\s+/g, ' ').trim();
20
+ }
21
+
22
+ // The body of one `## <name>` section, up to the next `## ` heading.
23
+ function section(text, name) {
24
+ const lines = String(text || '').split(/\r?\n/);
25
+ const start = lines.findIndex((line) => line.trim().toLowerCase() === `## ${name}`);
26
+ if (start < 0) return '';
27
+ const end = lines.findIndex((line, i) => i > start && /^##\s/.test(line));
28
+ return lines.slice(start + 1, end < 0 ? undefined : end).join('\n').trim();
29
+ }
30
+
31
+ // The job a mission file asks for next: its North Star as the goal and its
32
+ // check command. null when either is missing.
33
+ function missionNextJob(text) {
34
+ const northStar = oneLine(section(text, 'north star').split(/\n\s*\n/)[0]);
35
+ const checkBlock = section(text, 'check');
36
+ const tick = checkBlock.match(/`([^`\n]+)`/);
37
+ const check = oneLine(tick ? tick[1] : checkBlock.split(/\r?\n/).find((line) => line.trim()));
38
+ if (!northStar || !check) return null;
39
+ const goal = northStar.length > TITLE_MAX ? `${northStar.slice(0, TITLE_MAX - 3).trim()}...` : northStar;
40
+ return { title: `next step toward: ${goal}`, objective: northStar, check };
41
+ }
42
+
43
+ function localDay(ms) {
44
+ const d = new Date(ms);
45
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
46
+ }
47
+
48
+ // Write the member's next job from its mission. Returns { task } with the
49
+ // new open task, or { skip } with a plain reason. dryRun returns the task it
50
+ // would write without writing it.
51
+ function writeSelfNextJob(taskDb, db, { root, workspaceRoot, member, job, now = Date.now(), dryRun = false }) {
52
+ let text;
53
+ try {
54
+ text = fs.readFileSync(path.join(root, 'atris', 'team', member, 'MISSION.md'), 'utf8');
55
+ } catch {
56
+ return { skip: 'no mission file' };
57
+ }
58
+ const next = missionNextJob(text);
59
+ if (!next) return { skip: 'mission names no north star or no check' };
60
+ const metadata = {
61
+ verify: next.check,
62
+ objective: next.objective,
63
+ written_by: member,
64
+ source: 'member mission',
65
+ };
66
+ const probe = { status: 'open', title: next.title, tag: job, metadata };
67
+ if (!taskCheck(probe).runnable) return { skip: 'mission check cannot be run here' };
68
+ const held = skipReason(probe, job, { requireCheck: true, member });
69
+ if (held) return { skip: `the job it would write is not one it can take (${held})` };
70
+
71
+ const prefix = `self-next:${member}:`;
72
+ const mine = db.prepare(
73
+ 'SELECT id, status, source_key FROM tasks WHERE workspace_root = ? AND source_key LIKE ?'
74
+ ).all(workspaceRoot, `${prefix}%`);
75
+ if (mine.some((row) => row.status === 'open' || row.status === 'claimed')) {
76
+ return { skip: 'its last self-written job is still open' };
77
+ }
78
+ const day = localDay(now);
79
+ const today = mine.filter((row) => String(row.source_key).startsWith(`${prefix}${day}:`)).length;
80
+ if (today >= MAX_PER_DAY) return { skip: `already wrote ${MAX_PER_DAY} jobs for itself today` };
81
+
82
+ if (dryRun) return { task: { id: null, ...probe } };
83
+ const added = taskDb.addTask(db, {
84
+ title: next.title,
85
+ tag: job,
86
+ workspaceRoot,
87
+ sourceKey: `${prefix}${day}:${today + 1}`,
88
+ metadata,
89
+ });
90
+ return { task: taskDb.getTask(db, added.id) };
91
+ }
92
+
93
+ module.exports = {
94
+ MAX_PER_DAY,
95
+ missionNextJob,
96
+ writeSelfNextJob,
97
+ };
@@ -64,6 +64,12 @@ function isMemberAwake(name, root = process.cwd()) {
64
64
  return getMemberSwitch(name, root).awake !== false;
65
65
  }
66
66
 
67
+ // Names of members whose switch is off, so boards can show them asleep.
68
+ function asleepMembers(root = process.cwd()) {
69
+ const { members } = loadMemberSwitches(root);
70
+ return Object.keys(members).filter((name) => normalizeMemberEntry(members[name]).awake === false).sort();
71
+ }
72
+
67
73
  function isMemberLoopAwake(name, loopId, root = process.cwd()) {
68
74
  const entry = getMemberSwitch(name, root);
69
75
  if (entry.awake === false) return false;
@@ -132,6 +138,7 @@ module.exports = {
132
138
  isLocalMember,
133
139
  getMemberSwitch,
134
140
  isMemberAwake,
141
+ asleepMembers,
135
142
  setMemberAwake,
136
143
  setMemberLoopAwake,
137
144
  readLoopFlag,
@@ -1,8 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  // The next real job for a member with no idea given: the oldest open,
4
- // unclaimed task in this workspace whose job (build, review, search) matches
5
- // the member's, held neither for a human nor in a protected lane. Callers
4
+ // unclaimed task assigned to them, then an unassigned task whose job (build,
5
+ // review, search) matches theirs, held neither for a human nor in a protected lane. Callers
6
6
  // fall back to the member's own goal when this returns null, unless they
7
7
  // ask for requireCheck: then only tasks with a runnable check count, and
8
8
  // nothing found means the member stays idle.
@@ -66,32 +66,58 @@ function taskCheck(task) {
66
66
  return { command: verify, runnable: isAutoCertifyVerifyCommandAllowed(verify) };
67
67
  }
68
68
 
69
- function skipReason(task, job, { requireCheck = false } = {}) {
70
- if (!task || task.status !== 'open') return 'not_open';
71
- if (task.claimed_by) return 'claimed';
69
+ function assigneeOf(task) {
70
+ const assigned = task?.metadata?.assigned_to;
71
+ return typeof assigned === 'string' ? assigned.trim() : '';
72
+ }
73
+
74
+ // Held for a person, the one test both the picker and the merge-line close
75
+ // use, so a merge never closes a job the picker would leave alone.
76
+ function heldForPerson(task) {
72
77
  if (isDecisionTask(task) || metadataTags(task).some(isDecisionHoldTag)) return 'needs_human';
73
78
  if (protectedTask(task)) return 'protected_lane';
79
+ return '';
80
+ }
81
+
82
+ function skipReason(task, job, { requireCheck = false, member, recoverClaimed = false } = {}) {
83
+ const ownClaim = recoverClaimed && member && task?.status === 'claimed' && task.claimed_by === member;
84
+ if (!task || (task.status !== 'open' && !ownClaim)) return 'not_open';
85
+ if (task.claimed_by && !ownClaim) return 'claimed';
86
+ const assignee = assigneeOf(task);
87
+ if (assignee && assignee !== member) return 'other_member';
88
+ const held = heldForPerson(task);
89
+ if (held) return held;
74
90
  const check = taskCheck(task);
75
91
  if (!check.runnable) return 'check_not_runnable';
76
92
  if (requireCheck && !check.command) return 'no_check';
77
- if (jobForTask(task) !== job) return 'other_job';
93
+ // A named assignment is more specific than the inferred job type.
94
+ if (!assignee && jobForTask(task) !== job) return 'other_job';
78
95
  return '';
79
96
  }
80
97
 
81
- function fittingTasks(taskDb, db, { workspaceRoot, job, requireCheck = false }) {
82
- return taskDb.listTasks(db, { workspaceRoot, status: 'open' })
83
- .filter((task) => !skipReason(task, job, { requireCheck }))
84
- .sort((a, b) => (a.created_at - b.created_at) || String(a.id).localeCompare(String(b.id)));
98
+ function fittingTasks(taskDb, db, { workspaceRoot, job, requireCheck = false, member, recoverClaimed = false }) {
99
+ const tasks = taskDb.listTasks(db, { workspaceRoot, status: 'open' });
100
+ // Only a caller that checked the member has no live run may recover its
101
+ // own claim. Keep the same human, lane, job, and check gates.
102
+ if (recoverClaimed && member) tasks.push(...taskDb.listTasks(db, { workspaceRoot, status: 'claimed' }));
103
+ return tasks
104
+ .filter((task) => !skipReason(task, job, { requireCheck, member, recoverClaimed }))
105
+ .sort((a, b) => Number(Boolean(assigneeOf(b))) - Number(Boolean(assigneeOf(a)))
106
+ || (a.created_at - b.created_at) || String(a.id).localeCompare(String(b.id)));
85
107
  }
86
108
 
87
- function pickTaskForMember(taskDb, db, { workspaceRoot, job, requireCheck = false }) {
88
- return fittingTasks(taskDb, db, { workspaceRoot, job, requireCheck })[0] || null;
109
+ function pickTaskForMember(taskDb, db, options) {
110
+ return fittingTasks(taskDb, db, options)[0] || null;
89
111
  }
90
112
 
91
113
  // Claim the oldest fitting task for the member. A lost race moves on to the
92
114
  // next candidate; null when nothing fits or every claim lost.
93
- function claimTaskForMember(taskDb, db, { workspaceRoot, member, job, requireCheck = false }) {
94
- for (const task of fittingTasks(taskDb, db, { workspaceRoot, job, requireCheck })) {
115
+ function claimTaskForMember(taskDb, db, { workspaceRoot, member, job, requireCheck = false, recoverClaimed = false }) {
116
+ for (const task of fittingTasks(taskDb, db, { workspaceRoot, member, job, requireCheck, recoverClaimed })) {
117
+ if (task.status === 'claimed') {
118
+ const released = taskDb.releaseTask(db, { id: task.id, actor: member, expectedUpdatedAt: task.updated_at });
119
+ if (!released.released) continue;
120
+ }
95
121
  let result;
96
122
  try {
97
123
  result = taskDb.claimTask(db, { id: task.id, claimedBy: member });
@@ -109,6 +135,7 @@ module.exports = {
109
135
  taskCheck,
110
136
  claimTaskForMember,
111
137
  fittingTasks,
138
+ heldForPerson,
112
139
  jobForTask,
113
140
  pickTaskForMember,
114
141
  skipReason,