agent-bios 0.14.0 → 0.15.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.
@@ -0,0 +1,1170 @@
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
+ repo = repo_root(args.repo)
360
+ versions = require_versions(repo, "rollback")
361
+ if versions is None:
362
+ return 2
363
+ match = [v for v in versions if v["version"] == args.version]
364
+ if not match:
365
+ known = ", ".join(v["version"] for v in versions)
366
+ print(f"unknown corpus version: {args.version} (known: {known})", file=sys.stderr)
367
+ return 2
368
+ commit = match[0]["commit"]
369
+ blocker = deployable(repo, commit)
370
+ if blocker is not None:
371
+ print(f"refusing rollback to {args.version}: that corpus predates the assembled layout "
372
+ f"({blocker} is absent at {commit[:9]}), so its bundle cannot be rebuilt and only "
373
+ "part of the corpus would move. `list` marks which versions are available.",
374
+ file=sys.stderr)
375
+ return 2
376
+ # One deployment at a time, held from target/backup calculation through the
377
+ # final status update. The status lock covers only status writes; two
378
+ # unserialized rollbacks interleaved their file writes and BOTH reported
379
+ # success over a corpus split between their targets. Every input read again
380
+ # inside is validated inside: a wait behind another deployment is exactly
381
+ # when the world changes.
382
+ with _deploy_lock():
383
+ return _locked_rollback(args, repo, versions, commit)
384
+
385
+
386
+ def _locked_rollback(args: argparse.Namespace, repo: pathlib.Path,
387
+ versions: list[dict], commit: str) -> int:
388
+ # Validated UNDER the lock, where it is read: a selection that vanishes while
389
+ # this rollback waits behind another deployment must be a named refusal, not
390
+ # whatever error an unchecked read escalates into.
391
+ domains, why = _applied_selection()
392
+ if domains is None:
393
+ print(f"refusing rollback to {args.version}: {why} — without the applied "
394
+ "selection the bundle for that corpus cannot be assembled.", file=sys.stderr)
395
+ return 2
396
+ target_files = corpus_files(repo, commit)
397
+ previous, why = _deployed_previously(repo, versions)
398
+ if previous is None:
399
+ print(f"refusing rollback to {args.version}: {why} — removals cannot be "
400
+ "derived, so files from the deployed version would silently survive.",
401
+ file=sys.stderr)
402
+ return 2
403
+ # The union: HEAD names what an install deploys, `previous` names what a
404
+ # rollback deployed, and files from either side that the target lacks must go.
405
+ removal_candidates = sorted(
406
+ (previous | set(corpus_files(repo, "HEAD"))) - set(target_files))
407
+ # Per-transaction and collision-proof: two rollbacks in one second shared a
408
+ # second-granularity directory and overwrote each other's undo copies.
409
+ backup = (STATE_DIR / "backups"
410
+ / f"corpus-rollback-{datetime.datetime.now():%Y%m%d-%H%M%S}"
411
+ f"-{os.getpid()}-{uuid.uuid4().hex[:8]}")
412
+ # Assembled BEFORE any live write: an assembly that cannot run refuses the
413
+ # whole rollback with nothing to restore.
414
+ try:
415
+ bundle, codex_central = _assemble_at(repo, commit, domains)
416
+ except (RuntimeError, OSError, subprocess.CalledProcessError) as exc:
417
+ print(f"refusing rollback to {args.version}: {exc} — nothing was written.",
418
+ file=sys.stderr)
419
+ return 1
420
+ written = removed = 0
421
+ # Every step is recorded so it can be undone. A corpus is a SET of files that agree
422
+ # about which version they are: a run that stopped in the middle left nine files at
423
+ # the target and twenty-six at the previous one, wrote no status, and reported a raw
424
+ # OSError — so the record on disk went on naming the version the corpus no longer
425
+ # was. The backup taken a line below already holds what each write replaced, which
426
+ # makes putting it back the cheap half of this; saying so when even that fails is
427
+ # the half that matters.
428
+ undo: list[tuple[pathlib.Path, pathlib.Path | None]] = []
429
+
430
+ def restore() -> list[str]:
431
+ """Put every applied file back. Returns the ones that could not be restored."""
432
+ stuck = []
433
+ for target, saved in reversed(undo):
434
+ try:
435
+ if saved is None:
436
+ target.unlink(missing_ok=True)
437
+ else:
438
+ target.write_bytes(saved.read_bytes())
439
+ except OSError:
440
+ stuck.append(str(target))
441
+ return stuck
442
+
443
+ try:
444
+ for rel in target_files:
445
+ dst = deploy_target(rel)
446
+ if dst is None:
447
+ continue
448
+ content = subprocess.run(
449
+ ["git", "-C", str(repo), "show", f"{commit}:{rel}"],
450
+ check=True, capture_output=True,
451
+ ).stdout
452
+ if args.dry_run:
453
+ print(f"[dry-run] write {dst}")
454
+ continue
455
+ bak = None
456
+ if dst.is_file():
457
+ bak = backup / rel
458
+ bak.parent.mkdir(parents=True, exist_ok=True)
459
+ bak.write_bytes(dst.read_bytes())
460
+ dst.parent.mkdir(parents=True, exist_ok=True)
461
+ dst.write_bytes(content)
462
+ undo.append((dst, bak))
463
+ if rel.startswith("claude/hooks/"):
464
+ dst.chmod(0o755)
465
+ written += 1
466
+ for rel in removal_candidates:
467
+ dst = deploy_target(rel)
468
+ if dst is None or not dst.is_file():
469
+ continue
470
+ if args.dry_run:
471
+ print(f"[dry-run] remove {dst} (absent in {args.version})")
472
+ continue
473
+ bak = backup / rel
474
+ bak.parent.mkdir(parents=True, exist_ok=True)
475
+ bak.write_bytes(dst.read_bytes())
476
+ dst.unlink()
477
+ undo.append((dst, bak))
478
+ removed += 1
479
+ # The assembled surfaces land LAST: the bundle is what the agent reads, so
480
+ # it names the target version only once every guide it references has.
481
+ bundle_dst = CLAUDE_DIR / "central" / "bundle.md"
482
+ if args.dry_run:
483
+ print(f"[dry-run] write {bundle_dst} (assembled at {args.version})")
484
+ else:
485
+ bak = None
486
+ if bundle_dst.is_file():
487
+ bak = backup / "assembled/claude-central-bundle.md"
488
+ bak.parent.mkdir(parents=True, exist_ok=True)
489
+ bak.write_bytes(bundle_dst.read_bytes())
490
+ bundle_dst.parent.mkdir(parents=True, exist_ok=True)
491
+ bundle_dst.write_bytes(bundle)
492
+ undo.append((bundle_dst, bak))
493
+ written += 1
494
+ codex_dst = CODEX_DIR / "AGENTS.md"
495
+ if args.dry_run:
496
+ print(f"[dry-run] merge {codex_dst} central region (assembled at {args.version})")
497
+ else:
498
+ bak = None
499
+ if codex_dst.is_file():
500
+ bak = backup / "assembled/codex-AGENTS.md"
501
+ bak.parent.mkdir(parents=True, exist_ok=True)
502
+ bak.write_bytes(codex_dst.read_bytes())
503
+ # Merged into the file AS IT EXISTS NOW, through the same merge every
504
+ # install uses — atomic, mode-preserving, marker-bounded — never a
505
+ # whole-file write of a snapshot: an edit the user landed while the
506
+ # guides were copying rides through; only the region is ours to move.
507
+ merge_codex(CODEX_DIR, codex_central)
508
+ undo.append((codex_dst, bak))
509
+ written += 1
510
+ except (OSError, subprocess.CalledProcessError) as exc:
511
+ stuck = restore()
512
+ if stuck:
513
+ print(
514
+ f"rollback to {args.version} failed ({exc}) and {len(stuck)} file(s) could "
515
+ f"not be put back: {', '.join(stuck[:5])}. The corpus is SPLIT between "
516
+ f"versions; the previous content of every file this touched is at {backup}.",
517
+ file=sys.stderr,
518
+ )
519
+ return 1
520
+ print(
521
+ f"rollback to {args.version} failed ({exc}); every file it had already written "
522
+ f"was restored, so the corpus is unchanged. Nothing is split.",
523
+ file=sys.stderr,
524
+ )
525
+ return 1
526
+ if args.dry_run:
527
+ return 0
528
+ # Re-project, then mark the rollback (a plain project would report latest).
529
+ cmd_project(args)
530
+ # Sequential with cmd_project's lock, never nested inside it: flock on a
531
+ # second open of the same lock file would deadlock this process.
532
+ with _status_lock():
533
+ status = json.load(STATUS.open())
534
+ status["current_version"] = args.version
535
+ status["rolled_back_to"] = None if args.version == status["latest_version"] else args.version
536
+ # The next rollback's removal operand: exactly what is deployed now.
537
+ status["deployed_corpus"] = {
538
+ "version": args.version,
539
+ "files": sorted(rel for rel in target_files if deploy_target(rel) is not None),
540
+ }
541
+ _write_status(status)
542
+ print(
543
+ f"corpus rolled back to {args.version} ({commit[:12]}): "
544
+ f"{written} files written, {removed} removed; backup at {backup}. "
545
+ "System deployment (launcher, wrappers) unchanged. Roll forward by "
546
+ "rolling back to the latest version."
547
+ )
548
+ return 0
549
+
550
+
551
+ APPLY_OUTCOMES = ("applied", "canary_failed", "install_failed")
552
+
553
+
554
+ @contextlib.contextmanager
555
+ def _flock(lock_path: pathlib.Path):
556
+ """Exclusive advisory lock at `lock_path`, held for the with-block."""
557
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
558
+ # O_NOFOLLOW and no truncation: a "w" open follows a planted symlink and
559
+ # truncates its target merely by running the command.
560
+ fd = os.open(str(lock_path), os.O_CREAT | os.O_WRONLY | os.O_NOFOLLOW, 0o600)
561
+ with os.fdopen(fd, "w") as handle:
562
+ fcntl.flock(handle, fcntl.LOCK_EX)
563
+ yield
564
+
565
+
566
+ def _status_lock():
567
+ """Exclusive advisory lock over corpus-status writes. `project` and
568
+ `record-apply` both read-modify-write the whole file; unlocked, whichever
569
+ writes second silently reverts the other's fields."""
570
+ return _flock(STATUS.with_name(STATUS.name + ".lock"))
571
+
572
+
573
+ def _deploy_lock():
574
+ """Exclusive advisory lock over corpus deployment. One rollback at a time,
575
+ target to status: file writes serialized only by the status lock let two
576
+ rollbacks both report success over a split corpus. A DIFFERENT file from the
577
+ status lock, deliberately — rollback takes the status lock inside this one,
578
+ and flock on a second open of one file deadlocks a single process."""
579
+ return _flock(STATUS.with_name(STATUS.name + ".deploy.lock"))
580
+
581
+
582
+ def _write_status(status: dict) -> None:
583
+ """Every status write goes through the shared temp-write + os.replace: a
584
+ plain write_text truncates first and fills after, so an interrupted writer
585
+ left `{"current_version":` as the record and the next projection silently
586
+ replaced what it could not read."""
587
+ replace_atomically(STATUS, json.dumps(status, ensure_ascii=False, indent=1) + "\n")
588
+
589
+
590
+ def cmd_record_apply(args: argparse.Namespace) -> int:
591
+ """Record the outcome of one onboard apply into the existing status file.
592
+
593
+ Read-modify-write of `last_apply` only: the projection owns every other
594
+ field, and an outcome recorded against a status that does not exist yet
595
+ would invent one — refuse instead, loudly."""
596
+ with _status_lock():
597
+ if not STATUS.is_file():
598
+ print(f"no corpus status to record into: {STATUS}", file=sys.stderr)
599
+ return 1
600
+ try:
601
+ status = json.loads(STATUS.read_text())
602
+ except ValueError as exc:
603
+ print(f"corpus status unreadable: {exc}", file=sys.stderr)
604
+ return 1
605
+ status["last_apply"] = {
606
+ "requested": sorted(d for d in args.requested.split(",") if d),
607
+ "outcome": args.outcome,
608
+ "at": datetime.datetime.now().isoformat(timespec="seconds"),
609
+ "error_tail": args.error_tail or None,
610
+ }
611
+ try:
612
+ _write_status(status)
613
+ except OSError as exc:
614
+ print(f"cannot record last_apply ({exc}); the prior status is intact",
615
+ file=sys.stderr)
616
+ return 1
617
+ print(f"last_apply recorded: {args.outcome}")
618
+ return 0
619
+
620
+
621
+ def self_test() -> int:
622
+ """A rollback that fails partway must leave the corpus at ONE version — and a
623
+ clean one must move EVERY reader surface, remove what the deployed version
624
+ alone carried, refuse when it cannot know what is deployed, run one at a
625
+ time, and never leave the status file truncated.
626
+
627
+ Driven against a throwaway git repo rather than the real registry, because every
628
+ version registered today is UNAVAILABLE (it predates the assembled layout), so the
629
+ write loop is unreachable from `list` and this path would otherwise be covered by
630
+ nothing at all. The failing run and the clean one differ only in whether one write
631
+ raises — without the clean one, a rollback that wrote nothing would satisfy the
632
+ failing case too. The fixture's assemble.py is a runnable stand-in with the real
633
+ assembler's calling convention; the convention itself is pinned against the real
634
+ compose/assemble.py by the --help probe below.
635
+ """
636
+ # The variables that relocate a repository — git's own list, `local_repo_env` in
637
+ # environment.c, the ones it clears before entering another repo. The pre-commit hook
638
+ # exports GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE so the gates read the real index
639
+ # against the materialised stage, and this self-test runs under it. Inherited by the
640
+ # fixture they make `git init` re-initialise the REAL repository — writing
641
+ # core.worktree = the stage directory into its config, which outlives the stage and
642
+ # leaves the primary worktree unable to run `git status` — and point every later
643
+ # command, `cmd_rollback`'s included, at that repo instead of the fixture. So the
644
+ # fixture is only a fixture inside this scrub, and everything that touches it runs
645
+ # inside it.
646
+ REPO_ENV = ("GIT_DIR", "GIT_WORK_TREE", "GIT_IMPLICIT_WORK_TREE", "GIT_INDEX_FILE",
647
+ "GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES",
648
+ "GIT_PREFIX", "GIT_GRAFT_FILE", "GIT_SHALLOW_FILE", "GIT_NO_REPLACE_OBJECTS",
649
+ "GIT_REPLACE_REF_BASE", "GIT_CONFIG")
650
+
651
+ @contextlib.contextmanager
652
+ def own_repo_env():
653
+ saved = {name: os.environ.pop(name) for name in REPO_ENV if name in os.environ}
654
+ try:
655
+ yield
656
+ finally:
657
+ os.environ.update(saved)
658
+
659
+ def git(repo, *command):
660
+ return subprocess.run(["git", "-C", str(repo), *command], check=True,
661
+ capture_output=True, text=True).stdout.strip()
662
+
663
+ MINI_ASSEMBLER = r'''#!/usr/bin/env python3
664
+ """Self-test stand-in with the REAL assembler's calling convention. It does what
665
+ cmd_rollback relies on the assembler for: build the bundle from ITS OWN tree and
666
+ merge the codex AGENTS.md marker region, preserving text outside the markers."""
667
+ import argparse
668
+ import pathlib
669
+ ap = argparse.ArgumentParser()
670
+ ap.add_argument("--domains", required=True)
671
+ ap.add_argument("--claude-dir", required=True)
672
+ ap.add_argument("--codex-dir", required=True)
673
+ ap.add_argument("--state-dir", required=True)
674
+ a = ap.parse_args()
675
+ repo = pathlib.Path(__file__).resolve().parents[1]
676
+ claude = pathlib.Path(a.claude_dir)
677
+ codex = pathlib.Path(a.codex_dir)
678
+ (claude / "central").mkdir(parents=True, exist_ok=True)
679
+ stamp = (repo / "BUNDLE_STAMP").read_text().strip()
680
+ (claude / "central" / "bundle.md").write_text("BUNDLE " + stamp + "\n")
681
+ start = "<!-- agent-bios:central:start -->"
682
+ end = "<!-- agent-bios:central:end -->"
683
+ agents = codex / "AGENTS.md"
684
+ region = start + "\nCODEX " + stamp + "\n" + end + "\n"
685
+ if agents.is_file():
686
+ body = agents.read_text()
687
+ if start in body and end in body:
688
+ pre, rest = body.split(start, 1)
689
+ _, post = rest.split(end, 1)
690
+ body = pre + region + post
691
+ else:
692
+ body = region + "\n" + body
693
+ else:
694
+ codex.mkdir(parents=True, exist_ok=True)
695
+ body = region + "\n## Personal\n"
696
+ agents.write_text(body)
697
+ '''
698
+
699
+ def build_fixture(repo):
700
+ """Two corpus commits plus the registry: t1 carries a version-only file
701
+ (legacy.md) that t2 deletes, and each commit's stand-in assembler stamps
702
+ its own bundle — so a rollback that skips assembly, or derives removals
703
+ from the wrong version, is visible in the files."""
704
+ (repo / "claude" / "guides").mkdir(parents=True)
705
+ for index in range(6):
706
+ (repo / "claude" / "guides" / f"g{index}.md").write_text(f"TARGET {index}\n")
707
+ (repo / "claude" / "guides" / "legacy.md").write_text("OLD ONLY\n")
708
+ (repo / "BUNDLE_STAMP").write_text("one\n")
709
+ # `deployable` refuses a commit that predates the assembled layout, and it is
710
+ # right to: without these the bundle cannot be rebuilt. The fixture carries a
711
+ # RUNNABLE assemble.py so the assembly step is REACHED — a self-test that
712
+ # stops at that refusal would report OK having exercised nothing.
713
+ (repo / "compose").mkdir(parents=True, exist_ok=True)
714
+ (repo / "compose" / "assemble.py").write_text(MINI_ASSEMBLER)
715
+ (repo / "compose" / "domains.json").write_text(json.dumps({"domains": []}) + "\n")
716
+ with own_repo_env():
717
+ git(repo, "init", "-q")
718
+ git(repo, "add", "-A")
719
+ git(repo, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "old")
720
+ old = git(repo, "rev-parse", "HEAD")
721
+ for index in range(6):
722
+ (repo / "claude" / "guides" / f"g{index}.md").write_text(f"NEWER {index}\n")
723
+ (repo / "claude" / "guides" / "legacy.md").unlink()
724
+ (repo / "claude" / "guides" / "fresh.md").write_text("NEW ONLY\n")
725
+ (repo / "BUNDLE_STAMP").write_text("two\n")
726
+ with own_repo_env():
727
+ git(repo, "add", "-A")
728
+ git(repo, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "new")
729
+ new = git(repo, "rev-parse", "HEAD")
730
+ registry = repo / "design" / "session-distill"
731
+ registry.mkdir(parents=True)
732
+ (registry / "versions.json").write_text(json.dumps({"versions": [
733
+ {"version": "t1", "commit": old, "closed": "2026-01-01", "summary": "old"},
734
+ {"version": "t2", "commit": new, "closed": "2026-01-02", "summary": "new"},
735
+ ]}))
736
+ (registry / "ledger.json").write_text(json.dumps({"entries": []}))
737
+ with own_repo_env():
738
+ git(repo, "add", "-A")
739
+ git(repo, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "registry")
740
+ return old, new
741
+
742
+ problems = []
743
+ root = pathlib.Path(tempfile.mkdtemp(prefix="corpus-state-selftest-"))
744
+ try:
745
+ # Control for the scrub, run FIRST and under the hook's environment whether or
746
+ # not the hook is present: an "outer" repo stands in for the real one, GIT_DIR
747
+ # points at it, and building a fixture must leave it untouched — its
748
+ # core.worktree unset, the fixture holding its own HEAD. Without the scrub the
749
+ # first `git init` rewrites the outer config, which is exactly the defect.
750
+ outer = root / "outer"
751
+ outer.mkdir()
752
+ with own_repo_env():
753
+ git(outer, "init", "-q")
754
+ git(outer, "-c", "user.email=t@t", "-c", "user.name=t",
755
+ "commit", "-q", "--allow-empty", "-m", "outer")
756
+ stage = root / "stage"
757
+ stage.mkdir()
758
+ planted = {"GIT_DIR": str(outer / ".git"), "GIT_WORK_TREE": str(stage),
759
+ "GIT_INDEX_FILE": str(outer / ".git" / "index")}
760
+ saved_env = {name: os.environ.get(name) for name in planted}
761
+ os.environ.update(planted)
762
+ try:
763
+ build_fixture(root / "control")
764
+ except subprocess.CalledProcessError as exc:
765
+ # Caught rather than propagated so this reads as the defect it is: an
766
+ # uninsulated fixture's commands land in the outer repo and fail there.
767
+ problems.append(
768
+ "building the fixture under an exported GIT_DIR did not build one — "
769
+ f"`git {' '.join(exc.cmd[3:])}` failed against the wrong repository")
770
+ finally:
771
+ for name, value in saved_env.items():
772
+ if value is None:
773
+ os.environ.pop(name, None)
774
+ else:
775
+ os.environ[name] = value
776
+ with own_repo_env():
777
+ outer_worktree = subprocess.run(
778
+ ["git", "-C", str(outer), "config", "--get", "core.worktree"],
779
+ capture_output=True, text=True).stdout.strip()
780
+ if outer_worktree:
781
+ problems.append(
782
+ "building the fixture under an exported GIT_DIR rewrote the outer repo's "
783
+ f"core.worktree to {outer_worktree} — the fixture is not insulated")
784
+ if not (root / "control" / ".git" / "HEAD").exists():
785
+ problems.append(
786
+ "building the fixture under an exported GIT_DIR left it without a "
787
+ "repository of its own")
788
+
789
+ repo = root / "repo"
790
+ build_fixture(repo)
791
+
792
+ # The calling convention the mini-assembler stands in for, pinned against
793
+ # the REAL assembler: if compose/assemble.py stops answering for these
794
+ # flags, every fixture here keeps passing while live rollbacks break.
795
+ real_assembler = repo_root(None) / "compose" / "assemble.py"
796
+ probe = subprocess.run([sys.executable, str(real_assembler), "--help"],
797
+ capture_output=True, text=True)
798
+ missing = [flag for flag in ("--domains", "--claude-dir", "--codex-dir",
799
+ "--state-dir")
800
+ if flag not in probe.stdout]
801
+ if probe.returncode != 0 or missing:
802
+ problems.append(
803
+ "cmd_rollback dispatches --domains/--claude-dir/--codex-dir/--state-dir "
804
+ "to the target commit's assemble.py, but the real assembler no longer "
805
+ f"answers for: {missing or probe.stderr.strip()[:120]}")
806
+
807
+ codex_seed = ("<!-- agent-bios:central:start -->\nCODEX two\n"
808
+ "<!-- agent-bios:central:end -->\n\n## Personal\nMY OWN CODEX LINE\n")
809
+
810
+ def attempt(fail_at):
811
+ home = root / f"home-{fail_at}"
812
+ claude = home / ".claude"
813
+ codex_home = home / ".codex"
814
+ saved_claude = globals()["CLAUDE_DIR"]
815
+ globals()["CLAUDE_DIR"] = claude
816
+ # Located through deploy_target, not by guessing the layout: the corpus root
817
+ # moved once already, and a fixture that seeds the wrong directory asserts
818
+ # that an untouched file is untouched.
819
+ seeded = deploy_target("claude/guides/g0.md")
820
+ globals()["CLAUDE_DIR"] = saved_claude
821
+ seeded.parent.mkdir(parents=True, exist_ok=True)
822
+ seeded.write_text("PREVIOUS\n")
823
+ (claude / "central" / "bundle.md").write_text("LIVE BUNDLE\n")
824
+ codex_home.mkdir(parents=True, exist_ok=True)
825
+ (codex_home / "AGENTS.md").write_text(codex_seed)
826
+ # A user-restricted mode must ride through the region merge.
827
+ (codex_home / "AGENTS.md").chmod(0o600)
828
+ counter = [0]
829
+ real_write = pathlib.Path.write_bytes
830
+
831
+ def flaky(self, data):
832
+ if str(self).startswith(str(claude)) and "backups" not in str(self):
833
+ counter[0] += 1
834
+ if counter[0] == fail_at:
835
+ raise OSError("self-test write failure")
836
+ return real_write(self, data)
837
+
838
+ real_assemble_at = globals()["_assemble_at"]
839
+
840
+ def editing_assemble_at(repo_arg, commit_arg, domains_arg):
841
+ # The user's edit landing in the window between the assembly
842
+ # snapshot and publication: the merge must carry it through,
843
+ # never overwrite it with the pre-assembly state of the file.
844
+ result = real_assemble_at(repo_arg, commit_arg, domains_arg)
845
+ agents = codex_home / "AGENTS.md"
846
+ agents.write_text(agents.read_text().replace(
847
+ "MY OWN CODEX LINE", "MY OWN CODEX LINE\nMID-FLIGHT EDIT"))
848
+ return result
849
+
850
+ saved = (globals()["CLAUDE_DIR"], globals()["CODEX_DIR"],
851
+ globals()["STATE_DIR"], globals()["STATUS"],
852
+ globals()["cmd_project"], globals()["_assemble_at"])
853
+ globals()["CLAUDE_DIR"] = claude
854
+ globals()["CODEX_DIR"] = codex_home
855
+ globals()["STATE_DIR"] = home / "state"
856
+ globals()["STATUS"] = home / "state" / "corpus-status.json"
857
+ # The projection reads the real ledger and is a different question; what this
858
+ # asserts is that the FILES end up at one version. Stubbed so the fixture does
859
+ # not have to carry a ledger to answer a question it is not asking.
860
+ (home / "state").mkdir(parents=True, exist_ok=True)
861
+ (home / "state" / "selection.json").write_text(
862
+ json.dumps({"version": 1, "domains": []}))
863
+ globals()["STATUS"].write_text(json.dumps(
864
+ {"current_version": "before", "latest_version": "t1"}))
865
+ globals()["cmd_project"] = lambda _args: 0
866
+ globals()["_assemble_at"] = editing_assemble_at
867
+ pathlib.Path.write_bytes = flaky
868
+ try:
869
+ code = cmd_rollback(argparse.Namespace(
870
+ repo=str(repo), version="t1", dry_run=False))
871
+ except Exception as exc: # a raise is itself the defect this asserts against
872
+ code = f"raised {type(exc).__name__}"
873
+ finally:
874
+ pathlib.Path.write_bytes = real_write
875
+ (globals()["CLAUDE_DIR"], globals()["CODEX_DIR"],
876
+ globals()["STATE_DIR"], globals()["STATUS"],
877
+ globals()["cmd_project"], globals()["_assemble_at"]) = saved
878
+ landed = sorted(q.name for q in seeded.parent.glob("*.md"))
879
+ return (code, landed, seeded.read_text(),
880
+ (claude / "central" / "bundle.md").read_text(),
881
+ (codex_home / "AGENTS.md").read_text(),
882
+ (codex_home / "AGENTS.md").stat().st_mode & 0o777)
883
+
884
+ # `cmd_rollback` reads the fixture through `git -C <repo>`, which the hook's
885
+ # GIT_DIR would redirect at the real repository — same scrub, same reason.
886
+ with own_repo_env():
887
+ code, landed, seeded, bundle_text, agents_text, agents_mode = attempt(0)
888
+ if code != 0 or len(landed) < 6 or seeded.strip() != "TARGET 0":
889
+ problems.append(
890
+ f"clean rollback did not apply the corpus (code={code} files={len(landed)})")
891
+ if "MID-FLIGHT EDIT" not in agents_text:
892
+ problems.append(
893
+ "a user edit landing between the assembly snapshot and publication "
894
+ "was overwritten — AGENTS.md must be merged as it exists at "
895
+ "publication, never replaced by a stale snapshot")
896
+ if agents_mode != 0o600:
897
+ problems.append(
898
+ f"publishing the codex region widened a user-restricted AGENTS.md "
899
+ f"from 0o600 to {oct(agents_mode)}")
900
+ # The surfaces the agent actually reads must move WITH the guides: the
901
+ # defect was a rollback that reported an older version while the bundle
902
+ # and the codex central region silently stayed current.
903
+ if bundle_text.strip() != "BUNDLE one":
904
+ problems.append(
905
+ "rollback moved the guides but left the live bundle at the current "
906
+ "version — the assembled surface the agent reads did not roll back")
907
+ if "CODEX one" not in agents_text or "CODEX two" in agents_text:
908
+ problems.append(
909
+ "rollback did not rewrite the codex AGENTS.md central region to the "
910
+ "target version")
911
+ if "MY OWN CODEX LINE" not in agents_text:
912
+ problems.append("rollback destroyed the user's text outside the codex markers")
913
+ with own_repo_env():
914
+ code, landed, seeded, bundle_text, agents_text, agents_mode = attempt(3)
915
+ if code == 0:
916
+ problems.append("a rollback whose write failed reported success")
917
+ elif not isinstance(code, int):
918
+ problems.append(f"a failed rollback escaped as an exception ({code})")
919
+ if seeded.strip() != "PREVIOUS":
920
+ problems.append(
921
+ "a failed rollback left a file at the target version — the corpus is split")
922
+ if bundle_text.strip() != "LIVE BUNDLE":
923
+ problems.append(
924
+ "a failed rollback moved the live bundle — the reader surface is split")
925
+ if "CODEX two" not in agents_text or "MY OWN CODEX LINE" not in agents_text:
926
+ problems.append("a failed rollback did not leave the codex surface as it was")
927
+
928
+ # ---- The real CLI end to end, subprocess-driven against one fixture. ----
929
+ fx_home = root / "fx-home"
930
+ fx_claude = root / "fx-claude"
931
+ fx_codex = root / "fx-codex"
932
+ fx_tmp = root / "fx-tmp"
933
+ fx_state = fx_home / ".local" / "share" / "agent-bios"
934
+ fx_status = fx_state / "corpus-status.json"
935
+ for directory in (fx_home, fx_claude, fx_codex, fx_tmp, fx_state):
936
+ directory.mkdir(parents=True, exist_ok=True)
937
+ (fx_state / "selection.json").write_text(json.dumps({"version": 1, "domains": []}))
938
+ guides_live = fx_claude / "central" / "guides"
939
+ guides_live.mkdir(parents=True)
940
+ for index in range(6):
941
+ (guides_live / f"g{index}.md").write_text(f"NEWER {index}\n")
942
+ (guides_live / "fresh.md").write_text("NEW ONLY\n")
943
+ (fx_claude / "central" / "bundle.md").write_text("BUNDLE two\n")
944
+ (fx_codex / "AGENTS.md").write_text(codex_seed)
945
+ env = {name: value for name, value in os.environ.items() if name not in REPO_ENV}
946
+ env.update({"HOME": str(fx_home), "TMPDIR": str(fx_tmp),
947
+ "CLAUDE_CONFIG_DIR": str(fx_claude), "CODEX_HOME": str(fx_codex),
948
+ "AGENT_BIOS_CORPUS_STATUS": str(fx_status)})
949
+ me = pathlib.Path(__file__).resolve()
950
+
951
+ def run_cli(*argv):
952
+ return subprocess.run([sys.executable, str(me), *argv, "--repo", str(repo)],
953
+ capture_output=True, text=True, env=env)
954
+
955
+ project = run_cli("project")
956
+ if project.returncode != 0:
957
+ problems.append(f"subprocess project failed: {project.stderr.strip()[:200]}")
958
+ back = run_cli("rollback", "--version", "t1")
959
+ legacy = guides_live / "legacy.md"
960
+ fresh = guides_live / "fresh.md"
961
+ if back.returncode != 0:
962
+ problems.append("subprocess rollback to t1 failed: "
963
+ f"{(back.stdout + back.stderr).strip()[:200]}")
964
+ else:
965
+ if not legacy.is_file():
966
+ problems.append(
967
+ "rolling back did not deploy the file only the older version carries")
968
+ if fresh.exists():
969
+ problems.append("rolling back left a file the target version does not carry")
970
+ forward = run_cli("rollback", "--version", "t2")
971
+ if forward.returncode != 0:
972
+ problems.append("subprocess roll-forward to t2 failed: "
973
+ f"{(forward.stdout + forward.stderr).strip()[:200]}")
974
+ else:
975
+ # THE removal control: the operand must be what is deployed, and a
976
+ # gutted removal loop must be caught by a surviving file, not by a
977
+ # count nobody asserts.
978
+ if legacy.exists():
979
+ problems.append(
980
+ "rolling forward left a file that only the rolled-back version "
981
+ "deploys — the removal operand ignores what is actually deployed")
982
+ if (not fresh.is_file()
983
+ or (fx_claude / "central" / "bundle.md").read_text().strip() != "BUNDLE two"):
984
+ problems.append("rolling forward did not restore the newer corpus and bundle")
985
+ state = json.loads(fx_status.read_text())
986
+ if state.get("current_version") != "t2" or state.get("rolled_back_to") is not None:
987
+ problems.append("roll-forward status does not name the target version")
988
+ marks = [result.stdout.partition("backup at ")[2].partition(". System")[0]
989
+ for result in (back, forward)]
990
+ if all(marks) and marks[0] == marks[1]:
991
+ problems.append(
992
+ "two rollbacks shared one backup directory — their undo copies collide")
993
+
994
+ # ---- Mutual exclusion: a held deploy lock must queue a second rollback. ----
995
+ deploy_lock_path = fx_status.with_name(fx_status.name + ".deploy.lock")
996
+ lock_fd = os.open(str(deploy_lock_path), os.O_CREAT | os.O_WRONLY, 0o600)
997
+ fcntl.flock(lock_fd, fcntl.LOCK_EX)
998
+ g0_live = guides_live / "g0.md"
999
+ held = subprocess.Popen(
1000
+ [sys.executable, str(me), "rollback", "--version", "t1", "--repo", str(repo)],
1001
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env)
1002
+ try:
1003
+ held.wait(timeout=1.5)
1004
+ problems.append(
1005
+ "a rollback proceeded while another deployment held the deploy lock "
1006
+ "— concurrent rollbacks would both report success over a split corpus")
1007
+ except subprocess.TimeoutExpired:
1008
+ if g0_live.read_text() != "NEWER 0\n":
1009
+ problems.append("a lock-blocked rollback wrote files before holding the lock")
1010
+ os.close(lock_fd)
1011
+ try:
1012
+ queued = held.wait(timeout=60)
1013
+ except subprocess.TimeoutExpired:
1014
+ held.kill()
1015
+ held.wait()
1016
+ queued = None
1017
+ problems.append("a queued rollback never completed after the lock was released")
1018
+ if queued is not None and (queued != 0 or g0_live.read_text() != "TARGET 0\n"):
1019
+ problems.append("the queued rollback failed after the deploy lock was released")
1020
+
1021
+ # ---- A selection that vanishes while waiting is refused BY NAME. ----
1022
+ # The wait behind another deployment is exactly when the world changes:
1023
+ # unvalidated, the locked read escalated into a raw TypeError.
1024
+ selection_path = fx_state / "selection.json"
1025
+ saved_selection = selection_path.read_text()
1026
+ lock_fd = os.open(str(deploy_lock_path), os.O_CREAT | os.O_WRONLY, 0o600)
1027
+ fcntl.flock(lock_fd, fcntl.LOCK_EX)
1028
+ vanish = subprocess.Popen(
1029
+ [sys.executable, str(me), "rollback", "--version", "t2", "--repo", str(repo)],
1030
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env)
1031
+ try:
1032
+ vanish.wait(timeout=1.0)
1033
+ problems.append(
1034
+ "a rollback proceeded while the deploy lock was held (selection leg)")
1035
+ except subprocess.TimeoutExpired:
1036
+ selection_path.unlink()
1037
+ os.close(lock_fd)
1038
+ try:
1039
+ vanish_out, vanish_err = vanish.communicate(timeout=60)
1040
+ except subprocess.TimeoutExpired:
1041
+ vanish.kill()
1042
+ vanish_out, vanish_err = vanish.communicate()
1043
+ problems.append("the selection-vanish rollback never completed")
1044
+ if vanish.returncode != 2 or "no applied domain selection" not in vanish_err:
1045
+ problems.append(
1046
+ "a selection that vanished while waiting for the deploy lock was not "
1047
+ f"refused by name (rc={vanish.returncode}: "
1048
+ f"{vanish_err.strip().splitlines()[-1][:120] if vanish_err.strip() else 'no stderr'})")
1049
+ if g0_live.read_text() != "TARGET 0\n":
1050
+ problems.append("a selection-refused rollback still moved files")
1051
+ selection_path.write_text(saved_selection)
1052
+
1053
+ # ---- Refusal when the deployed set cannot be established. ----
1054
+ fx_status.write_text(json.dumps(
1055
+ {"current_version": "ghost", "latest_version": "t2", "rolled_back_to": "ghost"}))
1056
+ snapshot = sorted(path.name for path in guides_live.glob("*.md"))
1057
+ refused = run_cli("rollback", "--version", "t2")
1058
+ if refused.returncode == 0:
1059
+ problems.append("a rollback with an unknowable deployed set proceeded anyway")
1060
+ elif "cannot establish the deployed corpus" not in refused.stderr:
1061
+ problems.append("the refusal does not name the unestablishable deployed set")
1062
+ if sorted(path.name for path in guides_live.glob("*.md")) != snapshot:
1063
+ problems.append("a refused rollback still moved files")
1064
+
1065
+ # ---- An unreadable status is quarantined and reported, never discarded. ----
1066
+ fx_status.write_bytes(b'{"current_version":')
1067
+ reproject = run_cli("project")
1068
+ quarantines = sorted(fx_state.glob("corpus-status.json.corrupt-*"))
1069
+ if reproject.returncode != 0:
1070
+ problems.append(
1071
+ f"project over a truncated status failed: {reproject.stderr.strip()[:200]}")
1072
+ else:
1073
+ if not quarantines or quarantines[-1].read_bytes() != b'{"current_version":':
1074
+ problems.append(
1075
+ "projecting over a truncated status silently discarded the damaged "
1076
+ "bytes instead of quarantining them")
1077
+ if "unreadable" not in reproject.stderr:
1078
+ problems.append("the projection did not report the unreadable status")
1079
+ try:
1080
+ json.loads(fx_status.read_text())
1081
+ except ValueError:
1082
+ problems.append("the projection left the status unreadable")
1083
+
1084
+ # ---- An interrupted status write must not truncate the record. ----
1085
+ ra_home = root / "record-apply"
1086
+ ra_home.mkdir()
1087
+ seeded_status = {"current_version": "t2", "latest_version": "t2",
1088
+ "rolled_back_to": None, "last_apply": None}
1089
+ saved_state = (globals()["STATE_DIR"], globals()["STATUS"])
1090
+ globals()["STATE_DIR"] = ra_home
1091
+ globals()["STATUS"] = ra_home / "corpus-status.json"
1092
+ globals()["STATUS"].write_text(json.dumps(seeded_status))
1093
+ real_write_text = pathlib.Path.write_text
1094
+
1095
+ def truncating(self, text, *wargs, **kw):
1096
+ # The failure a plain write_text really has: truncate, fill partway, die.
1097
+ if self.name.startswith("corpus-status.json"):
1098
+ with open(self, "w") as handle:
1099
+ handle.write(text[:19])
1100
+ raise OSError("self-test write interruption")
1101
+ return real_write_text(self, text, *wargs, **kw)
1102
+
1103
+ pathlib.Path.write_text = truncating
1104
+ try:
1105
+ code = cmd_record_apply(argparse.Namespace(
1106
+ repo=None, requested="alpha", outcome="applied", error_tail=""))
1107
+ except Exception as exc:
1108
+ code = f"raised {type(exc).__name__}"
1109
+ finally:
1110
+ pathlib.Path.write_text = real_write_text
1111
+ if not isinstance(code, int) or code == 0:
1112
+ problems.append(
1113
+ f"an interrupted status write did not report failure (code={code})")
1114
+ try:
1115
+ after = json.loads(globals()["STATUS"].read_text())
1116
+ except ValueError:
1117
+ after = None
1118
+ problems.append(
1119
+ "an interrupted status write left the corpus status truncated — "
1120
+ "the write is not atomic")
1121
+ if after is not None and after != seeded_status:
1122
+ problems.append("an interrupted status write altered the recorded status")
1123
+ (globals()["STATE_DIR"], globals()["STATUS"]) = saved_state
1124
+ except subprocess.CalledProcessError as exc:
1125
+ # A fixture command failing outside the control above is the same defect seen
1126
+ # from the main run — named here so the gate reports it instead of a traceback.
1127
+ problems.append(
1128
+ f"a git command against the fixture failed (`git {' '.join(exc.cmd[3:])}`) — "
1129
+ "is the fixture insulated from the caller's GIT_DIR?")
1130
+ finally:
1131
+ shutil.rmtree(root, ignore_errors=True)
1132
+
1133
+ for problem in problems:
1134
+ print(f"corpus-state --self-test: FAIL: {problem}", file=sys.stderr)
1135
+ if problems:
1136
+ return 1
1137
+ print("corpus-state --self-test: OK (a failed rollback restores; a clean one moves "
1138
+ "every reader surface, removes what only the deployed version carried, runs "
1139
+ "one at a time, and status writes stay atomic)")
1140
+ return 0
1141
+
1142
+
1143
+ def main() -> int:
1144
+ # Before argparse, because --self-test takes none of the subcommands' arguments and
1145
+ # every subcommand here is required.
1146
+ if "--self-test" in sys.argv[1:]:
1147
+ return self_test()
1148
+ parser = argparse.ArgumentParser(description=__doc__)
1149
+ sub = parser.add_subparsers(dest="cmd", required=True)
1150
+ commands = {
1151
+ name: sub.add_parser(name)
1152
+ for name in ("project", "list", "rollback", "record-apply")
1153
+ }
1154
+ for command in commands.values():
1155
+ command.add_argument("--repo", help="repo root (default: derived from this script's path)")
1156
+ commands["rollback"].add_argument("--version", required=True)
1157
+ commands["rollback"].add_argument("--dry-run", action="store_true")
1158
+ commands["record-apply"].add_argument("--requested", required=True,
1159
+ help="comma-separated domain selection")
1160
+ commands["record-apply"].add_argument("--outcome", required=True, choices=APPLY_OUTCOMES)
1161
+ commands["record-apply"].add_argument("--error-tail", default="")
1162
+ args = parser.parse_args()
1163
+ return {
1164
+ "project": cmd_project, "list": cmd_list, "rollback": cmd_rollback,
1165
+ "record-apply": cmd_record_apply,
1166
+ }[args.cmd](args)
1167
+
1168
+
1169
+ if __name__ == "__main__":
1170
+ raise SystemExit(main())