agent-bios 0.9.8 → 0.9.9

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.
Files changed (49) hide show
  1. package/DEPENDENCIES.md +19 -19
  2. package/README.md +34 -11
  3. package/claude/CLAUDE.md +2 -1
  4. package/claude/guides/claude-prompting.md +1 -1
  5. package/claude/guides/cli-multi-model-workflow.md +19 -1
  6. package/claude/guides/coding-staged-workflow.md +32 -0
  7. package/claude/guides/gpt-prompting.md +1 -1
  8. package/claude/guides/learning-flow.md +5 -5
  9. package/claude/guides/llm-capability-boundary.md +6 -1
  10. package/claude/guides/session-distill-workflow.md +19 -9
  11. package/claude/guides/tooling-gotchas.md +16 -0
  12. package/claude/hooks/__pycache__/tooling-gotchas-hook.cpython-314.pyc +0 -0
  13. package/claude/hooks/tooling-gotchas-hook.py +7 -0
  14. package/codex/AGENTS.md +2 -1
  15. package/codex/guides/claude-prompting.md +1 -1
  16. package/codex/guides/cli-multi-model-workflow.md +19 -1
  17. package/codex/guides/coding-staged-workflow.md +32 -0
  18. package/codex/guides/gpt-prompting.md +1 -1
  19. package/codex/guides/learning-flow.md +5 -5
  20. package/codex/guides/llm-capability-boundary.md +6 -1
  21. package/codex/guides/session-distill-workflow.md +19 -9
  22. package/codex/guides/tooling-gotchas.md +16 -0
  23. package/{scripts → compose}/assemble.py +184 -16
  24. package/{scripts → compose}/canary.sh +14 -5
  25. package/{scripts → compose}/check-domains.py +9 -3
  26. package/{config → compose}/domains.json +1 -0
  27. package/{scripts → compose}/pkgid.py +8 -1
  28. package/compose/prune-backups.py +204 -0
  29. package/{scripts → compose}/register-hooks.py +3 -3
  30. package/{scripts/install.sh → install.sh} +401 -104
  31. package/launch/agent-launch.py +5294 -0
  32. package/launch/agent-launch.toml +376 -0
  33. package/{scripts → launch}/check-prompting-targets.sh +1 -1
  34. package/{scripts → launch}/provision-venv.sh +1 -1
  35. package/{scripts → learn}/check-learning.py +7 -7
  36. package/{scripts → learn}/collect-learning.py +10 -10
  37. package/{config → learn}/learning.schema.json +3 -3
  38. package/{scripts → learn}/migrate-learnings.py +95 -54
  39. package/{scripts → learn}/redact.py +4 -4
  40. package/package.json +25 -24
  41. package/wrappers/claude-run.sh +162 -0
  42. package/{scripts → wrappers}/codex-run.sh +62 -6
  43. package/config/agent-launch.toml +0 -143
  44. package/scripts/agent-launch.py +0 -2350
  45. package/scripts/check-parity.sh +0 -2003
  46. /package/{shell → launch}/agent-launch.zsh +0 -0
  47. /package/{config → learn}/promotions.json +0 -0
  48. /package/{scripts/session-cost.py → session-cost.py} +0 -0
  49. /package/{scripts → wrappers}/codex-helm.sh +0 -0
@@ -60,6 +60,38 @@ Per-domain menus for the global Verification Discipline loop; pick the narrowest
60
60
  - Branch/version test builds against real data: explicitly separate every state sink the app touches (files, DB, OS-level stores that ignore env overrides), confirm the launch path propagates the isolation to child processes, and back up live data before the first run — a mismatched schema that drops unknown fields on write is data loss, not a no-op.
61
61
  - Irreversible capture switches: when activation itself has unreproducible cost (a capture window that cannot be replayed), prove the downstream consumption path against existing samples before enabling — reversibility of the code path alone is not enough.
62
62
 
63
+ ### Deriving the case space
64
+
65
+ A check has two authored halves, and they rot differently. The **verdict** — what the
66
+ answer should be — rots by encoding a belief that was wrong from the start. The
67
+ **space** — which cases exist — rots by staying still while the thing it covers grows.
68
+ Recording the verdict is common practice; deriving the space is the half usually left
69
+ hand-written, and a suite can have every expectation derived and still cover a set
70
+ someone typed once.
71
+
72
+ - Record the verdict, do not type it. Run the real path and store what came back;
73
+ drift then shows as a diff instead of as a belief someone has to re-justify.
74
+ - Enumerate the space from the artifact that defines it — the config's entries, the
75
+ schema's fields, the router's routes, the installer's call sites. Adding one there
76
+ should widen coverage with no edit here.
77
+ - Derive the exemption rule too. If some cases legitimately have no answer, decide that
78
+ from a property the artifact carries, never from a list of names: the list is the
79
+ authored space coming back through a side door, and it absorbs the regression where
80
+ a case that should have an answer stops having one.
81
+ - Dedupe on the tuple that actually determines the outcome, and report how many
82
+ collapsed. A coverage count that hides its own truncation reads as more than it is.
83
+ - Split by cost, not by space. When the real path needs money, credentials, or a
84
+ network, run a cheap stand-in on every commit and the real one on demand — both from
85
+ the **same enumeration**, so the two can never disagree about which cases exist.
86
+ - Derivation moves authorship rather than removing it: the extractor and the invariants
87
+ are still written by hand. Give them a negative control, or the derived suite is just
88
+ a larger unfalsifiable one.
89
+ - Planting a violation to prove a control fires is a write into the working tree, and
90
+ the restore is not atomic with it: if the probe can time out, abort, or be
91
+ interrupted, a restore sitting after it never runs and the plant survives into a
92
+ commit. Plant in a copy where the shape allows it, and when it must be in place, snapshot
93
+ first and restore from the snapshot as its own step rather than trusting the probe to finish.
94
+
63
95
  ## Stop Conditions
