agent-bios 0.10.0 → 0.12.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/install.sh CHANGED
@@ -11,6 +11,7 @@
11
11
  # agent-bios install deploy into this environment (backs up + verifies)
12
12
  # agent-bios verify check the deployed state matches the source
13
13
  # agent-bios status show what is installed and where
14
+ # agent-bios cost the session cost / context meter, from any directory
14
15
  # agent-bios update git pull + reinstall (clone), or print the npm update line
15
16
  # agent-bios uninstall remove deployed files and the zsh hook
16
17
  # agent-bios help
@@ -70,6 +71,18 @@ HOOK_MARK='agent-launch/shell.zsh'
70
71
 
71
72
  DRY_RUN=0
72
73
  BACKUP_DIR=""
74
+ # Set when uninstall could not undo the spans the assembler merged into files we do not own.
75
+ # Global for the same reason UNBACKED is: the closing summary must not claim a clean removal
76
+ # that did not happen.
77
+ CLEANUP_FAILED=0
78
+ # Set when the assembler reported the user-owned entry file needs a line added by hand. That
79
+ # is an outstanding ACTION, not a failed deployment: everything else really did land, and
80
+ # treating it as a failure made cmd_install exit 1 and the EXIT trap restore the previous
81
+ # manifest — leaving the new corpus on disk with the record saying the old one was deployed.
82
+ ENTRY_NEEDS_ACTION=0
83
+ # Deployed files uninstall could not back up, so did not delete. Global rather than local
84
+ # because the closing summary must not claim a clean removal that did not happen.
85
+ UNBACKED=0
73
86
 
74
87
  log() { printf '%s\n' "$*"; }
75
88
  info() { printf ' %s\n' "$*"; }
@@ -117,6 +130,115 @@ deploy_glob() {
117
130
  done
118
131
  }
119
132
 
133
+ # A subtree, file by file, so every file lands in the manifest by its own name — the
134
+ # ownership rule the hooks fix settled on. Skills need this: a skill is a directory
135
+ # (SKILL.md plus free subdirectories) that lands in a directory the host scans and other
136
+ # tools populate, so nothing may claim it by path prefix, and uninstall removes exactly
137
+ # the names we wrote. `find -type f`, not a glob: subdirectories are the format.
138
+ deploy_tree() {
139
+ local srcdir="$1" dstdir="$2" f rel
140
+ [ -d "$srcdir" ] || { log "source tree missing: $srcdir"; return 1; }
141
+ while IFS= read -r f; do
142
+ rel="${f#"$srcdir"/}"
143
+ deploy_file "$f" "$dstdir/$rel"
144
+ done < <(find "$srcdir" -type f | LC_ALL=C sort)
145
+ }
146
+
147
+ # The skills we ship: every subdirectory of the repo's claude/skills/. One tree serves both
148
+ # hosts — the format is identical (SKILL.md + name/description frontmatter + free
149
+ # subdirectories) and the body names no host path — so the same files land under each
150
+ # host's skills directory. Iterated here rather than listed, so a second skill ships by
151
+ # being added to the tree. This is the SHIPPED set — what the tree carries; what a given
152
+ # machine RECEIVES is the selected set below.
153
+ shipped_skills() {
154
+ local d
155
+ for d in "$REPO/claude/skills"/*/; do
156
+ [ -f "$d/SKILL.md" ] || continue
157
+ basename "$d"
158
+ done
159
+ }
160
+
161
+ # The selection, as the assembler receives it: an explicit --domains, else the saved
162
+ # selection.json, else every domain (what "full" means). One function so the corpus and
163
+ # the skills are always answered from the same selection — a skill decided from a
164
+ # different reading than the bundle would ship a package the bundle disagrees with.
165
+ selection_args() { # -> SEL_ARGS
166
+ SEL_ARGS=()
167
+ if [ "${DOMAINS_SET:-0}" = 1 ]; then
168
+ SEL_ARGS=(--domains "$DOMAINS_ARG")
169
+ elif [ ! -f "$STATE_DIR/selection.json" ]; then
170
+ SEL_ARGS=(--domains "$(all_domains_csv)")
171
+ fi
172
+ }
173
+
174
+ # The skills this selection delivers, by name. Skills are classified in
175
+ # compose/domains.json like guides, hooks and agents, and the assembler owns the rule
176
+ # that turns a classification and a selection into a delivered set; this asks it rather
177
+ # than reading domains.json here, so there is one owner of "who gets what". Deploy,
178
+ # prune and verify all iterate THIS list; a skill the tree ships but the selection does
179
+ # not cover is treated exactly like a file a later release withdrew.
180
+ selected_skills() {
181
+ selection_args
182
+ python3 "$REPO/compose/assemble.py" --selected-skills --state-dir "$STATE_DIR" \
183
+ ${SEL_ARGS[@]+"${SEL_ARGS[@]}"}
184
+ }
185
+
186
+ # Membership in the selected set, for the loops that iterate the shipped one. Refuses to
187
+ # answer before the set is resolved: an unset variable read as "nothing selected" would
188
+ # make prune remove every skill and verify demand every absence — the fail-open shape.
189
+ skill_selected() { # $1: skill name; reads SELECTED_SKILLS
190
+ [ -n "${SELECTED_SKILLS+x}" ] || { log "BUG: skill selection read before it was resolved"; exit 1; }
191
+ case " $(printf '%s' "$SELECTED_SKILLS" | tr '\n' ' ') " in *" $1 "*) return 0 ;; esac
192
+ return 1
193
+ }
194
+
195
+ # Our skill directories, and only ours: `$CLAUDE_DIR/skills` and `$CODEX_DIR/skills` are
196
+ # the hosts' shared scan directories, holding other tools' skills, and are never touched.
197
+ # Deepest first, so a skill's subdirectories go before the skill itself; rmdir throughout,
198
+ # so a directory holding anything we did not write is left, visibly.
199
+ rmdir_skill_dirs() {
200
+ local skill="$1" sd d
201
+ for sd in "$CLAUDE_DIR/skills/$skill" "$CODEX_DIR/skills/$skill"; do
202
+ [ -d "$sd" ] || continue
203
+ while IFS= read -r d; do
204
+ rmdir "$d" 2>/dev/null && info "removed empty $d" || true
205
+ done < <(find "$sd" -depth -type d)
206
+ done
207
+ }
208
+
209
+ # A skill file the PREVIOUS install wrote and this release no longer ships is ours by the
210
+ # prior manifest and would otherwise stay on disk unowned: the rebuilt manifest stops
211
+ # naming it, verify enumerates only current files, and the host keeps loading it. So the
212
+ # prior manifest is read for paths under either host's skills root that no shipped file
213
+ # maps to, and those are removed — backed up like any overwrite — before the deploy.
214
+ # Only skill paths: every other deploy target is a fixed name a later release overwrites
215
+ # in place. Only names the manifest proves we wrote.
216
+ prune_stale_skills() {
217
+ local prior="$PRIOR_MANIFEST" f skill rel touched=""
218
+ [ "$DRY_RUN" = 1 ] && prior="$MANIFEST"
219
+ [ -f "$prior" ] || return 0
220
+ while IFS= read -r f; do
221
+ [ -n "$f" ] || continue
222
+ case "$f" in
223
+ "$CLAUDE_DIR/skills/"*) rel="${f#"$CLAUDE_DIR/skills/"}" ;;
224
+ "$CODEX_DIR/skills/"*) rel="${f#"$CODEX_DIR/skills/"}" ;;
225
+ *) continue ;;
226
+ esac
227
+ skill="${rel%%/*}"; rel="${rel#*/}"
228
+ # Still shipped AND still selected: only that combination keeps a prior file. A
229
+ # deselected skill's files are stale exactly like a withdrawn release's — the host
230
+ # would go on loading them for a package the user no longer takes.
231
+ [ -f "$REPO/claude/skills/$skill/$rel" ] && skill_selected "$skill" && continue
232
+ [ -f "$f" ] || continue
233
+ if [ -n "$BACKUP_DIR" ] && [ "$DRY_RUN" != 1 ]; then
234
+ mkdir -p "$(dirname "$BACKUP_DIR$f")" && cp "$f" "$BACKUP_DIR$f"
235
+ fi
236
+ run rm -f "$f"; [ "$DRY_RUN" = 1 ] || info "removed stale skill file $f"
237
+ case " $touched " in *" $skill "*) ;; *) touched="$touched $skill" ;; esac
238
+ done < "$prior"
239
+ for skill in $touched; do rmdir_skill_dirs "$skill"; done
240
+ }
241
+
120
242
  # Backups are made on every install and every uninstall, and nothing used to remove them — 23
121
243
  # directories in two weeks on the first machine measured, plus loose `.bak-*` files sitting in
122
244
  # the user's own config dirs. Pruning runs AFTER the new copy exists, never before, so a failed
@@ -207,15 +329,17 @@ prune_withheld() {
207
329
  # we never rewrite, so they are separately versioned by construction; a package can be added or
208
330
  # dropped later by re-assembling, with no need to know which bytes came from where; and uninstall
209
331
  # can take everything of ours because nothing of theirs is mixed into it.
332
+ all_domains_csv() {
333
+ python3 -c "import json,sys;print(','.join(sorted(json.load(open(sys.argv[1]))['domains'])))" \
334
+ "$REPO/compose/domains.json"
335
+ }
336
+
210
337
  assemble_corpus() {
211
338
  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
339
+ # No flag and no saved selection: install everything. This is what "full" meant, expressed as
340
+ # a selection so it goes down the same path as every other one (selection_args).
341
+ selection_args
342
+ args+=(${SEL_ARGS[@]+"${SEL_ARGS[@]}"})
219
343
  [ "$DRY_RUN" = 1 ] && args+=(--dry-run)
220
344
  # A dry run never truncated the manifest, so the live one still describes the last install.
221
345
  local prior="$PRIOR_MANIFEST"
@@ -223,24 +347,46 @@ assemble_corpus() {
223
347
  [ -f "$prior" ] && args+=(--prior-manifest "$prior")
224
348
  python3 "$REPO/compose/assemble.py" "${args[@]}" || rc=$?
225
349
  if [ "$rc" = 2 ]; then
350
+ ENTRY_NEEDS_ACTION=1
226
351
  log "entry file needs user action (import line missing); central content will not load until it is added"
227
352
  elif [ "$rc" != 0 ]; then
228
353
  return 1
229
354
  fi
230
355
  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
356
+ # The ownership rule is the assembler's, and it is one rule for every destination: the
357
+ # name exists in our source tree (compose/assemble.py copy_filtered "it is not in
358
+ # src_dir, so it is never a candidate"). `central/` was exempted as "ours whole" and
359
+ # scanned, which claimed whatever a user had put there and uninstall removes whatever
360
+ # the manifest names, so the assembler's care was undone one step later. That is the
361
+ # same defect this comment records having fixed for the SHARED $CODEX_DIR/guides; the
362
+ # directory being ours by convention did not make the scan a different mistake.
363
+ # bundle.md is generated rather than copied, so it has no source name and is ours
364
+ # unconditionally.
365
+ local g dst sub s
366
+ if [ -f "$CLAUDE_DIR/central/bundle.md" ]; then
367
+ printf '%s\n' "$CLAUDE_DIR/central/bundle.md" >> "$MANIFEST"
368
+ fi
369
+ for sub in guides hooks agents; do
370
+ for s in "$REPO"/claude/"$sub"/*; do
371
+ [ -f "$s" ] || continue
372
+ dst="$CLAUDE_DIR/central/$sub/$(basename "$s")"
373
+ # if-form for the reason spelled out below: a trailing false status becomes the
374
+ # loop's, then the function's, and the install dies silently after ASSEMBLED.
375
+ if [ -f "$dst" ]; then printf '%s\n' "$dst" >> "$MANIFEST"; fi
376
+ done
377
+ done
238
378
  for g in "$REPO"/codex/guides/*.md; do
239
379
  [ -f "$g" ] || continue
240
380
  dst="$CODEX_DIR/guides/$(basename "$g")"
241
- [ -f "$dst" ] && printf '%s\n' "$dst" >> "$MANIFEST"
381
+ # if-form, not `[ -f ] && printf`: when the alphabetically LAST guide is
382
+ # not deployed (a partial domain selection on a fresh machine), the
383
+ # compound's false status became the loop's, the loop's became the
384
+ # function's, and the install died silently right after ASSEMBLED —
385
+ # a latent first-user bug the I9 scenario exposed.
386
+ if [ -f "$dst" ]; then printf '%s\n' "$dst" >> "$MANIFEST"; fi
242
387
  done
243
388
  fi
389
+ return 0
244
390
  }
245
391
 
246
392
  add_zsh_hook() {
@@ -560,7 +706,18 @@ migrate_learnings() {
560
706
  local script="$REPO/learn/migrate-learnings.py"
561
707
  { [ -f "$script" ] && [ -f "$REPO/learn/promotions.json" ]; } || return 0
562
708
  local -a sel dry
563
- sel=(--selection-file "$STATE_DIR/selection.json")
709
+ # The SAME three-way assemble_corpus resolves, because these two must be answering one
710
+ # question. Naming only the selection file made a fresh `--dry-run` preview migration as
711
+ # "skipped" — a dry run deliberately does not write selection.json, while the real run
712
+ # writes it moments earlier and then migrates for real. A preview that reports the
713
+ # opposite of what the run does is worse than no preview.
714
+ if [ "${DOMAINS_SET:-0}" = 1 ]; then
715
+ sel=(--domains "$DOMAINS_ARG")
716
+ elif [ -f "$STATE_DIR/selection.json" ]; then
717
+ sel=(--selection-file "$STATE_DIR/selection.json")
718
+ else
719
+ sel=(--domains "$(all_domains_csv)")
720
+ fi
564
721
  [ "$DRY_RUN" = 1 ] && dry=(--dry-run) || dry=()
565
722
  # ${dry[@]+...}: expanding an empty array as "${dry[@]}" is an unbound-variable
566
723
  # error under `set -u` on bash 3.2 (macOS default) and would abort the install.
@@ -579,6 +736,18 @@ cmd_install() {
579
736
  BACKUP_DIR="$STATE_DIR/backups/$(date +%Y%m%d-%H%M%S)"
580
737
  [ -f "$MANIFEST" ] && cp "$MANIFEST" "$PRIOR_MANIFEST"
581
738
  : > "$MANIFEST"
739
+ # The manifest is emptied here and refilled as files are deployed, so between this
740
+ # line and a completed install it does not describe what is on disk. An install that
741
+ # fails in between — `--domains no-such-domain` is enough — left it EMPTY, and
742
+ # `uninstall` consumes it: it removed nothing, exited 0, and told the user the
743
+ # uninstall had succeeded while all fifty deployed files were still there. A
744
+ # destructive command reporting success for doing nothing is the worst reading in
745
+ # this file, so the prior manifest goes back unless the install reaches the end.
746
+ INSTALL_COMPLETED=0
747
+ trap 'if [ "${INSTALL_COMPLETED:-0}" != 1 ] && [ -f "$PRIOR_MANIFEST" ]; then
748
+ cp "$PRIOR_MANIFEST" "$MANIFEST"
749
+ log "install did not complete; restored the previous manifest so uninstall still knows what was deployed"
750
+ fi' EXIT
582
751
  fi
583
752
  log "Deploying agent-bios from $REPO"
584
753
  # Refuse loudly before doing any work: an unreadable declaration must not degrade
@@ -591,6 +760,16 @@ cmd_install() {
591
760
  prune_withheld "$CLAUDE_DIR/guides"
592
761
  migrate_learnings # Phase 4: clear personal copies now absorbed by the corpus
593
762
  deploy_glob "$REPO/codex/agents" "*.toml" "$CODEX_DIR/agents"
763
+ # Resolved once, before the prune that reads it: an assembler that cannot answer must
764
+ # stop the install here, not let `for skill in $(...)` iterate an empty answer and
765
+ # deploy nothing while reporting success.
766
+ SELECTED_SKILLS="$(selected_skills)" || { log "could not resolve the selected skills"; exit 1; }
767
+ prune_stale_skills
768
+ local skill
769
+ for skill in $SELECTED_SKILLS; do
770
+ deploy_tree "$REPO/claude/skills/$skill" "$CLAUDE_DIR/skills/$skill" || exit 1
771
+ deploy_tree "$REPO/claude/skills/$skill" "$CODEX_DIR/skills/$skill" || exit 1
772
+ done
594
773
  codex_config_additions merge || exit 1
595
774
  deploy_file "$REPO/wrappers/codex-run.sh" "$CODEX_DIR/bin/codex-run" "+x"
596
775
  deploy_file "$REPO/wrappers/codex-helm.sh" "$CODEX_DIR/bin/codex-helm" "+x"
@@ -601,7 +780,45 @@ cmd_install() {
601
780
  migrate_user_presets || exit 1 # must precede the deploy below, which overwrites profiles.toml
602
781
  deploy_file "$REPO/launch/agent-launch.toml" "$LAUNCH_DIR/profiles.toml"
603
782
  deploy_file "$REPO/launch/agent-launch.zsh" "$LAUNCH_DIR/shell.zsh"
783
+ # UI text catalogs: deploy-managed siblings of the config, one per language.
784
+ # The launcher resolves them from the config path, so the deployed home is
785
+ # $LAUNCH_DIR/i18n exactly as the checkout's is launch/i18n.
786
+ #
787
+ # The launcher goes FIRST, and the order is chosen for how an interrupted
788
+ # install FAILS rather than for how much it breaks. The deployed launcher keeps
789
+ # no catalog beside itself -- it lives in $BIN_DIR while the catalogs live under
790
+ # $LAUNCH_DIR -- so it renders whatever is deployed, and the two orders leave
791
+ # opposite states:
792
+ #
793
+ # launcher first -> new launcher, old catalogs: keys it added render as their
794
+ # own names AND t() prints "the deployed catalogs are older
795
+ # than this launcher; run: agent-bios install". Loud, and
796
+ # the message is the fix.
797
+ # catalogs first -> old launcher, new catalogs: a value whose SHAPE changed
798
+ # renders its template. Measured on the 1ba2d82 launcher
799
+ # against these catalogs, the checklist drew
800
+ # "선택 적용{pending}" with no diagnostic at all.
801
+ #
802
+ # The second is less broken and entirely silent, which is the worse trade: a
803
+ # user cannot act on what does not announce itself. This ordering was briefly
804
+ # the other way round on the claim that spare keys are harmless; they are, but
805
+ # changed values are not, and four keys in this change altered shape.
806
+ #
807
+ # The loudness is contingent, not structural, and narrower than an earlier version
808
+ # of this comment claimed: the notice fires only for a key the FIRST SCREEN itself
809
+ # requests and the old catalogs lack. Adding keys anywhere is not enough. This
810
+ # release adds four and the root screen requests none of them — they are reached
811
+ # from the registration wizard and the corpus screens — so for this release the
812
+ # ordering buys no diagnostic at all, only the smaller blast radius of a launcher
813
+ # that is newer than its catalogs rather than older. The shape-changed keys are
814
+ # silent in that direction too: an old value with no slot formats to itself and the
815
+ # argument simply vanishes. Keep the order for the blast radius; do not rely on the
816
+ # diagnostic unless a release actually adds a root-screen key.
604
817
  deploy_file "$REPO/launch/agent-launch.py" "$BIN_DIR/agent-launch" "+x"
818
+ local lang
819
+ for lang in en ko ja; do
820
+ deploy_file "$REPO/launch/i18n/$lang.toml" "$LAUNCH_DIR/i18n/$lang.toml"
821
+ done
605
822
  log ""
606
823
  log "Optional dependencies (missing ones only degrade the routes that need them)..."
607
824
  handle_capabilities "$WITH" || exit 1
@@ -613,7 +830,15 @@ cmd_install() {
613
830
  || log "warning: venv provisioning failed (numbered-prompt fallback applies)"
614
831
  fi
615
832
  add_zsh_hook
616
- if python3 "$REPO/compose/corpus-state.py" project --repo "$REPO" >/dev/null 2>&1; then
833
+ # Guarded, because this WRITES. The projection takes a lock and rewrites
834
+ # corpus-status.json, and it sat outside the dry-run branch above — so
835
+ # `install --dry-run` changed the file whose whole purpose is to describe what is
836
+ # deployed, against this script's own `--dry-run print actions without changing
837
+ # anything` and README's identical sentence. A dry run that edits state is worse than
838
+ # no dry run: it is consulted precisely when the user is unwilling to touch anything.
839
+ if [ "$DRY_RUN" = 1 ]; then
840
+ info "[dry-run] project corpus-status"
841
+ elif python3 "$REPO/compose/corpus-state.py" project --repo "$REPO" >/dev/null 2>&1; then
617
842
  info "corpus-status projected"
618
843
  else
619
844
  log "note: corpus-status projection unavailable (versions.json/ledger missing?)"
@@ -653,10 +878,29 @@ PY
653
878
  fi
654
879
  fi
655
880
  log ""
881
+ if [ "$DRY_RUN" = 1 ]; then
882
+ # A dry run wrote nothing, so there is nothing of THIS plan to verify. Running the live
883
+ # verifier anyway made a valid preview exit 1 on any machine where the planned files are
884
+ # not already present — which is every fresh one, the case a preview is most for. On an
885
+ # installed machine it is no better: it would be reporting on the previous install while
886
+ # standing where a verdict on the plan just printed belongs.
887
+ INSTALL_COMPLETED=1
888
+ log "[dry-run] nothing was written, so nothing is verified — re-run without --dry-run to install."
889
+ return 0
890
+ fi
656
891
  log "Verifying deployment..."
657
892
  if cmd_verify; then
893
+ # Reached the end: the manifest now describes what is actually deployed, so the
894
+ # restore armed above must not fire.
895
+ INSTALL_COMPLETED=1
658
896
  log ""
659
897
  log "Done. Open a new shell (or: source \"$ZSHRC\") to activate the zero-arg launcher."
898
+ if [ "${ENTRY_NEEDS_ACTION:-0}" = 1 ]; then
899
+ log ""
900
+ log "ONE STEP LEFT: add this line to $CLAUDE_DIR/CLAUDE.md (yours; we never rewrite it):"
901
+ log " @central/bundle.md"
902
+ log "Until then the deployed corpus will not load."
903
+ fi
660
904
  # An untouched backup dir means nothing was replaced; that healthy state
661
905
  # must not become a nonzero exit under set -e.
662
906
  { [ -n "$BACKUP_DIR" ] && [ -d "$BACKUP_DIR" ] && log "Replaced files were backed up under $BACKUP_DIR"; } || true
@@ -695,6 +939,12 @@ cmd_verify() {
695
939
  verify_present "$CLAUDE_DIR/central/bundle.md" || fail=1
696
940
  if grep -qF '@central/bundle.md' "$CLAUDE_DIR/CLAUDE.md" 2>/dev/null; then
697
941
  info "entry import line present"
942
+ elif [ "${ENTRY_NEEDS_ACTION:-0}" = 1 ]; then
943
+ # The install that just ran said this, and said it because the file is the user's and is
944
+ # never rewritten. Failing on it a second time turned a deployment that fully succeeded
945
+ # into one whose record was rolled back. Standalone `agent-bios verify` has this unset,
946
+ # so it still reports a corpus that is not loading as the failure it is.
947
+ log "ACTION NEEDED: add '@central/bundle.md' to $CLAUDE_DIR/CLAUDE.md — everything else deployed"
698
948
  else
699
949
  log "entry $CLAUDE_DIR/CLAUDE.md lacks '@central/bundle.md' — central corpus is NOT loading"; fail=1
700
950
  fi
@@ -725,6 +975,14 @@ cmd_verify() {
725
975
  verify_match "$REPO/launch/agent-launch.py" "$BIN_DIR/agent-launch" || fail=1
726
976
  verify_match "$REPO/launch/agent-launch.toml" "$LAUNCH_DIR/profiles.toml" || fail=1
727
977
  verify_match "$REPO/launch/agent-launch.zsh" "$LAUNCH_DIR/shell.zsh" || fail=1
978
+ # The catalogs were the one deploy-managed artifact nothing verified, which is
979
+ # the state that renders key names on screen: the deployed launcher keeps no
980
+ # catalog beside itself, so whatever is here IS the UI text. Byte-identity, the
981
+ # same bar as its siblings above.
982
+ local lang
983
+ for lang in en ko ja; do
984
+ verify_match "$REPO/launch/i18n/$lang.toml" "$LAUNCH_DIR/i18n/$lang.toml" || fail=1
985
+ done
728
986
  python3 - "$CODEX_DIR/agents" <<'PY' && info "agent TOMLs OK" || fail=1
729
987
  import sys, pathlib, tomllib
730
988
  root = pathlib.Path(sys.argv[1])
@@ -735,6 +993,26 @@ for p in sorted(root.glob("*.toml")):
735
993
  tomllib.loads(p.read_text())
736
994
  PY
737
995
  codex_config_additions check || fail=1
996
+ # Every file of every SELECTED skill, byte-identical on both hosts — the same bar as the
997
+ # launcher and its catalogs, because a skill's SKILL.md IS what the host reads. A skill
998
+ # the selection does not deliver must be absent on both hosts, or the host is loading a
999
+ # package the user did not take.
1000
+ local skill sf rel
1001
+ SELECTED_SKILLS="$(selected_skills)" || { log "could not resolve the selected skills"; fail=1; }
1002
+ for skill in $(shipped_skills); do
1003
+ if skill_selected "$skill"; then
1004
+ while IFS= read -r sf; do
1005
+ rel="${sf#"$REPO/claude/skills/$skill"/}"
1006
+ verify_match "$sf" "$CLAUDE_DIR/skills/$skill/$rel" || fail=1
1007
+ verify_match "$sf" "$CODEX_DIR/skills/$skill/$rel" || fail=1
1008
+ done < <(find "$REPO/claude/skills/$skill" -type f | LC_ALL=C sort)
1009
+ else
1010
+ for sf in "$CLAUDE_DIR/skills/$skill/SKILL.md" "$CODEX_DIR/skills/$skill/SKILL.md"; do
1011
+ if [ -f "$sf" ]; then log "deselected skill still deployed $sf"; fail=1
1012
+ else info "absent (deselected) $sf"; fi
1013
+ done
1014
+ fi
1015
+ done
738
1016
  if command -v codex >/dev/null 2>&1 && [ -x "$CODEX_DIR/bin/codex-helm" ]; then
739
1017
  if "$CODEX_DIR/bin/codex-helm" --dry-run --mode review "probe" >/dev/null 2>&1; then
740
1018
  info "codex-helm dry-run OK"
@@ -778,7 +1056,20 @@ PY
778
1056
  fi
779
1057
  # Repo-internal mirror parity is a maintainer gate; only meaningful from a clone.
780
1058
  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
1059
+ # Output kept, not discarded the third place in this repo where a gate's own
1060
+ # explanation went to /dev/null and left "it failed" as the entire report. The umbrella
1061
+ # and the install-scenario harness each learned this after a failure cost an eleven-
1062
+ # minute re-run that came back green; verify is where a USER meets it, with no clone to
1063
+ # re-run from.
1064
+ parity_log="$(mktemp -t verify-parity)"
1065
+ if "$REPO/gates/check-parity.sh" >"$parity_log" 2>&1; then
1066
+ info "repo mirror parity OK"; rm -f "$parity_log"
1067
+ else
1068
+ log "repo mirror parity FAILED"
1069
+ grep -a 'FAIL' "$parity_log" | head -10 | sed 's/^/ /'
1070
+ log " full output: $parity_log"
1071
+ fail=1
1072
+ fi
782
1073
  fi
783
1074
  # Prompting guides name concrete models, so they go stale on a model change
784
1075
  # rather than degrading quietly; this checks them against the launch config.
@@ -801,10 +1092,14 @@ cmd_uninstall() {
801
1092
  # invoking deleted files and instructions pointing at deleted guides. It reads ownership the
802
1093
  # same way the merge wrote it, so it cannot reach past what we put there.
803
1094
  if [ -f "$REPO/compose/assemble.py" ]; then
1095
+ # The failure is recorded, not only printed. A warning scrolls past and the summary at
1096
+ # the end went on saying "nothing of ours is left on this machine" — while the spans this
1097
+ # step exists to remove were still in the user's settings.json, now pointing at hook files
1098
+ # the next step deletes. A warning nobody reads is how the two came to disagree.
804
1099
  python3 "$REPO/compose/assemble.py" --remove-owned --claude-dir "$CLAUDE_DIR" \
805
1100
  --codex-dir "$CODEX_DIR" --state-dir "$STATE_DIR" ${DRY_RUN:+} \
806
1101
  $([ "$DRY_RUN" = 1 ] && echo --dry-run) \
807
- || log "warning: could not remove assembler-owned regions"
1102
+ || { CLEANUP_FAILED=1; log "warning: could not remove assembler-owned regions"; }
808
1103
  fi
809
1104
  if [ -f "$MANIFEST" ]; then
810
1105
  # Back up before deleting, the way install backs up before overwriting. Full mode deploys the
@@ -814,15 +1109,37 @@ cmd_uninstall() {
814
1109
  # would have been backed up. Removal is symmetric with deployment; recoverability now is too.
815
1110
  [ "$DRY_RUN" = 1 ] || { mkdir -p "$STATE_DIR"; BACKUP_DIR="$STATE_DIR/backups/uninstall-$(date +%Y%m%d-%H%M%S)"; }
816
1111
  local f
1112
+ UNBACKED=0
817
1113
  while IFS= read -r f; do
818
1114
  [ -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"
1115
+ [ -f "$f" ] || continue
1116
+ if [ -n "$BACKUP_DIR" ] && [ "$DRY_RUN" != 1 ]; then
1117
+ # The rule archive_and_purge applies below, applied here too: only what verifiably
1118
+ # reached a copy may be deleted. The mkdir and the cp both fail silently — a full
1119
+ # disk, a name already taken by a file — and the `&&` chain's result was never read,
1120
+ # so the deployed file was removed anyway and reached neither the backup nor the
1121
+ # archive built from it. Size equality, not cp's status, because the question is
1122
+ # whether it can be restored.
1123
+ if ! { mkdir -p "$(dirname "$BACKUP_DIR$f")" 2>/dev/null \
1124
+ && cp "$f" "$BACKUP_DIR$f" 2>/dev/null \
1125
+ && [ "$(wc -c <"$f" 2>/dev/null)" = "$(wc -c <"$BACKUP_DIR$f" 2>/dev/null)" ]; }; then
1126
+ rm -f "$BACKUP_DIR$f" 2>/dev/null # a partial copy must not look backed up
1127
+ UNBACKED=$((UNBACKED + 1))
1128
+ log "warning: could not back up $f — leaving it in place"
1129
+ continue
1130
+ fi
821
1131
  fi
822
- [ -f "$f" ] && { run rm -f "$f"; [ "$DRY_RUN" = 1 ] || info "removed $f"; }
1132
+ run rm -f "$f"; [ "$DRY_RUN" = 1 ] || info "removed $f"
823
1133
  done < "$MANIFEST"
824
1134
  { [ -n "$BACKUP_DIR" ] && [ -d "$BACKUP_DIR" ] && info "staged for the archive: $BACKUP_DIR"; } || true
825
- [ "$DRY_RUN" = 1 ] || rm -f "$MANIFEST"
1135
+ # The manifest survives whenever something it names is still on disk: it is the only
1136
+ # record of what to finish removing, and deleting it would strand those files unowned.
1137
+ if [ "$UNBACKED" -gt 0 ]; then
1138
+ log "warning: $UNBACKED deployed file(s) could not be backed up and were LEFT ON DISK;"
1139
+ log " keeping $MANIFEST so a later uninstall can finish the job"
1140
+ else
1141
+ [ "$DRY_RUN" = 1 ] || rm -f "$MANIFEST"
1142
+ fi
826
1143
  else
827
1144
  log "no manifest at $MANIFEST; removing known deploy targets"
828
1145
  # The two entry files are deliberately NOT in this list. `$CLAUDE_DIR/CLAUDE.md` is seeded
@@ -837,6 +1154,27 @@ cmd_uninstall() {
837
1154
  "$LAUNCH_DIR/profiles.toml" "$LAUNCH_DIR/shell.zsh" "$BIN_DIR/agent-launch"; do
838
1155
  [ -f "$p" ] && { run rm -f "$p"; [ "$DRY_RUN" = 1 ] || info "removed $p"; }
839
1156
  done
1157
+ # Skills: only the names the shipped tree carries, mirrored under each host's skills
1158
+ # directory, and only where the file IS ours — byte-identical to what this package
1159
+ # ships. Without a manifest, sameness of content is the one ownership evidence left:
1160
+ # a user who authored their own `skills/repo-charter/SKILL.md` before ever installing
1161
+ # would otherwise lose it here with no backup. Never the directory wholesale either —
1162
+ # a sibling skill of theirs shares the parent.
1163
+ local skill sf rel
1164
+ for skill in $(shipped_skills); do
1165
+ while IFS= read -r sf; do
1166
+ rel="${sf#"$REPO/claude/skills/$skill"/}"
1167
+ for p in "$CLAUDE_DIR/skills/$skill/$rel" "$CODEX_DIR/skills/$skill/$rel"; do
1168
+ if [ -f "$p" ]; then
1169
+ if cmp -s "$sf" "$p"; then
1170
+ run rm -f "$p"; [ "$DRY_RUN" = 1 ] || info "removed $p"
1171
+ else
1172
+ log "kept $p (differs from the shipped file and no manifest says we wrote it)"
1173
+ fi
1174
+ fi
1175
+ done
1176
+ done < <(find "$REPO/claude/skills/$skill" -type f | LC_ALL=C sort)
1177
+ done
840
1178
  fi
841
1179
  local d
842
1180
  # `central/` and its subdirectories are entirely ours — the assembler creates them and the
@@ -850,10 +1188,24 @@ cmd_uninstall() {
850
1188
  "$CODEX_DIR/guides" "$CODEX_DIR/agents" "$CODEX_DIR/bin" "$LAUNCH_DIR"; do
851
1189
  [ -d "$d" ] && rmdir "$d" 2>/dev/null && info "removed empty $d" || true
852
1190
  done
1191
+ local skill
1192
+ for skill in $(shipped_skills); do rmdir_skill_dirs "$skill"; done
853
1193
  remove_zsh_hook
854
1194
  archive_and_purge
855
1195
  log ""
856
- log "Uninstalled: deployed files, the zsh hook, state, backups, cache, and the managed venv."
1196
+ if [ "${UNBACKED:-0}" -gt 0 ]; then
1197
+ log "Uninstalled: the zsh hook, state, backups, cache, and the managed venv — and every"
1198
+ log "deployed file EXCEPT the $UNBACKED named above, which had no recoverable copy."
1199
+ else
1200
+ log "Uninstalled: deployed files, the zsh hook, state, backups, cache, and the managed venv."
1201
+ fi
1202
+ if [ "${CLEANUP_FAILED:-0}" = 1 ]; then
1203
+ log ""
1204
+ log "NOT removed: the registrations and instruction region this repo merged into files you"
1205
+ log "own — see the warning above. Re-run uninstall once that is resolved, or remove the"
1206
+ log "agent-bios entries from settings.json and the marked AGENTS.md region by hand."
1207
+ return 1
1208
+ fi
857
1209
  }
858
1210
 
859
1211
  # Uninstall is a SECURITY operation — nothing of ours may survive it on the machine. That
@@ -915,13 +1267,31 @@ archive_and_purge() {
915
1267
  while IFS= read -r -d '' rel; do rm -f "$rel" 2>/dev/null; done <"$staged"
916
1268
  rm -f "$list" "$staged"
917
1269
  rm -rf "$stage"
918
- rm -rf "$STATE_DIR" "$HOME/.cache/agent-launch" \
1270
+ # The state dir holds the manifest, and the manifest is the only record of what is still
1271
+ # deployed. When cmd_uninstall left a file behind because it could not be backed up,
1272
+ # purging that record here would strand the file unowned — so the same rule that keeps
1273
+ # the file keeps the thing that names it. Everything else goes either way.
1274
+ if [ "${UNBACKED:-0}" -eq 0 ]; then
1275
+ rm -rf "$STATE_DIR"
1276
+ fi
1277
+ rm -rf "$HOME/.cache/agent-launch" \
919
1278
  "${AGENT_LAUNCH_VENV:-$HOME/.local/share/agent-launch}"
920
1279
  log ""
921
1280
  log "Everything removed is in ONE archive: $out"
922
1281
  if [ "$skipped" -gt 0 ]; then
923
1282
  log "Move it somewhere central or delete it. $skipped backup copy(ies) could not be staged,"
924
1283
  log "so they were LEFT ON DISK rather than deleted with nothing to restore them from."
1284
+ elif [ "${UNBACKED:-0}" -gt 0 ]; then
1285
+ # Not "nothing of ours is left": cmd_uninstall said the opposite a moment ago, and two
1286
+ # lines of the same summary disagreeing is how an operator stops reading either.
1287
+ log "Move it somewhere central or delete it. $UNBACKED deployed file(s) and the manifest"
1288
+ log "naming them are still on this machine — re-run uninstall once the copy can be made."
1289
+ elif [ "${CLEANUP_FAILED:-0}" = 1 ]; then
1290
+ # The spans merged into files the user owns are still there — cmd_uninstall says so a
1291
+ # few lines below, and this line saying the opposite in the same summary is how an
1292
+ # operator learns to read neither.
1293
+ log "Move it somewhere central or delete it. What this repo merged into files you own"
1294
+ log "could not be removed — see the warning above."
925
1295
  else
926
1296
  log "Move it somewhere central or delete it — nothing of ours is left on this machine."
927
1297
  fi
@@ -966,14 +1336,44 @@ cmd_onboard() {
966
1336
  fi
967
1337
  DOMAINS_ARG="$sel"; DOMAINS_SET=1
968
1338
  log "selection: ${sel:-<core+infra only>}"
969
- cmd_install
1339
+ # Subshelled so a failing install can still record its outcome: the launcher's
1340
+ # corpus checklist reads `last_apply` from corpus-status.json, and an exit with
1341
+ # nothing recorded reads as "nothing happened". cmd_install's shell state stays
1342
+ # in the subshell; everything after here uses only top-level globals.
1343
+ local apply_rc=0
1344
+ # NOT `( cmd_install ) || apply_rc=$?`: a command list that tests the subshell
1345
+ # suppresses errexit for everything inside it, so an unguarded failure in
1346
+ # cmd_install would run through to a zero return (probed on bash 3.2/5.x).
1347
+ # Toggling -e around a STANDALONE subshell keeps errexit live inside while the
1348
+ # outer shell survives to record the outcome.
1349
+ set +e
1350
+ ( set -e; cmd_install )
1351
+ apply_rc=$?
1352
+ set -e
1353
+ if [ "$apply_rc" -ne 0 ]; then
1354
+ python3 "$REPO/compose/corpus-state.py" record-apply \
1355
+ --requested "$sel" --outcome install_failed >/dev/null 2>&1 || true
1356
+ exit "$apply_rc"
1357
+ fi
970
1358
  log ""
971
1359
  log "Activation canary (proves the bundle loads in a live session)..."
972
1360
  if [ "$DRY_RUN" = 1 ]; then info "[dry-run] skip canary probe"; return; fi
973
- bash "$REPO/compose/canary.sh" || {
1361
+ local canary_rc=0
1362
+ bash "$REPO/compose/canary.sh" || canary_rc=$?
1363
+ if [ "$canary_rc" -ne 0 ]; then
1364
+ # rc=1 is a real probe that answered "not loading"; rc=3 is "could not
1365
+ # probe" (no CLI/auth). Both leave the apply unproven, so both record as
1366
+ # canary_failed — the tail carries which, so the panel's loud line does
1367
+ # not send the operator to debug imports over an auth problem.
1368
+ python3 "$REPO/compose/corpus-state.py" record-apply \
1369
+ --requested "$sel" --outcome canary_failed \
1370
+ --error-tail "canary exit $canary_rc$([ "$canary_rc" = 3 ] && printf ' (could not probe)')" \
1371
+ >/dev/null 2>&1 || true
974
1372
  log "ONBOARDING INCOMPLETE: the bundle is installed but not loading — fix the cause above and re-run: agent-bios verify"
975
1373
  exit 1
976
- }
1374
+ fi
1375
+ python3 "$REPO/compose/corpus-state.py" record-apply \
1376
+ --requested "$sel" --outcome applied >/dev/null 2>&1 || true
977
1377
  # The prune is authorized by the canary's proof, and cmd_install ran before the canary existed
978
1378
  # for this bundle rev — so it kept everything. Now that loading is proven, run it for real.
979
1379
  migrate_learnings
@@ -1053,7 +1453,7 @@ drift_state() { # prints: match | drift | unknown
1053
1453
  # (bytes no commit describes; parsed via json_true, never byte-matched,
1054
1454
  # because json.dump's spacing defeated the compact grep) both make the
1055
1455
  # correspondence unprovable. Drift is a PROVEN mismatch and makes verify
1056
- # fail; branding the unprovable as drift made D-0050's accepted install path
1456
+ # fail; branding the unprovable as drift made D-20260809-7954fd's accepted install path
1057
1457
  # fail its own verification. Clone deployments stay version-compared — git
1058
1458
  # itself is their live provenance, and they persist no commit by design.
1059
1459
  if [ "$(json_field "$STATE_DIR/version.json" source 2>/dev/null || true)" = "package" ] \
@@ -1106,7 +1506,7 @@ cmd_status() {
1106
1506
  info "UNBOUND provenance.json is unreadable or names no commit — this artifact is not usably bound"
1107
1507
  fi
1108
1508
  else
1109
- # The absence is the finding: D-0050 accepts lifecycle-disabled packs as
1509
+ # The absence is the finding: D-20260809-7954fd accepts lifecycle-disabled packs as
1110
1510
  # residual BECAUSE this line makes an unbound artifact visible — a silent
1111
1511
  # skip here would unmake that decision's premise.
1112
1512
  info "UNBOUND no provenance stamp — packed with lifecycle scripts disabled, or a pre-provenance release; this artifact names no commit"
@@ -1156,6 +1556,8 @@ agent-bios — deploy the Claude/Codex instruction SSOT into $HOME (by copy).
1156
1556
  stdin; this is what the learn! flow calls, and it
1157
1557
  works from any directory, unlike a repo-relative path)
1158
1558
  agent-bios status show what is installed and where
1559
+ agent-bios cost the session cost / context meter (session-cost.py), from any
1560
+ directory: agent-bios cost [--context [--budget N]] <transcript>
1159
1561
  agent-bios update git pull + reinstall (clone), or print the npm update line
1160
1562
  agent-bios uninstall remove deployed files and the zsh hook
1161
1563
  agent-bios help
@@ -1205,6 +1607,16 @@ if [ "$CMD" = "learn" ]; then
1205
1607
  [ -f "$collector" ] || { log "learn: collector missing at $collector"; exit 1; }
1206
1608
  exec python3 "$collector" "$@" <&3
1207
1609
  fi
1610
+ # `cost` is the same shape for the same reason: the guides tell an installed user to
1611
+ # measure with session-cost.py, and on a packaged install that file lives inside the
1612
+ # npm package where no PATH reaches it — the instruction resolved to "command not
1613
+ # found" everywhere but a clone. Its arguments (--context, --budget, transcript
1614
+ # paths) are the meter's, so it bypasses the flag parser below.
1615
+ if [ "$CMD" = "cost" ]; then
1616
+ meter="$REPO/session-cost.py"
1617
+ [ -f "$meter" ] || { log "cost: meter missing at $meter"; exit 1; }
1618
+ exec python3 "$meter" "$@" <&3
1619
+ fi
1208
1620
 
1209
1621
  WITH=""
1210
1622
  DOMAINS_ARG=""