Skip to content

Commit a03c3a3

Browse files
authored
Merge pull request #48 from codejunkie99/codex/resolve-upgrade-issues
Fix upgrade path and skill manifest drift
2 parents 91a60f9 + d85765b commit a03c3a3

11 files changed

Lines changed: 588 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,22 @@ All notable changes to this project.
55
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Added
11+
- **`agentic-stack upgrade`.** Adds a safe project migration verb that refreshes
12+
skeleton-owned `.agent` infrastructure, copies new skills, supports
13+
`--dry-run`/`--yes`, and leaves adapter configs plus user memory untouched.
14+
- **`agentic-stack sync-manifest`.** Rebuilds `.agent/skills/_manifest.jsonl`
15+
from installed `SKILL.md` frontmatter so copied skills can trigger correctly.
16+
17+
### Fixed
18+
- Install/add now re-sync the skill manifest when a project already has
19+
`.agent/skills`, preventing `_index.md` / `_manifest.jsonl` drift.
20+
- `doctor` now warns when Claude Code hook commands reference missing `.agent`
21+
Python files or when hook scripts are present but not wired in
22+
`.claude/settings.json`.
23+
824
## [0.15.0] — 2026-05-06
925

1026
Minor release. Adds a production dashboard TUI for installed agentic-stack

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,9 @@ verb-style subcommands (works with both `install.sh` and `install.ps1`):
154154
./install.sh doctor # read-only audit; green / yellow / red per adapter
155155
./install.sh manage # interactive TUI: header pane + menu loop for add/remove/audit
156156
./install.sh transfer # onboarding-style wizard: export/import memory as a curl bridge
157+
./install.sh upgrade --dry-run # preview safe .agent infrastructure refresh
158+
./install.sh upgrade --yes # copy latest harness/memory/tools + new skills
159+
./install.sh sync-manifest # rebuild .agent/skills/_manifest.jsonl from SKILL.md frontmatter
157160
./install.sh remove cursor # confirm prompt + delete; no quarantine, no undo
158161
```
159162

@@ -172,6 +175,16 @@ synthesizes `install.json` from on-disk adapter signals so the new
172175
backend can track them. Installing on top without migration would
173176
orphan the prior installs.
174177

178+
Upgrading an already-installed project after `brew upgrade`? Run
179+
`agentic-stack upgrade --dry-run` in the project first, then
180+
`agentic-stack upgrade --yes` to refresh only skeleton-owned `.agent`
181+
infrastructure (`harness/**/*.py`, top-level `memory/*.py`, `tools/*.py`,
182+
the generated skill index, and new skill directories). It does not rewrite
183+
`CLAUDE.md`, `.claude/settings.json`, personal/semantic/episodic/working
184+
memory, candidates, or existing skill directories. `agentic-stack
185+
sync-manifest` is available as a repair command if `_manifest.jsonl` drifts
186+
from installed `SKILL.md` files.
187+
175188
## Onboarding wizard
176189

177190
If you ran bare `./install.sh` (no adapter name), the wizard starts
@@ -375,6 +388,8 @@ harness_manager/ # v0.9.0 manifest-driven Python backend
375388
├── transfer_tui.py # onboarding-style memory transfer wizard
376389
├── transfer_plan.py # natural-language target/scope planning
377390
├── transfer_bundle.py # export/import bundle codec + merge logic
391+
├── skill_manifest.py # rebuilds skills/_manifest.jsonl from SKILL.md
392+
├── upgrade.py # safe .agent infrastructure refresh
378393
└── cli.py # argparse dispatcher for install.sh / install.ps1
379394
380395
docs/ # architecture, getting-started, per-harness

harness_manager/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
33
This package is the implementation backend for `./install.sh` and `./install.ps1`.
44
The user-facing surface is plain verbs: install, add, remove, doctor, status,
5-
dashboard, manage, and transfer.
5+
dashboard, manage, transfer, upgrade, and sync-manifest.
66
The "harness_manager" name is internal only and never appears in CLI help, docs,
77
or error messages users see.
88
"""

harness_manager/cli.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,25 @@
1414
from . import install as install_mod
1515
from . import remove as remove_mod
1616
from . import schema as schema_mod
17+
from . import skill_manifest as skill_manifest_mod
1718
from . import state as state_mod
1819
from . import status as status_mod
20+
from . import upgrade as upgrade_mod
1921
from . import __version__
2022

2123

22-
VERBS = {"add", "remove", "doctor", "status", "manage", "dashboard", "dash", "transfer"}
24+
VERBS = {
25+
"add",
26+
"remove",
27+
"doctor",
28+
"status",
29+
"manage",
30+
"dashboard",
31+
"dash",
32+
"transfer",
33+
"upgrade",
34+
"sync-manifest",
35+
}
2336

2437

2538
def _stack_root() -> Path:
@@ -263,6 +276,37 @@ def cmd_transfer(args: list[str], target: Path) -> int:
263276
return transfer_tui.run(args, target_root=target, stack_root=_stack_root())
264277

265278

279+
def cmd_sync_manifest(target: Path) -> int:
280+
try:
281+
skill_manifest_mod.sync_manifest(target)
282+
return 0
283+
except FileNotFoundError as e:
284+
print(f"error: {e}", file=sys.stderr)
285+
return 2
286+
287+
288+
def cmd_upgrade(args: list[str], yes: bool) -> int:
289+
dry_run = False
290+
target_args: list[str] = []
291+
for arg in args:
292+
if arg == "--dry-run":
293+
dry_run = True
294+
elif arg in ("--yes", "-y"):
295+
yes = True
296+
else:
297+
target_args.append(arg)
298+
if len(target_args) > 1:
299+
print("usage: ./install.sh upgrade [target-dir] [--dry-run] [--yes]", file=sys.stderr)
300+
return 2
301+
target = Path(target_args[0]) if target_args else Path.cwd()
302+
return upgrade_mod.upgrade(
303+
target_root=target,
304+
stack_root=_stack_root(),
305+
dry_run=dry_run,
306+
yes=yes,
307+
)
308+
309+
266310
def cmd_bare(target: Path, wizard_flags: list[str]) -> int:
267311
"""`./install.sh` with no args.
268312
@@ -342,6 +386,8 @@ def cmd_bare(target: Path, wizard_flags: list[str]) -> int:
342386
print(" ./install.sh dashboard # interactive project dashboard")
343387
print(" ./install.sh manage # interactive TUI for adapter management")
344388
print(" ./install.sh transfer # onboarding-style memory transfer wizard")
389+
print(" ./install.sh upgrade # safely refresh .agent infrastructure")
390+
print(" ./install.sh sync-manifest # repair skills/_manifest.jsonl")
345391
return 2
346392

347393

@@ -487,6 +533,11 @@ def main(argv: list[str] | None = None) -> int:
487533
return cmd_dashboard(target, plain=plain)
488534
if verb == "transfer":
489535
return cmd_transfer(rest[1:], Path.cwd())
536+
if verb == "upgrade":
537+
return cmd_upgrade(rest[1:], yes=yes)
538+
if verb == "sync-manifest":
539+
target = Path(rest[1]) if len(rest) >= 2 else Path.cwd()
540+
return cmd_sync_manifest(target)
490541

491542
# Treat as adapter name (existing UX)
492543
adapter = first

harness_manager/doctor.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from __future__ import annotations
1212

1313
import os
14+
import json
15+
import shlex
1416
import shutil
1517
import sys
1618
from pathlib import Path
@@ -234,6 +236,12 @@ def _audit_adapter(
234236
# Unknown post_install action — just record
235237
lines.append(f"post_install {action}: {st}")
236238

239+
if adapter_name == "claude-code":
240+
hook_status, hook_lines = _audit_claude_hook_wiring(target_root)
241+
if hook_lines:
242+
lines.extend(hook_lines)
243+
status_overall = max(status_overall, hook_status, key=_status_rank)
244+
237245
# .agent/ brain still intact?
238246
if not (target_root / ".agent" / "AGENTS.md").is_file():
239247
lines.append(".agent/AGENTS.md missing — brain not present")
@@ -275,6 +283,91 @@ def _status_rank(s: str) -> int:
275283
return {GREEN: 0, YELLOW: 1, RED: 2}[s]
276284

277285

286+
def _audit_claude_hook_wiring(target_root: Path) -> tuple[str, list[str]]:
287+
settings = target_root / ".claude" / "settings.json"
288+
if not settings.is_file():
289+
return GREEN, []
290+
try:
291+
data = json.loads(settings.read_text(encoding="utf-8"))
292+
except json.JSONDecodeError as e:
293+
return YELLOW, [f".claude/settings.json unreadable JSON: {e}"]
294+
295+
referenced = _claude_hook_references(data)
296+
lines: list[str] = []
297+
missing = [
298+
rel
299+
for rel in sorted(referenced)
300+
if rel.startswith(".agent/") and not (target_root / rel).is_file()
301+
]
302+
if missing:
303+
lines.append(f"missing hook command file(s): {', '.join(missing)}")
304+
305+
hooks_dir = target_root / ".agent" / "harness" / "hooks"
306+
if hooks_dir.is_dir():
307+
wired = {rel for rel in referenced if rel.startswith(".agent/harness/hooks/")}
308+
orphaned = []
309+
for path in sorted(hooks_dir.glob("*.py")):
310+
if _ignore_claude_orphan_candidate(path.name):
311+
continue
312+
rel = path.relative_to(target_root).as_posix()
313+
if rel not in wired:
314+
orphaned.append(rel)
315+
if orphaned:
316+
lines.append(
317+
"orphaned hook files not referenced by .claude/settings.json: "
318+
+ ", ".join(orphaned)
319+
)
320+
return (YELLOW if lines else GREEN), lines
321+
322+
323+
def _claude_hook_references(settings: dict) -> set[str]:
324+
refs: set[str] = set()
325+
hooks = settings.get("hooks") or {}
326+
if not isinstance(hooks, dict):
327+
return refs
328+
for entries in hooks.values():
329+
if not isinstance(entries, list):
330+
continue
331+
for entry in entries:
332+
if not isinstance(entry, dict):
333+
continue
334+
for hook in entry.get("hooks") or []:
335+
if not isinstance(hook, dict):
336+
continue
337+
command = hook.get("command")
338+
if isinstance(command, str):
339+
refs.update(_agent_paths_from_command(command))
340+
return refs
341+
342+
343+
def _agent_paths_from_command(command: str) -> set[str]:
344+
try:
345+
tokens = shlex.split(command)
346+
except ValueError:
347+
tokens = command.split()
348+
refs: set[str] = set()
349+
for token in tokens:
350+
if ".agent/" not in token:
351+
continue
352+
rel = token[token.index(".agent/"):].strip(";,")
353+
if rel.endswith(".py"):
354+
refs.add(rel)
355+
return refs
356+
357+
358+
def _ignore_claude_orphan_candidate(filename: str) -> bool:
359+
if filename == "__init__.py" or filename.startswith("_"):
360+
return True
361+
if filename in {
362+
"on_failure.py",
363+
"post_execution.py",
364+
"pre_tool_call.py",
365+
"pi_post_tool.py",
366+
}:
367+
return True
368+
return False
369+
370+
278371
def _summary(doc: dict, any_red: bool) -> str:
279372
n = len(doc.get("adapters", {}))
280373
if any_red:

harness_manager/install.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from . import post_install as post_install_mod
1515
from . import schema as schema_mod
16+
from . import skill_manifest as skill_manifest_mod
1617
from . import state as state_mod
1718
from . import __version__
1819

@@ -189,6 +190,8 @@ def install(
189190
if not target_agent.exists():
190191
shutil.copytree(stack_root / ".agent", target_agent)
191192
log(" + .agent/ (portable brain)")
193+
if (target_agent / "skills").is_dir():
194+
skill_manifest_mod.sync_manifest(target_root, log=lambda _msg: None)
192195

193196
files_written: list[str] = [] # we created — safe for remove to delete
194197
files_overwritten: list[str] = [] # we modified but file pre-existed — DO NOT delete on remove

0 commit comments

Comments
 (0)