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.
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-bios",
3
- "version": "0.9.6",
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",
@@ -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
- """Path-marker merge: central-owned hook entries reference central/hooks/."""
157
- marker = "/central/hooks/"
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
- for event, entries in list(hooks.items()): # drop stale central-owned
163
- hooks[event] = [en for en in entries
164
- if not any(marker in h.get("command", "") for h in en.get("hooks", []))]
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
- # 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)"
@@ -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:
@@ -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
- deploy_glob "$REPO/claude/hooks" "*.py" "$CLAUDE_DIR/hooks" "+x"
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()
@@ -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()