atris 3.74.4 → 3.74.5

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 (117) hide show
  1. package/FOR_AGENTS.md +12 -12
  2. package/atris/AGENTS.md +7 -7
  3. package/atris/CLAUDE.md +10 -10
  4. package/atris/policies/atris-backend.md +1 -1
  5. package/atris/policies/atris-design.md +71 -71
  6. package/atris/policies/design-seed.md +28 -28
  7. package/atris/policies/writing.md +1 -1
  8. package/atris/skills/apps/SKILL.md +3 -3
  9. package/atris/skills/atris-feedback/SKILL.md +2 -2
  10. package/atris/skills/autopilot/SKILL.md +10 -10
  11. package/atris/skills/blocks/SKILL.md +16 -16
  12. package/atris/skills/calendar/SKILL.md +1 -1
  13. package/atris/skills/clawhub/atris/SKILL.md +4 -4
  14. package/atris/skills/clawhub/philosophy-of-work/SKILL.md +7 -7
  15. package/atris/skills/create-app/SKILL.md +18 -18
  16. package/atris/skills/create-member/SKILL.md +11 -11
  17. package/atris/skills/design/SKILL.md +1 -1
  18. package/atris/skills/drive/SKILL.md +25 -25
  19. package/atris/skills/email-agent/SKILL.md +11 -11
  20. package/atris/skills/endgame/SKILL.md +33 -33
  21. package/atris/skills/magic-inbox/SKILL.md +15 -15
  22. package/atris/skills/notion/SKILL.md +1 -1
  23. package/atris/skills/ramp/SKILL.md +7 -7
  24. package/atris/skills/research-search/SKILL.md +7 -7
  25. package/atris/skills/slides/SKILL.md +7 -7
  26. package/atris/skills/tidy/SKILL.md +11 -11
  27. package/atris/skills/wake/SKILL.md +11 -11
  28. package/atris/skills/writing/SKILL.md +5 -5
  29. package/atris/team/_template/SOUL.md +3 -3
  30. package/atris/team/brainstormer/MEMBER.md +2 -2
  31. package/atris/team/launcher/MEMBER.md +2 -2
  32. package/atris/team/mission-lead/MISSION.md +7 -7
  33. package/atris/team/opus-overnight/MISSION.md +16 -16
  34. package/atris/team/opus-overnight/STEERING.md +5 -5
  35. package/atris.md +9 -0
  36. package/bin/atris.js +16 -3
  37. package/commands/campaign.js +17 -2
  38. package/commands/card.js +19 -12
  39. package/commands/engine.js +92 -17
  40. package/commands/gm.js +30 -0
  41. package/commands/idea.js +263 -0
  42. package/commands/init.js +11 -2
  43. package/commands/land.js +39 -18
  44. package/commands/mission.js +4 -3
  45. package/commands/ninety-nine.js +288 -0
  46. package/commands/one-lap.js +46 -27
  47. package/commands/reel.js +12 -7
  48. package/commands/sync.js +10 -2
  49. package/lib/campaign-keep.js +23 -8
  50. package/lib/campaign-loop.js +24 -5
  51. package/lib/claude-boot-block.js +7 -4
  52. package/lib/engine-registry.js +139 -1
  53. package/lib/fleet.js +11 -2
  54. package/lib/gm-bet.js +338 -34
  55. package/lib/gm-phone-page.js +325 -33
  56. package/lib/gm-phone.js +5 -1
  57. package/lib/gm-season.js +14 -0
  58. package/lib/headless-chrome.js +39 -0
  59. package/lib/idea-gate.js +33 -0
  60. package/lib/idea-inbox.js +220 -0
  61. package/lib/idea-ledger.js +196 -0
  62. package/lib/idea-tick.js +384 -0
  63. package/lib/known-commands.js +1 -1
  64. package/lib/member-engine.js +1 -0
  65. package/lib/ninety-nine-sync.js +156 -0
  66. package/lib/ninety-nine.js +247 -0
  67. package/lib/opening-lines.js +94 -0
  68. package/lib/roster-markdown.js +39 -1
  69. package/lib/roster-models.js +1 -1
  70. package/lib/roster-swap.js +353 -0
  71. package/lib/spawn-env.js +29 -2
  72. package/lib/wish-audit.js +14 -0
  73. package/lib/yes-lock.js +343 -253
  74. package/lib/zen-apply.js +5 -3
  75. package/lib/zen.js +13 -1
  76. package/package.json +1 -1
  77. package/templates/business-starter/CLAUDE.md +7 -7
  78. package/templates/business-starter/MAP.md +1 -1
  79. package/templates/business-starter/TODO.md +1 -1
  80. package/templates/business-starter/atris.md +2 -2
  81. package/templates/business-starter/context/README.md +6 -6
  82. package/templates/business-starter/context/live-workspace.md +1 -1
  83. package/templates/business-starter/goals.md +1 -1
  84. package/templates/business-starter/instructions.md +1 -1
  85. package/templates/business-starter/memory.md +4 -4
  86. package/templates/business-starter/persona.md +3 -3
  87. package/templates/business-starter/policies/LESSONS.md +1 -1
  88. package/templates/business-starter/reports/README.md +1 -1
  89. package/templates/business-starter/skills/README.md +1 -1
  90. package/templates/business-starter/team/README.md +1 -1
  91. package/templates/business-starter/team/START_HERE.md +1 -1
  92. package/templates/business-starter/team/_template/SOUL.md +3 -3
  93. package/templates/business-starter/team/comms/SOUL.md +2 -2
  94. package/templates/business-starter/team/ops/SOUL.md +4 -4
  95. package/templates/business-starter/team/research/SOUL.md +3 -3
  96. package/templates/business-starter/wiki/index.md +1 -1
  97. package/templates/research-canonical/CLAUDE.md +7 -7
  98. package/templates/research-canonical/MAP.md +1 -1
  99. package/templates/research-canonical/TODO.md +1 -1
  100. package/templates/research-canonical/atris.md +2 -2
  101. package/templates/research-canonical/context/README.md +8 -8
  102. package/templates/research-canonical/context/live-workspace.md +1 -1
  103. package/templates/research-canonical/goals.md +2 -2
  104. package/templates/research-canonical/instructions.md +1 -1
  105. package/templates/research-canonical/memory.md +4 -4
  106. package/templates/research-canonical/persona.md +1 -1
  107. package/templates/research-canonical/policies/LESSONS.md +1 -1
  108. package/templates/research-canonical/reports/README.md +1 -1
  109. package/templates/research-canonical/skills/README.md +1 -1
  110. package/templates/research-canonical/team/README.md +1 -1
  111. package/templates/research-canonical/team/eval/SOUL.md +1 -1
  112. package/templates/research-canonical/team/experiment/SOUL.md +2 -2
  113. package/templates/research-canonical/team/hypothesis/SOUL.md +3 -3
  114. package/templates/research-canonical/team/literature/SOUL.md +2 -2
  115. package/templates/research-canonical/wiki/briefs/research-program.md +1 -1
  116. package/templates/research-canonical/wiki/index.md +1 -1
  117. package/utils/auth.js +1 -0
