agent-bios 0.11.0 → 0.12.1

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.
package/compose/canary.sh CHANGED
@@ -34,6 +34,7 @@ command -v claude >/dev/null 2>&1 || { echo "CANARY SKIP: claude CLI not found
34
34
 
35
35
  probe="Somewhere in your loaded instruction context there may be a line that starts with 'agent-bios-bundle-rev:'. Reply with ONLY that line, verbatim. If no such line is in your context, reply with exactly: BUNDLE-NOT-LOADED"
36
36
  out="$(cd "$HOME" && claude -p "$probe" 2>/dev/null)"
37
+ probe_status=$?
37
38
 
38
39
  if printf '%s' "$out" | grep -qF "$expected"; then
39
40
  # Record WHICH bundle was proven to load. This is the only evidence in the system that
@@ -64,6 +65,18 @@ if [ -z "$out" ]; then
64
65
  echo "CANARY SKIP: probe produced no output — cannot tell activation from a failed dispatch"
65
66
  exit 3
66
67
  fi
68
+ # The 1-vs-3 rule this file opens with, applied to the one signal it was not reading:
69
+ # the probe's own exit status. An empty reply was already treated as unprobed, but a
70
+ # FAILED dispatch that printed anything at all — a network error, an unrecognised flag,
71
+ # a CLI upgrade changing its error text — fell through to FAIL and sent the reader off
72
+ # to re-approve imports for a bundle that was never asked about. stderr is discarded
73
+ # above, so the message is quoted for whatever it is worth and the status decides.
74
+ if [ "$probe_status" -ne 0 ]; then
75
+ echo "CANARY SKIP: the probe command failed (exit $probe_status) — cannot tell activation"
76
+ echo " from a failed dispatch"
77
+ echo " probe replied: $(printf '%s' "$out" | head -c 200)"
78
+ exit 3
79
+ fi
67
80
 
68
81
  echo "CANARY FAIL: central bundle is NOT loading in live sessions."
69
82
  echo " expected marker: $expected"
@@ -11,8 +11,10 @@ Blocking checks (any violation exits 1, all violations listed):
11
11
  registry; domains list non-empty iff tier == "domain")
12
12
  2. bullet bijection: every manifest anchor matches exactly ONE `- ` bullet
13
13
  in claude/CLAUDE.md, and every bullet is claimed by exactly ONE entry
14
- 3. file coverage: every file in claude/guides|hooks|agents claimed exactly
15
- once; every claimed file exists on disk
14
+ 3. file coverage: every file in claude/guides|hooks|agents and every skill
15
+ directory in claude/skills claimed exactly once; every claimed one exists
16
+ on disk (a skill is a directory carrying SKILL.md — a directory there
17
+ without one is malformed, not unclaimed)
16
18
  4. router-guide co-package, both directions: a bullet or guide referencing a
17
19
  guide must have an audience covered by that guide's audience, and the target
18
20
  must be one an install actually receives; every guide must be pointed at by
@@ -47,7 +49,29 @@ FILE_SECTIONS = { # manifest key -> corpus dir, glob
47
49
  "guides": ("claude/guides", "*.md"),
48
50
  "hooks": ("claude/hooks", "*"),
49
51
  "agents": ("claude/agents", "*.md"),
52
+ # A skill's unit is its DIRECTORY (SKILL.md + free subdirectories), deployed as a
53
+ # tree by install.sh and asked of the assembler by name (`--selected-skills`), so the
54
+ # manifest classifies the directory name, not the files inside it.
55
+ "skills": ("claude/skills", "*"),
50
56
  }
57
+ SKILL_MARKER = "SKILL.md"
58
+
59
+
60
+ def units_on_disk(root, glob, key):
61
+ """The names the manifest section `key` must claim: files, or for skills the
62
+ directories that carry SKILL.md. Returns (names, malformed) — a skills subdirectory
63
+ without the marker is neither claimable nor ignorable."""
64
+ names, malformed = set(), []
65
+ for p in root.glob(glob):
66
+ if key == "skills":
67
+ if p.is_dir():
68
+ if (p / SKILL_MARKER).is_file():
69
+ names.add(p.name)
70
+ else:
71
+ malformed.append(p.name)
72
+ elif p.is_file():
73
+ names.add(p.name)
74
+ return names, malformed
51
75
 