64
96
 
65
97
  - If the issue boundary expands compared with the previous review, stop and ask the user to choose redesign/rework or continuing the current iteration.
@@ -114,5 +114,5 @@ Derived from the vendor's published prompting guidance for the `targets` models
114
114
  above. When a `targets` model changes, re-derive this guide from current vendor
115
115
  guidance rather than editing around the old rules — prompting guidance is
116
116
  version-bound, and the previous generation's advice inverted on this one.
117
- `scripts/check-prompting-targets.sh` fails when the launch config binds a model
117
+ `launch/check-prompting-targets.sh` fails when the launch config binds a model
118
118
  this guide does not list.
@@ -18,9 +18,9 @@ The **light**, per-user, single-session capture flow: turn a lesson from the
18
18
  current session into a **learning** (prose + a JSON record) that (a) applies to
19
19
  the user's own next session and (b) reaches the org for curation. This is the
20
20
  counterpart of the **heavy** session-distill pipeline (`distill!`), which mines
21
- many sessions and is curator/power-user only. Terms follow the agent-bios
22
- `LEXICON.md`; the full routing framework is
23
- `design/session-distill/PLACEMENT-FRAMEWORK.md` (agent-bios repo).
21
+ many sessions and is curator/power-user only. Terminology and the full routing
22
+ framework are maintained in the agent-bios repo; the criteria this flow applies
23
+ are stated below.
24
24
 
25
25
  Defer to the preset mission: if this session runs the **Session distill** preset
26
26
  (trigger `distill!`), that mission owns capture — do not also run this flow.
@@ -62,7 +62,7 @@ still-valid candidates are surfaced for the user's approval.
62
62
 
63
63
  ## Domain tagging (the curation join key)
64
64
 
65
- Suggest a `domain` from the registered vocabulary in `config/domains.json`
65
+ Suggest a `domain` from the registered vocabulary in `compose/domains.json`
66
66
  (domain keys for domain-specific lessons, or a tier name like `core`/`infra`
67
67
  for a genuinely cross-cutting lesson); the user **confirms**. If unsure, use
68
68
  `unclassified` (never blocks capture — the curator assigns later). If no
@@ -86,7 +86,7 @@ match your host):
86
86
  | agent-bios learn --host <claude|codex>
87
87
 
88
88
  The script (capability boundary) owns `learning_id` / `created` / `schema_version`,
89
- validates against `config/learning.schema.json`, logs the JSON record, and writes
89
+ validates against `learn/learning.schema.json`, logs the JSON record, and writes
90
90
  the lesson prose where THIS host loads it next session:
91
91
  - **Claude**: appended to the automation-owned personal learnings file, pulled in
92
92
  by the entry file's `@personal/learnings.md` import.
@@ -204,7 +204,12 @@ Use it per field or operation, not as a blanket replacement for LLM judgment.
204
204
  Use this procedure when designing a new LLM-assisted artifact or revising an
205
205
  existing one.
206
206
 
207
- 1. Identify the canonical artifact and downstream consumers.
207
+ 1. Identify the canonical artifact and downstream consumers. When the consumer already
208
+ exists, read its **acceptance predicate**, not only its schema: the schema says which
209
+ fields may appear, and the predicate says which combinations are credited. A producer
210
+ designed against the schema alone can emit records that are valid and never
211
+ accepted — one record per event where the consumer judges one record per subject is
212
+ the common shape of this, and it survives every field-level check.
208
213
  2. Split fields into semantic fields, deterministic fields, provenance fields,
209
214
  and side-effect operations.
210
215
  3. Assign each field or operation one primary authority.
@@ -2,13 +2,14 @@
2
2
  guide_id: session-distill-workflow
3
3
  language: en
4
4
  status: active
5
+ audience: author
5
6
  use_when:
6
7
  - a session was launched with the Session distill preset (mission-injected)
7
8
  - the launcher nudge says enough sessions accumulated for a mining window
8
9
  - mining local Claude/Codex sessions for learnings absent from the corpus
9
10
  - promoting, incubating, or retiring items in the session-distill ledger
10
11
  core_rules:
