atris 3.70.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 (76) hide show
  1. package/README.md +18 -0
  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 +359 -94
  20. package/commands/mission.js +249 -36
  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 +71 -17
  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 +108 -15
  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 +33 -3
  50. package/lib/engine-registry.js +2 -0
  51. package/lib/engine-resource.js +518 -0
  52. package/lib/fleet.js +125 -16
  53. package/lib/init-commit.js +80 -0
  54. package/lib/intents.js +8 -0
  55. package/lib/known-commands.js +1 -1
  56. package/lib/land-green.js +58 -7
  57. package/lib/landing-proof.js +352 -0
  58. package/lib/member-autoland.js +395 -18
  59. package/lib/member-board.js +7 -1
  60. package/lib/member-map-fold.js +126 -0
  61. package/lib/member-map-inbox.js +80 -0
  62. package/lib/member-progress.js +48 -1
  63. package/lib/member-report.js +127 -8
  64. package/lib/member-self-next-job.js +97 -0
  65. package/lib/member-task-pick.js +41 -14
  66. package/lib/member-up.js +249 -16
  67. package/lib/roster-prep.js +25 -2
  68. package/lib/runner-command.js +5 -0
  69. package/lib/task-db.js +47 -11
  70. package/lib/task-explanation.js +7 -0
  71. package/lib/task-list-keeper.js +8 -1
  72. package/lib/task-receipt.js +6 -1
  73. package/mcp/atris-mcp/index.mjs +212 -10
  74. package/package.json +2 -1
  75. package/scripts/det/engine-reading.js +37 -0
  76. package/scripts/det/ytnotes +29 -5
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+
3
+ // Member branches stop editing the shared map files. Before a branch goes to
4
+ // GitHub, its edits to any MAP.md or MAP-NOTES.md that already exist on the
5
+ // main branch are taken back out and saved as a patch in the map inbox, one
6
+ // file per branch. The shared map files land untouched, so two members never
7
+ // clash in them; the inbox patches are folded into the maps later on the main
8
+ // branch by one actor.
9
+
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+ const { spawnSync } = require('child_process');
14
+
15
+ const INBOX_DIR = 'atris/refs/map-inbox';
16
+ const SHARED_MAP = /(?:^|\/)MAP(?:-NOTES)?\.md$/;
17
+
18
+ function run(args, cwd, extraEnv = {}, { raw = false } = {}) {
19
+ const env = { ...process.env, ...extraEnv, GIT_NO_REPLACE_OBJECTS: '1' };
20
+ const res = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 120000, env });
21
+ const stdout = String(res.stdout || '');
22
+ return { ok: res.status === 0, stdout: raw ? stdout : stdout.trim(), stderr: String(res.stderr || '').trim() };
23
+ }
24
+
25
+ function inboxPath(branch) {
26
+ const slug = String(branch || '').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[-.]+|-+$/g, '') || 'branch';
27
+ return `${INBOX_DIR}/${slug}.patch`;
28
+ }
29
+
30
+ const SHA = /^[0-9a-f]{40,64}$/;
31
+
32
+ // Returns { sha } unchanged when the branch left the shared maps alone, or
33
+ // { sha, moved, inbox } for a new commit on top of headSha whose shared map
34
+ // files match the starting point and whose map edits sit in the inbox patch.
35
+ // Never touches the checkout. Any failure returns { hold }.
36
+ function inboxMapEdits({ headSha, baseSha, branch, cwd }) {
37
+ const bases = run(['merge-base', '--all', headSha, baseSha], cwd);
38
+ const mergeBase = bases.ok ? bases.stdout.split('\n').filter(Boolean) : [];
39
+ if (mergeBase.length !== 1) return { sha: headSha, moved: [] };
40
+ const changed = run(['diff', '--no-renames', '--name-status', mergeBase[0], headSha], cwd);
41
+ if (!changed.ok) return { hold: 'the branch\'s map edits could not be read' };
42
+ const moved = changed.stdout.split('\n')
43
+ .map((line) => line.split('\t'))
44
+ .filter(([status, file]) => status === 'M' && SHARED_MAP.test(file || ''))
45
+ .map(([, file]) => file);
46
+ if (!moved.length) return { sha: headSha, moved: [] };
47
+
48
+ const patch = run(['diff', '--no-color', '--no-ext-diff', '--no-renames', mergeBase[0], headSha, '--', ...moved], cwd, {}, { raw: true });
49
+ if (!patch.ok || !patch.stdout) return { hold: 'the branch\'s map edits could not be saved' };
50
+ const inbox = inboxPath(branch);
51
+ const earlier = run(['cat-file', 'blob', `${headSha}:${inbox}`], cwd, {}, { raw: true });
52
+
53
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-map-inbox-'));
54
+ try {
55
+ const index = { GIT_INDEX_FILE: path.join(tmp, 'index') };
56
+ if (!run(['read-tree', headSha], cwd, index).ok) return { hold: 'the map inbox could not be built' };
57
+ for (const file of moved) {
58
+ const entry = run(['ls-tree', mergeBase[0], '--', file], cwd).stdout.match(/^(\d{6}) blob ([0-9a-f]{40,64})\t/);
59
+ if (!entry || !run(['update-index', '--cacheinfo', `${entry[1]},${entry[2]},${file}`], cwd, index).ok) {
60
+ return { hold: `the map file ${file} could not be put back` };
61
+ }
62
+ }
63
+ const patchFile = path.join(tmp, 'patch');
64
+ fs.writeFileSync(patchFile, (earlier.ok ? earlier.stdout : '') + patch.stdout);
65
+ const blob = run(['hash-object', '-w', patchFile], cwd);
66
+ if (!blob.ok || !SHA.test(blob.stdout)) return { hold: 'the map inbox could not be saved' };
67
+ if (!run(['update-index', '--add', '--cacheinfo', `100644,${blob.stdout},${inbox}`], cwd, index).ok) {
68
+ return { hold: 'the map inbox could not be built' };
69
+ }
70
+ const tree = run(['write-tree'], cwd, index);
71
+ if (!tree.ok) return { hold: 'the map inbox could not be built' };
72
+ const commit = run(['commit-tree', tree.stdout, '-p', headSha, '-m', 'Move map edits to the map inbox'], cwd);
73
+ if (!commit.ok || !SHA.test(commit.stdout)) return { hold: 'the map inbox commit could not be saved' };
74
+ return { sha: commit.stdout, moved, inbox };
75
+ } finally {
76
+ fs.rmSync(tmp, { recursive: true, force: true });
77
+ }
78
+ }
79
+
80
+ module.exports = { inboxMapEdits, inboxPath, INBOX_DIR };
@@ -7,6 +7,51 @@ const fs = require('fs');
7
7
  const os = require('os');
