Skip to content

Commit 4c595b1

Browse files
author
codejunkie99
committed
fix(harness-manager): resolve 9 rounds of codex pre-PR review
Migration safety, ownership conservatism, and openclaw lifecycle fixes across the v0.9.0 backend. Highlights: - Brain-presence + adapter-signal gate for pre-v0.9 detection (state.py legacy_unregistered_adapters): require BOTH the distinctive .agent/ brain layout AND a strong adapter signal before refusing install. Fixes false-positive on clean repos with generic CLAUDE.md/AGENTS.md/run.py AND false-negative when only weak-only adapters (hermes, std-python) are migrated. - Wizard, cmd_install, cmd_add, manage TUI all gate on the same helper. Pre-v0.9 projects refuse with "run ./install.sh doctor first" instead of orphaning the prior install on top. - Remove conservativism: shared-file ownership handoff only transfers when another adapter PROVABLY wrote the file (file_results recorded written_new/written_overwrite). Loose handoff would let future remove delete user content the other adapter only observed. - post_install reverse only fires for status=="ok" (we performed the action), not "already_exists" (registration predated us). - doctor: skills_link target validation (catch repointed symlinks), binary_missing → RED for previously-ok openclaw registrations (config-file absence == registration lost), logical-path discipline via os.path.abspath (matches post_install.py for openclaw agent name stability across symlinked workspaces). - install: pre-existing regular file at skills_link dst now raises FileExistsError with a helpful message instead of crashing. - state.py: msvcrt-based locking for Windows so install.ps1 has the same concurrent-write protection as POSIX (was no-op before). - adapters/claude-code/settings.json: hardcode $CLAUDE_PROJECT_DIR instead of {{BRAIN_ROOT}} placeholder so manual-copy install path works without going through install.sh's substitution. - TUI _action_add runs onboard.py on first install (matches cmd_install / wizard) so PREFERENCES.md gets initialized. Tests: 40 passed, 1 skipped. 9 codex review rounds against master.
1 parent d150f5b commit 4c595b1

8 files changed

Lines changed: 535 additions & 71 deletions

File tree

adapters/claude-code/settings.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"hooks": [
88
{
99
"type": "command",
10-
"command": "python3 \"{{BRAIN_ROOT}}/.agent/harness/hooks/claude_code_post_tool.py\""
10+
"command": "python3 \"$CLAUDE_PROJECT_DIR/.agent/harness/hooks/claude_code_post_tool.py\""
1111
}
1212
]
1313
}
@@ -18,7 +18,7 @@
1818
"hooks": [
1919
{
2020
"type": "command",
21-
"command": "python3 \"{{BRAIN_ROOT}}/.agent/memory/auto_dream.py\""
21+
"command": "python3 \"$CLAUDE_PROJECT_DIR/.agent/memory/auto_dream.py\""
2222
}
2323
]
2424
}

harness_manager/cli.py

Lines changed: 228 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,37 @@ def _maybe_run_onboard(target: Path, wizard_flags: list[str]) -> int:
9696
# ---- subcommands -----------------------------------------------------
9797

