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.
- package/DEPENDENCIES.md +3 -3
- package/README.md +4 -4
- package/claude/guides/cli-multi-model-workflow.md +50 -4
- package/claude/guides/coding-staged-workflow.md +1 -1
- package/claude/guides/session-distill-workflow.md +3 -3
- package/claude/hooks/tooling-gotchas-hook.py +37 -2
- package/claude/skills/repo-charter/SKILL.md +149 -0
- package/codex/guides/cli-multi-model-workflow.md +50 -4
- package/codex/guides/coding-staged-workflow.md +1 -1
- package/codex/guides/session-distill-workflow.md +3 -3
- package/compose/assemble.py +184 -21
- package/compose/canary.sh +13 -0
- package/compose/check-domains.py +144 -20
- package/compose/domains.json +3 -0
- package/compose/prune-backups.py +57 -7
- package/install.sh +442 -30
- package/launch/agent-launch.py +4567 -659
- package/launch/agent-launch.toml +65 -110
- package/launch/agent-launch.zsh +7 -2
- package/launch/i18n/en.toml +186 -0
- package/launch/i18n/ja.toml +182 -0
- package/launch/i18n/ko.toml +182 -0
- package/learn/check-learning.py +19 -1
- package/learn/collect-learning.py +57 -2
- package/learn/migrate-learnings.py +140 -16
- package/learn/redact.py +14 -5
- package/package.json +4 -2
- package/provenance.json +1 -1
- package/session-cost.py +402 -33
- package/wrappers/claude-run.sh +49 -4
- package/wrappers/codex-run.sh +1 -1
package/compose/assemble.py
CHANGED
|
@@ -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
|
-
"""
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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(
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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"
|
package/compose/check-domains.py
CHANGED
|
@@ -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
|
|
15
|
-
once; every claimed
|
|
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
|
-
|
|
177
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
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
|
|
package/compose/domains.json
CHANGED
|
@@ -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
|
}
|