52
76
 
53
77
  def load_manifest(path=MANIFEST):
@@ -173,8 +197,21 @@ def refs_from(text, guides):
173
197
 
174
198
  def run_gate(manifest, bullets, repo=REPO):
175
199
  errors = []
176
- tiers = set(manifest.get("tiers", []))
177
- domains_reg = set(manifest.get("domains", {}))
200
+
201
+ def name_set(value, key):
202
+ """The registry's names, or an error and an empty set.
203
+
204
+ This gate's contract is that shape violations exit 1 with every violation LISTED.
205
+ `set(None)` raises instead, so a `"domains": null` — parse-valid JSON, and exactly
206
+ the shape a bad hand-edit leaves — came out of the gate as a TypeError traceback
207
+ before it could name anything at all."""
208
+ if isinstance(value, (list, dict, tuple, set)):
209
+ return set(value)
210
+ errors.append(f"registry: {key} must be a list or object, got {type(value).__name__}")
211
+ return set()
212
+
213
+ tiers = name_set(manifest.get("tiers", []), "tiers")
214
+ domains_reg = name_set(manifest.get("domains", {}), "domains")
178
215
  if not tiers or not domains_reg:
179
216
  errors.append("registry: tiers/domains registry empty")
180
217
 
@@ -188,6 +225,25 @@ def run_gate(manifest, bullets, repo=REPO):
188
225
  if not pkgid.is_valid(pid):
189
226
  errors.append(f"package_id: {pid!r} is not @scope/name (lowercase, hyphen-separated)")
190
227
 
228
+ # A registered domain with nothing in it. The gate asserts its subject sets are
229
+ # non-empty so a green run cannot be vacuous, and the DOMAIN registry was the one set it
230
+ # never asked that of: a name could be selectable, appear in the picker, be written into
231
+ # selection.json — and deliver only the universal tier, which every other selection
232
+ # delivers too. A domain that changes nothing about what you receive is a promise the
233
+ # assembler cannot keep, and nothing downstream can notice.
234
+ claimed = set()
235
+ for entry in manifest.get("bullets", []):
236
+ if isinstance(entry, dict):
237
+ claimed.update(d for d in entry.get("domains", []) if isinstance(d, str))
238
+ for kind in FILE_SECTIONS:
239
+ for entry in (manifest.get(kind) or {}).values():
240
+ if isinstance(entry, dict):
241
+ claimed.update(d for d in entry.get("domains", []) if isinstance(d, str))
242
+ for name in sorted(domains_reg - claimed):
243
+ errors.append(
244
+ f"domain {name!r} is registered but no bullet, guide, hook, agent or skill is in "
245
+ f"it — selecting it would deliver exactly what selecting nothing delivers")
246
+
191
247
  def check_registry(entry, ctx):
192
248
  if entry.get("tier") not in tiers:
193
249
  errors.append(f"{ctx}: tier {entry.get('tier')!r} not in registry")
@@ -231,9 +287,12 @@ def run_gate(manifest, bullets, repo=REPO):
231
287
  entries[key] = section
232
288
  if not section:
233
289
  errors.append(f"non-vacuity: manifest section {key!r} empty")
234
- on_disk = {p.name for p in (repo / rel).glob(glob) if p.is_file()}
290
+ on_disk, malformed = units_on_disk(repo / rel, glob, key)
235
291
  if not on_disk:
236
292
  errors.append(f"non-vacuity: no files on disk under {rel}")
293
+ for name in malformed:
294
+ errors.append(f"{key}: directory {name} in {rel} carries no {SKILL_MARKER} — "
295
+ f"not a skill the hosts can load, and not a name the manifest can claim")
237
296
  for name, entry in section.items():
238
297
  check_registry(entry, f"{key}/{name}")
239
298
  if name not in on_disk:
@@ -601,25 +660,43 @@ def self_test(manifest, bullets):
601
660
  muts = []
602
661
  m1 = copy.deepcopy(manifest)
603
662
  m1["bullets"] = m1["bullets"][1:]
