agent-bios 0.9.7 → 0.9.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/DEPENDENCIES.md +19 -19
  2. package/README.md +34 -11
  3. package/claude/CLAUDE.md +2 -1
  4. package/claude/guides/claude-prompting.md +1 -1
  5. package/claude/guides/cli-multi-model-workflow.md +19 -1
  6. package/claude/guides/coding-staged-workflow.md +32 -0
  7. package/claude/guides/gpt-prompting.md +1 -1
  8. package/claude/guides/learning-flow.md +5 -5
  9. package/claude/guides/llm-capability-boundary.md +6 -1
  10. package/claude/guides/session-distill-workflow.md +19 -9
  11. package/claude/guides/tooling-gotchas.md +16 -0
  12. package/claude/hooks/__pycache__/tooling-gotchas-hook.cpython-314.pyc +0 -0
  13. package/claude/hooks/tooling-gotchas-hook.py +7 -0
  14. package/codex/AGENTS.md +2 -1
  15. package/codex/guides/claude-prompting.md +1 -1
  16. package/codex/guides/cli-multi-model-workflow.md +19 -1
  17. package/codex/guides/coding-staged-workflow.md +32 -0
  18. package/codex/guides/gpt-prompting.md +1 -1
  19. package/codex/guides/learning-flow.md +5 -5
  20. package/codex/guides/llm-capability-boundary.md +6 -1
  21. package/codex/guides/session-distill-workflow.md +19 -9
  22. package/codex/guides/tooling-gotchas.md +16 -0
  23. package/{scripts → compose}/assemble.py +209 -25
  24. package/{scripts → compose}/canary.sh +14 -5
  25. package/{scripts → compose}/check-domains.py +9 -3
  26. package/{config → compose}/domains.json +1 -0
  27. package/{scripts → compose}/pkgid.py +8 -1
  28. package/compose/prune-backups.py +204 -0
  29. package/compose/register-hooks.py +44 -0
  30. package/{scripts/install.sh → install.sh} +403 -94
  31. package/launch/agent-launch.py +5294 -0
  32. package/launch/agent-launch.toml +376 -0
  33. package/{scripts → launch}/check-prompting-targets.sh +1 -1
  34. package/{scripts → launch}/provision-venv.sh +1 -1
  35. package/{scripts → learn}/check-learning.py +7 -7
  36. package/{scripts → learn}/collect-learning.py +10 -10
  37. package/{config → learn}/learning.schema.json +3 -3
  38. package/{scripts → learn}/migrate-learnings.py +95 -54
  39. package/{scripts → learn}/redact.py +4 -4
  40. package/package.json +25 -23
  41. package/wrappers/claude-run.sh +162 -0
  42. package/{scripts → wrappers}/codex-run.sh +62 -6
  43. package/config/agent-launch.toml +0 -143
  44. package/scripts/agent-launch.py +0 -2350
  45. package/scripts/check-parity.sh +0 -2003
  46. /package/{shell → launch}/agent-launch.zsh +0 -0
  47. /package/{config → learn}/promotions.json +0 -0
  48. /package/{scripts/session-cost.py → session-cost.py} +0 -0
  49. /package/{scripts → wrappers}/codex-helm.sh +0 -0
@@ -3,7 +3,7 @@
3
3
 
4
4
  After a push lands, a learning that was promoted into the shared corpus is now
5
5
  loaded from the corpus — its personal copy (written by `learn!`,
6
- scripts/collect-learning.py) would double-load. This user-side tool removes the
6
+ learn/collect-learning.py) would double-load. This user-side tool removes the
7
7
  absorbed personal copy, and ONLY it (never the user's hand-written entry
8
8
  CLAUDE.md `## Personal` section).
9
9
 
@@ -21,10 +21,11 @@ irreversible act. Every uncertainty resolves to KEEP: a foreign package we
21
21
  cannot confirm, an audience miss, an anchor absent from the corpus, a v1 record
22
22
  with no anchor to verify.
23
23
 
24
- Candidate selection (mirrors scripts/assemble.py `kept`): full install (original
25
- single-zone / no selection) -> everything installed; universal tier (core/infra)
26
- -> always installed; a domain key -> installed iff in the user's selection;
27
- anything else (env-personal / unclassified / unknown) -> KEEP.
24
+ Candidate selection (mirrors compose/assemble.py `kept`): universal tier
25
+ (core/infra) -> always installed; a domain key -> installed iff in the user's
26
+ selection; anything else (env-personal / unclassified / unknown) -> KEEP. There
27
+ is one install shape what used to be a "full" install is every domain
28
+ selected — so a selection always answers this.
28
29
 
29
30
  Run per host (mirrors collect-learning: --host + --config-dir); install.sh calls
30
31
  it after the corpus deploy. Manifest absent/empty -> no-op.
@@ -39,11 +40,14 @@ import shutil
39
40
  import sys
40
41
  import time
41
42
 
42
- sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
43
- import pkgid # noqa: E402 (sibling module; scripts/ is a flat toolbox)
43
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "compose"))
44
+ import pkgid # noqa: E402 (package-identity primitive owned by compose/)
44
45
 
