agent-bios 0.11.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.
@@ -61,6 +61,34 @@ HOSTS = {
61
61
  CLAUDE_IMPORT_LINE = "@personal/learnings.md"
62
62
  CLAUDE_CENTRAL_IMPORT = "@central/bundle.md"
63
63
 
64
+
65
+ def import_line_index(body, directive):
66
+ """Index of the line where `directive` is an ACTIVE import, or None.
67
+
68
+ Raw containment is not this question. `@personal/learnings.md` inside a fenced
69
+ example, or named in a sentence, loads nothing — and reading it as an import made
70
+ two different files claim a file was wired when it was not: capture reported the
71
+ entry `present` and wrote nothing, and migrate-learnings read a fenced central
72
+ import as evidence the corpus was loaded, which is half of what authorizes deleting
73
+ a user's personal copy. An import is a line whose whole content is the directive.
74
+
75
+ Fences are tracked rather than stripped so the index is an index into `body`'s own
76
+ lines, which is what the insertion point needs. Both ``` and ~~~ open and close.
77
+ """
78
+ fenced = False
79
+ for index, line in enumerate(body.splitlines()):
80
+ stripped = line.strip()
81
+ if stripped.startswith("```") or stripped.startswith("~~~"):
82
+ fenced = not fenced
83
+ continue
84
+ if not fenced and stripped == directive:
85
+ return index
86
+ return None
87
+
88
+
89
+ def has_active_import(body, directive):
90
+ return import_line_index(body, directive) is not None
91
+
64
92
  # Codex AGENTS.md markers. The central pair is owned by compose/assemble.py
65
93
  # (kept in sync here); the personal-learnings pair is this tool's own region,
66
94
  # placed outside the central pair so re-assembly preserves it.
@@ -220,13 +248,16 @@ def ensure_claude_import(home, dry):
220
248
  entry.write_text(f"# CLAUDE.md\n\n{CLAUDE_IMPORT_LINE}\n", encoding="utf-8")
221
249
  return "created"
222
250
  body = entry.read_text(encoding="utf-8")
223
- if CLAUDE_IMPORT_LINE in body:
251
+ if has_active_import(body, CLAUDE_IMPORT_LINE):
224
252
  return "present"
225
253
  if dry:
226
254
  print(f" [dry] insert '{CLAUDE_IMPORT_LINE}' into {entry}")
227
255
  return "inserted"
228
256
  lines = body.splitlines(keepends=True)
229
- idx = next((i for i, ln in enumerate(lines) if CLAUDE_CENTRAL_IMPORT in ln), 0)
257
+ # Anchored to the ACTIVE central import for the same reason: matching a fenced one
258
+ # would insert this import inside that fence, where it loads nothing either.
259
+ central = import_line_index(body, CLAUDE_CENTRAL_IMPORT)
260
+ idx = central if central is not None else 0
230
261
  backup(entry)
231
262
  lines.insert(idx + 1, CLAUDE_IMPORT_LINE + "\n")
232
263
  entry.write_text("".join(lines), encoding="utf-8")
@@ -543,6 +574,30 @@ def _self_test():
543
574
  "\n" not in nl["lesson"] and "\r" not in nl["lesson"]
544
575
  and "line one line two line three" == nl["lesson"]))
545
576
 