8
8
  const path = require('path');
9
9
 
10
+ const MAX_PROGRESS_BYTES = 256 * 1024;
11
+ const KEEP_PROGRESS_BYTES = 128 * 1024;
12
+
13
+ // Progress is a live view, not the durable mission receipt. Keep whole recent
14
+ // lines and the latest start marker so the board still knows the job and pid.
15
+ // Scan backwards in bounded chunks even when upgrading a very large old log.
16
+ function trimProgress(file) {
17
+ const fd = fs.openSync(file, 'r');
18
+ let recent;
19
+ let started = '';
20
+ try {
21
+ const size = fs.fstatSync(fd).size;
22
+ if (size <= MAX_PROGRESS_BYTES) return;
23
+ const from = Math.max(0, size - KEEP_PROGRESS_BYTES);
24
+ const tail = Buffer.alloc(size - from);
25
+ fs.readSync(fd, tail, 0, tail.length, from);
26
+ recent = tail.subarray(from ? tail.indexOf(0x0a) + 1 : 0).toString('utf8');
27
+ let end = size;
28
+ let partial = Buffer.alloc(0);
29
+ while (end > 0 && !started) {
30
+ const begin = Math.max(0, end - KEEP_PROGRESS_BYTES);
31
+ const chunk = Buffer.alloc(end - begin);
32
+ fs.readSync(fd, chunk, 0, chunk.length, begin);
33
+ const combined = Buffer.concat([chunk, partial]);
34
+ const boundary = begin ? combined.indexOf(0x0a) : -1;
35
+ partial = begin ? combined.subarray(0, boundary < 0 ? combined.length : boundary) : Buffer.alloc(0);
36
+ const lines = combined.subarray(begin ? boundary < 0 ? combined.length : boundary + 1 : 0).toString('utf8').split('\n');
37
+ for (let i = lines.length - 1; i >= 0; i--) {
38
+ if (/^\S+ \S+ started:/.test(lines[i])) { started = lines[i].slice(0, 500); break; }
39
+ }
40
+ // Malformed legacy lines must not make the backwards scan unbounded.
41
+ if (partial.length > KEEP_PROGRESS_BYTES) partial = Buffer.alloc(0);
42
+ end = begin;
43
+ }
44
+ } finally { fs.closeSync(fd); }
45
+ const prefix = started && !recent.includes(`${started}\n`) ? `${started}\n` : '';
46
+ const tmp = `${file}.${process.pid}.${newRunToken()}.tmp`;
47
+ try {
48
+ fs.writeFileSync(tmp, prefix + recent, 'utf8');
49
+ fs.renameSync(tmp, file);
50
+ } finally {
51
+ try { fs.unlinkSync(tmp); } catch {}
52
+ }
53
+ }
54
+
10
55
  function realish(p) {
11
56
  try { return fs.realpathSync(p); } catch {}
12
57
  try { return path.join(fs.realpathSync(path.dirname(p)), path.basename(p)); } catch {}
@@ -144,7 +189,9 @@ function appendProgress(member, line, missionId) {
144
189
  const dir = progressDir();
145
190
  fs.mkdirSync(dir, { recursive: true });
146
191
  const entry = `${new Date().toISOString()} ${missionId || '-'} ${shortenLine(line)}\n`;
147
- fs.appendFileSync(path.join(dir, `${safeMember(member)}.log`), entry, 'utf8');
192
+ const file = path.join(dir, `${safeMember(member)}.log`);
193
+ fs.appendFileSync(file, entry, 'utf8');
194
+ trimProgress(file);
148
195
  } catch {}
149
196
  }
