atris 3.70.0 → 3.71.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) 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 +38 -8
  7. package/commands/autoland.js +176 -7
  8. package/commands/autopilot.js +71 -11
  9. package/commands/campaign.js +779 -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/integrations.js +17 -5
  17. package/commands/interview.js +15 -3
  18. package/commands/land.js +22 -6
  19. package/commands/link.js +1 -0
  20. package/commands/member.js +359 -94
  21. package/commands/mission.js +249 -36
  22. package/commands/report.js +42 -8
  23. package/commands/run.js +9 -2
  24. package/commands/scout.js +9 -1
  25. package/commands/task.js +71 -17
  26. package/commands/team.js +212 -4
  27. package/commands/unknowns.js +15 -1
  28. package/commands/verify.js +46 -0
  29. package/commands/worktree.js +108 -15
  30. package/commands/write.js +12 -1
  31. package/commands/youtube.js +17 -2
  32. package/lib/accept-verify-gate.js +1 -0
  33. package/lib/account-bound.js +13 -0
  34. package/lib/auto-accept-certified.js +1 -0
  35. package/lib/autoland.js +13 -4
  36. package/lib/campaign-cadence.js +131 -0
  37. package/lib/campaign-core.js +330 -0
  38. package/lib/campaign-holdout.js +65 -0
  39. package/lib/campaign-loop.js +246 -0
  40. package/lib/campaign-missions.js +237 -0
  41. package/lib/campaign-new.js +251 -0
  42. package/lib/campaign-protect.js +75 -0
  43. package/lib/campaign-reading.js +147 -0
  44. package/lib/campaign-run-lock.js +59 -0
  45. package/lib/campaign-schema.js +196 -0
  46. package/lib/campaign-store.js +380 -0
  47. package/lib/campaign-sync.js +302 -0
  48. package/lib/campaign-trace.js +300 -0
  49. package/lib/conductor-artifacts.js +15 -1
  50. package/lib/daily-log.js +11 -1
  51. package/lib/dispatch-scout.js +18 -0
  52. package/lib/engine-ask.js +33 -3
  53. package/lib/engine-registry.js +2 -0
  54. package/lib/engine-resource.js +518 -0
  55. package/lib/fleet.js +125 -16
  56. package/lib/init-commit.js +80 -0
  57. package/lib/intents.js +8 -0
  58. package/lib/known-commands.js +1 -1
  59. package/lib/land-green.js +58 -7
  60. package/lib/landing-proof.js +352 -0
  61. package/lib/member-autoland.js +395 -18
  62. package/lib/member-board.js +7 -1
  63. package/lib/member-map-fold.js +126 -0
  64. package/lib/member-map-inbox.js +80 -0
  65. package/lib/member-progress.js +48 -1
  66. package/lib/member-report.js +127 -8
  67. package/lib/member-self-next-job.js +97 -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 +47 -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
@@ -4,6 +4,8 @@
4
4
  // GitHub auto-merge. Every local gate fails closed: the branch stays put and
5
5
  // one plain line says which gate held it. GitHub then waits for the branch to
6
6
  // be up to date and CI green; this never merges, rebases, or polls itself.
7
+ // The one exception is a project with no remote: there the branch merges into
8
+ // the main checkout's branch on this computer, past the same gates.
7
9
 
8
10
  const fs = require('fs');
9
11
  const os = require('os');
@@ -15,6 +17,7 @@ const {
15
17
  sniffedProtectedLane,
16
18
  } = require('./auto-accept-certified');
17
19
  const { isDecisionHoldTag, isDecisionTask } = require('./task-decision');
20
+ const { inboxMapEdits } = require('./member-map-inbox');
18
21
 
19
22
  // Git replacement refs can make a local check read different code than the
20
23
  // commit that gets pushed; every git and gh call here ignores them.
@@ -254,16 +257,19 @@ function unsavedHold(cwd) {
254
257
  return null;
255
258
  }
256
259
 
257
- const MAP_FILE = /(?:^|\/)MAP\.md$/;
260
+ // Files where every side only adds lines: map files, the map notes file, and
261
+ // member logs. A clash in these keeps both sides' lines instead of holding.
262
+ const MAP_FILE = /(?:^|\/)MAP(?:-NOTES)?\.md$|^atris\/team\/[^/]+\/logs\/[^/]+\.md$/;
258
263
 
