atris 3.48.0 → 3.48.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.
package/atris/CLAUDE.md CHANGED
@@ -100,6 +100,8 @@ The human approves work by reading, so how you report IS the product.
100
100
 
101
101
  ## Rules (Non‑Negotiable)
102
102
 
103
+ - YouTube link in a message: run `atris youtube notes <url>` before saying anything about the video. The transcript is the source; notes from model memory are fabrication.
104
+
103
105
  - Plan = ASCII visualization + approval gate. Do not execute during planning.
104
106
  - Execute step-by-step, verify as you go, update artifacts (`TODO.md`, `MAP.md`) when reality changes.
105
107
  - Delete completed tasks (validator cleans to target state = 0).
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: youtube
3
- description: "Process YouTube videos: extract insights, answer questions, store as knowledge. 5 credits per video. Triggers on: youtube, video, process video, watch this, learn from video."
4
- version: 2.3.0
3
+ description: "A YouTube link in any message routes here. Run atris youtube notes <url> FIRST: free, about 30 seconds, quotes verified against the transcript. Never summarize a video from model memory, that is fabrication. Use atris youtube process only to store it as queryable knowledge (5 credits). Triggers on: any youtube.com or youtu.be link, youtube, video, watch this, notes on this."
4
+ version: 2.4.0
5
5
  tags:
6
6
  - youtube
7
7
  - research
