agent-bios 0.9.6 → 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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-bios",
3
- "version": "0.9.6",
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/",
@@ -26,8 +26,6 @@
26
26
  "scripts/assemble.py",
27
27
  "scripts/check-domains.py",
28
28
  "scripts/canary.sh",
29
- "scripts/build-promotions.py",
30
- "scripts/ingest-learnings-export.py",
31
29
  "scripts/check-parity.sh",
32
30
  "scripts/check-prompting-targets.sh",
33
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)"
@@ -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:
@@ -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
 
@@ -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()