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.
- package/DEPENDENCIES.md +19 -19
- package/README.md +34 -11
- package/claude/CLAUDE.md +2 -1
- package/claude/guides/claude-prompting.md +1 -1
- package/claude/guides/cli-multi-model-workflow.md +19 -1
- package/claude/guides/coding-staged-workflow.md +32 -0
- package/claude/guides/gpt-prompting.md +1 -1
- package/claude/guides/learning-flow.md +5 -5
- package/claude/guides/llm-capability-boundary.md +6 -1
- package/claude/guides/session-distill-workflow.md +19 -9
- package/claude/guides/tooling-gotchas.md +16 -0
- package/claude/hooks/__pycache__/tooling-gotchas-hook.cpython-314.pyc +0 -0
- package/claude/hooks/tooling-gotchas-hook.py +7 -0
- package/codex/AGENTS.md +2 -1
- package/codex/guides/claude-prompting.md +1 -1
- package/codex/guides/cli-multi-model-workflow.md +19 -1
- package/codex/guides/coding-staged-workflow.md +32 -0
- package/codex/guides/gpt-prompting.md +1 -1
- package/codex/guides/learning-flow.md +5 -5
- package/codex/guides/llm-capability-boundary.md +6 -1
- package/codex/guides/session-distill-workflow.md +19 -9
- package/codex/guides/tooling-gotchas.md +16 -0
- package/{scripts → compose}/assemble.py +184 -16
- package/{scripts → compose}/canary.sh +14 -5
- package/{scripts → compose}/check-domains.py +9 -3
- package/{config → compose}/domains.json +1 -0
- package/{scripts → compose}/pkgid.py +8 -1
- package/compose/prune-backups.py +204 -0
- package/{scripts → compose}/register-hooks.py +3 -3
- package/{scripts/install.sh → install.sh} +401 -104
- package/launch/agent-launch.py +5294 -0
- package/launch/agent-launch.toml +376 -0
- package/{scripts → launch}/check-prompting-targets.sh +1 -1
- package/{scripts → launch}/provision-venv.sh +1 -1
- package/{scripts → learn}/check-learning.py +7 -7
- package/{scripts → learn}/collect-learning.py +10 -10
- package/{config → learn}/learning.schema.json +3 -3
- package/{scripts → learn}/migrate-learnings.py +95 -54
- package/{scripts → learn}/redact.py +4 -4
- package/package.json +25 -24
- package/wrappers/claude-run.sh +162 -0
- package/{scripts → wrappers}/codex-run.sh +62 -6
- package/config/agent-launch.toml +0 -143
- package/scripts/agent-launch.py +0 -2350
- package/scripts/check-parity.sh +0 -2003
- /package/{shell → launch}/agent-launch.zsh +0 -0
- /package/{config → learn}/promotions.json +0 -0
- /package/{scripts/session-cost.py → session-cost.py} +0 -0
- /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
|
-
`
|
|
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.
|
|
22
|
-
|
|
23
|
-
|
|
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 `
|
|
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 `
|
|
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
|
|
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/
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 `
|
|
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
|
-
`
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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!.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
316
|
-
copy_filtered(REPO / "claude" / "
|
|
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
|
-
|
|
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
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
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
|
|
23
|
-
|
|
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:
|
|
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
|
|
36
|
+
import pkgid # noqa: E402 (sibling module in compose/)
|
|
37
37
|
|
|
38
38
|
REPO = pathlib.Path(__file__).resolve().parent.parent
|
|
39
|
-
MANIFEST = REPO / "
|
|
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))
|