@@ -0,0 +1,288 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { randomUUID } = require('node:crypto');
6
+ const { argsWantHelp } = require('../lib/noninteractive');
7
+ const { getAtrisDir, loadCredentials } = require('../utils/auth');
8
+ const { stateHome } = require('../lib/state-home');
9
+ const { validateSync, checkpointKey, syncLines } = require('../lib/ninety-nine-sync');
10
+ const {
11
+ validLength,
12
+ parseSessionLog,
13
+ foldSessions,
14
+ runningSession,
15
+ startRecord,
16
+ endRecord,
17
+ automaticEndRecord,
18
+ pauseRecord,
19
+ resumeRecord,
20
+ sessionStatus,
21
+ sessionsToday,
22
+ healthToday,
23
+ healthSummary,
24
+ } = require('../lib/ninety-nine');
25
+
26
+ function usage() {
27
+ return [
28
+ 'usage: atris 99 focus <25|50|90>',
29
+ ' atris 99 meditate <10|20|30>',
30
+ ' atris 99 stop [note words]',
31
+ ' atris 99 pause',
32
+ ' atris 99 resume',
33
+ ' atris 99 status [--json]',
34
+ ' atris 99 today [--json]',
35
+ ' atris 99 sync [--dry-run]',
36
+ '',
37
+ 'sync is off without a sync block in ~/.atris/99/config.json (ATRIS_99_CONFIG overrides it).',
38
+ 'personal: {"sync":{"target":"personal","app_slug":"project-99"}}',
39
+ 'workspace: {"sync":{"target":"workspace","business":"your-business","workspace":"optional-id"}}',
40
+ 'both targets merge in both directions; workspace reads atris/99/sessions.*.jsonl and legacy sessions.jsonl.',
41
+ 'workspace writes only sessions.<device>.jsonl; its stable device id lives in the local sync state.',
42
+ 'personal uses your user login and defaults to app_slug project-99; its records api must be deployed first.',
43
+ 'dry-run reads cloud records and shows missing lines without saving logs or the sync cursor.',
44
+ ].join('\n');
45
+ }
46
+
47
+ function readJson(file, fallback) {
48
+ const text = readLog(file);
49
+ if (!text) return fallback;
50
+ try { return JSON.parse(text); } catch { throw new Error('check the 99 config or sync-state json'); }
51
+ }
52
+
53
+ function saveSyncState(file, state) {
54
+ const temporary = `${file}.${randomUUID()}.tmp`;
55
+ try {
56
+ fs.writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
57
+ fs.renameSync(temporary, file);
58
+ } finally {
59
+ fs.rmSync(temporary, { force: true });
60
+ }
61
+ }
62
+
63
+ async function syncNinetyNine(args, { log, logPath, configPath, statePath, request, credentials = loadCredentials }) {
64
+ if (args.length > 1 || (args.length === 1 && args[0] !== '--dry-run')) {
65
+ log('usage: atris 99 sync [--dry-run]');
66
+ return 2;
67
+ }
68
+ let lock;
69
+ let lockPath;
70
+ let stateLock;
71
+ let stateLockPath;
72
+ try {
73
+ const dir = path.join(stateHome(), '.atris', '99');
74
+ const config = readJson(configPath || process.env.ATRIS_99_CONFIG || path.join(dir, 'config.json'), {});
75
+ if (!config?.sync) {
76
+ log('sync is off (local only)');
77
+ return 0;
78
+ }
79
+ validateSync(config.sync);
80
+ const creds = await credentials();
81
+ if (!creds?.token) throw new Error('sync needs your signed-in user login; run atris login');
82
+ const dryRun = args.includes('--dry-run');
83
+ const file = logPath || process.env.ATRIS_99_LOG || path.join(dir, 'sessions.jsonl');
84
+ const stateFile = statePath || path.join(dir, 'sync-state.json');
85
+ if (!dryRun) {
86
+ for (const folder of new Set([path.dirname(file), path.dirname(stateFile)])) {
87
+ fs.mkdirSync(folder, { recursive: true, mode: 0o700 });
88
+ }
89
+ // Serialize cursor updates, even if callers use different local logs.
90
+ stateLockPath = `${stateFile}.lock`;
91
+ stateLock = fs.openSync(stateLockPath, 'wx', 0o600);
92
+ lockPath = `${file}.lock`;
93
+ lock = fs.openSync(lockPath, 'wx', 0o600);
94
+ }
95
+ let text = readLog(file);
96
+ const records = parseSessionLog(text);
97
+ const state = readJson(stateFile, { v: 1, targets: {} });
98
+ if (state?.v !== 1 || !state.targets || typeof state.targets !== 'object' || Array.isArray(state.targets)) {
99
+ throw new Error('check the 99 sync-state json');
100
+ }
101
+ const key = checkpointKey(config.sync, creds);
102
+ const checkpoint = state.targets[key] || {};
103
+ if ((checkpoint.known !== undefined && (!Array.isArray(checkpoint.known) || !checkpoint.known.every((item) => typeof item === 'string')))
104
+ || (checkpoint.since != null && !Number.isFinite(Date.parse(checkpoint.since)))) {
105
+ throw new Error('check the 99 sync-state json');
106
+ }
107
+ if (config.sync.target === 'workspace' && state.device_id === undefined) {
108
+ state.device_id = randomUUID();
109
+ // Save the identity before uploading, even if the reply or later log save fails.
110
+ // The state lock keeps simultaneous local runs on the same device file.
111
+ if (!dryRun) saveSyncState(stateFile, state);
112
+ }
113
+ const result = await syncLines({ records, sync: config.sync, checkpoint, deviceId: state.device_id, credentials: creds, dryRun, request });
114
+ if (dryRun) {
115
+ log(`would send ${result.send.length} lines and pull ${result.pull.length} lines (${config.sync.target})`);
116
+ for (const record of result.send) log(`send: ${JSON.stringify(record)}`);
117
+ for (const record of result.pull) log(`pull: ${JSON.stringify(record)}`);
118
+ } else {
119
+ for (const record of result.pull) text = appendRecord(file, text, record);
120
+ state.targets[key] = result.checkpoint;
121
+ saveSyncState(stateFile, state);
122
+ log(`sent ${result.send.length} lines and pulled ${result.pull.length} lines (${config.sync.target})`);
123
+ }
124
+ return 0;
125
+ } catch (error) {
126
+ const message = error.code === 'EEXIST' ? 'session sync is busy; try again'
127
+ : error.code ? 'check the sync file paths and permissions' : error.message;
128
+ log(`could not sync: ${String(message).replace(/\s+/g, ' ')}`);
129
+ return 1;
130
+ } finally {
131
+ for (const [handle, file] of [[lock, lockPath], [stateLock, stateLockPath]]) {
132
+ if (handle !== undefined) {
133
+ fs.closeSync(handle);
134
+ fs.unlinkSync(file);
135
+ }
136
+ }
137
+ }
138
+ }
139
+
140
+ function readLog(logPath) {
141
+ try { return fs.readFileSync(logPath, 'utf8'); } catch (error) {
142
+ if (error.code === 'ENOENT') return '';
143
+ throw error;
144
+ }
145
+ }
146
+
147
+ function appendRecord(logPath, previousText, record) {
148
+ // A valid last record may lack a newline. Preserve every existing byte.
149
+ const separator = previousText && !previousText.endsWith('\n') ? '\n' : '';
150
+ const line = `${separator}${JSON.stringify(record)}\n`;
151
+ fs.appendFileSync(logPath, line, { mode: 0o600 });
152
+ return previousText + line;
153
+ }
154
+
155
+ function ninetyNineAtris(args = process.argv.slice(3), {
156
+ log = console.log,
157
+ now = () => new Date(),
158
+ logPath,
159
+ configPath,
160
+ statePath,
161
+ request,
162
+ credentials,
163
+ } = {}) {
164
+ const [action, ...rest] = args;
165
+ // Words after stop belong to its note, including the word "help".
166
+ if ((action !== 'stop' && argsWantHelp(args)) || (action === 'stop' && ['--help', '-h'].includes(rest[0]))) {
167
+ log(usage());
168
+ return 0;
169
+ }
170
+ if (action === 'sync') return syncNinetyNine(rest, { log, logPath, configPath, statePath, request, credentials });
171
+ const json = rest.includes('--json');
172
+ if (action === 'focus' || action === 'meditate') {
173
+ if (rest.length !== 1 || !validLength(action, Number(rest[0])) || String(Number(rest[0])) !== rest[0]) {
174
+ log(action === 'focus' ? 'choose focus 25, 50, or 90 minutes' : 'choose meditate 10, 20, or 30 minutes');
175
+ return 2;
176
+ }
177
+ } else if (action === 'status' || action === 'today') {
178
+ if (rest.length > 1 || (rest.length === 1 && !json)) {
179
+ log(`usage: atris 99 ${action} [--json]`);
180
+ return 2;
181
+ }
182
+ } else if (action === 'pause' || action === 'resume') {
183
+ if (rest.length) {
184
+ log(`usage: atris 99 ${action}`);
185
+ return 2;
186
+ }
187
+ } else if (action !== 'stop') {
188
+ log(usage());
189
+ return 2;
190
+ }
191
+
192
+ let lock;
193
+ let lockPath;
194
+ try {
195
+ const override = logPath || process.env.ATRIS_99_LOG;
196
+ const file = override || path.join(getAtrisDir(), '99', 'sessions.jsonl');
197
+ const dir = path.dirname(file);
198
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
199
+ // An override may live in a shared folder. Only tighten our own folder.
200
+ if (!override) fs.chmodSync(dir, 0o700);
201
+ lockPath = `${file}.lock`;
202
+ try { lock = fs.openSync(lockPath, 'wx', 0o600); } catch (error) {
203
+ if (error.code === 'EEXIST') throw new Error('session log is busy; try again');
204
+ throw error;
205
+ }
206
+ let text = readLog(file);
207
+ const records = parseSessionLog(text);
208
+ let sessions = foldSessions(records);
209
+ const time = now();
210
+ let active = runningSession(sessions, time);
211
+ const stopTarget = action === 'stop'
212
+ ? active || sessions.findLast((session) => automaticEndRecord(session, time)?.status === 'done')
213
+ : null;
214
+
215
+ for (const session of sessions) {
216
+ const end = automaticEndRecord(session, time);
217
+ if (end && session !== stopTarget) {
218
+ text = appendRecord(file, text, end);
219
+ records.push(end);
220
+ }
221
+ }
222
+
223
+ if (action === 'stop') {
224
+ if (!stopTarget) {
225
+ log('nothing running');
226
+ return 1;
227
+ }
228
+ const end = endRecord(stopTarget, time, rest.join(' '));
229
+ appendRecord(file, text, end);
230
+ log(`${stopTarget.kind} ${end.status}: ${end.status === 'done' ? stopTarget.planned_min : 0} finished minutes`);
231
+ return 0;
232
+ }
233
+
234
+ sessions = foldSessions(records);
235
+ active = runningSession(sessions, time);
236
+ if (action === 'focus' || action === 'meditate') {
237
+ if (active) {
238
+ log('a session is already running; use atris 99 stop first');
239
+ return 1;
240
+ }
241
+ appendRecord(file, text, startRecord(action, Number(rest[0]), time, randomUUID()));
242
+ log(`${action} started for ${rest[0]} minutes`);
243
+ } else if (action === 'pause' || action === 'resume') {
244
+ let record;
245
+ try {
246
+ record = action === 'pause' ? pauseRecord(active, time) : resumeRecord(active, time);
247
+ } catch (error) {
248
+ log(error.message);
249
+ return 1;
250
+ }
251
+ appendRecord(file, text, record);
252
+ log(`${active.kind} ${action === 'pause' ? 'paused' : 'resumed'}`);
253
+ } else if (action === 'status') {
254
+ const result = sessionStatus(active, time);
255
+ if (json) log(JSON.stringify(result));
256
+ else if (!active) log('nothing running');
257
+ else {
258
+ const seconds = result.running.remaining_seconds;
259
+ log(`${active.kind}: ${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')} left${active.status === 'paused' ? ' (paused)' : ''}`);
260
+ }
261
+ } else {
262
+ const result = sessionsToday(sessions, time);
263
+ const health = healthToday(records, time);
264
+ if (health) result.health_day = health;
265
+ if (json) log(JSON.stringify(result));
266
+ else {
267
+ for (const session of result.sessions) {
268
+ log(`${session.kind} ${session.planned_min} minutes: ${session.status}`);
269
+ }
270
+ log(`${result.finished_minutes} finished minutes today`);
271
+ log(`Today: ${result.counts.focus} focus, ${result.counts.meditate} meditate, ${result.finished_minutes} min`);
272
+ if (health) log(healthSummary(health));
273
+ }
274
+ }
275
+ return 0;
276
+ } catch (error) {
277
+ const message = error.code ? 'check the file path and its permissions' : error.message;
278
+ log(`could not use the session log: ${String(message).replace(/\s+/g, ' ')}`);
279
+ return 1;
280
+ } finally {
281
+ if (lock !== undefined) {
282
+ fs.closeSync(lock);
283
+ fs.unlinkSync(lockPath);
284
+ }
285
+ }
286
+ }
287
+
288
+ module.exports = { ninetyNineAtris };
@@ -242,6 +242,18 @@ function lapModelPins(executor, validators = []) {
242
242
  };