604
- muts.append(("dropped bullet entry", m1, bullets))
663
+ muts.append(("dropped bullet entry", m1, bullets, "bijection"))
605
664
  m2 = copy.deepcopy(manifest)
606
665
  m2["bullets"][0]["anchor"] = "zz-no-such-phrase-zz"
607
- muts.append(("anchor matches nothing (reword drift)", m2, bullets))
666
+ muts.append(("anchor matches nothing (reword drift)", m2, bullets, "anchor"))
608
667
  m3 = copy.deepcopy(manifest)
609
668
  m3["bullets"][1]["anchor"] = m3["bullets"][0]["anchor"]
610
- muts.append(("duplicate anchor claim", m3, bullets))
669
+ muts.append(("duplicate anchor claim", m3, bullets, "anchor"))
611
670
  m4 = copy.deepcopy(manifest)
612
671
  first_guide = next(iter(m4["guides"]))
613
672
  del m4["guides"][first_guide]
614
- muts.append((f"unclaimed guide {first_guide}", m4, bullets))
673
+ muts.append((f"unclaimed guide {first_guide}", m4, bullets, "unclaimed"))
615
674
  b5 = bullets + ["- a brand new bullet the manifest never heard of"]
616
- muts.append(("bullet added without manifest entry", copy.deepcopy(manifest), b5))
675
+ muts.append(("bullet added without manifest entry", copy.deepcopy(manifest), b5, "bijection"))
617
676
  m6 = copy.deepcopy(manifest)
618
677
  m6["package_id"] = "@Acme/Builder" # uppercase is not a legal segment
619
- muts.append(("malformed package_id", m6, bullets))
678
+ muts.append(("malformed package_id", m6, bullets, "package_id"))
679
+ # A registry name with nothing in it. It is selectable, it is written into
680
+ # selection.json, and it delivers exactly the universal tier every other selection also
681
+ # delivers — so nothing downstream can tell it apart from choosing nothing at all.
682
+ m8 = copy.deepcopy(manifest)
683
+ m8["domains"]["empty-domain-control"] = "registered with no subjects"
684
+ muts.append(("a registered domain with no subjects", m8, bullets, "empty-domain-control"))
620
685
  m7 = copy.deepcopy(manifest)
621
686
  m7["hooks"] = {"renamed-hook.py": {"tier": "core", "domains": []}}
622
- muts.append(("settings registers a hook the manifest does not declare", m7, bullets))
687
+ muts.append(("settings registers a hook the manifest does not declare", m7, bullets, "settings"))
688
+ # Skills are directory units. Both directions of coverage, and the subject asserted
689
+ # first: a self-test that finds no shipped skill would "catch" its own absence.
690
+ if not manifest.get("skills"):
691
+ raise SystemExit("self-test: the manifest declares no skill, so the skill-coverage "
692
+ "controls have no subject and would pass vacuously")
693
+ m9 = copy.deepcopy(manifest)
694
+ first_skill = next(iter(m9["skills"]))
695
+ del m9["skills"][first_skill]
696
+ muts.append((f"unclaimed skill directory {first_skill}", m9, bullets, "not claimed"))
697
+ m10 = copy.deepcopy(manifest)
698
+ m10["skills"]["no-such-skill-control"] = {"tier": "domain", "domains": ["builder-base"]}
699
+ muts.append(("a claimed skill with no directory on disk", m10, bullets, "does not exist"))
623
700
 
624
701
  # A bullet that names a cross-domain guide in PROSE. This shipped for real: the reader
625
702
  # held the rule and could not hold the guide, and a path-only scan reported clean. The
@@ -638,13 +715,13 @@ def self_test(manifest, bullets):
638
715
  assert len(hits) == 1, "self-test: prose-router control could not locate its bullet"
639
716
  b8 = [b + f" (see the {gentry['handles'][0]})" if b is hits[0] else b for b in bullets]
640
717
  muts.append((f"bullet names {gname} in prose across a domain boundary",
641
- copy.deepcopy(manifest), b8))
718
+ copy.deepcopy(manifest), b8, "router"))
642
719
  # The dehyphenated PLURAL of the same promise: "the concept economy guides" resolved
643
720
  # to nothing while the singular was caught — the referring pattern must own plurals.
644
721
  spoken8 = gname[:-3].replace("-", " ")
645
722
  b8p = [b + f" — read the {spoken8} guides first" if b is hits[0] else b for b in bullets]
646
723
  muts.append((f"bullet names {gname} as a dehyphenated PLURAL prose reference",
647
- copy.deepcopy(manifest), b8p))
724
+ copy.deepcopy(manifest), b8p, "router"))
648
725
 
649
726
  # The orphan control removes a guide's ONLY pointer. It picks that guide by looking, not
650
727
  # from a name typed here: a typed name stops being the right subject the moment that guide
@@ -676,7 +753,7 @@ def self_test(manifest, bullets):
676
753
  raise SystemExit("self-test: the orphan control could not drop exactly one manifest "
677
754
  "entry with its bullet, so the mutation is not the one it claims")
678
755
  muts.append((f"guide {o_name} left with no pointer at all",
679
- m_orphan, [b for b in bullets if b != o_line]))
756
+ m_orphan, [b for b in bullets if b != o_line], "orphan"))
680
757
 
681
758
  # A bullet that exists but ships to NOBODY cannot root a guide: reclassify the
682
759
  # orphan guide's only router bullet as env-personal (assemble maps the tier to
@@ -696,6 +773,22 @@ def self_test(manifest, bullets):
696
773
  print(f"self-test [{'CAUGHT' if never_ok else 'MISSED'}] an env-personal (never-"
697
774
  f"delivered) router bullet leaves {o_name} an orphan")
698
775
 
776
+ # A registry of the wrong TYPE must be listed like any other violation. `set(None)` is
777
+ # not a diagnostic — it is a TypeError out of the gate before it can name anything, and
778
+ # `"domains": null` is exactly what a bad hand-edit of this file leaves behind. The
779
+ # contrast is the shape it should have: both must come back as errors, never as a raise.
780
+ for field, bad_value in (("domains", None), ("tiers", "core")):
781
+ m_shape = copy.deepcopy(manifest)
782
+ m_shape[field] = bad_value
783
+ try:
784
+ errs_shape, _ = run_gate(m_shape, bullets)
785
+ shape_ok = any(f"registry: {field} must be" in e for e in errs_shape)
786
+ except Exception as exc:
787
+ errs_shape, shape_ok = [], False
788
+ print(f"self-test [MISSED] {field}={bad_value!r} RAISED {type(exc).__name__}")
789
+ print(f"self-test [{'CAUGHT' if shape_ok else 'MISSED'}] a {field} registry of the "
790
+ f"wrong type is listed rather than raised")
791
+
699
792
  # One handle, one guide: copying a declared handle onto a second guide must fail as
700
793
  # a duplicate declaration, not silently resolve one prose router to both targets.
701
794
  m_dup = copy.deepcopy(manifest)
@@ -766,7 +859,7 @@ def self_test(manifest, bullets):
766
859
  m_cross = copy.deepcopy(manifest)
767
860
  m_cross["guides"][c_to] = {"tier": "domain", "domains": ["office-work"]}
768
861
  muts.append((f"guide {c_from} cites {c_to} after {c_to} moves to a domain it does not hold",
769
- m_cross, bullets))
862
+ m_cross, bullets, "router"))
770
863
 
771
864
  # Targeted: the settings leg must use the assembler's TOKEN rule, not a substring.
772
865
  # `.disabled` appended after the hook name is the reviewer-verified shape: substring
@@ -844,6 +937,22 @@ def self_test(manifest, bullets):
844
937
  print(f"self-test [{'CAUGHT' if not false_hits else 'MISSED'}] withheld {withheld} citing "
845
938
  f"{narrow} is exempt from audience coverage")
846
939
 
