agent-bios 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/DEPENDENCIES.md +35 -12
  2. package/README.md +346 -31
  3. package/claude/CLAUDE.md +2 -2
  4. package/claude/agents/frontier.md +1 -1
  5. package/claude/agents/sweep.md +3 -3
  6. package/claude/agents/workhorse.md +2 -2
  7. package/claude/guides/claude-prompting.md +119 -34
  8. package/claude/guides/cli-multi-model-workflow.md +33 -15
  9. package/claude/guides/gpt-prompting.md +148 -28
  10. package/claude/guides/review-request.md +27 -0
  11. package/claude/guides/session-distill-workflow.md +54 -2
  12. package/claude/guides/slide-writing/RUNBOOK.md +137 -0
  13. package/claude/guides/slide-writing/scripts/pair.py +979 -0
  14. package/claude/guides/slide-writing/scripts/render.mjs +82 -0
  15. package/claude/guides/slide-writing.md +195 -0
  16. package/claude/guides/svg-visualization-guide.md +9 -0
  17. package/claude/guides/verification-discipline.md +5 -1
  18. package/claude/hooks/tooling-gotchas-hook.py +7 -5
  19. package/codex/AGENTS.md +2 -2
  20. package/codex/agents/frontier.toml +2 -1
  21. package/codex/agents/reviewer.toml +1 -1
  22. package/codex/agents/sweep.toml +3 -3
  23. package/codex/agents/workhorse.toml +1 -1
  24. package/codex/config-additions.toml +1 -1
  25. package/codex/guides/claude-prompting.md +119 -34
  26. package/codex/guides/cli-multi-model-workflow.md +33 -15
  27. package/codex/guides/gpt-prompting.md +148 -28
  28. package/codex/guides/review-request.md +27 -0
  29. package/codex/guides/session-distill-workflow.md +54 -2
  30. package/codex/guides/slide-writing/RUNBOOK.md +137 -0
  31. package/codex/guides/slide-writing/scripts/pair.py +979 -0
  32. package/codex/guides/slide-writing/scripts/render.mjs +82 -0
  33. package/codex/guides/slide-writing.md +195 -0
  34. package/codex/guides/svg-visualization-guide.md +9 -0
  35. package/codex/guides/verification-discipline.md +5 -1
  36. package/compose/assemble.py +290 -14
  37. package/compose/bootstrap/SKILL.md +119 -0
  38. package/compose/check-domains.py +102 -9
  39. package/compose/corpus-state.py +1174 -0
  40. package/compose/corpus.py +387 -0
  41. package/compose/corpus_catalog.py +882 -0
  42. package/compose/corpus_install.py +1617 -0
  43. package/compose/corpus_session.py +726 -0
  44. package/compose/corpus_store.py +1414 -0
  45. package/compose/corpus_transaction.py +236 -0
  46. package/compose/corpus_ui.py +644 -0
  47. package/compose/domains.json +101 -100
  48. package/compose/write-update-cache.py +53 -0
  49. package/install.sh +174 -24
  50. package/launch/agent-launch.py +1327 -184
  51. package/launch/agent-launch.toml +12 -16
  52. package/launch/i18n/en.toml +113 -7
  53. package/launch/i18n/ja.toml +113 -7
  54. package/launch/i18n/ko.toml +113 -7
  55. package/learn/collect-learning.py +46 -19
  56. package/learn/migrate-learnings.py +10 -1
  57. package/package.json +13 -3
  58. package/provenance.json +1 -1
  59. package/session-cost.py +22 -2
  60. package/wrappers/codex-helm.sh +3 -3
