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
package/commands/team.js CHANGED
@@ -175,6 +175,19 @@ function memberIsActive({ awake, activity }) {
175
175
 
176
176
  const ACTIVITY_WINDOW_MS = IDLE_AFTER_DAYS * 24 * 60 * 60 * 1000;
177
177
 
178
+ // A log whose only entry is the note setup writes ("Member initialized") is
179
+ // not work: a fresh project's members have never run.
180
+ const SEED_LOG_MAX_BYTES = 1024;
181
+ function isSeedOnlyLog(file) {
182
+ try {
183
+ if (fs.statSync(file).size > SEED_LOG_MAX_BYTES) return false;
184
+ const headings = fs.readFileSync(file, 'utf8').split(/\r?\n/).filter((line) => line.startsWith('## '));
185
+ return headings.length > 0 && headings.every((line) => /·\s*Member initialized\s*$/.test(line));
186
+ } catch {
187
+ return false;
188
+ }
189
+ }
190
+
178
191
  // Dated file names under the member's logs/ folder and one level below it
179
192
  // (logs/2026/2026-09-28.md). The date in the name, never the file time: a
180
193
  // fresh checkout stamps every file with today.
@@ -191,12 +204,12 @@ function memberLogDays(member, root) {
191
204
  try { inner = fs.readdirSync(path.join(dir, entry.name)); } catch { inner = []; }
192
205
  for (const name of inner) {
193
206
  const day = logFileDay(name);
194
- if (day) days.push(day);
207
+ if (day && !isSeedOnlyLog(path.join(dir, entry.name, name))) days.push(day);
195
208
  }
196
209
  continue;
197
210
  }
198
211
  const day = logFileDay(entry.name);
199
- if (day) days.push(day);
212
+ if (day && !isSeedOnlyLog(path.join(dir, entry.name))) days.push(day);
200
213
  }
201
214
  return days;
202
215
  }
@@ -432,6 +445,63 @@ function liveRunJson(run) {
432
445
  };
433
446
  }
434
447
 
448
+ function realFolder(folder) {
449
+ try { return fs.realpathSync(folder); } catch { return path.resolve(folder); }
450
+ }
451
+
452
+ // Each run's working folder in one lookup: /proc on Linux, lsof elsewhere.
453
+ // A folder that cannot be read stays unknown.
454
+ function liveRunFolders(pids, deps = {}) {
455
+ if (typeof deps.runFolders === 'function') return deps.runFolders(pids);
456
+ const folders = new Map();
457
+ if (!pids.length) return folders;
458
+ if (process.platform === 'linux') {
459
+ for (const pid of pids) {
460
+ try { folders.set(pid, fs.readlinkSync(`/proc/${pid}/cwd`)); } catch { /* unknown */ }
461
+ }
462
+ return folders;
463
+ }
464
+ let result;
465
+ try {
466
+ result = spawnSync('lsof', ['-a', '-p', pids.join(','), '-d', 'cwd', '-Fpn'], { encoding: 'utf8', timeout: 3000 });
467
+ } catch {
468
+ return folders;
469
+ }
470
+ let pid = null;
471
+ for (const line of String(result?.stdout || '').split(/\r?\n/)) {
472
+ if (line.startsWith('p')) pid = Number(line.slice(1));
473
+ else if (line.startsWith('n') && pid) folders.set(pid, line.slice(1));
474
+ }
475
+ return folders;
476
+ }
477
+
478
+ // This project's folders: the checkout and every work copy git knows for it.
479
+ function projectFolders(root, deps = {}) {
480
+ if (Array.isArray(deps.projectFolders)) return deps.projectFolders.map(realFolder);
481
+ const folders = [realFolder(root)];
482
+ const result = spawnSync('git', ['worktree', 'list', '--porcelain'], { cwd: root, encoding: 'utf8', timeout: 3000 });
483
+ if (result && result.status === 0) {
484
+ for (const line of String(result.stdout || '').split(/\r?\n/)) {
485
+ if (line.startsWith('worktree ')) folders.push(realFolder(line.slice('worktree '.length)));
486
+ }
487
+ }
488
+ return [...new Set(folders)];
489
+ }
490
+
491
+ // Live runs on this machine that work in this project. A run in another
492
+ // project is not this team working; a run whose folder cannot be read stays.
493
+ function liveRunsInProject(runs, root, deps = {}) {
494
+ if (!runs.length) return runs;
495
+ const folders = projectFolders(root, deps);
496
+ const where = liveRunFolders(runs.map((run) => run.pid), deps);
497
+ return runs.filter((run) => {
498
+ const folder = where.get(run.pid);
499
+ if (!folder) return true;
500
+ const real = realFolder(folder);
501
+ return folders.some((base) => real === base || real.startsWith(base + path.sep));
502
+ });
503
+ }
504
+
435
505
  // The table's rows, in order, for text and json alike. Parked members are