150
197
 
@@ -2,18 +2,24 @@
2
2
 
3
3
  // `atris member report [--since <time>] [--json]`: a short per-member summary
4
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
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>-...)
6
7
  // finished diary mission ticks whose check passed, accepted tasks, completed missions
7
8
  // turned down tasks a person sent back (task db) and tasks set aside (diary), with the reason
8
9
  // waiting tasks sitting in review for a person right now (task db)
9
10
  // Diary headings are `## HH:MM · title` in local time and the file name holds
10
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).
11
14
 
12
15
  const fs = require('fs');
13
16
  const path = require('path');
17
+ const { spawnSync } = require('child_process');
14
18
 
15
19
  const DEFAULT_SINCE = '24h';
16
20
  const PER_GROUP = 3;
21
+ // A job picked up before the window still gets its real start time.
22
+ const HISTORY_DAYS = 7;
17
23
  const LOG_NAME_RE = /^(\d{4})-(\d{2})-(\d{2})\.md$/;
18
24
  const HEADING_RE = /^##\s+(\d{1,2}):(\d{2})\s*(?:·|-|:)?\s*(.*)$/;
19
25
  const FIELD_RE = /^-\s+([a-z_][a-z0-9_ ]*):\s*(.*)$/i;
@@ -174,6 +180,70 @@ function taskItems(tasks, sinceMs, members) {
174
180
  return items;
175
181
  }