577
+ # 10) An import is a LINE, not a substring. Reading a fenced example or a sentence as
578
+ # an active import made capture report the entry `present` and write nothing, so
579
+ # the learning it had just saved would never load. Each negative is paired with the
580
+ # positive that separates it: a guard that answered False to everything would
581
+ # satisfy the first three rows and fail the fourth.
582
+ real = f"# CLAUDE.md\n{CLAUDE_CENTRAL_IMPORT}\n{CLAUDE_IMPORT_LINE}\n"
583
+ for name, body, want in (
584
+ ("fenced ``` example is not an import", f"# e\n```\n{CLAUDE_IMPORT_LINE}\n```\n", False),
585
+ ("fenced ~~~ example is not an import", f"# e\n~~~md\n{CLAUDE_IMPORT_LINE}\n~~~\n", False),
586
+ ("a sentence naming the path is not an import",
587
+ f"# e\nWe import {CLAUDE_IMPORT_LINE} from here.\n", False),
588
+ ("a standalone directive line IS an import", real, True),
589
+ ("indentation and trailing space still import",
590
+ f"# e\n {CLAUDE_IMPORT_LINE} \n", True),
591
+ ):
592
+ checks.append((f"import detection: {name}",
593
+ has_active_import(body, CLAUDE_IMPORT_LINE) is want))
594
+ # The insertion point follows the same rule, or the new import lands inside the fence.
595
+ checks.append(("insertion anchors on an ACTIVE central import",
596
+ import_line_index(real, CLAUDE_CENTRAL_IMPORT) == 1))
597
+ checks.append(("a fenced central import anchors nothing",
598
+ import_line_index(f"# e\n```\n{CLAUDE_CENTRAL_IMPORT}\n```\n",
599
+ CLAUDE_CENTRAL_IMPORT) is None))
600
+
546
601
  failed = [name for name, ok in checks if not ok]
547
602
  if failed:
548
603
  for name in failed:
@@ -42,6 +42,7 @@ import time
42
42
 
43
43
  sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "compose"))
44
44
  import pkgid # noqa: E402 (package-identity primitive owned by compose/)
45
+ import assemble # noqa: E402 (owns the atomic-replace primitive both packages write with)
45
46
 
46
47
  REPO = pathlib.Path(__file__).resolve().parent.parent
47
48
  # Same home compose/corpus-state.py uses; the canary writes its activation proof here.
@@ -75,9 +76,21 @@ def load_collect():
75
76
 
76
77
 
77
78
  def load_manifest(path=MANIFEST):
79
+ # "Manifest absent/empty -> no-op" is this module's stated contract, and an existing
80
+ # zero-byte file is the empty case it was missing: json.loads("") raised out of the
81
+ # update path as a traceback. Whitespace-only reads as empty; anything else that
82
+ # will not parse is a REAL manifest that is broken, and says so through die().
78
83
  if not path.is_file():
79
84
  return []
80
- data = json.loads(path.read_text(encoding="utf-8"))
85
+ raw = path.read_text(encoding="utf-8")
86
+ if not raw.strip():
87
+ return []
88
+ try:
89
+ data = json.loads(raw)
90
+ except json.JSONDecodeError as exc:
91
+ die(f"manifest is not valid JSON: {path} ({exc})")
92
+ if not isinstance(data, dict):
93
+ die(f"manifest is not an object: {path}")
81
94
  return [p for p in data.get("promotions", [])
82
95
  if isinstance(p, dict) and isinstance(p.get("learning_id"), str)]
83
96
 
@@ -134,6 +147,18 @@ def corpus_text(path, collect=None):
134
147
  return body
135
148
 
136
149
 
150
+ # A placement is one of two kinds and they are verified differently, so the kind has to
151
+ # be readable. It is derived from the anchor rather than carried as a new manifest field
152
+ # because the two shapes do not overlap: a whole-file anchor is a bare filename, and a
153
+ # bullet anchor is a phrase lifted from the rule, which always has spaces in it.
154
+ FILE_ANCHOR_SUFFIXES = (".md", ".py", ".toml", ".json", ".sh")
155
+
156
+
157
+ def anchor_names_a_file(anchor):
158
+ return (not any(ch.isspace() for ch in anchor)
159
+ and anchor.endswith(FILE_ANCHOR_SUFFIXES))
160
+
161
+
137
162
  def placed_here(home, anchor, collect=None):
