agent-bios 0.14.0 → 0.15.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.
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env python3
2
+ """Write the update-check cache atomically.
3
+
4
+ The launcher READS this file on every start and never writes it, so a half-written
5
+ cache is a crash in the TUI rather than a stale badge — hence os.replace over a
6
+ temp file in the same directory, not a plain open-and-write.
7
+
8
+ `latest` is omitted when the lookup failed; `checked_at` is written regardless, so
9
+ an offline machine records its attempt and waits out the interval instead of
10
+ retrying on every launch. Absence of `latest` therefore means "asked, no answer",
11
+ which the launcher must not render as "up to date".
12
+ """
13
+ import json
14
+ import os
15
+ import sys
16
+ import tempfile
17
+
18
+
19
+ def main(argv: list[str]) -> int:
20
+ if len(argv) != 5:
21
+ print("usage: write-update-cache.py <out> <checked_at> <latest|''> <current>",
22
+ file=sys.stderr)
23
+ return 2
24
+ out, checked_at, latest, current = argv[1], argv[2], argv[3], argv[4]
25
+ try:
26
+ checked = int(checked_at)
27
+ except ValueError:
28
+ print(f"checked_at is not an integer: {checked_at!r}", file=sys.stderr)
29
+ return 2
30
+ payload: dict[str, object] = {"checked_at": checked}
31
+ if current:
32
+ payload["current"] = current
33
+ if latest:
34
+ payload["latest"] = latest
35
+ directory = os.path.dirname(out) or "."
36
+ os.makedirs(directory, exist_ok=True)
37
+ fd, tmp = tempfile.mkstemp(dir=directory, prefix=".update-check.")
38
+ try:
39
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
40
+ json.dump(payload, handle)
41
+ os.replace(tmp, out)
42
+ except BaseException:
43
+ # A temp file left in the state dir would be swept by nothing.
44
+ try:
45
+ os.unlink(tmp)
46
+ except OSError:
47
+ pass
48
+ raise
49
+ return 0
50
+
51
+
52
+ if __name__ == "__main__":
53
+ sys.exit(main(sys.argv))
package/install.sh CHANGED
@@ -13,6 +13,7 @@
13
13
  # agent-bios status show what is installed and where
14
14
  # agent-bios cost the session cost / context meter, from any directory
15
15
  # agent-bios update git pull + reinstall (clone), or print the npm update line
16
+ # agent-bios update --check cache the registry's latest version for the TUI badge
16
17
  # agent-bios uninstall remove deployed files and the zsh hook
17
18
  # agent-bios help
18
19
  #
@@ -95,6 +96,13 @@ CLEANUP_FAILED=0
95
96
  # treating it as a failure made cmd_install exit 1 and the EXIT trap restore the previous
96
97
  # manifest — leaving the new corpus on disk with the record saying the old one was deployed.
97
98
  ENTRY_NEEDS_ACTION=0
99
+ # Set when the required corpus-status projection could not be written. Deferred rather than
100
+ # returned on the spot: the projection runs AFTER the corpus is deployed, and returning
101
+ # there would fire the EXIT trap and restore the PREVIOUS manifest — leaving the new corpus
102
+ # on disk with the record naming the old one, which is the split state above. The manifest
103
+ # is completed first so it describes what is really there, and the command then exits
104
+ # non-zero. The files, the record, and the exit code then each say something true.
105
+ PROJECTION_FAILED=0
98
106
  # Deployed files uninstall could not back up, so did not delete. Global rather than local
99
107
  # because the closing summary must not claim a clean removal that did not happen.
100
108
  UNBACKED=0