243
243
  }
244
244
 
245
+ // The roster pins, then a caller's overrides: a time cap for the build wins
246
+ // over the roster's, and a caller's house rules ride at the end of the
247
+ // builder's brief (atris idea tick sends both).
248
+ function lapFlightPins(executor, validators = [], options = {}) {
249
+ const pins = lapModelPins(executor, validators);
250
+ const cap = Math.floor(Number(options.maxSeconds));
251
+ if (Number.isFinite(cap) && cap > 0) pins.maxSeconds = cap;
252
+ const brief = String(options.brief || '').trim();
253
+ if (brief) pins.brief = brief;
254
+ return pins;
255
+ }
256
+
245
257
  function taskById(runCli, taskId) {
246
258
  const result = runCli(['task', 'show', taskId, '--json']);
247
259
  if (!result || result.status !== 0) return null;
@@ -426,10 +438,16 @@ async function runOneLap(ask, options = {}) {
426
438
  const asJson = options.asJson === true;
427
439
  const runCli = options.ownCli || ((args, cwd = root) => ownCli(root, args, cwd));
428
440
  const progress = options.progress || ((line) => process.stderr.write(`${line}\n`));
441
+ // A caller that drives laps itself (atris idea tick) reads the result object
442
+ // instead of parsing printed text, and can keep the lap quiet.
443
+ const report = (result) => {
444
+ if (typeof options.onResult === 'function') options.onResult(result);
445
+ if (options.quiet !== true) emit(result, asJson);
446
+ };
429
447
  const text = String(ask || '').replace(/\s+/g, ' ').trim();
430
448
  if (!text) {
431
449
  const result = { schema: 'atris.one_lap.v1', ok: false, status: 'stuck', reason: 'a request is required', next_action: 'atris "<request>"' };
432
- emit(result, asJson);
450
+ report(result);
433
451
  return 2;
434
452
  }
435
453
 
@@ -453,7 +471,7 @@ async function runOneLap(ask, options = {}) {
453
471
  : oneLapRetryCommand(text, options),
454
472
  });
455
473
  result.resumed = true;
456
- emit(result, asJson);
474
+ report(result);
457
475
  return 0;
458
476
  }