45
46
  REPO = pathlib.Path(__file__).resolve().parent.parent
46
- MANIFEST = REPO / "config" / "promotions.json"
47
+ # Same home compose/corpus-state.py uses; the canary writes its activation proof here.
48
+ STATE_DIR = pathlib.Path(os.environ.get("AGENT_BIOS_STATE_DIR")
49
+ or pathlib.Path.home() / ".local/share/agent-bios")
50
+ MANIFEST = REPO / "learn" / "promotions.json"
47
51
  UNIVERSAL_TIERS = frozenset({"core", "infra"}) # == assemble.audience UNIVERSAL
48
52
  # learning_id inside a personal bullet's TRAILING comment (collect-learning
49
53
  # prose_bullet: "... <!-- learning_id: <uuid> created: <ts> -->"). Anchored to
@@ -58,7 +62,7 @@ def die(msg, code=1):
58
62
 
59
63
 
60
64
  def _load(name, filename):
61
- path = REPO / "scripts" / filename
65
+ path = REPO / "learn" / filename
62
66
  spec = importlib.util.spec_from_file_location(name, path)
63
67
  module = importlib.util.module_from_spec(spec)
64
68
  spec.loader.exec_module(module)
@@ -78,16 +82,17 @@ def load_manifest(path=MANIFEST):
78
82
  if isinstance(p, dict) and isinstance(p.get("learning_id"), str)]
79
83
 
80
84
 
81
- def make_in_bundle(full, selection):
85
+ def make_in_bundle(selection):
82
86
  """A promotion's placement AUDIENCE (tier + domains, derived by
83
87
  build-promotions from where the bullet actually landed) -> is it in THIS
84
88
  user's bundle? Mirrors assemble.py `kept`. Biased to KEEP on anything not
85
- provably installed."""
89
+ provably installed.
90
+
91
+ There is one install shape now: what used to be a "full" install is every domain
92
+ selected, so a selection always answers this and there is no mode to branch on."""
86
93
  selection = set(selection or ())
87
94
 
88
95
  def in_bundle(tier, domains):
89
- if full:
90
- return True
91
96
  if tier in UNIVERSAL_TIERS:
92
97
  return True
93
98
  if tier == "domain":
@@ -104,8 +109,8 @@ def corpus_surfaces(home):
104
109
  learning quotes the same lesson, so searching it would find the very thing
105
110
  we are deciding whether to delete and always answer yes.
106
111
  """
107
- texts = [home / "central" / "bundle.md", # packaged install
108
- home / "CLAUDE.md", home / "AGENTS.md"] # full install (entry is the corpus)
112
+ texts = [home / "central" / "bundle.md", # the assembled corpus
113
+ home / "CLAUDE.md", home / "AGENTS.md"] # entry files, for a pre-convergence layout
109
114
  dirs = [home / "central" / d for d in ("guides", "hooks", "agents")]
110
115
  dirs += [home / d for d in ("guides", "hooks", "agents")]
111
116
  return texts, dirs
@@ -227,17 +232,32 @@ def prune_codex_prose(home, remove_ids, collect, dry):
227
232
  return removed
228
233
 
229
234
 
230
- def claude_corpus_loaded(home, full):
231
- """Is the shared corpus actually LOADED for this claude home? Removing a
232
- personal copy while the corpus copy is not loaded would make the rule vanish
233
- (Review F2). Non-packaged (`full`): the corpus IS the entry monolith the
234
- deploy wrote -> loaded. Packaged: loads only via the entry's
235
- `@central/bundle.md` import; a user-owned entry that lacks it (assemble exit
236
- 2) is NOT loading central -> keep the personal copy."""
237
- if full:
238
- return True
235
+ def claude_corpus_loaded(home, state_dir=None):
236
+ """Is the shared corpus actually LOADED for this claude home?
237
+
238
+ Removing a personal copy while the corpus copy is not loaded makes the rule
239
+ vanish (Review F2). The entry import line is necessary and NOT sufficient. Testing
240
+ whether the file contains `@central/bundle.md` also passes on the string in a
241
+ fenced block or in prose, and no reading of the file can see an import the
242
+ harness was told not to load — the case PU-13 exists to name. So the
243
+ authorization is the activation canary's proof, written only after a live
244
+ session echoed the deployed bundle's rev back, and only for THAT rev: a later
245
+ reassembly invalidates it rather than inheriting it.
246
+
247
+ No proof means KEEP, not prune. A plain `install` runs no canary, so it now
248
+ prunes nothing and the next run after `onboard` does it — a kept duplicate is
249
+ redundant, a wrong removal is data loss.
250
+ """
239
251
  entry = home / "CLAUDE.md"
