atris 3.61.0 → 3.63.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 (53) hide show
  1. package/README.md +37 -5
  2. package/atris/features/README.md +8 -0
  3. package/atris/skills/README.md +1 -0
  4. package/atris/skills/commerce/SKILL.md +96 -0
  5. package/atris/skills/engines/SKILL.md +9 -9
  6. package/bin/atris.js +12 -0
  7. package/commands/autoland.js +36 -5
  8. package/commands/autopilot.js +105 -6
  9. package/commands/buy.js +242 -0
  10. package/commands/engine.js +96 -21
  11. package/commands/host.js +56 -4
  12. package/commands/land.js +129 -8
  13. package/commands/member.js +68 -14
  14. package/commands/mission.js +116 -4
  15. package/commands/one-lap.js +14 -10
  16. package/commands/stream.js +56 -22
  17. package/commands/task.js +7 -0
  18. package/commands/team.js +82 -10
  19. package/commands/transactions.js +115 -0
  20. package/commands/worktree.js +39 -0
  21. package/commands/youtube.js +58 -26
  22. package/lib/commerce.js +75 -0
  23. package/lib/engine-ask.js +112 -9
  24. package/lib/engine-registry.js +300 -39
  25. package/lib/fleet.js +444 -26
  26. package/lib/functional-owner.js +15 -6
  27. package/lib/host.js +296 -170
  28. package/lib/known-commands.js +1 -1
  29. package/lib/member-engine.js +25 -1
  30. package/lib/member-park.js +138 -0
  31. package/lib/member-scaffold.js +39 -0
  32. package/lib/member-type.js +27 -0
  33. package/lib/roster-markdown.js +1 -1
  34. package/lib/roster-prep.js +289 -0
  35. package/lib/roster-runs.js +389 -0
  36. package/lib/roster-suggest.js +145 -0
  37. package/lib/router-brain.js +111 -3
  38. package/lib/runner-command.js +37 -2
  39. package/lib/self-drive.js +26 -2
  40. package/lib/state-detection.js +3 -3
  41. package/lib/task-db.js +38 -3
  42. package/lib/team-lineup.js +7 -2
  43. package/lib/todo.js +1 -1
  44. package/lib/wish-audit.js +1 -1
  45. package/package.json +1 -1
  46. package/scripts/det/cpu-hotspots.js +216 -0
  47. package/scripts/det/ytnotes +169 -23
  48. package/scripts/det/ytquote-repair.js +18 -4
  49. package/scripts/det/ytrail-bench.js +214 -0
  50. package/scripts/det/ytrail-eval.js +128 -56
  51. package/templates/members/host/MEMBER.md +5 -3
  52. package/templates/members/host/MISSION.md +1 -1
  53. package/templates/members/host/skills/host/SKILL.md +64 -16
package/README.md CHANGED
@@ -24,7 +24,7 @@ atris init && atris
24
24
 
25
25
  ## For Coding Agents
26
26
 
27
- **Read [`FOR_AGENTS.md`](FOR_AGENTS.md) first** — why agents adopt Atris, 60-second start, spread-it instructions.
27
+ **Read [`FOR_AGENTS.md`](FOR_AGENTS.md) first**: why agents adopt Atris, 60-second start, spread-it instructions.
28
28
 
29
29
  If you're an agent, run:
30
30
 
@@ -110,6 +110,17 @@ atris site publish dist --slug my-site --spa
110
110
  atris site publish . --slug my-next-app --profile app --build # Next.js static export
111
111
  ```
112
112
 
113
+ ### Bring people together with the Host
114
+
115
+ The Host is a team member that gets to know everyone in a room (a company, a club, your own network) through one fun question at a time, keeps a card for each person, and introduces two people only when both say yes. Then it offers to find a time and checks that it happened. It never reads anyone's messages and keeps no scores.
116
+
117
+ ```bash
118
+ atris member install host
119
+ atris host setup --room personal # your own network: it asks you, and drafts messages for you to send
120
+ atris host import people.jsonl # bulk add people and who already knows whom
121
+ atris host room # the host's weekly read
122
+ ```
123
+
113
124
  ## Chat With Atris 2
114
125
 
115
126
  `ax` is the Atris 2 chat and coding-agent CLI. It uses the hosted Atris cloud by default, streams text, shows tool activity, and keeps fresh installs away from local setup.
@@ -123,7 +134,7 @@ ax --max "reason through this hard decision"
123
134
  ax --doctor
124
135
  ```
125
136
 
