agent-bios 0.11.0 → 0.12.1

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.
@@ -154,6 +154,50 @@ How much independence a review actually bought, as an ordinal grade per reviewer
154
154
  - After resume/clear/relocation, verify pwd, branch, and HEAD against the pinned handoff before acting.
155
155
  - Attribute a parallel session's action (commit, branch, resource) by execution evidence in that session's own transcript, never by token mentions — shared handoff/memory files inject the same tokens into every session's context.
156
156
 
157
+ ## Context Budget And Reset
158
+
159
+ Context growth is a property of the work, not of the host: over 1,075 sessions of 50+
160
+ requests across both CLIs it runs ~2,400 tokens per request (IQR 1,850-2,950), the two
161
+ hosts within 7% of each other (2,280 Claude, 2,450 Codex); the longest sessions (400+
162
+ requests) run lower, ~1,800. One budget therefore serves both, and what differs per host
163
+ is the price of ignoring it. The figure is a prior; the live session is measured below.
164
+
165
+ - **Automatic compaction fires only when the window is nearly full** — Claude at 84-87%
166
+ (windows cluster at 200K and 1M), Codex at ~95% of the window its transcript records as
167
+ `model_context_window` (258,400 on the sessions measured; a later Codex/model pair
168
+ records 353,400 — read the value, never assume it). Cache read is charged per
169
+ request against the whole loaded context, so leaving the reset to the host pays the
170
+ maximum on every request before it. Measured: input is 92-94% of session cost and
171
+ output 6-8%, at a 95-97% cache hit rate — the context is the bill, and uncached input
172
+ is 0.0% of it.
173
+ - Reset deliberately instead. Cost per request falls ~4x from an 867K auto-compact point
174
+ to 200K. The cost-theoretic optimum is ~65K, but it buys a compaction every ~32
175
+ requests at 2-3 minutes each, so 150-250K is the working range and the tail below it is
176
+ not worth chasing.
177
+ - **What bounds the budget is what survives the reset, not the token count.** A compaction
178
+ keeps a ~14K summary plus 3-4 recent messages and discards the rest — unguided when it
179
+ fires on its own. Anything already written to a file survives every reset, so the
180
+ earliest safe threshold is the one where durable state is already on disk. That is what
181
+ decouples cost from loss: without it, resetting more often loses proportionally more.
182
+ - Choose the mechanism by what is known, not by how large the context grew:
183
+
184
+ | Situation | Mechanism |
185
+ |---|---|
186
+ | Stage finished, what to keep is known | clear + dated handoff file — cheapest, and the loss is not a loss |
187
+ | Mid-stage, what to keep is known | write the handoff first, then compact with explicit instructions |
188
+ | Mid-stage, the needed detail is not yet identifiable | compact with instructions; a summary spans what a file cannot yet name |
189
+ | Original detail likely wanted later | clear, and record the transcript path in the handoff — transcripts persist on disk |
190
+ | The next question is unknown | new session plus messaging; only this keeps a round trip available |
191
+ | Growth is tool output | offload to subagents instead — measured ~20x cheaper (tier ~5x, context isolation ~4x) |
192
+ | The judgement trail itself is load-bearing | keep the session and pay the 2-5x resume |
193
+
194
+ - Measure rather than estimate: `agent-bios cost --context <transcript>` (the installed
195
+ entry to `session-cost.py`) reads either
196
+ host's transcript and reports current context, growth rate, compactions, and requests
197
+ remaining against a budget. Codex records `model_context_window` directly; Claude does
198
+ not, so the tool reports its observed auto-compaction point instead of assuming a
199
+ window.
200
+
157
201
  ## Handoff Contract
158
202
 
159
203
  Write for the next agent and re-verification, not narrative. Required content:
@@ -223,3 +267,5 @@ Single owner of numeric defaults; one production environment, 2026-07. Recalibra
223
267
  | two live delegation sessions | tiering saved ~3.3×; discarded prefixes made fresh respawn cheaper; unpinned reviewers inherited FRONTIER |
224
268
  | Codex reach contrast | inherit ~16.5K vs hermetic ~8.7K tokens; schema and stdout/stderr contract verified |
225
269
  | Codex native-spawn probe + HELM E2E | requested max/Ultra native children recorded xhigh/role null; separate read-only roots recorded max and Ultra successfully |
270
+ | 1,075 sessions of 50+ requests, both hosts (2026-08-16; the earlier 62-session top-by-size sample gave ~1,800-2,000) | context grows ~2,400 tok/request (IQR 1,850-2,950), hosts within 7%, ~1,800 in 400+-request sessions; auto-compaction fires at 84-95% of window, never earlier |
271
+ | 2 sessions decomposed by cost component (2026-08) | input 92-94% of cost, output 6-8%, cache hit 95-97%, uncached input 0.0%; an 867K→200K budget cuts cost per request ~4x |
@@ -80,6 +80,7 @@ patching them one at a time is a queue that refills.
80
80
 
81
81
  - At each stage, run review loops as appropriate: self review, subagent review when available, and structured multi-lens review when the repository or domain supports one (concrete tool: Environment Binding below).
82
82
  - Iterate until material issues reach zero: review, identify material issues, fix them, and review again.
83
+ - "Material issues reach zero" is counted over the declared defect criterion's stop-relevant class; choosing and declaring that criterion is owned by `${CODEX_HOME:-$HOME/.codex}/guides/review-defect-criteria.md`.
83
84
  - Use the severity contract for materiality — the canonical definition is the ladder below; external review tools map their levels onto it: blocker, high, and medium are material; low and info are non-material.
