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.
- 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 +134 -90
- package/commands/auth.js +6 -1
- package/commands/autopilot-front.js +29 -13
- package/commands/brainstorm.js +77 -476
- package/commands/business.js +13 -1
- package/commands/engine.js +7 -0
- package/commands/experiments.js +178 -0
- package/commands/fleet-report.js +2 -2
- package/commands/founder.js +12 -0
- package/commands/human-missions.js +64 -2
- package/commands/init.js +2 -35
- package/commands/integrations.js +100 -0
- package/commands/land.js +53 -23
- package/commands/later.js +52 -0
- package/commands/launchpad.js +45 -10
- package/commands/log.js +79 -30
- package/commands/mission.js +117 -22
- package/commands/next.js +153 -63
- package/commands/now.js +115 -38
- package/commands/recap.js +97 -4
- package/commands/run-front.js +20 -5
- package/commands/spaceship.js +52 -12
- package/commands/status.js +38 -7
- package/commands/task.js +56 -19
- package/commands/terminal.js +5 -5
- package/commands/workflow.js +19 -5
- package/commands/x-search.js +123 -13
- package/commands/youtube.js +941 -36
- package/lib/account-bound.js +7 -0
- package/lib/apply-gate.js +102 -0
- package/lib/config-guard.js +106 -0
- package/lib/context-gatherer.js +55 -8
- package/lib/engine-ask.js +16 -6
- package/lib/engine-registry.js +14 -4
- package/lib/first-minute.js +571 -26
- package/lib/known-commands.js +1 -1
- package/lib/pack-capabilities.js +13 -3
- package/lib/runner-command.js +5 -3
- package/lib/scratch-root.js +44 -0
- package/lib/workspace-scaffold.js +3 -1
- package/package.json +1 -1
- package/utils/auth.js +84 -6
package/lib/first-minute.js
CHANGED
|
@@ -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
|
|
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)
|
|
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
|
-
|
|
197
|
+
const projected = Array.isArray(parsed.tasks) ? parsed.tasks : [];
|
|
198
|
+
if (projected.some(isActionableTask) || !dbRows.length) return projected;
|
|
145
199
|
} catch {
|
|
146
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
584
|
+
`${greet(person)}${freshWinLine({ folder: room, files, root, commit, dirty, branch, later })}`,
|
|
253
585
|
'',
|
|
254
|
-
`next: ${
|
|
586
|
+
`next: ${freshNextCommand(files, room, extra)}`,
|
|
255
587
|
].join('\n');
|
|
256
588
|
}
|
|
257
589
|
|
|
258
|
-
function
|
|
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:
|
|
264
|
-
next_action:
|
|
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'
|
|
282
|
-
win =
|
|
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 (
|
|
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({
|
|
315
|
-
|
|
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
|
|
325
|
-
inboxCount
|
|
326
|
-
wipCount
|
|
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({
|
|
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
|
};
|