126
- Use Fast for quick chat and low-latency Atris loops. Use Pro for deeper tool loops. Use Max for the hardest jobs — it runs the highest-reasoning model and takes longer per turn. Advanced local workspace mode is opt-in with `--local` plus `AX_BACKEND_URL`.
137
+ Use Fast for quick chat and low-latency Atris loops. Use Pro for deeper tool loops. Use Max for the hardest jobs; it runs the highest-reasoning model and takes longer per turn. Advanced local workspace mode is opt-in with `--local` plus `AX_BACKEND_URL`.
127
138
 
128
139
  ## Play AgentXP
129
140
 
@@ -267,9 +278,30 @@ atris business record atris/reports/2026-04-12-operator-recap.md --outcome mixed
267
278
 
268
279
  ## Engines
269
280
 
270
- An engine is the intelligence that builds a mission. Every installed headless coding CLI is a swappable worker behind one contract: a bounded prompt goes in, verified proof comes out, and engines never self-certify their own work. The house default is `atris-fast` (`ax`); the roster also covers `claude`, `codex`, `cursor`, and `devin` when their CLIs are installed.
281
+ An engine is the intelligence that builds a mission. Every installed headless coding CLI is a swappable worker behind one contract: a bounded prompt goes in, verified proof comes out, and engines never self-certify their own work. The house default is `atris-fast` (`ax`); `claude`, `codex`, `cursor`, `devin`, `grok`, and others join when their CLIs are installed.
282
+
283
+ ### Who does each job
284
+
285
+ `ROSTER.md` says which tool and model does each job. Edit it any time; every project, team member, and new chat follows it.
286
+
287
+ ```markdown
288
+ ## build
289
+ - claude code, model: opus 5.5
290
+
291
+ ## review
292
+ - codex, model: gpt-6-astra, effort: medium, max: 20 min, prep: search
293
+ - claude code, model: opus 5.5
294
+ ```
295
+
296
+ The first ready worker leads and the rest are backups. `~/.atris/ROSTER.md` covers every project on the machine; `atris/ROSTER.md` overrides it for one project. A worker that stalls sits out 30 minutes and the next one takes over.
297
+
298
+ ```bash
299
+ atris engine roster # who does what, with each worker's recent record
300
+ atris engine assign build claude --model "opus 5.5" # change a job's lead
301
+ atris engine roster --available # tools and models on this machine
302
+ ```
271
303
 
272
- Show the roster and the current default:
304
+ Show every installed engine and the current default:
273
305
 
274
306
  ```bash
275
307
  atris engine
@@ -290,7 +322,7 @@ atris mission run "fix the flaky login test" --engine codex
290
322
 
291
323
  ### Fleet Flight
292
324
 
293
- `atris mission run --fleet` staffs every idle installed engine on the board's claimable safe-lane tasks — one mission per task, one worktree per engine. Builds run in parallel; arrivals land serially with rebase-before-ship, so a rebase conflict pauses that landing (never auto-resolved) and keeps its worktree instead of orphaning work. Each flight writes a receipt to `atris/runs/fleet-<stamp>.json`.
325
+ `atris mission run --fleet` staffs every idle installed engine on the board's claimable safe-lane tasks: one mission per task, one worktree per engine. Builds run in parallel; arrivals land serially with rebase-before-ship, so a rebase conflict pauses that landing (never auto-resolved) and keeps its worktree instead of orphaning work. Each flight writes a receipt to `atris/runs/fleet-<stamp>.json`.
294
326
 
295
327
  ```bash
296
328
  atris mission run --fleet --slots 3 # staff up to 3 engines
@@ -86,6 +86,14 @@ None.
86
86
 
87
87
  ### Completed Features
88
88
 
