agent-bios 0.3.0 → 0.5.0

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,231 @@
1
+ #!/usr/bin/env python3
2
+ """Learning record gate + validator (collection loop, Phase 0).
3
+
4
+ config/learning.schema.json is the SSOT for the learning record
5
+ (design/collection-loop/DESIGN.md); the dashboard mirrors only minimal
6
+ validation. Full client-side validity = JSON Schema conformance PLUS domain
7
+ membership in config/domains.json domains ∪ 'unclassified' — membership is
8
+ checked here, not frozen in the schema, so vocabulary evolution never needs
9
+ a schema_version bump.
10
+
11
+ Modes:
12
+ (no args) gate: schema validates against its 2020-12 metaschema;
13
+ every fixtures/valid-*.json passes; every fixtures/broken-*.json
14
+ fails AND the failure names the field the fixture breaks
15
+ (a broken fixture failing for an unrelated reason is a FAIL);
16
+ non-vacuity: >=1 valid fixture, >=1 broken fixture,
17
+ >=1 registered domain.
18
+ <file.json>… validate the given record file(s); exit 0 iff all valid.
19
+ This is the client-side validation entry point for Phase 1.
20
+ --self-test negative controls: in-memory mutations of a valid record
21
+ (each required field dropped, plus type/pattern/membership
22
+ mutations) must ALL be rejected; the unmutated record must
23
+ pass. Proves the gate can fail.
24
+
25
+ Any violation exits 1 with all violations listed.
26
+ """
27
+ import json
28
+ import pathlib
29
+ import sys
30
+
31
+ try:
32
+ import jsonschema
33
+ except ImportError:
34
+ sys.exit("check-learning: the 'jsonschema' package is required "
35
+ "(pip install jsonschema; verified 4.26.0 in DEPENDENCIES.md)")
36
+
37
+ REPO = pathlib.Path(__file__).resolve().parent.parent
38
+ SCHEMA = REPO / "config" / "learning.schema.json"
39
+ DOMAINS = REPO / "config" / "domains.json"
40
+ FIXTURES = REPO / "design" / "collection-loop" / "fixtures"
41
+
42
+ # broken fixture -> field its single defect lives in; the reported errors
43
+ # must mention it, so a fixture failing for an accidental other reason fails
44
+ # the gate instead of silently passing as "broken as expected".
45
+ BROKEN_EXPECT = {
46
+ "broken-missing-lesson.json": "lesson",
47
+ "broken-unknown-domain.json": "domain",
48
+ "broken-schema-version.json": "schema_version",
49
+ "broken-extra-field.json": "email",
50
+ "broken-empty-sessions.json": "supporting_sessions",
51
+ "broken-bad-created.json": "created",
52
+ "broken-proposed-domain-conflict.json": "domain",
53
+ "broken-bad-classification.json": "classification",
54
+ }
55
+
56
+
57
+ def load_json(path):
58
+ with open(path, encoding="utf-8") as f:
59
+ return json.load(f)
60
+
61
+
62
+ def valid_domain_values(path=DOMAINS):
63
+ """The set a record's `domain` may take, drawn from config/domains.json.
64
+
65
+ Ledger-compatible (design/session-distill/ledger.json): the ledger's
66
+ `domain` field uses BOTH domain keys (builder-base, …) for
67
+ domain-specific lessons AND tier names (core, infra, …) for cross-cutting
68
+ ones — 4 real entries carry core/infra. So the valid set is the union of
69
+ both registered vocabularies plus 'unclassified' (refinement B — the
70
+ not-yet-triaged escape; kept distinct from 'core', which means a genuinely
71
+ cross-cutting lesson). Reuses existing vocabulary; introduces none.
72
+ """
73
+ manifest = load_json(path)
74
+ values = set(manifest["domains"]) | set(manifest["tiers"]) | {"unclassified"}
75
+ if len(values) <= 1:
76
+ sys.exit("FAIL: config/domains.json registers no domains/tiers (vacuous gate)")
77
+ return values
78
+
79
+
80
+ def validate_record(record, validator, domain_values):
81
+ """Full validity: schema conformance + domain membership. Returns error strings."""
82
+ errors = []
83
+ for e in validator.iter_errors(record):
84
+ where = "/".join(str(p) for p in e.path) or "<root>"
85
+ errors.append(f"{where}: {e.message}")
86
+ if isinstance(record, dict):
87
+ dom = record.get("domain")
88
+ if isinstance(dom, str) and dom not in domain_values:
89
+ errors.append(
90
+ f"domain: {dom!r} is not a registered domain key, tier name, "
91
+ f"or 'unclassified' (config/domains.json)")
92
+ return errors
93
+
94
+
95
+ def build_validator():
96
+ schema = load_json(SCHEMA)
97
+ jsonschema.Draft202012Validator.check_schema(schema)
98
+ return jsonschema.Draft202012Validator(schema)
99
+
100
+
101
+ def gate():
102
+ validator = build_validator()
103
+ domain_values = valid_domain_values()
104
+ failures = []
105
+
106
+ valid_fx = sorted(FIXTURES.glob("valid-*.json"))
107
+ broken_fx = sorted(FIXTURES.glob("broken-*.json"))
108
+ if not valid_fx:
109
+ failures.append(f"no valid-*.json fixtures in {FIXTURES} (vacuous)")
110
+ if not broken_fx:
111
+ failures.append(f"no broken-*.json fixtures in {FIXTURES} (vacuous)")
112
+
113
+ for fx in valid_fx:
114
+ errors = validate_record(load_json(fx), validator, domain_values)
115
+ if errors:
116
+ failures.append(f"{fx.name} must validate but failed: {'; '.join(errors)}")
117
+
118
+ for fx in broken_fx:
119
+ errors = validate_record(load_json(fx), validator, domain_values)
120
+ expect = BROKEN_EXPECT.get(fx.name)
121
+ if expect is None:
122
+ failures.append(f"{fx.name} has no BROKEN_EXPECT entry (unmapped negative control)")
123
+ elif not errors:
124
+ failures.append(f"{fx.name} must FAIL but validated (negative control broken)")
125
+ elif not any(expect in err for err in errors):
126
+ failures.append(
127
+ f"{fx.name} failed for the wrong reason (expected mention of "
128
+ f"{expect!r}): {'; '.join(errors)}")
129
+ for name in BROKEN_EXPECT:
130
+ if not (FIXTURES / name).is_file():
131
+ failures.append(f"BROKEN_EXPECT names a missing fixture: {name}")
132
+
133
+ if failures:
134
+ print("check-learning: FAIL")
135
+ for f in failures:
136
+ print(f" - {f}")
137
+ return 1
138
+ print(f"check-learning: OK ({len(valid_fx)} valid, "
139
+ f"{len(broken_fx)} negative controls)")
140
+ return 0
141
+
142
+
143
+ def self_test():
144
+ validator = build_validator()
145
+ domain_values = valid_domain_values()
146
+ base = load_json(FIXTURES / "valid-minimal.json")
147
+ schema = load_json(SCHEMA)
148
+
149
+ def drop(field):
150
+ r = dict(base)
151
+ del r[field]
152
+ return r
153
+
154
+ def swap(field, value):
155
+ return {**base, field: value}
156
+
157
+ mutations = [(f"drop required '{f}'", drop(f), f) for f in schema["required"]]
158
+ mutations += [
159
+ ("schema_version as string", swap("schema_version", "1"), "schema_version"),
160
+ ("uppercase learning_id", swap("learning_id", base["learning_id"].upper()), "learning_id"),
161
+ ("lesson below minLength", swap("lesson", "short"), "lesson"),
162
+ ("capitalized domain", swap("domain", "Builder-Base"), "domain"),
163
+ ("session id without tool prefix", swap("supporting_sessions", ["517fbcea"]), "supporting_sessions"),
164
+ ("created without T/zone", swap("created", "20260720T050000Z"), "created"),
165
+ ("unknown extra key", {**base, "user_email": "x@y"}, "user_email"),
166
+ ("classification type out of A-G enum", swap("classification", {"type": "Z"}), "classification"),
167
+ ("classification unknown sub-key", swap("classification", {"mechanism": "x"}), "classification"),
168
+ ("proposed_domain with a concrete domain", {**base, "proposed_domain": "data-pipeline", "domain": "builder-base"}, "domain"),
169
+ ]
170
+
171
+ failures = []
172
+ if validate_record(base, validator, domain_values):
173
+ failures.append("positive control (valid-minimal) does not validate")
174
+ # positive control: a tier-name domain (real ledger precedent) must pass.
175
+ if validate_record({**base, "domain": "core"}, validator, domain_values):
176
+ failures.append("positive control (domain='core' tier name) does not validate")
177
+ # positive control: the optional slim classification block must pass.
178
+ if validate_record({**base, "classification": {"type": "B", "layer": "hook", "meets_bar": True}}, validator, domain_values):
179
+ failures.append("positive control (valid classification block) does not validate")
180
+ # positive control: proposed_domain rides only with an unclassified domain (base is unclassified).
181
+ if validate_record({**base, "proposed_domain": "data-pipeline"}, validator, domain_values):
182
+ failures.append("positive control (proposed_domain + unclassified) does not validate")
183
+ for name, record, expect in mutations:
184
+ errors = validate_record(record, validator, domain_values)
185
+ if not errors:
186
+ failures.append(f"mutation NOT caught: {name}")
187
+ elif not any(expect in err for err in errors):
188
+ failures.append(f"mutation caught for the wrong reason: {name} -> {errors}")
189
+
190
+ if failures:
191
+ print("check-learning --self-test: FAIL")
192
+ for f in failures:
193
+ print(f" - {f}")
194
+ return 1
195
+ print(f"check-learning --self-test: OK ({len(mutations)} mutations caught, "
196
+ f"4 positive controls)")
197
+ return 0
198
+
199
+
200
+ def validate_files(paths):
201
+ validator = build_validator()
202
+ domain_values = valid_domain_values()
203
+ rc = 0
204
+ for p in paths:
205
+ # Client entry point (Phase 1): inputs are not hand-authored fixtures,
206
+ # so malformed JSON / unreadable files report cleanly, never a traceback.
207
+ try:
208
+ record = load_json(p)
209
+ except (json.JSONDecodeError, OSError) as e:
210
+ rc = 1
211
+ print(f"INVALID {p}")
212
+ print(f" - not readable JSON: {e}")
213
+ continue
214
+ errors = validate_record(record, validator, domain_values)
215
+ if errors:
216
+ rc = 1
217
+ print(f"INVALID {p}")
218
+ for e in errors:
219
+ print(f" - {e}")
220
+ else:
221
+ print(f"OK {p}")
222
+ return rc
223
+
224
+
225
+ if __name__ == "__main__":
226
+ args = sys.argv[1:]
227
+ if args == ["--self-test"]:
228
+ sys.exit(self_test())
229
+ if args:
230
+ sys.exit(validate_files(args))
231
+ sys.exit(gate())
@@ -113,8 +113,43 @@ real Microsoft Excel engine|claude/CLAUDE.md|claude/guides/coding-staged-workflo
113
113
  severity contract|README.md|claude/guides/coding-staged-workflow.md
