agent-bios 0.19.1 → 0.19.3

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