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.
@@ -0,0 +1,15 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "Bash",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "python3 central/hooks/tooling-gotchas-hook.py"
10
+ }
11
+ ]
12
+ }
13
+ ]
14
+ }
15
+ }
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "version": 1,
3
+ "package_id": "@agent-bios/core",
3
4
  "tiers": ["core", "domain", "env-personal", "infra"],
4
5
  "domains": {
5
6
  "builder-base": "Technical-common: coding, verification menus, documentation hygiene, tooling safety, concept economy",
@@ -1,8 +1,10 @@
1
1
  {
2
- "version": 1,
2
+ "version": 2,
3
3
  "promotions": [
4
4
  {
5
5
  "learning_id": "a6feee41-2aeb-4fb6-acc6-4584c5c1336c",
6
+ "package_id": "@agent-bios/core",
7
+ "anchor": "tooling-gotchas.md",
6
8
  "tier": "domain",
7
9
  "domains": [
8
10
  "builder-base"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-bios",
3
- "version": "0.9.5",
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",
@@ -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
- # Exit: 0 loaded | 1 not loading (or no bundle) | 3 cannot probe (no CLI).
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
- [ -f "$BUNDLE" ] || { echo "CANARY FAIL: no bundle at $BUNDLE (run: agent-bios install --domains ...)"; exit 1; }
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)"
@@ -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:
@@ -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
- Bundle membership (mirrors scripts/assemble.py `kept`): full install (original
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
- remove_ids, kept_not_in_bundle = set(), 0
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
- if in_bundle(p.get("tier"), p.get("domains", [])):
211
- remove_ids.add(lid)
212
- else:
213
- kept_not_in_bundle += 1 # promoted but not in THIS user's bundle -> keep
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
- promos = [{"learning_id": L["core"], "tier": "core", "domains": []},
312
- {"learning_id": L["bb"], "tier": "domain", "domains": ["builder-base"]},
313
- {"learning_id": L["off"], "tier": "domain", "domains": ["office-work"]}]
314
-
315
- def seed_claude():
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")
@@ -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))
@@ -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()