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
@@ -3277,11 +3277,14 @@ async function deployBusiness(slug) {
3277
3277
  // Upload workspace files
3278
3278
  const workspaceDir = path.join(bizDir, 'workspace');
3279
3279
  let uploadCount = 0;
3280
+ let uploadFailureCount = 0;
3281
+ let uploadTotal = 0;
3280
3282
  if (fs.existsSync(workspaceDir)) {
3281
3283
  const files = walkDir(workspaceDir);
3282
3284
  for (const filePath of files) {
3283
3285
  const relativePath = path.relative(workspaceDir, filePath);
3284
3286
  if (relativePath.startsWith('.')) continue;
3287
+ uploadTotal++;
3285
3288
  try {
3286
3289
  const content = fs.readFileSync(filePath, 'utf8');
3287
3290
  const uploadResult = await apiRequestJson(
@@ -3291,9 +3294,14 @@ async function deployBusiness(slug) {
3291
3294
  if (uploadResult.ok) {
3292
3295
  uploadCount++;
3293
3296
  process.stdout.write(` Uploaded: ${relativePath}\n`);
3297
+ } else {
3298
+ uploadFailureCount++;
3299
+ const detail = uploadResult.errorMessage || uploadResult.error || uploadResult.status || 'unknown error';
3300
+ console.log(` Failed: ${relativePath} (${detail})`);
3294
3301
  }
3295
3302
  } catch (e) {
3296
- // Skip binary files or errors
3303
+ uploadFailureCount++;
3304
+ console.log(` Failed: ${relativePath} (${e.message || String(e)})`);
3297
3305
  }
3298
3306
  }
3299
3307
  }
@@ -3314,6 +3322,10 @@ async function deployBusiness(slug) {
3314
3322
 
3315
3323
  console.log(`\n Deployed ${uploadCount} files to ${bizConfig.name}`);
3316
3324
  console.log(` Dashboard: https://atris.ai/dashboard/gm/${bizConfig.business_id}`);
3325
+ if (uploadFailureCount > 0) {
3326
+ console.log(` ${uploadFailureCount} of ${uploadTotal} files failed to upload`);
3327
+ process.exitCode = 1;
3328
+ }
3317
3329
  console.log('');
3318
3330
  }
3319
3331
 
@@ -25,6 +25,7 @@ const {
25
25
  buildRunnerCommand,
26
26
  } = require('../lib/runner-command');
27
27
  const { parseScopeFlag } = require('../lib/cli-scope');
28
+ const { isFreshWorkspace, speakFirstMinute } = require('../lib/first-minute');
28
29
  const {
29
30
  ENGINE_ROLES,
30
31
  ENGINE_DUTIES,
@@ -1541,6 +1542,9 @@ function engineCommand(args = [], deps = {}) {
1541
1542
  }
1542
1543
 
1543
1544
  if (sub === 'chart' || scope.args.includes('--chart') || args.includes('--chart')) {
1545
+ if (isFreshWorkspace(root)) {
1546
+ return speakFirstMinute({ root, fresh: true, asJson: json });
1547
+ }
1544
1548
  printEngineChart(root);
1545
1549
  return 0;
1546
1550
  }
@@ -1551,6 +1555,9 @@ function engineCommand(args = [], deps = {}) {
1551
1555
  console.log('\n atris engine roster + current default\n atris engines --chart show the fleet as an org chart\n atris engine list --json full registry: default + engines with tier, roles, fallback, health\n atris engine set <name> --duty leader|errands|learning [--models "a, b"]\n arrange the fleet and save its model policy\n atris engine resolve <role> [--json]\n choose the best ready engine for navigator|executor|validator\n atris engine health <name> --set ready|not_installed|credit_out\n flip runtime health, for example when credits run out\n atris engine doctor [--json]\n probe which engine CLIs are installed here and sync that into health policy\n atris engine <name> make that engine the default here\n atris engine test [name] preflight: run the engine CLI headless, report pass/fail\n atris engine ask "<question>" --engine <name> [--engine <name> ...]\n ask several engines in parallel without allowing edits\n atris engine ask --jobs <jobs.json>\n ask different read-only questions in parallel\n atris engine validate <receipt-path|latest> [--engine <name>]\n check ask answers with a different read-only referee\n atris engine validate scoreboard\n show pass rates by worker engine\n atris engine dispatch <task-id> [<task-id> ...] --engine cursor|codex [--prompt-file <f>] [--yolo]\n one-command claim, worktree, build, verify, ship, ready\n atris engine login <provider> --yes\n upload a local provider CLI login to the backend vault\n atris engine login <provider> --computer [--seat <name>]\n atris engine login <provider> --business <id> [--seat <name>]\n sign in on an Atris computer by device flow\n atris engine login --list | --remove <provider>\n list or remove vaulted provider logins\n atris engine seats show which named accounts are ready to work\n atris engine seed <provider> --business <id>|--user\n push a vaulted login onto an Atris computer\n atris engine reset back to the house default\n --engine <name> one run on that engine (mission run / autopilot / run)\n');
1552
1556
  return 0;
1553
1557
  }
1558
+ if (isFreshWorkspace(root)) {
1559
+ return speakFirstMinute({ root, fresh: true, asJson: json });
1560
+ }
1554
1561
  if (json) {
1555
1562
  console.log(JSON.stringify(registryPayload(root, { scope: scope.kind }), null, 2));
1556
1563
  return 0;
@@ -138,6 +138,176 @@ function runPython(scriptPath, args = [], cwd = process.cwd()) {
138
138
  }
139
139
  }
140
140
 
141
+ function parseMeasureScore(stdout) {
142
+ const line = String(stdout || '').trim().split(/\r?\n/).filter(Boolean).pop();
143
+ if (!line) return { ok: false, error: 'measure.py printed no score' };
144
+ try {
145
+ const payload = JSON.parse(line);
146
+ const score = Number(payload.score);
147
+ if (!Number.isFinite(score)) return { ok: false, error: 'measure.py printed no score' };
148
+ return { ok: true, score, payload };
149
+ } catch {
150
+ return { ok: false, error: 'measure.py printed no score' };
151
+ }
152
+ }
153
+
154
+ function runMeasureJson(scriptPath, workspaceDir = process.cwd()) {
155
+ const python = resolvePython();
156
+ if (!python) {
157
+ return { ok: false, error: 'python not found. set ATRIS_EXPERIMENTS_PYTHON or install python3.' };
158
+ }
159
+
160
+ const result = spawnSync(python, [scriptPath], {
161
+ cwd: workspaceDir,
162
+ encoding: 'utf8',
163
+ env: {
164
+ ...process.env,
165
+ PYTHONDONTWRITEBYTECODE: '1',
166
+ ATRIS_REPO_ROOT: workspaceDir,
167
+ },
168
+ });
169
+
170
+ if (result.error) {
171
+ return { ok: false, error: result.error.message || 'measure.py failed to start' };
172
+ }
173
+
174
+ const parsed = parseMeasureScore(result.stdout);
175
+ if (!parsed.ok) {
176
+ const detail = String(result.stderr || '').trim().split(/\r?\n/).filter(Boolean).pop();
177
+ return { ok: false, error: detail || parsed.error };
178
+ }
179
+ return parsed;
180
+ }
181
+
182
+ function experimentsKeep(name) {
183
+ const token = String(name || '').trim();
184
+ if (!token || token === '--help' || token === '-h' || token === 'help') {
185
+ console.error('usage: atris experiments keep <slug>');
186
+ process.exit(2);
187
+ }
188
+ if (!SLUG_RE.test(token)) {
189
+ console.error('invalid experiment name. use a lowercase-hyphen slug.');
190
+ process.exit(2);
191
+ }
192
+
193
+ const workspaceDir = process.cwd();
194
+ if (!fs.existsSync(path.join(workspaceDir, 'atris'))) {
195
+ console.error('atris/ folder not found. run "atris init" first.');
196
+ process.exit(2);
197
+ }
198
+
199
+ const packDir = path.join(workspaceDir, 'atris', 'experiments', token);
200
+ if (!fs.existsSync(packDir) || !fs.statSync(packDir).isDirectory()) {
201
+ console.error(`experiment "${token}" not found.`);
202
+ process.exit(2);
203
+ }
204
+
205
+ const measurePath = path.join(packDir, 'measure.py');
206
+ if (!fs.existsSync(measurePath)) {
207
+ console.error(`experiment "${token}" has no measure.py.`);
208
+ process.exit(2);
209
+ }
210
+
211
+ // Change is already on disk. Minted packs start at 0; keep only if current score is 1.
212
+ const measured = runMeasureJson(measurePath, workspaceDir);
213
+ if (!measured.ok) {
214
+ console.error(`revert ${token}: ${measured.error}. refuse keep.`);
215
+ process.exit(1);
216
+ }
217
+
218
+ if (measured.score === 1) {
219
+ console.log(`keep ${token}: measure.py moved 0→1`);
220
+ return;
221
+ }
222
+
223
+ const stayed = measured.score === 0 ? '0' : String(measured.score);
224
+ console.error(`revert ${token}: measure.py stayed ${stayed}. refuse keep.`);
225
+ process.exit(1);
226
+ }
227
+
228
+ function lastProcessLine(result) {
229
+ const text = `${result && result.stderr ? result.stderr : ''}\n${result && result.stdout ? result.stdout : ''}`;
230
+ return text.trim().split(/\r?\n/).filter(Boolean).pop() || '';
231
+ }
232
+
233
+ function runResetScript(scriptPath, workspaceDir = process.cwd()) {
234
+ const python = resolvePython();
235
+ if (!python) {
236
+ return { ok: false, error: 'python not found. set ATRIS_EXPERIMENTS_PYTHON or install python3.' };
237
+ }
238
+
239
+ const result = spawnSync(python, [scriptPath], {
240
+ cwd: workspaceDir,
241
+ encoding: 'utf8',
242
+ env: {
243
+ ...process.env,
244
+ PYTHONDONTWRITEBYTECODE: '1',
245
+ ATRIS_REPO_ROOT: workspaceDir,
246
+ },
247
+ });
248
+
249
+ if (result.error) {
250
+ return { ok: false, error: result.error.message || 'reset.py failed to start' };
251
+ }
252
+
253
+ if (typeof result.status === 'number' && result.status !== 0) {
254
+ return { ok: false, error: lastProcessLine(result) || `reset.py exited ${result.status}` };
255
+ }
256
+
257
+ if (result.status == null) {
258
+ return { ok: false, error: lastProcessLine(result) || 'reset.py failed' };
259
+ }
260
+
261
+ return { ok: true };
262
+ }
263
+
264
+ function experimentsRevert(name) {
265
+ const token = String(name || '').trim();
266
+ if (!token || token === '--help' || token === '-h' || token === 'help') {
267
+ console.error('usage: atris experiments revert <slug>');
268
+ process.exit(2);
269
+ }
270
+ if (!SLUG_RE.test(token)) {
271
+ console.error('invalid experiment name. use a lowercase-hyphen slug.');
272
+ process.exit(2);
273
+ }
274
+
275
+ const workspaceDir = process.cwd();
276
+ if (!fs.existsSync(path.join(workspaceDir, 'atris'))) {
277
+ console.error('atris/ folder not found. run "atris init" first.');
278
+ process.exit(2);
279
+ }
280
+
281
+ const packDir = path.join(workspaceDir, 'atris', 'experiments', token);
282
+ if (!fs.existsSync(packDir) || !fs.statSync(packDir).isDirectory()) {
283
+ console.error(`experiment "${token}" not found.`);
284
+ process.exit(2);
285
+ }
286
+
287
+ const resetPath = path.join(packDir, 'reset.py');
288
+ if (!fs.existsSync(resetPath)) {
289
+ console.error(`experiment "${token}" has no reset.py.`);
290
+ process.exit(2);
291
+ }
292
+
293
+ const reset = runResetScript(resetPath, workspaceDir);
294
+ if (!reset.ok) {
295
+ console.error(`revert ${token}: ${reset.error}. refuse revert.`);
296
+ process.exit(1);
297
+ }
298
+
299
+ const measurePath = path.join(packDir, 'measure.py');
300
+ if (fs.existsSync(measurePath)) {
301
+ const measured = runMeasureJson(measurePath, workspaceDir);
302
+ if (measured.ok) {
303
+ console.log(`revert ${token}: reset.py ran. measure.py ${measured.score}`);
304
+ return;
305
+ }
306
+ }
307
+
308
+ console.log(`revert ${token}: reset.py ran`);
309
+ }
310
+
141
311
  function experimentsInit(name) {
142
312
  const { experimentsDir } = ensureExperimentsFramework();
143
313
 
@@ -573,6 +743,10 @@ function experimentsCommand(subcommand, ...args) {
573
743
  return experimentsReplay(args[0] || 'endstate');
574
744
  case 'run':
575
745
  return experimentsRun(args[0], ...args.slice(1));
746
+ case 'keep':
747
+ return experimentsKeep(args[0]);
748
+ case 'revert':
749
+ return experimentsRevert(args[0]);
576
750
  default:
577
751
  console.log('');
578
752
  console.log('Usage: atris experiments <subcommand> [name]');
@@ -581,6 +755,8 @@ function experimentsCommand(subcommand, ...args) {
581
755
  console.log(' init [slug] Prepare atris/experiments/ or scaffold a new pack');
582
756
  console.log(' validate [path|slug] Run structural validation on packs or a single pack');
583
757
  console.log(' run <slug> Execute a pack or record an Endstate benchmark receipt');
758
+ console.log(' keep <slug> Keep a pack only when measure.py moves 0 to 1');
759
+ console.log(' revert <slug> Run a pack reset.py to restore the working tree');
584
760
  console.log(' compare endstate Compare the latest baseline and stack receipts');
585
761
  console.log(' replay endstate Validate, dry-run, and compare the public benchmark flow');
586
762
  console.log(' benchmark [mode] Run validate/runtime/all benchmark harness');
@@ -592,6 +768,8 @@ function experimentsCommand(subcommand, ...args) {
592
768
  console.log(' atris experiments init self-heal');
593
769
  console.log(' atris experiments validate');
594
770
  console.log(' atris experiments run endstate-baseline --dry-run');
771
+ console.log(' atris experiments keep teach-teach01-s1');
772
+ console.log(' atris experiments revert teach-teach01-s1');
595
773
  console.log(' atris experiments compare endstate');
596
774
  console.log(' atris experiments replay endstate');
597
775
  console.log(' atris experiments benchmark runtime');
@@ -15,7 +15,7 @@
15
15
 
16
16
  const { loadCredentials } = require('../utils/auth');
17
17
  const { apiRequestJson } = require('../utils/api');
18
- const { requireAccountBound, refuseAccountGlobal } = require('../lib/account-bound');
18
+ const { looksLikeBusinessSlug, requireAccountBound, refuseAccountGlobal } = require('../lib/account-bound');
19
19
  const { argsWantHelp, wantsJson } = require('../lib/noninteractive');
20
20
 
21
21
  function sleep(ms) {
@@ -181,7 +181,7 @@ async function fleetReport() {
181
181
  const wake = gate.args.includes('--wake');
182
182
  const dryRun = gate.args.includes('--dry-run');
183
183
  const board = gate.args.includes('--leaderboard');
184
- const slug = gate.args.find((a) => !a.startsWith('--'));
184
+ const slug = gate.args.find((a) => looksLikeBusinessSlug(a));
185
185
 
186
186
  if (!allAlive && !slug && !board) {
187
187
  console.log(usage);
@@ -3,6 +3,7 @@
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const { spawnSync } = require('node:child_process');
6
+ const { isFreshWorkspace, speakFirstMinute } = require('../lib/first-minute');
6
7
 
7
8
  const DAY_MS = 24 * 60 * 60 * 1000;
8
9
  const DEFAULT_DAYS = 28;
@@ -321,6 +322,17 @@ function founderCommand(args = [], options = {}) {
321
322
 
322
323
  const cwd = path.resolve(options.cwd || process.cwd());
323
324
  const currentWorkspace = workspaceRoot(cwd);
325
+
326
+ // Empty folder talks like bare atris. Do not write scorecard.jsonl.
327
+ // After init, founder still lists and scores.
328
+ if (isFreshWorkspace(cwd) || isFreshWorkspace(currentWorkspace)) {
329
+ return speakFirstMinute({
330
+ root: cwd,
331
+ fresh: true,
332
+ asJson: parsed.json,
333
+ });
334
+ }
335
+
324
336
  const root = parsed.root
325
337
  ? path.resolve(cwd, parsed.root)
326
338
  : (parsed.global ? defaultScanRoot(cwd, currentWorkspace) : currentWorkspace);
@@ -14,7 +14,15 @@ const {
14
14
  const { apiRequestJson } = require('../utils/api');
15
15
  const { decodeJwtClaims, loadCredentials } = require('../utils/auth');
16
16
  const { isHelpToken } = require('../lib/noninteractive');
17
- const { isFreshWorkspace, speakFirstMinute } = require('../lib/first-minute');
17
+ const {
18
+ buildFirstMinute,
19
+ isClaimMinute,
20
+ isFreshWorkspace,
21
+ isKeepWorkingMinute,
22
+ speakFirstMinute,
23
+ speakKeepWorkingMinute,
24
+ speakNothingRunning,
25
+ } = require('../lib/first-minute');
18
26
 
19
27
  const PACKAGE_PATH = path.join(__dirname, '..', 'package.json');
20
28
  const HUMAN_STATES = Object.freeze({
@@ -168,7 +176,10 @@ function parseMissionId(args = []) {
168
176
 
169
177
  /**
170
178
  * Scratch / unbound folders must not drive account-current cloud work.
171
- * Require an explicit mission/task id: atris stop --mission <id>
179
+ * Empty-folder stop talks first-talk instead. After do/claim, nothing
180
+ * running talks keep-working instead. After init, claim-ready talks
181
+ * the claim instead. After a room exists without that keep-working
182
+ * or claim next, require an explicit id: atris stop --mission <id>
172
183
  */
173
184
  function refuseUnboundCloudComputer(args = [], options = {}, commandName = 'stop') {
174
185
  const root = options.root || process.cwd();
@@ -634,6 +645,15 @@ async function currentMissionCommand(args = [], options = {}) {
634
645
  outputCard(card, asJson, options.log || console.log);
635
646
  return 0;
636
647
  } catch (caught) {
648
+ if (options.fallbackOnMissing) {
649
+ const code = speakFirstMinute({
650
+ root: options.root || process.cwd(),
651
+ asJson,
652
+ log: options.log || console.log,
653
+ });
654
+ if (options.setProcessExitCode !== false) process.exitCode = code;
655
+ return code;
656
+ }
637
657
  return reportError(caught, 'show the current mission', asJson, options);
638
658
  }
639
659
  }
@@ -729,11 +749,53 @@ async function approveCommand(args, options = {}) {
729
749
  return changeCurrentMission('approve', {}, args, options);
730
750
  }
731
751
 
752
+ function hasLiveKeepWorkingRun(root = process.cwd()) {
753
+ try {
754
+ const { pickLiveLocalMission } = require('./mission');
755
+ return Boolean(pickLiveLocalMission(root));
756
+ } catch {
757
+ return false;
758
+ }
759
+ }
760
+
732
761
  async function stopCommand(args, options = {}) {
733
762
  if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
734
763
  (options.log || console.log)('Usage: atris stop [--mission <id>] [--json]');
735
764
  return 0;
736
765
  }
766
+ const root = options.root || process.cwd();
767
+ const asJson = args.includes('--json');
768
+ const speak = {
769
+ root,
770
+ asJson,
771
+ log: options.log || console.log,
772
+ };
773
+ // Fresh folder: empty talks first-talk. A file already here
774
+ // names that file, same as bare atris. Do not mint a room.
775
+ if (isFreshWorkspace(root) && !parseMissionId(args)) {
776
+ const unbound = refuseUnboundCloudComputer(args, options, 'stop');
777
+ if (unbound) {
778
+ return speakNothingRunning(speak);
779
+ }
780
+ }
781
+ // After do/claim, nothing running: same two keep-working
782
+ // lines as bare atris / status / recap. After init, next
783
+ // is claim: same two lines as bare atris / status / now.
784
+ // Not factory cloud-computer. Do not mint a room or bind
785
+ // a computer. A bound computer still stops the current
786
+ // mission.
787
+ if (!parseMissionId(args) && !hasLiveKeepWorkingRun(root)) {
788
+ const unbound = refuseUnboundCloudComputer(args, options, 'stop');
789
+ if (unbound) {
790
+ const minute = buildFirstMinute({ root });
791
+ if (isKeepWorkingMinute(minute)) {
792
+ return speakKeepWorkingMinute(speak);
793
+ }
794
+ if (isClaimMinute(minute)) {
795
+ return speakFirstMinute(speak);
796
+ }
797
+ }
798
+ }
737
799
  return changeCurrentMission('stop', {}, args, options);
738
800
  }
739
801
 
package/commands/init.js CHANGED
@@ -468,41 +468,8 @@ function initAtris() {
468
468
  }
469
469
  }
470
470
 
471
- // Create INTUITION.md - captures learnings, preferences, dead ends
472
- const intuitionFile = path.join(targetDir, 'INTUITION.md');
473
- if (!fs.existsSync(intuitionFile)) {
474
- fs.writeFileSync(intuitionFile, `# INTUITION.md
475
-
476
- > Accumulated learnings. Read before major decisions. Update after discoveries.
477
-
478
- ---
479
-
480
- ## Tripwires
481
-
482
- *Things that seem obvious but break unexpectedly. Check these first when debugging.*
483
-
484
- - (none yet — add when you hit surprising failures)
485
-
486
- ---
487
-
488
- ## Preferences
489
-
490
- *Patterns this codebase prefers. Follow these over generic best practices.*
491
-
492
- - (none yet — add as you discover the codebase style)
493
-
494
- ---
495
-
496
- ## Dead Ends
497
-
498
- *Approaches tried and abandoned. Don't retry without new information.*
499
-
500
- - (none yet — log failed approaches so future agents skip them)
501
-
502
- ---
503
- `);
504
- markReady('workspace', 'INTUITION.md', '✓ Created INTUITION.md');
505
- }
471
+ // INTUITION.md was retired into lessons.md. Do not write a parallel lesson file.
472
+ // Leave a user's existing INTUITION.md alone if one is already on disk.
506
473
 
507
474
  // Create lessons.md (feedback loop for learning across features)
508
475
  const lessonsFile = path.join(targetDir, 'lessons.md');
@@ -5,6 +5,7 @@
5
5
  * atris gmail inbox [--account <id>] - List recent emails for a mailbox
6
6
  * atris gmail read <id> [--account <id>] - Read specific email
7
7
  * atris gmail archive <id> [...] [--account <id>] - Archive messages
8
+ * atris gmail verdicts [--account <id>] [--limit N] - List recent archive verdicts
8
9
  * atris gmail send <to> <subject> <body...> [--body-file <path>] [--account <id>] - Send an email
9
10
  * atris gmail voice [account] [--clear] - Edit an account's writing voice
10
11
  * atris gmail connect [name] - Connect or reconnect a Gmail account
@@ -39,6 +40,77 @@ const GMAIL_CONNECT_POLL_MS = 3000;
39
40
  const GMAIL_CONNECT_TIMEOUT_MS = 3 * 60 * 1000;
40
41
  const GMAIL_SEND_USAGE = 'usage: atris gmail send <to> <subject> <body...> [--body-file <path>] [--account <id>]';
41
42
  const GMAIL_VOICE_USAGE = 'usage: atris gmail voice [account] [--clear]';
43
+ const GMAIL_VERDICTS_USAGE = 'usage: atris gmail verdicts [--account <id>] [--limit N]';
44
+
45
+ function gmailVerdictsPath(root = process.cwd()) {
46
+ return path.join(root, '.atris', 'state', 'gmail-verdicts.jsonl');
47
+ }
48
+
49
+ function appendGmailVerdicts(verdicts, options = {}) {
50
+ const rows = Array.isArray(verdicts) ? verdicts : [];
51
+ if (!rows.length) return;
52
+ const filePath = options.filePath || gmailVerdictsPath(options.root);
53
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
54
+ const lines = rows.map((row) => JSON.stringify({
55
+ ts: row.ts || new Date().toISOString(),
56
+ account: String(row.account || ''),
57
+ verdict: 'archive',
58
+ message_id: String(row.message_id || ''),
59
+ ...(row.from ? { from: row.from } : {}),
60
+ ...(row.subject ? { subject: row.subject } : {}),
61
+ }));
62
+ fs.appendFileSync(filePath, `${lines.join('\n')}\n`, 'utf8');
63
+ }
64
+
65
+ function readGmailVerdicts(options = {}) {
66
+ const filePath = options.filePath || gmailVerdictsPath(options.root);
67
+ let lines;
68
+ try {
69
+ lines = fs.readFileSync(filePath, 'utf8').split('\n').filter(Boolean);
70
+ } catch (error) {
71
+ if (error.code === 'ENOENT') return [];
72
+ throw error;
73
+ }
74
+ const account = String(options.account || '').trim();
75
+ const limit = Number.isInteger(options.limit) ? options.limit : 20;
76
+ return lines.reduce((rows, line) => {
77
+ try {
78
+ const row = JSON.parse(line);
79
+ if (!account || row.account === account) rows.push(row);
80
+ } catch {}
81
+ return rows;
82
+ }, []).reverse().slice(0, limit);
83
+ }
84
+
85
+ function printGmailVerdicts(options = {}) {
86
+ const rows = readGmailVerdicts(options);
87
+ if (!rows.length) {
88
+ console.log('no gmail verdicts found.');
89
+ return rows;
90
+ }
91
+ for (const row of rows) {
92
+ const details = [row.from && `from ${row.from}`, row.subject && `subject ${row.subject}`].filter(Boolean);
93
+ console.log(`${row.ts} archive ${row.message_id} account ${row.account}${details.length ? `, ${details.join(', ')}` : ''}`);
94
+ }
95
+ return rows;
96
+ }
97
+
98
+ function parseGmailVerdictsArgs(args = []) {
99
+ let account = null;
100
+ let limit = 20;
101
+ for (let i = 0; i < args.length; i += 1) {
102
+ const arg = args[i];
103
+ const value = String(args[i + 1] || '').trim();
104
+ if (arg === '--account' && value && !value.startsWith('--')) account = value;
105
+ else if (arg === '--limit' && /^\d+$/.test(value) && Number(value) > 0) limit = Number(value);
106
+ else {
107
+ console.error(GMAIL_VERDICTS_USAGE);
108
+ process.exit(1);
109
+ }
110
+ i += 1;
111
+ }
112
+ return { account, limit };
113
+ }
42
114
 
43
115
  function gmailAccountStatePath() {
44
116
  return process.env.ATRIS_GMAIL_ACCOUNT_FILE
@@ -312,6 +384,24 @@ async function gmailArchive(messageIds, options = {}) {
312
384
  }
313
385
 
314
386
  const archived = result.data?.archived ?? result.data?.count ?? ids.length;
387
+ const responseMessages = [
388
+ result.data?.messages,
389
+ result.data?.archived_messages,
390
+ result.data?.results,
391
+ ].find(Array.isArray) || [];
392
+ const metadata = new Map(responseMessages.map((message) => [
393
+ String(message.id || message.message_id || ''),
394
+ message,
395
+ ]));
396
+ appendGmailVerdicts(ids.map((messageId) => {
397
+ const message = metadata.get(messageId) || {};
398
+ return {
399
+ account: accountId,
400
+ message_id: messageId,
401
+ from: message.from || message.sender || undefined,
402
+ subject: message.subject || undefined,
403
+ };
404
+ }));
315
405
  console.log(`Archived ${archived} message${archived === 1 ? '' : 's'}. They stay searchable in All Mail.`);
316
406
  }
317
407
 
@@ -610,6 +700,11 @@ async function gmailCommand(subcommand, ...args) {
610
700
  await gmailArchive(parsed.positional, { accountId: parsed.accountId || undefined });
611
701
  break;
612
702
  }
703
+ case 'verdicts': {
704
+ const parsed = parseGmailVerdictsArgs(args);
705
+ printGmailVerdicts(parsed);
706
+ break;
707
+ }
613
708
  case 'send': {
614
709
  const parsed = parseGmailSendArgs(args);
615
710
  await gmailSend(parsed.to, parsed.subject, parsed.body, { accountId: parsed.accountId || undefined });
@@ -634,6 +729,7 @@ async function gmailCommand(subcommand, ...args) {
634
729
  console.log(' atris gmail inbox [--account <id>] - list recent emails for a mailbox');
635
730
  console.log(' atris gmail read <id> [--account <id>] - read specific email');
636
731
  console.log(' atris gmail archive <id> [...] [--account <id>] - archive messages (reversible, all mail keeps them)');
732
+ console.log(' atris gmail verdicts [--account <id>] [--limit N] - list recent archive verdicts');
637
733
  console.log(' atris gmail send <to> <subject> <body...> [--body-file <path>] [--account <id>] - send an email');
638
734
  console.log(' atris gmail voice [account] [--clear] - edit or clear an account writing voice');
639
735
  console.log(' atris gmail connect [name] - connect or reconnect a gmail account');
@@ -2153,6 +2249,10 @@ module.exports = {
2153
2249
  extractGmailMailboxAccount,
2154
2250
  parseGmailArgs,
2155
2251
  gmailAccountStatePath,
2252
+ gmailVerdictsPath,
2253
+ appendGmailVerdicts,
2254
+ readGmailVerdicts,
2255
+ printGmailVerdicts,
2156
2256
  calendarCommand,
2157
2257
  twitterCommand,
2158
2258
  slackCommand,