atris 3.47.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.
@@ -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) {
@@ -1,8 +1,12 @@
1
1
  const { apiRequestJson } = require('../utils/api');
2
2
  const { ensureValidCredentials } = require('../utils/auth');
3
3
  const { spawnSync } = require('child_process');
4
+ const path = require('path');
4
5
  const https = require('https');
5
6
 
7
+ const YTNOTES_USAGE = 'usage: ytnotes <youtube-url> [haiku|atris-fast|gemini|grok|codex|cursor]';
8
+ const YTNOTES_HINT = 'zero credits, local captions + a fast engine';
9
+
6
10
  const DEFAULT_QUERY = [
7
11
  'Create a timestamped YouTube brief for Atris.',
8
12
  'Include: metadata, timestamped outline, core claims with confidence, memorable examples, actionable takeaways, Atris/product implications, and next actions.',
@@ -19,9 +23,11 @@ const ALLOWED_CAPTION_HOST_SUFFIXES = [
19
23
 
20
24
  function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
21
25
  output('');
22
- output(`Usage: ${commandName} process <youtube-url> [options]`);
26
+ output(`Usage: ${commandName} notes <youtube-url> [engine]`);
27
+ output(` ${commandName} process <youtube-url> [options]`);
23
28
  output(` ${commandName} <youtube-url> [options]`);
24
29
  output('');
30
+ output('notes = free local notes, process = 5 credits cloud knowledge');
25
31
  output('Process a YouTube video through Atris using timestamped transcript-first analysis.');
26
32
  output('Falls back to cloud video processing when local captions are unavailable.');
27
33
  output('');
@@ -37,6 +43,7 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
37
43
  output(' metadata -> timestamped outline -> claims -> examples -> takeaways -> Atris implications -> next actions');
38
44
  output('');
39
45
  output('Examples:');
46
+ output(` ${commandName} notes https://www.youtube.com/watch?v=VIDEO_ID`);
40
47
  output(` ${commandName} https://www.youtube.com/watch?v=VIDEO_ID`);
41
48
  output(` ${commandName} process https://youtu.be/VIDEO_ID --query "Key takeaways"`);
42
49
  output('');
@@ -437,8 +444,31 @@ function formatYoutubeResult(data) {
437
444
  return lines.join('\n');
438
445
  }
439
446
 
447
+ function runYoutubeNotes(args = [], deps = {}) {
448
+ const output = deps.output || ((line = '') => console.error(line));
449
+ const url = args[0];
450
+ const engine = args[1];
451
+ if (!url) {
452
+ output(YTNOTES_USAGE);
453
+ output(YTNOTES_HINT);
454
+ return 2;
455
+ }
456
+
457
+ const script = path.join(__dirname, '..', 'scripts', 'det', 'ytnotes');
458
+ const spawn = deps.spawnSync || spawnSync;
459
+ const childArgs = engine ? [url, engine] : [url];
460
+ const result = spawn(script, childArgs, { stdio: 'inherit' });
461
+ if (result.status == null) return 1;
462
+ return result.status;
463
+ }
464
+
440
465
  async function youtubeCommand(argv = process.argv.slice(3), deps = {}) {
441
466
  const output = deps.output || ((line = '') => console.log(line));
467
+ if (argv[0] === 'notes') {
468
+ const code = runYoutubeNotes(argv.slice(1), deps);
469
+ if (!deps.output && !deps.spawnSync) process.exit(code);
470
+ return code;
471
+ }
442
472
  const options = parseYoutubeArgs(argv);
443
473
  if (options.help) {
444
474
  showYoutubeHelp(output, deps.commandName || 'atris youtube');
package/lib/engine-ask.js CHANGED
@@ -9,7 +9,11 @@ const {
9
9
  DEFAULT_CLAUDE_RUNNER_MODEL,
10
10
  RUNNER_PROFILE_DEFS,
11
11
  } = require('./runner-command');
12
- const { canonicalEngineName } = require('./engine-registry');
12
+ const {
13
+ canonicalEngineName,
14
+ engineFailureHealthStatus,
15
+ setEngineHealth,
16
+ } = require('./engine-registry');
13
17
  const {
14
18
  appendEngineLiveLogChunk,
15
19
  createEngineLiveLog,
@@ -452,6 +456,15 @@ function answerStatus(answer) {
452
456
  return engineTerminalStatus(answer);
453
457
  }
454
458
 
459
+ function recordEngineAskHealth(answers, root) {
460
+ for (const answer of answers) {
461
+ const status = answer.ok
462
+ ? 'ready'
463
+ : engineFailureHealthStatus({ ...answer, status: 'errored' });
464
+ if (status) setEngineHealth(answer.engine, status, root);
465
+ }
466
+ }
467
+
455
468
  function engineAskReceipt(answers, { concurrency, timeoutMs, at = new Date().toISOString() }) {
456
469
  const receiptAnswers = answers.map((answer) => ({ ...answer, status: answerStatus(answer) }));
457
470
  const answered = receiptAnswers.filter((answer) => answer.status === 'answered').length;
@@ -582,6 +595,7 @@ async function runEngineAskCommand(args, root = process.cwd(), deps = {}) {
582
595
  signal: abort.signal,
583
596
  onOutputChunk: (chunk, stream) => appendLiveLog(liveLogPath, chunk, stream),
584
597
  });
598
+ recordEngineAskHealth(answers, root);
585
599
  const receipt = engineAskReceipt(answers, {
586
600
  concurrency: parsed.concurrency,
587
601
  timeoutMs: parsed.timeoutMs,
@@ -16,6 +16,28 @@ const ENGINE_ROLES = Object.freeze(['navigator', 'executor', 'validator']);
16
16
  const ENGINE_DUTIES = Object.freeze(['leader', 'errands', 'learning']);
17
17
  const ENGINE_HEALTH_STATUSES = Object.freeze(['ready', 'not_installed', 'credit_out', 'error']);
18
18
 
19
+ function engineFailureHealthStatus(result) {
20
+ if (!result || result.status !== 'errored') return null;
21
+ const signalText = [
22
+ result.reason,
23
+ result.model_unavailable,
24
+ result.report,
25
+ result.stdout,
26
+ result.stderr,
27
+ result.error,
28
+ result.claude && result.claude.summary,
29
+ result.claude && result.claude.receipt_text,
30
+ result.claude && result.claude.stderr,
31
+ result.rate_limit_info && JSON.stringify(result.rate_limit_info),
32
+ ].filter(Boolean).join('\n').toLowerCase();
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
+ return 'credit_out';
35
+ }
36
+ if (/not installed|command not found|\benoent\b/.test(signalText)) return 'not_installed';
37
+ if (/timeout|model-unavailable/.test(signalText)) return 'not_installed';
38
+ return 'error';
39
+ }
40
+
19
41
  const ENGINE_SEED_META = Object.freeze({
20
42
  'atris-fast': Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator']), models: Object.freeze(['atris fast']), duty: 'learning', fallback_order: 10 }),
21
43
  codex: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), models: Object.freeze(['codex']), fallback_order: 10 }),
@@ -356,6 +378,7 @@ module.exports = {
356
378
  resolveEngineForRoleRanked,
357
379
  resolveEngineForRole,
358
380
  resolveEngineForRoleWithPreference,
381
+ engineFailureHealthStatus,
359
382
  setEngineOverrides,
360
383
  setEngineHealth,
361
384
  };
package/lib/fleet.js CHANGED
@@ -23,6 +23,7 @@ const {
23
23
  worktreeBaseRef,
24
24
  } = require('./brief-ledger');
25
25
  const { RUNNER_PROFILE_DEFS, buildRunnerCommand } = require('./runner-command');
26
+ const { engineFailureHealthStatus, setEngineHealth } = require('./engine-registry');
26
27
  const { resolveDefaultVerifier } = require('./default-verifier');
27
28
  const { rankEnginesDetailed } = require('./router-brain');
28
29
  const {
@@ -207,7 +208,7 @@ function buildEngineCommand(engineName, promptFile, { yolo = false, sealed = fal
207
208
  if (sealed && engineName === 'codex') {
208
209
  cmd = cmd.replace(/\bexec\b/, 'exec --sandbox workspace-write --ephemeral --ignore-user-config --ignore-rules');
209
210
  }
210
- if (sealed && engineName === 'claude') {
211
+ if (sealed && (engineName === 'claude' || engineName === 'fable')) {
211
212
  cmd = `${cmd} --safe-mode --no-session-persistence --permission-mode acceptEdits --settings '${JSON.stringify({ sandbox: { enabled: true, autoAllowBashIfSandboxed: true } })}'`;
212
213
  }
213
214
  if (sealed && engineName === 'cursor') cmd = `${cmd} --sandbox enabled`;
@@ -217,7 +218,7 @@ function buildEngineCommand(engineName, promptFile, { yolo = false, sealed = fal
217
218
  }
218
219
  if (engineName === 'devin') return cmd.replace(/^devin -p /, 'devin -p --permission-mode dangerous ');
219
220
  if (yolo && engineName === 'codex') cmd = cmd.replace(/\bexec\b/, `exec ${YOLO_ENGINE_FLAGS.codex}`);
220
- if (yolo && engineName === 'claude') cmd = `${cmd} ${YOLO_ENGINE_FLAGS.claude}`;
221
+ if (yolo && (engineName === 'claude' || engineName === 'fable')) cmd = `${cmd} ${YOLO_ENGINE_FLAGS.claude}`;
221
222
  if (engineName === 'codex') cmd = wrapCodexWithWatchdog(cmd, watchdogPath, watchdogReceiptPath);
222
223
  return cmd;
223
224
  } finally {
@@ -560,6 +561,37 @@ function detectDeadEngineDispatch(result) {
560
561
  return { reason: 'nonzero_exit', exitCode };
561
562
  }
562
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
+
583
+ function recordDispatchEngineHealth(result, failure, root) {
584
+ if (!result || !result.engine) return null;
585
+ const status = failure
586
+ ? engineFailureHealthStatus({
587
+ ...result,
588
+ status: 'errored',
589
+ reason: [result.reason, failure.reason].filter(Boolean).join('\n'),
590
+ })
591
+ : 'ready';
592
+ return status ? setEngineHealth(result.engine, status, root) : null;
593
+ }
594
+
563
595
  function normalizeInstalledEngines(engines) {
564
596
  return [...new Set((engines || [])
565
597
  .map((entry) => (typeof entry === 'string' ? entry : entry && entry.name))
@@ -672,6 +704,7 @@ async function dispatchEntryWithRestaff({
672
704
 
673
705
  const first = await runOnce(engine);
674
706
  const deadEngine = detectDeadEngineDispatch(first);
707
+ recordDispatchEngineHealth(first, deadEngine, root);
675
708
  if (!deadEngine) return first;
676
709
  stampDispatchBrief(root, first.brief_id, 'fail', `restaffed from ${engine}: ${deadEngine.reason}`);
677
710
 
@@ -687,6 +720,7 @@ async function dispatchEntryWithRestaff({
687
720
 
688
721
  restaffState.used = true;
689
722
  const fallbackResult = await runOnce(fallback);
723
+ recordDispatchEngineHealth(fallbackResult, detectDeadEngineDispatch(fallbackResult), root);
690
724
  return {
691
725
  ...fallbackResult,
692
726
  restaffed: {
@@ -1066,6 +1100,7 @@ module.exports = {
1066
1100
  shipWithRetry,
1067
1101
  shipFailureDetail,
1068
1102
  get FLEET_CAPABLE() { return FLEET_CAPABLE; },
1103
+ get DISPATCH_CAPABLE() { return DISPATCH_CAPABLE; },
1069
1104
  get runFleetFlight() { return runFleetFlight; },
1070
1105
  get focusedCheck() { return focusedCheck; },
1071
1106
  get dispatchCheck() { return dispatchCheck; },
@@ -1107,6 +1142,7 @@ module.exports = {
1107
1142
  // Engines that can edit a repo headlessly. atris-fast (ax) is a chat lane,
1108
1143
  // not a repo worker — it keeps owning normal mission ticks, not fleet builds.
1109
1144
  const FLEET_CAPABLE = ['claude', 'codex', 'cursor', 'devin', 'grok'];
1145
+ const DISPATCH_CAPABLE = [...FLEET_CAPABLE, 'fable'];
1110
1146
 
1111
1147
  let receiptSequence = 0;
1112
1148
  function nowStamp() {
@@ -2643,6 +2679,18 @@ async function runFleetFlight({
2643
2679
  // ---------------------------------------------------------------------------
2644
2680
  // T5 — one-command dispatch: `atris engine dispatch <task-id> --engine <name>`
2645
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
+
2646
2694
  // The manual version of this loop took 6 Bash calls per task the night this
2647
2695
  // was written: claim, worktree start, prompt file, engine -p, verify, ship.
2648
2696
  // One or more explicit task ids build in parallel isolated worktrees on ONE
@@ -2678,8 +2726,8 @@ async function runDispatchFlight({
2678
2726
  scoutAsk = null,
2679
2727
  } = {}) {
2680
2728
  if (!engine) throw new Error('runDispatchFlight: engine is required');
2681
- if (!FLEET_CAPABLE.includes(engine)) {
2682
- 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(', ')})`);
2683
2731
  }
2684
2732
  const ids = [...new Set((taskIds || []).map((id) => String(id).trim()).filter(Boolean))];
2685
2733
  if (!ids.length) throw new Error('runDispatchFlight: at least one task id is required');
@@ -2745,51 +2793,71 @@ async function runDispatchFlight({
2745
2793
  log(` x ${taskId} claim failed`);
2746
2794
  continue;
2747
2795
  }
2748
- let landingWorktreePath = '';
2749
- let remoteBoundary = null;
2750
- if (enforceRemoteBoundary) {
2751
- remoteBoundary = prepareReviewSandbox({ root, taskId, engine });
2752
- } else {
2753
- const started = cli(['worktree', 'start', '--agent', engine, '--task', `dispatch-${taskId.toLowerCase()}`, ...startBaseArgs]);
2754
- const wt = (started.stdout.match(/next: cd (.+)/) || [])[1];
2755
- if (!wt) {
2756
- flight.paused.push({ task: taskId, stage: 'worktree_start', detail: String(started.stderr || '').slice(0, 200) });
2757
- 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`);
2758
2829
  continue;
2759
2830
  }
2760
- landingWorktreePath = wt.trim();
2761
- }
2762
- if (enforceRemoteBoundary && (!remoteBoundary || remoteBoundary.ok !== true)) {
2763
- flight.paused.push({
2764
- task: taskId,
2765
- stage: 'remote_quarantine',
2766
- detail: String(remoteBoundary && remoteBoundary.detail || 'could not prepare a sealed review sandbox').slice(-500),
2767
- 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 } : {}),
2768
2850
  });
2769
- log(` paused ${taskId} because its sealed review sandbox could not be prepared`);
2770
- 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`);
2771
2860
  }
2772
- const worktreePath = enforceRemoteBoundary ? remoteBoundary.worktreePath : landingWorktreePath;
2773
- const startCommit = yolo ? readStartCommit({ worktreePath }) : '';
2774
- const basePrompt = promptOverride || buildFleetPrompt(task, { worktreePath, yolo });
2775
- const safetyPrompt = reviewOnly
2776
- ? `${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.`
2777
- : basePrompt;
2778
- const trustedPrompt = trustedVerifier
2779
- ? `${safetyPrompt}\n\nTrusted verifier (run it bare before reporting done): ${trustedVerifier}`
2780
- : promptOverride;
2781
- prepared.push({
2782
- task,
2783
- taskId,
2784
- worktreePath,
2785
- landingWorktreePath,
2786
- engine,
2787
- remoteBoundary,
2788
- remoteMasterBefore: remoteBoundary ? remoteBoundary.protectedMaster : '',
2789
- startCommit,
2790
- ...(trustedPrompt ? { prompt: trustedPrompt } : {}),
2791
- });
2792
- log(` building ${taskId} in ${path.basename(worktreePath)}`);
2793
2861
  }
2794
2862
 
2795
2863
  const dispatch = dispatcher || ((entry) => new Promise((resolve) => {
@@ -2813,6 +2881,10 @@ async function runDispatchFlight({
2813
2881
  }));
2814
2882
  const restaffState = { used: false };
2815
2883
 
2884
+ if (engine === 'fable' && prepared.length) {
2885
+ log(` fable handoff started: receipt ${path.relative(root, receiptPath)}`);
2886
+ }
2887
+
2816
2888
  const results = await Promise.all(prepared.map((entry) => {
2817
2889
  const startedAtMs = Date.now();
2818
2890
  return dispatchEntryWithRestaff({
@@ -3328,6 +3400,12 @@ async function runDispatchFlight({
3328
3400
  flight.finished_at = new Date().toISOString();
3329
3401
  writeDispatchReceipt(flight, receiptPath, { ids, reviewOnly, enforceRemoteBoundary });
3330
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
+ }
3331
3409
  const completedLabel = reviewOnly ? `${flight.ready.length} proof ready` : `${flight.landed.length} landed`;
3332
3410
  log(` dispatch over: ${completedLabel}, ${flight.paused.length} paused - receipt: ${path.relative(root, flight.receipt)}`);
3333
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
  }