240
- return entry.is_file() and collect_central_import() in entry.read_text(encoding="utf-8")
252
+ if not (entry.is_file() and collect_central_import() in entry.read_text(encoding="utf-8")):
253
+ return False
254
+ bundle = home / "central" / "bundle.md"
255
+ proof = (state_dir or STATE_DIR) / "activation.txt"
256
+ if not (bundle.is_file() and proof.is_file()):
257
+ return False
258
+ rev = next((ln for ln in bundle.read_text(encoding="utf-8").splitlines()
259
+ if ln.startswith("agent-bios-bundle-rev: ")), None)
260
+ return bool(rev) and rev.strip() == proof.read_text(encoding="utf-8").strip()
241
261
 
242
262
 
243
263
  def collect_central_import():
@@ -311,13 +331,12 @@ def main():
311
331
  ap.add_argument("--host", choices=("claude", "codex"))
312
332
  ap.add_argument("--config-dir", default=None,
313
333
  help="config home (default: $CLAUDE_CONFIG_DIR / $CODEX_HOME by host)")
314
- ap.add_argument("--full", action="store_true",
315
- help="non-packaged install: the full corpus is installed")
334
+
316
335
  ap.add_argument("--selection-file",
317
336
  help="packaged install: selection.json ({\"domains\":[...]})")
318
337
  ap.add_argument("--domains", help="packaged: comma-separated selection (overrides --selection-file)")
319
338
  ap.add_argument("--manifest", default=None,
320
- help="promotion manifest (default: config/promotions.json)")
339
+ help="promotion manifest (default: learn/promotions.json)")
321
340
  ap.add_argument("--dry-run", action="store_true")
322
341
  ap.add_argument("--self-test", action="store_true")
323
342
  args = ap.parse_args()
@@ -328,10 +347,8 @@ def main():
328
347
 
329
348
  if not args.host:
330
349
  die("--host claude|codex is required")
331
- if not args.full and args.selection_file is None and args.domains is None:
332
- die("pass --full (non-packaged) or --selection-file/--domains (packaged)")
333
- if args.full and (args.selection_file is not None or args.domains is not None):
334
- die("--full is mutually exclusive with --selection-file/--domains")
350
+ if args.selection_file is None and args.domains is None:
351
+ die("pass --selection-file or --domains")
335
352
 
336
353
  promotions = load_manifest(pathlib.Path(args.manifest) if args.manifest else MANIFEST)
337
354
  if not promotions:
@@ -344,19 +361,16 @@ def main():
344
361
  # F2 gate: only prune where the shared corpus is actually loaded. Codex always
345
362
  # loads AGENTS.md's central region; claude loads central only via the entry
346
363
  # import (or inline in a full install).
347
- corpus_loaded = True if args.host == "codex" else claude_corpus_loaded(home, args.full)
364
+ corpus_loaded = True if args.host == "codex" else claude_corpus_loaded(home)
348
365
 
349
- if args.full:
350
- in_bundle = make_in_bundle(True, ())
366
+ if args.domains is not None:
367
+ selection = [d for d in args.domains.split(",") if d]
351
368
  else:
352
- if args.domains is not None:
353
- selection = [d for d in args.domains.split(",") if d]
354
- else:
355
- sel_path = pathlib.Path(args.selection_file)
356
- if not sel_path.is_file():
357
- die(f"selection file not found: {sel_path} (pass --full for a non-packaged install)")
358
- selection = json.loads(sel_path.read_text(encoding="utf-8")).get("domains", [])
359
- in_bundle = make_in_bundle(False, selection)
369
+ sel_path = pathlib.Path(args.selection_file)
370
+ if not sel_path.is_file():
371
+ die(f"selection file not found: {sel_path}")
372
+ selection = json.loads(sel_path.read_text(encoding="utf-8")).get("domains", [])
373
+ in_bundle = make_in_bundle(selection)
360
374
 
361
375
  s = migrate(home, args.host, promotions, in_bundle, collect,
362
376
  dry=args.dry_run, corpus_loaded=corpus_loaded)
@@ -370,6 +384,12 @@ def main():
370
384
  f"kept_not_in_bundle={s['kept_not_in_bundle']}")
371
385
 
372
386
 
387
+ # What a "full" install now means: every domain selected. The fixtures that used to
388
+ # pass `full=True` say it this way, because that is the shape the code has.
389
+ ALL_DOMAINS = ["builder-base", "llm-pipeline-dev", "multi-agent-orchestration",
390
+ "office-work", "visualization-docs"]
391
+
392
+
373
393
  def _self_test():