138
163
  """Is the promoted item REALLY in this user's deployed corpus?
139
164
 
@@ -146,9 +171,13 @@ def placed_here(home, anchor, collect=None):
146
171
  if not isinstance(anchor, str) or not anchor:
147
172
  return False
148
173
  texts, dirs = corpus_surfaces(home)
149
- for d in dirs:
150
- if (d / anchor).is_file():
151
- return True
174
+ if anchor_names_a_file(anchor):
175
+ # A whole-file placement is proven by the deployed FILE and by nothing else. It
176
+ # used to fall through to the text scan below, where any corpus document that
177
+ # merely NAMES the guide counted as proof the guide was installed — and the
178
+ # corpus names its guides constantly. This is the reachable case rather than the
179
+ # hypothetical one: the live manifest's only anchor is `tooling-gotchas.md`.
180
+ return any((d / anchor).is_file() for d in dirs)
152
181
  for f in texts:
153
182
  if f.is_file() and anchor in corpus_text(f, collect):
154
183
  return True
@@ -160,13 +189,17 @@ def backup(path):
160
189
 
161
190
 
162
191
  def atomic_write(path, text):
163
- """Back up, then replace via a temp file + os.replace so a crash mid-write
164
- can never truncate the durable personal file (reuses collect-learning's
165
- write discipline)."""
192
+ """Back up, then replace atomically so a crash mid-write can never truncate the
193
+ durable personal file.
194
+
195
+ The replace itself is compose/assemble.py's — that module needed the same discipline
196
+ for the user's settings.json and AGENTS.md, and a four-line primitive written twice is
197
+ two places for the next `os.replace` subtlety to be got right in only one of them. The
198
+ BACKUP stays here: its `.bak-migrate-<ts>` name is what prune-backups.py matches to
199
+ know the copy is ours, and assemble's writes carry a different one.
200
+ """
166
201
  backup(path)
167
- tmp = path.with_suffix(path.suffix + ".tmp")
168
- tmp.write_text(text, encoding="utf-8")
169
- os.replace(tmp, path)
202
+ assemble.replace_atomically(path, text)
170
203
 
171
204
 
172
205
  def prune_jsonl(jsonl, remove_ids, dry):
@@ -215,16 +248,23 @@ def prune_claude_prose(home, remove_ids, dry):
215
248
 
216
249
 
217
250
  def prune_codex_prose(home, remove_ids, collect, dry):
251
+ """Bullets removed, or None when the personal region is MALFORMED.
252
+
253
+ None rather than 0, because the caller has to tell "nothing matched" from "this file
254
+ cannot be pruned at all". Both used to read as 0, and the jsonl prune ran anyway —
255
+ deleting the durable record out from under a bullet still on screen, which is the
256
+ opposite of the no-op the malformed case promises.
257
+ """
218
258
  agents = home / "AGENTS.md"
219
259
  if not agents.is_file():
220
- return 0
260
+ return 0 # no personal prose here at all — nothing to strand
221
261
  body = agents.read_text(encoding="utf-8")
222
262
  start, end = collect.PERSONAL_START, collect.PERSONAL_END
223
263
  if start not in body:
224
- return 0
264
+ return 0 # region never opened — same as above
225
265
  pre, rest = body.split(start, 1)
226
266
  if end not in rest: # malformed (missing, or END before START) → touch nothing
227
- return 0
267
+ return None
228
268
  region, post = rest.split(end, 1)
229
269
  new_region, removed = prune_bullets(region, remove_ids)
230
270
  if removed and not dry:
@@ -249,7 +289,15 @@ def claude_corpus_loaded(home, state_dir=None):
249
289
  redundant, a wrong removal is data loss.
250
290
  """
251
291
  entry = home / "CLAUDE.md"
252
- if not (entry.is_file() and collect_central_import() in entry.read_text(encoding="utf-8")):
292
+ if not entry.is_file():
293
+ return False
294
+ # ACTIVE import, not raw containment. The paragraph above already says a fenced or
295
+ # prose occurrence loads nothing; the test did not implement that, so the necessary
296
+ # half of the authorization passed on text that imports nothing. The proof below is
297
+ # bound to the bundle's rev and not to this file, so an entry edited after a passing
298
+ # canary keeps a matching proof — which is exactly when the weak half decides.
299
+ if not load_collect().has_active_import(
300
+ entry.read_text(encoding="utf-8"), collect_central_import()):
253
301
  return False
254
302
  bundle = home / "central" / "bundle.md"