259
- // Keep both sides' lines of one clashing map file, as a stored blob.
264
+ // Keep both sides' lines of one clashing map file, as a stored blob. A file
265
+ // both sides created, like the same day's member log, merges against empty.
260
266
  function unionMapBlob(file, mergeBase, ours, theirs, cwd, tmp) {
261
267
  const sides = [];
262
268
  for (const [name, rev] of [['base', mergeBase], ['ours', ours], ['theirs', theirs]]) {
263
269
  const blob = run('git', ['cat-file', 'blob', `${rev}:${file}`], cwd, {}, { raw: true });
264
- if (!blob.ok) return null;
270
+ if (!blob.ok && name !== 'base') return null;
265
271
  const out = path.join(tmp, name);
266
- fs.writeFileSync(out, blob.stdout);
272
+ fs.writeFileSync(out, blob.ok ? blob.stdout : '');
267
273
  sides.push(out);
268
274
  }
269
275
  const merged = run('git', ['merge-file', '-p', '--union', sides[1], sides[0], sides[2]], cwd, {}, { raw: true });
@@ -275,19 +281,40 @@ function unionMapBlob(file, mergeBase, ours, theirs, cwd, tmp) {
275
281
  }
276
282
 
277
283
  // Merge the main branch into the reviewed commit without touching the
278
- // checkout. A clean merge pushes the reviewed commit as is. When every clash
279
- // is a map file, both sides' lines are kept and a merge commit on top of the
280
- // reviewed commit is pushed instead, so GitHub has nothing left to resolve.
281
- // Any other clash holds.
282
- function mergeWithBase(headSha, base, branch, cwd) {
283
- const remoteRef = `refs/remotes/origin/${base}`;
284
- const fetched = run('git', ['fetch', '-q', 'origin', `+refs/heads/${base}:${remoteRef}`], cwd);
285
- if (!fetched.ok) return { hold: `the main branch could not be fetched (${fetched.stderr.split('\n')[0]})` };
286
- const baseRead = run('git', ['rev-parse', remoteRef], cwd);
284
+ // checkout. Edits to the shared map files first move to the map inbox in a
285
+ // commit on top of the reviewed commit, so they never clash. A clean merge
286
+ // pushes that commit as is. When every clash is a map file or member log, both
287
+ // sides' lines are kept and a merge commit is pushed instead, so GitHub has
288
+ // nothing left to resolve. Any other clash holds. With catchUp, a clean merge
289
+ // against a main branch that moved also becomes a merge commit, so the branch
290
+ // is up to date. With localBase, the main branch is the local one and nothing
291
+ // is fetched.
292
+ function mergeWithBase(reviewedSha, base, branch, cwd, { catchUp = false, localBase = false } = {}) {
293
+ const baseRef = localBase ? `refs/heads/${base}` : `refs/remotes/origin/${base}`;
294
+ if (!localBase) {
295
+ const fetched = run('git', ['fetch', '-q', 'origin', `+refs/heads/${base}:${baseRef}`], cwd);
296
+ if (!fetched.ok) return { hold: `the main branch could not be fetched (${fetched.stderr.split('\n')[0]})` };
297
+ }
298
+ const baseRead = run('git', ['rev-parse', baseRef], cwd);
287
299
  const baseSha = baseRead.ok ? baseRead.stdout : '';
288
300
  if (!/^[0-9a-f]{40,64}$/.test(baseSha)) return { hold: 'the main branch could not be read' };
301
+ const inboxed = inboxMapEdits({ headSha: reviewedSha, baseSha, branch, cwd });
302
+ if (inboxed.hold) return { hold: inboxed.hold };
303
+ const headSha = inboxed.sha;
304
+ const mapInboxed = inboxed.moved;
289
305
  const tree = run('git', ['merge-tree', '--write-tree', '--name-only', headSha, baseSha], cwd);
290
- if (tree.status === 0) return { sha: headSha };
306
+ if (tree.status === 0) {
307
+ if (!catchUp) return { sha: headSha, mapInboxed };
308
+ if (headSha === reviewedSha && run('git', ['merge-base', '--is-ancestor', baseSha, headSha], cwd).ok) {
309
+ return { sha: headSha, current: true };
310
+ }
311
+ const commit = run('git', [
312
+ 'commit-tree', tree.stdout.split(/\r?\n/)[0], '-p', headSha, '-p', baseSha,
313
+ '-m', `Merge ${base} into ${branch} to catch up`,
314
+ ], cwd);
315
+ if (!commit.ok || !/^[0-9a-f]{40,64}$/.test(commit.stdout)) return { hold: 'the catch-up merge could not be saved' };
316
+ return { sha: commit.stdout, mapInboxed };
317
+ }
291
318
  if (tree.status !== 1) return { hold: 'the branch could not be compared with the main branch' };
292
319
  const [treeId, ...files] = tree.stdout.split(/\r?\n\r?\n/)[0].split(/\r?\n/).filter(Boolean);
293
320
  const clashes = files.filter((file) => !MAP_FILE.test(file));
@@ -317,17 +344,106 @@ function mergeWithBase(headSha, base, branch, cwd) {
317
344
  '-m', `Merge ${base} into ${branch}, keeping map lines from both sides`,
318
345
  ], cwd);
319
346
  if (!commit.ok || !/^[0-9a-f]{40,64}$/.test(commit.stdout)) return { hold: 'the map merge could not be saved' };
320
- return { sha: commit.stdout, mapMerged: files };
347
+ return { sha: commit.stdout, mapMerged: files, mapInboxed };
321
348
  } finally {
322
349
  fs.rmSync(tmp, { recursive: true, force: true });
323
350
  }
324
351
  }
325
352
 
