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.
- package/bin/atris.js +17 -5
- package/commands/auth.js +6 -1
- package/commands/engine.js +7 -0
- package/commands/experiments.js +178 -0
- package/commands/founder.js +12 -0
- package/commands/human-missions.js +46 -8
- package/commands/launchpad.js +45 -10
- package/commands/now.js +36 -1
- package/commands/recap.js +34 -3
- package/commands/status.js +10 -2
- package/commands/workflow.js +4 -3
- package/commands/youtube.js +310 -8
- package/lib/apply-gate.js +15 -7
- package/lib/config-guard.js +106 -0
- package/lib/engine-registry.js +14 -4
- package/lib/first-minute.js +21 -2
- package/lib/pack-capabilities.js +13 -3
- package/lib/workspace-scaffold.js +3 -1
- package/package.json +1 -1
- package/utils/auth.js +76 -7
package/commands/status.js
CHANGED
|
@@ -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
|
|
150
|
-
//
|
|
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');
|
package/commands/workflow.js
CHANGED
|
@@ -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.
|
|
886
|
-
//
|
|
887
|
-
// context, not a
|
|
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) {
|
package/commands/youtube.js
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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({
|
|
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
|
+
};
|
package/lib/engine-registry.js
CHANGED
|
@@ -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)
|
|
243
|
-
//
|
|
244
|
-
//
|
|
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;
|