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.
Files changed (49) hide show
  1. package/DEPENDENCIES.md +19 -19
  2. package/README.md +34 -11
  3. package/claude/CLAUDE.md +2 -1
  4. package/claude/guides/claude-prompting.md +1 -1
  5. package/claude/guides/cli-multi-model-workflow.md +19 -1
  6. package/claude/guides/coding-staged-workflow.md +32 -0
  7. package/claude/guides/gpt-prompting.md +1 -1
  8. package/claude/guides/learning-flow.md +5 -5
  9. package/claude/guides/llm-capability-boundary.md +6 -1
  10. package/claude/guides/session-distill-workflow.md +19 -9
  11. package/claude/guides/tooling-gotchas.md +16 -0
  12. package/claude/hooks/__pycache__/tooling-gotchas-hook.cpython-314.pyc +0 -0
  13. package/claude/hooks/tooling-gotchas-hook.py +7 -0
  14. package/codex/AGENTS.md +2 -1
  15. package/codex/guides/claude-prompting.md +1 -1
  16. package/codex/guides/cli-multi-model-workflow.md +19 -1
  17. package/codex/guides/coding-staged-workflow.md +32 -0
  18. package/codex/guides/gpt-prompting.md +1 -1
  19. package/codex/guides/learning-flow.md +5 -5
  20. package/codex/guides/llm-capability-boundary.md +6 -1
  21. package/codex/guides/session-distill-workflow.md +19 -9
  22. package/codex/guides/tooling-gotchas.md +16 -0
  23. package/{scripts → compose}/assemble.py +209 -25
  24. package/{scripts → compose}/canary.sh +14 -5
  25. package/{scripts → compose}/check-domains.py +9 -3
  26. package/{config → compose}/domains.json +1 -0
  27. package/{scripts → compose}/pkgid.py +8 -1
  28. package/compose/prune-backups.py +204 -0
  29. package/compose/register-hooks.py +44 -0
  30. package/{scripts/install.sh → install.sh} +403 -94
  31. package/launch/agent-launch.py +5294 -0
  32. package/launch/agent-launch.toml +376 -0
  33. package/{scripts → launch}/check-prompting-targets.sh +1 -1
  34. package/{scripts → launch}/provision-venv.sh +1 -1
  35. package/{scripts → learn}/check-learning.py +7 -7
  36. package/{scripts → learn}/collect-learning.py +10 -10
  37. package/{config → learn}/learning.schema.json +3 -3
  38. package/{scripts → learn}/migrate-learnings.py +95 -54
  39. package/{scripts → learn}/redact.py +4 -4
  40. package/package.json +25 -23
  41. package/wrappers/claude-run.sh +162 -0
  42. package/{scripts → wrappers}/codex-run.sh +62 -6
  43. package/config/agent-launch.toml +0 -143
  44. package/scripts/agent-launch.py +0 -2350
  45. package/scripts/check-parity.sh +0 -2003
  46. /package/{shell → launch}/agent-launch.zsh +0 -0
  47. /package/{config → learn}/promotions.json +0 -0
  48. /package/{scripts/session-cost.py → session-cost.py} +0 -0
  49. /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()