atris 3.56.0 → 3.56.2

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 (45) hide show
  1. package/atris/skills/engines/SKILL.md +11 -3
  2. package/atris/skills/x-search/SKILL.md +20 -1
  3. package/atris/skills/youtube/SKILL.md +51 -43
  4. package/bin/atris.js +134 -90
  5. package/commands/auth.js +6 -1
  6. package/commands/autopilot-front.js +29 -13
  7. package/commands/brainstorm.js +77 -476
  8. package/commands/business.js +13 -1
  9. package/commands/engine.js +7 -0
  10. package/commands/experiments.js +178 -0
  11. package/commands/fleet-report.js +2 -2
  12. package/commands/founder.js +12 -0
  13. package/commands/human-missions.js +64 -2
  14. package/commands/init.js +2 -35
  15. package/commands/integrations.js +100 -0
  16. package/commands/land.js +53 -23
  17. package/commands/later.js +52 -0
  18. package/commands/launchpad.js +45 -10
  19. package/commands/log.js +79 -30
  20. package/commands/mission.js +117 -22
  21. package/commands/next.js +153 -63
  22. package/commands/now.js +115 -38
  23. package/commands/recap.js +97 -4
  24. package/commands/run-front.js +20 -5
  25. package/commands/spaceship.js +52 -12
  26. package/commands/status.js +38 -7
  27. package/commands/task.js +56 -19
  28. package/commands/terminal.js +5 -5
  29. package/commands/workflow.js +19 -5
  30. package/commands/x-search.js +123 -13
  31. package/commands/youtube.js +941 -36
  32. package/lib/account-bound.js +7 -0
  33. package/lib/apply-gate.js +102 -0
  34. package/lib/config-guard.js +106 -0
  35. package/lib/context-gatherer.js +55 -8
  36. package/lib/engine-ask.js +16 -6
  37. package/lib/engine-registry.js +14 -4
  38. package/lib/first-minute.js +571 -26
  39. package/lib/known-commands.js +1 -1
  40. package/lib/pack-capabilities.js +13 -3
  41. package/lib/runner-command.js +5 -3
  42. package/lib/scratch-root.js +44 -0
  43. package/lib/workspace-scaffold.js +3 -1
  44. package/package.json +1 -1
  45. package/utils/auth.js +84 -6
@@ -13,17 +13,42 @@
13
13
  * atris spaceship --hours 4 --repo /path/to/repo --interval 780 --yes
14
14
  * atris spaceship --hours 0.01 --tick-cmd /tmp/stub.sh --no-email --yes
15
15
  *
16
- * Without --yes, spaceship only prints the plan (no email, no overnight run).
16
+ * Without --yes, spaceship only talks the keep-working plan (no email, no run).
17
17
  * `--json` without --yes prints a JSON refuse and still does not start.
18
+ * `--yes` from an unbound scratch folder refuses: that folder is not a room.
18
19
  */
19
20
 
20
21
  const path = require('path');
21
22
  const fs = require('fs');
22
23
  const { spawn } = require('child_process');
23
24
  const { hasYesFlag, argsWantHelp, wantsJson } = require('../lib/noninteractive');
25
+ const { isUnboundScratchFolder, refuseUnboundScratch } = require('../lib/scratch-root');
26
+ const { resolveWorkspaceRoot } = require('../lib/mission-root');
27
+ const { personName } = require('../lib/first-minute');
24
28
 
25
29
  const SCRIPT = path.join(__dirname, '..', 'scripts', 'spaceship.sh');
26
30
 
