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