atris 3.56.1 → 3.56.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,9 @@ const { checkoutBehindMessage } = require('../lib/checkout-sync');
7
7
  const {
8
8
  isFreshWorkspace,
9
9
  isKeepWorkingMinute,
10
+ isClaimMinute,
10
11
  buildFirstMinute,
12
+ speakFirstMinute,
11
13
  speakKeepWorkingMinute,
12
14
  speakNothingRunning,
13
15
  } = require('../lib/first-minute');
@@ -146,13 +148,19 @@ function statusAtris(isQuick = false, jsonMode = false, verbose = false) {
146
148
  }
147
149
 
148
150
  // Just-minted file folder, nothing running: same two lines as
149
- // first-minute / the next do. A live mission still gets the board.
150
- // --verbose keeps the factory dump because the operator asked for it.
151
+ // first-minute / the next keep-working ready. After init, next is claim: same
152
+ // two lines as bare atris / now. Not factory let-it-run.
153
+ // A live mission still gets the board. --verbose keeps the
154
+ // factory dump because the operator asked for it. --json on
155
+ // the claim path keeps the factory board for scripts.
151
156
  if (!verbose && !hasLiveKeepWorkingRun()) {
152
157
  const minute = buildFirstMinute({ root: process.cwd() });
153
158
  if (isKeepWorkingMinute(minute)) {
154
159
  process.exit(speakKeepWorkingMinute({ asJson: jsonMode }));
155
160
  }
161
+ if (!jsonMode && isClaimMinute(minute)) {
162
+ process.exit(speakFirstMinute());
163
+ }
156
164
  }
157
165
 
158
166
  const targetDir = path.join(process.cwd(), 'atris');
