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.
- package/atris/skills/engines/SKILL.md +11 -3
- package/atris/skills/x-search/SKILL.md +20 -1
- package/atris/skills/youtube/SKILL.md +51 -43
- package/bin/atris.js +119 -87
- package/commands/autopilot-front.js +29 -13
- package/commands/brainstorm.js +77 -476
- package/commands/business.js +13 -1
- package/commands/fleet-report.js +2 -2
- package/commands/human-missions.js +26 -2
- package/commands/init.js +2 -35
- package/commands/integrations.js +266 -0
- package/commands/land.js +53 -23
- package/commands/later.js +52 -0
- package/commands/log.js +79 -30
- package/commands/mission.js +117 -22
- package/commands/next.js +153 -63
- package/commands/now.js +80 -38
- package/commands/recap.js +66 -4
- package/commands/run-front.js +20 -5
- package/commands/spaceship.js +52 -12
- package/commands/status.js +30 -7
- package/commands/task.js +70 -16
- package/commands/terminal.js +5 -5
- package/commands/workflow.js +18 -5
- package/commands/x-search.js +123 -13
- package/commands/youtube.js +639 -36
- package/lib/account-bound.js +7 -0
- package/lib/apply-gate.js +94 -0
- package/lib/context-gatherer.js +55 -8
- package/lib/engine-ask.js +16 -6
- package/lib/first-minute.js +552 -26
- package/lib/known-commands.js +1 -1
- package/lib/runner-command.js +5 -3
- package/lib/scratch-root.js +44 -0
- package/package.json +1 -1
- package/utils/auth.js +9 -0
package/lib/first-minute.js
CHANGED
|
@@ -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
|
|
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)
|
|
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
|
-
|
|
196
|
+
const projected = Array.isArray(parsed.tasks) ? parsed.tasks : [];
|
|
197
|
+
if (projected.some(isActionableTask) || !dbRows.length) return projected;
|
|
145
198
|
} catch {
|
|
146
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
572
|
+
`${greet(person)}${freshWinLine({ folder: room, files, root, commit, dirty, branch, later })}`,
|
|
253
573
|
'',
|
|
254
|
-
`next: ${
|
|
574
|
+
`next: ${freshNextCommand(files, room, extra)}`,
|
|
255
575
|
].join('\n');
|
|
256
576
|
}
|
|
257
577
|
|
|
258
|
-
function
|
|
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:
|
|
264
|
-
next_action:
|
|
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'
|
|
282
|
-
win =
|
|
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 (
|
|
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({
|
|
315
|
-
|
|
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
|
|
325
|
-
inboxCount
|
|
326
|
-
wipCount
|
|
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({
|
|
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
|
};
|