atris 3.58.8 → 3.59.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 (40) hide show
  1. package/AGENTS.md +3 -2
  2. package/atris/skills/engines/SKILL.md +28 -1
  3. package/bin/atris.js +3 -0
  4. package/commands/autopilot.js +75 -5
  5. package/commands/clean.js +17 -0
  6. package/commands/doc-health.js +11 -6
  7. package/commands/engine.js +384 -4
  8. package/commands/host.js +53 -0
  9. package/commands/member.js +57 -6
  10. package/commands/mission.js +72 -8
  11. package/commands/one-lap.js +58 -3
  12. package/commands/run.js +4 -1
  13. package/commands/scout.js +1 -1
  14. package/commands/search.js +5 -3
  15. package/commands/unknowns.js +5 -0
  16. package/commands/verify.js +12 -4
  17. package/commands/visualize.js +20 -3
  18. package/commands/workflow.js +17 -6
  19. package/lib/dispatch-scout.js +31 -22
  20. package/lib/engine-registry.js +1482 -13
  21. package/lib/feedback-triage.js +2 -0
  22. package/lib/fleet.js +107 -11
  23. package/lib/host.js +336 -0
  24. package/lib/known-commands.js +1 -1
  25. package/lib/map-refs.js +44 -1
  26. package/lib/member-engine.js +253 -0
  27. package/lib/roster-markdown.js +206 -0
  28. package/lib/roster-models.js +179 -0
  29. package/lib/runner-command.js +94 -14
  30. package/lib/self-drive.js +9 -1
  31. package/lib/wish-audit.js +10 -6
  32. package/lib/wish-delegate.js +10 -0
  33. package/package.json +1 -1
  34. package/templates/members/host/MEMBER.md +23 -0
  35. package/templates/members/host/MISSION.md +11 -0
  36. package/templates/members/host/SOUL.md +7 -0
  37. package/templates/members/host/context/why.md +3 -0
  38. package/templates/members/host/goals.json +5 -0
  39. package/templates/members/host/goals.md +3 -0
  40. package/templates/members/host/skills/host/SKILL.md +13 -0
package/AGENTS.md CHANGED
@@ -171,8 +171,9 @@ named none. These rules are the difference, in priority order:
171
171
  7. **Final report = files changed + verify command + its exact exit/output.**
172
172
  Judges read the worktree diff, not your prose; an empty report with a clean
173
173
  diff beats a confident report with no diff.
174
- 8. **Update `atris/MAP.md` sections you touched** (file:line refs drift on
175
- their own clock; stale refs contradict closed lessons for months).
174
+ 8. **Update the map sections you touched:** routing rows in `atris/MAP.md`,
175
+ file:line refs in `atris/refs/MAP-NOTES.md` (refs drift on their own clock;
176
+ stale refs contradict closed lessons for months).
176
177
  9. **Real runtime over mocks:** if you fixed live behavior, the regression test
177
178
  reproduces it against the real runtime, not a mock that can stay green
178
179
  through breakage.
@@ -24,6 +24,32 @@ tags:
24
24
 
25
25
  One contract, eleven live profiles. The orchestrator writes a bounded task prompt, dispatches it to an engine, then **independently verifies, lands, and pushes** the result. Engines never self-certify.
26
26
 