11
- - the ledger and HANDOFF are the SSOT; read them before touching the pipeline
12
+ - the ledger is the SSOT for state; read it before touching the pipeline
12
13
  - placement follows PLACEMENT-FRAMEWORK.md, never ad-hoc judgment
13
14
  - every promotion passes an explicit user-approval gate
14
15
  - global growth per round is hard-capped (~500 tokens) by a measured gate
@@ -17,19 +18,28 @@ core_rules:
17
18
 
18
19
  # Session-Distill Workflow
19
20
 
21
+ **Requires an agent-bios checkout.** This runbook edits the corpus itself, so it
22
+ names repo paths and runs repo scripts. On a packaged install those do not exist:
23
+ say so and stop rather than following steps you cannot execute.
24
+
20
25
  Runbook for a session-distill run: mine recent main-context sessions,
21
26
  verify candidates, place them through the framework, and apply with the user.
22
27
  Everything durable lives in the agent-bios repo.
23
28
 
24
29
  ## Read first (SSOT)
25
30
 
26
- 1. `design/session-distill/HANDOFF.md` — initiative state, prior decisions,
27
- open threads. 2. `design/session-distill/PLACEMENT-FRAMEWORK.md` the
28
- placement authority (typology A–G, layers, admission bars, lifecycle).
29
- 3. `design/session-distill/ledger.json` cumulative item states
30
- (placed / incubating / absorbed / retired) with provenance.
31
+ 1. `design/session-distill/ledger.json` — the initiative's state. Every item
32
+ carries its status (placed / incubating / incubating-G / absorbed /
33
+ adopted-no-text), strength, and provenance, so what is open, what was
34
+ promoted, and what is still incubating are all queries against this file.
35
+ Read state here and nowhere else: a count or a status written into prose is
36
+ correct on the day it is written and silently wrong afterwards.
37
+ 2. `design/session-distill/versions.json` — which closed mining window maps to
38
+ which commit, and therefore what a rollback restores.
39
+ 3. `design/session-distill/PLACEMENT-FRAMEWORK.md` — the placement authority
40
+ (typology A–G, layers, admission bars, lifecycle).
31
41
 
32
- ## Stage 1 — Mine (pipeline in `scripts/session-distill/`)
42
+ ## Stage 1 — Mine (pipeline in `session-distill/`)
33
43
 
34
44
  Run in order; each stage reads the previous stage's `out/`:
35
45
 
@@ -73,7 +83,7 @@ Run in order; each stage reads the previous stage's `out/`:
73
83
  stdout/stderr channel contracts) → codex/ + ko/ mirrors.
74
84
  - Verify per layer, not just by diff: enforcement/gate fixture tests
75
85
  (non-vacuous — known-bad must fire), hook trigger positive/negative sets,
76
- `scripts/check-parity.sh` exit 0 unpiped, prompting-target gate, then
86
+ `gates/check-parity.sh` exit 0 unpiped, prompting-target gate, then
77
87
  `agent-bios install` to activate and re-verify.
78
88
 
79
89
  ## Stage 4 — G-pass (principles, not directives)
@@ -95,6 +105,6 @@ Run in order; each stage reads the previous stage's `out/`:
95
105
  3. Register the corpus version: append {version = window end, commit = the
96
106
  corpus-close commit} to `design/session-distill/versions.json` — this is
97
107
  what the launcher's Versions & rollback screen offers — then run
98
- `python3 scripts/session-distill/update-state.py --window-end <date>`
108
+ `python3 session-distill/update-state.py --window-end <date>`
99
109
  (nudge baseline) and `corpus-state.py project` (launcher status panel).
100
110
  4. Merge the branch, push, and confirm deployed state (`agent-bios verify`).
@@ -62,6 +62,15 @@ depends on it, pin it explicitly instead of trusting the environment.
62
62
  early-exit consumers (`cmd | head -1` → SIGPIPE 141), so it is a per-command
63
63
  choice, not a global default. Does not apply when the final stage IS the
64
64
  assertion (`cmd | grep -q pattern`).
65
+ - **Passthrough arguments in a CLI you author**: an option meant to carry
66
+ another command's own flags cannot use a greedy-but-dash-stopping arity —
67
+ Python's `nargs="+"` ends at the first token starting with `-`, so the
68
+ wrapped command's `--model x` lands on the next positional and the error
69
+ names a parameter the caller never mentioned. Use the parser's
70
+ everything-after form (`argparse.REMAINDER`). A bare `--` separator is a
71
+ second, separate trap: argparse consumes it as its own positional marker
72
+ before the remainder sees it, so the form every caller reaches for first is
73
+ the one that breaks — normalize it out of `argv` before parsing.
65
74
  - **Reserved parameter names**: assigning to reserved shell names (`UID`,
66
75
  `EUID`, `GID`, `PPID`) can invoke the bound system behavior instead of
67
76
  storing a value — silently changing process credentials mid-script. Use
@@ -105,6 +114,13 @@ depends on it, pin it explicitly instead of trusting the environment.
105
114
  diffs use `git diff origin/base...HEAD` (merge-base form); suspect this