@@ -859,12 +867,36 @@ cmd_install() {
859
867
  # deployed, against this script's own `--dry-run print actions without changing
860
868
  # anything` and README's identical sentence. A dry run that edits state is worse than
861
869
  # no dry run: it is consulted precisely when the user is unwilling to touch anything.
870
+ #
871
+ # The projection is REQUIRED, not best-effort. It used to be neither: stderr and the
872
+ # exit status were both discarded and every failure printed one guess of a note —
873
+ # "versions.json/ledger missing?" — which was wrong for the failure that actually
874
+ # happened. `compose/corpus-state.py` was not in the npm package at all, so a real
875
+ # npm install rewrote the corpus, updated selection.json and version.json, passed
876
+ # verification, exited 0, and left corpus-status.json stale from a previous
877
+ # deployment. The launcher's corpus panel reads that file, so the machine reported a
878
+ # selection the successful run had not recorded. A command that deploys the launcher
879
+ # and advertises its panel cannot call that a success.
880
+ #
881
+ # The script now ships and degrades honestly in an npm layout (domains real,
882
+ # version/ledger reported unavailable), so the remaining failures are real ones:
883
+ # an unwritable destination, an invalid status, a missing interpreter. Those fail
884
+ # the install, and the reason reaches the operator instead of /dev/null.
862
885
  if [ "$DRY_RUN" = 1 ]; then
863
886
  info "[dry-run] project corpus-status"
864
- elif python3 "$REPO/compose/corpus-state.py" project --repo "$REPO" >/dev/null 2>&1; then
865
- info "corpus-status projected"
866
887
  else
867
- log "note: corpus-status projection unavailable (versions.json/ledger missing?)"
888
+ local projection_log
889
+ projection_log="$(mktemp -t corpus-projection)"
890
+ if python3 "$REPO/compose/corpus-state.py" project --repo "$REPO" >"$projection_log" 2>&1; then
891
+ info "corpus-status projected"
892
+ rm -f "$projection_log"
893
+ else
894
+ log "corpus-status projection FAILED — the launcher's corpus panel would report a"
895
+ log "selection this run did not record. Its own output:"
896
+ sed 's/^/ /' "$projection_log"
897
+ log " full output: $projection_log"
898
+ PROJECTION_FAILED=1
899
+ fi
868
900
  fi
869
901
  # Deploy/system version marker for the launcher's TUI version line, read from
870
902
  # package.json (version + releaseDate) — distinct from the corpus content
@@ -917,7 +949,13 @@ PY
917
949
  # restore armed above must not fire.
918
950
  INSTALL_COMPLETED=1
919
951
  log ""
920
- log "Done. Open a new shell (or: source \"$ZSHRC\") to activate the zero-arg launcher."
952
+ # Withheld when the projection failed: the run below reports INSTALL INCOMPLETE and
953
+ # exits non-zero, and printing "Done." first told the operator both things in the
954
+ # same breath. The manifest is already complete by here, which is the point — the
955
+ # files really are deployed; what failed is the record of WHICH selection they are.
956
+ if [ "$PROJECTION_FAILED" != 1 ]; then
957
+ log "Done. Open a new shell (or: source \"$ZSHRC\") to activate the zero-arg launcher."
958
+ fi
921
959
  if [ "${ENTRY_NEEDS_ACTION:-0}" = 1 ]; then
922
960
  log ""
923
961
  log "ONE STEP LEFT: add this line to $CLAUDE_DIR/CLAUDE.md (yours; we never rewrite it):"
@@ -928,6 +966,13 @@ PY
928
966
  # must not become a nonzero exit under set -e.
929
967
  { [ -n "$BACKUP_DIR" ] && [ -d "$BACKUP_DIR" ] && log "Replaced files were backed up under $BACKUP_DIR"; } || true
930
968
  prune_backups
969
+ if [ "$PROJECTION_FAILED" = 1 ]; then
970
+ log ""
971
+ log "INSTALL INCOMPLETE: the corpus is deployed and the manifest records it, but the"
972
+ log "corpus-status projection failed above — the launcher's panel would describe a"
973
+ log "state this run did not record. Fix the cause and re-run: agent-bios install"
974
+ exit 1
975
+ fi
931
976
  else
932
977
  log "VERIFY FAILED after install — see messages above"
933
978
  exit 1
@@ -1417,8 +1462,22 @@ cmd_onboard() {
1417
1462
  log "ONBOARDING INCOMPLETE: the bundle is installed but not loading — fix the cause above and re-run: agent-bios verify"
1418
1463
  exit 1
1419
1464
  fi
1420
- python3 "$REPO/compose/corpus-state.py" record-apply \
1421
- --requested "$sel" --outcome applied >/dev/null 2>&1 || true
1465
+ # The SUCCESS record is strict; the two failure records above stay tolerant. The
1466
+ # asymmetry is the point: a swallowed failure-record rides a run that is already
1467
+ # exiting non-zero and reporting why, while a swallowed success-record is how
1468
+ # onboarding prints a completed summary over a status file that never learned the
1469
+ # selection was applied. That is the state this whole change exists to remove, so it
1470
+ # cannot be the one still guarded by `|| true`.
1471
+ record_log="$(mktemp -t corpus-record-apply)"
1472
+ if ! python3 "$REPO/compose/corpus-state.py" record-apply \
1473
+ --requested "$sel" --outcome applied >"$record_log" 2>&1; then
1474
+ log "ONBOARDING INCOMPLETE: the corpus applied, but recording that outcome failed —"
1475
+ log "the corpus panel would not show this selection as applied. Its own output:"
1476
+ sed 's/^/ /' "$record_log"
1477
+ log " full output: $record_log"
1478
+ exit 1
1479
+ fi
1480
+ rm -f "$record_log"
1422
1481
  # The prune is authorized by the canary's proof, and cmd_install ran before the canary existed
1423
1482
  # for this bundle rev — so it kept everything. Now that loading is proven, run it for real.
1424
1483
  migrate_learnings
@@ -1613,7 +1672,49 @@ shell_shadow_summary() {
1613
1672
  fi
1614
1673
  }
1615
1674
 
1675
+ # The update check is a READ of a public version number: it sends the package name
1676
+ # and nothing derived from this machine, which is the same class as provision-venv's
1677
+ # PyPI fetch and the onboard model probe — see ENDPOINTS.md "Zero egress by default",
1678
+ # whose claim is scoped to DATA egress. It delegates to `npm view` rather than naming
1679
+ # a registry, so a private registry, a proxy, and the user's auth all keep working and
1680
+ # no shipped file carries a URL (gates/check-endpoints.py forbids that outright).
1681
+ #
1682
+ # `checked_at` is written even when the lookup FAILS. An offline machine that recorded
1683
+ # nothing would retry on every launch, which is the opposite of once a day.
1684
+ refresh_update_cache() {
1685
+ if [ "${AGENT_BIOS_UPDATE_CHECK:-1}" = "0" ]; then
1686
+ log "update check disabled (AGENT_BIOS_UPDATE_CHECK=0)"
1687
+ return 0
1688
+ fi
1689
+ local name latest now out
1690
+ name="$(json_field "$REPO/package.json" name)" || return 1
1691
+ [ -n "$name" ] || return 1
1692
+ now="$(date +%s)"
1693
+ latest=""
1694
+ if command -v npm >/dev/null 2>&1; then
1695
+ # `|| latest=""` is not defensive noise: this file runs under `set -euo pipefail`,
1696
+ # so an npm that exits non-zero (offline, firewalled, private registry down) makes
1697
+ # the PIPELINE fail and `set -e` abort the function before it can record the
1698
+ # attempt — turning every offline launch into a retry and `update --check` into
1699
+ # exit 1. Caught by I16's failing-registry stub, not by review.
1700
+ latest="$(npm view "$name" version 2>/dev/null | tr -d '[:space:]')" || latest=""
1701
+ fi
1702
+ mkdir -p "$STATE_DIR" || return 1
1703
+ out="$STATE_DIR/update-check.json"
1704
+ # Atomic: a half-written cache read by the launcher is a crash in the TUI.
1705
+ python3 "$REPO/compose/write-update-cache.py" "$out" "$now" "$latest" "$(source_version)" || return 1
1706
+ if [ -n "$latest" ]; then
1707
+ log "update check: latest $latest (installed $(source_version))"
1708
+ else
1709
+ log "update check: could not reach the registry; recorded the attempt"
1710
+ fi
1711
+ }
1712
+
1616
1713
  cmd_update() {
1714
+ if [ "${UPDATE_CHECK_ONLY:-0}" = 1 ]; then
1715
+ refresh_update_cache
1716
+ return $?
1717
+ fi
1617
1718
  if [ -e "$REPO/.git" ]; then
1618
1719
  log "Updating from git..."
1619
1720
  run git -C "$REPO" pull --ff-only
@@ -1641,6 +1742,9 @@ agent-bios — deploy the Claude/Codex instruction SSOT into $HOME (by copy).
1641
1742
  agent-bios cost the session cost / context meter (session-cost.py), from any
1642
1743
  directory: agent-bios cost [--context [--budget N]] <transcript>
1643
1744
  agent-bios update git pull + reinstall (clone), or print the npm update line
1745
+ agent-bios update --check ask the registry for the latest version and cache it
1746
+ for the launcher's badge; sends the package name and nothing
1747
+ else, runs at most daily, AGENT_BIOS_UPDATE_CHECK=0 disables it
1644
1748
  agent-bios uninstall remove deployed files and the zsh hook
1645
1749
  agent-bios help
1646
1750
 
@@ -1706,6 +1810,7 @@ DOMAINS_SET=0
1706
1810
  while [ $# -gt 0 ]; do
1707
1811
  case "$1" in
1708
1812
  --dry-run) DRY_RUN=1 ;;
1813
+ --check) UPDATE_CHECK_ONLY=1 ;;
1709
1814
  --with) shift; WITH="${1:-}"; [ -n "$WITH" ] || { log "--with needs a comma-separated capability list"; exit 2; } ;;
1710
1815
  --with=*) WITH="${1#--with=}"; [ -n "$WITH" ] || { log "--with needs a comma-separated capability list"; exit 2; } ;;