114
114
  Ambient state|claude/CLAUDE.md|claude/guides/tooling-gotchas.md
115
115
  the full lifecycle of what you create|claude/CLAUDE.md|claude/guides/tooling-gotchas.md
116
+ dual-provider frontier design drafts|claude/CLAUDE.md|claude/guides/cli-multi-model-workflow.md
116
117
  ANCHORS
117
118
 
119
+ # Domain-manifest gate: bullet<->anchor bijection, file coverage, router
120
+ # co-packaging (config/domains.json vs the monolith); its --self-test proves
121
+ # every negative control still fails, so a green gate is falsifiable.
122
+ if [ -f config/domains.json ]; then
123
+ python3 scripts/check-domains.py >/dev/null \
124
+ || { echo "FAIL: domains manifest gate (run scripts/check-domains.py)"; fail=1; }
125
+ python3 scripts/check-domains.py --self-test >/dev/null \
126
+ || { echo "FAIL: domains gate self-test missed a negative control"; fail=1; }
127
+ bash scripts/test-assemble.sh >/dev/null \
128
+ || { echo "FAIL: assembler scenario suite (run scripts/test-assemble.sh)"; fail=1; }
129
+ fi
130
+
131
+ # Learning record gate: config/learning.schema.json (collection-loop
132
+ # SSOT) vs its fixtures; --self-test proves every mutation is still caught.
133
+ if [ -f config/learning.schema.json ]; then
134
+ python3 scripts/check-learning.py >/dev/null \
135
+ || { echo "FAIL: learning record gate (run scripts/check-learning.py)"; fail=1; }
136
+ python3 scripts/check-learning.py --self-test >/dev/null \
137
+ || { echo "FAIL: learning gate self-test missed a negative control"; fail=1; }
138
+ # collect-learning Phase 2 transport: the watermark upload-drain logic
139
+ # (status classification, watermark advance, transient-stop, poison-skip).
140
+ python3 scripts/collect-learning.py --self-test >/dev/null \
141
+ || { echo "FAIL: collect-learning upload-drain self-test"; fail=1; }
142
+ fi
143
+
144
+ # Lexicon gate: forbid deprecated terminology tokens in live files
145
+ # (LEXICON.md is the SSOT); --self-test proves the detector can fire.
146
+ if [ -f LEXICON.md ]; then
147
+ python3 scripts/check-lexicon.py >/dev/null \
148
+ || { echo "FAIL: lexicon gate (run scripts/check-lexicon.py)"; fail=1; }
149
+ python3 scripts/check-lexicon.py --self-test >/dev/null \
150
+ || { echo "FAIL: lexicon gate self-test failed"; fail=1; }
151
+ fi
152
+
118
153
  # The launcher's Textual preflight UI tests need the managed venv (textual).