459
477
 
@@ -467,7 +485,7 @@ async function runOneLap(ask, options = {}) {
467
485
  nextAction: wishAnswerCommand(existing.wish),
468
486
  });
469
487
  result.resumed = true;
470
- emit(result, asJson);
488
+ report(result);
471
489
  return 0;
472
490
  }
473
491
 
@@ -483,7 +501,7 @@ async function runOneLap(ask, options = {}) {
483
501
  reason: 'the lap verifier is frozen after intake and cannot be replaced on retry',
484
502
  next_action: oneLapRetryCommand(text, { engine: options.engine || '', verifier: existingVerifier }),
485
503
  };
486
- emit(result, asJson);
504
+ report(result);
487
505
  return 2;
488
506
  }
489
507
  if (task && ['review', 'done'].includes(String(task.status || ''))) {
@@ -500,7 +518,7 @@ async function runOneLap(ask, options = {}) {
500
518
  reason: 'the matching task is in Review without a passing matching one-lap receipt',
501
519
  next_action: `atris task show ${shellQuote(task.display_id)}`,
502
520
  };
503
- emit(result, asJson);
521
+ report(result);
504
522
  return 1;
505
523
  }
506
524
  const result = {
@@ -528,7 +546,7 @@ async function runOneLap(ask, options = {}) {
528
546
  master_changed: false,
529
547
  ...(prior && prior.receipt && prior.receipt.result ? { result: prior.receipt.result } : {}),
530
548
  };
531
- emit(result, asJson);
549
+ report(result);
532
550
  return 0;
533
551
  }