1711
1816
  --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; } ;;
@@ -16,6 +16,7 @@ import string
16
16
  import subprocess
17
17
  import sys
18
18
  import tempfile
19
+ import time
19
20
  import unicodedata
20
21
  import textwrap
21
22
  import tomllib
@@ -7622,6 +7623,94 @@ VERSION_INFO_PATH = pathlib.Path(
7622
7623
  )
7623
7624
  )
7624
7625
 
7626
+ UPDATE_CHECK_PATH = pathlib.Path(
7627
+ os.environ.get(
7628
+ "AGENT_BIOS_UPDATE_CHECK_STATE",
7629
+ str(pathlib.Path.home() / ".local/share/agent-bios/update-check.json"),
7630
+ )
7631
+ )
7632
+
7633
+ # Once a day. The launcher never performs the lookup itself — it reads this cache and
7634
+ # at most SPAWNS the installer, detached, to refresh it. Two reasons, both load-bearing:
7635
+ # `npm view` routinely takes seconds and a launcher that blocks on the network is worse
7636
+ # than one that shows a stale badge, and the network operation belongs to the component
7637
+ # that already owns fetch-corpus (ENDPOINTS.md) rather than to the UI.
7638
+ UPDATE_CHECK_INTERVAL_S = 24 * 60 * 60
7639
+
7640
+
7641
+ def _version_tuple(text: str) -> tuple[int, ...] | None:
7642
+ """Numeric release prefix, or None when it is not one.
7643
+
7644
+ Deliberately refuses anything it does not fully understand rather than guessing:
7645
+ a prerelease like 1.2.3-rc1 returns None, so it is never compared and never
7646
+ announced. Announcing an upgrade to a version the user cannot get is worse than
7647
+ announcing nothing."""
7648
+ parts = text.strip().split(".")
7649
+ if not (2 <= len(parts) <= 4):
7650
+ return None
7651
+ out = []
7652
+ for part in parts:
7653
+ if not part.isdigit():
7654
+ return None
7655
+ out.append(int(part))
7656
+ return tuple(out)
7657
+
7658
+
7659
+ def read_update_cache() -> dict | None:
7660
+ try:
7661
+ data = json.loads(UPDATE_CHECK_PATH.read_text(encoding="utf-8"))
7662
+ except (OSError, ValueError):
7663
+ return None
7664
+ return data if isinstance(data, dict) else None
7665
+
7666
+
7667
+ def update_available(deployed: str | None, cache: dict | None) -> str | None:
7668
+ """The version to announce, or None.
7669
+
7670
+ None whenever anything is unknown — no cache, a cache whose lookup failed (it
7671
+ carries checked_at but no latest), an unparseable version on either side, or a
7672
+ latest that is not strictly greater. `latest` missing means "asked, no answer",
7673
+ which must not read as "up to date"; it simply says nothing."""
7674
+ if not deployed or not cache:
7675
+ return None
7676
+ latest = cache.get("latest")
7677
+ if not isinstance(latest, str):
7678
+ return None
7679
+ here, there = _version_tuple(deployed), _version_tuple(latest)
7680
+ if here is None or there is None:
7681
+ return None
7682
+ return latest if there > here else None
7683
+
7684
+
7685
+ def update_check_due(cache: dict | None, now: float) -> bool:
7686
+ if os.environ.get("AGENT_BIOS_UPDATE_CHECK") == "0":
7687
+ return False
7688
+ if cache is None:
7689
+ return True
7690
+ checked = cache.get("checked_at")
7691
+ if not isinstance(checked, (int, float)):
7692
+ return True
7693
+ return (now - checked) >= UPDATE_CHECK_INTERVAL_S
7694
+
7695
+
7696
+ def spawn_update_check() -> None:
7697
+ """Fire and forget. Every failure here is silent BY DESIGN — a background
7698
+ refresh that reported its own problems would interrupt a launch to say something
7699
+ the user did not ask for and cannot act on."""
7700
+ installer = shutil.which("agent-bios")
7701
+ if not installer:
7702
+ return
7703
+ try:
7704
+ subprocess.Popen(
7705
+ [installer, "update", "--check"],
7706
+ stdin=subprocess.DEVNULL,
7707
+ stdout=subprocess.DEVNULL,
7708
+ stderr=subprocess.DEVNULL,
7709
+ start_new_session=True,
7710
+ )
7711
+ except OSError:
7712
+ pass
7713
+
7625
7714
 