@@ -882,9 +882,10 @@ async function doAtris() {
882
882
  const targetDir = path.join(cwd, 'atris');
883
883
 
884
884
  // Empty folder talks like bare atris. Files already here start
885
- // first-talk, then next is atris do. A second do stays two first-minute
886
- // lines. Missing executor.md after init --minimal is optional
887
- // context, not a factory bounce.
885
+ // first-talk, then next is atris do. After that work is yours,
886
+ // next is task ready so keep-working is not a do loop. Missing
887
+ // executor.md after init --minimal is optional context, not a
888
+ // factory bounce.
888
889
  if (!fs.existsSync(targetDir)) {
889
890
  const visible = listUserVisibleWork(cwd);
890
891
  if (visible.length) {
@@ -53,7 +53,7 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
53
53
  output('Options:');
54
54
  output(' --limit <n> Max search results (default: 5)');
55
55
  output(' --paid Bill 5 credits for watch permalinks (search only)');
56
- output(' --save File brief, journal line, and apply stub (notes and teach)');
56
+ output(' --save File brief, journal, apply stub; teach also mints a keep/revert experiment');
57
57
  output(' --section <n> Chapter to teach, 1-based (teach only, default: 1)');
58
58
  output(' --unsave Delete filed brief and apply stub (no paid calls)');
59
59
  output(' --query, -q <text> Focus question for the analysis');
@@ -1938,7 +1938,9 @@ async function runYoutubeSearch(args = [], deps = {}) {
1938
1938
 
1939
1939
  const YTTEACH_USAGE = 'usage: atris youtube teach <youtube-url> [--section N] [--save]';
1940
1940
  const TEACH_PAID_REFUSE = 'teach is free local captions. drop --paid.';
1941
+ const TEACH_THIN_REFUSE = 'thin: no number or named mechanism. no brief.';
1941
1942
  const TEACH_APPLY_NEXT_MESSAGE = APPLY_NEXT_MESSAGE;
1943
+ const TEACH_KEEP_RULE = 'keep only if measure.py moves 0→1. scores 1 only when the fixture contains the check tokens.';
1942
1944
  const MECHANISM_STOP = new Set([
1943
1945
  'the', 'this', 'that', 'and', 'but', 'for', 'with', 'from', 'you', 'we', 'they',
1944
1946
  'what', 'when', 'how', 'why', 'there', 'here', 'then', 'just', 'also', 'very',
@@ -2137,7 +2139,7 @@ function quoteYoutubeUrl(url) {
2137
2139
  return `"${String(url || '').replace(/"/g, '')}"`;
2138
2140
  }
2139
2141
 
2140
- function formatTeachLesson({ url, section, chapters, chapter, cues, title } = {}) {
2142
+ function teachLessonFromCues({ url, section, chapters, chapter, cues, title } = {}) {
2141
2143
  const total = Array.isArray(chapters) && chapters.length ? chapters.length : 1;
2142
2144
  const heading = String(chapter?.title || 'full video').trim().toLowerCase();
2143
2145
  const videoTitle = String(title || '').trim().toLowerCase();
@@ -2166,13 +2168,293 @@ function formatTeachLesson({ url, section, chapters, chapter, cues, title } = {}
2166
2168
  lines.push('');
2167
2169
  lines.push('next: last section');
2168
2170
  }
2169
- return lines.join('\n');
2171
+ return { text: lines.join('\n'), numbers, mechanisms };
2172
+ }
2173
+
2174
+ function formatTeachLesson(opts) {
2175
+ return teachLessonFromCues(opts).text;
2176
+ }
2177
+
2178
+ function isThinTeachLesson(lesson = {}) {
2179
+ const numbers = Array.isArray(lesson.numbers) ? lesson.numbers : [];
2180
+ const mechanisms = Array.isArray(lesson.mechanisms) ? lesson.mechanisms : [];
2181
+ return numbers.length === 0 && mechanisms.length === 0;
2170
2182
  }
2171
2183
 
2172
2184
  function teachBriefRel(id, section) {
2173
2185
  return `atris/wiki/briefs/youtube-${id}-s${section}.md`;
2174
2186
  }
2175
2187
 
2188
+ function teachExperimentSlug(id, section) {
2189
+ const safe = String(id || 'video')
2190
+ .toLowerCase()
2191
+ .replace(/_/g, '-')
2192
+ .replace(/[^a-z0-9-]+/g, '')
2193
+ .replace(/-+/g, '-')
2194
+ .replace(/^-+|-+$/g, '') || 'video';
2195
+ return `teach-${safe}-s${Number(section) || 1}`;
2196
+ }
2197
+
2198
+ function teachExperimentRel(id, section) {
2199
+ return `atris/experiments/${teachExperimentSlug(id, section)}`;
2200
+ }
2201
+
2202
+ function teachCheckNeedles(lesson = {}) {
2203
+ const mechanisms = Array.isArray(lesson.mechanisms) ? lesson.mechanisms : [];
2204
+ const numbers = Array.isArray(lesson.numbers) ? lesson.numbers : [];
2205
+ if (mechanisms[0]) return [String(mechanisms[0]).toLowerCase()];
2206
+ if (numbers[0]) return [String(numbers[0]).toLowerCase()];
2207
+ return [];
2208
+ }
2209
+
2210
+ function teachCheckLine(lesson = {}) {
2211
+ return oneTeachCheck(lesson.mechanisms || [], lesson.numbers || [], '');
2212
+ }
2213
+
2214
+ function fileTeachExperiment({ cwd, url, section, lesson } = {}) {
2215
+ try {
2216
+ const id = videoIdFromUrl(url);
2217
+ if (!id || !cwd) return null;
2218
+ const slug = teachExperimentSlug(id, section);
2219
+ const rel = `atris/experiments/${slug}`;
2220
+ const dir = path.join(cwd, rel);
2221
+ fs.mkdirSync(dir, { recursive: true });
2222
+
2223
+ const check = teachCheckLine(lesson);
2224
+ const needles = teachCheckNeedles(lesson);
2225
+ const applyRel = applySidecarRel(`${id}-s${section}`);
2226
+ const program = [
2227
+ '# Program',
2228
+ '',
2229
+ `Target: the taught fail-able check ${JSON.stringify(check)}. measure.py scores 1 only when the fixture contains ${needles.map((n) => JSON.stringify(n)).join(' and ') || 'the check tokens'}. The default fixture is the teach apply sidecar. Score is 0 if that file is missing or omits the token. Keep a candidate only when the score moves from 0 to 1.`,
2230
+ '',
2231
+ ].join('\n');
2232
+
2233
+ const measurePy = [
2234
+ '"""Score whether the taught check is present in the product fixture."""',
2235
+ '',
2236
+ 'from __future__ import annotations',
2237
+ '',
2238
+ 'import json',
2239
+ 'import os',
2240
+ 'from pathlib import Path',
2241
+ '',
2242
+ '',
2243
+ 'EXPERIMENT_DIR = Path(__file__).resolve().parent',
2244
+ `CHECK = ${JSON.stringify(check)}`,
2245
+ `NEEDLES = ${JSON.stringify(needles)}`,
2246
+ `DEFAULT_TARGET = ${JSON.stringify(applyRel)}`,
2247
+ '',
2248
+ '',
2249
+ 'def repo_root() -> Path:',
2250
+ ' env = os.environ.get("ATRIS_REPO_ROOT")',
2251
+ ' if env:',
2252
+ ' return Path(env).resolve()',
2253
+ ' return EXPERIMENT_DIR.parents[2]',
2254
+ '',
2255
+ '',
2256
+ 'def fixture_path():',
2257
+ ' env = os.environ.get("ATRIS_TEACH_MEASURE_FIXTURE")',
2258
+ ' if env:',
2259
+ ' return Path(env).resolve()',
2260
+ ' target = repo_root() / DEFAULT_TARGET',
2261
+ ' return target if target.is_file() else None',
2262
+ '',
2263
+ '',
2264
+ 'def fail_payload(reason: str) -> dict:',
2265
+ ' return {',
2266
+ ' "score": 0,',
2267
+ ' "passed": 0,',
2268
+ ' "total": 1,',
2269
+ ' "status": "fail",',
2270
+ ' "reason": reason,',
2271
+ ' "check": CHECK,',
2272
+ ' }',
2273
+ '',
2274
+ '',
2275
+ 'def score_text(text: str) -> dict:',
2276
+ ' blob = text.lower()',
2277
+ ' found = bool(NEEDLES) and all(needle.lower() in blob for needle in NEEDLES)',
2278
+ ' score = 1 if found else 0',
2279
+ ' return {',
2280
+ ' "score": score,',
2281
+ ' "passed": score,',
2282
+ ' "total": 1,',
2283
+ ' "status": "pass" if score == 1 else "fail",',
2284
+ ' "check": CHECK,',
2285
+ ' }',
2286
+ '',
2287
+ '',
2288
+ 'def main() -> int:',
2289
+ ' path = fixture_path()',
2290
+ ' if path is None or not path.is_file():',
2291
+ ' payload = fail_payload("fixture missing")',
2292
+ ' else:',
2293
+ ' payload = score_text(path.read_text(encoding="utf-8"))',
2294
+ ' print(json.dumps(payload))',
2295
+ ' return 0',
2296
+ '',
2297
+ '',
2298
+ 'if __name__ == "__main__":',
2299
+ ' raise SystemExit(main())',
2300
+ '',
2301
+ ].join('\n');
2302
+
2303
+ const loopPy = [
2304
+ '"""Keep a candidate only when the taught-check score moves from 0 to 1."""',
2305
+ '',
2306
+ 'from __future__ import annotations',
2307
+ '',
2308
+ 'import argparse',
2309
+ 'import csv',
2310
+ 'import json',
2311
+ 'import os',
2312
+ 'from pathlib import Path',
2313
+ 'import subprocess',
2314
+ 'import sys',
2315
+ 'from datetime import datetime, timezone',
2316
+ '',
2317
+ '',
2318
+ 'EXPERIMENT_DIR = Path(__file__).resolve().parent',
2319
+ 'DEFAULT_MEASURE = EXPERIMENT_DIR / "measure.py"',
2320
+ 'DEFAULT_RESULTS = EXPERIMENT_DIR / "results.tsv"',
2321
+ '',
2322
+ '',
2323
+ 'def run_measure(measure_path: Path) -> dict:',
2324
+ ' proc = subprocess.run(',
2325
+ ' [sys.executable, str(measure_path)],',
2326
+ ' cwd=str(EXPERIMENT_DIR),',
2327
+ ' capture_output=True,',
2328
+ ' text=True,',
2329
+ ' check=True,',
2330
+ ' )',
2331
+ ' return json.loads(proc.stdout.strip().splitlines()[-1])',
2332
+ '',
2333
+ '',
2334
+ 'def append_result(results_path: Path, row: dict) -> None:',
2335
+ ' write_header = not results_path.exists() or results_path.stat().st_size == 0',
2336
+ ' with results_path.open("a", newline="", encoding="utf-8") as handle:',
2337
+ ' writer = csv.DictWriter(',
2338
+ ' handle,',
2339
+ ' fieldnames=[',
2340
+ ' "timestamp",',
2341
+ ' "trial",',
2342
+ ' "status",',
2343
+ ' "old_score",',
2344
+ ' "new_score",',
2345
+ ' "proposal",',
2346
+ ' "description",',
2347
+ ' ],',
2348
+ ' delimiter="\\t",',
2349
+ ' )',
2350
+ ' if write_header:',
2351
+ ' writer.writeheader()',
2352
+ ' writer.writerow(row)',
2353
+ '',
2354
+ '',
2355
+ 'def main() -> int:',
2356
+ ' parser = argparse.ArgumentParser(description="Run the taught-check keep/revert loop.")',
2357
+ ' parser.add_argument("--proposal", action="append", default=[])',
2358
+ ' args = parser.parse_args()',
2359
+ '',
2360
+ ' measure_path = DEFAULT_MEASURE.resolve()',
2361
+ ' results_path = DEFAULT_RESULTS.resolve()',
2362
+ '',
2363
+ ' baseline = run_measure(measure_path)',
2364
+ ' current_score = float(baseline["score"])',
2365
+ ' print(f"BASELINE {current_score:.4f}")',
2366
+ '',
2367
+ ' if not args.proposal:',
2368
+ ' append_result(',
2369
+ ' results_path,',
2370
+ ' {',
2371
+ ' "timestamp": datetime.now(timezone.utc).isoformat(),',
2372
+ ' "trial": 0,',
2373
+ ' "status": "baseline",',
2374
+ ' "old_score": f"{current_score:.4f}",',
2375
+ ' "new_score": f"{current_score:.4f}",',
2376
+ ' "proposal": "measure.py",',
2377
+ ' "description": "current taught-check fixture score",',
2378
+ ' },',
2379
+ ' )',
2380
+ '',
2381
+ ' for trial_index, proposal in enumerate(args.proposal, start=1):',
2382
+ ' proposal_path = Path(proposal).resolve()',
2383
+ ' status = "error"',
2384
+ ' old_score = current_score',
2385
+ ' new_score = current_score',
2386
+ ' description = ""',
2387
+ '',
2388
+ ' try:',
2389
+ ' proc = subprocess.run(',
2390
+ ' [sys.executable, str(proposal_path)],',
2391
+ ' cwd=str(EXPERIMENT_DIR),',
2392
+ ' capture_output=True,',
2393
+ ' text=True,',
2394
+ ' check=True,',
2395
+ ' env={**os.environ, "EXPERIMENT_DIR": str(EXPERIMENT_DIR)},',
2396
+ ' )',
2397
+ ' if proc.stdout.strip():',
2398
+ ' description = proc.stdout.strip().splitlines()[-1][:200]',
2399
+ '',
2400
+ ' measured = run_measure(measure_path)',
2401
+ ' new_score = float(measured["score"])',
2402
+ ' if old_score < 1 and new_score > old_score:',
2403
+ ' status = "kept"',
2404
+ ' current_score = new_score',
2405
+ ' else:',
2406
+ ' status = "reverted"',
2407
+ ' except subprocess.CalledProcessError as exc:',
2408
+ ' stderr = (exc.stderr or exc.stdout or "").strip()',
2409
+ ' description = (stderr.splitlines()[-1] if stderr else "proposal failed")[:200]',
2410
+ ' status = "error"',
2411
+ '',
2412
+ ' append_result(',
2413
+ ' results_path,',
2414
+ ' {',
2415
+ ' "timestamp": datetime.now(timezone.utc).isoformat(),',
2416
+ ' "trial": trial_index,',
2417
+ ' "status": status,',
2418
+ ' "old_score": f"{old_score:.4f}",',
2419
+ ' "new_score": f"{new_score:.4f}",',
2420
+ ' "proposal": proposal_path.name,',
2421
+ ' "description": description,',
2422
+ ' },',
2423
+ ' )',
2424
+ ' print(f"TRIAL {trial_index} {status.upper()} score={new_score:.4f} proposal={proposal_path.name}")',
2425
+ '',
2426
+ ' final_measure = run_measure(measure_path)',
2427
+ ' print(f"FINAL {final_measure[\'score\']:.4f}")',
2428
+ ' return 0',
2429
+ '',
2430
+ '',
2431
+ 'if __name__ == "__main__":',
2432
+ ' raise SystemExit(main())',
2433
+ '',
2434
+ ].join('\n');
2435
+
2436
+ fs.writeFileSync(path.join(dir, 'program.md'), program);
2437
+ fs.writeFileSync(path.join(dir, 'measure.py'), measurePy);
2438
+ fs.writeFileSync(path.join(dir, 'loop.py'), loopPy);
2439
+ fs.writeFileSync(
2440
+ path.join(dir, 'reset.py'),
2441
+ [
2442
+ '"""The target is the product fixture, not a local candidate file."""',
2443
+ '',
2444
+ 'print("teach experiment target is the apply sidecar or ATRIS_TEACH_MEASURE_FIXTURE; nothing local to restore")',
2445
+ '',
2446
+ ].join('\n'),
2447
+ );
2448
+ fs.writeFileSync(
2449
+ path.join(dir, 'results.tsv'),
2450
+ 'timestamp\ttrial\tstatus\told_score\tnew_score\tproposal\tdescription\n',
2451
+ );
2452
+ return rel;
2453
+ } catch {
2454
+ return null;
2455
+ }
2456
+ }
2457
+
2176
2458
  function fileTeachBrief({ cwd, url, section, lesson, now } = {}) {
2177
2459
  try {
2178
2460
  const id = videoIdFromUrl(url);
@@ -2208,16 +2490,22 @@ function fileTeachBrief({ cwd, url, section, lesson, now } = {}) {
2208
2490
  }
2209
2491
  }
2210
2492
 
2211
- function ensureTeachApply({ cwd, url, section, now, output } = {}) {
2493
+ function ensureTeachApply({ cwd, url, section, packRel, now, output } = {}) {
2212
2494
  const id = videoIdFromUrl(url);
2495
+ const pack = packRel || (id ? teachExperimentRel(id, section) : null);
2213
2496
  return applyGate.ensureApply({
2214
2497
  cwd,
2215
2498
  source: url,
2216
2499
  rel: id ? applySidecarRel(`${id}-s${section}`) : null,
2217
2500
  now,
2218
2501
  output,
2219
- incompleteMessage: TEACH_APPLY_NEXT_MESSAGE,
2502
+ incompleteMessage: pack
2503
+ ? `next: apply ${pack}. keep only if measure.py moves 0→1`
2504
+ : TEACH_APPLY_NEXT_MESSAGE,
2220
2505
  required: false,
2506
+ change: pack ? `apply ${pack}` : undefined,
2507
+ receipt: pack ? TEACH_KEEP_RULE : undefined,
2508
+ journalLine: pack ? `- [claimable] apply: ${pack}. ${TEACH_KEEP_RULE}` : undefined,
2221
2509
  });
2222
2510
  }
2223
2511
 
@@ -2285,7 +2573,7 @@ async function runYoutubeTeach(args = [], deps = {}) {
2285
2573
 
2286
2574
  const chapter = chapters[parsed.section - 1];
2287
2575
  const cues = sliceCuesForChapter(source.cues, chapter);
2288
- const lesson = formatTeachLesson({
2576
+ const lesson = teachLessonFromCues({
2289
2577
  url: parsed.url,
2290
2578
  section: parsed.section,
2291
2579
  chapters,
@@ -2293,22 +2581,33 @@ async function runYoutubeTeach(args = [], deps = {}) {
2293
2581
  cues,
2294
2582
  title: source.title,
2295
2583
  });
2296
- output(lesson);
2584
+ output(lesson.text);
2297
2585
 
2298
2586
  if (!parsed.save) return 0;
2587
+ if (isThinTeachLesson(lesson)) {
2588
+ output(TEACH_THIN_REFUSE);
2589
+ return 2;
2590
+ }
2299
2591
 
2300
2592
  fileTeachBrief({
2301
2593
  cwd: deps.cwd || process.cwd(),
2302
2594
  url: parsed.url,
2303
2595
  section: parsed.section,
2304
- lesson,
2596
+ lesson: lesson.text,
2305
2597
  now: deps.now,
2306
2598
  });
2599
+ const packRel = fileTeachExperiment({
2600
+ cwd: deps.cwd || process.cwd(),
2601
+ url: parsed.url,
2602
+ section: parsed.section,
2603
+ lesson,
2604
+ });
2307
2605
  const ensureApply = deps.ensureApply || ensureTeachApply;
2308
2606
  return ensureApply({
2309
2607
  cwd: deps.cwd || process.cwd(),
2310
2608
  url: parsed.url,
2311
2609
  section: parsed.section,
2610
+ packRel,
2312
2611
  now: deps.now,
2313
2612
  output,
2314
2613
  });
@@ -2404,5 +2703,8 @@ module.exports = {
2404
2703
  extractTeachNumbers,
2405
2704
  extractTeachMechanisms,
2406
2705
  extractTeachSource,
2706
+ isThinTeachLesson,
2707
+ TEACH_THIN_REFUSE,
2708
+ teachExperimentSlug,
2407
2709
  youtubeCommand,
2408
2710
  };
package/lib/apply-gate.js CHANGED
@@ -46,7 +46,7 @@ function readApplyReceipt({ cwd, rel } = {}) {
46
46
  return { rel, text: fs.readFileSync(abs, 'utf8') };
47
47
  }
48
48
 
49
- function writeApplyStub({ cwd, source, rel, now } = {}) {
49
+ function writeApplyStub({ cwd, source, rel, now, change, receipt, journalLine } = {}) {
50
50
  try {
51
51
  if (!rel || !cwd) return null;
52
52
  const wikiDir = path.join(cwd, 'atris', 'wiki');
@@ -54,11 +54,17 @@ function writeApplyStub({ cwd, source, rel, now } = {}) {
54
54
 
55
55
  const abs = path.join(cwd, rel);
56
56
  fs.mkdirSync(path.dirname(abs), { recursive: true });
57
- if (!fs.existsSync(abs)) {
57
+ const changeText = change || 'fill this';
58
+ const receiptText = receipt || 'fill this';
59
+ let shouldWrite = !fs.existsSync(abs);
60
+ if (!shouldWrite && change && receipt) {
61
+ shouldWrite = !isFilledApply(parseApplyFields(fs.readFileSync(abs, 'utf8')));
62
+ }
63
+ if (shouldWrite) {
58
64
  fs.writeFileSync(abs, [
59
65
  `source: ${source}`,
60
- 'change: fill this',
61
- 'receipt: fill this',
66
+ `change: ${changeText}`,
67
+ `receipt: ${receiptText}`,
62
68
  ].join('\n') + '\n');
63
69
  }
64
70
 
@@ -67,7 +73,7 @@ function writeApplyStub({ cwd, source, rel, now } = {}) {
67
73
  fs.mkdirSync(path.dirname(journalPath), { recursive: true });
68
74
  let existing = '';
69
75
  if (fs.existsSync(journalPath)) existing = fs.readFileSync(journalPath, 'utf8');
70
- const line = `- [claimable] apply: fill this -> ${rel}`;
76
+ const line = journalLine || `- [claimable] apply: fill this -> ${rel}`;
71
77
  if (!existing.includes(line)) {
72
78
  const prefix = existing && !existing.endsWith('\n') ? '\n' : '';
73
79
  fs.writeFileSync(journalPath, `${existing}${prefix}${line}\n`);
@@ -78,11 +84,13 @@ function writeApplyStub({ cwd, source, rel, now } = {}) {
78
84
  }
79
85
  }
80
86
 
81
- function ensureApply({ cwd, source, rel, now, output, incompleteMessage, required = true } = {}) {
87
+ function ensureApply({
88
+ cwd, source, rel, now, output, incompleteMessage, required = true, change, receipt, journalLine,
89
+ } = {}) {
82
90
  const print = typeof output === 'function' ? output : (line = '') => console.error(line);
83
91
  const existing = readApplyReceipt({ cwd, rel });
84
92
  if (existing && isFilledApply(parseApplyFields(existing.text))) return 0;
85
- if (!(required && existing)) writeApplyStub({ cwd, source, rel, now });
93
+ if (!(required && existing)) writeApplyStub({ cwd, source, rel, now, change, receipt, journalLine });
86
94
  print(incompleteMessage);
87
95
  return required ? 2 : 0;
88
96
  }
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ const os = require('os');
4
+ const path = require('path');
5
+
6
+ const SETTINGS_NAME = 'settings.json';
7
+ const CLAUDE_DIR_NAME = '.claude';
8
+ const DENY_REASON = 'pack runs cannot change .claude/settings.json to add SessionStart or disable hooks';
9
+
10
+ function expandUserPath(value, home) {
11
+ const text = String(value);
12
+ if (text === '~') return home;
13
+ if (text.startsWith('~/') || text.startsWith('~\\')) return path.join(home, text.slice(2));
14
+ return text;
15
+ }
16
+
17
+ function asText(value) {
18
+ if (value == null) return '';
19
+ if (typeof value === 'string') return value;
20
+ try {
21
+ return JSON.stringify(value);
22
+ } catch {
23
+ return String(value);
24
+ }
25
+ }
26
+
27
+ function settingsPlantPersistence(value) {
28
+ if (!value || typeof value !== 'object') return false;
29
+ if (value.disableAllHooks === true) return true;
30
+ if (value.hooks && Object.prototype.hasOwnProperty.call(value.hooks, 'SessionStart')) return true;
31
+ return Object.values(value).some((entry) => settingsPlantPersistence(entry));
32
+ }
33
+
34
+ function plantsPersistence(text) {
35
+ const source = asText(text);
36
+ if (!source) return false;
37
+ if (/\bSessionStart\b/.test(source)) return true;
38
+ if (/["']?disableAllHooks["']?\s*:\s*true\b/.test(source)) return true;
39
+ try {
40
+ return settingsPlantPersistence(JSON.parse(source));
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ function isClaudeSettingsPath(value, options = {}) {
47
+ if (typeof value !== 'string' || !value.trim()) return false;
48
+ const home = options.home || os.homedir();
49
+ const cwd = options.cwd || process.cwd();
50
+ const expanded = expandUserPath(value.trim(), home);
51
+ const resolved = path.resolve(cwd, expanded);
52
+ if (path.basename(resolved) === SETTINGS_NAME && path.basename(path.dirname(resolved)) === CLAUDE_DIR_NAME) {
53
+ return true;
54
+ }
55
+ const configDir = path.resolve(
56
+ options.configDir
57
+ || process.env.CLAUDE_CONFIG_DIR
58
+ || path.join(home, CLAUDE_DIR_NAME),
59
+ );
60
+ return resolved === path.join(configDir, SETTINGS_NAME);
61
+ }
62
+
63
+ function commandMentionsClaudeSettings(command) {
64
+ const text = String(command || '');
65
+ if (!text.trim()) return false;
66
+ return /(?:^|[^\w])(?:~\/|\.\/|(?:\.\.\/)+)?(?:\$\{?HOME\}?\/)?\.claude\/settings\.json\b/.test(text)
67
+ || /\$\{?CLAUDE_CONFIG_DIR\}?\/settings\.json/.test(text)
68
+ || /(?:^|[^\w])(?:\$HOME|\$\{HOME\})\/\.claude\/settings\.json\b/.test(text);
69
+ }
70
+
71
+ function fileToolPath(input) {
72
+ const toolInput = (input && input.tool_input) || {};
73
+ return toolInput.file_path || toolInput.path || null;
74
+ }
75
+
76
+ function bashCommand(input) {
77
+ const toolInput = (input && input.tool_input) || {};
78
+ return toolInput.command || toolInput.cmd || '';
79
+ }
80
+
81
+ function writeContents(input) {
82
+ const toolInput = (input && input.tool_input) || {};
83
+ return toolInput.contents ?? toolInput.content ?? toolInput.new_string ?? '';
84
+ }
85
+
86
+ function enforceConfigGuard(input, options = {}) {
87
+ const tool = input && input.tool_name;
88
+ if (tool === 'Write' || tool === 'Edit') {
89
+ if (!isClaudeSettingsPath(fileToolPath(input), options)) return { allowed: true };
90
+ if (!plantsPersistence(writeContents(input))) return { allowed: true };
91
+ return { allowed: false, reason: DENY_REASON };
92
+ }
93
+ if (tool === 'Bash') {
94
+ if (!commandMentionsClaudeSettings(bashCommand(input))) return { allowed: true };
95
+ return { allowed: false, reason: DENY_REASON };
96
+ }
97
+ return { allowed: true };
98
+ }
99
+
100
+ module.exports = {
101
+ DENY_REASON,
102
+ commandMentionsClaudeSettings,
103
+ enforceConfigGuard,
104
+ isClaudeSettingsPath,
105
+ plantsPersistence,
106
+ };
@@ -68,6 +68,14 @@ function engineRegistryFile(root = process.cwd()) {
68
68
  return path.join(root, '.atris', 'state', 'engines.json');
69
69
  }
70
70
 
71
+ // A scratch folder is not a room. First-touch seed must not mint .atris/.
72
+ // Updates stay allowed once the file or a real workspace already exists.
73
+ function canPersistEngineRegistry(root = process.cwd()) {
74
+ if (fs.existsSync(engineRegistryFile(root))) return true;
75
+ return fs.existsSync(path.join(root, 'atris'))
76
+ || fs.existsSync(path.join(root, '.atris', 'business.json'));
77
+ }
78
+
71
79
  // Machine probe. Routing never calls this on a settled registry: it runs once
72
80
  // when an engine first appears (seeding the policy file), at the execution
73
81
  // stage right before a spawn, and on the explicit `atris engine doctor`.
@@ -239,14 +247,16 @@ function setEngineOverrides(name, overrides = {}, root = process.cwd()) {
239
247
  }
240
248
 
241
249
  // A read only writes when normalization actually changed the saved engines
242
- // (first seed, schema drift). Settled registries stay untouched, so read
243
- // paths cannot stomp a mutation another process just landed. The comparison
244
- // uses the same raw snapshot the seed was built from: one read, one decision.
250
+ // (first seed, schema drift) and this folder is already a room or already
251
+ // has engines.json. Empty scratch stays empty. Settled registries stay
252
+ // untouched, so read paths cannot stomp a mutation another process just
253
+ // landed. The comparison uses the same raw snapshot the seed was built from:
254
+ // one read, one decision.
245
255
  function readEngineRegistry(root = process.cwd(), options = {}) {
246
256
  const raw = readRawRegistry(engineRegistryFile(root));
247
257
  const registry = seededRegistry(root, raw);
248
258
  const needsPersist = JSON.stringify(raw.engines || []) !== JSON.stringify(registry.engines);
249
- if (options.persist !== false && needsPersist) {
259
+ if (options.persist !== false && needsPersist && canPersistEngineRegistry(root)) {
250
260
  writeEngineRegistry(root, registry);
251
261
  }
252
262
  return registry;