84
85
  - Treat blocker as primary happy-path or core-contract failure.
85
86
  - Treat high as supported user, environment, data, or execution path failure.
@@ -0,0 +1,205 @@
1
+ ---
2
+ guide_id: review-defect-criteria
3
+ language: en
4
+ status: active
5
+ use_when:
6
+ - declaring what counts as a defect before dispatching any review
7
+ - choosing or writing the defect criterion for a system type × work goal
8
+ - a review loop plateaus, diverges, or its "material 0" stop never arrives
9
+ - a finding's class is contested at the boundary, or two lenses class it differently
10
+ core_rules:
11
+ - choose and declare the criterion before the review starts — undeclared, every reviewer substitutes its own
12
+ - the severity ladder answers how bad; the criterion answers whether it is a defect, of which class, and what "zero" is counted over
13
+ - a criterion with an empty cell is a hunch, not a criterion — do not start the review
14
+ - paste the goldens into the packet verbatim; a definition alone does not classify consistently at the boundary
15
+ - put the class enum on the accepting channel where one exists; on prose routes, run the fold procedure
16
+ - a mixed packet is split — never run two observers in one trajectory
17
+ verification_focus:
18
+ - every dispatched packet names its criterion and carries its goldens
19
+ - each round audits reported class against post-measurement class; repeated disagreement mints the next golden
20
+ - the stop condition is evaluated over the stop-relevant class only, never the whole finding count
21
+ ---
22
+
23
+ # Review Defect Criteria
24
+
25
+ A defect criterion is chosen, not assumed. This guide is a scoped extension of
26
+ `${CODEX_HOME:-$HOME/.codex}/guides/review-request.md`: before that guide's
27
+ request composition starts, this one decides what the review is hunting. The
28
+ evidence: one codebase, two stop criteria, opposite trajectories.
29
+
30
+ | Rounds | Criterion | Trajectory |
31
+ |---|---|---|
32
+ | early | material = contract violation **or** unprotected contract sentence, small frozen surface | 10 → 4 → 1 → 0 |
33
+ | late | same, scoped to client-observable behavior | 17 → 19 → 21 → 23 → 19 — **plateau** |
34
+ | final | split classes: behavioral_defect / coverage_gap / doc_gap; stop = behavioral 0 | behavioral 10 → 5 — falling again |
35
+
36
+ The plateau was the criterion, not the code: counting "a contract sentence no test
37
+ protects" as a defect means every fix adds contract rows, each a potential defect
38
+ next round — a self-refilling criterion cannot reach zero while its surface grows.
39
+ Yet that same criterion is exactly right for a library, where an unprotected promise
40
+ is a first-class defect. Neither is wrong; starting without choosing is.
41
+
42
+ The **severity ladder** (in `${CODEX_HOME:-$HOME/.codex}/guides/coding-staged-workflow.md`)
43
+ answers *how bad* a finding is. The **criterion** answers *whether* it is a defect at
44
+ all, of which class, and what "zero" is counted over. The plateau happened entirely
45
+ inside "material" — no severity adjustment could have ended it. Ordering: system type
46
+ + goal → defect criterion → classification → defect? → severity → materiality → stop.
47
+
48
+ ## The criterion schema
49
+
50
+ One entry carries all of these. An empty cell makes it a hunch, not a criterion — do
51
+ not start the review on it.
52
+
53
+ | Field | Meaning |
54
+ |---|---|
55
+ | observer | whose eyes judge — client, caller, operator, a model reading the output |
56
+ | defect | what the observer must experience |
57
+ | classes | the enum, exactly ONE class stop-relevant; the others are relief valves that keep the stop class honest |
58
+ | evidence | what one finding must present |
59
+ | non-defects | defect-lookalikes excluded by this criterion — named explicitly |
60
+ | stop condition | what "0" means, counted over the stop class, and WHY it is reachable — how a growing surface is pinned |
61
+ | misclassification cost | which error is expensive — the direction to tell the reviewer to lean |
62
+ | goldens | ≥2 positive, ≥2 negative, ≥1 boundary; each with why, date, and provenance: `measured` (dated incident) or `constructed` (authored to pin a boundary) |
63
+
64
+ ## The catalog
65
+
66
+ Four system types. A starting set, not a census.
67
+
68
+ ### Web service / API surface (contract compliance)
69
+
70
+ - **Observer**: the HTTP client.
71
+ - **Defect**: a client today receives a response that differs from the contract — status code, envelope, stream terminal frames, the model actually run, isolation, resource caps.
72
+ - **Classes**: `behavioral_defect` (stop-relevant) · `coverage_gap` · `doc_gap`.
73
+ - **Evidence**: the concrete request and observed vs promised response.
74
+ - **Non-defects**: internal lifecycle (unless a request sequence shows two different responses), absence of a test, documentation wording.
75
+ - **Stop**: behavioral 0 — reachable: the code surface is finite and shrinks monotonically unless a fix creates a new behavioral defect.
76
+ - **Misclassification cost**: false negatives (a client actually breaks); but inflating coverage gaps into defects destroys the stop condition — audit category creep separately.
77
+ - **Goldens** (all `measured`, 2026-08-13–15):
78
+ - **+** `GET` on a nonexistent path returned 405, not 404 — an existing-method complaint attached to a path that does not exist.
79
+ - **+** a streaming endpoint's terminal delta carried only `output_tokens`, so cache/input token counts never reach a streaming client — non-streaming was correct, which is why nobody saw it.
80
+ - **−** "no test catches a one-line flip of this default": today's behavior is correct — `coverage_gap` here, a defect only under the library criterion.
81
+ - **−** child-process kill grace period — out of scope until a request sequence shows a client-visible difference; pins the observer to the client.
82
+ - **±** rejecting a documented-as-valid boundary value (compression 100, docs say "below 100 requires jpeg/webp") — the documentation is what makes it a defect; without that sentence it is taste.
83
+
84
+ ### Library / SDK / contract-first system
85
+
86
+ - **Observer**: the caller **and** the future maintainer.
87
+ - **Defect**: a promise violated today, or a promise unprotected — a one-line change breaks it and nothing catches it. The API criterion's `coverage_gap` is first-class here.
88
+ - **Classes**: `contract_defect` (stop-relevant) · `style_note` · `internal_change`.
89
+ - **Evidence**: for a violation, as above; for an unprotected promise, the promise sentence + the one-line change that breaks it + the absence of any catching test.
90
+ - **Non-defects**: style, internal structure.
91
+ - **Stop**: caution — this criterion self-refills while the contract grows. Declare "0" only over a surface whose growth has stopped; on a growing surface, narrow the stop to "every promise this change added is protected."
92
+ - **Misclassification cost**: balanced — miss an unprotected promise and the next refactor breaks it silently; over-report and the loop never ends.
93
+ - **Goldens**:
94
+ - **+** a streaming-image event test matched only `.completed`, so an edit stream misnamed `image_generation.completed` still passed — mutation survival showed the promise unprotected. (`measured`, 2026-08-13–15)
95
+ - **+** the contract promised "storage serves a large image at least once"; the code evicted it on the next request — resolved by fixing the **contract**: the disagreement is the defect, whichever side moves. (`measured`, 2026-08-13–15)
96
+ - **−** renaming an internal helper no document promises — the observer holds the contract; undocumented internals carry no promise. (`measured`, 2026-08-13–15)
97
+ - **−** a caller breaking on undocumented iteration order — no promise existed; the rename boundary pinned from the caller's side. (`constructed`, 2026-08-19)
98
+ - **±** an inventory-listed test turns out vacuous — asserts nothing about its promise. "A test exists" is a claim about names until the assertion is read; hand the reviewer "check the assertion, not the name." (`measured`, 2026-08-13–15)
99
+
100
+ ### AI workbench / harness (agent orchestration, review loops, verification pipelines)
101
+
102
+ - **Observer**: the operator, and any model consuming the harness's output.
103
+ - **Defect**: **a false signal that looks true** — a wrong PASS, a vacuous test, a silent fallback, a packet missing the call site, a wrong denominator. Being wrong is not the defect; being wrong while *looking right* is. Second form: a run claimed without provable dispatch — no receipt.
104
+ - **Classes**: `false_signal` (stop-relevant) · `detected_miss`. An unproven dispatch — a run or PASS claimed with no receipt — is classed `false_signal`, not given its own relief valve: a signal that cannot be shown true is counted false, or a round could declare completion while every dispatch stayed unproven.
105
+ - **Evidence**: the input on which the instrument gave a false verdict, plus the known correct answer. The standard probe: run the instrument against an input whose answer is known to be the opposite.
106
+ - **Non-defects**: one output's style; a false positive the harness itself **detected** — a caught error is the harness working.
107
+ - **Stop**: every PASS emitted this run survives a known-opposite check and evidences its own dispatch. The unit is "this run's signals are trustworthy," not a standing zero.
108
+ - **Misclassification cost**: false passes dominate. An instrument bug reporting failure dies in minutes because someone looks; one reporting success survives — a selection effect.
109
+ - **Goldens**:
110
+ - **+** a shell test runner received one nonexistent path (zsh does not word-split an unquoted variable), so every run exited 1 and every mutation reported KILLED — a green instrument that never ran its subject. (`measured`, 2026-08-13–15)
111
+ - **+** a test-inventory grep matched only `^test('...'` and missed every parameterized name; three reviewers judged a populated file empty — nothing tied the inventory's denominator to the source's own count. (`measured`, 2026-08-13–15)
112
+ - **+** a negative control kept passing after a faithful revert of the fix it was written against — a guard satisfied by an absence, a false PASS about the gate itself; found only by re-running reverts. (`measured`, 2026-08)
113
+ - **+** a review round returned "clean" with no receipt evidencing that the declared packet was dispatched on the exact seat — `false_signal` although nothing observed was wrong: what the stop refuses is absence of proof of dispatch, not proof of falsity. (`constructed`, 2026-08-19, pinned after a classification dispute at exactly this boundary)
114
+ - **−** one reviewer over-classed an item as material; another lens plus measurement filtered it — the harness caught it, so it worked: `detected_miss`, not a defect. (`measured`, 2026-08-13–15)
115
+ - **−** five review rounds returned 8 → 9 → 10 → 5 → 12 findings, refusing to converge — and every count was true. A truthful unpleasant signal is not a false signal; the defect lived in the undeclared criterion. (`measured`, 2026-08-16–17)
116
+ - **±** a surviving mutation proved equivalent — the platform already normalized what the mutated guard checked. Neither a harness defect nor a test gap; but a harness that auto-reads "survived = gap" has a defect in that rule. (`measured`, 2026-08-13–15)
117
+
118
+ ### Decision ontology (an ontology a model decides from)
119
+
120
+ - **Observer**: a model or agent deciding from the ontology alone.
121
+ - **Defect**: a representation that produces a wrong decision or blocks a right one: overlapping concept boundaries, a missing distinction the decision needs, an instance contradicting reality, a wrong relation direction or cardinality, a name implying what the definition does not say.
122
+ - **Classes**: `decision_defect` (stop-relevant) · `representation_note` · `out_of_scope_gap`.
123
+ - **Evidence**: a **decision scenario** — "answered from the ontology alone, this question yields X; reality is Y" — with the question, the path taken, and the ground truth.
124
+ - **Non-defects**: representation format, completeness as such, a question the model would get wrong without the ontology too.
125
+ - **Stop**: zero wrong decisions over the agreed scenario set. The set is the scope — fix it first, or this criterion self-refills like the library one.
126
+ - **Misclassification cost**: situational — feeding a hard gate makes false negatives expensive; exploratory aid makes false positives expensive. Filling this cell is mandatory at declaration.
127
+ - **Goldens** (all `constructed`, 2026-08-19, authored to pin boundaries the source loop's decision-scenario framing left open):
128
+ - **+** `Customer` and `Account` both define "the paying party"; a refund-routing question resolves through both paths — overlap is a decision defect even when each definition is individually correct.
129
+ - **+** `Order —hasOne→ Payment` while split payments exist — cardinality is a claim, and a false claim misleads the deciding model.
130
+ - **−** a verbose concept description — no decision changes; form is outside this observer's sight.
131
+ - **−** a domain absent that no scenario in the agreed set needs — completeness is scoped by the set, not the world.
132
+ - **±** the distinction exists but the model cannot find it (name or link missing) — with the observer fixed as "a model seeing only the ontology," unreachable is a representation defect, not a search defect. The observer clause decides the class.
133
+
134
+ ## Before any review
135
+
136
+ 1. Write one sentence: the system type and this work's goal. ("API surface — make the responses clients receive today match the contract." / "Harness — make this run's PASS signals trustworthy.")
137
+ 2. Pick a criterion from the catalog, or fill the schema fresh. A cell you cannot fill means the review does not start.
138
+ 3. Paste the goldens into the packet verbatim. Never the definition alone.
139
+ 4. Declare the stop condition and why it is reachable — or how the scope was pinned to make it so.
140
+ 5. Put the classification on the accepting channel (next section).
141
+ 6. Audit every round: reported class vs the class confirmed after measurement. A repeated disagreement is the next golden.
142
+
143
+ ## Enforcing the enum, by channel
144
+
145
+ Ranked by how much the channel refuses for you:
146
+
147
+ 1. **A route with a submit schema**: classification is a required enum field. The
148
+ measured precedent is anchors — 2,466 of 2,466 findings carried one, because the
149
+ schema refuses output without it. Where this channel exists, use it.
150
+ 2. **Prose-packet routes** (the shipped deep-review methods): the packet header
151
+ declares `Criterion: <name>` with the goldens pasted verbatim, and the dispatching
152
+ agent runs this fold procedure on what comes back:
153
+ 1. For each returned finding row, look up its class against the declared enum.
154
+ 2. A row carrying a class from the enum enters the findings ledger under that class.
155
+ 3. A row with no class, or a class outside the enum, is **not admitted**: send it
156
+ back once for classification, or record it as refused with the reason. Never
157
+ admit it unclassified, and never guess its class for it.
158
+ 4. Count the stop condition over the stop-relevant class only.
159
+ 5. Record the audit pair (reported class, confirmed class) for procedure step 6.
160
+
161
+ Stated honestly: on a prose route this is steering, not control — the fold is
162
+ performed by an agent following this guide, and nothing structural refuses a
163
+ class-less row for it. That is the known weaker result of a request-only rule.
164
+ 3. A structural criterion slot in the review dispatch machinery is deliberately not
165
+ part of this guide: a criterion is per-review while dispatch config is per-launch,
166
+ and a slot with no schema-backed consumer would be an inert value. Until such a
167
+ channel exists, ranks 1–2 are the real ones.
168
+
169
+ ## Golden lifecycle
170
+
171
+ - **Admission**: only a golden that decides a boundary the definition leaves open,
172
+ evidenced by a recorded classification disagreement or an audited misclassification;
173
+ provenance labeled `measured` or `constructed`, each with its why and date.
174
+ - **Overturn**: only by a measured counterexample. Correct the golden in place — this
175
+ guide describes the present — plus a dated decision record, in whatever channel the
176
+ repo keeps decisions, naming the closed golden and the counterexample. Silent
177
+ deletion is forbidden: a wrong golden makes reviewers systematically wrong, and an
178
+ untracked fix hides that it ever did.
179
+
180
+ ## Declaring, switching, and mixed packets
181
+
182
+ - Every dispatched packet names its criterion. A harness round declares `AI harness`;
183
+ a round whose system type has no catalog entry declares a **task-local criterion**,
184
+ written in the packet itself, conforming to the schema above and labeled task-local
185
+ — repeated use is what earns a catalog entry.
186
+ - A mixed packet is split into one packet per criterion. Two observers in one
187
+ trajectory produce findings no single stop condition can count.
188
+ - When a criterion changes mid-loop: re-classify only the findings still open; rounds
189
+ already recorded are dated history. Never splice pre- and post-switch counts into
190
+ one series — they count different things.
191
+
192
+ ## Evidence base
193
+
194
+ Derived from a 39-round adversarial review loop (3 lenses × a frontier reviewer) on a
195
+ peer OAuth CLI-API adapter, 2026-08-13–15: the same codebase plateaued at
196
+ 17→19→21→23→19 findings under a self-refilling criterion and resumed falling
197
+ (behavioral 10→5) the round the classes were split and the stop counted over
198
+ `behavioral_defect` alone. The boundary evidence is from the same loop: a packet
199
+ instruction saying "do not inflate coverage gaps" still left two lenses classing one
200
+ finding `doc_gap` and `behavioral_defect`, both defensibly — one golden would have
201
+ decided it. The non-convergence golden (8→9→10→5→12, every count true) is this
202
+ environment's own launcher-review loop, 2026-08-16–17, run under an undeclared
203
+ "material" criterion; the two series are two populations under two criteria and are
204
+ cited separately for exactly the reason the migration section gives. Re-derive when
205
+ the review routes or the bound models change.
@@ -26,7 +26,11 @@ verification_focus:
26
26
  This guide is a scoped extension of the global Coding Guidelines. Use it when
27
27
  composing what you ask a reviewer for — the request, the evidence bar, the
28
28
  verdict shape. It does not cover when to review, how deep, or what counts as
29
- material (the severity ladder and review loop in `${CODEX_HOME:-$HOME/.codex}/guides/coding-staged-workflow.md` own that),
29
+ material — that last question splits in two: the severity ladder and review loop in
30
+ `${CODEX_HOME:-$HOME/.codex}/guides/coding-staged-workflow.md` own *how bad*,
31
+ while whether something is a defect at all, of which class, and what "zero" is
32
+ counted over is the defect criterion, owned by
33
+ `${CODEX_HOME:-$HOME/.codex}/guides/review-defect-criteria.md`. Nor
30
34
  which reviewer kind to route to — the convergence heuristic in
31
35
  `${CODEX_HOME:-$HOME/.codex}/guides/verification-discipline.md` owns that. Phrasing a prompt for a specific model
32
36
  family is out of scope here; where that guidance ships, the rule that needs it
@@ -38,6 +42,16 @@ is a per-model claim; these are the failures that persist regardless of who
38
42
  reviews. Every rule names the evidence behind it, because a review guide that
39
43
  asserts without evidence would fail its own bar.
40
44
 
45
+ ## Declare the criterion
46
+
47
+ Before composing anything else, name the defect criterion the review runs under —
48
+ the per-system-type definition of what a defect is, its class enum, and what "zero"
49
+ is counted over. `${CODEX_HOME:-$HOME/.codex}/guides/review-defect-criteria.md`
50
+ owns choosing it; this guide assumes one is declared. Put `Criterion: <name>` at the
51
+ top of the packet with that criterion's goldens pasted verbatim — a definition
52
+ without goldens does not classify consistently at the boundary, and an undeclared
53
+ criterion means every reviewer substitutes its own.
54
+
41
55
  ## Demand a failure path, not a gap
42
56
 
43
57
  The dominant reviewer failure is not hallucination. Across 372 rejected
@@ -22,8 +22,10 @@ env-personal is never assembled. The domains gate must be green first.
22
22
  """
23
23
  import argparse
24
24
  import json
25
+ import os
25
26
  import pathlib
26
27
  import re
28
+ import shlex
27
29
  import shutil
28
30
  import subprocess
29
31
  import sys
@@ -37,6 +39,14 @@ PERSONAL_IMPORT_LINE = "@personal/learnings.md"
37
39
  CLAUDE_VAR = "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
38
40
  CODEX_VAR = "${CODEX_HOME:-$HOME/.codex}"
39
41
  CODEX_ONLY_PREFIX = "- Codex-only standing authorization:"
42
+ # Where that bullet belongs. Owned here because the payload cannot import from gates/,
43
+ # and `gates/emit-mirrors.py` — which pins the same position in the STATIC projection —
44
+ # imports it from this module instead, the same direction check-package.sh already takes
45
+ # for `author_only`. The two were independent before, and they disagreed: the projection
46
+ # put the bullet under this heading while the assembler appended it to the end of the
47
+ # bundle, so the deployed AGENTS.md filed a multi-model rule under whatever section
48
+ # happened to come last.
49
+ CODEX_ONLY_ANCHOR = "## Multi-Model Workflow"
40
50
 
41
51
  ENTRY_SEED = f"""# CLAUDE.md
42
52
 
@@ -99,6 +109,30 @@ def parse_monolith(text):
99
109
  return title, sections
100
110
 
101
111
 
112
+ def place_codex_only(bundle, bullet):
113
+ """Put the Codex-only bullet first under CODEX_ONLY_ANCHOR, or refuse.
114
+
115
+ Appending was the old behaviour and it is what put a multi-model rule under Session
116
+ Learning in every deployed AGENTS.md: the bundle's sections are whatever the selection
117
+ kept, so "the end" is a different heading depending on what the user installed.
118
+
119
+ Refuses rather than falling back to appending. The bullet is only added when
120
+ `multi-agent-orchestration` is selected, and that domain is what carries the anchor
121
+ heading, so a missing anchor means the bundle is not the shape this rule assumes —
122
+ quietly filing the rule somewhere else is how the defect looked in the first place.
123
+ """
124
+ lines = bundle.split("\n")
125
+ hits = [i for i, line in enumerate(lines) if line == CODEX_ONLY_ANCHOR]
126
+ if len(hits) != 1:
127
+ die(f"codex bundle must hold exactly one {CODEX_ONLY_ANCHOR!r} to place "
128
+ f"{CODEX_ONLY_PREFIX!r} under; found {len(hits)}")
129
+ index = hits[0] + 1
130
+ while index < len(lines) and not lines[index].strip():
131
+ index += 1
132
+ lines.insert(index, bullet)
133
+ return "\n".join(lines)
134
+
135
+
102
136
  def build_bundle(monolith_text, manifest, selection, tool):
103
137
  entry_of = {}
104
138
  for e in manifest["bullets"]:
@@ -226,12 +260,58 @@ def copy_filtered(src_dir, names, dest, rewrite=None, dry=False, backup=None):
226
260
  target.write_text(body, encoding="utf-8")
227
261
 
228
262
 
263
+ def replace_atomically(path, text):
264
+ """Write via a temp file + os.replace, so a write that fails partway cannot truncate.
265
+
266
+ Every file this is used on is one the USER owns and edits — their settings.json, their
267
+ AGENTS.md — and a plain write_text truncates first and fills after. A short write (a full
268
+ disk, a crash) left AGENTS.md holding a fragment of our marker and none of their text,
269
+ with no copy in reach: the backup taken beside these calls is of the PREVIOUS content,
270
+ which is exactly what a half-written file destroys the value of.
271
+
272
+ learn/migrate-learnings.py has had this discipline and states the reason; it now shares
273
+ this one implementation rather than keeping a second. The temp file is a sibling so the
274
+ replace stays on one filesystem, where os.replace is atomic.
275
+
276
+ An existing target's permission bits ride through the swap: the temp is born with
277
+ umask mode, so replacing a user-restricted file — a 0600 AGENTS.md — silently widened
278
+ it to world-readable. Ownership is not copied; this never runs with the privilege to
279
+ change it, and a same-owner rename keeps it anyway.
280
+ """
281
+ path.parent.mkdir(parents=True, exist_ok=True)
282
+ tmp = path.with_name(path.name + ".tmp-agent-bios")
283
+ try:
284
+ tmp.write_text(text, encoding="utf-8")
285
+ if path.exists():
286
+ tmp.chmod(path.stat().st_mode & 0o777)
287
+ os.replace(tmp, path)
288
+ finally:
289
+ if tmp.exists():
290
+ tmp.unlink()
291
+
292
+
229
293
  def hook_command_matches(command, name):
230
- """A complete `/hooks/<name>` token: the command ends with it, or it is followed by an
231
- argument. One matcher for the merge below AND the domain gate's settings leg — the gate
232
- used a substring test, so `central/hooks/<name>.disabled` passed the gate while this
233
- merge skipped it, and the install carried no hook under a green gate."""
234
- return command.endswith("/hooks/" + name) or f"/hooks/{name} " in command + " "
294
+ """Does an argv TOKEN of this command end in `/hooks/<name>`?
295
+
296
+ One matcher for the merge below AND the domain gate's settings leg — the gate used a
297
+ substring test, so `central/hooks/<name>.disabled` passed the gate while this merge
298
+ skipped it, and the install carried no hook under a green gate.
299
+
300
+ Split as a shell would, not delimited by spaces. The registration this file writes is
301
+ `shlex.quote`d so a config home containing a space works, and the quoting puts a `'`
302
+ right after the filename — so a matcher wanting end-of-string or a following space
303
+ could not recognize the line it had just written, and every reinstall on a
304
+ `/Users/First Last` home appended another copy of the same hook. Splitting the way the
305
+ shell will is the only reading that survives its own quoting.
306
+
307
+ Still a PATH match and never a bare name: `wrap.py --inner <name>` passes the name as
308
+ an argument to somebody else's wrapper, and deleting a stranger's hook is not a right
309
+ this ownership rule ever claimed."""
310
+ try:
311
+ tokens = shlex.split(command)
312
+ except ValueError: # unbalanced quotes: not a command we wrote
313
+ tokens = command.split()
314
+ return any(token.endswith("/hooks/" + name) for token in tokens)
235
315
 
236
316
 
237
317
  def merge_settings(claude_dir, hook_names, template_path, dry=False, owned_names=None):
@@ -250,15 +330,41 @@ def merge_settings(claude_dir, hook_names, template_path, dry=False, owned_names
250
330
  """
251
331
  owned = set(owned_names if owned_names is not None else hook_names)
252
332
  spath = claude_dir / "settings.json"
253
- settings = json.loads(spath.read_text(encoding="utf-8")) if spath.exists() else {}
333
+ existed = spath.exists()
334
+ settings = json.loads(spath.read_text(encoding="utf-8")) if existed else {}
254
335
  template = json.loads(template_path.read_text(encoding="utf-8")) if template_path.exists() else {}
255
336
  hooks = settings.setdefault("hooks", {})
256
337
 
257
- def ours(en):
258
- return any(n in h.get("command", "") for h in en.get("hooks", []) for n in owned)
259
-
260
- for event, entries in list(hooks.items()): # drop every entry we own
261
- hooks[event] = [en for en in entries if not ours(en)]
338
+ def ours(hook):
339
+ # The same matcher the re-add below uses, which `hook_command_matches` already
340
+ # describes itself as being ("one matcher for the merge below AND the domain
341
+ # gate"). Only the ADD half honoured that; removal asked whether the name appeared
342
+ # anywhere in the command, so a user's own entry was deleted for mentioning ours
343
+ # `wrap.py --inner tooling-gotchas-hook.py`, a `.bak` copy, a directory named after
344
+ # it. Deleting a stranger's hook out of their settings is not something the
345
+ # ownership rule above ever claimed the right to do.
346
+ return any(hook_command_matches(hook.get("command", ""), n) for n in owned)
347
+
348
+ # Pruned per HOOK, not per entry. Ownership is a name on one command, and the entry is
349
+ # a matcher that can hold several — so `{"matcher": "Bash", "hooks": [ours, theirs]}`
350
+ # answered "ours" and took the stranger's hook with it. That is the same overreach the
351
+ # matcher above was narrowed to stop, one level up: the granularity of the removal has
352
+ # to match the granularity of the claim. An entry left with no hooks was only ever ours,
353
+ # so it still goes whole.
354
+ for event, entries in list(hooks.items()):
355
+ pruned = []
356
+ for en in entries:
357
+ children = en.get("hooks") if isinstance(en, dict) else None
358
+ if not isinstance(children, list):
359
+ pruned.append(en) # not a shape we wrote; not ours to judge
360
+ continue
361
+ keep = [h for h in children if not (isinstance(h, dict) and ours(h))]
362
+ if len(keep) == len(children):
363
+ pruned.append(en) # nothing of ours in it — untouched
364
+ elif keep:
365
+ en["hooks"] = keep # ours dropped, their siblings stay where they are
366
+ pruned.append(en)
367
+ hooks[event] = pruned
262
368
  for event, entries in template.get("hooks", {}).items(): # re-add per selection
263
369
  for en in entries:
264
370
  cmds = [h.get("command", "") for h in en.get("hooks", [])]
@@ -268,16 +374,32 @@ def merge_settings(claude_dir, hook_names, template_path, dry=False, owned_names
268
374
  continue
269
375
  clone = json.loads(json.dumps(en))
270
376
  for h in clone.get("hooks", []):
377
+ # Quoted, because this lands in a shell command line. A config home with a
378
+ # space in it — `/Users/First Last/.claude` — split into separate argv
379
+ # words, so the shell tried to run `/Users/First` and the deployed hook
380
+ # simply never fired. Nothing reported it: a hook that does not run looks
381
+ # exactly like a hook with nothing to say.
382
+ #
383
+ # A function replacement rather than a string one: shlex.quote emits
384
+ # backslashes for some paths and re.sub reads those as group references in
385
+ # a replacement string.
386
+ target = shlex.quote(str(claude_dir / "central" / "hooks" / owner))
271
387
  h["command"] = re.sub(r"\S*/hooks/" + re.escape(owner),
272
- str(claude_dir / "central" / "hooks" / owner),
388
+ lambda _match, value=target: value,
273
389
  h["command"])
274
390
  hooks.setdefault(event, []).append(clone)
275
391
  if dry:
276
392
  print(f" [dry] merge settings.json ({len(hook_names)} central hooks)")
277
393
  return
278
- if spath.exists():
394
+ if not existed and not any(hooks.values()):
395
+ # Nothing of ours to register and no file to preserve. Writing one would create a
396
+ # settings.json the user did not have — and this branch is reached by uninstall on a
397
+ # machine where the claude dir is gone, where creating the directory to hold it
398
+ # crashed on a missing parent instead.
399
+ return
400
+ if existed:
279
401
  shutil.copy2(spath, spath.with_suffix(f".json.bak-{time.strftime('%Y%m%d-%H%M%S')}"))
280
- spath.write_text(json.dumps(settings, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
402
+ replace_atomically(spath, json.dumps(settings, indent=2, ensure_ascii=False) + "\n")
281
403
 
282
404
 
283
405
  def seed_personal_learnings(claude_dir, dry=False):
@@ -335,7 +457,21 @@ def seed_entry(claude_dir, legacy_monolith, prior_deployed=(), dry=False):
335
457
  return "needs-action" # user content without the import line: report, never rewrite
336
458
 
337
459
 
338
- def merge_codex(codex_dir, central_text, dry=False):
460
+ def merge_codex(codex_dir, central_text, prior_deployed=(), dry=False):
461
+ """Write the central region into AGENTS.md, and never silence a file the user wrote.
462
+
463
+ A missing marker pair used to mean "legacy whole-file deploy", so any AGENTS.md without
464
+ them had its active body replaced by the region plus an empty `## Personal`. Every Codex
465
+ user who wrote an AGENTS.md before installing has exactly that file, and their
466
+ instructions stopped loading on the first install — the backup made it recoverable, not
467
+ noticed. Absence of a marker is absence of evidence, in both directions.
468
+
469
+ So the same evidence seed_entry uses on the Claude side decides it here: `prior_deployed`
470
+ is the previous install's manifest, and a path in it is ours by record. Anything else is
471
+ theirs, and the markers are adopted ABOVE their text rather than over it — which is what
472
+ the marker pair is for, and it leaves nothing of theirs unloaded. Nothing is lost that
473
+ way, so that branch needs no backup; the by-record branch keeps the one it always had.
474
+ """
339
475
  agents = codex_dir / "AGENTS.md"
340
476
  region = f"{MARK_START}\n{central_text}{MARK_END}\n"
341
477
  if agents.exists():
@@ -344,17 +480,21 @@ def merge_codex(codex_dir, central_text, dry=False):
344
480
  pre, rest = body.split(MARK_START, 1)
345
481
  _, post = rest.split(MARK_END, 1)
346
482
  new = pre + region + post
347
- else: # legacy whole-file deploy: back up, adopt markers, keep nothing implicit
483
+ elif str(pathlib.Path(agents)) in prior_deployed:
484
+ # Ours by record: an earlier release deployed this file whole, so replacing it
485
+ # with the marked shape is the upgrade, not a loss.
348
486
  if not dry:
349
487
  shutil.copy2(agents, agents.with_suffix(f".md.bak-legacy-{time.strftime('%Y%m%d-%H%M%S')}"))
350
488
  new = region + "\n## Personal\n"
489
+ else:
490
+ new = region + "\n" + body.lstrip("\n")
351
491
  else:
352
492
  new = region + "\n## Personal\n"
353
493
  if dry:
354
494
  print(f" [dry] write AGENTS.md central region ({len(central_text)} bytes)")
355
495
  return
356
496
  codex_dir.mkdir(parents=True, exist_ok=True)
357
- agents.write_text(new, encoding="utf-8")
497
+ replace_atomically(agents, new)
358
498
 
359
499
 
360
500
  def remove_owned(claude_dir, codex_dir, manifest, dry=False):
@@ -385,7 +525,7 @@ def remove_owned(claude_dir, codex_dir, manifest, dry=False):
385
525
  if dry:
386
526
  print(" [dry] strip AGENTS.md central region, keep everything outside the markers")
387
527
  return
388
- agents.write_text((pre + post).lstrip("\n"), encoding="utf-8")
528
+ replace_atomically(agents, (pre + post).lstrip("\n"))
389
529
 
390
530
 
391
531
  def main():
@@ -402,6 +542,11 @@ def main():
402
542
  "from it, so an earlier release's deployed CLAUDE.md is recognized as "
403
543
  "ours instead of being reported as the user's.")
404
544
  ap.add_argument("--dry-run", action="store_true")
545
+ ap.add_argument("--selected-skills", action="store_true",
546
+ help="print the names of the shipped skills the selection delivers, one "
547
+ "per line, and write nothing. install.sh deploys skills itself (a "
548
+ "skill is a tree the HOST scans, not a file under central/) and asks "
549
+ "here which ones, so the selection rule keeps one owner")
405
550
  args = ap.parse_args()
406
551
 
407
552
  import os
@@ -409,13 +554,28 @@ def main():
409
554
  codex_dir = pathlib.Path(args.codex_dir or os.environ.get("CODEX_HOME") or pathlib.Path.home() / ".codex")
410
555
  state_dir = pathlib.Path(args.state_dir or pathlib.Path.home() / ".local/share/agent-bios")
411
556
 
412
- manifest = json.loads((REPO / "compose" / "domains.json").read_text(encoding="utf-8"))
413
557
  if args.remove_owned:
414
558
  # No domains gate: removal does not depend on the manifest being well-formed, and an
415
559
  # uninstall that refuses to run because the corpus is mid-edit would strand the user.
560
+ # That was the claim; the parse sat ABOVE this branch and ran first, so a malformed
561
+ # domains.json raised out of uninstall before the branch that does not need it. The
562
+ # two halves of the removal need it differently: the Codex region is bounded by our
563
+ # markers and needs nothing, while the settings registrations are owned BY NAME and
564
+ # cannot be found without it. So the region goes either way, and a manifest we cannot
565
+ # read makes the registration half impossible rather than skippable — the caller has
566
+ # to hear that, not read a clean summary over it.
567
+ try:
568
+ manifest = json.loads((REPO / "compose" / "domains.json").read_text(encoding="utf-8"))
569
+ except (OSError, ValueError) as exc:
570
+ remove_owned(claude_dir, codex_dir, {"hooks": {}}, dry=args.dry_run)
571
+ die(f"the AGENTS.md central region was removed, but {REPO / 'compose' / 'domains.json'} "
572
+ f"could not be read ({exc}) — the hook registrations it names are STILL in "
573
+ f"settings.json. Restore that file and re-run uninstall.")
416
574
  remove_owned(claude_dir, codex_dir, manifest, dry=args.dry_run)
417
575
  return
418
576
 
577
+ manifest = json.loads((REPO / "compose" / "domains.json").read_text(encoding="utf-8"))
578
+
419
579
  gate = subprocess.run([sys.executable, str(REPO / "compose" / "check-domains.py")],
420
580
  capture_output=True, text=True)
421
581
  if gate.returncode != 0:
@@ -431,6 +591,16 @@ def main():
431
591
  if unknown:
432
592
  die(f"unknown domains: {sorted(unknown)} (known: {sorted(manifest['domains'])})")
433
593
 
594
+ if args.selected_skills:
595
+ # The same two rules the guides get: the manifest's audience, then the file's own
596
+ # `audience: author` declaration — the gate that tolerates author-side paths in a
597
+ # declared file assumes the assembler withholds it, and a skill deployed by
598
+ # install.sh from this list must keep that assumption true.
599
+ for name in filtered_files(manifest, "skills", selection):
600
+ if not author_only(REPO / "claude" / "skills" / name / "SKILL.md"):
601
+ print(name)
602
+ return
603
+
434
604
  monolith = (REPO / "claude" / "CLAUDE.md").read_text(encoding="utf-8")
435
605
  bundle, n_bullets = build_bundle(monolith, manifest, selection, "claude")
436
606
  codex_src = (REPO / "codex" / "AGENTS.md").read_text(encoding="utf-8")
@@ -438,7 +608,7 @@ def main():
438
608
  if "multi-agent-orchestration" in selection:
439
609
  codex_only = next((ln for ln in codex_src.splitlines() if ln.startswith(CODEX_ONLY_PREFIX)), None)
440
610
  if codex_only:
441
- codex_bundle += codex_only + "\n"
611
+ codex_bundle = place_codex_only(codex_bundle, codex_only)
442
612
 
443
613
  guides = filtered_files(manifest, "guides", selection)
444
614
  withheld = [n for n in guides if author_only(REPO / "claude" / "guides" / n)]
@@ -492,7 +662,7 @@ def main():
492
662
  prior.read_text(encoding="utf-8").splitlines() if ln.strip()}
493
663
  entry_state = seed_entry(claude_dir, monolith, prior_deployed, dry=dry)
494
664
 
495
- merge_codex(codex_dir, codex_bundle, dry=dry)
665
+ merge_codex(codex_dir, codex_bundle, prior_deployed, dry=dry)
496
666
  copy_filtered(REPO / "codex" / "guides", guides, codex_dir / "guides", dry=dry,
497
667
  backup=run_backup)
498
668