agent-bios 0.4.0 → 0.5.0

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.
@@ -0,0 +1,557 @@
1
+ #!/usr/bin/env python3
2
+ """Deterministic submit tool for the session learning flow (trigger `learn!`).
3
+
4
+ Capability boundary (design/collection-loop/DESIGN.md Phase 1 step 4): the LLM
5
+ supplies ONLY the semantic payload (lesson, domain, supporting_sessions, and the
6
+ optional criteria / classification / proposed_domain / context) plus its session
7
+ `--host`; THIS script owns every deterministic value and side effect:
8
+
9
+ * mints learning_id (a lowercase UUID) + created (ISO-8601) + schema_version;
10
+ * validates the full record against config/learning.schema.json — the single
11
+ validation source, reused from scripts/check-learning.py (no second schema);
12
+ * logs the JSON record to <home>/personal/learnings.jsonl — the durable,
13
+ append-only upload source that the Phase 2 watermark drain re-sends from;
14
+ * writes the lesson prose where THIS host loads it next session, and wires it:
15
+ - claude: appends to <home>/personal/learnings.md and ensures the
16
+ `@personal/learnings.md` import once in the entry CLAUDE.md;
17
+ - codex: appends into a preserved `agent-bios:personal-learnings` region
18
+ of <home>/AGENTS.md (Codex has no @import; AGENTS.md is always loaded).
19
+ The region lives OUTSIDE the central markers so scripts/assemble.py —
20
+ which only rewrites the central region — preserves it across re-assembly.
21
+
22
+ It REFUSES any script-owned field in the payload (deterministic values are never
23
+ hand-authored) and, on a schema/membership violation, REJECTS and exits 1 — it
24
+ never patches the payload to make it pass (runtime enforces, does not reason).
25
+
26
+ Input: the semantic payload as one JSON object on stdin.
27
+ Host: --host claude|codex (default: the tool prefix of supporting_sessions[0]).
28
+ Home: --config-dir, else $CLAUDE_CONFIG_DIR / $CODEX_HOME by host, else ~/.claude / ~/.codex.
29
+ After the local writes it best-effort uploads not-yet-delivered records to
30
+ {ingest-url}/api/ingest/learnings via a watermark over learnings.jsonl (Phase 2
31
+ transport; skipped when the dashboard hook is not installed, or with --no-upload).
32
+ """
33
+ import argparse
34
+ import datetime
35
+ import importlib.util
36
+ import json
37
+ import os
38
+ import pathlib
39
+ import shutil
40
+ import sys
41
+ import time
42
+ import urllib.error
43
+ import urllib.request
44
+ import uuid
45
+
46
+ REPO = pathlib.Path(__file__).resolve().parent.parent
47
+ OWNED_FIELDS = ("schema_version", "learning_id", "created")
48
+
49
+ # host -> (config-home env var, default home dirname under $HOME)
50
+ HOSTS = {
51
+ "claude": ("CLAUDE_CONFIG_DIR", ".claude"),
52
+ "codex": ("CODEX_HOME", ".codex"),
53
+ }
54
+
55
+ CLAUDE_IMPORT_LINE = "@personal/learnings.md"
56
+ CLAUDE_CENTRAL_IMPORT = "@central/bundle.md"
57
+
58
+ # Codex AGENTS.md markers. The central pair is owned by scripts/assemble.py
59
+ # (kept in sync here); the personal-learnings pair is this tool's own region,
60
+ # placed outside the central pair so re-assembly preserves it.
61
+ CENTRAL_START = "<!-- agent-bios:central:start -->"
62
+ CENTRAL_END = "<!-- agent-bios:central:end -->"
63
+ PERSONAL_START = "<!-- agent-bios:personal-learnings:start -->"
64
+ PERSONAL_END = "<!-- agent-bios:personal-learnings:end -->"
65
+
66
+ CLAUDE_LEARNINGS_HEADER = """# Personal learnings
67
+
68
+ <!-- Automation-owned: written by the session learning flow (`learn!`,
69
+ scripts/collect-learning.py). Do NOT hand-edit — promote→migrate clears
70
+ applied items by learning_id when the org redistributes them. Your own
71
+ personal rules belong in the entry CLAUDE.md '## Personal' section, never
72
+ here. This file is pulled into context by the entry file's
73
+ `@personal/learnings.md` import. -->
74
+ """
75
+
76
+ CODEX_REGION_HEADER = """## Personal learnings
77
+ <!-- Automation-owned: written by the session learning flow (`learn!`,
78
+ scripts/collect-learning.py). Codex loads this via AGENTS.md (no @import).
79
+ Do NOT hand-edit — promote→migrate clears applied items by learning_id.
80
+ Kept outside the agent-bios central markers so re-assembly preserves it. -->
81
+ """
82
+
83
+
84
+ def load_checker():
85
+ """Reuse scripts/check-learning.py as the single validation source."""
86
+ path = REPO / "scripts" / "check-learning.py"
87
+ spec = importlib.util.spec_from_file_location("check_learning", path)
88
+ module = importlib.util.module_from_spec(spec)
89
+ spec.loader.exec_module(module)
90
+ return module
91
+
92
+
93
+ def die(msg, code=1):
94
+ print(f"collect-learning: {msg}", file=sys.stderr)
95
+ sys.exit(code)
96
+
97
+
98
+ def read_payload():
99
+ raw = sys.stdin.read()
100
+ if not raw.strip():
101
+ die("no payload on stdin (expected one JSON object with the semantic fields)")
102
+ try:
103
+ payload = json.loads(raw)
104
+ except json.JSONDecodeError as e:
105
+ die(f"payload is not valid JSON: {e}")
106
+ if not isinstance(payload, dict):
107
+ die("payload must be a JSON object")
108
+ present_owned = [f for f in OWNED_FIELDS if f in payload]
109
+ if present_owned:
110
+ die(f"payload must not carry script-owned field(s) {present_owned} — "
111
+ "collect-learning mints schema_version/learning_id/created itself")
112
+ return payload
113
+
114
+
115
+ def resolve_host(cli_host, payload):
116
+ if cli_host:
117
+ return cli_host
118
+ sessions = payload.get("supporting_sessions")
119
+ if isinstance(sessions, list) and sessions and isinstance(sessions[0], str) and ":" in sessions[0]:
120
+ tool = sessions[0].split(":", 1)[0]
121
+ if tool in HOSTS:
122
+ return tool
123
+ die("cannot determine host — pass --host claude|codex "
124
+ "(or a supporting_sessions entry prefixed 'claude:'/'codex:')")
125
+
126
+
127
+ def resolve_home(host, config_dir):
128
+ if config_dir:
129
+ return pathlib.Path(config_dir)
130
+ env_var, default_name = HOSTS[host]
131
+ return pathlib.Path(os.environ.get(env_var) or pathlib.Path.home() / default_name)
132
+
133
+
134
+ def build_record(payload):
135
+ record = dict(payload)
136
+ record["schema_version"] = 1
137
+ record["learning_id"] = str(uuid.uuid4()) # canonical lowercase
138
+ record["created"] = datetime.datetime.now().astimezone().replace(microsecond=0).isoformat()
139
+ return record
140
+
141
+
142
+ def validate(record):
143
+ checker = load_checker()
144
+ validator = checker.build_validator()
145
+ domain_values = checker.valid_domain_values()
146
+ return checker.validate_record(record, validator, domain_values)
147
+
148
+
149
+ def prose_bullet(record):
150
+ return (f"- [{record['domain']}] {record['lesson']} "
151
+ f"<!-- learning_id: {record['learning_id']} created: {record['created']} -->")
152
+
153
+
154
+ def backup(path):
155
+ shutil.copy2(path, path.with_suffix(path.suffix + f".bak-learn-{time.strftime('%Y%m%d-%H%M%S')}"))
156
+
157
+
158
+ def append_record(home, record, dry):
159
+ jsonl = home / "personal" / "learnings.jsonl"
160
+ if dry:
161
+ print(f" [dry] append JSON record to {jsonl}")
162
+ return jsonl
163
+ jsonl.parent.mkdir(parents=True, exist_ok=True)
164
+ with open(jsonl, "a", encoding="utf-8") as f:
165
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
166
+ return jsonl
167
+
168
+
169
+ # ---- claude: personal/learnings.md + @personal/learnings.md import ----------
170
+
171
+ def apply_claude(home, bullet, dry):
172
+ md = home / "personal" / "learnings.md"
173
+ if dry:
174
+ if not md.exists():
175
+ print(f" [dry] create {md}")
176
+ print(f" [dry] append prose to {md}")
177
+ else:
178
+ md.parent.mkdir(parents=True, exist_ok=True)
179
+ if not md.exists():
180
+ md.write_text(CLAUDE_LEARNINGS_HEADER, encoding="utf-8")
181
+ with open(md, "a", encoding="utf-8") as f:
182
+ f.write(bullet + "\n")
183
+ import_state = ensure_claude_import(home, dry)
184
+ return md, f"entry import ({home / 'CLAUDE.md'}): {import_state}"
185
+
186
+
187
+ def ensure_claude_import(home, dry):
188
+ entry = home / "CLAUDE.md"
189
+ if not entry.exists():
190
+ if dry:
191
+ print(f" [dry] create entry {entry} with import line")
192
+ return "created"
193
+ home.mkdir(parents=True, exist_ok=True)
194
+ entry.write_text(f"# CLAUDE.md\n\n{CLAUDE_IMPORT_LINE}\n", encoding="utf-8")
195
+ return "created"
196
+ body = entry.read_text(encoding="utf-8")
197
+ if CLAUDE_IMPORT_LINE in body:
198
+ return "present"
199
+ if dry:
200
+ print(f" [dry] insert '{CLAUDE_IMPORT_LINE}' into {entry}")
201
+ return "inserted"
202
+ lines = body.splitlines(keepends=True)
203
+ idx = next((i for i, ln in enumerate(lines) if CLAUDE_CENTRAL_IMPORT in ln), 0)
204
+ backup(entry)
205
+ lines.insert(idx + 1, CLAUDE_IMPORT_LINE + "\n")
206
+ entry.write_text("".join(lines), encoding="utf-8")
207
+ return "inserted"
208
+
209
+
210
+ # ---- codex: personal-learnings region inside AGENTS.md ----------------------
211
+
212
+ def apply_codex(home, bullet, dry):
213
+ agents = home / "AGENTS.md"
214
+ if agents.exists():
215
+ body = agents.read_text(encoding="utf-8")
216
+ else:
217
+ # Degenerate (corpus not installed): seed EMPTY central markers so a
218
+ # later assemble fills them and keeps our region in the preserved tail.
219
+ body = f"{CENTRAL_START}\n{CENTRAL_END}\n"
220
+ if PERSONAL_START in body and PERSONAL_END in body:
221
+ pre, rest = body.split(PERSONAL_START, 1)
222
+ region_body, post = rest.split(PERSONAL_END, 1)
223
+ new_body = region_body.rstrip("\n") + "\n" + bullet + "\n"
224
+ new = f"{pre}{PERSONAL_START}{new_body}{PERSONAL_END}{post}"
225
+ else:
226
+ region = f"\n{PERSONAL_START}\n{CODEX_REGION_HEADER}{bullet}\n{PERSONAL_END}\n"
227
+ new = body.rstrip("\n") + "\n" + region
228
+ if dry:
229
+ print(f" [dry] write personal-learnings region in {agents}")
230
+ return agents, f"AGENTS.md region ({agents}): updated"
231
+ agents.parent.mkdir(parents=True, exist_ok=True)
232
+ if agents.exists():
233
+ backup(agents)
234
+ agents.write_text(new, encoding="utf-8")
235
+ return agents, f"AGENTS.md region ({agents}): updated"
236
+
237
+
238
+ APPLY = {"claude": apply_claude, "codex": apply_codex}
239
+
240
+
241
+ # ── Phase 2 transport: watermark upload over the durable learnings.jsonl ───────
242
+ #
243
+ # collect-learning runs on-demand (per `learn!`), so the upload piggybacks here:
244
+ # after the local writes, best-effort POST any not-yet-settled records to
245
+ # {ingest-url}/api/ingest/learnings and record which learning_ids are settled in
246
+ # a small state file (the watermark). The durable learnings.jsonl is the single
247
+ # source and is never capped, so nothing is lost across a multi-day server outage
248
+ # — unsettled records simply retry on the next `learn!`. A 2xx (incl. a duplicate
249
+ # re-send, which the server dedups by learning_id) settles a record; 400/413
250
+ # settle it as permanently rejected; any other status (401/403/404/429/503/5xx/
251
+ # network) is transient and stops the drain (no retry storm) to retry next time.
252
+ # (design/collection-loop/PHASE2-ENDPOINT-DESIGN.md D2.5.)
253
+
254
+ INGEST_PATH = "/api/ingest/learnings"
255
+ STATE_NAME = ".learnings-upload-state.json"
256
+ UPLOAD_LIMIT = 25 # max POSTs per invocation
257
+ UPLOAD_BUDGET_S = 5.0 # total wall-clock budget for the whole drain
258
+ UPLOAD_TIMEOUT_S = 3.0 # per-request socket timeout
259
+ PERMANENT_STATUSES = frozenset({400, 413}) # never succeeds → settle as dead
260
+
261
+
262
+ def transport_config(home):
263
+ """Read the dashboard hook's token + ingest base URL (READ-ONLY; the hook dir
264
+ is dashboard-owned — we never write it). Returns ((token, base), None) or
265
+ (None, reason) when the hook is not installed (Q-E: caller prints a notice)."""
266
+ hook_dir = home / "hooks"
267
+ token_file = hook_dir / "token"
268
+ if not token_file.is_file():
269
+ return None, "dashboard hook not installed (no token)"
270
+ try:
271
+ token = token_file.read_text(encoding="utf-8").strip()
272
+ except OSError as e:
273
+ return None, f"cannot read hook token ({e})"
274
+ if not token:
275
+ return None, "dashboard hook token is empty"
276
+ base = ""
277
+ # The installer writes `dashboard-url`; a migrated install may also have
278
+ # `ingest-url`. Prefer ingest-url, fall back to dashboard-url.
279
+ for name in ("ingest-url", "dashboard-url"):
280
+ f = hook_dir / name
281
+ if f.is_file():
282
+ candidate = f.read_text(encoding="utf-8").strip()
283
+ if candidate:
284
+ base = candidate
285
+ break
286
+ if not base:
287
+ return None, "dashboard hook ingest URL not found"
288
+ return (token, base.rstrip("/")), None
289
+
290
+
291
+ def read_jsonl_records(jsonl):
292
+ """(learning_id, record) for each well-formed line; malformed lines are
293
+ skipped so a single poison line never wedges the drain."""
294
+ out = []
295
+ if not jsonl.is_file():
296
+ return out
297
+ with open(jsonl, encoding="utf-8") as f:
298
+ for line in f:
299
+ line = line.strip()
300
+ if not line:
301
+ continue
302
+ try:
303
+ rec = json.loads(line)
304
+ except json.JSONDecodeError:
305
+ continue
306
+ lid = rec.get("learning_id") if isinstance(rec, dict) else None
307
+ if isinstance(lid, str) and lid:
308
+ out.append((lid, rec))
309
+ return out
310
+
311
+
312
+ def load_state(home):
313
+ path = home / "personal" / STATE_NAME
314
+ if not path.is_file():
315
+ return {"uploaded": set(), "dead": set()}
316
+ try:
317
+ data = json.loads(path.read_text(encoding="utf-8"))
318
+ return {"uploaded": set(data.get("uploaded", [])),
319
+ "dead": set(data.get("dead", []))}
320
+ except (json.JSONDecodeError, OSError):
321
+ return {"uploaded": set(), "dead": set()}
322
+
323
+
324
+ def save_state(home, state, present_ids):
325
+ # Prune settled ids no longer in the durable log (e.g. migrated out) so the
326
+ # state stays bounded to the log size. Atomic replace.
327
+ path = home / "personal" / STATE_NAME
328
+ path.parent.mkdir(parents=True, exist_ok=True)
329
+ data = {"uploaded": sorted(state["uploaded"] & present_ids),
330
+ "dead": sorted(state["dead"] & present_ids)}
331
+ tmp = path.with_suffix(path.suffix + ".tmp")
332
+ tmp.write_text(json.dumps(data, ensure_ascii=False) + "\n", encoding="utf-8")
333
+ os.replace(tmp, path)
334
+
335
+
336
+ def classify_status(status):
337
+ if status is None:
338
+ return "transient" # network error / timeout
339
+ if 200 <= status < 300:
340
+ return "ok"
341
+ if status in PERMANENT_STATUSES:
342
+ return "permanent"
343
+ return "transient" # 401/403/404/429/503/5xx → retry next time
344
+
345
+
346
+ def http_post(base, token, record, timeout=UPLOAD_TIMEOUT_S):
347
+ """POST one record; return the HTTP status int, or None on a network error."""
348
+ body = json.dumps(record, ensure_ascii=False).encode("utf-8")
349
+ req = urllib.request.Request(base + INGEST_PATH, data=body, method="POST")
350
+ req.add_header("Content-Type", "application/json")
351
+ req.add_header("X-Hook-Token", token)
352
+ try:
353
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
354
+ return resp.status
355
+ except urllib.error.HTTPError as e:
356
+ return e.code
357
+ except (urllib.error.URLError, TimeoutError, OSError):
358
+ return None
359
+
360
+
361
+ def drain_uploads(home, post_fn=http_post, now=time.monotonic,
362
+ limit=UPLOAD_LIMIT, budget_s=UPLOAD_BUDGET_S):
363
+ """Best-effort upload of not-yet-settled learnings. post_fn/now are injectable
364
+ for the self-test (no sockets). Returns a summary dict."""
365
+ config, reason = transport_config(home)
366
+ if config is None:
367
+ return {"status": "skipped", "reason": reason,
368
+ "uploaded": 0, "dead": 0, "pending": None}
369
+ token, base = config
370
+
371
+ records = read_jsonl_records(home / "personal" / "learnings.jsonl")
372
+ present_ids = {lid for lid, _ in records}
373
+ state = load_state(home)
374
+ settled = state["uploaded"] | state["dead"]
375
+ candidates = [(lid, rec) for lid, rec in records if lid not in settled]
376
+
377
+ deadline = now() + budget_s
378
+ uploaded = dead = sent = 0
379
+ stopped = None
380
+ for lid, rec in candidates:
381
+ if sent >= limit:
382
+ stopped = "limit"
383
+ break
384
+ if now() >= deadline:
385
+ stopped = "budget"
386
+ break
387
+ kind = classify_status(post_fn(base, token, rec))
388
+ sent += 1
389
+ if kind == "ok":
390
+ state["uploaded"].add(lid)
391
+ uploaded += 1
392
+ elif kind == "permanent":
393
+ state["dead"].add(lid)
394
+ dead += 1
395
+ else: # transient — server likely down / systemic; stop, retry next time
396
+ stopped = "transient"
397
+ break
398
+
399
+ save_state(home, state, present_ids)
400
+ pending = sum(1 for lid, _ in records
401
+ if lid not in (state["uploaded"] | state["dead"]))
402
+ return {"status": "ok", "reason": stopped, "uploaded": uploaded,
403
+ "dead": dead, "pending": pending}
404
+
405
+
406
+ def print_upload_summary(s):
407
+ if s["status"] == "skipped":
408
+ print(f" upload -> skipped ({s['reason']}); "
409
+ "retries on a later learn! once the hook is installed")
410
+ return
411
+ tail = f" (stopped: {s['reason']})" if s["reason"] else ""
412
+ print(f" upload -> uploaded={s['uploaded']} dead={s['dead']} "
413
+ f"pending={s['pending']}{tail}")
414
+
415
+
416
+ def _self_test():
417
+ """Socket-free verification of the drain: status classification, watermark
418
+ advance, transient-stop (no storm), permanent-drop, no-token skip, poison
419
+ line. Injects post_fn/now; exits non-zero on any failure."""
420
+ import tempfile
421
+
422
+ def make_home(records, with_token=True):
423
+ home = pathlib.Path(tempfile.mkdtemp(prefix="learn-selftest-"))
424
+ (home / "personal").mkdir(parents=True)
425
+ with open(home / "personal" / "learnings.jsonl", "w", encoding="utf-8") as f:
426
+ for r in records:
427
+ f.write(r if isinstance(r, str) else json.dumps(r))
428
+ f.write("\n")
429
+ if with_token:
430
+ (home / "hooks").mkdir(parents=True)
431
+ (home / "hooks" / "token").write_text("tok", encoding="utf-8")
432
+ (home / "hooks" / "dashboard-url").write_text(
433
+ "https://example.test/", encoding="utf-8")
434
+ return home
435
+
436
+ def rec(n):
437
+ return {"learning_id": f"0f8c1c2a-4d1e-4abc-9def-{n:012d}",
438
+ "schema_version": 1, "lesson": "x" * 12, "domain": "core",
439
+ "created": "2026-07-20T00:00:00Z",
440
+ "supporting_sessions": ["claude:abcd1234"]}
441
+
442
+ checks = []
443
+
444
+ # 1) no token → skipped, no state file written (watermark unadvanced).
445
+ h = make_home([rec(1)], with_token=False)
446
+ s = drain_uploads(h)
447
+ checks.append(("no-token skip", s["status"] == "skipped"
448
+ and not (h / "personal" / STATE_NAME).is_file()))
449
+
450
+ # 2) all ok → all uploaded, pending 0; re-run uploads nothing new.
451
+ h = make_home([rec(1), rec(2), rec(3)])
452
+ s = drain_uploads(h, post_fn=lambda *a: 200)
453
+ calls = {"n": 0}
454
+ def count_post(*a):
455
+ calls["n"] += 1
456
+ return 200
457
+ s2 = drain_uploads(h, post_fn=count_post)
458
+ checks.append(("all-ok then idempotent re-run",
459
+ s["uploaded"] == 3 and s["pending"] == 0
460
+ and calls["n"] == 0 and s2["uploaded"] == 0))
461
+
462
+ # 3) transient (500) → nothing settled, drain stops, pending == N; a later
463
+ # 200 run delivers everything (multi-day-outage recovery).
464
+ h = make_home([rec(1), rec(2)])
465
+ s = drain_uploads(h, post_fn=lambda *a: 500)
466
+ s2 = drain_uploads(h, post_fn=lambda *a: 200)
467
+ checks.append(("transient stop then recover",
468
+ s["uploaded"] == 0 and s["pending"] == 2
469
+ and s["reason"] == "transient"
470
+ and s2["uploaded"] == 2 and s2["pending"] == 0))
471
+
472
+ # 4) permanent (400) → settled as dead, not retried.
473
+ h = make_home([rec(1)])
474
+ s = drain_uploads(h, post_fn=lambda *a: 400)
475
+ hits = {"n": 0}
476
+ def once(*a):
477
+ hits["n"] += 1
478
+ return 400
479
+ s2 = drain_uploads(h, post_fn=once)
480
+ checks.append(("permanent drop, not retried",
481
+ s["dead"] == 1 and s["pending"] == 0 and hits["n"] == 0))
482
+
483
+ # 5) network error (None) is transient.
484
+ h = make_home([rec(1)])
485
+ s = drain_uploads(h, post_fn=lambda *a: None)
486
+ checks.append(("network error is transient",
487
+ s["uploaded"] == 0 and s["pending"] == 1))
488
+
489
+ # 6) poison line skipped, valid records still delivered.
490
+ h = make_home(["{ not json", rec(1), ""])
491
+ s = drain_uploads(h, post_fn=lambda *a: 200)
492
+ checks.append(("poison line skipped", s["uploaded"] == 1 and s["pending"] == 0))
493
+
494
+ # 7) wall-clock budget stops the drain (fake clock jumps past the deadline).
495
+ clock = {"t": 0.0}
496
+ def fake_now():
497
+ clock["t"] += 10.0
498
+ return clock["t"]
499
+ h = make_home([rec(1), rec(2)])
500
+ s = drain_uploads(h, post_fn=lambda *a: 200, now=fake_now, budget_s=5.0)
501
+ checks.append(("budget stop", s["reason"] == "budget"))
502
+
503
+ failed = [name for name, ok in checks if not ok]
504
+ if failed:
505
+ for name in failed:
506
+ print(f"collect-learning --self-test: FAIL: {name}", file=sys.stderr)
507
+ sys.exit(1)
508
+ print(f"collect-learning --self-test: OK ({len(checks)} upload-drain checks)")
509
+
510
+
511
+ def main():
512
+ ap = argparse.ArgumentParser(description="Submit a session learning (learn!).")
513
+ ap.add_argument("--host", choices=sorted(HOSTS),
514
+ help="session host (default: tool prefix of supporting_sessions[0])")
515
+ ap.add_argument("--config-dir", default=None,
516
+ help="config home (default: $CLAUDE_CONFIG_DIR / $CODEX_HOME by host)")
517
+ ap.add_argument("--dry-run", action="store_true",
518
+ help="validate and print actions; write nothing")
519
+ ap.add_argument("--no-upload", action="store_true",
520
+ help="skip the Phase 2 upload (local writes only)")
521
+ ap.add_argument("--self-test", action="store_true",
522
+ help="run the upload-drain self-test and exit")
523
+ args = ap.parse_args()
524
+
525
+ if args.self_test:
526
+ _self_test()
527
+ return
528
+
529
+ payload = read_payload()
530
+ host = resolve_host(args.host, payload)
531
+ home = resolve_home(host, args.config_dir)
532
+ record = build_record(payload)
533
+
534
+ errors = validate(record)
535
+ if errors:
536
+ print("collect-learning: REJECTED (record is not valid; not written)", file=sys.stderr)
537
+ for e in errors:
538
+ print(f" - {e}", file=sys.stderr)
539
+ sys.exit(1)
540
+
541
+ dry = args.dry_run
542
+ bullet = prose_bullet(record)
543
+ jsonl = append_record(home, record, dry)
544
+ prose_path, wiring = APPLY[host](home, bullet, dry)
545
+
546
+ print(f"collect-learning: {'[dry] ' if dry else ''}OK host={host} "
547
+ f"learning_id={record['learning_id']} domain={record['domain']}")
548
+ print(f" prose -> {prose_path}")
549
+ print(f" record -> {jsonl}")
550
+ print(f" {wiring}")
551
+
552
+ if not dry and not args.no_upload:
553
+ print_upload_summary(drain_uploads(home))
554
+
555
+
556
+ if __name__ == "__main__":
557
+ main()