89
+ #### youtube-notes
90
+ Free, fast, checked notes from any YouTube link
91
+ - **Files:** commands/youtube.js, scripts/det/ytnotes, scripts/det/ytquote-repair.js, test/ytnotes.test.js, test/ytquote-repair.test.js, test/youtube.test.js, atris/features/youtube-notes/*
92
+ - **Status:** complete; the `atris youtube bench` scorecard is in review
93
+ - **Keywords:** youtube, notes, captions, transcript, whisper, quotes
94
+ - **What:** `atris youtube notes <url>` reads the real English captions, transcribes on an Apple silicon Mac when there are none, writes with the fastest installed AI, and checks every quote against the transcript
95
+ - **Completed:** 2026-09-28
96
+
89
97
  #### audit-gaps
90
98
  Close remaining audit gaps from self-audit
91
99
  - **Files:** atris/team/*/MEMBER.md, atris/features/README.md, atris/features/audit-gaps/*
@@ -34,6 +34,7 @@ cp -r atris/skills/[name] ~/.codex/skills/
34
34
  | backend | Backend architecture policy | `policies/atris-backend.md` |
35
35
  | blocks | Author Atris block documents: docs, decks, reports | - |
36
36
  | calendar | Google Calendar via AtrisOS API | - |
37
+ | commerce | Quote a purchase or stock order; the person approves before anything is charged | - |
37
38
  | copy-editor | Detects and fixes AI writing patterns | - |
38
39
  | create-app | Build and deploy an Atris app from a description | - |
39
40
  | create-member | Create and manage MEMBER.md team members | - |
@@ -0,0 +1,96 @@
1
+ ---
2
+ name: commerce
3
+ description: "Buy or trade through Atris for a person: get a quote, show it, the person approves, check status, read their transactions. Use when asked to buy tickets, buy an item from a store, place a stock order, or list what someone bought or traded. Triggers on buy, purchase, order, trade, stock order, transactions, what did I spend."
4
+ version: 1.0.0
5
+ tags:
6
+ - commerce
7
+ - money
8
+ ---
9
+
10
+ # Commerce: buy or trade through Atris
11
+
12
+ You can quote and ask. Only the human can pay. Every dollar leaves only after
13
+ the person acts themselves: a tap in their Link app, a payment page they
14
+ fill in, or a text from their own phone.
15
+
16
+ ## The loop
17
+
18
+ 1. Quote. `atris buy quote --kind <kind> [fields]`.
19
+ 2. Show the person the summary, total, fee and approval text. Word for word on money.
20
+ 3. Only after they say yes, approve. `atris buy approve <id> [fields]`.
21
+ 4. Hand the person the approval link (Link or payment page). For trades, tell them to check their texts: Atris sends the one-time code only to their own text thread, never to you. Nothing else pays.
22
+ 5. Check `atris buy status <id>` until the state is `done`, `failed`, `refunded` or `unknown`.
23
+ 6. `atris transactions` lists everything they bought, paid in fees, traded or sent.
24
+
25
+ Add `--json` to any command for the raw response.
26
+
27
+ ## Kinds
28
+
29
+ | kind | quote flags | approve flags | human approves by |
30
+ |---|---|---|---|
31
+ | `ticket` | `--event-url` (vividseats.com), `--quantity`, `--min-each-usd`, `--max-each-usd` | `--pick` (1-3 or ticket_id), `--delivery-email` | Link app, or payment page |
32
+ | `shop` | `--query` or `--url`, `--quantity` | `--options '<json>'`, `--delivery-email`, `--address '<json>'` (US, once), `--accept-extras` | Link app |
33
+ | `trade` | `--side` buy/sell, `--symbol`, `--quantity` or `--dollars`, `--order-type` market/limit, `--limit-price` | none | replying to Atris's text with the exact phrase from their own phone, within 5 minutes |
34
+ | `flight` | not wired yet, quotes are refused | | |
35
+
36
+ Any field can also go in as JSON: `--intent '{"kind":"shop","query":"navy tee"}'` for quotes,
37
+ `--details '{"pick":"1"}'` for approve. Flags win over the JSON.
38
+
39
+ ## Rails (enforced in code, not by you)
40
+
41
+ - Approval is always human. An API key cannot confirm a trade or approve a Link spend.
42
+ - Caps: purchases up to `TEXT_TICKETS_MAX_USD` (default $400) and $500 per Link spend. Stocks: per-order and per-day caps, the Robinhood risk policy, market hours, no shorting, stocks only.
43
+ - Quotes expire: 30 minutes for purchases, 10 for trades. A trade quote stages at most one order. Ask again after that.
44
+ - A trade already waiting for the person's yes blocks a new one. Let them confirm or let it expire.
45
+ - No retries. If a result is `unknown`, money may have moved. Tell the person to check the store or Robinhood app. Never buy again to "fix" it.
46
+ - No advice on trades. Carry out only the person's own instruction.
47
+ - For Link-mode orders, status may carry `fee_payment_url`: the page where the person pays the Atris fee. The CLI prints it as "Pay the Atris fee".
48
+ - Status values from quote and approve: `quoted`, `pending_approval`, `needs_input` (ask the person for the named field), `needs_setup` (send them the connect link), `refused` (read the message to them).
49
+
50
+ ## With the CLI
51
+
52
+ ```bash
53
+ # Tickets
54
+ atris buy quote --kind ticket --event-url "https://www.vividseats.com/..." --quantity 2
55
+ atris buy approve ord_<uuid> --pick 1 --delivery-email sam@example.com
56
+ # -> Approve in Link: https://... Nothing has been charged yet.
57
+
58
+ # A store item
59
+ atris buy quote --kind shop --query "uniqlo supima crew tee navy" --quantity 1
60
+
61
+ # A stock order
62
+ atris buy quote --kind trade --side buy --symbol VTI --dollars 25
63
+ atris buy approve trq_<token>
64
+ # -> We texted you a confirm code. Reply to that text to place the trade.
65
+
66
+ # Status and history
67
+ atris buy status ord_<uuid>
68
+ atris transactions --since 2026-09-01 --kind ticket,shop,trade --limit 50
69
+ # Quotes nobody approved are left out; add --include-quotes to see them.
70
+ ```
71
+
72
+ ## With curl
73
+
74
+ ```bash
75
+ KEY="Authorization: Bearer $ATRIS_API_KEY"
76
+ API=https://api.atris.ai/api
77
+
78
+ curl -s -X POST $API/commerce/quote -H "$KEY" -H 'content-type: application/json' \
79
+ -d '{"kind":"ticket","event_url":"https://www.vividseats.com/...","quantity":2}'
80
+ curl -s -X POST $API/commerce/ord_<uuid>/approve -H "$KEY" -H 'content-type: application/json' \
81
+ -d '{"pick":"1","delivery_email":"sam@example.com"}'
82
+ # -> {"status":"pending_approval","approval":{"how":"link_app","url":"https://..."}}
83
+
84
+ curl -s -X POST $API/commerce/quote -H "$KEY" -H 'content-type: application/json' \
85
+ -d '{"kind":"trade","side":"buy","symbol":"VTI","dollars":25}'
86
+ curl -s -X POST $API/commerce/trq_<token>/approve -H "$KEY"
87
+ # -> {"status":"pending_approval","approval":{"how":"text_confirm","text":"Check your texts. ..."}}
88
+
89
+ curl -s $API/commerce/ord_<uuid> -H "$KEY"
90
+ curl -s "$API/transactions?since=2026-09-01&kinds=ticket,shop,trade&limit=50" -H "$KEY"
91
+ # Quotes nobody approved are left out; add &include_quotes=true to see them.
92
+ ```
93
+
94
+ Each transaction: `id, kind, when, what, amount_cents, currency, state, counterparty, receipt_url`.
95
+ `sources` says which ledgers loaded; if one says `failed`, the list is partial. Say so.
96
+ The CLI prints that as "Partial list: could not load ...".
@@ -26,29 +26,29 @@ One contract, eleven live profiles. The orchestrator writes a bounded task promp
26
26
 
27
27
  ## Pick the job owner first
28
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.
29
+ Before choosing an engine for search, build, review, or any other job, run `atris engine roster` and use that job's lead worker. Only deviate when the user names an engine.
30
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:
31
+ **The file.** `~/.atris/ROSTER.md` covers every project on the machine; `atris/ROSTER.md` overrides it job by job. Each `## job` lists workers in order, one per line, tool then model:
32
32
 
33
33
  ```
34
34
  ## review
35
- - codex, model: gpt-6-astra, effort: medium, max: 20 min
35
+ - codex, model: gpt-6-astra, effort: medium, max: 20 min, prep: search
36
36
  - claude code, model: opus 5.5
37
37
 
38
38
  ## small build
39
- - devin, model: swe-2-max, until 2026-10-24
39
+ - devin, model: swe-2-max, max: 20 min, until 2026-10-24
40
40
  - grok, model: grok 4.7 fast
41
41
  ```
42
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.
43
+ The first worker that is ready and not expired leads; the rest are backups in order, and the whole list is the job's team for parallel parts. Jobs are open-ended: add `## quick fixes (like build)` when the name does not say its kind. Use the small build lead for small, tightly specified slices.
44
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.
45
+ **Changing it.** Edit the file, or: `atris engine assign <job> <tool> [--model --effort --max --prep search --days N]` sets the lead and keeps the rest; `--add`, `--remove <tool>`, `--promote <tool>` (move an existing worker to the top, line unchanged), `--clear`; `--everywhere` writes the machine file; `--session` (with `ATRIS_ROSTER_SESSION=<name>`) changes this shell only. `atris engine roster --available` lists installed tools and models.
46
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.
47
+ **What happens on its own.** A worker that stalls or loses its connection sits out 30 minutes ("cooling") and the next worker takes the job; a failed task (tests failed) is not handed over and does not bench anyone. Every run is recorded; `atris engine roster --runs` lists them and the roster shows each worker's recent record. When the record says the order is wrong, the roster prints one suggestion with the exact command. It never edits the file: run it only when the owner says so.
48
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.
49
+ **Heavy models.** Astra and Fable should judge, not explore. Give their line `prep: search`: the search lead writes a brief of at most 300 lines first, and if prep fails the worker runs as usual.
50
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.
51
+ **Team members.** Navigators, researchers, and scouts do search; validators, reviewers, and judges do review; everyone else builds. A `## team` line overrides: `- researcher: search` or `- judge: claude code, model: opus 5.5`. `member run`, owned missions, and autopilot phases use the member's pick; `--engine` still wins. Parked members are skipped; `atris team park <name>` and `atris team unpark <name>` toggle it.
52
52
 
53
53
  ## three verbs
54
54
 
package/bin/atris.js CHANGED
@@ -574,6 +574,7 @@ function showHelpAll() {
574
574
  console.log(' who - Show local engines and team members working, waiting, done, or stale');
575
575
  console.log(' stream - Watch the whole team work live in one terminal');
576
576
  console.log(' team - One team view: members, roles, engine assignments (presence for live)');
577
+ console.log(' host - The Host: gets to know people, makes two-yes introductions (atris member install host)');
577
578
  console.log(' watch - Turn one sentence into an always-on background watcher');
578
579
  console.log(' ctop - Show a process-first live agent CPU/memory view');
579
580
  console.log(' doctor - Node/task/auth/workspace readiness (--json for agents)');
@@ -716,6 +717,8 @@ function showHelpAll() {
716
717
  console.log(' usage - Show developer API usage');
717
718
  console.log(' api-key - Create, list, rotate, or revoke a developer API key');
718
719
  console.log(' topup - Buy credits and print a Stripe checkout URL');
720
+ console.log(' buy - Quote a purchase or stock order; the person approves before anything is charged');
721
+ console.log(' transactions - List what you bought, paid in fees, traded, or sent');
719
722
  console.log(' design - Extract a site design system, check brand adherence, search brands');
720
723
  console.log(' mcp - Run the atris MCP server (stdio) for Claude Desktop and Cursor');
721
724
  console.log('');
@@ -2367,6 +2370,15 @@ if (command === 'guide') {
2367
2370
  Promise.resolve(require('../commands/topup').topupCommand(process.argv.slice(3)))
2368
2371
  .then((code) => process.exit(typeof code === 'number' ? code : 0))
2369
2372
  .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
2373
+ } else if (command === 'buy') {
2374
+ // Buy: quote, then hand the person the approval step. Never charges.
2375
+ Promise.resolve(require('../commands/buy').buyCommand(process.argv.slice(3)))
2376
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2377
+ .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
2378
+ } else if (command === 'transactions') {
2379
+ Promise.resolve(require('../commands/transactions').transactionsCommand(process.argv.slice(3)))
2380
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2381
+ .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
2370
2382
  } else if (command === '_resolve') {
2371
2383
  // Hidden: resolve a profile name query → print exact profile name
2372
2384
  require('../commands/auth').resolveProfile();
@@ -130,13 +130,38 @@ function flag(args, name, fallback = '') {
130
130
  return args[idx + 1] || fallback;
131
131
  }
132
132
 
133
- function runOwnCli(root, cliArgs) {
133
+ function runOwnCli(root, cliArgs, timeoutMs = 300000) {
134
134
  // Always spawn the bin this module shipped with. Resolving through the
135
135
  // target root or a global `atris` breaks whenever the tick runs a project
136
136
  // that isn't the CLI repo on a machine without a global install (CI, cron).
137
137
  const bin = path.resolve(__dirname, '..', 'bin', 'atris.js');
138
- const result = spawnSync(process.execPath, [bin, ...cliArgs], { cwd: root, encoding: 'utf8', timeout: 300000 });
139
- return { status: result.status, stdout: String(result.stdout || ''), stderr: String(result.stderr || '') };
138
+ const result = spawnSync(process.execPath, [bin, ...cliArgs], { cwd: root, encoding: 'utf8', timeout: timeoutMs });
139
+ return {
140
+ status: result.status,
141
+ stdout: String(result.stdout || ''),
142
+ stderr: String(result.stderr || ''),
143
+ error_code: result.error && result.error.code ? result.error.code : null,
144
+ signal: result.signal || null,
145
+ timeout_ms: timeoutMs,
146
+ };
147
+ }
148
+
149
+ // The accept sweep walks every pending-review row under --all, so a fixed
150
+ // 300s child timeout kills a real backlog mid-accept. Size the window from
151
+ // the queue: roughly 8s a row on top of a 60s floor, never under the old
152
+ // 300s default, capped at 25min.
153
+ function acceptSweepTimeoutMs(pendingRows) {
154
+ return Math.min(1500000, Math.max(300000, 60000 + pendingRows * 8000));
155
+ }
156
+
157
+ // A killed child truncates stdout and often leaves stderr empty, so the
158
+ // spawn failure itself is the first thing to name. Without this a timed-out
159
+ // sweep reports "output unreadable" forever.
160
+ function describeAcceptKillCause(accept) {
161
+ if (accept.error_code === 'ETIMEDOUT') return `auto-accept timed out at ${accept.timeout_ms}ms`;
162
+ if (accept.error_code) return `auto-accept spawn failed: ${accept.error_code}`;
163
+ if (accept.signal) return `auto-accept killed by ${accept.signal}`;
164
+ return null;
140
165
  }
141
166
 
142
167
  function readProjection(root) {
@@ -759,7 +784,11 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
759
784
  const cliArgs = ['task', 'auto-accept-certified', '--json', '--certify-first'];
760
785
  if (policy.accept_all) cliArgs.push('--all');
761
786
  else if (policy.strict_verify === false) cliArgs.push('--no-strict-verify');
762
- const accept = runOwnCli(root, cliArgs);
787
+ const pendingReviewRows = readProjection(root)
788
+ .filter((task) => task && task.status === 'review')
789
+ .filter((task) => String(task.review?.approval_status || task.metadata?.approval_status || 'pending') === 'pending')
790
+ .length;
791
+ const accept = runOwnCli(root, cliArgs, acceptSweepTimeoutMs(pendingReviewRows));
763
792
  try {
764
793
  const parsed = JSON.parse(accept.stdout);
765
794
  const results = Array.isArray(parsed.results) ? parsed.results : [];
@@ -801,7 +830,7 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
801
830
  receipt.skipped = parsed.skipped ?? null;
802
831
  receipt.undercounted = Boolean(parsed.undercounted);
803
832
  } catch (err) {
804
- receipt.accept_error = accept.stderr.slice(0, 200) || 'auto-accept output unreadable';
833
+ receipt.accept_error = describeAcceptKillCause(accept) || accept.stderr.slice(0, 200) || 'auto-accept output unreadable';
805
834
  }
806
835
 
807
836
  // 2b. tell the operator the moment something lands: one text, one landing
@@ -1108,6 +1137,8 @@ function autolandCommand(args = []) {
1108
1137
  module.exports = {
1109
1138
  autolandCommand,
1110
1139
  digestNextMoves,
1140
+ acceptSweepTimeoutMs,
1141
+ describeAcceptKillCause,
1111
1142
  digestStoryRows,
1112
1143
  digestTickStatus,
1113
1144
  engineAnswerValidationLine,
@@ -31,6 +31,10 @@ const { ownerIdentity } = require('../utils/owner-identity');
31
31
  const pkg = require('../package.json');
32
32
 
33
33
  const PHASE_TIMEOUT = 600000; // 10 min per phase
34
+ // A phase's cap covers its prep pass too: prep's elapsed time comes out of
35
+ // the cap before the phase command starts. The floor keeps a slow prep from
36
+ // leaving the phase no room at all.
37
+ const PHASE_MIN_AFTER_PREP_MS = 30000;
34
38
 
35
39
  function looksOwnerClaimed(claimed) {
36
40
  const text = String(claimed || '').trim().toLowerCase();
@@ -521,7 +525,17 @@ function buildPhaseRunner(phase, promptFile, cwd = process.cwd(), allowedTools =
521
525
  ...(picked.engine.roster_effort ? { effort: picked.engine.roster_effort } : {}),
522
526
  });
523
527
  const maxSeconds = Number(picked.engine.roster_max_seconds) || 0;
524
- return { command, timeoutMs: maxSeconds > 0 ? maxSeconds * 1000 : null };
528
+ return {
529
+ command,
530
+ timeoutMs: maxSeconds > 0 ? maxSeconds * 1000 : null,
531
+ engineId: picked.engine.id,
532
+ maxSeconds,
533
+ model: picked.model || '',
534
+ effort: picked.engine.roster_effort || '',
535
+ job: picked.job || '',
536
+ member: AUTOPILOT_PHASE_MEMBERS[phase],
537
+ prep: picked.engine.roster_prep || '',
538
+ };
525
539
  } finally {
526
540
  if (previous === undefined) delete process.env.ATRIS_RUNNER_PROFILE;
527
541
  else process.env.ATRIS_RUNNER_PROFILE = previous;
@@ -532,6 +546,54 @@ function buildPhaseRunnerCommand(phase, promptFile, cwd = process.cwd()) {
532
546
  return buildPhaseRunner(phase, promptFile, cwd).command;
533
547
  }
534
548
 
549
+ // One run-record line for a phase a roster worker ran. Never throws.
550
+ function recordPhaseRosterRun(root, phase, runner, startedMs, outcome, err = null) {
551
+ if (!runner || !runner.engineId) return;
552
+ try {
553
+ const { appendRosterRun, parseRunUsage } = require('../lib/roster-runs');
554
+ const { engineRunsView } = require('../lib/roster-models');
555
+ const { prepRecordFields } = require('../lib/roster-prep');
556
+ appendRosterRun(root, {
557
+ at: new Date(startedMs).toISOString(),
558
+ job: runner.job || '',
559
+ member: runner.member || '',
560
+ engine: runner.engineId,
561
+ model: runner.model || engineRunsView(runner.engineId).model || '',
562
+ effort: runner.effort || '',
563
+ max_seconds: runner.maxSeconds || 0,
564
+ seconds: Math.max(0, (Date.now() - startedMs) / 1000),
565
+ task: `autopilot ${phase}`,
566
+ source: 'autopilot',
567
+ ...prepRecordFields(runner.prepResult),
568
+ ...parseRunUsage({ stdout: outcome.stdout || (err && err.stdout) || '', stderr: (err && err.stderr) || '' }),
569
+ outcome: outcome.outcome,
570
+ ...(outcome.detail ? { detail: outcome.detail } : {}),
571
+ });
572
+ } catch { /* best effort */ }
573
+ }
574
+
575
+ // How a phase ended when its command threw: a timeout stalled, a signal or
576
+ // nonzero exit failed, a credit wall on stderr is credit out.
577
+ function phaseFailureOutcome(err, runner) {
578
+ if (isPhaseTimeoutError(err)) {
579
+ const cap = Number(runner && runner.maxSeconds) || 0;
580
+ return { outcome: 'stalled', detail: cap >= 60 ? `stalled at ${Math.round(cap / 60)} min` : cap > 0 ? `stalled at ${cap}s` : 'hit its time limit' };
581
+ }
582
+ if (isPhaseKillError(err)) return { outcome: 'failed', detail: `stopped by ${err.signal || 'a signal'}` };
583
+ const { engineFailureHealthStatus } = require('../lib/engine-registry');
584
+ const stderr = String(err && err.stderr || '');
585
+ if (engineFailureHealthStatus({ status: 'errored', stderr }) === 'credit_out') return { outcome: 'credit out', detail: 'the engine hit its usage limit' };
586
+ const line = stderr.trim().split('\n').pop() || String(err && err.message || '').split('\n')[0];
587
+ return { outcome: 'failed', detail: line || `exited ${err && err.status}` };
588
+ }
589
+
590
+ // What the prep worker reads as the task: the tick's task title.
591
+ function autopilotPhaseTaskTitle(context, phase) {
592
+ const task = context && context.task;
593
+ if (task && typeof task === 'object') return String(task.title || task.display_id || `autopilot ${phase}`);
594
+ return String(task || `autopilot ${phase}`);
595
+ }
596
+
535
597
  /**
536
598
  * Run a phase via the configured runner subprocess.
537
599
  */
