atris 3.80.0 → 3.82.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.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +10 -0
  3. package/atris/atris.md +14 -0
  4. package/atris/features/README.md +15 -0
  5. package/atris/policies/README.md +24 -0
  6. package/atris/skills/README.md +61 -45
  7. package/atris/skills/design/SKILL.md +6 -5
  8. package/atris/skills/taste/SKILL.md +39 -0
  9. package/atris/skills/taste/notes.md +29 -0
  10. package/atris/skills/taste/principles.md +23 -0
  11. package/atris/skills/wiki/SKILL.md +14 -2
  12. package/atris/skills/wiki/index-lines.md +30 -0
  13. package/atris/skills/wiki/measure.md +20 -0
  14. package/atris/skills/wiki/note-shape.md +16 -0
  15. package/atris/skills/wiki/upkeep.md +16 -0
  16. package/atris/team/README.md +38 -0
  17. package/atris.md +14 -0
  18. package/bin/atris.js +1 -1
  19. package/commands/activate.js +1 -1
  20. package/commands/auth.js +2 -2
  21. package/commands/autoland.js +25 -26
  22. package/commands/autopilot-front.js +46 -1
  23. package/commands/autopilot.js +55 -25
  24. package/commands/clean.js +22 -11
  25. package/commands/computer.js +166 -2413
  26. package/commands/doc-health.js +48 -1
  27. package/commands/improve.js +16 -3
  28. package/commands/init.js +4 -0
  29. package/commands/integrations.js +69 -1616
  30. package/commands/mission.js +109 -13
  31. package/commands/release.js +41 -10
  32. package/commands/security-review.js +5 -1
  33. package/commands/site-deploy.js +5 -1
  34. package/commands/status.js +1 -1
  35. package/commands/sync.js +12 -8
  36. package/commands/task.js +144 -118
  37. package/commands/unknowns.js +9 -5
  38. package/commands/visualize.js +1 -12
  39. package/commands/workflow.js +4 -3
  40. package/commands/worktree.js +68 -1602
  41. package/commands/xp.js +23 -2113
  42. package/commands/youtube.js +192 -3110
  43. package/lib/business-core.js +3 -4
  44. package/lib/campaign-run-lock.js +12 -1
  45. package/lib/computer-core.js +2447 -0
  46. package/lib/engine-ask.js +1 -1
  47. package/lib/engine-receipt-sweep.js +64 -0
  48. package/lib/file-lock.js +175 -0
  49. package/lib/fleet.js +1 -1
  50. package/lib/integrations-core.js +1695 -0
  51. package/lib/json-file.js +22 -1
  52. package/lib/loop-doctor.js +36 -2
  53. package/lib/map-refs.js +68 -8
  54. package/lib/member-engine.js +1 -1
  55. package/lib/member-map-fold.js +49 -11
  56. package/lib/member-map-inbox.js +2 -1
  57. package/lib/mission-core.js +9 -2
  58. package/lib/recall-exam.js +171 -0
  59. package/lib/roster-markdown.js +2 -2
  60. package/lib/runner-command.js +5 -5
  61. package/lib/security-audit-classes.js +20 -2
  62. package/lib/security-audit.js +31 -1
  63. package/lib/task-db.js +105 -37
  64. package/lib/taste-notes.js +35 -0
  65. package/lib/team-lineup.js +3 -3
  66. package/lib/todo.js +2 -0
  67. package/lib/wiki.js +11 -3
  68. package/lib/worktree-core.js +1517 -0
  69. package/lib/xp-core.js +2172 -0
  70. package/lib/youtube-core.js +3178 -0
  71. package/lib/zen-apply.js +3 -1
  72. package/package.json +10 -3
  73. package/utils/auth.js +18 -4
