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
@@ -5,12 +5,16 @@ const path = require('path');
5
5
  const { spawnSync } = require('child_process');
6
6
  const { hasFlag, readFlag } = require('../lib/arg-parser');
7
7
  const { stampLatestOpenBriefForWorktree } = require('../lib/brief-ledger');
8
- const { isConductorArtifact } = require('../lib/conductor-artifacts');
8
+ const { isConductorArtifact, isConductorUntracked } = require('../lib/conductor-artifacts');
9
9
  const { fixMapDocs } = require('../lib/map-refs');
10
10
  const { sleepSync } = require('../lib/sleep-sync');
11
11
  const close = require('./close');
12
12
 
13
- const REGEN_ADAPTER_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'];
13
+ // Files atris regenerates on its own. Unstaged churn in them is restored
14
+ // before ship commits, unless the commit message names the file. The task
15
+ // projection is rewritten by any task command run in a work folder, and two
16
+ // parallel landings that each carry a copy always clash.
17
+ const REGEN_ADAPTER_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md', '.atris/state/tasks.projection.json'];
14
18
  const COMMAND_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
15
19
  const GIT_OID_PATTERN = /^[0-9a-f]{40,64}$/;
16
20
  const ONE_LAP_PROOF_REF_PATTERN = /^refs\/atris\/one-lap\/([0-9a-f]{40,64})$/;
@@ -159,6 +163,34 @@ function branchName(member, task, now = new Date()) {
159
163
  return `codex/${slugify(member, 'member', 24)}-${slugify(task, 'task', 36)}-${stamp(now)}`;
160
164
  }
161
165
 
