atris 3.57.2 → 3.57.4

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.
@@ -30,7 +30,7 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
30
30
  output(`Usage: ${commandName} search "<query>" [--limit N] [--json]`);
31
31
  output(` ${commandName} search --paid "<query>" [--limit N] [--json]`);
32
32
  output(` ${commandName} notes <youtube-url> [youtube-url-or-playlist...] [engine] [--save]`);
33
- output(` ${commandName} teach <youtube-url> [--section N] [--save]`);
33
+ output(` ${commandName} teach <youtube-url> [--section N] [--save] [--recap TEXT] [--skip]`);
34
34
  output(` ${commandName} unsave <url-or-id>`);
35
35
  output(` ${commandName} process <youtube-url> [options]`);
36
36
  output(` ${commandName} digest [--days N]`);
@@ -44,6 +44,7 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
44
44
  output('search --paid = 5 credits, watch permalinks + titles from Atris');
45
45
  output('notes = free local notes to stdout; ephemeral unless --save');
46
46
  output('teach = one chapter from local captions; ephemeral unless --save');
47
+ output('rich ephemeral notes/teach print one apply next-step (no files)');
47
48
  output('process = 5 credits cloud knowledge (needs a filled Apply)');
48
49
  output('digest = one decision page from this week\'s video briefs');
49
50
  output('watch = subscribed channels turn into briefs without a human');
@@ -53,9 +54,11 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
53
54
  output('Options:');
54
55
  output(' --limit <n> Max search results (default: 5)');
55
56
  output(' --paid Bill 5 credits for watch permalinks (search only)');
56
- output(' --save File brief, journal, apply stub; teach also mints a keep/revert experiment');
57
+ output(' --save File brief, journal, apply; rich notes/teach mint a keep/revert experiment');
57
58
  output(' --section <n> Chapter to teach, 1-based (teach only, default: 1)');
58
- output(' --unsave Delete filed brief and apply stub (no paid calls)');
59
+ output(' --recap <text> Unlock the next teach section with the unpaid check');
60
+ output(' --skip Unlock the next teach section without answering');
61
+ output(' --unsave Delete filed brief, apply stub, and matching notes/teach experiment packs (no paid calls)');
59
62
  output(' --query, -q <text> Focus question for the analysis');
60
63
  output(' --agent <id> Agent id to store knowledge against');
61
64
  output(' --store Save as agent knowledge (requires --agent)');
@@ -645,16 +648,68 @@ function applySidecarRel(id) {
645
648
  return applyGate.applySidecarRel('youtube', id);
646
649
  }
647
650
 