106
115
  mechanism first when a diff looks too large or shows deletions in untouched
107
116
  files.
117
+ - **Reverting a path is not undoing your edit**: `git checkout <path>` and
118
+ `git restore <path>` discard *every* uncommitted change in that file. Used
119
+ to remove a planted probe it also removes whatever else was in flight there,
120
+ and the loss is silent. Check `git diff <path>` first, or plant in a copy and
121
+ restore from that. The same asymmetry makes the restore step fragile: if the
122
+ probe can time out or abort, the restore must not be the next command in the
123
+ same invocation — put it where a failure cannot skip it.
108
124
  - **Dirty-worktree pulls**: before pulling into a worktree with
109
125
  staged/unstaged/untracked changes, fetch first and compare incoming paths
110
126
  against every dirty path; on overlap or a non-fast-forward, stop and clear
@@ -51,12 +51,12 @@ ENTRY_SEED = f"""# CLAUDE.md
51
51
  """
52
52
 
53
53
  # Automation-owned personal learnings file, pulled in by PERSONAL_IMPORT_LINE.
54
- # Kept in sync with scripts/collect-learning.py (the light-flow submit tool),
54
+ # Kept in sync with learn/collect-learning.py (the light-flow submit tool),
55
55
  # which appends learnings here; seeding it keeps the import from dangling.
56
56
  PERSONAL_LEARNINGS_HEADER = """# Personal learnings
57
57
 
58
58
  <!-- Automation-owned: written by the session learning flow (`learn!`,
59
- scripts/collect-learning.py). Do NOT hand-edit — promote→migrate clears
59
+ learn/collect-learning.py). Do NOT hand-edit — promote→migrate clears
60
60
  applied items by learning_id when the org redistributes them. Your own
61
61
  personal rules belong in the entry CLAUDE.md '## Personal' section, never
62
62
  here. This file is pulled into context by the entry file's
@@ -139,9 +139,68 @@ def filtered_files(manifest, key, selection):
139
139
  return sorted(n for n, e in manifest.get(key, {}).items() if kept(e, selection))
140
140
 
141
141
 
142
- def copy_filtered(src_dir, names, dest, rewrite=None, dry=False):
142
+ def author_only(path):
143
+ """Does this file's own frontmatter say it is for the corpus author?
144
+
145
+ A guide declaring `audience: author` documents a step only the author can
146
+ perform, and names repository paths that exist in a checkout and nowhere
147
+ else. The tier says who NEEDS the subject; this says who can ACT on it, and
148
+ the two are independent — the distill workflow is `infra`, so tier alone
149
+ delivered it to every selection.
150
+
151
+ The declaration in the file is the authority, deliberately not restated in
152
+ `domains.json`: a second copy is one more thing that can disagree, and until
153
+ this read existed the label was consumed only by the gate that the label
154
+ exempts. A clone is a checkout, so `install.sh`'s non-packaged path still
155
+ deploys these; this filter is the packaged one.
156
+
157
+ THE single reader of that declaration. `gates/check-package.sh` imports this
158
+ rather than parsing the frontmatter again: the gate tolerates references that
159
+ only this withholding makes safe, so a second parser drifting from this one
160
+ would exempt a file the assembler still installs — precisely the defect the
161
+ pair exists to close. The direction is fixed: author-side may import shipped
162
+ code, never the reverse, because the payload cannot depend on `gates/`.
163
+ """
164
+ try:
165
+ text = path.read_text(encoding="utf-8")
166
+ except OSError:
167
+ return False
168
+ if not text.startswith("---\n"):
169
+ return False
170
+ front = text.split("---\n", 2)[1]
171
+ return any(ln.split(":", 1)[1].strip() == "author"
172
+ for ln in front.splitlines() if ln.startswith("audience:"))
173
+
174
+
175
+ def copy_filtered(src_dir, names, dest, rewrite=None, dry=False, backup=None):
176
+ """Write the selected files, and remove the ones we deployed and no longer select.
177
+
178
+ Writing alone leaves the destination describing a selection nobody chose: a machine that
179
+ took every domain and later narrowed to one kept the whole set on disk, so selection.json
180
+ stopped describing what was deployed. merge_settings already drops a deselected hook's
181
+ REGISTRATION through `owned_names`; this is the same reconciliation for the files.
182
+
183
+ Ownership is "the name exists in our source tree", which is what keeps a file the user put
184
+ in the same directory safe — it is not in `src_dir`, so it is never a candidate.
185
+ """
143
186
  if not dry:
144
187
  dest.mkdir(parents=True, exist_ok=True)
188
+ keep_names = set(names)
189
+ if dest.is_dir() and src_dir.is_dir():
190
+ ours = {p.name for p in src_dir.iterdir() if p.is_file()}
191
+ for path in sorted(dest.iterdir()):
192
+ if not path.is_file() or path.name in keep_names or path.name not in ours:
193
+ continue
194
+ print(f" {'[dry] ' if dry else ''}deselected, removed {path}")
195
+ if dry:
196
+ continue
197
+ # Backed up first, the way every other removal here is: these are copies of repo
198
+ # content, but a machine offline from the repo has no other way back.
199
+ if backup is not None:
200
+ kept = backup / "deselected" / str(path).lstrip("/")
201
+ kept.parent.mkdir(parents=True, exist_ok=True)
202
+ shutil.copy2(path, kept)
203
+ path.unlink()
145
204
  for n in names:
146
205
  if dry:
147
206
  print(f" [dry] copy {n} -> {dest}")
@@ -149,7 +208,22 @@ def copy_filtered(src_dir, names, dest, rewrite=None, dry=False):
149
208
  body = (src_dir / n).read_text(encoding="utf-8")
150
209
  if rewrite:
151
210
  body = body.replace(*rewrite)
152
- (dest / n).write_text(body, encoding="utf-8")
211
+ target = dest / n
212
+ # Replacing is as destructive as removing, and README promises a copy of the exact
213
+ # prior bytes under the state backup dir. install.sh's deploy_file kept that promise;
214
+ # the assembler is the default path now, so writing straight over a guide the user had
215
+ # edited broke it for every file it deploys. Identical content is not a replacement,
216
+ # and copying it would fill the backup dir on every no-op reinstall.
217
+ if backup is not None and target.is_file():
218
+ try:
219
+ changed = target.read_text(encoding="utf-8") != body
220
+ except (OSError, UnicodeDecodeError):
221
+ changed = True # unreadable is not "unchanged"; keep the bytes
222
+ if changed:
223
+ kept = backup / "replaced" / str(target).lstrip("/")
224
+ kept.parent.mkdir(parents=True, exist_ok=True)
225
+ shutil.copy2(target, kept)
226
+ target.write_text(body, encoding="utf-8")
153
227
 
154
228
 
155
229
  def merge_settings(claude_dir, hook_names, template_path, dry=False, owned_names=None):
@@ -200,7 +274,7 @@ def merge_settings(claude_dir, hook_names, template_path, dry=False, owned_names
200
274
  def seed_personal_learnings(claude_dir, dry=False):
201
275
  """Create the automation-owned personal learnings file when seeding the entry,