534
552
  if (task && String(task.status || '') === 'claimed') {
@@ -543,7 +561,7 @@ async function runOneLap(ask, options = {}) {
543
561
  nextAction: `atris task show ${shellQuote(task.display_id)}`,
544
562
  });
545
563
  result.resumed = true;
546
- emit(result, asJson);
564
+ report(result);
547
565
  return 0;
548
566
  }
549
567
  const released = runCli(['task', 'release', task.display_id, '--as', actor, '--json']);
@@ -555,7 +573,7 @@ async function runOneLap(ask, options = {}) {
555
573
  reason: `the abandoned one-lap claim could not be released: ${detail}`,
556
574
  next_action: `atris task show ${shellQuote(task.display_id)}`,
557
575
  };
558
- emit(result, asJson);
576
+ report(result);
559
577
  return 1;
560
578
  }
561
579
  task = taskById(runCli, task.display_id);
@@ -568,7 +586,7 @@ async function runOneLap(ask, options = {}) {
568
586
  // still has a front door: `atris wish` runs it through the gated flow.
569
587
  // Hand the new person that exact command, not unactionable prose.
570
588
  const result = { ...resultBase({ status: 'stuck', ask: text }), reason: intakeSafetyIssue, next_action: `atris wish ${shellQuote(text)}` };
571
- emit(result, asJson);
589
+ report(result);
572
590
  return 2;
573
591
  }
574
592
  }
@@ -578,12 +596,12 @@ async function runOneLap(ask, options = {}) {
578
596
  executor = readyExecutor(root, options.engine || '');
579
597
  } catch (error) {
580
598
  const result = { ...resultBase({ status: 'stuck', ask: text }), reason: error.message, next_action: 'atris engine' };
581
- emit(result, asJson);
599
+ report(result);
582
600
  return 2;
583
601
  }
584
602
  if (!executor) {
585
603
  const result = { ...resultBase({ status: 'stuck', ask: text }), reason: 'no ready executor engine is installed', next_action: 'atris engine' };
586
- emit(result, asJson);
604
+ report(result);
587
605
  return 2;
588
606
  }
589
607
 