255
303
  proof = (state_dir or STATE_DIR) / "activation.txt"
@@ -316,6 +364,15 @@ def migrate(home, host, promotions, in_bundle, collect, dry=False, corpus_loaded
316
364
  # would strand the prose bullet forever after a jsonl-only success (Review F4).
317
365
  if host == "codex":
318
366
  prose_removed = prune_codex_prose(home, remove_ids, collect, dry)
367
+ if prose_removed is None:
368
+ # The malformed-marker contract is "touch nothing", and it used to hold for
369
+ # exactly one of the two representations. A record and the bullet keyed by it
370
+ # move together or not at all.
371
+ return {"removed": 0, "jsonl_removed": 0, "prose_removed": 0,
372
+ "kept_not_in_bundle": kept_not_in_bundle,
373
+ "kept_not_placed": kept_not_placed,
374
+ "kept_foreign_package": kept_foreign_pkg,
375
+ "skipped": "personal-region-malformed"}
319
376
  else:
320
377
  prose_removed = prune_claude_prose(home, remove_ids, dry)
321
378
  jsonl_removed = prune_jsonl(jsonl, remove_ids, dry)
@@ -545,6 +602,37 @@ def _self_test():
545
602
  (fenced / "CLAUDE.md").write_text("# CLAUDE.md\n```\n@central/bundle.md\n```\n", encoding="utf-8")
546
603
  checks.append(("F2: import string with no activation proof -> KEEP",
547
604
  claude_corpus_loaded(fenced, state_dir=fenced) is False))
605
+ # …and the same file WITH a matching proof, which is the case the line above cannot
606
+ # separate: it passes whether the import test is honest or not, because the missing
607
+ # proof decides it either way. The proof is bound to the bundle's rev and not to this
608
+ # file, so an entry edited after a passing canary keeps one — and then the import test
609
+ # is the only thing left standing between a fenced example and a delete.
610
+ (fenced / "central").mkdir(parents=True, exist_ok=True)
611
+ (fenced / "central" / "bundle.md").write_text(
612
+ "# bundle\nagent-bios-bundle-rev: deadbeef\n", encoding="utf-8")
613
+ (fenced / "activation.txt").write_text("agent-bios-bundle-rev: deadbeef\n", encoding="utf-8")
614
+ checks.append(("F2: FENCED import + matching proof -> still KEEP",
615
+ claude_corpus_loaded(fenced, state_dir=fenced) is False))
616
+ (fenced / "CLAUDE.md").write_text("# CLAUDE.md\n@central/bundle.md\n", encoding="utf-8")
617
+ checks.append(("F2: the same proof with a REAL import -> loaded",
618
+ claude_corpus_loaded(fenced, state_dir=fenced) is True))
619
+
620
+ # placed_here verifies a whole-file placement as a FILE and a bullet anchor as TEXT.
621
+ # They used to be OR'd, so any corpus document that merely NAMED a guide authorized
622
+ # deleting the personal copy of a learning placed in it — and the live manifest's one
623
+ # anchor is `tooling-gotchas.md`, which the entry file names in prose.
624
+ mention = pathlib.Path(tempfile.mkdtemp(prefix="migrate-anchor-"))
625
+ (mention / "guides").mkdir(parents=True)
626
+ (mention / "CLAUDE.md").write_text(
627
+ "# CLAUDE.md\nsee guides/tooling-gotchas.md for the traps\nthe bullet text itself\n",
628
+ encoding="utf-8")
629
+ checks.append(("anchor: a named-but-absent guide is NOT placed",
630
+ placed_here(mention, "tooling-gotchas.md") is False))
631
+ checks.append(("anchor: a phrase anchor still matches corpus TEXT",
632
+ placed_here(mention, "the bullet text itself") is True))
633
+ (mention / "guides" / "tooling-gotchas.md").write_text("# guide\n", encoding="utf-8")
634
+ checks.append(("anchor: the deployed guide file IS placed",
635
+ placed_here(mention, "tooling-gotchas.md") is True))
548
636
  # (The old "full install is always loaded" case is gone with full mode: there is no shape
549
637
  # whose corpus loads without the entry import, so nothing is exempt from the proof.)
550
638
 
@@ -557,14 +645,50 @@ def _self_test():
557
645
  _, r_quoted = prune_bullets(twin, {"aaaa0000-0000-0000-0000-000000000000"})
558
646
  checks.append(("F5: trailing id removed, quoted id ignored", r_real == 1 and r_quoted == 0))
559
647
 
560
- # 7) malformed codex markers (END before START) -> no-op, no corruption.
648
+ # 7) malformed codex markers (END before START) -> no-op, no corruption. "No-op" is a
649
+ # claim about BOTH representations: reporting 0 removed said the same thing as
650
+ # "nothing matched", so migrate() went on to delete the durable jsonl record while
651
+ # the visible bullet stayed on screen. None is the signal that distinguishes them.
561
652
  bad = pathlib.Path(tempfile.mkdtemp(prefix="migrate-badcodex-"))
653
+ (bad / "personal").mkdir(parents=True)
562
654
  agents = bad / "AGENTS.md"
563
655
  agents.write_text(f"x {collect.PERSONAL_END} y {collect.PERSONAL_START} z", encoding="utf-8")
564
656
  before = agents.read_text(encoding="utf-8")
565
657
  rem = prune_codex_prose(bad, {"anything"}, collect, dry=False)
566
658
  checks.append(("malformed codex markers -> no-op",
567
- rem == 0 and agents.read_text(encoding="utf-8") == before))
659
+ rem is None and agents.read_text(encoding="utf-8") == before))
660
+ bad_rec = rec(L["bb"], "builder-base")
661
+ (bad / "personal" / "learnings.jsonl").write_text(
662
+ json.dumps(bad_rec, ensure_ascii=False) + "\n", encoding="utf-8")
663
+ s = migrate(bad, "codex", promos, make_in_bundle(ALL_DOMAINS), collect)
664
+ checks.append(("malformed markers -> the jsonl record survives too",
665
+ s.get("skipped") == "personal-region-malformed"
666
+ and s["removed"] == 0 and local_ids(bad) == {L["bb"]}))
667
+ # The control for that: a WELL-FORMED region under the same call really does delete,
668
+ # so the assertion above is not passing because migrate() stopped working.
669
+ ok_home = pathlib.Path(tempfile.mkdtemp(prefix="migrate-okcodex-"))
670
+ (ok_home / "personal").mkdir(parents=True)
671
+ (ok_home / "AGENTS.md").write_text(f"# AGENTS.md\n- {A['bb']}\n", encoding="utf-8")
672
+ with open(ok_home / "personal" / "learnings.jsonl", "w", encoding="utf-8") as f:
673
+ r = rec(L["bb"], "builder-base")
674
+ f.write(json.dumps(r, ensure_ascii=False) + "\n")
675
+ collect.apply_codex(ok_home, collect.prose_bullet(r), dry=False)
676
+ s = migrate(ok_home, "codex", promos, make_in_bundle(ALL_DOMAINS), collect)
677
+ checks.append(("well-formed markers -> both representations go",
678
+ s.get("skipped") is None and s["removed"] == 1 and not local_ids(ok_home)))
679
+
680
+ # 8) "Manifest absent/empty -> no-op" covers an existing zero-byte file, which used to
681
+ # reach json.loads("") and raise out of the update path.
682
+ empty_dir = pathlib.Path(tempfile.mkdtemp(prefix="migrate-manifest-"))
683
+ empty = empty_dir / "promotions.json"
684
+ empty.write_text("", encoding="utf-8")
685
+ checks.append(("empty manifest file -> no-op", load_manifest(empty) == []))
686
+ (empty_dir / "whitespace.json").write_text(" \n\t\n", encoding="utf-8")
687
+ checks.append(("whitespace-only manifest -> no-op",
688
+ load_manifest(empty_dir / "whitespace.json") == []))
689
+ valid = empty_dir / "valid.json"
690
+ valid.write_text('{"version": 2, "promotions": [{"learning_id": "x"}]}', encoding="utf-8")
691
+ checks.append(("a real manifest still loads", len(load_manifest(valid)) == 1))
568
692
 
