agent-bios 0.9.4 → 0.9.6
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/claude/CLAUDE.md +1 -1
- package/claude/guides/learning-flow.md +2 -2
- package/claude/settings.json +60 -0
- package/codex/AGENTS.md +1 -1
- package/codex/guides/learning-flow.md +2 -2
- package/config/domains.json +1 -0
- package/config/promotions.json +3 -1
- package/package.json +9 -2
- package/scripts/assemble.py +322 -0
- package/scripts/build-promotions.py +210 -0
- package/scripts/canary.sh +28 -0
- package/scripts/check-domains.py +256 -0
- package/scripts/ingest-learnings-export.py +258 -0
- package/scripts/install.sh +28 -0
- package/scripts/migrate-learnings.py +129 -12
- package/scripts/pkgid.py +45 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Curation intake — map a dashboard learnings export to ledger candidates.
|
|
3
|
+
|
|
4
|
+
Phase 3 of the collection loop (design/collection-loop/PHASE3-CURATION-DESIGN.md).
|
|
5
|
+
The curator exports RECEIVED learnings from the dashboard as ledger-compatible
|
|
6
|
+
JSON (GET /api/exports/learnings — verbatim payloads + provenance); THIS script
|
|
7
|
+
does the DETERMINISTIC half of intake:
|
|
8
|
+
|
|
9
|
+
* validates each exported record against config/learning.schema.json — the
|
|
10
|
+
single validation source, reused from scripts/check-learning.py (no second
|
|
11
|
+
schema), so an invalid/verbatim-but-nonconforming payload is caught here;
|
|
12
|
+
* buckets each row: VALID → a ledger-candidate entry; REJECTED → schema or
|
|
13
|
+
domain-membership failure (with the reasons); DEFERRED → schema_version != 1
|
|
14
|
+
(Phase 2 stores v2+ verbatim for forward-compat; the v1 intake cannot map it
|
|
15
|
+
yet — it is NOT a reject, it is re-exportable once a v2-aware intake lands);
|
|
16
|
+
* flags candidates whose learning_id already appears in ledger.json (a
|
|
17
|
+
deterministic dedup warning — string membership, not a semantic judgment);
|
|
18
|
+
* emits a curation WORKLIST the curator then works through by hand.
|
|
19
|
+
|
|
20
|
+
It does NOT do the SEMANTIC half — triage the domain, classify type/layer/
|
|
21
|
+
mechanism, or judge novelty vs the full canon. Those stay with the curator
|
|
22
|
+
(capability boundary); see design/collection-loop/CURATION-INTAKE.md.
|
|
23
|
+
|
|
24
|
+
PII boundary: the worklist carries `_provenance.user_email` (D3.3 — visibility
|
|
25
|
+
into who contributes what). The worklist is a LOCAL artifact — never commit it;
|
|
26
|
+
when merging a candidate into the git-tracked ledger.json, keep `learning_id`
|
|
27
|
+
(non-PII dedup key) and DROP `_provenance` (see the procedure doc).
|
|
28
|
+
|
|
29
|
+
Input: a learnings-export JSON file (positional arg; '-' or omitted = stdin).
|
|
30
|
+
Output: the worklist JSON to stdout, or to --out FILE.
|
|
31
|
+
"""
|
|
32
|
+
import argparse
|
|
33
|
+
import importlib.util
|
|
34
|
+
import json
|
|
35
|
+
import pathlib
|
|
36
|
+
import sys
|
|
37
|
+
|
|
38
|
+
REPO = pathlib.Path(__file__).resolve().parent.parent
|
|
39
|
+
LEDGER = REPO / "design" / "session-distill" / "ledger.json"
|
|
40
|
+
FIXTURE = REPO / "design" / "collection-loop" / "fixtures" / "export-sample.json"
|
|
41
|
+
SUPPORTED_SCHEMA_VERSION = 1
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def die(msg, code=1):
|
|
45
|
+
print(f"ingest-learnings-export: {msg}", file=sys.stderr)
|
|
46
|
+
sys.exit(code)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_checker():
|
|
50
|
+
"""Reuse scripts/check-learning.py as the single validation source."""
|
|
51
|
+
path = REPO / "scripts" / "check-learning.py"
|
|
52
|
+
spec = importlib.util.spec_from_file_location("check_learning", path)
|
|
53
|
+
module = importlib.util.module_from_spec(spec)
|
|
54
|
+
spec.loader.exec_module(module)
|
|
55
|
+
return module
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_ledger_learning_ids(path=LEDGER):
|
|
59
|
+
"""learning_ids already present in the ledger (dedup key). Entries sourced
|
|
60
|
+
from earlier learnings carry a top-level `learning_id`; the historical
|
|
61
|
+
session-distill entries do not, so they simply contribute nothing here.
|
|
62
|
+
A missing/unreadable ledger is not fatal — dedup just finds nothing."""
|
|
63
|
+
try:
|
|
64
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
65
|
+
except (OSError, json.JSONDecodeError):
|
|
66
|
+
return set()
|
|
67
|
+
out = set()
|
|
68
|
+
for e in data.get("entries", []):
|
|
69
|
+
lid = e.get("learning_id") if isinstance(e, dict) else None
|
|
70
|
+
if isinstance(lid, str) and lid:
|
|
71
|
+
out.add(lid.lower())
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def map_candidate(payload, row):
|
|
76
|
+
"""A validated export row -> a ledger-candidate entry (ledger.json shape).
|
|
77
|
+
Deterministic fields the record carries are copied; curator-only fields are
|
|
78
|
+
null for the curator to fill. `_provenance` is worklist-only (strip before
|
|
79
|
+
the ledger merge)."""
|
|
80
|
+
cls = payload.get("classification") or {}
|
|
81
|
+
return {
|
|
82
|
+
"id": None, # curator assigns (e.g. S4-02)
|
|
83
|
+
"learning_id": payload.get("learning_id"), # kept in ledger = dedup key
|
|
84
|
+
"lesson": payload.get("lesson"),
|
|
85
|
+
"strength": None, # curator: recurrence
|
|
86
|
+
"verdict": None, # curator: novel|partial|principle
|
|
87
|
+
"criteria": payload.get("criteria", []),
|
|
88
|
+
"supporting_sessions": payload.get("supporting_sessions", []),
|
|
89
|
+
"domain": payload.get("domain"),
|
|
90
|
+
"proposed_domain": payload.get("proposed_domain"),
|
|
91
|
+
"context": payload.get("context"), # curator-facing evidence note
|
|
92
|
+
"classification": {
|
|
93
|
+
"type": cls.get("type"),
|
|
94
|
+
"underlying_value": None,
|
|
95
|
+
"reformulation": None,
|
|
96
|
+
"meets_promotion_bar": cls.get("meets_bar"),
|
|
97
|
+
"layer": cls.get("layer"),
|
|
98
|
+
"mechanism": None,
|
|
99
|
+
"token_est": None,
|
|
100
|
+
"consumer_note": None,
|
|
101
|
+
"split": None,
|
|
102
|
+
"verification": None,
|
|
103
|
+
"proposed": False, # ledger convention: boolean
|
|
104
|
+
},
|
|
105
|
+
"status": "candidate",
|
|
106
|
+
"_provenance": {
|
|
107
|
+
"user_email": row.get("user_email"), # PII — worklist only, strip on merge
|
|
108
|
+
"received_at": row.get("received_at"), # server receipt time
|
|
109
|
+
"created": payload.get("created"), # user capture time (distinct)
|
|
110
|
+
"schema_version": payload.get("schema_version"),
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def process_export(export, checker, ledger_ids):
|
|
116
|
+
"""Bucket every export row. Deterministic: input order preserved, no
|
|
117
|
+
timestamps, so the same input yields byte-identical output."""
|
|
118
|
+
if not isinstance(export, dict) or not isinstance(export.get("learnings"), list):
|
|
119
|
+
die("not a learnings-export (expected an object with a `learnings` array)")
|
|
120
|
+
|
|
121
|
+
validator = checker.build_validator()
|
|
122
|
+
domain_values = checker.valid_domain_values()
|
|
123
|
+
|
|
124
|
+
entries, rejected, deferred, duplicates, warnings = [], [], [], [], []
|
|
125
|
+
for i, row in enumerate(export["learnings"]):
|
|
126
|
+
if not isinstance(row, dict) or not isinstance(row.get("payload"), dict):
|
|
127
|
+
rejected.append({"index": i, "learning_id": None,
|
|
128
|
+
"reasons": ["export row has no payload object"]})
|
|
129
|
+
continue
|
|
130
|
+
payload = row["payload"]
|
|
131
|
+
lid = payload.get("learning_id")
|
|
132
|
+
|
|
133
|
+
sv = payload.get("schema_version")
|
|
134
|
+
if sv != SUPPORTED_SCHEMA_VERSION:
|
|
135
|
+
deferred.append({"index": i, "learning_id": lid, "schema_version": sv,
|
|
136
|
+
"note": "re-export once a v%s-aware intake exists "
|
|
137
|
+
"(row stays available via includeExported)" % sv})
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
errors = checker.validate_record(payload, validator, domain_values)
|
|
141
|
+
if errors:
|
|
142
|
+
rejected.append({"index": i, "learning_id": lid, "reasons": errors})
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
# server-bug detector: the export's domain column should mirror payload.domain.
|
|
146
|
+
if row.get("domain") != payload.get("domain"):
|
|
147
|
+
warnings.append({"index": i, "learning_id": lid,
|
|
148
|
+
"detail": "export domain column %r != payload.domain %r"
|
|
149
|
+
% (row.get("domain"), payload.get("domain"))})
|
|
150
|
+
|
|
151
|
+
cand = map_candidate(payload, row)
|
|
152
|
+
if isinstance(lid, str) and lid.lower() in ledger_ids:
|
|
153
|
+
cand["duplicate_in_ledger"] = True
|
|
154
|
+
duplicates.append({"index": i, "learning_id": lid})
|
|
155
|
+
entries.append(cand)
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
"source": "curation-intake",
|
|
159
|
+
"generated_from": export.get("source", "learnings-export"),
|
|
160
|
+
"counts": {"valid": len(entries), "rejected": len(rejected),
|
|
161
|
+
"deferred": len(deferred), "duplicates": len(duplicates),
|
|
162
|
+
"warnings": len(warnings)},
|
|
163
|
+
"warnings": warnings,
|
|
164
|
+
"rejected": rejected,
|
|
165
|
+
"deferred": deferred,
|
|
166
|
+
"entries": entries,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def run(export, out_path=None):
|
|
171
|
+
worklist = process_export(export, load_checker(), load_ledger_learning_ids())
|
|
172
|
+
text = json.dumps(worklist, ensure_ascii=False, indent=2) + "\n"
|
|
173
|
+
if out_path and out_path != "-":
|
|
174
|
+
pathlib.Path(out_path).write_text(text, encoding="utf-8")
|
|
175
|
+
c = worklist["counts"]
|
|
176
|
+
print(f"ingest-learnings-export: wrote {out_path} "
|
|
177
|
+
f"(valid={c['valid']} rejected={c['rejected']} deferred={c['deferred']} "
|
|
178
|
+
f"duplicates={c['duplicates']})", file=sys.stderr)
|
|
179
|
+
else:
|
|
180
|
+
sys.stdout.write(text)
|
|
181
|
+
return worklist
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _self_test():
|
|
185
|
+
"""Verify bucketing against the committed export fixture (the cross-repo
|
|
186
|
+
contract artifact): valid rows map (cardinality > 0), a bad-domain row is
|
|
187
|
+
rejected with a domain reason (negative control), a v2 row is deferred not
|
|
188
|
+
rejected, mapping preserves context + meets_bar rename, and output is
|
|
189
|
+
deterministic. Exits non-zero on any failure."""
|
|
190
|
+
export = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
|
191
|
+
checker = load_checker()
|
|
192
|
+
w1 = process_export(export, checker, {"0f8c1c2a-4d1e-4abc-9def-000000000001"})
|
|
193
|
+
w2 = process_export(export, checker, {"0f8c1c2a-4d1e-4abc-9def-000000000001"})
|
|
194
|
+
|
|
195
|
+
valid_ids = {e["learning_id"] for e in w1["entries"]}
|
|
196
|
+
rej_reasons = " ".join(r for row in w1["rejected"] for r in row["reasons"])
|
|
197
|
+
deferred_svs = {d["schema_version"] for d in w1["deferred"]}
|
|
198
|
+
full = next((e for e in w1["entries"]
|
|
199
|
+
if e["learning_id"] == "0f8c1c2a-4d1e-4abc-9def-000000000002"), None)
|
|
200
|
+
|
|
201
|
+
checks = [
|
|
202
|
+
("valid rows mapped (cardinality > 0)", w1["counts"]["valid"] >= 3),
|
|
203
|
+
("bad-domain row rejected", w1["counts"]["rejected"] >= 1),
|
|
204
|
+
("reject reason names the domain (negative control)", "domain" in rej_reasons),
|
|
205
|
+
("v2 row deferred, not rejected", deferred_svs == {2}),
|
|
206
|
+
("deferred row absent from entries",
|
|
207
|
+
"0f8c1c2a-4d1e-4abc-9def-00000000000a" not in valid_ids),
|
|
208
|
+
("context preserved verbatim", full is not None and full["context"]
|
|
209
|
+
and "4분짜리" in full["context"]),
|
|
210
|
+
("meets_bar -> meets_promotion_bar",
|
|
211
|
+
full is not None and full["classification"]["meets_promotion_bar"] is True),
|
|
212
|
+
("classification.proposed is boolean false",
|
|
213
|
+
full is not None and full["classification"]["proposed"] is False),
|
|
214
|
+
("provenance carries user_email (worklist-only PII)",
|
|
215
|
+
full is not None and full["_provenance"]["user_email"] == "alice@day1company.co.kr"),
|
|
216
|
+
("provenance keeps both created and received_at",
|
|
217
|
+
full is not None and full["_provenance"]["created"] != full["_provenance"]["received_at"]),
|
|
218
|
+
("ledger dedup flags a known learning_id", w1["counts"]["duplicates"] == 1),
|
|
219
|
+
("deterministic (same input -> identical output)",
|
|
220
|
+
json.dumps(w1, ensure_ascii=False) == json.dumps(w2, ensure_ascii=False)),
|
|
221
|
+
]
|
|
222
|
+
failed = [name for name, ok in checks if not ok]
|
|
223
|
+
if failed:
|
|
224
|
+
for name in failed:
|
|
225
|
+
print(f"ingest-learnings-export --self-test: FAIL: {name}", file=sys.stderr)
|
|
226
|
+
sys.exit(1)
|
|
227
|
+
print(f"ingest-learnings-export --self-test: OK ({len(checks)} intake checks)")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def main():
|
|
231
|
+
ap = argparse.ArgumentParser(description="Map a learnings export to ledger candidates.")
|
|
232
|
+
ap.add_argument("export", nargs="?", default="-",
|
|
233
|
+
help="learnings-export JSON file ('-' or omitted = stdin)")
|
|
234
|
+
ap.add_argument("--out", default=None, help="write the worklist here (default: stdout)")
|
|
235
|
+
ap.add_argument("--self-test", action="store_true",
|
|
236
|
+
help="run the intake self-test against the fixture and exit")
|
|
237
|
+
args = ap.parse_args()
|
|
238
|
+
|
|
239
|
+
if args.self_test:
|
|
240
|
+
_self_test()
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
if args.export == "-":
|
|
244
|
+
raw = sys.stdin.read()
|
|
245
|
+
else:
|
|
246
|
+
try:
|
|
247
|
+
raw = pathlib.Path(args.export).read_text(encoding="utf-8")
|
|
248
|
+
except OSError as e:
|
|
249
|
+
die(f"cannot read export {args.export!r}: {e}")
|
|
250
|
+
try:
|
|
251
|
+
export = json.loads(raw)
|
|
252
|
+
except json.JSONDecodeError as e:
|
|
253
|
+
die(f"export is not valid JSON: {e}")
|
|
254
|
+
run(export, args.out)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
if __name__ == "__main__":
|
|
258
|
+
main()
|
package/scripts/install.sh
CHANGED
|
@@ -23,6 +23,9 @@ set -euo pipefail
|
|
|
23
23
|
# or env var. Detach stdin so no child (the codex-helm dry-run, pip, git) can
|
|
24
24
|
# block forever on an inherited idle stdin — that is what hangs an install under
|
|
25
25
|
# CI, pipes, and background runs, where stdin stays open but never delivers.
|
|
26
|
+
# `learn` is the one subcommand whose payload IS stdin, so keep the caller's on
|
|
27
|
+
# fd 3 first and hand it back only there; every other path still sees /dev/null.
|
|
28
|
+
exec 3<&0 2>/dev/null || exec 3</dev/null # tolerate a caller that closed fd 0
|
|
26
29
|
exec </dev/null
|
|
27
30
|
|
|
28
31
|
# Resolve this script through symlinks before locating the package: npm links the
|
|
@@ -562,6 +565,16 @@ PY
|
|
|
562
565
|
else
|
|
563
566
|
log "note: managed venv/textual unavailable (numbered-prompt fallback applies)"
|
|
564
567
|
fi
|
|
568
|
+
# A file this installer executes but never ships is invisible from a clone and
|
|
569
|
+
# fatal on npm, so the payload gate runs wherever it exists (maintainer-side).
|
|
570
|
+
if [ -x "$REPO/scripts/check-package.sh" ]; then
|
|
571
|
+
if "$REPO/scripts/check-package.sh" >/dev/null 2>&1; then
|
|
572
|
+
info "npm payload OK"
|
|
573
|
+
else
|
|
574
|
+
log "npm payload incomplete; run scripts/check-package.sh"
|
|
575
|
+
fail=1
|
|
576
|
+
fi
|
|
577
|
+
fi
|
|
565
578
|
# Repo-internal mirror parity is a maintainer gate; only meaningful from a clone.
|
|
566
579
|
if [ -d "$REPO/ko" ] && [ -x "$REPO/scripts/check-parity.sh" ]; then
|
|
567
580
|
if "$REPO/scripts/check-parity.sh" >/dev/null 2>&1; then info "repo mirror parity OK"; else log "repo mirror parity FAILED"; fail=1; fi
|
|
@@ -688,6 +701,9 @@ agent-bios — deploy the Claude/Codex instruction SSOT into $HOME (by copy).
|
|
|
688
701
|
agent-bios install deploy into this environment (backs up + verifies)
|
|
689
702
|
agent-bios onboard interactive domain selection + packaged install + activation canary
|
|
690
703
|
agent-bios verify check the deployed state matches the source
|
|
704
|
+
agent-bios learn submit a session learning (reads the JSON record on
|
|
705
|
+
stdin; this is what the learn! flow calls, and it
|
|
706
|
+
works from any directory, unlike a repo-relative path)
|
|
691
707
|
agent-bios status show what is installed and where
|
|
692
708
|
agent-bios update git pull + reinstall (clone), or print the npm update line
|
|
693
709
|
agent-bios uninstall remove deployed files and the zsh hook
|
|
@@ -713,6 +729,18 @@ EOF
|
|
|
713
729
|
# ---- dispatch ------------------------------------------------------------
|
|
714
730
|
CMD="${1:-help}"
|
|
715
731
|
if [ $# -gt 0 ]; then shift; fi
|
|
732
|
+
|
|
733
|
+
# `learn` forwards its arguments and stdin straight to the collector, so it must
|
|
734
|
+
# bypass the flag parser below (which rejects anything it does not know). This
|
|
735
|
+
# subcommand is the only PATH-reachable entry to capture: the corpus guide used
|
|
736
|
+
# to invoke scripts/collect-learning.py relative to the cwd, which works from a
|
|
737
|
+
# clone and silently fails for every other install.
|
|
738
|
+
if [ "$CMD" = "learn" ]; then
|
|
739
|
+
collector="$REPO/scripts/collect-learning.py"
|
|
740
|
+
[ -f "$collector" ] || { log "learn: collector missing at $collector"; exit 1; }
|
|
741
|
+
exec python3 "$collector" "$@" <&3
|
|
742
|
+
fi
|
|
743
|
+
|
|
716
744
|
WITH=""
|
|
717
745
|
DOMAINS_ARG=""
|
|
718
746
|
DOMAINS_SET=0
|
|
@@ -13,7 +13,15 @@ bundle. Per-domain opt-in means a promotion into a package the user did NOT
|
|
|
13
13
|
install must NOT trigger removal — that would silently lose the learning. When
|
|
14
14
|
unsure, KEEP (a kept duplicate is redundant; a wrong removal is data loss).
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
HOW THAT RULE IS ENFORCED (contract v2): audience metadata SELECTS candidates,
|
|
17
|
+
presence in the deployed corpus AUTHORIZES the delete. Metadata is a build-time
|
|
18
|
+
projection — it cannot see that this user runs a package or version whose bundle
|
|
19
|
+
never received the promoted bullet — so it is never the authority for an
|
|
20
|
+
irreversible act. Every uncertainty resolves to KEEP: a foreign package we
|
|
21
|
+
cannot confirm, an audience miss, an anchor absent from the corpus, a v1 record
|
|
22
|
+
with no anchor to verify.
|
|
23
|
+
|
|
24
|
+
Candidate selection (mirrors scripts/assemble.py `kept`): full install (original
|
|
17
25
|
single-zone / no selection) -> everything installed; universal tier (core/infra)
|
|
18
26
|
-> always installed; a domain key -> installed iff in the user's selection;
|
|
19
27
|
anything else (env-personal / unclassified / unknown) -> KEEP.
|
|
@@ -31,6 +39,9 @@ import shutil
|
|
|
31
39
|
import sys
|
|
32
40
|
import time
|
|
33
41
|
|
|
42
|
+
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
|
|
43
|
+
import pkgid # noqa: E402 (sibling module; scripts/ is a flat toolbox)
|
|
44
|
+
|
|
34
45
|
REPO = pathlib.Path(__file__).resolve().parent.parent
|
|
35
46
|
MANIFEST = REPO / "config" / "promotions.json"
|
|
36
47
|
UNIVERSAL_TIERS = frozenset({"core", "infra"}) # == assemble.audience UNIVERSAL
|
|
@@ -86,6 +97,59 @@ def make_in_bundle(full, selection):
|
|
|
86
97
|
return in_bundle
|
|
87
98
|
|
|
88
99
|
|
|
100
|
+
def corpus_surfaces(home):
|
|
101
|
+
"""Files and dirs that hold the DEPLOYED corpus for this host.
|
|
102
|
+
|
|
103
|
+
Deliberately excludes `personal/` — the user's own copy of a promoted
|
|
104
|
+
learning quotes the same lesson, so searching it would find the very thing
|
|
105
|
+
we are deciding whether to delete and always answer yes.
|
|
106
|
+
"""
|
|
107
|
+
texts = [home / "central" / "bundle.md", # packaged install
|
|
108
|
+
home / "CLAUDE.md", home / "AGENTS.md"] # full install (entry is the corpus)
|
|
109
|
+
dirs = [home / "central" / d for d in ("guides", "hooks", "agents")]
|
|
110
|
+
dirs += [home / d for d in ("guides", "hooks", "agents")]
|
|
111
|
+
return texts, dirs
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def corpus_text(path, collect=None):
|
|
115
|
+
"""A corpus file's text with the user's personal region removed.
|
|
116
|
+
|
|
117
|
+
On codex the personal copy lives INSIDE AGENTS.md, and a promoted bullet is
|
|
118
|
+
written FROM the user's lesson — so its anchor can legitimately appear in
|
|
119
|
+
their own bullet. Searching the region would let a personal copy authorize
|
|
120
|
+
its own deletion. Strip it before matching.
|
|
121
|
+
"""
|
|
122
|
+
body = path.read_text(encoding="utf-8", errors="replace")
|
|
123
|
+
if collect is None:
|
|
124
|
+
return body
|
|
125
|
+
start, end = getattr(collect, "PERSONAL_START", None), getattr(collect, "PERSONAL_END", None)
|
|
126
|
+
if start and end and start in body:
|
|
127
|
+
pre, rest = body.split(start, 1)
|
|
128
|
+
return pre + (rest.split(end, 1)[1] if end in rest else "")
|
|
129
|
+
return body
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def placed_here(home, anchor, collect=None):
|
|
133
|
+
"""Is the promoted item REALLY in this user's deployed corpus?
|
|
134
|
+
|
|
135
|
+
This is the authorization for an irreversible delete, so it asks the
|
|
136
|
+
filesystem rather than trusting build-time audience metadata, which is a
|
|
137
|
+
projection that goes stale the moment packages or versions diverge. A whole
|
|
138
|
+
guide/hook/agent placement is a deployed file; a bullet placement is its
|
|
139
|
+
anchor appearing in the deployed corpus text.
|
|
140
|
+
"""
|
|
141
|
+
if not isinstance(anchor, str) or not anchor:
|
|
142
|
+
return False
|
|
143
|
+
texts, dirs = corpus_surfaces(home)
|
|
144
|
+
for d in dirs:
|
|
145
|
+
if (d / anchor).is_file():
|
|
146
|
+
return True
|
|
147
|
+
for f in texts:
|
|
148
|
+
if f.is_file() and anchor in corpus_text(f, collect):
|
|
149
|
+
return True
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
|
|
89
153
|
def backup(path):
|
|
90
154
|
shutil.copy2(path, path.with_suffix(path.suffix + f".bak-migrate-{time.strftime('%Y%m%d-%H%M%S')}"))
|
|
91
155
|
|
|
@@ -202,15 +266,29 @@ def migrate(home, host, promotions, in_bundle, collect, dry=False, corpus_loaded
|
|
|
202
266
|
if isinstance(lid, str):
|
|
203
267
|
local_ids.add(lid)
|
|
204
268
|
|
|
205
|
-
|
|
269
|
+
# Metadata SELECTS candidates; presence in the deployed corpus AUTHORIZES the
|
|
270
|
+
# delete (contract v2 §4). Audience metadata is a build-time projection: it
|
|
271
|
+
# cannot see that this user is on a package or version whose bundle never
|
|
272
|
+
# received the promoted bullet, and acting on it alone loses the learning.
|
|
273
|
+
# Every uncertainty below resolves to KEEP.
|
|
274
|
+
remove_ids, kept_not_in_bundle, kept_not_placed, kept_foreign_pkg = set(), 0, 0, 0
|
|
206
275
|
for p in promotions:
|
|
207
276
|
lid = p["learning_id"]
|
|
208
277
|
if lid not in local_ids:
|
|
209
278
|
continue # not held locally (never captured here, or already migrated)
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
279
|
+
pid = pkgid.resolve(p)
|
|
280
|
+
if pid != pkgid.CORE:
|
|
281
|
+
# Stage 3 resolves a package selection; until then the only package
|
|
282
|
+
# whose composition we can confirm is core.
|
|
283
|
+
kept_foreign_pkg += 1
|
|
284
|
+
continue
|
|
285
|
+
if not in_bundle(p.get("tier"), p.get("domains", [])):
|
|
286
|
+
kept_not_in_bundle += 1 # promoted but not in THIS user's audience
|
|
287
|
+
continue
|
|
288
|
+
if not placed_here(home, p.get("anchor"), collect):
|
|
289
|
+
kept_not_placed += 1 # audience says yes, the corpus does not have it
|
|
290
|
+
continue
|
|
291
|
+
remove_ids.add(lid)
|
|
214
292
|
|
|
215
293
|
# Prune PROSE first, jsonl second: the gate keys off ids still in the jsonl
|
|
216
294
|
# (line ~"if lid not in local_ids: continue"), so if a prose write fails, the
|
|
@@ -223,7 +301,8 @@ def migrate(home, host, promotions, in_bundle, collect, dry=False, corpus_loaded
|
|
|
223
301
|
jsonl_removed = prune_jsonl(jsonl, remove_ids, dry)
|
|
224
302
|
|
|
225
303
|
return {"removed": len(remove_ids), "jsonl_removed": jsonl_removed,
|
|
226
|
-
"prose_removed": prose_removed, "kept_not_in_bundle": kept_not_in_bundle
|
|
304
|
+
"prose_removed": prose_removed, "kept_not_in_bundle": kept_not_in_bundle,
|
|
305
|
+
"kept_not_placed": kept_not_placed, "kept_foreign_package": kept_foreign_pkg}
|
|
227
306
|
|
|
228
307
|
|
|
229
308
|
def main():
|
|
@@ -308,13 +387,21 @@ def _self_test():
|
|
|
308
387
|
L = {"core": "0f8c1c2a-4d1e-4abc-9def-0000000000a1",
|
|
309
388
|
"bb": "0f8c1c2a-4d1e-4abc-9def-0000000000b2",
|
|
310
389
|
"off": "0f8c1c2a-4d1e-4abc-9def-0000000000c3"}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
390
|
+
A = {"core": "universal corpus rule", "bb": "builder corpus rule",
|
|
391
|
+
"off": "office corpus rule"}
|
|
392
|
+
promos = [{"learning_id": L["core"], "anchor": A["core"], "tier": "core", "domains": []},
|
|
393
|
+
{"learning_id": L["bb"], "anchor": A["bb"], "tier": "domain", "domains": ["builder-base"]},
|
|
394
|
+
{"learning_id": L["off"], "anchor": A["off"], "tier": "domain", "domains": ["office-work"]}]
|
|
395
|
+
|
|
396
|
+
def seed_claude(placed=("core", "bb", "off")):
|
|
397
|
+
"""`placed` = which promoted anchors this user's DEPLOYED corpus actually
|
|
398
|
+
carries. Deletion is authorized by that, not by the audience metadata, so
|
|
399
|
+
a fixture without a corpus would let a metadata-only bug pass."""
|
|
316
400
|
home = pathlib.Path(tempfile.mkdtemp(prefix="migrate-selftest-"))
|
|
317
401
|
(home / "personal").mkdir(parents=True)
|
|
402
|
+
(home / "central").mkdir(parents=True)
|
|
403
|
+
(home / "central" / "bundle.md").write_text(
|
|
404
|
+
"# bundle\n" + "".join(f"- {A[k]}\n" for k in placed), encoding="utf-8")
|
|
318
405
|
jsonl = home / "personal" / "learnings.jsonl"
|
|
319
406
|
with open(jsonl, "w", encoding="utf-8") as f:
|
|
320
407
|
for k, dom in (("core", "core"), ("bb", "builder-base"), ("off", "office-work")):
|
|
@@ -350,6 +437,35 @@ def _self_test():
|
|
|
350
437
|
s2 = migrate(home, "claude", promos, in_bundle, collect)
|
|
351
438
|
checks.append(("idempotent re-run", s2["removed"] == 0))
|
|
352
439
|
|
|
440
|
+
# 1b) CONTRAST CONTROL for the v2 authorization. Same audience metadata as
|
|
441
|
+
# above — builder-base IS selected — but this user's deployed corpus does
|
|
442
|
+
# NOT carry the bullet (a package/version whose bundle never got it). The
|
|
443
|
+
# metadata-only rule deletes here and loses the learning; presence keeps.
|
|
444
|
+
# If placed_here() ever returns True unconditionally, this check fails.
|
|
445
|
+
home = seed_claude(placed=("core",))
|
|
446
|
+
s = migrate(home, "claude", promos, make_in_bundle(False, ["builder-base"]), collect)
|
|
447
|
+
checks.append(("audience says yes but corpus lacks it -> KEEP",
|
|
448
|
+
s["removed"] == 1 and s["kept_not_placed"] == 1
|
|
449
|
+
and L["bb"] in local_ids(home) and md_has(home, L["bb"])))
|
|
450
|
+
|
|
451
|
+
# 1c) A promotion from a package whose composition cannot be confirmed is
|
|
452
|
+
# never acted on (stage 3 resolves package selections).
|
|
453
|
+
home = seed_claude()
|
|
454
|
+
foreign = [dict(promos[1], package_id="@acme/security")]
|
|
455
|
+
s = migrate(home, "claude", foreign, make_in_bundle(True, ()), collect)
|
|
456
|
+
checks.append(("foreign package -> KEEP",
|
|
457
|
+
s["removed"] == 0 and s["kept_foreign_package"] == 1))
|
|
458
|
+
|
|
459
|
+
# 1d) The personal copy must not authorize its own deletion: on codex the
|
|
460
|
+
# copy lives inside AGENTS.md, so the anchor can appear there legitimately.
|
|
461
|
+
ahome = pathlib.Path(tempfile.mkdtemp(prefix="migrate-selfauth-"))
|
|
462
|
+
(ahome / "AGENTS.md").write_text(
|
|
463
|
+
f"# AGENTS.md\n{collect.PERSONAL_START}\n- {A['bb']}\n{collect.PERSONAL_END}\n",
|
|
464
|
+
encoding="utf-8")
|
|
465
|
+
checks.append(("personal region cannot authorize its own delete",
|
|
466
|
+
placed_here(ahome, A["bb"], collect) is False
|
|
467
|
+
and placed_here(ahome, A["bb"], None) is True))
|
|
468
|
+
|
|
353
469
|
# 2) Full (non-packaged) install: everything in bundle -> all removed.
|
|
354
470
|
home = seed_claude()
|
|
355
471
|
s = migrate(home, "claude", promos, make_in_bundle(True, ()), collect)
|
|
@@ -365,6 +481,7 @@ def _self_test():
|
|
|
365
481
|
# 4) codex host: seed the AGENTS.md region, migrate prunes it there.
|
|
366
482
|
chome = pathlib.Path(tempfile.mkdtemp(prefix="migrate-codex-"))
|
|
367
483
|
(chome / "personal").mkdir(parents=True)
|
|
484
|
+
(chome / "AGENTS.md").write_text(f"# AGENTS.md\n- {A['bb']}\n", encoding="utf-8")
|
|
368
485
|
with open(chome / "personal" / "learnings.jsonl", "w", encoding="utf-8") as f:
|
|
369
486
|
r = rec(L["bb"], "builder-base")
|
|
370
487
|
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
package/scripts/pkgid.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Canonical package identity for the domain-package ecosystem (contract v2).
|
|
3
|
+
|
|
4
|
+
A package is `@scope/name` — **unversioned**, and independent of the domains it
|
|
5
|
+
declares. A domain's canonical identity is the pair `(package_id, domain)`, so a
|
|
6
|
+
bare domain string is only meaningful relative to a package.
|
|
7
|
+
|
|
8
|
+
`CORE` is the built-in engine manifest. A manifest or record that carries no
|
|
9
|
+
package id means `CORE`, permanently: that reservation is what keeps every
|
|
10
|
+
artifact written before v2 — selections, promotions, ledger placements — valid
|
|
11
|
+
with no migration.
|
|
12
|
+
|
|
13
|
+
Spec: design/adapter-split/ECOSYSTEM-ARCHITECTURE.md, "Foundational contract v2".
|
|
14
|
+
"""
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
CORE = "@agent-bios/core"
|
|
18
|
+
|
|
19
|
+
_SEG = r"[a-z0-9]+(?:-[a-z0-9]+)*"
|
|
20
|
+
PATTERN = re.compile(rf"^@{_SEG}/{_SEG}$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_valid(pid):
|
|
24
|
+
"""True for a well-formed package id. Callers validate before deriving paths."""
|
|
25
|
+
return isinstance(pid, str) and PATTERN.match(pid) is not None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def resolve(obj, key="package_id"):
|
|
29
|
+
"""The package id an object declares, or CORE when it declares none.
|
|
30
|
+
|
|
31
|
+
Absent means CORE — never guess from context, and never treat a present but
|
|
32
|
+
malformed id as absent: that would silently promote a typo to core's
|
|
33
|
+
authority. Validate with is_valid() where the value is first accepted.
|
|
34
|
+
"""
|
|
35
|
+
if not isinstance(obj, dict):
|
|
36
|
+
return CORE
|
|
37
|
+
pid = obj.get(key)
|
|
38
|
+
return CORE if pid is None else pid
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def segments(pid):
|
|
42
|
+
"""('scope', 'name') for deriving deploy paths. Only call on a valid id."""
|
|
43
|
+
if not is_valid(pid):
|
|
44
|
+
raise ValueError(f"not a package id: {pid!r}")
|
|
45
|
+
return tuple(pid[1:].split("/", 1))
|