@@ -593,7 +611,7 @@ async function runOneLap(ask, options = {}) {
593
611
  const parsedIntakeVerifier = parseVerifyCommand(intakeVerifier);
594
612
  if (!parsedIntakeVerifier.ok) {
595
613
  const result = { ...resultBase({ status: 'stuck', ask: text, engine: executor, verifier: intakeVerifier }), reason: `the verifier is not safe to run (${parsedIntakeVerifier.reason})`, next_action: 'choose a local test command' };
596
- emit(result, asJson);
614
+ report(result);
597
615
  return 2;
598
616
  }
599
617
  }
@@ -609,7 +627,7 @@ async function runOneLap(ask, options = {}) {
609
627
  if (!payload) {
610
628
  const detail = String(captured && (captured.stderr || captured.stdout) || 'wish intake failed').trim().slice(-500);
611
629
  const result = { ...resultBase({ status: 'stuck', ask: text, engine: executor }), reason: detail, next_action: oneLapRetryCommand(text, { engine: executor.id, verifier: intakeVerifier }) };
612
- emit(result, asJson);
630
+ report(result);
613
631
  return captured && captured.status === 2 ? 2 : 1;
614
632
  }
615
633
  const durableWish = readWishes(root).find((row) => row.id === payload.wish_id);
@@ -621,12 +639,12 @@ async function runOneLap(ask, options = {}) {
621
639
  question: Array.isArray(payload.questions) ? payload.questions[0] : '',
622
640
  nextAction: wishAnswerCommand(wish),
623
641
  });
624
- emit(result, asJson);
642
+ report(result);
625
643
  return 0;
626
644
  }
627
645
  if (payload.status !== 'delegated' || !payload.task_id || !payload.mission_id) {
628
646
  const result = { ...resultBase({ status: 'stuck', ask: text, wish, engine: executor }), reason: 'wish intake did not produce one scoped task and mission', next_action: `atris wish show ${payload.wish_id}` };
629
- emit(result, asJson);
647
+ report(result);
630
648
  return 2;
631
649
  }
632
650
  mission = listMissions(root).find((row) => row.id === payload.mission_id) || null;
@@ -635,13 +653,13 @@ async function runOneLap(ask, options = {}) {
635
653
 
636
654
  if (!mission || !task) {
637
655
  const result = { ...resultBase({ status: 'stuck', ask: text, wish, mission, task, engine: executor }), reason: 'the scoped task or mission could not be loaded', next_action: wish && wish.id ? `atris wish show ${wish.id}` : 'atris wish list' };
638
- emit(result, asJson);
656
+ report(result);
639
657
  return 1;
640
658
  }
641
659
  const safetyIssue = oneLapSafetyIssue(task);
642
660
  if (safetyIssue) {
643
661
  const result = { ...resultBase({ status: 'stuck', ask: text, wish, mission, task, engine: executor }), reason: safetyIssue, next_action: `atris task show ${task.display_id}` };
644
- emit(result, asJson);
662
+ report(result);
645
663
  return 2;
646
664
  }
647
665
  let frozenVerifier = missionVerifier(mission);
@@ -652,7 +670,7 @@ async function runOneLap(ask, options = {}) {
652
670
  reason: 'the lap verifier is frozen after intake and cannot be replaced on retry',
653
671
  next_action: oneLapRetryCommand(text, { engine: options.engine || '', verifier: frozenVerifier }),
654
672
  };
655
- emit(result, asJson);
673
+ report(result);
656
674
  return 2;
657
675
  }
658
676
  const verifier = frozenVerifier || requestedVerifier || projectVerifier(root);
@@ -666,13 +684,13 @@ async function runOneLap(ask, options = {}) {
666
684
  nextAction: oneLapRetryCommand(text, { engine: options.engine || '', verifier: '<command>' }),
667
685
  });
668
686
  result.engine = executor.id;
669
- emit(result, asJson);
687
+ report(result);
670
688
  return 0;
671
689
  }
672
690
  const parsedVerifier = parseVerifyCommand(verifier);
673
691
  if (!parsedVerifier.ok) {
674
692
  const result = { ...resultBase({ status: 'stuck', ask: text, wish, mission, task, engine: executor, verifier }), reason: `the mission has no safe runnable verifier (${parsedVerifier.reason})`, next_action: `atris mission status ${mission.id}` };
675
- emit(result, asJson);
693
+ report(result);
676
694
  return 2;
677
695
  }
678
696
  if (!frozenVerifier) {
@@ -685,7 +703,7 @@ async function runOneLap(ask, options = {}) {
685
703
  reason: `the verifier could not be frozen before dispatch: ${error.message}`,
686
704
  next_action: `atris mission status ${mission.id}`,
687
705
  };
688
- emit(result, asJson);
706
+ report(result);
689
707
  return 1;
690
708
  }
691
709
  }
@@ -697,7 +715,7 @@ async function runOneLap(ask, options = {}) {
697
715
  reason: 'no distinct ready validator engine is installed',
698
716
  next_action: 'atris engine',
699
717
  };
700
- emit(result, asJson);
718
+ report(result);
701
719
  return 2;
702
720
  }
703
721
 
@@ -711,7 +729,7 @@ async function runOneLap(ask, options = {}) {
711
729
  reviewOnly: true,
712
730
  verifierCommand: verifier,
713
731
  validatorEngines: validators.map((entry) => entry.id),
714
- ...lapModelPins(executor, validators),
732
+ ...lapFlightPins(executor, validators, options),
715
733
  actor: mission.owner || 'mission-lead',
716
734
  installedEngines: engineRegistryView(root)
717
735
  .filter((entry) => engineReadyAt(entry))
@@ -738,7 +756,7 @@ async function runOneLap(ask, options = {}) {
738
756
  });