package/bin/atris.js CHANGED
@@ -513,6 +513,7 @@ function showHelp() {
513
513
  console.log(' orb - Pick next moves while engine jobs work in the background');
514
514
  console.log(' activate - Load Atris context');
515
515
  console.log(' radar - Show live agents joined with tasks, missions, and worktrees');
516
+ console.log(' who - Show local engines and team members working, waiting, done, or stale');
516
517
  console.log(' stream - Watch the whole team work live in one terminal');
517
518
  console.log(' team - One team view: members, roles, engine assignments (presence for live)');
518
519
  console.log(' watch - Turn one sentence into an always-on background watcher');
@@ -1829,6 +1830,10 @@ if (command === 'init') {
1829
1830
  Promise.resolve(require('../commands/team').teamCommand(process.argv.slice(3)))
1830
1831
  .then((code) => process.exit(code || 0))
1831
1832
  .catch((err) => { console.error(`\nerror: ${err.message || err}`); process.exit(1); });
1833
+ } else if (command === 'who') {
1834
+ Promise.resolve(require('../commands/who').whoCommand(process.argv.slice(3)))
1835
+ .then((code) => process.exit(code || 0))
1836
+ .catch((err) => { console.error(`\nerror: ${err.message || err}`); process.exit(1); });
1832
1837
  } else if (command === 'wish') {
1833
1838
  Promise.resolve(require('../commands/wish').wishCommand(process.argv.slice(3)))
1834
1839
  .then((code) => process.exit(code || 0))
package/commands/dream.js CHANGED
@@ -10,7 +10,7 @@ const {
10
10
 
11
11
  const DREAMS_FILE = ['.atris', 'state', 'dreams.jsonl'];
12
12
  const DAY_MS = 24 * 60 * 60 * 1000;
13
- const DREAM_TIMEOUT_MS = 60 * 1000;
13
+ const DREAM_TIMEOUT_MS = 5 * 60 * 1000;
14
14
  const MAX_CONTEXT_CHARS = 7000;
15
15
  const MAX_SECTION_CHARS = 2400;
16
16
 
@@ -37,10 +37,11 @@ const {
37
37
  resolveEngineForRoleRanked,
38
38
  requireEngineBin,
39
39
  engineDoctorReport,
40
+ engineFailureHealthStatus,
40
41
  setEngineOverrides,
41
42
  setEngineHealth,
42
43
  } = require('../lib/engine-registry');
43
- const { FLEET_CAPABLE, runDispatchFlight } = require('../lib/fleet');
44
+ const { DISPATCH_CAPABLE, runDispatchFlight } = require('../lib/fleet');
44
45
  const {
45
46
  buildReadOnlyEngineInvocation,
46
47
  runEngineAskCommand,
@@ -1379,12 +1380,12 @@ function parseDispatchArgs(args) {
1379
1380
  function runDispatchCommand(args, root) {
1380
1381
  const { taskIds, engine, promptFile, base, json, yolo } = parseDispatchArgs(args);
1381
1382
  if (!taskIds.length || !engine) {
1382
- console.error('usage: atris engine dispatch <task-id> [<task-id> ...] --engine cursor|codex [--prompt-file <f>] [--yolo]');
1383
+ console.error('usage: atris engine dispatch <task-id> [<task-id> ...] --engine <engine> [--prompt-file <f>] [--yolo]');
1383
1384
  return 2;
1384
1385
  }
1385
1386
  const canonical = canonicalEngineName(engine);
1386
- if (!canonical || !FLEET_CAPABLE.includes(canonical)) {
1387
- console.error(`engine dispatch: --engine must be one of ${FLEET_CAPABLE.join(', ')}`);
1387
+ if (!canonical || !DISPATCH_CAPABLE.includes(canonical)) {
1388
+ console.error(`engine dispatch: --engine must be one of ${DISPATCH_CAPABLE.join(', ')}`);
1388
1389
  return 2;
1389
1390
  }
1390
1391
  // Argument-shape errors surface before environment errors: --prompt-file
@@ -1407,7 +1408,13 @@ function runDispatchCommand(args, root) {
1407
1408
  try {
1408
1409
  requireEngineBin(canonical);
1409
1410
  } catch (err) {
1410
- console.error(`engine dispatch: ${err.message}`);
1411
+ const status = engineFailureHealthStatus({ status: 'errored', reason: err.message });
1412
+ if (status) {
1413
+ try { setEngineHealth(canonical, status, root); } catch { /* best effort */ }
1414
+ }
1415
+ console.error(canonical === 'fable'
1416
+ ? `fable handoff failed: ${err.message}`
1417
+ : `engine dispatch: ${err.message}`);
1411
1418
  return 2;
1412
1419
  }
1413
1420
  return runDispatchFlight({ root, taskIds, engine: canonical, prompt: promptOverride, yolo, ...(base ? { checkoutBase: base } : {}) }).then((flight) => {
@@ -7288,6 +7288,7 @@ function composeWakeDecision(ctx, verdict) {
7288
7288
  state: ctx.state,
7289
7289
  goal: verdict.goal,
7290
7290
  current_experiment: verdict.current_experiment,
7291
+ now_file: ctx.nowFile,
7291
7292
  };
7292
7293
  if ('autonomous_problem' in verdict) result.autonomous_problem = verdict.autonomous_problem;
7293
7294
  result.checks = ctx.checks;
@@ -7471,6 +7472,11 @@ const WAKE_DECISION_RULES = [
7471
7472
 
7472
7473
  function wakeDecision(name, paths, { force = false, runtimeKind = memberRuntimeKind(name) } = {}) {
7473
7474
  const purpose = missionPurpose(paths);
7475
+ const nowPath = path.join(paths.memberDir, 'now.md');
7476
+ const nowFile = fs.existsSync(nowPath) ? {
7477
+ path: path.relative(process.cwd(), nowPath),
7478
+ excerpt: String(purpose.nowText || '').slice(0, 500),
7479
+ } : null;
7474
7480
  const steering = readSteeringMemory(paths, name);
7475
7481
  const state = loadMemberGoals(name, paths);
7476
7482
  const goal = activeGoal(state);
@@ -7519,7 +7525,7 @@ function wakeDecision(name, paths, { force = false, runtimeKind = memberRuntimeK
7519
7525
  next_command: wakeScores.selected.next_command,
7520
7526
  } : null;
7521
7527
 
7522
- const ctx = { name, force, purpose, steering, state, goal, current, blocked, evidence, workspace, checks, wakeScores };
7528
+ const ctx = { name, force, purpose, nowFile, steering, state, goal, current, blocked, evidence, workspace, checks, wakeScores };
7523
7529
  for (const rule of WAKE_DECISION_RULES) {
7524
7530
  const verdict = rule(ctx);
7525
7531
  if (verdict) return composeWakeDecision(ctx, verdict);
@@ -7624,6 +7630,11 @@ async function runMemberWake(name, { execute = false, confirmed = false, force =
7624
7630
  nextCommand = `atris member review ${name} ${experiment.id} --accept --proof "..." --value 4`;
7625
7631
  }
7626
7632
 
7633
+ const goals = (state.goals || []).map((item) => ({
7634
+ id: item.id || null,
7635
+ title: item.title || null,
7636
+ status: item.status || null,
7637
+ }));
7627
7638
  const receiptPayload = {
7628
7639
  schema: 'atris.member_wake.v1',
7629
7640
  created_at: now,
@@ -7636,6 +7647,8 @@ async function runMemberWake(name, { execute = false, confirmed = false, force =
7636
7647
  ask: planned.ask || null,
7637
7648
  next_command: nextCommand,
7638
7649
  mission: planned.mission,
7650
+ now_file: planned.now_file,
7651
+ goals,
7639
7652
  steering: planned.steering,
7640
7653
  evidence: planned.evidence,
7641
7654
  checks: planned.checks,
@@ -7692,6 +7705,8 @@ async function runMemberWake(name, { execute = false, confirmed = false, force =
7692
7705
  ask: planned.ask || null,
7693
7706
  next_command: nextCommand,
7694
7707
  mission: planned.mission,
7708
+ now_file: planned.now_file,
7709
+ goals,
7695
7710
  steering: planned.steering,
7696
7711
  evidence: planned.evidence,
7697
7712
  checks: planned.checks,
@@ -8972,6 +8987,25 @@ async function memberImprovements(businessId, ...args) {
8972
8987
 
8973
8988
  // --- Command Dispatcher ---
8974
8989
 
8990
+ async function memberChat(name, ...rest) {
8991
+ const paths = requireMemberDir(name);
8992
+ const flags = rest.filter((a) => String(a).startsWith('--'));
8993
+ const words = rest.filter((a) => !String(a).startsWith('--'));
8994
+ const fromFlag = flags.find((f) => f.startsWith('--from='));
8995
+ const from = fromFlag ? fromFlag.slice('--from='.length) : (process.env.USER || 'terminal');
8996
+ const text = words.join(' ').trim();
8997
+ if (!text) {
8998
+ console.error('Usage: atris member chat <name> "your message" [--from=<sender>]');
8999
+ process.exit(1);
9000
+ }
9001
+ // Append-only inbox the Atris desktop polls into the member's desk chat.
9002
+ // Contract shared with obelisk src/lib/memberChatInbox.cjs: one JSON object per line.
9003
+ const line = JSON.stringify({ at: new Date().toISOString(), from, text });
9004
+ fs.appendFileSync(path.join(paths.memberDir, 'chat-inbox.jsonl'), `${line}\n`, 'utf8');
9005
+ console.log(`Sent to ${paths.storageName || name}'s desk. It appears in Atris Desktop within about 15 seconds when the app is open.`);
9006
+ return { ok: true };
9007
+ }
9008
+
8975
9009
  async function memberCommand(subcommand, ...args) {
8976
9010
  // Subcommands that take a member name as args[0] otherwise treat `--help` as
8977
9011
  // a name and error with "Member '--help' not found". `create`/`new` handle
@@ -9003,6 +9037,8 @@ async function memberCommand(subcommand, ...args) {
9003
9037
  case 'goal-from-score':
9004
9038
  case 'score-goal':
9005
9039
  return memberGoalFromScore(args[0], ...args.slice(1));
9040
+ case 'chat':
9041
+ return memberChat(args[0], ...args.slice(1));
9006
9042
  case 'tick':
9007
9043
  return memberTick(args[0], ...args.slice(1));
9008
9044
  case 'wake':
@@ -9046,6 +9082,7 @@ async function memberCommand(subcommand, ...args) {
9046
9082
  console.log('');
9047
9083
  console.log('Subcommands:');
9048
9084
  console.log(' create <name> Scaffold a new team member (MEMBER.md + dirs) [--push]');
9085
+ console.log(' chat <name> "..." Send a message to this member\'s desk in Atris Desktop');
9049
9086
  console.log(' list Show all team members');
9050
9087
  console.log(' activate <name> Symlink member skills, show context and permissions');
9051
9088
  console.log(' upgrade <name> Convert flat file (name.md) to directory format');
package/commands/task.js CHANGED
@@ -6028,25 +6028,66 @@ function cmdStatus(args) {
6028
6028
  if (history) console.log(`history feed ${status.swarlo.feed.length} event${status.swarlo.feed.length === 1 ? '' : 's'}`);
6029
6029
  }
6030
6030
 
6031
+ function readProjectionFile(workspaceRoot) {
6032
+ const candidatePaths = [
6033
+ workspaceRoot ? path.join(workspaceRoot, '.atris', 'state', 'tasks.projection.json') : null,
6034
+ path.resolve('.atris', 'state', 'tasks.projection.json'),
6035
+ ].filter(Boolean);
6036
+ for (const candidate of candidatePaths) {
6037
+ try {
6038
+ if (fs.existsSync(candidate)) {
6039
+ const content = fs.readFileSync(candidate, 'utf8');
6040
+ const parsed = JSON.parse(content);
6041
+ if (parsed && Array.isArray(parsed.tasks)) return parsed;
6042
+ }
6043
+ } catch {}
6044
+ }
6045
+ return null;
6046
+ }
6047
+
6031
6048
  function resolveTaskRef(taskDb, db, ref) {
6032
6049
  const token = String(ref || '').trim();
6033
6050
  if (!token) return { ok: false, reason: 'missing' };
6034
- const exact = taskDb.getTask(db, token);
6051
+ const exact = db ? taskDb.getTask(db, token) : null;
6035
6052
  if (exact) return { ok: true, id: exact.id, row: exact };
6036
6053
  const normalized = taskDb.normalizeTaskRef ? taskDb.normalizeTaskRef(token) : token.replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
6037
- const rows = taskDb.withTaskDisplayRefs(taskDb.listTasks(db, { workspaceRoot: taskDb.workspaceRoot() }));
6038
- const seen = new Set();
6039
- const matches = rows.filter(r => {
6040
- const id = String(r.id || '').toUpperCase();
6041
- const display = taskDb.normalizeTaskRef ? taskDb.normalizeTaskRef(r.display_id) : String(r.display_id || '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
6042
- const legacy = taskDb.normalizeTaskRef ? taskDb.normalizeTaskRef(r.legacy_ref) : String(r.legacy_ref || '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
6043
- const matched = id.startsWith(normalized) || display === normalized || legacy === normalized;
6044
- if (!matched || seen.has(r.id)) return false;
6045
- seen.add(r.id);
6046
- return true;
6047
- });
6048
- if (matches.length === 1) return { ok: true, id: matches[0].id, row: matches[0] };
6049
- if (matches.length > 1) return { ok: false, reason: 'ambiguous', matches };
6054
+ const wsRoot = taskDb.workspaceRoot ? taskDb.workspaceRoot() : process.cwd();
6055
+
6056
+ const proj = readProjectionFile(wsRoot);
6057
+ if (proj && Array.isArray(proj.tasks)) {
6058
+ const seen = new Set();
6059
+ const matches = proj.tasks.filter(r => {
6060
+ const id = String(r.id || '').toUpperCase();
6061
+ const display = taskDb.normalizeTaskRef ? taskDb.normalizeTaskRef(r.display_id) : String(r.display_id || '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
6062
+ const legacy = taskDb.normalizeTaskRef ? taskDb.normalizeTaskRef(r.legacy_ref) : String(r.legacy_ref || '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
6063
+ const matched = id.startsWith(normalized) || (display && display === normalized) || (legacy && legacy === normalized);
6064
+ if (!matched || seen.has(r.id)) return false;
6065
+ seen.add(r.id);
6066
+ return true;
6067
+ });
6068
+ if (matches.length === 1) {
6069
+ const match = matches[0];
6070
+ const row = db ? (taskDb.getTask(db, match.id) || match) : match;
6071
+ return { ok: true, id: match.id, row };
6072
+ }
6073
+ if (matches.length > 1) return { ok: false, reason: 'ambiguous', matches };
6074
+ }
6075
+
6076
+ if (db) {
6077
+ const rows = taskDb.withTaskDisplayRefs(taskDb.listTasks(db, { workspaceRoot: wsRoot }));
6078
+ const seen = new Set();
6079
+ const matches = rows.filter(r => {
6080
+ const id = String(r.id || '').toUpperCase();
6081
+ const display = taskDb.normalizeTaskRef ? taskDb.normalizeTaskRef(r.display_id) : String(r.display_id || '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
6082
+ const legacy = taskDb.normalizeTaskRef ? taskDb.normalizeTaskRef(r.legacy_ref) : String(r.legacy_ref || '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
6083
+ const matched = id.startsWith(normalized) || (display && display === normalized) || (legacy && legacy === normalized);
6084
+ if (!matched || seen.has(r.id)) return false;
6085
+ seen.add(r.id);
6086
+ return true;
6087
+ });
6088
+ if (matches.length === 1) return { ok: true, id: matches[0].id, row: matches[0] };
6089
+ if (matches.length > 1) return { ok: false, reason: 'ambiguous', matches };
6090
+ }
6050
6091
  return { ok: false, reason: 'not_found' };
6051
6092
  }
6052
6093
 
@@ -6067,7 +6108,7 @@ function workspaceRefRows(taskDb, db, options = {}) {
6067
6108
  }
6068
6109
 
6069
6110
  function renderTaskDesk(rows, refRows = rows) {
6070
- const displayRows = getTaskDb().withTaskDisplayRefs(rows, refRows);
6111
+ const displayRows = (rows && rows.length && rows[0].display_id) ? rows : getTaskDb().withTaskDisplayRefs(rows, refRows);
6071
6112
  const active = displayRows.filter(r => r.status !== 'done' && r.status !== 'archived');
6072
6113
  const done = displayRows.filter(r => r.status === 'done');
6073
6114
  if (rows.length === 0) {
@@ -6078,7 +6119,7 @@ function renderTaskDesk(rows, refRows = rows) {
6078
6119
  console.log('TASK DESK');
6079
6120
  console.log('');
6080
6121
  for (const r of active.slice(0, 12)) {
6081
- const explanation = taskExplanation(r);
6122
+ const explanation = r.explanation || taskExplanation(r);
6082
6123
  const owner = r.claimed_by ? ` @${r.claimed_by}` : '';
6083
6124
  const assigned = !r.claimed_by && taskAssignee(r) ? ` -> ${taskAssignee(r)}` : '';
6084
6125
  const tag = r.tag ? ` #${r.tag}` : '';
@@ -6415,7 +6456,17 @@ function cmdHome(args) {
6415
6456
  workspaceRoot,
6416
6457
  limit: all ? null : 200,
6417
6458
  });
6418
- const { projection, outPath } = writeDefaultProjection(taskDb, db, { all, everywhere });
6459
+ let projection;
6460
+ let outPath;
6461
+ const existingProj = readProjectionFile(workspaceRoot);
6462
+ if (rows.length === 0 && existingProj && Array.isArray(existingProj.tasks) && existingProj.tasks.length > 0) {
6463
+ projection = existingProj;
6464
+ outPath = path.resolve(path.join(workspaceRoot || '.', '.atris', 'state', 'tasks.projection.json'));
6465
+ } else {
6466
+ const written = writeDefaultProjection(taskDb, db, { all, everywhere });
6467
+ projection = written.projection;
6468
+ outPath = written.outPath;
6469
+ }
6419
6470
  if (wantsJson(args)) {
6420
6471
  printJson({
6421
6472
  ok: true,
@@ -6427,7 +6478,7 @@ function cmdHome(args) {
6427
6478
  });
6428
6479
  return;
6429
6480
  }
6430
- renderTaskDesk(rows, rows);
6481
+ renderTaskDesk(projection.tasks);
6431
6482
  }
6432
6483
 
6433
6484
  function cmdList(args) {
@@ -7382,7 +7433,14 @@ function cmdInspect(args) {
7382
7433
  const db = taskDb.open();
7383
7434
  const taskId = requireTaskId(taskDb, db, ref, 'atris task inspect');
7384
7435
  const projection = enrichTaskProjection(taskDb.taskProjection(db, { taskId }));
7385
- const task = projection.tasks[0];
7436
+ let task = projection.tasks[0];
7437
+ if (!task) {
7438
+ const wsRoot = scopedWorkspaceRoot(taskDb, args) || process.cwd();
7439
+ const proj = readProjectionFile(wsRoot);
7440
+ if (proj && Array.isArray(proj.tasks)) {
7441
+ task = proj.tasks.find(t => t.id === taskId) || null;
7442
+ }
7443
+ }
7386
7444
  if (!task) {
7387
7445
  failTask('atris task inspect', 'not_found', `task not found: ${ref}`, 1);
7388
7446
  }
@@ -7411,7 +7469,14 @@ function cmdShow(args) {
7411
7469
  const db = taskDb.open();
7412
7470
  const taskId = requireTaskId(taskDb, db, id, 'atris task show');
7413
7471
  const projection = enrichTaskProjection(taskDb.taskProjection(db, { taskId }));
7414
- const task = projection.tasks[0];
7472
+ let task = projection.tasks[0];
7473
+ if (!task) {
7474
+ const wsRoot = scopedWorkspaceRoot(taskDb, args) || process.cwd();
7475
+ const proj = readProjectionFile(wsRoot);
7476
+ if (proj && Array.isArray(proj.tasks)) {
7477
+ task = proj.tasks.find(t => t.id === taskId) || null;
7478
+ }
7479
+ }
7415
7480
  if (!task) {
7416
7481
  console.error(`task not found: ${id}`);
7417
7482
  process.exit(1);
@@ -7550,7 +7615,14 @@ function cmdReviewChat(args) {
7550
7615
 
7551
7616
  function taskDetail(taskDb, db, taskId) {
7552
7617
  const detailedProjection = taskDb.taskProjection(db, { taskId });
7553
- const detailedTask = detailedProjection.tasks[0] || null;
7618
+ let detailedTask = detailedProjection.tasks[0] || null;
7619
+ if (!detailedTask) {
7620
+ const wsRoot = taskDb.workspaceRoot ? taskDb.workspaceRoot() : process.cwd();
7621
+ const proj = readProjectionFile(wsRoot);
7622
+ if (proj && Array.isArray(proj.tasks)) {
7623
+ detailedTask = proj.tasks.find(t => t.id === taskId) || null;
7624
+ }
7625
+ }
7554
7626
  if (!detailedTask) return null;
7555
7627
  const workspaceRoot = detailedTask.workspace_root || taskDb.workspaceRoot();
7556
7628
  const contextProjection = enrichTaskProjection(taskDb.taskProjection(db, {
@@ -7558,7 +7630,7 @@ function taskDetail(taskDb, db, taskId) {
7558
7630
  limit: 5000,
7559
7631
  }));
7560
7632
  const enrichedTask = contextProjection.tasks.find(task => task.id === detailedTask.id) || null;
7561
- if (!enrichedTask) return enrichTaskProjection(detailedProjection).tasks[0] || null;
7633
+ if (!enrichedTask) return enrichTaskProjection(detailedProjection).tasks[0] || detailedTask;
7562
7634
  return {
7563
7635
  ...enrichedTask,
7564
7636
  current_version: detailedTask.current_version,
@@ -0,0 +1,176 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { execFileSync } = require('node:child_process');
6
+
7
+ const { repoRoot } = require('./stream');
8
+ const {
9
+ buildWorkforcePresence,
10
+ isFinishedReceipt,
11
+ parsePsOutput,
12
+ receiptEngine,
13
+ renderWorkforcePresence,
14
+ } = require('../lib/workforce-presence');
15
+
16
+ function readJson(file, fsModule = fs, fallback = null) {
17
+ try {
18
+ return JSON.parse(fsModule.readFileSync(file, 'utf8'));
19
+ } catch {
20
+ return fallback;
21
+ }
22
+ }
23
+
24
+ function readJsonLines(file, fsModule = fs) {
25
+ let text;
26
+ try {
27
+ text = fsModule.readFileSync(file, 'utf8');
28
+ } catch {
29
+ return [];
30
+ }
31
+ return String(text)
32
+ .split(/\r?\n/)
33
+ .map((line) => line.trim())
34
+ .filter(Boolean)
35
+ .map((line) => {
36
+ try { return JSON.parse(line); } catch { return null; }
37
+ })
38
+ .filter(Boolean);
39
+ }
40
+
41
+ function collectTasks(root, fsModule = fs) {
42
+ const payload = readJson(path.join(root, '.atris', 'state', 'tasks.projection.json'), fsModule, {});
43
+ return Array.isArray(payload?.tasks) ? payload.tasks : [];
44
+ }
45
+
46
+ function collectMissions(root, fsModule = fs) {
47
+ const rows = readJsonLines(path.join(root, '.atris', 'state', 'missions.jsonl'), fsModule);
48
+ const latestById = new Map();
49
+ for (const row of rows) {
50
+ const id = String(row?.id || '').trim();
51
+ if (id) latestById.set(id, row);
52
+ }
53
+ return [...latestById.values()];
54
+ }
55
+
56
+ function collectReceipts(root, fsModule = fs) {
57
+ const runsDir = path.join(root, 'atris', 'runs');
58
+ let names;
59
+ try {
60
+ names = fsModule.readdirSync(runsDir).filter((name) => name.endsWith('.json')).sort();
61
+ } catch {
62
+ return [];
63
+ }
64
+ const receipts = [];
65
+ for (const name of names) {
66
+ const receiptPath = path.join(runsDir, name);
67
+ const receipt = readJson(receiptPath, fsModule);
68
+ if (!receipt || !receiptEngine(receipt)) continue;
69
+ let mtimeMs = 0;
70
+ try { mtimeMs = fsModule.statSync(receiptPath).mtimeMs; } catch {}
71
+ receipts.push({ name, path: receiptPath, mtimeMs, receipt });
72
+ }
73
+ return receipts;
74
+ }
75
+
76
+ function collectProcesses(deps = {}) {
77
+ if (Array.isArray(deps.processes)) return deps.processes;
78
+ const execFile = deps.execFile || execFileSync;
79
+ try {
80
+ const output = execFile('ps', ['-eo', 'pid=,ppid=,lstart=,command='], {
81
+ encoding: 'utf8',
82
+ stdio: ['ignore', 'pipe', 'ignore'],
83
+ });
84
+ return parsePsOutput(output);
85
+ } catch {
86
+ return [];
87
+ }
88
+ }
89
+
90
+ function collectWorkforcePresence(deps = {}) {
91
+ const fsModule = deps.fs || fs;
92
+ const root = deps.root || repoRoot(deps.cwd || process.cwd());
93
+ const receipts = Array.isArray(deps.receipts) ? deps.receipts : collectReceipts(root, fsModule);
94
+ return buildWorkforcePresence({
95
+ nowMs: typeof deps.now === 'function' ? deps.now() : Date.now(),
96
+ staleAfterMs: deps.staleAfterMs,
97
+ processes: collectProcesses(deps),
98
+ tasks: Array.isArray(deps.tasks) ? deps.tasks : collectTasks(root, fsModule),
99
+ missions: Array.isArray(deps.missions) ? deps.missions : collectMissions(root, fsModule),
100
+ receipts,
101
+ });
102
+ }
103
+
104
+ function archiveFinishedRuns(root, receipts, fsModule = fs) {
105
+ const archiveDir = path.join(root, 'atris', 'runs', 'archive');
106
+ const finished = receipts.filter((entry) => isFinishedReceipt(entry.receipt));
107
+ const summary = {
108
+ schema: 'atris.workforce_clear.v1',
109
+ archive_dir: path.relative(root, archiveDir),
110
+ archived: 0,
111
+ failed: [],
112
+ };
113
+ if (!finished.length) return summary;
114
+ fsModule.mkdirSync(archiveDir, { recursive: true });
115
+ for (const entry of finished) {
116
+ const destination = path.join(archiveDir, entry.name);
117
+ if (fsModule.existsSync(destination)) {
118
+ summary.failed.push({ receipt: entry.name, reason: 'archive destination already exists' });
119
+ continue;
120
+ }
121
+ try {
122
+ fsModule.renameSync(entry.path, destination);
123
+ summary.archived += 1;
124
+ } catch (error) {
125
+ summary.failed.push({ receipt: entry.name, reason: error.message || String(error) });
126
+ }
127
+ }
128
+ return summary;
129
+ }
130
+
131
+ function helpText() {
132
+ return [
133
+ 'atris who - show local engines and team members from live process and final run state',
134
+ '',
135
+ 'usage: atris who [--json]',
136
+ 'usage: atris who --clear [--json]',
137
+ '',
138
+ '--clear archives finished run receipts under atris/runs/archive/.',
139
+ ].join('\n');
140
+ }
141
+
142
+ function whoCommand(args = [], deps = {}) {
143
+ const write = deps.write || process.stdout.write.bind(process.stdout);
144
+ const error = deps.error || process.stderr.write.bind(process.stderr);
145
+ if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
146
+ write(`${helpText()}\n`);
147
+ return 0;
148
+ }
149
+ if (args.some((arg) => !['--json', '--clear'].includes(arg))) {
150
+ error('usage: atris who [--json] [--clear]\n');
151
+ return 2;
152
+ }
153
+
154
+ const fsModule = deps.fs || fs;
155
+ const root = deps.root || repoRoot(deps.cwd || process.cwd());
156
+ const receipts = Array.isArray(deps.receipts) ? deps.receipts : collectReceipts(root, fsModule);
157
+ if (args.includes('--clear')) {
158
+ const summary = archiveFinishedRuns(root, receipts, fsModule);
159
+ if (args.includes('--json')) write(`${JSON.stringify(summary, null, 2)}\n`);
160
+ else if (summary.failed.length) write(`archived ${summary.archived} finished runs; ${summary.failed.length} could not be archived.\n`);
161
+ else if (summary.archived) write(`archived ${summary.archived} finished run${summary.archived === 1 ? '' : 's'} in ${summary.archive_dir}.\n`);
162
+ else write('no finished runs to clear.\n');
163
+ return summary.failed.length ? 1 : 0;
164
+ }
165
+
166
+ const presence = collectWorkforcePresence({ ...deps, root, receipts });
167
+ const output = args.includes('--json')
168
+ ? JSON.stringify(presence, null, 2)
169
+ : renderWorkforcePresence(presence);
170
+ write(`${output}\n`);
171
+ return 0;
172
+ }
173
+
174
+ module.exports = {
175
+ whoCommand,
176
+ };
@@ -160,6 +160,8 @@ function listWorktrees(root = repoRoot()) {
160
160
  // Duplicate flight guard. On 2026-08-07 two agents were dispatched onto the
161
161
  // same map rewrite 44 seconds apart: fleet dispatch claims a task first, but a
162
162
  // direct `worktree start` had no pre-check beyond the target path existing.
163
+ // Refuse only the same task slug. A shared word on an unrelated flight is not
164
+ // a collision (dispatch-cli-900 must not block dispatch-cli-901).
163
165
  const AGENT_FLIGHT_NAME_PATTERN = /^codex\/(.+)-(\d{8}-\d{6})$/;
164
166
  const FLIGHT_WINDOW_MS = 24 * 60 * 60 * 1000;
165
167
  const FLIGHT_STOPWORDS = new Set([
@@ -234,10 +236,10 @@ function inFlightAgentFlights({ root = repoRoot(), now = new Date(), windowMs =
234
236
  return [...flights.values()].sort((a, b) => b.stampMs - a.stampMs);
235
237
  }
236
238
 
237
- function collidingFlights(flights, tokens) {
238
- const wanted = new Set(tokens);
239
- if (!wanted.size) return [];
240
- return flights.filter((flight) => flight.tokens.some((token) => wanted.has(token)));
239
+ function collidingFlights(flights, taskSlug) {
240
+ const wanted = String(taskSlug || '').trim().toLowerCase();
241
+ if (!wanted) return [];
242
+ return flights.filter((flight) => String(flight.taskSlug || '').toLowerCase() === wanted);
241
243
  }
242
244
 
243
245
  function describeFlightAge(flight, nowMs = Date.now()) {
@@ -511,7 +513,7 @@ function startWorktree(args) {
511
513
  const active = flights.filter((flight) => flight.kind === 'worktree');
512
514
  const repoName = path.basename(findPrimaryRoot(root));
513
515
  console.log(`flights: ${active.length} active agent ${active.length === 1 ? 'worktree' : 'worktrees'} for ${repoName}`);
514
- const collisions = collidingFlights(flights, taskTokens(slugify(task, 'task', 36)));
516
+ const collisions = collidingFlights(flights, slugify(task, 'task', 36));
515
517
  if (collisions.length) {
516
518
  const label = force ? 'warning' : 'refusing';
517
519
  for (const flight of collisions) {
@@ -33,6 +33,7 @@ function engineFailureHealthStatus(result) {
33
33
  if (/usage[ _-]?limit|purchase more credits|insufficient credits|credit(?:s)?[ _-]?(?:out|limit)|rate[ _-]?limit|not authenticated|please log in|login required|auth(?:entication)?[ _-]?expired|payment required|subscription/.test(signalText)) {
34
34
  return 'credit_out';
35
35
  }
36
+ if (/not installed|command not found|\benoent\b/.test(signalText)) return 'not_installed';
36
37
  if (/timeout|model-unavailable/.test(signalText)) return 'not_installed';
37
38
  return 'error';
38
39
  }
package/lib/fleet.js CHANGED
@@ -208,7 +208,7 @@ function buildEngineCommand(engineName, promptFile, { yolo = false, sealed = fal
208
208
  if (sealed && engineName === 'codex') {
209
209
  cmd = cmd.replace(/\bexec\b/, 'exec --sandbox workspace-write --ephemeral --ignore-user-config --ignore-rules');
210
210
  }
211
- if (sealed && engineName === 'claude') {
211
+ if (sealed && (engineName === 'claude' || engineName === 'fable')) {
212
212
  cmd = `${cmd} --safe-mode --no-session-persistence --permission-mode acceptEdits --settings '${JSON.stringify({ sandbox: { enabled: true, autoAllowBashIfSandboxed: true } })}'`;
213
213
  }
214
214
  if (sealed && engineName === 'cursor') cmd = `${cmd} --sandbox enabled`;
@@ -218,7 +218,7 @@ function buildEngineCommand(engineName, promptFile, { yolo = false, sealed = fal
218
218
  }
219
219
  if (engineName === 'devin') return cmd.replace(/^devin -p /, 'devin -p --permission-mode dangerous ');
220
220
  if (yolo && engineName === 'codex') cmd = cmd.replace(/\bexec\b/, `exec ${YOLO_ENGINE_FLAGS.codex}`);
221
- if (yolo && engineName === 'claude') cmd = `${cmd} ${YOLO_ENGINE_FLAGS.claude}`;
221
+ if (yolo && (engineName === 'claude' || engineName === 'fable')) cmd = `${cmd} ${YOLO_ENGINE_FLAGS.claude}`;
222
222
  if (engineName === 'codex') cmd = wrapCodexWithWatchdog(cmd, watchdogPath, watchdogReceiptPath);
223
223
  return cmd;
224
224
  } finally {
@@ -561,6 +561,25 @@ function detectDeadEngineDispatch(result) {
561
561
  return { reason: 'nonzero_exit', exitCode };
562
562
  }
563
563
 
564
+ function plainDispatchFailureCause(result, failure = {}) {
565
+ const reason = String(failure.reason || failure.stage || '').trim();
566
+ const output = dispatchResultOutput(result);
567
+ if (reason === 'no_output') return 'the engine returned no output';
568
+ if (/not authenticated|please log in|login required|auth(?:entication)?[ _-]?expired/i.test(output)) {
569
+ return 'the engine login expired';
570
+ }
571
+ if (/spawn[^\n]*enoent|enoent[^\n]*spawn|failed to spawn/i.test(output)) {
572
+ return 'the engine could not start';
573
+ }
574
+ if (reason === 'timeout') return 'the engine timed out';
575
+ if (reason === 'cancelled') return 'the engine run was cancelled';
576
+ if (reason === 'unknown') return 'the engine exited without a status';
577
+ if (reason === 'signalled') return `the engine stopped with ${failure.signal || 'a signal'}`;
578
+ const detail = String(failure.detail || output || '').trim().split('\n')[0].trim();
579
+ if (detail) return detail.replace(/[.]+$/, '');
580
+ return (reason || 'the engine failed').replace(/_/g, ' ');
581
+ }
582
+
564
583
  function recordDispatchEngineHealth(result, failure, root) {
565
584
  if (!result || !result.engine) return null;
566
585
  const status = failure
@@ -1081,6 +1100,7 @@ module.exports = {
1081
1100
  shipWithRetry,
1082
1101
  shipFailureDetail,
1083
1102
  get FLEET_CAPABLE() { return FLEET_CAPABLE; },
1103
+ get DISPATCH_CAPABLE() { return DISPATCH_CAPABLE; },
1084
1104
  get runFleetFlight() { return runFleetFlight; },
1085
1105
  get focusedCheck() { return focusedCheck; },
1086
1106
  get dispatchCheck() { return dispatchCheck; },
@@ -1122,6 +1142,7 @@ module.exports = {
1122
1142
  // Engines that can edit a repo headlessly. atris-fast (ax) is a chat lane,
1123
1143
  // not a repo worker — it keeps owning normal mission ticks, not fleet builds.
1124
1144
  const FLEET_CAPABLE = ['claude', 'codex', 'cursor', 'devin', 'grok'];
1145
+ const DISPATCH_CAPABLE = [...FLEET_CAPABLE, 'fable'];
1125
1146
 
1126
1147
  let receiptSequence = 0;
1127
1148
  function nowStamp() {
@@ -2658,6 +2679,18 @@ async function runFleetFlight({
2658
2679
  // ---------------------------------------------------------------------------
2659
2680
  // T5 — one-command dispatch: `atris engine dispatch <task-id> --engine <name>`
2660
2681
 
2682
+ // A claim taken before the engine starts must not stay held if the flight
2683
+ // refuses or errors first. Release through the same task plane the claim used.
2684
+ function releaseUnstartedDispatchClaim(cli, { taskId, actor, detail }) {
2685
+ const released = cli(['task', 'release', taskId, '--as', actor]);
2686
+ const releasedOk = Boolean(released && released.status === 0);
2687
+ const suffix = releasedOk
2688
+ ? 'claim released, safe to retry'
2689
+ : `claim release failed: ${String(released && (released.stderr || released.stdout) || 'unknown').trim().slice(0, 80)}`;
2690
+ const base = String(detail || '').trim().slice(0, 200);
2691
+ return { released: releasedOk, detail: base ? `${base}. ${suffix}` : suffix };
2692
+ }
2693
+
2661
2694
  // The manual version of this loop took 6 Bash calls per task the night this
2662
2695
  // was written: claim, worktree start, prompt file, engine -p, verify, ship.
2663
2696
  // One or more explicit task ids build in parallel isolated worktrees on ONE
@@ -2693,8 +2726,8 @@ async function runDispatchFlight({
2693
2726
  scoutAsk = null,
2694
2727
  } = {}) {
2695
2728
  if (!engine) throw new Error('runDispatchFlight: engine is required');
2696
- if (!FLEET_CAPABLE.includes(engine)) {
2697
- throw new Error(`runDispatchFlight: engine "${engine}" cannot build headlessly (capable: ${FLEET_CAPABLE.join(', ')})`);
2729
+ if (!DISPATCH_CAPABLE.includes(engine)) {
2730
+ throw new Error(`runDispatchFlight: engine "${engine}" cannot build headlessly (capable: ${DISPATCH_CAPABLE.join(', ')})`);
2698
2731
  }
2699
2732
  const ids = [...new Set((taskIds || []).map((id) => String(id).trim()).filter(Boolean))];
2700
2733
  if (!ids.length) throw new Error('runDispatchFlight: at least one task id is required');
@@ -2760,51 +2793,71 @@ async function runDispatchFlight({
2760
2793
  log(` x ${taskId} claim failed`);
2761
2794
  continue;
2762
2795
  }
2763
- let landingWorktreePath = '';
2764
- let remoteBoundary = null;
2765
- if (enforceRemoteBoundary) {
2766
- remoteBoundary = prepareReviewSandbox({ root, taskId, engine });
2767
- } else {
2768
- const started = cli(['worktree', 'start', '--agent', engine, '--task', `dispatch-${taskId.toLowerCase()}`, ...startBaseArgs]);
2769
- const wt = (started.stdout.match(/next: cd (.+)/) || [])[1];
2770
- if (!wt) {
2771
- flight.paused.push({ task: taskId, stage: 'worktree_start', detail: String(started.stderr || '').slice(0, 200) });
2772
- log(` ✗ ${taskId} worktree start failed`);
2796
+ try {
2797
+ let landingWorktreePath = '';
2798
+ let remoteBoundary = null;
2799
+ if (enforceRemoteBoundary) {
2800
+ remoteBoundary = prepareReviewSandbox({ root, taskId, engine });
2801
+ } else {
2802
+ const started = cli(['worktree', 'start', '--agent', engine, '--task', `dispatch-${taskId.toLowerCase()}`, ...startBaseArgs]);
2803
+ const wt = (started.stdout.match(/next: cd (.+)/) || [])[1];
2804
+ if (!wt) {
2805
+ const released = releaseUnstartedDispatchClaim(cli, {
2806
+ taskId,
2807
+ actor: taskActor,
2808
+ detail: String(started.stderr || '').slice(0, 200),
2809
+ });
2810
+ flight.paused.push({ task: taskId, stage: 'worktree_start', detail: released.detail });
2811
+ log(` ✗ ${taskId} worktree start failed`);
2812
+ continue;
2813
+ }
2814
+ landingWorktreePath = wt.trim();
2815
+ }
2816
+ if (enforceRemoteBoundary && (!remoteBoundary || remoteBoundary.ok !== true)) {
2817
+ const released = releaseUnstartedDispatchClaim(cli, {
2818
+ taskId,
2819
+ actor: taskActor,
2820
+ detail: String(remoteBoundary && remoteBoundary.detail || 'could not prepare a sealed review sandbox').slice(-500),
2821
+ });
2822
+ flight.paused.push({
2823
+ task: taskId,
2824
+ stage: 'remote_quarantine',
2825
+ detail: released.detail,
2826
+ worktree: null,
2827
+ });
2828
+ log(` paused ${taskId} because its sealed review sandbox could not be prepared`);
2773
2829
  continue;
2774
2830
  }
2775
- landingWorktreePath = wt.trim();
2776
- }
2777
- if (enforceRemoteBoundary && (!remoteBoundary || remoteBoundary.ok !== true)) {
2778
- flight.paused.push({
2779
- task: taskId,
2780
- stage: 'remote_quarantine',
2781
- detail: String(remoteBoundary && remoteBoundary.detail || 'could not prepare a sealed review sandbox').slice(-500),
2782
- worktree: null,
2831
+ const worktreePath = enforceRemoteBoundary ? remoteBoundary.worktreePath : landingWorktreePath;
2832
+ const startCommit = yolo ? readStartCommit({ worktreePath }) : '';
2833
+ const basePrompt = promptOverride || buildFleetPrompt(task, { worktreePath, yolo });
2834
+ const safetyPrompt = reviewOnly
2835
+ ? `${basePrompt}\n\nSafety boundary: edit and test this checkout only. Do not push, merge, deploy, publish, send messages, change cloud state, install dependencies, or use credentials.`
2836
+ : basePrompt;
2837
+ const trustedPrompt = trustedVerifier
2838
+ ? `${safetyPrompt}\n\nTrusted verifier (run it bare before reporting done): ${trustedVerifier}`
2839
+ : promptOverride;
2840
+ prepared.push({
2841
+ task,
2842
+ taskId,
2843
+ worktreePath,
2844
+ landingWorktreePath,
2845
+ engine,
2846
+ remoteBoundary,
2847
+ remoteMasterBefore: remoteBoundary ? remoteBoundary.protectedMaster : '',
2848
+ startCommit,
2849
+ ...(trustedPrompt ? { prompt: trustedPrompt } : {}),
2783
2850
  });
2784
- log(` paused ${taskId} because its sealed review sandbox could not be prepared`);
2785
- continue;
2851
+ log(` building ${taskId} in ${path.basename(worktreePath)}`);
2852
+ } catch (err) {
2853
+ const released = releaseUnstartedDispatchClaim(cli, {
2854
+ taskId,
2855
+ actor: taskActor,
2856
+ detail: String(err && err.message || err).slice(0, 200),
2857
+ });
2858
+ flight.paused.push({ task: taskId, stage: 'prepare', detail: released.detail });
2859
+ log(` ✗ ${taskId} prepare failed`);
2786
2860
  }
2787
- const worktreePath = enforceRemoteBoundary ? remoteBoundary.worktreePath : landingWorktreePath;
2788
- const startCommit = yolo ? readStartCommit({ worktreePath }) : '';
2789
- const basePrompt = promptOverride || buildFleetPrompt(task, { worktreePath, yolo });
2790
- const safetyPrompt = reviewOnly
2791
- ? `${basePrompt}\n\nSafety boundary: edit and test this checkout only. Do not push, merge, deploy, publish, send messages, change cloud state, install dependencies, or use credentials.`
2792
- : basePrompt;
2793
- const trustedPrompt = trustedVerifier
2794
- ? `${safetyPrompt}\n\nTrusted verifier (run it bare before reporting done): ${trustedVerifier}`
2795
- : promptOverride;
2796
- prepared.push({
2797
- task,
2798
- taskId,
2799
- worktreePath,
2800
- landingWorktreePath,
2801
- engine,
2802
- remoteBoundary,
2803
- remoteMasterBefore: remoteBoundary ? remoteBoundary.protectedMaster : '',
2804
- startCommit,
2805
- ...(trustedPrompt ? { prompt: trustedPrompt } : {}),
2806
- });
2807
- log(` building ${taskId} in ${path.basename(worktreePath)}`);
2808
2861
  }
2809
2862
 
2810
2863
  const dispatch = dispatcher || ((entry) => new Promise((resolve) => {
@@ -2828,6 +2881,10 @@ async function runDispatchFlight({
2828
2881
  }));
2829
2882
  const restaffState = { used: false };
2830
2883
 
2884
+ if (engine === 'fable' && prepared.length) {
2885
+ log(` fable handoff started: receipt ${path.relative(root, receiptPath)}`);
2886
+ }
2887
+
2831
2888
  const results = await Promise.all(prepared.map((entry) => {
2832
2889
  const startedAtMs = Date.now();
2833
2890
  return dispatchEntryWithRestaff({
@@ -3343,6 +3400,12 @@ async function runDispatchFlight({
3343
3400
  flight.finished_at = new Date().toISOString();
3344
3401
  writeDispatchReceipt(flight, receiptPath, { ids, reviewOnly, enforceRemoteBoundary });
3345
3402
  log('');
3403
+ if (engine === 'fable' && flight.paused.length) {
3404
+ const paused = flight.paused[0];
3405
+ const failed = results.find(({ entry }) => entry.taskId === paused.task);
3406
+ const cause = plainDispatchFailureCause(failed && failed.result, paused);
3407
+ log(` fable handoff failed: ${cause}. receipt: ${path.relative(root, receiptPath)}`);
3408
+ }
3346
3409
  const completedLabel = reviewOnly ? `${flight.ready.length} proof ready` : `${flight.landed.length} landed`;
3347
3410
  log(` dispatch over: ${completedLabel}, ${flight.paused.length} paused - receipt: ${path.relative(root, flight.receipt)}`);
3348
3411
  log('');
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const knownCommands = ['init', 'log', 'logs', 'wish', 'ask', 'approve', 'stop', 'ready', 'check', 'drill', 'dream', 'now', 'goal', 'wtf', 'founder', 'orb', 'radar', 'stream', 'ctop', 'launchpad', 'status', 'analytics', 'visualize', 'brain', 'brainstorm', 'autopilot', 'run', '_start', 'plan', 'do', 'review', 'release',
3
+ const knownCommands = ['init', 'log', 'logs', 'wish', 'ask', 'approve', 'stop', 'ready', 'check', 'drill', 'dream', 'now', 'goal', 'wtf', 'founder', 'orb', 'radar', 'who', 'stream', 'ctop', 'launchpad', 'status', 'analytics', 'visualize', 'brain', 'brainstorm', 'autopilot', 'run', '_start', 'plan', 'do', 'review', 'release',
4
4
  'activate', '_activate', 'agent', 'team', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
5
5
  'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube',
6
6
  'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'sync-checkout',
package/lib/task-db.js CHANGED
@@ -369,8 +369,8 @@ function withTaskDisplayRefs(rows, refRows = rows) {
369
369
  const ids = sorted.map(row => row && row.id);
370
370
  sorted.forEach((row, index) => {
371
371
  refs.set(row.id, {
372
- display_id: taskDisplayRef(row, index),
373
- legacy_ref: shortestUniqueTaskRef(row.id, ids, 8),
372
+ display_id: row.display_id || taskDisplayRef(row, index),
373
+ legacy_ref: row.legacy_ref || shortestUniqueTaskRef(row.id, ids, 8),
374
374
  });
375
375
  });
376
376
  }
@@ -0,0 +1,448 @@
1
+ 'use strict';
2
+
3
+ const DEFAULT_STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
4
+ const PROCESS_START_TOLERANCE_MS = 30 * 60 * 1000;
5
+ const ACTIVE_TASK_STATUSES = new Set(['claimed', 'do', 'doing', 'in_progress', 'review']);
6
+ const ACTIVE_MISSION_STATUSES = new Set(['planning', 'active', 'running', 'ready']);
7
+ const RUNNING_RECEIPT_STATUSES = new Set(['active', 'in_progress', 'running', 'started', 'working']);
8
+ const TERMINAL_RECEIPT_STATUSES = new Set([
9
+ 'cancelled',
10
+ 'completed',
11
+ 'done',
12
+ 'failed',
13
+ 'landed',
14
+ 'no_output',
15
+ 'passed',
16
+ 'presumed_dead',
17
+ 'succeeded',
18
+ 'timed_out',
19
+ ]);
20
+ const WORK_TOKEN_STOP_WORDS = new Set([
21
+ 'agent', 'build', 'building', 'engine', 'local', 'mission', 'process', 'running', 'task', 'working',
22
+ ]);
23
+
24
+ function timestampMs(value) {
25
+ if (value == null || value === '') return 0;
26
+ if (typeof value === 'number') {
27
+ if (!Number.isFinite(value)) return 0;
28
+ return value > 1000000000000 ? value : value * 1000;
29
+ }
30
+ const numeric = Number(value);
31
+ if (Number.isFinite(numeric) && String(value).trim()) return timestampMs(numeric);
32
+ const parsed = Date.parse(String(value));
33
+ return Number.isFinite(parsed) ? parsed : 0;
34
+ }
35
+
36
+ function isoTimestamp(value) {
37
+ const ms = timestampMs(value);
38
+ return ms ? new Date(ms).toISOString() : null;
39
+ }
40
+
41
+ function ageSeconds(value, nowMs) {
42
+ const ms = timestampMs(value);
43
+ return ms ? Math.max(0, Math.floor((nowMs - ms) / 1000)) : null;
44
+ }
45
+
46
+ function normalizeEngine(value) {
47
+ const engine = String(value || '').trim().toLowerCase();
48
+ if (!engine) return '';
49
+ if (engine === 'cursor-agent' || engine === 'cursor agent') return 'cursor';
50
+ if (engine === 'claude-code') return 'claude';
51
+ return engine.replace(/[^a-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '');
52
+ }
53
+
54
+ function engineForCommand(command) {
55
+ const text = String(command || '');
56
+ if (/ChatGPT\.app|Codex Framework\.framework|Claude\.app/.test(text)) return '';
57
+ const executable = text.trim().split(/\s+/)[0] || '';
58
+ const name = executable.split('/').pop().toLowerCase();
59
+ if (name === 'cursor-agent') return 'cursor';
60
+ if (/^codex(?:-|$)/.test(name)) return 'codex';
61
+ if (name === 'grok') return 'grok';
62
+ if (name === 'devin') return 'devin';
63
+ if (name === 'droid') return 'droid';
64
+ if (name === 'agy') return 'agy';
65
+ if (name === 'claude' && /(^|\s)(?:-p|--print)(?:\s|$)/.test(text)) return 'claude';
66
+ return '';
67
+ }
68
+
69
+ function parsePsOutput(text) {
70
+ const allRows = [];
71
+ for (const line of String(text || '').split(/\r?\n/)) {
72
+ const parts = line.trim().split(/\s+/);
73
+ if (parts.length < 8) continue;
74
+ const pid = Number(parts[0]);
75
+ const ppid = Number(parts[1]);
76
+ const command = parts.slice(7).join(' ');
77
+ if (!Number.isInteger(pid) || pid <= 0) continue;
78
+ const started = Date.parse(parts.slice(2, 7).join(' '));
79
+ allRows.push({
80
+ pid,
81
+ ppid: Number.isInteger(ppid) && ppid > 0 ? ppid : null,
82
+ command,
83
+ started_at: Number.isFinite(started) ? new Date(started).toISOString() : null,
84
+ });
85
+ }
86
+ const byPid = new Map(allRows.map((row) => [row.pid, row]));
87
+ const engineRows = allRows
88
+ .map((row) => ({ ...row, engine: engineForCommand(row.command) }))
89
+ .filter((row) => row.engine);
90
+ const parentPids = new Set(engineRows.map((row) => row.ppid).filter(Boolean));
91
+ return engineRows
92
+ .filter((row) => !parentPids.has(row.pid))
93
+ .map((row) => {
94
+ const ancestorPids = [];
95
+ let parent = row.ppid;
96
+ while (parent && !ancestorPids.includes(parent) && ancestorPids.length < 64) {
97
+ ancestorPids.push(parent);
98
+ parent = byPid.get(parent)?.ppid || null;
99
+ }
100
+ return { ...row, ancestor_pids: ancestorPids };
101
+ });
102
+ }
103
+
104
+ function normalizeProcesses(processes) {
105
+ const byPid = new Map();
106
+ for (const row of Array.isArray(processes) ? processes : []) {
107
+ const pid = Number(row?.pid);
108
+ const engine = normalizeEngine(row?.engine) || engineForCommand(row?.command);
109
+ if (!Number.isInteger(pid) || pid <= 0 || !engine) continue;
110
+ byPid.set(pid, {
111
+ pid,
112
+ ppid: Number(row?.ppid) || null,
113
+ engine,
114
+ command: String(row?.command || ''),
115
+ started_at: isoTimestamp(row?.started_at || row?.start || row?.at),
116
+ ancestor_pids: (Array.isArray(row?.ancestor_pids) ? row.ancestor_pids : [])
117
+ .map(Number)
118
+ .filter((value) => Number.isInteger(value) && value > 0),
119
+ });
120
+ }
121
+ return [...byPid.values()].sort((left, right) => left.pid - right.pid);
122
+ }
123
+
124
+ function taskRef(task) {
125
+ return String(task?.display_id || task?.legacy_ref || task?.id || '').trim();
126
+ }
127
+
128
+ function taskOwner(task) {
129
+ return String(task?.claimed_by || task?.assigned_to || task?.metadata?.assigned_to || '').trim();
130
+ }
131
+
132
+ function taskActivity(task) {
133
+ return task?.updated_at || task?.claimed_at || task?.created_at || null;
134
+ }
135
+
136
+ function receiptTaskRefs(receipt) {
137
+ const values = [receipt?.task_id, receipt?.task];
138
+ if (Array.isArray(receipt?.tasks)) values.push(...receipt.tasks);
139
+ if (Array.isArray(receipt?.task_ids)) values.push(...receipt.task_ids);
140
+ if (Array.isArray(receipt?.results)) values.push(...receipt.results.map((row) => row?.task || row?.task_id));
141
+ return [...new Set(values.map((value) => {
142
+ if (value && typeof value === 'object') return value.display_id || value.id || value.task;
143
+ return value;
144
+ }).map((value) => String(value || '').trim()).filter(Boolean))];
145
+ }
146
+
147
+ function receiptEngine(receipt) {
148
+ return normalizeEngine(
149
+ receipt?.engine
150
+ || receipt?.engines?.[0]
151
+ || receipt?.results?.find((row) => row?.engine)?.engine,
152
+ );
153
+ }
154
+
155
+ function receiptStatus(receipt) {
156
+ return String(receipt?.status || '').trim().toLowerCase();
157
+ }
158
+
159
+ function isRunningReceipt(receipt) {
160
+ return !receipt?.finished_at && RUNNING_RECEIPT_STATUSES.has(receiptStatus(receipt));
161
+ }
162
+
163
+ function isFinishedReceipt(receipt) {
164
+ if (!receipt || isRunningReceipt(receipt)) return false;
165
+ return Boolean(receipt.finished_at || TERMINAL_RECEIPT_STATUSES.has(receiptStatus(receipt)));
166
+ }
167
+
168
+ function receiptStartedAt(receipt, fallback) {
169
+ return receipt?.started_at || receipt?.at || receipt?.created_at || fallback || null;
170
+ }
171
+
172
+ function receiptFinishedAt(receipt, fallback) {
173
+ return receipt?.finished_at || receipt?.completed_at || receipt?.updated_at || fallback || null;
174
+ }
175
+
176
+ function finalResult(receipt) {
177
+ if (typeof receipt?.result === 'string') return receipt.result.trim();
178
+ if (receipt?.result && typeof receipt.result === 'object') {
179
+ const kind = String(receipt.result.kind || '').trim();
180
+ if (typeof receipt.result.passed === 'boolean') return `${kind || 'result'} ${receipt.result.passed ? 'passed' : 'failed'}`;
181
+ if (kind) return kind;
182
+ }
183
+ if (receipt?.summary && typeof receipt.summary === 'object') {
184
+ const answered = Number(receipt.summary.answered) || 0;
185
+ const failed = Number(receipt.summary.failed) || 0;
186
+ if (answered || failed) return `${answered} answered, ${failed} failed`;
187
+ }
188
+ return receiptStatus(receipt) || 'finished';
189
+ }
190
+
191
+ function taskLookup(tasks) {
192
+ const byRef = new Map();
193
+ for (const task of tasks) {
194
+ for (const ref of [task?.id, task?.display_id, task?.legacy_ref]) {
195
+ const key = String(ref || '').trim().toLowerCase();
196
+ if (key) byRef.set(key, task);
197
+ }
198
+ }
199
+ return byRef;
200
+ }
201
+
202
+ function firstTaskForRefs(refs, byRef) {
203
+ for (const ref of refs) {
204
+ const task = byRef.get(String(ref).toLowerCase());
205
+ if (task) return task;
206
+ }
207
+ return null;
208
+ }
209
+
210
+ function rowTask(refs, task) {
211
+ return taskRef(task) || refs[0] || null;
212
+ }
213
+
214
+ function baseRow({ member, task, title, engine, source, at, nowMs }) {
215
+ return {
216
+ member: member || null,
217
+ task: task || null,
218
+ title: title || null,
219
+ engine: engine || null,
220
+ source,
221
+ at: isoTimestamp(at),
222
+ age_seconds: ageSeconds(at, nowMs),
223
+ };
224
+ }
225
+
226
+ function buildWorkforcePresence(input = {}) {
227
+ const nowMs = timestampMs(input.nowMs ?? input.now ?? Date.now()) || Date.now();
228
+ const staleAfterMs = Number(input.staleAfterMs) > 0 ? Number(input.staleAfterMs) : DEFAULT_STALE_AFTER_MS;
229
+ const tasks = (Array.isArray(input.tasks) ? input.tasks : [])
230
+ .filter((task) => ACTIVE_TASK_STATUSES.has(String(task?.status || '').toLowerCase()) && taskOwner(task));
231
+ const missions = (Array.isArray(input.missions) ? input.missions : [])
232
+ .filter((mission) => ACTIVE_MISSION_STATUSES.has(String(mission?.status || '').toLowerCase()));
233
+ const receipts = Array.isArray(input.receipts) ? input.receipts : [];
234
+ const processes = normalizeProcesses(input.processes);
235
+ const byTaskRef = taskLookup(tasks);
236
+ const usedPids = new Set();
237
+ const representedTasks = new Set();
238
+ const working = [];
239
+ const waiting = [];
240
+ const done = [];
241
+ const stale = [];
242
+
243
+ const claimProcess = (engine, pid, expectedStart) => {
244
+ const wantedPid = Number(pid);
245
+ if (Number.isInteger(wantedPid) && wantedPid > 0) {
246
+ const exact = processes.find((row) => (
247
+ row.pid === wantedPid || row.ancestor_pids.includes(wantedPid)
248
+ ) && row.engine === engine && !usedPids.has(row.pid) && (
249
+ !timestampMs(expectedStart)
250
+ || !timestampMs(row.started_at)
251
+ || Math.abs(timestampMs(row.started_at) - timestampMs(expectedStart)) <= PROCESS_START_TOLERANCE_MS
252
+ ));
253
+ if (exact) usedPids.add(exact.pid);
254
+ return exact || null;
255
+ }
256
+ return null;
257
+ };
258
+ const workTokens = (value) => new Set(
259
+ (String(value || '').toLowerCase().match(/[a-z][a-z0-9]{4,}/g) || [])
260
+ .filter((token) => !WORK_TOKEN_STOP_WORDS.has(token)),
261
+ );
262
+ const processMatchesWork = (row, refs, title) => {
263
+ const command = String(row.command || '').toLowerCase();
264
+ if (refs.some((ref) => command.includes(String(ref).toLowerCase()))) return true;
265
+ const titleTokens = workTokens(title);
266
+ const commandTokens = workTokens(command);
267
+ return [...titleTokens].some((token) => commandTokens.has(token));
268
+ };
269
+ const claimMatchingProcess = (engine, refs, title) => {
270
+ const candidate = processes.find((row) => (
271
+ (!engine || row.engine === engine)
272
+ && !usedPids.has(row.pid)
273
+ && processMatchesWork(row, refs, title)
274
+ ));
275
+ if (candidate) usedPids.add(candidate.pid);
276
+ return candidate || null;
277
+ };
278
+
279
+ for (const entry of receipts) {
280
+ const receipt = entry?.receipt || entry;
281
+ const engine = receiptEngine(receipt);
282
+ if (!engine) continue;
283
+ const refs = receiptTaskRefs(receipt);
284
+ const task = firstTaskForRefs(refs, byTaskRef);
285
+ const member = String(receipt?.member || receipt?.owner || receipt?.actor || taskOwner(task) || '').trim();
286
+ const taskValue = rowTask(refs, task);
287
+ const startedAt = receiptStartedAt(receipt, entry?.mtimeMs);
288
+ const common = baseRow({
289
+ member,
290
+ task: taskValue,
291
+ title: task?.title || receipt?.objective || '',
292
+ engine,
293
+ source: 'receipt',
294
+ at: startedAt,
295
+ nowMs,
296
+ });
297
+ if (isRunningReceipt(receipt)) {
298
+ const processRow = claimProcess(engine, receipt.pid, startedAt)
299
+ || claimMatchingProcess(engine, refs, task?.title || receipt?.objective || '');
300
+ const row = {
301
+ ...common,
302
+ pid: processRow?.pid || Number(receipt.pid) || null,
303
+ receipt: entry?.name || receipt?.receipt || null,
304
+ };
305
+ if (processRow) working.push(row);
306
+ else if (Number(receipt.pid) > 0 || nowMs - timestampMs(startedAt) > staleAfterMs) {
307
+ stale.push({ ...row, reason: 'run has no live process' });
308
+ } else {
309
+ waiting.push({ ...row, reason: 'run has not started a local process' });
310
+ }
311
+ if (taskValue) representedTasks.add(String(taskValue).toLowerCase());
312
+ continue;
313
+ }
314
+ if (isFinishedReceipt(receipt)) {
315
+ done.push({
316
+ ...common,
317
+ at: isoTimestamp(receiptFinishedAt(receipt, entry?.mtimeMs)),
318
+ age_seconds: ageSeconds(receiptFinishedAt(receipt, entry?.mtimeMs), nowMs),
319
+ run_status: receiptStatus(receipt) || 'finished',
320
+ result: finalResult(receipt),
321
+ receipt: entry?.name || receipt?.receipt || null,
322
+ });
323
+ }
324
+ }
325
+
326
+ for (const mission of missions) {
327
+ const refs = Array.isArray(mission?.task_ids) ? mission.task_ids.map(String) : [];
328
+ if (refs.some((ref) => representedTasks.has(ref.toLowerCase()))) continue;
329
+ const task = firstTaskForRefs(refs, byTaskRef);
330
+ const engine = normalizeEngine(mission?.runner || mission?.engine || mission?.executed_by);
331
+ const member = String(mission?.owner || mission?.member || taskOwner(task) || '').trim();
332
+ const at = mission?.last_tick_at || mission?.updated_at || mission?.created_at;
333
+ const common = baseRow({
334
+ member,
335
+ task: rowTask(refs, task),
336
+ title: task?.title || mission?.objective || mission?.name || '',
337
+ engine,
338
+ source: 'mission',
339
+ at,
340
+ nowMs,
341
+ });
342
+ const processRow = engine
343
+ ? claimProcess(engine, mission?.pid, at) || claimMatchingProcess(engine, refs, common.title)
344
+ : null;
345
+ if (processRow) working.push({ ...common, pid: processRow.pid, mission: mission?.id || null });
346
+ else if (nowMs - timestampMs(at) > staleAfterMs) stale.push({ ...common, reason: 'mission has no live process', mission: mission?.id || null });
347
+ else waiting.push({ ...common, reason: 'mission is waiting for a local process', mission: mission?.id || null });
348
+ for (const ref of refs) representedTasks.add(ref.toLowerCase());
349
+ }
350
+
351
+ for (const task of tasks) {
352
+ const ref = taskRef(task);
353
+ if (representedTasks.has(ref.toLowerCase())) continue;
354
+ const engine = normalizeEngine(task?.executed_by || task?.metadata?.executed_by || task?.metadata?.engine);
355
+ const at = taskActivity(task);
356
+ const common = baseRow({
357
+ member: taskOwner(task),
358
+ task: ref,
359
+ title: task?.title || '',
360
+ engine,
361
+ source: 'task',
362
+ at,
363
+ nowMs,
364
+ });
365
+ const processRow = claimProcess(engine, task?.pid || task?.metadata?.pid, at)
366
+ || claimMatchingProcess(engine, [ref], '');
367
+ if (processRow) working.push({ ...common, engine: processRow.engine, pid: processRow.pid });
368
+ else if (nowMs - timestampMs(at) > staleAfterMs) stale.push({ ...common, reason: 'claim is older than seven days with no live process' });
369
+ else waiting.push({ ...common, reason: 'claim has no live process yet' });
370
+ }
371
+
372
+ const unowned = processes
373
+ .filter((row) => !usedPids.has(row.pid))
374
+ .map((row) => ({
375
+ engine: row.engine,
376
+ pid: row.pid,
377
+ command: row.command,
378
+ started_at: row.started_at,
379
+ age_seconds: ageSeconds(row.started_at, nowMs),
380
+ reason: 'no matching claim, mission, or run receipt',
381
+ }));
382
+
383
+ const newestFirst = (left, right) => timestampMs(right.at || right.started_at) - timestampMs(left.at || left.started_at);
384
+ working.sort(newestFirst);
385
+ waiting.sort(newestFirst);
386
+ done.sort(newestFirst);
387
+ stale.sort(newestFirst);
388
+
389
+ return {
390
+ schema: 'atris.workforce_presence.v1',
391
+ generated_at: new Date(nowMs).toISOString(),
392
+ stale_after_seconds: Math.round(staleAfterMs / 1000),
393
+ totals: {
394
+ working: working.length,
395
+ waiting: waiting.length,
396
+ done: done.length,
397
+ stale: stale.length,
398
+ unowned: unowned.length,
399
+ },
400
+ working,
401
+ waiting,
402
+ done,
403
+ stale,
404
+ unowned,
405
+ };
406
+ }
407
+
408
+ function formatAge(seconds) {
409
+ if (seconds == null) return 'age unknown';
410
+ if (seconds < 60) return `${seconds}s`;
411
+ const minutes = Math.floor(seconds / 60);
412
+ if (minutes < 60) return `${minutes}m`;
413
+ const hours = Math.floor(minutes / 60);
414
+ if (hours < 48) return `${hours}h`;
415
+ return `${Math.floor(hours / 24)}d`;
416
+ }
417
+
418
+ function rowSubject(row) {
419
+ const member = row.member || 'unassigned';
420
+ const task = row.task || row.title || 'local work';
421
+ return `${member}: ${row.engine || 'unknown engine'} on ${task}`;
422
+ }
423
+
424
+ function renderWorkforcePresence(presence) {
425
+ const lines = [];
426
+ const section = (name, rows, render, limit = rows.length) => {
427
+ lines.push(`${name}:`);
428
+ if (!rows.length) lines.push(' none');
429
+ else rows.slice(0, limit).forEach((row) => lines.push(` ${render(row)}`));
430
+ if (rows.length > limit) lines.push(` ${rows.length - limit} more; clear finished runs with atris who --clear`);
431
+ };
432
+ section('working', presence.working, (row) => `${rowSubject(row)} (${formatAge(row.age_seconds)}, pid ${row.pid || '?'})`);
433
+ section('waiting', presence.waiting, (row) => `${rowSubject(row)} (${formatAge(row.age_seconds)}), ${row.reason}`);
434
+ section('done', presence.done, (row) => `${rowSubject(row)} (${formatAge(row.age_seconds)}), ${row.run_status}: ${row.result}`, 10);
435
+ section('stale', presence.stale, (row) => `${rowSubject(row)} (${formatAge(row.age_seconds)}), ${row.reason}`);
436
+ section('unowned', presence.unowned, (row) => `${row.engine} pid ${row.pid} (${formatAge(row.age_seconds)}), ${row.reason}`);
437
+ const totals = presence.totals;
438
+ lines.push(`totals: ${totals.working} working, ${totals.waiting} waiting, ${totals.done} done, ${totals.stale} stale, ${totals.unowned} unowned`);
439
+ return lines.join('\n');
440
+ }
441
+
442
+ module.exports = {
443
+ buildWorkforcePresence,
444
+ isFinishedReceipt,
445
+ parsePsOutput,
446
+ receiptEngine,
447
+ renderWorkforcePresence,
448
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.48.0",
3
+ "version": "3.48.1",
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": {