package/commands/auth.js CHANGED
@@ -737,7 +737,7 @@ async function loginAtris(options = {}) {
737
737
 
738
738
  openBrowser(loginUrl);
739
739
 
740
- const code = await promptUser('CLI code: ');
740
+ const code = await promptUser('CLI code: ', { secret: true });
741
741
  if (!code) {
742
742
  console.error('Error: Code is required.');
743
743
  process.exit(1);
@@ -774,7 +774,7 @@ async function loginAtris(options = {}) {
774
774
  } else if (methodChoice === '2') {
775
775
  console.log('\nGet your token from: https://atris.ai/auth/cli\n');
776
776
 
777
- const tokenInput = await promptUser('API token: ');
777
+ const tokenInput = await promptUser('API token: ', { secret: true });
778
778
 
779
779
  if (!tokenInput) {
780
780
  console.error('Error: Token is required.');
@@ -9,7 +9,8 @@ const { localDay } = require('../lib/local-day');
9
9
  const autoland = require('../lib/autoland');
10
10
  const { gateForHuman, plainLandingReason } = require('../lib/voice-gate');
11
11
  const { evaluateAutoAccept } = require('../lib/auto-accept-certified');
12
- const { sweepEngineAskReceipts } = require('../lib/engine-receipt-sweep');
12
+ const { sweepEngineAskReceipts, sweepFleetReceipts } = require('../lib/engine-receipt-sweep');
13
+ const { acquireFileLock } = require('../lib/file-lock');
13
14
  const { readSshAgents, findLeakedSshAgents, findAgentStartLines, agentStartLineText, stopLeakedSshAgents, closeLostRuns, readResourceHistory, errorReason, leakGrowthNote } = require('../lib/engine-resource');
14
15
  const { validateRecentAskReceipts } = require('../lib/engine-validate');
15
16
  const { operatorReady, hasAgentJargon, explainResult } = autoland;
@@ -731,15 +732,6 @@ function runDigest(root, args, { forceSend = false } = {}) {
731
732
  return 0;
732
733
  }
733
734
 
734
- function pidAlive(pid) {
735
- try {
736
- process.kill(Number(pid), 0);
737
- return true;
738
- } catch {
739
- return false;
740
- }
741
- }
742
-
743
735
  async function runTick(root, args) {
744
736
  const json = args.includes('--json');
745
737
  const policy = autoland.readPolicy(root);
@@ -756,24 +748,23 @@ async function runTick(root, args) {
756
748
  // double-alarms, and double-digests. A stale lock (dead pid or >55min)
757
749
  // never wedges the loop.
758
750
  const lockPath = path.join(root, '.atris', 'state', 'autoland.tick.lock');
751
+ let lock;
759
752
  try {
760
- const prev = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
761
- const fresh = Date.now() - Number(prev.at || 0) < 55 * 60 * 1000;
762
- if (fresh && pidAlive(prev.pid)) {
763
- receipt.skipped_reason = 'tick_already_running';
764
- if (json) console.log(JSON.stringify(receipt));
765
- else console.log('autoland tick: another tick is still running; skipped.');
766
- return 0;
767
- }
768
- } catch {}
769
- try {
770
- fs.mkdirSync(path.dirname(lockPath), { recursive: true });
771
- fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, at: Date.now() }));
772
- } catch {}
753
+ lock = acquireFileLock(lockPath, { staleAfterMs: 55 * 60 * 1000 });
754
+ } catch {
755
+ lock = null;
756
+ }
757
+ if (lock && !lock.ok) {
758
+ receipt.skipped_reason = 'tick_already_running';
759
+ if (json) console.log(JSON.stringify(receipt));
760
+ else console.log('autoland tick: another tick is still running; skipped.');
761
+ return 0;
762
+ }
773
763
  try {
774
764
  return await runTickBody(root, { json, policy, receipt });
775
765
  } finally {
776
- try { fs.unlinkSync(lockPath); } catch {}
766
+ // Only removes the lock while it is still this tick's.
767
+ if (lock) lock.release();
777
768
  }
778
769
  }
779
770
 
@@ -864,8 +855,10 @@ function digestTickStatus(receipt) {
864
855
 
865
856
  function engineReceiptSweepNote(receipt) {
866
857
  const finalized = Number(receipt.engine_receipt_sweep?.finalized) || 0;
867
- if (!finalized) return '';
868
- return `, finalized ${finalized} stale engine receipt${finalized === 1 ? '' : 's'} as presumed dead`;
858
+ const flights = Number(receipt.fleet_receipt_sweep?.finalized) || 0;
859
+ const engineNote = finalized ? `, finalized ${finalized} stale engine receipt${finalized === 1 ? '' : 's'} as presumed dead` : '';
860
+ const fleetNote = flights ? `, marked ${flights} crashed flight${flights === 1 ? '' : 's'} presumed dead` : '';
861
+ return engineNote + fleetNote;
869
862
  }
870
863
 
871
864
  // Read only: how many ssh-agents look leaked right now, and which readers
@@ -1041,6 +1034,12 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
1041
1034
  } catch (err) {
1042
1035
  receipt.engine_receipt_sweep_error = String((err && err.message) || err).slice(0, 200);
1043
1036
  }
1037
+ try {
1038
+ const fleetSweep = sweepFleetReceipts(root);
1039
+ if (fleetSweep.scanned > 0) receipt.fleet_receipt_sweep = fleetSweep;
1040
+ } catch (err) {
1041
+ receipt.fleet_receipt_sweep_error = String((err && err.message) || err).slice(0, 200);
1042
+ }
1044
1043
 
1045
1044
  runSshAgentSweep(root, receipt);
1046
1045
  runLostRunSweep(root, receipt);
@@ -12,6 +12,7 @@ const { wantsJson, hasYesFlag } = require('../lib/noninteractive');
12
12
  const { isUnboundScratchFolder, refuseUnboundScratch } = require('../lib/scratch-root');
13
13
  const { resolveWorkspaceRoot } = require('../lib/mission-root');
14
14
  const { personName } = require('../lib/first-minute');
15
+ const { acquireFileLock } = require('../lib/file-lock');
15
16
 
16
17
  const CLI_PATH = path.join(__dirname, '..', 'bin', 'atris.js');
17
18
  const DEFAULT_LEG_WALL_SECONDS = 3600;
@@ -21,6 +22,7 @@ const MAX_FAST_FAILS = 5;
21
22
 
22
23
  function statePath(root) { return path.join(root, '.atris', 'state', 'autopilot.json'); }
23
24
  function stopPath(root) { return path.join(root, '.atris', 'state', 'autopilot.stop'); }
25
+ function lockPath(root) { return path.join(root, '.atris', 'state', 'autopilot.lock'); }
24
26
 
25
27
  function readState(root) {
26
28
  try { return JSON.parse(fs.readFileSync(statePath(root), 'utf8')); } catch { return null; }
@@ -42,6 +44,26 @@ function requestStop(root) {
42
44
  }
43
45
  function clearStop(root) { try { fs.unlinkSync(stopPath(root)); } catch {} }
44
46
 
47
+ // When `autopilot stop` wrote its marker, in ms, or null without one.
48
+ function stopRequestedAtMs(root) {
49
+ let stat;
50
+ try { stat = fs.statSync(stopPath(root)); } catch { return null; }
51
+ let stamped = NaN;
52
+ try { stamped = Date.parse(fs.readFileSync(stopPath(root), 'utf8').trim()); } catch {}
53
+ return Number.isFinite(stamped) ? Math.max(stamped, stat.mtimeMs) : stat.mtimeMs;
54
+ }
55
+
56
+ // A stop marker left over from before this start is cleared. One written
57
+ // after this process launched is a real stop that raced the start, so it is
58
+ // kept and honored. Returns true when such a stop is pending.
59
+ function clearStaleStop(root, startRequestedAtMs) {
60
+ const at = stopRequestedAtMs(root);
61
+ if (at == null) return false;
62
+ if (at >= startRequestedAtMs) return true;
63
+ clearStop(root);
64
+ return false;
65
+ }
66
+
45
67
  function pidAlive(pid) {
46
68
  if (!Number.isFinite(pid) || pid <= 0) return false;
47
69
  try { process.kill(pid, 0); return true; } catch { return false; }
@@ -261,8 +283,28 @@ async function autopilotFront(args = []) {
261
283
  console.log(`Autopilot already running (pid ${existing.pid}). Stop it first: atris autopilot stop`);
262
284
  return 1;
263
285
  }
286
+ // The state check above is read-then-write; two starts in the same instant
287
+ // both passed it. The lock file is created exclusively, so only one wins.
288
+ const lock = acquireFileLock(lockPath(root));
289
+ if (!lock.ok) {
290
+ console.log(`Autopilot already running (pid ${lock.holder?.pid || '?'}). Stop it first: atris autopilot stop`);
291
+ return 1;
292
+ }
293
+ try {
294
+ return await runFrontLoop(root, args, lock);
295
+ } finally {
296
+ lock.release();
297
+ }
298
+ }
264
299
 
265
- clearStop(root);
300
+ async function runFrontLoop(root, args, lock) {
301
+ // The moment this process launched. A stop issued after it is not stale.
302
+ const startRequestedAtMs = Date.now() - Math.round(process.uptime() * 1000);
303
+ if (clearStaleStop(root, startRequestedAtMs)) {
304
+ clearStop(root);
305
+ console.log('\nAutopilot off (stop requested). Legs run: 0.');
306
+ return 0;
307
+ }
266
308
  const budgetSeconds = runBudgetSeconds(args);
267
309
  const legWallDefault = positiveNumber(readValueFlag(args, '--leg-wall')) || DEFAULT_LEG_WALL_SECONDS;
268
310
  // Honor the legacy contract callers still speak: `--iterations=N` caps the
@@ -283,6 +325,8 @@ async function autopilotFront(args = []) {
283
325
  console.log(`\nAutopilot off (${reason}). Legs run: ${state.legs}.`);
284
326
  clearState(root);
285
327
  clearStop(root);
328
+ // process.exit skips the caller's finally, so release here.
329
+ try { lock.release(); } catch {}
286
330
  process.exit(130);
287
331
  };
288
332
  process.on('SIGINT', onSignal);
@@ -344,6 +388,7 @@ module.exports = {
344
388
  stopRequested,
345
389
  requestStop,
346
390
  clearStop,
391
+ clearStaleStop,
347
392
  readState,
348
393
  writeState,
349
394
  watchStopFile,
@@ -1867,24 +1867,41 @@ function isDoPhaseTimeoutMessage(message) {
1867
1867
  * un-struck bullet whose normalized text contains the normalized title
1868
1868
  * prefix; `- **T33:** …` becomes `- [x] **T33:** …`, `- [ ]` becomes `- [x]`.
1869
1869
  * Returns true if a bullet was marked.
1870
+ *
1871
+ * A rendered TODO.md (it carries the "Regenerated from durable Atris task
1872
+ * state" marker) is a view of the task database, so there the job itself is
1873
+ * finished, the way `atris task done` finishes it, and the view re-rendered.
1874
+ * Returns false when no single open or claimed job matches: re-rendering alone
1875
+ * used to return true, and the tick reported a job advanced that never moved.
1870
1876
  */
1871
1877
  function markTodoBulletDone(cwd, taskTitle) {
1878
+ const needle = taskMatchNeedle(taskTitle);
1879
+ let isRenderedView = false;
1872
1880
  try {
1873
- // A rendered TODO.md carries the "Regenerated from durable Atris task
1874
- // state" marker; those are projections and never hand-edited. Legacy
1875
- // hand-written TODO.md files (no marker) keep the bullet-mark fallback.
1876
1881
  const renderedTodoPath = path.join(cwd, 'atris', 'TODO.md');
1877
- const isRenderedView = fs.existsSync(renderedTodoPath)
1882
+ isRenderedView = fs.existsSync(renderedTodoPath)
1878
1883
  && fs.readFileSync(renderedTodoPath, 'utf8').includes('Regenerated from durable Atris task state');
1879
- if (isRenderedView) {
1884
+ } catch {
1885
+ isRenderedView = false;
1886
+ }
1887
+ if (isRenderedView) {
1888
+ if (!needle) return false;
1889
+ try {
1880
1890
  require('node:sqlite');
1881
- const rendered = require('./task').autoRenderTodoFromDb(cwd);
1882
- if (rendered) return true;
1891
+ const out = require('./task').completeTodoTaskByTitle(cwd, taskTitle, {
1892
+ matches: (title) => {
1893
+ const norm = normalizeForMatch(title);
1894
+ // The reverse test covers a tick title with a prefix ("T33: ..."); a short job title must not match by it.
1895
+ return Boolean(norm) && (norm.includes(needle) || (norm.length >= 12 && needle.includes(norm)));
1896
+ },
1897
+ actor: 'autopilot',
1898
+ proof: `autopilot tick finished "${String(taskTitle || '').slice(0, 200)}" and its completion receipt is in today's journal`,
1899
+ });
1900
+ return Boolean(out && out.completed);
1901
+ } catch {
1902
+ // Older Node runtimes without node:sqlite fall through to the markdown edit.
1883
1903
  }
1884
- } catch {
1885
- // Older Node runtimes and legacy workspaces retain the markdown fallback.
1886
1904
  }
1887
- const needle = taskMatchNeedle(taskTitle);
1888
1905
  if (!needle) return false;
1889
1906
  for (const name of ['TODO.md', 'todo.md']) {
1890
1907
  const todoPath = path.join(cwd, 'atris', name);
@@ -2211,26 +2228,38 @@ function logCompletion(description) {
2211
2228
  ensureLogDirectory();
2212
2229
  const { logFile, dateFormatted } = getLogPath();
2213
2230
 
2214
- if (!fs.existsSync(logFile)) {
2215
- createLogFile(logFile, dateFormatted);
2216
- }
2231
+ // The next C number is read and the entry written as one step under a lock file, so two ticks
2232
+ // finishing at once cannot both take the same number. A lock that cannot be had in a few seconds
2233
+ // does not cost the entry: it is written anyway, as before.
2234
+ const write = () => {
2235
+ if (!fs.existsSync(logFile)) {
2236
+ createLogFile(logFile, dateFormatted);
2237
+ }
2217
2238
 
2218
- let content = fs.readFileSync(logFile, 'utf8');
2239
+ let content = fs.readFileSync(logFile, 'utf8');
2219
2240
 
2220
- const completionMatch = content.match(/\*\*C(\d+):/g);
2221
- const nextId = completionMatch
2222
- ? Math.max(...completionMatch.map(m => parseInt(m.match(/\d+/)[0]))) + 1
2223
- : 1;
2241
+ const completionMatch = content.match(/\*\*C(\d+):/g);
2242
+ const nextId = completionMatch
2243
+ ? Math.max(...completionMatch.map(m => parseInt(m.match(/\d+/)[0]))) + 1
2244
+ : 1;
2224
2245
 
2225
- const entry = `- **C${nextId}:** ${description} [reviewed]`;
2246
+ const entry = `- **C${nextId}:** ${description} [reviewed]`;
2226
2247
 
2227
- if (content.includes('## Completed')) {
2228
- content = content.replace(/(## Completed[^\n]*\n)/, `$1\n${entry}\n`);
2229
- } else {
2230
- content += `\n## Completed\n\n${entry}\n`;
2231
- }
2248
+ // Readers look for "## Completed ✅" (and, since this fix, a bare "## Completed" too).
2249
+ if (/^## Completed\b/m.test(content)) {
2250
+ content = content.replace(/^(## Completed[^\n]*\n)/m, `$1\n${entry}\n`);
2251
+ } else {
2252
+ content += `\n## Completed ✅\n\n${entry}\n`;
2253
+ }
2232
2254
 
2233
- fs.writeFileSync(logFile, content);
2255
+ fs.writeFileSync(logFile, content);
2256
+ };
2257
+ try {
2258
+ require('../lib/handoff-wake').withFileLock(`${logFile}.lock`, write, { staleMs: 60 * 1000, waitMs: 5 * 1000 });
2259
+ } catch (err) {
2260
+ if (!/stayed locked/.test(String(err && err.message))) throw err;
2261
+ write();
2262
+ }
2234
2263
  }
2235
2264
 
2236
2265
  /**
@@ -4055,6 +4084,7 @@ module.exports = {
4055
4084
  diffAdvancedRepoHeads,
4056
4085
  reconcileTimedOutTick,
4057
4086
  markTodoBulletDone,
4087
+ logCompletion,
4058
4088
  findCompletionReceipt,
4059
4089
  isDoPhaseTimeoutMessage,
4060
4090
  validateVerifyCommandShape,
package/commands/clean.js CHANGED
@@ -359,23 +359,34 @@ function findStaleTasks(atrisDir) {
359
359
  * Detects both out-of-bounds AND drift (symbol moved to different line)
360
360
  * Returns { healed: number, unhealable: array }
361
361
  */
362
- // The boot map routes; atris/refs/MAP-NOTES.md holds the deep file:line refs.
363
- // Heal both. Entries from the notes file carry `map` so reports can say where.
362
+ // The boot map routes; atris/refs/MAP-NOTES.md holds the deep file:line refs,
363
+ // or indexes area notes under atris/refs/map/ that hold them. Heal every one.
364
+ // Entries from a notes file carry `map` so reports can say where.
364
365
  const MAP_NOTES_FILE = path.join('refs', 'MAP-NOTES.md');
366
+ const MAP_NOTES_DIR = path.join('refs', 'map');
367
+
368
+ function mapNoteFiles(atrisDir) {
369
+ const files = [path.join(atrisDir, MAP_NOTES_FILE)];
370
+ try {
371
+ for (const name of fs.readdirSync(path.join(atrisDir, MAP_NOTES_DIR)).sort()) {
372
+ if (name.endsWith('.md')) files.push(path.join(atrisDir, MAP_NOTES_DIR, name));
373
+ }
374
+ } catch { /* no area notes */ }
375
+ return files.filter((file) => { try { return fs.statSync(file).isFile(); } catch { return false; } });
376
+ }
365
377
 
366
378
  function healBrokenMapRefs(cwd, atrisDir, dryRun = false, homeDir = os.homedir()) {
367
379
  const mapFile = path.join(atrisDir, 'MAP.md');
368
380
  if (!fs.existsSync(mapFile)) return { healed: 0, unhealable: [] };
369
381
  const result = healMapFileRefs(cwd, mapFile, dryRun, homeDir);
370
- const notesFile = path.join(atrisDir, MAP_NOTES_FILE);
371
- if (!fs.existsSync(notesFile)) return result;
372
- const notes = healMapFileRefs(cwd, notesFile, dryRun, homeDir);
373
- const map = path.relative(cwd, notesFile).split(path.sep).join('/');
374
- return {
375
- healed: result.healed + notes.healed,
376
- unhealable: [...result.unhealable, ...notes.unhealable.map((entry) => ({ ...entry, map }))],
377
- replacements: [...result.replacements, ...notes.replacements.map((entry) => ({ ...entry, map }))],
378
- };
382
+ for (const notesFile of mapNoteFiles(atrisDir)) {
383
+ const notes = healMapFileRefs(cwd, notesFile, dryRun, homeDir);
384
+ const map = path.relative(cwd, notesFile).split(path.sep).join('/');
385
+ result.healed += notes.healed;
386
+ result.unhealable = [...result.unhealable, ...notes.unhealable.map((entry) => ({ ...entry, map }))];
387
+ result.replacements = [...(result.replacements || []), ...notes.replacements.map((entry) => ({ ...entry, map }))];
388
+ }
389
+ return result;
379
390
  }
380
391
 
381
392
  function healMapFileRefs(cwd, mapFile, dryRun, homeDir) {