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
@@ -3,10 +3,13 @@
3
3
  const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
+ const { spawnSync } = require('child_process');
6
7
  const { isGenericScratchRoot } = require('./scratch-root');
7
8
 
8
9
  const FIRST_USE_COMMAND = 'atris "help me choose the first useful step for this project"';
9
- const INIT_COMMAND = 'atris init --minimal';
10
+ const FIRST_TALK_ASK = 'what do you want here';
11
+ const CLAIMED_KEEP_WORKING_VERIFY = 'git diff --check';
12
+ const LATER_NOTE_FILE = '.atris-later';
10
13
  const BARE_ATRIS_FLAGS = new Set(['--yes', '-y', '--json', '--verbose']);
11
14
  const NOT_A_PERSON = new Set([
12
15
  'root', 'node', 'ubuntu', 'admin', 'nobody', 'www', 'daemon', 'guest',
@@ -102,6 +105,31 @@ function greet(person) {
102
105
  return person ? `hey ${person}, ` : '';
103
106
  }
104
107
 
108
+ function firstTalkCommand(_folder = 'this folder') {
109
+ return `atris "${FIRST_TALK_ASK}?"`;
110
+ }
111
+
112
+ function isFirstTalkLine(value) {
113
+ const text = String(value || '')
114
+ .trim()
115
+ .replace(/^atris\s+/i, '')
116
+ .replace(/^["']+|["']+$/g, '')
117
+ .toLowerCase()
118
+ .replace(/[?.!]+$/g, '')
119
+ .replace(/\s+/g, ' ')
120
+ .trim();
121
+ return text === FIRST_TALK_ASK;
122
+ }
123
+
124
+ function hasLiveWork({
125
+ task = null,
126
+ backlogCount = 0,
127
+ wipCount = 0,
128
+ inboxCount = 0,
129
+ } = {}) {
130
+ return Boolean(task || backlogCount > 0 || wipCount > 0 || inboxCount > 0);
131
+ }
132
+
105
133
  function softTitle(title, maxWords = 5) {
106
134
  const words = String(title || '').replace(/\s+/g, ' ').trim().split(' ').filter(Boolean);
107
135
  if (!words.length) return '';
@@ -128,23 +156,50 @@ function isCertifiedReview(task) {
128
156
  return Number(review.agent_review_pass_count || metadata.agent_review_pass_count || 0) >= 2;
129
157
  }
130
158
 
159
+ function isActionableTask(task) {
160
+ const status = task && task.status;
161
+ return status === 'open' || status === 'claimed' || status === 'review';
162
+ }
163
+
164
+ function titlesFromTodoSection(root, section) {
165
+ try {
166
+ const text = fs.readFileSync(path.join(root, 'atris', 'TODO.md'), 'utf8');
167
+ const match = String(text).match(new RegExp(`##\\s+${section}\\n([\\s\\S]*?)(?=\\n##|$)`, 'i'));
168
+ if (!match) return null;
169
+ const body = String(match[1] || '').trim();
170
+ if (!body || /\(empty|\(see /i.test(body)) return [];
171
+ return body
172
+ .split('\n')
173
+ .map((line) => line.trim())
174
+ .filter((line) => /^-\s+/.test(line) && !/\(empty/i.test(line))
175
+ .map((line) => line.replace(/^-+\s*/, '').trim())
176
+ .filter(Boolean);
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
131
182
  function loadLocalTasks(root = process.cwd()) {
183
+ let dbRows = [];
132
184
  try {
133
185
  const taskDb = require('./task-db');
134
186
  const db = taskDb.open();
135
187
  const workspaceRoot = taskDb.workspaceRoot(root);
136
188
  const rows = taskDb.listTasks(db, { workspaceRoot, limit: 200 });
137
- if (Array.isArray(rows) && rows.length) return taskDb.withTaskDisplayRefs(rows);
189
+ if (Array.isArray(rows) && rows.length) dbRows = taskDb.withTaskDisplayRefs(rows);
138
190
  } catch {
139
191
  // Fall through to the local projection. Tests and fresh folders often have no db.
140
192
  }
193
+ if (dbRows.some(isActionableTask)) return dbRows;
141
194
  const projectionPath = path.join(root, '.atris', 'state', 'tasks.projection.json');
142
195
  try {
143
196
  const parsed = JSON.parse(fs.readFileSync(projectionPath, 'utf8'));
144
- return Array.isArray(parsed.tasks) ? parsed.tasks : [];
197
+ const projected = Array.isArray(parsed.tasks) ? parsed.tasks : [];
198
+ if (projected.some(isActionableTask) || !dbRows.length) return projected;
145
199
  } catch {
146
- return [];
200
+ // Keep any done-only db rows below.
147
201
  }
202
+ return dbRows;
148
203
  }
149
204
 
150
205
  function loadLatestRecap(root = process.cwd()) {
@@ -183,15 +238,25 @@ function loadLatestRecap(root = process.cwd()) {
183
238
  return title ? { title, file: hit.file } : null;
184
239
  }
185
240
 
241
+ function claimedKeepWorkingCommand(task) {
242
+ const ref = taskRef(task);
243
+ return ref
244
+ ? `atris task ready ${ref} --verify "${CLAIMED_KEEP_WORKING_VERIFY}"`
245
+ : `atris task ready --verify "${CLAIMED_KEEP_WORKING_VERIFY}"`;
246
+ }
247
+
248
+ function isClaimedKeepWorkingCommand(command) {
249
+ return /^atris task ready\b/.test(String(command || ''));
250
+ }
251
+
186
252
  function taskCommand(task, person) {
187
253
  const ref = taskRef(task);
188
254
  const who = person || 'operator';
189
255
  if (!task) return FIRST_USE_COMMAND;
190
256
  if (task.status === 'review') {
191
- if (isCertifiedReview(task)) return ref ? `atris task accept ${ref}` : 'atris task reviews --limit 5';
192
- return ref ? `atris task review-chat ${ref} --as codex-review` : 'atris task reviews --limit 5';
257
+ return ref ? `atris task accept ${ref}` : 'atris task reviews --limit 5';
193
258
  }
194
- if (task.status === 'claimed') return ref ? `atris task ready ${ref}` : 'atris do';
259
+ if (task.status === 'claimed') return claimedKeepWorkingCommand(task);
195
260
  if (task.status === 'open') {
196
261
  return ref ? `atris task claim ${ref} --as ${who}` : 'atris task next';
197
262
  }
@@ -246,22 +311,342 @@ function pickNext({
246
311
  return { command: FIRST_USE_COMMAND };
247
312
  }
248
313
 
249
- function renderFresh({ person = '', folder = 'this folder' } = {}) {
314
+ function isUserVisibleName(name) {
315
+ const text = String(name || '');
316
+ if (!text || text === '.' || text === '..') return false;
317
+ if (text.startsWith('.')) return false;
318
+ // The local task store can land in the folder during a look.
319
+ // It is system noise, not user work.
320
+ return !/^tasks\.db(-wal|-shm)?$/i.test(text);
321
+ }
322
+
323
+ function directoryHasUserVisibleWork(dir) {
324
+ let names = [];
325
+ try {
326
+ names = fs.readdirSync(dir);
327
+ } catch {
328
+ return false;
329
+ }
330
+ return names.some(isUserVisibleName);
331
+ }
332
+
333
+ function isUserVisibleWorkEntry(root, name) {
334
+ if (!isUserVisibleName(name)) return false;
335
+ const full = path.join(root, name);
336
+ let stat;
337
+ try {
338
+ stat = fs.lstatSync(full);
339
+ } catch {
340
+ return false;
341
+ }
342
+ if (stat.isDirectory()) return directoryHasUserVisibleWork(full);
343
+ return true;
344
+ }
345
+
346
+ function listUserVisibleWork(root = process.cwd()) {
347
+ let names = [];
348
+ try {
349
+ names = fs.readdirSync(root);
350
+ } catch {
351
+ return [];
352
+ }
353
+ return names.filter((name) => isUserVisibleWorkEntry(root, name))
354
+ .sort((a, b) => a.localeCompare(b));
355
+ }
356
+
357
+ function spokenVisibleNames(files) {
358
+ const seen = new Set();
359
+ const names = [];
360
+ for (const raw of Array.isArray(files) ? files : []) {
361
+ const name = path.basename(String(raw || '')).replace(/\s+/g, ' ').trim();
362
+ if (!isUserVisibleName(name) || seen.has(name)) continue;
363
+ seen.add(name);
364
+ names.push(name);
365
+ }
366
+ return names.sort((a, b) => a.localeCompare(b));
367
+ }
368
+
369
+ function spokenCommitSubject(value) {
370
+ const text = clip(value, 72);
371
+ if (text.endsWith('...')) return text;
372
+ return text.replace(/[.,;:!?]+$/g, '');
373
+ }
374
+
375
+ function spokenBranchName(value) {
376
+ let text = String(value || '').replace(/\s+/g, ' ').trim();
377
+ if (!text) return '';
378
+ if (/^origin\//.test(text)) text = text.slice('origin/'.length).replace(/\s+/g, ' ').trim();
379
+ if (!text || text === 'HEAD') return '';
380
+ if (/^(main|master)$/i.test(text)) return '';
381
+ if (/^[0-9a-f]{7,}$/i.test(text)) return '';
382
+ return clip(text, 72);
383
+ }
384
+
385
+ function gitProbeEnv() {
386
+ const env = { ...process.env, GIT_OPTIONAL_LOCKS: '0' };
387
+ delete env.GIT_DIR;
388
+ delete env.GIT_WORK_TREE;
389
+ delete env.GIT_INDEX_FILE;
390
+ return env;
391
+ }
392
+
393
+ function hasLocalGit(root = process.cwd()) {
394
+ try {
395
+ return fs.existsSync(path.join(path.resolve(root || '.'), '.git'));
396
+ } catch {
397
+ return false;
398
+ }
399
+ }
400
+
401
+ function gitProbe(root, args) {
402
+ return spawnSync('git', [
403
+ '-C', path.resolve(root || '.'),
404
+ '-c', 'safe.directory=*',
405
+ ...args,
406
+ ], {
407
+ encoding: 'utf8',
408
+ timeout: 3000,
409
+ stdio: ['ignore', 'pipe', 'ignore'],
410
+ env: gitProbeEnv(),
411
+ });
412
+ }
413
+
414
+ function porcelainPath(line) {
415
+ const text = String(line || '');
416
+ if (text.length < 4) return '';
417
+ let body = text.slice(3);
418
+ if (/^[CR]/.test(text)) {
419
+ const arrow = body.lastIndexOf(' -> ');
420
+ if (arrow >= 0) body = body.slice(arrow + 4);
421
+ }
422
+ const trimmed = body.trim();
423
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
424
+ return trimmed.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
425
+ }
426
+ return trimmed;
427
+ }
428
+
429
+ function listDirtyWork(root = process.cwd()) {
430
+ if (!hasLocalGit(root)) return null;
431
+ try {
432
+ const result = gitProbe(root, ['status', '--porcelain']);
433
+ if (!result || result.status !== 0) return null;
434
+ const names = String(result.stdout || '')
435
+ .split('\n')
436
+ .map(porcelainPath)
437
+ .filter(Boolean);
438
+ return names.length ? names : null;
439
+ } catch {
440
+ return null;
441
+ }
442
+ }
443
+
444
+ function spokenOpenWork(files) {
445
+ const names = spokenVisibleNames(files);
446
+ if (names.length === 1) return `${names[0]} is still open.`;
447
+ if (names.length === 2) return `${names[0]} and ${names[1]} are still open.`;
448
+ return 'this folder still has open work.';
449
+ }
450
+
451
+ function headCommitSubject(root = process.cwd()) {
452
+ if (!hasLocalGit(root)) return '';
453
+ try {
454
+ const result = gitProbe(root, [
455
+ '-c', 'log.showSignature=false',
456
+ 'log', '-1', '--pretty=format:%s',
457
+ ]);
458
+ if (!result || result.status !== 0) return '';
459
+ return spokenCommitSubject(result.stdout);
460
+ } catch {
461
+ return '';
462
+ }
463
+ }
464
+
465
+ function headBranchName(root = process.cwd()) {
466
+ if (!hasLocalGit(root)) return '';
467
+ try {
468
+ const result = gitProbe(root, ['branch', '--show-current']);
469
+ if (!result || result.status !== 0) return '';
470
+ return spokenBranchName(result.stdout);
471
+ } catch {
472
+ return '';
473
+ }
474
+ }
475
+
476
+ function resolveFreshCommit({ root, files, commit } = {}) {
477
+ const visible = spokenVisibleNames(files);
478
+ if (!visible.length) return '';
479
+ if (commit !== undefined) return spokenCommitSubject(commit);
480
+ return root ? headCommitSubject(root) : '';
481
+ }
482
+
483
+ function resolveFreshBranch({ root, files, commit, branch } = {}) {
484
+ const visible = spokenVisibleNames(files);
485
+ if (!visible.length) return '';
486
+ if (branch !== undefined) return spokenBranchName(branch);
487
+ if (commit !== undefined) return '';
488
+ return root ? headBranchName(root) : '';
489
+ }
490
+
491
+ function resolveFreshOpenWork({ root, files, commit, dirty } = {}) {
492
+ const visible = spokenVisibleNames(files);
493
+ if (!visible.length) return '';
494
+ if (dirty !== undefined) {
495
+ const names = spokenVisibleNames(Array.isArray(dirty) ? dirty : []);
496
+ if (!names.length) return '';
497
+ return spokenOpenWork(names);
498
+ }
499
+ if (commit !== undefined) return '';
500
+ if (!root) return '';
501
+ if (!headCommitSubject(root)) return '';
502
+ const listed = listDirtyWork(root);
503
+ if (!listed) return '';
504
+ const names = spokenVisibleNames(listed);
505
+ if (!names.length) return '';
506
+ return spokenOpenWork(names);
507
+ }
508
+
509
+ function laterNotePath(root = process.cwd()) {
510
+ return path.join(path.resolve(root || '.'), LATER_NOTE_FILE);
511
+ }
512
+
513
+ function spokenLaterNote(value) {
514
+ const text = clip(String(value || '').replace(/\s+/g, ' ').trim(), 72);
515
+ if (!text) return '';
516
+ if (text.endsWith('...')) return text;
517
+ return text.replace(/[.,;:!?]+$/g, '');
518
+ }
519
+
520
+ function readLaterNote(root = process.cwd()) {
521
+ try {
522
+ const text = fs.readFileSync(laterNotePath(root), 'utf8');
523
+ return spokenLaterNote(String(text).split(/\r?\n/)[0]);
524
+ } catch {
525
+ return '';
526
+ }
527
+ }
528
+
529
+ function writeLaterNote(root, sentence) {
530
+ const remembered = spokenLaterNote(sentence);
531
+ if (!remembered) return '';
532
+ fs.writeFileSync(laterNotePath(root), `${remembered}\n`, 'utf8');
533
+ return remembered;
534
+ }
535
+
536
+ function resolveLaterNote({ root, later } = {}) {
537
+ if (later !== undefined) return spokenLaterNote(later);
538
+ return root ? readLaterNote(root) : '';
539
+ }
540
+
541
+ function laterNextCommand(files, extra = {}) {
542
+ const remembered = resolveLaterNote(extra);
543
+ if (!remembered) return '';
544
+ if (spokenVisibleNames(files).length) return 'atris';
545
+ return 'atris do';
546
+ }
547
+
548
+ function freshWinLine({ folder = 'this folder', files, root, commit, dirty, branch, later } = {}) {
549
+ const remembered = resolveLaterNote({ root, later });
550
+ if (remembered) return `${remembered} is still open.`;
551
+ const visible = spokenVisibleNames(files);
552
+ const open = resolveFreshOpenWork({ root, files: visible, commit, dirty });
553
+ if (open) return open;
554
+ const named = resolveFreshBranch({ root, files: visible, commit, branch });
555
+ if (named) return `${named} is already here.`;
556
+ const subject = resolveFreshCommit({ root, files: visible, commit });
557
+ if (subject) return `${subject} is already here.`;
558
+ if (visible.length === 1) return `${visible[0]} is already here.`;
559
+ if (visible.length === 2) return `${visible[0]} and ${visible[1]} are already here.`;
560
+ if (visible.length > 2) return 'this folder already has work.';
561
+ return `${folder || 'this folder'} is empty.`;
562
+ }
563
+
564
+ function freshNextCommand(files, folder = 'this folder', extra = {}) {
565
+ const laterNext = laterNextCommand(files, extra);
566
+ if (laterNext) return laterNext;
567
+ if (spokenVisibleNames(files).length) return 'atris do';
568
+ return firstTalkCommand(folder);
569
+ }
570
+
571
+ function renderLaterRemember({ person = '', sentence = '', files, root, later } = {}) {
572
+ const remembered = spokenLaterNote(sentence || later);
573
+ return [
574
+ `${greet(person)}I'll remember ${remembered}.`,
575
+ '',
576
+ `next: ${freshNextCommand(files, 'this folder', { root, later: remembered })}`,
577
+ ].join('\n');
578
+ }
579
+
580
+ function renderFresh({ person = '', folder = 'this folder', files, root, commit, dirty, branch, later } = {}) {
250
581
  const room = folder || 'this folder';
582
+ const extra = { root, later };
251
583
  return [
252
- `${greet(person)}${room} is a clean start.`,
584
+ `${greet(person)}${freshWinLine({ folder: room, files, root, commit, dirty, branch, later })}`,
253
585
  '',
254
- `next: ${INIT_COMMAND}`,
586
+ `next: ${freshNextCommand(files, room, extra)}`,
255
587
  ].join('\n');
256
588
  }
257
589
 
258
- function freshMinuteJson() {
590
+ function spokenStarterTitle(title, folder = 'this folder') {
591
+ const text = String(title || '').replace(/\s+/g, ' ').trim();
592
+ if (!text) return folder || 'this folder';
593
+ return text.replace(/[.,;:!?]+$/g, '');
594
+ }
595
+
596
+ function visibleWorkTitle(files, folder = 'this folder') {
597
+ const visible = spokenVisibleNames(files);
598
+ if (visible.length === 1) return visible[0];
599
+ if (visible.length === 2) return `${visible[0]} and ${visible[1]}`;
600
+ return folder || 'this folder';
601
+ }
602
+
603
+ function firstTalkNext({ starter = null, person = '', folder = 'this folder' } = {}) {
604
+ const room = folder || 'this folder';
605
+ if (starter && starter.display_id) {
606
+ return taskCommand({
607
+ display_id: starter.display_id,
608
+ status: starter.status || 'claimed',
609
+ }, person);
610
+ }
611
+ const title = spokenStarterTitle(starter && starter.title, room);
612
+ return `atris task new "${title}"`;
613
+ }
614
+
615
+ function firstTalkJson({ starter = null, person = '', folder = 'this folder' } = {}) {
616
+ return {
617
+ schema: 'atris.one_lap.v1',
618
+ ok: true,
619
+ status: 'started',
620
+ next_action: firstTalkNext({ starter, person, folder }),
621
+ task: starter && starter.display_id
622
+ ? { display_id: starter.display_id, title: starter.title }
623
+ : null,
624
+ };
625
+ }
626
+
627
+ function renderFirstTalk({ person = '', folder = 'this folder', starter = null } = {}) {
628
+ const room = folder || 'this folder';
629
+ const title = spokenStarterTitle(starter && starter.title, room);
630
+ return [
631
+ `${greet(person)}${title} is ready.`,
632
+ '',
633
+ `next: ${firstTalkNext({ starter, person, folder: room })}`,
634
+ ].join('\n');
635
+ }
636
+
637
+ function freshMinuteJson(folder = 'this folder', files, extra = {}) {
638
+ const root = extra && extra.root;
639
+ const commit = extra && extra.commit;
640
+ const dirty = extra && extra.dirty;
641
+ const branch = extra && extra.branch;
642
+ const later = extra && extra.later;
643
+ const fields = { folder, files, root, commit, dirty, branch, later };
259
644
  return {
260
645
  schema: 'atris.one_lap.v1',
261
646
  ok: false,
262
647
  status: 'stuck',
263
- reason: 'this workspace is not initialized',
264
- next_action: `${INIT_COMMAND} --yes`,
648
+ reason: freshWinLine(fields).replace(/\.$/, ''),
649
+ next_action: freshNextCommand(files, folder, { root, later }),
265
650
  };
266
651
  }
267
652
 
@@ -272,24 +657,28 @@ function renderWorkspace({
272
657
  recap = null,
273
658
  completedTitle = '',
274
659
  nextCommand = FIRST_USE_COMMAND,
660
+ liveWork = false,
661
+ movingTitle = '',
275
662
  } = {}) {
276
663
  const who = greet(person);
277
664
  const title = task && softTitle(task.title);
278
665
  let win = `${who}${folder || 'this folder'} is set up.`;
279
666
  if (task && task.status === 'done' && title) {
280
667
  win = `${who}you already shipped ${title}.`;
281
- } else if (task && task.status === 'review' && title) {
282
- win = isCertifiedReview(task)
283
- ? `${who}${title} is waiting for your ok.`
284
- : `${who}${title} is ready to look at.`;
668
+ } else if (task && task.status === 'review') {
669
+ win = `${who}something finished. waiting on you.`;
285
670
  } else if (task && task.status === 'claimed' && title) {
286
671
  win = `${who}${title} is already yours.`;
287
672
  } else if (task && task.status === 'open' && title) {
288
673
  win = `${who}${title} is ready to claim.`;
289
- } else if (completedTitle) {
674
+ } else if (movingTitle && liveWork) {
675
+ win = `${who}${softTitle(movingTitle)} is waiting.`;
676
+ } else if (completedTitle && !liveWork) {
290
677
  win = `${who}you already shipped ${softTitle(completedTitle)}.`;
291
- } else if (recap && recap.title) {
678
+ } else if (recap && recap.title && !liveWork) {
292
679
  win = `${who}you already have a recap in ${folder || 'this folder'}: ${recap.title}.`;
680
+ } else if (liveWork) {
681
+ win = `${who}${folder || 'this folder'} has work in motion.`;
293
682
  }
294
683
  const lines = [win];
295
684
  if (recap && recap.title && task) lines.push(`last recap: ${recap.title}.`);
@@ -305,28 +694,66 @@ function buildFirstMinute({
305
694
  folder,
306
695
  context = {},
307
696
  missions = [],
697
+ files,
698
+ commit,
699
+ dirty,
700
+ branch,
701
+ later,
308
702
  } = {}) {
309
703
  const who = person != null ? person : personName();
310
704
  const room = folder != null ? folder : folderName(root);
311
705
  if (fresh) {
706
+ const visible = files != null ? spokenVisibleNames(files) : listUserVisibleWork(root);
707
+ const remembered = later !== undefined ? spokenLaterNote(later) : readLaterNote(root);
312
708
  return {
313
709
  kind: 'fresh',
314
- text: renderFresh({ person: who, folder: room }),
315
- nextCommand: INIT_COMMAND,
710
+ text: renderFresh({
711
+ person: who,
712
+ folder: room,
713
+ files: visible,
714
+ root,
715
+ commit,
716
+ dirty,
717
+ branch,
718
+ later: remembered,
719
+ }),
720
+ nextCommand: freshNextCommand(visible, room, { root, later: remembered }),
721
+ files: visible,
722
+ later: remembered,
316
723
  };
317
724
  }
318
725
  const tasks = loadLocalTasks(root);
726
+ const todoBacklog = titlesFromTodoSection(root, 'Backlog');
727
+ const todoWip = titlesFromTodoSection(root, 'In Progress');
728
+ const backlogCount = todoBacklog
729
+ ? todoBacklog.length
730
+ : (Array.isArray(context.backlogTasks) ? context.backlogTasks.length : 0);
731
+ const inboxCount = typeof context.inboxCount === 'number' ? context.inboxCount : 0;
732
+ const wipCount = todoWip
733
+ ? todoWip.length
734
+ : (Array.isArray(context.inProgressTasks) ? context.inProgressTasks.length : 0);
319
735
  const picked = pickNext({
320
736
  tasks,
321
737
  missions,
322
738
  person: who,
323
739
  completedTitles: Array.isArray(context.completedTasks) ? context.completedTasks : [],
324
- backlogCount: Array.isArray(context.backlogTasks) ? context.backlogTasks.length : 0,
325
- inboxCount: typeof context.inboxCount === 'number' ? context.inboxCount : 0,
326
- wipCount: Array.isArray(context.inProgressTasks) ? context.inProgressTasks.length : 0,
740
+ backlogCount,
741
+ inboxCount,
742
+ wipCount,
327
743
  });
328
744
  const recap = loadLatestRecap(root);
329
745
  const completedTitle = Array.isArray(context.completedTasks) ? context.completedTasks[0] : '';
746
+ const movingTitle = (todoWip && todoWip[0])
747
+ || (todoBacklog && todoBacklog[0])
748
+ || (!todoBacklog && !todoWip && ((Array.isArray(context.inProgressTasks) && context.inProgressTasks[0])
749
+ || (Array.isArray(context.backlogTasks) && context.backlogTasks[0])))
750
+ || '';
751
+ const liveWork = hasLiveWork({
752
+ task: picked.task || null,
753
+ backlogCount,
754
+ wipCount,
755
+ inboxCount,
756
+ });
330
757
  return {
331
758
  kind: 'workspace',
332
759
  text: renderWorkspace({
@@ -336,6 +763,8 @@ function buildFirstMinute({
336
763
  recap,
337
764
  completedTitle,
338
765
  nextCommand: picked.command,
766
+ liveWork,
767
+ movingTitle,
339
768
  }),
340
769
  nextCommand: picked.command,
341
770
  task: picked.task || null,
@@ -350,6 +779,26 @@ function shouldAutoInitFresh(args = process.argv.slice(2), _env = process.env) {
350
779
  return list.includes('--yes') || list.includes('-y');
351
780
  }
352
781
 
782
+ // Looking at the next task is not first-talk. A quoted `task next`
783
+ // must not init a room in an empty folder.
784
+ function isTaskNextLook(value) {
785
+ return /^task\s+next\b/i.test(String(value || '').trim());
786
+ }
787
+
788
+ const LEFTOVER_LOOK_VERBS = new Set(['brainstorm', 'wish', 'log', 'plan', 'do', 'later', 'mission', 'next']);
789
+
790
+ // Leftover words after a known verb are not first-talk. A quoted
791
+ // `brainstorm hi` / `wish hi` / `later hi` / `mission hi` / `next hi`
792
+ // must not init a room or write a later note in an empty folder.
793
+ function isLeftoverVerbLook(value) {
794
+ const text = String(value || '').replace(/\s+/g, ' ').trim();
795
+ if (!text) return false;
796
+ if (isTaskNextLook(text)) return true;
797
+ const match = text.match(/^([A-Za-z][\w-]*)\s+\S/);
798
+ if (!match) return false;
799
+ return LEFTOVER_LOOK_VERBS.has(match[1].toLowerCase());
800
+ }
801
+
353
802
  function spokenLineCount(text) {
354
803
  return String(text || '')
355
804
  .split(/\n+/)
@@ -366,33 +815,129 @@ function speakFirstMinute({
366
815
  fresh,
367
816
  asJson = false,
368
817
  log = console.log,
818
+ files,
369
819
  } = {}) {
370
820
  const isFresh = fresh != null ? Boolean(fresh) : isFreshWorkspace(root);
821
+ const visible = files != null ? spokenVisibleNames(files) : listUserVisibleWork(root);
371
822
  if (asJson && isFresh) {
372
- log(JSON.stringify(freshMinuteJson(), null, 2));
823
+ log(JSON.stringify(freshMinuteJson(folderName(root), visible, { root }), null, 2));
373
824
  return 2;
374
825
  }
375
- const screen = buildFirstMinute({ root, fresh: isFresh });
826
+ const screen = buildFirstMinute({
827
+ root,
828
+ fresh: isFresh,
829
+ files: isFresh ? visible : undefined,
830
+ });
376
831
  log('');
377
832
  log(screen.text);
378
833
  return 0;
379
834
  }
380
835
 
836
+ function speakNothingRunning({
837
+ root = process.cwd(),
838
+ asJson = false,
839
+ log = console.log,
840
+ files,
841
+ } = {}) {
842
+ // Empty folder: nothing is running, same talk next as bare atris.
843
+ // A file or later note already here: name it, next is do. Do not mint.
844
+ const visible = files != null ? spokenVisibleNames(files) : listUserVisibleWork(root);
845
+ if (visible.length || resolveLaterNote({ root })) {
846
+ return speakFirstMinute({ root, fresh: true, asJson, log, files: visible });
847
+ }
848
+ const next = firstTalkCommand();
849
+ if (asJson) {
850
+ log(JSON.stringify({
851
+ schema: 'atris.one_lap.v1',
852
+ ok: false,
853
+ status: 'stuck',
854
+ reason: 'nothing is running',
855
+ next_action: next,
856
+ }, null, 2));
857
+ return 2;
858
+ }
859
+ log('');
860
+ log(`${greet(personName())}nothing is running.\n\nnext: ${next}`);
861
+ return 0;
862
+ }
863
+
864
+ function spokenWinReason(text) {
865
+ const line = String(text || '').split('\n').map((s) => s.trim()).find(Boolean) || '';
866
+ return line.replace(/^hey [^,]+, /i, '').replace(/\.$/, '');
867
+ }
868
+
869
+ function isKeepWorkingMinute(minute) {
870
+ const next = String((minute && minute.nextCommand) || '');
871
+ if (next === 'atris do') return true;
872
+ return isClaimedKeepWorkingCommand(next);
873
+ }
874
+
875
+ function isClaimMinute(minute) {
876
+ return Boolean(minute && /^atris task claim\b/.test(minute.nextCommand || ''));
877
+ }
878
+
879
+ function speakKeepWorkingMinute({
880
+ root = process.cwd(),
881
+ asJson = false,
882
+ log = console.log,
883
+ } = {}) {
884
+ // Just-minted file folder, nothing running: same two lines as
885
+ // first-minute / the next do. Not the factory board.
886
+ const minute = buildFirstMinute({ root });
887
+ if (asJson) {
888
+ const task = minute.task && minute.task.display_id
889
+ ? { display_id: minute.task.display_id, title: minute.task.title }
890
+ : null;
891
+ log(JSON.stringify({
892
+ schema: 'atris.one_lap.v1',
893
+ ok: true,
894
+ status: 'started',
895
+ reason: spokenWinReason(minute.text),
896
+ next_action: minute.nextCommand,
897
+ task,
898
+ }, null, 2));
899
+ return 0;
900
+ }
901
+ log('');
902
+ log(minute.text);
903
+ return 0;
904
+ }
905
+
381
906
  module.exports = {
382
907
  buildFirstMinute,
383
908
  deskNextCommand,
909
+ firstTalkCommand,
910
+ firstTalkJson,
911
+ firstTalkNext,
384
912
  folderName,
385
913
  freshMinuteJson,
914
+ freshNextCommand,
915
+ laterNextCommand,
916
+ laterNotePath,
386
917
  isBareAtrisFlag,
387
918
  isCertifiedReview,
919
+ isFirstTalkLine,
388
920
  isFreshWorkspace,
921
+ isLeftoverVerbLook,
922
+ isTaskNextLook,
923
+ listUserVisibleWork,
389
924
  personName,
390
925
  pickNext,
926
+ renderFirstTalk,
927
+ renderLaterRemember,
928
+ spokenLaterNote,
929
+ visibleWorkTitle,
391
930
  renderFresh,
392
931
  renderWorkspace,
393
932
  shouldAutoInitFresh,
394
933
  speakFirstMinute,
934
+ speakKeepWorkingMinute,
935
+ speakNothingRunning,
395
936
  spokenLineCount,
937
+ spokenWinReason,
938
+ isKeepWorkingMinute,
939
+ isClaimMinute,
396
940
  taskCommand,
397
941
  taskNextCommand,
942
+ writeLaterNote,
398
943
  };