374
394
  """Temp-home verification: the safety gate (keep a promotion not in the
375
395
  user's bundle), removal of an in-bundle promotion from BOTH prose + jsonl,
@@ -425,7 +445,7 @@ def _self_test():
425
445
  # 1) Packaged, selection={builder-base}: core (universal) + builder-base
426
446
  # removed; office-work KEPT (the silent-loss guard).
427
447
  home = seed_claude()
428
- in_bundle = make_in_bundle(False, ["builder-base"])
448
+ in_bundle = make_in_bundle(["builder-base"])
429
449
  s = migrate(home, "claude", promos, in_bundle, collect)
430
450
  ids = local_ids(home)
431
451
  checks.append(("packaged: removed core+builder-base", s["removed"] == 2 and s["kept_not_in_bundle"] == 1))
@@ -443,7 +463,7 @@ def _self_test():
443
463
  # metadata-only rule deletes here and loses the learning; presence keeps.
444
464
  # If placed_here() ever returns True unconditionally, this check fails.
445
465
  home = seed_claude(placed=("core",))
446
- s = migrate(home, "claude", promos, make_in_bundle(False, ["builder-base"]), collect)
466
+ s = migrate(home, "claude", promos, make_in_bundle(["builder-base"]), collect)
447
467
  checks.append(("audience says yes but corpus lacks it -> KEEP",
448
468
  s["removed"] == 1 and s["kept_not_placed"] == 1
449
469
  and L["bb"] in local_ids(home) and md_has(home, L["bb"])))
@@ -452,7 +472,7 @@ def _self_test():
452
472
  # never acted on (stage 3 resolves package selections).
453
473
  home = seed_claude()
454
474
  foreign = [dict(promos[1], package_id="@acme/security")]
455
- s = migrate(home, "claude", foreign, make_in_bundle(True, ()), collect)
475
+ s = migrate(home, "claude", foreign, make_in_bundle(ALL_DOMAINS), collect)
456
476
  checks.append(("foreign package -> KEEP",
457
477
  s["removed"] == 0 and s["kept_foreign_package"] == 1))
458
478
 
@@ -468,13 +488,13 @@ def _self_test():
468
488
 
469
489
  # 2) Full (non-packaged) install: everything in bundle -> all removed.
470
490
  home = seed_claude()
471
- s = migrate(home, "claude", promos, make_in_bundle(True, ()), collect)
491
+ s = migrate(home, "claude", promos, make_in_bundle(ALL_DOMAINS), collect)
472
492
  checks.append(("full install: all 3 removed", s["removed"] == 3 and not local_ids(home)))
473
493
 
474
494
  # 3) dry-run changes nothing on disk.
475
495
  home = seed_claude()
476
496
  before = (home / "personal" / "learnings.jsonl").read_text(encoding="utf-8")
477
- migrate(home, "claude", promos, make_in_bundle(True, ()), collect, dry=True)
497
+ migrate(home, "claude", promos, make_in_bundle(ALL_DOMAINS), collect, dry=True)
478
498
  checks.append(("dry-run writes nothing",
479
499
  (home / "personal" / "learnings.jsonl").read_text(encoding="utf-8") == before))
480
500
 
@@ -486,7 +506,7 @@ def _self_test():
486
506
  r = rec(L["bb"], "builder-base")
487
507
  f.write(json.dumps(r, ensure_ascii=False) + "\n")
488
508
  collect.apply_codex(chome, collect.prose_bullet(r), dry=False)
489
- s = migrate(chome, "codex", promos, make_in_bundle(True, ()), collect)
509
+ s = migrate(chome, "codex", promos, make_in_bundle(ALL_DOMAINS), collect)
490
510
  agents_txt = (chome / "AGENTS.md").read_text(encoding="utf-8")
491
511
  checks.append(("codex: bullet removed from AGENTS.md region",
492
512
  s["prose_removed"] == 1 and L["bb"] not in agents_txt))
@@ -495,17 +515,38 @@ def _self_test():
495
515
  # corpus is NOT loaded -> keep everything (never orphan a personal copy).
496
516
  home = seed_claude() # apply_claude writes an entry WITHOUT @central/bundle.md
497
517
  checks.append(("F2: unwired packaged entry -> corpus not loaded",
498
- claude_corpus_loaded(home, full=False) is False))
499
- s = migrate(home, "claude", promos, make_in_bundle(False, ["builder-base"]),
518
+ claude_corpus_loaded(home) is False))
519
+ s = migrate(home, "claude", promos, make_in_bundle(["builder-base"]),
500
520
  collect, corpus_loaded=False)
501
521
  checks.append(("F2: corpus-not-loaded keeps ALL",
502
522
  s.get("skipped") == "corpus-not-loaded"
503
523
  and local_ids(home) == {L["core"], L["bb"], L["off"]}))
504
524
  entry = home / "CLAUDE.md"
505
525
  entry.write_text(entry.read_text(encoding="utf-8") + "\n@central/bundle.md\n", encoding="utf-8")
506
- checks.append(("F2: wired entry -> corpus loaded",
507
- claude_corpus_loaded(home, full=False) is True))
508
- checks.append(("F2: full install always loaded", claude_corpus_loaded(home, full=True) is True))
526
+ # The import line is necessary and NOT sufficient. Reading the file cannot see an import the
527
+ # harness declined, which is what the canary exists to detect, so the authorization is its
528
+ # recorded proof and the proof is bound to a rev, so a reassembly does not inherit it.
529
+ checks.append(("F2: wired entry, no activation proof -> KEEP",
530
+ claude_corpus_loaded(home, state_dir=home / "state") is False))
531
+ bundle = home / "central" / "bundle.md"
532
+ bundle.write_text(bundle.read_text(encoding="utf-8") + "\nagent-bios-bundle-rev: deadbeef\n",
533
+ encoding="utf-8")
534
+ state = home / "state"
535
+ state.mkdir(parents=True, exist_ok=True)
536
+ (state / "activation.txt").write_text("agent-bios-bundle-rev: deadbeef\n", encoding="utf-8")
537
+ checks.append(("F2: wired entry + matching proof -> corpus loaded",
538
+ claude_corpus_loaded(home, state_dir=state) is True))
539
+ (state / "activation.txt").write_text("agent-bios-bundle-rev: 00000000\n", encoding="utf-8")
540
+ checks.append(("F2: proof for a different rev -> KEEP",
541
+ claude_corpus_loaded(home, state_dir=state) is False))
542
+ # CONTRAST CONTROL for the substring hole: the import string inside a fenced block is not an
543
+ # import, and before the proof requirement this alone authorized the delete.
544
+ fenced = pathlib.Path(tempfile.mkdtemp(prefix="migrate-fenced-"))
545
+ (fenced / "CLAUDE.md").write_text("# CLAUDE.md\n```\n@central/bundle.md\n```\n", encoding="utf-8")
546
+ checks.append(("F2: import string with no activation proof -> KEEP",
547
+ claude_corpus_loaded(fenced, state_dir=fenced) is False))
548
+ # (The old "full install is always loaded" case is gone with full mode: there is no shape
549
+ # whose corpus loads without the entry import, so nothing is exempt from the proof.)
509
550
 
510
551
  # 6) F5: the TRAILING comment's id wins; a learning_id quoted in the lesson
511
552
  # body must never shadow it (else a jsonl/prose desync).
@@ -3,8 +3,8 @@
3
3
 
4
4
  Both flows that egress session-derived text redact through here, so the floor
5
5
  is single-sourced (concept economy) rather than duplicated per call site:
6
- * heavy flow — scripts/session-distill/digest.py (per-session digests);
7
- * light flow — scripts/collect-learning.py (`learn!` prose + upload + the
6
+ * heavy flow — session-distill/digest.py (per-session digests);
7
+ * light flow — learn/collect-learning.py (`learn!` prose + upload + the
8
8
  curator export downstream, since the server stores the payload verbatim).
9
9
 
10
10
  `design/corpus-domain-packaging.md` declares this floor as an inherited
@@ -15,8 +15,8 @@ the `key: value` / `key=value` form and well-known token shapes, keep prose. A
15
15
  touched, so lessons ABOUT tokens (e.g. "trust the X-Hook-Token header") survive.
16
16
 
17
17
  This module has no side effects on import and is safe to load by path.
18
- scripts/redact.py is shipped in the npm package (package.json `files`) because
19
- the shipped scripts/collect-learning.py imports it at runtime.
18
+ learn/redact.py is shipped in the npm package (package.json `files`) because
19
+ the shipped learn/collect-learning.py imports it at runtime.
20
20
  """
