atris 3.57.4 → 3.58.6

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 (175) hide show
  1. package/README.md +12 -1
  2. package/atris/skills/clawhub/member-runtime/SKILL.md +9 -9
  3. package/atris/skills/create-member/SKILL.md +16 -13
  4. package/atris/skills/design/SKILL.md +27 -4
  5. package/atris/skills/engines/SKILL.md +55 -12
  6. package/atris/skills/x-search/SKILL.md +8 -6
  7. package/atris/skills/youtube/SKILL.md +66 -21
  8. package/atris/team/brainstormer/MEMBER.md +2 -2
  9. package/atris/team/executor/MEMBER.md +3 -3
  10. package/atris/team/launcher/MEMBER.md +4 -4
  11. package/atris/team/navigator/MEMBER.md +3 -3
  12. package/atris/team/researcher/MEMBER.md +2 -2
  13. package/atris/team/validator/MEMBER.md +1 -1
  14. package/atris.md +14 -0
  15. package/ax +153 -23
  16. package/bin/atris.js +78 -30
  17. package/commands/aeo.js +4 -4
  18. package/commands/agents.js +1 -1
  19. package/commands/align.js +12 -12
  20. package/commands/analytics.js +3 -3
  21. package/commands/app.js +5 -5
  22. package/commands/ask.js +175 -0
  23. package/commands/auth.js +59 -31
  24. package/commands/autoland.js +29 -5
  25. package/commands/autopilot-front.js +6 -6
  26. package/commands/autopilot.js +90 -90
  27. package/commands/avail.js +5 -5
  28. package/commands/bench.js +63 -1
  29. package/commands/browse.js +2 -2
  30. package/commands/business.js +12 -12
  31. package/commands/card.js +2 -2
  32. package/commands/clean.js +13 -13
  33. package/commands/close.js +3 -3
  34. package/commands/compile.js +13 -13
  35. package/commands/computer.js +94 -27
  36. package/commands/console.js +5 -5
  37. package/commands/decide.js +1 -1
  38. package/commands/deck.js +9 -9
  39. package/commands/drive.js +4 -4
  40. package/commands/engine.js +173 -19
  41. package/commands/errors.js +3 -3
  42. package/commands/experiments.js +18 -1
  43. package/commands/feed.js +2 -2
  44. package/commands/feedback.js +4 -4
  45. package/commands/fleet.js +12 -12
  46. package/commands/game.js +1 -1
  47. package/commands/guide.js +49 -0
  48. package/commands/improve.js +19 -17
  49. package/commands/init.js +67 -19
  50. package/commands/interview.js +7 -7
  51. package/commands/land.js +40 -15
  52. package/commands/learn.js +216 -50
  53. package/commands/lesson.js +2 -2
  54. package/commands/log.js +1 -1
  55. package/commands/loops.js +4 -4
  56. package/commands/member.js +75 -50
  57. package/commands/mission.js +90 -82
  58. package/commands/one-lap.js +1 -1
  59. package/commands/pack.js +74 -8
  60. package/commands/plugin.js +6 -6
  61. package/commands/probe.js +9 -9
  62. package/commands/pull.js +35 -35
  63. package/commands/pulse.js +1 -1
  64. package/commands/push.js +21 -21
  65. package/commands/rainmaker.js +1 -1
  66. package/commands/recap.js +7 -7
  67. package/commands/reel.js +2 -2
  68. package/commands/release.js +3 -3
  69. package/commands/report.js +1 -1
  70. package/commands/review.js +5 -5
  71. package/commands/revisions.js +1 -1
  72. package/commands/run.js +1 -1
  73. package/commands/scout.js +3 -3
  74. package/commands/security-review.js +7 -7
  75. package/commands/serve.js +9 -9
  76. package/commands/setup.js +1 -1
  77. package/commands/sign.js +4 -4
  78. package/commands/signup.js +4 -4
  79. package/commands/site.js +8 -3
  80. package/commands/skill.js +6 -6
  81. package/commands/slop.js +18 -18
  82. package/commands/social.js +3 -3
  83. package/commands/soul.js +14 -14
  84. package/commands/spaceship.js +1 -1
  85. package/commands/status.js +2 -2
  86. package/commands/strings.js +13 -13
  87. package/commands/study.js +4 -4
  88. package/commands/sync.js +8 -8
  89. package/commands/task.js +50 -25
  90. package/commands/teach.js +2 -2
  91. package/commands/team.js +1 -1
  92. package/commands/theme.js +2 -2
  93. package/commands/tree.js +34 -0
  94. package/commands/truth.js +7 -7
  95. package/commands/verify.js +12 -12
  96. package/commands/visualize.js +2 -2
  97. package/commands/workflow.js +101 -48
  98. package/commands/worktree.js +1 -1
  99. package/commands/write.js +9 -9
  100. package/commands/x-search.js +93 -22
  101. package/commands/xp.js +6 -3
  102. package/commands/youtube.js +1019 -123
  103. package/lib/accept-verify-gate.js +3 -3
  104. package/lib/activity-stream.js +1 -1
  105. package/lib/auto-accept-certified.js +28 -12
  106. package/lib/autoland.js +6 -6
  107. package/lib/bench/engines.js +59 -8
  108. package/lib/bench/report.js +1 -1
  109. package/lib/bench/runner.js +204 -5
  110. package/lib/bench/tree-render.js +76 -0
  111. package/lib/card.js +1 -1
  112. package/lib/chat-log-scan.js +1 -1
  113. package/lib/claude-boot-block.js +2 -0
  114. package/lib/deck-compose.js +1 -1
  115. package/lib/deck-history.js +2 -2
  116. package/lib/deck-layout.js +1 -1
  117. package/lib/deck-review.js +5 -5
  118. package/lib/deck-schema.js +1 -1
  119. package/lib/default-verifier.js +1 -1
  120. package/lib/developer-api.js +3 -0
  121. package/lib/engine-ask.js +45 -7
  122. package/lib/engine-registry.js +15 -2
  123. package/lib/file-ops.js +1 -1
  124. package/lib/fleet.js +71 -35
  125. package/lib/html-render.js +1 -1
  126. package/lib/intents.js +172 -0
  127. package/lib/journal.js +1 -1
  128. package/lib/known-commands.js +2 -2
  129. package/lib/land-green.js +364 -0
  130. package/lib/lesson-contradiction.js +2 -2
  131. package/lib/lesson-preflight.js +1 -1
  132. package/lib/manifest.js +5 -5
  133. package/lib/member-alive.js +9 -5
  134. package/lib/memory-view.js +3 -3
  135. package/lib/mission-protected-lane.js +1 -1
  136. package/lib/mission-root.js +2 -2
  137. package/lib/orb-context.js +10 -10
  138. package/lib/pack-capabilities.js +861 -3
  139. package/lib/permission-grants.js +2 -2
  140. package/lib/policy-lessons.js +6 -6
  141. package/lib/pulse.js +8 -8
  142. package/lib/receipt-evidence.js +1 -1
  143. package/lib/reel.js +1 -1
  144. package/lib/review-integrity.js +1 -1
  145. package/lib/runner-command.js +35 -5
  146. package/lib/scorecard.js +4 -4
  147. package/lib/security-scan.js +5 -5
  148. package/lib/self-drive.js +1 -1
  149. package/lib/site-publish.js +372 -0
  150. package/lib/site.js +1 -1
  151. package/lib/slides-deck.js +2 -2
  152. package/lib/state-detection.js +2 -2
  153. package/lib/sync-telemetry.js +2 -2
  154. package/lib/task-db.js +17 -10
  155. package/lib/task-explanation.js +1 -1
  156. package/lib/task-proof.js +2 -2
  157. package/lib/task-receipt.js +3 -3
  158. package/lib/theme.js +12 -12
  159. package/lib/todo-fallback.js +3 -3
  160. package/lib/todo-sections.js +1 -1
  161. package/lib/tree-hash.js +79 -0
  162. package/lib/usage.js +1 -1
  163. package/lib/wish-delegate.js +1 -1
  164. package/lib/workspace-safety.js +4 -4
  165. package/lib/workspace-scaffold.js +3 -3
  166. package/package.json +1 -1
  167. package/scripts/det/concierge-bakeoff.sh +26 -0
  168. package/scripts/det/ytnotes +82 -5
  169. package/scripts/det/ytquote-repair.js +0 -1
  170. package/scripts/det/ytrail-race.js +0 -5
  171. package/scripts/member-operate.mjs +52 -14
  172. package/scripts/outbound-artifact-gate.js +0 -3
  173. package/utils/auth.js +47 -9
  174. package/utils/config.js +0 -1
  175. package/scripts/det/checklist-score.js +0 -191
@@ -31,6 +31,8 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
31
31
  output(` ${commandName} search --paid "<query>" [--limit N] [--json]`);
32
32
  output(` ${commandName} notes <youtube-url> [youtube-url-or-playlist...] [engine] [--save]`);
33
33
  output(` ${commandName} teach <youtube-url> [--section N] [--save] [--recap TEXT] [--skip]`);
34
+ output(` ${commandName} teach owed [--json]`);
35
+ output(` ${commandName} teach next [--json]`);
34
36
  output(` ${commandName} unsave <url-or-id>`);
35
37
  output(` ${commandName} process <youtube-url> [options]`);
36
38
  output(` ${commandName} digest [--days N]`);
@@ -40,14 +42,14 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
40
42
  output(` ${commandName} watch tick`);
41
43
  output(` ${commandName} <youtube-url> [options]`);
42
44
  output('');
43
- output('search = free local discovery (ytsearch / yt-dlp), returns youtu.be links');
44
- output('search --paid = 5 credits, watch permalinks + titles from Atris');
45
- output('notes = free local notes to stdout; ephemeral unless --save');
46
- output('teach = one chapter from local captions; ephemeral unless --save');
47
- output('rich ephemeral notes/teach print one apply next-step (no files)');
48
- output('process = 5 credits cloud knowledge (needs a filled Apply)');
49
- output('digest = one decision page from this week\'s video briefs');
50
- output('watch = subscribed channels turn into briefs without a human');
45
+ output('search = free local discovery (ytsearch / yt-dlp), returns youtu.be links; rich free search writes one apply and a failing keep/revert pack; thin hands off to teach');
46
+ output('search --paid = 5 credits, watch permalinks + titles from Atris; rich paid search writes one apply and a failing keep/revert pack; thin hands off to teach');
47
+ output('notes = free local notes to stdout; ephemeral unless --save; hands off to teach');
48
+ output('teach = one chapter from local captions; bare teach resumes unpaid checks, then the next chapter after recap or skip');
49
+ output('rich ephemeral notes/teach print one apply next-step and one failing check (no files)');
50
+ output('process = 5 credits cloud knowledge (needs a filled Apply); rich process writes one apply and a failing keep/revert pack');
51
+ output('digest = one decision page from this week\'s video briefs; rich digest writes one apply and a failing keep/revert pack');
52
+ output('watch = subscribed channels turn into briefs without a human; add hands off to tick; tick hands off to teach when it briefed');
51
53
  output('Process a YouTube video through Atris using timestamped transcript-first analysis.');
52
54
  output('Falls back to cloud video processing when local captions are unavailable.');
53
55
  output('');
@@ -58,6 +60,8 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
58
60
  output(' --section <n> Chapter to teach, 1-based (teach only, default: 1)');
59
61
  output(' --recap <text> Unlock the next teach section with the unpaid check');
60
62
  output(' --skip Unlock the next teach section without answering');
63
+ output(' owed Print the unpaid teach check (no network)');
64
+ output(' next Next unlocked chapter (no url, no billing)');
61
65
  output(' --unsave Delete filed brief, apply stub, and matching notes/teach experiment packs (no paid calls)');
62
66
  output(' --query, -q <text> Focus question for the analysis');
63
67
  output(' --agent <id> Agent id to store knowledge against');
@@ -77,6 +81,7 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
77
81
  output(` ${commandName} notes https://www.youtube.com/watch?v=VIDEO_ID --save`);
78
82
  output(` ${commandName} teach "https://www.youtube.com/watch?v=VIDEO_ID"`);
79
83
  output(` ${commandName} teach "https://www.youtube.com/watch?v=VIDEO_ID" --section 2`);