202
276
  so the entry's @personal/learnings.md import always resolves before the first
203
- learn!. scripts/collect-learning.py appends to it thereafter."""
277
+ learn!. learn/collect-learning.py appends to it thereafter."""
204
278
  md = claude_dir / "personal" / "learnings.md"
205
279
  if md.exists():
206
280
  return
@@ -211,7 +285,18 @@ def seed_personal_learnings(claude_dir, dry=False):
211
285
  md.write_text(PERSONAL_LEARNINGS_HEADER, encoding="utf-8")
212
286
 
213
287
 
214
- def seed_entry(claude_dir, legacy_monolith, dry=False):
288
+ def seed_entry(claude_dir, legacy_monolith, prior_deployed=(), dry=False):
289
+ """Seed the entry, and never rewrite a file the user wrote.
290
+
291
+ Telling those apart used to be a byte-comparison against THIS commit's monolith, which
292
+ recognizes only a re-install of the same release. Anyone upgrading from an earlier one had
293
+ that release's monolith on disk — our file, not theirs — and it was reported as user-owned,
294
+ so the import line was never added and the corpus did not load until they edited it by hand.
295
+
296
+ `prior_deployed` is the previous install's manifest, which is the repo's existing answer to
297
+ "did we write this": deploy_file records every destination it writes, and the pre-unification
298
+ full install deployed the entry through it. Ownership is read the same way it was written.
299
+ """
215
300
  entry = claude_dir / "CLAUDE.md"
216
301
  if not entry.exists():
217
302
  if dry:
@@ -225,7 +310,11 @@ def seed_entry(claude_dir, legacy_monolith, dry=False):
225
310
  body = entry.read_text(encoding="utf-8")
226
311
  if IMPORT_LINE in body:
227
312
  return "ok"
228
- if body == legacy_monolith: # untouched legacy deploy squatting on the path
313
+ # Ours by content (a re-install of this release) OR by record (any earlier one).
314
+ # Both sides go through pathlib first: the manifest is written by shell, so a config dir
315
+ # with a trailing or doubled slash lands in it verbatim, and raw string equality then
316
+ # misses a path that is the same file.
317
+ if body == legacy_monolith or str(pathlib.Path(entry)) in prior_deployed:
229
318
  if dry:
230
319
  print(" [dry] replace legacy deployed CLAUDE.md with seed (backup)")
231
320
  seed_personal_learnings(claude_dir, dry)
@@ -259,12 +348,50 @@ def merge_codex(codex_dir, central_text, dry=False):
259
348
  agents.write_text(new, encoding="utf-8")
260
349
 
261
350
 