21
21
  import re
22
22
  import sys
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "agent-bios",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
4
4
  "releaseDate": "2026-07-26",
5
5
  "description": "A thin, low-level instruction layer for LLM CLI agents: one set of principles and behavior whichever model you run. Deploys into $HOME by copy via an explicit `agent-bios install`.",
6
6
  "bin": {
7
- "agent-bios": "scripts/install.sh"
7
+ "agent-bios": "install.sh"
8
8
  },
9
9
  "files": [
10
10
  "claude/CLAUDE.md",
@@ -16,27 +16,29 @@
16
16
  "codex/guides/",
17
17
  "codex/agents/",
18
18
  "codex/config-additions.toml",
19
- "config/agent-launch.toml",
20
- "config/domains.json",
21
- "config/learning.schema.json",
22
- "config/promotions.json",
23
- "shell/agent-launch.zsh",
24
- "scripts/agent-launch.py",
25
- "scripts/pkgid.py",
26
- "scripts/assemble.py",
27
- "scripts/check-domains.py",
28
- "scripts/canary.sh",
29
- "scripts/check-parity.sh",
30
- "scripts/check-prompting-targets.sh",
31
- "scripts/check-learning.py",
32
- "scripts/collect-learning.py",
33
- "scripts/migrate-learnings.py",
34
- "scripts/redact.py",
35
- "scripts/codex-run.sh",
36
- "scripts/codex-helm.sh",
37
- "scripts/install.sh",
38
- "scripts/provision-venv.sh",
39
- "scripts/session-cost.py",
19
+ "launch/agent-launch.toml",
20
+ "compose/domains.json",
21
+ "learn/learning.schema.json",
22
+ "learn/promotions.json",
23
+ "launch/agent-launch.zsh",
24
+ "launch/agent-launch.py",
25
+ "compose/pkgid.py",
26
+ "compose/register-hooks.py",
27
+ "compose/assemble.py",
28
+ "compose/prune-backups.py",
29
+ "compose/check-domains.py",
30
+ "compose/canary.sh",
31
+ "launch/check-prompting-targets.sh",
32
+ "learn/check-learning.py",
33
+ "learn/collect-learning.py",
34
+ "learn/migrate-learnings.py",
35
+ "learn/redact.py",
36
+ "wrappers/codex-run.sh",
37
+ "wrappers/codex-helm.sh",
38
+ "wrappers/claude-run.sh",
39
+ "install.sh",
40
+ "launch/provision-venv.sh",
41
+ "session-cost.py",
40
42
  "README.md",
41
43
  "DEPENDENCIES.md"
42
44
  ],
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env bash
2
+ # claude-run.sh — thin, controllable raw adapter around `claude -p`.
3
+ #
4
+ # The claude-side twin of codex-run. Both exist for the same reason: a review
5
+ # dispatch has to name the seat it actually ran on, and a raw CLI call does not.
6
+ # Until this file existed the asymmetry was silent — the codex host dispatched
7
+ # reviews through our adapter while the claude host dispatched the bare binary,
8
+ # so half of every cross-family review had no place to report from.
9
+ #
10
+ # It is an adapter, not a policy boundary. Callers passing expert overrides after
11
+ # `--` are making expert decisions, exactly as with codex-run.
12
+ #
13
+ # (1) seat --model + --effort, both REQUIRED (see below)
14
+ # (2) mutation reach --permission-mode, defaulting to a no-edit posture
15
+ # (3) prompt read from stdin; the final message goes to stdout
16
+ #
17
+ # --model and --effort are required rather than optional, which is the one place
18
+ # this diverges from codex-run. codex-run tolerates an unpinned dispatch and warns,
19
+ # and that warning goes to a channel nobody reads; an unpinned review is the exact
20
+ # failure the receipt contract exists to catch, so here it is refused up front.
21
+ #
22
+ # NOT A SANDBOX. codex-run's `--sandbox read-only` is enforced by the OS; Claude Code
23
+ # has no equivalent, so the default here denies the mutating TOOLS and nothing more.
24
+ # A reviewer reading a self-contained packet on stdin needs no more reach than that,
25
+ # but do not read the two adapters' defaults as equivalent guarantees.
26
+ #
27
+ # Receipts: when REVIEW_RECEIPT_DIR is set, this adapter records what it observed —
28
+ # argv-independent facts only: exit status, a hash of the packet it fed the tool, a
29
+ # hash of the bytes the tool returned, and the seat it actually sent. Unset, it
30
+ # behaves exactly as it would without this block and writes nothing.
31
+ #
32
+ # Verified against claude 2.1.221.
33
+ set -euo pipefail
34
+
35
+ usage() {
36
+ cat <<'USAGE'
37
+ Usage: claude-run.sh --model M --effort E [--permission-mode MODE] [--cd DIR]
38
+ [-- ARG ...] < packet
39
+ --model M model to pin (required)
40
+ --effort E reasoning effort to pin (required)
41
+ --permission-mode MODE
42
+ claude permission mode (default: the no-edit posture below)
43
+ --cd DIR working root
44
+ -- everything after is passed to claude verbatim (expert override)
45
+
46
+ Environment (the adapter calling convention; all optional):
47
+ REVIEW_RECEIPT_DIR directory to write one ReviewReceipt/v1 into
48
+ REVIEW_METHOD_ID the review method this dispatch serves
49
+ REVIEW_ORDERING_SEED, REVIEW_SWAP_GROUP
50
+ the orchestrator's panel controls, copied into the receipt
51
+ USAGE
52
+ }
53
+
54
+ model=""
55
+ effort=""
56
+ permission_mode=""
57
+ cd_dir=""
58
+ passthrough=()
59
+
60
+ while [ $# -gt 0 ]; do
61
+ case "$1" in
62
+ --model) model="${2:?--model needs a value}"; shift 2 ;;
63
+ --effort) effort="${2:?--effort needs a value}"; shift 2 ;;
64
+ --permission-mode) permission_mode="${2:?--permission-mode needs a value}"; shift 2 ;;
65
+ --cd) cd_dir="${2:?--cd needs a value}"; shift 2 ;;
66
+ --) shift; while [ $# -gt 0 ]; do passthrough+=("$1"); shift; done ;;
67
+ -p|--print) shift ;; # already implied; a caller carrying it over is not an error
68
+ -) shift ;;
69
+ -h|--help) usage; exit 0 ;;
70
+ # Forwarded rather than refused. This adapter is on the dispatch path now, and a
71
+ # caller that reached for one of claude's own flags should get claude's behaviour
72
+ # and claude's error message — not exit 2 from the wrapper, which reads as "the
73
+ # reviewer is broken" and takes the review down with it.
74
+ *) passthrough+=("$1"); shift ;;
75
+ esac
76
+ done
77
+
78
+ if [ -z "$model" ] || [ -z "$effort" ]; then
79
+ # Warn and dispatch, matching codex-run. Refusing outright was right while nothing
80
+ # called this file; on the live path it converts "the review ran unpinned" into "the
81
+ # review did not run", and the honest signal already exists — an unpinned dispatch
82
+ # can name no seat, so no receipt is emitted and the method adjudicates to UNKNOWN.
83
+ echo "claude-run: WARNING: no --model/--effort pin; the seat cannot be named and this" >&2
84
+ echo " dispatch will produce no receipt." >&2
85
+ fi
86
+
87
+ # Built conditionally: an empty pin must be ABSENT, not passed as `--model ""`, which
88
+ # claude rejects — that would turn the warning above back into the hard failure it
89
+ # deliberately stopped being.
90
+ args=(-p)
91
+ if [ -n "$model" ]; then args+=(--model "$model"); fi
92
+ if [ -n "$effort" ]; then args+=(--effort "$effort"); fi
93
+ if [ -n "$permission_mode" ]; then
94
+ args+=(--permission-mode "$permission_mode")
95
+ else
96
+ # Deny the mutating tools rather than picking a permission mode: `plan` would also
97
+ # reframe the task as planning, and a reviewer asked for a plan writes one.
98
+ args+=(--disallowed-tools Edit Write NotebookEdit)
99
+ fi
100
+ if [ -n "$cd_dir" ]; then args+=(--add-dir "$cd_dir"); fi
101
+ if [ "${#passthrough[@]}" -gt 0 ]; then args+=("${passthrough[@]}"); fi
102
+
103
+ cleanup_paths=()
104
+ cleanup_all() {
105
+ if [ "${#cleanup_paths[@]}" -gt 0 ]; then
106
+ for p in "${cleanup_paths[@]}"; do rm -rf "$p"; done
107
+ fi
108
+ }
109
+ trap cleanup_all EXIT
110
+
111
+ # Dispatch audit, mirroring codex-run: the line goes to the log file only, because
112
+ # stdout carries the review result and stderr carries claude's own progress.
113
+ log_home="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
114
+ mkdir -p "$log_home/log" 2>/dev/null || true
115
+ printf '%s dispatch model=%s effort=%s permission=%s\n' \
116
+ "$(date +%Y-%m-%dT%H:%M:%S%z)" "$model" "$effort" \
117
+ "${permission_mode:-no-edit-tools}" \
118
+ >> "$log_home/log/claude-run-dispatch.log" 2>/dev/null || true
119
+
120
+ emit_receipt() {
121
+ # Recording never breaks dispatch: every failure here is a warning and the tool's
122
+ # own exit status is what this script returns. A missing receipt adjudicates to
123
+ # UNKNOWN, which is the honest outcome — silence is not evidence of failure.
124
+ local status="$1" packet="$2" result="$3" launcher
125
+ launcher="${AGENT_LAUNCH_BIN:-$HOME/.local/bin/agent-launch}"
126
+ if [ ! -x "$launcher" ]; then
127
+ launcher="$(command -v agent-launch 2>/dev/null || true)"
128
+ fi
129
+ if [ -z "$launcher" ]; then
130
+ echo "claude-run: WARNING: agent-launch not found; no receipt emitted" >&2
131
+ return 0
132
+ fi
133
+ if [ -z "${REVIEW_METHOD_ID:-}" ]; then
134
+ echo "claude-run: WARNING: REVIEW_METHOD_ID unset; the receipt will name no method" >&2
135
+ fi
136
+ # The provider is core knowledge and not a flag: this adapter reaches exactly one
137
+ # family, so letting a caller name a different one would only ever be a false claim.
138
+ "$launcher" --emit-receipt "${REVIEW_METHOD_ID:-}" "anthropic:$model/$effort" \
139
+ "$status" "$packet" "$result" >/dev/null \
140
+ || echo "claude-run: WARNING: receipt not emitted" >&2
141
+ }
142
+
143
+ if [ -n "${REVIEW_RECEIPT_DIR:-}" ]; then
144
+ work="$(mktemp -d "${TMPDIR:-/tmp}/claude-run-XXXXXX")"
145
+ cleanup_paths+=("$work")
146
+ # stdin is captured rather than inherited ONLY on this branch: hashing the packet
147
+ # requires holding it, and teeing stdout requires a pipe. Both change the channel
148
+ # shape, so neither is reached unless a receipt was actually asked for.
149
+ cat > "$work/packet"
150
+ set +e
151
+ claude "${args[@]}" < "$work/packet" | tee "$work/result"
152
+ status=${PIPESTATUS[0]}
153
+ set -e
154
+ emit_receipt "$status" "$work/packet" "$work/result"
155
+ else
156
+ set +e
157
+ claude "${args[@]}"
158
+ status=$?
159
+ set -e
160
+ fi
161
+
162
+ exit "$status"
@@ -22,6 +22,12 @@
22
22
  # (with --schema, JSON conforming to the schema) and progress to stderr directly.
