atris 3.43.0 → 3.45.1

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 (52) hide show
  1. package/atris/skills/design/SKILL.md +7 -1
  2. package/atris/skills/engines/SKILL.md +44 -13
  3. package/atris/team/customer-lead/MEMBER.md +45 -0
  4. package/atris/team/customer-lead/SOUL.md +33 -0
  5. package/atris/team/customer-lead/START_HERE.md +7 -0
  6. package/atris/team/customer-lead/skills/customer-commitments/SKILL.md +45 -0
  7. package/atris/team/improver/MEMBER.md +33 -0
  8. package/bin/atris.js +36 -3
  9. package/commands/aeo.js +5 -2
  10. package/commands/align.js +5 -2
  11. package/commands/autoland.js +15 -1
  12. package/commands/caretaker.js +303 -0
  13. package/commands/clean.js +76 -0
  14. package/commands/computer.js +5 -2
  15. package/commands/engine-watch.js +212 -0
  16. package/commands/engine.js +99 -11
  17. package/commands/founder.js +304 -0
  18. package/commands/human-missions.js +844 -0
  19. package/commands/improve.js +29 -6
  20. package/commands/init.js +16 -7
  21. package/commands/mission.js +124 -69
  22. package/commands/pull.js +5 -2
  23. package/commands/push.js +5 -2
  24. package/commands/slop.js +34 -3
  25. package/commands/task.js +51 -4
  26. package/commands/team.js +329 -13
  27. package/commands/terminal.js +5 -2
  28. package/commands/verify.js +99 -6
  29. package/commands/workflow.js +10 -3
  30. package/commands/worktree.js +119 -4
  31. package/lib/auto-accept-certified.js +302 -0
  32. package/lib/cloud-mission.js +59 -2
  33. package/lib/conductor-artifacts.js +1 -1
  34. package/lib/dispatch-scout.js +386 -0
  35. package/lib/engine-ask.js +645 -0
  36. package/lib/engine-job-lifecycle.js +65 -0
  37. package/lib/engine-receipt-sweep.js +98 -0
  38. package/lib/engine-registry.js +2 -2
  39. package/lib/engine-validate.js +382 -0
  40. package/lib/fleet.js +459 -106
  41. package/lib/known-commands.js +2 -2
  42. package/lib/member-alive.js +2 -2
  43. package/lib/policy-lessons.js +70 -0
  44. package/lib/receipt-evidence.js +56 -1
  45. package/lib/runner-command.js +1 -1
  46. package/lib/secret-gateway.js +588 -0
  47. package/lib/team-presence.js +13 -1
  48. package/lib/voice-gate.js +6 -0
  49. package/lib/wish-audit.js +5 -205
  50. package/lib/wish-delegate.js +5 -2
  51. package/package.json +6 -1
  52. package/utils/auth.js +56 -9
