agent-bios 0.9.8 → 0.10.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.
Files changed (59) hide show
  1. package/DEPENDENCIES.md +19 -19
  2. package/README.md +43 -12
  3. package/claude/CLAUDE.md +5 -41
  4. package/claude/guides/claude-prompting.md +1 -1
  5. package/claude/guides/cli-multi-model-workflow.md +22 -4
  6. package/claude/guides/coding-staged-workflow.md +49 -15
  7. package/claude/guides/concept-economy.md +187 -0
  8. package/claude/guides/documentation-hygiene.md +112 -0
  9. package/claude/guides/gpt-prompting.md +1 -1
  10. package/claude/guides/learning-flow.md +5 -5
  11. package/claude/guides/llm-capability-boundary.md +6 -1
  12. package/claude/guides/review-request.md +9 -7
  13. package/claude/guides/session-distill-workflow.md +19 -9
  14. package/claude/guides/tooling-gotchas.md +26 -0
  15. package/claude/guides/verification-discipline.md +166 -0
  16. package/claude/hooks/tooling-gotchas-hook.py +329 -12
  17. package/codex/AGENTS.md +5 -41
  18. package/codex/guides/claude-prompting.md +1 -1
  19. package/codex/guides/cli-multi-model-workflow.md +22 -4
  20. package/codex/guides/coding-staged-workflow.md +49 -15
  21. package/codex/guides/concept-economy.md +187 -0
  22. package/codex/guides/documentation-hygiene.md +112 -0
  23. package/codex/guides/gpt-prompting.md +1 -1
  24. package/codex/guides/learning-flow.md +5 -5
  25. package/codex/guides/llm-capability-boundary.md +6 -1
  26. package/codex/guides/review-request.md +9 -7
  27. package/codex/guides/session-distill-workflow.md +19 -9
  28. package/codex/guides/tooling-gotchas.md +26 -0
  29. package/codex/guides/verification-discipline.md +166 -0
  30. package/{scripts → compose}/assemble.py +194 -17
  31. package/{scripts → compose}/canary.sh +14 -5
  32. package/compose/check-domains.py +1178 -0
  33. package/{config → compose}/domains.json +11 -44
  34. package/{scripts → compose}/pkgid.py +8 -1
  35. package/compose/prune-backups.py +204 -0
  36. package/{scripts → compose}/register-hooks.py +3 -3
  37. package/install.sh +1233 -0
  38. package/launch/agent-launch.py +5294 -0
  39. package/launch/agent-launch.toml +376 -0
  40. package/{scripts → launch}/check-prompting-targets.sh +1 -1
  41. package/{scripts → launch}/provision-venv.sh +1 -1
  42. package/{scripts → learn}/check-learning.py +7 -7
  43. package/{scripts → learn}/collect-learning.py +10 -10
  44. package/{config → learn}/learning.schema.json +3 -3
  45. package/{scripts → learn}/migrate-learnings.py +95 -54
  46. package/{scripts → learn}/redact.py +4 -4
  47. package/package.json +32 -27
  48. package/provenance.json +1 -0
  49. package/wrappers/claude-run.sh +162 -0
  50. package/{scripts → wrappers}/codex-run.sh +62 -6
  51. package/config/agent-launch.toml +0 -143
  52. package/scripts/agent-launch.py +0 -2350
  53. package/scripts/check-domains.py +0 -296
  54. package/scripts/check-parity.sh +0 -2003
  55. package/scripts/install.sh +0 -819
  56. /package/{shell → launch}/agent-launch.zsh +0 -0
  57. /package/{config → learn}/promotions.json +0 -0
  58. /package/{scripts/session-cost.py → session-cost.py} +0 -0
  59. /package/{scripts → wrappers}/codex-helm.sh +0 -0