648
- function ensureNotesApply({ cwd, url, now, output } = {}) {
651
+ function notesExperimentSlug(id) {
652
+ return `notes-${experimentIdToken(id)}`;
653
+ }
654
+
655
+ function notesExperimentRel(id) {
656
+ return `atris/experiments/${notesExperimentSlug(id)}`;
657
+ }
658
+
659
+ function notesLessonFromText(text) {
660
+ const body = String(text || '');
661
+ return {
662
+ numbers: extractTeachNumbers(body),
663
+ mechanisms: extractTeachMechanisms(body),
664
+ };
665
+ }
666
+
667
+ function readNotesText({ url, workDir } = {}) {
668
+ const id = videoIdFromUrl(url);
669
+ if (!id || !workDir) return '';
670
+ const notesPath = path.join(workDir, `yt_${id}.md`);
671
+ try {
672
+ if (!fs.existsSync(notesPath)) return '';
673
+ return fs.readFileSync(notesPath, 'utf8');
674
+ } catch {
675
+ return '';
676
+ }
677
+ }
678
+
679
+ function saveRichNotes(url, deps = {}) {
680
+ const workDir = deps.workDir || path.join(process.env.TMPDIR || '/tmp', 'ytnotes');
681
+ const lesson = notesLessonFromText(readNotesText({ url, workDir }));
682
+ if (isThinTeachLesson(lesson)) {
683
+ return { thin: true, brief: null, packRel: null };
684
+ }
685
+ const brief = fileNotesBrief(url, deps);
649
686
  const id = videoIdFromUrl(url);
687
+ const packRel = fileTeachExperiment({
688
+ cwd: deps.cwd || process.cwd(),
689
+ url,
690
+ lesson,
691
+ slug: id ? notesExperimentSlug(id) : null,
692
+ applyRel: id ? applySidecarRel(id) : null,
693
+ });
694
+ return { thin: false, brief, packRel };
695
+ }
696
+
697
+ function ensureNotesApply({ cwd, url, packRel, now, output } = {}) {
698
+ const id = videoIdFromUrl(url);
699
+ const pack = packRel || (id ? notesExperimentRel(id) : null);
650
700
  return applyGate.ensureApply({
651
701
  cwd,
652
702
  source: url,
653
703
  rel: id ? applySidecarRel(id) : null,
654
704
  now,
655
705
  output,
656
- incompleteMessage: APPLY_NEXT_MESSAGE,
706
+ incompleteMessage: pack
707
+ ? `next: apply ${pack}. keep only if measure.py moves 0→1`
708
+ : APPLY_NEXT_MESSAGE,
657
709
  required: false,
710
+ change: pack ? `apply ${pack}` : undefined,
711
+ receipt: pack ? TEACH_KEEP_RULE : undefined,
712
+ journalLine: pack ? `- [claimable] apply: ${pack}. ${TEACH_KEEP_RULE}` : undefined,
658
713
  });
659
714
  }
660
715
 
@@ -662,6 +717,50 @@ function youtubeBriefRel(id) {
662
717
  return `atris/wiki/briefs/youtube-${id}.md`;
663
718
  }
664
719
 
720
+ function removeUnsaveRel(cwd, rel, removed) {
721
+ const abs = path.join(cwd, rel);
722
+ try {
723
+ if (!fs.existsSync(abs)) return;
724
+ const st = fs.lstatSync(abs);
725
+ if (st.isDirectory()) fs.rmSync(abs, { recursive: true, force: true });
726
+ else fs.unlinkSync(abs);
727
+ removed.push(rel);
728
+ } catch {
729
+ // already gone or unreadable: do not error
730
+ }
731
+ }
732
+
733
+ function listTeachSectionNumbers(cwd, id) {
734
+ const root = path.join(cwd, 'atris', 'experiments');
735
+ const sections = [];
736
+ try {
737
+ if (!fs.existsSync(root)) return sections;
738
+ for (const name of fs.readdirSync(root)) {
739
+ const match = String(name).match(/^teach-.+-s(\d+)$/);
740
+ if (!match) continue;
741
+ const section = Number(match[1]);
742
+ if (name === teachExperimentSlug(id, section)) sections.push(section);
743
+ }
744
+ } catch {
745
+ // missing experiments dir is fine
746
+ }
747
+ return sections.sort((a, b) => a - b);
748
+ }
749
+
750
+ function listTeachSidecarRels(cwd, id) {
751
+ const briefsDir = path.join(cwd, 'atris', 'wiki', 'briefs');
752
+ const prefix = `youtube-${id}-s`;
753
+ try {
754
+ if (!fs.existsSync(briefsDir)) return [];
755
+ return fs.readdirSync(briefsDir)
756
+ .filter((name) => name.startsWith(prefix) && name.endsWith('.md'))
757
+ .sort((a, b) => a.localeCompare(b, 'en'))
758
+ .map((name) => `atris/wiki/briefs/${name}`);
759
+ } catch {
760
+ return [];
761
+ }
762
+ }
763
+
665
764
  function unsaveYoutubeNotes(target, deps = {}) {
666
765
  const output = deps.output || ((line = '') => console.log(line));
667
766
  const cwd = deps.cwd || process.cwd();
@@ -672,18 +771,26 @@ function unsaveYoutubeNotes(target, deps = {}) {
672
771
  }
673
772
  const briefRel = youtubeBriefRel(id);
674
773
  const applyRel = applySidecarRel(id);
675
- const removed = [];
676
- for (const rel of [briefRel, applyRel]) {
677
- const abs = path.join(cwd, rel);
678
- try {
679
- if (fs.existsSync(abs)) {
680
- fs.unlinkSync(abs);
681
- removed.push(rel);
682
- }
683
- } catch {
684
- // already gone or unreadable: do not error
685
- }
774
+ const sections = listTeachSectionNumbers(cwd, id);
775
+ const rels = [];
776
+ const seen = new Set();
777
+ const add = (rel) => {
778
+ if (!rel || seen.has(rel)) return;
779
+ seen.add(rel);
780
+ rels.push(rel);
781
+ };
782
+ add(briefRel);
783
+ add(applyRel);
784
+ for (const section of sections) {
785
+ add(teachBriefRel(id, section));
786
+ add(applySidecarRel(`${id}-s${section}`));
686
787
  }
788
+ for (const rel of listTeachSidecarRels(cwd, id)) add(rel);
789
+ add(notesExperimentRel(id));
790
+ for (const section of sections) add(teachExperimentRel(id, section));
791
+
792
+ const removed = [];
793
+ for (const rel of rels) removeUnsaveRel(cwd, rel, removed);
687
794
  if (!removed.length) {
688
795
  output(`already gone: ${briefRel} and ${applyRel}`);
689
796
  return 0;
@@ -1339,9 +1446,30 @@ function runOneNotesItem(item, engine, deps = {}) {
1339
1446
  } catch {
1340
1447
  status = 1;
1341
1448
  }
1342
- const brief = status === 0 && deps.save ? fileNotesBrief(item.url, deps) : null;
1449
+ let brief = null;
1450
+ let ok = status === 0;
1451
+ if (ok && deps.save) {
1452
+ const saved = saveRichNotes(item.url, deps);
1453
+ if (saved.thin) {
1454
+ output(TEACH_THIN_REFUSE);
1455
+ ok = false;
1456
+ } else {
1457
+ brief = saved.brief;
1458
+ const ensureApply = deps.ensureApply || ensureNotesApply;
1459
+ try {
1460
+ ensureApply({
1461
+ cwd: deps.cwd || process.cwd(),
1462
+ url: item.url,
1463
+ packRel: saved.packRel,
1464
+ now: deps.now,
1465
+ output,
1466
+ });
1467
+ } catch {
1468
+ // apply filing must never break the batch
1469
+ }
1470
+ }
1471
+ }
1343
1472
  const seconds = Math.max(0, Math.round((readNowMs(deps) - started) / 1000));
1344
- const ok = status === 0;
1345
1473
  output(`${label} ${seconds}s ${ok ? (brief || 'ok') : 'FAILED'}`);
1346
1474
  return { url: item.url, id: item.id, seconds, ok, brief };
1347
1475
  }
@@ -1380,12 +1508,23 @@ function runSingleYoutubeNotes(url, engine, deps = {}) {
1380
1508
  }
1381
1509
  const status = readRunnerStatus(result);
1382
1510
  if (status !== 0) return status;
1383
- if (!deps.save) return 0;
1384
- fileNotesBrief(url, deps);
1511
+ const output = deps.output || ((line = '') => console.error(line));
1512
+ 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');
1516
+ return 0;
1517
+ }
1518
+ const saved = saveRichNotes(url, deps);
1519
+ if (saved.thin) {
1520
+ output(TEACH_THIN_REFUSE);
1521
+ return 2;
1522
+ }
1385
1523
  const ensureApply = deps.ensureApply || ensureNotesApply;
1386
1524
  return ensureApply({
1387
1525
  cwd: deps.cwd || process.cwd(),
1388
1526
  url,
1527
+ packRel: saved.packRel,
1389
1528
  now: deps.now,
1390
1529
  output: deps.output,
1391
1530
  });
@@ -1936,9 +2075,11 @@ async function runYoutubeSearch(args = [], deps = {}) {
1936
2075
  return 0;
1937
2076
  }
1938
2077
 
1939
- const YTTEACH_USAGE = 'usage: atris youtube teach <youtube-url> [--section N] [--save]';
2078
+ const YTTEACH_USAGE = 'usage: atris youtube teach <youtube-url> [--section N] [--save] [--recap TEXT] [--skip]';
1940
2079
  const TEACH_PAID_REFUSE = 'teach is free local captions. drop --paid.';
1941
2080
  const TEACH_THIN_REFUSE = 'thin: no number or named mechanism. no brief.';
2081
+ const TEACH_OWED_FILE = 'youtube-teach-owed.json';
2082
+ const TEACH_RECAP_MISSING = '--recap needs the unpaid check';
1942
2083
  const TEACH_APPLY_NEXT_MESSAGE = APPLY_NEXT_MESSAGE;
1943
2084
  const TEACH_KEEP_RULE = 'keep only if measure.py moves 0→1. scores 1 only when the fixture contains the check tokens.';
1944
2085
  const MECHANISM_STOP = new Set([
@@ -1965,6 +2106,9 @@ function parseTeachArgs(argv = []) {
1965
2106
  const options = {
1966
2107
  help: false,
1967
2108
  save: false,
2109
+ json: false,
2110
+ skip: false,
2111
+ recap: null,
1968
2112
  url: null,
1969
2113
  section: 1,
1970
2114
  };
@@ -1980,8 +2124,23 @@ function parseTeachArgs(argv = []) {
1980
2124
  options.help = true;
1981
2125
  } else if (arg === '--save') {
1982
2126
  options.save = true;
2127
+ } else if (arg === '--json') {
2128
+ options.json = true;
2129
+ } else if (arg === '--skip') {
2130
+ options.skip = true;
1983
2131
  } else if (arg === '--paid') {
1984
2132
  throw new Error(TEACH_PAID_REFUSE);
2133
+ } else if (arg === '--recap') {
2134
+ const raw = args[i + 1];
2135
+ if (raw == null || String(raw).startsWith('--')) {
2136
+ throw new Error(TEACH_RECAP_MISSING);
2137
+ }
2138
+ options.recap = String(raw);
2139
+ i += 1;
2140
+ } else if (arg.startsWith('--recap=')) {
2141
+ const value = arg.slice('--recap='.length);
2142
+ if (!value) throw new Error(TEACH_RECAP_MISSING);
2143
+ options.recap = value;
1985
2144
  } else if (arg === '--section') {
1986
2145
  const raw = args[i + 1];
1987
2146
  const value = Number.parseInt(raw, 10);
@@ -1998,6 +2157,18 @@ function parseTeachArgs(argv = []) {
1998
2157
  options.section = value;
1999
2158
  } else if (arg.startsWith('-')) {
2000
2159
  throw new Error(`Unknown option: ${arg}`);
2160
+ } else if (arg === 'recap' && options.recap == null && !options.url) {
2161
+ const parts = [];
2162
+ i += 1;
2163
+ while (i < args.length && !String(args[i]).startsWith('--')) {
2164
+ parts.push(String(args[i]));
2165
+ i += 1;
2166
+ }
2167
+ i -= 1;
2168
+ options.recap = parts.join(' ').trim();
2169
+ if (!options.recap) throw new Error(TEACH_RECAP_MISSING);
2170
+ } else if (arg === 'skip' && !options.url) {
2171
+ options.skip = true;
2001
2172
  } else if (!options.url && looksLikeYoutubeUrl(arg)) {
2002
2173
  options.url = arg;
2003
2174
  } else {
@@ -2006,7 +2177,9 @@ function parseTeachArgs(argv = []) {
2006
2177
  }
2007
2178
 
2008
2179
  if (options.help) return options;
2009
- if (!options.url) throw new Error('Missing YouTube URL. Run "atris youtube teach --help".');
2180
+ if (!options.url && options.recap == null && !options.skip) {
2181
+ throw new Error('Missing YouTube URL. Run "atris youtube teach --help".');
2182
+ }
2010
2183
  return options;
2011
2184
  }
2012
2185
 
@@ -2185,14 +2358,17 @@ function teachBriefRel(id, section) {
2185
2358
  return `atris/wiki/briefs/youtube-${id}-s${section}.md`;
2186
2359
  }
2187
2360
 
2188
- function teachExperimentSlug(id, section) {
2189
- const safe = String(id || 'video')
2361
+ function experimentIdToken(id) {
2362
+ return String(id || 'video')
2190
2363
  .toLowerCase()
2191
2364
  .replace(/_/g, '-')
2192
2365
  .replace(/[^a-z0-9-]+/g, '')
2193
2366
  .replace(/-+/g, '-')
2194
2367
  .replace(/^-+|-+$/g, '') || 'video';
2195
- return `teach-${safe}-s${Number(section) || 1}`;
2368
+ }
2369
+
2370
+ function teachExperimentSlug(id, section) {
2371
+ return `teach-${experimentIdToken(id)}-s${Number(section) || 1}`;
2196
2372
  }
2197
2373
 
2198
2374
  function teachExperimentRel(id, section) {
@@ -2207,22 +2383,118 @@ function teachCheckNeedles(lesson = {}) {
2207
2383
  return [];
2208
2384
  }
2209
2385
 
2386
+ function teachRecapTokens(lesson = {}) {
2387
+ const tokens = [];
2388
+ const seen = new Set();
2389
+ for (const value of [...(lesson.mechanisms || []), ...(lesson.numbers || [])]) {
2390
+ const token = String(value || '').trim().toLowerCase();
2391
+ if (!token || seen.has(token)) continue;
2392
+ seen.add(token);
2393
+ tokens.push(token);
2394
+ }
2395
+ return tokens;
2396
+ }
2397
+
2398
+ function recapHitsTokens(text, tokens) {
2399
+ const hay = String(text || '').toLowerCase();
2400
+ return (tokens || []).some((token) => {
2401
+ const needle = String(token || '').trim().toLowerCase();
2402
+ return needle.length > 0 && hay.includes(needle);
2403
+ });
2404
+ }
2405
+
2406
+ function teachOwedPath(deps = {}) {
2407
+ if (deps.teachOwedPath) return deps.teachOwedPath;
2408
+ const cwd = deps.cwd || process.cwd();
2409
+ return path.join(cwd, '.atris', TEACH_OWED_FILE);
2410
+ }
2411
+
2412
+ function readTeachOwedStore(deps = {}) {
2413
+ try {
2414
+ const parsed = JSON.parse(fs.readFileSync(teachOwedPath(deps), 'utf8'));
2415
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
2416
+ return parsed;
2417
+ } catch {
2418
+ return {};
2419
+ }
2420
+ }
2421
+
2422
+ function writeTeachOwedStore(deps, store) {
2423
+ try {
2424
+ const filePath = teachOwedPath(deps);
2425
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
2426
+ fs.writeFileSync(filePath, `${JSON.stringify(store || {})}\n`);
2427
+ } catch {
2428
+ // owed recap is ephemeral; a failed write must not break teach
2429
+ }
2430
+ }
2431
+
2432
+ function rememberTeachOwed(deps, { url, section, lesson } = {}) {
2433
+ const id = videoIdFromUrl(url);
2434
+ if (!id) return;
2435
+ const store = readTeachOwedStore(deps);
2436
+ store[id] = {
2437
+ section: Number(section) || 1,
2438
+ check: teachCheckLine(lesson),
2439
+ tokens: teachRecapTokens(lesson),
2440
+ };
2441
+ writeTeachOwedStore(deps, store);
2442
+ }
2443
+
2444
+ function previousTeachUnlocked(owed, section) {
2445
+ if (!owed) return true;
2446
+ return Number(owed.section) >= Number(section);
2447
+ }
2448
+
2449
+ function applyTeachRecap(parsed, deps, output) {
2450
+ const store = readTeachOwedStore(deps);
2451
+ let id = parsed.url ? videoIdFromUrl(parsed.url) : null;
2452
+ let entry = id ? store[id] : null;
2453
+ if (!entry && !id) {
2454
+ const ids = Object.keys(store);
2455
+ if (parsed.recap) {
2456
+ id = ids.find((key) => recapHitsTokens(parsed.recap, store[key] && store[key].tokens));
2457
+ }
2458
+ if (!id && ids.length === 1) id = ids[0];
2459
+ entry = id ? store[id] : null;
2460
+ }
2461
+ if (!entry) {
2462
+ if (parsed.skip) return 0;
2463
+ if (!parsed.json) output('recap the previous section first');
2464
+ return 2;
2465
+ }
2466
+ if (parsed.skip) {
2467
+ delete store[id];
2468
+ writeTeachOwedStore(deps, store);
2469
+ return 0;
2470
+ }
2471
+ if (!recapHitsTokens(parsed.recap, entry.tokens)) {
2472
+ if (!parsed.json) output(entry.check);
2473
+ return 2;
2474
+ }
2475
+ delete store[id];
2476
+ writeTeachOwedStore(deps, store);
2477
+ return 0;
2478
+ }
2479
+
2210
2480
  function teachCheckLine(lesson = {}) {
2211
2481
  return oneTeachCheck(lesson.mechanisms || [], lesson.numbers || [], '');
2212
2482
  }
2213
2483
 
2214
- function fileTeachExperiment({ cwd, url, section, lesson } = {}) {
2484
+ function fileTeachExperiment({ cwd, url, section, lesson, slug, applyRel } = {}) {
2215
2485
  try {
2216
- const id = videoIdFromUrl(url);
2217
- if (!id || !cwd) return null;
2218
- const slug = teachExperimentSlug(id, section);
2219
- const rel = `atris/experiments/${slug}`;
2486
+ const id = url ? videoIdFromUrl(url) : null;
2487
+ if (!cwd) return null;
2488
+ const packSlug = slug || (id ? teachExperimentSlug(id, section) : null);
2489
+ if (!packSlug) return null;
2490
+ const rel = `atris/experiments/${packSlug}`;
2220
2491
  const dir = path.join(cwd, rel);
2221
2492
  fs.mkdirSync(dir, { recursive: true });
2222
2493
 
2223
2494
  const check = teachCheckLine(lesson);
2224
2495
  const needles = teachCheckNeedles(lesson);
2225
- const applyRel = applySidecarRel(`${id}-s${section}`);
2496
+ const sidecarRel = applyRel || (id ? applySidecarRel(`${id}-s${section}`) : null);
2497
+ if (!sidecarRel) return null;
2226
2498
  const program = [
2227
2499
  '# Program',
2228
2500
  '',
@@ -2243,7 +2515,7 @@ function fileTeachExperiment({ cwd, url, section, lesson } = {}) {
2243
2515
  'EXPERIMENT_DIR = Path(__file__).resolve().parent',
2244
2516
  `CHECK = ${JSON.stringify(check)}`,
2245
2517
  `NEEDLES = ${JSON.stringify(needles)}`,
2246
- `DEFAULT_TARGET = ${JSON.stringify(applyRel)}`,
2518
+ `DEFAULT_TARGET = ${JSON.stringify(sidecarRel)}`,
2247
2519
  '',
2248
2520
  '',
2249
2521
  'def repo_root() -> Path:',
@@ -2559,6 +2831,26 @@ async function runYoutubeTeach(args = [], deps = {}) {
2559
2831
  return 0;
2560
2832
  }
2561
2833
 
2834
+ const owedDeps = { ...deps, cwd: deps.cwd || process.cwd() };
2835
+ if (parsed.recap != null || parsed.skip) {
2836
+ const recapCode = applyTeachRecap(parsed, owedDeps, output);
2837
+ if (recapCode !== 0) return recapCode;
2838
+ if (!parsed.url || parsed.section <= 1) return 0;
2839
+ }
2840
+
2841
+ if (!parsed.url) {
2842
+ output('Missing YouTube URL. Run "atris youtube teach --help".');
2843
+ return 2;
2844
+ }
2845
+
2846
+ if (parsed.section > 1) {
2847
+ const owed = readTeachOwedStore(owedDeps)[videoIdFromUrl(parsed.url) || ''];
2848
+ if (!previousTeachUnlocked(owed, parsed.section)) {
2849
+ if (!parsed.json && owed && owed.check) output(owed.check);
2850
+ return 2;
2851
+ }
2852
+ }
2853
+
2562
2854
  const source = await (deps.extractTeachSource || extractTeachSource)(parsed.url, deps);
2563
2855
  if (!source || !Array.isArray(source.cues) || !source.cues.length) {
2564
2856
  output('no english captions for this url. teach stays local and will not call process.');
@@ -2582,8 +2874,12 @@ async function runYoutubeTeach(args = [], deps = {}) {
2582
2874
  title: source.title,
2583
2875
  });
2584
2876
  output(lesson.text);
2877
+ rememberTeachOwed(owedDeps, { url: parsed.url, section: parsed.section, lesson });
2585
2878
 
2586
- if (!parsed.save) return 0;
2879
+ if (!parsed.save) {
2880
+ if (!isThinTeachLesson(lesson)) applyGate.hintEphemeralApply(output, 'teach');
2881
+ return 0;
2882
+ }
2587
2883
  if (isThinTeachLesson(lesson)) {
2588
2884
  output(TEACH_THIN_REFUSE);
2589
2885
  return 2;
@@ -2706,5 +3002,7 @@ module.exports = {
2706
3002
  isThinTeachLesson,
2707
3003
  TEACH_THIN_REFUSE,
2708
3004
  teachExperimentSlug,
3005
+ notesExperimentSlug,
3006
+ fileTeachExperiment,
2709
3007
  youtubeCommand,
2710
3008
  };
package/lib/apply-gate.js CHANGED
@@ -95,8 +95,21 @@ function ensureApply({
95
95
  return required ? 2 : 0;
96
96
  }
97
97
 
98
+ function ephemeralApplyMessage(kind) {
99
+ const label = String(kind || '').trim() || 'result';
100
+ return `next: write one apply (change + receipt) for this ${label}`;
101
+ }
102
+
103
+ function hintEphemeralApply(output, kind) {
104
+ const print = typeof output === 'function' ? output : (line = '') => console.error(line);
105
+ print(ephemeralApplyMessage(kind));
106
+ return 0;
107
+ }
108
+
98
109
  module.exports = {
99
110
  applySlug,
100
111
  applySidecarRel,
101
112
  ensureApply,
113
+ ephemeralApplyMessage,
114
+ hintEphemeralApply,
102
115
  };
package/lib/engine-ask.js CHANGED
@@ -222,7 +222,6 @@ function buildReadOnlyEngineInvocation(engineName, prompt, modelName = '') {
222
222
  '--model', model || profile.model || DEFAULT_CLAUDE_RUNNER_MODEL,
223
223
  '--tools', 'Read,Glob,Grep,WebSearch,WebFetch',
224
224
  '--permission-mode', 'plan',
225
- '--safe-mode',
226
225
  '--no-session-persistence',
227
226
  ],
228
227
  };
@@ -293,6 +292,25 @@ function cancelledAskResult(job, extra = {}) {
293
292
  };
294
293
  }
295
294
 
295
+ function osAccountName() {
296
+ try {
297
+ return String(os.userInfo().username || '').trim();
298
+ } catch {
299
+ return '';
300
+ }
301
+ }
302
+
303
+ // Claude Code looks up macOS Keychain with process.env.USER, not whoami.
304
+ // Host apps sometimes pass a display name, so pin USER and LOGNAME to the OS account.
305
+ function buildAskSpawnEnv(sourceEnv = process.env) {
306
+ const env = { ...sourceEnv };
307
+ const username = osAccountName();
308
+ if (!username) return env;
309
+ env.USER = username;
310
+ env.LOGNAME = username;
311
+ return env;
312
+ }
313
+
296
314
  function runAskProcess(invocation, {
297
315
  cwd = process.cwd(),
298
316
  timeoutMs = DEFAULT_ASK_TIMEOUT_MS,
@@ -387,7 +405,7 @@ function runAskProcess(invocation, {
387
405
  try {
388
406
  child = spawnProcess(invocation.bin, invocation.args, {
389
407
  cwd,
390
- env: process.env,
408
+ env: buildAskSpawnEnv(),
391
409
  detached: process.platform !== 'win32',
392
410
  stdio: ['ignore', 'pipe', 'pipe'],
393
411
  });
@@ -671,6 +689,7 @@ module.exports = {
671
689
  MAX_ASK_PROMPT_BYTES,
672
690
  parseEngineAskArgs,
673
691
  buildReadOnlyEngineInvocation,
692
+ buildAskSpawnEnv,
674
693
  runAskProcess,
675
694
  runEngineAskJobs,
676
695
  runEngineAskCommand,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.57.2",
3
+ "version": "3.57.4",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {