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.
- package/DEPENDENCIES.md +19 -19
- package/README.md +43 -12
- package/claude/CLAUDE.md +5 -41
- package/claude/guides/claude-prompting.md +1 -1
- package/claude/guides/cli-multi-model-workflow.md +22 -4
- package/claude/guides/coding-staged-workflow.md +49 -15
- package/claude/guides/concept-economy.md +187 -0
- package/claude/guides/documentation-hygiene.md +112 -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/review-request.md +9 -7
- package/claude/guides/session-distill-workflow.md +19 -9
- package/claude/guides/tooling-gotchas.md +26 -0
- package/claude/guides/verification-discipline.md +166 -0
- package/claude/hooks/tooling-gotchas-hook.py +329 -12
- package/codex/AGENTS.md +5 -41
- package/codex/guides/claude-prompting.md +1 -1
- package/codex/guides/cli-multi-model-workflow.md +22 -4
- package/codex/guides/coding-staged-workflow.md +49 -15
- package/codex/guides/concept-economy.md +187 -0
- package/codex/guides/documentation-hygiene.md +112 -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/review-request.md +9 -7
- package/codex/guides/session-distill-workflow.md +19 -9
- package/codex/guides/tooling-gotchas.md +26 -0
- package/codex/guides/verification-discipline.md +166 -0
- package/{scripts → compose}/assemble.py +194 -17
- package/{scripts → compose}/canary.sh +14 -5
- package/compose/check-domains.py +1178 -0
- package/{config → compose}/domains.json +11 -44
- package/{scripts → compose}/pkgid.py +8 -1
- package/compose/prune-backups.py +204 -0
- package/{scripts → compose}/register-hooks.py +3 -3
- package/install.sh +1233 -0
- 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 +32 -27
- package/provenance.json +1 -0
- 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-domains.py +0 -296
- package/scripts/check-parity.sh +0 -2003
- package/scripts/install.sh +0 -819
- /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
package/install.sh
ADDED
|
@@ -0,0 +1,1233 @@
|
|
|
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
|
+
# The braces matter. `exec` with redirections and no command applies them to the
|
|
29
|
+
# SHELL, permanently — so the bare `exec 3<&0 2>/dev/null` this used to be sent
|
|
30
|
+
# every later error message on this script's stderr to /dev/null: bash's own
|
|
31
|
+
# set -e diagnostics, python tracebacks from deployed steps, and any `>&2` an
|
|
32
|
+
# author writes. Only stdout survived, which is why a failing install could stop
|
|
33
|
+
# with no reason on screen. The group scopes the suppression to the one command
|
|
34
|
+
# whose error is expected when a caller closed fd 0.
|
|
35
|
+
{ exec 3<&0; } 2>/dev/null || exec 3</dev/null # tolerate a caller that closed fd 0
|
|
36
|
+
exec </dev/null
|
|
37
|
+
|
|
38
|
+
# Resolve this script through symlinks before locating the package: npm links the
|
|
39
|
+
# bin into node_modules/.bin and the global bin dir, so $0 is a link and its
|
|
40
|
+
# dirname is the link's directory, not the package. Without this the source tree
|
|
41
|
+
# resolves to the bin dir's parent (e.g. /opt/homebrew) and every deploy fails.
|
|
42
|
+
SOURCE="${BASH_SOURCE[0]}"
|
|
43
|
+
while [ -L "$SOURCE" ]; do
|
|
44
|
+
LINKDIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
|
|
45
|
+
SOURCE="$(readlink "$SOURCE")"
|
|
46
|
+
case "$SOURCE" in
|
|
47
|
+
/*) ;;
|
|
48
|
+
*) SOURCE="$LINKDIR/$SOURCE" ;;
|
|
49
|
+
esac
|
|
50
|
+
done
|
|
51
|
+
SELF="$(cd -P "$(dirname "$SOURCE")" && pwd)"
|
|
52
|
+
REPO="$SELF"
|
|
53
|
+
|
|
54
|
+
CLAUDE_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
|
|
55
|
+
CODEX_DIR="${CODEX_HOME:-$HOME/.codex}"
|
|
56
|
+
LAUNCH_DIR="$HOME/.config/agent-launch"
|
|
57
|
+
USER_PRESETS_NAME="presets.local.toml" # user-owned; never deployed or verified
|
|
58
|
+
BIN_DIR="$HOME/.local/bin"
|
|
59
|
+
STATE_DIR="$HOME/.local/share/agent-bios"
|
|
60
|
+
LEGACY_STATE_DIR="$HOME/.local/share/agent-dotfiles" # pre-rename state; migrated on first run
|
|
61
|
+
MANIFEST="$STATE_DIR/manifest.txt"
|
|
62
|
+
# The manifest is truncated at the start of every install, so what the PREVIOUS one deployed
|
|
63
|
+
# must be preserved before that happens. It is the only record that a file now on disk was
|
|
64
|
+
# written by us rather than authored by the user, and seed_entry needs exactly that to tell a
|
|
65
|
+
# previous release's deployed entry from someone's own CLAUDE.md.
|
|
66
|
+
PRIOR_MANIFEST="$STATE_DIR/manifest.prev.txt"
|
|
67
|
+
ZSHRC="${ZDOTDIR:-$HOME}/.zshrc"
|
|
68
|
+
ZSH_HOOK='[ -r "$HOME/.config/agent-launch/shell.zsh" ] && source "$HOME/.config/agent-launch/shell.zsh"'
|
|
69
|
+
HOOK_MARK='agent-launch/shell.zsh'
|
|
70
|
+
|
|
71
|
+
DRY_RUN=0
|
|
72
|
+
BACKUP_DIR=""
|
|
73
|
+
|
|
74
|
+
log() { printf '%s\n' "$*"; }
|
|
75
|
+
info() { printf ' %s\n' "$*"; }
|
|
76
|
+
run() { if [ "$DRY_RUN" = 1 ]; then printf ' [dry-run] %s\n' "$*"; else "$@"; fi; }
|
|
77
|
+
|
|
78
|
+
# ---- prerequisites -------------------------------------------------------
|
|
79
|
+
check_prereqs() {
|
|
80
|
+
local ok=0
|
|
81
|
+
command -v git >/dev/null 2>&1 || { log "missing prerequisite: git"; ok=1; }
|
|
82
|
+
command -v python3 >/dev/null 2>&1 || { log "missing prerequisite: python3"; ok=1; }
|
|
83
|
+
if command -v python3 >/dev/null 2>&1; then
|
|
84
|
+
python3 -c 'import sys; sys.exit(0 if sys.version_info>=(3,11) else 1)' 2>/dev/null \
|
|
85
|
+
|| { log "python3 >= 3.11 required (tomllib)"; ok=1; }
|
|
86
|
+
fi
|
|
87
|
+
command -v zsh >/dev/null 2>&1 || log "note: zsh not found; the shell hook targets zsh"
|
|
88
|
+
command -v codex >/dev/null 2>&1 || log "note: codex CLI not found; Codex-side steps will be skipped"
|
|
89
|
+
command -v claude >/dev/null 2>&1 || log "note: claude CLI not found"
|
|
90
|
+
return $ok
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# ---- copy with backup + manifest ----------------------------------------
|
|
94
|
+
deploy_file() {
|
|
95
|
+
local src="$1" dst="$2" mode="${3:-}"
|
|
96
|
+
[ -f "$src" ] || { log "source missing: $src"; return 1; }
|
|
97
|
+
if [ -f "$dst" ] && cmp -s "$src" "$dst"; then
|
|
98
|
+
info "unchanged $dst"
|
|
99
|
+
else
|
|
100
|
+
if [ -f "$dst" ] && [ -n "$BACKUP_DIR" ]; then
|
|
101
|
+
run mkdir -p "$(dirname "$BACKUP_DIR$dst")"
|
|
102
|
+
run cp "$dst" "$BACKUP_DIR$dst"
|
|
103
|
+
fi
|
|
104
|
+
run mkdir -p "$(dirname "$dst")"
|
|
105
|
+
run cp "$src" "$dst"
|
|
106
|
+
if [ -n "$mode" ]; then run chmod "$mode" "$dst"; fi
|
|
107
|
+
[ "$DRY_RUN" = 1 ] || info "installed $dst"
|
|
108
|
+
fi
|
|
109
|
+
[ "$DRY_RUN" = 1 ] || printf '%s\n' "$dst" >> "$MANIFEST"
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
deploy_glob() {
|
|
113
|
+
local srcdir="$1" pat="$2" dstdir="$3" mode="${4:-}" f
|
|
114
|
+
for f in "$srcdir"/$pat; do
|
|
115
|
+
[ -f "$f" ] || continue
|
|
116
|
+
deploy_file "$f" "$dstdir/$(basename "$f")" "$mode"
|
|
117
|
+
done
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
# Backups are made on every install and every uninstall, and nothing used to remove them — 23
|
|
121
|
+
# directories in two weeks on the first machine measured, plus loose `.bak-*` files sitting in
|
|
122
|
+
# the user's own config dirs. Pruning runs AFTER the new copy exists, never before, so a failed
|
|
123
|
+
# run cannot leave the user with neither the change nor its backup.
|
|
124
|
+
prune_backups() {
|
|
125
|
+
[ -f "$REPO/compose/prune-backups.py" ] || return 0
|
|
126
|
+
python3 "$REPO/compose/prune-backups.py" --state-dir "$STATE_DIR" \
|
|
127
|
+
--claude-dir "$CLAUDE_DIR" --codex-dir "$CODEX_DIR" \
|
|
128
|
+
$([ "$DRY_RUN" = 1 ] && echo --dry-run) || log "note: backup pruning skipped"
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# Guides whose frontmatter declares `audience: author`. They document steps only
|
|
132
|
+
# the corpus author can perform and name paths that exist in a checkout and
|
|
133
|
+
# nowhere else, so they are never installed. compose/assemble.py owns the
|
|
134
|
+
# declaration and withholds them from the destinations it writes; this asks it
|
|
135
|
+
# rather than keeping a second parser. Both callers below need that same answer
|
|
136
|
+
# for ground the assembler never writes — the sweep of the pre-unification
|
|
137
|
+
# $CLAUDE_DIR/guides, and verify's check that the absence holds in all three
|
|
138
|
+
# directories. Absence of the assembler degrades to withholding nothing.
|
|
139
|
+
# Fails LOUD, never open. An empty answer here means "withhold nothing", so
|
|
140
|
+
# swallowing an unreadable declaration would deploy exactly the guides this rule
|
|
141
|
+
# exists to hold back — the fail-open shape of every other degrade-on-absence
|
|
142
|
+
# fallback in this file, but inverted. compose/assemble.py ships, so its absence
|
|
143
|
+
# is a broken payload rather than a configuration a user might have.
|
|
144
|
+
author_only_guides() {
|
|
145
|
+
local out
|
|
146
|
+
if ! out=$(python3 - "$REPO" <<'PY'
|
|
147
|
+
import pathlib, sys
|
|
148
|
+
root = pathlib.Path(sys.argv[1])
|
|
149
|
+
sys.path.insert(0, str(root / "compose"))
|
|
150
|
+
try:
|
|
151
|
+
from assemble import author_only
|
|
152
|
+
except ImportError as exc:
|
|
153
|
+
raise SystemExit(f"compose/assemble.py owns the audience declaration and "
|
|
154
|
+
f"could not be imported ({exc})")
|
|
155
|
+
guides = root / "claude" / "guides"
|
|
156
|
+
if not guides.is_dir():
|
|
157
|
+
raise SystemExit(f"{guides} is missing; the payload is incomplete")
|
|
158
|
+
for p in sorted(guides.glob("*.md")):
|
|
159
|
+
if author_only(p):
|
|
160
|
+
print(p.name)
|
|
161
|
+
PY
|
|
162
|
+
); then
|
|
163
|
+
# stderr, not stdout: every caller reads this function through a command
|
|
164
|
+
# substitution, so anything printed normally would be captured INTO the
|
|
165
|
+
# variable instead of reaching the operator — and `exit` here would leave
|
|
166
|
+
# only the subshell, letting the caller proceed with the error text as its
|
|
167
|
+
# list of withheld guides. The caller checks the status and exits itself.
|
|
168
|
+
log "cannot read which guides are author-only — refusing to deploy rather than" >&2
|
|
169
|
+
log " shipping them by default. compose/assemble.py owns that declaration and" >&2
|
|
170
|
+
log " must be present; reinstall the package." >&2
|
|
171
|
+
return 1
|
|
172
|
+
fi
|
|
173
|
+
printf '%s\n' "$out"
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
# Remove a withheld guide from a directory this installer deploys into, backing it
|
|
177
|
+
# up first exactly as deploy_file backs up a file it is about to overwrite. An
|
|
178
|
+
# unconditional rm would delete a same-named file this installer never wrote, and
|
|
179
|
+
# a shared or symlinked guides directory makes that somebody's own file.
|
|
180
|
+
prune_withheld() {
|
|
181
|
+
local dstdir="$1" base
|
|
182
|
+
printf '%s\n' "$WITHHELD_GUIDES" | while IFS= read -r base; do
|
|
183
|
+
[ -n "$base" ] || continue
|
|
184
|
+
[ -f "$dstdir/$base" ] || continue
|
|
185
|
+
if [ -n "$BACKUP_DIR" ]; then
|
|
186
|
+
run mkdir -p "$(dirname "$BACKUP_DIR$dstdir/$base")"
|
|
187
|
+
run cp "$dstdir/$base" "$BACKUP_DIR$dstdir/$base"
|
|
188
|
+
fi
|
|
189
|
+
run rm -f "$dstdir/$base"
|
|
190
|
+
[ "$DRY_RUN" = 1 ] || info "withheld (audience: author), removed stale copy: $dstdir/$base"
|
|
191
|
+
done
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
# ---- corpus deploy -------------------------------------------------------
|
|
195
|
+
# assemble.py owns the corpus surfaces (central tree, entry seeding, codex marker region,
|
|
196
|
+
# settings merge). The entry CLAUDE.md and AGENTS.md are NOT manifested — the entry is
|
|
197
|
+
# user-owned after seeding and AGENTS.md holds a personal region — so uninstall removes our
|
|
198
|
+
# central tree and marked regions and leaves the user's file itself alone.
|
|
199
|
+
# There is ONE install shape. There used to be two: a "full" deploy that wrote the corpus into
|
|
200
|
+
# the entry file, and a packaged one that assembled selected domains under `central/` and left the
|
|
201
|
+
# entry file to the user. They differed in the thing that matters most — who owns the entry file —
|
|
202
|
+
# so the same path was ours in one mode and theirs in the other, and no rule about user-owned
|
|
203
|
+
# content could be true of both. Full mode is now "every domain selected": the same assembly, the
|
|
204
|
+
# same ownership, one set of answers.
|
|
205
|
+
#
|
|
206
|
+
# What this buys, in the order the questions were asked: the user's own additions live in a file
|
|
207
|
+
# we never rewrite, so they are separately versioned by construction; a package can be added or
|
|
208
|
+
# dropped later by re-assembling, with no need to know which bytes came from where; and uninstall
|
|
209
|
+
# can take everything of ours because nothing of theirs is mixed into it.
|
|
210
|
+
assemble_corpus() {
|
|
211
|
+
local args=(--claude-dir "$CLAUDE_DIR" --codex-dir "$CODEX_DIR" --state-dir "$STATE_DIR") rc=0
|
|
212
|
+
if [ "${DOMAINS_SET:-0}" = 1 ]; then
|
|
213
|
+
args+=(--domains "$DOMAINS_ARG")
|
|
214
|
+
elif [ ! -f "$STATE_DIR/selection.json" ]; then
|
|
215
|
+
# No flag and no saved selection: install everything. This is what "full" meant, expressed as
|
|
216
|
+
# a selection so it goes down the same path as every other one.
|
|
217
|
+
args+=(--domains "$(python3 -c "import json,sys;print(','.join(sorted(json.load(open(sys.argv[1]))['domains'])))" "$REPO/compose/domains.json")")
|
|
218
|
+
fi
|
|
219
|
+
[ "$DRY_RUN" = 1 ] && args+=(--dry-run)
|
|
220
|
+
# A dry run never truncated the manifest, so the live one still describes the last install.
|
|
221
|
+
local prior="$PRIOR_MANIFEST"
|
|
222
|
+
[ "$DRY_RUN" = 1 ] && prior="$MANIFEST"
|
|
223
|
+
[ -f "$prior" ] && args+=(--prior-manifest "$prior")
|
|
224
|
+
python3 "$REPO/compose/assemble.py" "${args[@]}" || rc=$?
|
|
225
|
+
if [ "$rc" = 2 ]; then
|
|
226
|
+
log "entry file needs user action (import line missing); central content will not load until it is added"
|
|
227
|
+
elif [ "$rc" != 0 ]; then
|
|
228
|
+
return 1
|
|
229
|
+
fi
|
|
230
|
+
if [ "$DRY_RUN" != 1 ]; then
|
|
231
|
+
# `central/` is ours whole, so scanning it is right. `$CODEX_DIR/guides` is SHARED — the
|
|
232
|
+
# user and other tools keep files there, and the assembler deliberately leaves any name it
|
|
233
|
+
# did not deploy alone. Scanning that directory claimed those files anyway, and uninstall
|
|
234
|
+
# removes whatever the manifest names, so the protection was undone one step later. The
|
|
235
|
+
# ownership rule is the assembler's: the name exists in our source tree.
|
|
236
|
+
find "$CLAUDE_DIR/central" -type f 2>/dev/null >> "$MANIFEST"
|
|
237
|
+
local g dst
|
|
238
|
+
for g in "$REPO"/codex/guides/*.md; do
|
|
239
|
+
[ -f "$g" ] || continue
|
|
240
|
+
dst="$CODEX_DIR/guides/$(basename "$g")"
|
|
241
|
+
[ -f "$dst" ] && printf '%s\n' "$dst" >> "$MANIFEST"
|
|
242
|
+
done
|
|
243
|
+
fi
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
add_zsh_hook() {
|
|
247
|
+
if [ -f "$ZSHRC" ] && grep -qF "$HOOK_MARK" "$ZSHRC"; then
|
|
248
|
+
info "zsh hook present $ZSHRC"
|
|
249
|
+
return
|
|
250
|
+
fi
|
|
251
|
+
if [ "$DRY_RUN" = 1 ]; then info "[dry-run] append zsh hook to $ZSHRC"; return; fi
|
|
252
|
+
printf '%s\n' "$ZSH_HOOK" >> "$ZSHRC"
|
|
253
|
+
info "added zsh hook $ZSHRC"
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
# Carry state written under the pre-rename directory so an existing install keeps
|
|
257
|
+
# its manifest and backups instead of stranding them.
|
|
258
|
+
migrate_state() {
|
|
259
|
+
if [ -d "$STATE_DIR" ] || [ ! -d "$LEGACY_STATE_DIR" ]; then
|
|
260
|
+
return
|
|
261
|
+
fi
|
|
262
|
+
if [ "$DRY_RUN" = 1 ]; then
|
|
263
|
+
info "[dry-run] migrate state $LEGACY_STATE_DIR -> $STATE_DIR"
|
|
264
|
+
return
|
|
265
|
+
fi
|
|
266
|
+
mkdir -p "$(dirname "$STATE_DIR")"
|
|
267
|
+
mv "$LEGACY_STATE_DIR" "$STATE_DIR" && info "migrated state $LEGACY_STATE_DIR -> $STATE_DIR"
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
# ---- codex live-config additions -----------------------------------------
|
|
271
|
+
# The live ~/.codex/config.toml is user/runtime-owned; agent-bios never
|
|
272
|
+
# deploys or overwrites it. codex/config-additions.toml declares the only
|
|
273
|
+
# content agent-bios manages there — one marked [agents.*] block plus a tagged
|
|
274
|
+
# features.multi_agent line — and this helper merges (install), checks
|
|
275
|
+
# (verify), or removes (uninstall) exactly that content, backed up and
|
|
276
|
+
# tomllib-validated before any write. Modes: merge | check | remove.
|
|
277
|
+
codex_config_additions() {
|
|
278
|
+
AB_MODE="$1" AB_CODEX_DIR="$CODEX_DIR" AB_FRAGMENT="$REPO/codex/config-additions.toml" \
|
|
279
|
+
AB_BACKUP="${BACKUP_DIR:-}" AB_DRY="$DRY_RUN" python3 - <<'PY'
|
|
280
|
+
import os, pathlib, sys, tomllib
|
|
281
|
+
|
|
282
|
+
mode = os.environ["AB_MODE"]
|
|
283
|
+
codex_dir = pathlib.Path(os.environ["AB_CODEX_DIR"])
|
|
284
|
+
fragment_path = pathlib.Path(os.environ["AB_FRAGMENT"])
|
|
285
|
+
backup_root = os.environ.get("AB_BACKUP", "")
|
|
286
|
+
dry = os.environ.get("AB_DRY") == "1"
|
|
287
|
+
target = codex_dir / "config.toml"
|
|
288
|
+
BEGIN = "# >>> agent-bios additions >>>"
|
|
289
|
+
END = "# <<< agent-bios additions <<<"
|
|
290
|
+
TAG = "# agent-bios"
|
|
291
|
+
|
|
292
|
+
def info(msg): print(f" {msg}")
|
|
293
|
+
def fail(msg): print(msg); sys.exit(1)
|
|
294
|
+
|
|
295
|
+
frag_text = fragment_path.read_text().replace("${CODEX_HOME}", str(codex_dir))
|
|
296
|
+
want_agents = tomllib.loads(frag_text)["agents"]
|
|
297
|
+
live_text = target.read_text() if target.is_file() else ""
|
|
298
|
+
try:
|
|
299
|
+
live = tomllib.loads(live_text) if live_text else {}
|
|
300
|
+
except Exception as exc:
|
|
301
|
+
fail(f"live codex config does not parse; not touching it: {target} ({exc})")
|
|
302
|
+
|
|
303
|
+
def state_ok():
|
|
304
|
+
if live.get("features", {}).get("multi_agent") is not True:
|
|
305
|
+
return False
|
|
306
|
+
return all(
|
|
307
|
+
live.get("agents", {}).get(name, {}).get(key) == spec[key]
|
|
308
|
+
for name, spec in want_agents.items()
|
|
309
|
+
for key in ("description", "config_file")
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
if mode == "check":
|
|
313
|
+
problems = []
|
|
314
|
+
if live.get("features", {}).get("multi_agent") is not True:
|
|
315
|
+
problems.append("features.multi_agent is not true")
|
|
316
|
+
for name, spec in want_agents.items():
|
|
317
|
+
if live.get("agents", {}).get(name, {}).get("config_file") != spec["config_file"]:
|
|
318
|
+
problems.append(f"agents.{name}.config_file drifted or missing")
|
|
319
|
+
elif not pathlib.Path(spec["config_file"]).is_file():
|
|
320
|
+
problems.append(f"agents.{name} template missing: {spec['config_file']}")
|
|
321
|
+
if problems:
|
|
322
|
+
fail(f"codex config additions: {'; '.join(problems)} ({target})")
|
|
323
|
+
info("codex config additions OK")
|
|
324
|
+
sys.exit(0)
|
|
325
|
+
|
|
326
|
+
def strip_managed(text):
|
|
327
|
+
out, skipping = [], False
|
|
328
|
+
for line in text.splitlines(keepends=True):
|
|
329
|
+
s = line.strip()
|
|
330
|
+
if s == BEGIN: skipping = True; continue
|
|
331
|
+
if s == END: skipping = False; continue
|
|
332
|
+
if skipping or s.endswith(TAG): continue
|
|
333
|
+
out.append(line)
|
|
334
|
+
return "".join(out)
|
|
335
|
+
|
|
336
|
+
if mode == "remove":
|
|
337
|
+
if not target.is_file():
|
|
338
|
+
sys.exit(0)
|
|
339
|
+
stripped = strip_managed(live_text)
|
|
340
|
+
if stripped == live_text:
|
|
341
|
+
info(f"no agent-bios additions in {target}")
|
|
342
|
+
sys.exit(0)
|
|
343
|
+
try:
|
|
344
|
+
tomllib.loads(stripped)
|
|
345
|
+
except Exception as exc:
|
|
346
|
+
fail(f"refusing removal; result would not parse: {exc}")
|
|
347
|
+
if dry:
|
|
348
|
+
info(f"[dry-run] remove agent-bios additions from {target}")
|
|
349
|
+
sys.exit(0)
|
|
350
|
+
backup = target.with_name(target.name + ".bak-agent-bios-uninstall")
|
|
351
|
+
backup.write_text(live_text)
|
|
352
|
+
target.write_text(stripped)
|
|
353
|
+
info(f"removed additions {target} (backup: {backup.name})")
|
|
354
|
+
sys.exit(0)
|
|
355
|
+
|
|
356
|
+
# mode == merge
|
|
357
|
+
if state_ok():
|
|
358
|
+
info(f"unchanged {target} (additions present)")
|
|
359
|
+
sys.exit(0)
|
|
360
|
+
|
|
361
|
+
# A drifted [agents.<tier>] outside our markers would become a duplicate
|
|
362
|
+
# table if we appended ours; that conflict needs the user, not a clobber.
|
|
363
|
+
base = strip_managed(live_text)
|
|
364
|
+
base_data = tomllib.loads(base) if base.strip() else {}
|
|
365
|
+
clash = [name for name in want_agents if name in base_data.get("agents", {})]
|
|
366
|
+
if clash:
|
|
367
|
+
fail(
|
|
368
|
+
f"unmanaged [agents.{'/'.join(clash)}] with drifted content in {target}; "
|
|
369
|
+
"align or remove them, then rerun install"
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
block_lines = [BEGIN]
|
|
373
|
+
if "features" not in base_data:
|
|
374
|
+
block_lines += ["[features]", f"multi_agent = true {TAG}"]
|
|
375
|
+
for name, spec in want_agents.items():
|
|
376
|
+
block_lines += [
|
|
377
|
+
f"[agents.{name}]",
|
|
378
|
+
f'description = "{spec["description"]}"',
|
|
379
|
+
f'config_file = "{spec["config_file"]}"',
|
|
380
|
+
]
|
|
381
|
+
block_lines.append(END)
|
|
382
|
+
block = "\n".join(block_lines) + "\n"
|
|
383
|
+
|
|
384
|
+
new_text = base
|
|
385
|
+
if "features" in base_data:
|
|
386
|
+
if base_data["features"].get("multi_agent") is None:
|
|
387
|
+
lines = new_text.splitlines(keepends=True)
|
|
388
|
+
for i, line in enumerate(lines):
|
|
389
|
+
if line.strip() == "[features]":
|
|
390
|
+
lines.insert(i + 1, f"multi_agent = true {TAG}\n")
|
|
391
|
+
break
|
|
392
|
+
new_text = "".join(lines)
|
|
393
|
+
elif base_data["features"].get("multi_agent") is not True:
|
|
394
|
+
info(f"note: features.multi_agent explicitly set in {target}; leaving it")
|
|
395
|
+
if new_text and not new_text.endswith("\n"):
|
|
396
|
+
new_text += "\n"
|
|
397
|
+
new_text += ("\n" if new_text else "") + block
|
|
398
|
+
|
|
399
|
+
try:
|
|
400
|
+
tomllib.loads(new_text)
|
|
401
|
+
except Exception as exc:
|
|
402
|
+
fail(f"merge result would not parse; live config untouched ({exc})")
|
|
403
|
+
if dry:
|
|
404
|
+
info(f"[dry-run] merge agent-bios additions into {target}")
|
|
405
|
+
sys.exit(0)
|
|
406
|
+
if live_text and backup_root:
|
|
407
|
+
bpath = pathlib.Path(backup_root + str(target))
|
|
408
|
+
bpath.parent.mkdir(parents=True, exist_ok=True)
|
|
409
|
+
bpath.write_text(live_text)
|
|
410
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
411
|
+
target.write_text(new_text)
|
|
412
|
+
info(f"merged additions {target}")
|
|
413
|
+
PY
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
# ---- optional dependencies -----------------------------------------------
|
|
417
|
+
# Capabilities are optional: a missing one only degrades the review routes that
|
|
418
|
+
# need it. launch/agent-launch.toml is the single source for both the command
|
|
419
|
+
# that gates a route and the install line offered here.
|
|
420
|
+
capability_table() {
|
|
421
|
+
python3 - "$REPO/launch/agent-launch.toml" <<'PY'
|
|
422
|
+
import sys, tomllib
|
|
423
|
+
# `${backend}` means "the CLI of the host this capability offers on". Resolved HERE because
|
|
424
|
+
# the probe below runs `command -v` on whatever this prints, and the literal sentinel is not
|
|
425
|
+
# a command: the built-in workflow capability reported as unavailable while the launcher ran
|
|
426
|
+
# it perfectly well.
|
|
427
|
+
data = tomllib.load(open(sys.argv[1], "rb"))
|
|
428
|
+
backends = data.get("backends", {})
|
|
429
|
+
for name, cap in data.get("capabilities", {}).items():
|
|
430
|
+
command = cap.get("command", "")
|
|
431
|
+
if command == "${backend}":
|
|
432
|
+
hosts = sorted({h for offer in cap.get("offers", []) for h in offer.get("hosts", [])})
|
|
433
|
+
command = next(
|
|
434
|
+
(backends[h]["command"] for h in hosts if backends.get(h, {}).get("command")), ""
|
|
435
|
+
)
|
|
436
|
+
print("\t".join((name, command, cap.get("install", ""))))
|
|
437
|
+
PY
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
install_capability() {
|
|
441
|
+
local name="$1" line="$2"
|
|
442
|
+
log "Installing optional dependency $name: $line"
|
|
443
|
+
if [ "$DRY_RUN" = 1 ]; then info "[dry-run] $line"; return 0; fi
|
|
444
|
+
if sh -c "$line"; then info "installed $name"; else log "warning: installing $name failed; routes needing it stay degraded"; fi
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
handle_capabilities() {
|
|
448
|
+
local requested="$1" name command line
|
|
449
|
+
# Fail on a typo rather than silently installing nothing.
|
|
450
|
+
local known; known=$(capability_table | cut -f1)
|
|
451
|
+
local want
|
|
452
|
+
for want in ${requested//,/ }; do
|
|
453
|
+
printf '%s\n' "$known" | grep -qx "$want" || {
|
|
454
|
+
log "unknown --with capability: $want (known: $(printf '%s' "$known" | tr '\n' ' '))"; return 1; }
|
|
455
|
+
done
|
|
456
|
+
while IFS=$'\t' read -r name command line; do
|
|
457
|
+
[ -n "$name" ] || continue
|
|
458
|
+
if command -v "$command" >/dev/null 2>&1; then
|
|
459
|
+
info "capability present $name ($command)"
|
|
460
|
+
# Explicitly requested, already there, and nothing to install: say so, because after
|
|
461
|
+
# the rename `--with ultracode` names the claude-backed reviewer — which needs no
|
|
462
|
+
# install — while the tool the user probably meant stays missing and is only hinted
|
|
463
|
+
# at further down. The request silently no-opped and install still exited 0.
|
|
464
|
+
if [ -z "$line" ] && printf '%s\n' "${requested//,/ }" | tr ' ' '\n' | grep -qx "$name"; then
|
|
465
|
+
log "note: $name needs no install (it is the host CLI); nothing was installed for it"
|
|
466
|
+
local installable
|
|
467
|
+
installable=$(capability_table | awk -F'\t' -v me="$name" '$1 != me && $3 != "" { print $1 }' | tr '\n' ' ')
|
|
468
|
+
[ -n "$installable" ] && log " capabilities that DO install: ${installable% }"
|
|
469
|
+
fi
|
|
470
|
+
continue
|
|
471
|
+
fi
|
|
472
|
+
if printf '%s\n' "${requested//,/ }" | tr ' ' '\n' | grep -qx "$name"; then
|
|
473
|
+
[ -n "$line" ] && install_capability "$name" "$line" \
|
|
474
|
+
|| log "note: $name has no configured install line"
|
|
475
|
+
elif [ -n "$line" ] && [ -t 0 ] && [ "$DRY_RUN" != 1 ]; then
|
|
476
|
+
printf ' Install optional dependency %s? (%s) [y/N] ' "$name" "$line"
|
|
477
|
+
local answer=""; read -r answer </dev/tty || answer=""
|
|
478
|
+
case "$answer" in
|
|
479
|
+
[yY]*) install_capability "$name" "$line" ;;
|
|
480
|
+
*) info "skipped $name — install later: $line" ;;
|
|
481
|
+
esac
|
|
482
|
+
else
|
|
483
|
+
info "optional $name unavailable; routes needing it degrade${line:+ — install: $line}"
|
|
484
|
+
fi
|
|
485
|
+
done <<EOF
|
|
486
|
+
$(capability_table)
|
|
487
|
+
EOF
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
# Presets the launcher saved into the deployed profiles.toml (pre-split, or by hand)
|
|
491
|
+
# would be lost to the cp below; move them into the user-owned presets file first.
|
|
492
|
+
migrate_user_presets() {
|
|
493
|
+
local src="$REPO/launch/agent-launch.toml"
|
|
494
|
+
local dst="$LAUNCH_DIR/profiles.toml"
|
|
495
|
+
local user="$LAUNCH_DIR/$USER_PRESETS_NAME"
|
|
496
|
+
[ -f "$dst" ] || return 0
|
|
497
|
+
python3 - "$src" "$dst" "$user" "$DRY_RUN" <<'PY' || { log "user preset migration failed; not overwriting $LAUNCH_DIR/profiles.toml"; return 1; }
|
|
498
|
+
import os, pathlib, re, sys, tomllib
|
|
499
|
+
|
|
500
|
+
src, dst, user, dry_run = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]), pathlib.Path(sys.argv[3]), sys.argv[4] == "1"
|
|
501
|
+
dst_text = dst.read_text()
|
|
502
|
+
shipped = set(tomllib.loads(src.read_text()).get("presets", {}))
|
|
503
|
+
extra = set(tomllib.loads(dst_text).get("presets", {})) - shipped
|
|
504
|
+
if not extra:
|
|
505
|
+
sys.exit(0)
|
|
506
|
+
existing_text = user.read_text() if user.is_file() else ""
|
|
507
|
+
already = set(tomllib.loads(existing_text).get("presets", {})) if existing_text else set()
|
|
508
|
+
move = sorted(extra - already)
|
|
509
|
+
for name in sorted(extra & already):
|
|
510
|
+
print(f" kept in {user.name} {name} (already saved there)")
|
|
511
|
+
if not move:
|
|
512
|
+
sys.exit(0)
|
|
513
|
+
|
|
514
|
+
header = re.compile(r'^\[presets\.(?:"([^"]+)"|([A-Za-z0-9][A-Za-z0-9_-]*))(?:[.\]])')
|
|
515
|
+
blocks, current = {}, None
|
|
516
|
+
for line in dst_text.splitlines(keepends=True):
|
|
517
|
+
stripped = line.strip()
|
|
518
|
+
if stripped.startswith("["):
|
|
519
|
+
found = header.match(stripped)
|
|
520
|
+
current = (found.group(1) or found.group(2)) if found else None
|
|
521
|
+
if current in move:
|
|
522
|
+
blocks.setdefault(current, []).append(line)
|
|
523
|
+
# Refuse to deploy over a preset we could not carry across, rather than drop it.
|
|
524
|
+
missing = [name for name in move if name not in blocks]
|
|
525
|
+
if missing:
|
|
526
|
+
sys.exit(f"cannot extract preset block(s) from {dst}: {', '.join(missing)}")
|
|
527
|
+
|
|
528
|
+
out = existing_text.rstrip("\n") or (
|
|
529
|
+
"# agent-launch user presets, moved out of profiles.toml by agent-bios install.\n"
|
|
530
|
+
"# The installer never deploys or verifies this file, so presets here survive upgrades."
|
|
531
|
+
)
|
|
532
|
+
for name in move:
|
|
533
|
+
out += "\n\n" + "".join(blocks[name]).strip("\n")
|
|
534
|
+
for name in move:
|
|
535
|
+
print(f" {'[dry-run] ' if dry_run else ''}moved preset {name} -> {user}")
|
|
536
|
+
if dry_run:
|
|
537
|
+
sys.exit(0)
|
|
538
|
+
user.parent.mkdir(parents=True, exist_ok=True)
|
|
539
|
+
temporary = user.with_name(f".{user.name}.{os.getpid()}.tmp")
|
|
540
|
+
temporary.write_text(out + "\n")
|
|
541
|
+
os.replace(temporary, user)
|
|
542
|
+
PY
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
remove_zsh_hook() {
|
|
546
|
+
if [ ! -f "$ZSHRC" ] || ! grep -qF "$HOOK_MARK" "$ZSHRC"; then
|
|
547
|
+
info "no zsh hook to remove"
|
|
548
|
+
return
|
|
549
|
+
fi
|
|
550
|
+
if [ "$DRY_RUN" = 1 ]; then info "[dry-run] remove zsh hook from $ZSHRC"; return; fi
|
|
551
|
+
grep -vF "$HOOK_MARK" "$ZSHRC" > "$ZSHRC.agent-tmp" && mv "$ZSHRC.agent-tmp" "$ZSHRC"
|
|
552
|
+
info "removed zsh hook $ZSHRC"
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
# Promote -> migrate (collection loop, Phase 4): after the corpus is deployed,
|
|
556
|
+
# clear personal copies of learnings that have been promoted into the shared
|
|
557
|
+
# corpus AND are in this user's assembled bundle. Best-effort: a prune failure
|
|
558
|
+
# (or an absent manifest/script) never fails the install. Runs per host.
|
|
559
|
+
migrate_learnings() {
|
|
560
|
+
local script="$REPO/learn/migrate-learnings.py"
|
|
561
|
+
{ [ -f "$script" ] && [ -f "$REPO/learn/promotions.json" ]; } || return 0
|
|
562
|
+
local -a sel dry
|
|
563
|
+
sel=(--selection-file "$STATE_DIR/selection.json")
|
|
564
|
+
[ "$DRY_RUN" = 1 ] && dry=(--dry-run) || dry=()
|
|
565
|
+
# ${dry[@]+...}: expanding an empty array as "${dry[@]}" is an unbound-variable
|
|
566
|
+
# error under `set -u` on bash 3.2 (macOS default) and would abort the install.
|
|
567
|
+
python3 "$script" --host claude --config-dir "$CLAUDE_DIR" "${sel[@]}" ${dry[@]+"${dry[@]}"} \
|
|
568
|
+
|| info "learnings migrate (claude) skipped"
|
|
569
|
+
python3 "$script" --host codex --config-dir "$CODEX_DIR" "${sel[@]}" ${dry[@]+"${dry[@]}"} \
|
|
570
|
+
|| info "learnings migrate (codex) skipped"
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
# ---- subcommands ---------------------------------------------------------
|
|
574
|
+
cmd_install() {
|
|
575
|
+
check_prereqs || { log "resolve the prerequisites above and retry"; exit 1; }
|
|
576
|
+
migrate_state
|
|
577
|
+
if [ "$DRY_RUN" != 1 ]; then
|
|
578
|
+
mkdir -p "$STATE_DIR"
|
|
579
|
+
BACKUP_DIR="$STATE_DIR/backups/$(date +%Y%m%d-%H%M%S)"
|
|
580
|
+
[ -f "$MANIFEST" ] && cp "$MANIFEST" "$PRIOR_MANIFEST"
|
|
581
|
+
: > "$MANIFEST"
|
|
582
|
+
fi
|
|
583
|
+
log "Deploying agent-bios from $REPO"
|
|
584
|
+
# Refuse loudly before doing any work: an unreadable declaration must not degrade
|
|
585
|
+
# into withholding nothing, which is the fail-open shape this rule exists to avoid.
|
|
586
|
+
WITHHELD_GUIDES="$(author_only_guides)" || exit 1
|
|
587
|
+
assemble_corpus || exit 1
|
|
588
|
+
# assemble.py withholds author-only guides and prunes the destinations it writes.
|
|
589
|
+
# $CLAUDE_DIR/guides is not one of them — it is where the pre-unification full
|
|
590
|
+
# install put guides, so a machine that installed then would keep its copy for good.
|
|
591
|
+
prune_withheld "$CLAUDE_DIR/guides"
|
|
592
|
+
migrate_learnings # Phase 4: clear personal copies now absorbed by the corpus
|
|
593
|
+
deploy_glob "$REPO/codex/agents" "*.toml" "$CODEX_DIR/agents"
|
|
594
|
+
codex_config_additions merge || exit 1
|
|
595
|
+
deploy_file "$REPO/wrappers/codex-run.sh" "$CODEX_DIR/bin/codex-run" "+x"
|
|
596
|
+
deploy_file "$REPO/wrappers/codex-helm.sh" "$CODEX_DIR/bin/codex-helm" "+x"
|
|
597
|
+
# The claude-side adapter lands under CLAUDE_DIR for the same reason its codex twin
|
|
598
|
+
# lands under CODEX_DIR: each is that host's, and a single shared bin would make the
|
|
599
|
+
# two families' review routes indistinguishable from their install paths.
|
|
600
|
+
deploy_file "$REPO/wrappers/claude-run.sh" "$CLAUDE_DIR/bin/claude-run" "+x"
|
|
601
|
+
migrate_user_presets || exit 1 # must precede the deploy below, which overwrites profiles.toml
|
|
602
|
+
deploy_file "$REPO/launch/agent-launch.toml" "$LAUNCH_DIR/profiles.toml"
|
|
603
|
+
deploy_file "$REPO/launch/agent-launch.zsh" "$LAUNCH_DIR/shell.zsh"
|
|
604
|
+
deploy_file "$REPO/launch/agent-launch.py" "$BIN_DIR/agent-launch" "+x"
|
|
605
|
+
log ""
|
|
606
|
+
log "Optional dependencies (missing ones only degrade the routes that need them)..."
|
|
607
|
+
handle_capabilities "$WITH" || exit 1
|
|
608
|
+
log ""
|
|
609
|
+
if [ "$DRY_RUN" = 1 ]; then
|
|
610
|
+
info "[dry-run] provision managed Textual venv"
|
|
611
|
+
else
|
|
612
|
+
bash "$REPO/launch/provision-venv.sh" && info "managed venv OK" \
|
|
613
|
+
|| log "warning: venv provisioning failed (numbered-prompt fallback applies)"
|
|
614
|
+
fi
|
|
615
|
+
add_zsh_hook
|
|
616
|
+
if python3 "$REPO/compose/corpus-state.py" project --repo "$REPO" >/dev/null 2>&1; then
|
|
617
|
+
info "corpus-status projected"
|
|
618
|
+
else
|
|
619
|
+
log "note: corpus-status projection unavailable (versions.json/ledger missing?)"
|
|
620
|
+
fi
|
|
621
|
+
# Deploy/system version marker for the launcher's TUI version line, read from
|
|
622
|
+
# package.json (version + releaseDate) — distinct from the corpus content
|
|
623
|
+
# version. Best-effort: a failure here never fails the install.
|
|
624
|
+
if [ "$DRY_RUN" != 1 ]; then
|
|
625
|
+
if python3 - "$REPO/package.json" "$STATE_DIR/version.json" "$REPO/provenance.json" \
|
|
626
|
+
"$([ -e "$REPO/.git" ] && echo clone || echo package)" <<'PY' 2>/dev/null
|
|
627
|
+
import json, os, sys
|
|
628
|
+
pkg = json.load(open(sys.argv[1]))
|
|
629
|
+
out = {"version": pkg.get("version"), "releaseDate": pkg.get("releaseDate"),
|
|
630
|
+
"source": sys.argv[4]}
|
|
631
|
+
# Publication provenance rides along ONLY for a package-layout source: in a
|
|
632
|
+
# clone, git itself is the live provenance and a provenance.json on disk is by
|
|
633
|
+
# definition residue (a failed pack's leftover stamp) — ingesting it once let a
|
|
634
|
+
# clone deployment of commit B carry a stale label A into the state marker.
|
|
635
|
+
if sys.argv[4] == "package" and os.path.isfile(sys.argv[3]):
|
|
636
|
+
try:
|
|
637
|
+
prov = json.load(open(sys.argv[3]))
|
|
638
|
+
if prov.get("commit"):
|
|
639
|
+
out["commit"] = prov["commit"]
|
|
640
|
+
if prov.get("dirty"):
|
|
641
|
+
out["dirty"] = True
|
|
642
|
+
except Exception:
|
|
643
|
+
pass
|
|
644
|
+
with open(sys.argv[2], "w") as f:
|
|
645
|
+
json.dump(out, f)
|
|
646
|
+
f.write("\n")
|
|
647
|
+
PY
|
|
648
|
+
then
|
|
649
|
+
printf '%s\n' "$STATE_DIR/version.json" >> "$MANIFEST"
|
|
650
|
+
info "version marker written ($STATE_DIR/version.json)"
|
|
651
|
+
else
|
|
652
|
+
log "note: version marker not written (package.json unreadable)"
|
|
653
|
+
fi
|
|
654
|
+
fi
|
|
655
|
+
log ""
|
|
656
|
+
log "Verifying deployment..."
|
|
657
|
+
if cmd_verify; then
|
|
658
|
+
log ""
|
|
659
|
+
log "Done. Open a new shell (or: source \"$ZSHRC\") to activate the zero-arg launcher."
|
|
660
|
+
# An untouched backup dir means nothing was replaced; that healthy state
|
|
661
|
+
# must not become a nonzero exit under set -e.
|
|
662
|
+
{ [ -n "$BACKUP_DIR" ] && [ -d "$BACKUP_DIR" ] && log "Replaced files were backed up under $BACKUP_DIR"; } || true
|
|
663
|
+
prune_backups
|
|
664
|
+
else
|
|
665
|
+
log "VERIFY FAILED after install — see messages above"
|
|
666
|
+
exit 1
|
|
667
|
+
fi
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
verify_match() { if cmp -s "$1" "$2"; then info "match $2"; else log "MISMATCH/absent $2"; return 1; fi; }
|
|
671
|
+
verify_present() { if [ -f "$1" ]; then return 0; else log "missing $1"; return 1; fi; }
|
|
672
|
+
|
|
673
|
+
cmd_verify() {
|
|
674
|
+
local fail=0 gp gb
|
|
675
|
+
if [ ! -e "$REPO/.git" ] && [ "$(drift_state)" = "drift" ]; then
|
|
676
|
+
log "deploy drift: $(drift_detail) — run: agent-bios install"
|
|
677
|
+
fail=1
|
|
678
|
+
fi
|
|
679
|
+
# Not mode-specific: an author-only guide must be absent from EVERY directory any
|
|
680
|
+
# install writes, including the one the pre-unification full install owned.
|
|
681
|
+
local verify_withheld
|
|
682
|
+
verify_withheld="$(author_only_guides)" || return 1
|
|
683
|
+
for gb in $verify_withheld; do
|
|
684
|
+
for gp in "$CLAUDE_DIR/guides/$gb" "$CLAUDE_DIR/central/guides/$gb" "$CODEX_DIR/guides/$gb"; do
|
|
685
|
+
if [ -f "$gp" ]; then
|
|
686
|
+
log "author-only guide is still installed: $gp"; fail=1
|
|
687
|
+
fi
|
|
688
|
+
done
|
|
689
|
+
done
|
|
690
|
+
[ -n "$verify_withheld" ] && info "author-only guides withheld: $(printf '%s' "$verify_withheld" | tr '\n' ' ')"
|
|
691
|
+
# Corpus surfaces are selection-derived, not repo-identical, so verify reads the assembled
|
|
692
|
+
# shape rather than byte-comparing against the repo. The entry file is user-owned — READ-check
|
|
693
|
+
# the import line, never rewrite it.
|
|
694
|
+
python3 "$REPO/compose/check-domains.py" >/dev/null 2>&1 && info "domains gate OK" || { log "domains gate FAILED"; fail=1; }
|
|
695
|
+
verify_present "$CLAUDE_DIR/central/bundle.md" || fail=1
|
|
696
|
+
if grep -qF '@central/bundle.md' "$CLAUDE_DIR/CLAUDE.md" 2>/dev/null; then
|
|
697
|
+
info "entry import line present"
|
|
698
|
+
else
|
|
699
|
+
log "entry $CLAUDE_DIR/CLAUDE.md lacks '@central/bundle.md' — central corpus is NOT loading"; fail=1
|
|
700
|
+
fi
|
|
701
|
+
if grep -qF 'agent-bios:central:start' "$CODEX_DIR/AGENTS.md" 2>/dev/null; then
|
|
702
|
+
info "codex central region present"
|
|
703
|
+
else
|
|
704
|
+
log "codex AGENTS.md central region missing"; fail=1
|
|
705
|
+
fi
|
|
706
|
+
# Every capability must probe as a real command. `${backend}` reaching this table means
|
|
707
|
+
# the sentinel was not resolved, and the probe below would then report a capability the
|
|
708
|
+
# launcher can run perfectly well as unavailable.
|
|
709
|
+
if capability_table | awk -F'\t' '$2 == "" || $2 == "${backend}" { print; found=1 } END { exit !found }' >/dev/null 2>&1; then
|
|
710
|
+
log "capability table has an unresolved or empty command — the installer would misreport it"; fail=1
|
|
711
|
+
else
|
|
712
|
+
info "capability commands resolve"
|
|
713
|
+
fi
|
|
714
|
+
# The help's --with list must BE the capability table, not a copy of it: the copy went
|
|
715
|
+
# stale on the first rename and advertised a name that selects a different capability
|
|
716
|
+
# than the one carrying the install line.
|
|
717
|
+
local advertised table
|
|
718
|
+
advertised=$(usage 2>/dev/null | sed -n 's/^ *--with names: //p')
|
|
719
|
+
table=$(capability_table 2>/dev/null | cut -f1 | tr '\n' ' ')
|
|
720
|
+
if [ "$advertised" = "${table% }" ]; then
|
|
721
|
+
info "--with help matches the capability table"
|
|
722
|
+
else
|
|
723
|
+
log "--with help advertises '$advertised' but the capabilities are '${table% }'"; fail=1
|
|
724
|
+
fi
|
|
725
|
+
verify_match "$REPO/launch/agent-launch.py" "$BIN_DIR/agent-launch" || fail=1
|
|
726
|
+
verify_match "$REPO/launch/agent-launch.toml" "$LAUNCH_DIR/profiles.toml" || fail=1
|
|
727
|
+
verify_match "$REPO/launch/agent-launch.zsh" "$LAUNCH_DIR/shell.zsh" || fail=1
|
|
728
|
+
python3 - "$CODEX_DIR/agents" <<'PY' && info "agent TOMLs OK" || fail=1
|
|
729
|
+
import sys, pathlib, tomllib
|
|
730
|
+
root = pathlib.Path(sys.argv[1])
|
|
731
|
+
required = {"frontier.toml", "workhorse.toml", "sweep.toml", "reviewer.toml"}
|
|
732
|
+
missing = required - {p.name for p in root.glob("*.toml")}
|
|
733
|
+
assert not missing, f"missing agent TOMLs in {root}: {sorted(missing)}"
|
|
734
|
+
for p in sorted(root.glob("*.toml")):
|
|
735
|
+
tomllib.loads(p.read_text())
|
|
736
|
+
PY
|
|
737
|
+
codex_config_additions check || fail=1
|
|
738
|
+
if command -v codex >/dev/null 2>&1 && [ -x "$CODEX_DIR/bin/codex-helm" ]; then
|
|
739
|
+
if "$CODEX_DIR/bin/codex-helm" --dry-run --mode review "probe" >/dev/null 2>&1; then
|
|
740
|
+
info "codex-helm dry-run OK"
|
|
741
|
+
else
|
|
742
|
+
log "codex-helm dry-run FAILED"; fail=1
|
|
743
|
+
fi
|
|
744
|
+
fi
|
|
745
|
+
# Existence first, because the guard below is written as `if executable` and a MISSING
|
|
746
|
+
# adapter would satisfy it by never running — a deploy target whose only check skips
|
|
747
|
+
# itself when the deploy failed is not checked at all.
|
|
748
|
+
verify_present "$CLAUDE_DIR/bin/claude-run" || fail=1
|
|
749
|
+
# Then WHICH version landed, because this file is on the panel's dispatch path now and
|
|
750
|
+
# an older copy would dispatch reviews that quietly emit nothing. The earlier check
|
|
751
|
+
# here asserted that an unpinned dispatch is refused; that guard was deliberately
|
|
752
|
+
# removed when the adapter went live — refusing turned "the review ran unpinned" into
|
|
753
|
+
# "the review did not run" — so asserting it would now fail against correct behaviour.
|
|
754
|
+
# `--help` is the only probe that reaches no network: an unpinned run warns and then
|
|
755
|
+
# dispatches for real.
|
|
756
|
+
if [ -x "$CLAUDE_DIR/bin/claude-run" ]; then
|
|
757
|
+
if "$CLAUDE_DIR/bin/claude-run" --help 2>/dev/null | grep -q 'REVIEW_RECEIPT_DIR'; then
|
|
758
|
+
info "claude-run is receipt-aware"
|
|
759
|
+
else
|
|
760
|
+
log "claude-run predates the receipt contract — reviews through it emit nothing"; fail=1
|
|
761
|
+
fi
|
|
762
|
+
fi
|
|
763
|
+
local vpy="${AGENT_LAUNCH_VENV:-$HOME/.local/share/agent-launch/venv}/bin/python"
|
|
764
|
+
if [ -x "$vpy" ] && "$vpy" -c 'import textual' 2>/dev/null; then
|
|
765
|
+
info "managed venv (textual) OK"
|
|
766
|
+
else
|
|
767
|
+
log "note: managed venv/textual unavailable (numbered-prompt fallback applies)"
|
|
768
|
+
fi
|
|
769
|
+
# A file this installer executes but never ships is invisible from a clone and
|
|
770
|
+
# fatal on npm, so the payload gate runs wherever it exists (maintainer-side).
|
|
771
|
+
if [ -x "$REPO/gates/check-package.sh" ]; then
|
|
772
|
+
if "$REPO/gates/check-package.sh" >/dev/null 2>&1; then
|
|
773
|
+
info "npm payload OK"
|
|
774
|
+
else
|
|
775
|
+
log "npm payload incomplete; run gates/check-package.sh"
|
|
776
|
+
fail=1
|
|
777
|
+
fi
|
|
778
|
+
fi
|
|
779
|
+
# Repo-internal mirror parity is a maintainer gate; only meaningful from a clone.
|
|
780
|
+
if [ -d "$REPO/ko" ] && [ -x "$REPO/gates/check-parity.sh" ]; then
|
|
781
|
+
if "$REPO/gates/check-parity.sh" >/dev/null 2>&1; then info "repo mirror parity OK"; else log "repo mirror parity FAILED"; fail=1; fi
|
|
782
|
+
fi
|
|
783
|
+
# Prompting guides name concrete models, so they go stale on a model change
|
|
784
|
+
# rather than degrading quietly; this checks them against the launch config.
|
|
785
|
+
if [ -x "$REPO/launch/check-prompting-targets.sh" ]; then
|
|
786
|
+
if "$REPO/launch/check-prompting-targets.sh" >/dev/null 2>&1; then
|
|
787
|
+
info "prompting targets OK"
|
|
788
|
+
else
|
|
789
|
+
log "prompting guides do not cover a configured model; run launch/check-prompting-targets.sh"
|
|
790
|
+
fail=1
|
|
791
|
+
fi
|
|
792
|
+
fi
|
|
793
|
+
return $fail
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
cmd_uninstall() {
|
|
797
|
+
migrate_state
|
|
798
|
+
codex_config_additions remove || log "warning: could not remove codex config additions"
|
|
799
|
+
# The assembler's two spans in files we do not own — settings registrations and the AGENTS.md
|
|
800
|
+
# central region — are merged in and were never removed here, so uninstall used to leave hooks
|
|
801
|
+
# invoking deleted files and instructions pointing at deleted guides. It reads ownership the
|
|
802
|
+
# same way the merge wrote it, so it cannot reach past what we put there.
|
|
803
|
+
if [ -f "$REPO/compose/assemble.py" ]; then
|
|
804
|
+
python3 "$REPO/compose/assemble.py" --remove-owned --claude-dir "$CLAUDE_DIR" \
|
|
805
|
+
--codex-dir "$CODEX_DIR" --state-dir "$STATE_DIR" ${DRY_RUN:+} \
|
|
806
|
+
$([ "$DRY_RUN" = 1 ] && echo --dry-run) \
|
|
807
|
+
|| log "warning: could not remove assembler-owned regions"
|
|
808
|
+
fi
|
|
809
|
+
if [ -f "$MANIFEST" ]; then
|
|
810
|
+
# Back up before deleting, the way install backs up before overwriting. Full mode deploys the
|
|
811
|
+
# entry CLAUDE.md/AGENTS.md as ordinary targets, so they are manifested and removed here —
|
|
812
|
+
# correct, since in that mode the entry file IS the corpus. What was wrong is that anything a
|
|
813
|
+
# user added to it disappeared with no copy, while the same file overwritten during install
|
|
814
|
+
# would have been backed up. Removal is symmetric with deployment; recoverability now is too.
|
|
815
|
+
[ "$DRY_RUN" = 1 ] || { mkdir -p "$STATE_DIR"; BACKUP_DIR="$STATE_DIR/backups/uninstall-$(date +%Y%m%d-%H%M%S)"; }
|
|
816
|
+
local f
|
|
817
|
+
while IFS= read -r f; do
|
|
818
|
+
[ -n "$f" ] || continue
|
|
819
|
+
if [ -f "$f" ] && [ -n "$BACKUP_DIR" ] && [ "$DRY_RUN" != 1 ]; then
|
|
820
|
+
mkdir -p "$(dirname "$BACKUP_DIR$f")" && cp "$f" "$BACKUP_DIR$f"
|
|
821
|
+
fi
|
|
822
|
+
[ -f "$f" ] && { run rm -f "$f"; [ "$DRY_RUN" = 1 ] || info "removed $f"; }
|
|
823
|
+
done < "$MANIFEST"
|
|
824
|
+
{ [ -n "$BACKUP_DIR" ] && [ -d "$BACKUP_DIR" ] && info "staged for the archive: $BACKUP_DIR"; } || true
|
|
825
|
+
[ "$DRY_RUN" = 1 ] || rm -f "$MANIFEST"
|
|
826
|
+
else
|
|
827
|
+
log "no manifest at $MANIFEST; removing known deploy targets"
|
|
828
|
+
# The two entry files are deliberately NOT in this list. `$CLAUDE_DIR/CLAUDE.md` is seeded
|
|
829
|
+
# once and the user's thereafter, and `$CODEX_DIR/AGENTS.md` is ours only between the central
|
|
830
|
+
# markers, which --remove-owned above already took. This branch runs precisely when state is
|
|
831
|
+
# missing or agent-bios was never installed here, so nothing says we wrote either file —
|
|
832
|
+
# and deleting them whole destroyed personal instructions with no copy at all, since
|
|
833
|
+
# BACKUP_DIR is set only on the manifest path above.
|
|
834
|
+
local p
|
|
835
|
+
for p in "$CODEX_DIR/bin/codex-run" "$CODEX_DIR/bin/codex-helm" \
|
|
836
|
+
"$CLAUDE_DIR/bin/claude-run" \
|
|
837
|
+
"$LAUNCH_DIR/profiles.toml" "$LAUNCH_DIR/shell.zsh" "$BIN_DIR/agent-launch"; do
|
|
838
|
+
[ -f "$p" ] && { run rm -f "$p"; [ "$DRY_RUN" = 1 ] || info "removed $p"; }
|
|
839
|
+
done
|
|
840
|
+
fi
|
|
841
|
+
local d
|
|
842
|
+
# `central/` and its subdirectories are entirely ours — the assembler creates them and the
|
|
843
|
+
# manifest covers every file inside — so they belong in this sweep. They were missing from it,
|
|
844
|
+
# which left three empty directories in the user's config dir after a "leave no trace" removal.
|
|
845
|
+
# rmdir, never rm -rf: a directory that is not empty is one we did not fully account for, and
|
|
846
|
+
# the right answer then is to leave it and be visibly incomplete.
|
|
847
|
+
for d in "$CLAUDE_DIR/central/guides" "$CLAUDE_DIR/central/hooks" "$CLAUDE_DIR/central/agents" \
|
|
848
|
+
"$CLAUDE_DIR/central" \
|
|
849
|
+
"$CLAUDE_DIR/guides" "$CLAUDE_DIR/agents" "$CLAUDE_DIR/bin" \
|
|
850
|
+
"$CODEX_DIR/guides" "$CODEX_DIR/agents" "$CODEX_DIR/bin" "$LAUNCH_DIR"; do
|
|
851
|
+
[ -d "$d" ] && rmdir "$d" 2>/dev/null && info "removed empty $d" || true
|
|
852
|
+
done
|
|
853
|
+
remove_zsh_hook
|
|
854
|
+
archive_and_purge
|
|
855
|
+
log ""
|
|
856
|
+
log "Uninstalled: deployed files, the zsh hook, state, backups, cache, and the managed venv."
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
# Uninstall is a SECURITY operation — nothing of ours may survive it on the machine. That
|
|
860
|
+
# conflicts with never destroying what a user added, because full mode writes the corpus into an
|
|
861
|
+
# entry file they then edit, so removal takes their work with it. One artifact settles both:
|
|
862
|
+
# everything removed leaves as a single archive that can be handed off or deleted in one act,
|
|
863
|
+
# and every managed location is then purged. The archive lands in $HOME, outside every path we
|
|
864
|
+
# manage, because a copy inside a directory we are about to delete is not a copy.
|
|
865
|
+
#
|
|
866
|
+
# Order is the safety property: archive first, verify the archive exists, purge only then. A
|
|
867
|
+
# failed archive leaves the machine untouched rather than clean and empty-handed.
|
|
868
|
+
archive_and_purge() {
|
|
869
|
+
local ts stage rel out list staged skipped
|
|
870
|
+
ts="$(date +%Y%m%d-%H%M%S)"
|
|
871
|
+
out="${AGENT_BIOS_UNINSTALL_ARCHIVE:-$HOME}/agent-bios-uninstall-$ts.tar.gz"
|
|
872
|
+
if [ "$DRY_RUN" = 1 ]; then
|
|
873
|
+
info "[dry-run] archive removed content to $out, then purge $STATE_DIR, the cache and the venv"
|
|
874
|
+
return 0
|
|
875
|
+
fi
|
|
876
|
+
stage="$(mktemp -d)" || { log "warning: no temp dir; leaving state in place"; return 0; }
|
|
877
|
+
list="$(mktemp)" || { rm -rf "$stage"; log "warning: no temp file; leaving state in place"; return 0; }
|
|
878
|
+
[ -d "$STATE_DIR" ] && cp -R "$STATE_DIR" "$stage/state" 2>/dev/null
|
|
879
|
+
# WHICH loose copies are ours is compose/prune-backups.py's question — it deletes them too, on
|
|
880
|
+
# the retention path, and a rule written in both places drifts on one side. `*.bak-*` was the
|
|
881
|
+
# match here, which also claims a `notes.bak-old` the user saved by hand; these sit in
|
|
882
|
+
# directories we share with them and with other tools, so the name must carry the ownership.
|
|
883
|
+
if [ -f "$REPO/compose/prune-backups.py" ]; then
|
|
884
|
+
python3 "$REPO/compose/prune-backups.py" --list-owned \
|
|
885
|
+
--claude-dir "$CLAUDE_DIR" --codex-dir "$CODEX_DIR" >"$list" 2>/dev/null || : >"$list"
|
|
886
|
+
else
|
|
887
|
+
log "note: compose/prune-backups.py is absent, so loose backup copies are left where they are"
|
|
888
|
+
fi
|
|
889
|
+
# Staged UNDER their absolute path, the way deploy_file backs up to "$BACKUP_DIR$dst".
|
|
890
|
+
# Flattening them into one directory let two same-named copies from the two host trees
|
|
891
|
+
# collide, and the loser was then deleted with nothing in the archive to restore it from.
|
|
892
|
+
# Only what verifiably reached the stage may be deleted later. Every failure mode here is
|
|
893
|
+
# silent — a full temp filesystem, an unreadable source, a directory that cannot be created —
|
|
894
|
+
# and purging from the ORIGINAL list removed files the archive does not contain. Size equality
|
|
895
|
+
# rather than cp's exit status alone, because the question is whether the archive can restore
|
|
896
|
+
# it, not whether the copy was attempted.
|
|
897
|
+
staged="$(mktemp)" || { rm -f "$list"; rm -rf "$stage"
|
|
898
|
+
log "warning: no temp file; leaving state in place"; return 0; }
|
|
899
|
+
skipped=0
|
|
900
|
+
while IFS= read -r -d '' rel; do
|
|
901
|
+
if mkdir -p "$(dirname "$stage/loose$rel")" 2>/dev/null \
|
|
902
|
+
&& cp "$rel" "$stage/loose$rel" 2>/dev/null \
|
|
903
|
+
&& [ "$(wc -c <"$rel" 2>/dev/null)" = "$(wc -c <"$stage/loose$rel" 2>/dev/null)" ]; then
|
|
904
|
+
printf '%s\0' "$rel" >> "$staged"
|
|
905
|
+
else
|
|
906
|
+
skipped=$((skipped + 1))
|
|
907
|
+
rm -f "$stage/loose$rel" 2>/dev/null # a partial copy must not look archived
|
|
908
|
+
log "warning: could not stage $rel — leaving it in place"
|
|
909
|
+
fi
|
|
910
|
+
done <"$list"
|
|
911
|
+
if tar czf "$out" -C "$stage" . 2>/dev/null && [ -s "$out" ]; then
|
|
912
|
+
# Only here, with the archive written and non-empty, is removing the originals recoverable.
|
|
913
|
+
# Deleting them before the tar meant a failed archive took the backups with it — while the
|
|
914
|
+
# warning below told the operator they had been left in place.
|
|
915
|
+
while IFS= read -r -d '' rel; do rm -f "$rel" 2>/dev/null; done <"$staged"
|
|
916
|
+
rm -f "$list" "$staged"
|
|
917
|
+
rm -rf "$stage"
|
|
918
|
+
rm -rf "$STATE_DIR" "$HOME/.cache/agent-launch" \
|
|
919
|
+
"${AGENT_LAUNCH_VENV:-$HOME/.local/share/agent-launch}"
|
|
920
|
+
log ""
|
|
921
|
+
log "Everything removed is in ONE archive: $out"
|
|
922
|
+
if [ "$skipped" -gt 0 ]; then
|
|
923
|
+
log "Move it somewhere central or delete it. $skipped backup copy(ies) could not be staged,"
|
|
924
|
+
log "so they were LEFT ON DISK rather than deleted with nothing to restore them from."
|
|
925
|
+
else
|
|
926
|
+
log "Move it somewhere central or delete it — nothing of ours is left on this machine."
|
|
927
|
+
fi
|
|
928
|
+
else
|
|
929
|
+
rm -f "$list" "$staged"
|
|
930
|
+
rm -rf "$stage"
|
|
931
|
+
log "warning: could not write $out — state, backups and the venv were left in place rather"
|
|
932
|
+
log " than deleted with no copy. Re-run once the archive path is writable."
|
|
933
|
+
fi
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
cmd_onboard() {
|
|
937
|
+
log "agent-bios onboarding — pick your domain packages (core + infra always install)"
|
|
938
|
+
local names=() line i=1 choice sel="" n picks
|
|
939
|
+
while IFS= read -r line; do names+=("$line"); done \
|
|
940
|
+
< <(python3 -c "import json;print('\n'.join(sorted(json.load(open('$REPO/compose/domains.json'))['domains'])))")
|
|
941
|
+
[ "${#names[@]}" -ge 1 ] || { log "no domains found in compose/domains.json"; exit 1; }
|
|
942
|
+
if [ "$DOMAINS_SET" = 1 ]; then
|
|
943
|
+
# Non-interactive path, per this installer's input contract (stdin is
|
|
944
|
+
# detached at the top of the script): selection arrives as domain names.
|
|
945
|
+
[ "$DOMAINS_ARG" = none ] && DOMAINS_ARG=""
|
|
946
|
+
sel="$DOMAINS_ARG"
|
|
947
|
+
elif ( : </dev/tty ) 2>/dev/null; then
|
|
948
|
+
for line in "${names[@]}"; do info "$i) $line"; i=$((i+1)); done
|
|
949
|
+
printf 'Select by number, comma-separated (empty = core+infra only): '
|
|
950
|
+
read -r choice </dev/tty || choice=""
|
|
951
|
+
if [ -n "$choice" ]; then
|
|
952
|
+
# bash 3.2 + set -u: expanding an EMPTY array errors, so split only
|
|
953
|
+
# when there is input; empty input means core+infra only.
|
|
954
|
+
IFS=',' read -ra picks <<<"$choice"
|
|
955
|
+
for n in "${picks[@]}"; do
|
|
956
|
+
n="${n// /}"; [ -n "$n" ] || continue
|
|
957
|
+
case "$n" in (*[!0-9]*) log "invalid selection: $n"; exit 2 ;; esac
|
|
958
|
+
[ "$n" -ge 1 ] && [ "$n" -le "${#names[@]}" ] || { log "selection out of range: $n"; exit 2; }
|
|
959
|
+
sel="$sel${sel:+,}${names[$((n-1))]}"
|
|
960
|
+
done
|
|
961
|
+
fi
|
|
962
|
+
else
|
|
963
|
+
log "onboard needs a terminal or an explicit selection — run:"
|
|
964
|
+
log " agent-bios onboard --domains a,b (or --domains none for core+infra only)"
|
|
965
|
+
exit 2
|
|
966
|
+
fi
|
|
967
|
+
DOMAINS_ARG="$sel"; DOMAINS_SET=1
|
|
968
|
+
log "selection: ${sel:-<core+infra only>}"
|
|
969
|
+
cmd_install
|
|
970
|
+
log ""
|
|
971
|
+
log "Activation canary (proves the bundle loads in a live session)..."
|
|
972
|
+
if [ "$DRY_RUN" = 1 ]; then info "[dry-run] skip canary probe"; return; fi
|
|
973
|
+
bash "$REPO/compose/canary.sh" || {
|
|
974
|
+
log "ONBOARDING INCOMPLETE: the bundle is installed but not loading — fix the cause above and re-run: agent-bios verify"
|
|
975
|
+
exit 1
|
|
976
|
+
}
|
|
977
|
+
# The prune is authorized by the canary's proof, and cmd_install ran before the canary existed
|
|
978
|
+
# for this bundle rev — so it kept everything. Now that loading is proven, run it for real.
|
|
979
|
+
migrate_learnings
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
# ---- deploy-chain drift ---------------------------------------------------
|
|
983
|
+
# A repo edit is inert until it is published AND globally installed AND
|
|
984
|
+
# deployed. The middle two are checkable: the installer stamps the package
|
|
985
|
+
# version it deployed into the state dir, so a stamp older than the package now
|
|
986
|
+
# running means someone updated the package and never re-deployed. Reading the
|
|
987
|
+
# registry cannot see this, which is why it went unnoticed three times.
|
|
988
|
+
json_field() { # $1=file $2=key
|
|
989
|
+
[ -f "$1" ] || return 1
|
|
990
|
+
python3 -c 'import json,sys
|
|
991
|
+
try:
|
|
992
|
+
v=json.load(open(sys.argv[1])).get(sys.argv[2])
|
|
993
|
+
except Exception:
|
|
994
|
+
sys.exit(1)
|
|
995
|
+
sys.exit(0) if v is None else print(v)' "$1" "$2" 2>/dev/null
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
deployed_version() { json_field "$STATE_DIR/version.json" version; }
|
|
999
|
+
source_version() { json_field "$REPO/package.json" version; }
|
|
1000
|
+
|
|
1001
|
+
json_true() { # exit 0 iff $2 in $1 is JSON true — json.dump writes '"k": true'
|
|
1002
|
+
# with a space, so byte-matching a compact spelling read the real writer's
|
|
1003
|
+
# output as false forever.
|
|
1004
|
+
[ -f "$1" ] || return 1
|
|
1005
|
+
python3 -c 'import json,sys
|
|
1006
|
+
try:
|
|
1007
|
+
v=json.load(open(sys.argv[1])).get(sys.argv[2])
|
|
1008
|
+
except Exception:
|
|
1009
|
+
sys.exit(1)
|
|
1010
|
+
sys.exit(0 if v is True else 1)' "$1" "$2" 2>/dev/null
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
current_commit() { # the CURRENT side's commit: git HEAD in a clone, the stamp in a package
|
|
1014
|
+
# A clone's provenance is its live HEAD — a provenance.json on its disk is a
|
|
1015
|
+
# failed pack's residue, and reading it once let a stale label A match a
|
|
1016
|
+
# checkout sitting at B.
|
|
1017
|
+
if [ -e "$REPO/.git" ]; then
|
|
1018
|
+
git -C "$REPO" rev-parse HEAD 2>/dev/null || true
|
|
1019
|
+
else
|
|
1020
|
+
json_field "$REPO/provenance.json" commit 2>/dev/null || true
|
|
1021
|
+
fi
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
current_dirty() { # exit 0 iff the current side is a dirty-stamped PACKAGE artifact
|
|
1025
|
+
[ ! -e "$REPO/.git" ] && json_true "$REPO/provenance.json" dirty
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
drift_state() { # prints: match | drift | unknown
|
|
1029
|
+
local d s dc sc
|
|
1030
|
+
d="$(deployed_version)" || { echo unknown; return; }
|
|
1031
|
+
s="$(source_version)" || { echo unknown; return; }
|
|
1032
|
+
[ -n "$d" ] && [ -n "$s" ] || { echo unknown; return; }
|
|
1033
|
+
[ "$d" = "$s" ] || { echo drift; return; }
|
|
1034
|
+
# Same VERSION is not same SOURCE: two locally packed tarballs keep one
|
|
1035
|
+
# version across different commits (npm pack is an accommodated flow), and
|
|
1036
|
+
# the commit was persisted into version.json exactly so this comparison
|
|
1037
|
+
# could read it. Judged only when both sides carry one — a clone deploy
|
|
1038
|
+
# stamps no commit, and half a comparison is none.
|
|
1039
|
+
dc="$(json_field "$STATE_DIR/version.json" commit 2>/dev/null || true)"
|
|
1040
|
+
sc="$(current_commit)"
|
|
1041
|
+
# Dirt is judged BEFORE the commit comparison: a dirty artifact's bytes are
|
|
1042
|
+
# described by no commit, so differing commit labels prove nothing about
|
|
1043
|
+
# differing payloads — unknown, not drift, or npm-layout verify fails on a
|
|
1044
|
+
# mismatch nobody proved.
|
|
1045
|
+
if json_true "$STATE_DIR/version.json" dirty || current_dirty; then
|
|
1046
|
+
echo unknown; return
|
|
1047
|
+
fi
|
|
1048
|
+
if [ -n "$dc" ] && [ -n "$sc" ] && [ "$dc" != "$sc" ]; then
|
|
1049
|
+
echo drift; return
|
|
1050
|
+
fi
|
|
1051
|
+
# UNPROVABLE is not drift: an unbound side (npm layout without a commit — a
|
|
1052
|
+
# lifecycle-disabled pack, or the hook's materialized tree) and a DIRTY side
|
|
1053
|
+
# (bytes no commit describes; parsed via json_true, never byte-matched,
|
|
1054
|
+
# because json.dump's spacing defeated the compact grep) both make the
|
|
1055
|
+
# correspondence unprovable. Drift is a PROVEN mismatch and makes verify
|
|
1056
|
+
# fail; branding the unprovable as drift made D-0050's accepted install path
|
|
1057
|
+
# fail its own verification. Clone deployments stay version-compared — git
|
|
1058
|
+
# itself is their live provenance, and they persist no commit by design.
|
|
1059
|
+
if [ "$(json_field "$STATE_DIR/version.json" source 2>/dev/null || true)" = "package" ] \
|
|
1060
|
+
&& [ -z "$dc" ]; then
|
|
1061
|
+
echo unknown; return
|
|
1062
|
+
fi
|
|
1063
|
+
if [ ! -e "$REPO/.git" ] && [ -z "$sc" ]; then
|
|
1064
|
+
echo unknown; return
|
|
1065
|
+
fi
|
|
1066
|
+
echo match
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
drift_detail() { # one line naming WHAT drifted: versions, or commits behind one version
|
|
1070
|
+
local d s dc sc
|
|
1071
|
+
d="$(deployed_version)"; s="$(source_version)"
|
|
1072
|
+
dc="$(json_field "$STATE_DIR/version.json" commit 2>/dev/null || true)"
|
|
1073
|
+
sc="$(current_commit)"
|
|
1074
|
+
if [ "$d" = "$s" ] && { json_true "$STATE_DIR/version.json" dirty || current_dirty; }; then
|
|
1075
|
+
printf 'a DIRTY-packed artifact is involved — its bytes are described by no commit; a clean-pack redeploy restores a provable match'
|
|
1076
|
+
elif [ "$d" = "$s" ] && [ -n "$dc" ] && [ -n "$sc" ] && [ "$dc" != "$sc" ]; then
|
|
1077
|
+
printf 'deployed commit %s but this source is at %s (both version %s)' "$dc" "$sc" "$s"
|
|
1078
|
+
elif [ "$(json_field "$STATE_DIR/version.json" source 2>/dev/null || true)" = "package" ] \
|
|
1079
|
+
&& [ -z "$dc" ]; then
|
|
1080
|
+
printf 'the deployed artifact is UNBOUND (packed with lifecycle scripts disabled) — its bytes are proven by no commit; a bound redeploy restores a provable match'
|
|
1081
|
+
elif [ ! -e "$REPO/.git" ] && [ -z "$sc" ]; then
|
|
1082
|
+
printf 'this package is UNBOUND (no provenance) — its bytes are proven by no commit'
|
|
1083
|
+
else
|
|
1084
|
+
printf 'deployed %s but this package is %s' "$d" "$s"
|
|
1085
|
+
fi
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
cmd_status() {
|
|
1089
|
+
local version p
|
|
1090
|
+
if [ -e "$REPO/.git" ]; then
|
|
1091
|
+
version="$(git -C "$REPO" describe --tags --always --dirty 2>/dev/null || git -C "$REPO" rev-parse --short HEAD 2>/dev/null || echo '?')"
|
|
1092
|
+
log "agent-bios (git clone: $version)"
|
|
1093
|
+
elif [ -f "$REPO/package.json" ]; then
|
|
1094
|
+
version="$(node -e "try{process.stdout.write(require('$REPO/package.json').version)}catch(e){process.stdout.write('?')}" 2>/dev/null || echo '?')"
|
|
1095
|
+
log "agent-bios (npm package: $version)"
|
|
1096
|
+
if [ -f "$REPO/provenance.json" ]; then
|
|
1097
|
+
local built_from
|
|
1098
|
+
# Tolerated, never trusted: a malformed stamp used to kill status via
|
|
1099
|
+
# set -e mid-output, and a well-formed one with no commit passed in
|
|
1100
|
+
# silence — both are UNBOUND states, not absences of the question.
|
|
1101
|
+
built_from="$(json_field "$REPO/provenance.json" commit 2>/dev/null || true)"
|
|
1102
|
+
if [ -n "$built_from" ]; then
|
|
1103
|
+
info "built from commit ${built_from}$(
|
|
1104
|
+
grep -q '"dirty":true' "$REPO/provenance.json" && printf ' (DIRTY tree at pack time)')"
|
|
1105
|
+
else
|
|
1106
|
+
info "UNBOUND provenance.json is unreadable or names no commit — this artifact is not usably bound"
|
|
1107
|
+
fi
|
|
1108
|
+
else
|
|
1109
|
+
# The absence is the finding: D-0050 accepts lifecycle-disabled packs as
|
|
1110
|
+
# residual BECAUSE this line makes an unbound artifact visible — a silent
|
|
1111
|
+
# skip here would unmake that decision's premise.
|
|
1112
|
+
info "UNBOUND no provenance stamp — packed with lifecycle scripts disabled, or a pre-provenance release; this artifact names no commit"
|
|
1113
|
+
fi
|
|
1114
|
+
else
|
|
1115
|
+
log "agent-bios"
|
|
1116
|
+
fi
|
|
1117
|
+
log " source: $REPO"
|
|
1118
|
+
case "$(drift_state)" in
|
|
1119
|
+
match) info "deployed version $(deployed_version) (matches this package)" ;;
|
|
1120
|
+
drift) log "DRIFT $(drift_detail) — run: agent-bios install" ;;
|
|
1121
|
+
unknown) if [ -f "$STATE_DIR/version.json" ]; then
|
|
1122
|
+
info "correspondence unprovable: $(drift_detail)"
|
|
1123
|
+
else
|
|
1124
|
+
info "deployed version unknown (no state marker yet)"
|
|
1125
|
+
fi ;;
|
|
1126
|
+
esac
|
|
1127
|
+
for p in "$CLAUDE_DIR/CLAUDE.md" "$CODEX_DIR/AGENTS.md" "$BIN_DIR/agent-launch" \
|
|
1128
|
+
"$LAUNCH_DIR/profiles.toml" "$LAUNCH_DIR/shell.zsh"; do
|
|
1129
|
+
if [ -e "$p" ]; then info "present $p"; else info "MISSING $p"; fi
|
|
1130
|
+
done
|
|
1131
|
+
if [ -f "$ZSHRC" ] && grep -qF "$HOOK_MARK" "$ZSHRC"; then info "zsh hook present"; else info "zsh hook absent"; fi
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
cmd_update() {
|
|
1135
|
+
if [ -e "$REPO/.git" ]; then
|
|
1136
|
+
log "Updating from git..."
|
|
1137
|
+
run git -C "$REPO" pull --ff-only
|
|
1138
|
+
cmd_install
|
|
1139
|
+
else
|
|
1140
|
+
log "Installed as an npm package. Update with:"
|
|
1141
|
+
log " npm install -g agent-bios@latest && agent-bios install"
|
|
1142
|
+
log "Then confirm what actually landed — right after a publish the cached"
|
|
1143
|
+
log "packument can serve the PREVIOUS version at exit 0:"
|
|
1144
|
+
log " agent-bios status # must show the version you expected, and no DRIFT"
|
|
1145
|
+
fi
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
usage() {
|
|
1149
|
+
cat <<'EOF'
|
|
1150
|
+
agent-bios — deploy the Claude/Codex instruction SSOT into $HOME (by copy).
|
|
1151
|
+
|
|
1152
|
+
agent-bios install deploy into this environment (backs up + verifies)
|
|
1153
|
+
agent-bios onboard interactive domain selection + install + activation canary
|
|
1154
|
+
agent-bios verify check the deployed state matches the source
|
|
1155
|
+
agent-bios learn submit a session learning (reads the JSON record on
|
|
1156
|
+
stdin; this is what the learn! flow calls, and it
|
|
1157
|
+
works from any directory, unlike a repo-relative path)
|
|
1158
|
+
agent-bios status show what is installed and where
|
|
1159
|
+
agent-bios update git pull + reinstall (clone), or print the npm update line
|
|
1160
|
+
agent-bios uninstall remove deployed files and the zsh hook
|
|
1161
|
+
agent-bios help
|
|
1162
|
+
|
|
1163
|
+
Flags: --dry-run print actions without changing anything
|
|
1164
|
+
--domains a,b assemble ONLY the named domain packages (plus core+infra);
|
|
1165
|
+
with onboard, 'none' means core+infra only. The selection
|
|
1166
|
+
persists in the state dir and later installs/updates reuse
|
|
1167
|
+
it. Default (no flag, no saved selection) selects every
|
|
1168
|
+
domain — one install shape, "full" is just everything
|
|
1169
|
+
selected.
|
|
1170
|
+
--with a,b also install the named optional dependencies (install only).
|
|
1171
|
+
Without it, install offers each missing one when the terminal
|
|
1172
|
+
is interactive, and otherwise just prints its install line.
|
|
1173
|
+
Missing ones are not fatal — they only degrade the review
|
|
1174
|
+
routes that need them.
|
|
1175
|
+
Env: CLAUDE_CONFIG_DIR, CODEX_HOME, AGENT_LAUNCH_VENV, ZDOTDIR
|
|
1176
|
+
EOF
|
|
1177
|
+
# DERIVED, not typed: the hardcoded pair went stale the moment a capability was renamed,
|
|
1178
|
+
# and the name it still advertised selected a different capability than the one that
|
|
1179
|
+
# carries the install line.
|
|
1180
|
+
local known; known=$(capability_table 2>/dev/null | cut -f1 | tr '\n' ' ')
|
|
1181
|
+
[ -n "$known" ] && printf ' --with names: %s\n' "${known% }"
|
|
1182
|
+
|
|
1183
|
+
# Recovery exists in both modes at different granularity, and naming the wrong one is worse
|
|
1184
|
+
# than naming none: a clone can roll the corpus back to a registered mining window, while an
|
|
1185
|
+
# npm install has no git history to read and rolls the whole package back by version instead.
|
|
1186
|
+
# Derived from which install this is, for the same reason --with names is.
|
|
1187
|
+
if [ -e "$REPO/.git" ]; then
|
|
1188
|
+
printf '\nRecover: python3 %s/compose/corpus-state.py list, then rollback --version V\n' "$REPO"
|
|
1189
|
+
else
|
|
1190
|
+
printf '\nRecover: npm install -g agent-bios@<older-version> && agent-bios install\n'
|
|
1191
|
+
fi
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
# ---- dispatch ------------------------------------------------------------
|
|
1195
|
+
CMD="${1:-help}"
|
|
1196
|
+
if [ $# -gt 0 ]; then shift; fi
|
|
1197
|
+
|
|
1198
|
+
# `learn` forwards its arguments and stdin straight to the collector, so it must
|
|
1199
|
+
# bypass the flag parser below (which rejects anything it does not know). This
|
|
1200
|
+
# subcommand is the only PATH-reachable entry to capture: the corpus guide used
|
|
1201
|
+
# to invoke learn/collect-learning.py relative to the cwd, which works from a
|
|
1202
|
+
# clone and silently fails for every other install.
|
|
1203
|
+
if [ "$CMD" = "learn" ]; then
|
|
1204
|
+
collector="$REPO/learn/collect-learning.py"
|
|
1205
|
+
[ -f "$collector" ] || { log "learn: collector missing at $collector"; exit 1; }
|
|
1206
|
+
exec python3 "$collector" "$@" <&3
|
|
1207
|
+
fi
|
|
1208
|
+
|
|
1209
|
+
WITH=""
|
|
1210
|
+
DOMAINS_ARG=""
|
|
1211
|
+
DOMAINS_SET=0
|
|
1212
|
+
while [ $# -gt 0 ]; do
|
|
1213
|
+
case "$1" in
|
|
1214
|
+
--dry-run) DRY_RUN=1 ;;
|
|
1215
|
+
--with) shift; WITH="${1:-}"; [ -n "$WITH" ] || { log "--with needs a comma-separated capability list"; exit 2; } ;;
|
|
1216
|
+
--with=*) WITH="${1#--with=}"; [ -n "$WITH" ] || { log "--with needs a comma-separated capability list"; exit 2; } ;;
|
|
1217
|
+
--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; } ;;
|
|
1218
|
+
--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; } ;;
|
|
1219
|
+
*) log "unknown flag: $1"; exit 2 ;;
|
|
1220
|
+
esac
|
|
1221
|
+
shift
|
|
1222
|
+
done
|
|
1223
|
+
|
|
1224
|
+
case "$CMD" in
|
|
1225
|
+
install) cmd_install ;;
|
|
1226
|
+
onboard) cmd_onboard ;;
|
|
1227
|
+
update) cmd_update ;;
|
|
1228
|
+
uninstall) cmd_uninstall ;;
|
|
1229
|
+
verify) if cmd_verify; then log "VERIFY OK"; else log "VERIFY FAILED"; exit 1; fi ;;
|
|
1230
|
+
status) cmd_status ;;
|
|
1231
|
+
help|-h|--help) usage ;;
|
|
1232
|
+
*) log "unknown command: $CMD"; usage; exit 2 ;;
|
|
1233
|
+
esac
|