9898
def cmd_install(adapter_name: str, target: Path, wizard_flags: list[str]) -> int:
99-
"""Install one adapter into target. Existing `./install.sh <adapter>` UX."""
99+
"""Install one adapter into target. Existing `./install.sh <adapter>` UX.
100+
101+
Refuses on pre-v0.9 projects (no install.json) when STRONG adapter
102+
signals are already on disk — without this guard, the install would
103+
create a fresh install.json containing only the newly-installed
104+
adapter, orphaning every pre-v0.9 install (they'd vanish from
105+
status/doctor/remove even though their files remain on disk). The
106+
same gate cmd_add uses. Weak signals (plain CLAUDE.md, AGENTS.md,
107+
run.py) are ignored to avoid false-refusing clean repos that happen
108+
to contain one of those common files.
109+
"""
110+
detected = state_mod.legacy_unregistered_adapters(target)
111+
if detected:
112+
# Pre-v0.9 project: brain is present AND adapter signals exist.
113+
# Refuse so doctor can synthesize install.json first and
114+
# preserve the prior install(s). Brain-without-signals is NOT
115+
# gated — cloning agentic-stack itself, or copying the brain
116+
# template before first install, shouldn't deadlock the user.
117+
print(
118+
f"error: {target}/.agent/ exists but install.json does not.\n"
119+
f"this looks like a pre-v0.9 install. detected adapters: {detected}\n"
120+
f"\n"
121+
f"run this first to register them safely:\n"
122+
f" ./install.sh doctor\n"
123+
f"\n"
124+
f"proceeding would otherwise create a fresh install.json with only\n"
125+
f"the new adapter, leaving the existing ones invisible to\n"
126+
f"status/doctor/remove.",
127+
file=sys.stderr,
128+
)
129+
return 2
100130
manifest = _adapter_manifest(adapter_name)
101131
install_mod.install(
102132
manifest=manifest,
@@ -107,7 +137,51 @@ def cmd_install(adapter_name: str, target: Path, wizard_flags: list[str]) -> int
107137
# Propagate the onboarding wizard's exit code: Ctrl-C, exception, or
108138
# explicit failure inside onboard.py should fail the install command,
109139
# matching the pre-v0.9.0 `exec python3 onboard.py` semantics.
110-
return _maybe_run_onboard(target, wizard_flags)
140+
rc = _maybe_run_onboard(target, wizard_flags)
141+
if rc != 0:
142+
return rc
143+
# Post-install: offer the manage TUI so users who installed one
144+
# adapter can immediately add others without re-running install.sh.
145+
# Skip if --yes (scripted) or non-TTY (CI safety).
146+
if "--yes" not in wizard_flags and sys.stdin.isatty() and sys.stdout.isatty():
147+
_maybe_offer_manage(target)
148+
return 0
149+
150+
151+
def _maybe_offer_manage(target: Path) -> None:
152+
"""Offer the manage TUI after a single-adapter install.
153+
154+
Only invoked from cmd_install when the shell is interactive and the
155+
user didn't pass --yes. If every available adapter is already
156+
installed, skip the prompt — nothing useful to do in the TUI. Default
157+
is no, so just hitting enter dismisses without entering the TUI.
158+
"""
159+
doc = state_mod.load(target) or {}
160+
installed = set(doc.get("adapters") or {})
161+
available = set()
162+
root = _stack_root() / "adapters"
163+
if root.is_dir():
164+
for p in root.iterdir():
165+
if p.is_dir() and (p / "adapter.json").is_file():
166+
available.add(p.name)
167+
not_installed = sorted(available - installed)
168+
if not not_installed:
169+
return
170+
sys.path.insert(0, str(_stack_root()))
171+
import onboard_widgets as widgets # noqa: E402
172+
print()
173+
try:
174+
choice = widgets.ask_confirm(
175+
f"install or manage other adapters? ({len(not_installed)} available)",
176+
default=False,
177+
)
178+
except KeyboardInterrupt:
179+
# Install already succeeded; treat Ctrl-C at the offer as "no."
180+
print()
181+
return
182+
if choice:
183+
from . import manage_tui
184+
manage_tui.run(target_root=target, stack_root=_stack_root())
111185

112186

113187
def cmd_add(adapter_name: str, target: Path) -> int:
@@ -119,28 +193,21 @@ def cmd_add(adapter_name: str, target: Path) -> int:
119193
disappear from status/doctor/remove tracking even though their files
120194
are still on disk.
121195
"""
122-
if state_mod.load(target) is None:
123-
# Pre-v0.9 project. Detect adapters and refuse with a clear path forward.
124-
from . import doctor as doctor_mod
125-
signals_present = []
126-
for name, signals in doctor_mod.DETECT_SIGNALS.items():
127-
if any((target / f).exists() for f, _ in signals):
128-
signals_present.append(name)
129-
if signals_present:
130-
print(
131-
f"error: {target}/.agent/install.json not present, but these adapters\n"
132-
f"appear to already be installed: {sorted(signals_present)}\n"
133-
f"\n"
134-
f"run this first to register them safely:\n"
135-
f" ./install.sh doctor\n"
136-
f"\n"
137-
f"`add` would otherwise create a fresh install.json with only\n"
138-
f"the new adapter, leaving the existing ones invisible to\n"
139-
f"status/doctor/remove.",
140-
file=sys.stderr,
141-
)
142-
return 2
143-
# No prior install detected; safe to proceed (`add` on a clean repo).
196+
detected = state_mod.legacy_unregistered_adapters(target)
197+
if detected:
198+
print(
199+
f"error: {target}/.agent/ exists but install.json does not.\n"
200+
f"this looks like a pre-v0.9 install. detected adapters: {detected}\n"
201+
f"\n"
202+
f"run this first to register them safely:\n"
203+
f" ./install.sh doctor\n"
204+
f"\n"
205+
f"`add` would otherwise create a fresh install.json with only\n"
206+
f"the new adapter, leaving the existing ones invisible to\n"
207+
f"status/doctor/remove.",
208+
file=sys.stderr,
209+
)
210+
return 2
144211
manifest = _adapter_manifest(adapter_name)
145212
install_mod.install(
146213
manifest=manifest,
@@ -180,43 +247,151 @@ def cmd_manage(target: Path) -> int:
180247
return manage_tui.run(target_root=target, stack_root=_stack_root())
181248

182249

183-
def cmd_bare(target: Path) -> int:
250+
def cmd_bare(target: Path, wizard_flags: list[str]) -> int:
184251
"""`./install.sh` with no args.
185252
186-
If install.json present: dispatch to add mode (offer adapters not yet
187-
installed). If not: print usage and exit non-zero.
253+
Behavior:
254+
- install.json present → list what's still installable
255+
- no install.json + TTY → enter the onboarding wizard (multi-select
256+
harness step, then per-adapter install, then PREFERENCES.md flow)
257+
- no install.json + non-TTY → print usage and exit 2 (CI safety)
188258
"""
189259
doc = state_mod.load(target)
190-
if doc is None:
191-
print("usage: ./install.sh <adapter-name> [target-dir]")
192-
print(f"adapters: {_list_adapters()}")
260+
if doc is not None:
261+
installed = set(doc.get("adapters", {}).keys())
262+
available = set()
263+
root = _stack_root() / "adapters"
264+
if root.is_dir():
265+
for p in root.iterdir():
266+
if p.is_dir() and (p / "adapter.json").is_file():
267+
available.add(p.name)
268+
not_installed = sorted(available - installed)
269+
if not not_installed:
270+
print(f"all available adapters already installed: {sorted(installed)}")
271+
print("run `./install.sh status` for a summary.")
272+
return 0
273+
print(f"already installed: {sorted(installed)}")
274+
print(f"available to add: {not_installed}")
193275
print()
194-
print("on a project that's already installed, run:")
195-
print(" ./install.sh doctor # audit")
196-
print(" ./install.sh status # quick read-only view")
197-
print(" ./install.sh add <name> # install another adapter")
198-
print(" ./install.sh remove <name> # remove an adapter (with confirm)")
199-
print(" ./install.sh manage # interactive TUI for everything")
276+
print(f"to add one: ./install.sh add <name>")
277+
print(f"or interactively: ./install.sh manage")
278+
return 0
279+
280+
# No install.json. Pre-v0.9 migration gate: if this is a legacy
281+
# install (brain AND adapter signals present) we must register
282+
# via doctor first. Brain-only (no signals) falls through to the
283+
# wizard — someone dropped the template here but never installed
284+
# anything, so there's nothing to migrate.
285+
detected = state_mod.legacy_unregistered_adapters(target)
286+
if detected:
287+
print(
288+
f"pre-v0.9 install detected at {target}.",
289+
file=sys.stderr,
290+
)
291+
print(
292+
f".agent/ exists but install.json does not. detected adapters: "
293+
f"{detected}",
294+
file=sys.stderr,
295+
)
296+
print(file=sys.stderr)
297+
print(
298+
"run this first to register them safely:", file=sys.stderr,
299+
)
300+
print(" ./install.sh doctor", file=sys.stderr)
301+
print(file=sys.stderr)
302+
print(
303+
"then re-run ./install.sh to add more adapters or use "
304+
"./install.sh manage.",
305+
file=sys.stderr,
306+
)
200307
return 2
201308

202-
installed = set(doc.get("adapters", {}).keys())
203-
available = set()
204-
root = _stack_root() / "adapters"
205-
if root.is_dir():
206-
for p in root.iterdir():
207-
if p.is_dir() and (p / "adapter.json").is_file():
208-
available.add(p.name)
209-
not_installed = sorted(available - installed)
210-
if not not_installed:
211-
print(f"all available adapters already installed: {sorted(installed)}")
212-
print("run `./install.sh status` for a summary.")
213-
return 0
309+
# No install.json and not a legacy install — fresh project. Two paths.
310+
if sys.stdin.isatty() and sys.stdout.isatty():
311+
return _run_install_wizard(target, wizard_flags)
214312

215-
print(f"already installed: {sorted(installed)}")
216-
print(f"available to add: {not_installed}")
313+
# Non-TTY (CI, scripted) → print usage, exit 2.
314+
print("usage: ./install.sh <adapter-name> [target-dir]")
315+
print(f"adapters: {_list_adapters()}")
217316
print()
218-
print(f"to add one: ./install.sh add <name>")
219-
return 0
317+
print("on a project that's already installed, run:")
318+
print(" ./install.sh doctor # audit")
319+
print(" ./install.sh status # quick read-only view")
320+
print(" ./install.sh add <name> # install another adapter")
321+
print(" ./install.sh remove <name> # remove an adapter (with confirm)")
322+
print(" ./install.sh manage # interactive TUI for everything")
323+
return 2
324+
325+
326+
def _run_install_wizard(target: Path, wizard_flags: list[str]) -> int:
327+
"""Onboarding wizard: detect → multi-select → install each → PREFERENCES.md.
328+
329+
The original "give people options like Claude Code and all of that"
330+
flow. Reuses the manage TUI's multi-select widget for harness pick.
331+
Auto-checks adapters whose detection signals are present in `target`,
332+
so a user who already has CLAUDE.md / .cursor/ in their repo gets
333+
those pre-selected.
334+
"""
335+
# Lazy imports — wizard path only.
336+
sys.path.insert(0, str(_stack_root()))
337+
import onboard_widgets as widgets # noqa: E402
338+
from onboard_ui import print_banner, intro, R, MUTED, GREEN # noqa: E402
339+
from . import doctor as doctor_mod
340+
341+
print_banner()
342+
intro("agentic-stack onboarding")
343+
344+
# Discover all available adapters.
345+
available = sorted(n for n, _ in schema_mod.discover_all(_stack_root()))
346+
if not available:
347+
print(f" {MUTED}no adapters available — repo seems empty{R}")
348+
return 1
349+
350+
# Auto-detect what's already on disk in the target. Only STRONG
351+
# signals count for default-check — a weak signal like a generic
352+
# CLAUDE.md, AGENTS.md, or run.py can belong to any project; pre-
353+
# checking one and hitting Enter at the multiselect would silently
354+
# overwrite the user's file (claude-code's CLAUDE.md is
355+
# merge_policy: overwrite). Weak-only matches still surface to the
356+
# user via the adapter list but stay unchecked until toggled.
357+
detected = set()
358+
for name in available:
359+
signals = doctor_mod.DETECT_SIGNALS.get(name, [])
360+
if any(
361+
(target / f).exists()
362+
for f, strength in signals
363+
if strength == "strong"
364+
):
365+
detected.add(name)
366+
defaults = [available.index(n) for n in available if n in detected]
367+
368+
if detected:
369+
print(f" {GREEN}detected{R}: {sorted(detected)} — pre-checked below.")
370+
print()
371+
372+
chosen = widgets.ask_multiselect(
373+
"which harnesses are you using?",
374+
available,
375+
defaults=defaults,
376+
)
377+
if not chosen:
378+
print(f" {MUTED}no adapters selected; brain not installed.{R}")
379+
print(f" {MUTED}you can run `./install.sh <adapter>` later.{R}")
380+
return 0
381+
382+
# Install each selected adapter via the manifest backend.
383+
for name in chosen:
384+
manifest_path = _stack_root() / "adapters" / name / "adapter.json"
385+
manifest = schema_mod.validate(manifest_path)
386+
install_mod.install(
387+
manifest=manifest,
388+
target_root=target,
389+
adapter_dir=_stack_root() / "adapters" / name,
390+
stack_root=_stack_root(),
391+
)
392+
393+
# Continue to existing PREFERENCES.md flow.
394+
return _maybe_run_onboard(target, wizard_flags)
220395

221396

222397
# ---- main ------------------------------------------------------------
@@ -246,7 +421,7 @@ def main(argv: list[str] | None = None) -> int:
246421

247422
if not rest:
248423
target = Path.cwd()
249-
return cmd_bare(target)
424+
return cmd_bare(target, wizard_flags)
250425

251426
first = rest[0]
252427

0 commit comments

Comments
 (0)