agent-bios 0.9.6 → 0.9.8
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/package.json +3 -4
- package/scripts/assemble.py +23 -7
- package/scripts/canary.sh +38 -2
- package/scripts/check-domains.py +41 -1
- package/scripts/install.sh +53 -2
- package/scripts/register-hooks.py +44 -0
- package/claude/settings.json +0 -60
- package/scripts/build-promotions.py +0 -210
- package/scripts/ingest-learnings-export.py +0 -258
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-bios",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.8",
|
|
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/",
|
|
@@ -23,11 +23,10 @@
|
|
|
23
23
|
"shell/agent-launch.zsh",
|
|
24
24
|
"scripts/agent-launch.py",
|
|
25
25
|
"scripts/pkgid.py",
|
|
26
|
+
"scripts/register-hooks.py",
|
|
26
27
|
"scripts/assemble.py",
|
|
27
28
|
"scripts/check-domains.py",
|
|
28
29
|
"scripts/canary.sh",
|
|
29
|
-
"scripts/build-promotions.py",
|
|
30
|
-
"scripts/ingest-learnings-export.py",
|
|
31
30
|
"scripts/check-parity.sh",
|
|
32
31
|
"scripts/check-prompting-targets.sh",
|
|
33
32
|
"scripts/check-learning.py",
|
package/scripts/assemble.py
CHANGED
|
@@ -152,16 +152,31 @@ def copy_filtered(src_dir, names, dest, rewrite=None, dry=False):
|
|
|
152
152
|
(dest / n).write_text(body, encoding="utf-8")
|
|
153
153
|
|
|
154
154
|
|
|
155
|
-
def merge_settings(claude_dir, hook_names, template_path, dry=False):
|
|
156
|
-
"""
|
|
157
|
-
|
|
155
|
+
def merge_settings(claude_dir, hook_names, template_path, dry=False, owned_names=None):
|
|
156
|
+
"""Merge our hook registrations into the user's settings, owning by NAME.
|
|
157
|
+
|
|
158
|
+
`hook_names` is what to register now (the selection); `owned_names` is every
|
|
159
|
+
hook the manifest declares, which is what we may remove. Ownership is the
|
|
160
|
+
manifest name rather than a path marker because the same hook has lived at
|
|
161
|
+
two paths — `<claude>/hooks/` on the old full-install layout and
|
|
162
|
+
`central/hooks/` now — and a path-marker drop would leave the old entry
|
|
163
|
+
behind, registering the hook twice after the move. Dropping every declared
|
|
164
|
+
name and re-adding only the selected ones also makes deselection work.
|
|
165
|
+
|
|
166
|
+
Entries we do not own are never touched: `<claude>/hooks/` is shared with
|
|
167
|
+
other tools' hooks and state.
|
|
168
|
+
"""
|
|
169
|
+
owned = set(owned_names if owned_names is not None else hook_names)
|
|
158
170
|
spath = claude_dir / "settings.json"
|
|
159
171
|
settings = json.loads(spath.read_text(encoding="utf-8")) if spath.exists() else {}
|
|
160
172
|
template = json.loads(template_path.read_text(encoding="utf-8")) if template_path.exists() else {}
|
|
161
173
|
hooks = settings.setdefault("hooks", {})
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
174
|
+
|
|
175
|
+
def ours(en):
|
|
176
|
+
return any(n in h.get("command", "") for h in en.get("hooks", []) for n in owned)
|
|
177
|
+
|
|
178
|
+
for event, entries in list(hooks.items()): # drop every entry we own
|
|
179
|
+
hooks[event] = [en for en in entries if not ours(en)]
|
|
165
180
|
for event, entries in template.get("hooks", {}).items(): # re-add per selection
|
|
166
181
|
for en in entries:
|
|
167
182
|
cmds = [h.get("command", "") for h in en.get("hooks", [])]
|
|
@@ -299,7 +314,8 @@ def main():
|
|
|
299
314
|
rewrite=(f"{CLAUDE_VAR}/guides/", f"{CLAUDE_VAR}/central/guides/"), dry=dry)
|
|
300
315
|
copy_filtered(REPO / "claude" / "hooks", hooks, central / "hooks", dry=dry)
|
|
301
316
|
copy_filtered(REPO / "claude" / "agents", agents, central / "agents", dry=dry)
|
|
302
|
-
merge_settings(claude_dir, hooks, REPO / "claude" / "settings.json", dry=dry
|
|
317
|
+
merge_settings(claude_dir, hooks, REPO / "claude" / "settings.template.json", dry=dry,
|
|
318
|
+
owned_names=manifest.get("hooks", {})) # deselected hooks must drop too
|
|
303
319
|
entry_state = seed_entry(claude_dir, monolith, dry=dry)
|
|
304
320
|
|
|
305
321
|
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
|
@@ -90,6 +90,8 @@ def run_gate(manifest, bullets, repo=REPO):
|
|
|
90
90
|
if not tiers or not domains_reg:
|
|
91
91
|
errors.append("registry: tiers/domains registry empty")
|
|
92
92
|
|
|
93
|
+
errors += check_settings_template(manifest, repo)
|
|
94
|
+
|
|
93
95
|
# -- 0. package identity ------------------------------------------------
|
|
94
96
|
# Absent means core (the reservation that keeps pre-v2 artifacts valid), but
|
|
95
97
|
# a PRESENT malformed id must fail rather than fall back — silently treating
|
|
@@ -196,6 +198,33 @@ def run_gate(manifest, bullets, repo=REPO):
|
|
|
196
198
|
return errors, sizes
|
|
197
199
|
|
|
198
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
|
+
|
|
199
228
|
def self_test(manifest, bullets):
|
|
200
229
|
"""Negative controls: each mutation MUST make the gate fail."""
|
|
201
230
|
import copy
|
|
@@ -219,8 +248,19 @@ def self_test(manifest, bullets):
|
|
|
219
248
|
m6 = copy.deepcopy(manifest)
|
|
220
249
|
m6["package_id"] = "@Acme/Builder" # uppercase is not a legal segment
|
|
221
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")
|
|
222
262
|
|
|
223
|
-
failed = []
|
|
263
|
+
failed = [] if settings_errs else ["settings rule fires on its own"]
|
|
224
264
|
for name, mm, bb in muts:
|
|
225
265
|
errs, _ = run_gate(mm, bb)
|
|
226
266
|
if not errs:
|
package/scripts/install.sh
CHANGED
|
@@ -111,6 +111,15 @@ deploy_glob() {
|
|
|
111
111
|
# marker region, settings merge); the entry CLAUDE.md and AGENTS.md are NOT
|
|
112
112
|
# manifested — the entry is user-owned after seeding, AGENTS.md holds a
|
|
113
113
|
# personal region — so uninstall never deletes them.
|
|
114
|
+
# Full mode never runs the assembler, so nothing registered the hooks it
|
|
115
|
+
# deployed — the files landed and never fired. Reuse the assembler's own merge
|
|
116
|
+
# so both install paths register identically, under one ownership rule.
|
|
117
|
+
register_hooks_full() {
|
|
118
|
+
[ "$DRY_RUN" = 1 ] && { info "[dry-run] register central hooks"; return 0; }
|
|
119
|
+
python3 "$REPO/scripts/register-hooks.py" "$REPO" "$CLAUDE_DIR" \
|
|
120
|
+
|| log "note: hook registration failed; the deployed hooks will not fire"
|
|
121
|
+
}
|
|
122
|
+
|
|
114
123
|
packaged_mode() { [ "${DOMAINS_SET:-0}" = 1 ] || [ -f "$STATE_DIR/selection.json" ]; }
|
|
115
124
|
|
|
116
125
|
assemble_packaged() {
|
|
@@ -450,7 +459,10 @@ cmd_install() {
|
|
|
450
459
|
deploy_file "$REPO/claude/CLAUDE.md" "$CLAUDE_DIR/CLAUDE.md"
|
|
451
460
|
deploy_glob "$REPO/claude/guides" "*.md" "$CLAUDE_DIR/guides"
|
|
452
461
|
deploy_glob "$REPO/claude/agents" "*.md" "$CLAUDE_DIR/agents"
|
|
453
|
-
|
|
462
|
+
# Our hooks go under central/: <claude>/hooks is shared with other tools
|
|
463
|
+
# files, caches, and state, and we must not own a directory we share.
|
|
464
|
+
deploy_glob "$REPO/claude/hooks" "*.py" "$CLAUDE_DIR/central/hooks" "+x"
|
|
465
|
+
register_hooks_full
|
|
454
466
|
deploy_file "$REPO/codex/AGENTS.md" "$CODEX_DIR/AGENTS.md"
|
|
455
467
|
deploy_glob "$REPO/codex/guides" "*.md" "$CODEX_DIR/guides"
|
|
456
468
|
fi
|
|
@@ -516,6 +528,10 @@ verify_present() { if [ -f "$1" ]; then return 0; else log "missing $1"; return
|
|
|
516
528
|
|
|
517
529
|
cmd_verify() {
|
|
518
530
|
local fail=0 gp
|
|
531
|
+
if [ ! -d "$REPO/.git" ] && [ "$(drift_state)" = "drift" ]; then
|
|
532
|
+
log "deploy drift: deployed $(deployed_version), package $(source_version) — run: agent-bios install"
|
|
533
|
+
fail=1
|
|
534
|
+
fi
|
|
519
535
|
if packaged_mode; then
|
|
520
536
|
# Packaged: corpus surfaces are selection-derived, not repo-identical.
|
|
521
537
|
# The entry file is user-owned — READ-check the import line, never rewrite.
|
|
@@ -536,7 +552,7 @@ cmd_verify() {
|
|
|
536
552
|
verify_match "$REPO/codex/AGENTS.md" "$CODEX_DIR/AGENTS.md" || fail=1
|
|
537
553
|
for gp in "$REPO"/claude/guides/*.md; do verify_present "$CLAUDE_DIR/guides/$(basename "$gp")" || fail=1; done
|
|
538
554
|
for gp in "$REPO"/claude/agents/*.md; do verify_present "$CLAUDE_DIR/agents/$(basename "$gp")" || fail=1; done
|
|
539
|
-
for gp in "$REPO"/claude/hooks/*.py; do verify_present "$CLAUDE_DIR/hooks/$(basename "$gp")" || fail=1; done
|
|
555
|
+
for gp in "$REPO"/claude/hooks/*.py; do verify_present "$CLAUDE_DIR/central/hooks/$(basename "$gp")" || fail=1; done
|
|
540
556
|
for gp in "$REPO"/codex/guides/*.md; do verify_present "$CODEX_DIR/guides/$(basename "$gp")" || fail=1; done
|
|
541
557
|
fi
|
|
542
558
|
verify_match "$REPO/scripts/agent-launch.py" "$BIN_DIR/agent-launch" || fail=1
|
|
@@ -664,6 +680,33 @@ cmd_onboard() {
|
|
|
664
680
|
}
|
|
665
681
|
}
|
|
666
682
|
|
|
683
|
+
# ---- deploy-chain drift ---------------------------------------------------
|
|
684
|
+
# A repo edit is inert until it is published AND globally installed AND
|
|
685
|
+
# deployed. The middle two are checkable: the installer stamps the package
|
|
686
|
+
# version it deployed into the state dir, so a stamp older than the package now
|
|
687
|
+
# running means someone updated the package and never re-deployed. Reading the
|
|
688
|
+
# registry cannot see this, which is why it went unnoticed three times.
|
|
689
|
+
json_field() { # $1=file $2=key
|
|
690
|
+
[ -f "$1" ] || return 1
|
|
691
|
+
python3 -c 'import json,sys
|
|
692
|
+
try:
|
|
693
|
+
v=json.load(open(sys.argv[1])).get(sys.argv[2])
|
|
694
|
+
except Exception:
|
|
695
|
+
sys.exit(1)
|
|
696
|
+
sys.exit(0) if v is None else print(v)' "$1" "$2" 2>/dev/null
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
deployed_version() { json_field "$STATE_DIR/version.json" version; }
|
|
700
|
+
source_version() { json_field "$REPO/package.json" version; }
|
|
701
|
+
|
|
702
|
+
drift_state() { # prints: match | drift | unknown
|
|
703
|
+
local d s
|
|
704
|
+
d="$(deployed_version)" || { echo unknown; return; }
|
|
705
|
+
s="$(source_version)" || { echo unknown; return; }
|
|
706
|
+
[ -n "$d" ] && [ -n "$s" ] || { echo unknown; return; }
|
|
707
|
+
[ "$d" = "$s" ] && echo match || echo drift
|
|
708
|
+
}
|
|
709
|
+
|
|
667
710
|
cmd_status() {
|
|
668
711
|
local version p
|
|
669
712
|
if [ -d "$REPO/.git" ]; then
|
|
@@ -676,6 +719,11 @@ cmd_status() {
|
|
|
676
719
|
log "agent-bios"
|
|
677
720
|
fi
|
|
678
721
|
log " source: $REPO"
|
|
722
|
+
case "$(drift_state)" in
|
|
723
|
+
match) info "deployed version $(deployed_version) (matches this package)" ;;
|
|
724
|
+
drift) log "DRIFT deployed $(deployed_version) but this package is $(source_version) — run: agent-bios install" ;;
|
|
725
|
+
unknown) info "deployed version unknown (no state marker yet)" ;;
|
|
726
|
+
esac
|
|
679
727
|
for p in "$CLAUDE_DIR/CLAUDE.md" "$CODEX_DIR/AGENTS.md" "$BIN_DIR/agent-launch" \
|
|
680
728
|
"$LAUNCH_DIR/profiles.toml" "$LAUNCH_DIR/shell.zsh"; do
|
|
681
729
|
if [ -e "$p" ]; then info "present $p"; else info "MISSING $p"; fi
|
|
@@ -691,6 +739,9 @@ cmd_update() {
|
|
|
691
739
|
else
|
|
692
740
|
log "Installed as an npm package. Update with:"
|
|
693
741
|
log " npm install -g agent-bios@latest && agent-bios install"
|
|
742
|
+
log "Then confirm what actually landed — right after a publish the cached"
|
|
743
|
+
log "packument can serve the PREVIOUS version at exit 0:"
|
|
744
|
+
log " agent-bios status # must show the version you expected, and no DRIFT"
|
|
694
745
|
fi
|
|
695
746
|
}
|
|
696
747
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Register the manifest's hooks in a deployed settings.json (full-install path).
|
|
3
|
+
|
|
4
|
+
The packaged path gets this for free: assemble.py composes the corpus and calls
|
|
5
|
+
merge_settings on the way out. A full install never runs the assembler, so the
|
|
6
|
+
hook files were deployed and nothing ever registered them — they sat on disk and
|
|
7
|
+
never fired. This runs the assembler's own merge so both paths register
|
|
8
|
+
identically, under the same name-based ownership rule.
|
|
9
|
+
|
|
10
|
+
Usage: register-hooks.py <repo> <claude-dir>
|
|
11
|
+
Exit 0 on success; non-zero (with a message) if the merge could not run, which
|
|
12
|
+
the installer reports as a note rather than failing the whole install.
|
|
13
|
+
"""
|
|
14
|
+
import importlib.util
|
|
15
|
+
import json
|
|
16
|
+
import pathlib
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_assemble(repo):
|
|
21
|
+
"""Import assemble.py by path — scripts/ is a flat toolbox, not a package."""
|
|
22
|
+
spec = importlib.util.spec_from_file_location("assemble", repo / "scripts" / "assemble.py")
|
|
23
|
+
mod = importlib.util.module_from_spec(spec)
|
|
24
|
+
spec.loader.exec_module(mod)
|
|
25
|
+
return mod
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main():
|
|
29
|
+
if len(sys.argv) != 3:
|
|
30
|
+
sys.exit("usage: register-hooks.py <repo> <claude-dir>")
|
|
31
|
+
repo, claude_dir = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])
|
|
32
|
+
manifest = json.loads((repo / "config" / "domains.json").read_text(encoding="utf-8"))
|
|
33
|
+
names = sorted(manifest.get("hooks", {}))
|
|
34
|
+
if not names:
|
|
35
|
+
sys.exit("register-hooks: manifest declares no hooks — refusing to rewrite settings")
|
|
36
|
+
mod = load_assemble(repo)
|
|
37
|
+
mod.merge_settings(claude_dir, names,
|
|
38
|
+
repo / "claude" / "settings.template.json",
|
|
39
|
+
owned_names=names)
|
|
40
|
+
print(f" hooks registered ({len(names)})")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
main()
|
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,210 +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
|
-
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
|
|
33
|
-
import pkgid # noqa: E402 (sibling module; scripts/ is a flat toolbox)
|
|
34
|
-
|
|
35
|
-
REPO = pathlib.Path(__file__).resolve().parent.parent
|
|
36
|
-
LEDGER = REPO / "design" / "session-distill" / "ledger.json"
|
|
37
|
-
DOMAINS = REPO / "config" / "domains.json"
|
|
38
|
-
MANIFEST = REPO / "config" / "promotions.json"
|
|
39
|
-
FIXTURE = REPO / "design" / "collection-loop" / "fixtures" / "ledger-promote-sample.json"
|
|
40
|
-
MANIFEST_VERSION = 2
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
class ManifestError(Exception):
|
|
44
|
-
pass
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def resolve_audience(anchor, domains_manifest):
|
|
48
|
-
"""(tier, [domains]) for a placed anchor, or None if it does not resolve.
|
|
49
|
-
Bullets match by their `anchor` field; a whole guide/hook/agent matches by
|
|
50
|
-
key. This is the authoritative placement audience (mirrors what assemble.py
|
|
51
|
-
reads to decide who gets the bullet)."""
|
|
52
|
-
for b in domains_manifest.get("bullets", []):
|
|
53
|
-
if b.get("anchor") == anchor:
|
|
54
|
-
return b.get("tier"), list(b.get("domains", []))
|
|
55
|
-
for kind in ("guides", "hooks", "agents"):
|
|
56
|
-
entry = domains_manifest.get(kind, {}).get(anchor)
|
|
57
|
-
if entry is not None:
|
|
58
|
-
return entry.get("tier"), list(entry.get("domains", []))
|
|
59
|
-
return None
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def build_manifest(ledger, domains_manifest):
|
|
63
|
-
"""Ledger + domains manifest -> the promotion manifest. Raises ManifestError
|
|
64
|
-
on a placed+learning_id entry whose placement can't be verified (missing or
|
|
65
|
-
unresolvable placed_anchor) — the F3 silent-loss guard. Sorted by
|
|
66
|
-
learning_id for a stable, deterministic artifact."""
|
|
67
|
-
promotions = []
|
|
68
|
-
for e in ledger.get("entries", []):
|
|
69
|
-
if not isinstance(e, dict):
|
|
70
|
-
continue
|
|
71
|
-
lid = e.get("learning_id")
|
|
72
|
-
if e.get("status") != "placed" or not (isinstance(lid, str) and lid):
|
|
73
|
-
continue
|
|
74
|
-
anchor = e.get("placed_anchor")
|
|
75
|
-
if not anchor:
|
|
76
|
-
raise ManifestError(
|
|
77
|
-
f"placed learning {e.get('id')!r} ({lid}) has no `placed_anchor` — "
|
|
78
|
-
"record where the bullet landed (a config/domains.json anchor/key) "
|
|
79
|
-
"before promoting")
|
|
80
|
-
aud = resolve_audience(anchor, domains_manifest)
|
|
81
|
-
if aud is None:
|
|
82
|
-
raise ManifestError(
|
|
83
|
-
f"placed_anchor {anchor!r} (learning {lid}) does not resolve in "
|
|
84
|
-
"config/domains.json — the promotion has no verifiable placement")
|
|
85
|
-
tier, domains = aud
|
|
86
|
-
pid = pkgid.resolve(e, "placed_package_id")
|
|
87
|
-
if not pkgid.is_valid(pid):
|
|
88
|
-
raise ManifestError(
|
|
89
|
-
f"placed_package_id {pid!r} (learning {lid}) is not @scope/name")
|
|
90
|
-
# `anchor` ships alongside the derived audience because the consumer
|
|
91
|
-
# (migrate-learnings) must verify the bullet is REALLY in the user's
|
|
92
|
-
# deployed corpus before deleting their personal copy. Audience metadata
|
|
93
|
-
# alone cannot answer that — it is a build-time projection that drifts.
|
|
94
|
-
promotions.append({"learning_id": lid.lower(), "package_id": pid,
|
|
95
|
-
"anchor": anchor, "tier": tier, "domains": domains})
|
|
96
|
-
promotions.sort(key=lambda p: p["learning_id"])
|
|
97
|
-
return {"version": MANIFEST_VERSION, "promotions": promotions}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def render(manifest):
|
|
101
|
-
return json.dumps(manifest, ensure_ascii=False, indent=2) + "\n"
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def _self_test():
|
|
105
|
-
ledger = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
|
106
|
-
# Fixture domains manifest the fixture's placed_anchors resolve against.
|
|
107
|
-
domains = {
|
|
108
|
-
"bullets": [
|
|
109
|
-
{"anchor": "universal placed rule", "tier": "core", "domains": []},
|
|
110
|
-
{"anchor": "builder placed rule", "tier": "domain", "domains": ["builder-base"]},
|
|
111
|
-
],
|
|
112
|
-
"guides": {}, "hooks": {}, "agents": {},
|
|
113
|
-
}
|
|
114
|
-
m = build_manifest(ledger, domains)
|
|
115
|
-
by = {p["learning_id"]: p for p in m["promotions"]}
|
|
116
|
-
a1 = "0f8c1c2a-4d1e-4abc-9def-0000000000a1"
|
|
117
|
-
b2 = "0f8c1c2a-4d1e-4abc-9def-0000000000b2"
|
|
118
|
-
|
|
119
|
-
checks = [
|
|
120
|
-
("only placed+learning_id promoted (cardinality > 0)", set(by) == {a1, b2}),
|
|
121
|
-
("session-distill (no learning_id) excluded",
|
|
122
|
-
all(p["learning_id"] for p in m["promotions"])),
|
|
123
|
-
("incubating excluded",
|
|
124
|
-
"0f8c1c2a-4d1e-4abc-9def-0000000000c1" not in by),
|
|
125
|
-
("audience DERIVED from the anchor, not the ledger domain tag",
|
|
126
|
-
by[a1]["tier"] == "core" and by[a1]["domains"] == []
|
|
127
|
-
and by[b2]["tier"] == "domain" and by[b2]["domains"] == ["builder-base"]),
|
|
128
|
-
("sorted + deterministic",
|
|
129
|
-
[p["learning_id"] for p in m["promotions"]] == sorted(by)
|
|
130
|
-
and render(build_manifest(ledger, domains)) == render(m)),
|
|
131
|
-
]
|
|
132
|
-
|
|
133
|
-
# F3 guards: missing / unresolvable placed_anchor must FAIL.
|
|
134
|
-
def raises(mut):
|
|
135
|
-
led = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
|
136
|
-
mut(led)
|
|
137
|
-
try:
|
|
138
|
-
build_manifest(led, domains)
|
|
139
|
-
return False
|
|
140
|
-
except ManifestError:
|
|
141
|
-
return True
|
|
142
|
-
|
|
143
|
-
checks.append(("missing placed_anchor fails",
|
|
144
|
-
raises(lambda l: l["entries"][0].pop("placed_anchor", None))))
|
|
145
|
-
checks.append(("unresolvable placed_anchor fails",
|
|
146
|
-
raises(lambda l: l["entries"][0].__setitem__("placed_anchor", "no-such-anchor"))))
|
|
147
|
-
|
|
148
|
-
# v2 identity + locator. Without these the manifest looks fine while the
|
|
149
|
-
# consumer that must verify placement has nothing to verify against.
|
|
150
|
-
checks.append(("manifest is v2", m["version"] == 2))
|
|
151
|
-
checks.append(("every promotion carries its anchor",
|
|
152
|
-
all(p.get("anchor") for p in m["promotions"])))
|
|
153
|
-
checks.append(("absent placed_package_id resolves to core",
|
|
154
|
-
all(p.get("package_id") == pkgid.CORE for p in m["promotions"])))
|
|
155
|
-
led2 = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
|
156
|
-
led2["entries"][0]["placed_package_id"] = "@acme/security"
|
|
157
|
-
checks.append(("declared placed_package_id is carried through",
|
|
158
|
-
any(p["package_id"] == "@acme/security"
|
|
159
|
-
for p in build_manifest(led2, domains)["promotions"])))
|
|
160
|
-
checks.append(("malformed placed_package_id fails",
|
|
161
|
-
raises(lambda l: l["entries"][0].__setitem__("placed_package_id", "Acme/Sec"))))
|
|
162
|
-
|
|
163
|
-
failed = [n for n, ok in checks if not ok]
|
|
164
|
-
if failed:
|
|
165
|
-
for n in failed:
|
|
166
|
-
print(f"build-promotions --self-test: FAIL: {n}", file=sys.stderr)
|
|
167
|
-
sys.exit(1)
|
|
168
|
-
print(f"build-promotions --self-test: OK ({len(checks)} manifest-derivation checks)")
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
def _load_and_build():
|
|
172
|
-
ledger = json.loads(LEDGER.read_text(encoding="utf-8"))
|
|
173
|
-
domains = json.loads(DOMAINS.read_text(encoding="utf-8"))
|
|
174
|
-
try:
|
|
175
|
-
return render(build_manifest(ledger, domains))
|
|
176
|
-
except ManifestError as e:
|
|
177
|
-
print(f"build-promotions: {e}", file=sys.stderr)
|
|
178
|
-
sys.exit(1)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
def main():
|
|
182
|
-
ap = argparse.ArgumentParser(description="Derive config/promotions.json from the ledger.")
|
|
183
|
-
ap.add_argument("--check", action="store_true",
|
|
184
|
-
help="fail if config/promotions.json is stale vs the ledger (gate)")
|
|
185
|
-
ap.add_argument("--self-test", action="store_true")
|
|
186
|
-
args = ap.parse_args()
|
|
187
|
-
|
|
188
|
-
if args.self_test:
|
|
189
|
-
_self_test()
|
|
190
|
-
return
|
|
191
|
-
|
|
192
|
-
want = _load_and_build()
|
|
193
|
-
|
|
194
|
-
if args.check:
|
|
195
|
-
have = MANIFEST.read_text(encoding="utf-8") if MANIFEST.is_file() else ""
|
|
196
|
-
if have != want:
|
|
197
|
-
print("build-promotions --check: config/promotions.json is STALE vs the "
|
|
198
|
-
"ledger — run `python3 scripts/build-promotions.py` and commit.",
|
|
199
|
-
file=sys.stderr)
|
|
200
|
-
sys.exit(1)
|
|
201
|
-
print("build-promotions --check: promotions.json is current with the ledger")
|
|
202
|
-
return
|
|
203
|
-
|
|
204
|
-
MANIFEST.write_text(want, encoding="utf-8")
|
|
205
|
-
n = want.count('"learning_id"')
|
|
206
|
-
print(f"build-promotions: wrote {MANIFEST} ({n} promotion(s))")
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if __name__ == "__main__":
|
|
210
|
-
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()
|