agent-bios 0.9.5 → 0.9.7
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/settings.template.json +15 -0
- package/config/domains.json +1 -0
- package/config/promotions.json +3 -1
- package/package.json +3 -4
- package/scripts/assemble.py +1 -1
- package/scripts/canary.sh +38 -2
- package/scripts/check-domains.py +55 -1
- package/scripts/install.sh +39 -0
- package/scripts/migrate-learnings.py +129 -12
- package/scripts/pkgid.py +45 -0
- package/claude/settings.json +0 -60
- package/scripts/build-promotions.py +0 -183
- package/scripts/ingest-learnings-export.py +0 -258
package/config/domains.json
CHANGED
package/config/promotions.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-bios",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.7",
|
|
4
4
|
"releaseDate": "2026-07-26",
|
|
5
5
|
"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`.",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"claude/CLAUDE.md",
|
|
11
|
-
"claude/settings.json",
|
|
11
|
+
"claude/settings.template.json",
|
|
12
12
|
"claude/guides/",
|
|
13
13
|
"claude/hooks/",
|
|
14
14
|
"claude/agents/",
|
|
@@ -22,11 +22,10 @@
|
|
|
22
22
|
"config/promotions.json",
|
|
23
23
|
"shell/agent-launch.zsh",
|
|
24
24
|
"scripts/agent-launch.py",
|
|
25
|
+
"scripts/pkgid.py",
|
|
25
26
|
"scripts/assemble.py",
|
|
26
27
|
"scripts/check-domains.py",
|
|
27
28
|
"scripts/canary.sh",
|
|
28
|
-
"scripts/build-promotions.py",
|
|
29
|
-
"scripts/ingest-learnings-export.py",
|
|
30
29
|
"scripts/check-parity.sh",
|
|
31
30
|
"scripts/check-prompting-targets.sh",
|
|
32
31
|
"scripts/check-learning.py",
|
package/scripts/assemble.py
CHANGED
|
@@ -299,7 +299,7 @@ def main():
|
|
|
299
299
|
rewrite=(f"{CLAUDE_VAR}/guides/", f"{CLAUDE_VAR}/central/guides/"), dry=dry)
|
|
300
300
|
copy_filtered(REPO / "claude" / "hooks", hooks, central / "hooks", dry=dry)
|
|
301
301
|
copy_filtered(REPO / "claude" / "agents", agents, central / "agents", dry=dry)
|
|
302
|
-
merge_settings(claude_dir, hooks, REPO / "claude" / "settings.json", dry=dry)
|
|
302
|
+
merge_settings(claude_dir, hooks, REPO / "claude" / "settings.template.json", dry=dry)
|
|
303
303
|
entry_state = seed_entry(claude_dir, monolith, dry=dry)
|
|
304
304
|
|
|
305
305
|
merge_codex(codex_dir, codex_bundle, dry=dry)
|
package/scripts/canary.sh
CHANGED
|
@@ -3,12 +3,29 @@
|
|
|
3
3
|
# session — file presence cannot detect a declined @import approval or a
|
|
4
4
|
# broken entry import line, so this asks a headless session to echo the
|
|
5
5
|
# bundle's rev marker back.
|
|
6
|
-
#
|
|
6
|
+
#
|
|
7
|
+
# Exit: 0 loading | 1 NOT loading | 3 nothing to probe / cannot probe.
|
|
8
|
+
#
|
|
9
|
+
# The 1-vs-3 split is the point. A canary that answers "not loading" when it
|
|
10
|
+
# never ran a probe sends you to debug imports while the real cause is that
|
|
11
|
+
# there is no packaged bundle here, or that the CLI is not authenticated. Only
|
|
12
|
+
# a real probe that really replied may return 1.
|
|
7
13
|
set -u
|
|
8
14
|
CLAUDE_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
|
|
9
15
|
BUNDLE="$CLAUDE_DIR/central/bundle.md"
|
|
10
16
|
|
|
11
|
-
|
|
17
|
+
# A full (non-packaged) install has no central bundle by design — the entry file
|
|
18
|
+
# IS the corpus. That is not a failure, and telling the user to reinstall with
|
|
19
|
+
# --domains would silently narrow their corpus to fix a non-problem.
|
|
20
|
+
if [ ! -f "$BUNDLE" ]; then
|
|
21
|
+
if [ -f "$CLAUDE_DIR/CLAUDE.md" ]; then
|
|
22
|
+
echo "CANARY N/A: full install — no central bundle to probe (packaged mode creates one)"
|
|
23
|
+
exit 3
|
|
24
|
+
fi
|
|
25
|
+
echo "CANARY FAIL: no corpus deployed at $CLAUDE_DIR (run: agent-bios install)"
|
|
26
|
+
exit 1
|
|
27
|
+
fi
|
|
28
|
+
|
|
12
29
|
expected="$(grep -m1 '^agent-bios-bundle-rev: ' "$BUNDLE")"
|
|
13
30
|
[ -n "$expected" ] || { echo "CANARY FAIL: bundle has no rev marker (reassemble with a current assemble.py)"; exit 1; }
|
|
14
31
|
command -v claude >/dev/null 2>&1 || { echo "CANARY SKIP: claude CLI not found — cannot probe activation"; exit 3; }
|
|
@@ -20,6 +37,25 @@ if printf '%s' "$out" | grep -qF "$expected"; then
|
|
|
20
37
|
echo "CANARY PASS: central bundle is loading ($expected)"
|
|
21
38
|
exit 0
|
|
22
39
|
fi
|
|
40
|
+
|
|
41
|
+
# A pre-dispatch refusal produces no model output at all, so the reply carries
|
|
42
|
+
# nothing about activation. Attributing it to a declined import would blame the
|
|
43
|
+
# corpus for an auth or quota problem.
|
|
44
|
+
case "$out" in
|
|
45
|
+
*"Not logged in"*|*"/login"*|*"Invalid API key"*|*"authentication"*|*"Authentication"*)
|
|
46
|
+
echo "CANARY SKIP: claude CLI is not authenticated for this config dir — cannot probe activation"
|
|
47
|
+
echo " probe replied: $(printf '%s' "$out" | head -c 200)"
|
|
48
|
+
exit 3 ;;
|
|
49
|
+
*"usage limit"*|*"rate limit"*|*"quota"*)
|
|
50
|
+
echo "CANARY SKIP: provider refused the probe (limit) — cannot probe activation"
|
|
51
|
+
echo " probe replied: $(printf '%s' "$out" | head -c 200)"
|
|
52
|
+
exit 3 ;;
|
|
53
|
+
esac
|
|
54
|
+
if [ -z "$out" ]; then
|
|
55
|
+
echo "CANARY SKIP: probe produced no output — cannot tell activation from a failed dispatch"
|
|
56
|
+
exit 3
|
|
57
|
+
fi
|
|
58
|
+
|
|
23
59
|
echo "CANARY FAIL: central bundle is NOT loading in live sessions."
|
|
24
60
|
echo " expected marker: $expected"
|
|
25
61
|
echo " probe replied: $(printf '%s' "$out" | head -c 200)"
|
package/scripts/check-domains.py
CHANGED
|
@@ -32,6 +32,9 @@ import pathlib
|
|
|
32
32
|
import re
|
|
33
33
|
import sys
|
|
34
34
|
|
|
35
|
+
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
|
|
36
|
+
import pkgid # noqa: E402 (sibling module; scripts/ is a flat toolbox, not a package)
|
|
37
|
+
|
|
35
38
|
REPO = pathlib.Path(__file__).resolve().parent.parent
|
|
36
39
|
MANIFEST = REPO / "config" / "domains.json"
|
|
37
40
|
MONOLITH = REPO / "claude" / "CLAUDE.md"
|
|
@@ -87,6 +90,16 @@ def run_gate(manifest, bullets, repo=REPO):
|
|
|
87
90
|
if not tiers or not domains_reg:
|
|
88
91
|
errors.append("registry: tiers/domains registry empty")
|
|
89
92
|
|
|
93
|
+
errors += check_settings_template(manifest, repo)
|
|
94
|
+
|
|
95
|
+
# -- 0. package identity ------------------------------------------------
|
|
96
|
+
# Absent means core (the reservation that keeps pre-v2 artifacts valid), but
|
|
97
|
+
# a PRESENT malformed id must fail rather than fall back — silently treating
|
|
98
|
+
# a typo as core would hand it the engine's unconditional-injection audience.
|
|
99
|
+
pid = pkgid.resolve(manifest)
|
|
100
|
+
if not pkgid.is_valid(pid):
|
|
101
|
+
errors.append(f"package_id: {pid!r} is not @scope/name (lowercase, hyphen-separated)")
|
|
102
|
+
|
|
90
103
|
def check_registry(entry, ctx):
|
|
91
104
|
if entry.get("tier") not in tiers:
|
|
92
105
|
errors.append(f"{ctx}: tier {entry.get('tier')!r} not in registry")
|
|
@@ -185,6 +198,33 @@ def run_gate(manifest, bullets, repo=REPO):
|
|
|
185
198
|
return errors, sizes
|
|
186
199
|
|
|
187
200
|
|
|
201
|
+
def check_settings_template(manifest, repo=REPO):
|
|
202
|
+
"""Every hook registered in the shipped settings template must be a hook the
|
|
203
|
+
manifest declares.
|
|
204
|
+
|
|
205
|
+
`merge_settings` only re-adds template entries whose command names a
|
|
206
|
+
manifest-declared hook (assemble.py:167-169), so an entry for anything else
|
|
207
|
+
is silently inert — it looks registered, ships to every user, and never runs.
|
|
208
|
+
That is how a machine-local registration ends up committed in a
|
|
209
|
+
deploy-managed file. Make it invalid instead of asking people to remember.
|
|
210
|
+
"""
|
|
211
|
+
errors = []
|
|
212
|
+
spath = repo / "claude" / "settings.template.json"
|
|
213
|
+
if not spath.is_file():
|
|
214
|
+
return ["settings: claude/settings.template.json missing"]
|
|
215
|
+
names = set(manifest.get("hooks", {}))
|
|
216
|
+
entries = [(ev, h.get("command", ""))
|
|
217
|
+
for ev, evs in json.loads(spath.read_text(encoding="utf-8")).get("hooks", {}).items()
|
|
218
|
+
for en in evs for h in en.get("hooks", [])]
|
|
219
|
+
if not entries:
|
|
220
|
+
errors.append("non-vacuity: settings template registers no hooks")
|
|
221
|
+
for ev, cmd in entries:
|
|
222
|
+
if not any(("/hooks/" + n) in cmd for n in names):
|
|
223
|
+
errors.append(f"settings: {ev} hook {cmd!r} names no manifest hook "
|
|
224
|
+
f"(known: {sorted(names)}) — it would never deploy")
|
|
225
|
+
return errors
|
|
226
|
+
|
|
227
|
+
|
|
188
228
|
def self_test(manifest, bullets):
|
|
189
229
|
"""Negative controls: each mutation MUST make the gate fail."""
|
|
190
230
|
import copy
|
|
@@ -205,8 +245,22 @@ def self_test(manifest, bullets):
|
|
|
205
245
|
muts.append((f"unclaimed guide {first_guide}", m4, bullets))
|
|
206
246
|
b5 = bullets + ["- a brand new bullet the manifest never heard of"]
|
|
207
247
|
muts.append(("bullet added without manifest entry", copy.deepcopy(manifest), b5))
|
|
248
|
+
m6 = copy.deepcopy(manifest)
|
|
249
|
+
m6["package_id"] = "@Acme/Builder" # uppercase is not a legal segment
|
|
250
|
+
muts.append(("malformed package_id", m6, bullets))
|
|
251
|
+
m7 = copy.deepcopy(manifest)
|
|
252
|
+
m7["hooks"] = {"renamed-hook.py": {"tier": "core", "domains": []}}
|
|
253
|
+
muts.append(("settings registers a hook the manifest does not declare", m7, bullets))
|
|
254
|
+
|
|
255
|
+
# Targeted: the settings rule itself must speak, not just some neighbouring
|
|
256
|
+
# check tripping on the same mutation.
|
|
257
|
+
import copy as _c
|
|
258
|
+
m_off = _c.deepcopy(manifest)
|
|
259
|
+
m_off["hooks"] = {"renamed-hook.py": {"tier": "core", "domains": []}}
|
|
260
|
+
settings_errs = [e for e in check_settings_template(m_off) if e.startswith("settings:")]
|
|
261
|
+
print(f"self-test [{'CAUGHT' if settings_errs else 'MISSED'}] settings rule fires on its own")
|
|
208
262
|
|
|
209
|
-
failed = []
|
|
263
|
+
failed = [] if settings_errs else ["settings rule fires on its own"]
|
|
210
264
|
for name, mm, bb in muts:
|
|
211
265
|
errs, _ = run_gate(mm, bb)
|
|
212
266
|
if not errs:
|
package/scripts/install.sh
CHANGED
|
@@ -516,6 +516,10 @@ verify_present() { if [ -f "$1" ]; then return 0; else log "missing $1"; return
|
|
|
516
516
|
|
|
517
517
|
cmd_verify() {
|
|
518
518
|
local fail=0 gp
|
|
519
|
+
if [ ! -d "$REPO/.git" ] && [ "$(drift_state)" = "drift" ]; then
|
|
520
|
+
log "deploy drift: deployed $(deployed_version), package $(source_version) — run: agent-bios install"
|
|
521
|
+
fail=1
|
|
522
|
+
fi
|
|
519
523
|
if packaged_mode; then
|
|
520
524
|
# Packaged: corpus surfaces are selection-derived, not repo-identical.
|
|
521
525
|
# The entry file is user-owned — READ-check the import line, never rewrite.
|
|
@@ -664,6 +668,33 @@ cmd_onboard() {
|
|
|
664
668
|
}
|
|
665
669
|
}
|
|
666
670
|
|
|
671
|
+
# ---- deploy-chain drift ---------------------------------------------------
|
|
672
|
+
# A repo edit is inert until it is published AND globally installed AND
|
|
673
|
+
# deployed. The middle two are checkable: the installer stamps the package
|
|
674
|
+
# version it deployed into the state dir, so a stamp older than the package now
|
|
675
|
+
# running means someone updated the package and never re-deployed. Reading the
|
|
676
|
+
# registry cannot see this, which is why it went unnoticed three times.
|
|
677
|
+
json_field() { # $1=file $2=key
|
|
678
|
+
[ -f "$1" ] || return 1
|
|
679
|
+
python3 -c 'import json,sys
|
|
680
|
+
try:
|
|
681
|
+
v=json.load(open(sys.argv[1])).get(sys.argv[2])
|
|
682
|
+
except Exception:
|
|
683
|
+
sys.exit(1)
|
|
684
|
+
sys.exit(0) if v is None else print(v)' "$1" "$2" 2>/dev/null
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
deployed_version() { json_field "$STATE_DIR/version.json" version; }
|
|
688
|
+
source_version() { json_field "$REPO/package.json" version; }
|
|
689
|
+
|
|
690
|
+
drift_state() { # prints: match | drift | unknown
|
|
691
|
+
local d s
|
|
692
|
+
d="$(deployed_version)" || { echo unknown; return; }
|
|
693
|
+
s="$(source_version)" || { echo unknown; return; }
|
|
694
|
+
[ -n "$d" ] && [ -n "$s" ] || { echo unknown; return; }
|
|
695
|
+
[ "$d" = "$s" ] && echo match || echo drift
|
|
696
|
+
}
|
|
697
|
+
|
|
667
698
|
cmd_status() {
|
|
668
699
|
local version p
|
|
669
700
|
if [ -d "$REPO/.git" ]; then
|
|
@@ -676,6 +707,11 @@ cmd_status() {
|
|
|
676
707
|
log "agent-bios"
|
|
677
708
|
fi
|
|
678
709
|
log " source: $REPO"
|
|
710
|
+
case "$(drift_state)" in
|
|
711
|
+
match) info "deployed version $(deployed_version) (matches this package)" ;;
|
|
712
|
+
drift) log "DRIFT deployed $(deployed_version) but this package is $(source_version) — run: agent-bios install" ;;
|
|
713
|
+
unknown) info "deployed version unknown (no state marker yet)" ;;
|
|
714
|
+
esac
|
|
679
715
|
for p in "$CLAUDE_DIR/CLAUDE.md" "$CODEX_DIR/AGENTS.md" "$BIN_DIR/agent-launch" \
|
|
680
716
|
"$LAUNCH_DIR/profiles.toml" "$LAUNCH_DIR/shell.zsh"; do
|
|
681
717
|
if [ -e "$p" ]; then info "present $p"; else info "MISSING $p"; fi
|
|
@@ -691,6 +727,9 @@ cmd_update() {
|
|
|
691
727
|
else
|
|
692
728
|
log "Installed as an npm package. Update with:"
|
|
693
729
|
log " npm install -g agent-bios@latest && agent-bios install"
|
|
730
|
+
log "Then confirm what actually landed — right after a publish the cached"
|
|
731
|
+
log "packument can serve the PREVIOUS version at exit 0:"
|
|
732
|
+
log " agent-bios status # must show the version you expected, and no DRIFT"
|
|
694
733
|
fi
|
|
695
734
|
}
|
|
696
735
|
|
|
@@ -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))
|
package/claude/settings.json
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"hooks": {
|
|
3
|
-
"Stop": [
|
|
4
|
-
{
|
|
5
|
-
"matcher": ".*",
|
|
6
|
-
"hooks": [
|
|
7
|
-
{
|
|
8
|
-
"type": "command",
|
|
9
|
-
"command": "/opt/homebrew/bin/node /Users/kangmin/.claude/hooks/collect-session.js"
|
|
10
|
-
}
|
|
11
|
-
]
|
|
12
|
-
}
|
|
13
|
-
],
|
|
14
|
-
"SessionEnd": [
|
|
15
|
-
{
|
|
16
|
-
"matcher": ".*",
|
|
17
|
-
"hooks": [
|
|
18
|
-
{
|
|
19
|
-
"type": "command",
|
|
20
|
-
"command": "/opt/homebrew/bin/node /Users/kangmin/.claude/hooks/collect-session.js"
|
|
21
|
-
}
|
|
22
|
-
]
|
|
23
|
-
}
|
|
24
|
-
],
|
|
25
|
-
"PostToolUse": [
|
|
26
|
-
{
|
|
27
|
-
"matcher": ".*",
|
|
28
|
-
"hooks": [
|
|
29
|
-
{
|
|
30
|
-
"type": "command",
|
|
31
|
-
"command": "/opt/homebrew/bin/node /Users/kangmin/.claude/hooks/collect-session.js"
|
|
32
|
-
}
|
|
33
|
-
]
|
|
34
|
-
}
|
|
35
|
-
],
|
|
36
|
-
"PostToolUseFailure": [
|
|
37
|
-
{
|
|
38
|
-
"matcher": ".*",
|
|
39
|
-
"hooks": [
|
|
40
|
-
{
|
|
41
|
-
"type": "command",
|
|
42
|
-
"command": "/opt/homebrew/bin/node /Users/kangmin/.claude/hooks/collect-session.js"
|
|
43
|
-
}
|
|
44
|
-
]
|
|
45
|
-
}
|
|
46
|
-
],
|
|
47
|
-
"PreToolUse": [
|
|
48
|
-
{
|
|
49
|
-
"matcher": "Bash",
|
|
50
|
-
"hooks": [
|
|
51
|
-
{
|
|
52
|
-
"type": "command",
|
|
53
|
-
"command": "python3 /Users/kangmin/.claude/hooks/tooling-gotchas-hook.py"
|
|
54
|
-
}
|
|
55
|
-
]
|
|
56
|
-
}
|
|
57
|
-
]
|
|
58
|
-
},
|
|
59
|
-
"remoteControlAtStartup": false
|
|
60
|
-
}
|
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Derive the promotion manifest from the ledger (collection loop, Phase 4).
|
|
3
|
-
|
|
4
|
-
`config/promotions.json` names the personal learnings that have been PROMOTED
|
|
5
|
-
into the shared corpus, so the user-side migrate rule can clear the now-absorbed
|
|
6
|
-
personal copy (scripts/migrate-learnings.py). The manifest is DERIVED — never
|
|
7
|
-
hand-edited: a promoted user learning is a ledger entry with `status == placed`
|
|
8
|
-
AND a `learning_id` (the Phase 3 dedup key kept on merge). Session-distill
|
|
9
|
-
entries have no `learning_id`, so they are never in the manifest.
|
|
10
|
-
|
|
11
|
-
F3 hardening (the one silent-loss path in the safety design): the audience a
|
|
12
|
-
promotion belongs to (which users' bundles carry it) is derived from where the
|
|
13
|
-
bullet ACTUALLY landed — the placed entry's `placed_anchor`, resolved against
|
|
14
|
-
`config/domains.json` (the placement source of truth) — NOT the ledger's free
|
|
15
|
-
`domain` tag. A placed+learning_id entry with a missing or unresolvable
|
|
16
|
-
`placed_anchor` FAILS the build/--check, so a promotion can neither ship with a
|
|
17
|
-
wrong audience nor ship without actually landing in the corpus.
|
|
18
|
-
|
|
19
|
-
Manifest shape (no PII — learning_id + the derived audience):
|
|
20
|
-
{ "version": 1, "promotions": [
|
|
21
|
-
{ "learning_id": "<uuid>", "tier": "core|infra|domain", "domains": ["<key>", ...] } ] }
|
|
22
|
-
(`domains` is empty for the universal tiers core/infra.)
|
|
23
|
-
|
|
24
|
-
Release step: regenerate before a push. `--check` fails if the on-disk manifest
|
|
25
|
-
is stale vs the ledger (a check-parity gate). `--self-test` runs the derivation.
|
|
26
|
-
"""
|
|
27
|
-
import argparse
|
|
28
|
-
import json
|
|
29
|
-
import pathlib
|
|
30
|
-
import sys
|
|
31
|
-
|
|
32
|
-
REPO = pathlib.Path(__file__).resolve().parent.parent
|
|
33
|
-
LEDGER = REPO / "design" / "session-distill" / "ledger.json"
|
|
34
|
-
DOMAINS = REPO / "config" / "domains.json"
|
|
35
|
-
MANIFEST = REPO / "config" / "promotions.json"
|
|
36
|
-
FIXTURE = REPO / "design" / "collection-loop" / "fixtures" / "ledger-promote-sample.json"
|
|
37
|
-
MANIFEST_VERSION = 1
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
class ManifestError(Exception):
|
|
41
|
-
pass
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def resolve_audience(anchor, domains_manifest):
|
|
45
|
-
"""(tier, [domains]) for a placed anchor, or None if it does not resolve.
|
|
46
|
-
Bullets match by their `anchor` field; a whole guide/hook/agent matches by
|
|
47
|
-
key. This is the authoritative placement audience (mirrors what assemble.py
|
|
48
|
-
reads to decide who gets the bullet)."""
|
|
49
|
-
for b in domains_manifest.get("bullets", []):
|
|
50
|
-
if b.get("anchor") == anchor:
|
|
51
|
-
return b.get("tier"), list(b.get("domains", []))
|
|
52
|
-
for kind in ("guides", "hooks", "agents"):
|
|
53
|
-
entry = domains_manifest.get(kind, {}).get(anchor)
|
|
54
|
-
if entry is not None:
|
|
55
|
-
return entry.get("tier"), list(entry.get("domains", []))
|
|
56
|
-
return None
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def build_manifest(ledger, domains_manifest):
|
|
60
|
-
"""Ledger + domains manifest -> the promotion manifest. Raises ManifestError
|
|
61
|
-
on a placed+learning_id entry whose placement can't be verified (missing or
|
|
62
|
-
unresolvable placed_anchor) — the F3 silent-loss guard. Sorted by
|
|
63
|
-
learning_id for a stable, deterministic artifact."""
|
|
64
|
-
promotions = []
|
|
65
|
-
for e in ledger.get("entries", []):
|
|
66
|
-
if not isinstance(e, dict):
|
|
67
|
-
continue
|
|
68
|
-
lid = e.get("learning_id")
|
|
69
|
-
if e.get("status") != "placed" or not (isinstance(lid, str) and lid):
|
|
70
|
-
continue
|
|
71
|
-
anchor = e.get("placed_anchor")
|
|
72
|
-
if not anchor:
|
|
73
|
-
raise ManifestError(
|
|
74
|
-
f"placed learning {e.get('id')!r} ({lid}) has no `placed_anchor` — "
|
|
75
|
-
"record where the bullet landed (a config/domains.json anchor/key) "
|
|
76
|
-
"before promoting")
|
|
77
|
-
aud = resolve_audience(anchor, domains_manifest)
|
|
78
|
-
if aud is None:
|
|
79
|
-
raise ManifestError(
|
|
80
|
-
f"placed_anchor {anchor!r} (learning {lid}) does not resolve in "
|
|
81
|
-
"config/domains.json — the promotion has no verifiable placement")
|
|
82
|
-
tier, domains = aud
|
|
83
|
-
promotions.append({"learning_id": lid.lower(), "tier": tier, "domains": domains})
|
|
84
|
-
promotions.sort(key=lambda p: p["learning_id"])
|
|
85
|
-
return {"version": MANIFEST_VERSION, "promotions": promotions}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def render(manifest):
|
|
89
|
-
return json.dumps(manifest, ensure_ascii=False, indent=2) + "\n"
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def _self_test():
|
|
93
|
-
ledger = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
|
94
|
-
# Fixture domains manifest the fixture's placed_anchors resolve against.
|
|
95
|
-
domains = {
|
|
96
|
-
"bullets": [
|
|
97
|
-
{"anchor": "universal placed rule", "tier": "core", "domains": []},
|
|
98
|
-
{"anchor": "builder placed rule", "tier": "domain", "domains": ["builder-base"]},
|
|
99
|
-
],
|
|
100
|
-
"guides": {}, "hooks": {}, "agents": {},
|
|
101
|
-
}
|
|
102
|
-
m = build_manifest(ledger, domains)
|
|
103
|
-
by = {p["learning_id"]: p for p in m["promotions"]}
|
|
104
|
-
a1 = "0f8c1c2a-4d1e-4abc-9def-0000000000a1"
|
|
105
|
-
b2 = "0f8c1c2a-4d1e-4abc-9def-0000000000b2"
|
|
106
|
-
|
|
107
|
-
checks = [
|
|
108
|
-
("only placed+learning_id promoted (cardinality > 0)", set(by) == {a1, b2}),
|
|
109
|
-
("session-distill (no learning_id) excluded",
|
|
110
|
-
all(p["learning_id"] for p in m["promotions"])),
|
|
111
|
-
("incubating excluded",
|
|
112
|
-
"0f8c1c2a-4d1e-4abc-9def-0000000000c1" not in by),
|
|
113
|
-
("audience DERIVED from the anchor, not the ledger domain tag",
|
|
114
|
-
by[a1]["tier"] == "core" and by[a1]["domains"] == []
|
|
115
|
-
and by[b2]["tier"] == "domain" and by[b2]["domains"] == ["builder-base"]),
|
|
116
|
-
("sorted + deterministic",
|
|
117
|
-
[p["learning_id"] for p in m["promotions"]] == sorted(by)
|
|
118
|
-
and render(build_manifest(ledger, domains)) == render(m)),
|
|
119
|
-
]
|
|
120
|
-
|
|
121
|
-
# F3 guards: missing / unresolvable placed_anchor must FAIL.
|
|
122
|
-
def raises(mut):
|
|
123
|
-
led = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
|
124
|
-
mut(led)
|
|
125
|
-
try:
|
|
126
|
-
build_manifest(led, domains)
|
|
127
|
-
return False
|
|
128
|
-
except ManifestError:
|
|
129
|
-
return True
|
|
130
|
-
|
|
131
|
-
checks.append(("missing placed_anchor fails",
|
|
132
|
-
raises(lambda l: l["entries"][0].pop("placed_anchor", None))))
|
|
133
|
-
checks.append(("unresolvable placed_anchor fails",
|
|
134
|
-
raises(lambda l: l["entries"][0].__setitem__("placed_anchor", "no-such-anchor"))))
|
|
135
|
-
|
|
136
|
-
failed = [n for n, ok in checks if not ok]
|
|
137
|
-
if failed:
|
|
138
|
-
for n in failed:
|
|
139
|
-
print(f"build-promotions --self-test: FAIL: {n}", file=sys.stderr)
|
|
140
|
-
sys.exit(1)
|
|
141
|
-
print(f"build-promotions --self-test: OK ({len(checks)} manifest-derivation checks)")
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
def _load_and_build():
|
|
145
|
-
ledger = json.loads(LEDGER.read_text(encoding="utf-8"))
|
|
146
|
-
domains = json.loads(DOMAINS.read_text(encoding="utf-8"))
|
|
147
|
-
try:
|
|
148
|
-
return render(build_manifest(ledger, domains))
|
|
149
|
-
except ManifestError as e:
|
|
150
|
-
print(f"build-promotions: {e}", file=sys.stderr)
|
|
151
|
-
sys.exit(1)
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
def main():
|
|
155
|
-
ap = argparse.ArgumentParser(description="Derive config/promotions.json from the ledger.")
|
|
156
|
-
ap.add_argument("--check", action="store_true",
|
|
157
|
-
help="fail if config/promotions.json is stale vs the ledger (gate)")
|
|
158
|
-
ap.add_argument("--self-test", action="store_true")
|
|
159
|
-
args = ap.parse_args()
|
|
160
|
-
|
|
161
|
-
if args.self_test:
|
|
162
|
-
_self_test()
|
|
163
|
-
return
|
|
164
|
-
|
|
165
|
-
want = _load_and_build()
|
|
166
|
-
|
|
167
|
-
if args.check:
|
|
168
|
-
have = MANIFEST.read_text(encoding="utf-8") if MANIFEST.is_file() else ""
|
|
169
|
-
if have != want:
|
|
170
|
-
print("build-promotions --check: config/promotions.json is STALE vs the "
|
|
171
|
-
"ledger — run `python3 scripts/build-promotions.py` and commit.",
|
|
172
|
-
file=sys.stderr)
|
|
173
|
-
sys.exit(1)
|
|
174
|
-
print("build-promotions --check: promotions.json is current with the ledger")
|
|
175
|
-
return
|
|
176
|
-
|
|
177
|
-
MANIFEST.write_text(want, encoding="utf-8")
|
|
178
|
-
n = want.count('"learning_id"')
|
|
179
|
-
print(f"build-promotions: wrote {MANIFEST} ({n} promotion(s))")
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if __name__ == "__main__":
|
|
183
|
-
main()
|
|
@@ -1,258 +0,0 @@
|
|
|
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()
|