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