351
+ def remove_owned(claude_dir, codex_dir, manifest, dry=False):
352
+ """Undo the two spans this file writes into files it does not own.
353
+
354
+ Every merge here needs a matching removal, and for a long time these two did not have one:
355
+ uninstall deleted the deployed hook FILES while leaving their registrations in the user's
356
+ settings.json, and deleted the guides while leaving the AGENTS.md central region that
357
+ references them. The user was left with hooks invoking missing paths and instructions
358
+ pointing at deleted files, after a command that reported success.
359
+
360
+ Ownership is read the same way it is written — `merge_settings` owning by manifest NAME, and
361
+ the marker pair for the Codex region — so removal can never reach further than the merge did.
362
+ Text outside the markers, and settings entries this repo did not register, are untouched.
363
+ """
364
+ owned = sorted({h for h in manifest.get("hooks", {})})
365
+ merge_settings(claude_dir, [], REPO / "claude" / "settings.template.json",
366
+ dry=dry, owned_names=owned)
367
+
368
+ agents = codex_dir / "AGENTS.md"
369
+ if not agents.is_file():
370
+ return
371
+ body = agents.read_text(encoding="utf-8")
372
+ if MARK_START not in body or MARK_END not in body:
373
+ return # nothing of ours in there; a whole-file legacy deploy is not ours to judge
374
+ pre, rest = body.split(MARK_START, 1)
375
+ _, post = rest.split(MARK_END, 1)
376
+ if dry:
377
+ print(" [dry] strip AGENTS.md central region, keep everything outside the markers")
378
+ return
379
+ agents.write_text((pre + post).lstrip("\n"), encoding="utf-8")
380
+
381
+
262
382
  def main():
263
383
  ap = argparse.ArgumentParser()
384
+ ap.add_argument("--remove-owned", action="store_true",
385
+ help="undo the settings registrations and the AGENTS.md central region "
386
+ "(uninstall's half of the merge); writes nothing else")
264
387
  ap.add_argument("--domains", help="comma-separated selection; overrides selection.json")
265
388
  ap.add_argument("--claude-dir", default=None)
266
389
  ap.add_argument("--codex-dir", default=None)
267
390
  ap.add_argument("--state-dir", default=None)
391
+ ap.add_argument("--prior-manifest", default=None,
392
+ help="the previous install's manifest. Ownership of the entry file is read "
393
+ "from it, so an earlier release's deployed CLAUDE.md is recognized as "
394
+ "ours instead of being reported as the user's.")
268
395
  ap.add_argument("--dry-run", action="store_true")
269
396
  args = ap.parse_args()
270
397
 
@@ -273,12 +400,17 @@ def main():
273
400
  codex_dir = pathlib.Path(args.codex_dir or os.environ.get("CODEX_HOME") or pathlib.Path.home() / ".codex")
274
401
  state_dir = pathlib.Path(args.state_dir or pathlib.Path.home() / ".local/share/agent-bios")
275
402
 
276
- gate = subprocess.run([sys.executable, str(REPO / "scripts" / "check-domains.py")],
403
+ manifest = json.loads((REPO / "compose" / "domains.json").read_text(encoding="utf-8"))
404
+ if args.remove_owned:
405
+ # No domains gate: removal does not depend on the manifest being well-formed, and an
406
+ # uninstall that refuses to run because the corpus is mid-edit would strand the user.
407
+ remove_owned(claude_dir, codex_dir, manifest, dry=args.dry_run)
408
+ return
409
+
410
+ gate = subprocess.run([sys.executable, str(REPO / "compose" / "check-domains.py")],
277
411
  capture_output=True, text=True)
278
412
  if gate.returncode != 0:
279
413
  die("domains gate FAILED — fix manifest/corpus first:\n" + gate.stdout + gate.stderr)
280
-
281
- manifest = json.loads((REPO / "config" / "domains.json").read_text(encoding="utf-8"))
282
414
  if args.domains is not None:
283
415
  selection = frozenset(d for d in args.domains.split(",") if d)
284
416
  else:
@@ -300,6 +432,29 @@ def main():
300
432
  codex_bundle += codex_only + "\n"
301
433
 
302
434
  guides = filtered_files(manifest, "guides", selection)
435
+ withheld = [n for n in guides if author_only(REPO / "claude" / "guides" / n)]
436
+ guides = [n for n in guides if n not in withheld]
437
+ # Not writing it is not enough for anyone who installed before this rule: the
438
+ # manifest is rebuilt from the current deploy, so a file that stops being
439
+ # deployed stops being tracked and would sit there for good.
440
+ stale = [d / n for n in withheld
441
+ for d in (claude_dir / "central" / "guides", codex_dir / "guides")
442
+ if (d / n).is_file()]
443
+ # Copy before removing, the way install.sh backs up a file it replaces. The
444
+ # name matching ours does not prove we wrote it — a shared or symlinked guides
445
+ # directory can hold somebody's own file under the same name, and a deleted
446
+ # one is not recoverable from anywhere else.
447
+ # One timestamped directory per run, with a subdirectory per reason for the removal:
448
+ # `withheld` is an audience decision, `deselected` is a selection change.
449
+ run_backup = state_dir / "backups" / time.strftime("%Y%m%d-%H%M%S")
450
+ backup = run_backup / "withheld"
451
+ for path in stale:
452
+ print(f" {'[dry] ' if args.dry_run else ''}remove withheld {path}")
453
+ if not args.dry_run:
454
+ keep = backup / str(path).lstrip("/")
455
+ keep.parent.mkdir(parents=True, exist_ok=True)
456
+ shutil.copy2(path, keep)
457
+ path.unlink()
303
458
  hooks = filtered_files(manifest, "hooks", selection)
304
459
  agents = filtered_files(manifest, "agents", selection)
305
460
  dry = args.dry_run
@@ -307,19 +462,30 @@ def main():
307
462
  central = claude_dir / "central"
308
463
  if dry:
309
464
  print(f"[dry] bundle.md: {n_bullets} bullets; guides={guides} hooks={hooks} agents={agents}")
465
+ if withheld:
466
+ print(f" [dry] withheld (audience: author): {withheld}")
310
467
  else:
311
468
  central.mkdir(parents=True, exist_ok=True)
312
469
  (central / "bundle.md").write_text(bundle, encoding="utf-8")
313
470
  copy_filtered(REPO / "claude" / "guides", guides, central / "guides",
314
- rewrite=(f"{CLAUDE_VAR}/guides/", f"{CLAUDE_VAR}/central/guides/"), dry=dry)
315
- copy_filtered(REPO / "claude" / "hooks", hooks, central / "hooks", dry=dry)
316
- copy_filtered(REPO / "claude" / "agents", agents, central / "agents", dry=dry)
471
+ rewrite=(f"{CLAUDE_VAR}/guides/", f"{CLAUDE_VAR}/central/guides/"), dry=dry,
472
+ backup=run_backup)
473
+ copy_filtered(REPO / "claude" / "hooks", hooks, central / "hooks", dry=dry, backup=run_backup)
474
+ copy_filtered(REPO / "claude" / "agents", agents, central / "agents", dry=dry,
475
+ backup=run_backup)
317
476
  merge_settings(claude_dir, hooks, REPO / "claude" / "settings.template.json", dry=dry,
318
477
  owned_names=manifest.get("hooks", {})) # deselected hooks must drop too