27
+ ## Pick the job owner first
28
+
29
+ Before choosing an engine for search, build, or review, run `atris engine roster` and use that job's lead worker. Only deviate when the user names an engine.
30
+
31
+ The roster is a markdown file the owner can edit any time: `atris/ROSTER.md` for this project, `~/.atris/ROSTER.md` for every project on this machine. A project job wins over the machine job with the same name. Each `## job` heading lists its workers in order, one per line, naming the tool and the model:
32
+
33
+ ```
34
+ ## review
35
+ - codex, model: gpt-6-astra, effort: medium, max: 20 min
36
+ - claude code, model: opus 5.5
37
+
38
+ ## small build
39
+ - devin, model: swe-2-max, until 2026-10-24
40
+ - grok, model: grok 4.7 fast
41
+ ```
42
+
43
+ The first worker that is ready and not expired leads; the rest are its backups, in order. Tools take engine ids or `claude code`, `atris fast`, and `gemini`. The older one-line shape (`build: claude opus 5.5, backup codex`) still reads the same, and the first `atris engine assign` rewrites the file into sections. `assign <job> <tool>` sets the lead and keeps the rest; `--add` puts a worker at the end, `--remove <tool>` drops one, `--clear` drops the job, and `--everywhere` writes the machine file. `atris engine roster --available` lists the tools and models on this machine.
44
+
45
+ A job's list is also its team. When a task splits into parallel parts, give the parts to that job's workers in order, first worker first, instead of piling them all on the lead. Heavy models like astra and fable should only judge: have the search lead write them a trimmed brief first.
46
+
47
+ Changes for one shell only go in a session file above both rosters. Set `ATRIS_ROSTER_SESSION=<name>` (agent shells have no terminal, so they must), then `atris engine assign <job> <tool> --session`. A session job replaces that job's whole list for this shell; `atris engine roster session` shows the changes and `atris engine roster session clear` drops them. A session nobody used for a day is dropped on its own.
48
+
49
+ Jobs are open-ended: besides search, build, and review, the owner can add a job like `## small build`, or `## quick fixes (like build)` when the name does not say its kind. For a small, tightly specified build slice, use the small build lead when `atris engine roster` shows one; bigger builds use build.
50
+
51
+ Team members pick automatically. Navigators, researchers, and scouts do search; validators, reviewers, judges, critics, and verifiers do review; everyone else builds. A line under `## team` overrides that, with a job (`- researcher: search`) or a tool and model (`- judge: claude code, model: opus 5.5`). `member run`, missions with an owner, and autopilot's plan, do, and review use the member's pick; `--engine` still wins. `atris engine roster` lists the team and warns about any line it could not use.
52
+
27
53
  ## three verbs
28
54
 
29
55
  - ask: `atris engine <name> "<question>"`; pin a model with `atris engine <name> --model <model> "<question>"`