166
+ // The newest earlier run of this same member+job, if its commits never reached
167
+ // the base, so a retry continues that work instead of redoing it. Only the
168
+ // newest run counts: once it landed or did nothing, anything older is stale.
169
+ function lastRunBranch(root, owner, task, base, skip) {
170
+ const prefix = `codex/${slugify(owner, 'member', 24)}-${slugify(task, 'task', 36)}-`;
171
+ const listed = runGit(['for-each-ref', '--format=%(refname:short)', `refs/heads/${prefix}*`], { cwd: root, check: false });
172
+ if (listed.status !== 0) return null;
173
+ // Only exact runs of this job (stamp plus optional same-second number), so
174
+ // "fix-login" never matches "fix-login-page".
175
+ const runs = listed.stdout.split('\n').map((name) => name.trim())
176
+ .map((name) => ({ name, run: name.slice(prefix.length).match(/^(\d{8}-\d{6})(?:-(\d+))?$/) }))
177
+ .filter(({ name, run }) => run && name !== skip)
178
+ .sort((a, b) => b.run[1].localeCompare(a.run[1]) || Number(b.run[2] || 1) - Number(a.run[2] || 1));
179
+ if (!runs.length) return null;
180
+ const newest = runs[0].name;
181
+ const cherry = runGit(['cherry', base, newest], { cwd: root, check: false });
182
+ if (cherry.status !== 0) return null;
183
+ return cherry.stdout.split('\n').some((line) => line.startsWith('+')) ? newest : null;
184
+ }
185
+
186
+ // True only when git proves the branch no longer merges cleanly with the base.
187
+ // merge-tree --write-tree exits 1 on conflict; an error or old git keeps the
188
+ // resume as before.
189
+ function conflictsWithBase(root, base, branch) {
190
+ const result = runGit(['merge-tree', '--write-tree', '--name-only', base, branch], { cwd: root, check: false });
191
+ return result.status === 1;
192
+ }
193
+
162
194
  function defaultWorktreePath(root, member, task, now = new Date()) {
163
195
  const name = `${slugify(member, 'member', 24)}-${slugify(task, 'task', 36)}-${stamp(now)}`;
164
196
  return path.join(path.dirname(root), '.agent-worktrees', path.basename(root), name);
@@ -446,10 +478,30 @@ function statusCounts(root, {
446
478
 
447
479
  // Remove conductor plumbing from the index after a broad `git add -A`, so the commit
448
480
  // records engine output only. Paths that were never staged are simply not listed.
481
+ // GitHub refuses a merge with "Base branch was modified" when another PR
482
+ // merged into the same base a moment earlier; the same merge succeeds on a
483
+ // second try. On 2026-10-15 five parallel dispatches lost three landings to
484
+ // it. Retry only that message, a few times, with a short wait.
485
+ const BASE_MODIFIED_PATTERN = /Base branch was modified/i;
486
+ function mergePrWithRetry(mergeArgs, root, { run = spawnSync, wait = sleepSync, attempts = 4 } = {}) {
487
+ let merged = run('gh', mergeArgs, { cwd: root, encoding: 'utf8' });
488
+ for (let attempt = 1; attempt < attempts && merged.status !== 0
489
+ && BASE_MODIFIED_PATTERN.test(`${merged.stderr || ''}${merged.stdout || ''}`); attempt += 1) {
490
+ console.log(`merge: base branch moved, retrying (${attempt + 1}/${attempts})`);
491
+ wait(1500 * attempt);
492
+ merged = run('gh', mergeArgs, { cwd: root, encoding: 'utf8' });
493
+ }
494
+ return merged;
495
+ }
496
+
449
497
  function unstageConductorArtifacts(root) {
450
498
  const staged = runGit(['diff', '--cached', '--name-only'], { cwd: root, check: false });
451
499
  if (staged.status !== 0) return [];
452
- const drop = String(staged.stdout || '').split(/\r?\n/).filter(Boolean).filter(isConductorArtifact);
500
+ const added = runGit(['diff', '--cached', '--name-only', '--diff-filter=A'], { cwd: root, check: false });
501
+ const newFiles = new Set(String(added.stdout || '').split(/\r?\n/).filter(Boolean));
502
+ // A file this commit would add under .atris/ is the tool's own state, never work.
503
+ const drop = String(staged.stdout || '').split(/\r?\n/).filter(Boolean)
504
+ .filter((file) => isConductorArtifact(file) || (newFiles.has(file) && isConductorUntracked(file)));
453
505
  if (drop.length) runGit(['reset', '-q', 'HEAD', '--', ...drop], { cwd: root, check: false });
454
506
  return drop;
455
507
  }
@@ -529,8 +581,18 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
529
581
  reapedBeforeStart = cleaned.removed;
530
582
  cachePrunedBeforeStart = cleaned.cachePruned;
531
583
  } catch {}
532
- const branch = branchOverride || branchName(owner, task, now);
533
- const target = path.resolve(pathOverride || defaultWorktreePath(root, owner, task, now));
584
+ let branch = branchOverride || branchName(owner, task, now);
585
+ let target = path.resolve(pathOverride || defaultWorktreePath(root, owner, task, now));
586
+ // Two runs of the same job in the same second get the same name; number
587
+ // the later one instead of failing it.
588
+ if (!pathOverride && !branchOverride) {
589
+ const baseBranch = branch;
590
+ const baseTarget = target;
591
+ for (let n = 2; fs.existsSync(target) && n < 100; n += 1) {
592
+ branch = `${baseBranch}-${n}`;
593
+ target = `${baseTarget}-${n}`;
594
+ }
595
+ }
534
596
  // Agent/member worktrees always cut from the mainline: honoring the
535
597
  // launcher checkout's upstream let a dispatch inherit another session's
536
598
  // in-progress feature branch (2026-07-09: an auth fix arrived carrying 13
@@ -540,9 +602,24 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
540
602
  if (fs.existsSync(target)) throw new Error(`worktree path already exists: ${target}`);
541
603
  fs.mkdirSync(path.dirname(target), { recursive: true });
542
604
  const staleBase = refreshRemoteRef(root, checkoutBase);
605
+ // A retry of the same job starts from the last run's unlanded commits; it
606
+ // still ships to the mainline.
607
+ let resumedFrom = (!pathOverride && !branchOverride)
608
+ ? lastRunBranch(root, owner, task, checkoutBase, branch)
609
+ : null;
610
+ // A last run that now conflicts with the mainline would hand its conflict
611
+ // to the retry, which then cannot land on the first try. Start fresh; the
612
+ // old branch keeps its commits.
613
+ let skippedResume = null;
614
+ if (resumedFrom && conflictsWithBase(root, checkoutBase, resumedFrom)) {
615
+ skippedResume = resumedFrom;
616
+ resumedFrom = null;
617
+ console.error(`note: the last run's branch ${skippedResume} no longer merges cleanly with ${checkoutBase}, so this run starts fresh; that branch keeps its commits`);
618
+ }
619
+ const startPoint = resumedFrom || checkoutBase;
543
620
  const ref = `refs/heads/${branch}`;
544
621
  const existed = runGit(['show-ref', '--verify', '--quiet', ref], { cwd: root, check: false }).status !== 1;
545
- const baseCommit = runGit(['rev-parse', '--verify', `${checkoutBase}^{commit}`], { cwd: root, check: false });
622
+ const baseCommit = runGit(['rev-parse', '--verify', `${startPoint}^{commit}`], { cwd: root, check: false });
546
623
  const startCommit = baseCommit.status === 0 ? baseCommit.stdout.trim() : '';
547
624
  // With no local copy the start fails on its own; only a real cut gets the warning.
548
625
  if (staleBase && startCommit) {
@@ -554,7 +631,7 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
554
631
  if (registered) {
555
632
  if (!fs.existsSync(target) || fs.realpathSync(registered.path) !== fs.realpathSync(target)
556
633
  || registered.head !== startCommit) return;
557
- const dirt = statusCounts(target, { ignoreUntracked: isConductorArtifact });
634
+ const dirt = statusCounts(target, { ignoreUntracked: isConductorUntracked });
558
635
  if (!dirt || dirt.staged || dirt.unstaged || dirt.untracked) return;
559
636
  // No engine has started. Normal removal still refuses unexpected dirt.
560
637
  runGit(['worktree', 'remove', target], { cwd: root });
@@ -562,7 +639,7 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
562
639
  if (!fs.existsSync(target)) runGit(['update-ref', '-d', ref, startCommit], { cwd: root });
563
640
  };
564
641
  for (let attempt = 0; attempt < 3; attempt += 1) {
565
- const added = runGit(['worktree', 'add', '-b', branch, target, checkoutBase], { cwd: root, check: false });
642
+ const added = runGit(['worktree', 'add', '-b', branch, target, startPoint], { cwd: root, check: false });
566
643
  if (added.status === 0) break;
567
644
  // Git can create the branch before failing to write its tracking config.
568
645
  // Drop only that untouched ref, never an existing branch or a checkout.
@@ -572,6 +649,15 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
572
649
  }
573
650
  sleepSync(100 * (attempt + 1));
574
651
  }
652
+ // A resumed branch takes in what the mainline gained since, or a job that
653
+ // keeps resuming drifts further behind every run until it clashes
654
+ // (2026-10-15: five long jobs sat 103 to 129 commits behind). The clash
655
+ // check above proved this merge is clean; if git still refuses, the run
656
+ // keeps the old start instead of failing.
657
+ if (resumedFrom) {
658
+ const caughtUp = runGit(['merge', '--no-edit', '-m', `Merge ${checkoutBase} into ${branch}`, checkoutBase], { cwd: target, check: false });
659
+ if (caughtUp.status !== 0) runGit(['merge', '--abort'], { cwd: target, check: false });
660
+ }
575
661
  try {
576
662
  runGit(['config', `branch.${branch}.atris-base`, shipBase], { cwd: target });
577
663
  runGit(['config', `branch.${branch}.atris-owner`, owner], { cwd: target });
@@ -587,6 +673,8 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
587
673
  branch,
588
674
  base: shipBase,
589
675
  checkout_base: checkoutBase,
676
+ resumed_from: resumedFrom,
677
+ skipped_resume: skippedResume,
590
678
  workspace_root: root,
591
679
  created_at: now.toISOString(),
592
680
  }, null, 2) + '\n',
@@ -596,7 +684,7 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
596
684
  discardFailedStart();
597
685
  throw error;
598
686
  }