319
- entry_state = seed_entry(claude_dir, monolith, dry=dry)
478
+ prior_deployed = set()
479
+ if args.prior_manifest:
480
+ prior = pathlib.Path(args.prior_manifest)
481
+ if prior.is_file():
482
+ prior_deployed = {str(pathlib.Path(ln.strip())) for ln in
483
+ prior.read_text(encoding="utf-8").splitlines() if ln.strip()}
484
+ entry_state = seed_entry(claude_dir, monolith, prior_deployed, dry=dry)
320
485
 
321
486
  merge_codex(codex_dir, codex_bundle, dry=dry)
322
- copy_filtered(REPO / "codex" / "guides", guides, codex_dir / "guides", dry=dry)
487
+ copy_filtered(REPO / "codex" / "guides", guides, codex_dir / "guides", dry=dry,
488
+ backup=run_backup)
323
489
 
324
490
  if not dry:
325
491
  state_dir.mkdir(parents=True, exist_ok=True)
@@ -327,7 +493,9 @@ def main():
327
493
  json.dumps({"version": 1, "domains": sorted(selection)}, indent=2) + "\n", encoding="utf-8")
328
494
 
329
495
  print(f"ASSEMBLED: {n_bullets} bullets, {len(guides)} guides, {len(hooks)} hooks, "
330
- f"{len(agents)} agents for selection {sorted(selection)}; entry={entry_state}")
496
+ f"{len(agents)} agents for selection {sorted(selection)}; entry={entry_state}"
497
+ + (f"; withheld {len(withheld)} author-only guide(s): {', '.join(withheld)}"
498
+ if withheld else ""))
331
499
  if entry_state == "needs-action":
332
500
  print(f"ACTION NEEDED: {claude_dir / 'CLAUDE.md'} is user-owned and lacks '{IMPORT_LINE}' — "
333
501
  "add the import line manually; the installer will not rewrite your file.")
@@ -14,13 +14,15 @@ set -u
14
14
  CLAUDE_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
15
15
  BUNDLE="$CLAUDE_DIR/central/bundle.md"
16
16
 
17
- # A full (non-packaged) install has no central bundle by design — the entry file
18
- # IS the corpus. That is not a failure, and telling the user to reinstall with
19
- # --domains would silently narrow their corpus to fix a non-problem.
17
+ # Every install assembles a central bundle now — the shape that had none, where the entry file
18
+ # WAS the corpus, is gone. So a missing bundle is a real failure again rather than the N/A it
19
+ # used to be, and an entry file with no bundle beside it is a pre-convergence layout that a
20
+ # re-install fixes.
20
21
  if [ ! -f "$BUNDLE" ]; then
21
22
  if [ -f "$CLAUDE_DIR/CLAUDE.md" ]; then
22
- echo "CANARY N/A: full install no central bundle to probe (packaged mode creates one)"
23
- exit 3
23
+ echo "CANARY FAIL: entry file present but no central bundle at $BUNDLE this is the old"
24
+ echo " whole-file layout; re-run: agent-bios install"
25
+ exit 1
24
26
  fi
