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/README.md +4 -4
- package/claude/guides/cli-multi-model-workflow.md +46 -0
- package/claude/guides/coding-staged-workflow.md +1 -0
- package/claude/guides/review-defect-criteria.md +205 -0
- package/claude/guides/review-request.md +15 -1
- 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 +46 -0
- package/codex/guides/coding-staged-workflow.md +1 -0
- package/codex/guides/review-defect-criteria.md +205 -0
- package/codex/guides/review-request.md +15 -1
- package/compose/assemble.py +191 -21
- package/compose/canary.sh +13 -0
- package/compose/check-domains.py +144 -20
- package/compose/domains.json +4 -0
- package/compose/prune-backups.py +57 -7
- package/install.sh +396 -27
- package/launch/agent-launch.py +3976 -595
- package/launch/agent-launch.toml +10 -4
- package/launch/agent-launch.zsh +7 -2
- package/launch/i18n/en.toml +127 -7
- package/launch/i18n/ja.toml +126 -7
- package/launch/i18n/ko.toml +126 -7
- 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 +3 -2
- package/provenance.json +1 -1
- package/session-cost.py +402 -33
- package/wrappers/claude-run.sh +49 -4
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
|
@@ -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
|
}
|
package/compose/prune-backups.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|