119
154
  # Provision it if missing; every non-UI check above runs under system python.
120
155
  VENV="${AGENT_LAUNCH_VENV:-$HOME/.local/share/agent-launch/venv}"
@@ -551,7 +586,9 @@ if launcher.is_file() and launch_profile:
551
586
  def set_plan(self, plan):
552
587
  self.plan = plan
553
588
 
554
- def choose(self, title, options, default, allow_back, preview=None, learning_lines=None):
589
+ def choose(self, title, options, default, allow_back, preview=None, corpus_lines=None):
590
+ if title == "Mode":
591
+ return "builder"
555
592
  if title == "Preset":
556
593
  if preview is not None:
557
594
  balanced = launcher_module.setup_summary_lines(preview("balanced"))
@@ -772,12 +809,17 @@ if launcher.is_file() and launch_profile:
772
809
  os.close(master)
773
810
  return bytes(transcript), picker_status
774
811
 
812
+ # Root menu is now a Mode picker (General user / Builder / Session
813
+ # distill); Builder is the default highlight and lists Custom. Reaching
814
+ # Custom means: Enter the root to open Builder's preset submenu, then
815
+ # step down through its fixed presets (Balanced default-highlighted).
775
816
  open_custom_steps = (
817
+ (b"Esc cancel | q cancel", b"\r"),
776
818
  (b"native multi-perspective review", b""),
777
- (b"Esc cancel | q cancel", b"\x1b[B"),
819
+ (b"Esc back | q cancel", b"\x1b[B"),
778
820
  (b"hybrid onto", b"\x1b[B"),
779
821
  (b"high-volume", b"\x1b[B"),
780
- (b"learning-refinement", b"\x1b[B"),
822
+ (b"the standing spawn policy is lifted", b"\x1b[B"),
781
823
  (b"Open a settings hub", b"\r"),
782
824
  )