@@ -0,0 +1,303 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { spawnSync } = require('node:child_process');
6
+
7
+ const RECEIPT_REL = path.join('.atris', 'state', 'caretaker.scan.latest.json');
8
+ const LIST_LIMIT = '100';
9
+ const LIST_JSON_FIELDS = ['number', 'title', 'headRefOid'].join(',');
10
+ const DETAIL_JSON_FIELDS = [
11
+ 'number',
12
+ 'title',
13
+ 'headRefOid',
14
+ 'statusCheckRollup',
15
+ 'reviews',
16
+ 'latestReviews',
17
+ 'commits',
18
+ 'reviewDecision',
19
+ ].join(',');
20
+
21
+ const FAIL_CONCLUSIONS = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED']);
22
+ const FAIL_STATES = new Set(['FAILURE', 'ERROR']);
23
+ const PASS_CONCLUSIONS = new Set(['SUCCESS', 'SKIPPED', 'NEUTRAL']);
24
+ const PASS_STATES = new Set(['SUCCESS']);
25
+ const PENDING_STATUSES = new Set(['QUEUED', 'IN_PROGRESS', 'PENDING', 'REQUESTED', 'WAITING', 'EXPECTED']);
26
+
27
+ function runGh(args, { cwd, env } = {}) {
28
+ return spawnSync('gh', args, {
29
+ cwd: cwd || process.cwd(),
30
+ encoding: 'utf8',
31
+ env: env || process.env,
32
+ maxBuffer: 8 * 1024 * 1024,
33
+ });
34
+ }
35
+
36
+ function printGhUnavailable() {
37
+ console.error('gh is missing or not signed in. install github cli and run gh auth login.');
38
+ }
39
+
40
+ function ensureGh(options = {}) {
41
+ const version = runGh(['--version'], options);
42
+ if ((version.error && version.error.code === 'ENOENT') || version.error || version.status !== 0) {
43
+ return false;
44
+ }
45
+ const auth = runGh(['auth', 'status'], options);
46
+ if ((auth.error && auth.error.code === 'ENOENT') || auth.error || auth.status !== 0) {
47
+ return false;
48
+ }
49
+ return true;
50
+ }
51
+
52
+ function ghFailedDetail(result, fallback) {
53
+ const detail = String(result.stderr || result.stdout || result.error?.message || fallback).trim();
54
+ return detail.split(/\r?\n/).find(Boolean) || fallback;
55
+ }
56
+
57
+ function checkName(check) {
58
+ if (!check || typeof check !== 'object') return '';
59
+ return String(check.name || check.context || '').trim();
60
+ }
61
+
62
+ function checkTime(check) {
63
+ const raw = check?.completedAt || check?.startedAt || check?.submittedAt || '';
64
+ const ms = Date.parse(raw);
65
+ return Number.isFinite(ms) ? ms : 0;
66
+ }
67
+
68
+ function isFailingCheck(check) {
69
+ const conclusion = String(check?.conclusion || '').toUpperCase();
70
+ const state = String(check?.state || '').toUpperCase();
71
+ const status = String(check?.status || '').toUpperCase();
72
+ if (FAIL_CONCLUSIONS.has(conclusion) || FAIL_STATES.has(state)) return true;
73
+ if (status === 'COMPLETED' && conclusion && !PASS_CONCLUSIONS.has(conclusion)) return true;
74
+ return false;
75
+ }
76
+
77
+ function isPassingCheck(check) {
78
+ const conclusion = String(check?.conclusion || '').toUpperCase();
79
+ const state = String(check?.state || '').toUpperCase();
80
+ if (PASS_CONCLUSIONS.has(conclusion) || PASS_STATES.has(state)) return true;
81
+ return false;
82
+ }
83
+
84
+ function isPendingCheck(check) {
85
+ const status = String(check?.status || '').toUpperCase();
86
+ const state = String(check?.state || '').toUpperCase();
87
+ if (PENDING_STATUSES.has(status) || PENDING_STATUSES.has(state)) return true;
88
+ if (!check?.conclusion && !PASS_STATES.has(state) && !FAIL_STATES.has(state)) {
89
+ return status !== 'COMPLETED';
90
+ }
91
+ return false;
92
+ }
93
+
94
+ function latestChecks(rollup = []) {
95
+ const byName = new Map();
96
+ for (const check of Array.isArray(rollup) ? rollup : []) {
97
+ const name = checkName(check);
98
+ if (!name) continue;
99
+ const prev = byName.get(name);
100
+ if (!prev || checkTime(check) >= checkTime(prev)) byName.set(name, check);
101
+ }
102
+ return [...byName.values()];
103
+ }
104
+
105
+ function lastPushAt(pr) {
106
+ const commits = Array.isArray(pr?.commits) ? pr.commits : [];
107
+ let latest = 0;
108
+ let latestIso = '';
109
+ for (const commit of commits) {
110
+ const iso = commit?.committedDate || commit?.authoredDate || '';
111
+ const ms = Date.parse(iso);
112
+ if (Number.isFinite(ms) && ms >= latest) {
113
+ latest = ms;
114
+ latestIso = iso;
115
+ }
116
+ }
117
+ return latestIso;
118
+ }
119
+
120
+ function collectReviews(pr) {
121
+ const out = [];
122
+ for (const list of [pr?.reviews, pr?.latestReviews]) {
123
+ if (!Array.isArray(list)) continue;
124
+ for (const review of list) out.push(review);
125
+ }
126
+ return out;
127
+ }
128
+
129
+ function activeChangesRequested(pr) {
130
+ const pushAt = lastPushAt(pr);
131
+ const pushMs = Date.parse(pushAt);
132
+ const reviews = collectReviews(pr).filter((review) => {
133
+ if (String(review?.state || '').toUpperCase() !== 'CHANGES_REQUESTED') return false;
134
+ const submittedMs = Date.parse(review?.submittedAt || '');
135
+ if (!Number.isFinite(submittedMs)) return false;
136
+ if (!Number.isFinite(pushMs)) return true;
137
+ return submittedMs > pushMs;
138
+ });
139
+ return reviews;
140
+ }
141
+
142
+ function classifyPullRequest(pr) {
143
+ const checks = latestChecks(pr?.statusCheckRollup);
144
+ const failing = checks.filter(isFailingCheck);
145
+ if (failing.length) {
146
+ const name = checkName(failing[0]) || 'ci';
147
+ return {
148
+ state: 'ci-red',
149
+ reason: `the latest ${name} check failed`,
150
+ };
151
+ }
152
+
153
+ if (activeChangesRequested(pr).length) {
154
+ return {
155
+ state: 'changes-requested',
156
+ reason: 'a review asked for changes after the last push',
157
+ };
158
+ }
159
+
160
+ if (!checks.length) {
161
+ return {
162
+ state: 'waiting',
163
+ reason: 'no checks have reported yet',
164
+ };
165
+ }
166
+
167
+ if (checks.some(isPendingCheck)) {
168
+ return {
169
+ state: 'waiting',
170
+ reason: 'checks are still running',
171
+ };
172
+ }
173
+
174
+ if (checks.every(isPassingCheck)) {
175
+ return {
176
+ state: 'green-mergeable',
177
+ reason: 'checks passed and nothing blocks merge',
178
+ };
179
+ }
180
+
181
+ return {
182
+ state: 'waiting',
183
+ reason: 'checks or reviews are not ready to judge',
184
+ };
185
+ }
186
+
187
+ function formatSentence(entry) {
188
+ const title = String(entry.title || 'untitled').trim() || 'untitled';
189
+ return `pr ${entry.number} "${title}" is ${entry.state} because ${entry.reason}.`.toLowerCase();
190
+ }
191
+
192
+ function writeReceipt(root, receipt) {
193
+ const file = path.join(root, RECEIPT_REL);
194
+ fs.mkdirSync(path.dirname(file), { recursive: true });
195
+ fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`, 'utf8');
196
+ return file;
197
+ }
198
+
199
+ function listOpenPullRequestRefs(options = {}) {
200
+ const result = runGh([
201
+ 'pr',
202
+ 'list',
203
+ '--state',
204
+ 'open',
205
+ '--limit',
206
+ LIST_LIMIT,
207
+ '--json',
208
+ LIST_JSON_FIELDS,
209
+ ], options);
210
+ if (result.error || result.status !== 0) {
211
+ throw new Error(ghFailedDetail(result, 'gh pr list failed'));
212
+ }
213
+ const raw = String(result.stdout || '').trim();
214
+ if (!raw) return [];
215
+ const parsed = JSON.parse(raw);
216
+ return Array.isArray(parsed) ? parsed : [];
217
+ }
218
+
219
+ function fetchPullRequestDetail(number, options = {}) {
220
+ const result = runGh([
221
+ 'pr',
222
+ 'view',
223
+ String(number),
224
+ '--json',
225
+ DETAIL_JSON_FIELDS,
226
+ ], options);
227
+ if (result.error || result.status !== 0) {
228
+ throw new Error(ghFailedDetail(result, `gh pr view ${number} failed`));
229
+ }
230
+ const raw = String(result.stdout || '').trim();
231
+ if (!raw) {
232
+ throw new Error(`gh pr view ${number} returned empty output`);
233
+ }
234
+ return JSON.parse(raw);
235
+ }
236
+
237
+ function scanPullRequests({ cwd, env, now } = {}) {
238
+ const root = path.resolve(cwd || process.cwd());
239
+ const scannedAt = (now instanceof Date ? now : new Date()).toISOString();
240
+ const refs = listOpenPullRequestRefs({ cwd: root, env });
241
+ const prs = refs.map((ref) => {
242
+ const number = Number(ref.number);
243
+ const detail = fetchPullRequestDetail(number, { cwd: root, env });
244
+ const pr = {
245
+ ...detail,
246
+ number: Number(detail.number || number),
247
+ title: detail.title != null ? detail.title : ref.title,
248
+ headRefOid: detail.headRefOid || ref.headRefOid || '',
249
+ };
250
+ const classified = classifyPullRequest(pr);
251
+ return {
252
+ number: pr.number,
253
+ title: String(pr.title || ''),
254
+ state: classified.state,
255
+ reason: classified.reason,
256
+ head_sha: String(pr.headRefOid || ''),
257
+ scanned_at: scannedAt,
258
+ };
259
+ });
260
+ const receipt = { scanned_at: scannedAt, prs };
261
+ const receiptPath = writeReceipt(root, receipt);
262
+ return { root, receipt, receiptPath, prs };
263
+ }
264
+
265
+ function showCaretakerHelp() {
266
+ console.log('usage: atris caretaker scan');
267
+ console.log('classify open pull requests on origin. detection only; no fix, comment, or merge.');
268
+ }
269
+
270
+ function caretakerCommand(args = [], options = {}) {
271
+ const argv = Array.isArray(args) ? args.filter((arg) => arg !== '--') : [];
272
+ if (!argv.length || argv[0] === 'help' || argv.includes('--help') || argv.includes('-h')) {
273
+ showCaretakerHelp();
274
+ return 0;
275
+ }
276
+ if (argv[0] !== 'scan') {
277
+ console.error(`unknown caretaker command: ${argv[0]}`);
278
+ showCaretakerHelp();
279
+ return 1;
280
+ }
281
+
282
+ const cwd = path.resolve(options.cwd || process.cwd());
283
+ const env = options.env || process.env;
284
+ if (!ensureGh({ cwd, env })) {
285
+ printGhUnavailable();
286
+ return 1;
287
+ }
288
+
289
+ try {
290
+ const { prs } = scanPullRequests({ cwd, env, now: options.now });
291
+ if (!prs.length) {
292
+ console.log('no open pull requests.');
293
+ return 0;
294
+ }
295
+ for (const entry of prs) console.log(formatSentence(entry));
296
+ return 0;
297
+ } catch (error) {
298
+ console.error(`caretaker scan failed: ${error.message || error}`);
299
+ return 1;
300
+ }
301
+ }
302
+
303
+ module.exports = { caretakerCommand };
package/commands/clean.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const os = require('os');
3
3
  const path = require('path');
4
+ const { spawnSync } = require('child_process');
4
5
  const escapeRegExp = require('../lib/escape-regexp');
5
6
  const { wikiMetabolismNudge } = require('../lib/wiki');
6
7
 
@@ -49,6 +50,10 @@ function cleanAtris(options = {}) {
49
50
  results.healedRefDetails = replacements;
50
51
  results.unhealableRefs = unhealable;
51
52
 
53
+ // 2b. Let the repo refresh and validate its own MAP.md, if it ships scripts for it
54
+ const mapScripts = runMapScripts(cwd, options.dryRun);
55
+ results.mapScripts = mapScripts;
56
+
52
57
  // 3. Archive old journals (>30 days)
53
58
  const archived = archiveOldJournals(atrisDir, options.dryRun);
54
59
  results.archivedJournals = archived;
@@ -127,6 +132,9 @@ function cleanAtris(options = {}) {
127
132
  console.log('✓ All MAP.md refs valid');
128
133
  }
129
134
 
135
+ // Repo-local map scripts
136
+ mapScripts.forEach(step => console.log(mapScriptLine(step)));
137
+
130
138
  // Archived journals
131
139
  if (archived > 0) {
132
140
  const verb = options.dryRun ? 'Would archive' : 'Archived';
@@ -185,6 +193,70 @@ function cleanAtris(options = {}) {
185
193
  return results;
186
194
  }
187
195
 
196
+ // Repo-local MAP.md maintenance scripts, run in order when the workspace ships them.
197
+ const MAP_SCRIPTS = [
198
+ {
199
+ file: 'refresh_map_active.py',
200
+ ranLabel: 'Refreshed MAP.md active files',
201
+ dryLabel: 'Would refresh MAP.md active files',
202
+ failLabel: 'MAP.md active files refresh failed',
203
+ },
204
+ {
205
+ file: 'validate_map.py',
206
+ ranLabel: 'MAP.md validation passed',
207
+ dryLabel: 'Would validate MAP.md',
208
+ failLabel: 'MAP.md validation failed',
209
+ },
210
+ ];
211
+
212
+ /**
213
+ * Run the workspace's own MAP.md scripts (scripts/refresh_map_active.py, then
214
+ * scripts/validate_map.py) when they exist. Report-only: a failing script does
215
+ * not stop clean.
216
+ */
217
+ function runMapScripts(cwd, dryRun = false) {
218
+ const steps = [];
219
+
220
+ for (const script of MAP_SCRIPTS) {
221
+ const scriptPath = path.join(cwd, 'scripts', script.file);
222
+ if (!fs.existsSync(scriptPath)) continue;
223
+
224
+ const step = { script: `scripts/${script.file}`, status: 'ok', detail: '' };
225
+
226
+ if (dryRun) {
227
+ step.status = 'would_run';
228
+ steps.push(step);
229
+ continue;
230
+ }
231
+
232
+ const proc = spawnSync('python3', [scriptPath], { cwd, encoding: 'utf8', env: process.env });
233
+ if (proc.error) {
234
+ step.status = 'skipped';
235
+ step.detail = proc.error.code === 'ENOENT' ? 'python3 not available' : proc.error.message;
236
+ } else if ((proc.status ?? 0) !== 0) {
237
+ step.status = 'failed';
238
+ step.detail = firstLine(proc.stderr) || firstLine(proc.stdout) || `exit ${proc.status}`;
239
+ }
240
+
241
+ steps.push(step);
242
+ }
243
+
244
+ return steps;
245
+ }
246
+
247
+ function firstLine(text) {
248
+ if (!text) return '';
249
+ return String(text).split('\n').map(l => l.trim()).find(l => l.length > 0) || '';
250
+ }
251
+
252
+ function mapScriptLine(step) {
253
+ const script = MAP_SCRIPTS.find(s => step.script.endsWith(s.file));
254
+ if (step.status === 'would_run') return `✓ ${script.dryLabel} (${step.script})`;
255
+ if (step.status === 'skipped') return `○ Skipped ${step.script} — ${step.detail}`;
256
+ if (step.status === 'failed') return `✗ ${script.failLabel} — ${step.detail}`;
257
+ return `✓ ${script.ranLabel}`;
258
+ }
259
+
188
260
  function cleanResultPayload(results, options = {}, cwd = process.cwd()) {
189
261
  const manualAction = [];
190
262
  if (results.staleTasks.length > 0) manualAction.push('Delete stale tasks or finish them');
@@ -227,6 +299,10 @@ function cleanResultPayload(results, options = {}, cwd = process.cwd()) {
227
299
  items: results.deadFiles || [],
228
300
  test_only: results.testOnlyFiles || [],
229
301
  },
302
+ map_scripts: {
303
+ count: (results.mapScripts || []).length,
304
+ items: results.mapScripts || [],
305
+ },
230
306
  },
231
307
  manual_action: manualAction,
232
308
  };
@@ -22,7 +22,7 @@ const os = require('os');
22
22
  const path = require('path');
23
23
  const readline = require('readline');
24
24
  const { spawnSync } = require('child_process');
25
- const { loadCredentials, decodeJwtClaims, promptUser } = require('../utils/auth');
25
+ const { loadCredentials, decodeJwtClaims, promptUser, abortOnAuthFailure } = require('../utils/auth');
26
26
  const { apiRequestJson, getApiBaseUrl, getAppBaseUrl } = require('../utils/api');
27
27
  const { loadBusinesses, saveBusinesses } = require('./business');
28
28
  const {
@@ -2258,15 +2258,18 @@ async function runBusinessPromptViaRunnerProxy(token, ctx, prompt, options = {})
2258
2258
 
2259
2259
  async function ensureBusinessAwake(token, ctx, maxWaitSec = 90, options = {}) {
2260
2260
  const status = await apiRequestJson(`/business/${ctx.businessId}/ai-computer/status`, { method: 'GET', token });
2261
+ abortOnAuthFailure(status);
2261
2262
  if (status.ok && status.data && status.data.status === 'running' && status.data.endpoint) {
2262
2263
  return true;
2263
2264
  }
2264
2265
  if (!options.quiet) process.stdout.write(' Waking business computer... ');
2265
- await apiRequestJson(`/business/${ctx.businessId}/ai-computer/wake`, { method: 'POST', token, body: {} });
2266
+ const wake = await apiRequestJson(`/business/${ctx.businessId}/ai-computer/wake`, { method: 'POST', token, body: {} });
2267
+ abortOnAuthFailure(wake, !options.quiet);
2266
2268
  const start = Date.now();
2267
2269
  while (Date.now() - start < maxWaitSec * 1000) {
2268
2270
  await sleep(3000);
2269
2271
  const next = await apiRequestJson(`/business/${ctx.businessId}/ai-computer/status`, { method: 'GET', token });
2272
+ abortOnAuthFailure(next, !options.quiet);
2270
2273
  if (next.ok && next.data && next.data.status === 'running' && next.data.endpoint) {
2271
2274
  const elapsed = Math.floor((Date.now() - start) / 1000);
2272
2275
  if (!options.quiet) console.log(`awake (${elapsed}s)`);
@@ -0,0 +1,212 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const DEFAULT_TAIL_LINES = 20;
7
+ const DEFAULT_POLL_MS = 250;
8
+ const MAX_TAIL_BYTES = 128 * 1024;
9
+
10
+ function processIsAlive(pid) {
11
+ if (!Number.isInteger(pid) || pid <= 0) return false;
12
+ try {
13
+ process.kill(pid, 0);
14
+ return true;
15
+ } catch (error) {
16
+ return error && error.code !== 'ESRCH';
17
+ }
18
+ }
19
+
20
+ function readReceipt(receiptPath, fsModule = fs) {
21
+ try {
22
+ const receipt = JSON.parse(fsModule.readFileSync(receiptPath, 'utf8'));
23
+ return receipt && typeof receipt === 'object' ? receipt : null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ function receiptStartedMs(receipt, stat) {
30
+ const parsed = Date.parse(receipt.started_at || receipt.at || '');
31
+ return Number.isFinite(parsed) ? parsed : stat.mtimeMs;
32
+ }
33
+
34
+ function listWatchableReceipts(root, fsModule = fs) {
35
+ const runsDir = path.join(root, 'atris', 'runs');
36
+ let names;
37
+ try {
38
+ names = fsModule.readdirSync(runsDir).filter((name) => name.endsWith('.json'));
39
+ } catch {
40
+ return [];
41
+ }
42
+ const rows = [];
43
+ for (const name of names) {
44
+ const receiptPath = path.join(runsDir, name);
45
+ const receipt = readReceipt(receiptPath, fsModule);
46
+ if (!receipt || !receipt.live_log) continue;
47
+ let stat;
48
+ try { stat = fsModule.statSync(receiptPath); } catch { continue; }
49
+ rows.push({
50
+ id: path.basename(name, '.json'),
51
+ receipt,
52
+ receiptPath,
53
+ startedMs: receiptStartedMs(receipt, stat),
54
+ });
55
+ }
56
+ return rows.sort((left, right) => right.startedMs - left.startedMs);
57
+ }
58
+
59
+ function resolveWatchReceipt(root, id, fsModule = fs) {
60
+ const rows = listWatchableReceipts(root, fsModule);
61
+ if (id === 'latest') return rows[0] || null;
62
+ const wanted = String(id || '').replace(/\.json$/, '');
63
+ return rows.find((row) => row.id === wanted || row.receipt.id === id) || null;
64
+ }
65
+
66
+ function liveLogPathForReceipt(root, row) {
67
+ const value = String(row.receipt.live_log || '');
68
+ return path.isAbsolute(value) ? value : path.resolve(root, value);
69
+ }
70
+
71
+ function receiptDisplayStatus(receipt, pidIsAlive = processIsAlive) {
72
+ const status = String(receipt.status || 'unknown').trim().toLowerCase();
73
+ if (status === 'running' && !pidIsAlive(Number(receipt.pid))) return 'presumed dead';
74
+ return status;
75
+ }
76
+
77
+ function formatAge(value, nowMs = Date.now()) {
78
+ const elapsedMs = Math.max(0, nowMs - Number(value || 0));
79
+ const seconds = Math.floor(elapsedMs / 1000);
80
+ if (seconds < 2) return 'now';
81
+ if (seconds < 60) return `${seconds}s ago`;
82
+ const minutes = Math.floor(seconds / 60);
83
+ if (minutes < 60) return `${minutes}m ago`;
84
+ const hours = Math.floor(minutes / 60);
85
+ if (hours < 48) return `${hours}h ago`;
86
+ return `${Math.floor(hours / 24)}d ago`;
87
+ }
88
+
89
+ function lastLogAge(root, row, nowMs, fsModule = fs) {
90
+ try {
91
+ const logPath = liveLogPathForReceipt(root, row);
92
+ const stat = fsModule.statSync(logPath);
93
+ if (stat.size === 0) return 'none';
94
+ return formatAge(stat.mtimeMs, nowMs);
95
+ } catch {
96
+ return 'none';
97
+ }
98
+ }
99
+
100
+ function readLastLines(filePath, count = DEFAULT_TAIL_LINES, fsModule = fs) {
101
+ let descriptor;
102
+ try {
103
+ const stat = fsModule.statSync(filePath);
104
+ if (!stat.size) return [];
105
+ const bytes = Math.min(stat.size, MAX_TAIL_BYTES);
106
+ const start = stat.size - bytes;
107
+ const buffer = Buffer.alloc(bytes);
108
+ descriptor = fsModule.openSync(filePath, 'r');
109
+ fsModule.readSync(descriptor, buffer, 0, bytes, start);
110
+ const lines = buffer.toString('utf8').split(/\r?\n/);
111
+ if (start > 0) lines.shift();
112
+ if (lines[lines.length - 1] === '') lines.pop();
113
+ return lines.slice(-count);
114
+ } catch {
115
+ return [];
116
+ } finally {
117
+ if (descriptor !== undefined) fsModule.closeSync(descriptor);
118
+ }
119
+ }
120
+
121
+ function writeNewLogBytes(filePath, offset, write, fsModule = fs) {
122
+ let descriptor;
123
+ try {
124
+ const stat = fsModule.statSync(filePath);
125
+ let position = stat.size < offset ? 0 : offset;
126
+ if (stat.size <= position) return position;
127
+ descriptor = fsModule.openSync(filePath, 'r');
128
+ const buffer = Buffer.alloc(64 * 1024);
129
+ while (position < stat.size) {
130
+ const bytes = fsModule.readSync(descriptor, buffer, 0, Math.min(buffer.length, stat.size - position), position);
131
+ if (!bytes) break;
132
+ write(buffer.subarray(0, bytes).toString('utf8'));
133
+ position += bytes;
134
+ }
135
+ return position;
136
+ } catch {
137
+ return offset;
138
+ } finally {
139
+ if (descriptor !== undefined) fsModule.closeSync(descriptor);
140
+ }
141
+ }
142
+
143
+ function followReceipt(row, root, deps = {}) {
144
+ const fsModule = deps.fs || fs;
145
+ const pidIsAlive = deps.pidIsAlive || processIsAlive;
146
+ const write = deps.write || process.stdout.write.bind(process.stdout);
147
+ const pollMs = deps.pollMs || DEFAULT_POLL_MS;
148
+ const liveLogPath = liveLogPathForReceipt(root, row);
149
+ let offset = 0;
150
+ try { offset = fsModule.statSync(liveLogPath).size; } catch {}
151
+ return new Promise((resolve) => {
152
+ const interval = setInterval(() => {
153
+ offset = writeNewLogBytes(liveLogPath, offset, write, fsModule);
154
+ const receipt = readReceipt(row.receiptPath, fsModule);
155
+ if (!receipt || receiptDisplayStatus(receipt, pidIsAlive) !== 'running') {
156
+ offset = writeNewLogBytes(liveLogPath, offset, write, fsModule);
157
+ clearInterval(interval);
158
+ resolve(0);
159
+ }
160
+ }, pollMs);
161
+ });
162
+ }
163
+
164
+ function printRunningRoster(root, deps = {}) {
165
+ const fsModule = deps.fs || fs;
166
+ const pidIsAlive = deps.pidIsAlive || processIsAlive;
167
+ const log = deps.log || console.log;
168
+ const nowMs = deps.nowMs === undefined ? Date.now() : deps.nowMs;
169
+ const rows = listWatchableReceipts(root, fsModule)
170
+ .filter((row) => String(row.receipt.status || '').toLowerCase() === 'running');
171
+ if (!rows.length) {
172
+ log('no engine work is running.');
173
+ return 0;
174
+ }
175
+ for (const row of rows) {
176
+ const engine = String(row.receipt.engine || 'unknown');
177
+ const status = receiptDisplayStatus(row.receipt, pidIsAlive);
178
+ const started = row.receipt.started_at || row.receipt.at || 'unknown';
179
+ log(`${row.id} ${engine} ${status} started ${started} last log ${lastLogAge(root, row, nowMs, fsModule)}`);
180
+ }
181
+ return 0;
182
+ }
183
+
184
+ async function runEngineWatchCommand(args = [], root = process.cwd(), deps = {}) {
185
+ const fsModule = deps.fs || fs;
186
+ const pidIsAlive = deps.pidIsAlive || processIsAlive;
187
+ const log = deps.log || console.log;
188
+ const noFollow = args.includes('--no-follow');
189
+ const id = args.find((arg) => !String(arg).startsWith('--')) || '';
190
+ if (!id) return printRunningRoster(root, deps);
191
+
192
+ const row = resolveWatchReceipt(root, id, fsModule);
193
+ if (!row) {
194
+ log(`engine watch: no receipt found for ${id}`);
195
+ return 2;
196
+ }
197
+ const status = receiptDisplayStatus(row.receipt, pidIsAlive);
198
+ const engine = String(row.receipt.engine || 'unknown');
199
+ const started = row.receipt.started_at || row.receipt.at || 'unknown';
200
+ log(`${row.id} ${engine} ${status} started ${started}`);
201
+ const liveLogPath = liveLogPathForReceipt(root, row);
202
+ const lines = readLastLines(liveLogPath, DEFAULT_TAIL_LINES, fsModule);
203
+ if (lines.length) lines.forEach((line) => log(line));
204
+ else log('no live output yet.');
205
+
206
+ if (noFollow || status !== 'running') return 0;
207
+ return followReceipt(row, root, deps);
208
+ }
209
+
210
+ module.exports = {
211
+ runEngineWatchCommand,
212
+ };