agent-bios 0.9.3 → 0.9.5

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,258 @@
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()
@@ -23,6 +23,9 @@ set -euo pipefail
23
23
  # or env var. Detach stdin so no child (the codex-helm dry-run, pip, git) can
24
24
  # block forever on an inherited idle stdin — that is what hangs an install under
25
25
  # CI, pipes, and background runs, where stdin stays open but never delivers.
26
+ # `learn` is the one subcommand whose payload IS stdin, so keep the caller's on
27
+ # fd 3 first and hand it back only there; every other path still sees /dev/null.
28
+ exec 3<&0 2>/dev/null || exec 3</dev/null # tolerate a caller that closed fd 0
26
29
  exec </dev/null
27
30
 
28
31
  # Resolve this script through symlinks before locating the package: npm links the
@@ -562,6 +565,16 @@ PY
562
565
  else
563
566
  log "note: managed venv/textual unavailable (numbered-prompt fallback applies)"
564
567
  fi
568
+ # A file this installer executes but never ships is invisible from a clone and
569
+ # fatal on npm, so the payload gate runs wherever it exists (maintainer-side).
570
+ if [ -x "$REPO/scripts/check-package.sh" ]; then
571
+ if "$REPO/scripts/check-package.sh" >/dev/null 2>&1; then
572
+ info "npm payload OK"
573
+ else
574
+ log "npm payload incomplete; run scripts/check-package.sh"
575
+ fail=1
576
+ fi
577
+ fi
565
578
  # Repo-internal mirror parity is a maintainer gate; only meaningful from a clone.
566
579
  if [ -d "$REPO/ko" ] && [ -x "$REPO/scripts/check-parity.sh" ]; then
567
580
  if "$REPO/scripts/check-parity.sh" >/dev/null 2>&1; then info "repo mirror parity OK"; else log "repo mirror parity FAILED"; fail=1; fi
@@ -688,6 +701,9 @@ agent-bios — deploy the Claude/Codex instruction SSOT into $HOME (by copy).
688
701
  agent-bios install deploy into this environment (backs up + verifies)
689
702
  agent-bios onboard interactive domain selection + packaged install + activation canary
690
703
  agent-bios verify check the deployed state matches the source
704
+ agent-bios learn submit a session learning (reads the JSON record on
705
+ stdin; this is what the learn! flow calls, and it
706
+ works from any directory, unlike a repo-relative path)
691
707
  agent-bios status show what is installed and where
692
708
  agent-bios update git pull + reinstall (clone), or print the npm update line
693
709
  agent-bios uninstall remove deployed files and the zsh hook
@@ -713,6 +729,18 @@ EOF
713
729
  # ---- dispatch ------------------------------------------------------------
714
730
  CMD="${1:-help}"