783
825
  down = b"\x1b[B"
@@ -798,7 +840,7 @@ if launcher.is_file() and launch_profile:
798
840
  b"WORKHORSE",
799
841
  b"SWEEP",
800
842
  b"About highlighted option",
801
- b"HELM default for everyday work",
843
+ b"Tune tiers, review routes, and permissions",
802
844
  b"Options (",
803
845
  )
804
846
  positions = {token: transcript.find(token) for token in layout_tokens}
@@ -809,7 +851,7 @@ if launcher.is_file() and launch_profile:
809
851
  positions[b"Current setup"]
810
852
  < min(positions[token] for token in (b"FRONTIER", b"HELM", b"WORKHORSE", b"SWEEP"))
811
853
  < positions[b"About highlighted option"]
812
- < positions[b"HELM default for everyday work"]
854
+ < positions[b"Tune tiers, review routes, and permissions"]
813
855
  < positions[b"Options ("]
814
856
  ):
815
857
  mark_fail("agent-launch layout is not setup then description then options")
@@ -821,12 +863,27 @@ if launcher.is_file() and launch_profile:
821
863
  if picker_status != 130:
822
864
  mark_fail(f"agent-launch root Esc returned {picker_status}, want 130")
823
865
 
866
+ # Root Mode picker navigation: Esc from the Builder preset submenu
867
+ # returns to a freshly re-rendered Mode picker (Builder's own
868
+ # description reappears) rather than cancelling the launcher outright.
869
+ _, picker_status = run_picker_scenario(
870
+ "mode picker navigation",
871
+ (
872
+ (b"Esc cancel | q cancel", b"\r"),
873
+ (b"native multi-perspective review", b"\x1b"),
874
+ (b"Tune tiers, review routes, and permissions", b"q"),
875
+ ),
876
+ )
877
+ if picker_status != 130:
878
+ mark_fail(f"agent-launch mode picker Esc-back returned {picker_status}, want 130")
879
+
824
880
  if argv_log.exists():
