atris 3.80.1 → 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 (66) 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/autoland.js +25 -26
  21. package/commands/autopilot-front.js +46 -1
  22. package/commands/autopilot.js +55 -25
  23. package/commands/clean.js +22 -11
  24. package/commands/computer.js +1 -1
  25. package/commands/doc-health.js +48 -1
  26. package/commands/improve.js +16 -3
  27. package/commands/init.js +4 -0
  28. package/commands/integrations.js +69 -1616
  29. package/commands/mission.js +109 -13
  30. package/commands/release.js +41 -10
  31. package/commands/status.js +1 -1
  32. package/commands/sync.js +12 -8
  33. package/commands/task.js +144 -118
  34. package/commands/unknowns.js +9 -5
  35. package/commands/visualize.js +1 -12
  36. package/commands/workflow.js +4 -3
  37. package/commands/worktree.js +68 -1602
  38. package/commands/xp.js +23 -2113
  39. package/commands/youtube.js +192 -3110
  40. package/lib/business-core.js +3 -4
  41. package/lib/campaign-run-lock.js +12 -1
  42. package/lib/engine-ask.js +1 -1
  43. package/lib/engine-receipt-sweep.js +64 -0
  44. package/lib/file-lock.js +175 -0
  45. package/lib/fleet.js +1 -1
  46. package/lib/integrations-core.js +1695 -0
  47. package/lib/json-file.js +22 -1
  48. package/lib/loop-doctor.js +36 -2
  49. package/lib/map-refs.js +68 -8
  50. package/lib/member-engine.js +1 -1
  51. package/lib/member-map-fold.js +49 -11
  52. package/lib/member-map-inbox.js +2 -1
  53. package/lib/mission-core.js +9 -2
  54. package/lib/recall-exam.js +171 -0
  55. package/lib/roster-markdown.js +2 -2
  56. package/lib/runner-command.js +5 -5
  57. package/lib/task-db.js +105 -37
  58. package/lib/taste-notes.js +35 -0
  59. package/lib/team-lineup.js +3 -3
  60. package/lib/todo.js +2 -0
  61. package/lib/wiki.js +11 -3
  62. package/lib/worktree-core.js +1517 -0
  63. package/lib/xp-core.js +2172 -0
  64. package/lib/youtube-core.js +3178 -0
  65. package/lib/zen-apply.js +3 -1
  66. package/package.json +2 -2