353
+ // Every branch autoland handed to GitHub, so a sweep can tell when GitHub
354
+ // dropped one. Lives in the main checkout beside the policy.
355
+ function queueFile(cwd) {
356
+ const root = mainCheckoutRoot(cwd);
357
+ return root ? path.join(root, '.atris', 'state', 'autoland-queue.json') : null;
358
+ }
359
+
360
+ function readQueue(file) {
361
+ try {
362
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
363
+ if (data && typeof data.entries === 'object' && !Array.isArray(data.entries)) return data.entries;
364
+ } catch {}
365
+ return {};
366
+ }
367
+
368
+ function writeQueue(file, entries) {
369
+ fs.mkdirSync(path.dirname(file), { recursive: true });
370
+ const tmp = `${file}.${process.pid}.tmp`;
371
+ fs.writeFileSync(tmp, `${JSON.stringify({ schema: 'atris.autoland_queue.v1', entries }, null, 2)}\n`);
372
+ fs.renameSync(tmp, file);
373
+ }
374
+
375
+ function recordQueued(cwd, entry) {
376
+ const file = queueFile(cwd);
377
+ if (!file) return;
378
+ try {
379
+ const entries = readQueue(file);
380
+ entries[entry.branch] = entry;
381
+ writeQueue(file, entries);
382
+ } catch {}
383
+ }
384
+
326
385
  function held(gate, line) {
327
386
  return { merged: false, auto_merge: false, gate, line: `left the branch for you: ${line}` };
328
387
  }
329
388
 
