agent-bios 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEPENDENCIES.md +5 -4
- package/README.md +2 -2
- package/claude/CLAUDE.md +14 -5
- package/claude/agents/frontier.md +9 -0
- package/claude/agents/sweep.md +9 -0
- package/claude/agents/workhorse.md +8 -0
- package/claude/guides/claude-prompting.md +123 -0
- package/claude/guides/cli-multi-model-workflow.md +11 -4
- package/claude/guides/coding-staged-workflow.md +8 -0
- package/claude/guides/gpt-prompting.md +118 -0
- package/claude/guides/llm-capability-boundary-patterns.md +5 -0
- package/claude/guides/mock-realization-boundary.md +9 -0
- package/claude/guides/review-request.md +274 -0
- package/claude/guides/session-learning-workflow.md +100 -0
- package/claude/guides/tooling-gotchas.md +155 -0
- package/claude/hooks/tooling-gotchas-hook.py +79 -0
- package/codex/AGENTS.md +14 -5
- package/codex/config-additions.toml +22 -0
- package/codex/guides/claude-prompting.md +123 -0
- package/codex/guides/cli-multi-model-workflow.md +11 -4
- package/codex/guides/coding-staged-workflow.md +8 -0
- package/codex/guides/gpt-prompting.md +118 -0
- package/codex/guides/llm-capability-boundary-patterns.md +5 -0
- package/codex/guides/mock-realization-boundary.md +9 -0
- package/codex/guides/review-request.md +274 -0
- package/codex/guides/session-learning-workflow.md +100 -0
- package/codex/guides/tooling-gotchas.md +155 -0
- package/config/agent-launch.toml +23 -0
- package/package.json +6 -2
- package/scripts/agent-launch.py +504 -29
- package/scripts/check-parity.sh +73 -6
- package/scripts/check-prompting-targets.sh +62 -0
- package/scripts/codex-helm.sh +19 -1
- package/scripts/codex-run.sh +19 -0
- package/scripts/install.sh +298 -6
package/scripts/check-parity.sh
CHANGED
|
@@ -111,6 +111,8 @@ current-state dashboard|claude/CLAUDE.md|claude/guides/implementation-map.md
|
|
|
111
111
|
Verification Menus|claude/CLAUDE.md|claude/guides/coding-staged-workflow.md
|
|
112
112
|
real Microsoft Excel engine|claude/CLAUDE.md|claude/guides/coding-staged-workflow.md
|
|
113
113
|
severity contract|README.md|claude/guides/coding-staged-workflow.md
|
|
114
|
+
Ambient state|claude/CLAUDE.md|claude/guides/tooling-gotchas.md
|
|
115
|
+
the full lifecycle of what you create|claude/CLAUDE.md|claude/guides/tooling-gotchas.md
|
|
114
116
|
ANCHORS
|
|
115
117
|
|
|
116
118
|
# The launcher's Textual preflight UI tests need the managed venv (textual).
|
|
@@ -264,6 +266,33 @@ for filename, (expected_name, expected_model, expected_effort) in expected_agent
|
|
|
264
266
|
f"want {expected_effort!r}"
|
|
265
267
|
)
|
|
266
268
|
|
|
269
|
+
# codex/config-additions.toml is the additive fragment install merges into the
|
|
270
|
+
# live ~/.codex/config.toml; its agent entries are projections of the canonical
|
|
271
|
+
# codex/agents/*.toml templates and must not drift from them.
|
|
272
|
+
fragment_path = pathlib.Path("codex/config-additions.toml")
|
|
273
|
+
if not fragment_path.is_file():
|
|
274
|
+
mark_fail("required file missing: codex/config-additions.toml")
|
|
275
|
+
else:
|
|
276
|
+
fragment = tomllib.loads(fragment_path.read_text())
|
|
277
|
+
if fragment.get("features", {}).get("multi_agent") is not True:
|
|
278
|
+
mark_fail("config-additions must set features.multi_agent = true")
|
|
279
|
+
fragment_agents = fragment.get("agents", {})
|
|
280
|
+
if set(fragment_agents) != {"frontier", "workhorse", "sweep"}:
|
|
281
|
+
mark_fail(
|
|
282
|
+
"config-additions agents must be exactly the spawnable tiers "
|
|
283
|
+
f"(no helm): {sorted(fragment_agents)}"
|
|
284
|
+
)
|
|
285
|
+
for tier, spec in fragment_agents.items():
|
|
286
|
+
template_path = agent_dir / f"{tier}.toml"
|
|
287
|
+
template = tomllib.loads(template_path.read_text()) if template_path.is_file() else {}
|
|
288
|
+
if spec.get("description") != template.get("description"):
|
|
289
|
+
mark_fail(f"config-additions {tier} description drifted from {template_path}")
|
|
290
|
+
if spec.get("config_file") != f"${{CODEX_HOME}}/agents/{tier}.toml":
|
|
291
|
+
mark_fail(
|
|
292
|
+
f"config-additions {tier} config_file must be "
|
|
293
|
+
f"${{CODEX_HOME}}/agents/{tier}.toml"
|
|
294
|
+
)
|
|
295
|
+
|
|
267
296
|
|
|
268
297
|
def table_row(path, slot):
|
|
269
298
|
for raw_line in path.read_text().splitlines():
|
|
@@ -522,7 +551,7 @@ if launcher.is_file() and launch_profile:
|
|
|
522
551
|
def set_plan(self, plan):
|
|
523
552
|
self.plan = plan
|
|
524
553
|
|
|
525
|
-
def choose(self, title, options, default, allow_back, preview=None):
|
|
554
|
+
def choose(self, title, options, default, allow_back, preview=None, learning_lines=None):
|
|
526
555
|
if title == "Preset":
|
|
527
556
|
if preview is not None:
|
|
528
557
|
balanced = launcher_module.setup_summary_lines(preview("balanced"))
|
|
@@ -748,6 +777,7 @@ if launcher.is_file() and launch_profile:
|
|
|
748
777
|
(b"Esc cancel | q cancel", b"\x1b[B"),
|
|
749
778
|
(b"hybrid onto", b"\x1b[B"),
|
|
750
779
|
(b"high-volume", b"\x1b[B"),
|
|
780
|
+
(b"learning-refinement", b"\x1b[B"),
|
|
751
781
|
(b"Open a settings hub", b"\r"),
|
|
752
782
|
)
|
|
753
783
|
down = b"\x1b[B"
|
|
@@ -1098,10 +1128,11 @@ if launcher.is_file() and launch_profile:
|
|
|
1098
1128
|
# TERM=dumb routes to numbered prompts (textual renders on any usable
|
|
1099
1129
|
# terminal, so the numbered fallback is gated on TERM/non-TTY/textual
|
|
1100
1130
|
# availability, not a terminfo probe). 'b' is the numbered back command.
|
|
1131
|
+
custom_number = str(len(fake_data["presets"]) + 1).encode()
|
|
1101
1132
|
transcript, picker_status = run_picker_scenario(
|
|
1102
1133
|
"numbered fallback (TERM=dumb)",
|
|
1103
1134
|
(
|
|
1104
|
-
(b"Open a settings hub", b"
|
|
1135
|
+
(b"Open a settings hub", custom_number + b"\n"),
|
|
1105
1136
|
(b"Custom settings", b"b\n"),
|
|
1106
1137
|
(b"HELM default for everyday work", b"1\n"),
|
|
1107
1138
|
),
|
|
@@ -1114,6 +1145,28 @@ if launcher.is_file() and launch_profile:
|
|
|
1114
1145
|
):
|
|
1115
1146
|
mark_fail("agent-launch numbered fallback (TERM=dumb) did not preserve numbered back")
|
|
1116
1147
|
|
|
1148
|
+
# Session Learning hub: enter, render status, back out, launch balanced.
|
|
1149
|
+
learning_number = str(len(fake_data["presets"]) + 2).encode()
|
|
1150
|
+
transcript, picker_status = run_picker_scenario(
|
|
1151
|
+
"numbered learning hub (TERM=dumb)",
|
|
1152
|
+
(
|
|
1153
|
+
(b"Session Learning", learning_number + b"\n"),
|
|
1154
|
+
(b"Versions & rollback", b"4\n"),
|
|
1155
|
+
(b"HELM default for everyday work", b"1\n"),
|
|
1156
|
+
),
|
|
1157
|
+
term="dumb",
|
|
1158
|
+
)
|
|
1159
|
+
learning_panel_rendered = (
|
|
1160
|
+
b"Applied version" in transcript or b"not projected yet" in transcript
|
|
1161
|
+
)
|
|
1162
|
+
if (
|
|
1163
|
+
picker_status != 0
|
|
1164
|
+
or b"Traceback" in transcript
|
|
1165
|
+
or not learning_panel_rendered
|
|
1166
|
+
or b"Preset Balanced" not in transcript
|
|
1167
|
+
):
|
|
1168
|
+
mark_fail("agent-launch numbered learning hub did not render or return")
|
|
1169
|
+
|
|
1117
1170
|
passed = invoke([
|
|
1118
1171
|
sys.executable, str(launcher), "--no-tui", "codex", "--", "exec", "--json", "probe"
|
|
1119
1172
|
], env=env)
|
|
@@ -1421,7 +1474,16 @@ if launcher.is_file() and launch_profile:
|
|
|
1421
1474
|
except (ValueError, IndexError, json.JSONDecodeError) as exc:
|
|
1422
1475
|
mark_fail(f"agent-launch Claude --agents projection is not valid JSON: {exc}")
|
|
1423
1476
|
else:
|
|
1424
|
-
|
|
1477
|
+
expected_spawnable = {
|
|
1478
|
+
tier: expected_claude_tiers[tier]
|
|
1479
|
+
for tier in ("frontier", "workhorse", "sweep")
|
|
1480
|
+
}
|
|
1481
|
+
if set(agents_arg) != set(expected_spawnable):
|
|
1482
|
+
mark_fail(
|
|
1483
|
+
"Claude --agents must project exactly the spawnable tiers "
|
|
1484
|
+
f"(HELM is the main, never a subagent): {sorted(agents_arg)}"
|
|
1485
|
+
)
|
|
1486
|
+
for tier, (model, effort) in expected_spawnable.items():
|
|
1425
1487
|
role = agents_arg.get(tier, {})
|
|
1426
1488
|
if role.get("model") != model or role.get("effort") != effort:
|
|
1427
1489
|
mark_fail(f"agent-launch Claude {tier} role projection drifted: {role!r}")
|
|
@@ -1585,7 +1647,8 @@ if launcher.is_file() and launch_profile:
|
|
|
1585
1647
|
unavailable,
|
|
1586
1648
|
"agent-launch codex degrade-to-native review",
|
|
1587
1649
|
(
|
|
1588
|
-
"Review setup hybrid → effective native (onto,ultracode unavailable
|
|
1650
|
+
"Review setup hybrid → effective native (onto,ultracode unavailable;"
|
|
1651
|
+
" install: npm i -g onto-mcp && npm i -g ultracode-for-codex)",
|
|
1589
1652
|
"degraded to native same-model",
|
|
1590
1653
|
"fall back to native same-model subagent review",
|
|
1591
1654
|
),
|
|
@@ -1600,7 +1663,8 @@ if launcher.is_file() and launch_profile:
|
|
|
1600
1663
|
unavailable_claude,
|
|
1601
1664
|
"agent-launch claude degrade-to-native review",
|
|
1602
1665
|
(
|
|
1603
|
-
"Review setup hybrid → effective native (onto,ultracode unavailable
|
|
1666
|
+
"Review setup hybrid → effective native (onto,ultracode unavailable;"
|
|
1667
|
+
" install: npm i -g onto-mcp && npm i -g ultracode-for-codex)",
|
|
1604
1668
|
"fall back to native same-model subagent review",
|
|
1605
1669
|
),
|
|
1606
1670
|
("--mcp-config",),
|
|
@@ -1637,8 +1701,11 @@ if launcher.is_file() and launch_profile:
|
|
|
1637
1701
|
):
|
|
1638
1702
|
mark_fail("saved codex tier override leaked into the claude host")
|
|
1639
1703
|
launcher_module.save_preset(round_trip, reloaded, save_target, "mysetup")
|
|
1640
|
-
|
|
1704
|
+
saved_file = launcher_module.user_presets_path(save_target)
|
|
1705
|
+
if saved_file.read_text().count("[presets.mysetup]") != 1:
|
|
1641
1706
|
mark_fail("re-saving a preset duplicated its block")
|
|
1707
|
+
if "[presets.mysetup]" in save_target.read_text():
|
|
1708
|
+
mark_fail("save_preset wrote a user preset into the installer-owned config")
|
|
1642
1709
|
|
|
1643
1710
|
# The Custom hub 'save' action persists the named preset then launches.
|
|
1644
1711
|
ui_save_target = tmp / "ui-save.toml"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Prompting guides are model-version-bound: unlike the rest of the guide set,
|
|
3
|
+
# their rules change when the model changes (the current generation inverted the
|
|
4
|
+
# previous one's advice). So they name concrete models, and this gate keeps that
|
|
5
|
+
# naming honest — it fails when the launch config binds a model no prompting
|
|
6
|
+
# guide covers, which is exactly when the guidance needs re-deriving from the
|
|
7
|
+
# vendor's current docs.
|
|
8
|
+
set -u
|
|
9
|
+
cd "$(dirname "$0")/.." || exit 2
|
|
10
|
+
|
|
11
|
+
python3 - "$@" <<'PY'
|
|
12
|
+
import pathlib, re, sys, tomllib
|
|
13
|
+
|
|
14
|
+
config = pathlib.Path("config/agent-launch.toml")
|
|
15
|
+
hosts = tomllib.loads(config.read_text())["hosts"]
|
|
16
|
+
|
|
17
|
+
# host -> the guide that owns prompting guidance for that host's model family
|
|
18
|
+
OWNERS = {"codex": "gpt-prompting", "claude": "claude-prompting"}
|
|
19
|
+
|
|
20
|
+
def targets(guide_id):
|
|
21
|
+
path = pathlib.Path(f"codex/guides/{guide_id}.md")
|
|
22
|
+
if not path.is_file():
|
|
23
|
+
sys.exit(f"FAIL: missing prompting guide: {path}")
|
|
24
|
+
front = re.match(r"---\n(.*?)\n---\n", path.read_text(), re.S)
|
|
25
|
+
if not front:
|
|
26
|
+
sys.exit(f"FAIL: missing YAML frontmatter: {path}")
|
|
27
|
+
block = re.search(r"^targets:\n((?:\s+-\s+\S+\n)+)", front.group(1), re.M)
|
|
28
|
+
if not block:
|
|
29
|
+
sys.exit(f"FAIL: {path} declares no targets:")
|
|
30
|
+
return {line.strip().lstrip("- ").strip() for line in block.group(1).splitlines()}
|
|
31
|
+
|
|
32
|
+
fail = 0
|
|
33
|
+
for host, guide_id in OWNERS.items():
|
|
34
|
+
configured = set(hosts.get(host, {}).get("models", []))
|
|
35
|
+
for tier in hosts.get(host, {}).get("tiers", {}).values():
|
|
36
|
+
if tier.get("model"):
|
|
37
|
+
configured.add(tier["model"])
|
|
38
|
+
if not configured:
|
|
39
|
+
print(f"FAIL: no models configured for host {host}; nothing to check")
|
|
40
|
+
fail = 1
|
|
41
|
+
continue
|
|
42
|
+
declared = targets(guide_id)
|
|
43
|
+
stale = sorted(configured - declared)
|
|
44
|
+
if stale:
|
|
45
|
+
print(
|
|
46
|
+
f"FAIL: {guide_id}.md does not cover configured {host} model(s): "
|
|
47
|
+
f"{', '.join(stale)} — re-derive it from current vendor guidance and "
|
|
48
|
+
f"update its targets:"
|
|
49
|
+
)
|
|
50
|
+
fail = 1
|
|
51
|
+
else:
|
|
52
|
+
print(f" {guide_id}: covers all {len(configured)} configured {host} model(s)")
|
|
53
|
+
unused = sorted(declared - configured)
|
|
54
|
+
if unused:
|
|
55
|
+
print(f" note: {guide_id} also targets unbound model(s): {', '.join(unused)}")
|
|
56
|
+
|
|
57
|
+
sys.exit(fail)
|
|
58
|
+
PY
|
|
59
|
+
status=$?
|
|
60
|
+
[ $status -eq 0 ] && echo "PROMPTING TARGETS OK: every configured model is covered" \
|
|
61
|
+
|| echo "PROMPTING TARGETS FAILED"
|
|
62
|
+
exit $status
|
package/scripts/codex-helm.sh
CHANGED
|
@@ -268,9 +268,27 @@ EOF
|
|
|
268
268
|
EOF
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
# Review packets must carry the whole subject: range-based diffs silently omit
|
|
272
|
+
# staged-but-uncommitted changes and untracked files, so the dispatcher itself
|
|
273
|
+
# appends the subject tree's actual state to the packet.
|
|
274
|
+
scope_note=""
|
|
275
|
+
if [ "$mode" = "review" ] && { [ -n "$cd_dir" ] || [ "$reach" != "hermetic" ]; }; then
|
|
276
|
+
scope_root="${cd_dir:-$PWD}"
|
|
277
|
+
if git -C "$scope_root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
278
|
+
scope_status="$(git -C "$scope_root" status --porcelain 2>/dev/null || true)"
|
|
279
|
+
if [ -n "$scope_status" ]; then
|
|
280
|
+
scope_note="Review scope manifest (dispatcher-generated \`git status --porcelain\` of the subject tree; uncommitted/untracked entries are part of the review subject unless the task says otherwise):
|
|
281
|
+
$scope_status"
|
|
282
|
+
echo "codex-helm: subject tree has uncommitted/untracked entries; scope manifest appended to the packet" >&2
|
|
283
|
+
fi
|
|
284
|
+
fi
|
|
285
|
+
fi
|
|
286
|
+
|
|
271
287
|
final_prompt="$(build_preamble)
|
|
272
288
|
|
|
273
|
-
|
|
289
|
+
${scope_note:+$scope_note
|
|
290
|
+
|
|
291
|
+
}User task:
|
|
274
292
|
$user_prompt"
|
|
275
293
|
|
|
276
294
|
cleanup_dirs=()
|
package/scripts/codex-run.sh
CHANGED
|
@@ -167,6 +167,25 @@ if [ "${#extra_c[@]}" -gt 0 ]; then
|
|
|
167
167
|
for kv in "${extra_c[@]}"; do args+=(-c "$kv"); done
|
|
168
168
|
fi
|
|
169
169
|
|
|
170
|
+
# Dispatch audit: verifier diversity is only as real as the pinned backing
|
|
171
|
+
# model — an unpinned dispatch inherits config defaults and can silently
|
|
172
|
+
# collapse two "different" reviewers onto one backend. The audit line goes to
|
|
173
|
+
# the log file only; stdout/stderr stay reserved for the codex channels.
|
|
174
|
+
sandbox_label="$sandbox"
|
|
175
|
+
if [ "$bypass_sandbox" -eq 1 ]; then sandbox_label="bypass"; fi
|
|
176
|
+
cmodel=""
|
|
177
|
+
if [ "${#extra_c[@]}" -gt 0 ]; then
|
|
178
|
+
for kv in "${extra_c[@]}"; do
|
|
179
|
+
case "$kv" in model=*) cmodel="${kv#model=}" ;; esac
|
|
180
|
+
done
|
|
181
|
+
fi
|
|
182
|
+
dispatch_note="dispatch profile=$profile model=${model:-INHERITED_DEFAULT}${cmodel:+ c-model-override=$cmodel} effort=${effort:-config-default} sandbox=$sandbox_label"
|
|
183
|
+
if [ -z "$model" ] && [ -z "$cmodel" ]; then
|
|
184
|
+
echo "codex-run: WARNING: no --model pin; the backing model inherits the active config default" >&2
|
|
185
|
+
fi
|
|
186
|
+
mkdir -p "$real_home/log" 2>/dev/null || true
|
|
187
|
+
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
|
+
|
|
170
189
|
# stdin, stdout, and stderr already match this adapter's channel contract.
|
|
171
190
|
set +e
|
|
172
191
|
CODEX_HOME="$run_home" codex "${args[@]}"
|
package/scripts/install.sh
CHANGED
|
@@ -44,6 +44,7 @@ REPO="$(cd "$SELF/.." && pwd)"
|
|
|
44
44
|
CLAUDE_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
|
|
45
45
|
CODEX_DIR="${CODEX_HOME:-$HOME/.codex}"
|
|
46
46
|
LAUNCH_DIR="$HOME/.config/agent-launch"
|
|
47
|
+
USER_PRESETS_NAME="presets.local.toml" # user-owned; never deployed or verified
|
|
47
48
|
BIN_DIR="$HOME/.local/bin"
|
|
48
49
|
STATE_DIR="$HOME/.local/share/agent-bios"
|
|
49
50
|
LEGACY_STATE_DIR="$HOME/.local/share/agent-dotfiles" # pre-rename state; migrated on first run
|
|
@@ -125,6 +126,259 @@ migrate_state() {
|
|
|
125
126
|
mv "$LEGACY_STATE_DIR" "$STATE_DIR" && info "migrated state $LEGACY_STATE_DIR -> $STATE_DIR"
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
# ---- codex live-config additions -----------------------------------------
|
|
130
|
+
# The live ~/.codex/config.toml is user/runtime-owned; agent-bios never
|
|
131
|
+
# deploys or overwrites it. codex/config-additions.toml declares the only
|
|
132
|
+
# content agent-bios manages there — one marked [agents.*] block plus a tagged
|
|
133
|
+
# features.multi_agent line — and this helper merges (install), checks
|
|
134
|
+
# (verify), or removes (uninstall) exactly that content, backed up and
|
|
135
|
+
# tomllib-validated before any write. Modes: merge | check | remove.
|
|
136
|
+
codex_config_additions() {
|
|
137
|
+
AB_MODE="$1" AB_CODEX_DIR="$CODEX_DIR" AB_FRAGMENT="$REPO/codex/config-additions.toml" \
|
|
138
|
+
AB_BACKUP="${BACKUP_DIR:-}" AB_DRY="$DRY_RUN" python3 - <<'PY'
|
|
139
|
+
import os, pathlib, sys, tomllib
|
|
140
|
+
|
|
141
|
+
mode = os.environ["AB_MODE"]
|
|
142
|
+
codex_dir = pathlib.Path(os.environ["AB_CODEX_DIR"])
|
|
143
|
+
fragment_path = pathlib.Path(os.environ["AB_FRAGMENT"])
|
|
144
|
+
backup_root = os.environ.get("AB_BACKUP", "")
|
|
145
|
+
dry = os.environ.get("AB_DRY") == "1"
|
|
146
|
+
target = codex_dir / "config.toml"
|
|
147
|
+
BEGIN = "# >>> agent-bios additions >>>"
|
|
148
|
+
END = "# <<< agent-bios additions <<<"
|
|
149
|
+
TAG = "# agent-bios"
|
|
150
|
+
|
|
151
|
+
def info(msg): print(f" {msg}")
|
|
152
|
+
def fail(msg): print(msg); sys.exit(1)
|
|
153
|
+
|
|
154
|
+
frag_text = fragment_path.read_text().replace("${CODEX_HOME}", str(codex_dir))
|
|
155
|
+
want_agents = tomllib.loads(frag_text)["agents"]
|
|
156
|
+
live_text = target.read_text() if target.is_file() else ""
|
|
157
|
+
try:
|
|
158
|
+
live = tomllib.loads(live_text) if live_text else {}
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
fail(f"live codex config does not parse; not touching it: {target} ({exc})")
|
|
161
|
+
|
|
162
|
+
def state_ok():
|
|
163
|
+
if live.get("features", {}).get("multi_agent") is not True:
|
|
164
|
+
return False
|
|
165
|
+
return all(
|
|
166
|
+
live.get("agents", {}).get(name, {}).get(key) == spec[key]
|
|
167
|
+
for name, spec in want_agents.items()
|
|
168
|
+
for key in ("description", "config_file")
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if mode == "check":
|
|
172
|
+
problems = []
|
|
173
|
+
if live.get("features", {}).get("multi_agent") is not True:
|
|
174
|
+
problems.append("features.multi_agent is not true")
|
|
175
|
+
for name, spec in want_agents.items():
|
|
176
|
+
if live.get("agents", {}).get(name, {}).get("config_file") != spec["config_file"]:
|
|
177
|
+
problems.append(f"agents.{name}.config_file drifted or missing")
|
|
178
|
+
elif not pathlib.Path(spec["config_file"]).is_file():
|
|
179
|
+
problems.append(f"agents.{name} template missing: {spec['config_file']}")
|
|
180
|
+
if problems:
|
|
181
|
+
fail(f"codex config additions: {'; '.join(problems)} ({target})")
|
|
182
|
+
info("codex config additions OK")
|
|
183
|
+
sys.exit(0)
|
|
184
|
+
|
|
185
|
+
def strip_managed(text):
|
|
186
|
+
out, skipping = [], False
|
|
187
|
+
for line in text.splitlines(keepends=True):
|
|
188
|
+
s = line.strip()
|
|
189
|
+
if s == BEGIN: skipping = True; continue
|
|
190
|
+
if s == END: skipping = False; continue
|
|
191
|
+
if skipping or s.endswith(TAG): continue
|
|
192
|
+
out.append(line)
|
|
193
|
+
return "".join(out)
|
|
194
|
+
|
|
195
|
+
if mode == "remove":
|
|
196
|
+
if not target.is_file():
|
|
197
|
+
sys.exit(0)
|
|
198
|
+
stripped = strip_managed(live_text)
|
|
199
|
+
if stripped == live_text:
|
|
200
|
+
info(f"no agent-bios additions in {target}")
|
|
201
|
+
sys.exit(0)
|
|
202
|
+
try:
|
|
203
|
+
tomllib.loads(stripped)
|
|
204
|
+
except Exception as exc:
|
|
205
|
+
fail(f"refusing removal; result would not parse: {exc}")
|
|
206
|
+
if dry:
|
|
207
|
+
info(f"[dry-run] remove agent-bios additions from {target}")
|
|
208
|
+
sys.exit(0)
|
|
209
|
+
backup = target.with_name(target.name + ".bak-agent-bios-uninstall")
|
|
210
|
+
backup.write_text(live_text)
|
|
211
|
+
target.write_text(stripped)
|
|
212
|
+
info(f"removed additions {target} (backup: {backup.name})")
|
|
213
|
+
sys.exit(0)
|
|
214
|
+
|
|
215
|
+
# mode == merge
|
|
216
|
+
if state_ok():
|
|
217
|
+
info(f"unchanged {target} (additions present)")
|
|
218
|
+
sys.exit(0)
|
|
219
|
+
|
|
220
|
+
# A drifted [agents.<tier>] outside our markers would become a duplicate
|
|
221
|
+
# table if we appended ours; that conflict needs the user, not a clobber.
|
|
222
|
+
base = strip_managed(live_text)
|
|
223
|
+
base_data = tomllib.loads(base) if base.strip() else {}
|
|
224
|
+
clash = [name for name in want_agents if name in base_data.get("agents", {})]
|
|
225
|
+
if clash:
|
|
226
|
+
fail(
|
|
227
|
+
f"unmanaged [agents.{'/'.join(clash)}] with drifted content in {target}; "
|
|
228
|
+
"align or remove them, then rerun install"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
block_lines = [BEGIN]
|
|
232
|
+
if "features" not in base_data:
|
|
233
|
+
block_lines += ["[features]", f"multi_agent = true {TAG}"]
|
|
234
|
+
for name, spec in want_agents.items():
|
|
235
|
+
block_lines += [
|
|
236
|
+
f"[agents.{name}]",
|
|
237
|
+
f'description = "{spec["description"]}"',
|
|
238
|
+
f'config_file = "{spec["config_file"]}"',
|
|
239
|
+
]
|
|
240
|
+
block_lines.append(END)
|
|
241
|
+
block = "\n".join(block_lines) + "\n"
|
|
242
|
+
|
|
243
|
+
new_text = base
|
|
244
|
+
if "features" in base_data:
|
|
245
|
+
if base_data["features"].get("multi_agent") is None:
|
|
246
|
+
lines = new_text.splitlines(keepends=True)
|
|
247
|
+
for i, line in enumerate(lines):
|
|
248
|
+
if line.strip() == "[features]":
|
|
249
|
+
lines.insert(i + 1, f"multi_agent = true {TAG}\n")
|
|
250
|
+
break
|
|
251
|
+
new_text = "".join(lines)
|
|
252
|
+
elif base_data["features"].get("multi_agent") is not True:
|
|
253
|
+
info(f"note: features.multi_agent explicitly set in {target}; leaving it")
|
|
254
|
+
if new_text and not new_text.endswith("\n"):
|
|
255
|
+
new_text += "\n"
|
|
256
|
+
new_text += ("\n" if new_text else "") + block
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
tomllib.loads(new_text)
|
|
260
|
+
except Exception as exc:
|
|
261
|
+
fail(f"merge result would not parse; live config untouched ({exc})")
|
|
262
|
+
if dry:
|
|
263
|
+
info(f"[dry-run] merge agent-bios additions into {target}")
|
|
264
|
+
sys.exit(0)
|
|
265
|
+
if live_text and backup_root:
|
|
266
|
+
bpath = pathlib.Path(backup_root + str(target))
|
|
267
|
+
bpath.parent.mkdir(parents=True, exist_ok=True)
|
|
268
|
+
bpath.write_text(live_text)
|
|
269
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
270
|
+
target.write_text(new_text)
|
|
271
|
+
info(f"merged additions {target}")
|
|
272
|
+
PY
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
# ---- optional dependencies -----------------------------------------------
|
|
276
|
+
# Capabilities are optional: a missing one only degrades the review routes that
|
|
277
|
+
# need it. config/agent-launch.toml is the single source for both the command
|
|
278
|
+
# that gates a route and the install line offered here.
|
|
279
|
+
capability_table() {
|
|
280
|
+
python3 - "$REPO/config/agent-launch.toml" <<'PY'
|
|
281
|
+
import sys, tomllib
|
|
282
|
+
for name, cap in tomllib.load(open(sys.argv[1], "rb")).get("capabilities", {}).items():
|
|
283
|
+
print("\t".join((name, cap.get("command", ""), cap.get("install", ""))))
|
|
284
|
+
PY
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
install_capability() {
|
|
288
|
+
local name="$1" line="$2"
|
|
289
|
+
log "Installing optional dependency $name: $line"
|
|
290
|
+
if [ "$DRY_RUN" = 1 ]; then info "[dry-run] $line"; return 0; fi
|
|
291
|
+
if sh -c "$line"; then info "installed $name"; else log "warning: installing $name failed; routes needing it stay degraded"; fi
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
handle_capabilities() {
|
|
295
|
+
local requested="$1" name command line
|
|
296
|
+
# Fail on a typo rather than silently installing nothing.
|
|
297
|
+
local known; known=$(capability_table | cut -f1)
|
|
298
|
+
local want
|
|
299
|
+
for want in ${requested//,/ }; do
|
|
300
|
+
printf '%s\n' "$known" | grep -qx "$want" || {
|
|
301
|
+
log "unknown --with capability: $want (known: $(printf '%s' "$known" | tr '\n' ' '))"; return 1; }
|
|
302
|
+
done
|
|
303
|
+
while IFS=$'\t' read -r name command line; do
|
|
304
|
+
[ -n "$name" ] || continue
|
|
305
|
+
if command -v "$command" >/dev/null 2>&1; then
|
|
306
|
+
info "capability present $name ($command)"
|
|
307
|
+
continue
|
|
308
|
+
fi
|
|
309
|
+
if printf '%s\n' "${requested//,/ }" | tr ' ' '\n' | grep -qx "$name"; then
|
|
310
|
+
[ -n "$line" ] && install_capability "$name" "$line" \
|
|
311
|
+
|| log "note: $name has no configured install line"
|
|
312
|
+
elif [ -n "$line" ] && [ -t 0 ] && [ "$DRY_RUN" != 1 ]; then
|
|
313
|
+
printf ' Install optional dependency %s? (%s) [y/N] ' "$name" "$line"
|
|
314
|
+
local answer=""; read -r answer </dev/tty || answer=""
|
|
315
|
+
case "$answer" in
|
|
316
|
+
[yY]*) install_capability "$name" "$line" ;;
|
|
317
|
+
*) info "skipped $name — install later: $line" ;;
|
|
318
|
+
esac
|
|
319
|
+
else
|
|
320
|
+
info "optional $name unavailable; routes needing it degrade${line:+ — install: $line}"
|
|
321
|
+
fi
|
|
322
|
+
done <<EOF
|
|
323
|
+
$(capability_table)
|
|
324
|
+
EOF
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
# Presets the launcher saved into the deployed profiles.toml (pre-split, or by hand)
|
|
328
|
+
# would be lost to the cp below; move them into the user-owned presets file first.
|
|
329
|
+
migrate_user_presets() {
|
|
330
|
+
local src="$REPO/config/agent-launch.toml"
|
|
331
|
+
local dst="$LAUNCH_DIR/profiles.toml"
|
|
332
|
+
local user="$LAUNCH_DIR/$USER_PRESETS_NAME"
|
|
333
|
+
[ -f "$dst" ] || return 0
|
|
334
|
+
python3 - "$src" "$dst" "$user" "$DRY_RUN" <<'PY' || { log "user preset migration failed; not overwriting $LAUNCH_DIR/profiles.toml"; return 1; }
|
|
335
|
+
import os, pathlib, re, sys, tomllib
|
|
336
|
+
|
|
337
|
+
src, dst, user, dry_run = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]), pathlib.Path(sys.argv[3]), sys.argv[4] == "1"
|
|
338
|
+
dst_text = dst.read_text()
|
|
339
|
+
shipped = set(tomllib.loads(src.read_text()).get("presets", {}))
|
|
340
|
+
extra = set(tomllib.loads(dst_text).get("presets", {})) - shipped
|
|
341
|
+
if not extra:
|
|
342
|
+
sys.exit(0)
|
|
343
|
+
existing_text = user.read_text() if user.is_file() else ""
|
|
344
|
+
already = set(tomllib.loads(existing_text).get("presets", {})) if existing_text else set()
|
|
345
|
+
move = sorted(extra - already)
|
|
346
|
+
for name in sorted(extra & already):
|
|
347
|
+
print(f" kept in {user.name} {name} (already saved there)")
|
|
348
|
+
if not move:
|
|
349
|
+
sys.exit(0)
|
|
350
|
+
|
|
351
|
+
header = re.compile(r'^\[presets\.(?:"([^"]+)"|([A-Za-z0-9][A-Za-z0-9_-]*))(?:[.\]])')
|
|
352
|
+
blocks, current = {}, None
|
|
353
|
+
for line in dst_text.splitlines(keepends=True):
|
|
354
|
+
stripped = line.strip()
|
|
355
|
+
if stripped.startswith("["):
|
|
356
|
+
found = header.match(stripped)
|
|
357
|
+
current = (found.group(1) or found.group(2)) if found else None
|
|
358
|
+
if current in move:
|
|
359
|
+
blocks.setdefault(current, []).append(line)
|
|
360
|
+
# Refuse to deploy over a preset we could not carry across, rather than drop it.
|
|
361
|
+
missing = [name for name in move if name not in blocks]
|
|
362
|
+
if missing:
|
|
363
|
+
sys.exit(f"cannot extract preset block(s) from {dst}: {', '.join(missing)}")
|
|
364
|
+
|
|
365
|
+
out = existing_text.rstrip("\n") or (
|
|
366
|
+
"# agent-launch user presets, moved out of profiles.toml by agent-bios install.\n"
|
|
367
|
+
"# The installer never deploys or verifies this file, so presets here survive upgrades."
|
|
368
|
+
)
|
|
369
|
+
for name in move:
|
|
370
|
+
out += "\n\n" + "".join(blocks[name]).strip("\n")
|
|
371
|
+
for name in move:
|
|
372
|
+
print(f" {'[dry-run] ' if dry_run else ''}moved preset {name} -> {user}")
|
|
373
|
+
if dry_run:
|
|
374
|
+
sys.exit(0)
|
|
375
|
+
user.parent.mkdir(parents=True, exist_ok=True)
|
|
376
|
+
temporary = user.with_name(f".{user.name}.{os.getpid()}.tmp")
|
|
377
|
+
temporary.write_text(out + "\n")
|
|
378
|
+
os.replace(temporary, user)
|
|
379
|
+
PY
|
|
380
|
+
}
|
|
381
|
+
|
|
128
382
|
remove_zsh_hook() {
|
|
129
383
|
if [ ! -f "$ZSHRC" ] || ! grep -qF "$HOOK_MARK" "$ZSHRC"; then
|
|
130
384
|
info "no zsh hook to remove"
|
|
@@ -147,14 +401,22 @@ cmd_install() {
|
|
|
147
401
|
log "Deploying agent-bios from $REPO"
|
|
148
402
|
deploy_file "$REPO/claude/CLAUDE.md" "$CLAUDE_DIR/CLAUDE.md"
|
|
149
403
|
deploy_glob "$REPO/claude/guides" "*.md" "$CLAUDE_DIR/guides"
|
|
404
|
+
deploy_glob "$REPO/claude/agents" "*.md" "$CLAUDE_DIR/agents"
|
|
405
|
+
deploy_glob "$REPO/claude/hooks" "*.py" "$CLAUDE_DIR/hooks" "+x"
|
|
150
406
|
deploy_file "$REPO/codex/AGENTS.md" "$CODEX_DIR/AGENTS.md"
|
|
151
407
|
deploy_glob "$REPO/codex/guides" "*.md" "$CODEX_DIR/guides"
|
|
152
408
|
deploy_glob "$REPO/codex/agents" "*.toml" "$CODEX_DIR/agents"
|
|
409
|
+
codex_config_additions merge || exit 1
|
|
153
410
|
deploy_file "$REPO/scripts/codex-run.sh" "$CODEX_DIR/bin/codex-run" "+x"
|
|
154
411
|
deploy_file "$REPO/scripts/codex-helm.sh" "$CODEX_DIR/bin/codex-helm" "+x"
|
|
412
|
+
migrate_user_presets || exit 1 # must precede the deploy below, which overwrites profiles.toml
|
|
155
413
|
deploy_file "$REPO/config/agent-launch.toml" "$LAUNCH_DIR/profiles.toml"
|
|
156
414
|
deploy_file "$REPO/shell/agent-launch.zsh" "$LAUNCH_DIR/shell.zsh"
|
|
157
415
|
deploy_file "$REPO/scripts/agent-launch.py" "$BIN_DIR/agent-launch" "+x"
|
|
416
|
+
log ""
|
|
417
|
+
log "Optional dependencies (missing ones only degrade the routes that need them)..."
|
|
418
|
+
handle_capabilities "$WITH" || exit 1
|
|
419
|
+
log ""
|
|
158
420
|
if [ "$DRY_RUN" = 1 ]; then
|
|
159
421
|
info "[dry-run] provision managed Textual venv"
|
|
160
422
|
else
|
|
@@ -162,12 +424,19 @@ cmd_install() {
|
|
|
162
424
|
|| log "warning: venv provisioning failed (numbered-prompt fallback applies)"
|
|
163
425
|
fi
|
|
164
426
|
add_zsh_hook
|
|
427
|
+
if python3 "$REPO/scripts/session-learning/learning-state.py" project --repo "$REPO" >/dev/null 2>&1; then
|
|
428
|
+
info "learning-status projected"
|
|
429
|
+
else
|
|
430
|
+
log "note: learning-status projection unavailable (versions.json/ledger missing?)"
|
|
431
|
+
fi
|
|
165
432
|
log ""
|
|
166
433
|
log "Verifying deployment..."
|
|
167
434
|
if cmd_verify; then
|
|
168
435
|
log ""
|
|
169
436
|
log "Done. Open a new shell (or: source \"$ZSHRC\") to activate the zero-arg launcher."
|
|
170
|
-
|
|
437
|
+
# An untouched backup dir means nothing was replaced; that healthy state
|
|
438
|
+
# must not become a nonzero exit under set -e.
|
|
439
|
+
{ [ -n "$BACKUP_DIR" ] && [ -d "$BACKUP_DIR" ] && log "Replaced files were backed up under $BACKUP_DIR"; } || true
|
|
171
440
|
else
|
|
172
441
|
log "VERIFY FAILED after install — see messages above"
|
|
173
442
|
exit 1
|
|
@@ -185,6 +454,8 @@ cmd_verify() {
|
|
|
185
454
|
verify_match "$REPO/config/agent-launch.toml" "$LAUNCH_DIR/profiles.toml" || fail=1
|
|
186
455
|
verify_match "$REPO/shell/agent-launch.zsh" "$LAUNCH_DIR/shell.zsh" || fail=1
|
|
187
456
|
for gp in "$REPO"/claude/guides/*.md; do verify_present "$CLAUDE_DIR/guides/$(basename "$gp")" || fail=1; done
|
|
457
|
+
for gp in "$REPO"/claude/agents/*.md; do verify_present "$CLAUDE_DIR/agents/$(basename "$gp")" || fail=1; done
|
|
458
|
+
for gp in "$REPO"/claude/hooks/*.py; do verify_present "$CLAUDE_DIR/hooks/$(basename "$gp")" || fail=1; done
|
|
188
459
|
for gp in "$REPO"/codex/guides/*.md; do verify_present "$CODEX_DIR/guides/$(basename "$gp")" || fail=1; done
|
|
189
460
|
python3 - "$CODEX_DIR/agents" <<'PY' && info "agent TOMLs OK" || fail=1
|
|
190
461
|
import sys, pathlib, tomllib
|
|
@@ -195,6 +466,7 @@ assert not missing, f"missing agent TOMLs in {root}: {sorted(missing)}"
|
|
|
195
466
|
for p in sorted(root.glob("*.toml")):
|
|
196
467
|
tomllib.loads(p.read_text())
|
|
197
468
|
PY
|
|
469
|
+
codex_config_additions check || fail=1
|
|
198
470
|
if command -v codex >/dev/null 2>&1 && [ -x "$CODEX_DIR/bin/codex-helm" ]; then
|
|
199
471
|
if "$CODEX_DIR/bin/codex-helm" --dry-run --mode review "probe" >/dev/null 2>&1; then
|
|
200
472
|
info "codex-helm dry-run OK"
|
|
@@ -212,11 +484,22 @@ PY
|
|
|
212
484
|
if [ -d "$REPO/ko" ] && [ -x "$REPO/scripts/check-parity.sh" ]; then
|
|
213
485
|
if "$REPO/scripts/check-parity.sh" >/dev/null 2>&1; then info "repo mirror parity OK"; else log "repo mirror parity FAILED"; fail=1; fi
|
|
214
486
|
fi
|
|
487
|
+
# Prompting guides name concrete models, so they go stale on a model change
|
|
488
|
+
# rather than degrading quietly; this checks them against the launch config.
|
|
489
|
+
if [ -x "$REPO/scripts/check-prompting-targets.sh" ]; then
|
|
490
|
+
if "$REPO/scripts/check-prompting-targets.sh" >/dev/null 2>&1; then
|
|
491
|
+
info "prompting targets OK"
|
|
492
|
+
else
|
|
493
|
+
log "prompting guides do not cover a configured model; run scripts/check-prompting-targets.sh"
|
|
494
|
+
fail=1
|
|
495
|
+
fi
|
|
496
|
+
fi
|
|
215
497
|
return $fail
|
|
216
498
|
}
|
|
217
499
|
|
|
218
500
|
cmd_uninstall() {
|
|
219
501
|
migrate_state
|
|
502
|
+
codex_config_additions remove || log "warning: could not remove codex config additions"
|
|
220
503
|
if [ -f "$MANIFEST" ]; then
|
|
221
504
|
local f
|
|
222
505
|
while IFS= read -r f; do
|
|
@@ -234,7 +517,7 @@ cmd_uninstall() {
|
|
|
234
517
|
done
|
|
235
518
|
fi
|
|
236
519
|
local d
|
|
237
|
-
for d in "$CLAUDE_DIR/guides" "$CODEX_DIR/guides" "$CODEX_DIR/agents" "$CODEX_DIR/bin" "$LAUNCH_DIR"; do
|
|
520
|
+
for d in "$CLAUDE_DIR/guides" "$CLAUDE_DIR/agents" "$CODEX_DIR/guides" "$CODEX_DIR/agents" "$CODEX_DIR/bin" "$LAUNCH_DIR"; do
|
|
238
521
|
[ -d "$d" ] && rmdir "$d" 2>/dev/null && info "removed empty $d" || true
|
|
239
522
|
done
|
|
240
523
|
remove_zsh_hook
|
|
@@ -284,7 +567,12 @@ agent-bios — deploy the Claude/Codex instruction SSOT into $HOME (by copy).
|
|
|
284
567
|
agent-bios uninstall remove deployed files and the zsh hook
|
|
285
568
|
agent-bios help
|
|
286
569
|
|
|
287
|
-
Flags: --dry-run
|
|
570
|
+
Flags: --dry-run print actions without changing anything
|
|
571
|
+
--with a,b also install the named optional dependencies (install only).
|
|
572
|
+
Without it, install offers each missing one when the terminal
|
|
573
|
+
is interactive, and otherwise just prints its install line.
|
|
574
|
+
Known: onto, ultracode. Missing ones are not fatal — they only
|
|
575
|
+
degrade the review routes that need them.
|
|
288
576
|
Env: CLAUDE_CONFIG_DIR, CODEX_HOME, AGENT_LAUNCH_VENV, ZDOTDIR
|
|
289
577
|
EOF
|
|
290
578
|
}
|
|
@@ -292,11 +580,15 @@ EOF
|
|
|
292
580
|
# ---- dispatch ------------------------------------------------------------
|
|
293
581
|
CMD="${1:-help}"
|
|
294
582
|
if [ $# -gt 0 ]; then shift; fi
|
|
295
|
-
|
|
296
|
-
|
|
583
|
+
WITH=""
|
|
584
|
+
while [ $# -gt 0 ]; do
|
|
585
|
+
case "$1" in
|
|
297
586
|
--dry-run) DRY_RUN=1 ;;
|
|
298
|
-
|
|
587
|
+
--with) shift; WITH="${1:-}"; [ -n "$WITH" ] || { log "--with needs a comma-separated capability list"; exit 2; } ;;
|
|
588
|
+
--with=*) WITH="${1#--with=}"; [ -n "$WITH" ] || { log "--with needs a comma-separated capability list"; exit 2; } ;;
|
|
589
|
+
*) log "unknown flag: $1"; exit 2 ;;
|
|
299
590
|
esac
|
|
591
|
+
shift
|
|
300
592
|
done
|
|
301
593
|
|
|
302
594
|
case "$CMD" in
|