@@ -0,0 +1,1174 @@
1
+ #!/usr/bin/env python3
2
+ """Corpus-version state: project status for the launcher, list, and rollback.
3
+
4
+ Corpus versions are CONTENT versions of the instruction corpus (globals,
5
+ guides, hooks), distinct from deployment/system versions. Git is the content
6
+ store: design/session-distill/versions.json maps each closed mining window
7
+ to the repo commit whose corpus reflects it. Rollback materializes that
8
+ commit, runs the commit's OWN assembler against the live domain selection,
9
+ and deploys the corpus files plus the two assembled surfaces the agent
10
+ actually reads — the Claude bundle and the Codex central region; the system
11
+ (launcher, wrappers, scripts) stays at its current deployment.
12
+
13
+ Subcommands:
14
+ project write ~/.local/share/agent-bios/corpus-status.json from the
15
+ repo ledger + versions registry (called by agent-bios install)
16
+ list print registered corpus versions
17
+ rollback --version V [--dry-run]: deploy the corpus as of that version
18
+ """
19
+ import argparse
20
+ import datetime
21
+ import io
22
+ import json
23
+ import contextlib
24
+ import fcntl
25
+ import os
26
+ import pathlib
27
+ import shutil
28
+ import subprocess
29
+ import sys
30
+ import tarfile
31
+ import tempfile
32
+ import uuid
33
+
34
+ # compose/ travels as one directory — checkout and npm package alike — so the sibling
35
+ # assembler is present wherever this script is, and it owns the payload's one
36
+ # temp-write + os.replace primitive. Imported rather than copied: a second copy is a
37
+ # second thing that can disagree, and gates/ code is not importable from shipped code.
38
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
39
+ from assemble import MARK_END, MARK_START, merge_codex, replace_atomically # noqa: E402
40
+
41
+ STATE_DIR = pathlib.Path.home() / ".local/share/agent-bios"
42
+ STATUS = pathlib.Path(
43
+ os.environ.get("AGENT_BIOS_CORPUS_STATUS", str(STATE_DIR / "corpus-status.json"))
44
+ )
45
+ CLAUDE_DIR = pathlib.Path(os.environ.get("CLAUDE_CONFIG_DIR", str(pathlib.Path.home() / ".claude")))
46
+ CODEX_DIR = pathlib.Path(os.environ.get("CODEX_HOME", str(pathlib.Path.home() / ".codex")))
47
+
48
+ # Managed corpus paths (repo-relative) and their deploy roots. The ko/
49
+ # tree is repo-only reference; wrappers and agent templates are system, not
50
+ # corpus content.
51
+ #
52
+ # The entry files are deliberately absent. `claude/CLAUDE.md` is seeded once and is the
53
+ # USER'S thereafter, and `codex/AGENTS.md` is ours only between the central markers — so a
54
+ # whole-file write of either is not a rollback, it is overwriting somebody's file. They used
55
+ # to be here, from the era when full mode wrote the corpus into the entry and the entry was
56
+ # therefore ours. compose/assemble.py owns both surfaces now — which is why rollback runs
57
+ # the target commit's assembler (`_assemble_at`) instead of touching them from here: the
58
+ # Claude entry stays the user's, and the Codex region moves only between the markers.
59
+ #
60
+ # Guides and hooks deploy under `central/`, which is where the assembler writes and where the
61
+ # corpus is read from. The pre-unification paths (`<claude>/guides`, `<claude>/hooks`) are
62
+ # swept by the installer but never read, so writing there rolls nothing back.
63
+ CORPUS = [
64
+ ("claude/guides/", lambda p: CLAUDE_DIR / "central" / "guides" / pathlib.Path(p).name),
65
+ ("claude/hooks/", lambda p: CLAUDE_DIR / "central" / "hooks" / pathlib.Path(p).name),
66
+ ("codex/guides/", lambda p: CODEX_DIR / "guides" / pathlib.Path(p).name),
67
+ ]
68
+
69
+
70
+ def repo_root(explicit: str | None) -> pathlib.Path:
71
+ if explicit:
72
+ return pathlib.Path(explicit).resolve()
73
+ return pathlib.Path(__file__).resolve().parents[1]
74
+
75
+
76
+ def git(repo: pathlib.Path, *args: str) -> str:
77
+ return subprocess.run(
78
+ ["git", "-C", str(repo), *args], check=True, capture_output=True, text=True
79
+ ).stdout
80
+
81
+
82
+ def deploy_target(rel: str) -> pathlib.Path | None:
83
+ for prefix, to in CORPUS:
84
+ if rel == prefix or (prefix.endswith("/") and rel.startswith(prefix)):
85
+ return to(rel)
86
+ return None
87
+
88
+
89
+ def corpus_files(repo: pathlib.Path, ref: str) -> list[str]:
90
+ paths = [p.rstrip("/") for p, _ in CORPUS]
91
+ out = git(repo, "ls-tree", "-r", "--name-only", ref, "--", *paths)
92
+ return [line for line in out.splitlines() if line]
93
+
94
+
95
+ # The mining registries are author-side and deliberately outside `package.json` files[]:
96
+ # they carry curation history, and an npm rollback would still lack the git history it
97
+ # needs. So a packaged install has the DOMAIN half of this projection and not the VERSION
98
+ # half, and the two must not fail together. `None` means "this install cannot know", which
99
+ # is not `[]`/`{}` ("known, and empty") — the same distinction `domains_projection` already
100
+ # draws for `applied`. Consumers must render None as unavailable rather than as zero;
101
+ # `_corpus_summary_lines` in the launcher keys on `versions is None` for exactly that.
102
+ def load_versions(repo: pathlib.Path) -> dict | None:
103
+ path = repo / "design/session-distill/versions.json"
104
+ if not path.is_file():
105
+ return None
106
+ return json.loads(path.read_text())
107
+
108
+
109
+ def require_versions(repo: pathlib.Path, command: str) -> list | None:
110
+ """For the commands that are ABOUT versions. They cannot degrade — a rollback with no
111
+ version registry and no git history has nothing to roll back to — so they refuse by
112
+ name instead of dying on a FileNotFoundError the caller has to decode."""
113
+ doc = load_versions(repo)
114
+ if doc is None:
115
+ print(f"corpus-state {command}: the corpus version registry is not part of a "
116
+ f"packaged install, and a rollback needs the repository history as well — "
117
+ f"run this from a checkout", file=sys.stderr)
118
+ return None
119
+ return doc["versions"]
120
+
121
+
122
+ def ledger_summary(repo: pathlib.Path) -> dict | None:
123
+ path = repo / "design/session-distill/ledger.json"
124
+ if not path.is_file():
125
+ return None
126
+ ledger = json.loads(path.read_text())
127
+ entries = ledger["entries"]
128
+ by_status: dict[str, int] = {}
129
+ by_layer: dict[str, int] = {}
130
+ for e in entries:
131
+ by_status[e["status"]] = by_status.get(e["status"], 0) + 1
132
+ if e["status"] == "placed":
133
+ layer = (e.get("classification") or {}).get("layer") or "?"
134
+ by_layer[layer] = by_layer.get(layer, 0) + 1
135
+ return {"entries": len(entries), "by_status": by_status, "placed_by_layer": by_layer}
136
+
137
+
138
+ def domains_projection(repo: pathlib.Path) -> dict:
139
+ """Available domains from the manifest, applied selection from the state dir.
140
+
141
+ The launcher's corpus checklist reads BOTH from here rather than from repo
142
+ paths it cannot know: the installer owns this file, and every install run
143
+ rewrites the projection, so a stale list is impossible without a stale
144
+ install. `applied` is null (never []) when no selection was ever assembled —
145
+ "nothing chosen yet" and "core+infra only" must not read the same."""
146
+ available = sorted(
147
+ json.loads((repo / "compose" / "domains.json").read_text())["domains"]
148
+ )
149
+ applied = None
150
+ selection = STATE_DIR / "selection.json"
151
+ if selection.is_file():
152
+ try:
153
+ applied = sorted(json.loads(selection.read_text())["domains"])
154
+ except (ValueError, KeyError):
155
+ applied = None
156
+ return {"available": available, "applied": applied}
157
+
158
+
159
+ def cmd_project(args: argparse.Namespace) -> int:
160
+ repo = repo_root(args.repo)
161
+ versions_doc = load_versions(repo)
162
+ versions = versions_doc["versions"] if versions_doc is not None else None
163
+ current = STATUS_current_override = None
164
+ last_apply = None
165
+ deployed_corpus = None
166
+ # Read and write under one lock: unlocked, a concurrent record-apply or
167
+ # rollback landing between this read and the write below is reverted whole.
168
+ with _status_lock():
169
+ if STATUS.is_file():
170
+ try:
171
+ prior = json.load(STATUS.open())
172
+ STATUS_current_override = prior.get("rolled_back_to")
173
+ # The apply outcome is recorded by `record-apply` at the end of an
174
+ # onboard run; a reprojection must carry it, not erase it.
175
+ last_apply = prior.get("last_apply")
176
+ # What rollback last deployed; the next rollback's removal operand.
177
+ deployed_corpus = prior.get("deployed_corpus")
178
+ except ValueError as exc:
179
+ # Quarantined, never discarded: an unreadable status is where the
180
+ # rollback/apply record LIVES, and a projection that shrugs over it
181
+ # replaces "the corpus is at v1" with "the corpus is at latest"
182
+ # without a word. The bytes move aside so a person can still read
183
+ # what the record held, and the loss is reported, not silent.
184
+ quarantine = STATUS.with_name(
185
+ STATUS.name
186
+ + f".corrupt-{datetime.datetime.now():%Y%m%d-%H%M%S}"
187
+ )
188
+ os.replace(STATUS, quarantine)
189
+ print(
190
+ f"corpus status at {STATUS} was unreadable ({exc}); the damaged "
191
+ f"file is quarantined at {quarantine}, and any rollback/apply "
192
+ "state it held could not be carried into this projection",
193
+ file=sys.stderr,
194
+ )
195
+ latest = versions[-1]["version"] if versions else None
196
+ current = STATUS_current_override or latest
197
+ status = {
198
+ "repo": str(repo),
199
+ "current_version": current,
200
+ "latest_version": latest,
201
+ "rolled_back_to": STATUS_current_override,
202
+ "versions": versions,
203
+ "summary": ledger_summary(repo),
204
+ "domains": domains_projection(repo),
205
+ "last_apply": last_apply,
206
+ "deployed_corpus": deployed_corpus,
207
+ "generated": datetime.datetime.now().isoformat(timespec="seconds"),
208
+ }
209
+ _write_status(status)
210
+ print(f"corpus-status written: {STATUS} (current={current}, latest={latest})")
211
+ return 0
212
+
213
+
214
+ # What a rollback target must carry to be deployable. The live corpus is `central/bundle.md`,
215
+ # assembled from these two, so a commit without them cannot produce one — the guides would move
216
+ # and the bundle, which is what the agent reads, would stay. Asked in one place because `list`
217
+ # has to advertise exactly what `rollback` will accept.
218
+ ASSEMBLY_PARTS = ("compose/assemble.py", "compose/domains.json")
219
+
220
+
221
+ def deployable(repo: pathlib.Path, commit: str) -> str | None:
222
+ """None if the commit can be rolled back to, else the part that makes it impossible."""
223
+ for part in ASSEMBLY_PARTS:
224
+ if subprocess.run(["git", "-C", str(repo), "cat-file", "-e", f"{commit}:{part}"],
225
+ capture_output=True).returncode != 0:
226
+ return part
227
+ return None
228
+
229
+
230
+ def cmd_list(args: argparse.Namespace) -> int:
231
+ repo = repo_root(args.repo)
232
+ versions = require_versions(repo, "list")
233
+ if versions is None:
234
+ return 2
235
+ usable = 0
236
+ for v in versions:
237
+ blocker = deployable(repo, v["commit"])
238
+ usable += blocker is None
239
+ note = "" if blocker is None else f" [UNAVAILABLE: predates {blocker}]"
240
+ print(f"{v['version']} commit={v['commit'][:12]} closed={v['closed']} "
241
+ f"{v.get('summary', '')}{note}")
242
+ # Listing targets that all refuse is how a recovery path looks available while being gone.
243
+ if versions and not usable:
244
+ print("\nNo registered version can be rolled back to: every one predates the assembled "
245
+ "layout. Register a corpus version from a commit that carries "
246
+ f"{' and '.join(ASSEMBLY_PARTS)} before relying on this.", file=sys.stderr)
247
+ return 0
248
+
249
+
250
+ def _applied_selection() -> tuple[list[str] | None, str]:
251
+ """The live domain selection, or (None, why) — the assembly cannot run blind.
252
+
253
+ Read from the installer-owned projection rather than asked: a rollback that
254
+ guessed a selection would assemble a bundle nobody chose."""
255
+ selection = STATE_DIR / "selection.json"
256
+ if not selection.is_file():
257
+ return None, f"no applied domain selection at {selection}; run onboarding first"
258
+ try:
259
+ return sorted(json.loads(selection.read_text())["domains"]), ""
260
+ except (ValueError, KeyError, TypeError) as exc:
261
+ return None, f"unreadable domain selection at {selection} ({exc})"
262
+
263
+
264
+ def _deployed_previously(repo: pathlib.Path, versions: list[dict]) -> tuple[set[str] | None, str]:
265
+ """The managed corpus set the live homes hold NOW, or (None, why it is unknowable).
266
+
267
+ HEAD is only the answer while the deployment tracks HEAD. After a rollback it
268
+ does not, and deriving removals from HEAD is exactly how rolling forward left
269
+ a file that only the rolled-back version deploys. Preference order: the
270
+ manifest the last rollback recorded; else the recorded rollback version's own
271
+ commit; else HEAD. A state that names a version the registry no longer
272
+ carries is refused rather than guessed around."""
273
+ prior = None
274
+ if STATUS.is_file():
275
+ try:
276
+ prior = json.loads(STATUS.read_text())
277
+ except ValueError as exc:
278
+ return None, (f"cannot establish the deployed corpus: the status at "
279
+ f"{STATUS} is unreadable ({exc}); run `project` first "
280
+ "(it quarantines the damaged file)")
281
+ if isinstance(prior, dict):
282
+ deployed = prior.get("deployed_corpus")
283
+ if isinstance(deployed, dict):
284
+ files = deployed.get("files")
285
+ if isinstance(files, list) and all(isinstance(f, str) for f in files):
286
+ return set(files), ""
287
+ return None, ("cannot establish the deployed corpus: the recorded "
288
+ "deployed_corpus manifest is malformed")
289
+ rolled = prior.get("rolled_back_to")
290
+ if isinstance(rolled, str) and rolled:
291
+ match = [v for v in versions if v["version"] == rolled]
292
+ if not match:
293
+ return None, (f"cannot establish the deployed corpus: the live corpus "
294
+ f"is version {rolled}, which the registry no longer carries")
295
+ return set(corpus_files(repo, match[0]["commit"])), ""
296
+ return set(corpus_files(repo, "HEAD")), ""
297
+
298
+
299
+ def _assemble_at(repo: pathlib.Path, commit: str, domains: list[str]) -> tuple[bytes, str]:
300
+ """Assemble the corpus AS OF a commit, in a sandbox; nothing live is touched.
301
+
302
+ The commit's own assemble.py runs against its own tree — `deployable` already
303
+ holds targets to carrying one, and the current assembler has never been asked
304
+ about that corpus — with the LIVE selection, into empty sandbox homes. Returns
305
+ the two things rollback takes from here: the Claude bundle's bytes, and the
306
+ codex CENTRAL TEXT extracted from between the sandbox's markers. The region,
307
+ never the merged file: publication merges it into AGENTS.md as that file
308
+ exists AT PUBLICATION, so an edit the user lands while this runs rides
309
+ through instead of being overwritten by a stale snapshot. Raises RuntimeError
310
+ with the assembler's own words on any failure, before a single live write."""
311
+ root = pathlib.Path(tempfile.mkdtemp(prefix="corpus-rollback-assemble-"))
312
+ try:
313
+ src = root / "tree"
314
+ src.mkdir()
315
+ archive = subprocess.run(["git", "-C", str(repo), "archive", commit],
316
+ check=True, capture_output=True)
317
+ with tarfile.open(fileobj=io.BytesIO(archive.stdout)) as tar:
318
+ try:
319
+ tar.extractall(src, filter="data")
320
+ except TypeError: # Python < 3.12: no filter parameter
321
+ tar.extractall(src)
322
+ claude = root / "claude-home"
323
+ codex = root / "codex-home"
324
+ state = root / "state-home"
325
+ for directory in (claude, codex, state):
326
+ directory.mkdir()
327
+ run = subprocess.run(
328
+ [sys.executable, str(src / "compose" / "assemble.py"),
329
+ "--domains", ",".join(domains),
330
+ "--claude-dir", str(claude), "--codex-dir", str(codex),
331
+ "--state-dir", str(state)],
332
+ capture_output=True, text=True)
333
+ if run.returncode != 0:
334
+ tail = (run.stdout + run.stderr).strip().splitlines()[-3:]
335
+ raise RuntimeError(
336
+ f"the assembler at {commit[:9]} failed: {' | '.join(tail) or 'no output'}")
337
+ bundle = claude / "central" / "bundle.md"
338
+ agents = codex / "AGENTS.md"
339
+ for produced in (bundle, agents):
340
+ if not produced.is_file():
341
+ raise RuntimeError(
342
+ f"the assembler at {commit[:9]} reported success but produced "
343
+ f"no {produced.name}")
344
+ agents_text = agents.read_text(encoding="utf-8")
345
+ if MARK_START not in agents_text or MARK_END not in agents_text:
346
+ raise RuntimeError(
347
+ f"the assembler at {commit[:9]} produced an AGENTS.md without the "
348
+ "owned marker region")
349
+ central = agents_text.split(MARK_START, 1)[1].split(MARK_END, 1)[0]
350
+ # merge_codex writes MARK_START + "\n" + central_text; give it back
351
+ # exactly what it will re-wrap.
352
+ central = central[1:] if central.startswith("\n") else central
353
+ return bundle.read_bytes(), central
354
+ finally:
355
+ shutil.rmtree(root, ignore_errors=True)
356
+
357
+
358
+ def cmd_rollback(args: argparse.Namespace) -> int:
359
+ if os.environ.get("AGENT_BIOS_LEGACY_INSTALL") != "1":
360
+ print("corpus-state rollback: global rollback is retired; use agent-bios corpus plan with op=rollback and a private baseline_ref", file=sys.stderr)
361
+ return 2
362
+ repo = repo_root(args.repo)
363
+ versions = require_versions(repo, "rollback")
364
+ if versions is None:
365
+ return 2
366
+ match = [v for v in versions if v["version"] == args.version]
367
+ if not match:
368
+ known = ", ".join(v["version"] for v in versions)
369
+ print(f"unknown corpus version: {args.version} (known: {known})", file=sys.stderr)
370
+ return 2
371
+ commit = match[0]["commit"]
372
+ blocker = deployable(repo, commit)
373
+ if blocker is not None:
374
+ print(f"refusing rollback to {args.version}: that corpus predates the assembled layout "
375
+ f"({blocker} is absent at {commit[:9]}), so its bundle cannot be rebuilt and only "
376
+ "part of the corpus would move. `list` marks which versions are available.",
377
+ file=sys.stderr)
378
+ return 2
379
+ # One deployment at a time, held from target/backup calculation through the
380
+ # final status update. The status lock covers only status writes; two
381
+ # unserialized rollbacks interleaved their file writes and BOTH reported
382
+ # success over a corpus split between their targets. Every input read again
383
+ # inside is validated inside: a wait behind another deployment is exactly
384
+ # when the world changes.
385
+ with _deploy_lock():
386
+ return _locked_rollback(args, repo, versions, commit)
387
+
388
+
389
+ def _locked_rollback(args: argparse.Namespace, repo: pathlib.Path,
390
+ versions: list[dict], commit: str) -> int:
391
+ # Validated UNDER the lock, where it is read: a selection that vanishes while
392
+ # this rollback waits behind another deployment must be a named refusal, not
393
+ # whatever error an unchecked read escalates into.
394
+ domains, why = _applied_selection()
395
+ if domains is None:
396
+ print(f"refusing rollback to {args.version}: {why} — without the applied "
397
+ "selection the bundle for that corpus cannot be assembled.", file=sys.stderr)
398
+ return 2
399
+ target_files = corpus_files(repo, commit)
400
+ previous, why = _deployed_previously(repo, versions)
401
+ if previous is None:
402
+ print(f"refusing rollback to {args.version}: {why} — removals cannot be "
403
+ "derived, so files from the deployed version would silently survive.",
404
+ file=sys.stderr)
405
+ return 2
406
+ # The union: HEAD names what an install deploys, `previous` names what a
407
+ # rollback deployed, and files from either side that the target lacks must go.
408
+ removal_candidates = sorted(
409
+ (previous | set(corpus_files(repo, "HEAD"))) - set(target_files))
410
+ # Per-transaction and collision-proof: two rollbacks in one second shared a
411
+ # second-granularity directory and overwrote each other's undo copies.
412
+ backup = (STATE_DIR / "backups"
413
+ / f"corpus-rollback-{datetime.datetime.now():%Y%m%d-%H%M%S}"
414
+ f"-{os.getpid()}-{uuid.uuid4().hex[:8]}")
415
+ # Assembled BEFORE any live write: an assembly that cannot run refuses the
416
+ # whole rollback with nothing to restore.
417
+ try:
418
+ bundle, codex_central = _assemble_at(repo, commit, domains)
419
+ except (RuntimeError, OSError, subprocess.CalledProcessError) as exc:
420
+ print(f"refusing rollback to {args.version}: {exc} — nothing was written.",
421
+ file=sys.stderr)
422
+ return 1
423
+ written = removed = 0
424
+ # Every step is recorded so it can be undone. A corpus is a SET of files that agree
425
+ # about which version they are: a run that stopped in the middle left nine files at
426
+ # the target and twenty-six at the previous one, wrote no status, and reported a raw
427
+ # OSError — so the record on disk went on naming the version the corpus no longer
428
+ # was. The backup taken a line below already holds what each write replaced, which
429
+ # makes putting it back the cheap half of this; saying so when even that fails is
430
+ # the half that matters.
431
+ undo: list[tuple[pathlib.Path, pathlib.Path | None]] = []
432
+
433
+ def restore() -> list[str]:
434
+ """Put every applied file back. Returns the ones that could not be restored."""
435
+ stuck = []
436
+ for target, saved in reversed(undo):
437
+ try:
438
+ if saved is None:
439
+ target.unlink(missing_ok=True)
440
+ else:
441
+ target.write_bytes(saved.read_bytes())
442
+ except OSError:
443
+ stuck.append(str(target))
444
+ return stuck
445
+
446
+ try:
447
+ for rel in target_files:
448
+ dst = deploy_target(rel)
449
+ if dst is None:
450
+ continue
451
+ content = subprocess.run(
452
+ ["git", "-C", str(repo), "show", f"{commit}:{rel}"],
453
+ check=True, capture_output=True,
454
+ ).stdout
455
+ if args.dry_run:
456
+ print(f"[dry-run] write {dst}")
457
+ continue
458
+ bak = None
459
+ if dst.is_file():
460
+ bak = backup / rel
461
+ bak.parent.mkdir(parents=True, exist_ok=True)
462
+ bak.write_bytes(dst.read_bytes())
463
+ dst.parent.mkdir(parents=True, exist_ok=True)
464
+ dst.write_bytes(content)
465
+ undo.append((dst, bak))
466
+ if rel.startswith("claude/hooks/"):
467
+ dst.chmod(0o755)
468
+ written += 1
469
+ for rel in removal_candidates:
470
+ dst = deploy_target(rel)
471
+ if dst is None or not dst.is_file():
472
+ continue
473
+ if args.dry_run:
474
+ print(f"[dry-run] remove {dst} (absent in {args.version})")
475
+ continue
476
+ bak = backup / rel
477
+ bak.parent.mkdir(parents=True, exist_ok=True)
478
+ bak.write_bytes(dst.read_bytes())
479
+ dst.unlink()
480
+ undo.append((dst, bak))
481
+ removed += 1
482
+ # The assembled surfaces land LAST: the bundle is what the agent reads, so
483
+ # it names the target version only once every guide it references has.
484
+ bundle_dst = CLAUDE_DIR / "central" / "bundle.md"
485
+ if args.dry_run:
486
+ print(f"[dry-run] write {bundle_dst} (assembled at {args.version})")
487
+ else:
488
+ bak = None
489
+ if bundle_dst.is_file():
490
+ bak = backup / "assembled/claude-central-bundle.md"
491
+ bak.parent.mkdir(parents=True, exist_ok=True)
492
+ bak.write_bytes(bundle_dst.read_bytes())
493
+ bundle_dst.parent.mkdir(parents=True, exist_ok=True)
494
+ bundle_dst.write_bytes(bundle)
495
+ undo.append((bundle_dst, bak))
496
+ written += 1
497
+ codex_dst = CODEX_DIR / "AGENTS.md"
498
+ if args.dry_run:
499
+ print(f"[dry-run] merge {codex_dst} central region (assembled at {args.version})")
500
+ else:
501
+ bak = None
502
+ if codex_dst.is_file():
503
+ bak = backup / "assembled/codex-AGENTS.md"
504
+ bak.parent.mkdir(parents=True, exist_ok=True)
505
+ bak.write_bytes(codex_dst.read_bytes())
506
+ # Merged into the file AS IT EXISTS NOW, through the same merge every
507
+ # install uses — atomic, mode-preserving, marker-bounded — never a
508
+ # whole-file write of a snapshot: an edit the user landed while the
509
+ # guides were copying rides through; only the region is ours to move.
510
+ merge_codex(CODEX_DIR, codex_central)
511
+ undo.append((codex_dst, bak))
512
+ written += 1
513
+ except (OSError, subprocess.CalledProcessError) as exc:
514
+ stuck = restore()
515
+ if stuck:
516
+ print(
517
+ f"rollback to {args.version} failed ({exc}) and {len(stuck)} file(s) could "
518
+ f"not be put back: {', '.join(stuck[:5])}. The corpus is SPLIT between "
519
+ f"versions; the previous content of every file this touched is at {backup}.",
520
+ file=sys.stderr,
521
+ )
522
+ return 1
523
+ print(
524
+ f"rollback to {args.version} failed ({exc}); every file it had already written "
525
+ f"was restored, so the corpus is unchanged. Nothing is split.",
526
+ file=sys.stderr,
527
+ )
528
+ return 1
529
+ if args.dry_run:
530
+ return 0
531
+ # Re-project, then mark the rollback (a plain project would report latest).
532
+ cmd_project(args)
533
+ # Sequential with cmd_project's lock, never nested inside it: flock on a
534
+ # second open of the same lock file would deadlock this process.
535
+ with _status_lock():
536
+ status = json.load(STATUS.open())
537
+ status["current_version"] = args.version
538
+ status["rolled_back_to"] = None if args.version == status["latest_version"] else args.version
539
+ # The next rollback's removal operand: exactly what is deployed now.
540
+ status["deployed_corpus"] = {
541
+ "version": args.version,
542
+ "files": sorted(rel for rel in target_files if deploy_target(rel) is not None),
543
+ }
544
+ _write_status(status)
545
+ print(
546
+ f"corpus rolled back to {args.version} ({commit[:12]}): "
547
+ f"{written} files written, {removed} removed; backup at {backup}. "
548
+ "System deployment (launcher, wrappers) unchanged. Roll forward by "
549
+ "rolling back to the latest version."
550
+ )
551
+ return 0
552
+
553
+
554
+ APPLY_OUTCOMES = ("applied", "canary_failed", "install_failed")
555
+
556
+
557
+ @contextlib.contextmanager
558
+ def _flock(lock_path: pathlib.Path):
559
+ """Exclusive advisory lock at `lock_path`, held for the with-block."""
560
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
561
+ # O_NOFOLLOW and no truncation: a "w" open follows a planted symlink and
562
+ # truncates its target merely by running the command.
563
+ fd = os.open(str(lock_path), os.O_CREAT | os.O_WRONLY | os.O_NOFOLLOW, 0o600)
564
+ with os.fdopen(fd, "w") as handle:
565
+ fcntl.flock(handle, fcntl.LOCK_EX)
566
+ yield
567
+
568
+
569
+ def _status_lock():
570
+ """Exclusive advisory lock over corpus-status writes. `project` and
571
+ `record-apply` both read-modify-write the whole file; unlocked, whichever
572
+ writes second silently reverts the other's fields."""
573
+ return _flock(STATUS.with_name(STATUS.name + ".lock"))
574
+
575
+
576
+ def _deploy_lock():
577
+ """Exclusive advisory lock over corpus deployment. One rollback at a time,
578
+ target to status: file writes serialized only by the status lock let two
579
+ rollbacks both report success over a split corpus. A DIFFERENT file from the
580
+ status lock, deliberately — rollback takes the status lock inside this one,
581
+ and flock on a second open of one file deadlocks a single process."""
582
+ return _flock(STATUS.with_name(STATUS.name + ".deploy.lock"))
583
+
584
+
585
+ def _write_status(status: dict) -> None:
586
+ """Every status write goes through the shared temp-write + os.replace: a
587
+ plain write_text truncates first and fills after, so an interrupted writer
588
+ left `{"current_version":` as the record and the next projection silently
589
+ replaced what it could not read."""
590
+ replace_atomically(STATUS, json.dumps(status, ensure_ascii=False, indent=1) + "\n")
591
+
592
+
593
+ def cmd_record_apply(args: argparse.Namespace) -> int:
594
+ """Record the outcome of one onboard apply into the existing status file.
595
+
596
+ Read-modify-write of `last_apply` only: the projection owns every other
597
+ field, and an outcome recorded against a status that does not exist yet
598
+ would invent one — refuse instead, loudly."""
599
+ with _status_lock():
600
+ if not STATUS.is_file():
601
+ print(f"no corpus status to record into: {STATUS}", file=sys.stderr)
602
+ return 1
603
+ try:
604
+ status = json.loads(STATUS.read_text())
605
+ except ValueError as exc:
606
+ print(f"corpus status unreadable: {exc}", file=sys.stderr)
607
+ return 1
608
+ status["last_apply"] = {
609
+ "requested": sorted(d for d in args.requested.split(",") if d),
610
+ "outcome": args.outcome,
611
+ "at": datetime.datetime.now().isoformat(timespec="seconds"),
612
+ "error_tail": args.error_tail or None,
613
+ }
614
+ try:
615
+ _write_status(status)
616
+ except OSError as exc:
617
+ print(f"cannot record last_apply ({exc}); the prior status is intact",
618
+ file=sys.stderr)
619
+ return 1
620
+ print(f"last_apply recorded: {args.outcome}")
621
+ return 0
622
+
623
+
624
+ def self_test() -> int:
625
+ """A rollback that fails partway must leave the corpus at ONE version — and a
626
+ clean one must move EVERY reader surface, remove what the deployed version
627
+ alone carried, refuse when it cannot know what is deployed, run one at a
628
+ time, and never leave the status file truncated.
629
+
630
+ Driven against a throwaway git repo rather than the real registry, because every
631
+ version registered today is UNAVAILABLE (it predates the assembled layout), so the
632
+ write loop is unreachable from `list` and this path would otherwise be covered by
633
+ nothing at all. The failing run and the clean one differ only in whether one write
634
+ raises — without the clean one, a rollback that wrote nothing would satisfy the
635
+ failing case too. The fixture's assemble.py is a runnable stand-in with the real
636
+ assembler's calling convention; the convention itself is pinned against the real
637
+ compose/assemble.py by the --help probe below.
638
+ """
639
+ # The variables that relocate a repository — git's own list, `local_repo_env` in
640
+ # environment.c, the ones it clears before entering another repo. The pre-commit hook
641
+ # exports GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE so the gates read the real index
642
+ # against the materialised stage, and this self-test runs under it. Inherited by the
643
+ # fixture they make `git init` re-initialise the REAL repository — writing
644
+ # core.worktree = the stage directory into its config, which outlives the stage and
645
+ # leaves the primary worktree unable to run `git status` — and point every later
646
+ # command, `cmd_rollback`'s included, at that repo instead of the fixture. So the
647
+ # fixture is only a fixture inside this scrub, and everything that touches it runs
648
+ # inside it.
649
+ REPO_ENV = ("GIT_DIR", "GIT_WORK_TREE", "GIT_IMPLICIT_WORK_TREE", "GIT_INDEX_FILE",
650
+ "GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES",
651
+ "GIT_PREFIX", "GIT_GRAFT_FILE", "GIT_SHALLOW_FILE", "GIT_NO_REPLACE_OBJECTS",
652
+ "GIT_REPLACE_REF_BASE", "GIT_CONFIG")
653
+
654
+ @contextlib.contextmanager
655
+ def own_repo_env():
656
+ saved = {name: os.environ.pop(name) for name in REPO_ENV if name in os.environ}
657
+ try:
658
+ yield
659
+ finally:
660
+ os.environ.update(saved)
661
+
662
+ def git(repo, *command):
663
+ return subprocess.run(["git", "-C", str(repo), *command], check=True,
664
+ capture_output=True, text=True).stdout.strip()
665
+
666
+ MINI_ASSEMBLER = r'''#!/usr/bin/env python3
667
+ """Self-test stand-in with the REAL assembler's calling convention. It does what
668
+ cmd_rollback relies on the assembler for: build the bundle from ITS OWN tree and
669
+ merge the codex AGENTS.md marker region, preserving text outside the markers."""
670
+ import argparse
671
+ import pathlib
672
+ ap = argparse.ArgumentParser()
673
+ ap.add_argument("--domains", required=True)
674
+ ap.add_argument("--claude-dir", required=True)
675
+ ap.add_argument("--codex-dir", required=True)
676
+ ap.add_argument("--state-dir", required=True)
677
+ a = ap.parse_args()
678
+ repo = pathlib.Path(__file__).resolve().parents[1]
679
+ claude = pathlib.Path(a.claude_dir)
680
+ codex = pathlib.Path(a.codex_dir)
681
+ (claude / "central").mkdir(parents=True, exist_ok=True)
682
+ stamp = (repo / "BUNDLE_STAMP").read_text().strip()
683
+ (claude / "central" / "bundle.md").write_text("BUNDLE " + stamp + "\n")
684
+ start = "<!-- agent-bios:central:start -->"
685
+ end = "<!-- agent-bios:central:end -->"
686
+ agents = codex / "AGENTS.md"
687
+ region = start + "\nCODEX " + stamp + "\n" + end + "\n"
688
+ if agents.is_file():
689
+ body = agents.read_text()
690
+ if start in body and end in body:
691
+ pre, rest = body.split(start, 1)
692
+ _, post = rest.split(end, 1)
693
+ body = pre + region + post
694
+ else:
695
+ body = region + "\n" + body
696
+ else:
697
+ codex.mkdir(parents=True, exist_ok=True)
698
+ body = region + "\n## Personal\n"
699
+ agents.write_text(body)
700
+ '''
701
+
702
+ def build_fixture(repo):
703
+ """Two corpus commits plus the registry: t1 carries a version-only file
704
+ (legacy.md) that t2 deletes, and each commit's stand-in assembler stamps
705
+ its own bundle — so a rollback that skips assembly, or derives removals
706
+ from the wrong version, is visible in the files."""
707
+ (repo / "claude" / "guides").mkdir(parents=True)
708
+ for index in range(6):
709
+ (repo / "claude" / "guides" / f"g{index}.md").write_text(f"TARGET {index}\n")
710
+ (repo / "claude" / "guides" / "legacy.md").write_text("OLD ONLY\n")
711
+ (repo / "BUNDLE_STAMP").write_text("one\n")
712
+ # `deployable` refuses a commit that predates the assembled layout, and it is
713
+ # right to: without these the bundle cannot be rebuilt. The fixture carries a
714
+ # RUNNABLE assemble.py so the assembly step is REACHED — a self-test that
715
+ # stops at that refusal would report OK having exercised nothing.
716
+ (repo / "compose").mkdir(parents=True, exist_ok=True)
717
+ (repo / "compose" / "assemble.py").write_text(MINI_ASSEMBLER)
718
+ (repo / "compose" / "domains.json").write_text(json.dumps({"domains": []}) + "\n")
719
+ with own_repo_env():
720
+ git(repo, "init", "-q")
721
+ git(repo, "add", "-A")
722
+ git(repo, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "old")
723
+ old = git(repo, "rev-parse", "HEAD")
724
+ for index in range(6):
725
+ (repo / "claude" / "guides" / f"g{index}.md").write_text(f"NEWER {index}\n")
726
+ (repo / "claude" / "guides" / "legacy.md").unlink()
727
+ (repo / "claude" / "guides" / "fresh.md").write_text("NEW ONLY\n")
728
+ (repo / "BUNDLE_STAMP").write_text("two\n")
729
+ with own_repo_env():
730
+ git(repo, "add", "-A")
731
+ git(repo, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "new")
732
+ new = git(repo, "rev-parse", "HEAD")
733
+ registry = repo / "design" / "session-distill"
734
+ registry.mkdir(parents=True)
735
+ (registry / "versions.json").write_text(json.dumps({"versions": [
736
+ {"version": "t1", "commit": old, "closed": "2026-01-01", "summary": "old"},
737
+ {"version": "t2", "commit": new, "closed": "2026-01-02", "summary": "new"},
738
+ ]}))
739
+ (registry / "ledger.json").write_text(json.dumps({"entries": []}))
740
+ with own_repo_env():
741
+ git(repo, "add", "-A")
742
+ git(repo, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "registry")
743
+ return old, new
744
+
745
+ problems = []
746
+ root = pathlib.Path(tempfile.mkdtemp(prefix="corpus-state-selftest-"))
747
+ try:
748
+ # Control for the scrub, run FIRST and under the hook's environment whether or
749
+ # not the hook is present: an "outer" repo stands in for the real one, GIT_DIR
750
+ # points at it, and building a fixture must leave it untouched — its
751
+ # core.worktree unset, the fixture holding its own HEAD. Without the scrub the
752
+ # first `git init` rewrites the outer config, which is exactly the defect.
753
+ outer = root / "outer"
754
+ outer.mkdir()
755
+ with own_repo_env():
756
+ git(outer, "init", "-q")
757
+ git(outer, "-c", "user.email=t@t", "-c", "user.name=t",
758
+ "commit", "-q", "--allow-empty", "-m", "outer")
759
+ stage = root / "stage"
760
+ stage.mkdir()
761
+ planted = {"GIT_DIR": str(outer / ".git"), "GIT_WORK_TREE": str(stage),
762
+ "GIT_INDEX_FILE": str(outer / ".git" / "index")}
763
+ saved_env = {name: os.environ.get(name) for name in planted}
764
+ os.environ.update(planted)
765
+ try:
766
+ build_fixture(root / "control")
767
+ except subprocess.CalledProcessError as exc:
768
+ # Caught rather than propagated so this reads as the defect it is: an
769
+ # uninsulated fixture's commands land in the outer repo and fail there.
770
+ problems.append(
771
+ "building the fixture under an exported GIT_DIR did not build one — "
772
+ f"`git {' '.join(exc.cmd[3:])}` failed against the wrong repository")
773
+ finally:
774
+ for name, value in saved_env.items():
775
+ if value is None:
776
+ os.environ.pop(name, None)
777
+ else:
778
+ os.environ[name] = value
779
+ with own_repo_env():
780
+ outer_worktree = subprocess.run(
781
+ ["git", "-C", str(outer), "config", "--get", "core.worktree"],
782
+ capture_output=True, text=True).stdout.strip()
783
+ if outer_worktree:
784
+ problems.append(
785
+ "building the fixture under an exported GIT_DIR rewrote the outer repo's "
786
+ f"core.worktree to {outer_worktree} — the fixture is not insulated")
787
+ if not (root / "control" / ".git" / "HEAD").exists():
788
+ problems.append(
789
+ "building the fixture under an exported GIT_DIR left it without a "
790
+ "repository of its own")
791
+
792
+ repo = root / "repo"
793
+ build_fixture(repo)
794
+
795
+ # The calling convention the mini-assembler stands in for, pinned against
796
+ # the REAL assembler: if compose/assemble.py stops answering for these
797
+ # flags, every fixture here keeps passing while live rollbacks break.
798
+ real_assembler = repo_root(None) / "compose" / "assemble.py"
799
+ probe = subprocess.run([sys.executable, str(real_assembler), "--help"],
800
+ capture_output=True, text=True)
801
+ missing = [flag for flag in ("--domains", "--claude-dir", "--codex-dir",
802
+ "--state-dir")
803
+ if flag not in probe.stdout]
804
+ if probe.returncode != 0 or missing:
805
+ problems.append(
806
+ "cmd_rollback dispatches --domains/--claude-dir/--codex-dir/--state-dir "
807
+ "to the target commit's assemble.py, but the real assembler no longer "
808
+ f"answers for: {missing or probe.stderr.strip()[:120]}")
809
+
810
+ codex_seed = ("<!-- agent-bios:central:start -->\nCODEX two\n"
811
+ "<!-- agent-bios:central:end -->\n\n## Personal\nMY OWN CODEX LINE\n")
812
+
813
+ def attempt(fail_at):
814
+ home = root / f"home-{fail_at}"
815
+ claude = home / ".claude"
816
+ codex_home = home / ".codex"
817
+ saved_claude = globals()["CLAUDE_DIR"]
818
+ globals()["CLAUDE_DIR"] = claude
819
+ # Located through deploy_target, not by guessing the layout: the corpus root
820
+ # moved once already, and a fixture that seeds the wrong directory asserts
821
+ # that an untouched file is untouched.
822
+ seeded = deploy_target("claude/guides/g0.md")
823
+ globals()["CLAUDE_DIR"] = saved_claude
824
+ seeded.parent.mkdir(parents=True, exist_ok=True)
825
+ seeded.write_text("PREVIOUS\n")
826
+ (claude / "central" / "bundle.md").write_text("LIVE BUNDLE\n")
827
+ codex_home.mkdir(parents=True, exist_ok=True)
828
+ (codex_home / "AGENTS.md").write_text(codex_seed)
829
+ # A user-restricted mode must ride through the region merge.
830
+ (codex_home / "AGENTS.md").chmod(0o600)
831
+ counter = [0]
832
+ real_write = pathlib.Path.write_bytes
833
+
834
+ def flaky(self, data):
835
+ if str(self).startswith(str(claude)) and "backups" not in str(self):
836
+ counter[0] += 1
837
+ if counter[0] == fail_at:
838
+ raise OSError("self-test write failure")
839
+ return real_write(self, data)
840
+
841
+ real_assemble_at = globals()["_assemble_at"]
842
+
843
+ def editing_assemble_at(repo_arg, commit_arg, domains_arg):
844
+ # The user's edit landing in the window between the assembly
845
+ # snapshot and publication: the merge must carry it through,
846
+ # never overwrite it with the pre-assembly state of the file.
847
+ result = real_assemble_at(repo_arg, commit_arg, domains_arg)
848
+ agents = codex_home / "AGENTS.md"
849
+ agents.write_text(agents.read_text().replace(
850
+ "MY OWN CODEX LINE", "MY OWN CODEX LINE\nMID-FLIGHT EDIT"))
851
+ return result
852
+
853
+ saved = (globals()["CLAUDE_DIR"], globals()["CODEX_DIR"],
854
+ globals()["STATE_DIR"], globals()["STATUS"],
855
+ globals()["cmd_project"], globals()["_assemble_at"])
856
+ globals()["CLAUDE_DIR"] = claude
857
+ globals()["CODEX_DIR"] = codex_home
858
+ globals()["STATE_DIR"] = home / "state"
859
+ globals()["STATUS"] = home / "state" / "corpus-status.json"
860
+ # The projection reads the real ledger and is a different question; what this
861
+ # asserts is that the FILES end up at one version. Stubbed so the fixture does
862
+ # not have to carry a ledger to answer a question it is not asking.
863
+ (home / "state").mkdir(parents=True, exist_ok=True)
864
+ (home / "state" / "selection.json").write_text(
865
+ json.dumps({"version": 1, "domains": []}))
866
+ globals()["STATUS"].write_text(json.dumps(
867
+ {"current_version": "before", "latest_version": "t1"}))
868
+ globals()["cmd_project"] = lambda _args: 0
869
+ globals()["_assemble_at"] = editing_assemble_at
870
+ pathlib.Path.write_bytes = flaky
871
+ try:
872
+ code = cmd_rollback(argparse.Namespace(
873
+ repo=str(repo), version="t1", dry_run=False))
874
+ except Exception as exc: # a raise is itself the defect this asserts against
875
+ code = f"raised {type(exc).__name__}"
876
+ finally:
877
+ pathlib.Path.write_bytes = real_write
878
+ (globals()["CLAUDE_DIR"], globals()["CODEX_DIR"],
879
+ globals()["STATE_DIR"], globals()["STATUS"],
880
+ globals()["cmd_project"], globals()["_assemble_at"]) = saved
881
+ landed = sorted(q.name for q in seeded.parent.glob("*.md"))
882
+ return (code, landed, seeded.read_text(),
883
+ (claude / "central" / "bundle.md").read_text(),
884
+ (codex_home / "AGENTS.md").read_text(),
885
+ (codex_home / "AGENTS.md").stat().st_mode & 0o777)
886
+
887
+ # `cmd_rollback` reads the fixture through `git -C <repo>`, which the hook's
888
+ # GIT_DIR would redirect at the real repository — same scrub, same reason.
889
+ with own_repo_env():
890
+ code, landed, seeded, bundle_text, agents_text, agents_mode = attempt(0)
891
+ if code != 0 or len(landed) < 6 or seeded.strip() != "TARGET 0":
892
+ problems.append(
893
+ f"clean rollback did not apply the corpus (code={code} files={len(landed)})")
894
+ if "MID-FLIGHT EDIT" not in agents_text:
895
+ problems.append(
896
+ "a user edit landing between the assembly snapshot and publication "
897
+ "was overwritten — AGENTS.md must be merged as it exists at "
898
+ "publication, never replaced by a stale snapshot")
899
+ if agents_mode != 0o600:
900
+ problems.append(
901
+ f"publishing the codex region widened a user-restricted AGENTS.md "
902
+ f"from 0o600 to {oct(agents_mode)}")
903
+ # The surfaces the agent actually reads must move WITH the guides: the
904
+ # defect was a rollback that reported an older version while the bundle
905
+ # and the codex central region silently stayed current.
906
+ if bundle_text.strip() != "BUNDLE one":
907
+ problems.append(
908
+ "rollback moved the guides but left the live bundle at the current "
909
+ "version — the assembled surface the agent reads did not roll back")
910
+ if "CODEX one" not in agents_text or "CODEX two" in agents_text:
911
+ problems.append(
912
+ "rollback did not rewrite the codex AGENTS.md central region to the "
913
+ "target version")
914
+ if "MY OWN CODEX LINE" not in agents_text:
915
+ problems.append("rollback destroyed the user's text outside the codex markers")
916
+ with own_repo_env():
917
+ code, landed, seeded, bundle_text, agents_text, agents_mode = attempt(3)
918
+ if code == 0:
919
+ problems.append("a rollback whose write failed reported success")
920
+ elif not isinstance(code, int):
921
+ problems.append(f"a failed rollback escaped as an exception ({code})")
922
+ if seeded.strip() != "PREVIOUS":
923
+ problems.append(
924
+ "a failed rollback left a file at the target version — the corpus is split")
925
+ if bundle_text.strip() != "LIVE BUNDLE":
926
+ problems.append(
927
+ "a failed rollback moved the live bundle — the reader surface is split")
928
+ if "CODEX two" not in agents_text or "MY OWN CODEX LINE" not in agents_text:
929
+ problems.append("a failed rollback did not leave the codex surface as it was")
930
+
931
+ # ---- The real CLI end to end, subprocess-driven against one fixture. ----
932
+ fx_home = root / "fx-home"
933
+ fx_claude = root / "fx-claude"
934
+ fx_codex = root / "fx-codex"
935
+ fx_tmp = root / "fx-tmp"
936
+ fx_state = fx_home / ".local" / "share" / "agent-bios"
937
+ fx_status = fx_state / "corpus-status.json"
938
+ for directory in (fx_home, fx_claude, fx_codex, fx_tmp, fx_state):
939
+ directory.mkdir(parents=True, exist_ok=True)
940
+ (fx_state / "selection.json").write_text(json.dumps({"version": 1, "domains": []}))
941
+ guides_live = fx_claude / "central" / "guides"
942
+ guides_live.mkdir(parents=True)
943
+ for index in range(6):
944
+ (guides_live / f"g{index}.md").write_text(f"NEWER {index}\n")
945
+ (guides_live / "fresh.md").write_text("NEW ONLY\n")
946
+ (fx_claude / "central" / "bundle.md").write_text("BUNDLE two\n")
947
+ (fx_codex / "AGENTS.md").write_text(codex_seed)
948
+ env = {name: value for name, value in os.environ.items() if name not in REPO_ENV}
949
+ env.update({"HOME": str(fx_home), "TMPDIR": str(fx_tmp),
950
+ "CLAUDE_CONFIG_DIR": str(fx_claude), "CODEX_HOME": str(fx_codex),
951
+ "AGENT_BIOS_CORPUS_STATUS": str(fx_status)})
952
+ me = pathlib.Path(__file__).resolve()
953
+
954
+ def run_cli(*argv):
955
+ return subprocess.run([sys.executable, str(me), *argv, "--repo", str(repo)],
956
+ capture_output=True, text=True, env=env)
957
+
958
+ project = run_cli("project")
959
+ if project.returncode != 0:
960
+ problems.append(f"subprocess project failed: {project.stderr.strip()[:200]}")
961
+ back = run_cli("rollback", "--version", "t1")
962
+ legacy = guides_live / "legacy.md"
963
+ fresh = guides_live / "fresh.md"
964
+ if back.returncode != 0:
965
+ problems.append("subprocess rollback to t1 failed: "
966
+ f"{(back.stdout + back.stderr).strip()[:200]}")
967
+ else:
968
+ if not legacy.is_file():
969
+ problems.append(
970
+ "rolling back did not deploy the file only the older version carries")
971
+ if fresh.exists():
972
+ problems.append("rolling back left a file the target version does not carry")
973
+ forward = run_cli("rollback", "--version", "t2")
974
+ if forward.returncode != 0:
975
+ problems.append("subprocess roll-forward to t2 failed: "
976
+ f"{(forward.stdout + forward.stderr).strip()[:200]}")
977
+ else:
978
+ # THE removal control: the operand must be what is deployed, and a
979
+ # gutted removal loop must be caught by a surviving file, not by a
980
+ # count nobody asserts.
981
+ if legacy.exists():
982
+ problems.append(
983
+ "rolling forward left a file that only the rolled-back version "
984
+ "deploys — the removal operand ignores what is actually deployed")
985
+ if (not fresh.is_file()
986
+ or (fx_claude / "central" / "bundle.md").read_text().strip() != "BUNDLE two"):
987
+ problems.append("rolling forward did not restore the newer corpus and bundle")
988
+ state = json.loads(fx_status.read_text())
989
+ if state.get("current_version") != "t2" or state.get("rolled_back_to") is not None:
990
+ problems.append("roll-forward status does not name the target version")
991
+ marks = [result.stdout.partition("backup at ")[2].partition(". System")[0]
992
+ for result in (back, forward)]
993
+ if all(marks) and marks[0] == marks[1]:
994
+ problems.append(
995
+ "two rollbacks shared one backup directory — their undo copies collide")
996
+
997
+ # ---- Mutual exclusion: a held deploy lock must queue a second rollback. ----
998
+ deploy_lock_path = fx_status.with_name(fx_status.name + ".deploy.lock")
999
+ lock_fd = os.open(str(deploy_lock_path), os.O_CREAT | os.O_WRONLY, 0o600)
1000
+ fcntl.flock(lock_fd, fcntl.LOCK_EX)
1001
+ g0_live = guides_live / "g0.md"
1002
+ held = subprocess.Popen(
1003
+ [sys.executable, str(me), "rollback", "--version", "t1", "--repo", str(repo)],
1004
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env)
1005
+ try:
1006
+ held.wait(timeout=1.5)
1007
+ problems.append(
1008
+ "a rollback proceeded while another deployment held the deploy lock "
1009
+ "— concurrent rollbacks would both report success over a split corpus")
1010
+ except subprocess.TimeoutExpired:
1011
+ if g0_live.read_text() != "NEWER 0\n":
1012
+ problems.append("a lock-blocked rollback wrote files before holding the lock")
1013
+ os.close(lock_fd)
1014
+ try:
1015
+ queued = held.wait(timeout=60)
1016
+ except subprocess.TimeoutExpired:
1017
+ held.kill()
1018
+ held.wait()
1019
+ queued = None
1020
+ problems.append("a queued rollback never completed after the lock was released")
1021
+ if queued is not None and (queued != 0 or g0_live.read_text() != "TARGET 0\n"):
1022
+ problems.append("the queued rollback failed after the deploy lock was released")
1023
+
1024
+ # ---- A selection that vanishes while waiting is refused BY NAME. ----
1025
+ # The wait behind another deployment is exactly when the world changes:
1026
+ # unvalidated, the locked read escalated into a raw TypeError.
1027
+ selection_path = fx_state / "selection.json"
1028
+ saved_selection = selection_path.read_text()
1029
+ lock_fd = os.open(str(deploy_lock_path), os.O_CREAT | os.O_WRONLY, 0o600)
1030
+ fcntl.flock(lock_fd, fcntl.LOCK_EX)
1031
+ vanish = subprocess.Popen(
1032
+ [sys.executable, str(me), "rollback", "--version", "t2", "--repo", str(repo)],
1033
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env)
1034
+ try:
1035
+ vanish.wait(timeout=1.0)
1036
+ problems.append(
1037
+ "a rollback proceeded while the deploy lock was held (selection leg)")
1038
+ except subprocess.TimeoutExpired:
1039
+ selection_path.unlink()
1040
+ os.close(lock_fd)
1041
+ try:
1042
+ vanish_out, vanish_err = vanish.communicate(timeout=60)
1043
+ except subprocess.TimeoutExpired:
1044
+ vanish.kill()
1045
+ vanish_out, vanish_err = vanish.communicate()
1046
+ problems.append("the selection-vanish rollback never completed")
1047
+ if vanish.returncode != 2 or "no applied domain selection" not in vanish_err:
1048
+ problems.append(
1049
+ "a selection that vanished while waiting for the deploy lock was not "
1050
+ f"refused by name (rc={vanish.returncode}: "
1051
+ f"{vanish_err.strip().splitlines()[-1][:120] if vanish_err.strip() else 'no stderr'})")
1052
+ if g0_live.read_text() != "TARGET 0\n":
1053
+ problems.append("a selection-refused rollback still moved files")
1054
+ selection_path.write_text(saved_selection)
1055
+
1056
+ # ---- Refusal when the deployed set cannot be established. ----
1057
+ fx_status.write_text(json.dumps(
1058
+ {"current_version": "ghost", "latest_version": "t2", "rolled_back_to": "ghost"}))
1059
+ snapshot = sorted(path.name for path in guides_live.glob("*.md"))
1060
+ refused = run_cli("rollback", "--version", "t2")
1061
+ if refused.returncode == 0:
1062
+ problems.append("a rollback with an unknowable deployed set proceeded anyway")
1063
+ elif "cannot establish the deployed corpus" not in refused.stderr:
1064
+ problems.append("the refusal does not name the unestablishable deployed set")
1065
+ if sorted(path.name for path in guides_live.glob("*.md")) != snapshot:
1066
+ problems.append("a refused rollback still moved files")
1067
+
1068
+ # ---- An unreadable status is quarantined and reported, never discarded. ----
1069
+ fx_status.write_bytes(b'{"current_version":')
1070
+ reproject = run_cli("project")
1071
+ quarantines = sorted(fx_state.glob("corpus-status.json.corrupt-*"))
1072
+ if reproject.returncode != 0:
1073
+ problems.append(
1074
+ f"project over a truncated status failed: {reproject.stderr.strip()[:200]}")
1075
+ else:
1076
+ if not quarantines or quarantines[-1].read_bytes() != b'{"current_version":':
1077
+ problems.append(
1078
+ "projecting over a truncated status silently discarded the damaged "
1079
+ "bytes instead of quarantining them")
1080
+ if "unreadable" not in reproject.stderr:
1081
+ problems.append("the projection did not report the unreadable status")
1082
+ try:
1083
+ json.loads(fx_status.read_text())
1084
+ except ValueError:
1085
+ problems.append("the projection left the status unreadable")
1086
+
1087
+ # ---- An interrupted status write must not truncate the record. ----
1088
+ ra_home = root / "record-apply"
1089
+ ra_home.mkdir()
1090
+ seeded_status = {"current_version": "t2", "latest_version": "t2",
1091
+ "rolled_back_to": None, "last_apply": None}
1092
+ saved_state = (globals()["STATE_DIR"], globals()["STATUS"])
1093
+ globals()["STATE_DIR"] = ra_home
1094
+ globals()["STATUS"] = ra_home / "corpus-status.json"
1095
+ globals()["STATUS"].write_text(json.dumps(seeded_status))
1096
+ real_write_text = pathlib.Path.write_text
1097
+
1098
+ def truncating(self, text, *wargs, **kw):
1099
+ # The failure a plain write_text really has: truncate, fill partway, die.
1100
+ if self.name.startswith("corpus-status.json"):
1101
+ with open(self, "w") as handle:
1102
+ handle.write(text[:19])
1103
+ raise OSError("self-test write interruption")
1104
+ return real_write_text(self, text, *wargs, **kw)
1105
+
1106
+ pathlib.Path.write_text = truncating
1107
+ try:
1108
+ code = cmd_record_apply(argparse.Namespace(
1109
+ repo=None, requested="alpha", outcome="applied", error_tail=""))
1110
+ except Exception as exc:
1111
+ code = f"raised {type(exc).__name__}"
1112
+ finally:
1113
+ pathlib.Path.write_text = real_write_text
1114
+ if not isinstance(code, int) or code == 0:
1115
+ problems.append(
1116
+ f"an interrupted status write did not report failure (code={code})")
1117
+ try:
1118
+ after = json.loads(globals()["STATUS"].read_text())
1119
+ except ValueError:
1120
+ after = None
1121
+ problems.append(
1122
+ "an interrupted status write left the corpus status truncated — "
1123
+ "the write is not atomic")
1124
+ if after is not None and after != seeded_status:
1125
+ problems.append("an interrupted status write altered the recorded status")
1126
+ (globals()["STATE_DIR"], globals()["STATUS"]) = saved_state
1127
+ except subprocess.CalledProcessError as exc:
1128
+ # A fixture command failing outside the control above is the same defect seen
1129
+ # from the main run — named here so the gate reports it instead of a traceback.
1130
+ problems.append(
1131
+ f"a git command against the fixture failed (`git {' '.join(exc.cmd[3:])}`) — "
1132
+ "is the fixture insulated from the caller's GIT_DIR?")
1133
+ finally:
1134
+ shutil.rmtree(root, ignore_errors=True)
1135
+
1136
+ for problem in problems:
1137
+ print(f"corpus-state --self-test: FAIL: {problem}", file=sys.stderr)
1138
+ if problems:
1139
+ return 1
1140
+ print("corpus-state --self-test: OK (a failed rollback restores; a clean one moves "
1141
+ "every reader surface, removes what only the deployed version carried, runs "
1142
+ "one at a time, and status writes stay atomic)")
1143
+ return 0
1144
+
1145
+
1146
+ def main() -> int:
1147
+ # Before argparse, because --self-test takes none of the subcommands' arguments and
1148
+ # every subcommand here is required.
1149
+ if "--self-test" in sys.argv[1:]:
1150
+ os.environ["AGENT_BIOS_LEGACY_INSTALL"] = "1"
1151
+ return self_test()
1152
+ parser = argparse.ArgumentParser(description=__doc__)
1153
+ sub = parser.add_subparsers(dest="cmd", required=True)
1154
+ commands = {
1155
+ name: sub.add_parser(name)
1156
+ for name in ("project", "list", "rollback", "record-apply")
1157
+ }
1158
+ for command in commands.values():
1159
+ command.add_argument("--repo", help="repo root (default: derived from this script's path)")
1160
+ commands["rollback"].add_argument("--version", required=True)
1161
+ commands["rollback"].add_argument("--dry-run", action="store_true")
1162
+ commands["record-apply"].add_argument("--requested", required=True,
1163
+ help="comma-separated domain selection")
1164
+ commands["record-apply"].add_argument("--outcome", required=True, choices=APPLY_OUTCOMES)
1165
+ commands["record-apply"].add_argument("--error-tail", default="")
1166
+ args = parser.parse_args()
1167
+ return {
1168
+ "project": cmd_project, "list": cmd_list, "rollback": cmd_rollback,
1169
+ "record-apply": cmd_record_apply,
1170
+ }[args.cmd](args)
1171
+
1172
+
1173
+ if __name__ == "__main__":
1174
+ raise SystemExit(main())