@@ -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) {
@@ -2105,7 +2105,7 @@ async function runComputer(argv = process.argv.slice(3), deps = {}) {
2105
2105
  console.log(' atris computer local');
2106
2106
  console.log(' atris computer codex');
2107
2107
  console.log(' atris computer --cloud');
2108
- console.log(' atris computer --cloud --worker openai --model gpt-5.4');
2108
+ console.log(' atris computer --cloud --worker openai --model <model>');
2109
2109
  console.log(' atris computer cloud');
2110
2110
  console.log(' atris computer codeops');
2111
2111
  console.log(' atris computer codeops status');
@@ -5,6 +5,7 @@ const path = require('path');
5
5
  const { resolveWorkspaceRoot } = require('../lib/mission-root');
6
6
  const { readText } = require('./brain');
7
7
  const { checkMapDocs, fixMapDocs } = require('../lib/map-refs');
8
+ const { runRecallExam } = require('../lib/recall-exam');
8
9
 
9
10
  const BOOT_FILES = [
10
11
  'CLAUDE.md', 'AGENTS.md', 'atris/atris.md', 'atris/MAP.md',
@@ -357,12 +358,58 @@ function renderDocHealth(payload) {
357
358
  return lines.join('\n');
358
359
  }
359
360
 
361
+ function flagValue(args, name) {
362
+ const index = args.indexOf(name);
363
+ if (index >= 0 && args[index + 1] && !args[index + 1].startsWith('--')) return args[index + 1];
364
+ return args.find(arg => arg.startsWith(`${name}=`))?.slice(name.length + 1);
365
+ }
366
+
367
+ function renderRecall(report, summaryOnly) {
368
+ const pct = value => (value === null ? 'n/a' : `${Math.round(value * 100)}%`);
369
+ const lines = [
370
+ `recall exam: ${report.found}/${report.questions} found (${pct(report.found_share)})`,
371
+ `boot read first: ${report.boot_tokens} tokens`,
372
+ `files opened after boot: median ${report.files_opened.median}, mean ${report.files_opened.mean === null ? 'n/a' : report.files_opened.mean.toFixed(2)}`,
373
+ `wrong opens (opened but not on the path to the answer): total ${report.wrong_opens.total}, mean ${report.wrong_opens.mean === null ? 'n/a' : report.wrong_opens.mean.toFixed(2)}`,
374
+ `tokens opened after boot: median ${report.tokens_opened.median}, mean ${report.tokens_opened.mean}, median when found ${report.tokens_opened_when_found.median}`,
375
+ ...table(['area', 'found', 'total'], Object.entries(report.by_area).sort().map(([area, row]) => [area, row.found, row.total])),
376
+ ];
377
+ if (report.invalid_lines.length) lines.push(`invalid question lines skipped: ${report.invalid_lines.join(', ')}`);
378
+ if (!summaryOnly) {
379
+ lines.push('', ...table(['id', 'found', 'opened', 'wrong', 'tokens', 'answer in'],
380
+ report.results.map(row => [row.id || '-', row.found ? 'yes' : 'no', row.files_opened, row.wrong_opens, row.tokens_opened, row.found_in || '-'])));
381
+ }
382
+ return lines.join('\n');
383
+ }
384
+
385
+ function recallCommand(args, options, file) {
386
+ const root = resolveWorkspaceRoot(options.cwd || process.cwd());
387
+ const questionsFile = path.resolve(options.cwd || process.cwd(), file);
388
+ if (!stat(questionsFile)?.isFile()) {
389
+ console.log(`no recall questions at ${file}. one object per line: {"q":"...","needle":"exact answer phrase"}`);
390
+ return 1;
391
+ }
392
+ const maxOpens = Number(flagValue(args, '--max-opens')) || undefined;
393
+ const report = runRecallExam(root, questionsFile, { bootFiles: BOOT_FILES, maxOpens });
394
+ // A held-out set is scored without echoing its questions back to the improver.
395
+ const summaryOnly = args.includes('--summary');
396
+ if (args.includes('--json')) {
397
+ const { results, ...totals } = report;
398
+ console.log(JSON.stringify({ ok: true, action: 'doc-health recall', ...totals, ...(summaryOnly ? {} : { results }) }, null, 2));
399
+ } else console.log(renderRecall(report, summaryOnly));
400
+ return 0;
401
+ }
402
+
360
403
  function docHealthCommand(args = [], options = {}) {
361
404
  if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
362
- console.log('usage: atris doc-health [--json] [--questions <path>] [--fix-refs]');
405
+ console.log('usage: atris doc-health [--json] [--questions <path>] [--fix-refs] [--recall <path> [--summary] [--max-opens N]]');
363
406
  console.log(' --fix-refs rewrite map line refs whose code moved; list the ones a human must fix');
407
+ console.log(' --recall follow links from the boot files to answer each question; report found, files opened, tokens read');
408
+ console.log(' --summary with --recall, print totals only so a held-out set stays unseen');
364
409
  return 0;
365
410
  }
411
+ const recallFile = flagValue(args, '--recall');
412
+ if (recallFile) return recallCommand(args, options, recallFile);
366
413
  if (args.includes('--fix-refs')) {
367
414
  const root = resolveWorkspaceRoot(options.cwd || process.cwd());
368
415
  if (!stat(path.join(root, 'atris', 'MAP.md'))?.isFile()) {
@@ -96,6 +96,7 @@ function parseImproveArgs(argv = []) {
96
96
  timeoutMs: DEFAULT_TIMEOUT_MS,
97
97
  help: false,
98
98
  history: false,
99
+ unknownCommand: null,
99
100
  };
100
101
 
101
102
  for (let i = 0; i < args.length; i++) {
@@ -113,6 +114,7 @@ function parseImproveArgs(argv = []) {
113
114
  if (a.startsWith('--timeout=')) { const v = Number(a.split('=')[1]); if (v > 0) opts.timeoutMs = Math.round(v * 1000); continue; }
114
115
  // positional mode (plan|full|delegate)
115
116
  if (!a.startsWith('-') && VALID_MODES.has(a)) { opts.mode = a; continue; }
117
+ if (i === 0 && !a.startsWith('-')) { opts.unknownCommand = a; continue; }
116
118
  }
117
119
 
118
120
  if (!VALID_MODES.has(opts.mode)) opts.mode = 'full';
@@ -300,7 +302,7 @@ function formatTickHistory(summary = {}) {
300
302
  if (summary.rewardTrend && summary.rewardTrend.length) {
301
303
  lines.push(` trend: ${summary.rewardTrend.map((r) => (r == null ? '·' : r)).join(' → ')}`);
302
304
  }
303
- if (!summary.ticks) lines.push(' (no ticks yet, run `atris improve`)');
305
+ if (!summary.ticks) lines.push(' (no ticks yet, run `atris improve tick`)');
304
306
  return lines.join('\n');
305
307
  }
306
308
 
@@ -1049,6 +1051,7 @@ const REVISION_WINDOW_HOURS = 72;
1049
1051
  const REVISION_IGNORED_FILES = [
1050
1052
  /^atris\/MAP\.md$/,
1051
1053
  /^atris\/refs\/MAP-NOTES\.md$/,
1054
+ /^atris\/refs\/map\/[^/]+\.md$/,
1052
1055
  /^test\/fast-tests\.txt$/,
1053
1056
  /^atris\/logs\//,
1054
1057
  /^atris\/runs\//,
@@ -1507,7 +1510,7 @@ Modes (positional or --mode):
1507
1510
 
1508
1511
  Options:
1509
1512
  --member <name> attribute the tick to a member (the loop's owner)
1510
- --model <id> override the model (e.g. claude-sonnet-4-6)
1513
+ --model <id> override the model (e.g. sonnet)
1511
1514
  --dry-run plan and run but do not commit
1512
1515
  --no-fallback do not fall back to a local tick if the backend is down
1513
1516
  --workspace <p> workspace path (default: cwd)
@@ -1523,7 +1526,11 @@ the backend is unreachable or you are not logged in.`);
1523
1526
  }
1524
1527
 
1525
1528
  function isBareVitalsArgs(argv = []) {
1526
- return argv.length === 0 || (argv.length === 1 && argv[0] === '--json');
1529
+ if (argv.length === 0 || (argv.length === 1 && argv[0] === '--json')) return true;
1530
+ if (argv[0] === 'status' || argv[0] === 'vitals') {
1531
+ return argv.length === 1 || (argv.length === 2 && argv[1] === '--json');
1532
+ }
1533
+ return false;
1527
1534
  }
1528
1535
 
1529
1536
  const LOOP_DOCTOR_OPEN_STATUSES = new Set(['planning', 'ready', 'running', 'paused', 'blocked']);
@@ -1693,6 +1700,12 @@ async function run(argv = [], deps = {}) {
1693
1700
  const routedArgs = args[0] === 'tick' ? args.slice(1) : args;
1694
1701
  const opts = parseImproveArgs(routedArgs);
1695
1702
  if (opts.help) { showHelp(); return 0; }
1703
+ if (opts.unknownCommand) {
1704
+ const error = `unknown improve subcommand "${opts.unknownCommand}". try: atris improve, atris improve tick, atris improve history, atris improve doctor, or atris improve revisions.`;
1705
+ if (opts.json) console.log(JSON.stringify({ ok: false, error, subcommand: opts.unknownCommand }));
1706
+ else console.error(error);
1707
+ return 1;
1708
+ }
1696
1709
 
1697
1710
  if (opts.history) {
1698
1711
  const summary = summarizeTickHistory(readTickHistory(opts.workspace));
package/commands/init.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const fs = require('fs');
2
+ const { ensureProjectTasteFile } = require('../lib/taste-notes');
2
3
  const path = require('path');
3
4
  const { ensureMemberBundle } = require('../lib/member-scaffold');
4
5
  const { ensureExperimentsFramework } = require('./experiments');
@@ -507,6 +508,9 @@ function initAtris() {
507
508
  markReady('workspace', 'lessons.md', '✓ Created lessons.md');
508
509
  }
509
510
 
511
+ // Project taste starts empty and is never overwritten; sync leaves it alone.
512
+ if (ensureProjectTasteFile(targetDir)) markReady('workspace', 'taste/TASTE.md', '✓ Created taste/TASTE.md');
513
+
510
514
  // Create features directory and README
511
515
  const featuresDir = path.join(targetDir, 'features');
512
516
  const templatesDir = path.join(featuresDir, '_templates');