569
693
  failed = [n for n, ok in checks if not ok]
570
694
  if failed:
package/learn/redact.py CHANGED
@@ -23,7 +23,14 @@ import sys
23
23
 
24
24
  # Conservative: kill obvious secrets/identifiers, keep prose.
25
25
  SECRET_RE = [
26
- re.compile(r'(?i)(api[_-]?key|secret|token|password|authorization|bearer)\s*[:=]\s*\S+'),
26
+ # The optional scheme word is what makes the HEADER form work. `\S+` alone stopped at
27
+ # `Bearer` and published the credential after it: `Authorization: Bearer <token>`
28
+ # redacted to `<REDACTED> <token>`, which is the single most common way a secret
29
+ # appears in a developer's terminal, and this floor is what stands between a captured
30
+ # learning and an upload. The `=` form was always fully covered — the two spellings
31
+ # of one header disagreeing is the defect, not the strictness.
32
+ re.compile(r'(?i)(api[_-]?key|secret|token|password|authorization|bearer)\s*[:=]\s*'
33
+ r'(?:(?:bearer|basic|digest|token)\s+)?\S+'),
27
34
  re.compile(r'sk-[A-Za-z0-9_-]{16,}'),
28
35
  re.compile(r'gh[pousr]_[A-Za-z0-9]{20,}'),
29
36
  re.compile(r'eyJ[A-Za-z0-9_-]{20,}\.'), # JWT-ish
@@ -48,11 +55,13 @@ def _self_test():
48
55
  any miss so the umbrella gate catches a regression in the floor."""
49
56
  must_redact = [
50
57
  ("api_key value", "my api_key=sk_live_ABCDEF0123456789 leaked", "sk_live_"),
51
- # key[:=]value form. NB the "Authorization: Bearer <token>" space/scheme
52
- # form is only partially caught here (up to the scheme word) unless the
53
- # token itself matches a shape below (JWT/sk-/ghp_/…) — a known floor
54
- # boundary carried over from the heavy flow, not tightened in Phase 3.
55
58
  ("authorization value", "set authorization=topsecretvalue123 here", "topsecretvalue123"),
59
+ # Both spellings of the SAME header, because only one of them used to work: the
60
+ # `=` form was fully redacted while the wire form published its credential.
61
+ ("authorization header form", "Authorization: Bearer opaque0123456789abcdef", "opaque0123456789abcdef"),
62
+ ("authorization header lowercase", "authorization: bearer abcdef0123456789xyz", "abcdef0123456789xyz"),
63
+ ("authorization header inside a command",
64
+ "curl -H 'Authorization: Bearer wire9876543210value'", "wire9876543210value"),
56
65
  ("openai key", "used sk-abcdefghij0123456789 here", "sk-abcdefghij"),
57
66
  ("github token", "token ghp_abcdefghij0123456789klmn committed", "ghp_"),
58
67
  ("aws key id", "key AKIA0123456789ABCD in env", "AKIA0123456789"),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-bios",
3
- "version": "0.11.0",
4
- "releaseDate": "2026-08-10",
3
+ "version": "0.12.0",
4
+ "releaseDate": "2026-08-18",
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"
@@ -12,6 +12,7 @@
12
12
  "claude/guides/",
13
13
  "claude/hooks/",
14
14
  "claude/agents/",
15
+ "claude/skills/",
15
16
  "codex/AGENTS.md",
16
17
  "codex/guides/",
17
18
  "codex/agents/",
package/provenance.json CHANGED
@@ -1 +1 @@
1
- {"commit":"7726933922b63dc7d3749e6aa971fb395a26cfad","committedAt":"2026-08-10T16:38:38+09:00","dirty":false}
1
+ {"commit":"bfcc56715fb031564e370ce236739e4208b7c513","committedAt":"2026-08-18T17:23:41+09:00","dirty":false}