436
506
  // left out unless `all` (or unless they are working right now).
437
507
  function teamTableRows(rosterRows, { lineup = null, liveRuns = [], all = false } = {}) {
@@ -537,6 +607,139 @@ function teamJsonRows(allRows, deps = {}) {
537
607
  return [...members, ...others];
538
608
  }
539
609
 
610
+ // The first paragraph under the title of a MEMBER.md, for files written
611
+ // without a header block. A leading "role:" label is dropped.
612
+ function memberFileOpening(member) {
613
+ let text = '';
614
+ try { text = fs.readFileSync(member.path, 'utf8'); } catch { return ''; }
615
+ const body = text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '');
616
+ const paragraph = [];
617
+ for (const line of body.split(/\r?\n/)) {
618
+ const trimmed = line.trim();
619
+ if (trimmed.startsWith('#') || !trimmed) {
620
+ if (paragraph.length) break;
621
+ continue;
622
+ }
623
+ paragraph.push(trimmed);
624
+ }
625
+ return paragraph.join(' ').replace(/^role:\s*/i, '');
626
+ }
627
+
628
+ const MEMBER_DOES_MAX = 140;
629
+
630
+ // What a member does, in its own words: the description, else the role,
631
+ // else the opening of its file. Only the first sentence, so the column
632
+ // stays one idea per member.
633
+ function memberDoes(member) {
634
+ const role = String(member?.role || '').trim();
635
+ const candidates = [member?.description, role === '(no role)' ? '' : role, memberFileOpening(member)];
636
+ const name = String(member?.name || '').trim().toLowerCase();
637
+ for (const candidate of candidates) {
638
+ const sentence = String(candidate || '').trim().split(/(?<=\.)\s/)[0]
639
+ .replace(/[\u2013\u2014]/g, '-')
640
+ .replace(/\s+/g, ' ')
641
+ .toLowerCase()
642
+ .replace(/[.\s]+$/, '');
643
+ const text = name && sentence.startsWith(name) ? sentence.slice(name.length).replace(/^[\s:,-]+/, '') : sentence;
644
+ if (!text) continue;
645
+ if (text.length <= MEMBER_DOES_MAX) return text;
646
+ const cut = text.slice(0, MEMBER_DOES_MAX);
647
+ return `${cut.slice(0, cut.lastIndexOf(' ')).replace(/[\s,;:-]+$/, '')}…`;
648
+ }
649
+ return 'no role written yet';
650
+ }
651
+
652
+ // `atris member roster`: one line per member with the full name, working or
653
+ // idle, the engine it really runs on, and what it does. Same facts as the
654
+ // team table: a live run in this project means working, the lineup names
655
+ // the engine. Parked members leave the list unless `all`.
656
+ function memberRosterRows(members, { lineup = null, liveRuns = [], parked = new Set(), all = false } = {}) {
657
+ const runsByMember = new Map();
658
+ for (const run of Array.isArray(liveRuns) ? liveRuns : []) {
659
+ const key = String(run.member || '').trim().toLowerCase();
660
+ if (key && !runsByMember.has(key)) runsByMember.set(key, run);
661
+ }
662
+ const rows = members.map((member) => {
663
+ const key = String(member.name || '').trim().toLowerCase();
664
+ const run = runsByMember.get(key);
665
+ const place = memberLineup(lineup, key);
666
+ const isParked = parked.has(member.name) || isParkedFrontmatter(member.frontmatter);
667
+ return {
668
+ name: member.name,
669
+ state: run ? 'working' : isParked ? 'parked' : 'idle',
670
+ engine: run ? liveRunEngineText(run) : place && place.engine ? placedEngineText(place) : 'not set',
671
+ does: memberDoes(member),
672
+ parked: isParked,
673
+ };
674
+ });
675
+ const order = { working: 0, idle: 1, parked: 2 };
676
+ return rows
677
+ .filter((row) => all || !row.parked || row.state === 'working')
678
+ .sort((a, b) => order[a.state] - order[b.state] || a.name.localeCompare(b.name));
679
+ }
680
+
681
+ const MEMBER_ROSTER_COLUMNS = Object.freeze([
682
+ { header: 'MEMBER' },
683
+ { header: 'STATUS' },
684
+ { header: 'ENGINE' },
685
+ { header: 'WHAT IT DOES', wrap: true, min: 20 },
686
+ ]);
687
+
688
+ function renderMemberRoster(members, deps = {}) {
689
+ if (!members.length) return 'no team members yet. create one with: atris member create <name> --role="..."';
690
+ const width = terminalWidth(deps.termWidth);
691
+ const rows = memberRosterRows(members, deps);
692
+ const lines = [renderTable(MEMBER_ROSTER_COLUMNS, rows.map((row) => [row.name, row.state, row.engine, row.does]), { width })];
693
+ const working = rows.filter((row) => row.state === 'working').length;
694
+ lines.push('', `${rows.length} ${rows.length === 1 ? 'member' : 'members'}, ${working} working now.`);
695
+ const shown = new Set(rows.map((row) => row.name));
696
+ const hidden = members.filter((member) => !shown.has(member.name)).map((member) => member.name);
697
+ if (hidden.length) {
698
+ const names = [...hidden];
699
+ names[0] = `parked (${hidden.length}): ${names[0]}`;
700
+ names[names.length - 1] = `${names[names.length - 1]} · atris member roster --all to show them`;
701
+ lines.push(wrapCommaNames(names, width));
702
+ }
703
+ return lines.join('\n');
704
+ }
705
+
706
+ function memberRosterCommand(args = [], deps = {}) {
707
+ const root = deps.root || process.cwd();
708
+ const members = (deps.members || collectMembers(root, deps)).filter((member) => !isTemplateMember(member));
709
+ const parked = new Set(members.filter((member) => isMemberParked(root, member.name)).map((member) => member.name));
710
+ const lineup = deps.lineup !== undefined ? deps.lineup : readLineupSafe(root, deps.lineupNow || new Date());
711
+ const liveRuns = Array.isArray(deps.liveRuns) ? deps.liveRuns : liveRunsInProject(listLiveEngineRuns(deps), root, deps);
712
+ const view = { lineup: lineup && lineup.ok ? lineup : null, liveRuns, parked, all: args.includes('--all') };
713
+ const write = deps.write || ((text) => process.stdout.write(text));
714
+ if (args.includes('--json')) {
715
+ const byName = new Map(memberRosterRows(members, { ...view, all: true }).map((row) => [row.name, row]));
716
+ write(`${JSON.stringify({
717
+ ok: true,
718
+ count: members.length,
719
+ members: members.map((member) => {
720
+ const row = byName.get(member.name);
721
+ return {
722
+ name: member.name,
723
+ ...(row.parked ? { parked: true } : {}),
724
+ state: row.state,
725
+ engine: row.engine,
726
+ does: row.does,
727
+ role: member.role,
728
+ format: member.format,
729
+ skills: member.format === 'directory' ? member.skillCount : null,
730
+ context: member.format === 'directory' ? member.contextCount : null,
731
+ version: member.version || null,
732
+ };
733
+ }),
734
+ }, null, 2)}\n`);
735
+ return 0;
736
+ }
737
+ const output = renderMemberRoster(members, { ...view, termWidth: deps.termWidth });
738
+ if (deps.lineup === undefined && lineup && !lineup.ok) write(`${output}\n\n${LINEUP_UNREADABLE}\n`);
739
+ else write(`${output}\n`);
740
+ return 0;
741
+ }
742
+
540
743
  function renderTeamRosterHtml(allRows, meta = {}) {
541
744
  const rosterRows = withParkedLabels(allRows, Boolean(meta.all)).rows;
542
745
  const activeRows = rosterRows.filter((entry) => entry.active);
@@ -907,8 +1110,10 @@ function teamCommand(args = [], deps = {}) {
907
1110
  const lineup = deps.lineup !== undefined
908
1111
  ? deps.lineup
909
1112
  : readLineupSafe(deps.root || repoRoot(deps.cwd || process.cwd()), deps.lineupNow || new Date());
910
- // Who is working this minute: live engine runs on this machine.
911
- const liveRuns = Array.isArray(deps.liveRuns) ? deps.liveRuns : listLiveEngineRuns(deps);
1113
+ // Who is working this minute: live engine runs in this project.
1114
+ const liveRuns = Array.isArray(deps.liveRuns)
1115
+ ? deps.liveRuns
1116
+ : liveRunsInProject(listLiveEngineRuns(deps), deps.root || repoRoot(deps.cwd || process.cwd()), deps);
912
1117
  const output = json
913
1118
  ? JSON.stringify(teamJsonRows(roster, { lineup, liveRuns }), null, 2)
914
1119
  : renderTeamRoster(roster, { ...deps, lineup, liveRuns, all });
@@ -933,6 +1138,9 @@ module.exports = {
933
1138
  collectTeamPrune,
934
1139
  recordMemberActivity,
935
1140
  collectTeamRoster,
1141
+ memberRosterCommand,
1142
+ memberRosterRows,
1143
+ renderMemberRoster,
936
1144
  renderTeamPrune,
937
1145
  renderTeamRoster,
938
1146
  teamCommand,
@@ -261,6 +261,9 @@ function runRunnerPrompt(root, prompt) {
261
261
  const cmd = buildRunnerCommand({ promptFile });
262
262
  const env = { ...process.env };
263
263
  delete env.CLAUDECODE;
264
+ // Machine memory and load at both ends of the launch, so one killed with
265
+ // SIGKILL says whether the machine was short on memory.
266
+ const endResource = require('../lib/engine-resource').startEngineRun(root, { engine: 'runner', task: 'unknowns' });
264
267
  const result = spawnSync(cmd, [], {
265
268
  cwd: root,
266
269
  encoding: 'utf8',
@@ -270,7 +273,17 @@ function runRunnerPrompt(root, prompt) {
270
273
  maxBuffer: 10 * 1024 * 1024,
271
274
  env,
272
275
  });
273
- if (result.error) return { ok: false, reason: result.error.message || 'runner failed' };
276
+ // spawnSync does not throw, so a failure is passed in the thrown shape.
277
+ const failed = result.error || result.signal || result.status !== 0;
278
+ const end = endResource(failed ? {
279
+ code: result.error && result.error.code,
280
+ signal: result.signal,
281
+ stderr: result.stderr,
282
+ message: result.error ? result.error.message : `runner exited ${result.status}`,
283
+ } : null);
284
+ const cause = end && end.cause ? `; ${end.cause}` : '';
285
+ if (result.error) return { ok: false, reason: `${result.error.message || 'runner failed'}${cause}` };
286
+ if (result.signal) return { ok: false, reason: `runner stopped by ${result.signal}${cause}` };
274
287
  if (result.status !== 0) {
275
288
  return { ok: false, reason: clipLine(result.stderr || result.stdout || `runner exited ${result.status}`, 300) };
276
289
  }
@@ -618,5 +631,6 @@ module.exports = {
618
631
  unknownsCommand,
619
632
  showHelp,
620
633
  fallbackPayload,
634
+ runRunnerPrompt,
621
635
  gatherTerritoryContext,
622
636
  };
@@ -581,6 +581,45 @@ function checkMapForFiles(atrisDir, changes) {
581
581
  return { documented: documented >= fileChanges.length / 2 };
582
582
  }
583
583
 
584
+ const NPM_MUTATING_SUBCOMMANDS = new Set([
585
+ 'i', 'install', 'add',
586
+ 'un', 'uninstall', 'rm', 'r', 'remove', 'unlink',
587
+ 'up', 'update', 'upgrade',
588
+ ]);
589
+ const NPM_GLOBAL_FLAG_RE = /^(?:-g|--global(?:=true)?|--location=global)$/;
590
+ const NPM_GLOBAL_ENV_RE = /\bnpm_config_(?:global=(?:true|1)\b|location=global\b)/i;
591
+
592
+ /**
593
+ * Return the first line of a rubric script that installs, removes, or links
594
+ * a package globally, or null. A rubric once ran `npm i -g atris@<old>` and
595
+ * swapped the CLI under every agent on the machine; proofs stay read-only.
596
+ */
597
+ function findGlobalInstall(script) {
598
+ const text = String(script || '').replace(/\\\n/g, ' ');
599
+ // An exported or inline npm_config_global turns any later install global.
600
+ const globalEnv = NPM_GLOBAL_ENV_RE.test(text.replace(/(^|\s)#.*$/gm, ''));
601
+ for (const rawLine of text.split('\n')) {
602
+ const line = rawLine.replace(/(^|\s)#.*$/, '').trim();
603
+ if (!line) continue;
604
+ for (const segment of line.split(/&&|\|\||[;|&]/)) {
605
+ const tokens = segment.trim().split(/\s+/).filter(Boolean);
606
+ while (tokens.length && (tokens[0] === 'sudo' || /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0]))) tokens.shift();
607
+ const tool = tokens[0];
608
+ if (tool === 'yarn' && tokens[1] === 'global' && tokens[2] !== 'list' && tokens[2] !== 'bin' && tokens[2] !== 'dir') {
609
+ return rawLine.trim();
610
+ }
611
+ if (tool !== 'npm' && tool !== 'pnpm') continue;
612
+ const args = tokens.slice(1);
613
+ const sub = args.find((token, i) => !token.startsWith('-') && args[i - 1] !== '--location');
614
+ if (sub === 'link' || sub === 'ln') return rawLine.trim();
615
+ const isGlobal = globalEnv || args.some((token, i) =>
616
+ NPM_GLOBAL_FLAG_RE.test(token) || (token === '--location' && args[i + 1] === 'global'));
617
+ if (isGlobal && NPM_MUTATING_SUBCOMMANDS.has(sub)) return rawLine.trim();
618
+ }
619
+ }
620
+ return null;
621
+ }
622
+
584
623
  /**
585
624
  * atris verify <slug> --section <name>
586
625
  *
@@ -617,6 +656,13 @@ function verifyRubric(slug, section, opts = {}) {
617
656
  return 2;
618
657
  }
619
658
  const script = match[1];
659
+ const globalInstall = findGlobalInstall(script);
660
+ if (globalInstall) {
661
+ console.error(`✗ Refusing to run "## ${section}": it changes globally installed packages.`);
662
+ console.error(` ${globalInstall}`);
663
+ console.error(' Proofs must be read-only. Move install steps to prose or a non-bash fence.');
664
+ return 2;
665
+ }
620
666
  const os = require('os');
621
667
  const tmpFile = path.join(os.tmpdir(), `atris-verify-${Date.now()}-${Math.floor(Math.random() * 1e6)}.sh`);
622
668
  fs.writeFileSync(tmpFile, `#!/usr/bin/env bash\nset -e\n${script}\n`);