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
@@ -7,6 +7,7 @@ const fs = require('fs');
7
7
  const path = require('path');
8
8
  const taskDb = require('./task-db');
9
9
  const { protectedLaneOf } = require('./auto-accept-certified');
10
+ const { isDecisionTask } = require('./task-decision');
10
11
 
11
12
  const DAY_MS = 24 * 60 * 60 * 1000;
12
13
  const OPEN_STATUSES = new Set(['open', 'claimed', 'review']);
@@ -72,6 +73,12 @@ function isProtectedRow(row) {
72
73
  return Boolean(protectedLaneOf({ ...row, metadata: parsedMetadata(row) }));
73
74
  }
74
75
 
76
+ // A task tagged needs-human or decision is waiting on a person, not stuck.
77
+ // Their silence is not an answer, so idle time never puts it away.
78
+ function isHeldForPerson(row) {
79
+ return isDecisionTask({ ...row, metadata: parsedMetadata(row) });
80
+ }
81
+
75
82
  function decisionFor(row, now, { includeDone = true } = {}) {
76
83
  if (!row || row.status === 'archived') return null;
77
84
  if (row.status === 'done') {
@@ -79,7 +86,7 @@ function decisionFor(row, now, { includeDone = true } = {}) {
79
86
  return { reason: REASONS.done, fromDone: true, fromFailed: false };
80
87
  }
81
88
  if (!Object.prototype.hasOwnProperty.call(IDLE_LIMITS_MS, row.status)) return null;
82
- if (OPEN_STATUSES.has(row.status) && isProtectedRow(row)) return null;
89
+ if (OPEN_STATUSES.has(row.status) && (isProtectedRow(row) || isHeldForPerson(row))) return null;
83
90
  const at = Number(row.updated_at || row.created_at || 0);
84
91
  if (!((now - at) > IDLE_LIMITS_MS[row.status])) return null;
85
92
  return { reason: REASONS[row.status], fromDone: false, fromFailed: row.status === 'failed' };
@@ -51,7 +51,12 @@ function writeTaskReceipt({ taskId, command, root = process.cwd(), runner } = {}
51
51
  if (!taskId) return { ok: false, passed: false, reason: 'task_id_required' };
52
52
  if (!cmd) return { ok: false, passed: false, reason: 'verify_command_required' };
53
53
  const run = runner || spawnSync;
54
- const result = run('bash', ['-lc', cmd], {
54
+ // A login shell can put an older node first on PATH (macOS path_helper puts
55
+ // /usr/local/bin ahead), so the check would run on a node atris does not
56
+ // support. Put the node running atris first, after the profile has loaded.
57
+ const nodeDir = path.dirname(process.execPath).replace(/'/g, `'\\''`);
58
+ const script = `export PATH='${nodeDir}':"$PATH"\n${cmd}`;
59
+ const result = run('bash', ['-lc', script], {
55
60
  cwd: root,
56
61
  encoding: 'utf8',
57
62
  timeout: 120000,
@@ -1,9 +1,13 @@
1
1
  #!/usr/bin/env node
2
- // atris mcp, a stdio Model Context Protocol server exposing the design API.
3
- // Tools: design_extract, design_check, design_search.
4
- // Auth resolves the same way as the CLI: ATRIS_API_KEY, then
5
- // the logged-in atris token, then ~/.atris/design-api-key.
2
+ // atris mcp, a stdio Model Context Protocol server.
3
+ // Design tools (design_extract, design_check, design_search) call the design
4
+ // API. Auth resolves the same way as the CLI: ATRIS_API_KEY, then the
5
+ // logged-in atris token, then ~/.atris/design-api-key.
6
+ // Campaign tools (campaign_list, campaign_show, campaign_new, campaign_start,
7
+ // campaign_run, campaign_ask, campaign_answer) run `atris campaign ... --json`
8
+ // in the workspace and need no key.
6
9
 
10
+ import { spawn } from 'node:child_process';
7
11
  import { readFileSync, realpathSync } from 'node:fs';
8
12
  import { createInterface } from 'node:readline';
9
13
  import { fileURLToPath } from 'node:url';
@@ -11,7 +15,7 @@ import designApi from '../../lib/design-api.js';
11
15
 
12
16
  const { resolveDesignKey, designRequest, pollDesignJob, billingOf } = designApi;
13
17
 
14
- export const TOOLS = [
18
+ const DESIGN_TOOLS = [
15
19
  {
16
20
  name: 'design_extract',
17
21
  description: 'extract a site\'s design system (colors, typography, layout, voice) as json. costs 10 credits, 2 on a cache hit.',
@@ -49,6 +53,104 @@ export const TOOLS = [
49
53
  },
50
54
  ];
51
55
 
56
+ const CWD = { type: 'string', description: 'workspace folder to run in; defaults to the folder this server started in' };
57
+ const ID = { type: 'string', description: 'campaign id, e.g. cmp-ship-billing-1a2b' };
58
+ const AS = { type: 'string', description: 'the member you are working as, e.g. builder; writes are never recorded as a person' };
59
+
60
+ export const CAMPAIGN_TOOLS = [
61
+ {
62
+ name: 'campaign_list',
63
+ description: 'list every atris campaign with its state, money spent, and the next move. a campaign is a long goal with a judge (a command that exits 0 when the goal is met), a budget, and members.',
64
+ inputSchema: { type: 'object', properties: { cwd: CWD } },
65
+ },
66
+ {
67
+ name: 'campaign_show',
68
+ description: 'show one campaign: judge and its last reading, budget, seats, missions, open asks, and the next move.',
69
+ inputSchema: { type: 'object', properties: { id: ID, cwd: CWD }, required: ['id'] },
70
+ },
71
+ {
72
+ name: 'campaign_new',
73
+ description: 'the easiest start: give a goal in plain words. the owner\'s engine drafts a judge script, atris runs it to prove it fails today, and returns the draft. start: true also starts the campaign. budget defaults to $20.',
74
+ inputSchema: {
75
+ type: 'object',
76
+ properties: {
77
+ goal: { type: 'string', description: 'the goal in plain words' },
78
+ budget: { type: 'number', description: 'dollars the campaign may spend (default 20)' },
79
+ owner: { type: 'string', description: 'member who owns it (default mission-lead)' },
80
+ start: { type: 'boolean', description: 'start the campaign once the drafted judge fails today' },
81
+ cwd: CWD,
82
+ },
83
+ required: ['goal'],
84
+ },
85
+ },
86
+ {
87
+ name: 'campaign_start',
88
+ description: 'start a campaign with a judge you wrote. the judge runs first and a judge that already passes is refused: it must fail today. print graded output like "ℹ pass 3" and "ℹ fail 2" so progress shows.',
89
+ inputSchema: {
90
+ type: 'object',
91
+ properties: {
92
+ title: { type: 'string', description: 'short name for the goal' },
93
+ judge: { type: 'string', description: 'shell command that exits 0 only when the goal is met' },
94
+ budget: { type: 'number', description: 'dollars the campaign may spend' },
95
+ owner: { type: 'string', description: 'member who owns it (default mission-lead)' },
96
+ seats: { type: 'array', items: { type: 'string' }, description: 'members who work on it, as member or member:role' },
97
+ bet: { type: 'string', description: 'what you think will work' },
98
+ id: { type: 'string', description: 'optional id, cmp-<lowercase-words>' },
99
+ as: AS,
100
+ cwd: CWD,
101
+ },
102
+ required: ['title', 'judge', 'budget'],
103
+ },
104
+ },
105
+ {
106
+ name: 'campaign_run',
107
+ description: 'let the campaign run itself for at most max_steps steps: it judges, proposes missions, and runs them. it stops early when the judge passes, the money runs out, or a person must answer an ask. returns the stop reason.',
108
+ inputSchema: {
109
+ type: 'object',
110
+ properties: {
111
+ id: ID,
112
+ max_steps: { type: 'number', description: 'steps before it stops, 1 to 20 (default 3)' },
113
+ parallel: { type: 'number', description: 'missions at once, 1 to 5 (default 3)' },
114
+ as: AS,
115
+ cwd: CWD,
116
+ },
117
+ required: ['id'],
118
+ },
119
+ },
120
+ {
121
+ name: 'campaign_ask',
122
+ description: 'stop for a person before anything that cannot be undone: sending, paying, deleting, deploying, publishing. the campaign waits until a person answers.',
123
+ inputSchema: {
124
+ type: 'object',
125
+ properties: {
126
+ id: ID,
127
+ question: { type: 'string', description: 'the question, with what happens on yes' },
128
+ mission: { type: 'string', description: 'mission id the ask belongs to' },
129
+ as: AS,
130
+ cwd: CWD,
131
+ },
132
+ required: ['id', 'question'],
133
+ },
134
+ },
135
+ {
136
+ name: 'campaign_answer',
137
+ description: 'pass along a person\'s answer to an open ask. an agent cannot close an ask: the answer is recorded and the ask stays open until the person runs the returned person_command in their own terminal.',
138
+ inputSchema: {
139
+ type: 'object',
140
+ properties: {
141
+ id: ID,
142
+ ask_id: { type: 'string', description: 'ask id from campaign_show, e.g. a-1b2c3d' },
143
+ answer: { type: 'string', description: 'the person\'s answer in their words' },
144
+ as: AS,
145
+ cwd: CWD,
146
+ },
147
+ required: ['id', 'ask_id', 'answer'],
148
+ },
149
+ },
150
+ ];
151
+
152
+ export const TOOLS = [...DESIGN_TOOLS, ...CAMPAIGN_TOOLS];
153
+
52
154
  function terminal(job) {
53
155
  const s = String((job && job.status) || '').toLowerCase();
54
156
  return s === 'completed' || s === 'failed' || s === 'error' || s === 'succeeded';
@@ -112,6 +214,100 @@ async function runJob(first, pollPath, key) {
112
214
  return job;
113
215
  }
114
216
 
217
+ const CLI = fileURLToPath(new URL('../../bin/atris.js', import.meta.url));
218
+
219
+ function text(value) {
220
+ return String(value ?? '').trim();
221
+ }
222
+
223
+ function bounded(value, min, max, fallback) {
224
+ const n = Number(value);
225
+ return Number.isFinite(n) ? Math.min(max, Math.max(min, Math.round(n))) : fallback;
226
+ }
227
+
228
+ // The argv for `atris campaign` for one tool call, or { error }.
229
+ export function campaignArgs(name, args = {}) {
230
+ const as = text(args.as) ? ['--as', text(args.as)] : [];
231
+ const need = (...keys) => keys.filter((key) => !text(args[key]));
232
+ if (name === 'campaign_list') return ['campaign', 'list', '--json'];
233
+ if (name === 'campaign_show') {
234
+ if (need('id').length) return { error: 'campaign_show needs an id' };
235
+ return ['campaign', 'show', text(args.id), '--json'];
236
+ }
237
+ if (name === 'campaign_new') {
238
+ if (need('goal').length) return { error: 'campaign_new needs a goal' };
239
+ const out = ['campaign', 'new', text(args.goal)];
240
+ if (args.budget !== undefined) out.push('--budget', String(args.budget));
241
+ if (text(args.owner)) out.push('--owner', text(args.owner));
242
+ if (args.start === true) out.push('--yes');
243
+ return [...out, '--json'];
244
+ }
245
+ if (name === 'campaign_start') {
246
+ const missing = need('title', 'judge');
247
+ if (missing.length || args.budget === undefined) return { error: 'campaign_start needs a title, a judge, and a budget' };
248
+ const out = ['campaign', 'start', text(args.title), '--judge', text(args.judge), '--budget', String(args.budget)];
249
+ if (text(args.owner)) out.push('--owner', text(args.owner));
250
+ for (const seat of Array.isArray(args.seats) ? args.seats : []) if (text(seat)) out.push('--seat', text(seat));
251
+ if (text(args.bet)) out.push('--bet', text(args.bet));
252
+ if (text(args.id)) out.push('--id', text(args.id));
253
+ return [...out, ...as, '--json'];
254
+ }
255
+ if (name === 'campaign_run') {
256
+ if (need('id').length) return { error: 'campaign_run needs an id' };
257
+ return ['campaign', 'run', text(args.id), '--max-steps', String(bounded(args.max_steps, 1, 20, 3)),
258
+ '--parallel', String(bounded(args.parallel, 1, 5, 3)), ...as, '--json'];
259
+ }
260
+ if (name === 'campaign_ask') {
261
+ if (need('id', 'question').length) return { error: 'campaign_ask needs an id and a question' };
262
+ const out = ['campaign', 'ask', text(args.id), text(args.question)];
263
+ if (text(args.mission)) out.push('--mission', text(args.mission));
264
+ return [...out, ...as, '--json'];
265
+ }
266
+ if (name === 'campaign_answer') {
267
+ if (need('id', 'ask_id', 'answer').length) return { error: 'campaign_answer needs an id, an ask_id, and an answer' };
268
+ return ['campaign', 'answer', text(args.id), text(args.ask_id), text(args.answer), ...as, '--json'];
269
+ }
270
+ return { error: `unknown tool: ${name}` };
271
+ }
272
+
273
+ // Every write from here is an agent's: the CLI never records it as a person.
274
+ function runCli(argv, cwd) {
275
+ const env = { ...process.env, ATRIS_SKIP_UPDATE_CHECK: '1', ATRIS_NONINTERACTIVE: '1', ATRIS_AGENT_ID: process.env.ATRIS_AGENT_ID || 'mcp-agent' };
276
+ delete env.ATRIS_HUMAN;
277
+ return new Promise((resolve) => {
278
+ const child = spawn(process.execPath, [CLI, ...argv], { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
279
+ let stdout = '';
280
+ let stderr = '';
281
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
282
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
283
+ child.on('error', (error) => resolve({ code: null, stdout, stderr: error.message }));
284
+ child.on('close', (code) => resolve({ code, stdout, stderr }));
285
+ });
286
+ }
287
+
288
+ function lastJson(stdout) {
289
+ const lines = String(stdout).split('\n').map((line) => line.trim()).filter(Boolean).reverse();
290
+ for (const line of lines) {
291
+ try { return JSON.parse(line); } catch {}
292
+ }
293
+ return null;
294
+ }
295
+
296
+ export async function handleCampaignTool(name, args = {}) {
297
+ const argv = campaignArgs(name, args);
298
+ if (!Array.isArray(argv)) return fail(argv.error);
299
+ const ran = await runCli(argv, text(args.cwd) || process.cwd());
300
+ const data = lastJson(ran.stdout);
301
+ if (!data) return fail(`atris campaign gave no json (exit ${ran.code}): ${text(ran.stderr || ran.stdout).slice(-400)}`);
302
+ if (name === 'campaign_answer' && data.campaign && data.campaign.asks.some((ask) => ask.id === text(args.ask_id))) {
303
+ data.still_open = true;
304
+ data.person_command = `atris campaign answer ${text(args.id)} ${text(args.ask_id)} ${JSON.stringify(text(args.answer))}`;
305
+ }
306
+ const result = { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
307
+ if (data.ok === false) result.isError = true;
308
+ return result;
309
+ }
310
+
115
311
  export async function handleTool(name, args = {}, key) {
116
312
  if (name === 'design_extract') {
117
313
  const url = String(args.url || '').trim();
@@ -184,13 +380,19 @@ async function serveStdio() {
184
380
  } else if (request.method === 'tools/call') {
185
381
  const pending = { cancelled: false };
186
382
  const work = (async () => {
383
+ const toolName = String(request.params?.name || '');
384
+ const isCampaign = toolName.startsWith('campaign_');
187
385
  try {
188
- const key = resolveDesignKey();
189
- result = key
190
- ? await handleTool(request.params?.name, request.params?.arguments || {}, key)
191
- : fail('no api key found. set ATRIS_API_KEY or run: atris login (or save a key in ~/.atris/design-api-key)');
386
+ if (isCampaign) {
387
+ result = await handleCampaignTool(toolName, request.params?.arguments || {});
388
+ } else {
389
+ const key = resolveDesignKey();
390
+ result = key
391
+ ? await handleTool(request.params?.name, request.params?.arguments || {}, key)
392
+ : fail('no api key found. set ATRIS_API_KEY or run: atris login (or save a key in ~/.atris/design-api-key)');
393
+ }
192
394
  } catch (error) {
193
- result = fail(`design call failed: ${(error && error.message) || error}`);
395
+ result = fail(`${isCampaign ? 'campaign' : 'design'} call failed: ${(error && error.message) || error}`);
194
396
  }
195
397
  if (!pending.cancelled) {
196
398
  await new Promise((resolve, reject) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.69.0",
3
+ "version": "3.71.0",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {
@@ -73,6 +73,7 @@
73
73
  "test:fast": "xargs node --test < test/fast-tests.txt",
74
74
  "lint:decks": "node scripts/lint-all-decks.js",
75
75
  "audit:markdown-whitespace": "node scripts/audit-markdown-whitespace.js",
76
+ "validate:campaign": "node scripts/campaign-conformance.js --merged",
76
77
  "publish:release": "node scripts/publish-atris-release.js",
77
78
  "prepare": "cp scripts/pre-commit .git/hooks/pre-commit 2>/dev/null && cp scripts/commit-msg .git/hooks/commit-msg 2>/dev/null && cp scripts/prepare-commit-msg .git/hooks/prepare-commit-msg 2>/dev/null && chmod +x .git/hooks/pre-commit .git/hooks/commit-msg .git/hooks/prepare-commit-msg || true"
78
79
  },
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Machine readings for an engine a shell script launches, written to the
5
+ // caller's Atris workspace like every other engine run.
6
+ // engine-reading.js start <engine> <task> <pid> prints a token
7
+ // engine-reading.js end <engine> <task> <token> <status> prints the kill cause, if any
8
+ // The token is the run id and the start time, so the end can say how long it ran.
9
+ // The pid is the calling script's, which outlives this helper, so a sweep
10
+ // closes the run only once the script itself is gone.
11
+ // A shell sees a SIGKILL as exit 137 and an alarm as 142. Outside an Atris
12
+ // workspace (no atris/ folder) it writes nothing and prints nothing.
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const crypto = require('crypto');
17
+ const { recordEngineResource } = require('../../lib/engine-resource');
18
+
19
+ const [phase, engine, task, tokenOrPid, status] = process.argv.slice(2);
20
+ const root = process.cwd();
21
+
22
+ if (fs.existsSync(path.join(root, 'atris'))) {
23
+ if (phase === 'start') {
24
+ const id = crypto.randomUUID();
25
+ recordEngineResource(root, { phase: 'start', engine, task, run_id: id }, { pid: Number(tokenOrPid) });
26
+ process.stdout.write(`${id}@${Date.now()}`);
27
+ } else if (phase === 'end' && tokenOrPid) {
28
+ const [runId, startedAt] = tokenOrPid.split('@');
29
+ const code = Number(status);
30
+ const signal = code === 137 ? 'SIGKILL' : null;
31
+ const timedOut = code === 142;
32
+ const error = code && !signal && !timedOut ? `exited ${code}` : '';
33
+ const durationMs = Math.max(0, Date.now() - Number(startedAt));
34
+ const entry = recordEngineResource(root, { phase: 'end', engine, task, run_id: runId, signal, timed_out: timedOut, error, duration_ms: durationMs });
35
+ if (entry && entry.cause) process.stdout.write(entry.cause);
36
+ }
37
+ }
@@ -315,6 +315,28 @@ run_with_timeout() {
315
315
  perl -e '$seconds = shift @ARGV; alarm $seconds; exec @ARGV or do { print STDERR "$ARGV[0]: not installed\n"; exit 127 }' "$seconds" "$@"
316
316
  }
317
317
 
318
+ # Machine memory and load at both ends of one writer launch, in the caller's
319
+ # Atris workspace, so a writer killed with SIGKILL says whether the machine
320
+ # was short on memory. A reading that fails never stops the notes.
321
+ engine_reading() {
322
+ (cd "$CALLER_CWD" && node "$SCRIPT_DIR/engine-reading.js" "$@") 2>/dev/null || true
323
+ }
324
+
325
+ with_readings() {
326
+ local engine="$1" token cause status=0
327
+ shift
328
+ token=$(engine_reading start "$engine" "youtube notes" "$$")
329
+ "$@" || status=$?
330
+ [ -n "$token" ] || return "$status"
331
+ cause=$(engine_reading end "$engine" "youtube notes" "$token" "$status")
332
+ if [ -n "$cause" ]; then
333
+ echo "$engine stopped by SIGKILL; $cause" >&2
334
+ # auto sends writer errors to a file; it shows this line on screen.
335
+ echo "$engine stopped by SIGKILL; $cause" > "$WORK/yt_${ID}.kill"
336
+ fi
337
+ return "$status"
338
+ }
339
+
318
340
  run_atris_fast() {
319
341
  if [ ! -d "$CALLER_CWD/atris" ]; then
320
342
  echo "atris-fast requires ytnotes to run from an initialized Atris workspace" >&2
@@ -392,12 +414,12 @@ process.stdin.on("end", () => {
392
414
  }
393
415
 
394
416
  run_grok() {
395
- run_with_timeout 240 grok --verbatim --no-memory --no-plan --no-subagents \
417
+ with_readings grok run_with_timeout 240 grok --verbatim --no-memory --no-plan --no-subagents \
396
418
  --disable-web-search --max-turns 3 -p "$PROMPT"
397
419
  }
398
420
 
399
421
  run_haiku() {
400
- run_with_timeout 240 claude -p "$PROMPT" --model "${1:-claude-haiku-5-5}"
422
+ with_readings claude run_with_timeout 240 claude -p "$PROMPT" --model "${1:-claude-haiku-5-5}"
401
423
  }
402
424
 
403
425
  # Gemini Flash through the Antigravity CLI (agy). On an 85-minute transcript it
@@ -426,7 +448,7 @@ run_gemini() {
426
448
  command -v agy >/dev/null 2>&1 || { echo "agy is not installed" >&2; return 127; }
427
449
  agy_is_cli || { echo "agy on PATH is the Antigravity editor launcher, which has no -p mode. Install the Antigravity CLI for Gemini notes." >&2; return 127; }
428
450
  agy_signed_in || { echo "agy is installed but not signed in. Run: agy (sign in once) for Gemini notes." >&2; return 127; }
429
- run_with_timeout 120 agy -p "$PROMPT" --model "${ATRIS_YTNOTES_GEMINI_MODEL:-${1:-gemini-3.8-flash}}" \
451
+ with_readings agy run_with_timeout 120 agy -p "$PROMPT" --model "${ATRIS_YTNOTES_GEMINI_MODEL:-${1:-gemini-3.8-flash}}" \
430
452
  --effort "${2:-low}" --print-timeout 110s </dev/null
431
453
  }
432
454
 
@@ -483,8 +505,8 @@ run_writer() {
483
505
  case "$1" in
484
506
  agy) run_gemini "$model" "${3:-}" ;;
485
507
  claude) run_haiku "$model" ;;
486
- codex) run_with_timeout 240 codex exec ${model:+-m "$model"} --sandbox read-only --skip-git-repo-check "$PROMPT" </dev/null ;;
487
- cursor-agent) run_with_timeout 240 cursor-agent --trust ${model:+--model "$model"} -p "$PROMPT" ;;
508
+ codex) with_readings codex run_with_timeout 240 codex exec ${model:+-m "$model"} --sandbox read-only --skip-git-repo-check "$PROMPT" </dev/null ;;
509
+ cursor-agent) with_readings cursor-agent run_with_timeout 240 cursor-agent --trust ${model:+--model "$model"} -p "$PROMPT" ;;
488
510
  esac
489
511
  }
490
512
 
@@ -501,11 +523,13 @@ run_auto() {
501
523
  if [ -n "$tried" ]; then
502
524
  echo "$tried did not answer; writing the notes with $label instead." >&2
503
525
  fi
526
+ rm -f "$WORK/yt_${ID}.kill"
504
527
  if out=$(run_writer "$cmd" "$model" "$effort" 2>>"$WORK/yt_${ID}.engine.err") && printf '%s' "$out" | grep -q '[[:alnum:]]'; then
505
528
  printf '%s\n' "$out"
506
529
  echo "notes by $label" >&2
507
530
  return 0
508
531
  fi
532
+ [ -f "$WORK/yt_${ID}.kill" ] && cat "$WORK/yt_${ID}.kill" >&2
509
533
  tried="$label"
510
534
  done
511
535
  if [ -n "$tried" ]; then