739
757
  } catch (error) {
740
758
  const result = { ...resultBase({ status: 'stuck', ask: text, wish, mission, task, engine: executor, verifier }), reason: error.message || String(error), next_action: `atris task show ${task.display_id}` };
741
- emit(result, asJson);
759
+ report(result);
742
760
  return 1;
743
761
  }
744
762
 
@@ -769,7 +787,7 @@ async function runOneLap(ask, options = {}) {
769
787
  worktree: paused.worktree || null,
770
788
  next_action: paused.worktree ? `cd ${shellQuote(paused.worktree)} && atris worktree guard` : `atris task show ${shellQuote(task.display_id)}`,
771
789
  };
772
- emit(result, asJson);
790
+ report(result);
773
791
  return 1;
774
792
  }
775
793
 
@@ -790,7 +808,7 @@ async function runOneLap(ask, options = {}) {
790
808
  worktree: ready.worktree,
791
809
  next_action: `atris mission status ${mission.id}`,
792
810
  };
793
- emit(result, asJson);
811
+ report(result);
794
812
  return 1;
795
813
  }
796
814
 
@@ -819,7 +837,7 @@ async function runOneLap(ask, options = {}) {
819
837
  master_changed: false,
820
838
  result: flight.result,
821
839
  };
822
- emit(result, asJson);
840
+ report(result);
823
841
  return 0;