715
731
  if [ $# -gt 0 ]; then shift; fi
732
+
733
+ # `learn` forwards its arguments and stdin straight to the collector, so it must
734
+ # bypass the flag parser below (which rejects anything it does not know). This
735
+ # subcommand is the only PATH-reachable entry to capture: the corpus guide used
736
+ # to invoke scripts/collect-learning.py relative to the cwd, which works from a
737
+ # clone and silently fails for every other install.
738
+ if [ "$CMD" = "learn" ]; then
739
+ collector="$REPO/scripts/collect-learning.py"
740
+ [ -f "$collector" ] || { log "learn: collector missing at $collector"; exit 1; }
741
+ exec python3 "$collector" "$@" <&3
742
+ fi
743
+
716
744
  WITH=""
717
745
  DOMAINS_ARG=""
718
746
  DOMAINS_SET=0
@@ -2,7 +2,8 @@
2
2
  """Aggregate token usage & cost for a Claude Code session (main + subagents).
3
3
 
4
4
  Usage: session-cost.py <session>.jsonl [...]
5
- Reads the session transcript and, if present, <session-dir>/subagents/agent-*.jsonl.
5
+ Reads the session transcript, splitting main-loop from subagent (sidechain)
6
+ usage, and also picks up <session-dir>/subagents/agent-*.jsonl when present.
6
7
  Prints per-source, per-model token sums, modeled cost, and wall-clock span.
7
8
  """
8
9
  import json, sys, glob, os
@@ -28,9 +29,17 @@ def price_for(model):
28
29
  return None
29
30
 
30
31
  def scan(path):
31
- """-> {model: {in,out,cr,cw5,cw1,turns}}, (t_min, t_max)"""
32
- agg, tmin, tmax, seen = {}, None, None, set()
33
- for line in open(path):
32
+ """-> {(scope, model): {in,out,cr,cw5,cw1,turns}}, (t_min, t_max), {scope: agent_ids}
33
+
34
+ A single API response is written to the transcript several times as it
35
+ streams, each line carrying output_tokens *so far* (e.g. 2, 2, 2, 540).
36
+ Keep the record with the LARGEST output_tokens per message id: keeping the
37
+ first one instead under-reports subagent output by ~95%, because sidechain
38
+ messages get snapshotted far more often than main-loop ones do.
39
+ """
40
+ best, tmin, tmax = {}, None, None
41
+ agents = {"main": set(), "sub": set()}
42
+ for line in open(path, errors="replace"):
34
43
  try:
35
44
  d = json.loads(line)
36
45
  except json.JSONDecodeError:
@@ -42,11 +51,17 @@ def scan(path):
42
51
  u, model = m.get("usage"), m.get("model")
43
52
  if not u or not model or model == "<synthetic>":
44
53
  continue
45
- rid = m.get("id") or d.get("requestId")
46
- if rid in seen:
47
- continue # one usage record per API request
48
- seen.add(rid)
49
- a = agg.setdefault(model, dict(inp=0, out=0, cr=0, cw5=0, cw1=0, turns=0))
54
+ scope = "sub" if d.get("isSidechain") else "main"
55
+ if d.get("agentId"):
56
+ agents[scope].add(d["agentId"])
57
+ key = (scope, m.get("id") or d.get("requestId"))
58
+ prev = best.get(key)
59
+ if prev is None or u.get("output_tokens", 0) > prev[1].get("output_tokens", 0):
60
+ best[key] = (model, u)
61
+
62
+ agg = {}
63
+ for (scope, _), (model, u) in best.items():
64
+ a = agg.setdefault((scope, model), dict(inp=0, out=0, cr=0, cw5=0, cw1=0, turns=0))
50
65
  a["inp"] += u.get("input_tokens", 0)
51
66
  a["out"] += u.get("output_tokens", 0)
52
67
  a["cr"] += u.get("cache_read_input_tokens", 0)
@@ -57,7 +72,7 @@ def scan(path):
57
72
  else:
58
73
  a["cw5"] += u.get("cache_creation_input_tokens", 0)
59
74
  a["turns"] += 1
60
- return agg, (tmin, tmax)
75
+ return agg, (tmin, tmax), agents
61
76
 
62
77
  def cost(model, a):
63
78
  p = price_for(model)
@@ -71,19 +86,27 @@ def fmt(n):
71
86
 
72
87
  def report(session_path):
73
88
  base = session_path[:-6] # strip .jsonl
74
- sources = [("main", session_path)]
89
+ sources = [(None, session_path)]
75
90
  sources += [(os.path.basename(f)[:-6], f)
76
91
  for f in sorted(glob.glob(os.path.join(base, "subagents", "*.jsonl")))]
77
92
  print(f"\n=== {os.path.basename(session_path)} ===")
78
93
  grand, unpriced = 0.0, []
79
- hdr = f"{'source':<38}{'model':<22}{'turns':>6}{'input':>9}{'output':>9}{'cache_rd':>10}{'cache_wr':>10}{'cost$':>9}"
94
+ hdr = (f"{'source':<38}{'model':<22}{'turns':>6}{'input':>9}{'output':>9}"
95
+ f"{'cache_rd':>10}{'cache_wr':>10}{'cost$':>9}")
80
96
  print(hdr); print("-" * len(hdr))
81
97
  span_min = span_max = None
82
- for name, path in sources:
83
- agg, (tmin, tmax) = scan(path)
98
+ for label, path in sources:
99
+ agg, (tmin, tmax), agents = scan(path)
84
100
  if tmin:
85
101
  span_min = min(span_min or tmin, tmin); span_max = max(span_max or tmax, tmax)
86
- for model, a in agg.items():
102
+ for (scope, model), a in sorted(agg.items()):
103
+ if label is not None:
104
+ name = label
105
+ elif scope == "sub":
106
+ n = len(agents["sub"])
107
+ name = f"subagents (n={n})" if n else "subagents"
108
+ else:
109
+ name = "main"
87
110
  c = cost(model, a)
88
111
  cs = f"{c:9.2f}" if c is not None else " ?"
89
112
  if c is None: