agent-bios 0.10.0 → 0.12.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.
@@ -22,8 +22,10 @@ env-personal is never assembled. The domains gate must be green first.
22
22
  """
23
23
  import argparse
24
24
  import json
25
+ import os
25
26
  import pathlib
26
27
  import re
28
+ import shlex
27
29
  import shutil
28
30
  import subprocess
29
31
  import sys
@@ -37,6 +39,14 @@ PERSONAL_IMPORT_LINE = "@personal/learnings.md"
37
39
  CLAUDE_VAR = "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
38
40
  CODEX_VAR = "${CODEX_HOME:-$HOME/.codex}"
39
41
  CODEX_ONLY_PREFIX = "- Codex-only standing authorization:"
42
+ # Where that bullet belongs. Owned here because the payload cannot import from gates/,
43
+ # and `gates/emit-mirrors.py` — which pins the same position in the STATIC projection —
44
+ # imports it from this module instead, the same direction check-package.sh already takes
45
+ # for `author_only`. The two were independent before, and they disagreed: the projection
46
+ # put the bullet under this heading while the assembler appended it to the end of the
47
+ # bundle, so the deployed AGENTS.md filed a multi-model rule under whatever section
48
+ # happened to come last.
49
+ CODEX_ONLY_ANCHOR = "## Multi-Model Workflow"
40
50
 
41
51
  ENTRY_SEED = f"""# CLAUDE.md
42
52
 
@@ -99,6 +109,30 @@ def parse_monolith(text):
99
109
  return title, sections
100
110
 
101
111
 
112
+ def place_codex_only(bundle, bullet):
113
+ """Put the Codex-only bullet first under CODEX_ONLY_ANCHOR, or refuse.
114
+
115
+ Appending was the old behaviour and it is what put a multi-model rule under Session
116
+ Learning in every deployed AGENTS.md: the bundle's sections are whatever the selection
117
+ kept, so "the end" is a different heading depending on what the user installed.
118
+
119
+ Refuses rather than falling back to appending. The bullet is only added when
120
+ `multi-agent-orchestration` is selected, and that domain is what carries the anchor
121
+ heading, so a missing anchor means the bundle is not the shape this rule assumes —
122
+ quietly filing the rule somewhere else is how the defect looked in the first place.
123
+ """
124
+ lines = bundle.split("\n")
125
+ hits = [i for i, line in enumerate(lines) if line == CODEX_ONLY_ANCHOR]
126
+ if len(hits) != 1:
127
+ die(f"codex bundle must hold exactly one {CODEX_ONLY_ANCHOR!r} to place "
128
+ f"{CODEX_ONLY_PREFIX!r} under; found {len(hits)}")
129
+ index = hits[0] + 1
130
+ while index < len(lines) and not lines[index].strip():
131
+ index += 1
132
+ lines.insert(index, bullet)
133
+ return "\n".join(lines)
134
+
135
+
102
136
  def build_bundle(monolith_text, manifest, selection, tool):
103
137
  entry_of = {}
104
138
  for e in manifest["bullets"]:
@@ -226,12 +260,51 @@ def copy_filtered(src_dir, names, dest, rewrite=None, dry=False, backup=None):
226
260
  target.write_text(body, encoding="utf-8")
227
261
 
228
262
 
263
+ def replace_atomically(path, text):
264
+ """Write via a temp file + os.replace, so a write that fails partway cannot truncate.
265
+
266
+ Every file this is used on is one the USER owns and edits — their settings.json, their
267
+ AGENTS.md — and a plain write_text truncates first and fills after. A short write (a full
268
+ disk, a crash) left AGENTS.md holding a fragment of our marker and none of their text,
269
+ with no copy in reach: the backup taken beside these calls is of the PREVIOUS content,
270
+ which is exactly what a half-written file destroys the value of.
271
+
272
+ learn/migrate-learnings.py has had this discipline and states the reason; it now shares
273
+ this one implementation rather than keeping a second. The temp file is a sibling so the
274
+ replace stays on one filesystem, where os.replace is atomic.
275
+ """
276
+ path.parent.mkdir(parents=True, exist_ok=True)
277
+ tmp = path.with_name(path.name + ".tmp-agent-bios")
278
+ try:
279
+ tmp.write_text(text, encoding="utf-8")
280
+ os.replace(tmp, path)
281
+ finally:
282
+ if tmp.exists():
283
+ tmp.unlink()
284
+
285
+
229
286
  def hook_command_matches(command, name):
230
- """A complete `/hooks/<name>` token: the command ends with it, or it is followed by an
231
- argument. One matcher for the merge below AND the domain gate's settings leg — the gate
232
- used a substring test, so `central/hooks/<name>.disabled` passed the gate while this
233
- merge skipped it, and the install carried no hook under a green gate."""
234
- return command.endswith("/hooks/" + name) or f"/hooks/{name} " in command + " "
287
+ """Does an argv TOKEN of this command end in `/hooks/<name>`?
288
+
289
+ One matcher for the merge below AND the domain gate's settings leg — the gate used a
290
+ substring test, so `central/hooks/<name>.disabled` passed the gate while this merge
291
+ skipped it, and the install carried no hook under a green gate.
292
+
293
+ Split as a shell would, not delimited by spaces. The registration this file writes is
294
+ `shlex.quote`d so a config home containing a space works, and the quoting puts a `'`
295
+ right after the filename — so a matcher wanting end-of-string or a following space
296
+ could not recognize the line it had just written, and every reinstall on a
297
+ `/Users/First Last` home appended another copy of the same hook. Splitting the way the
298
+ shell will is the only reading that survives its own quoting.
299
+
300
+ Still a PATH match and never a bare name: `wrap.py --inner <name>` passes the name as
301
+ an argument to somebody else's wrapper, and deleting a stranger's hook is not a right
302
+ this ownership rule ever claimed."""
303
+ try:
304
+ tokens = shlex.split(command)
305
+ except ValueError: # unbalanced quotes: not a command we wrote
306
+ tokens = command.split()
307
+ return any(token.endswith("/hooks/" + name) for token in tokens)
235
308
 
236
309
 
237
310
  def merge_settings(claude_dir, hook_names, template_path, dry=False, owned_names=None):
@@ -250,15 +323,41 @@ def merge_settings(claude_dir, hook_names, template_path, dry=False, owned_names
250
323
  """
251
324
  owned = set(owned_names if owned_names is not None else hook_names)
252
325
  spath = claude_dir / "settings.json"
253
- settings = json.loads(spath.read_text(encoding="utf-8")) if spath.exists() else {}
326
+ existed = spath.exists()
327
+ settings = json.loads(spath.read_text(encoding="utf-8")) if existed else {}
254
328
  template = json.loads(template_path.read_text(encoding="utf-8")) if template_path.exists() else {}
255
329
  hooks = settings.setdefault("hooks", {})
256
330
 
257
- def ours(en):
258
- return any(n in h.get("command", "") for h in en.get("hooks", []) for n in owned)
259
-
260
- for event, entries in list(hooks.items()): # drop every entry we own
261
- hooks[event] = [en for en in entries if not ours(en)]
331
+ def ours(hook):
332
+ # The same matcher the re-add below uses, which `hook_command_matches` already
333
+ # describes itself as being ("one matcher for the merge below AND the domain
334
+ # gate"). Only the ADD half honoured that; removal asked whether the name appeared
335
+ # anywhere in the command, so a user's own entry was deleted for mentioning ours
336
+ # `wrap.py --inner tooling-gotchas-hook.py`, a `.bak` copy, a directory named after
337
+ # it. Deleting a stranger's hook out of their settings is not something the
338
+ # ownership rule above ever claimed the right to do.
339
+ return any(hook_command_matches(hook.get("command", ""), n) for n in owned)
340
+
341
+ # Pruned per HOOK, not per entry. Ownership is a name on one command, and the entry is
342
+ # a matcher that can hold several — so `{"matcher": "Bash", "hooks": [ours, theirs]}`
343
+ # answered "ours" and took the stranger's hook with it. That is the same overreach the
344
+ # matcher above was narrowed to stop, one level up: the granularity of the removal has
345
+ # to match the granularity of the claim. An entry left with no hooks was only ever ours,
346
+ # so it still goes whole.
347
+ for event, entries in list(hooks.items()):
348
+ pruned = []
349
+ for en in entries:
350
+ children = en.get("hooks") if isinstance(en, dict) else None
351
+ if not isinstance(children, list):
352
+ pruned.append(en) # not a shape we wrote; not ours to judge
353
+ continue
354
+ keep = [h for h in children if not (isinstance(h, dict) and ours(h))]
355
+ if len(keep) == len(children):
356
+ pruned.append(en) # nothing of ours in it — untouched
357
+ elif keep:
358
+ en["hooks"] = keep # ours dropped, their siblings stay where they are
359
+ pruned.append(en)
360
+ hooks[event] = pruned
262
361
  for event, entries in template.get("hooks", {}).items(): # re-add per selection
263
362
  for en in entries:
264
363
  cmds = [h.get("command", "") for h in en.get("hooks", [])]
@@ -268,16 +367,32 @@ def merge_settings(claude_dir, hook_names, template_path, dry=False, owned_names
268
367
  continue
269
368
  clone = json.loads(json.dumps(en))
270
369
  for h in clone.get("hooks", []):
370
+ # Quoted, because this lands in a shell command line. A config home with a
371
+ # space in it — `/Users/First Last/.claude` — split into separate argv
372
+ # words, so the shell tried to run `/Users/First` and the deployed hook
373
+ # simply never fired. Nothing reported it: a hook that does not run looks
374
+ # exactly like a hook with nothing to say.
375
+ #
376
+ # A function replacement rather than a string one: shlex.quote emits
377
+ # backslashes for some paths and re.sub reads those as group references in
378
+ # a replacement string.
379
+ target = shlex.quote(str(claude_dir / "central" / "hooks" / owner))
271
380
  h["command"] = re.sub(r"\S*/hooks/" + re.escape(owner),
272
- str(claude_dir / "central" / "hooks" / owner),
381
+ lambda _match, value=target: value,
273
382
  h["command"])
274
383
  hooks.setdefault(event, []).append(clone)
275
384
  if dry:
276
385
  print(f" [dry] merge settings.json ({len(hook_names)} central hooks)")
277
386
  return
278
- if spath.exists():
387
+ if not existed and not any(hooks.values()):
388
+ # Nothing of ours to register and no file to preserve. Writing one would create a
389
+ # settings.json the user did not have — and this branch is reached by uninstall on a
390
+ # machine where the claude dir is gone, where creating the directory to hold it
391
+ # crashed on a missing parent instead.
392
+ return
393
+ if existed:
279
394
  shutil.copy2(spath, spath.with_suffix(f".json.bak-{time.strftime('%Y%m%d-%H%M%S')}"))
280
- spath.write_text(json.dumps(settings, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
395
+ replace_atomically(spath, json.dumps(settings, indent=2, ensure_ascii=False) + "\n")
281
396
 
282
397
 
283
398
  def seed_personal_learnings(claude_dir, dry=False):
@@ -335,7 +450,21 @@ def seed_entry(claude_dir, legacy_monolith, prior_deployed=(), dry=False):
335
450
  return "needs-action" # user content without the import line: report, never rewrite
336
451
 
337
452
 
338
- def merge_codex(codex_dir, central_text, dry=False):
453
+ def merge_codex(codex_dir, central_text, prior_deployed=(), dry=False):
454
+ """Write the central region into AGENTS.md, and never silence a file the user wrote.
455
+
456
+ A missing marker pair used to mean "legacy whole-file deploy", so any AGENTS.md without
457
+ them had its active body replaced by the region plus an empty `## Personal`. Every Codex
458
+ user who wrote an AGENTS.md before installing has exactly that file, and their
459
+ instructions stopped loading on the first install — the backup made it recoverable, not
460
+ noticed. Absence of a marker is absence of evidence, in both directions.
461
+
462
+ So the same evidence seed_entry uses on the Claude side decides it here: `prior_deployed`
463
+ is the previous install's manifest, and a path in it is ours by record. Anything else is
464
+ theirs, and the markers are adopted ABOVE their text rather than over it — which is what
465
+ the marker pair is for, and it leaves nothing of theirs unloaded. Nothing is lost that
466
+ way, so that branch needs no backup; the by-record branch keeps the one it always had.
467
+ """
339
468
  agents = codex_dir / "AGENTS.md"
340
469
  region = f"{MARK_START}\n{central_text}{MARK_END}\n"
341
470
  if agents.exists():
@@ -344,17 +473,21 @@ def merge_codex(codex_dir, central_text, dry=False):
344
473
  pre, rest = body.split(MARK_START, 1)
345
474
  _, post = rest.split(MARK_END, 1)
346
475
  new = pre + region + post
347
- else: # legacy whole-file deploy: back up, adopt markers, keep nothing implicit
476
+ elif str(pathlib.Path(agents)) in prior_deployed:
477
+ # Ours by record: an earlier release deployed this file whole, so replacing it
478
+ # with the marked shape is the upgrade, not a loss.
348
479
  if not dry:
349
480
  shutil.copy2(agents, agents.with_suffix(f".md.bak-legacy-{time.strftime('%Y%m%d-%H%M%S')}"))
350
481
  new = region + "\n## Personal\n"
482
+ else:
483
+ new = region + "\n" + body.lstrip("\n")
351
484
  else:
352
485
  new = region + "\n## Personal\n"
353
486
  if dry:
354
487
  print(f" [dry] write AGENTS.md central region ({len(central_text)} bytes)")
355
488
  return
356
489
  codex_dir.mkdir(parents=True, exist_ok=True)
357
- agents.write_text(new, encoding="utf-8")
490
+ replace_atomically(agents, new)
358
491
 
359
492
 
360
493
  def remove_owned(claude_dir, codex_dir, manifest, dry=False):
@@ -385,7 +518,7 @@ def remove_owned(claude_dir, codex_dir, manifest, dry=False):
385
518
  if dry:
386
519
  print(" [dry] strip AGENTS.md central region, keep everything outside the markers")
387
520
  return
388
- agents.write_text((pre + post).lstrip("\n"), encoding="utf-8")
521
+ replace_atomically(agents, (pre + post).lstrip("\n"))
389
522
 
390
523
 
391
524
  def main():
@@ -402,6 +535,11 @@ def main():
402
535
  "from it, so an earlier release's deployed CLAUDE.md is recognized as "
403
536
  "ours instead of being reported as the user's.")
404
537
  ap.add_argument("--dry-run", action="store_true")
538
+ ap.add_argument("--selected-skills", action="store_true",
539
+ help="print the names of the shipped skills the selection delivers, one "
540
+ "per line, and write nothing. install.sh deploys skills itself (a "
541
+ "skill is a tree the HOST scans, not a file under central/) and asks "
542
+ "here which ones, so the selection rule keeps one owner")
405
543
  args = ap.parse_args()
406
544
 
407
545
  import os
@@ -409,13 +547,28 @@ def main():
409
547
  codex_dir = pathlib.Path(args.codex_dir or os.environ.get("CODEX_HOME") or pathlib.Path.home() / ".codex")
410
548
  state_dir = pathlib.Path(args.state_dir or pathlib.Path.home() / ".local/share/agent-bios")
411
549
 
412
- manifest = json.loads((REPO / "compose" / "domains.json").read_text(encoding="utf-8"))
413
550
  if args.remove_owned:
414
551
  # No domains gate: removal does not depend on the manifest being well-formed, and an
415
552
  # uninstall that refuses to run because the corpus is mid-edit would strand the user.
553
+ # That was the claim; the parse sat ABOVE this branch and ran first, so a malformed
554
+ # domains.json raised out of uninstall before the branch that does not need it. The
555
+ # two halves of the removal need it differently: the Codex region is bounded by our
556
+ # markers and needs nothing, while the settings registrations are owned BY NAME and
557
+ # cannot be found without it. So the region goes either way, and a manifest we cannot
558
+ # read makes the registration half impossible rather than skippable — the caller has
559
+ # to hear that, not read a clean summary over it.
560
+ try:
561
+ manifest = json.loads((REPO / "compose" / "domains.json").read_text(encoding="utf-8"))
562
+ except (OSError, ValueError) as exc:
563
+ remove_owned(claude_dir, codex_dir, {"hooks": {}}, dry=args.dry_run)
564
+ die(f"the AGENTS.md central region was removed, but {REPO / 'compose' / 'domains.json'} "
565
+ f"could not be read ({exc}) — the hook registrations it names are STILL in "
566
+ f"settings.json. Restore that file and re-run uninstall.")
416
567
  remove_owned(claude_dir, codex_dir, manifest, dry=args.dry_run)
417
568
  return
418
569
 
570
+ manifest = json.loads((REPO / "compose" / "domains.json").read_text(encoding="utf-8"))
571
+
419
572
  gate = subprocess.run([sys.executable, str(REPO / "compose" / "check-domains.py")],
420
573
  capture_output=True, text=True)
421
574
  if gate.returncode != 0:
@@ -431,6 +584,16 @@ def main():
431
584
  if unknown:
432
585
  die(f"unknown domains: {sorted(unknown)} (known: {sorted(manifest['domains'])})")
433
586
 
587
+ if args.selected_skills:
588
+ # The same two rules the guides get: the manifest's audience, then the file's own
589
+ # `audience: author` declaration — the gate that tolerates author-side paths in a
590
+ # declared file assumes the assembler withholds it, and a skill deployed by
591
+ # install.sh from this list must keep that assumption true.
592
+ for name in filtered_files(manifest, "skills", selection):
593
+ if not author_only(REPO / "claude" / "skills" / name / "SKILL.md"):
594
+ print(name)
595
+ return
596
+
434
597
  monolith = (REPO / "claude" / "CLAUDE.md").read_text(encoding="utf-8")
435
598
  bundle, n_bullets = build_bundle(monolith, manifest, selection, "claude")
436
599
  codex_src = (REPO / "codex" / "AGENTS.md").read_text(encoding="utf-8")
@@ -438,7 +601,7 @@ def main():
438
601
  if "multi-agent-orchestration" in selection:
439
602
  codex_only = next((ln for ln in codex_src.splitlines() if ln.startswith(CODEX_ONLY_PREFIX)), None)
440
603
  if codex_only:
441
- codex_bundle += codex_only + "\n"
604
+ codex_bundle = place_codex_only(codex_bundle, codex_only)
442
605
 
443
606
  guides = filtered_files(manifest, "guides", selection)
444
607
  withheld = [n for n in guides if author_only(REPO / "claude" / "guides" / n)]
@@ -492,7 +655,7 @@ def main():
492
655
  prior.read_text(encoding="utf-8").splitlines() if ln.strip()}
493
656
  entry_state = seed_entry(claude_dir, monolith, prior_deployed, dry=dry)
494
657
 
495
- merge_codex(codex_dir, codex_bundle, dry=dry)
658
+ merge_codex(codex_dir, codex_bundle, prior_deployed, dry=dry)
496
659
  copy_filtered(REPO / "codex" / "guides", guides, codex_dir / "guides", dry=dry,
497
660
  backup=run_backup)
498
661
 
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
 
@@ -114,5 +114,8 @@
114
114
  "frontier.md": {"tier": "domain", "domains": ["multi-agent-orchestration"]},
115
115
  "sweep.md": {"tier": "domain", "domains": ["multi-agent-orchestration"]},
116
116
  "workhorse.md": {"tier": "domain", "domains": ["multi-agent-orchestration"]}
117
+ },
118
+ "skills": {
119
+ "repo-charter": {"tier": "domain", "domains": ["builder-base"]}
117
120
  }
118
121
  }