599
- return { path: target, branch, base: shipBase, checkoutBase, owner, reapedBeforeStart, cachePrunedBeforeStart };
687
+ return { path: target, branch, base: shipBase, checkoutBase, resumedFrom, skippedResume, owner, reapedBeforeStart, cachePrunedBeforeStart };
600
688
  }
601
689
 
602
690
  function startWorktree(args) {
@@ -912,7 +1000,7 @@ function shipWorktree(args) {
912
1000
  const ignoredUnstagedFiles = dryRun ? new Set(skippedAdapters) : new Set();
913
1001
  // Conductor plumbing must not make the checkout look dirty, or a flight where the
914
1002
  // engine wrote nothing commits the fleet's own prompt file and calls it work.
915
- const counts = statusCounts(root, { ignoredUnstagedFiles, ignoreUntracked: isConductorArtifact })
1003
+ const counts = statusCounts(root, { ignoredUnstagedFiles, ignoreUntracked: isConductorUntracked })
916
1004
  || { staged: 0, unstaged: 0, untracked: 0 };
917
1005
  const dirty = counts.staged || counts.unstaged || counts.untracked;
918
1006
  if (dirty && !message) {
@@ -952,7 +1040,7 @@ function shipWorktree(args) {
952
1040
  // already present before the verifier ran, so counting it here blocks a clean ship.
953
1041
  const afterVerify = dryRun
954
1042
  ? { staged: 0, unstaged: 0, untracked: 0 }
955
- : statusCounts(root, { ignoreUntracked: isConductorArtifact })
1043
+ : statusCounts(root, { ignoreUntracked: isConductorUntracked })
956
1044
  || { staged: 0, unstaged: 0, untracked: 0 };
957
1045
  if (!dryRun && (afterVerify.staged || afterVerify.unstaged || afterVerify.untracked)) {
958
1046
  console.error(
@@ -992,7 +1080,7 @@ function shipWorktree(args) {
992
1080
  throw new Error((switched.stderr || switched.stdout || `git switch ${targetBranch} failed`).trim());
993
1081
  }
994
1082
  }
995
- const primaryCounts = statusCounts(primary, { ignoreUntracked: isConductorArtifact }) || { staged: 0, unstaged: 0, untracked: 0 };
1083
+ const primaryCounts = statusCounts(primary, { ignoreUntracked: isConductorUntracked }) || { staged: 0, unstaged: 0, untracked: 0 };
996
1084
  if (primaryCounts.staged || primaryCounts.unstaged || primaryCounts.untracked) {
997
1085
  throw new Error(
998
1086
  `primary checkout dirty staged=${primaryCounts.staged} ` +
@@ -1023,7 +1111,7 @@ function shipWorktree(args) {
1023
1111
  const mergeArgs = ['pr', 'merge'];
1024
1112
  if (mergeRef) mergeArgs.push(mergeRef);
1025
1113
  mergeArgs.push('--merge');
1026
- const merged = spawnSync('gh', mergeArgs, { cwd: root, encoding: 'utf8' });
1114
+ const merged = mergePrWithRetry(mergeArgs, root);
1027
1115
  if (merged.status !== 0) throw new Error((merged.stderr || merged.stdout || 'gh pr merge failed').trim());
1028
1116
  // `gh pr merge` on an already-merged PR exits 0 with a warning
1029
1117
  // instead of failing, which would otherwise report a false landing.
@@ -1067,7 +1155,7 @@ function guard(args) {
1067
1155
  // Conductor plumbing (worktree metadata, the fleet prompt written for this very
1068
1156
  // engine, runtime scratch, the brief ledger) must not count as dirt, otherwise the
1069
1157
  // harness blocks the engine with the harness's own files.
1070
- const counts = statusCounts(root, { ignoreUntracked: isConductorArtifact });
1158
+ const counts = statusCounts(root, { ignoreUntracked: isConductorUntracked });
1071
1159
  if (counts && (counts.staged || counts.unstaged || counts.untracked) && !hasFlag(args, '--allow-dirty')) {
1072
1160
  console.error(`blocked: checkout is dirty staged=${counts.staged} unstaged=${counts.unstaged} untracked=${counts.untracked}`);
1073
1161
  return 3;
@@ -1125,12 +1213,14 @@ function canonicalPath(value) {
1125
1213
  }
1126
1214
  }
1127
1215
 
1216
+ // null means the scan failed: we cannot tell who is inside, so callers keep
1217
+ // every folder. An empty list would read as "nobody is here" and delete.
1128
1218
  function listActiveProcessCwds() {
1129
1219
  const result = spawnSync('lsof', ['-n', '-a', '-d', 'cwd', '-F', 'n'], {
1130
1220
  encoding: 'utf8',
1131
1221
  maxBuffer: COMMAND_MAX_BUFFER_BYTES,
1132
1222
  });
1133
- if (result.status !== 0) return [];
1223
+ if (result.error || result.status !== 0) return null;
1134
1224
  return String(result.stdout || '')
1135
1225
  .split(/\r?\n/)
1136
1226
  .filter((line) => line.startsWith('n'))
@@ -1138,7 +1228,45 @@ function listActiveProcessCwds() {
1138
1228
  .filter(Boolean);
1139
1229
  }
1140
1230
 
1231
+ // Absolute paths named on each program's command line, including flag values
1232
+ // like --require=/x.js. A program can run a script from a work folder while
1233
+ // its current folder is elsewhere; the folder scan alone misses it. The list
1234
+ // prints arguments unquoted, so a path with a space is also kept as the rest
1235
+ // of the line from where it starts; the folder match is a prefix check.
1236
+ function parseProcessArgPaths(psOutput) {
1237
+ const paths = new Set();
1238
+ const starts = /(?:^|\s)(?:[^\s=]*=)?(?=\/)/g;
1239
+ for (const raw of String(psOutput || '').split(/\r?\n/)) {
1240
+ const line = raw.trim();
1241
+ for (const match of line.matchAll(starts)) {
1242
+ const rest = line.slice(match.index + match[0].length);
1243
+ const token = rest.split(/\s/, 1)[0];
1244
+ if (token.length > 1) paths.add(token);
1245
+ if (rest.length > token.length) paths.add(rest);
1246
+ }
1247
+ }
1248
+ return [...paths];
1249
+ }
1250
+
1251
+ function listActiveProcessArgPaths() {
1252
+ const result = spawnSync('ps', ['-axww', '-o', 'args='], {
1253
+ encoding: 'utf8',
1254
+ maxBuffer: COMMAND_MAX_BUFFER_BYTES,
1255
+ });
1256
+ if (result.error || result.status !== 0) return [];
1257
+ return parseProcessArgPaths(result.stdout);
1258
+ }
1259
+
1260
+ // Everything a running program is anchored to: its current folder plus any
1261
+ // script path on its command line.
1262
+ function listActiveProcessPaths() {
1263
+ const cwds = listActiveProcessCwds();
1264
+ if (!Array.isArray(cwds)) return cwds;
1265
+ return cwds.concat(listActiveProcessArgPaths());
1266
+ }
1267
+
1141
1268
  function worktreeHasActiveProcess(worktreePath, activeCwds) {
1269
+ if (!Array.isArray(activeCwds)) return true;
1142
1270
  const resolved = canonicalPath(worktreePath);
1143
1271
  const prefix = `${resolved}${path.sep}`;
1144
1272
  return activeCwds.some((cwd) => {
@@ -1186,7 +1314,7 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
1186
1314
  const worktrees = listWorktrees(root);
1187
1315
  const primary = worktrees[0]?.path ? path.resolve(worktrees[0].path) : '';
1188
1316
  const current = path.resolve(root);
1189
- const activeCwds = Array.isArray(activeCwdsOverride) ? activeCwdsOverride.map(canonicalPath) : listActiveProcessCwds();
1317
+ const activeCwds = Array.isArray(activeCwdsOverride) ? activeCwdsOverride.map(canonicalPath) : listActiveProcessPaths();
1190
1318
  const base = normalizeTargetRef(root, baseOverride || defaultShipTarget(root));
1191
1319
  refreshRemoteRef(root, base);
1192
1320
  const candidates = [];
@@ -1221,7 +1349,7 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
1221
1349
  continue;
1222
1350
  }
1223
1351
  if (worktreeHasActiveProcess(wtPath, activeCwds)) {
1224
- kept.push({ ...item, reason: 'active_process' });
1352
+ kept.push({ ...item, reason: activeCwds ? 'active_process' : 'process_scan_failed' });
1225
1353
  continue;
1226
1354
  }
1227
1355
  const rawCounts = statusCounts(wt.path);
@@ -1401,6 +1529,7 @@ function worktreeCommand(args = []) {
1401
1529
  module.exports = {
1402
1530
  branchName,
1403
1531
  collidingFlights,
1532
+ mergePrWithRetry,
1404
1533
  createAgentWorktree,
1405
1534
  deniedLaneForShip,
1406
1535
  createOrFindPr,
@@ -1411,9 +1540,10 @@ module.exports = {
1411
1540
  gitNetworkEnvironment,
1412
1541
  describeFlightAge,
1413
1542
  flightStampMs,
1414
- listActiveProcessCwds,
1543
+ listActiveProcessPaths,
1415
1544
  listWorktrees,
1416
1545
  parseAgentFlightName,
1546
+ parseProcessArgPaths,
1417
1547
  parseWorktrees,
1418
1548
  normalizeTargetRef,
1419
1549
  recordWorktreeRemoval,
package/commands/write.js CHANGED
@@ -341,7 +341,17 @@ function coach(argv, root = process.cwd()) {
341
341
 
342
342
  if (!offline && claudeAvailable()) {
343
343
  const prompt = coachPrompt({ topic, beats, counts, findings, planText, draftText, currentBeat: current.title });
344
+ // Machine memory and load at both ends of the launch, so one killed with
345
+ // SIGKILL says whether the machine was short on memory.
346
+ const endResource = require('../lib/engine-resource').startEngineRun(root, { engine: 'claude', task: 'write coach' });
344
347
  const res = spawnSync('claude', ['-p', prompt], { encoding: 'utf8', timeout: 120000, maxBuffer: 1024 * 1024 });
348
+ // spawnSync does not throw, so a failure is passed in the thrown shape.
349
+ const end = endResource(res.error || res.signal || res.status !== 0 ? {
350
+ code: res.error && res.error.code,
351
+ signal: res.signal,
352
+ stderr: res.stderr,
353
+ message: res.error ? res.error.message : `claude exited ${res.status}`,
354
+ } : null);
345
355
  const out = (res.stdout || '').trim();
346
356
  if (res.status === 0 && /QUESTION:/.test(out)) {
347
357
  console.log(out.split('\n').map((l) => ` ${l}`).join('\n'));
@@ -350,7 +360,8 @@ function coach(argv, root = process.cwd()) {
350
360
  console.log(`\n your move: answer under "## ${current.title}" in draft.md, then \`atris write status\`\n`);
351
361
  return 0;
352
362
  }
353
- console.log(' (claude coach unavailable, falling back to the offline coach)\n');
363
+ const stopped = end && end.cause ? `claude coach stopped by ${res.signal}; ${end.cause}` : 'claude coach unavailable';
364
+ console.log(` (${stopped}, falling back to the offline coach)\n`);
354
365
  }
355
366
 
356
367
  // Offline coach: still proactive, still gets you going. Seeds + one question.
@@ -1239,17 +1239,32 @@ function buildDigestPrompt(briefs = []) {
1239
1239
 
1240
1240
  function defaultDigestRunner(prompt, deps = {}) {
1241
1241
  const spawn = deps.spawnSync || spawnSync;
1242
- return spawn('claude', ['-p', prompt, '--model', 'claude-haiku-5-5'], {
1242
+ // Machine memory and load at both ends of the launch, so one killed with
1243
+ // SIGKILL says whether the machine was short on memory.
1244
+ const endResource = require('../lib/engine-resource').startEngineRun(deps.cwd || process.cwd(), { engine: 'claude', task: 'youtube digest' });
1245
+ const result = spawn('claude', ['-p', prompt, '--model', 'claude-haiku-5-5'], {
1243
1246
  encoding: 'utf8',
1244
1247
  timeout: DIGEST_ENGINE_TIMEOUT_MS,
1245
1248
  maxBuffer: 10 * 1024 * 1024,
1246
1249
  });
1250
+ // spawnSync does not throw, so a failure is passed in the thrown shape.
1251
+ const failed = !result || result.error || result.signal || result.status !== 0;
1252
+ const end = endResource(failed ? {
1253
+ code: result && result.error && result.error.code,
1254
+ signal: result && result.signal,
1255
+ stderr: result && result.stderr,
1256
+ message: result && result.error ? result.error.message : `claude exited ${result && result.status}`,
1257
+ } : null);
1258
+ return end && end.cause && result && !(result.error && result.error.code === 'ETIMEDOUT') ? { ...result, cause: end.cause } : result;
1247
1259
  }
1248
1260
 
1249
1261
  function invokeDigestEngine(prompt, deps = {}) {
1250
1262
  const runner = deps.runner || ((nextPrompt) => defaultDigestRunner(nextPrompt, deps));
1251
1263
  const result = runner(prompt, deps);
1252
1264
  if (typeof result === 'string') return result.trim();
1265
+ if (result && result.signal && !result.error) {
1266
+ throw new Error(`digest engine stopped by ${result.signal}${result.cause ? `; ${result.cause}` : ''}`);
1267
+ }
1253
1268
  if (!result || result.error || (result.status != null && result.status !== 0)) {
1254
1269
  const detail = String(result?.stderr || result?.error?.message || 'digest engine failed').trim();
1255
1270
  throw new Error(detail || 'digest engine failed');
@@ -1401,7 +1416,7 @@ function runYoutubeDigest(args = [], deps = {}) {
1401
1416
  const prompt = buildDigestPrompt(briefs);
1402
1417
  let text;
1403
1418
  try {
1404
- text = invokeDigestEngine(prompt, deps);
1419
+ text = invokeDigestEngine(prompt, { ...deps, cwd });
1405
1420
  } catch (err) {
1406
1421
  output(err.message || 'digest engine failed');
1407
1422
  return 1;
@@ -123,4 +123,5 @@ function evaluateAcceptVerify(task, workspaceRoot, { cache = null } = {}) {
123
123
  module.exports = {
124
124
  storedVerifyCommand,
125
125
  evaluateAcceptVerify,
126
+ nonFalsifyingIssue,
126
127
  };
@@ -1398,6 +1398,7 @@ module.exports = {
1398
1398
  DENIED_TAGS,
1399
1399
  deniedTagOf,
1400
1400
  candidatePolicyGate,
1401
+ declaredCandidatePathValues,
1401
1402
  declaredProtectedLane,
1402
1403
  sniffedProtectedLane,
1403
1404
  protectedLaneOf,
package/lib/autoland.js CHANGED
@@ -267,6 +267,8 @@ function acceptedInLastDay(tasks, now = Date.now()) {
267
267
  result: String(t.result || t.metadata?.result || '').slice(0, 220),
268
268
  happened: String(t.review?.landing?.happened || t.metadata?.landing_happened || '').slice(0, 300),
269
269
  member: String(t.claimed_by || t.metadata?.executed_by || t.assigned_to || '').trim(),
270
+ // Accepted is a review verdict; only a passed landing proof is landed.
271
+ landed: t.metadata?.landing?.status === 'landed',
270
272
  };
271
273
  if (t.metadata?.auto_accepted_at) auto.push(entry);
272
274
  else human.push(entry);
@@ -481,6 +483,9 @@ function unexplainedSummaryRows(items, label) {
481
483
  if (label === 'landed') {
482
484
  return [`${plur(count, 'landed result')} stayed hidden because ${count === 1 ? 'its outcome was' : 'their outcomes were'} unclear`];
483
485
  }
486
+ if (label === 'accepted') {
487
+ return [`${plur(count, 'accepted result')} stayed hidden because ${count === 1 ? 'its outcome was' : 'their outcomes were'} unclear`];
488
+ }
484
489
  if (label === 'in review') {
485
490
  return [`${plur(count, 'review')} stayed hidden because ${count === 1 ? 'its next action was' : 'their next actions were'} unclear`];
486
491
  }
@@ -547,6 +552,10 @@ function composeDigest({ accepted, waiting, protectedWaiting = [], waitingWishes
547
552
  lines.push(`atris ${project}: last 24 hours`);
548
553
  const autoCount = accepted.auto.length;
549
554
  const humanCount = accepted.human.length;
555
+ const landedCount = accepted.auto.filter((item) => item.landed === true).length;
556
+ // "N landed" only when every one of them proved it; otherwise say both.
557
+ const finished = landedCount === autoCount ? `${autoCount} landed` : `${autoCount} accepted, ${landedCount} landed`;
558
+ const finishedWord = landedCount === autoCount ? 'landed' : 'accepted';
550
559
  const wishWaits = (Array.isArray(waitingWishes) ? waitingWishes : [])
551
560
  .filter((wish) => wish && String(wish.text || '').trim());
552
561
  if (autoCount > 0) {
@@ -564,8 +573,8 @@ function composeDigest({ accepted, waiting, protectedWaiting = [], waitingWishes
564
573
  // The header states the actual bar: under accept-all the claim
565
574
  // "verified twice" would be a lie the operator builds trust on.
566
575
  lines.push(acceptAll
567
- ? `${autoCount} landed; ${plur(ready.length, 'clear result')}, protected lanes held back for you:`
568
- : `${autoCount} landed; ${plur(ready.length, 'clear result')}, each verified twice with proof on file:`);
576
+ ? `${finished}; ${plur(ready.length, 'clear result')}, protected lanes held back for you:`
577
+ : `${finished}; ${plur(ready.length, 'clear result')}, each verified twice with proof on file:`);
569
578
  for (const { item, line } of visibleWork) {
570
579
  lines.push('');
571
580
  lines.push(`- ${line || item.ref}${memberTrustLabel(item.member, root)}`);
@@ -577,9 +586,9 @@ function composeDigest({ accepted, waiting, protectedWaiting = [], waitingWishes
577
586
  : `${plur(held, 'more result')} when you want them: atris autoland digest`);
578
587
  }
579
588
  } else {
580
- lines.push(`${autoCount} landed with proof on file; no clear result was ready to show:`);
589
+ lines.push(`${finished} with proof on file; no clear result was ready to show:`);
581
590
  }
582
- for (const row of unexplainedSummaryRows(unexplainedWork, 'landed')) lines.push(row);
591
+ for (const row of unexplainedSummaryRows(unexplainedWork, finishedWord)) lines.push(row);
583
592
  }
584
593
  if (autoCount + humanCount === 0) lines.push('nothing finished in the last day');
585
594
  if (waiting.length > 0) {
@@ -0,0 +1,131 @@
1
+ // Work and review in rounds (Astra's rule: let agents work 50 minutes, then
2
+ // take 10). In the work window the campaign loop runs: it plans, runs missions
3
+ // at once, and judges. In the break the judge runs if due, reserves are
4
+ // reconciled, and the owner writes a short review of the round into the
5
+ // campaign's lessons file, which the next round's planning reads. Each round
6
+ // leaves the campaign a little smarter than the last.
7
+
8
+ 'use strict';
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { getCampaign, openCampaignStore } = require('./campaign-store');
13
+
14
+ const LESSON_LINES_FOR_PLANNING = 30;
15
+
16
+ function parseDuration(text, fallbackMs) {
17
+ if (text === undefined || text === null || text === '') return fallbackMs;
18
+ const match = /^(\d+(?:\.\d+)?)\s*(s|m|h)?$/i.exec(String(text).trim());
19
+ if (!match) throw new Error(`durations look like 50m, 10m, 90s, or 1h (got ${text})`);
20
+ const unit = (match[2] || 'm').toLowerCase();
21
+ return Math.round(Number(match[1]) * { s: 1000, m: 60000, h: 3600000 }[unit]);
22
+ }
23
+
24
+ function lessonsPath(root, campaignId) {
25
+ return path.join(root, 'atris', 'campaigns', campaignId, 'lessons.md');
26
+ }
27
+
28
+ // The newest lessons, for the planner.
29
+ function readLessons(root, campaignId, lines = LESSON_LINES_FOR_PLANNING) {
30
+ try {
31
+ const text = fs.readFileSync(lessonsPath(root, campaignId), 'utf8');
32
+ return text.split('\n').filter((line) => line.trim().startsWith('- ')).slice(-lines).join('\n');
33
+ } catch {
34
+ return '';
35
+ }
36
+ }
37
+
38
+ function roundSummary(before, after) {
39
+ const fresh = after.missions.filter((m) => !before.missions.some((b) => b.id === m.id && b.status === m.status));
40
+ const kept = fresh.filter((m) => m.status === 'ended' && m.outcome === 'kept').length;
41
+ const failed = fresh.filter((m) => m.status === 'ended' && m.outcome !== 'kept').length;
42
+ const vetoed = fresh.filter((m) => m.status === 'vetoed').length;
43
+ const spent = Math.round((after.budget.spent - before.budget.spent) * 100) / 100;
44
+ return { kept, failed, vetoed, spent, fresh };
45
+ }
46
+
47
+ function reviewPrompt(view, summary, lessons) {
48
+ const missions = summary.fresh.map((m) => `- ${m.id}: ${m.status}${m.outcome ? ` (${m.outcome})` : ''}${m.cost_usd !== null ? `, $${m.cost_usd}` : ''}`).join('\n') || '- none ended this round';
49
+ return [
50
+ 'You review one round of an Atris campaign. Reply with 2 to 6 short lessons, one per line, each starting with "- ".',
51
+ 'A lesson says what to do differently next round, in plain words. Do not repeat a lesson already listed.',
52
+ '',
53
+ `Campaign: ${view.title}`,
54
+ `Judge: ${view.judge}`,
55
+ `Judge now: ${view.last_judge ? `${view.last_judge.passed ? 'passed' : 'failed'}${view.last_judge.reading !== null ? ` (reading ${view.last_judge.reading})` : ''}: ${view.answer.text || ''}` : 'never run'}`,
56
+ `This round: ${summary.kept} kept, ${summary.failed} failed, ${summary.vetoed} could not start, $${summary.spent} spent`,
57
+ 'Missions this round:',
58
+ missions,
59
+ '',
60
+ 'Lessons so far:',
61
+ lessons || '- none yet',
62
+ ].join('\n');
63
+ }
64
+
65
+ function makeCadence(root, loop, options = {}, deps = {}) {
66
+ const store = deps.store || openCampaignStore(root);
67
+ const log = deps.log || ((line) => console.log(line));
68
+ const now = deps.now || (() => Date.now());
69
+ const sleep = deps.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
70
+ const ask = deps.ask;
71
+ const workMs = parseDuration(options.work, 50 * 60000);
72
+ const breakMs = parseDuration(options.break, 10 * 60000);
73
+ const rounds = Number(options.rounds) > 0 ? Number(options.rounds) : Infinity;
74
+
75
+ async function review(id, before) {
76
+ const after = getCampaign(store, id);
77
+ const summary = roundSummary(before, after);
78
+ const lessons = readLessons(root, id, 200);
79
+ let written = [];
80
+ if (ask) {
81
+ const answer = await ask(root, after, reviewPrompt(after, summary, lessons));
82
+ // A lesson already on file is not written twice.
83
+ const known = new Set(lessons.split('\n').map((line) => line.trim().toLowerCase()));
84
+ written = String((answer && answer.text) || '').split('\n').map((line) => line.trim())
85
+ .filter((line) => line.startsWith('- ') && !known.has(line.toLowerCase())).slice(0, 6);
86
+ }
87
+ const file = lessonsPath(root, id);
88
+ fs.mkdirSync(path.dirname(file), { recursive: true });
89
+ if (!fs.existsSync(file)) fs.writeFileSync(file, `# Lessons: ${after.title}\n\nWritten by the owner at each review; the next round plans from them.\n`);
90
+ const stamp = new Date(now()).toISOString().slice(0, 16).replace('T', ' ');
91
+ const header = `\n## ${stamp}: ${summary.kept} kept, ${summary.failed} failed, $${summary.spent} spent\n`;
92
+ fs.appendFileSync(file, `${header}${(written.length ? written : ['- (no review written this round)']).join('\n')}\n`);
93
+ return { summary, lessons: written };
94
+ }
95
+
96
+ async function run(id) {
97
+ let round = 0;
98
+ let last = { reason: 'not started' };
99
+ while (round < rounds) {
100
+ round += 1;
101
+ const before = getCampaign(store, id);
102
+ const until = now() + workMs;
103
+ log(`round ${round}: working until ${new Date(until).toISOString().slice(11, 16)}`);
104
+ let stopped = null;
105
+ while (now() < until) {
106
+ const result = await loop.step(id);
107
+ if (result.stop) {
108
+ stopped = result.reason;
109
+ break;
110
+ }
111
+ }
112
+ const breakStarted = now();
113
+ log(`round ${round}: review`);
114
+ const { summary, lessons } = await review(id, before);
115
+ log(` ${summary.kept} kept, ${summary.failed} failed, $${summary.spent} spent; ${lessons.length} lesson${lessons.length === 1 ? '' : 's'} written`);
116
+ const view = getCampaign(store, id);
117
+ last = { reason: stopped || 'work window ended', round };
118
+ // Finished, out of money, or waiting on a person: no point in another round.
119
+ if (['done', 'out_of_budget', 'needs_you', 'paused'].includes(view.state) || (stopped && !/missions running|nothing usable/.test(stopped))) {
120
+ return { rounds: round, reason: stopped || view.state, campaign: view };
121
+ }
122
+ const rest = breakMs - (now() - breakStarted);
123
+ if (round < rounds && rest > 0) await sleep(rest);
124
+ }
125
+ return { rounds: round, reason: last.reason, campaign: getCampaign(store, id) };
126
+ }
127
+
128
+ return { run, review };
129
+ }
130
+
131
+ module.exports = { lessonsPath, makeCadence, parseDuration, readLessons, reviewPrompt };