agent-bios 0.5.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,4 @@
1
+ {
2
+ "version": 1,
3
+ "promotions": []
4
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-bios",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "A thin, low-level instruction layer for LLM CLI agents: one set of principles and behavior whichever model you run. Deploys into $HOME by copy via an explicit `agent-bios install`.",
5
5
  "bin": {
6
6
  "agent-bios": "scripts/install.sh"
@@ -17,12 +17,15 @@
17
17
  "config/agent-launch.toml",
18
18
  "config/domains.json",
19
19
  "config/learning.schema.json",
20
+ "config/promotions.json",
20
21
  "shell/agent-launch.zsh",
21
22
  "scripts/agent-launch.py",
22
23
  "scripts/check-parity.sh",
23
24
  "scripts/check-prompting-targets.sh",
24
25
  "scripts/check-learning.py",
25
26
  "scripts/collect-learning.py",
27
+ "scripts/migrate-learnings.py",
28
+ "scripts/redact.py",
26
29
  "scripts/codex-run.sh",
27
30
  "scripts/codex-helm.sh",
28
31
  "scripts/install.sh",
@@ -136,11 +136,43 @@ if [ -f config/learning.schema.json ]; then
136
136
  python3 scripts/check-learning.py --self-test >/dev/null \
137
137
  || { echo "FAIL: learning gate self-test missed a negative control"; fail=1; }
138
138
  # collect-learning Phase 2 transport: the watermark upload-drain logic
139
- # (status classification, watermark advance, transient-stop, poison-skip).
139
+ # (status classification, watermark advance, transient-stop, poison-skip)
140
+ # plus the capture-time secret-redaction wiring.
140
141
  python3 scripts/collect-learning.py --self-test >/dev/null \
141
142
  || { echo "FAIL: collect-learning upload-drain self-test"; fail=1; }
142
143
  fi
143
144
 
145
+ # Secret-redaction floor (scripts/redact.py) — single-sourced by the heavy
146
+ # (digest.py) and light (collect-learning.py) flows; --self-test proves each
147
+ # pattern fires and that lessons ABOUT secrets are not over-redacted.
148
+ if [ -f scripts/redact.py ]; then
149
+ python3 scripts/redact.py --self-test >/dev/null \
150
+ || { echo "FAIL: secret-redaction floor self-test (scripts/redact.py)"; fail=1; }
151
+ fi
152
+
153
+ # Phase 3 curation intake (scripts/ingest-learnings-export.py) — validates a
154
+ # dashboard learnings export and maps it to ledger candidates; --self-test
155
+ # proves valid rows map (cardinality > 0) and broken/non-v1 rows are diverted.
156
+ if [ -f scripts/ingest-learnings-export.py ]; then
157
+ python3 scripts/ingest-learnings-export.py --self-test >/dev/null \
158
+ || { echo "FAIL: curation-intake self-test (scripts/ingest-learnings-export.py)"; fail=1; }
159
+ fi
160
+
161
+ # Phase 4 promote->migrate: the promotion manifest (config/promotions.json) is
162
+ # DERIVED from the ledger — --check fails if it is stale, so a promotion can't
163
+ # ship without its manifest entry; migrate-learnings clears personal copies only
164
+ # for in-bundle promotions (--self-test proves the not-in-bundle keep guard).
165
+ if [ -f scripts/build-promotions.py ]; then
166
+ python3 scripts/build-promotions.py --self-test >/dev/null \
167
+ || { echo "FAIL: build-promotions self-test"; fail=1; }
168
+ python3 scripts/build-promotions.py --check >/dev/null \
169
+ || { echo "FAIL: config/promotions.json is stale vs the ledger (run scripts/build-promotions.py)"; fail=1; }
170
+ fi
171
+ if [ -f scripts/migrate-learnings.py ]; then
172
+ python3 scripts/migrate-learnings.py --self-test >/dev/null \
173
+ || { echo "FAIL: migrate-learnings self-test (personal-copy prune + in-bundle guard)"; fail=1; }
174
+ fi
175
+
144
176
  # Lexicon gate: forbid deprecated terminology tokens in live files
145
177
  # (LEXICON.md is the SSOT); --self-test proves the detector can fire.
146
178
  if [ -f LEXICON.md ]; then
@@ -45,6 +45,12 @@ import uuid
45
45
 
46
46
  REPO = pathlib.Path(__file__).resolve().parent.parent
47
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")
48
54
 
49
55
  # host -> (config-home env var, default home dirname under $HOME)
50
56
  HOSTS = {
@@ -90,6 +96,16 @@ def load_checker():
90
96
  return module
91
97
 
92
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
+
93
109
  def die(msg, code=1):
94
110
  print(f"collect-learning: {msg}", file=sys.stderr)
95
111
  sys.exit(code)
@@ -132,7 +148,17 @@ def resolve_home(host, config_dir):
132
148
 
133
149
 
134
150
  def build_record(payload):
151
+ redactor = load_redactor()
135
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", " ")
136
162
  record["schema_version"] = 1
137
163
  record["learning_id"] = str(uuid.uuid4()) # canonical lowercase
138
164
  record["created"] = datetime.datetime.now().astimezone().replace(microsecond=0).isoformat()
@@ -416,7 +442,8 @@ def print_upload_summary(s):
416
442
  def _self_test():
417
443
  """Socket-free verification of the drain: status classification, watermark
418
444
  advance, transient-stop (no storm), permanent-drop, no-token skip, poison
419
- line. Injects post_fn/now; exits non-zero on any failure."""
445
+ line, plus capture-time redaction wiring. Injects post_fn/now; exits
446
+ non-zero on any failure."""
420
447
  import tempfile
421
448
 
422
449
  def make_home(records, with_token=True):
@@ -500,6 +527,22 @@ def _self_test():
500
527
  s = drain_uploads(h, post_fn=lambda *a: 200, now=fake_now, budget_s=5.0)
501
528
  checks.append(("budget stop", s["reason"] == "budget"))
502
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
+
503
546
  failed = [name for name, ok in checks if not ok]
504
547
  if failed:
505
548
  for name in failed:
@@ -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"
@@ -0,0 +1,423 @@
1
+ #!/usr/bin/env python3
2
+ """Promote -> migrate: clear the personal copy of promoted learnings (Phase 4).
3
+
4
+ After a push lands, a learning that was promoted into the shared corpus is now
5
+ loaded from the corpus — its personal copy (written by `learn!`,
6
+ scripts/collect-learning.py) would double-load. This user-side tool removes the
7
+ absorbed personal copy, and ONLY it (never the user's hand-written entry
8
+ CLAUDE.md `## Personal` section).
9
+
10
+ THE SAFETY RULE (design/corpus-domain-packaging.md:161-168): remove a personal
11
+ copy ONLY after verifying the promoted item is actually in THIS user's assembled
12
+ bundle. Per-domain opt-in means a promotion into a package the user did NOT
13
+ install must NOT trigger removal — that would silently lose the learning. When
14
+ unsure, KEEP (a kept duplicate is redundant; a wrong removal is data loss).
15
+
16
+ Bundle membership (mirrors scripts/assemble.py `kept`): full install (original
17
+ single-zone / no selection) -> everything installed; universal tier (core/infra)
18
+ -> always installed; a domain key -> installed iff in the user's selection;
19
+ anything else (env-personal / unclassified / unknown) -> KEEP.
20
+
21
+ Run per host (mirrors collect-learning: --host + --config-dir); install.sh calls
22
+ it after the corpus deploy. Manifest absent/empty -> no-op.
23
+ """
24
+ import argparse
25
+ import importlib.util
26
+ import json
27
+ import os
28
+ import pathlib
29
+ import re
30
+ import shutil
31
+ import sys
32
+ import time
33
+
34
+ REPO = pathlib.Path(__file__).resolve().parent.parent
35
+ MANIFEST = REPO / "config" / "promotions.json"
36
+ DOMAINS = REPO / "config" / "domains.json"
37
+ UNIVERSAL_TIERS = frozenset({"core", "infra"}) # == assemble.audience UNIVERSAL
38
+ # learning_id inside a personal bullet's TRAILING comment (collect-learning
39
+ # prose_bullet: "... <!-- learning_id: <uuid> created: <ts> -->"). Anchored to
40
+ # end-of-line so a lesson body that quotes a `<!-- learning_id: … -->` string
41
+ # can never shadow the bullet's own id (this corpus is about agent tooling).
42
+ BULLET_LID_RE = re.compile(r"<!--\s*learning_id:\s*([0-9a-fA-F-]+)[^>]*-->\s*$")
43
+
44
+
45
+ def die(msg, code=1):
46
+ print(f"migrate-learnings: {msg}", file=sys.stderr)
47
+ sys.exit(code)
48
+
49
+
50
+ def _load(name, filename):
51
+ path = REPO / "scripts" / filename
52
+ spec = importlib.util.spec_from_file_location(name, path)
53
+ module = importlib.util.module_from_spec(spec)
54
+ spec.loader.exec_module(module)
55
+ return module
56
+
57
+
58
+ def load_collect():
59
+ """Reuse collect-learning.py's host/home/marker constants (single source)."""
60
+ return _load("collect_learning", "collect-learning.py")
61
+
62
+
63
+ def load_manifest(path=MANIFEST):
64
+ if not path.is_file():
65
+ return []
66
+ data = json.loads(path.read_text(encoding="utf-8"))
67
+ return [p for p in data.get("promotions", [])
68
+ if isinstance(p, dict) and isinstance(p.get("learning_id"), str)]
69
+
70
+
71
+ def load_domain_keys(path=DOMAINS):
72
+ return set(json.loads(path.read_text(encoding="utf-8")).get("domains", {}))
73
+
74
+
75
+ def make_in_bundle(full, selection, domain_keys):
76
+ """A promotion's placement domain -> is it in THIS user's bundle? Biased to
77
+ KEEP on anything not provably installed."""
78
+ selection = set(selection or ())
79
+
80
+ def in_bundle(domain):
81
+ if full:
82
+ return True
83
+ if domain in UNIVERSAL_TIERS:
84
+ return True
85
+ if domain in domain_keys:
86
+ return domain in selection
87
+ return False # env-personal / unclassified / unknown -> keep
88
+
89
+ return in_bundle
90
+
91
+
92
+ def backup(path):
93
+ shutil.copy2(path, path.with_suffix(path.suffix + f".bak-migrate-{time.strftime('%Y%m%d-%H%M%S')}"))
94
+
95
+
96
+ def atomic_write(path, text):
97
+ """Back up, then replace via a temp file + os.replace so a crash mid-write
98
+ can never truncate the durable personal file (reuses collect-learning's
99
+ write discipline)."""
100
+ backup(path)
101
+ tmp = path.with_suffix(path.suffix + ".tmp")
102
+ tmp.write_text(text, encoding="utf-8")
103
+ os.replace(tmp, path)
104
+
105
+
106
+ def prune_jsonl(jsonl, remove_ids, dry):
107
+ """Drop records whose learning_id is being removed. Returns count removed."""
108
+ if not jsonl.is_file():
109
+ return 0
110
+ kept, removed = [], 0
111
+ for line in jsonl.read_text(encoding="utf-8").splitlines():
112
+ s = line.strip()
113
+ if s:
114
+ try:
115
+ rec = json.loads(s)
116
+ if isinstance(rec, dict) and rec.get("learning_id") in remove_ids:
117
+ removed += 1
118
+ continue
119
+ except json.JSONDecodeError:
120
+ pass # keep malformed lines untouched
121
+ kept.append(line)
122
+ if removed and not dry:
123
+ atomic_write(jsonl, "\n".join(kept) + ("\n" if kept else ""))
124
+ return removed
125
+
126
+
127
+ def prune_bullets(text, remove_ids):
128
+ """Drop bullet lines whose comment learning_id is being removed. Returns
129
+ (new_text, count). Non-bullet lines (header/comments) are preserved."""
130
+ out, removed = [], 0
131
+ for line in text.splitlines():
132
+ m = BULLET_LID_RE.search(line)
133
+ if m and m.group(1) in remove_ids:
134
+ removed += 1
135
+ continue
136
+ out.append(line)
137
+ new = "\n".join(out) + ("\n" if text.endswith("\n") and out else "")
138
+ return new, removed
139
+
140
+
141
+ def prune_claude_prose(home, remove_ids, dry):
142
+ md = home / "personal" / "learnings.md"
143
+ if not md.is_file():
144
+ return 0
145
+ new, removed = prune_bullets(md.read_text(encoding="utf-8"), remove_ids)
146
+ if removed and not dry:
147
+ atomic_write(md, new)
148
+ return removed
149
+
150
+
151
+ def prune_codex_prose(home, remove_ids, collect, dry):
152
+ agents = home / "AGENTS.md"
153
+ if not agents.is_file():
154
+ return 0
155
+ body = agents.read_text(encoding="utf-8")
156
+ start, end = collect.PERSONAL_START, collect.PERSONAL_END
157
+ if start not in body:
158
+ return 0
159
+ pre, rest = body.split(start, 1)
160
+ if end not in rest: # malformed (missing, or END before START) → touch nothing
161
+ return 0
162
+ region, post = rest.split(end, 1)
163
+ new_region, removed = prune_bullets(region, remove_ids)
164
+ if removed and not dry:
165
+ atomic_write(agents, f"{pre}{start}{new_region}{end}{post}")
166
+ return removed
167
+
168
+
169
+ def claude_corpus_loaded(home, full):
170
+ """Is the shared corpus actually LOADED for this claude home? Removing a
171
+ personal copy while the corpus copy is not loaded would make the rule vanish
172
+ (Review F2). Non-packaged (`full`): the corpus IS the entry monolith the
173
+ deploy wrote -> loaded. Packaged: loads only via the entry's
174
+ `@central/bundle.md` import; a user-owned entry that lacks it (assemble exit
175
+ 2) is NOT loading central -> keep the personal copy."""
176
+ if full:
177
+ return True
178
+ entry = home / "CLAUDE.md"
179
+ return entry.is_file() and collect_central_import() in entry.read_text(encoding="utf-8")
180
+
181
+
182
+ def collect_central_import():
183
+ return "@central/bundle.md"
184
+
185
+
186
+ def migrate(home, host, promotions, in_bundle, collect, dry=False, corpus_loaded=True):
187
+ """Remove personal copies of promoted+in-bundle learnings present locally.
188
+ corpus_loaded=False (the corpus is not actually loaded for this host) -> keep
189
+ everything (never orphan a personal copy)."""
190
+ if not corpus_loaded:
191
+ return {"removed": 0, "jsonl_removed": 0, "prose_removed": 0,
192
+ "kept_not_in_bundle": 0, "skipped": "corpus-not-loaded"}
193
+ jsonl = home / "personal" / "learnings.jsonl"
194
+ local_ids = set()
195
+ if jsonl.is_file():
196
+ for line in jsonl.read_text(encoding="utf-8").splitlines():
197
+ s = line.strip()
198
+ if not s:
199
+ continue
200
+ try:
201
+ rec = json.loads(s)
202
+ except json.JSONDecodeError:
203
+ continue
204
+ lid = rec.get("learning_id") if isinstance(rec, dict) else None
205
+ if isinstance(lid, str):
206
+ local_ids.add(lid)
207
+
208
+ remove_ids, kept_not_in_bundle = set(), 0
209
+ for p in promotions:
210
+ lid = p["learning_id"]
211
+ if lid not in local_ids:
212
+ continue # not held locally (never captured here, or already migrated)
213
+ if in_bundle(p.get("domain")):
214
+ remove_ids.add(lid)
215
+ else:
216
+ kept_not_in_bundle += 1 # promoted but not in THIS user's bundle -> keep
217
+
218
+ # Prune PROSE first, jsonl second: the gate keys off ids still in the jsonl
219
+ # (line ~"if lid not in local_ids: continue"), so if a prose write fails, the
220
+ # id survives in jsonl and a re-run re-enters and converges. The reverse order
221
+ # would strand the prose bullet forever after a jsonl-only success (Review F4).
222
+ if host == "codex":
223
+ prose_removed = prune_codex_prose(home, remove_ids, collect, dry)
224
+ else:
225
+ prose_removed = prune_claude_prose(home, remove_ids, dry)
226
+ jsonl_removed = prune_jsonl(jsonl, remove_ids, dry)
227
+
228
+ return {"removed": len(remove_ids), "jsonl_removed": jsonl_removed,
229
+ "prose_removed": prose_removed, "kept_not_in_bundle": kept_not_in_bundle}
230
+
231
+
232
+ def main():
233
+ ap = argparse.ArgumentParser(description="Clear personal copies of promoted learnings.")
234
+ # Not argparse-required, so --self-test runs standalone; validated below.
235
+ ap.add_argument("--host", choices=("claude", "codex"))
236
+ ap.add_argument("--config-dir", default=None,
237
+ help="config home (default: $CLAUDE_CONFIG_DIR / $CODEX_HOME by host)")
238
+ ap.add_argument("--full", action="store_true",
239
+ help="non-packaged install: the full corpus is installed")
240
+ ap.add_argument("--selection-file",
241
+ help="packaged install: selection.json ({\"domains\":[...]})")
242
+ ap.add_argument("--domains", help="packaged: comma-separated selection (overrides --selection-file)")
243
+ ap.add_argument("--manifest", default=None,
244
+ help="promotion manifest (default: config/promotions.json)")
245
+ ap.add_argument("--dry-run", action="store_true")
246
+ ap.add_argument("--self-test", action="store_true")
247
+ args = ap.parse_args()
248
+
249
+ if args.self_test:
250
+ _self_test()
251
+ return
252
+
253
+ if not args.host:
254
+ die("--host claude|codex is required")
255
+ if not args.full and args.selection_file is None and args.domains is None:
256
+ die("pass --full (non-packaged) or --selection-file/--domains (packaged)")
257
+ if args.full and (args.selection_file is not None or args.domains is not None):
258
+ die("--full is mutually exclusive with --selection-file/--domains")
259
+
260
+ promotions = load_manifest(pathlib.Path(args.manifest) if args.manifest else MANIFEST)
261
+ if not promotions:
262
+ print("migrate-learnings: no promotions in the manifest — nothing to migrate")
263
+ return
264
+
265
+ collect = load_collect()
266
+ home = collect.resolve_home(args.host, args.config_dir)
267
+
268
+ # F2 gate: only prune where the shared corpus is actually loaded. Codex always
269
+ # loads AGENTS.md's central region; claude loads central only via the entry
270
+ # import (or inline in a full install).
271
+ corpus_loaded = True if args.host == "codex" else claude_corpus_loaded(home, args.full)
272
+
273
+ if args.full:
274
+ in_bundle = make_in_bundle(True, (), set())
275
+ else:
276
+ if args.domains is not None:
277
+ selection = [d for d in args.domains.split(",") if d]
278
+ else:
279
+ sel_path = pathlib.Path(args.selection_file)
280
+ if not sel_path.is_file():
281
+ die(f"selection file not found: {sel_path} (pass --full for a non-packaged install)")
282
+ selection = json.loads(sel_path.read_text(encoding="utf-8")).get("domains", [])
283
+ in_bundle = make_in_bundle(False, selection, load_domain_keys())
284
+
285
+ s = migrate(home, args.host, promotions, in_bundle, collect,
286
+ dry=args.dry_run, corpus_loaded=corpus_loaded)
287
+ tag = "[dry] " if args.dry_run else ""
288
+ if s.get("skipped"):
289
+ print(f"migrate-learnings: {tag}host={args.host} skipped ({s['skipped']}) — "
290
+ "kept every personal copy")
291
+ return
292
+ print(f"migrate-learnings: {tag}host={args.host} removed={s['removed']} "
293
+ f"(jsonl={s['jsonl_removed']} prose={s['prose_removed']}) "
294
+ f"kept_not_in_bundle={s['kept_not_in_bundle']}")
295
+
296
+
297
+ def _self_test():
298
+ """Temp-home verification: the safety gate (keep a promotion not in the
299
+ user's bundle), removal of an in-bundle promotion from BOTH prose + jsonl,
300
+ idempotency, and that the user's own '## Personal' text is untouched. Uses
301
+ collect-learning's own writers so the seeded format is authoritative."""
302
+ import tempfile
303
+
304
+ collect = load_collect()
305
+
306
+ def rec(lid, domain):
307
+ return {"lesson": f"lesson for {domain} " + "x" * 10, "domain": domain,
308
+ "supporting_sessions": ["claude:abcd1234"],
309
+ "learning_id": lid, "schema_version": 1, "created": "2026-07-21T00:00:00Z"}
310
+
311
+ L = {"core": "0f8c1c2a-4d1e-4abc-9def-0000000000a1",
312
+ "bb": "0f8c1c2a-4d1e-4abc-9def-0000000000b2",
313
+ "off": "0f8c1c2a-4d1e-4abc-9def-0000000000c3"}
314
+ promos = [{"learning_id": L["core"], "domain": "core"},
315
+ {"learning_id": L["bb"], "domain": "builder-base"},
316
+ {"learning_id": L["off"], "domain": "office-work"}]
317
+
318
+ def seed_claude():
319
+ home = pathlib.Path(tempfile.mkdtemp(prefix="migrate-selftest-"))
320
+ (home / "personal").mkdir(parents=True)
321
+ jsonl = home / "personal" / "learnings.jsonl"
322
+ with open(jsonl, "w", encoding="utf-8") as f:
323
+ for k, dom in (("core", "core"), ("bb", "builder-base"), ("off", "office-work")):
324
+ r = rec(L[k], dom)
325
+ f.write(json.dumps(r, ensure_ascii=False) + "\n")
326
+ collect.apply_claude(home, collect.prose_bullet(r), dry=False)
327
+ # a user-owned '## Personal' line that migrate must never touch
328
+ entry = home / "CLAUDE.md"
329
+ entry.write_text(entry.read_text(encoding="utf-8") + "\n## Personal\n- my own rule\n", encoding="utf-8")
330
+ return home
331
+
332
+ def local_ids(home):
333
+ return {json.loads(l)["learning_id"]
334
+ for l in (home / "personal" / "learnings.jsonl").read_text(encoding="utf-8").splitlines() if l.strip()}
335
+
336
+ def md_has(home, lid):
337
+ return lid in (home / "personal" / "learnings.md").read_text(encoding="utf-8")
338
+
339
+ checks = []
340
+
341
+ # 1) Packaged, selection={builder-base}: core (universal) + builder-base
342
+ # removed; office-work KEPT (the silent-loss guard).
343
+ home = seed_claude()
344
+ in_bundle = make_in_bundle(False, ["builder-base"], {"builder-base", "office-work", "llm-pipeline-dev"})
345
+ s = migrate(home, "claude", promos, in_bundle, collect)
346
+ ids = local_ids(home)
347
+ checks.append(("packaged: removed core+builder-base", s["removed"] == 2 and s["kept_not_in_bundle"] == 1))
348
+ checks.append(("packaged: office-work KEPT (not selected)", L["off"] in ids and md_has(home, L["off"])))
349
+ checks.append(("packaged: core+bb gone from jsonl", L["core"] not in ids and L["bb"] not in ids))
350
+ checks.append(("packaged: core+bb gone from prose", not md_has(home, L["core"]) and not md_has(home, L["bb"])))
351
+ checks.append(("user '## Personal' untouched", "my own rule" in (home / "CLAUDE.md").read_text(encoding="utf-8")))
352
+ # idempotent re-run removes nothing more
353
+ s2 = migrate(home, "claude", promos, in_bundle, collect)
354
+ checks.append(("idempotent re-run", s2["removed"] == 0))
355
+
356
+ # 2) Full (non-packaged) install: everything in bundle -> all removed.
357
+ home = seed_claude()
358
+ s = migrate(home, "claude", promos, make_in_bundle(True, (), set()), collect)
359
+ checks.append(("full install: all 3 removed", s["removed"] == 3 and not local_ids(home)))
360
+
361
+ # 3) dry-run changes nothing on disk.
362
+ home = seed_claude()
363
+ before = (home / "personal" / "learnings.jsonl").read_text(encoding="utf-8")
364
+ migrate(home, "claude", promos, make_in_bundle(True, (), set()), collect, dry=True)
365
+ checks.append(("dry-run writes nothing",
366
+ (home / "personal" / "learnings.jsonl").read_text(encoding="utf-8") == before))
367
+
368
+ # 4) codex host: seed the AGENTS.md region, migrate prunes it there.
369
+ chome = pathlib.Path(tempfile.mkdtemp(prefix="migrate-codex-"))
370
+ (chome / "personal").mkdir(parents=True)
371
+ with open(chome / "personal" / "learnings.jsonl", "w", encoding="utf-8") as f:
372
+ r = rec(L["bb"], "builder-base")
373
+ f.write(json.dumps(r, ensure_ascii=False) + "\n")
374
+ collect.apply_codex(chome, collect.prose_bullet(r), dry=False)
375
+ s = migrate(chome, "codex", promos, make_in_bundle(True, (), set()), collect)
376
+ agents_txt = (chome / "AGENTS.md").read_text(encoding="utf-8")
377
+ checks.append(("codex: bullet removed from AGENTS.md region",
378
+ s["prose_removed"] == 1 and L["bb"] not in agents_txt))
379
+
380
+ # 5) F2 gate: a packaged claude entry lacking @central/bundle.md means the
381
+ # corpus is NOT loaded -> keep everything (never orphan a personal copy).
382
+ home = seed_claude() # apply_claude writes an entry WITHOUT @central/bundle.md
383
+ checks.append(("F2: unwired packaged entry -> corpus not loaded",
384
+ claude_corpus_loaded(home, full=False) is False))
385
+ s = migrate(home, "claude", promos, make_in_bundle(False, ["builder-base"], {"builder-base"}),
386
+ collect, corpus_loaded=False)
387
+ checks.append(("F2: corpus-not-loaded keeps ALL",
388
+ s.get("skipped") == "corpus-not-loaded"
389
+ and local_ids(home) == {L["core"], L["bb"], L["off"]}))
390
+ entry = home / "CLAUDE.md"
391
+ entry.write_text(entry.read_text(encoding="utf-8") + "\n@central/bundle.md\n", encoding="utf-8")
392
+ checks.append(("F2: wired entry -> corpus loaded",
393
+ claude_corpus_loaded(home, full=False) is True))
394
+ checks.append(("F2: full install always loaded", claude_corpus_loaded(home, full=True) is True))
395
+
396
+ # 6) F5: the TRAILING comment's id wins; a learning_id quoted in the lesson
397
+ # body must never shadow it (else a jsonl/prose desync).
398
+ twin = ("- [core] cf <!-- learning_id: aaaa0000-0000-0000-0000-000000000000 "
399
+ "created: x --> here <!-- learning_id: 11112222-3333-4444-5555-666677778888 "
400
+ "created: y -->\n")
401
+ _, r_real = prune_bullets(twin, {"11112222-3333-4444-5555-666677778888"})
402
+ _, r_quoted = prune_bullets(twin, {"aaaa0000-0000-0000-0000-000000000000"})
403
+ checks.append(("F5: trailing id removed, quoted id ignored", r_real == 1 and r_quoted == 0))
404
+
405
+ # 7) malformed codex markers (END before START) -> no-op, no corruption.
406
+ bad = pathlib.Path(tempfile.mkdtemp(prefix="migrate-badcodex-"))
407
+ agents = bad / "AGENTS.md"
408
+ agents.write_text(f"x {collect.PERSONAL_END} y {collect.PERSONAL_START} z", encoding="utf-8")
409
+ before = agents.read_text(encoding="utf-8")
410
+ rem = prune_codex_prose(bad, {"anything"}, collect, dry=False)
411
+ checks.append(("malformed codex markers -> no-op",
412
+ rem == 0 and agents.read_text(encoding="utf-8") == before))
413
+
414
+ failed = [n for n, ok in checks if not ok]
415
+ if failed:
416
+ for n in failed:
417
+ print(f"migrate-learnings --self-test: FAIL: {n}", file=sys.stderr)
418
+ sys.exit(1)
419
+ print(f"migrate-learnings --self-test: OK ({len(checks)} migrate checks)")
420
+
421
+
422
+ if __name__ == "__main__":
423
+ main()
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env python3
2
+ """Secret-redaction floor — the ONE canonical secret scrubber for the repo.
3
+
4
+ Both flows that egress session-derived text redact through here, so the floor
5
+ is single-sourced (concept economy) rather than duplicated per call site:
6
+ * heavy flow — scripts/session-distill/digest.py (per-session digests);
7
+ * light flow — scripts/collect-learning.py (`learn!` prose + upload + the
8
+ curator export downstream, since the server stores the payload verbatim).
9
+
10
+ `design/corpus-domain-packaging.md` declares this floor as an inherited
11
+ constraint ("the digest pipeline's secret-redaction carries over to harvest").
12
+ The patterns are deliberately conservative: kill obvious secrets/identifiers in
13
+ the `key: value` / `key=value` form and well-known token shapes, keep prose. A
14
+ `token`/`secret`/`bearer` word standing alone (no `:`/`=` + value) is NOT
15
+ touched, so lessons ABOUT tokens (e.g. "trust the X-Hook-Token header") survive.
16
+
17
+ This module has no side effects on import and is safe to load by path.
18
+ scripts/redact.py is shipped in the npm package (package.json `files`) because
19
+ the shipped scripts/collect-learning.py imports it at runtime.
20
+ """
21
+ import re
22
+ import sys
23
+
24
+ # Conservative: kill obvious secrets/identifiers, keep prose.
25
+ SECRET_RE = [
26
+ re.compile(r'(?i)(api[_-]?key|secret|token|password|authorization|bearer)\s*[:=]\s*\S+'),
27
+ re.compile(r'sk-[A-Za-z0-9_-]{16,}'),
28
+ re.compile(r'gh[pousr]_[A-Za-z0-9]{20,}'),
29
+ re.compile(r'eyJ[A-Za-z0-9_-]{20,}\.'), # JWT-ish
30
+ re.compile(r'AKIA[0-9A-Z]{12,}'), # AWS key id
31
+ re.compile(r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}'), # email
32
+ ]
33
+ PLACEHOLDER = '<REDACTED>'
34
+
35
+
36
+ def redact(s):
37
+ """Return `s` with obvious secrets/identifiers replaced by <REDACTED>.
38
+ Falsy/non-str input is returned unchanged (callers may pass None)."""
39
+ if not s or not isinstance(s, str):
40
+ return s
41
+ for r in SECRET_RE:
42
+ s = r.sub(PLACEHOLDER, s)
43
+ return s
44
+
45
+
46
+ def _self_test():
47
+ """Prove each pattern fires and that clean prose survives; exits non-zero on
48
+ any miss so the umbrella gate catches a regression in the floor."""
49
+ must_redact = [
50
+ ("api_key value", "my api_key=sk_live_ABCDEF0123456789 leaked", "sk_live_"),
51
+ # key[:=]value form. NB the "Authorization: Bearer <token>" space/scheme
52
+ # form is only partially caught here (up to the scheme word) unless the
53
+ # token itself matches a shape below (JWT/sk-/ghp_/…) — a known floor
54
+ # boundary carried over from the heavy flow, not tightened in Phase 3.
55
+ ("authorization value", "set authorization=topsecretvalue123 here", "topsecretvalue123"),
56
+ ("openai key", "used sk-abcdefghij0123456789 here", "sk-abcdefghij"),
57
+ ("github token", "token ghp_abcdefghij0123456789klmn committed", "ghp_"),
58
+ ("aws key id", "key AKIA0123456789ABCD in env", "AKIA0123456789"),
59
+ ("jwt", "cookie eyJhbGciOiJIUzI1NiIsInR5cCI6.rest here", "eyJ"),
60
+ ("email", "ping alice.dev@example.com about it", "alice.dev@example.com"),
61
+ ]
62
+ must_keep = [
63
+ # a lesson ABOUT tokens/secrets, no `key: value` → untouched.
64
+ "trust the X-Hook-Token header; the server derives identity from it",
65
+ "the secret sauce is verifying the real dispatch path",
66
+ "password rotation policy matters but state no value",
67
+ ]
68
+ checks = []
69
+ for name, src, needle in must_redact:
70
+ out = redact(src)
71
+ checks.append((f"redacts {name}", PLACEHOLDER in out and needle not in out))
72
+ for src in must_keep:
73
+ out = redact(src)
74
+ checks.append((f"keeps: {src[:32]}…", out == src))
75
+ checks.append(("None passthrough", redact(None) is None))
76
+ checks.append(("empty passthrough", redact("") == ""))
77
+
78
+ failed = [name for name, ok in checks if not ok]
79
+ if failed:
80
+ for name in failed:
81
+ print(f"redact --self-test: FAIL: {name}", file=sys.stderr)
82
+ sys.exit(1)
83
+ print(f"redact --self-test: OK ({len(checks)} redaction-floor checks)")
84
+
85
+
86
+ if __name__ == "__main__":
87
+ if len(sys.argv) > 1 and sys.argv[1] == "--self-test":
88
+ _self_test()
89
+ else:
90
+ # Filter mode: redact stdin → stdout (handy for ad-hoc use).
91
+ sys.stdout.write(redact(sys.stdin.read()))