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.
- package/config/promotions.json +4 -0
- package/package.json +4 -1
- package/scripts/check-parity.sh +33 -1
- package/scripts/collect-learning.py +44 -1
- package/scripts/install.sh +19 -0
- package/scripts/migrate-learnings.py +423 -0
- package/scripts/redact.py +91 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-bios",
|
|
3
|
-
"version": "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",
|
package/scripts/check-parity.sh
CHANGED
|
@@ -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
|
|
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:
|
package/scripts/install.sh
CHANGED
|
@@ -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()))
|