agent-bios 0.9.7 → 0.9.9
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 +19 -19
- package/README.md +34 -11
- package/claude/CLAUDE.md +2 -1
- package/claude/guides/claude-prompting.md +1 -1
- package/claude/guides/cli-multi-model-workflow.md +19 -1
- package/claude/guides/coding-staged-workflow.md +32 -0
- package/claude/guides/gpt-prompting.md +1 -1
- package/claude/guides/learning-flow.md +5 -5
- package/claude/guides/llm-capability-boundary.md +6 -1
- package/claude/guides/session-distill-workflow.md +19 -9
- package/claude/guides/tooling-gotchas.md +16 -0
- package/claude/hooks/__pycache__/tooling-gotchas-hook.cpython-314.pyc +0 -0
- package/claude/hooks/tooling-gotchas-hook.py +7 -0
- package/codex/AGENTS.md +2 -1
- package/codex/guides/claude-prompting.md +1 -1
- package/codex/guides/cli-multi-model-workflow.md +19 -1
- package/codex/guides/coding-staged-workflow.md +32 -0
- package/codex/guides/gpt-prompting.md +1 -1
- package/codex/guides/learning-flow.md +5 -5
- package/codex/guides/llm-capability-boundary.md +6 -1
- package/codex/guides/session-distill-workflow.md +19 -9
- package/codex/guides/tooling-gotchas.md +16 -0
- package/{scripts → compose}/assemble.py +209 -25
- package/{scripts → compose}/canary.sh +14 -5
- package/{scripts → compose}/check-domains.py +9 -3
- package/{config → compose}/domains.json +1 -0
- package/{scripts → compose}/pkgid.py +8 -1
- package/compose/prune-backups.py +204 -0
- package/compose/register-hooks.py +44 -0
- package/{scripts/install.sh → install.sh} +403 -94
- package/launch/agent-launch.py +5294 -0
- package/launch/agent-launch.toml +376 -0
- package/{scripts → launch}/check-prompting-targets.sh +1 -1
- package/{scripts → launch}/provision-venv.sh +1 -1
- package/{scripts → learn}/check-learning.py +7 -7
- package/{scripts → learn}/collect-learning.py +10 -10
- package/{config → learn}/learning.schema.json +3 -3
- package/{scripts → learn}/migrate-learnings.py +95 -54
- package/{scripts → learn}/redact.py +4 -4
- package/package.json +25 -23
- package/wrappers/claude-run.sh +162 -0
- package/{scripts → wrappers}/codex-run.sh +62 -6
- package/config/agent-launch.toml +0 -143
- package/scripts/agent-launch.py +0 -2350
- package/scripts/check-parity.sh +0 -2003
- /package/{shell → launch}/agent-launch.zsh +0 -0
- /package/{config → learn}/promotions.json +0 -0
- /package/{scripts/session-cost.py → session-cost.py} +0 -0
- /package/{scripts → wrappers}/codex-helm.sh +0 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Retention for the copies this repo makes before it overwrites or removes something.
|
|
3
|
+
|
|
4
|
+
A backup exists so a mistake is recoverable, so pruning is the one operation here that can
|
|
5
|
+
destroy the thing the feature is for. That decides the rule: a copy is deleted only when it is
|
|
6
|
+
BOTH older than the age window AND outside the most recent N. Either condition alone would be
|
|
7
|
+
enough to delete something someone still wants — a busy week would age out yesterday's work, and
|
|
8
|
+
a quiet quarter would trim a set small enough to keep whole.
|
|
9
|
+
|
|
10
|
+
Worked through, that single condition gives the intended behaviour:
|
|
11
|
+
|
|
12
|
+
20 copies made inside one month -> none are old enough; keep all 20
|
|
13
|
+
8 copies spread over two months -> none are outside the newest 10; keep all 8
|
|
14
|
+
11 copies, 7 of them this month -> the oldest is both aged and outside the 10; keep 10
|
|
15
|
+
|
|
16
|
+
Two groups are pruned, because this repo writes backups in two shapes: timestamped directories
|
|
17
|
+
under the state dir, and sibling backup files next to the file they copy (assemble, learn and
|
|
18
|
+
migrate write those). They are pruned independently — ten of each — since losing one group's
|
|
19
|
+
history to the other group's churn is not what either was kept for.
|
|
20
|
+
|
|
21
|
+
This module also answers WHICH loose files are ours (`OWNED_SIBLING`, `owned_siblings`), for
|
|
22
|
+
itself and for uninstall. Both delete, so the rule lives in one place.
|
|
23
|
+
|
|
24
|
+
--claude-dir/--codex-dir/--state-dir where to look
|
|
25
|
+
--dry-run print what would go, delete nothing
|
|
26
|
+
--list-owned print the owned loose backups, NUL-separated (uninstall reads this)
|
|
27
|
+
--self-test the retention rule and the ownership rule, both directions
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import os
|
|
33
|
+
import pathlib
|
|
34
|
+
import re
|
|
35
|
+
import shutil
|
|
36
|
+
import sys
|
|
37
|
+
import time
|
|
38
|
+
|
|
39
|
+
KEEP_RECENT = 10
|
|
40
|
+
MAX_AGE_DAYS = 30
|
|
41
|
+
|
|
42
|
+
# What counts as OUR loose backup — and the only place that question is answered.
|
|
43
|
+
# `install.sh` asks this module rather than restating the pattern, because both sides
|
|
44
|
+
# delete, and a rule stated twice is a rule that drifts on one side.
|
|
45
|
+
#
|
|
46
|
+
# The shapes below are exactly what this repo writes: compose/assemble.py (a plain
|
|
47
|
+
# timestamp for settings.json, `legacy` for a migrated entry file), learn/collect-learning.py,
|
|
48
|
+
# learn/migrate-learnings.py, and install.sh's codex-config removal. A bare `.bak-`
|
|
49
|
+
# substring — what this used to match — also claims `notes.bak-old`, which is the user's.
|
|
50
|
+
# These live in directories we SHARE with the user and with other tools, so ownership has
|
|
51
|
+
# to be carried by the name; the directory cannot confer it.
|
|
52
|
+
# The bare `.bak-<timestamp>` form carries no marker of its own, so it is pinned to the ONE
|
|
53
|
+
# basename that receives it — `settings.json`, from merge_settings. Left generic it also claimed
|
|
54
|
+
# a `notes.bak-20260101-000000` the user wrote, which is a correctly shaped timestamp on a file
|
|
55
|
+
# that is not ours; matching the timestamp SHAPE is not the same as matching our ownership.
|
|
56
|
+
_TS = r"\d{8}-\d{6}"
|
|
57
|
+
OWNED_SIBLING = re.compile(
|
|
58
|
+
rf"^(?:settings\.json\.bak-{_TS}"
|
|
59
|
+
rf"|.+\.bak-(?:legacy|learn|migrate)-{_TS}"
|
|
60
|
+
rf"|.+\.bak-agent-bios-uninstall)$")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def owned_siblings(root):
|
|
64
|
+
"""Every loose backup under `root` that this repo wrote. Nothing else is ours."""
|
|
65
|
+
if not root.is_dir():
|
|
66
|
+
return []
|
|
67
|
+
return [f for f in root.rglob("*") if f.is_file() and OWNED_SIBLING.search(f.name)]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def to_delete(entries, now, keep_recent=KEEP_RECENT, max_age_days=MAX_AGE_DAYS):
|
|
71
|
+
"""(path, mtime) pairs -> the ones both aged out AND outside the newest `keep_recent`.
|
|
72
|
+
|
|
73
|
+
Both conditions, never either: this is the whole retention policy, and it is a pure function
|
|
74
|
+
so the policy can be tested without creating and destroying real backups.
|
|
75
|
+
"""
|
|
76
|
+
newest_first = sorted(entries, key=lambda e: e[1], reverse=True)
|
|
77
|
+
cutoff = now - max_age_days * 86400
|
|
78
|
+
return [path for i, (path, mtime) in enumerate(newest_first)
|
|
79
|
+
if i >= keep_recent and mtime < cutoff]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _entries(paths):
|
|
83
|
+
out = []
|
|
84
|
+
for p in paths:
|
|
85
|
+
try:
|
|
86
|
+
out.append((p, p.stat().st_mtime))
|
|
87
|
+
except OSError:
|
|
88
|
+
continue # vanished under us; nothing to prune
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def prune(state_dir, homes, now=None, dry=False):
|
|
93
|
+
"""Prune both groups. Returns the paths removed (or that would be)."""
|
|
94
|
+
now = time.time() if now is None else now
|
|
95
|
+
removed = []
|
|
96
|
+
|
|
97
|
+
backups = state_dir / "backups"
|
|
98
|
+
if backups.is_dir():
|
|
99
|
+
for path in to_delete(_entries([d for d in backups.iterdir() if d.is_dir()]), now):
|
|
100
|
+
removed.append(path)
|
|
101
|
+
if not dry:
|
|
102
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
103
|
+
|
|
104
|
+
for home in homes:
|
|
105
|
+
for path in to_delete(_entries(owned_siblings(home)), now):
|
|
106
|
+
removed.append(path)
|
|
107
|
+
if not dry:
|
|
108
|
+
path.unlink(missing_ok=True)
|
|
109
|
+
return removed
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def self_test():
|
|
113
|
+
day = 86400
|
|
114
|
+
now = 1_000_000_000.0
|
|
115
|
+
cases = [
|
|
116
|
+
("20 within a month -> keep all",
|
|
117
|
+
[(f"b{i}", now - i * day) for i in range(20)], 0),
|
|
118
|
+
("8 over two months -> keep all",
|
|
119
|
+
[(f"b{i}", now - i * 8 * day) for i in range(8)], 0),
|
|
120
|
+
("11 with 7 recent -> drop the oldest one",
|
|
121
|
+
[(f"b{i}", now - i * day) for i in range(7)]
|
|
122
|
+
+ [(f"o{i}", now - (40 + i * 5) * day) for i in range(4)], 1),
|
|
123
|
+
("11 all recent -> drop none",
|
|
124
|
+
[(f"b{i}", now - i * day) for i in range(11)], 0),
|
|
125
|
+
("30 all aged -> keep the newest ten",
|
|
126
|
+
[(f"b{i}", now - (40 + i) * day) for i in range(30)], 20),
|
|
127
|
+
]
|
|
128
|
+
bad = 0
|
|
129
|
+
for label, entries, want in cases:
|
|
130
|
+
got = len(to_delete(entries, now))
|
|
131
|
+
ok = got == want
|
|
132
|
+
bad += not ok
|
|
133
|
+
print(f" {'ok ' if ok else 'FAIL'} {label} (deleted {got}, want {want})")
|
|
134
|
+
# A rule that deletes nothing whatever the input would pass every case above by accident.
|
|
135
|
+
proof = to_delete([(f"x{i}", now - (100 + i) * day) for i in range(50)], now)
|
|
136
|
+
if len(proof) != 40:
|
|
137
|
+
print(" FAIL contrast control: the rule never deletes anything")
|
|
138
|
+
bad += 1
|
|
139
|
+
else:
|
|
140
|
+
print(" ok contrast control: it does delete when both conditions hold")
|
|
141
|
+
|
|
142
|
+
# Ownership, in both directions. The first list is every shape this repo writes; the
|
|
143
|
+
# second is what a person or another tool leaves in the same directories. Matching the
|
|
144
|
+
# first proves the rule still finds our copies after a rename; refusing the second is
|
|
145
|
+
# the half that matters, because uninstall DELETES whatever this claims.
|
|
146
|
+
ours = ["settings.json.bak-20260101-000000", "CLAUDE.md.bak-legacy-20260101-000000",
|
|
147
|
+
"learnings.md.bak-learn-20260101-000000", "learnings.md.bak-migrate-20260101-000000",
|
|
148
|
+
"config.toml.bak-agent-bios-uninstall"]
|
|
149
|
+
theirs = ["notes.bak-old", "db.bak-2026", "x.bak-", "report.bak-final.txt",
|
|
150
|
+
"settings.json.bak-2026010-000000", "a.bak-legacy-nope",
|
|
151
|
+
# A correctly shaped timestamp on a basename that is not ours. The first pass
|
|
152
|
+
# only rejected MALFORMED timestamps, which tested the shape and not the
|
|
153
|
+
# ownership — so the rule went on claiming these and the control stayed green.
|
|
154
|
+
"notes.bak-20260101-000000", "db.bak-20240301-120000",
|
|
155
|
+
"settings.json.txt.bak-20260101-000000"]
|
|
156
|
+
missed = [n for n in ours if not OWNED_SIBLING.search(n)]
|
|
157
|
+
grabbed = [n for n in theirs if OWNED_SIBLING.search(n)]
|
|
158
|
+
for name in missed:
|
|
159
|
+
print(f" FAIL ownership: {name} is ours but went unclaimed")
|
|
160
|
+
for name in grabbed:
|
|
161
|
+
print(f" FAIL ownership: {name} is NOT ours but was claimed for deletion")
|
|
162
|
+
bad += len(missed) + len(grabbed)
|
|
163
|
+
if not missed and not grabbed:
|
|
164
|
+
print(f" ok ownership: claims {len(ours)} shapes we write, "
|
|
165
|
+
f"refuses {len(theirs)} we do not")
|
|
166
|
+
print(f"prune-backups self-test: {'OK' if not bad else 'FAIL'} ({len(cases) + 2} checks)")
|
|
167
|
+
return 1 if bad else 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def main():
|
|
171
|
+
ap = argparse.ArgumentParser()
|
|
172
|
+
ap.add_argument("--state-dir")
|
|
173
|
+
ap.add_argument("--claude-dir")
|
|
174
|
+
ap.add_argument("--codex-dir")
|
|
175
|
+
ap.add_argument("--dry-run", action="store_true")
|
|
176
|
+
ap.add_argument("--self-test", action="store_true")
|
|
177
|
+
ap.add_argument("--list-owned", action="store_true",
|
|
178
|
+
help="print every owned loose backup under the config dirs, NUL-separated, "
|
|
179
|
+
"then exit. uninstall consumes this instead of restating the rule.")
|
|
180
|
+
args = ap.parse_args()
|
|
181
|
+
if args.self_test:
|
|
182
|
+
sys.exit(self_test())
|
|
183
|
+
|
|
184
|
+
state = pathlib.Path(args.state_dir or pathlib.Path.home() / ".local/share/agent-bios")
|
|
185
|
+
homes = [pathlib.Path(d) for d in (
|
|
186
|
+
args.claude_dir or os.environ.get("CLAUDE_CONFIG_DIR") or pathlib.Path.home() / ".claude",
|
|
187
|
+
args.codex_dir or os.environ.get("CODEX_HOME") or pathlib.Path.home() / ".codex")]
|
|
188
|
+
if args.list_owned:
|
|
189
|
+
# NUL-separated: these are real paths from a user's home, and a newline in one would
|
|
190
|
+
# otherwise split a single file into two entries — one of which the caller then deletes.
|
|
191
|
+
for home in homes:
|
|
192
|
+
for path in owned_siblings(home):
|
|
193
|
+
sys.stdout.write(f"{path}\0")
|
|
194
|
+
return
|
|
195
|
+
removed = prune(state, homes, dry=args.dry_run)
|
|
196
|
+
for path in removed:
|
|
197
|
+
print(f" {'[dry] ' if args.dry_run else ''}pruned backup {path}")
|
|
198
|
+
if removed and not args.dry_run:
|
|
199
|
+
print(f" pruned {len(removed)} backup(s) past retention "
|
|
200
|
+
f"(kept: newest {KEEP_RECENT}, plus anything under {MAX_AGE_DAYS} days)")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
if __name__ == "__main__":
|
|
204
|
+
main()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Register the manifest's hooks in a deployed settings.json (full-install path).
|
|
3
|
+
|
|
4
|
+
The packaged path gets this for free: assemble.py composes the corpus and calls
|
|
5
|
+
merge_settings on the way out. A full install never runs the assembler, so the
|
|
6
|
+
hook files were deployed and nothing ever registered them — they sat on disk and
|
|
7
|
+
never fired. This runs the assembler's own merge so both paths register
|
|
8
|
+
identically, under the same name-based ownership rule.
|
|
9
|
+
|
|
10
|
+
Usage: register-hooks.py <repo> <claude-dir>
|
|
11
|
+
Exit 0 on success; non-zero (with a message) if the merge could not run, which
|
|
12
|
+
the installer reports as a note rather than failing the whole install.
|
|
13
|
+
"""
|
|
14
|
+
import importlib.util
|
|
15
|
+
import json
|
|
16
|
+
import pathlib
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_assemble(repo):
|
|
21
|
+
"""Import assemble.py by path — compose/ is a flat toolbox, not a package."""
|
|
22
|
+
spec = importlib.util.spec_from_file_location("assemble", repo / "compose" / "assemble.py")
|
|
23
|
+
mod = importlib.util.module_from_spec(spec)
|
|
24
|
+
spec.loader.exec_module(mod)
|
|
25
|
+
return mod
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main():
|
|
29
|
+
if len(sys.argv) != 3:
|
|
30
|
+
sys.exit("usage: register-hooks.py <repo> <claude-dir>")
|
|
31
|
+
repo, claude_dir = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])
|
|
32
|
+
manifest = json.loads((repo / "compose" / "domains.json").read_text(encoding="utf-8"))
|
|
33
|
+
names = sorted(manifest.get("hooks", {}))
|
|
34
|
+
if not names:
|
|
35
|
+
sys.exit("register-hooks: manifest declares no hooks — refusing to rewrite settings")
|
|
36
|
+
mod = load_assemble(repo)
|
|
37
|
+
mod.merge_settings(claude_dir, names,
|
|
38
|
+
repo / "claude" / "settings.template.json",
|
|
39
|
+
owned_names=names)
|
|
40
|
+
print(f" hooks registered ({len(names)})")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
main()
|