23
23
  # Exit status mirrors `codex exec`.
24
24
  #
25
+ # Receipts: when REVIEW_RECEIPT_DIR is set, this adapter records what it observed —
26
+ # exit status, a hash of the packet it fed codex, a hash of the bytes codex returned,
27
+ # and the seat it actually sent. Unset, it behaves exactly as it did before that block
28
+ # existed and writes nothing. The dispatch-audit line below predates receipts and stays:
29
+ # it is a human-readable trail, not an adjudicable record.
30
+ #
25
31
  # Verified against codex-cli 0.144.1.
26
32
  set -euo pipefail
27
33
 
@@ -53,6 +59,12 @@ Usage: codex-run.sh [--profile inherit|hermetic|custom] [--home DIR]
53
59
  -c k=v expert pass-through config override (repeatable); may override
54
60
  wrapper defaults, e.g. -c model_verbosity="low"
55
61
  - optional explicit stdin marker
62
+
63
+ Environment (the adapter calling convention; all optional):
64
+ REVIEW_RECEIPT_DIR directory to write one ReviewReceipt/v1 into
65
+ REVIEW_METHOD_ID the review method this dispatch serves
66
+ REVIEW_ORDERING_SEED, REVIEW_SWAP_GROUP
67
+ the orchestrator's panel controls, copied into the receipt
56
68
  USAGE
57
69
  }
58
70
 
@@ -174,9 +186,18 @@ fi
174
186
  sandbox_label="$sandbox"
175
187
  if [ "$bypass_sandbox" -eq 1 ]; then sandbox_label="bypass"; fi
176
188
  cmodel=""
189
+ ceffort=""
177
190
  if [ "${#extra_c[@]}" -gt 0 ]; then
178
191
  for kv in "${extra_c[@]}"; do
179
- case "$kv" in model=*) cmodel="${kv#model=}" ;; esac
192
+ case "$kv" in
193
+ model=*) cmodel="${kv#model=}" ;;
194
+ # An expert -c may override the effort too, and the seat a receipt reports has to
195
+ # be the one actually SENT — reading only --effort would report the requested seat
196
+ # while the dispatch ran on another, which is the drift the receipt exists to catch.
197
+ model_reasoning_effort=*)
198
+ ceffort="${kv#model_reasoning_effort=}"
199
+ ceffort="${ceffort%\"}"; ceffort="${ceffort#\"}" ;;
200
+ esac
180
201
  done
181
202
  fi
182
203
  dispatch_note="dispatch profile=$profile model=${model:-INHERITED_DEFAULT}${cmodel:+ c-model-override=$cmodel} effort=${effort:-config-default} sandbox=$sandbox_label"