825
881
  argv_log.unlink()
826
882
  _, picker_status = run_picker_scenario(
827
883
  "launch confirmation q cancellation",
828
884
  (
829
885
  (b"Esc cancel | q cancel", b"\r"),
886
+ (b"HELM default for everyday work", b"\r"),
830
887
  (b"Launch? [Y/n/q]:", b"q\r"),
831
888
  ),
832
889
  dry_run=False,
@@ -1128,10 +1185,19 @@ if launcher.is_file() and launch_profile:
1128
1185
  # TERM=dumb routes to numbered prompts (textual renders on any usable
1129
1186
  # terminal, so the numbered fallback is gated on TERM/non-TTY/textual
1130
1187
  # availability, not a terminfo probe). 'b' is the numbered back command.
1131
- custom_number = str(len(fake_data["presets"]) + 1).encode()
1188
+ # The root Mode menu is fixed (General=1, Builder=2, Session distill=3)
1189
+ # regardless of preset count; only Custom's position within Builder's
1190
+ # own preset submenu depends on how many builder-mode presets exist.
1191
+ builder_preset_count = sum(
1192
+ 1
1193
+ for data in fake_data["presets"].values()
1194
+ if data.get("mode", "builder") == "builder"
1195
+ )
1196
+ custom_number = str(builder_preset_count + 1).encode()
1132
1197
  transcript, picker_status = run_picker_scenario(
1133
1198
  "numbered fallback (TERM=dumb)",
1134
1199
  (
1200
+ (b"Tune tiers, review routes, and permissions", b"2\n"),
1135
1201
  (b"Open a settings hub", custom_number + b"\n"),
1136
1202
  (b"Custom settings", b"b\n"),
1137
1203
  (b"HELM default for everyday work", b"1\n"),
@@ -1145,27 +1211,27 @@ if launcher.is_file() and launch_profile:
1145
1211
  ):
1146
1212
  mark_fail("agent-launch numbered fallback (TERM=dumb) did not preserve numbered back")
1147
1213
 
1148
- # Session Learning hub: enter, render status, back out, launch balanced.
1149
- learning_number = str(len(fake_data["presets"]) + 2).encode()
1214
+ # Session Distill hub: enter, render status, back out, launch balanced.
1150
1215
  transcript, picker_status = run_picker_scenario(
1151
- "numbered learning hub (TERM=dumb)",
1216
+ "numbered distill hub (TERM=dumb)",
1152
1217
  (
1153
- (b"Session Learning", learning_number + b"\n"),
1218
+ (b"Session distill", b"3\n"),
1154
1219
  (b"Versions & rollback", b"4\n"),
1220
+ (b"Tune tiers, review routes, and permissions", b"2\n"),
1155
1221
  (b"HELM default for everyday work", b"1\n"),
1156
1222
  ),
1157
1223
  term="dumb",
1158
1224
  )
1159
- learning_panel_rendered = (
1225
+ corpus_panel_rendered = (
1160
1226
  b"Applied version" in transcript or b"not projected yet" in transcript
1161
1227
  )
1162
1228
  if (
1163
1229
  picker_status != 0
1164
1230
  or b"Traceback" in transcript
1165
- or not learning_panel_rendered
1231
+ or not corpus_panel_rendered
1166
1232
  or b"Preset Balanced" not in transcript
1167
1233
  ):
1168
- mark_fail("agent-launch numbered learning hub did not render or return")
1234
+ mark_fail("agent-launch numbered distill hub did not render or return")
1169
1235
 
1170
1236
  passed = invoke([
1171
1237
  sys.executable, str(launcher), "--no-tui", "codex", "--", "exec", "--json", "probe"
@@ -1492,6 +1558,29 @@ if launcher.is_file() and launch_profile:
1492
1558
  "agent-launch Claude expert overrides were not appended last"
1493
1559
  )
1494
1560
 
1561
+ # Vanilla (mode=general): the raw backend with nothing applied — no
1562
+ # launch contract, no agents, no permission-bypass flag.
1563
+ vanilla_configured = invoke([
1564
+ sys.executable, str(launcher), "--preset", "vanilla", "--yes", "--dry-run",
1565
+ "claude",
1566
+ ], env=env)
1567
+ if vanilla_configured.returncode != 0:
1568
+ mark_fail(
1569
+ f"agent-launch vanilla dry-run failed: {vanilla_configured.stderr.strip()}"
1570
+ )
1571
+ else:
1572
+ vanilla_argv = json.loads(vanilla_configured.stdout.splitlines()[-1])
1573
+ for forbidden in (
1574
+ "--append-system-prompt", "--agents", "--dangerously-skip-permissions",
1575
+ ):
1576
+ if forbidden in vanilla_argv:
1577
+ mark_fail(f"agent-launch vanilla preset unexpectedly carried {forbidden}")
1578
+ if vanilla_argv != [str(backend)]:
1579
+ mark_fail(
1580
+ "agent-launch vanilla preset must project the bare backend with "
1581
+ f"nothing applied: {vanilla_argv!r}"
1582
+ )
1583
+
1495
1584
  # Cross-family default: each main routes every review route to the OPPOSITE
1496
1585
  # model family, with concrete tools/paths/bindings named in the contract.
1497
1586
  # The cross CODEX_HOME needs the reviewer wrappers (bin) and the same-family
@@ -1766,5 +1855,5 @@ then
1766
1855
  fail=1
1767
1856
  fi
1768
1857
 
1769
- [ "$fail" -eq 0 ] && echo "PARITY OK: mirrors, globals, guides, launch profile, bypass paths, role bindings, and wrapper defaults aligned"
1858
+ [ "$fail" -eq 0 ] && echo "PARITY OK: mirrors, globals, guides, domain manifest, assembler, launch profile, bypass paths, role bindings, and wrapper defaults aligned"
1770
1859
  exit "$fail"