@@ -539,20 +601,43 @@ function executePhaseDetailed(phase, context, options = {}) {
539
601
  const { verbose = false } = options;
540
602
  let timeout = options.timeout || PHASE_TIMEOUT;
541
603
 
542
- const prompt = buildPrompt(phase, context, options);
604
+ let prompt = buildPrompt(phase, context, options);
543
605
  const tmpFile = path.join(process.cwd(), '.autopilot-prompt.tmp');
544
606
  fs.writeFileSync(tmpFile, prompt);
545
607
 
608
+ let rosterRunner = null;
609
+ let startedMs = Date.now();
546
610
  try {
547
611
  let cmd = options.cmdOverride;
548
612
  if (!cmd) {
549
613
  const runner = buildPhaseRunner(phase, tmpFile);
550
- cmd = runner.command;
551
- // A roster time cap stops the phase at that time.
614
+ if (runner.engineId) rosterRunner = runner;
615
+ // A roster time cap stops the phase at that time, and prep runs inside
616
+ // it: the phase gets what remains after the pass, floored so it can
617
+ // still try.
552
618
  if (runner.timeoutMs) timeout = runner.timeoutMs;
619
+ // A worker whose roster line asks for prep gets its brief first, the
620
+ // same pass fleet runs; the launch reads the prompt file, brief added.
621
+ if (runner.engineId && runner.prep) {
622
+ const prepStartedMs = Date.now();
623
+ const { runPrepPassSync, withPrepBrief } = require('../lib/roster-prep');
624
+ runner.prepResult = runPrepPassSync({
625
+ prepJob: runner.prep,
626
+ forJob: runner.job || AUTOPILOT_PHASE_MEMBERS[phase],
627
+ task: { display_id: `autopilot ${phase}`, title: autopilotPhaseTaskTitle(context, phase) },
628
+ prompt,
629
+ root: process.cwd(),
630
+ record: { member: runner.member || '' },
631
+ });
632
+ prompt = withPrepBrief(prompt, runner.prepResult);
633
+ fs.writeFileSync(tmpFile, prompt);
634
+ timeout = Math.max(PHASE_MIN_AFTER_PREP_MS, timeout - (Date.now() - prepStartedMs));
635
+ }
636
+ cmd = runner.command;
553
637
  }
554
638
  const env = { ...process.env };
555
639
  delete env.CLAUDECODE;
640
+ startedMs = Date.now();
556
641
  const output = execPhaseCommandSync(cmd, {
557
642
  cwd: process.cwd(),
558
643
  encoding: 'utf8',
@@ -563,10 +648,19 @@ function executePhaseDetailed(phase, context, options = {}) {
563
648
  });
564
649
 
565
650
  try { fs.unlinkSync(tmpFile); } catch {}
651
+ recordPhaseRosterRun(process.cwd(), phase, rosterRunner, startedMs, { outcome: 'landed', stdout: output || '' });
566
652
  return { prompt, output: output || '' };
567
653
  } catch (err) {
568
654
  try { fs.unlinkSync(tmpFile); } catch {}
655
+ recordPhaseRosterRun(process.cwd(), phase, rosterRunner, startedMs, phaseFailureOutcome(err, rosterRunner), err);
569
656
  if (isPhaseTimeoutError(err)) {
657
+ // A roster worker that stalls sits out the cooldown, so the next
658
+ // phase run goes to the job's backup.
659
+ if (rosterRunner) {
660
+ try {
661
+ require('../lib/engine-registry').recordEngineRunHealth(rosterRunner.engineId, { timed_out: true, max_seconds: rosterRunner.maxSeconds }, process.cwd());
662
+ } catch { /* best effort */ }
663
+ }
570
664
  throw new Error(`${phase} phase timed out after ${timeout / 1000}s (configured runner hit the wall; any work it committed survives, reconcile from pre-tick HEADs)`);
571
665
  }
572
666
  if (isPhaseKillError(err)) {
@@ -1419,7 +1513,7 @@ function parseProposedBlock(lines) {
1419
1513
  // behind it, the configured runner runs exactly as before.
1420
1514
  function buildPlanReviewRunner(promptFile, cwd = process.cwd(), timeout = 180000) {
1421
1515
  const runner = buildPhaseRunner('review', promptFile, cwd, 'Bash,Read,Grep,Glob');
1422
- return { command: runner.command, timeoutMs: runner.timeoutMs || timeout };
1516
+ return { ...runner, command: runner.command, timeoutMs: runner.timeoutMs || timeout };
1423
1517
  }
1424
1518
 
1425
1519
  /**
@@ -1429,12 +1523,15 @@ function buildPlanReviewRunner(promptFile, cwd = process.cwd(), timeout = 180000
1429
1523
  function defaultPlanReviewExecutor(prompt, { cwd, timeout = 180000 } = {}) {
1430
1524
  const tmpFile = path.join(cwd, '.autopilot-plan-review.tmp');
1431
1525
  fs.writeFileSync(tmpFile, prompt);
1526
+ let runner = null;
1527
+ let startedMs = Date.now();
1432
1528
  try {
1433
- const runner = buildPlanReviewRunner(tmpFile, cwd, timeout);
1529
+ runner = buildPlanReviewRunner(tmpFile, cwd, timeout);
1434
1530
  const cmd = runner.command;
1435
1531
  timeout = runner.timeoutMs;
1436
1532
  const env = { ...process.env };
1437
1533
  delete env.CLAUDECODE;
1534
+ startedMs = Date.now();
1438
1535
  const output = execPhaseCommandSync(cmd, {
1439
1536
  cwd,
1440
1537
  encoding: 'utf8',
@@ -1443,8 +1540,10 @@ function defaultPlanReviewExecutor(prompt, { cwd, timeout = 180000 } = {}) {
1443
1540
  maxBuffer: 10 * 1024 * 1024,
1444
1541
  env,
1445
1542
  });
1543
+ recordPhaseRosterRun(cwd, 'plan review', runner, startedMs, { outcome: 'landed', stdout: output || '' });
1446
1544
  return output || '';
1447
1545
  } catch (err) {
1546
+ recordPhaseRosterRun(cwd, 'plan review', runner, startedMs, phaseFailureOutcome(err, runner), err);
1448
1547
  if (err.stdout) return err.stdout;
1449
1548
  throw err;
1450
1549
  } finally {