824
842
  } finally {
825
843
  releaseAskLock(lapLock);
@@ -832,4 +850,5 @@ module.exports = {
832
850
  readyExecutor,
833
851
  readyValidators,
834
852
  lapModelPins,
853
+ lapFlightPins,
835
854
  };
package/commands/reel.js CHANGED
@@ -14,6 +14,7 @@ const { promisify } = require('util');
14
14
  const pexec = promisify(execFile);
15
15
  const { buildReelFrame, reelFrames } = require('../lib/reel');
16
16
  const { SIZES, KINDS } = require('../lib/card');
17
+ const { makeProfileDir, removeProfileDir, headlessArgs } = require('../lib/headless-chrome');
17
18
 
18
19
  function parseFlags(argv) {
19
20
  const flags = {}; const pos = [];
@@ -56,12 +57,16 @@ async function renderFrames(chrome, ts, spec, dir, w, h, onDone) {
56
57
  const hf = path.join(dir, `f-${pad(i)}.html`);
57
58
  const pf = path.join(dir, `f-${pad(i)}.png`);
58
59
  fs.writeFileSync(hf, html);
59
- await pexec(chrome, [
60
- '--headless=new', '--disable-gpu', '--hide-scrollbars', '--force-device-scale-factor=1',
61
- `--window-size=${w},${h}`, '--virtual-time-budget=2200',
62
- `--user-data-dir=${path.join(dir, 'ud-' + i)}`,
63
- `--screenshot=${pf}`, `file://${hf}`,
64
- ], { timeout: 60000 });
60
+ const profile = makeProfileDir('atris-reel-chrome-');
61
+ try {
62
+ await pexec(chrome, headlessArgs(profile, [
63
+ '--disable-gpu', '--hide-scrollbars', '--force-device-scale-factor=1',
64
+ `--window-size=${w},${h}`, '--virtual-time-budget=2200',
65
+ `--screenshot=${pf}`, `file://${hf}`,
66
+ ]), { timeout: 60000 });
67
+ } finally {
68
+ removeProfileDir(profile);
69
+ }
65
70
  onDone && onDone(++done, ts.length);
66
71
  }
67
72
  }
@@ -125,4 +130,4 @@ async function run(argv) {
125
130
  return 0;
126
131
  }
127
132
 
128
- module.exports = { run, findChrome, findFfmpeg };
133
+ module.exports = { run, findChrome, findFfmpeg, renderFrames };
package/commands/sync.js CHANGED
@@ -3,6 +3,7 @@ const path = require('path');
3
3
  const { stateHome } = require('../lib/state-home');
4
4
  const { ensureWikiScaffold } = require('../lib/wiki');
5
5
  const { upsertAtrisClaudeBootBlock } = require('../lib/claude-boot-block');
6
+ const { openingLinesForRoot, upsertAgentsOpeningLines } = require('../lib/opening-lines');
6
7
  const { ensureRunIgnore } = require('../lib/gitignore');
7
8
  const {
8
9
  upsertAgentVoiceCard,
@@ -647,6 +648,13 @@ function syncAtris(options = {}) {
647
648
  updated++;
648
649
  }
649
650
 
651
+ const openingLines = openingLinesForRoot(process.cwd());
652
+ const agentsOpeningResult = upsertAgentsOpeningLines(agentsMdFile, openingLines, { dryRun });
653
+ if (agentsOpeningResult.action !== 'unchanged') {
654
+ console.log(`${dryRun ? 'Would update' : '✓ Updated'} AGENTS.md operating rules`);
655
+ updated++;
656
+ }
657
+
650
658
  // Update .claude/skills/atris/SKILL.md (legacy - now handled above, keeping for compatibility)
651
659
  const claudeSkillsDir = path.join(process.cwd(), '.claude', 'skills', 'atris');
652
660
  const claudeSkillFile = path.join(claudeSkillsDir, 'SKILL.md');
@@ -727,7 +735,7 @@ Key behaviors:
727
735
 
728
736
  if (fs.existsSync(rootClaudeMd)) {
729
737
  const source = fs.readFileSync(rootClaudeMd, 'utf8');
730
- const result = upsertAtrisClaudeBootBlock(source);
738
+ const result = upsertAtrisClaudeBootBlock(source, { openingLines });
731
739
  if (result.action === 'repaired') {
732
740
  applyClaudeUpdate(result.content, 'repair atris block in CLAUDE.md (missing end marker)', 'repaired atris block in CLAUDE.md (missing end marker)');
733
741
  } else if (result.action === 'updated') {
@@ -737,7 +745,7 @@ Key behaviors:
737
745
  }
738
746
  } else {
739
747
  // Create new CLAUDE.md with just Atris block
740
- const content = upsertAtrisClaudeBootBlock().content;
748
+ const content = upsertAtrisClaudeBootBlock('', { openingLines }).content;
741
749
  applyClaudeUpdate(content, 'create CLAUDE.md with atris block', 'created CLAUDE.md with atris block');
742
750
  }
743
751
 
@@ -20,15 +20,30 @@ function shellQuote(text) {
20
20
  return `'${String(text).replace(/'/g, `'\\''`)}'`;
21
21
  }
22
22
 
23
+ // The job a timer runs. Campaign wake is the default; `atris idea on` passes
24
+ // its own (idea tick every ten minutes).
25
+ const CAMPAIGN_WAKE_JOB = Object.freeze({
26
+ name: 'campaign-wake',
27
+ args: ['campaign', 'wake', '--json'],
28
+ every_seconds: 60,
29
+ marker: MARKER,
30
+ });
31
+
32
+ function cronSchedule(seconds) {
33
+ const minutes = Math.max(1, Math.round(Number(seconds || 60) / 60));
34
+ return minutes === 1 ? '* * * * *' : `*/${minutes} * * * *`;
35
+ }
36
+
23
37
  // One timer per checkout: the label and marker carry a short hash of its root.
24
- function planKeep({ root, home, platform = process.platform, homeDir = os.homedir(), nodeBin = 'node', atrisBin, envPath = process.env.PATH || '' }) {
38
+ function planKeep({ root, home, platform = process.platform, homeDir = os.homedir(), nodeBin = 'node', atrisBin, envPath = process.env.PATH || '', job = CAMPAIGN_WAKE_JOB }) {
25
39
  const slot = crypto.createHash('sha256').update(root).digest('hex').slice(0, 8);
40
+ const every = Number(job.every_seconds) || 60;
26
41
  // node by name on the saved PATH, so upgrading node does not break the timer.
27
- const command = ['/usr/bin/env', nodeBin, atrisBin, 'campaign', 'wake', '--json'];
28
- const logPath = path.join(home, 'campaign-wake.log');
29
- const base = { root, slot, log_path: logPath, command: command.join(' '), every_seconds: 60 };
42
+ const command = ['/usr/bin/env', nodeBin, atrisBin, ...job.args];
43
+ const logPath = path.join(home, job.log || `${job.name}.log`);
44
+ const base = { root, slot, log_path: logPath, command: command.join(' '), every_seconds: every };
30
45
  if (platform === 'darwin') {
31
- const label = `ai.atris.campaign-wake.${slot}`;
46
+ const label = `ai.atris.${job.name}.${slot}`;
32
47
  const file = path.join(homeDir, 'Library', 'LaunchAgents', `${label}.plist`);
33
48
  const contents = `<?xml version="1.0" encoding="UTF-8"?>
34
49
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -40,7 +55,7 @@ function planKeep({ root, home, platform = process.platform, homeDir = os.homedi
40
55
  <key>WorkingDirectory</key><string>${xml(root)}</string>
41
56
  <key>EnvironmentVariables</key>
42
57
  <dict><key>PATH</key><string>${xml(envPath)}</string><key>ATRIS_SKIP_UPDATE_CHECK</key><string>1</string></dict>
43
- <key>StartInterval</key><integer>60</integer>
58
+ <key>StartInterval</key><integer>${every}</integer>
44
59
  <key>StandardOutPath</key><string>${xml(logPath)}</string>
45
60
  <key>StandardErrorPath</key><string>${xml(logPath)}</string>
46
61
  </dict>
@@ -48,8 +63,8 @@ function planKeep({ root, home, platform = process.platform, homeDir = os.homedi
48
63
  `;
49
64
  return { ...base, kind: 'launchd', label, file, contents };
50
65
  }
51
- const marker = `${MARKER}_${slot}`;
52
- const line = `* * * * * cd ${shellQuote(root)} && PATH=${shellQuote(envPath)} ATRIS_SKIP_UPDATE_CHECK=1 ${command.map(shellQuote).join(' ')} >> ${shellQuote(logPath)} 2>&1 # ${marker}`;
66
+ const marker = `${job.marker || MARKER}_${slot}`;
67
+ const line = `${cronSchedule(every)} cd ${shellQuote(root)} && PATH=${shellQuote(envPath)} ATRIS_SKIP_UPDATE_CHECK=1 ${command.map(shellQuote).join(' ')} >> ${shellQuote(logPath)} 2>&1 # ${marker}`;
53
68
  return { ...base, kind: 'cron', marker, crontab_line: line };
54
69
  }
55
70