agent-bios 0.2.0 → 0.4.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.
@@ -102,6 +102,29 @@ deploy_glob() {
102
102
  done
103
103
  }
104
104
 
105
+ # ---- packaged (domain-selected) corpus deploy ----------------------------
106
+ # Active when --domains is passed or a prior selection exists in STATE_DIR.
107
+ # assemble.py owns the corpus surfaces (central tree, entry seeding, codex
108
+ # marker region, settings merge); the entry CLAUDE.md and AGENTS.md are NOT
109
+ # manifested — the entry is user-owned after seeding, AGENTS.md holds a
110
+ # personal region — so uninstall never deletes them.
111
+ packaged_mode() { [ "${DOMAINS_SET:-0}" = 1 ] || [ -f "$STATE_DIR/selection.json" ]; }
112
+
113
+ assemble_packaged() {
114
+ local args=(--claude-dir "$CLAUDE_DIR" --codex-dir "$CODEX_DIR" --state-dir "$STATE_DIR") rc=0
115
+ [ "${DOMAINS_SET:-0}" = 1 ] && args+=(--domains "$DOMAINS_ARG")
116
+ [ "$DRY_RUN" = 1 ] && args+=(--dry-run)
117
+ python3 "$REPO/scripts/assemble.py" "${args[@]}" || rc=$?
118
+ if [ "$rc" = 2 ]; then
119
+ log "packaged: entry file needs user action (import line missing); central content will not load until it is added"
120
+ elif [ "$rc" != 0 ]; then
121
+ return 1
122
+ fi
123
+ if [ "$DRY_RUN" != 1 ]; then
124
+ find "$CLAUDE_DIR/central" "$CODEX_DIR/guides" -type f 2>/dev/null >> "$MANIFEST"
125
+ fi
126
+ }
127
+
105
128
  add_zsh_hook() {
106
129
  if [ -f "$ZSHRC" ] && grep -qF "$HOOK_MARK" "$ZSHRC"; then
107
130
  info "zsh hook present $ZSHRC"
@@ -126,6 +149,152 @@ migrate_state() {
126
149
  mv "$LEGACY_STATE_DIR" "$STATE_DIR" && info "migrated state $LEGACY_STATE_DIR -> $STATE_DIR"
127
150
  }
128
151
 
152
+ # ---- codex live-config additions -----------------------------------------
153
+ # The live ~/.codex/config.toml is user/runtime-owned; agent-bios never
154
+ # deploys or overwrites it. codex/config-additions.toml declares the only
155
+ # content agent-bios manages there — one marked [agents.*] block plus a tagged
156
+ # features.multi_agent line — and this helper merges (install), checks
157
+ # (verify), or removes (uninstall) exactly that content, backed up and
158
+ # tomllib-validated before any write. Modes: merge | check | remove.
159
+ codex_config_additions() {
160
+ AB_MODE="$1" AB_CODEX_DIR="$CODEX_DIR" AB_FRAGMENT="$REPO/codex/config-additions.toml" \
161
+ AB_BACKUP="${BACKUP_DIR:-}" AB_DRY="$DRY_RUN" python3 - <<'PY'
162
+ import os, pathlib, sys, tomllib
163
+
164
+ mode = os.environ["AB_MODE"]
165
+ codex_dir = pathlib.Path(os.environ["AB_CODEX_DIR"])
166
+ fragment_path = pathlib.Path(os.environ["AB_FRAGMENT"])
167
+ backup_root = os.environ.get("AB_BACKUP", "")
168
+ dry = os.environ.get("AB_DRY") == "1"
169
+ target = codex_dir / "config.toml"
170
+ BEGIN = "# >>> agent-bios additions >>>"
171
+ END = "# <<< agent-bios additions <<<"
172
+ TAG = "# agent-bios"
173
+
174
+ def info(msg): print(f" {msg}")
175
+ def fail(msg): print(msg); sys.exit(1)
176
+
177
+ frag_text = fragment_path.read_text().replace("${CODEX_HOME}", str(codex_dir))
178
+ want_agents = tomllib.loads(frag_text)["agents"]
179
+ live_text = target.read_text() if target.is_file() else ""
180
+ try:
181
+ live = tomllib.loads(live_text) if live_text else {}
182
+ except Exception as exc:
183
+ fail(f"live codex config does not parse; not touching it: {target} ({exc})")
184
+
185
+ def state_ok():
186
+ if live.get("features", {}).get("multi_agent") is not True:
187
+ return False
188
+ return all(
189
+ live.get("agents", {}).get(name, {}).get(key) == spec[key]
190
+ for name, spec in want_agents.items()
191
+ for key in ("description", "config_file")
192
+ )
193
+
194
+ if mode == "check":
195
+ problems = []
196
+ if live.get("features", {}).get("multi_agent") is not True:
197
+ problems.append("features.multi_agent is not true")
198
+ for name, spec in want_agents.items():
199
+ if live.get("agents", {}).get(name, {}).get("config_file") != spec["config_file"]:
200
+ problems.append(f"agents.{name}.config_file drifted or missing")
201
+ elif not pathlib.Path(spec["config_file"]).is_file():
202
+ problems.append(f"agents.{name} template missing: {spec['config_file']}")
203
+ if problems:
204
+ fail(f"codex config additions: {'; '.join(problems)} ({target})")
205
+ info("codex config additions OK")
206
+ sys.exit(0)
207
+
208
+ def strip_managed(text):
209
+ out, skipping = [], False
210
+ for line in text.splitlines(keepends=True):
211
+ s = line.strip()
212
+ if s == BEGIN: skipping = True; continue
213
+ if s == END: skipping = False; continue
214
+ if skipping or s.endswith(TAG): continue
215
+ out.append(line)
216
+ return "".join(out)
217
+
218
+ if mode == "remove":
219
+ if not target.is_file():
220
+ sys.exit(0)
221
+ stripped = strip_managed(live_text)
222
+ if stripped == live_text:
223
+ info(f"no agent-bios additions in {target}")
224
+ sys.exit(0)
225
+ try:
226
+ tomllib.loads(stripped)
227
+ except Exception as exc:
228
+ fail(f"refusing removal; result would not parse: {exc}")
229
+ if dry:
230
+ info(f"[dry-run] remove agent-bios additions from {target}")
231
+ sys.exit(0)
232
+ backup = target.with_name(target.name + ".bak-agent-bios-uninstall")
233
+ backup.write_text(live_text)
234
+ target.write_text(stripped)
235
+ info(f"removed additions {target} (backup: {backup.name})")
236
+ sys.exit(0)
237
+
238
+ # mode == merge
239
+ if state_ok():
240
+ info(f"unchanged {target} (additions present)")
241
+ sys.exit(0)
242
+
243
+ # A drifted [agents.<tier>] outside our markers would become a duplicate
244
+ # table if we appended ours; that conflict needs the user, not a clobber.
245
+ base = strip_managed(live_text)
246
+ base_data = tomllib.loads(base) if base.strip() else {}
247
+ clash = [name for name in want_agents if name in base_data.get("agents", {})]
248
+ if clash:
249
+ fail(
250
+ f"unmanaged [agents.{'/'.join(clash)}] with drifted content in {target}; "
251
+ "align or remove them, then rerun install"
252
+ )
253
+
254
+ block_lines = [BEGIN]
255
+ if "features" not in base_data:
256
+ block_lines += ["[features]", f"multi_agent = true {TAG}"]
257
+ for name, spec in want_agents.items():
258
+ block_lines += [
259
+ f"[agents.{name}]",
260
+ f'description = "{spec["description"]}"',
261
+ f'config_file = "{spec["config_file"]}"',
262
+ ]
263
+ block_lines.append(END)
264
+ block = "\n".join(block_lines) + "\n"
265
+
266
+ new_text = base
267
+ if "features" in base_data:
268
+ if base_data["features"].get("multi_agent") is None:
269
+ lines = new_text.splitlines(keepends=True)
270
+ for i, line in enumerate(lines):
271
+ if line.strip() == "[features]":
272
+ lines.insert(i + 1, f"multi_agent = true {TAG}\n")
273
+ break
274
+ new_text = "".join(lines)
275
+ elif base_data["features"].get("multi_agent") is not True:
276
+ info(f"note: features.multi_agent explicitly set in {target}; leaving it")
277
+ if new_text and not new_text.endswith("\n"):
278
+ new_text += "\n"
279
+ new_text += ("\n" if new_text else "") + block
280
+
281
+ try:
282
+ tomllib.loads(new_text)
283
+ except Exception as exc:
284
+ fail(f"merge result would not parse; live config untouched ({exc})")
285
+ if dry:
286
+ info(f"[dry-run] merge agent-bios additions into {target}")
287
+ sys.exit(0)
288
+ if live_text and backup_root:
289
+ bpath = pathlib.Path(backup_root + str(target))
290
+ bpath.parent.mkdir(parents=True, exist_ok=True)
291
+ bpath.write_text(live_text)
292
+ target.parent.mkdir(parents=True, exist_ok=True)
293
+ target.write_text(new_text)
294
+ info(f"merged additions {target}")
295
+ PY
296
+ }
297
+
129
298
  # ---- optional dependencies -----------------------------------------------
130
299
  # Capabilities are optional: a missing one only degrades the review routes that
131
300
  # need it. config/agent-launch.toml is the single source for both the command
@@ -253,11 +422,19 @@ cmd_install() {
253
422
  : > "$MANIFEST"
254
423
  fi
255
424
  log "Deploying agent-bios from $REPO"
256
- deploy_file "$REPO/claude/CLAUDE.md" "$CLAUDE_DIR/CLAUDE.md"
257
- deploy_glob "$REPO/claude/guides" "*.md" "$CLAUDE_DIR/guides"
258
- deploy_file "$REPO/codex/AGENTS.md" "$CODEX_DIR/AGENTS.md"
259
- deploy_glob "$REPO/codex/guides" "*.md" "$CODEX_DIR/guides"
425
+ if packaged_mode; then
426
+ log "packaged mode: assembling selected domains (state: $STATE_DIR/selection.json)"
427
+ assemble_packaged || exit 1
428
+ else
429
+ deploy_file "$REPO/claude/CLAUDE.md" "$CLAUDE_DIR/CLAUDE.md"
430
+ deploy_glob "$REPO/claude/guides" "*.md" "$CLAUDE_DIR/guides"
431
+ deploy_glob "$REPO/claude/agents" "*.md" "$CLAUDE_DIR/agents"
432
+ deploy_glob "$REPO/claude/hooks" "*.py" "$CLAUDE_DIR/hooks" "+x"
433
+ deploy_file "$REPO/codex/AGENTS.md" "$CODEX_DIR/AGENTS.md"
434
+ deploy_glob "$REPO/codex/guides" "*.md" "$CODEX_DIR/guides"
435
+ fi
260
436
  deploy_glob "$REPO/codex/agents" "*.toml" "$CODEX_DIR/agents"
437
+ codex_config_additions merge || exit 1
261
438
  deploy_file "$REPO/scripts/codex-run.sh" "$CODEX_DIR/bin/codex-run" "+x"
262
439
  deploy_file "$REPO/scripts/codex-helm.sh" "$CODEX_DIR/bin/codex-helm" "+x"
263
440
  migrate_user_presets || exit 1 # must precede the deploy below, which overwrites profiles.toml
@@ -275,12 +452,19 @@ cmd_install() {
275
452
  || log "warning: venv provisioning failed (numbered-prompt fallback applies)"
276
453
  fi
277
454
  add_zsh_hook
455
+ if python3 "$REPO/scripts/session-distill/corpus-state.py" project --repo "$REPO" >/dev/null 2>&1; then
456
+ info "corpus-status projected"
457
+ else
458
+ log "note: corpus-status projection unavailable (versions.json/ledger missing?)"
459
+ fi
278
460
  log ""
279
461
  log "Verifying deployment..."
280
462
  if cmd_verify; then
281
463
  log ""
282
464
  log "Done. Open a new shell (or: source \"$ZSHRC\") to activate the zero-arg launcher."
283
- [ -n "$BACKUP_DIR" ] && [ -d "$BACKUP_DIR" ] && log "Replaced files were backed up under $BACKUP_DIR"
465
+ # An untouched backup dir means nothing was replaced; that healthy state
466
+ # must not become a nonzero exit under set -e.
467
+ { [ -n "$BACKUP_DIR" ] && [ -d "$BACKUP_DIR" ] && log "Replaced files were backed up under $BACKUP_DIR"; } || true
284
468
  else
285
469
  log "VERIFY FAILED after install — see messages above"
286
470
  exit 1
@@ -292,13 +476,32 @@ verify_present() { if [ -f "$1" ]; then return 0; else log "missing $1"; return
292
476
 
293
477
  cmd_verify() {
294
478
  local fail=0 gp
295
- verify_match "$REPO/claude/CLAUDE.md" "$CLAUDE_DIR/CLAUDE.md" || fail=1
296
- verify_match "$REPO/codex/AGENTS.md" "$CODEX_DIR/AGENTS.md" || fail=1
479
+ if packaged_mode; then
480
+ # Packaged: corpus surfaces are selection-derived, not repo-identical.
481
+ # The entry file is user-owned — READ-check the import line, never rewrite.
482
+ python3 "$REPO/scripts/check-domains.py" >/dev/null 2>&1 && info "domains gate OK" || { log "domains gate FAILED"; fail=1; }
483
+ verify_present "$CLAUDE_DIR/central/bundle.md" || fail=1
484
+ if grep -qF '@central/bundle.md' "$CLAUDE_DIR/CLAUDE.md" 2>/dev/null; then
485
+ info "entry import line present"
486
+ else
487
+ log "entry $CLAUDE_DIR/CLAUDE.md lacks '@central/bundle.md' — central corpus is NOT loading"; fail=1
488
+ fi
489
+ if grep -qF 'agent-bios:central:start' "$CODEX_DIR/AGENTS.md" 2>/dev/null; then
490
+ info "codex central region present"
491
+ else
492
+ log "codex AGENTS.md central region missing"; fail=1
493
+ fi
494
+ else
495
+ verify_match "$REPO/claude/CLAUDE.md" "$CLAUDE_DIR/CLAUDE.md" || fail=1
496
+ verify_match "$REPO/codex/AGENTS.md" "$CODEX_DIR/AGENTS.md" || fail=1
497
+ for gp in "$REPO"/claude/guides/*.md; do verify_present "$CLAUDE_DIR/guides/$(basename "$gp")" || fail=1; done
498
+ for gp in "$REPO"/claude/agents/*.md; do verify_present "$CLAUDE_DIR/agents/$(basename "$gp")" || fail=1; done
499
+ for gp in "$REPO"/claude/hooks/*.py; do verify_present "$CLAUDE_DIR/hooks/$(basename "$gp")" || fail=1; done
500
+ for gp in "$REPO"/codex/guides/*.md; do verify_present "$CODEX_DIR/guides/$(basename "$gp")" || fail=1; done
501
+ fi
297
502
  verify_match "$REPO/scripts/agent-launch.py" "$BIN_DIR/agent-launch" || fail=1
298
503
  verify_match "$REPO/config/agent-launch.toml" "$LAUNCH_DIR/profiles.toml" || fail=1
299
504
  verify_match "$REPO/shell/agent-launch.zsh" "$LAUNCH_DIR/shell.zsh" || fail=1
300
- for gp in "$REPO"/claude/guides/*.md; do verify_present "$CLAUDE_DIR/guides/$(basename "$gp")" || fail=1; done
301
- for gp in "$REPO"/codex/guides/*.md; do verify_present "$CODEX_DIR/guides/$(basename "$gp")" || fail=1; done
302
505
  python3 - "$CODEX_DIR/agents" <<'PY' && info "agent TOMLs OK" || fail=1
303
506
  import sys, pathlib, tomllib
304
507
  root = pathlib.Path(sys.argv[1])
@@ -308,6 +511,7 @@ assert not missing, f"missing agent TOMLs in {root}: {sorted(missing)}"
308
511
  for p in sorted(root.glob("*.toml")):
309
512
  tomllib.loads(p.read_text())
310
513
  PY
514
+ codex_config_additions check || fail=1
311
515
  if command -v codex >/dev/null 2>&1 && [ -x "$CODEX_DIR/bin/codex-helm" ]; then
312
516
  if "$CODEX_DIR/bin/codex-helm" --dry-run --mode review "probe" >/dev/null 2>&1; then
313
517
  info "codex-helm dry-run OK"
@@ -340,6 +544,7 @@ PY
340
544
 
341
545
  cmd_uninstall() {
342
546
  migrate_state
547
+ codex_config_additions remove || log "warning: could not remove codex config additions"
343
548
  if [ -f "$MANIFEST" ]; then
344
549
  local f
345
550
  while IFS= read -r f; do
@@ -357,7 +562,7 @@ cmd_uninstall() {
357
562
  done
358
563
  fi
359
564
  local d
360
- for d in "$CLAUDE_DIR/guides" "$CODEX_DIR/guides" "$CODEX_DIR/agents" "$CODEX_DIR/bin" "$LAUNCH_DIR"; do
565
+ for d in "$CLAUDE_DIR/guides" "$CLAUDE_DIR/agents" "$CODEX_DIR/guides" "$CODEX_DIR/agents" "$CODEX_DIR/bin" "$LAUNCH_DIR"; do
361
566
  [ -d "$d" ] && rmdir "$d" 2>/dev/null && info "removed empty $d" || true
362
567
  done
363
568
  remove_zsh_hook
@@ -366,6 +571,49 @@ cmd_uninstall() {
366
571
  log "Kept: managed venv + backups under $STATE_DIR (rm -rf \"$STATE_DIR\" to purge)."
367
572
  }
368
573
 
574
+ cmd_onboard() {
575
+ log "agent-bios onboarding — pick your domain packages (core + infra always install)"
576
+ local names=() line i=1 choice sel="" n picks
577
+ while IFS= read -r line; do names+=("$line"); done \
578
+ < <(python3 -c "import json;print('\n'.join(sorted(json.load(open('$REPO/config/domains.json'))['domains'])))")
579
+ [ "${#names[@]}" -ge 1 ] || { log "no domains found in config/domains.json"; exit 1; }
580
+ if [ "$DOMAINS_SET" = 1 ]; then
581
+ # Non-interactive path, per this installer's input contract (stdin is
582
+ # detached at the top of the script): selection arrives as domain names.
583
+ [ "$DOMAINS_ARG" = none ] && DOMAINS_ARG=""
584
+ sel="$DOMAINS_ARG"
585
+ elif ( : </dev/tty ) 2>/dev/null; then
586
+ for line in "${names[@]}"; do info "$i) $line"; i=$((i+1)); done
587
+ printf 'Select by number, comma-separated (empty = core+infra only): '
588
+ read -r choice </dev/tty || choice=""
589
+ if [ -n "$choice" ]; then
590
+ # bash 3.2 + set -u: expanding an EMPTY array errors, so split only
591
+ # when there is input; empty input means core+infra only.
592
+ IFS=',' read -ra picks <<<"$choice"
593
+ for n in "${picks[@]}"; do
594
+ n="${n// /}"; [ -n "$n" ] || continue
595
+ case "$n" in (*[!0-9]*) log "invalid selection: $n"; exit 2 ;; esac
596
+ [ "$n" -ge 1 ] && [ "$n" -le "${#names[@]}" ] || { log "selection out of range: $n"; exit 2; }
597
+ sel="$sel${sel:+,}${names[$((n-1))]}"
598
+ done
599
+ fi
600
+ else
601
+ log "onboard needs a terminal or an explicit selection — run:"
602
+ log " agent-bios onboard --domains a,b (or --domains none for core+infra only)"
603
+ exit 2
604
+ fi
605
+ DOMAINS_ARG="$sel"; DOMAINS_SET=1
606
+ log "selection: ${sel:-<core+infra only>}"
607
+ cmd_install
608
+ log ""
609
+ log "Activation canary (proves the bundle loads in a live session)..."
610
+ if [ "$DRY_RUN" = 1 ]; then info "[dry-run] skip canary probe"; return; fi
611
+ bash "$REPO/scripts/canary.sh" || {
612
+ log "ONBOARDING INCOMPLETE: the bundle is installed but not loading — fix the cause above and re-run: agent-bios verify"
613
+ exit 1
614
+ }
615
+ }
616
+
369
617
  cmd_status() {
370
618
  local version p
371
619
  if [ -d "$REPO/.git" ]; then
@@ -401,6 +649,7 @@ usage() {
401
649
  agent-bios — deploy the Claude/Codex instruction SSOT into $HOME (by copy).
402
650
 
403
651
  agent-bios install deploy into this environment (backs up + verifies)
652
+ agent-bios onboard interactive domain selection + packaged install + activation canary
404
653
  agent-bios verify check the deployed state matches the source
405
654
  agent-bios status show what is installed and where
406
655
  agent-bios update git pull + reinstall (clone), or print the npm update line
@@ -408,6 +657,13 @@ agent-bios — deploy the Claude/Codex instruction SSOT into $HOME (by copy).
408
657
  agent-bios help
409
658
 
410
659
  Flags: --dry-run print actions without changing anything
660
+ --domains a,b packaged mode (install/onboard): assemble ONLY the named
661
+ domain packages (plus core+infra) instead of the full
662
+ corpus; with onboard, 'none' means core+infra only. The
663
+ selection persists in the state dir, so later
664
+ installs/updates stay packaged until the selection file
665
+ is removed. Default (no flag, no saved selection) keeps
666
+ today's full-corpus deploy.
411
667
  --with a,b also install the named optional dependencies (install only).
412
668
  Without it, install offers each missing one when the terminal
413
669
  is interactive, and otherwise just prints its install line.
@@ -421,11 +677,15 @@ EOF
421
677
  CMD="${1:-help}"
422
678
  if [ $# -gt 0 ]; then shift; fi
423
679
  WITH=""
680
+ DOMAINS_ARG=""
681
+ DOMAINS_SET=0
424
682
  while [ $# -gt 0 ]; do
425
683
  case "$1" in
426
684
  --dry-run) DRY_RUN=1 ;;
427
685
  --with) shift; WITH="${1:-}"; [ -n "$WITH" ] || { log "--with needs a comma-separated capability list"; exit 2; } ;;
428
686
  --with=*) WITH="${1#--with=}"; [ -n "$WITH" ] || { log "--with needs a comma-separated capability list"; exit 2; } ;;
687
+ --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; } ;;
688
+ --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; } ;;
429
689
  *) log "unknown flag: $1"; exit 2 ;;
430
690
  esac
431
691
  shift
@@ -433,6 +693,7 @@ done
433
693
 
434
694
  case "$CMD" in
435
695
  install) cmd_install ;;
696
+ onboard) cmd_onboard ;;
436
697
  update) cmd_update ;;
437
698
  uninstall) cmd_uninstall ;;
438
699
  verify) if cmd_verify; then log "VERIFY OK"; else log "VERIFY FAILED"; exit 1; fi ;;