31
+ function greet(person) {
32
+ return person ? `hey ${person}, ` : '';
33
+ }
34
+
35
+ function readFlagValue(args, name) {
36
+ const list = Array.isArray(args) ? args : [];
37
+ const idx = list.indexOf(name);
38
+ const raw = idx !== -1 && list[idx + 1] && !String(list[idx + 1]).startsWith('-')
39
+ ? String(list[idx + 1]).trim()
40
+ : '';
41
+ return raw;
42
+ }
43
+
44
+ function spokenHours(raw) {
45
+ const text = raw === undefined || raw === null ? '4' : String(raw).trim();
46
+ const n = Number(text);
47
+ if (!Number.isFinite(n) || n <= 0) return '4 hours';
48
+ if (n === 1) return `${text} hour`;
49
+ return `${text} hours`;
50
+ }
51
+
27
52
  function printUsage() {
28
53
  const lines = [
29
54
  'Usage: atris spaceship [--hours N] [--interval SEC] [--repo PATH]',
@@ -31,25 +56,34 @@ function printUsage() {
31
56
  ' [--idle-alert N] [--halt-alert N] [--label NAME]',
32
57
  ' [--no-email] [--yes]',
33
58
  '',
34
- 'Bounded overnight runner. Survives bad ticks and emails on state changes.',
35
- 'Without --yes, prints the plan only (no email, no overnight run).',
36
- 'Defaults: --hours 4, --interval 780, tick = atris autopilot --auto --iterations=1',
59
+ "Keep working here for a few hours. I'll write you if something changes.",
60
+ 'Without --yes, I only say the plan. --no-email stays quiet.',
37
61
  ];
38
62
  console.log(lines.join('\n'));
39
63
  }
40
64
 
41
65
  function planLines(args = []) {
42
- const hoursIdx = args.indexOf('--hours');
43
- const hours = hoursIdx !== -1 && args[hoursIdx + 1] ? args[hoursIdx + 1] : '4';
66
+ const list = Array.isArray(args) ? args : [];
67
+ const hours = spokenHours(readFlagValue(list, '--hours') || '4');
68
+ const next = list.includes('--no-email')
69
+ ? 'next: atris spaceship --yes'
70
+ : "I'll write you if something changes. next: atris spaceship --yes";
44
71
  return [
45
- 'spaceship plan (no run):',
46
- ` budget: ${hours}h`,
47
- ' email: on meaningful state changes (unless --no-email)',
48
- ' tick: atris autopilot --auto --iterations=1',
49
- 'Pass --yes to start the overnight run.',
72
+ `${greet(personName())}I can keep working here for ${hours}.`,
73
+ '',
74
+ next,
50
75
  ];
51
76
  }
52
77
 
78
+ function spaceshipTargetRoot(args = []) {
79
+ const list = Array.isArray(args) ? args : [];
80
+ const idx = list.indexOf('--repo');
81
+ const raw = idx !== -1 && list[idx + 1] && !String(list[idx + 1]).startsWith('-')
82
+ ? path.resolve(list[idx + 1])
83
+ : process.cwd();
84
+ return resolveWorkspaceRoot(raw);
85
+ }
86
+
53
87
  function spaceship(args = []) {
54
88
  const list = Array.isArray(args) ? args : [];
55
89
  if (argsWantHelp(list) || list.includes('--help') || list.includes('-h')) {
@@ -75,6 +109,12 @@ function spaceship(args = []) {
75
109
  process.exit(2);
76
110
  }
77
111
 
112
+ // --yes starts the run. It is not a workspace unlock. An unbound
113
+ // scratch folder is not a room (same class as slack/gmail from scratch).
114
+ if (isUnboundScratchFolder(spaceshipTargetRoot(list))) {
115
+ process.exit(refuseUnboundScratch());
116
+ }
117
+
78
118
  const runArgs = list.filter((a) => a !== '--yes' && a !== '-y' && a !== '--json');
79
119
  return new Promise((resolve, reject) => {
80
120
  if (!fs.existsSync(SCRIPT)) {
@@ -99,4 +139,4 @@ function spaceship(args = []) {
99
139
  });
100
140
  }
101
141
 
102
- module.exports = { spaceship, SCRIPT, printUsage, planLines };
142
+ module.exports = { spaceship, SCRIPT, printUsage, planLines, spokenHours, spaceshipTargetRoot };
@@ -4,6 +4,15 @@ const { getLogPath, ensureLogDirectory, createLogFile } = require('../lib/journa
4
4
  const { parseTodo, getTeamActivity } = require('../lib/todo');
5
5
  const { clarify } = require('../lib/autoland');
6
6
  const { checkoutBehindMessage } = require('../lib/checkout-sync');
7
+ const {
8
+ isFreshWorkspace,
9
+ isKeepWorkingMinute,
10
+ isClaimMinute,
11
+ buildFirstMinute,
12
+ speakFirstMinute,
13
+ speakKeepWorkingMinute,
14
+ speakNothingRunning,
15
+ } = require('../lib/first-minute');
7
16
 
8
17
  // Box drawing helpers
9
18
  const W = 64; // inner width
@@ -122,18 +131,40 @@ function parseStatusTodo(todoFile) {
122
131
  }
123
132
  }
124
133
 
134
+ function hasLiveKeepWorkingRun(root = process.cwd()) {
135
+ try {
136
+ const { pickLiveLocalMission } = require('./mission');
137
+ return Boolean(pickLiveLocalMission(root));
138
+ } catch {
139
+ return false;
140
+ }
141
+ }
142
+
125
143
  function statusAtris(isQuick = false, jsonMode = false, verbose = false) {
126
- const targetDir = path.join(process.cwd(), 'atris');
144
+ // Fresh folder: empty talks first-talk. A file already here
145
+ // names that file, same as bare atris. Do not mint a room.
146
+ if (isFreshWorkspace()) {
147
+ process.exit(speakNothingRunning({ asJson: jsonMode }));
148
+ }
127
149
 
128
- if (!fs.existsSync(targetDir)) {
129
- if (jsonMode) {
130
- console.log(JSON.stringify({ error: 'atris/ folder not found' }));
131
- process.exit(1);
150
+ // Just-minted file folder, nothing running: same two lines as
151
+ // first-minute / the next keep-working ready. After init, next is claim: same
152
+ // two lines as bare atris / now. Not factory let-it-run.
153
+ // A live mission still gets the board. --verbose keeps the
154
+ // factory dump because the operator asked for it. --json on
155
+ // the claim path keeps the factory board for scripts.
156
+ if (!verbose && !hasLiveKeepWorkingRun()) {
157
+ const minute = buildFirstMinute({ root: process.cwd() });
158
+ if (isKeepWorkingMinute(minute)) {
159
+ process.exit(speakKeepWorkingMinute({ asJson: jsonMode }));
160
+ }
161
+ if (!jsonMode && isClaimMinute(minute)) {
162
+ process.exit(speakFirstMinute());
132
163
  }
133
- console.log('✗ atris/ folder not found. Run "atris init" first.');
134
- process.exit(1);
135
164
  }
136
165
 
166
+ const targetDir = path.join(process.cwd(), 'atris');
167
+
137
168
  // Load task board state.
138
169
  const todoFile = path.join(targetDir, 'TODO.md');
139
170
  const todo = parseStatusTodo(todoFile);
package/commands/task.js CHANGED
@@ -60,7 +60,18 @@ const {
60
60
  decisionMarkerFor,
61
61
  DECISION_REFUSE_REASON,
62
62
  } = require('../lib/task-decision');
63
- const { buildFirstMinute, deskNextCommand, personName, pickNext, speakFirstMinute, taskCommand, taskNextCommand } = require('../lib/first-minute');
63
+ const {
64
+ buildFirstMinute,
65
+ deskNextCommand,
66
+ firstTalkCommand,
67
+ folderName,
68
+ personName,
69
+ pickNext,
70
+ speakFirstMinute,
71
+ taskCommand,
72
+ taskNextCommand,
73
+ } = require('../lib/first-minute');
74
+ const { loadContext } = require('../lib/state-detection');
64
75
 
65
76
  const DEFAULT_OWNER = process.env.ATRIS_AGENT_ID
66
77
  || process.env.USER
@@ -6172,9 +6183,9 @@ function cmdAdd(args) {
6172
6183
  if (isUninitializedTaskFolder(root)) {
6173
6184
  if (wantsJson(args)) {
6174
6185
  printJson({
6175
- ok: true,
6176
- action: 'init',
6177
- command: 'atris init --minimal',
6186
+ ok: false,
6187
+ action: 'none',
6188
+ command: firstTalkCommand(folderName(root)),
6178
6189
  task_id: null,
6179
6190
  projection_path: null,
6180
6191
  task: null,
@@ -6493,9 +6504,19 @@ function cmdDay(args) {
6493
6504
 
6494
6505
  function cmdFirstMinute() {
6495
6506
  const root = process.cwd();
6507
+ const fresh = !fs.existsSync(path.join(root, 'atris'));
6508
+ let context = {};
6509
+ if (!fresh) {
6510
+ try {
6511
+ context = loadContext(root);
6512
+ } catch {
6513
+ context = {};
6514
+ }
6515
+ }
6496
6516
  const screen = buildFirstMinute({
6497
6517
  root,
6498
- fresh: !fs.existsSync(path.join(root, 'atris')),
6518
+ fresh,
6519
+ context,
6499
6520
  });
6500
6521
  console.log(screen.text);
6501
6522
  }
@@ -6826,10 +6847,12 @@ function createEndgameSeedTask(taskDb, db, seed, owner) {
6826
6847
  }
6827
6848
 
6828
6849
  function nextActionFromCommand(command) {
6829
- const match = String(command || '').trim().match(/^atris (?:task|mission) (\S+)/);
6850
+ const text = String(command || '').trim();
6851
+ const match = text.match(/^atris (?:task|mission) (\S+)/);
6830
6852
  const verb = match ? match[1] : '';
6831
- if (!verb || verb === 'new') return 'none';
6832
- return verb;
6853
+ if (verb && verb !== 'new') return verb;
6854
+ if (/^atris do\b/.test(text)) return 'do';
6855
+ return 'none';
6833
6856
  }
6834
6857
 
6835
6858
  function cmdNextTruth(args) {
@@ -6840,9 +6863,24 @@ function cmdNextTruth(args) {
6840
6863
  const workspaceRoot = taskDb.workspaceRoot();
6841
6864
  const rows = taskDb.listTasks(db, { workspaceRoot, limit: 500 });
6842
6865
  const existingProj = readProjectionFile(workspaceRoot);
6866
+ const dbHasActionable = rows.some((task) => {
6867
+ const status = task && task.status;
6868
+ return status === 'open' || status === 'claimed' || status === 'review';
6869
+ });
6870
+ const projHasActionable = Boolean(
6871
+ existingProj
6872
+ && Array.isArray(existingProj.tasks)
6873
+ && existingProj.tasks.some((task) => {
6874
+ const status = task && task.status;
6875
+ return status === 'open' || status === 'claimed' || status === 'review';
6876
+ }),
6877
+ );
6843
6878
  let projection;
6844
6879
  let outPath;
6845
- if (rows.length === 0 && existingProj && Array.isArray(existingProj.tasks) && existingProj.tasks.length > 0) {
6880
+ if (!dbHasActionable && projHasActionable) {
6881
+ projection = existingProj;
6882
+ outPath = path.resolve(path.join(workspaceRoot || '.', '.atris', 'state', 'tasks.projection.json'));
6883
+ } else if (rows.length === 0 && existingProj && Array.isArray(existingProj.tasks) && existingProj.tasks.length > 0) {
6846
6884
  projection = existingProj;
6847
6885
  outPath = path.resolve(path.join(workspaceRoot || '.', '.atris', 'state', 'tasks.projection.json'));
6848
6886
  } else {
@@ -6911,9 +6949,9 @@ function cmdNext(args) {
6911
6949
  if (isUninitializedTaskFolder(root)) {
6912
6950
  if (wantsJson(args)) {
6913
6951
  printJson({
6914
- ok: true,
6915
- action: 'init',
6916
- command: 'atris init --minimal',
6952
+ ok: false,
6953
+ action: 'none',
6954
+ command: firstTalkCommand(folderName(root)),
6917
6955
  task_id: null,
6918
6956
  owner: String(flag(args, '--as') || personName() || DEFAULT_OWNER),
6919
6957
  scope: normalizeTaskQueueScope(taskQueueScopeFromArgs(args)),
@@ -6922,9 +6960,7 @@ function cmdNext(args) {
6922
6960
  });
6923
6961
  return;
6924
6962
  }
6925
- const screen = buildFirstMinute({ root, fresh: true });
6926
- console.log(screen.text);
6927
- return;
6963
+ return speakFirstMinute({ root, fresh: true });
6928
6964
  }
6929
6965
  if (!hasFlag(args, '--create-next')) return cmdNextTruth(args);
6930
6966
  const owner = flag(args, '--as') || DEFAULT_OWNER;
@@ -9796,16 +9832,17 @@ function appendRelabelArchivedJournalReceipt(workspaceRoot, { actor, count, ids
9796
9832
  function cmdReady(args) {
9797
9833
  const pos = positional(args);
9798
9834
  const id = pos[0];
9799
- if (!id) {
9800
- console.error('atris task ready: id required');
9835
+ const proofFlag = flag(args, '--proof');
9836
+ const verifyFlag = flag(args, '--verify');
9837
+ const resultFlag = textFlag(args, ['--result']);
9838
+ if (!id || (!proofFlag && !verifyFlag && !resultFlag)) {
9839
+ console.log('Usage: atris task ready <id> --proof "..." --result "<sentence>"');
9801
9840
  process.exit(2);
9802
9841
  }
9803
9842
  // Two ways to prove: --proof "<note>" (claimed, pattern-checked) or
9804
9843
  // --verify "<command>" which actually RUNS the command and gates ready on exit 0.
9805
9844
  // If --proof names npm test, node --test, or git diff --check, this process
9806
9845
  // must have run that command. A sentence that names one of those is a lie.
9807
- const proofFlag = flag(args, '--proof');
9808
- const verifyFlag = flag(args, '--verify');
9809
9846
  const proofUrl = textFlag(args, ['--proof-url']);
9810
9847
  const iFetched = hasFlag(args, '--i-fetched');
9811
9848
  if (proofUrl && !iFetched) {
@@ -2,7 +2,7 @@
2
2
  * atris terminal <business> <command...> [--timeout N]
3
3
  *
4
4
  * Run a shell command directly on a business EC2 workspace via the warm runner.
5
- * This is the load-bearing primitive for fast bulk ops one bash call beats
5
+ * This is the load-bearing primitive for fast bulk ops: one bash call beats
6
6
  * hundreds of rate-limited individual file API calls.
7
7
  *
8
8
  * SAFETY:
@@ -27,7 +27,7 @@ const path = require('path');
27
27
  const { loadCredentials, abortOnAuthFailure } = require('../utils/auth');
28
28
  const { apiRequestJson } = require('../utils/api');
29
29
  const { loadBusinesses, saveBusinesses } = require('./business');
30
- const { requireAccountBound, refuseAccountGlobal } = require('../lib/account-bound');
30
+ const { looksLikeBusinessSlug, requireAccountBound, refuseAccountGlobal } = require('../lib/account-bound');
31
31
  const { argsWantHelp, wantsJson } = require('../lib/noninteractive');
32
32
 
33
33
  function sleep(ms) {
@@ -151,8 +151,8 @@ async function terminalAtris() {
151
151
  try { return JSON.parse(fs.readFileSync(bizFile, 'utf8')).slug; } catch { return null; }
152
152
  })();
153
153
 
154
- // If first arg is a single word with no shell metacharacters, it might be a slug
155
- const firstLooksLikeSlug = args[0] && /^[a-z0-9-]+$/i.test(args[0]) && !args[0].includes(' ');
154
+ // Flags like --json match a hyphenated word regex. Require a real slug first.
155
+ const firstLooksLikeSlug = looksLikeBusinessSlug(args[0]);
156
156
 
157
157
  if (firstLooksLikeSlug && args.length > 1) {
158
158
  slug = args[0];
@@ -161,7 +161,7 @@ async function terminalAtris() {
161
161
  slug = cwdSlug;
162
162
  command = args.join(' ');
163
163
  } else if (firstLooksLikeSlug && args.length === 1) {
164
- // First (and only) arg is a slug-shaped word — could be the slug with no command
164
+ // First and only arg is a slug-shaped word with no command.
165
165
  console.error('Missing command. Usage: atris terminal <business> <command>');
166
166
  process.exit(1);
167
167
  } else {
@@ -6,13 +6,16 @@ const {
6
6
  folderName,
7
7
  freshMinuteJson,
8
8
  isCertifiedReview,
9
+ listUserVisibleWork,
9
10
  isFreshWorkspace,
10
11
  personName,
11
12
  pickNext,
12
13
  renderWorkspace,
13
14
  speakFirstMinute,
14
15
  taskCommand,
16
+ visibleWorkTitle,
15
17
  } = require('../lib/first-minute');
18
+ const { startFirstTalk } = require('../lib/context-gatherer');
16
19
  const { isNonInteractive } = require('../lib/noninteractive');
17
20
  const { loadContext } = require('../lib/state-detection');
18
21
  const { buildToolResultBody } = require('../lib/tool-result-encode');
@@ -64,7 +67,8 @@ function reviewSoftTitle(title, maxWords = 5) {
64
67
  }
65
68
 
66
69
  function isHumanDeskNext(command) {
67
- return /^atris task (?:claim|ready|accept)\b/.test(String(command || ''));
70
+ const text = String(command || '');
71
+ return /^atris do\b/.test(text) || /^atris task (?:claim|show|ready|accept)\b/.test(text);
68
72
  }
69
73
 
70
74
  function loadReviewTasks(root = process.cwd()) {
@@ -506,7 +510,7 @@ async function planAtris(userInput = null) {
506
510
  // init --minimal is optional context, not a factory bounce.
507
511
  if (!fs.existsSync(targetDir)) {
508
512
  if (args.includes('--json')) {
509
- console.log(JSON.stringify(freshMinuteJson(), null, 2));
513
+ console.log(JSON.stringify(freshMinuteJson(folderName(cwd), listUserVisibleWork(cwd), { root: cwd }), null, 2));
510
514
  process.exit(2);
511
515
  }
512
516
  const screen = buildFirstMinute({ root: cwd, fresh: true });
@@ -877,11 +881,21 @@ async function doAtris() {
877
881
  const cwd = process.cwd();
878
882
  const targetDir = path.join(cwd, 'atris');
879
883
 
880
- // Empty folder talks like bare atris. Missing executor.md after
881
- // init --minimal is optional context, not a factory bounce.
884
+ // Empty folder talks like bare atris. Files already here start
885
+ // first-talk, then next is atris do. After that work is yours,
886
+ // next is task ready so keep-working is not a do loop. Missing
887
+ // executor.md after init --minimal is optional context, not a
888
+ // factory bounce.
882
889
  if (!fs.existsSync(targetDir)) {
890
+ const visible = listUserVisibleWork(cwd);
891
+ if (visible.length) {
892
+ const title = visibleWorkTitle(visible, folderName(cwd));
893
+ const code = startFirstTalk(cwd, title, { asJson: args.includes('--json') });
894
+ if (code !== 0) process.exit(code);
895
+ return;
896
+ }
883
897
  if (args.includes('--json')) {
884
- console.log(JSON.stringify(freshMinuteJson(), null, 2));
898
+ console.log(JSON.stringify(freshMinuteJson(folderName(cwd), visible, { root: cwd }), null, 2));
885
899
  process.exit(2);
886
900
  }
887
901
  const screen = buildFirstMinute({ root: cwd, fresh: true });
@@ -2,9 +2,12 @@
2
2
 
3
3
  const { apiRequestJson } = require('../utils/api');
4
4
  const { ensureBilledCommandAuth } = require('./auth');
5
+ const applyGate = require('../lib/apply-gate');
5
6
 
6
7
  const DEFAULT_TIMEOUT_MS = 120000;
7
8
  const COST_HINT = '5 credits per search';
9
+ const APPLY_NEXT_MESSAGE =
10
+ 'next: write one apply (change + receipt) for this query.';
8
11
 
9
12
  function showXSearchHelp(output = console.log, commandName = 'atris x-search') {
10
13
  output('');
@@ -13,6 +16,7 @@ function showXSearchHelp(output = console.log, commandName = 'atris x-search') {
13
16
  output('');
14
17
  output(`Search X/Twitter via Atris (${COST_HINT}).`);
15
18
  output('Requires login. Same auth path as atris youtube process.');
19
+ output('Empty or failed search refunds the credits.');
16
20
  output('');
17
21
  output('Options:');
18
22
  output(' --limit <n> Max results hint (search only)');
@@ -213,6 +217,58 @@ function resultErrorText(result) {
213
217
  }
214
218
  }
215
219
 
220
+ function unwrapXSearchPayload(data) {
221
+ return data?.data && typeof data.data === 'object' ? data.data : data;
222
+ }
223
+
224
+ function xSearchContent(data) {
225
+ const payload = unwrapXSearchPayload(data);
226
+ return payload?.content != null ? String(payload.content).trim() : '';
227
+ }
228
+
229
+ function xSearchCitations(data) {
230
+ const payload = unwrapXSearchPayload(data);
231
+ if (Array.isArray(payload?.citations)) return payload.citations;
232
+ if (Array.isArray(data?.citations)) return data.citations;
233
+ return [];
234
+ }
235
+
236
+ function xSearchCredits(data) {
237
+ if (!data || typeof data !== 'object') {
238
+ return { used: undefined, remaining: undefined, refunded: undefined };
239
+ }
240
+ const payload = unwrapXSearchPayload(data) || {};
241
+ const used = data.credits_used !== undefined ? data.credits_used : payload.credits_used;
242
+ const remaining = data.credits_remaining !== undefined
243
+ ? data.credits_remaining
244
+ : payload.credits_remaining;
245
+ let refunded = data.credits_refunded !== undefined
246
+ ? data.credits_refunded
247
+ : payload.credits_refunded;
248
+ if (refunded === undefined && (data.refunded === true || payload.refunded === true)) {
249
+ refunded = true;
250
+ }
251
+ return { used, remaining, refunded };
252
+ }
253
+
254
+ function creditsWereRefunded(credits) {
255
+ if (!credits) return false;
256
+ if (credits.used === 0) return true;
257
+ if (credits.refunded === true) return true;
258
+ return typeof credits.refunded === 'number' && credits.refunded > 0;
259
+ }
260
+
261
+ function formatCreditsLines(credits) {
262
+ const lines = [];
263
+ if (credits.used !== undefined || credits.remaining !== undefined) {
264
+ lines.push(`Credits: ${credits.used !== undefined ? credits.used : '?'} used, ${credits.remaining !== undefined ? credits.remaining : '?'} remaining`);
265
+ }
266
+ if (creditsWereRefunded(credits)) {
267
+ lines.push('credits refunded');
268
+ }
269
+ return lines;
270
+ }
271
+
216
272
  function xSearchFailureError(result) {
217
273
  const hint = result.status === 401
218
274
  ? ' Run "atris login --force".'
@@ -221,7 +277,13 @@ function xSearchFailureError(result) {
221
277
  : result.status === 502
222
278
  ? ' xAI is unavailable; retry in a few seconds.'
223
279
  : '';
224
- return new Error(`X search failed (${result.status}): ${resultErrorText(result)}.${hint}`);
280
+ const credits = xSearchCredits(result.data);
281
+ const refundHint = result.status === 502 && creditsWereRefunded(credits)
282
+ ? ' credits refunded.'
283
+ : '';
284
+ const lines = [`X search failed (${result.status}): ${resultErrorText(result)}.${hint}${refundHint}`];
285
+ lines.push(...formatCreditsLines(credits));
286
+ return new Error(lines.join('\n'));
225
287
  }
226
288
 
227
289
  async function ensureToken(deps = {}) {
@@ -265,13 +327,35 @@ async function runXSearch(options, deps = {}) {
265
327
  return result.data;
266
328
  }
267
329
 
330
+ function xSearchApplySource(options) {
331
+ if (options?.mode === 'person') return options.name;
332
+ return options?.query;
333
+ }
334
+
335
+ function xSearchApplyRel(source) {
336
+ return applyGate.applySidecarRel('x-search', applyGate.applySlug(source));
337
+ }
338
+
339
+ function xSearchHasResults(data) {
340
+ return Boolean(xSearchContent(data)) || xSearchCitations(data).length > 0;
341
+ }
342
+
343
+ function ensureXSearchApply({ cwd, source, now, output } = {}) {
344
+ return applyGate.ensureApply({
345
+ cwd,
346
+ source,
347
+ rel: source ? xSearchApplyRel(source) : null,
348
+ now,
349
+ output,
350
+ incompleteMessage: APPLY_NEXT_MESSAGE,
351
+ required: false,
352
+ });
353
+ }
354
+
268
355
  function formatXSearchResult(data) {
269
356
  const lines = [];
270
- const payload = data?.data && typeof data.data === 'object' ? data.data : data;
271
- const content = payload?.content != null ? String(payload.content).trim() : '';
272
- const citations = Array.isArray(payload?.citations)
273
- ? payload.citations
274
- : (Array.isArray(data?.citations) ? data.citations : []);
357
+ const content = xSearchContent(data);
358
+ const citations = xSearchCitations(data);
275
359
 
276
360
  if (content) {
277
361
  lines.push(content);
@@ -289,18 +373,25 @@ function formatXSearchResult(data) {
289
373
  }
290
374
  }
291
375
 
292
- const used = data?.credits_used !== undefined ? data.credits_used : payload?.credits_used;
293
- const remaining = data?.credits_remaining !== undefined
294
- ? data.credits_remaining
295
- : payload?.credits_remaining;
296
- if (used !== undefined || remaining !== undefined) {
376
+ const creditLines = formatCreditsLines(xSearchCredits(data));
377
+ if (creditLines.length) {
297
378
  lines.push('');
298
- lines.push(`Credits: ${used !== undefined ? used : '?'} used, ${remaining !== undefined ? remaining : '?'} remaining`);
379
+ lines.push(...creditLines);
299
380
  }
300
381
 
301
382
  return lines.join('\n');
302
383
  }
303
384
 
385
+ function formatEmptyXSearchResult(data) {
386
+ const lines = ['no results'];
387
+ const creditLines = formatCreditsLines(xSearchCredits(data));
388
+ if (creditLines.length) {
389
+ lines.push('');
390
+ lines.push(...creditLines);
391
+ }
392
+ return lines.join('\n');
393
+ }
394
+
304
395
  async function xSearchCommand(argv = process.argv.slice(3), deps = {}) {
305
396
  const output = deps.output || ((line = '') => console.log(line));
306
397
  let options;
@@ -319,7 +410,23 @@ async function xSearchCommand(argv = process.argv.slice(3), deps = {}) {
319
410
  let status = 0;
320
411
  try {
321
412
  const data = await runXSearch(options, deps);
322
- output(options.json ? JSON.stringify(data, null, 2) : formatXSearchResult(data));
413
+ const hasResults = xSearchHasResults(data);
414
+ if (options.json) {
415
+ output(JSON.stringify(data, null, 2));
416
+ } else {
417
+ output(hasResults ? formatXSearchResult(data) : formatEmptyXSearchResult(data));
418
+ }
419
+ if (hasResults) {
420
+ const ensureApply = deps.ensureApply || ensureXSearchApply;
421
+ status = ensureApply({
422
+ cwd: deps.cwd || process.cwd(),
423
+ source: xSearchApplySource(options),
424
+ now: deps.applyNow,
425
+ output,
426
+ });
427
+ } else {
428
+ status = 2;
429
+ }
323
430
  } catch (err) {
324
431
  output(err.message);
325
432
  status = 1;
@@ -332,9 +439,12 @@ async function xSearchCommand(argv = process.argv.slice(3), deps = {}) {
332
439
 
333
440
  module.exports = {
334
441
  DEFAULT_TIMEOUT_MS,
442
+ APPLY_NEXT_MESSAGE,
335
443
  parseXSearchArgs,
336
444
  buildSearchPayload,
337
445
  buildPersonPayload,
338
446
  formatXSearchResult,
447
+ xSearchHasResults,
448
+ xSearchApplyRel,
339
449
  xSearchCommand,
340
450
  };