330
- function autolandMemberRun({ mission, cwd, summary = '', workerEngine = '', loadTask = defaultLoadTask } = {}) {
389
+ function hasOrigin(cwd) {
390
+ return run('git', ['remote', 'get-url', 'origin'], cwd).ok;
391
+ }
392
+
393
+ // The branch the main checkout is on, or '' when it is not on one.
394
+ function mainCheckoutBranch(cwd) {
395
+ const root = mainCheckoutRoot(cwd);
396
+ const read = root ? run('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], root) : null;
397
+ return read?.ok ? read.stdout : '';
398
+ }
399
+
400
+ // Where a finished run's work goes, as one plain line for the start of a run.
401
+ function landingDestination(cwd) {
402
+ if (!policyOn(cwd)) return 'finished work stays on its own branch for you; self-landing is off';
403
+ if (!hasOrigin(cwd)) {
404
+ const base = mainCheckoutBranch(cwd);
405
+ return `finished work merges into ${base || 'the main branch'} on this computer once its check and review pass`;
406
+ }
407
+ return 'finished work goes to GitHub as a pull request that merges itself once checks pass';
408
+ }
409
+
410
+ // A project with no remote has no GitHub to hand the branch to, so the main
411
+ // checkout's own branch is the target. The reviewed commit catches up with it
412
+ // without touching any checkout, then the main checkout fast-forwards to that
413
+ // commit, which refuses rather than overwrite anything there.
414
+ function landLocally({ headSha, branch, cwd, pinnedTree }) {
415
+ const root = mainCheckoutRoot(cwd);
416
+ const base = mainCheckoutBranch(cwd);
417
+ if (!root || !base) return held('base', 'the main checkout is not on a branch');
418
+ if (base === branch) return held('branch', 'the work is not on its own branch');
419
+ const mainState = gitCodeState(root);
420
+ if (mainState.hidden || codeStatus(mainState) !== '') {
421
+ return held('unsaved', 'the main checkout has unsaved changes; merging there could mix them in');
422
+ }
423
+ const landing = mergeWithBase(headSha, base, branch, cwd, { catchUp: true, localBase: true });
424
+ if (landing.hold) return held('conflict', landing.hold);
425
+ if (!pinnedTree()) return held('review', 'the reviewed commit no longer holds the reviewed code');
426
+ const forward = run('git', ['merge', '--ff-only', '-q', landing.sha], root);
427
+ if (!forward.ok) return held('merge', `the main checkout could not move forward (${forward.stderr.split('\n')[0]})`);
428
+ const mapNote = landing.mapInboxed?.length ? ' (map edits saved to the map inbox)'
429
+ : landing.mapMerged ? ' (map lines from both sides kept)' : '';
430
+ return {
431
+ merged: true,
432
+ auto_merge: false,
433
+ local: true,
434
+ gate: null,
435
+ base,
436
+ landed: landing.sha,
437
+ map_merged: landing.mapMerged || [],
438
+ map_inboxed: landing.mapInboxed || [],
439
+ line: `merged into ${base} on this computer${mapNote}; this project has no GitHub remote`,
440
+ };
441
+ }
442
+
443
+ // closeTaskIds: jobs taken off the list for this run. They only ride along
444
+ // so the sweep can close them once the change merges; unlike the mission's
445
+ // own linked tasks they do not gate the landing.
446
+ function autolandMemberRun({ mission, cwd, summary = '', workerEngine = '', loadTask = defaultLoadTask, closeTaskIds = [] } = {}) {
331
447
  if (!mission || typeof mission !== 'object') return held('mission', 'the mission could not be read');
332
448
  if (mission.verifier_result?.passed !== true) return held('check', 'the check did not pass');
333
449
  if (!policyOn(cwd)) return held('policy', 'self-landing is not switched on in the main checkout');
@@ -376,7 +492,8 @@ function autolandMemberRun({ mission, cwd, summary = '', workerEngine = '', load
376
492
  if (!branch || branch === 'HEAD' || branch === 'master' || branch === 'main') {
377
493
  return held('branch', 'the work is not on its own branch');
378
494
  }
379
- const remoteHead = run('git', ['ls-remote', '--symref', 'origin', 'HEAD'], cwd);
495
+ if (!hasOrigin(cwd)) return landLocally({ headSha, branch, cwd, pinnedTree });
496
+ const remoteHead =run('git', ['ls-remote', '--symref', 'origin', 'HEAD'], cwd);
380
497
  const base = (remoteHead.stdout.match(/^ref: refs\/heads\/(\S+)\s+HEAD$/m) || [])[1] || '';
381
498
  if (!remoteHead.ok || !base) return held('base', 'the main branch on GitHub could not be read');
382
499
  if (branch === base) return held('branch', 'the work is not on its own branch');
@@ -423,7 +540,14 @@ function autolandMemberRun({ mission, cwd, summary = '', workerEngine = '', load
423
540
 
424
541
  const merge = run('gh', ['pr', 'merge', String(number), '--auto', '--merge', '--match-head-commit', pushSha], cwd);
425
542
  if (!merge.ok) return held('auto-merge', `auto-merge could not be switched on for ${label}`);
426
- const mapNote = landing.mapMerged ? ' (map lines from both sides kept)' : '';
543
+ recordQueued(cwd, {
544
+ branch, base, number, pushed: pushSha, mission_id: mission.id || null,
545
+ task_ids: [...new Set([...taskIds, ...(Array.isArray(closeTaskIds) ? closeTaskIds : [])
546
+ .filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())])],
547
+ requeues: 0, queued_at: new Date().toISOString(),
548
+ });
549
+ const mapNote = landing.mapInboxed?.length ? ' (map edits saved to the map inbox)'
550
+ : landing.mapMerged ? ' (map lines from both sides kept)' : '';
427
551
  return {
428
552
  merged: false,
429
553
  auto_merge: true,
@@ -431,12 +555,265 @@ function autolandMemberRun({ mission, cwd, summary = '', workerEngine = '', load
431
555
  url: label,
432
556
  pushed: pushSha,
433
557
  map_merged: landing.mapMerged || [],
558
+ map_inboxed: landing.mapInboxed || [],
434
559
  line: `merges on its own once checks pass${mapNote}: ${label}`,
435
560
  };
436
561
  }
437
562
 
563
+ const MAX_REQUEUES = 3;
564
+
565
+ // One queued branch: leave it, forget it, or catch it up and queue it again.
566
+ // Only the exact commit autoland queued (or its own catch-up) is re-queued; a
567
+ // push by anyone else, a closed pull request, or a real clash leaves it.
568
+ // GitHub keeps auto-merge on when master moves under a queued branch and the
569
+ // two clash, so a clashing branch is caught up even while still queued.
570
+ // GitHub runs holding a failed check on this pull request. gh exits nonzero
571
+ // when any check failed, so the list is read either way.
572
+ function failedCheckRuns(number, cwd) {
573
+ const out = run('gh', ['pr', 'checks', String(number), '--json', 'bucket,link'], cwd);
574
+ let checks = [];
575
+ try {
576
+ checks = JSON.parse(out.stdout || '[]');
577
+ } catch {
578
+ return [];
579
+ }
580
+ if (!Array.isArray(checks)) return [];
581
+ const ids = checks
582
+ .filter((check) => check && check.bucket === 'fail')
583
+ .map((check) => (String(check.link || '').match(/\/actions\/runs\/(\d+)/) || [])[1])
584
+ .filter(Boolean);
585
+ return [...new Set(ids)];
586
+ }
587
+
588
+ function inMergeQueue(number, cwd) {
589
+ const out = run('gh', ['api', 'graphql', '-F', 'owner={owner}', '-F', 'name={repo}', '-F', `number=${number}`,
590
+ '-f', 'query=query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){isInMergeQueue}}}',
591
+ '--jq', '.data.repository.pullRequest.isInMergeQueue'], cwd);
592
+ return out.ok && out.stdout === 'true';
593
+ }
594
+
595
+ function requeueOne(entry, cwd, maxRequeues) {
596
+ const { branch, base, number } = entry;
597
+ const label = `pull request #${number} (${branch})`;
598
+ const keep = (state, line) => ({ branch, number, state, keep: true, line: `${label}: ${line}` });
599
+ const forget = (state, line) => ({ branch, number, state, keep: false, line: `${label}: ${line}` });
600
+ const viewed = run('gh', ['pr', 'view', String(number), '--json',
601
+ 'number,state,headRefName,baseRefName,headRefOid,isCrossRepository,autoMergeRequest,mergeable'], cwd);
602
+ let pr = null;
603
+ try {
604
+ pr = viewed.ok ? JSON.parse(viewed.stdout) : null;
605
+ } catch {
606
+ pr = null;
607
+ }
608
+ if (!pr || typeof pr !== 'object') return keep('unreadable', 'github could not be read; will look again');
609
+ // Only the commit autoland pushed counts as the member's own change; a merge
610
+ // that carried someone else's push leaves the job for a person.
611
+ if (pr.state === 'MERGED') {
612
+ if (pr.headRefOid !== entry.pushed) return forget('merged', 'merged with commits someone else pushed; its job is left for you');
613
+ return { ...forget('merged', 'merged'), own: true };
614
+ }
615
+ if (pr.state !== 'OPEN') return forget('closed', 'closed by someone; left alone');
616
+ if (pr.headRefName !== branch || pr.baseRefName !== base || pr.isCrossRepository !== false) {
617
+ return forget('moved', 'no longer points from this branch into the main branch; left for you');
618
+ }
619
+ // In GitHub's merge line a change shows no auto-merge request and its
620
+ // branch is locked, so a catch-up push there fails. Leave it to the line.
621
+ if (!pr.autoMergeRequest && inMergeQueue(number, cwd)) return keep('queued', 'in github\'s merge line');
622
+ if (pr.autoMergeRequest && pr.mergeable !== 'CONFLICTING') {
623
+ // A failed check holds the change forever. Run the failed checks again
624
+ // once per pushed commit: a test that fails now and then passes, a real
625
+ // failure fails twice and waits for a fix.
626
+ const failed = pr.headRefOid === entry.pushed ? failedCheckRuns(number, cwd) : [];
627
+ if (failed.length && entry.reran !== entry.pushed) {
628
+ const reran = failed.filter((id) => run('gh', ['run', 'rerun', id, '--failed'], cwd).ok);
629
+ if (reran.length) return { ...keep('rerun', 'a check failed; ran the failed checks again once'), reran: entry.pushed };
630
+ return keep('queued', 'a check failed and could not be run again; will look again');
631
+ }
632
+ if (failed.length) return keep('failing', 'its checks failed again after one retry; it needs a fix');
633
+ return keep('queued', 'still queued');
634
+ }
635
+ if (pr.headRefOid !== entry.pushed) return forget('changed', 'someone pushed to it after it was queued; left for you');
636
+ if ((Number(entry.requeues) || 0) >= maxRequeues) {
637
+ return forget('gave-up', `dropped again after ${maxRequeues} catch-ups; left for you`);
638
+ }
639
+ const fetched = run('git', ['fetch', '-q', 'origin', `+refs/heads/${branch}:refs/remotes/origin/${branch}`], cwd);
640
+ const remote = run('git', ['rev-parse', `refs/remotes/origin/${branch}`], cwd);
641
+ if (!fetched.ok || !remote.ok || remote.stdout !== entry.pushed) {
642
+ return keep('unreadable', 'the branch on github could not be read; will look again');
643
+ }
644
+ const landing = mergeWithBase(entry.pushed, base, branch, cwd, { catchUp: true });
645
+ if (landing.hold) return { ...forget('clash', `${landing.hold}; left for you`), why: landing.hold };
646
+ if (!landing.current) {
647
+ const push = run('git', ['push', 'origin', `${landing.sha}:refs/heads/${branch}`], cwd);
648
+ if (!push.ok) return keep('push', `the catch-up could not be pushed (${push.stderr.split('\n')[0]})`);
649
+ }
650
+ const merge = run('gh', ['pr', 'merge', String(number), '--auto', '--merge', '--match-head-commit', landing.sha], cwd);
651
+ // The catch-up may already be on github; carry it so the next look does not
652
+ // mistake our own push for someone else's.
653
+ if (!merge.ok) return { ...keep('auto-merge', 'auto-merge could not be switched back on; will look again'), pushed: landing.sha };
654
+ return {
655
+ branch, number, state: 'requeued', keep: true, pushed: landing.sha,
656
+ line: `${label}: ${landing.current ? 'queued again' : 'caught up with the main branch and queued again'}`,
657
+ };
658
+ }
659
+
660
+ // The job picker's own hold test, so the two never drift apart. A row that
661
+ // cannot be read counts as held.
662
+ function heldAtClose(row) {
663
+ if (!row || typeof row !== 'object') return true;
664
+ return Boolean(require('./member-task-pick').heldForPerson(row));
665
+ }
666
+
667
+ function defaultCloseTask(id, { number }) {
668
+ const taskDb = require('./task-db');
669
+ const db = taskDb.open();
670
+ // A hold put on the job while its change waited to merge still wins: that
671
+ // job stays for a person.
672
+ const row = taskDb.getTask(db, id);
673
+ if (heldAtClose(row)) return { updated: false };
674
+ const out = taskDb.doneTask(db, {
675
+ id, actor: 'autoland', allowReview: true, autoAccepted: true,
676
+ action: 'merged', proof: `its own pull request #${number} merged`,
677
+ });
678
+ return { ...out, replaced: row.metadata && row.metadata.replaced_pull_requests };
679
+ }
680
+
681
+ // The member's own change merged: close every job it was queued for. A job
682
+ // already closed, or one that cannot be read, is left as it is. Each closed
683
+ // job hands back the clashing pull requests its redo replaced.
684
+ function closeMergedJobs(entry, closeTask) {
685
+ const ids = Array.isArray(entry.task_ids) ? entry.task_ids.filter((id) => typeof id === 'string' && id.trim()) : [];
686
+ const closed = [];
687
+ const replaced = [];
688
+ for (const id of ids) {
689
+ let out = null;
690
+ try {
691
+ out = closeTask(id, { number: entry.number, branch: entry.branch });
692
+ } catch {
693
+ out = null;
694
+ }
695
+ if (!out || !out.updated) continue;
696
+ closed.push(id);
697
+ if (Array.isArray(out.replaced)) replaced.push(...out.replaced);
698
+ }
699
+ return { closed, replaced };
700
+ }
701
+
702
+ // A clashing pull request stayed open after its job's redo merged, one more
703
+ // stale change for a person to sweep (2026-10-15: 29 open, most of them
704
+ // replaced). Close it once the redo merges, unless someone else pushed to it
705
+ // or it is no longer open.
706
+ function closeReplacedPullRequests(replaced, number, cwd) {
707
+ const closed = [];
708
+ for (const old of replaced) {
709
+ if (!old || !Number.isInteger(old.number) || old.number <= 0 || old.number === number) continue;
710
+ if (closed.includes(old.number)) continue;
711
+ const viewed = run('gh', ['pr', 'view', String(old.number), '--json', 'state,headRefOid'], cwd);
712
+ let pr = null;
713
+ try {
714
+ pr = viewed.ok ? JSON.parse(viewed.stdout) : null;
715
+ } catch {
716
+ pr = null;
717
+ }
718
+ if (!pr || pr.state !== 'OPEN' || pr.headRefOid !== old.pushed) continue;
719
+ const done = run('gh', ['pr', 'close', String(old.number), '--comment',
720
+ `Replaced by #${number}, which merged. Its job is done.`], cwd);
721
+ if (done.ok) closed.push(old.number);
722
+ }
723
+ return closed;
724
+ }
725
+
726
+ // A clashing change is dropped from the merge line, and its job used to sit in
727
+ // review forever with one line in a terminal (2026-10-15: fifteen green pull
728
+ // requests stuck clashing, none picked up again). The job goes back on the
729
+ // list instead; the member's next run sees the old branch clashes and starts
730
+ // from the main branch. After two such restarts it stays for a person.
731
+ const MAX_CLASH_RESTARTS = 2;
732
+
733
+ function defaultSendBack(id, { number, pushed, why }) {
734
+ const taskDb = require('./task-db');
735
+ const db = taskDb.open();
736
+ const row = taskDb.getTask(db, id);
737
+ if (!row || row.status !== 'review' || heldAtClose(row)) return { reopened: false };
738
+ const restarts = Number(row.metadata && row.metadata.clash_restarts) || 0;
739
+ if (restarts >= MAX_CLASH_RESTARTS) return { reopened: false };
740
+ // Remember the clashing change so the redo's merge can close it.
741
+ const prior = Array.isArray(row.metadata && row.metadata.replaced_pull_requests) ? row.metadata.replaced_pull_requests : [];
742
+ return taskDb.reopenTask(db, {
743
+ id, actor: 'autoland', fromReview: true,
744
+ metadata: { clash_restarts: restarts + 1, replaced_pull_requests: [...prior, { number, pushed }] },
745
+ reason: `pull request #${number} could not merge: ${why}; start again from the main branch`,
746
+ });
747
+ }
748
+
749
+ function sendBackClashedJobs(entry, why, sendBack) {
750
+ const ids = Array.isArray(entry.task_ids) ? entry.task_ids.filter((id) => typeof id === 'string' && id.trim()) : [];
751
+ const sent = [];
752
+ for (const id of ids) {
753
+ let out = null;
754
+ try {
755
+ out = sendBack(id, { number: entry.number, branch: entry.branch, pushed: entry.pushed, why });
756
+ } catch {
757
+ out = null;
758
+ }
759
+ if (out && out.reopened) sent.push(id);
760
+ }
761
+ return sent;
762
+ }
763
+
764
+ // Look at every branch autoland queued and re-queue the ones GitHub dropped.
765
+ // A branch whose own change merged closes the jobs it was queued for; one that
766
+ // clashes sends its jobs back on the list.
767
+ function requeueDroppedAutolands({ cwd, maxRequeues = MAX_REQUEUES, closeTask = defaultCloseTask, sendBack = defaultSendBack } = {}) {
768
+ const file = queueFile(cwd);
769
+ if (!file) return { checked: 0, results: [], line: 'not inside a git checkout' };
770
+ if (!policyOn(cwd)) return { checked: 0, results: [], line: 'self-landing is not switched on in the main checkout' };
771
+ const entries = readQueue(file);
772
+ const results = [];
773
+ for (const [key, entry] of Object.entries(entries)) {
774
+ const valid = entry && typeof entry.branch === 'string' && entry.branch === key && typeof entry.base === 'string'
775
+ && Number.isInteger(entry.number) && entry.number > 0 && /^[0-9a-f]{40,64}$/.test(String(entry.pushed || ''));
776
+ const result = valid ? requeueOne(entry, cwd, maxRequeues)
777
+ : { branch: key, state: 'unreadable', keep: false, line: `${key}: queue entry could not be read; dropped from the list` };
778
+ if (valid && result.own) {
779
+ const merged = closeMergedJobs(entry, closeTask);
780
+ result.closed = merged.closed;
781
+ const n = result.closed.length;
782
+ if (n) result.line += `; closed ${n === 1 ? 'its job' : `its ${n} jobs`}`;
783
+ result.replaced = closeReplacedPullRequests(merged.replaced, entry.number, cwd);
784
+ if (result.replaced.length) {
785
+ result.line += `; closed the clashing ${result.replaced.length === 1 ? 'change' : 'changes'} it replaced (${result.replaced.map((r) => `#${r}`).join(', ')})`;
786
+ }
787
+ }
788
+ if (valid && result.state === 'clash') {
789
+ result.sent_back = sendBackClashedJobs(entry, result.why, sendBack);
790
+ const n = result.sent_back.length;
791
+ if (n) result.line = result.line.replace(/; left for you$/, `; ${n === 1 ? 'its job went' : `its ${n} jobs went`} back on the list`);
792
+ }
793
+ results.push(result);
794
+ if (!result.keep) delete entries[key];
795
+ else if (result.state === 'requeued' || (result.pushed && result.pushed !== entry.pushed)) {
796
+ entries[key] = { ...entry, pushed: result.pushed, requeues: (Number(entry.requeues) || 0) + 1, requeued_at: new Date().toISOString() };
797
+ } else if (result.reran) {
798
+ entries[key] = { ...entry, reran: result.reran, reran_at: new Date().toISOString() };
799
+ }
800
+ }
801
+ if (results.length) writeQueue(file, entries);
802
+ const requeued = results.filter((r) => r.state === 'requeued').length;
803
+ const rerun = results.filter((r) => r.state === 'rerun').length;
804
+ const closed = results.reduce((sum, r) => sum + (r.closed ? r.closed.length : 0), 0);
805
+ const closedNote = (closed ? `; ${closed} finished ${closed === 1 ? 'job' : 'jobs'} closed after merging` : '')
806
+ + (rerun ? `; ${rerun} failed ${rerun === 1 ? 'check' : 'checks'} run again` : '');
807
+ const line = results.length
808
+ ? `${requeued} of ${results.length} queued ${results.length === 1 ? 'change' : 'changes'} put back in line${closedNote}`
809
+ : 'nothing is waiting in github\'s merge line';
810
+ return { checked: results.length, requeued, rerun, closed, results, line };
811
+ }
812
+
438
813
  module.exports = {
439
814
  autolandMemberRun,
815
+ landingDestination,
816
+ requeueDroppedAutolands,
440
817
  codeStatus,
441
818
  gitCodeState,
442
819
  };
@@ -454,8 +454,14 @@ function memberBoardCommand(...args) {
454
454
  if (args.includes('--follow')) {
455
455
  const dir = progressDir();
456
456
  const write = (obj) => process.stdout.write(`${JSON.stringify(obj)}\n`);
457
+ // Mark where every log ends before reading the snapshot, so a line written
458
+ // while the snapshot prints is streamed after it instead of lost.
459
+ const early = [];
460
+ let send = (event) => early.push(event);
461
+ const follower = followBoard({ dir, emit: (event) => { if (!only || only.includes(event.member)) send(event); } });
457
462
  write({ kind: 'snapshot', at: new Date().toISOString(), dir, members: readBoard({ dir, all, asleep: sleepingHere(), only }) });
458
- const follower = followBoard({ dir, emit: (event) => { if (!only || only.includes(event.member)) write(event); } });
463
+ send = write;
464
+ early.forEach(write);
459
465
  // Runs until ctrl-c or the reader closes the pipe.
460
466
  return new Promise((resolve) => {
461
467
  const done = () => { follower.stop(); resolve(); };
@@ -0,0 +1,126 @@
1
+ 'use strict';
2
+
3
+ // Member changes save map notes as their own small files under
4
+ // atris/refs/map-notes.d/ instead of editing atris/refs/MAP-NOTES.md, so two
5
+ // changes never clash on the shared map. Once an hour the autoland tick folds
6
+ // those files into MAP-NOTES.md on the main branch and removes them, in one
7
+ // commit built without touching any checkout. A push that loses a race with
8
+ // another landing is dropped; the next hour tries again from the new main.
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 NOTES_FILE = 'atris/refs/MAP-NOTES.md';
16
+ const DROP_DIR = 'atris/refs/map-notes.d';
17
+ const FOLD_EVERY_MS = 60 * 60 * 1000;
18
+
19
+ function git(args, cwd, extraEnv = {}) {
20
+ const res = spawnSync('git', args, {
21
+ cwd,
22
+ encoding: 'utf8',
23
+ timeout: 120000,
24
+ env: { ...process.env, ...extraEnv, GIT_NO_REPLACE_OBJECTS: '1' },
25
+ });
26
+ return {
27
+ ok: res.status === 0,
28
+ stdout: String(res.stdout || ''),
29
+ stderr: String(res.stderr || res.error?.message || '').trim(),
30
+ };
31
+ }
32
+
33
+ function foldDue(lastAt, now = Date.now()) {
34
+ const last = Date.parse(lastAt || '');
35
+ return !Number.isFinite(last) || now - last >= FOLD_EVERY_MS;
36
+ }
37
+
38
+ const isHeading = (line) => /^## \S/.test(line);
39
+ const trimEnd = (text) => String(text || '').replace(/\r\n/g, '\n').replace(/\s+$/, '');
40
+
41
+ // Fold drop texts into the notes text. A drop that opens with a "## " heading
42
+ // already in the notes adds its new lines at the end of that section; any
43
+ // other drop is added at the end of the file as written. A drop whose lines
44
+ // are all already there adds nothing.
45
+ function foldNotesText(notes, drops) {
46
+ const lines = trimEnd(notes) ? trimEnd(notes).split('\n') : [];
47
+ let added = 0;
48
+ for (const drop of drops) {
49
+ const body = trimEnd(drop.text).split('\n');
50
+ while (body.length && !body[0].trim()) body.shift();
51
+ if (!body.length) continue;
52
+ const at = isHeading(body[0]) ? lines.findIndex((l) => l.trim() === body[0].trim()) : -1;
53
+ if (at >= 0) {
54
+ let end = lines.findIndex((l, i) => i > at && isHeading(l));
55
+ if (end < 0) end = lines.length;
56
+ const seen = new Set(lines.slice(at, end).map((l) => l.trim()));
57
+ const fresh = body.slice(1).filter((l) => l.trim() && !seen.has(l.trim()));
58
+ if (!fresh.length) continue;
59
+ while (end > at + 1 && !lines[end - 1].trim()) end--;
60
+ lines.splice(end, 0, ...fresh);
61
+ added += fresh.length;
62
+ } else {
63
+ const seen = new Set(lines.map((l) => l.trim()));
64
+ const content = body.filter((l) => l.trim());
65
+ if (content.every((l) => seen.has(l.trim()))) continue;
66
+ if (lines.length) lines.push('');
67
+ lines.push(...body);
68
+ added += content.length;
69
+ }
70
+ }
71
+ return { text: lines.length ? `${lines.join('\n')}\n` : '', added };
72
+ }
73
+
74
+ function mainBranch(root, remote) {
75
+ const head = git(['symbolic-ref', '--short', `refs/remotes/${remote}/HEAD`], root).stdout.trim();
76
+ return head.startsWith(`${remote}/`) ? head.slice(remote.length + 1) : 'master';
77
+ }
78
+
79
+ // Fold every drop file on the main branch into the shared map notes and push
80
+ // the result. Returns what happened; never throws for an ordinary miss.
81
+ function foldMapNotesOnMain(root, { base = '', remote = 'origin', push = true } = {}) {
82
+ base = base || mainBranch(root, remote);
83
+ const remoteRef = `refs/remotes/${remote}/${base}`;
84
+ const fetched = git(['fetch', '-q', remote, `+refs/heads/${base}:${remoteRef}`], root);
85
+ if (!fetched.ok) return { folded: 0, reason: 'main_unreadable', detail: fetched.stderr.split('\n')[0] };
86
+ const baseSha = git(['rev-parse', remoteRef], root).stdout.trim();
87
+ if (!/^[0-9a-f]{40,64}$/.test(baseSha)) return { folded: 0, reason: 'main_unreadable' };
88
+
89
+ const listed = git(['ls-tree', '-r', '--name-only', baseSha, '--', `${DROP_DIR}/`], root);
90
+ const files = listed.stdout.split('\n').filter((f) => f.endsWith('.md')).sort();
91
+ if (!files.length) return { folded: 0, reason: 'nothing_to_fold', base: baseSha };
92
+
93
+ const drops = [];
94
+ for (const file of files) {
95
+ const blob = git(['cat-file', 'blob', `${baseSha}:${file}`], root);
96
+ if (!blob.ok) return { folded: 0, reason: 'drop_unreadable', detail: file };
97
+ drops.push({ file, text: blob.stdout });
98
+ }
99
+ const current = git(['cat-file', 'blob', `${baseSha}:${NOTES_FILE}`], root);
100
+ const { text, added } = foldNotesText(current.ok ? current.stdout : '', drops);
101
+
102
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-map-fold-'));
103
+ try {
104
+ const index = { GIT_INDEX_FILE: path.join(tmp, 'index') };
105
+ const notesPath = path.join(tmp, 'notes');
106
+ fs.writeFileSync(notesPath, text);
107
+ const blob = git(['hash-object', '-w', notesPath], root).stdout.trim();
108
+ const built = git(['read-tree', baseSha], root, index).ok
109
+ && /^[0-9a-f]{40,64}$/.test(blob)
110
+ && git(['update-index', '--add', '--cacheinfo', `100644,${blob},${NOTES_FILE}`], root, index).ok
111
+ && git(['update-index', '--force-remove', '--', ...files], root, index).ok;
112
+ const tree = built ? git(['write-tree'], root, index).stdout.trim() : '';
113
+ const message = `Fold ${files.length} map ${files.length === 1 ? 'note' : 'notes'} from member changes into the shared map`;
114
+ const commit = tree ? git(['commit-tree', tree, '-p', baseSha, '-m', message], root).stdout.trim() : '';
115
+ if (!/^[0-9a-f]{40,64}$/.test(commit)) return { folded: 0, reason: 'commit_failed' };
116
+ if (!push) return { folded: files.length, added, sha: commit, pushed: false, files };
117
+ const pushed = git(['push', '-q', remote, `${commit}:refs/heads/${base}`], root);
118
+ if (!pushed.ok) return { folded: 0, reason: 'push_rejected', detail: pushed.stderr.split('\n')[0], sha: commit };
119
+ git(['update-ref', remoteRef, commit], root);
120
+ return { folded: files.length, added, sha: commit, pushed: true, files };
121
+ } finally {
122
+ fs.rmSync(tmp, { recursive: true, force: true });
123
+ }
124
+ }
125
+
126
+ module.exports = { NOTES_FILE, DROP_DIR, FOLD_EVERY_MS, foldDue, foldNotesText, foldMapNotesOnMain };