7626
7715
  def load_corpus_status() -> dict[str, Any] | None:
7627
7716
  """The corpus status projection, or None when the file is unreadable or is not
@@ -7653,7 +7742,12 @@ def version_label() -> str | None:
7653
7742
  if not version:
7654
7743
  return None
7655
7744
  released = info.get("releaseDate")
7656
- return f"agent-bios v{version} · {released}" if released else f"agent-bios v{version}"
7745
+ label = f"agent-bios v{version} · {released}" if released else f"agent-bios v{version}"
7746
+ cache = read_update_cache()
7747
+ if update_check_due(cache, time.time()):
7748
+ spawn_update_check()
7749
+ newer = update_available(version, cache)
7750
+ return f"{label} · update v{newer} available" if newer else label
7657
7751
 
7658
7752
 
7659
7753
  def display_width(text: str) -> int:
@@ -7723,21 +7817,33 @@ def _corpus_summary_lines(status: dict[str, Any] | None) -> list[str]:
7723
7817
  def row(label: str, value: str) -> str:
7724
7818
  return label + " " * max(1, column - display_width(label)) + value
7725
7819
 
7820
+ # `versions is None` is the projection saying "this install cannot know" — a packaged
7821
+ # install has no author version/ledger registry (compose/corpus-state.py). It is not
7822
+ # `[]`, which would mean a checkout whose registry is genuinely empty, and it must not
7823
+ # render as `0 placed · 0 versions`: a fabricated zero is worse than a blank, because
7824
+ # a reader cannot tell it from a real count. The domain rows below stay real, which is
7825
+ # the whole point of degrading rather than withholding the projection.
7826
+ unavailable = status.get("versions") is None
7827
+ summary = status.get("summary") or {}
7726
7828
  current = status.get("current_version", "?")
7727
7829
  latest = status.get("latest_version", "?")
7728
- head = row(labels[0], str(current))
7830
+ # `str(current)` printed the literal "None" on a checkout whose registry exists but
7831
+ # holds no version — pre-existing, and visible the moment the row above it learned to
7832
+ # say "unavailable". A known absence reads as none; an unknown one says so.
7833
+ head = row(labels[0], t("panel.unavailable") if unavailable
7834
+ else str(current) if current else t("panel.layers.none"))
7729
7835
  if status.get("rolled_back_to"):
7730
7836
  head += " " + t("panel.rolled-back").format(latest=latest)
7731
- layers = status.get("summary", {}).get("placed_by_layer", {})
7837
+ layers = summary.get("placed_by_layer", {})
7732
7838
  order = ("global", "guide", "hook", "enforcement", "gate")
7733
7839
  layer_text = " · ".join(
7734
7840
  f"{name} {layers[name]}" for name in order if layers.get(name)
7735
7841
  ) or t("panel.layers.none")
7736
- by_status = status.get("summary", {}).get("by_status", {})
7842
+ by_status = summary.get("by_status", {})
7737
7843
  lines = [
7738
7844
  head,
7739
- row(labels[1], layer_text),
7740
- row(labels[2], t("panel.ledger.value").format(
7845
+ row(labels[1], t("panel.unavailable") if unavailable else layer_text),
7846
+ row(labels[2], t("panel.unavailable") if unavailable else t("panel.ledger.value").format(
7741
7847
  placed=by_status.get("placed", 0),
7742
7848
  incubating=by_status.get("incubating", 0) + by_status.get("incubating-G", 0),
7743
7849
  versions=len(status_list(status.get("versions"))),
@@ -169,6 +169,7 @@
169
169
  "panel.domains.label" = "Domains"
170
170
  "panel.rolled-back" = "(ROLLED BACK; latest is {latest})"
171
171
  "panel.layers.none" = "none"
172
+ "panel.unavailable" = "unavailable in a packaged install"
172
173
  "panel.ledger.value" = "placed {placed} · incubating {incubating} · versions {versions}"
173
174
  "panel.domains.unset" = "(none selected yet)"
174
175
  "panel.last-apply" = "⚠ LAST APPLY {outcome} at {at} — requested: {requested}"
@@ -165,6 +165,7 @@
165
165
  "panel.domains.label" = "ドメイン"
166
166
  "panel.rolled-back" = "(ロールバック済み。最新は {latest})"
167
167
  "panel.layers.none" = "なし"
168
+ "panel.unavailable" = "パッケージ導入では取得不可"
168
169
  "panel.ledger.value" = "配置 {placed} · インキュベート {incubating} · バージョン {versions}"
169
170
  "panel.domains.unset" = "(未選択)"
170
171
  "panel.last-apply" = "⚠ 最終適用 {outcome} @ {at} — 要求: {requested}"
@@ -165,6 +165,7 @@
165
165
  "panel.domains.label" = "도메인"
166
166
  "panel.rolled-back" = "(롤백됨. 최신은 {latest})"
167
167
  "panel.layers.none" = "없음"
168
+ "panel.unavailable" = "패키지 설치에서는 확인 불가"
168
169
  "panel.ledger.value" = "배치 {placed} · 인큐베이팅 {incubating} · 버전 {versions}"
169
170
  "panel.domains.unset" = "(아직 선택 안 함)"
170
171
  "panel.last-apply" = "⚠ 마지막 적용 {outcome} @ {at} — 요청: {requested}"
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-bios",
3
- "version": "0.14.0",
4
- "releaseDate": "2026-08-31",
3
+ "version": "0.15.0",
4
+ "releaseDate": "2026-09-01",
5
5
  "description": "A thin, low-level instruction layer for LLM CLI agents: one set of principles and behavior whichever model you run. Deploys into $HOME by copy via an explicit `agent-bios install`.",
6
6
  "bin": {
7
7
  "agent-bios": "install.sh"
@@ -29,6 +29,8 @@
29
29
  "compose/assemble.py",
30
30
  "compose/prune-backups.py",
31
31
  "compose/check-domains.py",
32
+ "compose/corpus-state.py",
33
+ "compose/write-update-cache.py",
32
34
  "compose/canary.sh",
33
35
  "launch/check-prompting-targets.sh",
34
36
  "learn/check-learning.py",
package/provenance.json CHANGED
@@ -1 +1 @@
1
- {"commit":"db5e745b6f832a02b43556889c14ce1396688177","committedAt":"2026-08-31T13:13:32+09:00","dirty":false}
1
+ {"commit":"ff09dd50ec264eee660513f8a6768d3481a4e9c8","committedAt":"2026-09-01T14:37:00+09:00","dirty":false}