25
27
  echo "CANARY FAIL: no corpus deployed at $CLAUDE_DIR (run: agent-bios install)"
26
28
  exit 1
@@ -34,6 +36,13 @@ probe="Somewhere in your loaded instruction context there may be a line that sta
34
36
  out="$(cd "$HOME" && claude -p "$probe" 2>/dev/null)"
35
37
 
36
38
  if printf '%s' "$out" | grep -qF "$expected"; then
39
+ # Record WHICH bundle was proven to load. This is the only evidence in the system that
40
+ # distinguishes a corpus that landed from one that is read, so the irreversible act that
41
+ # depends on that distinction — pruning a user's personal copy of a promoted learning — reads
42
+ # this file rather than re-deriving a weaker answer from file contents. The rev is part of the
43
+ # proof: a later reassembly invalidates it instead of inheriting it.
44
+ STATE_DIR="${AGENT_BIOS_STATE_DIR:-$HOME/.local/share/agent-bios}"
45
+ mkdir -p "$STATE_DIR" 2>/dev/null && printf '%s\n' "$expected" > "$STATE_DIR/activation.txt" 2>/dev/null || true
37
46
  echo "CANARY PASS: central bundle is loading ($expected)"
38
47
  exit 0
39
48
  fi
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """Bijection/coverage gate: config/domains.json vs the canonical corpus.
2
+ """Bijection/coverage gate: compose/domains.json vs the canonical corpus.
3
3
 
4
4
  The manifest is the sole classification authority (tagged-monolith layout);
5
5
  claude/CLAUDE.md stays the sole text authority. This gate closes the
@@ -33,10 +33,10 @@ import re
33
33
  import sys
34
34
 
35
35
  sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
36
- import pkgid # noqa: E402 (sibling module; scripts/ is a flat toolbox, not a package)
36
+ import pkgid # noqa: E402 (sibling module in compose/)
37
37
 
38
38
  REPO = pathlib.Path(__file__).resolve().parent.parent
39
- MANIFEST = REPO / "config" / "domains.json"
39
+ MANIFEST = REPO / "compose" / "domains.json"
40
40
  MONOLITH = REPO / "claude" / "CLAUDE.md"
41
41
  FILE_SECTIONS = { # manifest key -> corpus dir, glob
42
42
  "guides": ("claude/guides", "*.md"),
@@ -272,6 +272,12 @@ def self_test(manifest, bullets):
272
272
  def main():
273
273
  manifest = load_manifest()
274
274
  bullets = corpus_bullets()
275
+ extra = [a for a in sys.argv[1:] if a != "--self-test"]
276
+ if extra:
277
+ sys.exit(f"check-domains: refusing unknown argument(s) {extra!r}. This gate "
278
+ f"validates the core manifest only; a package manifest path would be "
279
+ f"silently ignored, reporting a check that never ran. Parameterizing "
280
+ f"it is contract v2 §3.")
275
281
  if "--self-test" in sys.argv:
276
282
  missed = self_test(manifest, bullets)
277
283
  if missed:
@@ -106,6 +106,7 @@
106
106
  {"anchor": "For work spanning multiple models or CLI agents", "tier": "domain", "domains": ["multi-agent-orchestration"]},
107
107
  {"anchor": "For composing a prompt, packet, or tool description", "tier": "domain", "domains": ["multi-agent-orchestration"]},
108
108
  {"anchor": "Allocate models by difficulty", "tier": "domain", "domains": ["multi-agent-orchestration"]},
109
+ {"anchor": "Judge a review by how much independence", "tier": "domain", "domains": ["multi-agent-orchestration"]},
109
110
  {"anchor": "dual-provider frontier design drafts", "tier": "domain", "domains": ["multi-agent-orchestration"]},
110
111
  {"anchor": "Never retry-storm a live rate limit", "tier": "domain", "domains": ["multi-agent-orchestration"]},
111
112
  {"anchor": "On any resumed, cleared, or relocated session", "tier": "domain", "domains": ["multi-agent-orchestration"]},
@@ -39,7 +39,14 @@ def resolve(obj, key="package_id"):
39
39
 
40
40
 
41
41
  def segments(pid):
42
- """('scope', 'name') for deriving deploy paths. Only call on a valid id."""
42
+ """('scope', 'name') for deriving deploy paths. Only call on a valid id.
43
+
44
+ No caller yet, and that is recorded rather than left to be rediscovered: deploy-path
45
+ derivation belongs to the multi-package composer, which
46
+ design/adapter-split/ECOSYSTEM-ARCHITECTURE.md defers "to the first real second package".
47
+ Kept because the id grammar it splits is settled and rewriting it later would re-derive the
48
+ same two lines; delete it if that deferral is ever abandoned rather than landed.
49
+ """
43
50
  if not is_valid(pid):
44
51
  raise ValueError(f"not a package id: {pid!r}")
45
52
  return tuple(pid[1:].split("/", 1))