84
+ output(` ${commandName} teach next`);
80
85
  output(` ${commandName} notes --unsave VIDEO_ID`);
81
86
  output(` ${commandName} unsave VIDEO_ID`);
82
87
  output(` ${commandName} notes https://www.youtube.com/watch?v=VIDEO_ID https://youtu.be/OTHER_ID`);
@@ -206,8 +211,16 @@ function youtubeFailureError(result) {
206
211
  ? ' Run "atris login --force".'
207
212
  : result.status === 402
208
213
  ? ' Check Atris credits.'
209
- : '';
210
- return new Error(`YouTube processing failed (${result.status}): ${resultErrorText(result)}.${hint}`);
214
+ : result.status === 502
215
+ ? ' YouTube processing is unavailable; retry in a few seconds.'
216
+ : '';
217
+ const credits = paidSearchCredits(result.data);
218
+ const refundHint = result.status === 502 && creditsRefundedExplicitly(credits)
219
+ ? ' credits refunded.'
220
+ : '';
221
+ const lines = [`YouTube processing failed (${result.status}): ${resultErrorText(result)}.${hint}${refundHint}`];
222
+ lines.push(...formatCreditsLines(credits));
223
+ return new Error(lines.join('\n'));
211
224
  }
212
225
 
213
226
  function captionHostAllowed(urlString) {
@@ -401,33 +414,110 @@ function parseCaptionText(raw) {
401
414
  return segments.join(' ');
402
415
  }
403
416
 
417
+ function parseYtDlpInfoJson(result) {
418
+ const raw = String((result && result.stdout) || '').trim();
419
+ if (!raw) return null;
420
+ try {
421
+ const info = JSON.parse(raw);
422
+ return info && typeof info === 'object' && !Array.isArray(info) ? info : null;
423
+ } catch {
424
+ return null;
425
+ }
426
+ }
427
+
428
+ function localCaptionNames(id) {
429
+ // scripts/det/ytnotes keeps the same VTT names (not clean.txt).
430
+ return [
431
+ `yt_${id}.en.vtt`,
432
+ `yt_${id}.en-orig.vtt`,
433
+ `yt_${id}.en-US.vtt`,
434
+ `yt_${id}.en-GB.vtt`,
435
+ `yt_${id}.clean.txt`,
436
+ ];
437
+ }
438
+
439
+ function captionLookupIds({ url, id } = {}) {
440
+ const ids = [];
441
+ const seen = new Set();
442
+ const add = (value) => {
443
+ const text = String(value || '').trim();
444
+ if (!text || seen.has(text)) return;
445
+ seen.add(text);
446
+ ids.push(text);
447
+ };
448
+ add(id);
449
+ add(videoIdFromUrl(url));
450
+ return ids;
451
+ }
452
+
453
+ function readLocalCaptionText({ url, id, workDir } = {}) {
454
+ if (!workDir) return '';
455
+ for (const videoId of captionLookupIds({ url, id })) {
456
+ for (const name of localCaptionNames(videoId)) {
457
+ const abs = path.join(workDir, name);
458
+ try {
459
+ if (!fs.existsSync(abs)) continue;
460
+ const text = fs.readFileSync(abs, 'utf8');
461
+ if (String(text).trim()) return text;
462
+ } catch {
463
+ // try the next written caption file
464
+ }
465
+ }
466
+ }
467
+ return '';
468
+ }
469
+
470
+ async function loadCaptionRaw(info, youtubeUrl, deps = {}) {
471
+ const payload = info && typeof info === 'object' ? info : {};
472
+ const selected = chooseCaptionTrack(payload);
473
+ let raw = '';
474
+ if (selected?.track?.url) {
475
+ raw = await (deps.fetchCaptionText || fetchCaptionText)(selected.track.url);
476
+ }
477
+ if (String(raw || '').trim()) {
478
+ return { raw, language: selected?.language || 'unknown' };
479
+ }
480
+ const local = readLocalCaptionText({
481
+ url: youtubeUrl,
482
+ id: payload.id,
483
+ workDir: deps.workDir,
484
+ });
485
+ if (!String(local || '').trim()) return null;
486
+ return { raw: local, language: selected?.language || 'en' };
487
+ }
488
+
489
+ function ytDlpInfoArgs(youtubeUrl) {
490
+ return ['-J', '--skip-download', '--no-warnings', '--no-playlist', youtubeUrl];
491
+ }
492
+
493
+ function teachSourceVideoId(info, youtubeUrl) {
494
+ const fromUrl = videoIdFromUrl(youtubeUrl);
495
+ const fromInfo = info && info.id;
496
+ const entries = info && Array.isArray(info.entries) ? info.entries : [];
497
+ const fromEntry = entries[0] && entries[0].id;
498
+ if (info && (info._type === 'playlist' || entries.length)) {
499
+ return fromUrl || fromEntry || fromInfo;
500
+ }
501
+ return fromInfo || fromUrl;
502
+ }
503
+
404
504
  async function extractLocalTranscript(youtubeUrl, deps = {}) {
405
505
  if (process.env.ATRIS_YOUTUBE_LOCAL_TRANSCRIPT === '0') return null;
406
506
  const runner = deps.spawnSync || spawnSync;
407
- const result = runner('yt-dlp', ['-J', '--skip-download', '--no-warnings', youtubeUrl], {
507
+ const result = runner('yt-dlp', ytDlpInfoArgs(youtubeUrl), {
408
508
  encoding: 'utf8',
409
509
  timeout: 20000,
410
510
  maxBuffer: 10 * 1024 * 1024,
411
511
  });
412
- if (result.error || result.status !== 0 || !result.stdout) return null;
413
-
414
- let info;
415
- try {
416
- info = JSON.parse(result.stdout);
417
- } catch {
418
- return null;
419
- }
420
-
421
- const selected = chooseCaptionTrack(info);
422
- if (!selected?.track?.url) return null;
423
- const rawCaption = await (deps.fetchCaptionText || fetchCaptionText)(selected.track.url);
424
- const transcript = parseCaptionText(rawCaption);
512
+ const info = parseYtDlpInfoJson(result);
513
+ const loaded = await loadCaptionRaw(info, youtubeUrl, deps);
514
+ const transcript = parseCaptionText(loaded && loaded.raw);
425
515
  if (!transcript) return null;
426
516
 
427
517
  return {
428
518
  transcriptText: transcript.slice(0, LOCAL_TRANSCRIPT_MAX_CHARS),
429
- language: selected.language || 'unknown',
430
- durationSeconds: Number(info.duration || 0) || undefined,
519
+ language: loaded.language || 'unknown',
520
+ durationSeconds: Number((info && info.duration) || 0) || undefined,
431
521
  };
432
522
  }
433
523
 
@@ -463,6 +553,12 @@ async function processYoutube(options, deps = {}) {
463
553
  if (!result.ok && result.status === 401 && !auth.minted) {
464
554
  const remint = await ensureBilled('youtube', { ...deps, forceMint: true });
465
555
  if (remint?.ok && remint.token) {
556
+ if (!options.json) {
557
+ const print = typeof deps.output === 'function' ? deps.output : () => {};
558
+ for (const line of formatCreditsLines(paidSearchCredits(result.data))) {
559
+ print(line);
560
+ }
561
+ }
466
562
  auth = remint;
467
563
  result = await apiFn('/agent/process_youtube', {
468
564
  method: 'POST',
@@ -479,7 +575,10 @@ async function processYoutube(options, deps = {}) {
479
575
  const localExtractor = deps.extractLocalTranscript || extractLocalTranscript;
480
576
  let localTranscript = null;
481
577
  try {
482
- localTranscript = await localExtractor(options.youtubeUrl, deps);
578
+ localTranscript = await localExtractor(options.youtubeUrl, {
579
+ ...deps,
580
+ workDir: deps.workDir || notesWorkDir(deps),
581
+ });
483
582
  } catch {
484
583
  localTranscript = null;
485
584
  }
@@ -496,6 +595,12 @@ async function processYoutube(options, deps = {}) {
496
595
  if (transcriptResult.status === 401 || transcriptResult.status === 402 || transcriptResult.status === 400) {
497
596
  throw youtubeFailureError(transcriptResult);
498
597
  }
598
+ if (!options.json) {
599
+ const print = typeof deps.output === 'function' ? deps.output : () => {};
600
+ for (const line of formatCreditsLines(paidSearchCredits(transcriptResult.data))) {
601
+ print(line);
602
+ }
603
+ }
499
604
  }
500
605
 
501
606
  const result = await requestYoutube(buildYoutubePayload(options));
@@ -507,6 +612,17 @@ async function processYoutube(options, deps = {}) {
507
612
  return result.data;
508
613
  }
509
614
 
615
+ function processAnalysisText(data) {
616
+ return data?.video_analysis || data?.analysis || data?.result || '';
617
+ }
618
+
619
+ function printProcessLearnerGate(data, { json = false } = {}, output) {
620
+ printLearnerCheckGate(output, notesLessonFromText(processAnalysisText(data)), {
621
+ includeCheck: true,
622
+ json,
623
+ });
624
+ }
625
+
510
626
  function formatYoutubeResult(data) {
511
627
  const lines = [];
512
628
  const metadata = data?.metadata || {};
@@ -521,12 +637,9 @@ function formatYoutubeResult(data) {
521
637
  : '';
522
638
  lines.push(`Processing: ${method}${source}`);
523
639
  }
524
- if (data?.credits_used !== undefined || data?.credits_remaining !== undefined) {
525
- const used = data.credits_used !== undefined ? data.credits_used : '?';
526
- const remaining = data.credits_remaining !== undefined ? data.credits_remaining : '?';
527
- lines.push(`Credits: ${used} used, ${remaining} remaining`);
528
- }
529
- const analysis = data?.video_analysis || data?.analysis || data?.result;
640
+ const creditLines = formatCreditsLines(paidSearchCredits(data));
641
+ if (creditLines.length) lines.push(...creditLines);
642
+ const analysis = processAnalysisText(data);
530
643
  if (analysis) {
531
644
  lines.push('');
532
645
  lines.push(String(analysis).trim());
@@ -535,11 +648,13 @@ function formatYoutubeResult(data) {
535
648
  }
536
649
 
537
650
  function videoIdFromUrl(url) {
538
- const text = String(url || '');
651
+ const text = String(url || '').split('#')[0];
539
652
  const watch = text.match(/[?&]v=([^&]+)/);
540
653
  if (watch) return watch[1];
541
654
  const short = text.match(/youtu\.be\/([^?&/]+)/);
542
- return short ? short[1] : null;
655
+ if (short) return short[1];
656
+ const pathId = text.match(/\/(?:shorts|embed|live|v|e)\/([^?&/]+)/i);
657
+ return pathId ? pathId[1] : null;
543
658
  }
544
659
 
545
660
  function videoIdFromArg(arg) {
@@ -553,7 +668,7 @@ function videoIdFromArg(arg) {
553
668
  function looksLikeYoutubeUrl(arg) {
554
669
  const text = String(arg || '').trim();
555
670
  if (!text || text.startsWith('-')) return false;
556
- return /youtube\.com|youtu\.be/i.test(text);
671
+ return /youtube\.com|youtu\.be|youtube-nocookie\.com/i.test(text);
557
672
  }
558
673
 
559
674
  function isPlaylistUrl(url) {
@@ -567,22 +682,24 @@ function parseNotesArgs(argv = []) {
567
682
  let help = false;
568
683
  let save = false;
569
684
  let unsave = false;
685
+ let json = false;
570
686
  for (const raw of argv) {
571
687
  const arg = String(raw);
572
688
  if (arg === '--help' || arg === '-h' || arg === 'help') help = true;
573
689
  else if (arg === '--save') save = true;
574
690
  else if (arg === '--unsave') unsave = true;
691
+ else if (arg === '--json') json = true;
575
692
  }
576
693
  for (const raw of argv) {
577
694
  const arg = String(raw);
578
695
  if (arg === '--help' || arg === '-h' || arg === 'help') continue;
579
- if (arg === '--save' || arg === '--unsave') continue;
696
+ if (arg === '--save' || arg === '--unsave' || arg === '--json') continue;
580
697
  if (arg.startsWith('-')) continue;
581
698
  if (looksLikeYoutubeUrl(arg)) urls.push(arg);
582
699
  else if (unsave && videoIdFromArg(arg)) urls.push(arg);
583
700
  else engine = arg;
584
701
  }
585
- return { urls, engine, help, save, unsave };
702
+ return { urls, engine, help, save, unsave, json };
586
703
  }
587
704
 
588
705
  function dateStamp(now) {
@@ -676,11 +793,19 @@ function readNotesText({ url, workDir } = {}) {
676
793
  }
677
794
  }
678
795
 
796
+ function notesWorkDir(deps = {}) {
797
+ return deps.workDir || path.join(process.env.TMPDIR || '/tmp', 'ytnotes');
798
+ }
799
+
800
+ function keptPrintedNotes({ url, workDir } = {}) {
801
+ return Boolean(String(readNotesText({ url, workDir }) || '').trim());
802
+ }
803
+
679
804
  function saveRichNotes(url, deps = {}) {
680
805
  const workDir = deps.workDir || path.join(process.env.TMPDIR || '/tmp', 'ytnotes');
681
806
  const lesson = notesLessonFromText(readNotesText({ url, workDir }));
682
807
  if (isThinTeachLesson(lesson)) {
683
- return { thin: true, brief: null, packRel: null };
808
+ return { thin: true, brief: null, packRel: null, lesson };
684
809
  }
685
810
  const brief = fileNotesBrief(url, deps);
686
811
  const id = videoIdFromUrl(url);
@@ -691,20 +816,21 @@ function saveRichNotes(url, deps = {}) {
691
816
  slug: id ? notesExperimentSlug(id) : null,
692
817
  applyRel: id ? applySidecarRel(id) : null,
693
818
  });
694
- return { thin: false, brief, packRel };
819
+ return { thin: false, brief, packRel, lesson };
695
820
  }
696
821
 
697
822
  function ensureNotesApply({ cwd, url, packRel, now, output } = {}) {
698
823
  const id = videoIdFromUrl(url);
699
824
  const pack = packRel || (id ? notesExperimentRel(id) : null);
825
+ const slug = pack ? path.basename(pack) : null;
700
826
  return applyGate.ensureApply({
701
827
  cwd,
702
828
  source: url,
703
829
  rel: id ? applySidecarRel(id) : null,
704
830
  now,
705
831
  output,
706
- incompleteMessage: pack
707
- ? `next: apply ${pack}. keep only if measure.py moves 0→1`
832
+ incompleteMessage: slug
833
+ ? `next: atris experiments keep ${slug}`
708
834
  : APPLY_NEXT_MESSAGE,
709
835
  required: false,
710
836
  change: pack ? `apply ${pack}` : undefined,
@@ -787,6 +913,8 @@ function unsaveYoutubeNotes(target, deps = {}) {
787
913
  }
788
914
  for (const rel of listTeachSidecarRels(cwd, id)) add(rel);
789
915
  add(notesExperimentRel(id));
916
+ add(processApplyRel(id));
917
+ add(processExperimentRel(id));
790
918
  for (const section of sections) add(teachExperimentRel(id, section));
791
919
 
792
920
  const removed = [];
@@ -815,6 +943,9 @@ function runYoutubeUnsave(args = [], deps = {}) {
815
943
  const status = unsaveYoutubeNotes(target, deps);
816
944
  if (status !== 0) code = status;
817
945
  }
946
+ if (code === 0 && parsed.json !== true && deps.json !== true) {
947
+ printWatchSearchNext(output);
948
+ }
818
949
  return code;
819
950
  }
820
951
 
@@ -831,6 +962,85 @@ function ensureProcessApply({ cwd, url, now, output } = {}) {
831
962
  });
832
963
  }
833
964
 
965
+ function processExperimentSlug(id) {
966
+ return `process-${experimentIdToken(id)}`;
967
+ }
968
+
969
+ function processExperimentRel(id) {
970
+ return `atris/experiments/${processExperimentSlug(id)}`;
971
+ }
972
+
973
+ function processApplyRel(id) {
974
+ return applyGate.applySidecarRel('process', experimentIdToken(id));
975
+ }
976
+
977
+ function saveRichProcess({ cwd, url, lesson } = {}) {
978
+ if (isThinTeachLesson(lesson)) {
979
+ return { thin: true, packRel: null, lesson };
980
+ }
981
+ if (cwd) fs.mkdirSync(path.join(cwd, 'atris', 'wiki'), { recursive: true });
982
+ const id = videoIdFromUrl(url);
983
+ const packRel = fileTeachExperiment({
984
+ cwd,
985
+ url,
986
+ lesson,
987
+ slug: id ? processExperimentSlug(id) : null,
988
+ applyRel: id ? processApplyRel(id) : null,
989
+ });
990
+ return { thin: false, packRel, lesson, source: url };
991
+ }
992
+
993
+ function ensureProcessLearnerApply({ cwd, url, packRel, now, output, source } = {}) {
994
+ const id = videoIdFromUrl(url);
995
+ const pack = packRel || (id ? processExperimentRel(id) : null);
996
+ const slug = pack ? path.basename(pack) : null;
997
+ if (cwd) fs.mkdirSync(path.join(cwd, 'atris', 'wiki'), { recursive: true });
998
+ return applyGate.ensureApply({
999
+ cwd,
1000
+ source: source || url || (id ? `process:${id}` : 'process'),
1001
+ rel: id ? processApplyRel(id) : null,
1002
+ now,
1003
+ output,
1004
+ incompleteMessage: slug
1005
+ ? `next: atris experiments keep ${slug}`
1006
+ : applyGate.ephemeralApplyMessage('process'),
1007
+ required: false,
1008
+ change: pack ? `apply ${pack}` : undefined,
1009
+ receipt: pack ? TEACH_KEEP_RULE : undefined,
1010
+ journalLine: pack ? `- [claimable] apply: ${pack}. ${TEACH_KEEP_RULE}` : undefined,
1011
+ });
1012
+ }
1013
+
1014
+ function mintRichProcess({ cwd, url, data, now, output, ensureApply, json } = {}) {
1015
+ const print = typeof output === 'function' ? output : (line = '') => console.log(line);
1016
+ const lesson = notesLessonFromText(processAnalysisText(data));
1017
+ if (isThinTeachLesson(lesson)) {
1018
+ printProcessLearnerGate(data, { json }, print);
1019
+ return 0;
1020
+ }
1021
+ const saved = saveRichProcess({ cwd, url, lesson });
1022
+ const applyFn = ensureApply || ensureProcessLearnerApply;
1023
+ const applyCode = applyFn({
1024
+ cwd,
1025
+ url,
1026
+ packRel: saved.packRel,
1027
+ now,
1028
+ output: print,
1029
+ source: url,
1030
+ });
1031
+ if (ensureApply) return applyCode;
1032
+ const id = videoIdFromUrl(url);
1033
+ const baseline = proveSavedLearnerBaseline({
1034
+ cwd,
1035
+ applyRel: id ? processApplyRel(id) : null,
1036
+ lesson: saved.lesson,
1037
+ output: print,
1038
+ json,
1039
+ });
1040
+ if (baseline !== 0) return baseline;
1041
+ return applyCode;
1042
+ }
1043
+
834
1044
  const DIGEST_ENGINE_TIMEOUT_MS = 240000;
835
1045
  const DEFAULT_DIGEST_DAYS = 7;
836
1046
 
@@ -977,6 +1187,113 @@ function appendDigestJournal({ cwd, date, relDigest }) {
977
1187
  fs.writeFileSync(journalPath, `${existing}${prefix}${line}\n`);
978
1188
  }
979
1189
 
1190
+ function digestExperimentSlug(date) {
1191
+ return `digest-${String(date || '').slice(0, 10)}`;
1192
+ }
1193
+
1194
+ function digestExperimentRel(date) {
1195
+ return `atris/experiments/${digestExperimentSlug(date)}`;
1196
+ }
1197
+
1198
+ function digestApplyRel(date) {
1199
+ return applyGate.applySidecarRel('digest', String(date || '').slice(0, 10));
1200
+ }
1201
+
1202
+ function ensureDigestApply({ cwd, date, packRel, now, output, source } = {}) {
1203
+ const stamp = String(date || '').slice(0, 10);
1204
+ const pack = packRel || (stamp ? digestExperimentRel(stamp) : null);
1205
+ const slug = pack ? path.basename(pack) : null;
1206
+ return applyGate.ensureApply({
1207
+ cwd,
1208
+ source: source || (stamp ? `digest:${stamp}` : 'digest'),
1209
+ rel: stamp ? digestApplyRel(stamp) : null,
1210
+ now,
1211
+ output,
1212
+ incompleteMessage: slug
1213
+ ? `next: atris experiments keep ${slug}`
1214
+ : applyGate.ephemeralApplyMessage('digest'),
1215
+ required: false,
1216
+ change: pack ? `apply ${pack}` : undefined,
1217
+ receipt: pack ? TEACH_KEEP_RULE : undefined,
1218
+ journalLine: pack ? `- [claimable] apply: ${pack}. ${TEACH_KEEP_RULE}` : undefined,
1219
+ });
1220
+ }
1221
+
1222
+ function saveRichDigest({ cwd, date, lesson, source } = {}) {
1223
+ if (isThinTeachLesson(lesson)) {
1224
+ return { thin: true, packRel: null, lesson };
1225
+ }
1226
+ const packRel = fileTeachExperiment({
1227
+ cwd,
1228
+ lesson,
1229
+ slug: date ? digestExperimentSlug(date) : null,
1230
+ applyRel: date ? digestApplyRel(date) : null,
1231
+ });
1232
+ return { thin: false, packRel, lesson, source };
1233
+ }
1234
+
1235
+ function watchExperimentSlug(id) {
1236
+ return `watch-${experimentIdToken(id)}`;
1237
+ }
1238
+
1239
+ function watchExperimentRel(id) {
1240
+ return `atris/experiments/${watchExperimentSlug(id)}`;
1241
+ }
1242
+
1243
+ function watchApplyRel(id) {
1244
+ return applyGate.applySidecarRel('watch', experimentIdToken(id));
1245
+ }
1246
+
1247
+ function ensureWatchWiki(cwd) {
1248
+ if (!cwd) return;
1249
+ fs.mkdirSync(path.join(cwd, 'atris', 'wiki'), { recursive: true });
1250
+ }
1251
+
1252
+ function ensureWatchApply({ cwd, url, packRel, now, output, source } = {}) {
1253
+ const id = videoIdFromUrl(url);
1254
+ const pack = packRel || (id ? watchExperimentRel(id) : null);
1255
+ const slug = pack ? path.basename(pack) : null;
1256
+ ensureWatchWiki(cwd);
1257
+ return applyGate.ensureApply({
1258
+ cwd,
1259
+ source: source || url || (id ? `watch:${id}` : 'watch'),
1260
+ rel: id ? watchApplyRel(id) : null,
1261
+ now,
1262
+ output,
1263
+ incompleteMessage: slug
1264
+ ? `next: atris experiments keep ${slug}`
1265
+ : applyGate.ephemeralApplyMessage('watch'),
1266
+ required: false,
1267
+ change: pack ? `apply ${pack}` : undefined,
1268
+ receipt: pack ? TEACH_KEEP_RULE : undefined,
1269
+ journalLine: pack ? `- [claimable] apply: ${pack}. ${TEACH_KEEP_RULE}` : undefined,
1270
+ });
1271
+ }
1272
+
1273
+ function firstRichWatchLesson(urls, workDir) {
1274
+ for (const url of Array.isArray(urls) ? urls : []) {
1275
+ if (!url) continue;
1276
+ const lesson = notesLessonFromText(readNotesText({ url, workDir }));
1277
+ if (!isThinTeachLesson(lesson)) return { url, lesson };
1278
+ }
1279
+ return null;
1280
+ }
1281
+
1282
+ function saveRichWatch({ cwd, url, lesson } = {}) {
1283
+ if (isThinTeachLesson(lesson)) {
1284
+ return { thin: true, packRel: null, lesson };
1285
+ }
1286
+ const id = videoIdFromUrl(url);
1287
+ const packRel = fileTeachExperiment({
1288
+ cwd,
1289
+ url,
1290
+ lesson,
1291
+ slug: id ? watchExperimentSlug(id) : null,
1292
+ applyRel: id ? watchApplyRel(id) : null,
1293
+ });
1294
+ return { thin: false, packRel, lesson, source: url };
1295
+ }
1296
+
980
1297
  function runYoutubeDigest(args = [], deps = {}) {
981
1298
  const output = deps.output || ((line = '') => console.log(line));
982
1299
  let options;
@@ -996,6 +1313,7 @@ function runYoutubeDigest(args = [], deps = {}) {
996
1313
  const briefs = collectVideoBriefs({ cwd, now, days: options.days });
997
1314
  if (!briefs.length) {
998
1315
  output(`no video briefs in the last ${options.days} days`);
1316
+ printWatchSearchNext(output);
999
1317
  return 0;
1000
1318
  }
1001
1319
 
@@ -1023,7 +1341,32 @@ function runYoutubeDigest(args = [], deps = {}) {
1023
1341
  fs.writeFileSync(path.join(cwd, relDigest), `${header}\n\n${text}\n`);
1024
1342
  appendDigestJournal({ cwd, date, relDigest });
1025
1343
  output(`digest filed: ${relDigest} (${briefs.length} briefs)`);
1026
- return 0;
1344
+ const lesson = notesLessonFromText(text);
1345
+ if (isThinTeachLesson(lesson)) {
1346
+ printLearnerCheckGate(output, lesson, { includeCheck: true });
1347
+ printWatchTickNext(output);
1348
+ return 0;
1349
+ }
1350
+
1351
+ const saved = saveRichDigest({ cwd, date, lesson, source: relDigest });
1352
+ const ensureApply = deps.ensureApply || ensureDigestApply;
1353
+ const applyCode = ensureApply({
1354
+ cwd,
1355
+ date,
1356
+ packRel: saved.packRel,
1357
+ now,
1358
+ output,
1359
+ source: relDigest,
1360
+ });
1361
+ if (deps.ensureApply) return applyCode;
1362
+ const baseline = proveSavedLearnerBaseline({
1363
+ cwd,
1364
+ applyRel: digestApplyRel(date),
1365
+ lesson,
1366
+ output,
1367
+ });
1368
+ if (baseline !== 0) return baseline;
1369
+ return applyCode;
1027
1370
  }
1028
1371
 
1029
1372
  function watchStatePath(cwd = process.cwd()) {
@@ -1130,6 +1473,7 @@ function defaultChannelFetcher(videosUrl, deps = {}) {
1130
1473
  const result = spawn('yt-dlp', [
1131
1474
  '--no-update',
1132
1475
  '--flat-playlist',
1476
+ '--no-warnings',
1133
1477
  '--playlist-end',
1134
1478
  '3',
1135
1479
  '--print',
@@ -1140,11 +1484,13 @@ function defaultChannelFetcher(videosUrl, deps = {}) {
1140
1484
  timeout: 60000,
1141
1485
  maxBuffer: 2 * 1024 * 1024,
1142
1486
  });
1487
+ const videos = parseFlatPlaylist(result && result.stdout);
1488
+ if (videos.length) return videos;
1143
1489
  if (result.error || result.status !== 0) {
1144
1490
  const detail = String(result.stderr || result.error?.message || 'fetch failed').trim();
1145
1491
  throw new Error(detail || 'fetch failed');
1146
1492
  }
1147
- return parseFlatPlaylist(result.stdout);
1493
+ return videos;
1148
1494
  }
1149
1495
 
1150
1496
  function defaultNotesRunner(url, deps = {}) {
@@ -1172,6 +1518,22 @@ function markSeen(state, channel, id, timestamp) {
1172
1518
  state.seenByChannel[channel][id] = timestamp;
1173
1519
  }
1174
1520
 
1521
+ const WATCH_TICK_NEXT = 'next: atris youtube watch tick';
1522
+ const WATCH_ADD_NEXT = 'next: atris youtube watch add <channel-url-or-@handle>';
1523
+ const WATCH_SEARCH_NEXT = 'next: atris youtube search " "';
1524
+
1525
+ function printWatchTickNext(output) {
1526
+ output(WATCH_TICK_NEXT);
1527
+ }
1528
+
1529
+ function printWatchAddNext(output) {
1530
+ output(WATCH_ADD_NEXT);
1531
+ }
1532
+
1533
+ function printWatchSearchNext(output) {
1534
+ output(WATCH_SEARCH_NEXT);
1535
+ }
1536
+
1175
1537
  function addWatchChannel(channelInput, deps = {}) {
1176
1538
  const output = deps.output || ((line = '') => console.log(line));
1177
1539
  if (!channelInput) {
@@ -1191,6 +1553,7 @@ function addWatchChannel(channelInput, deps = {}) {
1191
1553
  const state = loadWatchState(statePath);
1192
1554
  if (state.channels.some((row) => row.channel === channel)) {
1193
1555
  output(`already watching ${channel}`);
1556
+ printWatchTickNext(output);
1194
1557
  return 0;
1195
1558
  }
1196
1559
 
@@ -1200,6 +1563,7 @@ function addWatchChannel(channelInput, deps = {}) {
1200
1563
  });
1201
1564
  saveWatchState(statePath, state);
1202
1565
  output(`watching ${channel}`);
1566
+ printWatchTickNext(output);
1203
1567
  return 0;
1204
1568
  }
1205
1569
 
@@ -1208,6 +1572,7 @@ function listWatchChannels(deps = {}) {
1208
1572
  const state = loadWatchState(resolveWatchStatePath(deps));
1209
1573
  if (!state.channels.length) {
1210
1574
  output('no channels watched');
1575
+ printWatchAddNext(output);
1211
1576
  return 0;
1212
1577
  }
1213
1578
 
@@ -1215,6 +1580,7 @@ function listWatchChannels(deps = {}) {
1215
1580
  const count = Object.keys(channelSeenMap(state, row.channel)).length;
1216
1581
  output(`${index + 1}. ${row.channel} (${count} seen)`);
1217
1582
  });
1583
+ printWatchTickNext(output);
1218
1584
  return 0;
1219
1585
  }
1220
1586
 
@@ -1231,6 +1597,11 @@ function removeWatchChannel(rawNumber, deps = {}) {
1231
1597
  const [removed] = state.channels.splice(index - 1, 1);
1232
1598
  saveWatchState(statePath, state);
1233
1599
  output(`removed ${removed.channel}`);
1600
+ if (!state.channels.length) {
1601
+ printWatchAddNext(output);
1602
+ } else {
1603
+ printWatchTickNext(output);
1604
+ }
1234
1605
  return 0;
1235
1606
  }
1236
1607
 
@@ -1248,6 +1619,8 @@ async function tickWatch(deps = {}) {
1248
1619
  const state = loadWatchState(statePath);
1249
1620
  let totalNew = 0;
1250
1621
  let totalBriefed = 0;
1622
+ let firstBriefedUrl = null;
1623
+ const briefedUrls = [];
1251
1624
 
1252
1625
  for (const row of state.channels) {
1253
1626
  const videosUrl = channelVideosUrl(row.channel);
@@ -1294,6 +1667,8 @@ async function tickWatch(deps = {}) {
1294
1667
  }
1295
1668
  markSeen(state, row.channel, video.id, timestamp);
1296
1669
  briefed += 1;
1670
+ briefedUrls.push(url);
1671
+ if (!firstBriefedUrl) firstBriefedUrl = url;
1297
1672
  }
1298
1673
 
1299
1674
  if (!isFresh) {
@@ -1311,6 +1686,41 @@ async function tickWatch(deps = {}) {
1311
1686
 
1312
1687
  output(`total: ${totalNew} new, ${totalBriefed} briefed`);
1313
1688
  saveWatchState(statePath, state);
1689
+ if (totalBriefed > 0) {
1690
+ const rich = firstRichWatchLesson(briefedUrls, workDir);
1691
+ if (rich) {
1692
+ const saved = saveRichWatch({ cwd, url: rich.url, lesson: rich.lesson });
1693
+ const ensureApply = deps.ensureApply || ensureWatchApply;
1694
+ const applyCode = ensureApply({
1695
+ cwd,
1696
+ url: rich.url,
1697
+ packRel: saved.packRel,
1698
+ now,
1699
+ output,
1700
+ source: rich.url,
1701
+ });
1702
+ if (deps.ensureApply) return applyCode;
1703
+ const id = videoIdFromUrl(rich.url);
1704
+ const baseline = proveSavedLearnerBaseline({
1705
+ cwd,
1706
+ applyRel: id ? watchApplyRel(id) : null,
1707
+ lesson: rich.lesson,
1708
+ output,
1709
+ });
1710
+ if (baseline !== 0) return baseline;
1711
+ return applyCode;
1712
+ }
1713
+ if (firstBriefedUrl) {
1714
+ const lesson = notesLessonFromText(readNotesText({ url: firstBriefedUrl, workDir }));
1715
+ printLearnerCheckGate(output, lesson, { includeCheck: true });
1716
+ printYoutubeTeachNext(firstBriefedUrl, {}, output);
1717
+ return 0;
1718
+ }
1719
+ printYoutubeTeachNext(firstBriefedUrl, {}, output);
1720
+ return 0;
1721
+ }
1722
+ if (!state.channels.length) printWatchAddNext(output);
1723
+ else printWatchSearchNext(output);
1314
1724
  return 0;
1315
1725
  }
1316
1726
 
@@ -1342,11 +1752,13 @@ function defaultPlaylistExpander(playlistUrl, deps = {}) {
1342
1752
  timeout: 60000,
1343
1753
  maxBuffer: 2 * 1024 * 1024,
1344
1754
  });
1755
+ const videos = parseFlatPlaylist(result && result.stdout);
1756
+ if (videos.length) return videos;
1345
1757
  if (result.error || (result.status != null && result.status !== 0)) {
1346
1758
  const detail = String(result.stderr || result.error?.message || 'playlist expand failed').trim();
1347
1759
  throw new Error(detail || 'playlist expand failed');
1348
1760
  }
1349
- return parseFlatPlaylist(result.stdout);
1761
+ return videos;
1350
1762
  }
1351
1763
 
1352
1764
  function defaultNotesItemRunner(url, engine, deps = {}) {
@@ -1440,14 +1852,20 @@ function runOneNotesItem(item, engine, deps = {}) {
1440
1852
  }
1441
1853
 
1442
1854
  const started = readNowMs(deps);
1443
- let status = 1;
1855
+ let result = { status: 1 };
1444
1856
  try {
1445
- status = readRunnerStatus(invokeNotesRunner(item.url, engine, deps));
1446
- } catch {
1447
- status = 1;
1857
+ result = invokeNotesRunner(item.url, engine, deps);
1858
+ } catch (err) {
1859
+ result = { status: 1, stderr: String((err && err.message) || err || '') };
1448
1860
  }
1861
+ const status = readRunnerStatus(result);
1449
1862
  let brief = null;
1450
- let ok = status === 0;
1863
+ let lesson = null;
1864
+ let ok = status === 0 || keptPrintedNotes({
1865
+ url: item.url,
1866
+ workDir: notesWorkDir(deps),
1867
+ result,
1868
+ });
1451
1869
  if (ok && deps.save) {
1452
1870
  const saved = saveRichNotes(item.url, deps);
1453
1871
  if (saved.thin) {
@@ -1455,6 +1873,7 @@ function runOneNotesItem(item, engine, deps = {}) {
1455
1873
  ok = false;
1456
1874
  } else {
1457
1875
  brief = saved.brief;
1876
+ lesson = saved.lesson;
1458
1877
  const ensureApply = deps.ensureApply || ensureNotesApply;
1459
1878
  try {
1460
1879
  ensureApply({
@@ -1471,7 +1890,7 @@ function runOneNotesItem(item, engine, deps = {}) {
1471
1890
  }
1472
1891
  const seconds = Math.max(0, Math.round((readNowMs(deps) - started) / 1000));
1473
1892
  output(`${label} ${seconds}s ${ok ? (brief || 'ok') : 'FAILED'}`);
1474
- return { url: item.url, id: item.id, seconds, ok, brief };
1893
+ return { url: item.url, id: item.id, seconds, ok, brief, lesson };
1475
1894
  }
1476
1895
 
1477
1896
  function formatNotesSummary(rows = []) {
@@ -1483,7 +1902,14 @@ function formatNotesSummary(rows = []) {
1483
1902
  return lines.join('\n');
1484
1903
  }
1485
1904
 
1486
- function runYoutubeNotesBatch({ urls, engine, save } = {}, deps = {}) {
1905
+ function printEphemeralNotesLearnerGate(url, { json = false, workDir } = {}, output) {
1906
+ const dir = workDir || path.join(process.env.TMPDIR || '/tmp', 'ytnotes');
1907
+ const lesson = notesLessonFromText(readNotesText({ url, workDir: dir }));
1908
+ if (!isThinTeachLesson(lesson)) applyGate.hintEphemeralApply(output, 'notes');
1909
+ printLearnerCheckGate(output, lesson, { includeCheck: true, json });
1910
+ }
1911
+
1912
+ function runYoutubeNotesBatch({ urls, engine, save, json } = {}, deps = {}) {
1487
1913
  deps = { ...deps, save: save === true || deps.save === true };
1488
1914
  const output = deps.output || ((line = '') => console.error(line));
1489
1915
  const items = expandNotesTargets(urls || [], deps);
@@ -1496,7 +1922,30 @@ function runYoutubeNotesBatch({ urls, engine, save } = {}, deps = {}) {
1496
1922
  output(formatNotesSummary(rows));
1497
1923
  }
1498
1924
  if (!rows.length) return 2;
1499
- return rows.some((row) => row.ok) ? 0 : 2;
1925
+ const firstOk = rows.find((row) => row.ok);
1926
+ const asJson = json === true || deps.json === true;
1927
+ if (firstOk && !deps.save) {
1928
+ if (!asJson) printEphemeralNotesLearnerGate(firstOk.url, { workDir: deps.workDir }, output);
1929
+ printYoutubeTeachNext(firstOk.url, { json: asJson }, output);
1930
+ }
1931
+ if (firstOk && deps.save) {
1932
+ if (deps.ensureApply) return 0;
1933
+ const id = firstOk.id || videoIdFromUrl(firstOk.url);
1934
+ const workDir = deps.workDir || path.join(process.env.TMPDIR || '/tmp', 'ytnotes');
1935
+ const lesson = firstOk.lesson || notesLessonFromText(readNotesText({
1936
+ url: firstOk.url,
1937
+ workDir,
1938
+ }));
1939
+ const baseline = proveSavedLearnerBaseline({
1940
+ cwd: deps.cwd || process.cwd(),
1941
+ applyRel: id ? applySidecarRel(id) : null,
1942
+ lesson,
1943
+ output,
1944
+ json: asJson,
1945
+ });
1946
+ if (baseline !== 0) return baseline;
1947
+ }
1948
+ return firstOk ? 0 : 2;
1500
1949
  }
1501
1950
 
1502
1951
  function runSingleYoutubeNotes(url, engine, deps = {}) {
@@ -1504,30 +1953,49 @@ function runSingleYoutubeNotes(url, engine, deps = {}) {
1504
1953
  try {
1505
1954
  result = invokeNotesRunner(url, engine, deps);
1506
1955
  } catch {
1507
- return 1;
1956
+ result = { status: 1 };
1508
1957
  }
1509
1958
  const status = readRunnerStatus(result);
1510
- if (status !== 0) return status;
1959
+ if (status !== 0 && !keptPrintedNotes({
1960
+ url,
1961
+ workDir: notesWorkDir(deps),
1962
+ result,
1963
+ })) {
1964
+ return status == null ? 1 : status;
1965
+ }
1511
1966
  const output = deps.output || ((line = '') => console.error(line));
1512
1967
  if (!deps.save) {
1513
- const workDir = deps.workDir || path.join(process.env.TMPDIR || '/tmp', 'ytnotes');
1514
- const lesson = notesLessonFromText(readNotesText({ url, workDir }));
1515
- if (!isThinTeachLesson(lesson)) applyGate.hintEphemeralApply(output, 'notes');
1968
+ const json = deps.json === true;
1969
+ printEphemeralNotesLearnerGate(url, { json, workDir: deps.workDir }, output);
1970
+ printYoutubeTeachNext(url, { json }, output);
1516
1971
  return 0;
1517
1972
  }
1518
1973
  const saved = saveRichNotes(url, deps);
1519
1974
  if (saved.thin) {
1520
1975
  output(TEACH_THIN_REFUSE);
1976
+ printYoutubeTeachNext(url, { json: deps.json }, output);
1521
1977
  return 2;
1522
1978
  }
1523
1979
  const ensureApply = deps.ensureApply || ensureNotesApply;
1524
- return ensureApply({
1525
- cwd: deps.cwd || process.cwd(),
1980
+ const cwd = deps.cwd || process.cwd();
1981
+ const applyCode = ensureApply({
1982
+ cwd,
1526
1983
  url,
1527
1984
  packRel: saved.packRel,
1528
1985
  now: deps.now,
1529
1986
  output: deps.output,
1530
1987
  });
1988
+ if (deps.ensureApply) return applyCode;
1989
+ const id = videoIdFromUrl(url);
1990
+ const baseline = proveSavedLearnerBaseline({
1991
+ cwd,
1992
+ applyRel: id ? applySidecarRel(id) : null,
1993
+ lesson: saved.lesson,
1994
+ output,
1995
+ json: deps.json === true,
1996
+ });
1997
+ if (baseline !== 0) return baseline;
1998
+ return applyCode;
1531
1999
  }
1532
2000
 
1533
2001
  function runYoutubeNotes(args = [], deps = {}) {
@@ -1545,7 +2013,7 @@ function runYoutubeNotes(args = [], deps = {}) {
1545
2013
  output(YTNOTES_HINT);
1546
2014
  return 2;
1547
2015
  }
1548
- const nextDeps = { ...deps, save: parsed.save };
2016
+ const nextDeps = { ...deps, save: parsed.save, json: parsed.json };
1549
2017
  if (parsed.urls.length === 1 && !isPlaylistUrl(parsed.urls[0])) {
1550
2018
  return runSingleYoutubeNotes(parsed.urls[0], parsed.engine, nextDeps);
1551
2019
  }
@@ -1575,11 +2043,14 @@ function showYoutubeSearchHelp(output = console.log, commandName = 'atris youtub
1575
2043
  output('');
1576
2044
  output('Free local discovery. Uses ytsearch on PATH when present, else the');
1577
2045
  output('bundled scripts/det/ytsearch, else yt-dlp ytsearchN with the same print contract.');
1578
- output('Does not bill credits. Process stays the 5-credit step after you pick a URL.');
2046
+ output('Does not bill credits. A thin hit prints check: fill this and one next: atris youtube teach <first-url>.');
2047
+ output('A rich hit writes one apply and a failing keep/revert pack (score 0).');
1579
2048
  output('');
1580
2049
  output(`--paid buys watch permalinks from Atris (${PAID_SEARCH_COST_HINT}).`);
1581
2050
  output('Requires login. Same auth path as atris youtube process.');
1582
- output('Empty or failed paid search refunds the credits.');
2051
+ output('A rich hit writes one apply and a failing keep/revert pack (score 0).');
2052
+ output('A thin hit prints check: fill this and one next: atris youtube teach <first-url>.');
2053
+ output('Empty or failed paid search prints credits refunded only when the server marks a refund.');
1583
2054
  output('');
1584
2055
  output('Options:');
1585
2056
  output(` --limit <n> Max results (default: ${DEFAULT_SEARCH_LIMIT})`);
@@ -1709,6 +2180,17 @@ function resolveSearchCachePath(deps = {}) {
1709
2180
  return path.join(homeDir, '.atris', LOCAL_SEARCH_CACHE_FILE);
1710
2181
  }
1711
2182
 
2183
+ function printYoutubeTeachNext(url, options, output) {
2184
+ if (options && options.json) return;
2185
+ if (!url) return;
2186
+ output(`next: atris youtube teach ${quoteYoutubeUrl(url)}`);
2187
+ }
2188
+
2189
+ function printSearchTeachNext(rows, options, output) {
2190
+ const firstUrl = Array.isArray(rows) && rows[0] && rows[0].url;
2191
+ printYoutubeTeachNext(firstUrl, options, output);
2192
+ }
2193
+
1712
2194
  function printSearchRows(rows, options, output) {
1713
2195
  if (options.json) {
1714
2196
  output(JSON.stringify(rows, null, 2));
@@ -1813,9 +2295,8 @@ function paidSearchCredits(data) {
1813
2295
  return { used, remaining, refunded };
1814
2296
  }
1815
2297
 
1816
- function creditsWereRefunded(credits) {
2298
+ function creditsRefundedExplicitly(credits) {
1817
2299
  if (!credits) return false;
1818
- if (credits.used === 0) return true;
1819
2300
  if (credits.refunded === true) return true;
1820
2301
  return typeof credits.refunded === 'number' && credits.refunded > 0;
1821
2302
  }
@@ -1825,7 +2306,7 @@ function formatCreditsLines(credits) {
1825
2306
  if (credits.used !== undefined || credits.remaining !== undefined) {
1826
2307
  lines.push(`Credits: ${credits.used !== undefined ? credits.used : '?'} used, ${credits.remaining !== undefined ? credits.remaining : '?'} remaining`);
1827
2308
  }
1828
- if (creditsWereRefunded(credits)) {
2309
+ if (creditsRefundedExplicitly(credits)) {
1829
2310
  lines.push('credits refunded');
1830
2311
  }
1831
2312
  return lines;
@@ -1852,7 +2333,7 @@ function youtubeSearchFailureError(result) {
1852
2333
  ? ' YouTube search is unavailable; retry in a few seconds.'
1853
2334
  : '';
1854
2335
  const credits = paidSearchCredits(result.data);
1855
- const refundHint = result.status === 502 && creditsWereRefunded(credits)
2336
+ const refundHint = result.status === 502 && creditsRefundedExplicitly(credits)
1856
2337
  ? ' credits refunded.'
1857
2338
  : '';
1858
2339
  const lines = [`YouTube search failed (${result.status}): ${resultErrorText(result)}.${hint}${refundHint}`];
@@ -1881,6 +2362,12 @@ async function requestPaidYoutubeSearch(options, deps = {}) {
1881
2362
  if (!result.ok && result.status === 401 && !auth.minted) {
1882
2363
  const remint = await ensureBilled('youtube', { ...deps, forceMint: true });
1883
2364
  if (remint?.ok && remint.token) {
2365
+ if (!options.json) {
2366
+ const print = typeof deps.output === 'function' ? deps.output : () => {};
2367
+ for (const line of formatCreditsLines(paidSearchCredits(result.data))) {
2368
+ print(line);
2369
+ }
2370
+ }
1884
2371
  auth = remint;
1885
2372
  result = await call(auth.token);
1886
2373
  }
@@ -1908,10 +2395,146 @@ async function runPaidYoutubeSearch(options, deps = {}) {
1908
2395
  const rendered = formatPaidSearchResults(data);
1909
2396
  if (!videos.length) {
1910
2397
  output(rendered ? `no videos found\n${rendered}` : 'no videos found');
2398
+ printWatchTickNext(output);
1911
2399
  return 2;
1912
2400
  }
1913
2401
  output(rendered);
1914
- return 0;
2402
+ return gateSearchLearner(videos, options, output, deps);
2403
+ }
2404
+
2405
+ function searchLessonText(rows) {
2406
+ return (Array.isArray(rows) ? rows : [])
2407
+ .map((row) => String(row && row.title ? row.title : '').trim())
2408
+ .filter(Boolean)
2409
+ .join('\n');
2410
+ }
2411
+
2412
+ function searchExperimentSlug(query) {
2413
+ return `search-${applyGate.applySlug(query)}`;
2414
+ }
2415
+
2416
+ function searchExperimentRel(query) {
2417
+ return `atris/experiments/${searchExperimentSlug(query)}`;
2418
+ }
2419
+
2420
+ function searchApplyRel(query) {
2421
+ return applyGate.applySidecarRel('search', applyGate.applySlug(query));
2422
+ }
2423
+
2424
+ function searchApplyNow(now) {
2425
+ if (typeof now === 'function') {
2426
+ const value = now();
2427
+ if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value)) return value;
2428
+ const ms = Number(value);
2429
+ if (Number.isFinite(ms) && ms > 0) return new Date(ms).toISOString().slice(0, 10);
2430
+ return undefined;
2431
+ }
2432
+ return now;
2433
+ }
2434
+
2435
+ function saveRichSearch({ cwd, query, lesson } = {}) {
2436
+ if (isThinTeachLesson(lesson)) {
2437
+ return { thin: true, packRel: null, lesson };
2438
+ }
2439
+ if (cwd) fs.mkdirSync(path.join(cwd, 'atris', 'wiki'), { recursive: true });
2440
+ const packRel = fileTeachExperiment({
2441
+ cwd,
2442
+ lesson,
2443
+ slug: query ? searchExperimentSlug(query) : null,
2444
+ applyRel: query ? searchApplyRel(query) : null,
2445
+ });
2446
+ return { thin: false, packRel, lesson, source: query };
2447
+ }
2448
+
2449
+ function ensureSearchApply({ cwd, query, packRel, now, output, source } = {}) {
2450
+ const pack = packRel || (query ? searchExperimentRel(query) : null);
2451
+ const slug = pack ? path.basename(pack) : null;
2452
+ if (cwd) fs.mkdirSync(path.join(cwd, 'atris', 'wiki'), { recursive: true });
2453
+ return applyGate.ensureApply({
2454
+ cwd,
2455
+ source: source || query || 'search',
2456
+ rel: query ? searchApplyRel(query) : null,
2457
+ now,
2458
+ output,
2459
+ incompleteMessage: slug
2460
+ ? `next: atris experiments keep ${slug}`
2461
+ : applyGate.ephemeralApplyMessage('search'),
2462
+ required: false,
2463
+ change: pack ? `apply ${pack}` : undefined,
2464
+ receipt: pack ? TEACH_KEEP_RULE : undefined,
2465
+ journalLine: pack ? `- [claimable] apply: ${pack}. ${TEACH_KEEP_RULE}` : undefined,
2466
+ });
2467
+ }
2468
+
2469
+ function mintRichSearch({ cwd, query, rows, now, output, ensureApply, json } = {}) {
2470
+ const print = typeof output === 'function' ? output : (line = '') => console.log(line);
2471
+ const lesson = notesLessonFromText(searchLessonText(rows));
2472
+ if (json) return { thin: false, code: 0, lesson };
2473
+ if (isThinTeachLesson(lesson)) {
2474
+ printSearchLearnerGate(rows, { json: false }, print);
2475
+ return { thin: true, code: 0, lesson };
2476
+ }
2477
+ const saved = saveRichSearch({ cwd, query, lesson });
2478
+ const applyFn = ensureApply || ensureSearchApply;
2479
+ const applyCode = applyFn({
2480
+ cwd,
2481
+ query,
2482
+ packRel: saved.packRel,
2483
+ now: searchApplyNow(now),
2484
+ output: print,
2485
+ source: query,
2486
+ });
2487
+ if (ensureApply) return { thin: false, code: applyCode, lesson: saved.lesson };
2488
+ const baseline = proveSavedLearnerBaseline({
2489
+ cwd,
2490
+ applyRel: query ? searchApplyRel(query) : null,
2491
+ lesson: saved.lesson,
2492
+ output: print,
2493
+ json,
2494
+ });
2495
+ if (baseline !== 0) return { thin: false, code: baseline, lesson: saved.lesson };
2496
+ return { thin: false, code: applyCode, lesson: saved.lesson };
2497
+ }
2498
+
2499
+ function printSearchLearnerGate(rows, options, output) {
2500
+ printLearnerCheckGate(output, notesLessonFromText(searchLessonText(rows)), {
2501
+ includeCheck: true,
2502
+ json: Boolean(options && options.json),
2503
+ });
2504
+ }
2505
+
2506
+ function gateSearchLearner(rows, options, output, deps = {}) {
2507
+ if (options && options.json) return 0;
2508
+ const minted = mintRichSearch({
2509
+ cwd: deps.cwd || process.cwd(),
2510
+ query: options && options.query,
2511
+ rows,
2512
+ now: deps.now,
2513
+ output,
2514
+ ensureApply: deps.ensureApply,
2515
+ json: false,
2516
+ });
2517
+ if (minted.thin) printSearchTeachNext(rows, options, output);
2518
+ return minted.code;
2519
+ }
2520
+
2521
+ function printSearchOutcome(rows, options, output, deps = {}) {
2522
+ printSearchRows(rows, options, output);
2523
+ return gateSearchLearner(rows, options, output, deps);
2524
+ }
2525
+
2526
+ function resultStdout(result) {
2527
+ if (typeof result === 'string') return result;
2528
+ return String((result && result.stdout) || '');
2529
+ }
2530
+
2531
+ function searchRowsFromResult(result) {
2532
+ return parseSearchStdout(resultStdout(result));
2533
+ }
2534
+
2535
+ function finishSuccessfulSearch(rows, options, output, deps) {
2536
+ writeLocalSearchCache(options.query, rows, deps);
2537
+ return printSearchOutcome(rows, options, output, deps);
1915
2538
  }
1916
2539
 
1917
2540
  function commandOnPath(name, deps = {}) {
@@ -2030,6 +2653,9 @@ async function runYoutubeSearch(args = [], deps = {}) {
2030
2653
  return 2;
2031
2654
  }
2032
2655
 
2656
+ let rows = searchRowsFromResult(result);
2657
+ if (rows.length) return finishSuccessfulSearch(rows, options, output, deps);
2658
+
2033
2659
  let status = searchRunnerStatus(result);
2034
2660
  if (status != null && status !== 0 && isLocalSearchRateLimited(result)) {
2035
2661
  await waitLocalSearchBackoff(deps);
@@ -2043,14 +2669,16 @@ async function runYoutubeSearch(args = [], deps = {}) {
2043
2669
  output('ytsearch and yt-dlp not found. Install yt-dlp or put ytsearch on PATH.');
2044
2670
  return 2;
2045
2671
  }
2672
+ rows = searchRowsFromResult(result);
2673
+ if (rows.length) return finishSuccessfulSearch(rows, options, output, deps);
2046
2674
  status = searchRunnerStatus(result);
2047
2675
  if (status != null && status !== 0 && isLocalSearchRateLimited(result)) {
2048
2676
  const cached = readFreshLocalSearchCache(options.query, deps);
2049
2677
  if (cached) {
2050
- const rows = cached.rows.slice(0, options.limit);
2051
- printSearchRows(rows, options, output);
2678
+ const cachedRows = cached.rows.slice(0, options.limit);
2679
+ const code = printSearchOutcome(cachedRows, options, output, deps);
2052
2680
  output(LOCAL_SEARCH_CACHE_NOTE);
2053
- return 0;
2681
+ return code;
2054
2682
  }
2055
2683
  output(LOCAL_SEARCH_RATE_LIMIT_MESSAGE);
2056
2684
  return status == null ? 1 : status;
@@ -2063,23 +2691,20 @@ async function runYoutubeSearch(args = [], deps = {}) {
2063
2691
  return status == null ? 1 : status;
2064
2692
  }
2065
2693
 
2066
- const stdout = typeof result === 'string' ? result : String((result && result.stdout) || '');
2067
- const rows = parseSearchStdout(stdout);
2068
- if (!rows.length) {
2069
- output('no videos found');
2070
- return 2;
2071
- }
2072
-
2073
- writeLocalSearchCache(options.query, rows, deps);
2074
- printSearchRows(rows, options, output);
2075
- return 0;
2694
+ output('no videos found');
2695
+ if (!options.json) printWatchTickNext(output);
2696
+ return 2;
2076
2697
  }
2077
2698
 
2078
- const YTTEACH_USAGE = 'usage: atris youtube teach <youtube-url> [--section N] [--save] [--recap TEXT] [--skip]';
2699
+ const YTTEACH_USAGE = 'usage: atris youtube teach <youtube-url> [--section N] [--save] [--recap TEXT] [--skip] | owed | next';
2079
2700
  const TEACH_PAID_REFUSE = 'teach is free local captions. drop --paid.';
2080
2701
  const TEACH_THIN_REFUSE = 'thin: no number or named mechanism. no brief.';
2081
2702
  const TEACH_OWED_FILE = 'youtube-teach-owed.json';
2082
2703
  const TEACH_RECAP_MISSING = '--recap needs the unpaid check';
2704
+ const TEACH_RESUME_START = 'atris youtube teach <url>';
2705
+ const TEACH_RESUME_NEXT = 'next: atris youtube teach recap TEXT or atris youtube teach skip';
2706
+ const TEACH_WATCH_TICK_NEXT = WATCH_TICK_NEXT;
2707
+ const TEACH_CONTINUE_NEXT = 'next: atris youtube teach next';
2083
2708
  const TEACH_APPLY_NEXT_MESSAGE = APPLY_NEXT_MESSAGE;
2084
2709
  const TEACH_KEEP_RULE = 'keep only if measure.py moves 0→1. scores 1 only when the fixture contains the check tokens.';
2085
2710
  const MECHANISM_STOP = new Set([
@@ -2108,12 +2733,16 @@ function parseTeachArgs(argv = []) {
2108
2733
  save: false,
2109
2734
  json: false,
2110
2735
  skip: false,
2736
+ owed: false,
2737
+ resume: false,
2738
+ next: false,
2111
2739
  recap: null,
2112
2740
  url: null,
2113
2741
  section: 1,
2114
2742
  };
2743
+ let sectionSet = false;
2115
2744
 
2116
- if (args.length === 0 || ['help', '--help', '-h'].includes(args[0])) {
2745
+ if (args.length > 0 && ['help', '--help', '-h'].includes(args[0])) {
2117
2746
  options.help = true;
2118
2747
  return options;
2119
2748
  }
@@ -2128,6 +2757,8 @@ function parseTeachArgs(argv = []) {
2128
2757
  options.json = true;
2129
2758
  } else if (arg === '--skip') {
2130
2759
  options.skip = true;
2760
+ } else if (arg === '--owed') {
2761
+ options.owed = true;
2131
2762
  } else if (arg === '--paid') {
2132
2763
  throw new Error(TEACH_PAID_REFUSE);
2133
2764
  } else if (arg === '--recap') {
@@ -2148,6 +2779,7 @@ function parseTeachArgs(argv = []) {
2148
2779
  throw new Error('--section must be a positive integer');
2149
2780
  }
2150
2781
  options.section = value;
2782
+ sectionSet = true;
2151
2783
  i += 1;
2152
2784
  } else if (arg.startsWith('--section=')) {
2153
2785
  const value = Number.parseInt(arg.slice('--section='.length), 10);
@@ -2155,6 +2787,7 @@ function parseTeachArgs(argv = []) {
2155
2787
  throw new Error('--section must be a positive integer');
2156
2788
  }
2157
2789
  options.section = value;
2790
+ sectionSet = true;
2158
2791
  } else if (arg.startsWith('-')) {
2159
2792
  throw new Error(`Unknown option: ${arg}`);
2160
2793
  } else if (arg === 'recap' && options.recap == null && !options.url) {
@@ -2169,6 +2802,10 @@ function parseTeachArgs(argv = []) {
2169
2802
  if (!options.recap) throw new Error(TEACH_RECAP_MISSING);
2170
2803
  } else if (arg === 'skip' && !options.url) {
2171
2804
  options.skip = true;
2805
+ } else if (arg === 'owed' && !options.url) {
2806
+ options.owed = true;
2807
+ } else if (arg === 'next' && !options.url) {
2808
+ options.next = true;
2172
2809
  } else if (!options.url && looksLikeYoutubeUrl(arg)) {
2173
2810
  options.url = arg;
2174
2811
  } else {
@@ -2177,7 +2814,12 @@ function parseTeachArgs(argv = []) {
2177
2814
  }
2178
2815
 
2179
2816
  if (options.help) return options;
2180
- if (!options.url && options.recap == null && !options.skip) {
2817
+ if (!options.url && options.recap == null && !options.skip && !options.owed && !options.next) {
2818
+ if (!sectionSet && !options.save) {
2819
+ options.owed = true;
2820
+ options.resume = true;
2821
+ return options;
2822
+ }
2181
2823
  throw new Error('Missing YouTube URL. Run "atris youtube teach --help".');
2182
2824
  }
2183
2825
  return options;
@@ -2297,7 +2939,13 @@ function extractTeachMechanisms(text) {
2297
2939
  return found.slice(0, 8);
2298
2940
  }
2299
2941
 
2300
- function oneTeachCheck(mechanisms, numbers, title) {
2942
+ const LEARNER_CHECK_FILL = 'fill this';
2943
+ const LEARNER_SCORE_ZERO = 'score: 0';
2944
+ const LEARNER_CHECK_INCOMPLETE = 'incomplete: no measurable check. check: fill this';
2945
+ const LEARNER_BASELINE_INCOMPLETE = 'incomplete: check already passes. refuse invented success.';
2946
+ const LEARNER_APPLY_MISSING = 'incomplete: apply missing. check cannot score.';
2947
+
2948
+ function oneTeachCheck(mechanisms, numbers) {
2301
2949
  if (mechanisms[0]) {
2302
2950
  const name = mechanisms[0];
2303
2951
  const named = new RegExp(`\\b(?:${MECHANISM_HEADS})\\b`, 'i').test(name);
@@ -2305,7 +2953,63 @@ function oneTeachCheck(mechanisms, numbers, title) {
2305
2953
  return `what is ${name}?`;
2306
2954
  }
2307
2955
  if (numbers[0]) return `what does ${numbers[0]} measure in this chapter?`;
2308
- return `what is the point of ${title || 'this chapter'}?`;
2956
+ return LEARNER_CHECK_FILL;
2957
+ }
2958
+
2959
+ function learnerCheckFromLesson(lesson = {}) {
2960
+ const mechanisms = Array.isArray(lesson.mechanisms) ? lesson.mechanisms : [];
2961
+ const numbers = Array.isArray(lesson.numbers) ? lesson.numbers : [];
2962
+ const inferred = mechanisms.length > 0 || numbers.length > 0;
2963
+ return {
2964
+ inferred,
2965
+ line: inferred ? oneTeachCheck(mechanisms, numbers) : LEARNER_CHECK_FILL,
2966
+ needles: teachCheckNeedles({ mechanisms, numbers }),
2967
+ };
2968
+ }
2969
+
2970
+ function scoreLearnerNeedles(text, needles) {
2971
+ const list = Array.isArray(needles) ? needles : [];
2972
+ if (!list.length) return 0;
2973
+ const blob = String(text || '').toLowerCase();
2974
+ return list.every((needle) => blob.includes(String(needle || '').toLowerCase())) ? 1 : 0;
2975
+ }
2976
+
2977
+ function printLearnerCheckGate(output, lesson, { includeCheck = false, json = false } = {}) {
2978
+ if (json || typeof output !== 'function') return;
2979
+ const check = learnerCheckFromLesson(lesson);
2980
+ if (includeCheck) output(`check: ${check.line}`);
2981
+ if (check.inferred) output(LEARNER_SCORE_ZERO);
2982
+ }
2983
+
2984
+ function proveSavedLearnerBaseline({ cwd, applyRel, lesson, output, json } = {}) {
2985
+ const print = typeof output === 'function' ? output : () => {};
2986
+ const check = learnerCheckFromLesson(lesson);
2987
+ if (!check.inferred) {
2988
+ if (!json) print(LEARNER_CHECK_INCOMPLETE);
2989
+ return 2;
2990
+ }
2991
+ if (!cwd || !applyRel) {
2992
+ if (!json) print(LEARNER_APPLY_MISSING);
2993
+ return 2;
2994
+ }
2995
+ const abs = path.join(cwd, applyRel);
2996
+ let text = '';
2997
+ try {
2998
+ if (!fs.existsSync(abs)) {
2999
+ if (!json) print(LEARNER_APPLY_MISSING);
3000
+ return 2;
3001
+ }
3002
+ text = fs.readFileSync(abs, 'utf8');
3003
+ } catch {
3004
+ if (!json) print(LEARNER_APPLY_MISSING);
3005
+ return 2;
3006
+ }
3007
+ if (scoreLearnerNeedles(text, check.needles) !== 0) {
3008
+ if (!json) print(LEARNER_BASELINE_INCOMPLETE);
3009
+ return 2;
3010
+ }
3011
+ if (!json) print(LEARNER_SCORE_ZERO);
3012
+ return 0;
2309
3013
  }
2310
3014
 
2311
3015
  function quoteYoutubeUrl(url) {
@@ -2336,10 +3040,7 @@ function teachLessonFromCues({ url, section, chapters, chapter, cues, title } =
2336
3040
  lines.push(oneTeachCheck(mechanisms, numbers, heading));
2337
3041
  if (section < total) {
2338
3042
  lines.push('');
2339
- lines.push(`next: atris youtube teach ${quoteYoutubeUrl(url)} --section ${section + 1}`);
2340
- } else {
2341
- lines.push('');
2342
- lines.push('next: last section');
3043
+ lines.push(TEACH_RESUME_NEXT);
2343
3044
  }
2344
3045
  return { text: lines.join('\n'), numbers, mechanisms };
2345
3046
  }
@@ -2429,32 +3130,136 @@ function writeTeachOwedStore(deps, store) {
2429
3130
  }
2430
3131
  }
2431
3132
 
2432
- function rememberTeachOwed(deps, { url, section, lesson } = {}) {
3133
+ function rememberTeachOwed(deps, { url, section, lesson, total } = {}) {
2433
3134
  const id = videoIdFromUrl(url);
2434
3135
  if (!id) return;
2435
3136
  const store = readTeachOwedStore(deps);
2436
- store[id] = {
3137
+ const row = {
2437
3138
  section: Number(section) || 1,
2438
3139
  check: teachCheckLine(lesson),
2439
3140
  tokens: teachRecapTokens(lesson),
3141
+ url: url || undefined,
2440
3142
  };
3143
+ const totalChapters = Number(total);
3144
+ if (Number.isInteger(totalChapters) && totalChapters > 0) row.total = totalChapters;
3145
+ store[id] = row;
2441
3146
  writeTeachOwedStore(deps, store);
2442
3147
  }
2443
3148
 
3149
+ function writeTeachContinue(store, id, entry) {
3150
+ const nextSection = (Number(entry && entry.section) || 1) + 1;
3151
+ const total = Number(entry && entry.total);
3152
+ if (Number.isInteger(total) && total > 0 && nextSection > total) {
3153
+ delete store[id];
3154
+ return null;
3155
+ }
3156
+ const cursor = {
3157
+ section: nextSection,
3158
+ url: (entry && entry.url) || undefined,
3159
+ };
3160
+ if (Number.isInteger(total) && total > 0) cursor.total = total;
3161
+ store[id] = cursor;
3162
+ return cursor;
3163
+ }
3164
+
3165
+ function lastTeachContinueEntry(store) {
3166
+ const ids = Object.keys(store || {});
3167
+ for (let i = ids.length - 1; i >= 0; i -= 1) {
3168
+ const entry = store[ids[i]];
3169
+ if (!entry || typeof entry !== 'object') continue;
3170
+ if (String(entry.check || '').trim()) continue;
3171
+ const url = String(entry.url || '').trim();
3172
+ if (!url) continue;
3173
+ return {
3174
+ id: ids[i],
3175
+ url,
3176
+ section: Number(entry.section) || 1,
3177
+ };
3178
+ }
3179
+ return null;
3180
+ }
3181
+
3182
+ function lastTeachUnpaidEntry(store) {
3183
+ const ids = Object.keys(store || {});
3184
+ for (let i = ids.length - 1; i >= 0; i -= 1) {
3185
+ const entry = store[ids[i]];
3186
+ if (!entry || typeof entry !== 'object') continue;
3187
+ const check = String(entry.check || '').trim();
3188
+ if (!check) continue;
3189
+ return {
3190
+ id: ids[i],
3191
+ check,
3192
+ url: entry.url || null,
3193
+ section: Number(entry.section) || 1,
3194
+ };
3195
+ }
3196
+ return null;
3197
+ }
3198
+
2444
3199
  function previousTeachUnlocked(owed, section) {
2445
3200
  if (!owed) return true;
2446
3201
  return Number(owed.section) >= Number(section);
2447
3202
  }
2448
3203
 
3204
+ function printTeachUnlockNext(parsed, entry, output) {
3205
+ if (!parsed || parsed.json) return;
3206
+ const url = parsed.url || (entry && entry.url) || '';
3207
+ if (!url) return;
3208
+ output(TEACH_CONTINUE_NEXT);
3209
+ }
3210
+
3211
+ function printTeachWatchTickNext(parsed, lesson, total, output) {
3212
+ if (!parsed || parsed.json) return;
3213
+ if (parsed.save && !isThinTeachLesson(lesson)) return;
3214
+ const section = Number(parsed.section) || 0;
3215
+ if (section < Number(total)) return;
3216
+ printWatchTickNext(output);
3217
+ }
3218
+
3219
+ function listTeachOwedEntries(store) {
3220
+ return Object.keys(store || {}).map((id) => {
3221
+ const entry = store[id] && typeof store[id] === 'object' ? store[id] : {};
3222
+ return {
3223
+ id,
3224
+ url: entry.url || null,
3225
+ section: Number(entry.section) || 1,
3226
+ check: String(entry.check || '').trim(),
3227
+ };
3228
+ }).filter((row) => row.check);
3229
+ }
3230
+
3231
+ function printTeachOwed(parsed, deps, output) {
3232
+ const rows = listTeachOwedEntries(readTeachOwedStore(deps));
3233
+ if (parsed.json) {
3234
+ output(JSON.stringify(rows));
3235
+ return 0;
3236
+ }
3237
+ if (!rows.length) {
3238
+ output('nothing owed');
3239
+ if (parsed.resume) output(TEACH_RESUME_START);
3240
+ return 0;
3241
+ }
3242
+ rows.forEach((row, index) => {
3243
+ if (index) output('');
3244
+ output(row.url || row.id);
3245
+ output(`section ${row.section}`);
3246
+ if (row.check) output(row.check);
3247
+ });
3248
+ if (parsed.resume) output(TEACH_RESUME_NEXT);
3249
+ return 0;
3250
+ }
3251
+
2449
3252
  function applyTeachRecap(parsed, deps, output) {
2450
3253
  const store = readTeachOwedStore(deps);
2451
3254
  let id = parsed.url ? videoIdFromUrl(parsed.url) : null;
2452
3255
  let entry = id ? store[id] : null;
2453
3256
  if (!entry && !id) {
2454
3257
  const ids = Object.keys(store);
3258
+ const unpaidIds = ids.filter((key) => store[key] && String(store[key].check || '').trim());
2455
3259
  if (parsed.recap) {
2456
- id = ids.find((key) => recapHitsTokens(parsed.recap, store[key] && store[key].tokens));
3260
+ id = unpaidIds.find((key) => recapHitsTokens(parsed.recap, store[key] && store[key].tokens));
2457
3261
  }
3262
+ if (!id && unpaidIds.length === 1) id = unpaidIds[0];
2458
3263
  if (!id && ids.length === 1) id = ids[0];
2459
3264
  entry = id ? store[id] : null;
2460
3265
  }
@@ -2463,8 +3268,13 @@ function applyTeachRecap(parsed, deps, output) {
2463
3268
  if (!parsed.json) output('recap the previous section first');
2464
3269
  return 2;
2465
3270
  }
3271
+ if (!String(entry.check || '').trim()) {
3272
+ printTeachUnlockNext(parsed, entry, output);
3273
+ return 0;
3274
+ }
2466
3275
  if (parsed.skip) {
2467
- delete store[id];
3276
+ printTeachUnlockNext(parsed, entry, output);
3277
+ writeTeachContinue(store, id, entry);
2468
3278
  writeTeachOwedStore(deps, store);
2469
3279
  return 0;
2470
3280
  }
@@ -2472,11 +3282,40 @@ function applyTeachRecap(parsed, deps, output) {
2472
3282
  if (!parsed.json) output(entry.check);
2473
3283
  return 2;
2474
3284
  }
2475
- delete store[id];
3285
+ printTeachUnlockNext(parsed, entry, output);
3286
+ writeTeachContinue(store, id, entry);
2476
3287
  writeTeachOwedStore(deps, store);
2477
3288
  return 0;
2478
3289
  }
2479
3290
 
3291
+ function applyTeachNext(parsed, deps, output) {
3292
+ const store = readTeachOwedStore(deps);
3293
+ const cont = lastTeachContinueEntry(store);
3294
+ if (cont) {
3295
+ parsed.url = cont.url;
3296
+ parsed.section = cont.section;
3297
+ return 0;
3298
+ }
3299
+ const unpaid = lastTeachUnpaidEntry(store);
3300
+ if (unpaid) {
3301
+ if (!parsed.json) output(unpaid.check);
3302
+ return 2;
3303
+ }
3304
+ if (parsed.json) {
3305
+ output('[]');
3306
+ return 0;
3307
+ }
3308
+ output('nothing owed');
3309
+ output(TEACH_RESUME_START);
3310
+ return 0;
3311
+ }
3312
+
3313
+ function applyTeachResume(parsed, deps, output) {
3314
+ const store = readTeachOwedStore(deps);
3315
+ if (lastTeachUnpaidEntry(store)) return printTeachOwed(parsed, deps, output);
3316
+ return applyTeachNext(parsed, deps, output);
3317
+ }
3318
+
2480
3319
  function teachCheckLine(lesson = {}) {
2481
3320
  return oneTeachCheck(lesson.mechanisms || [], lesson.numbers || [], '');
2482
3321
  }
@@ -2765,14 +3604,15 @@ function fileTeachBrief({ cwd, url, section, lesson, now } = {}) {
2765
3604
  function ensureTeachApply({ cwd, url, section, packRel, now, output } = {}) {
2766
3605
  const id = videoIdFromUrl(url);
2767
3606
  const pack = packRel || (id ? teachExperimentRel(id, section) : null);
3607
+ const slug = pack ? path.basename(pack) : null;
2768
3608
  return applyGate.ensureApply({
2769
3609
  cwd,
2770
3610
  source: url,
2771
3611
  rel: id ? applySidecarRel(`${id}-s${section}`) : null,
2772
3612
  now,
2773
3613
  output,
2774
- incompleteMessage: pack
2775
- ? `next: apply ${pack}. keep only if measure.py moves 0→1`
3614
+ incompleteMessage: slug
3615
+ ? `next: atris experiments keep ${slug}`
2776
3616
  : TEACH_APPLY_NEXT_MESSAGE,
2777
3617
  required: false,
2778
3618
  change: pack ? `apply ${pack}` : undefined,
@@ -2786,33 +3626,23 @@ async function extractTeachSource(youtubeUrl, deps = {}) {
2786
3626
  return deps.extractTeachSource(youtubeUrl, deps);
2787
3627
  }
2788
3628
  const runner = deps.spawnSync || spawnSync;
2789
- const result = runner('yt-dlp', ['-J', '--skip-download', '--no-warnings', youtubeUrl], {
3629
+ const result = runner('yt-dlp', ytDlpInfoArgs(youtubeUrl), {
2790
3630
  encoding: 'utf8',
2791
3631
  timeout: 20000,
2792
3632
  maxBuffer: 10 * 1024 * 1024,
2793
3633
  });
2794
- if (result.error || result.status !== 0 || !result.stdout) return null;
2795
-
2796
- let info;
2797
- try {
2798
- info = JSON.parse(result.stdout);
2799
- } catch {
2800
- return null;
2801
- }
2802
-
2803
- const selected = chooseCaptionTrack(info);
2804
- if (!selected?.track?.url) return null;
2805
- const rawCaption = await (deps.fetchCaptionText || fetchCaptionText)(selected.track.url);
2806
- const cues = parseCaptionCues(rawCaption);
3634
+ const info = parseYtDlpInfoJson(result);
3635
+ const loaded = await loadCaptionRaw(info, youtubeUrl, deps);
3636
+ const cues = parseCaptionCues(loaded && loaded.raw);
2807
3637
  if (!cues.length) return null;
2808
3638
 
2809
3639
  return {
2810
- id: info.id || videoIdFromUrl(youtubeUrl),
2811
- title: info.title || '',
3640
+ id: teachSourceVideoId(info, youtubeUrl),
3641
+ title: (info && info.title) || '',
2812
3642
  url: youtubeUrl,
2813
- durationSeconds: Number(info.duration || 0) || undefined,
2814
- language: selected.language || 'unknown',
2815
- chapters: normalizeChapters(info.chapters, info.duration),
3643
+ durationSeconds: Number((info && info.duration) || 0) || undefined,
3644
+ language: loaded.language || 'unknown',
3645
+ chapters: normalizeChapters(info && info.chapters, info && info.duration),
2816
3646
  cues,
2817
3647
  };
2818
3648
  }
@@ -2832,11 +3662,22 @@ async function runYoutubeTeach(args = [], deps = {}) {
2832
3662
  }
2833
3663
 
2834
3664
  const owedDeps = { ...deps, cwd: deps.cwd || process.cwd() };
3665
+ if (parsed.owed) {
3666
+ if (!parsed.resume) return printTeachOwed(parsed, owedDeps, output);
3667
+ const resumeCode = applyTeachResume(parsed, owedDeps, output);
3668
+ if (resumeCode !== 0) return resumeCode;
3669
+ if (!parsed.url) return 0;
3670
+ }
2835
3671
  if (parsed.recap != null || parsed.skip) {
2836
3672
  const recapCode = applyTeachRecap(parsed, owedDeps, output);
2837
3673
  if (recapCode !== 0) return recapCode;
2838
3674
  if (!parsed.url || parsed.section <= 1) return 0;
2839
3675
  }
3676
+ if (parsed.next) {
3677
+ const nextCode = applyTeachNext(parsed, owedDeps, output);
3678
+ if (nextCode !== 0) return nextCode;
3679
+ if (!parsed.url) return 0;
3680
+ }
2840
3681
 
2841
3682
  if (!parsed.url) {
2842
3683
  output('Missing YouTube URL. Run "atris youtube teach --help".');
@@ -2851,7 +3692,10 @@ async function runYoutubeTeach(args = [], deps = {}) {
2851
3692
  }
2852
3693
  }
2853
3694
 
2854
- const source = await (deps.extractTeachSource || extractTeachSource)(parsed.url, deps);
3695
+ const source = await (deps.extractTeachSource || extractTeachSource)(parsed.url, {
3696
+ ...deps,
3697
+ workDir: deps.workDir || notesWorkDir(deps),
3698
+ });
2855
3699
  if (!source || !Array.isArray(source.cues) || !source.cues.length) {
2856
3700
  output('no english captions for this url. teach stays local and will not call process.');
2857
3701
  return 2;
@@ -2874,39 +3718,59 @@ async function runYoutubeTeach(args = [], deps = {}) {
2874
3718
  title: source.title,
2875
3719
  });
2876
3720
  output(lesson.text);
2877
- rememberTeachOwed(owedDeps, { url: parsed.url, section: parsed.section, lesson });
3721
+ rememberTeachOwed(owedDeps, {
3722
+ url: parsed.url,
3723
+ section: parsed.section,
3724
+ lesson,
3725
+ total: chapters.length,
3726
+ });
2878
3727
 
2879
3728
  if (!parsed.save) {
2880
3729
  if (!isThinTeachLesson(lesson)) applyGate.hintEphemeralApply(output, 'teach');
3730
+ printLearnerCheckGate(output, lesson, { includeCheck: true, json: parsed.json === true });
3731
+ printTeachWatchTickNext(parsed, lesson, chapters.length, output);
2881
3732
  return 0;
2882
3733
  }
2883
3734
  if (isThinTeachLesson(lesson)) {
2884
3735
  output(TEACH_THIN_REFUSE);
3736
+ printTeachWatchTickNext(parsed, lesson, chapters.length, output);
2885
3737
  return 2;
2886
3738
  }
2887
3739
 
3740
+ const cwd = deps.cwd || process.cwd();
2888
3741
  fileTeachBrief({
2889
- cwd: deps.cwd || process.cwd(),
3742
+ cwd,
2890
3743
  url: parsed.url,
2891
3744
  section: parsed.section,
2892
3745
  lesson: lesson.text,
2893
3746
  now: deps.now,
2894
3747
  });
2895
3748
  const packRel = fileTeachExperiment({
2896
- cwd: deps.cwd || process.cwd(),
3749
+ cwd,
2897
3750
  url: parsed.url,
2898
3751
  section: parsed.section,
2899
3752
  lesson,
2900
3753
  });
2901
3754
  const ensureApply = deps.ensureApply || ensureTeachApply;
2902
- return ensureApply({
2903
- cwd: deps.cwd || process.cwd(),
3755
+ const applyCode = ensureApply({
3756
+ cwd,
2904
3757
  url: parsed.url,
2905
3758
  section: parsed.section,
2906
3759
  packRel,
2907
3760
  now: deps.now,
2908
3761
  output,
2909
3762
  });
3763
+ if (deps.ensureApply) return applyCode;
3764
+ const id = videoIdFromUrl(parsed.url);
3765
+ const baseline = proveSavedLearnerBaseline({
3766
+ cwd,
3767
+ applyRel: id ? applySidecarRel(`${id}-s${parsed.section}`) : null,
3768
+ lesson,
3769
+ output,
3770
+ json: parsed.json === true,
3771
+ });
3772
+ if (baseline !== 0) return baseline;
3773
+ return applyCode;
2910
3774
  }
2911
3775
 
2912
3776
  async function youtubeCommand(argv = process.argv.slice(3), deps = {}) {
@@ -2951,7 +3815,20 @@ async function youtubeCommand(argv = process.argv.slice(3), deps = {}) {
2951
3815
  let status = 0;
2952
3816
  try {
2953
3817
  const data = await processYoutube(options, deps);
2954
- output(options.json ? JSON.stringify(data, null, 2) : formatYoutubeResult(data));
3818
+ if (options.json) {
3819
+ output(JSON.stringify(data, null, 2));
3820
+ } else {
3821
+ output(formatYoutubeResult(data));
3822
+ const mintCode = mintRichProcess({
3823
+ cwd: deps.cwd || process.cwd(),
3824
+ url: options.youtubeUrl,
3825
+ data,
3826
+ now: deps.now,
3827
+ output,
3828
+ ensureApply: deps.ensureApply,
3829
+ });
3830
+ if (mintCode !== 0) status = mintCode;
3831
+ }
2955
3832
  } catch (err) {
2956
3833
  if (!err.applyRequired) output(err.message);
2957
3834
  status = Number.isInteger(err.exitCode) ? err.exitCode : 1;
@@ -2968,10 +3845,12 @@ module.exports = {
2968
3845
  parseYoutubeArgs,
2969
3846
  buildYoutubePayload,
2970
3847
  extractLocalTranscript,
3848
+ parseYtDlpInfoJson,
2971
3849
  processYoutube,
2972
3850
  shouldRetryWithLocalTranscript,
2973
3851
  formatYoutubeResult,
2974
3852
  fileBriefFromNotes,
3853
+ keptPrintedNotes,
2975
3854
  ensureNotesApply,
2976
3855
  unsaveYoutubeNotes,
2977
3856
  APPLY_NEXT_MESSAGE,
@@ -2999,10 +3878,27 @@ module.exports = {
2999
3878
  extractTeachNumbers,
3000
3879
  extractTeachMechanisms,
3001
3880
  extractTeachSource,
3881
+ readLocalCaptionText,
3882
+ oneTeachCheck,
3883
+ learnerCheckFromLesson,
3884
+ scoreLearnerNeedles,
3885
+ LEARNER_CHECK_FILL,
3886
+ LEARNER_SCORE_ZERO,
3887
+ printLearnerCheckGate,
3888
+ proveSavedLearnerBaseline,
3002
3889
  isThinTeachLesson,
3003
3890
  TEACH_THIN_REFUSE,
3891
+ TEACH_RESUME_NEXT,
3892
+ TEACH_WATCH_TICK_NEXT,
3004
3893
  teachExperimentSlug,
3005
3894
  notesExperimentSlug,
3895
+ digestExperimentSlug,
3896
+ watchExperimentSlug,
3897
+ processExperimentSlug,
3898
+ processApplyRel,
3899
+ searchExperimentSlug,
3900
+ searchApplyRel,
3901
+ firstRichWatchLesson,
3006
3902
  fileTeachExperiment,
3007
3903
  youtubeCommand,
3008
3904
  };