@@ -80,7 +106,8 @@ Headless dispatch permissions (verified 2026-08-11): `codex exec`, `grok`, and `
80
106
 
81
107
  ## Picking an engine
82
108
 
83
- - **Multi-file or long build**: Devin `swe-2-max` while its Free tag holds (check `devin models list`); Codex `gpt-6-sol` if the tag is gone.
109
+ - **Cheap bounded errands**: Devin `swe-2-max` while its Free tag holds (check `devin models list`).
110
+ - **Multi-file or long build**: Use the project's build pick. If there is no pick, use the router's choice.
84
111
  - **Judgment-heavy build**: Opus 5.5 subagent (Claude row).
85
112
  - **Review / deep judgment**: FABLE profile; Codex `gpt-6-sol` or Opus 5.5 as second validator.
86
113
  - **Quick fix**: Cursor.
package/bin/atris.js CHANGED
@@ -1248,6 +1248,7 @@ if (!command || !knownCommands.includes(command)) {
1248
1248
  error: command ? `unknown command: ${command}` : 'unknown command',
1249
1249
  command: command || null,
1250
1250
  input: rawNaturalArgs.join(' '),
1251
+ suggestions: suggestCommands(command),
1251
1252
  usage: 'atris help',
1252
1253
  }, null, 2));
1253
1254
  process.exit(2);
@@ -3288,6 +3289,8 @@ if (command === 'guide') {
3288
3289
  .then(() => process.exit(0))
3289
3290
  .catch((err) => { console.error(`✗ Error: ${err.message || err}`); process.exit(1); });
3290
3291
  }
3292
+ } else if (command === 'host') {
3293
+ require('../commands/host').hostCommand();
3291
3294
  } else if (command === 'social') {
3292
3295
  require('../commands/social').socialCommand()
3293
3296
  .then(() => process.exit(0))
@@ -18,6 +18,7 @@ const {
18
18
  runnerAvailabilityFailureMessage,
19
19
  } = require('../lib/runner-command');
20
20
  const { findStalePages, findStaleTasks, healBrokenMapRefs } = require('./clean');
21
+ const { mapNotesPath } = require('../lib/map-refs');
21
22
  const {
22
23
  buildScorecardData,
23
24
  readScorecards,
@@ -483,19 +484,73 @@ function execPhaseCommandSync(cmd, opts = {}) {
483
484
  }
484
485
  }
485
486
 
487
+ // plan, do, and review run as the navigator, executor, and validator. When
488
+ // the roster names an engine for that member, the phase spawns it. A runner
489
+ // set in the environment still wins, and with no roster line behind the
490
+ // member the configured runner runs exactly as before.
491
+ const AUTOPILOT_PHASE_MEMBERS = Object.freeze({ plan: 'navigator', do: 'executor', review: 'validator' });
492
+ const RUNNER_ENV_OVERRIDES = Object.freeze([
493
+ 'ATRIS_RUNNER_PROFILE',
494
+ 'ATRIS_RUNNER_COMMAND_TEMPLATE',
495
+ 'ATRIS_RUNNER_BIN',
496
+ 'ATRIS_CLAUDE_COMMAND_TEMPLATE',
497
+ 'ATRIS_CLAUDE_BIN',
498
+ ]);
499
+
500
+ function autopilotPhaseEngine(phase, cwd = process.cwd()) {
501
+ const member = AUTOPILOT_PHASE_MEMBERS[phase];
502
+ if (!member) return null;
503
+ if (RUNNER_ENV_OVERRIDES.some((name) => String(process.env[name] || '').trim())) return null;
504
+ const { memberRosterEngine } = require('../lib/member-engine');
505
+ return memberRosterEngine(member, cwd, { requireMember: false });
506
+ }
507
+
508
+ // The phase's launch command and, when its roster line sets "max 20 min",
509
+ // the time cap in ms (null means the caller's own timeout stands).
510
+ function buildPhaseRunner(phase, promptFile, cwd = process.cwd(), allowedTools = 'Bash,Read,Write,Edit,Glob,Grep') {
511
+ const picked = autopilotPhaseEngine(phase, cwd);
512
+ if (!picked) return { command: buildRunnerCommand({ promptFile, allowedTools }), timeoutMs: null };
513
+ if (process.env.ATRIS_ROUTER_EXPLAIN !== '0') console.error(picked.reason);
514
+ const previous = process.env.ATRIS_RUNNER_PROFILE;
515
+ process.env.ATRIS_RUNNER_PROFILE = picked.engine.id;
516
+ try {
517
+ const command = buildRunnerCommand({
518
+ promptFile,
519
+ allowedTools,
520
+ ...(picked.model ? { model: picked.model } : {}),
521
+ ...(picked.engine.roster_effort ? { effort: picked.engine.roster_effort } : {}),
522
+ });
523
+ const maxSeconds = Number(picked.engine.roster_max_seconds) || 0;
524
+ return { command, timeoutMs: maxSeconds > 0 ? maxSeconds * 1000 : null };
525
+ } finally {
526
+ if (previous === undefined) delete process.env.ATRIS_RUNNER_PROFILE;
527
+ else process.env.ATRIS_RUNNER_PROFILE = previous;
528
+ }
529
+ }
530
+
531
+ function buildPhaseRunnerCommand(phase, promptFile, cwd = process.cwd()) {
532
+ return buildPhaseRunner(phase, promptFile, cwd).command;
533
+ }
534
+
486
535
  /**
487
536
  * Run a phase via the configured runner subprocess.
488
537
  */
489
538
  function executePhaseDetailed(phase, context, options = {}) {
490
- const { verbose = false, timeout = PHASE_TIMEOUT } = options;
539
+ const { verbose = false } = options;
540
+ let timeout = options.timeout || PHASE_TIMEOUT;
491
541
 
492
542
  const prompt = buildPrompt(phase, context, options);
493
543
  const tmpFile = path.join(process.cwd(), '.autopilot-prompt.tmp');
494
544
  fs.writeFileSync(tmpFile, prompt);
495
545
 
496
546
  try {
497
- const cmd = options.cmdOverride
498
- || buildRunnerCommand({ promptFile: tmpFile, allowedTools: 'Bash,Read,Write,Edit,Glob,Grep' });
547
+ let cmd = options.cmdOverride;
548
+ if (!cmd) {
549
+ const runner = buildPhaseRunner(phase, tmpFile);
550
+ cmd = runner.command;
551
+ // A roster time cap stops the phase at that time.
552
+ if (runner.timeoutMs) timeout = runner.timeoutMs;
553
+ }
499
554
  const env = { ...process.env };
500
555
  delete env.CLAUDECODE;
501
556
  const output = execPhaseCommandSync(cmd, {
@@ -625,6 +680,8 @@ function getContextFiles(phase, options = {}) {
625
680
  agentSpec && fs.existsSync(path.join(cwd, agentSpec)) ? agentSpec : null,
626
681
  'atris/PERSONA.md',
627
682
  'atris/MAP.md',
683
+ // Exact file:line refs live beside the short routing map.
684
+ mapNotesPath(cwd),
628
685
  'atris/TODO.md',
629
686
  fs.existsSync(path.join(cwd, 'atris/lessons.md')) ? 'atris/lessons.md' : null,
630
687
  (() => { const { logFile } = getLogPath(); return fs.existsSync(logFile) ? path.relative(cwd, logFile) : null; })(),
@@ -1357,15 +1414,25 @@ function parseProposedBlock(lines) {
1357
1414
  return Object.keys(proposed).length ? proposed : null;
1358
1415
  }
1359
1416
 
1417
+ // Plan review is review work, so it runs on the review pick like the review
1418
+ // phase, with that line's model, effort, and time cap. With no roster line
1419
+ // behind it, the configured runner runs exactly as before.
1420
+ function buildPlanReviewRunner(promptFile, cwd = process.cwd(), timeout = 180000) {
1421
+ const runner = buildPhaseRunner('review', promptFile, cwd, 'Bash,Read,Grep,Glob');
1422
+ return { command: runner.command, timeoutMs: runner.timeoutMs || timeout };
1423
+ }
1424
+
1360
1425
  /**
1361
- * Default executor for plan-review: spawn a fresh configured runner call.
1426
+ * Default executor for plan-review: spawn a fresh runner call on the review pick.
1362
1427
  * Kept thin so tests can inject a stub via options.planReviewExec.
1363
1428
  */
1364
1429
  function defaultPlanReviewExecutor(prompt, { cwd, timeout = 180000 } = {}) {
1365
1430
  const tmpFile = path.join(cwd, '.autopilot-plan-review.tmp');
1366
1431
  fs.writeFileSync(tmpFile, prompt);
1367
1432
  try {
1368
- const cmd = buildRunnerCommand({ promptFile: tmpFile, allowedTools: 'Bash,Read,Grep,Glob' });
1433
+ const runner = buildPlanReviewRunner(tmpFile, cwd, timeout);
1434
+ const cmd = runner.command;
1435
+ timeout = runner.timeoutMs;
1369
1436
  const env = { ...process.env };
1370
1437
  delete env.CLAUDECODE;
1371
1438
  const output = execPhaseCommandSync(cmd, {
@@ -3872,6 +3939,9 @@ module.exports = {
3872
3939
  isPhaseKillError,
3873
3940
  execPhaseCommandSync,
3874
3941
  executePhaseDetailed,
3942
+ buildPhaseRunnerCommand,
3943
+ buildPhaseRunner,
3944
+ buildPlanReviewRunner,
3875
3945
  buildAutopilotGlassLog,
3876
3946
  writeAutopilotGlassLog,
3877
3947
  lessonSlug
package/commands/clean.js CHANGED
@@ -358,9 +358,26 @@ function findStaleTasks(atrisDir) {
358
358
  * Detects both out-of-bounds AND drift (symbol moved to different line)
359
359
  * Returns { healed: number, unhealable: array }
360
360
  */
361
+ // The boot map routes; atris/refs/MAP-NOTES.md holds the deep file:line refs.
362
+ // Heal both. Entries from the notes file carry `map` so reports can say where.
363
+ const MAP_NOTES_FILE = path.join('refs', 'MAP-NOTES.md');
364
+
361
365
  function healBrokenMapRefs(cwd, atrisDir, dryRun = false, homeDir = os.homedir()) {
362
366
  const mapFile = path.join(atrisDir, 'MAP.md');
363
367
  if (!fs.existsSync(mapFile)) return { healed: 0, unhealable: [] };
368
+ const result = healMapFileRefs(cwd, mapFile, dryRun, homeDir);
369
+ const notesFile = path.join(atrisDir, MAP_NOTES_FILE);
370
+ if (!fs.existsSync(notesFile)) return result;
371
+ const notes = healMapFileRefs(cwd, notesFile, dryRun, homeDir);
372
+ const map = path.relative(cwd, notesFile).split(path.sep).join('/');
373
+ return {
374
+ healed: result.healed + notes.healed,
375
+ unhealable: [...result.unhealable, ...notes.unhealable.map((entry) => ({ ...entry, map }))],
376
+ replacements: [...result.replacements, ...notes.replacements.map((entry) => ({ ...entry, map }))],
377
+ };
378
+ }
379
+
380
+ function healMapFileRefs(cwd, mapFile, dryRun, homeDir) {
364
381
 
365
382
  let mapContent = fs.readFileSync(mapFile, 'utf8');
366
383
  const unhealable = [];
@@ -4,7 +4,7 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { resolveWorkspaceRoot } = require('../lib/mission-root');
6
6
  const { readText } = require('./brain');
7
- const { checkMapRefs, fixMapRefs } = require('../lib/map-refs');
7
+ const { checkMapDocs, fixMapDocs } = require('../lib/map-refs');
8
8
 
9
9
  const BOOT_FILES = [
10
10
  'CLAUDE.md', 'AGENTS.md', 'atris/atris.md', 'atris/MAP.md',
@@ -76,9 +76,10 @@ function collectMap(root, text, featureNames, memberNames) {
76
76
  const files = [...paths].map(file => ({ path: file, exists: fs.existsSync(path.resolve(root, file)) }));
77
77
  const existing = files.filter(file => file.exists).length;
78
78
  const pathScore = files.length ? existing / files.length : 0;
79
- const { refs: all, ...refs } = checkMapRefs(root, text);
79
+ // Line refs are checked in the boot map and in the notes file it points to.
80
+ const { refs: all, ...refs } = checkMapDocs(root, text);
80
81
  refs.flagged = all.filter(ref => ref.status !== 'ok').map(ref => ({
81
- map_line: ref.map_line, ref: ref.raw, symbol: ref.symbol, status: ref.status,
82
+ doc: ref.doc, map_line: ref.map_line, ref: ref.raw, symbol: ref.symbol, status: ref.status,
82
83
  ...(ref.line ? { line: ref.line } : {}), ...(ref.lines ? { lines: ref.lines } : {}),
83
84
  ...(ref.reason ? { reason: ref.reason } : {}),
84
85
  }));
@@ -286,21 +287,25 @@ function refsLine(refs) {
286
287
  return `map refs: ${refs.ok} right, ${refs.moved} moved, ${refs.ambiguous} unclear, ${gone} gone`;
287
288
  }
288
289
 
290
+ function docLine(ref) {
291
+ return `${ref.doc === 'atris/MAP.md' ? 'MAP' : ref.doc} line ${ref.map_line}`;
292
+ }
293
+
289
294
  function describeLeft(ref) {
290
- const where = `MAP line ${ref.map_line}: ${ref.raw}${ref.symbol ? ` (${ref.symbol})` : ''}`;
295
+ const where = `${docLine(ref)}: ${ref.raw}${ref.symbol ? ` (${ref.symbol})` : ''}`;
291
296
  if (ref.status === 'ambiguous') return `${where} found on lines ${ref.lines.join(', ')}`;
292
297
  if (ref.status === 'missing-file') return `${where} file is gone`;
293
298
  return `${where} ${ref.reason}`;
294
299
  }
295
300
 
296
301
  function fixRefsCommand(root, asJson) {
297
- const { changes, left } = fixMapRefs(root);
302
+ const { changes, left } = fixMapDocs(root);
298
303
  if (asJson) {
299
304
  console.log(JSON.stringify({ ok: true, action: 'doc-health fix-refs', changes, left }, null, 2));
300
305
  return 0;
301
306
  }
302
307
  const lines = [changes.length ? `map refs: moved ${changes.length}` : 'map refs: nothing to move.'];
303
- for (const change of changes) lines.push(` MAP line ${change.map_line}: ${change.from} -> ${change.to} (${change.symbol})`);
308
+ for (const change of changes) lines.push(` ${docLine(change)}: ${change.from} -> ${change.to} (${change.symbol})`);
304
309
  if (left.length) {
305
310
  lines.push(`left for a human: ${left.length}`);
306
311
  for (const ref of left) lines.push(` ${describeLeft(ref)}`);