176
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
+
177
247
  function sameTitle(a, b) {
178
248
  return String(a || '').trim().toLowerCase() === String(b || '').trim().toLowerCase();
179
249
  }
@@ -183,26 +253,35 @@ function summarize(items) {
183
253
  const sorted = [...items].sort((a, b) => b.at - a.at);
184
254
  for (const item of sorted) {
185
255
  if (!item.title) continue;
186
- // Newest word on a piece of work wins, and merged beats finished.
187
- if (Object.values(groups).some((list) => list.some((seen) => sameTitle(seen.title, 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;
188
260
  const { member, at, group, ...rest } = item;
189
261
  groups[group].push(rest);
190
262
  }
191
263
  return groups;
192
264
  }
193
265
 
194
- function memberReport(root, { since = DEFAULT_SINCE, now = new Date(), tasks = [] } = {}) {
266
+ function memberReport(root, { since = DEFAULT_SINCE, now = new Date(), tasks = [], merges = [] } = {}) {
195
267
  const sinceAt = parseSince(since, now);
196
268
  if (!sinceAt) throw new Error(`--since "${since}" is not a time; try 2h, yesterday, or 2026-10-14`);
197
269
  const names = memberNames(root);
198
270
  if (!names) throw new Error('no atris/team folder here; run this inside an atris workspace');
199
- const fromTasks = taskItems(tasks, sinceAt.getTime(), names);
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);
200
274
  const members = names
201
275
  .map((member) => {
202
- const items = diaryEntriesSince(root, member, sinceAt)
276
+ const history = diaryEntriesSince(root, member, historyAt);
277
+ const timings = jobTimings(history);
278
+ const items = history
279
+ .filter((entry) => entry.at.getTime() >= sinceAt.getTime())
203
280
  .map((entry) => {
204
281
  const item = classifyDiaryEntry(entry);
205
- return item ? { member, at: entry.at.getTime(), ...item } : null;
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 };
206
285
  })
207
286
  .filter(Boolean)
208
287
  .concat(fromTasks.filter((item) => item.member === member));
@@ -243,7 +322,26 @@ function firstSentence(text) {
243
322
  return match ? match[0] : flat;
244
323
  }
245
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
+
246
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) {
247
345
  if (key === 'finished' && item.detail) return clip(firstSentence(item.detail), 90);
248
346
  if (key === 'merged') return item.link ? `${clip(item.title, 70)} ${item.link}` : clip(item.title, 90);
249
347
  if (key === 'turned_down') return item.reason ? `${clip(item.title, 50)}: ${clip(item.reason, 70)}` : clip(item.title, 90);
@@ -280,6 +378,23 @@ function loadTasks(root) {
280
378
  }
281
379
  }
282
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
+
283
398
  function memberReportCommand(...args) {
284
399
  let parsed;
285
400
  try {
@@ -296,7 +411,9 @@ function memberReportCommand(...args) {
296
411
  let report;
297
412
  try {
298
413
  const root = process.cwd();
299
- report = memberReport(root, { since: parsed.since, tasks: loadTasks(root) });
414
+ const sinceAt = parseSince(parsed.since);
415
+ const merges = sinceAt ? loadGithubMerges(root, sinceAt) : [];
416
+ report = memberReport(root, { since: parsed.since, tasks: loadTasks(root), merges });
300
417
  } catch (error) {
301
418
  console.error(error.message);
302
419
  process.exitCode = 1;
@@ -308,6 +425,8 @@ function memberReportCommand(...args) {
308
425
 
309
426
  module.exports = {
310
427
  formatReport,
428
+ githubMergeItems,
429
+ memberFromBranch,
311
430
  memberReport,
312
431
  memberReportCommand,
313
432
  parseSince,
@@ -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
+ };
@@ -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,