940
+ # A directory under claude/skills that carries no SKILL.md is malformed — the hosts
941
+ # cannot load it and the manifest cannot claim it — and must be named, not skipped
942
+ # the way a stray file under guides/ is. Planted in a throwaway copy of the tree.
943
+ with tempfile.TemporaryDirectory() as td:
944
+ tmp_sk = pathlib.Path(td)
945
+ for sub in ("claude", "ko", "launch"):
946
+ if (REPO / sub).is_dir():
947
+ shutil.copytree(REPO / sub, tmp_sk / sub)
948
+ (tmp_sk / "claude" / "skills" / "half-a-skill-control" / "notes").mkdir(parents=True)
949
+ errs_sk, _ = run_gate(manifest, bullets, repo=tmp_sk)
950
+ sk_ok = any("half-a-skill-control" in e and "no SKILL.md" in e for e in errs_sk)
951
+ print(f"self-test [{'CAUGHT' if sk_ok else 'MISSED'}] a skills/ directory without "
952
+ f"SKILL.md is reported as malformed")
953
+ if not sk_ok:
954
+ return ["a skills/ directory without SKILL.md was not reported"]
955
+
847
956
  # Launch missions: a deployed-form reference to a non-universal or withheld guide must
848
957
  # fail; the real checkout-form reference must stay clean (it is the distill mission's
849
958
  # deliberate shape, guarded by check-package instead).
@@ -1137,11 +1246,26 @@ def self_test(manifest, bullets):
1137
1246
  failed.append("handle-only reference resolves")
1138
1247
  if not handle_bounded:
1139
1248
  failed.append("handle embedded in a longer word must not resolve")
1140
- for name, mm, bb in muts:
1249
+ # A clean baseline first. Every row below reads "this mutation makes the gate complain",
1250
+ # and that sentence is only true if the unmutated manifest does NOT — otherwise each row
1251
+ # is reporting a pre-existing error as its own catch.
1252
+ baseline, _ = run_gate(manifest, bullets)
1253
+ if baseline:
1254
+ failed.append("baseline: the unmutated manifest already fails, so every mutation "
1255
+ f"below is judged against noise ({baseline[:1]})")
1256
+ print(f"self-test [MISSED] baseline is clean before mutating ({len(baseline)} errors)")
1257
+
1258
+ for name, mm, bb, expect in muts:
1141
1259
  errs, _ = run_gate(mm, bb)
1142
- if not errs:
1143
- failed.append(name)
1144
- print(f"self-test [{'CAUGHT' if errs else 'MISSED'}] {name}")
1260
+ # The mutation's OWN diagnostic, not merely some error. Asking only whether the list
1261
+ # is non-empty let an unrelated complaint stand in for the one the row exists to
1262
+ # provoke suppress the bijection checks entirely and this loop still reported
1263
+ # CAUGHT, because a malformed package id elsewhere in the same manifest kept the
1264
+ # list non-empty. A negative control that any failure satisfies tests nothing.
1265
+ hit = any(expect in e for e in errs)
1266
+ if not hit:
1267
+ failed.append(f"{name} (wanted a diagnostic naming {expect!r}, got {errs[:2]})")
1268
+ print(f"self-test [{'CAUGHT' if hit else 'MISSED'}] {name}")
1145
1269
  return failed
1146
1270
 
1147
1271
 
@@ -100,6 +100,7 @@
100
100
  "llm-capability-boundary-patterns.md": {"tier": "domain", "domains": ["llm-pipeline-dev"]},
101
101
  "llm-capability-boundary.md": {"tier": "domain", "domains": ["llm-pipeline-dev"]},
102
102
  "mock-realization-boundary.md": {"tier": "domain", "domains": ["builder-base"]},
103
+ "review-defect-criteria.md": {"tier": "domain", "domains": ["builder-base"]},
103
104
  "review-request.md": {"tier": "domain", "domains": ["builder-base"]},
104
105
  "session-distill-workflow.md": {"tier": "infra", "domains": []},
105
106
  "learning-flow.md": {"tier": "infra", "domains": []},
@@ -114,5 +115,8 @@
114
115
  "frontier.md": {"tier": "domain", "domains": ["multi-agent-orchestration"]},
115
116
  "sweep.md": {"tier": "domain", "domains": ["multi-agent-orchestration"]},
116
117
  "workhorse.md": {"tier": "domain", "domains": ["multi-agent-orchestration"]}
118
+ },
119
+ "skills": {
120
+ "repo-charter": {"tier": "domain", "domains": ["builder-base"]}
117
121
  }