@@ -186,10 +207,45 @@ fi
186
207
  mkdir -p "$real_home/log" 2>/dev/null || true
187
208
  printf '%s %s\n' "$(date +%Y-%m-%dT%H:%M:%S%z)" "$dispatch_note" >> "$real_home/log/codex-run-dispatch.log" 2>/dev/null || true
188
209
 
189
- # stdin, stdout, and stderr already match this adapter's channel contract.
190
- set +e
191
- CODEX_HOME="$run_home" codex "${args[@]}"
192
- status=$?
193
- set -e
210
+ emit_receipt() {
211
+ # Recording never breaks dispatch: every failure here is a warning and codex's own
212
+ # exit status is what this script returns. A missing receipt adjudicates to UNKNOWN,
213
+ # which is the honest outcome — silence is not evidence of failure.
214
+ status="$1"; packet="$2"; result="$3"
215
+ launcher="${AGENT_LAUNCH_BIN:-$HOME/.local/bin/agent-launch}"
216
+ if [ ! -x "$launcher" ]; then
217
+ launcher="$(command -v agent-launch 2>/dev/null || true)"
218
+ fi
219
+ if [ -z "$launcher" ]; then
220
+ echo "codex-run: WARNING: agent-launch not found; no receipt emitted" >&2
221
+ return 0
222
+ fi
223
+ # The seat as SENT: an expert -c override beats the flag it overrode, and an
224
+ # unpinned dispatch names no seat at all, so the receipt is refused rather than
225
+ # invented — which is the same failure the warning above already reports.
226
+ "$launcher" --emit-receipt "${REVIEW_METHOD_ID:-}" \
227
+ "openai:${cmodel:-$model}/${ceffort:-$effort}" "$status" "$packet" "$result" >/dev/null \
228
+ || echo "codex-run: WARNING: receipt not emitted" >&2
229
+ }
230
+
231
+ if [ -n "${REVIEW_RECEIPT_DIR:-}" ]; then
232
+ # stdin is captured rather than inherited ONLY on this branch: hashing the packet
233
+ # requires holding it, and teeing stdout requires a pipe. Both change the channel
234
+ # shape, so neither is reached unless a receipt was actually asked for.
235
+ work="$(mktemp -d "${TMPDIR:-/tmp}/codex-run-receipt-XXXXXX")"
236
+ cleanup_paths+=("$work")
237
+ cat > "$work/packet"
238
+ set +e
239
+ CODEX_HOME="$run_home" codex "${args[@]}" < "$work/packet" | tee "$work/result"
240
+ status=${PIPESTATUS[0]}
241
+ set -e
242
+ emit_receipt "$status" "$work/packet" "$work/result"
243
+ else
244
+ # stdin, stdout, and stderr already match this adapter's channel contract.
245
+ set +e
246
+ CODEX_HOME="$run_home" codex "${args[@]}"
247
+ status=$?
248
+ set -e
249
+ fi
194
250
 
195
251
  exit "$status"