@@ -1,819 +0,0 @@
1
- #!/usr/bin/env bash
2
- # agent-bios installer.
3
- #
4
- # Deploys the single-source-of-truth (globals, scoped guides, Codex agent
5
- # templates, Codex wrappers, the launch profile/shell/bin, a managed Textual
6
- # venv, and the zsh hook) into $HOME by COPY — idempotent, backed up before
7
- # overwrite, and reversible. Distributed as an npm bin; the actual $HOME
8
- # deployment is this explicit command (never a postinstall side effect).
9
- #
10
- # Usage:
11
- # agent-bios install deploy into this environment (backs up + verifies)
12
- # agent-bios verify check the deployed state matches the source
13
- # agent-bios status show what is installed and where
14
- # agent-bios update git pull + reinstall (clone), or print the npm update line
15
- # agent-bios uninstall remove deployed files and the zsh hook
16
- # agent-bios help
17
- #
18
- # Flags: --dry-run (print actions, change nothing).
19
- # Env overrides: CLAUDE_CONFIG_DIR, CODEX_HOME, AGENT_LAUNCH_VENV, ZDOTDIR.
20
- set -euo pipefail
21
-
22
- # This installer is non-interactive: every input arrives as a subcommand, flag,
23
- # or env var. Detach stdin so no child (the codex-helm dry-run, pip, git) can
24
- # block forever on an inherited idle stdin — that is what hangs an install under
25
- # CI, pipes, and background runs, where stdin stays open but never delivers.
26
- # `learn` is the one subcommand whose payload IS stdin, so keep the caller's on
27
- # fd 3 first and hand it back only there; every other path still sees /dev/null.
28
- exec 3<&0 2>/dev/null || exec 3</dev/null # tolerate a caller that closed fd 0
29
- exec </dev/null
30
-
31
- # Resolve this script through symlinks before locating the package: npm links the
32
- # bin into node_modules/.bin and the global bin dir, so $0 is a link and its
33
- # dirname is the link's directory, not the package. Without this the source tree
34
- # resolves to the bin dir's parent (e.g. /opt/homebrew) and every deploy fails.
35
- SOURCE="${BASH_SOURCE[0]}"
36
- while [ -L "$SOURCE" ]; do
37
- LINKDIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
38
- SOURCE="$(readlink "$SOURCE")"
39
- case "$SOURCE" in
40
- /*) ;;
41
- *) SOURCE="$LINKDIR/$SOURCE" ;;
42
- esac
43
- done
44
- SELF="$(cd -P "$(dirname "$SOURCE")" && pwd)"
45
- REPO="$(cd "$SELF/.." && pwd)"
46
-
47
- CLAUDE_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
48
- CODEX_DIR="${CODEX_HOME:-$HOME/.codex}"
49
- LAUNCH_DIR="$HOME/.config/agent-launch"
50
- USER_PRESETS_NAME="presets.local.toml" # user-owned; never deployed or verified
51
- BIN_DIR="$HOME/.local/bin"
52
- STATE_DIR="$HOME/.local/share/agent-bios"
53
- LEGACY_STATE_DIR="$HOME/.local/share/agent-dotfiles" # pre-rename state; migrated on first run
54
- MANIFEST="$STATE_DIR/manifest.txt"
55
- ZSHRC="${ZDOTDIR:-$HOME}/.zshrc"
56
- ZSH_HOOK='[ -r "$HOME/.config/agent-launch/shell.zsh" ] && source "$HOME/.config/agent-launch/shell.zsh"'
57
- HOOK_MARK='agent-launch/shell.zsh'
58
-
59
- DRY_RUN=0
60
- BACKUP_DIR=""
61
-
62
- log() { printf '%s\n' "$*"; }
63
- info() { printf ' %s\n' "$*"; }
64
- run() { if [ "$DRY_RUN" = 1 ]; then printf ' [dry-run] %s\n' "$*"; else "$@"; fi; }
65
-
66
- # ---- prerequisites -------------------------------------------------------
67
- check_prereqs() {
68
- local ok=0
69
- command -v git >/dev/null 2>&1 || { log "missing prerequisite: git"; ok=1; }
70
- command -v python3 >/dev/null 2>&1 || { log "missing prerequisite: python3"; ok=1; }
71
- if command -v python3 >/dev/null 2>&1; then
72
- python3 -c 'import sys; sys.exit(0 if sys.version_info>=(3,11) else 1)' 2>/dev/null \
73
- || { log "python3 >= 3.11 required (tomllib)"; ok=1; }
74
- fi
75
- command -v zsh >/dev/null 2>&1 || log "note: zsh not found; the shell hook targets zsh"
76
- command -v codex >/dev/null 2>&1 || log "note: codex CLI not found; Codex-side steps will be skipped"
77
- command -v claude >/dev/null 2>&1 || log "note: claude CLI not found"
78
- return $ok
79
- }
80
-
81
- # ---- copy with backup + manifest ----------------------------------------
82
- deploy_file() {
83
- local src="$1" dst="$2" mode="${3:-}"
84
- [ -f "$src" ] || { log "source missing: $src"; return 1; }
85
- if [ -f "$dst" ] && cmp -s "$src" "$dst"; then
86
- info "unchanged $dst"
87
- else
88
- if [ -f "$dst" ] && [ -n "$BACKUP_DIR" ]; then
89
- run mkdir -p "$(dirname "$BACKUP_DIR$dst")"
90
- run cp "$dst" "$BACKUP_DIR$dst"
91
- fi
92
- run mkdir -p "$(dirname "$dst")"
93
- run cp "$src" "$dst"
94
- if [ -n "$mode" ]; then run chmod "$mode" "$dst"; fi
95
- [ "$DRY_RUN" = 1 ] || info "installed $dst"
96
- fi
97
- [ "$DRY_RUN" = 1 ] || printf '%s\n' "$dst" >> "$MANIFEST"
98
- }
99
-
100
- deploy_glob() {
101
- local srcdir="$1" pat="$2" dstdir="$3" mode="${4:-}" f
102
- for f in "$srcdir"/$pat; do
103
- [ -f "$f" ] || continue
104
- deploy_file "$f" "$dstdir/$(basename "$f")" "$mode"
105
- done
106
- }
107
-
108
- # ---- packaged (domain-selected) corpus deploy ----------------------------
109
- # Active when --domains is passed or a prior selection exists in STATE_DIR.
110
- # assemble.py owns the corpus surfaces (central tree, entry seeding, codex
111
- # marker region, settings merge); the entry CLAUDE.md and AGENTS.md are NOT
112
- # manifested — the entry is user-owned after seeding, AGENTS.md holds a
113
- # personal region — so uninstall never deletes them.
114
- # Full mode never runs the assembler, so nothing registered the hooks it
115
- # deployed — the files landed and never fired. Reuse the assembler's own merge
116
- # so both install paths register identically, under one ownership rule.
117
- register_hooks_full() {
118
- [ "$DRY_RUN" = 1 ] && { info "[dry-run] register central hooks"; return 0; }
119
- python3 "$REPO/scripts/register-hooks.py" "$REPO" "$CLAUDE_DIR" \
120
- || log "note: hook registration failed; the deployed hooks will not fire"
121
- }
122
-
123
- packaged_mode() { [ "${DOMAINS_SET:-0}" = 1 ] || [ -f "$STATE_DIR/selection.json" ]; }
124
-
125
- assemble_packaged() {
126
- local args=(--claude-dir "$CLAUDE_DIR" --codex-dir "$CODEX_DIR" --state-dir "$STATE_DIR") rc=0
127
- [ "${DOMAINS_SET:-0}" = 1 ] && args+=(--domains "$DOMAINS_ARG")
128
- [ "$DRY_RUN" = 1 ] && args+=(--dry-run)
129
- python3 "$REPO/scripts/assemble.py" "${args[@]}" || rc=$?
130
- if [ "$rc" = 2 ]; then
131
- log "packaged: entry file needs user action (import line missing); central content will not load until it is added"
132
- elif [ "$rc" != 0 ]; then
133
- return 1
134
- fi
135
- if [ "$DRY_RUN" != 1 ]; then
136
- find "$CLAUDE_DIR/central" "$CODEX_DIR/guides" -type f 2>/dev/null >> "$MANIFEST"
137
- fi
138
- }
139
-
140
- add_zsh_hook() {
141
- if [ -f "$ZSHRC" ] && grep -qF "$HOOK_MARK" "$ZSHRC"; then
142
- info "zsh hook present $ZSHRC"
143
- return
144
- fi
145
- if [ "$DRY_RUN" = 1 ]; then info "[dry-run] append zsh hook to $ZSHRC"; return; fi
146
- printf '%s\n' "$ZSH_HOOK" >> "$ZSHRC"
147
- info "added zsh hook $ZSHRC"
148
- }
149
-
150
- # Carry state written under the pre-rename directory so an existing install keeps
151
- # its manifest and backups instead of stranding them.
152
- migrate_state() {
153
- if [ -d "$STATE_DIR" ] || [ ! -d "$LEGACY_STATE_DIR" ]; then
154
- return
155
- fi
156
- if [ "$DRY_RUN" = 1 ]; then
157
- info "[dry-run] migrate state $LEGACY_STATE_DIR -> $STATE_DIR"
158
- return
159
- fi
160
- mkdir -p "$(dirname "$STATE_DIR")"
161
- mv "$LEGACY_STATE_DIR" "$STATE_DIR" && info "migrated state $LEGACY_STATE_DIR -> $STATE_DIR"
162
- }
163
-
164
- # ---- codex live-config additions -----------------------------------------
165
- # The live ~/.codex/config.toml is user/runtime-owned; agent-bios never
166
- # deploys or overwrites it. codex/config-additions.toml declares the only
167
- # content agent-bios manages there — one marked [agents.*] block plus a tagged
168
- # features.multi_agent line — and this helper merges (install), checks
169
- # (verify), or removes (uninstall) exactly that content, backed up and
170
- # tomllib-validated before any write. Modes: merge | check | remove.
171
- codex_config_additions() {
172
- AB_MODE="$1" AB_CODEX_DIR="$CODEX_DIR" AB_FRAGMENT="$REPO/codex/config-additions.toml" \
173
- AB_BACKUP="${BACKUP_DIR:-}" AB_DRY="$DRY_RUN" python3 - <<'PY'
174
- import os, pathlib, sys, tomllib
175
-
176
- mode = os.environ["AB_MODE"]
177
- codex_dir = pathlib.Path(os.environ["AB_CODEX_DIR"])
178
- fragment_path = pathlib.Path(os.environ["AB_FRAGMENT"])
179
- backup_root = os.environ.get("AB_BACKUP", "")
180
- dry = os.environ.get("AB_DRY") == "1"
181
- target = codex_dir / "config.toml"
182
- BEGIN = "# >>> agent-bios additions >>>"
183
- END = "# <<< agent-bios additions <<<"
184
- TAG = "# agent-bios"
185
-
186
- def info(msg): print(f" {msg}")
187
- def fail(msg): print(msg); sys.exit(1)
188
-
189
- frag_text = fragment_path.read_text().replace("${CODEX_HOME}", str(codex_dir))
190
- want_agents = tomllib.loads(frag_text)["agents"]
191
- live_text = target.read_text() if target.is_file() else ""
192
- try:
193
- live = tomllib.loads(live_text) if live_text else {}
194
- except Exception as exc:
195
- fail(f"live codex config does not parse; not touching it: {target} ({exc})")
196
-
197
- def state_ok():
198
- if live.get("features", {}).get("multi_agent") is not True:
199
- return False
200
- return all(
201
- live.get("agents", {}).get(name, {}).get(key) == spec[key]
202
- for name, spec in want_agents.items()
203
- for key in ("description", "config_file")
204
- )
205
-
206
- if mode == "check":
207
- problems = []
208
- if live.get("features", {}).get("multi_agent") is not True:
209
- problems.append("features.multi_agent is not true")
210
- for name, spec in want_agents.items():
211
- if live.get("agents", {}).get(name, {}).get("config_file") != spec["config_file"]:
212
- problems.append(f"agents.{name}.config_file drifted or missing")
213
- elif not pathlib.Path(spec["config_file"]).is_file():
214
- problems.append(f"agents.{name} template missing: {spec['config_file']}")
215
- if problems:
216
- fail(f"codex config additions: {'; '.join(problems)} ({target})")
217
- info("codex config additions OK")
218
- sys.exit(0)
219
-
220
- def strip_managed(text):
221
- out, skipping = [], False
222
- for line in text.splitlines(keepends=True):
223
- s = line.strip()
224
- if s == BEGIN: skipping = True; continue
225
- if s == END: skipping = False; continue
226
- if skipping or s.endswith(TAG): continue
227
- out.append(line)
228
- return "".join(out)
229
-
230
- if mode == "remove":
231
- if not target.is_file():
232
- sys.exit(0)
233
- stripped = strip_managed(live_text)
234
- if stripped == live_text:
235
- info(f"no agent-bios additions in {target}")
236
- sys.exit(0)
237
- try:
238
- tomllib.loads(stripped)
239
- except Exception as exc:
240
- fail(f"refusing removal; result would not parse: {exc}")
241
- if dry:
242
- info(f"[dry-run] remove agent-bios additions from {target}")
243
- sys.exit(0)
244
- backup = target.with_name(target.name + ".bak-agent-bios-uninstall")
245
- backup.write_text(live_text)
246
- target.write_text(stripped)
247
- info(f"removed additions {target} (backup: {backup.name})")
248
- sys.exit(0)
249
-
250
- # mode == merge
251
- if state_ok():
252
- info(f"unchanged {target} (additions present)")
253
- sys.exit(0)
254
-
255
- # A drifted [agents.<tier>] outside our markers would become a duplicate
256
- # table if we appended ours; that conflict needs the user, not a clobber.
257
- base = strip_managed(live_text)
258
- base_data = tomllib.loads(base) if base.strip() else {}
259
- clash = [name for name in want_agents if name in base_data.get("agents", {})]
260
- if clash:
261
- fail(
262
- f"unmanaged [agents.{'/'.join(clash)}] with drifted content in {target}; "
263
- "align or remove them, then rerun install"
264
- )
265
-
266
- block_lines = [BEGIN]
267
- if "features" not in base_data:
268
- block_lines += ["[features]", f"multi_agent = true {TAG}"]
269
- for name, spec in want_agents.items():
270
- block_lines += [
271
- f"[agents.{name}]",
272
- f'description = "{spec["description"]}"',
273
- f'config_file = "{spec["config_file"]}"',
274
- ]
275
- block_lines.append(END)
276
- block = "\n".join(block_lines) + "\n"
277
-
278
- new_text = base
279
- if "features" in base_data:
280
- if base_data["features"].get("multi_agent") is None:
281
- lines = new_text.splitlines(keepends=True)
282
- for i, line in enumerate(lines):
283
- if line.strip() == "[features]":
284
- lines.insert(i + 1, f"multi_agent = true {TAG}\n")
285
- break
286
- new_text = "".join(lines)
287
- elif base_data["features"].get("multi_agent") is not True:
288
- info(f"note: features.multi_agent explicitly set in {target}; leaving it")
289
- if new_text and not new_text.endswith("\n"):
290
- new_text += "\n"
291
- new_text += ("\n" if new_text else "") + block
292
-
293
- try:
294
- tomllib.loads(new_text)
295
- except Exception as exc:
296
- fail(f"merge result would not parse; live config untouched ({exc})")
297
- if dry:
298
- info(f"[dry-run] merge agent-bios additions into {target}")
299
- sys.exit(0)
300
- if live_text and backup_root:
301
- bpath = pathlib.Path(backup_root + str(target))
302
- bpath.parent.mkdir(parents=True, exist_ok=True)
303
- bpath.write_text(live_text)
304
- target.parent.mkdir(parents=True, exist_ok=True)
305
- target.write_text(new_text)
306
- info(f"merged additions {target}")
307
- PY
308
- }
309
-
310
- # ---- optional dependencies -----------------------------------------------
311
- # Capabilities are optional: a missing one only degrades the review routes that
312
- # need it. config/agent-launch.toml is the single source for both the command
313
- # that gates a route and the install line offered here.
314
- capability_table() {
315
- python3 - "$REPO/config/agent-launch.toml" <<'PY'
316
- import sys, tomllib
317
- for name, cap in tomllib.load(open(sys.argv[1], "rb")).get("capabilities", {}).items():
318
- print("\t".join((name, cap.get("command", ""), cap.get("install", ""))))
319
- PY
320
- }
321
-
322
- install_capability() {
323
- local name="$1" line="$2"
324
- log "Installing optional dependency $name: $line"
325
- if [ "$DRY_RUN" = 1 ]; then info "[dry-run] $line"; return 0; fi
326
- if sh -c "$line"; then info "installed $name"; else log "warning: installing $name failed; routes needing it stay degraded"; fi
327
- }
328
-
329
- handle_capabilities() {
330
- local requested="$1" name command line
331
- # Fail on a typo rather than silently installing nothing.
332
- local known; known=$(capability_table | cut -f1)
333
- local want
334
- for want in ${requested//,/ }; do
335
- printf '%s\n' "$known" | grep -qx "$want" || {
336
- log "unknown --with capability: $want (known: $(printf '%s' "$known" | tr '\n' ' '))"; return 1; }
337
- done
338
- while IFS=$'\t' read -r name command line; do
339
- [ -n "$name" ] || continue
340
- if command -v "$command" >/dev/null 2>&1; then
341
- info "capability present $name ($command)"
342
- continue
343
- fi
344
- if printf '%s\n' "${requested//,/ }" | tr ' ' '\n' | grep -qx "$name"; then
345
- [ -n "$line" ] && install_capability "$name" "$line" \
346
- || log "note: $name has no configured install line"
347
- elif [ -n "$line" ] && [ -t 0 ] && [ "$DRY_RUN" != 1 ]; then
348
- printf ' Install optional dependency %s? (%s) [y/N] ' "$name" "$line"
349
- local answer=""; read -r answer </dev/tty || answer=""
350
- case "$answer" in
351
- [yY]*) install_capability "$name" "$line" ;;
352
- *) info "skipped $name — install later: $line" ;;
353
- esac
354
- else
355
- info "optional $name unavailable; routes needing it degrade${line:+ — install: $line}"
356
- fi
357
- done <<EOF
358
- $(capability_table)
359
- EOF
360
- }
361
-
362
- # Presets the launcher saved into the deployed profiles.toml (pre-split, or by hand)
363
- # would be lost to the cp below; move them into the user-owned presets file first.
364
- migrate_user_presets() {
365
- local src="$REPO/config/agent-launch.toml"
366
- local dst="$LAUNCH_DIR/profiles.toml"
367
- local user="$LAUNCH_DIR/$USER_PRESETS_NAME"
368
- [ -f "$dst" ] || return 0
369
- python3 - "$src" "$dst" "$user" "$DRY_RUN" <<'PY' || { log "user preset migration failed; not overwriting $LAUNCH_DIR/profiles.toml"; return 1; }
370
- import os, pathlib, re, sys, tomllib
371
-
372
- src, dst, user, dry_run = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]), pathlib.Path(sys.argv[3]), sys.argv[4] == "1"
373
- dst_text = dst.read_text()
374
- shipped = set(tomllib.loads(src.read_text()).get("presets", {}))
375
- extra = set(tomllib.loads(dst_text).get("presets", {})) - shipped
376
- if not extra:
377
- sys.exit(0)
378
- existing_text = user.read_text() if user.is_file() else ""
379
- already = set(tomllib.loads(existing_text).get("presets", {})) if existing_text else set()
380
- move = sorted(extra - already)
381
- for name in sorted(extra & already):
382
- print(f" kept in {user.name} {name} (already saved there)")
383
- if not move:
384
- sys.exit(0)
385
-
386
- header = re.compile(r'^\[presets\.(?:"([^"]+)"|([A-Za-z0-9][A-Za-z0-9_-]*))(?:[.\]])')
387
- blocks, current = {}, None
388
- for line in dst_text.splitlines(keepends=True):
389
- stripped = line.strip()
390
- if stripped.startswith("["):
391
- found = header.match(stripped)
392
- current = (found.group(1) or found.group(2)) if found else None
393
- if current in move:
394
- blocks.setdefault(current, []).append(line)
395
- # Refuse to deploy over a preset we could not carry across, rather than drop it.
396
- missing = [name for name in move if name not in blocks]
397
- if missing:
398
- sys.exit(f"cannot extract preset block(s) from {dst}: {', '.join(missing)}")
399
-
400
- out = existing_text.rstrip("\n") or (
401
- "# agent-launch user presets, moved out of profiles.toml by agent-bios install.\n"
402
- "# The installer never deploys or verifies this file, so presets here survive upgrades."
403
- )
404
- for name in move:
405
- out += "\n\n" + "".join(blocks[name]).strip("\n")
406
- for name in move:
407
- print(f" {'[dry-run] ' if dry_run else ''}moved preset {name} -> {user}")
408
- if dry_run:
409
- sys.exit(0)
410
- user.parent.mkdir(parents=True, exist_ok=True)
411
- temporary = user.with_name(f".{user.name}.{os.getpid()}.tmp")
412
- temporary.write_text(out + "\n")
413
- os.replace(temporary, user)
414
- PY
415
- }
416
-
417
- remove_zsh_hook() {
418
- if [ ! -f "$ZSHRC" ] || ! grep -qF "$HOOK_MARK" "$ZSHRC"; then
419
- info "no zsh hook to remove"
420
- return
421
- fi
422
- if [ "$DRY_RUN" = 1 ]; then info "[dry-run] remove zsh hook from $ZSHRC"; return; fi
423
- grep -vF "$HOOK_MARK" "$ZSHRC" > "$ZSHRC.agent-tmp" && mv "$ZSHRC.agent-tmp" "$ZSHRC"
424
- info "removed zsh hook $ZSHRC"
425
- }
426
-
427
- # Promote -> migrate (collection loop, Phase 4): after the corpus is deployed,
428
- # clear personal copies of learnings that have been promoted into the shared
429
- # corpus AND are in this user's assembled bundle. Best-effort: a prune failure
430
- # (or an absent manifest/script) never fails the install. Runs per host.
431
- migrate_learnings() {
432
- local script="$REPO/scripts/migrate-learnings.py"
433
- { [ -f "$script" ] && [ -f "$REPO/config/promotions.json" ]; } || return 0
434
- local -a sel dry
435
- if packaged_mode; then sel=(--selection-file "$STATE_DIR/selection.json"); else sel=(--full); fi
436
- [ "$DRY_RUN" = 1 ] && dry=(--dry-run) || dry=()
437
- # ${dry[@]+...}: expanding an empty array as "${dry[@]}" is an unbound-variable
438
- # error under `set -u` on bash 3.2 (macOS default) and would abort the install.
439
- python3 "$script" --host claude --config-dir "$CLAUDE_DIR" "${sel[@]}" ${dry[@]+"${dry[@]}"} \
440
- || info "learnings migrate (claude) skipped"
441
- python3 "$script" --host codex --config-dir "$CODEX_DIR" "${sel[@]}" ${dry[@]+"${dry[@]}"} \
442
- || info "learnings migrate (codex) skipped"
443
- }
444
-
445
- # ---- subcommands ---------------------------------------------------------
446
- cmd_install() {
447
- check_prereqs || { log "resolve the prerequisites above and retry"; exit 1; }
448
- migrate_state
449
- if [ "$DRY_RUN" != 1 ]; then
450
- mkdir -p "$STATE_DIR"
451
- BACKUP_DIR="$STATE_DIR/backups/$(date +%Y%m%d-%H%M%S)"
452
- : > "$MANIFEST"
453
- fi
454
- log "Deploying agent-bios from $REPO"
455
- if packaged_mode; then
456
- log "packaged mode: assembling selected domains (state: $STATE_DIR/selection.json)"
457
- assemble_packaged || exit 1
458
- else
459
- deploy_file "$REPO/claude/CLAUDE.md" "$CLAUDE_DIR/CLAUDE.md"
460
- deploy_glob "$REPO/claude/guides" "*.md" "$CLAUDE_DIR/guides"
461
- deploy_glob "$REPO/claude/agents" "*.md" "$CLAUDE_DIR/agents"
462
- # Our hooks go under central/: <claude>/hooks is shared with other tools
463
- # files, caches, and state, and we must not own a directory we share.
464
- deploy_glob "$REPO/claude/hooks" "*.py" "$CLAUDE_DIR/central/hooks" "+x"
465
- register_hooks_full
466
- deploy_file "$REPO/codex/AGENTS.md" "$CODEX_DIR/AGENTS.md"
467
- deploy_glob "$REPO/codex/guides" "*.md" "$CODEX_DIR/guides"
468
- fi
469
- migrate_learnings # Phase 4: clear personal copies now absorbed by the corpus
470
- deploy_glob "$REPO/codex/agents" "*.toml" "$CODEX_DIR/agents"
471
- codex_config_additions merge || exit 1
472
- deploy_file "$REPO/scripts/codex-run.sh" "$CODEX_DIR/bin/codex-run" "+x"
473
- deploy_file "$REPO/scripts/codex-helm.sh" "$CODEX_DIR/bin/codex-helm" "+x"
474
- migrate_user_presets || exit 1 # must precede the deploy below, which overwrites profiles.toml
475
- deploy_file "$REPO/config/agent-launch.toml" "$LAUNCH_DIR/profiles.toml"
476
- deploy_file "$REPO/shell/agent-launch.zsh" "$LAUNCH_DIR/shell.zsh"
477
- deploy_file "$REPO/scripts/agent-launch.py" "$BIN_DIR/agent-launch" "+x"
478
- log ""
479
- log "Optional dependencies (missing ones only degrade the routes that need them)..."
480
- handle_capabilities "$WITH" || exit 1
481
- log ""
482
- if [ "$DRY_RUN" = 1 ]; then
483
- info "[dry-run] provision managed Textual venv"
484
- else
485
- bash "$REPO/scripts/provision-venv.sh" && info "managed venv OK" \
486
- || log "warning: venv provisioning failed (numbered-prompt fallback applies)"
487
- fi
488
- add_zsh_hook
489
- if python3 "$REPO/scripts/session-distill/corpus-state.py" project --repo "$REPO" >/dev/null 2>&1; then
490
- info "corpus-status projected"
491
- else
492
- log "note: corpus-status projection unavailable (versions.json/ledger missing?)"
493
- fi
494
- # Deploy/system version marker for the launcher's TUI version line, read from
495
- # package.json (version + releaseDate) — distinct from the corpus content
496
- # version. Best-effort: a failure here never fails the install.
497
- if [ "$DRY_RUN" != 1 ]; then
498
- if python3 - "$REPO/package.json" "$STATE_DIR/version.json" <<'PY' 2>/dev/null
499
- import json, sys
500
- pkg = json.load(open(sys.argv[1]))
501
- with open(sys.argv[2], "w") as f:
502
- json.dump({"version": pkg.get("version"), "releaseDate": pkg.get("releaseDate")}, f)
503
- f.write("\n")
504
- PY
505
- then
506
- printf '%s\n' "$STATE_DIR/version.json" >> "$MANIFEST"
507
- info "version marker written ($STATE_DIR/version.json)"
508
- else
509
- log "note: version marker not written (package.json unreadable)"
510
- fi
511
- fi
512
- log ""
513
- log "Verifying deployment..."
514
- if cmd_verify; then
515
- log ""
516
- log "Done. Open a new shell (or: source \"$ZSHRC\") to activate the zero-arg launcher."
517
- # An untouched backup dir means nothing was replaced; that healthy state
518
- # must not become a nonzero exit under set -e.
519
- { [ -n "$BACKUP_DIR" ] && [ -d "$BACKUP_DIR" ] && log "Replaced files were backed up under $BACKUP_DIR"; } || true
520
- else
521
- log "VERIFY FAILED after install — see messages above"
522
- exit 1
523
- fi
524
- }
525
-
526
- verify_match() { if cmp -s "$1" "$2"; then info "match $2"; else log "MISMATCH/absent $2"; return 1; fi; }
527
- verify_present() { if [ -f "$1" ]; then return 0; else log "missing $1"; return 1; fi; }
528
-
529
- cmd_verify() {
530
- local fail=0 gp
531
- if [ ! -d "$REPO/.git" ] && [ "$(drift_state)" = "drift" ]; then
532
- log "deploy drift: deployed $(deployed_version), package $(source_version) — run: agent-bios install"
533
- fail=1
534
- fi
535
- if packaged_mode; then
536
- # Packaged: corpus surfaces are selection-derived, not repo-identical.
537
- # The entry file is user-owned — READ-check the import line, never rewrite.
538
- python3 "$REPO/scripts/check-domains.py" >/dev/null 2>&1 && info "domains gate OK" || { log "domains gate FAILED"; fail=1; }
539
- verify_present "$CLAUDE_DIR/central/bundle.md" || fail=1
540
- if grep -qF '@central/bundle.md' "$CLAUDE_DIR/CLAUDE.md" 2>/dev/null; then
541
- info "entry import line present"
542
- else
543
- log "entry $CLAUDE_DIR/CLAUDE.md lacks '@central/bundle.md' — central corpus is NOT loading"; fail=1
544
- fi
545
- if grep -qF 'agent-bios:central:start' "$CODEX_DIR/AGENTS.md" 2>/dev/null; then
546
- info "codex central region present"
547
- else
548
- log "codex AGENTS.md central region missing"; fail=1
549
- fi
550
- else
551
- verify_match "$REPO/claude/CLAUDE.md" "$CLAUDE_DIR/CLAUDE.md" || fail=1
552
- verify_match "$REPO/codex/AGENTS.md" "$CODEX_DIR/AGENTS.md" || fail=1
553
- for gp in "$REPO"/claude/guides/*.md; do verify_present "$CLAUDE_DIR/guides/$(basename "$gp")" || fail=1; done
554
- for gp in "$REPO"/claude/agents/*.md; do verify_present "$CLAUDE_DIR/agents/$(basename "$gp")" || fail=1; done
555
- for gp in "$REPO"/claude/hooks/*.py; do verify_present "$CLAUDE_DIR/central/hooks/$(basename "$gp")" || fail=1; done
556
- for gp in "$REPO"/codex/guides/*.md; do verify_present "$CODEX_DIR/guides/$(basename "$gp")" || fail=1; done
557
- fi
558
- verify_match "$REPO/scripts/agent-launch.py" "$BIN_DIR/agent-launch" || fail=1
559
- verify_match "$REPO/config/agent-launch.toml" "$LAUNCH_DIR/profiles.toml" || fail=1
560
- verify_match "$REPO/shell/agent-launch.zsh" "$LAUNCH_DIR/shell.zsh" || fail=1
561
- python3 - "$CODEX_DIR/agents" <<'PY' && info "agent TOMLs OK" || fail=1
562
- import sys, pathlib, tomllib
563
- root = pathlib.Path(sys.argv[1])
564
- required = {"frontier.toml", "workhorse.toml", "sweep.toml", "reviewer.toml"}
565
- missing = required - {p.name for p in root.glob("*.toml")}
566
- assert not missing, f"missing agent TOMLs in {root}: {sorted(missing)}"
567
- for p in sorted(root.glob("*.toml")):
568
- tomllib.loads(p.read_text())
569
- PY
570
- codex_config_additions check || fail=1
571
- if command -v codex >/dev/null 2>&1 && [ -x "$CODEX_DIR/bin/codex-helm" ]; then
572
- if "$CODEX_DIR/bin/codex-helm" --dry-run --mode review "probe" >/dev/null 2>&1; then
573
- info "codex-helm dry-run OK"
574
- else
575
- log "codex-helm dry-run FAILED"; fail=1
576
- fi
577
- fi
578
- local vpy="${AGENT_LAUNCH_VENV:-$HOME/.local/share/agent-launch/venv}/bin/python"
579
- if [ -x "$vpy" ] && "$vpy" -c 'import textual' 2>/dev/null; then
580
- info "managed venv (textual) OK"
581
- else
582
- log "note: managed venv/textual unavailable (numbered-prompt fallback applies)"
583
- fi
584
- # A file this installer executes but never ships is invisible from a clone and
585
- # fatal on npm, so the payload gate runs wherever it exists (maintainer-side).
586
- if [ -x "$REPO/scripts/check-package.sh" ]; then
587
- if "$REPO/scripts/check-package.sh" >/dev/null 2>&1; then
588
- info "npm payload OK"
589
- else
590
- log "npm payload incomplete; run scripts/check-package.sh"
591
- fail=1
592
- fi
593
- fi
594
- # Repo-internal mirror parity is a maintainer gate; only meaningful from a clone.
595
- if [ -d "$REPO/ko" ] && [ -x "$REPO/scripts/check-parity.sh" ]; then
596
- if "$REPO/scripts/check-parity.sh" >/dev/null 2>&1; then info "repo mirror parity OK"; else log "repo mirror parity FAILED"; fail=1; fi
597
- fi
598
- # Prompting guides name concrete models, so they go stale on a model change
599
- # rather than degrading quietly; this checks them against the launch config.
600
- if [ -x "$REPO/scripts/check-prompting-targets.sh" ]; then
601
- if "$REPO/scripts/check-prompting-targets.sh" >/dev/null 2>&1; then
602
- info "prompting targets OK"
603
- else
604
- log "prompting guides do not cover a configured model; run scripts/check-prompting-targets.sh"
605
- fail=1
606
- fi
607
- fi
608
- return $fail
609
- }
610
-
611
- cmd_uninstall() {
612
- migrate_state
613
- codex_config_additions remove || log "warning: could not remove codex config additions"
614
- if [ -f "$MANIFEST" ]; then
615
- local f
616
- while IFS= read -r f; do
617
- [ -n "$f" ] || continue
618
- [ -f "$f" ] && { run rm -f "$f"; [ "$DRY_RUN" = 1 ] || info "removed $f"; }
619
- done < "$MANIFEST"
620
- [ "$DRY_RUN" = 1 ] || rm -f "$MANIFEST"
621
- else
622
- log "no manifest at $MANIFEST; removing known deploy targets"
623
- local p
624
- for p in "$CLAUDE_DIR/CLAUDE.md" "$CODEX_DIR/AGENTS.md" \
625
- "$CODEX_DIR/bin/codex-run" "$CODEX_DIR/bin/codex-helm" \
626
- "$LAUNCH_DIR/profiles.toml" "$LAUNCH_DIR/shell.zsh" "$BIN_DIR/agent-launch"; do
627
- [ -f "$p" ] && { run rm -f "$p"; [ "$DRY_RUN" = 1 ] || info "removed $p"; }
628
- done
629
- fi
630
- local d
631
- for d in "$CLAUDE_DIR/guides" "$CLAUDE_DIR/agents" "$CODEX_DIR/guides" "$CODEX_DIR/agents" "$CODEX_DIR/bin" "$LAUNCH_DIR"; do
632
- [ -d "$d" ] && rmdir "$d" 2>/dev/null && info "removed empty $d" || true
633
- done
634
- remove_zsh_hook
635
- log ""
636
- log "Uninstalled deployed files and the zsh hook."
637
- log "Kept: managed venv + backups under $STATE_DIR (rm -rf \"$STATE_DIR\" to purge)."
638
- }
639
-
640
- cmd_onboard() {
641
- log "agent-bios onboarding — pick your domain packages (core + infra always install)"
642
- local names=() line i=1 choice sel="" n picks
643
- while IFS= read -r line; do names+=("$line"); done \
644
- < <(python3 -c "import json;print('\n'.join(sorted(json.load(open('$REPO/config/domains.json'))['domains'])))")
645
- [ "${#names[@]}" -ge 1 ] || { log "no domains found in config/domains.json"; exit 1; }
646
- if [ "$DOMAINS_SET" = 1 ]; then
647
- # Non-interactive path, per this installer's input contract (stdin is
648
- # detached at the top of the script): selection arrives as domain names.
649
- [ "$DOMAINS_ARG" = none ] && DOMAINS_ARG=""
650
- sel="$DOMAINS_ARG"
651
- elif ( : </dev/tty ) 2>/dev/null; then
652
- for line in "${names[@]}"; do info "$i) $line"; i=$((i+1)); done
653
- printf 'Select by number, comma-separated (empty = core+infra only): '
654
- read -r choice </dev/tty || choice=""
655
- if [ -n "$choice" ]; then
656
- # bash 3.2 + set -u: expanding an EMPTY array errors, so split only
657
- # when there is input; empty input means core+infra only.
658
- IFS=',' read -ra picks <<<"$choice"
659
- for n in "${picks[@]}"; do
660
- n="${n// /}"; [ -n "$n" ] || continue
661
- case "$n" in (*[!0-9]*) log "invalid selection: $n"; exit 2 ;; esac
662
- [ "$n" -ge 1 ] && [ "$n" -le "${#names[@]}" ] || { log "selection out of range: $n"; exit 2; }
663
- sel="$sel${sel:+,}${names[$((n-1))]}"
664
- done
665
- fi
666
- else
667
- log "onboard needs a terminal or an explicit selection — run:"
668
- log " agent-bios onboard --domains a,b (or --domains none for core+infra only)"
669
- exit 2
670
- fi
671
- DOMAINS_ARG="$sel"; DOMAINS_SET=1
672
- log "selection: ${sel:-<core+infra only>}"
673
- cmd_install
674
- log ""
675
- log "Activation canary (proves the bundle loads in a live session)..."
676
- if [ "$DRY_RUN" = 1 ]; then info "[dry-run] skip canary probe"; return; fi
677
- bash "$REPO/scripts/canary.sh" || {
678
- log "ONBOARDING INCOMPLETE: the bundle is installed but not loading — fix the cause above and re-run: agent-bios verify"
679
- exit 1
680
- }
681
- }
682
-
683
- # ---- deploy-chain drift ---------------------------------------------------
684
- # A repo edit is inert until it is published AND globally installed AND
685
- # deployed. The middle two are checkable: the installer stamps the package
686
- # version it deployed into the state dir, so a stamp older than the package now
687
- # running means someone updated the package and never re-deployed. Reading the
688
- # registry cannot see this, which is why it went unnoticed three times.
689
- json_field() { # $1=file $2=key
690
- [ -f "$1" ] || return 1
691
- python3 -c 'import json,sys
692
- try:
693
- v=json.load(open(sys.argv[1])).get(sys.argv[2])
694
- except Exception:
695
- sys.exit(1)
696
- sys.exit(0) if v is None else print(v)' "$1" "$2" 2>/dev/null
697
- }
698
-
699
- deployed_version() { json_field "$STATE_DIR/version.json" version; }
700
- source_version() { json_field "$REPO/package.json" version; }
701
-
702
- drift_state() { # prints: match | drift | unknown
703
- local d s
704
- d="$(deployed_version)" || { echo unknown; return; }
705
- s="$(source_version)" || { echo unknown; return; }
706
- [ -n "$d" ] && [ -n "$s" ] || { echo unknown; return; }
707
- [ "$d" = "$s" ] && echo match || echo drift
708
- }
709
-
710
- cmd_status() {
711
- local version p
712
- if [ -d "$REPO/.git" ]; then
713
- version="$(git -C "$REPO" describe --tags --always --dirty 2>/dev/null || git -C "$REPO" rev-parse --short HEAD 2>/dev/null || echo '?')"
714
- log "agent-bios (git clone: $version)"
715
- elif [ -f "$REPO/package.json" ]; then
716
- version="$(node -e "try{process.stdout.write(require('$REPO/package.json').version)}catch(e){process.stdout.write('?')}" 2>/dev/null || echo '?')"
717
- log "agent-bios (npm package: $version)"
718
- else
719
- log "agent-bios"
720
- fi
721
- log " source: $REPO"
722
- case "$(drift_state)" in
723
- match) info "deployed version $(deployed_version) (matches this package)" ;;
724
- drift) log "DRIFT deployed $(deployed_version) but this package is $(source_version) — run: agent-bios install" ;;
725
- unknown) info "deployed version unknown (no state marker yet)" ;;
726
- esac
727
- for p in "$CLAUDE_DIR/CLAUDE.md" "$CODEX_DIR/AGENTS.md" "$BIN_DIR/agent-launch" \
728
- "$LAUNCH_DIR/profiles.toml" "$LAUNCH_DIR/shell.zsh"; do
729
- if [ -e "$p" ]; then info "present $p"; else info "MISSING $p"; fi
730
- done
731
- if [ -f "$ZSHRC" ] && grep -qF "$HOOK_MARK" "$ZSHRC"; then info "zsh hook present"; else info "zsh hook absent"; fi
732
- }
733
-
734
- cmd_update() {
735
- if [ -d "$REPO/.git" ]; then
736
- log "Updating from git..."
737
- run git -C "$REPO" pull --ff-only
738
- cmd_install
739
- else
740
- log "Installed as an npm package. Update with:"
741
- log " npm install -g agent-bios@latest && agent-bios install"
742
- log "Then confirm what actually landed — right after a publish the cached"
743
- log "packument can serve the PREVIOUS version at exit 0:"
744
- log " agent-bios status # must show the version you expected, and no DRIFT"
745
- fi
746
- }
747
-
748
- usage() {
749
- cat <<'EOF'
750
- agent-bios — deploy the Claude/Codex instruction SSOT into $HOME (by copy).
751
-
752
- agent-bios install deploy into this environment (backs up + verifies)
753
- agent-bios onboard interactive domain selection + packaged install + activation canary
754
- agent-bios verify check the deployed state matches the source
755
- agent-bios learn submit a session learning (reads the JSON record on
756
- stdin; this is what the learn! flow calls, and it
757
- works from any directory, unlike a repo-relative path)
758
- agent-bios status show what is installed and where
759
- agent-bios update git pull + reinstall (clone), or print the npm update line
760
- agent-bios uninstall remove deployed files and the zsh hook
761
- agent-bios help
762
-
763
- Flags: --dry-run print actions without changing anything
764
- --domains a,b packaged mode (install/onboard): assemble ONLY the named
765
- domain packages (plus core+infra) instead of the full
766
- corpus; with onboard, 'none' means core+infra only. The
767
- selection persists in the state dir, so later
768
- installs/updates stay packaged until the selection file
769
- is removed. Default (no flag, no saved selection) keeps
770
- today's full-corpus deploy.
771
- --with a,b also install the named optional dependencies (install only).
772
- Without it, install offers each missing one when the terminal
773
- is interactive, and otherwise just prints its install line.
774
- Known: onto, ultracode. Missing ones are not fatal — they only
775
- degrade the review routes that need them.
776
- Env: CLAUDE_CONFIG_DIR, CODEX_HOME, AGENT_LAUNCH_VENV, ZDOTDIR
777
- EOF
778
- }
779
-
780
- # ---- dispatch ------------------------------------------------------------
781
- CMD="${1:-help}"
782
- if [ $# -gt 0 ]; then shift; fi
783
-
784
- # `learn` forwards its arguments and stdin straight to the collector, so it must
785
- # bypass the flag parser below (which rejects anything it does not know). This
786
- # subcommand is the only PATH-reachable entry to capture: the corpus guide used
787
- # to invoke scripts/collect-learning.py relative to the cwd, which works from a
788
- # clone and silently fails for every other install.
789
- if [ "$CMD" = "learn" ]; then
790
- collector="$REPO/scripts/collect-learning.py"
791
- [ -f "$collector" ] || { log "learn: collector missing at $collector"; exit 1; }
792
- exec python3 "$collector" "$@" <&3
793
- fi
794
-
795
- WITH=""
796
- DOMAINS_ARG=""
797
- DOMAINS_SET=0
798
- while [ $# -gt 0 ]; do
799
- case "$1" in
800
- --dry-run) DRY_RUN=1 ;;
801
- --with) shift; WITH="${1:-}"; [ -n "$WITH" ] || { log "--with needs a comma-separated capability list"; exit 2; } ;;
802
- --with=*) WITH="${1#--with=}"; [ -n "$WITH" ] || { log "--with needs a comma-separated capability list"; exit 2; } ;;
803
- --domains) shift; DOMAINS_ARG="${1:-}"; DOMAINS_SET=1; [ -n "$DOMAINS_ARG" ] || { log "--domains needs a comma-separated domain list (use onboard for core-only)"; exit 2; } ;;
804
- --domains=*) DOMAINS_ARG="${1#--domains=}"; DOMAINS_SET=1; [ -n "$DOMAINS_ARG" ] || { log "--domains needs a comma-separated domain list (use onboard for core-only)"; exit 2; } ;;
805
- *) log "unknown flag: $1"; exit 2 ;;
806
- esac
807
- shift
808
- done
809
-
810
- case "$CMD" in
811
- install) cmd_install ;;
812
- onboard) cmd_onboard ;;
813
- update) cmd_update ;;
814
- uninstall) cmd_uninstall ;;
815
- verify) if cmd_verify; then log "VERIFY OK"; else log "VERIFY FAILED"; exit 1; fi ;;
816
- status) cmd_status ;;
817
- help|-h|--help) usage ;;
818
- *) log "unknown command: $CMD"; usage; exit 2 ;;
819
- esac