118
122
  }
@@ -94,18 +94,29 @@ def prune(state_dir, homes, now=None, dry=False):
94
94
  now = time.time() if now is None else now
95
95
  removed = []
96
96
 
97
+ # Recorded AFTER the removal is confirmed gone. Both deleters swallow their errors on
98
+ # purpose — a backup that will not delete must not fail an install — but appending
99
+ # first made the returned list a record of what was ATTEMPTED, and the caller prints it
100
+ # as what was removed. A copy still on disk being reported as pruned is the reading
101
+ # that sends someone looking for space that was never freed.
102
+ def take(path, remove):
103
+ if dry:
104
+ removed.append(path)
105
+ return
106
+ remove(path)
107
+ if path.exists():
108
+ print(f" warning: could not remove {path}", file=sys.stderr)
109
+ return
110
+ removed.append(path)
111
+
97
112
  backups = state_dir / "backups"
98
113
  if backups.is_dir():
99
114
  for path in to_delete(_entries([d for d in backups.iterdir() if d.is_dir()]), now):
100
- removed.append(path)
101
- if not dry:
102
- shutil.rmtree(path, ignore_errors=True)
115
+ take(path, lambda p: shutil.rmtree(p, ignore_errors=True))
103
116
 
104
117
  for home in homes:
105
118
  for path in to_delete(_entries(owned_siblings(home)), now):
106
- removed.append(path)
107
- if not dry:
108
- path.unlink(missing_ok=True)
119
+ take(path, lambda p: p.unlink(missing_ok=True))
109
120
  return removed
110
121
 
111
122
 
@@ -163,7 +174,46 @@ def self_test():
163
174
  if not missed and not grabbed:
164
175
  print(f" ok ownership: claims {len(ours)} shapes we write, "
165
176
  f"refuses {len(theirs)} we do not")
166
- print(f"prune-backups self-test: {'OK' if not bad else 'FAIL'} ({len(cases) + 2} checks)")
177
+ # The reported list is what the caller prints as removed, so it has to be what is GONE.
178
+ # Both deleters swallow their errors by design — a stuck copy must not fail an install —
179
+ # and appending before the call made the list a record of attempts instead. Driven on a
180
+ # real directory, because the defect lives in the deletion and not in the pure retention
181
+ # rule above; the deletable run beside it is what keeps this from passing on a pruner
182
+ # that reports nothing at all.
183
+ import os as os_, tempfile as tempfile_
184
+
185
+ def reported_vs_gone(block_one):
186
+ root = pathlib.Path(tempfile_.mkdtemp())
187
+ state = root / "state"
188
+ (state / "backups").mkdir(parents=True)
189
+ made = []
190
+ for i in range(KEEP_RECENT + 2):
191
+ aged = state / "backups" / f"2020{i:04d}-000000"
192
+ aged.mkdir()
193
+ (aged / "f.txt").write_text("x")
194
+ stamp = now - (MAX_AGE_DAYS + 10 + i) * day
195
+ os_.utime(aged, (stamp, stamp))
196
+ made.append(aged)
197
+ stuck = sorted(made, key=lambda q: q.stat().st_mtime)[0]
198
+ if block_one:
199
+ stuck.chmod(0o500)
200
+ count = len(prune(state, [], now=now))
201
+ gone = len([q for q in made if not q.exists()])
202
+ if block_one:
203
+ stuck.chmod(0o700)
204
+ shutil.rmtree(root, ignore_errors=True)
205
+ return count, gone
206
+
207
+ for label, block_one in (("every aged backup deletable", False),
208
+ ("one of them undeletable", True)):
209
+ count, gone = reported_vs_gone(block_one)
210
+ if count != gone or (not block_one and gone == 0):
211
+ print(f" FAIL {label}: reported {count} removed, {gone} actually gone")
212
+ bad += 1
213
+ else:
214
+ print(f" ok {label}: reported {count} == gone {gone}")
215
+
216
+ print(f"prune-backups self-test: {'OK' if not bad else 'FAIL'} ({len(cases) + 4} checks)")
167
217
  return 1 if bad else 0
168
218
 
169
219