diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 2e82ec2cf..4b45eb230 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "genie", - "version": "5.260710.2", + "version": "5.260710.7", "source": "./plugins/genie", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, wish them into plans, make with parallel agents, ship as one team. A coding genie that grows with your project." } diff --git a/.genie/INDEX.md b/.genie/INDEX.md index e501a95fb..50a697a19 100644 --- a/.genie/INDEX.md +++ b/.genie/INDEX.md @@ -17,21 +17,24 @@ - [WISH: hook-injection-hardening](wishes/hook-injection-hardening/WISH.md) — BLOCKED-clearing safety edit: `execFileSync` at 3 hook sites (audit-context, freshness×2) + hostile-filename regression tests + `core.bare` probe removal; flips panel verdict BLOCKED→FIX-FIRST — SHIPPED → PR #2536 (wish/hook-injection-hardening→main), G1+G2+whole-wish reviews SHIP, 729 pass/0 fail (2026-07-09) -- [WISH: v5-completion](wishes/v5-completion/WISH.md) — CLAUDE.md-for-v5 rewrite ∥ Codex launch target + Hermes decision ∥ distribution 5.x (drafted 2026-07-02) -- [WISH: dispatch-inproc-default](wishes/dispatch-inproc-default/WISH.md) — HIGH discovered defect: v5 hooks fall open by default (daemon deleted in demolition); re-arm branch-guard + unblock omni approvals (drafted 2026-07-02) -- [WISH: omni-approval-ux](wishes/omni-approval-ux/WISH.md) — correlated approval identity, reaction approve/deny, anti-spam feedback; grounded in the 2026-07-03 live WhatsApp QA (drafted 2026-07-03) -- [WISH: omni-runner-port](wishes/omni-runner-port/WISH.md) — umbrella Group 5: approval-capture spike, global genie.db queue, genie omni serve, inbound one-shot (drafted 2026-07-02) -- [WISH: warp-integration](wishes/warp-integration/WISH.md) — umbrella Group 3: genie init, Warp launch-config emitter, genie launch, /work multi-session opt-in (drafted 2026-07-02) +- [WISH: v5-completion](wishes/v5-completion/WISH.md) — **DONE** (all 3 groups SHIP-reviewed 2026-07-02; reconcile scope w/ cross-agent-delegate + agent-sync per HANDOFF §4): CLAUDE.md-for-v5 rewrite ∥ Codex launch target + Hermes decision ∥ distribution 5.x +- [WISH: dispatch-inproc-default](wishes/dispatch-inproc-default/WISH.md) — **DONE** (both groups SHIP-reviewed 2026-07-02; branch-guard re-armed, regression-gated — hooks demonstrably armed tonight): v5 hooks no longer fall open by default; re-arm branch-guard + unblock omni approvals +- [WISH: omni-approval-ux](wishes/omni-approval-ux/WISH.md) — **DONE** (G1–G3 SHIP-reviewed, 2 HIGHs found+fixed; one Felipe-approved live round-trip pending): correlated approval identity, reaction approve/deny, anti-spam feedback +- [WISH: omni-runner-port](wishes/omni-runner-port/WISH.md) — **DONE** (all 5 groups SHIP-reviewed 2026-07-02; live WhatsApp QA blocked-with-runbook, needs Felipe Omni instance): approval-capture spike, global genie.db queue, genie omni serve, inbound one-shot +- [WISH: warp-integration](wishes/warp-integration/WISH.md) — **DONE** (all 4 groups SHIP-reviewed 2026-07-02; pane-render checklist awaiting Felipe): genie init, Warp launch-config emitter, genie launch, /work multi-session opt-in ## Poured +- [agent-sync](wishes/agent-sync/DESIGN.md) · [WISH](wishes/agent-sync/WISH.md) · [COORDINATION](wishes/agent-sync/COORDINATION.md) — **MERGED #2541 (dev, 2026-07-10 06:47Z; dev now v5.260710.5→.6)** — `genie update` = único verbo canônico que converge TODOS os coding agents detectados (Claude Code skills+council stamp, Codex Agent-Skills em .curated/, Hermes symlink+enable); engine interna manifest+adopt-with-backup+orphan-removal; hook CC vira trigger; mata scripts/smart-install.js divergente. Na dev: G1 `346c02e1`, G2 `ff497ae4`, G3 `642c9f27` (doctor freshness + manifest-verified uninstall), final-review doc `46cc3fb7`; a coerção string-args do /council entrou separada como `ec68cd8f`. PR-topology resolvida no rebase (os 3 commits plugin-resource-shipping saíram; conteúdo já estava em dev via #2540). **Resta:** ritual live user-gated (`genie update` ×2 pós-stable → verificar os 7 role agents como subagent types) — ver [MORNING-BRIEF](brainstorms/genie-token-efficiency-program/MORNING-BRIEF-20260710.md). - [council-workflow](brainstorms/council-workflow/DESIGN.md) · [WISH](wishes/council-workflow/WISH.md) — /council vira saved workflow nativo (deliberation + audit), lens library 13 (7 persona skills renomeadas por lane + 6 cards), distribuição via install-stamp em ~/.claude/workflows; implementa a disposição council do skill-absorbs G4 — EXECUTADO 2026-07-10: G1 `0b222e17`, G2 `12270b21` (1 fix loop: unwrap p/ body-style do runtime), G3 `22a7ed50`, G4 `2af7ebd9`, G5-eng `6494253d` — todos SHIP; QA vivo USER-GATED pós-release (ritual Felipe: merge→release→plugin update→rodar /council "revisar tudo"; g5-gate para na cauda qa/ até lá); final execution review após o QA -- [plugin-resource-shipping](brainstorms/plugin-resource-shipping/DRAFT.md) · [WISH](wishes/plugin-resource-shipping/WISH.md) — in-skill template via ${CLAUDE_SKILL_DIR}, probe-guarded lint refs, resource-shipping lint, fresh-install CI smoke — plan review SHIP (2 fix loops), /work user-gated, G1 dep on routing-matrix:3 (2026-07-09) -- [routing-matrix](brainstorms/routing-matrix/DRAFT.md) · [WISH](wishes/routing-matrix/WISH.md) — pinned role agents (Fable gates / Opus ladder / Haiku scouts), stage pins + pane flags, complexity columns + lint, escalation caps — executed; execution review SHIP, final gate 719 pass / 1 skip / 0 fail; live LangWatch pin QA pending (2026-07-09) +- [plugin-resource-shipping](brainstorms/plugin-resource-shipping/DRAFT.md) · [WISH](wishes/plugin-resource-shipping/WISH.md) — in-skill template via ${CLAUDE_SKILL_DIR}, probe-guarded lint refs, resource-shipping lint, fresh-install CI smoke — EXECUTED 2026-07-10 (first wish under the routing matrix: engineers opus·high, gate fable·high; 0 fix loops all groups; gate SHIP after branch surgery — concurrent session had switched the shared checkout): G1 ecbb67fc, G3 203c97df, G2 bbd6439e → PR #2540 merged, CI 10/10; QA pending: live installed-plugin scaffold on next release +- [routing-matrix](brainstorms/routing-matrix/DRAFT.md) · [WISH](wishes/routing-matrix/WISH.md) — pinned role agents (Fable gates / Opus ladder / Haiku scouts), stage pins + pane flags, complexity columns + lint, escalation caps — executed; execution review SHIP, final gate 719 pass / 1 skip / 0 fail. **Day-1 live pin QA recorded 2026-07-10 ([qa/](wishes/routing-matrix/qa/routing-pin-qa-20260710.md)) — inconclusive by delivery gap (pins not yet subagent types); re-test post-stable + `genie update` ×2** (2026-07-09) +- [skills-fable5-revamp](wishes/skills-fable5-revamp/WISH.md) · [execution review](wishes/skills-fable5-revamp/reports/execution-review-20260710.md) — Fable-5 revamp of all 36 genie/omni prompt surfaces (118→0 dead-CLI refs, ≥40% line cut, byte-0 frontmatter) + G8 v4-legacy cleanup engine — **merged PR #2518; execution review SHIP 2026-07-10 (HIGH 0 / MEDIUM 0 / 3 LOW: omni-side confirm, stale diff-count, superseded-note for `learn`/`council`)** +- [rolling-pr-auth-hardening](wishes/rolling-pr-auth-hardening/WISH.md) — fail-fast on dead/absent PAT + read/create token split in rolling-pr.yml — **DONE (already implemented on dev+main: `c4fdb32b` + `422caaa2`; found by council freshness probe 2026-07-10; PAT mint remains Felipe's open action)** - [genie-token-efficiency-program](brainstorms/genie-token-efficiency-program/DESIGN.md) — umbrella (WRS 100, independent review SHIP; 9 seed groups): genie → control-plane thesis; routing matrix (Fable gates, Opus ladder, no Sonnet, $17.9k/21d baseline); 17-skill dispositions; always-on identity; cross-agent delegate (Codex/Hermes companion sessions); genie spend; brainstorm domain-map upgrade — crystallized 2026-07-09 -- [genie-mcp](brainstorms/genie-mcp/DESIGN.md) · [WISH](wishes/genie-mcp/WISH.md) — genie MCP server: Warp/Claude Code/Codex consume genie.db state read-only; stdio, auto-registered; spike-first (2026-07-03) +- [genie-mcp](brainstorms/genie-mcp/DESIGN.md) · [WISH](wishes/genie-mcp/WISH.md) — **DONE** (G1–G3 SHIP-reviewed 2026-07-03; Warp live-UI QA awaits Felipe): genie MCP server — Warp/Claude Code/Codex consume genie.db state read-only; stdio, auto-registered; spike-first - [Genie v5 — lightweight body](brainstorms/genie-v5-lightweight-body/DESIGN.md) — skills+files+stock Warp replace the v4 harness; CC/Codex/Hermes targets; omni keeps one runner; crystallized 2026-07-01, umbrella-scale (8 seed groups) - [WISH: v5-foundation](wishes/v5-foundation/WISH.md) — umbrella Groups 1+2: genie.db engine + CLI + core skills — DONE 2026-07-02, all groups SHIP - [WISH: v5-demolition](wishes/v5-demolition/WISH.md) — umbrella Group 6 pulled forward (D8): harness deletion, bare-name cutover, v4 branch, PR #2499 — DONE 2026-07-02 - - [WISH: v5-housekeeping](wishes/v5-housekeeping/WISH.md) — true-lightweight tree cleanup (root files, .genie v4 history, metrics bot) + README replan (drafted 2026-07-02) -- [WISH: taxonomy-rehoming](wishes/taxonomy-rehoming/WISH.md) — plans migrate to genie's own taxonomy (`.genie/wishes|brainstorms`); path claims made coherent; user skills speak genie (2026-07-02) + - [WISH: v5-housekeeping](wishes/v5-housekeeping/WISH.md) — **DONE** (all 3 groups SHIP-reviewed 2026-07-02): true-lightweight tree cleanup (root files, .genie v4 history, metrics bot) + README replan +- [WISH: taxonomy-rehoming](wishes/taxonomy-rehoming/WISH.md) — **DONE** (both groups SHIP-reviewed 2026-07-02; one user action pending: PATH line in ~/.zshrc): plans migrate to genie's own taxonomy (`.genie/wishes|brainstorms`); path claims made coherent; user skills speak genie diff --git a/.genie/brainstorms/always-on-genie/DRAFT.md b/.genie/brainstorms/always-on-genie/DRAFT.md index 0924caf13..adc50b736 100644 --- a/.genie/brainstorms/always-on-genie/DRAFT.md +++ b/.genie/brainstorms/always-on-genie/DRAFT.md @@ -9,6 +9,12 @@ - 3 of 5 plugin hook scripts are silently DEAD on v5 shapes (session-context, validate-wish, validate-completion; one says "Run /forge"). rules/genie-orchestration.md (20 lines) is the one v5-correct plugin doc. - Native worktrees: `isolation:"worktree"` per-agent, `--worktree`, EnterWorktree, `.worktreeinclude`, worktree.baseRef; project plugins auto-load in worktrees (v2.1.200). +## EVIDENCE for G10 worktree isolation (2026-07-09 / 2026-07-10) + +The worktree-isolation policy now has hard-won proof, not just a thesis: +- **Shared-checkout collision happened TWICE.** (a) A concurrent session switched the shared checkout `~/workspace/genie` to another wish's branch mid-workflow — three commits landed on the wrong branch and cost a full branch-surgery recovery (isolated worktree off origin/dev + `git mv`/`cherry-pick --no-commit` fold + `git update-ref`). (b) A second session narrowly avoided grabbing another session's live-uncommitted files out of the same working tree. Both are the exact failure mode G10 exists to remove: **never execute wish work in the shared checkout; always `git worktree add` an isolated tree per wish.** Tonight's overnight run enforced this by hand (every worker + this docs finisher ran in a separate worktree off `origin/dev`, merges serialized) — that manual discipline is what G10 should make automatic. +- **Council 3-of-3 takeover de-escalation (2026-07-10).** The first real `/council` deliberation reviewed an "agent-sync takeover" clause that would have let one session seize the shared checkout, and **de-escalated it 3-of-3 to a read-only watch** — i.e. the humans-in-the-loop lenses independently converged on *observe, never take over the shared tree*. This is corroborating design pressure for G10: isolation is the mechanism that makes "read-only watch" the only needed posture, because no session ever needs write access to another's tree. + ## DECIDED (umbrella D9, D10, D14) - Two-layer always-on: (1) thin rules identity ≤40 lines (who genie is, lifecycle routing, control-plane invariants); (2) SessionStart additionalContext via in-process dispatch — live wishes+status, ready groups from genie.db, first-run wizard branch. Deep playbooks stay on-demand. - Hook contract: schema-version manifest, v4/v5 fixtures, per-hook CI smoke, unknown schema ⇒ fail closed with message; silent noop prohibited; dead scripts rewritten in-process or deleted; /forge purged. diff --git a/.genie/brainstorms/cross-agent-delegate/DRAFT.md b/.genie/brainstorms/cross-agent-delegate/DRAFT.md index 1fc78d163..c2c213ba6 100644 --- a/.genie/brainstorms/cross-agent-delegate/DRAFT.md +++ b/.genie/brainstorms/cross-agent-delegate/DRAFT.md @@ -20,8 +20,12 @@ ## Degradation policy (learned live, 2026-07-09) First real invocation of the auto plan-gate counter-read hit cegonha unreachable (network path down; host fine an hour earlier). Decided behavior to encode in the delegate skill: **counter-read fails OPEN** — the gate proceeds on the internal reviewer alone, logs "counter-read unavailable (host unreachable)" in the review record, and the next gate retries. Never block a plan gate on external-agent availability; never silently pretend the counter-read happened. +## RECONCILE (2026-07-10 — agent-sync shipped, PR #2541 merged to dev) + +**The Codex *plumbing* is now built and owned by agent-sync — this track consumes it, does not rebuild it.** `genie update` (and `genie install`) now fan the canonical `~/.genie/plugins/genie` source into every DETECTED coding agent on every invocation, including a **Codex adapter that ships genie skills to `~/.codex/skills/.curated/`** (managed manifest + adopt-with-backup + orphan removal). So the "one delegate skill + per-agent adapter references" decision is unchanged, but its Codex prerequisite — *getting genie's skills onto the Codex side* — is solved for free: the delegate skill can assume the curated genie skills are present on any machine that has run `genie update`. What remains this track's own work is the **delegation runtime** (companion `codex exec` / `hermes chat -q` sessions, JSON hand-back, background+poll, the `wish-` session persistence on the genie.db row), not the skill distribution. The Codex-install/auth GAP below narrows accordingly to *auth + which roles first*, since "are genie's skills installed on Codex" is answered by agent-sync. + ## GAPS -- [ ] Codex reality on your machines: installed? auth method (API key vs OAuth)? Which lifecycle roles do you want Codex for first — engineer on suitable groups, PR review dissent, or both? +- [ ] Codex reality on your machines: installed? auth method (API key vs OAuth)? Which lifecycle roles do you want Codex for first — engineer on suitable groups, PR review dissent, or both? (Skill distribution to `~/.codex/skills/.curated/` is now handled by agent-sync — see RECONCILE above; this GAP is now auth + role-scoping only.) - [ ] Hermes canonical vs alias: confirm `-z` is a cegonha alias (helper works today; adapter should document canonical `hermes chat -q` + fallback). - [ ] Budget/limits for external agents: max concurrent companion sessions? Hermes host load limits (cegonha is shared infra — benchmarks run there)? - [ ] Session lifecycle: when a wish ships, are its companion sessions retired (hermes sessions delete is gated on shared infra) or kept as history? diff --git a/.genie/brainstorms/genie-spend/DRAFT.md b/.genie/brainstorms/genie-spend/DRAFT.md index 3900737af..6b1245b79 100644 --- a/.genie/brainstorms/genie-spend/DRAFT.md +++ b/.genie/brainstorms/genie-spend/DRAFT.md @@ -6,7 +6,7 @@ - Baseline: $17,857 / 95.77M billable tokens / 11.79B cache-read tokens / 1097 traces in 21d (~1000 in last 4 days). Fable $13.8k · Opus $7.1k · Haiku $1.2k · Sonnet $0.5k (span-attributed shares). - Working analytics recipes (archived in parent DRAFT): tokens+cost by model (groupBy metadata.model), top sessions (groupBy metadata.thread_id), by effort (filter-loop on gen_ai.request.reasoning_effort — no effort groupBy exists), top-N traces (trace/search + client sort, scrollId pagination). - `metadata.labels` groupBy EXISTS but CC's OTel export emits no genie labels; thread_id = whole CC session (no per-subagent split); model lives on spans, not traces. -- LangWatch CLI (`npm i -g langwatch`) wraps the same endpoints, `--format json`, designed to be agent-driven. +- LangWatch CLI (`npm i -g langwatch`) wraps the same endpoints, `--format json`, designed to be agent-driven. **Access proven 2026-07-10: the existing OTLP-ingest key authenticates fine through the CLI; hand-rolled REST `POST /api/analytics` + `/api/trace/search` returned 403 (payload/route shape, not the key). Phase 1 should shell out to the `langwatch` CLI, NOT hand-roll REST.** Substrate + recipes archived in [genie-spend-calibration-20260710.md](genie-spend-calibration-20260710.md). - Hermes north star: **cost per group accepted without reopening** — measure decisions, not calls. ## 7-DAY BURN ANALYSIS (run 2026-07-09 — first real analysis pass) @@ -55,8 +55,28 @@ Totals: **$18,175 · 1,113 traces · 63 sessions.** Token buckets: prompt (fresh 3. Fewer tool calls per turn (batching, scripts over call-chains): every call re-reads full context — new guidance item for work/dispatch contract. 4. Stable prompt prefixes so cache writes stay linear (constraint on always-on-genie inject: deterministic, session-start-only). +## CALIBRATION — 2026-07-10 (day-1 pin-QA window, partial ~6.85h) — [full file](genie-spend-calibration-20260710.md) + +Measured via the `langwatch` CLI during the routing-pin day-1 QA. This window is a gate/review-heavy overnight dogfood — good for **shape/recipe** calibration, not a representative full day. Cross-ref [routing-matrix/qa/routing-pin-qa-20260710.md](../../wishes/routing-matrix/qa/routing-pin-qa-20260710.md). + +**$/day trend point (span-level `performance.total_cost`):** 07-08 full **$4,352** · 07-09 full **$1,230** (pins merged 21:51Z) · 07-10 00:00–06:51Z **$1,613** partial → **~$5,650/day run-rate** (upper-ish bound, not typical). The 21-day baseline averages ~$850/day, but recent dogfood days sit well above — the exact heavy-spend spike `genie spend` needs to surface. + +**$/model split (07-10, billable `cost_billed`):** Fable $956 (**57%**) · Opus $562 (34%) · Haiku $159 (9%) — Fable share rose on every measure this window (see the pin-QA file). + +**Per-effort cost-per-trace (SOLID — from trace search's per-trace `reasoning_effort` + `total_cost`):** this is the cleanest per-lane number available and a good basis for a `genie spend --by-effort` view / per-lane budget alerts. +| Effort | n | p50 | p90 | mean | +|---|---:|---:|---:|---:| +| xhigh | 37 | $5.39 | $26.23 | $9.74 | +| high | 27 | $5.00 | $20.81 | $7.83 | +| max | 20 | $18.76 | $43.56 | $23.85 | +| **all** | 84 | **$6.76** | **$32.33** | $12.49 | + +`max` is the expensive deep-reasoning/gate tier (~3–4× the p50 of xhigh/high). + +**Phase-1 query shapes that proved WORKABLE (all via the CLI, existing key):** cost-by-model, cost-by-day (per-day windows; each query returns the prior period free), token-volume-by-model, trace-count-by-model, top-sessions-by-cost (`--group-by metadata.thread_id`), effort histogram + per-effort cost percentiles. **BROKEN/blocked tonight:** (1) direct REST 403 → use the CLI; (2) **effort-filtered analytics** unavailable (`analytics query` has no `--filter`; underlying effort-filter silently broken) → **effort splits must be computed client-side from trace search**; (3) **per-model p50/p90 NOT derivable** — trace search returns no per-trace model (spans empty; model lives only at span level in analytics) → **parked**; per-effort percentiles are the usable substitute, or span-level export the CLI doesn't expose; (4) **cache-read + `--group-by metadata.model`** hits a known ClickHouse bug → avoid (query cache-read without the model groupBy). Net: a CLI-backed Phase-1 covering model/day/thread/effort splits is achievable now; per-model percentiles and effort-filtered analytics are the two gaps to design around. + ## GAPS -- [ ] Key/endpoint source for `genie spend`: read from CC settings env (OTEL_EXPORTER_OTLP_*), from genie config, or both with precedence? (Machine-portability: teammates' machines have the same settings?) +- [ ] Key/endpoint source for `genie spend`: read from CC settings env (OTEL_EXPORTER_OTLP_*), from genie config, or both with precedence? (Machine-portability: teammates' machines have the same settings?) — **2026-07-10 evidence: the OTLP-ingest key from `OTEL_EXPORTER_OTLP_HEADERS` in `~/.claude/settings.json` authenticates the CLI; endpoint `https://langwatch.khal.ai`.** - [ ] Cadence: on-demand only, or a scheduled snapshot (e.g. daily line into .genie/ or omni message)? You already run "live metrics" commits — integrate or keep separate? - [ ] Consumers: just you, or team/omni-channel reporting? - [ ] Should `genie doctor` warn when burn-rate exceeds a threshold (needs a threshold from you)? diff --git a/.genie/brainstorms/genie-spend/genie-spend-calibration-20260710.md b/.genie/brainstorms/genie-spend/genie-spend-calibration-20260710.md new file mode 100644 index 000000000..9b93e3f59 --- /dev/null +++ b/.genie/brainstorms/genie-spend/genie-spend-calibration-20260710.md @@ -0,0 +1,51 @@ +# `genie spend` — Calibration Evidence (2026-07-10) + +**For:** the genie-spend DRAFT (Phase-1 query design). +**Source:** LangWatch `https://langwatch.khal.ai` via `langwatch` CLI. Measured 2026-07-10 ~06:51Z. +**Scope note:** 07-10 is a **partial ~6.85h overnight window** (00:00–06:51Z) from a gate/review-heavy multi-agent dogfood run — good for *shape/recipe* calibration, not representative of a normal full day's absolute spend. + +--- + +## $/day trend point + +Span-level `performance.total_cost`, per UTC calendar day: + +| Day | Window | Total cost | Note | +|---|---|---:|---| +| 07-08 | full | **$4,352** | full pre-pin day | +| 07-09 | full | **$1,230** | pre-pin (pins merged 21:51Z) | +| 07-10 | 00:00–06:51Z (~6.85h) | **$1,613** partial → **~$5,650/day run-rate** | overnight dogfood; run-rate is an upper-ish bound, not typical | + +Context: the 21-day baseline averages ~$850/day ($17,857 / 21d), but recent dogfood days (07-08 at $4.3k, the 07-10 overnight run-rate at ~$5.6k) sit well above that average — the spend curve is in a heavy-dogfood spike, which is exactly the regime `genie spend` needs to surface. + +## $/model split (07-10, billable `cost_billed`) + +| Model | Billable cost | Share | +|---|---:|---:| +| Fable | $956 | **57%** | +| Opus | $562 | 34% | +| Haiku | $159 | 9% | +| **Total** | **$1,677** | | + +(For directional trend vs 07-09 and token/trace shares, see `routing-pin-qa-20260710.md` — Fable share rose on every measure this window.) + +## Per-lane cost calibration + +**Per-effort cost-per-trace (solid — derived from trace search, per-trace effort + cost):** + +| Effort lane | 07-10 n | p50 | p90 | mean | +|---|---:|---:|---:|---:| +| xhigh | 37 | $5.39 | $26.23 | $9.74 | +| high | 27 | $5.00 | $20.81 | $7.83 | +| max | 20 | $18.76 | $43.56 | $23.85 | +| **all** | 84 | **$6.76** | **$32.33** | $12.49 | + +`max` is the expensive deep-reasoning/gate tier (~3–4× the p50 of xhigh/high). This is the cleanest per-lane number available and is a good basis for a `genie spend --by-effort` view or per-lane budget alerts. + +**Per-model mean cost-per-trace (rough — use with caution):** Fable ≈ $12.8, Opus ≈ $13.4 per model-touch (07-10). These divide span-level cost by *overlapping* model-touch counts (a multi-model trace counts under each model), so they are means, not medians, and denominators are inflated. **Per-model p50/p90 is NOT derivable via the CLI** — trace search returns no per-trace model (spans empty; model lives only at span level inside analytics). If `genie spend` wants per-model percentiles, it needs span-level export, which the CLI does not expose. + +--- + +## Which Phase-1 queries proved workable vs broken tonight + +**Workable — all through the `langwatch` CLI (`analytics query` + `trace search`), no bespoke REST layer needed, and the existing OTLP-ingest key authenticates fine:** cost-by-model, cost-by-day (via per-day windows; each query also returns the prior period free), token-volume-by-model (`performance.total_tokens`), trace-count-by-model (`trace-count`/cardinality), top-sessions-by-cost (`--group-by metadata.thread_id`), and the effort histogram with per-effort cost percentiles (from trace search's per-trace `reasoning_effort` + `total_cost`). Any `genie spend` Phase-1 that maps to those six shapes can be built directly on the CLI. **Broken or blocked tonight:** (1) direct REST `POST /api/analytics` and `/api/trace/search` returned **403** — the CLI wraps the same endpoints and works, so Phase-1 should shell out to the CLI rather than hand-roll REST; (2) **effort-filtered analytics** is unavailable — the CLI `analytics query` has no `--filter` flag and the underlying effort-filter is reported silently broken, so any effort-scoped spend number must be computed client-side from trace search; (3) **per-model cost distributions (p50/p90)** are not derivable (no per-trace model attribution); (4) **cache-read-by-model** hits a known ClickHouse bug when combined with `--group-by metadata.model` and must be avoided (query cache-read without the model groupBy). Net: a CLI-backed Phase-1 covering model/day/thread/effort splits is achievable now; per-model percentiles and effort-filtered analytics are the two gaps to design around. diff --git a/.genie/brainstorms/genie-token-efficiency-program/HANDOFF-20260710.md b/.genie/brainstorms/genie-token-efficiency-program/HANDOFF-20260710.md new file mode 100644 index 000000000..354da40b6 --- /dev/null +++ b/.genie/brainstorms/genie-token-efficiency-program/HANDOFF-20260710.md @@ -0,0 +1,130 @@ +# HANDOFF — 2026-07-10 (session 8050dd5c, Fable 5) + +**For the fresh session.** Predecessor: [HANDOFF-20260709.md](HANDOFF-20260709.md) (full program background). +This file is intentionally UNTRACKED — the shared checkout sits on another session's branch +(`wish/agent-sync`); commit this to dev only after that branch resolves (§2). + +--- + +## 1. What shipped today (2026-07-10) — the new capabilities you should leverage + +| Delivery | State | Leverage | +|---|---|---| +| **routing-matrix** | Merged (#2535), released stable v5.260710.2, LIVE | 7 pinned role agents ship in the plugin (`engineer-trivial/standard/complex` opus·low/high/xhigh, `fixer` opus·medium, `reviewer` opus·xhigh, `final-gate` fable·high, `scout` haiku·low) — a fresh session should see them as subagent types. Route ALL engineering to the opus ladder; Fable only at gates. First dogfooded wish ran at ~11% Fable tokens. | +| **council-workflow** | Merged (#2538), released v5.260710.2; `/council` stamped on this machine | `/council` = native saved workflow (deliberate/audit) + 7 specialist lane skills + 6 lens cards. Live QA still pending (§3). | +| **hook-injection-hardening** | Released v5.260710.1 | — | +| **plugin-resource-shipping** | Merged to dev (#2540), NOT yet in a stable release | Skills self-contained via `${CLAUDE_SKILL_DIR}`; resource-shipping lint + fresh-install CI smoke now guard every PR. | +| **Token-efficiency program docs** | Committed (3d40966c + council PR) | Umbrella DESIGN (WRS 100) + 9 track DRAFTs + 2 executed wishes. | + +Versions: CLI v5.260710.2 (stable channel), plugin cache 5.260710.2, dev at ≥ `bedd2062` (v5.260710.3 bump). + +## 2. IN FLIGHT — agent-sync (highest priority, do this first) + +**The wish:** `.genie/wishes/agent-sync/WISH.md` (on branch `wish/agent-sync`, commit 63f63c95) — +`genie update` becomes the single canonical updater converging EVERY detected agent (Claude Code +skills + `/council` stamp, Codex `.curated` skills, Hermes symlink) on every invocation; SessionStart +hook demoted to trigger. This is Felipe's "deliver lazy" ruling made real. + +**State at handoff (04:55Z):** +- G1 (engine, `src/lib/agent-sync*.ts` + `genie-home.ts`) DONE, execution-reviewed SHIP — files + UNTRACKED in the shared checkout `~/workspace/genie`. +- G2 was being written LIVE at handoff time (uncommitted modifications to `src/genie-commands/update.ts`, + `install.ts`, `plugins/genie/scripts/{council-stamp.cjs,smart-install.js}`, `scripts/smart-install.js` + deleted, `skills/{review,brainstorm}/SKILL.md` anchors, tests) by the owning session. +- **Felipe directive on record:** PR to dev REQUIRED as soon as gates pass — see + `.genie/wishes/agent-sync/COORDINATION.md` (untracked; also contains the reset+rebase commands and + the #2540 intersection facts). +- **Branch hazard:** `wish/agent-sync` still carries 3 duplicate commits (805afcd4/0584639d/422dc6dd — + already merged via #2540 as ecbb67fc/203c97df/bbd6439e). MUST `git reset --hard 63f63c95` + + `git rebase origin/dev` before pushing, or the PR drags duplicates. +- A 3h PR watcher was polling in the dying session — IT IS DEAD NOW. Fresh session: check + `gh pr list --state open` for an agent-sync PR; when it appears, CI-watch and (with Felipe's + green-CI authorization restated) merge on green. §19: dev-target merges only. + +**After merge:** trigger the stable promotion (Felipe must name/run "Rolling PR Maintenance" — the +permission classifier and §19 both want the human in that loop; main-target PR merge is human-via-UI +only). Then the delivery caveat (wish Decision 9): the release introducing self-sync needs +`genie update` run twice, ONCE ever; afterwards every update self-syncs. + +## 3. Pending QA gates (user-ritual or data-dependent) + +1. **Council live QA** — Felipe's ritual tail: run `/council "revisar tudo"`, then write the + council-workflow wish's final execution review (INDEX says g5-gate parks in `qa/` until then). + Known QA observation to record: stamp fired only on SessionStart → mid-session `/plugin update` + left a gap (manually verified working via direct `smart-install.js` run); agent-sync supersedes. +2. **routing-matrix live pin QA** — today (07-10) is the first full day of traces under the pins. + Pull LangWatch: model split should show Fable's trace share collapsing on execution work + (baseline: Fable 71% of traces, $14.1k/7d). Endpoint langwatch.khal.ai, creds in CC settings env; + working query recipes archived in `.genie/brainstorms/genie-spend/DRAFT.md` (§KNOWN). Beware: + analytics effort-filter silently broken; groupBy metadata.model + cache-read series hits a + ClickHouse bug (worth filing upstream). +3. **plugin-resource-shipping QA criterion 1** — live installed-plugin `/wish` scaffold on a bare + repo AFTER the next stable release ships it. +4. **Follow-ups noted in the shipped wish (LOW):** fresh-install-smoke temp-dir cleanup bypassed by + `process.exit` on phase-b failures; G2 same-line guard is substring-based ("package.json" anywhere + on the line passes); G1's validation sweep not replay-safe post-G2 (trips on the lint rule's own + negative fixtures). + +## 4. The jar — planned work not yet executed + +**Program tracks (all under `.genie/brainstorms//DRAFT.md`, ordered by readiness):** + +| Track | % | Blocker / next move | +|---|---|---| +| intent-to-wish-compiler | 92 | Breaker contract RATIFIED ("cut breadth/attempts, never proof; only humans cut payout"; autonomous flex cuts; human-only partial-ship via omni). Program-scale: pours as 4 child wishes (lane classifier + breaker state machine · intake compiler · roadmap surface · spend calibration). Needs control-plane-contract + brainstorm-domain-map convergence first. | +| brainstorm-domain-map | 80 | Executable-spec compiler decided (requirement IDs, oracle classes machine/model/human, proof packets). Open: who owns irreducibly subjective truth (oracle-ownership boundary) — Felipe ruling. | +| genie-spend | ~70 | CLOSEST TO WISHABLE. 4 Felipe gaps: key/endpoint source, cadence, consumers, doctor burn threshold. Today's pin-QA data (§3.2) is its natural opener. It is also the calibration organ the ratified breaker needs (per-lane p50/p90). | +| cross-agent-delegate | ~65 | Felipe gaps: Codex install/auth reality (NOTE: agent-sync's codex adapter now ships genie skills to `~/.codex/skills/.curated/` — overlaps, reconcile), Hermes `-z` alias canonicalization, prompt-method walkthrough for refine style cards, collision with v5-completion's "Codex launch target". | +| skill-absorbs | ~60 | council disposition DONE (council-workflow shipped). pm-absorb materially unblocked by intent-compiler + domain-map. Remaining: trace→fix, wizard→genie router, report→LangWatch. | +| always-on-genie | ~60 | NEW EVIDENCE: shared-checkout collision happened TWICE today (see §5) — G10 worktree-isolation policy now has its proof. SessionStart identity/state inject design decided. | +| control-plane-contract | ~55 | skills-fable5-revamp sequencing call still open; dispatch-contract single-source design decided. | +| dream-replatform | ~50 | Substrate choice is Felipe's (local cron vs cloud agents vs hybrid). | + +**Pre-program Ready wishes (INDEX):** v5-completion (reconcile w/ cross-agent-delegate + agent-sync), +dispatch-inproc-default (re-verify: hooks demonstrably ARE armed now — §19 and git-safety both fired +today; the wish may be partially obsolete), omni-approval-ux, omni-runner-port, warp-integration, +genie-mcp, v5-housekeeping, taxonomy-rehoming. + +**Recommended order for the fresh session:** +1. Close agent-sync (merge → promote → `genie update` ×2 → verify lazy delivery incl. `/council`). +2. Council live QA + final execution review (closes council-workflow). +3. Pull pin-QA numbers → close genie-spend gaps conversationally → `/wish genie-spend` (small, high-leverage). +4. Brainstorm refinements needing Felipe: brainstorm-domain-map oracle boundary → unlocks + intent-to-wish-compiler pour; cross-agent-delegate gaps. + +## 5. Safety learnings for autonomous operation (hard-won today) + +- **Shared-checkout collision is THE failure mode.** Two incidents: (a) another session switched + `~/workspace/genie` to `wish/agent-sync` mid-workflow — three commits landed on the wrong branch, + cost a full branch surgery; (b) narrowly avoided grabbing live-uncommitted G2 files. RULE: never + execute wish work in the shared checkout; always `git worktree add` an isolated tree per wish + (this is always-on-genie G10 — until it ships, do it manually). The branch-surgery recipe that + worked: isolated worktree off origin/dev, `git mv` + `cherry-pick --no-commit` to fold, plain + cherry-picks for the rest, `git update-ref` to point branches (git-safety false-positives on + `branch -f`). +- **Hooks that fired correctly today (trust them):** §19 (agents merge dev-target only; main = + human via GitHub UI); git-safety (no `--no-verify`, no `--force`; pre-push runs full + `bun run check`); commitlint ≤100-char headers; permission classifier requires Felipe to NAME + workflow dispatches ("Rolling PR Maintenance"). +- **The merge pattern that works:** push → `gh pr create --base dev` → background + `gh pr checks N --watch --fail-fast` → merge on green. Watchers DIE with the session. +- **Validation from script files only** — inline multi-line bash false-passes (proven again today); + the G1 engineer/reviewer handled ugrep-vs-BSD-grep and binary-file leaks in sweeps: prefer + `git grep -In` with pathspec excludes. +- **Ultracode is per-turn opt-in** (keyword); Felipe's green-CI merge authorization was per-goal — + have him restate both in the fresh session for autonomous operation. +- **Cegonha (10.114.1.121) DOWN all day** — Hermes counter-reads fail-open ×3 gates, logged each time. + +## 6. Machine/tree state at handoff + +- Shared checkout `~/workspace/genie`: branch `wish/agent-sync` (other session's; duplicates + live + G2 edits). Untracked: G1 files, `COORDINATION.md`, this handoff, `.codex/`, `AGENTS.md`, + `.genie/repo-profile.md`, `.genie/wishes/omni-branch-drift-sync/`, `perr.txt` (empty — deletable). +- Codex worktree exists: `~/.codex/worktrees/8a2e/genie` (detached). +- Stale gitignored `dist/{darwin-arm64,linux-*}` trees were moved to the DYING session's scratchpad + (auto-cleaned eventually) — regenerable via `npm run build-and-sync`; sweeps now see a clean tree. +- `~/.claude/workflows/council.js` stamped and live. +- INDEX.md on dev is current through #2540 (`bedd2062`); the shared checkout's INDEX is the + agent-sync branch variant (+1 line). +- Memory: `~/.claude/projects/-Users-feliperosa-workspace-genie/memory/` (review-dispatch rule, + board daily-driver, rtk grep unreliability, one-subject-at-a-time) — all still valid. diff --git a/.genie/brainstorms/genie-token-efficiency-program/MORNING-BRIEF-20260710.md b/.genie/brainstorms/genie-token-efficiency-program/MORNING-BRIEF-20260710.md new file mode 100644 index 000000000..40f519e98 --- /dev/null +++ b/.genie/brainstorms/genie-token-efficiency-program/MORNING-BRIEF-20260710.md @@ -0,0 +1,51 @@ +# MORNING BRIEF — 2026-07-10 (Felipe's decision menu) + +One page, in priority order. This is the living document; [HANDOFF-20260710.md](HANDOFF-20260710.md) is the historical record of the night. Each item says what to do and what it unblocks. + +--- + +### 1. Merge promotion PR #2542 → stable release *(human-via-UI, §19)* +[PR #2542](https://github.com/automagik-dev/genie/pull/2542) `release: agent-sync + /council native workflow` is OPEN, base `main` ← head `dev`. **Verified: it already carries the resource-shipping LOW follow-ups (`65759f53`) and dev tip `9b15140f` — its head IS the live dev branch (v5.260710.6).** Only the *title label* says "5.260710.5" (stale from when it was created before #2543 merged); the diff is current. No rolling-PR refresh needed for correctness — merge it as-is via the GitHub UI. This mints the signed CalVer stable release that carries agent-sync + /council. + +### 2. After stable: `genie update` ×2 (one-time self-sync bootstrap) +Run `genie update` **twice** on the dogfood host — once ever. The release that introduces self-sync needs the first run to install the new updater and the second to let it self-sync (wish Decision 9). Then verify the delivery actually happened: +- **7 role agents appear as subagent types** in a fresh Claude Code session (`engineer-trivial/standard/complex`, `fixer`, `reviewer`, `final-gate`, `scout`). This is the exact gap that made last night's pin QA inconclusive. +- **string-args fix is stamped** into the local council workflow: `grep -n "typeof" ~/.claude/workflows/council.js` (or grep for the string-coercion the fix added) confirms `ec68cd8f` reached the stamped script. + +### 3. Council live-QA ritual: `/council "revisar tudo"` +Run it against the released, self-synced `council.js`. This exercises the string-args path end-to-end on the shipped artifact and **unblocks the council-workflow final execution review** (the g5-gate parks in `.genie/wishes/council-workflow/qa/` until then). Defect story + secondary skill-text-vs-script mismatch are pre-written in [overnight-observations-20260710.md](../../wishes/council-workflow/qa/overnight-observations-20260710.md). + +### 4. Routing-pin re-test +Once #2 confirms the 7 agents are real subagent types, re-pull the exact LangWatch comparison. Method + working `langwatch` CLI recipes are in [routing-pin-qa-20260710.md](../../wishes/routing-matrix/qa/routing-pin-qa-20260710.md). Expect Fable token share to fall toward gate-only (~11%, the level the one properly-pinned wish hit) and Opus engineering share to rise. Last night was inconclusive *only* because pins weren't mechanically enforced — not because the design is wrong. + +### 5. genie-spend — close the 4 open gaps (closest-to-wishable track) +Tonight's calibration ([genie-spend DRAFT](../genie-spend/DRAFT.md) CALIBRATION section) answers most of these; your ruling turns them into a `/wish`: +- **Key/endpoint source** → recommend: read the OTLP-ingest key from `OTEL_EXPORTER_OTLP_HEADERS` in `~/.claude/settings.json`, endpoint `https://langwatch.khal.ai`. **Proven to authenticate the `langwatch` CLI last night.** +- **Substrate** → recommend: **shell out to the `langwatch` CLI, not hand-rolled REST** (direct REST returned 403; the CLI wraps the same endpoints and works). +- **Effort splits** → recommend: compute **client-side from trace search** (`analytics query` has no `--filter`; the effort-filter is silently broken). +- **Percentiles** → recommend: ship **per-effort p50/p90** (solid: xhigh $5.39/$26.23, high $5.00/$20.81, max $18.76/$43.56); **per-model p50/p90 is parked** — not derivable via the CLI (no per-trace model). Also decide **cadence** (on-demand vs a daily line into `.genie/`), **consumers** (just you vs team/omni), and whether **`genie doctor`** warns above a burn threshold (needs a number from you). + +### 6. Mint the rolling-pr PAT *(only open action on rolling-pr-auth-hardening)* +The workflow hardening already shipped (`c4fdb32b` + `422caaa2`, on dev AND main). The one remaining human action: repo Settings → Secrets → `RELEASE_PLEASE_TOKEN`, a PAT with `contents:read` + `pull-requests:write`. Until then the hourly rolling-PR run fails fast with the actionable error (by design). + +### 7. brainstorm-domain-map — oracle-ownership ruling +Decide **who owns irreducibly subjective truth** (the oracle-ownership boundary: machine / model / human oracle classes). This is the one open blocker; ruling it **unblocks the intent-to-wish-compiler pour** (WRS 92, ready to split into 4 child wishes once domain-map + control-plane-contract converge). + +### 8. dream-replatform — substrate choice +Your call: local cron vs cloud agents vs hybrid for the scheduler. Cron stays *trigger, never authority*; omni approval gates. This is the gate on the dream track (~50%). + +### 9. LOW leftovers (skills-fable5-revamp execution review, all optional) +- **omni-side confirm** — have a reviewer with the `automagik-dev/omni` checkout re-verify the omni G5/G6 numbers (1,329→631); last night's SHIP for the omni half rests on `verification.md` attestation (that repo isn't in the genie checkout). +- **superseded note** — add a one-line "superseded by later wishes" marker where `verification.md` lists `learn`/`council` (both shipped correctly then removed by other wishes) to prevent future diff confusion. + +--- + +### What happened tonight (2026-07-10) + +| Thread | Outcome | +|---|---| +| **agent-sync** | Merged to dev as **PR #2541** (by the owning session; final review SHIP `46cc3fb7`). Dev → v5.260710.5. | +| **resource-shipping LOW follow-ups** | Merged as **#2543** (`65759f53`) — temp-dir cleanup, precise same-line guard, replay-safe sweep. | +| **/council** | First real dogfood (3 lenses, 2 rounds); live QA caught a string-args defect, fixed + merged as **`ec68cd8f`**. | +| **routing-pin QA** | Pulled — Fable share *rose* (inconclusive: pins weren't yet subagent types). Re-test after #1+#2. | +| **rolling-pr-auth-hardening** | Found **already implemented** on dev+main (`c4fdb32b`+`422caaa2`) — no work; only the PAT mint (#6) is left. | diff --git a/.genie/brainstorms/skill-absorbs/DRAFT.md b/.genie/brainstorms/skill-absorbs/DRAFT.md index f206c31b6..756d30970 100644 --- a/.genie/brainstorms/skill-absorbs/DRAFT.md +++ b/.genie/brainstorms/skill-absorbs/DRAFT.md @@ -13,6 +13,7 @@ - pm → ABSORB: triage guidance → work reference; autopilot → dream successor (with human-gate policy). - council → lens LIBRARY (consumed by review panels + brainstorm domain-experts) + THIN /council route preserved (strategy pre-artifact, dissent preservation, appeal court for reviewer↔gate disagreements). - **2026-07-09 UPDATE — implemented via [council-workflow](../council-workflow/DESIGN.md) (poured):** /council = native saved workflow (deliberation + audit modes); lens library = 7 lane skills (renamed personas) + 6 deliberation cards; "thin route" superseded — the route IS the workflow command. GAP "/council name?" closed (name kept). Consumer rewiring (/review panels + /brainstorm domain-experts) moved INTO that wish's scope. + - **2026-07-10 — DONE.** council-workflow EXECUTED (G1–G5 all SHIP, merged to dev) and **dogfooded for the first time tonight** (a 3-lens / 2-round `/council` deliberation, plus the string-args fix `ec68cd8f` found in live QA). The council disposition of this umbrella is now delivered; only the council-workflow live-QA ritual + final execution review remain, tracked in that wish, not here. - report → REFACTOR: observability re-sourced to LangWatch + portable local markdown/JSON fallback; keep gh-issue intake. - refine → re-scoped to cross-LLM prompt adapter (OWN track: cross-agent-delegate). - learn → PARKED at .genie/attic/skills/learn/ (brain transfer pending; not this wish). diff --git a/.genie/wishes/agent-sync/COORDINATION.md b/.genie/wishes/agent-sync/COORDINATION.md new file mode 100644 index 000000000..4173e5d0f --- /dev/null +++ b/.genie/wishes/agent-sync/COORDINATION.md @@ -0,0 +1,59 @@ +# Coordination note for G2 dispatch (from the plugin-resource-shipping session, 2026-07-10) + +> **FELIPE DIRECTIVE (04:50Z): a PR to automagik-dev/genie is REQUIRED — local delivery is not +> sufficient.** Push the branch and open the PR (base `dev`) as soon as G2 gates pass; do not sit on +> local commits. G3 can ride the same PR or a fast follow. Do the §1 reset BEFORE pushing, or the PR +> drags three already-merged duplicate commits. The orchestrating session is watching for the PR and +> will CI-gate + merge it on green (Felipe's standing authorization, dev-target only). + +Felipe ruled: this session owns G2+G3. Facts you need before dispatching G2: + +## 1. Branch surgery required first +This branch (`wish/agent-sync`) carries three foreign commits stacked on your 63f63c95 — +`805afcd4` (G1), `0584639d` (G3), `422dc6dd` (G2) of wish **plugin-resource-shipping**. They were +committed here by accident (shared checkout; your session switched the branch mid-flight of our +workflow). They are now **merged into dev via PR #2540** (rebuilt as `ecbb67fc`/`203c97df`/`bbd6439e`), +so the copies here are pure duplicates: + +```bash +git reset --hard 63f63c95 # shed duplicates (your docs commit becomes tip; G1 work is in untracked src/lib/*) +git rebase origin/dev # pick up #2540 + council merge + version bumps (dev tip ≥ bedd2062) +``` + +## 2. What #2540 changed that intersects your G2 +- `skills/brainstorm/SKILL.md:91` — the wishes-lint sentence was REWORDED (paraphrase rule). Your + lens-root anchor sentence lands in this file; rebase textually, don't restore old wording. +- `skills/wish/SKILL.md`, `skills/README.md`, `tests/e2e/v5-lifecycle.sh` — template now lives at + `skills/wish/templates/wish-template.md`, addressed via `${CLAUDE_SKILL_DIR}`. +- **New lint you must pass**: `scripts/skills-lint.ts` now has a resource-shipping rule scanning + bash fences AND inline-code spans in skill files. Imperative repo-root resource refs fail + (`cp templates/…`, unguarded `bun run wishes:lint`, imperative `scripts/*.ts` runs). Your anchor + sentence is prose/descriptive so it should pass — but run `bun run skills:lint` before committing. +- **New CI step**: `scripts/fresh-install-smoke.ts` (unit job) asserts every `${CLAUDE_SKILL_DIR}` + ref in any SKILL.md resolves inside that skill's dir, and scaffolds a wish in a bare repo with no + genie on PATH. If your lens-root anchor introduces `${CLAUDE_SKILL_DIR}` refs, they must resolve. + +## 3. Live-QA datapoint for your G2 stamp logic +The shipped stamp mechanism was verified working on this machine today: `smart-install.js` (run +manually, plugin cache 5.260710.2) stamped `~/.claude/workflows/council.js` successfully — the +SessionStart-only trigger gap Felipe hit is exactly your wish's premise. Note for your +adopt-with-backup tests: this machine now has a PRE-EXISTING stamped `council.js`, so your first +live `genie update` sync exercises the adopt/managed-manifest path, not fresh-create. + +## 4. Stale dist trees +Old gitignored `dist/{darwin-arm64,linux-*}/` trees (5.260702.1 vintage, containing pre-Fable +SKILL.md copies) were relocated to this session's scratchpad to unpollute path sweeps. Regenerable +via `npm run build-and-sync`. If your gates sweep dist/, they're gone — that's intentional. + +## 5. Note from the overnight orchestrator (session d0554818, ~03:30Z 2026-07-10) + +I am the successor of the orchestrating session. Standing watch is ACTIVE: the moment your PR to +dev appears I `gh pr checks --watch --fail-fast` and merge on green (Felipe restated the +authorization tonight, dev-target only). I will NOT touch this checkout — worktree isolation is +law; my overnight work (resource-shipping LOW follow-ups, docs) runs in separate worktrees off +origin/dev and my merges are serialized AFTER yours (you have priority; I rebase on moved dev). +Thanks for upstreaming the council.js string-args coercion (d0d61211) — I hit that live tonight; +my QA notes credit it. Reminder from your own §1: shed the three duplicate commits before pushing +(now that G1/G2/fix commits are stacked, that's `git rebase --onto origin/dev 422cdd6c`-style — +i.e. rebase onto origin/dev dropping 805afcd4/0584639d/422dc6dd; plain `git rebase origin/dev` +should also auto-skip them as already-applied patches). diff --git a/.genie/wishes/agent-sync/DESIGN.md b/.genie/wishes/agent-sync/DESIGN.md new file mode 100644 index 000000000..8e97f2c80 --- /dev/null +++ b/.genie/wishes/agent-sync/DESIGN.md @@ -0,0 +1,57 @@ +# Plan: agent-sync — `genie update` converges every detected coding agent + +## Context + +Felipe's requirement (verbatim intent): **no new command** — `genie update` is the single canonical updater; the Claude Code plugin auto-update is merely a *trigger* that converges into it; and one update must refresh genie skills **in every detected coding agent: Claude Code, Codex, Hermes**. + +Today `genie update` refreshes only `~/.genie/{plugins,skills,templates}` and no agent ever sees the result: the CC marketplace plugin is disabled+stale on the reference machine (no genie SessionStart hook runs, `~/.claude/skills` are hand-frozen copies), `~/.hermes/plugins` is empty (hermes-genie sits staged in `~/.genie/plugins/hermes-genie`, unbridged), and `~/.codex/skills` has only OpenAI built-ins (the historical genie→Codex skill-installer rule in `~/.codex/rules/default.rules` points at a dead repo slug). The council workflow stamp lives only in a hook that never fires there. Outcome wanted: **merge → release → `genie update` → everything testable in all three agents.** + +## Architecture + +### Single source of truth +`~/.genie/plugins/genie` — refreshed atomically by the existing `syncAuxiliaryContent` (`src/genie-commands/update.ts:1626,1652`); the tarball's `cp -RL` (scripts/build-binary.sh:64) dereferences the skills symlink so this one root carries `skills/` + `references/lenses/` + `workflows/council.js`, version-coherent. Fallback root `~/.genie/bin/plugins/genie` (install.sh:265 layout). Hermes source: sibling `~/.genie/plugins/hermes-genie`. + +### Engine (internal — NOT a user-facing command) +New `src/lib/agent-sync.ts` (+ tiny `src/lib/genie-home.ts`: `resolveGenieHome()`; all target dirs injectable for tests): +- **Managed-dir model**: each synced skill dir carries `.genie-sync.json` `{managedBy:'genie-agent-sync', version, digest, syncedAt}`; digest = sha256 over sorted (relpath, sha256) pairs excluding the manifest. +- **Auto-adopt-with-backup, zero friction**: genie owns every name it ships. Existing same-name dir (unmanaged or user-modified managed) → backed up to `~/.genie/state-backups/agent-sync-///`, then replaced; reported line by line. Dirs genie never shipped: untouched. +- **Removal of managed orphans** (mandatory): a managed dir absent from source is backed up + removed — kills the zombie `council/` skill that would otherwise resurrect the skill-vs-workflow name collision. +- **Atomicity**: staged `.new` + `rename` (pre-cleaning stale `.new`/`.old`), same pattern as `swapAuxiliaryTree` (reimplemented ~20 lines; do not export update.ts's private helper). +- **Report object** per agent: created/updated/unchanged/adopted/removed/skipped + advisory lines; update prints it. + +### Adapters (detect → sync) +| Agent | detect() | sync() targets | +|---|---|---| +| **claude** | `${CLAUDE_CONFIG_DIR:-~/.claude}` exists | all source skills → `skills//`; council stamp → `workflows/council.js` — TS `stampWorkflow` twin of `council-stamp.cjs` (parity test), `LENS_ROOT` = stable source root | +| **codex** | `~/.codex/` exists | all source skills as Agent-Skills folders → `~/.codex/skills/.curated//` (native SKILL.md support confirmed on-machine; `.system` is OpenAI's, never touched); advisory line "restart Codex to pick up skills" (vendor-documented). Implementation-time verification: confirm `.curated/` discovery with one skill before fanning out; fall back to the skill-installer-visible location actually scanned | +| **hermes** | `${HERMES_HOME:-~/.hermes}` exists or `hermes` on PATH | ensure symlink `~/.hermes/plugins/genie -> ~/.genie/plugins/hermes-genie` (install-local.sh's default mechanism — future updates freshen Hermes for free via the atomic source swap); honor sticky-profile variant (`profiles//plugins/genie`); real dir found → adopt-with-backup then symlink; foreign symlink (dev checkout) → leave + report; newly linked + `hermes` binary present → `hermes plugins enable genie` (non-fatal; downgrade to advisory if enable proves non-idempotent). Remote cegonha: OUT — only local agents converge | + +Notes: the `/council` **workflow** remains CC-only (Codex/Hermes have no dynamic-workflow runtime — already wish-OUT); Codex/Hermes receive the skills (incl. the 7 lanes). CC-specific tool references inside skills are an accepted lossy edge (historical precedent: the old skill-installer rule shipped the same skills to Codex). + +### Triggers — one canonical place +- **`genie update`**: sync phase runs on EVERY invocation — including the "already at latest" short-circuit path (`shortCircuitIfCurrent`, update.ts:1424): update = converge everything, not just the binary. After a real swap, the OLD process execs the NEW binary (established pattern — `--version` probes at :693/:881/:1592) with internal env `GENIE_UPDATE_SYNC_ONLY=1` so the freshly landed version's sync logic runs immediately. Failures are non-fatal advisories. +- **`genie install`**: runs the sync phase in-process (fresh installs already execute the new binary via install.sh:374 handoff) + `normalizeAuxLayout()` fixing the `~/.genie/bin/{plugins,skills,templates}` mismatch. +- **CC plugin auto-update (SessionStart trigger)**: `plugins/genie/scripts/smart-install.js` — when the genie CLI is on PATH, exec `genie update` with `GENIE_UPDATE_SYNC_ONLY=1` (quiet, try/catch, throttled by a `~/.genie/.last-agent-sync` marker, e.g. 6h, so session starts stay cheap); its own council-stamp block shrinks to the CLI-less fallback path only (stamp via `resolveStampInputs` preferring the stable `~/.genie/plugins/genie` root, falling back to `CLAUDE_PLUGIN_ROOT` — kills the stale-cache downgrade ping-pong). No sync logic duplicated in the hook. +- **No new command, no new visible flag** — the internal env var is the only re-entry contract. + +### Cleanups bundled (root-cause fixes) +- Delete `scripts/smart-install.js` + the `scripts/build.js:103-107` copy block (single source: the shipped `plugins/genie/scripts/smart-install.js` — currently one `bun run build:plugin` away from clobbering the council stamp). +- `skills/review/SKILL.md` + `skills/brainstorm/SKILL.md`: one identical anchor sentence for the lens root (`$GENIE_HOME/plugins/genie`, default `~/.genie/plugins/genie`; repo-relative inside the genie repo) so synced copies work in any repo; `validate/g4-consumers.sh` gains the `GENIE_HOME`-mention assertion. All existing literal path substrings stay intact (gate compatibility). +- `genie doctor`: per-agent freshness section (detected/enabled/linked, managed-current vs stale, council.js present/current); reports the marketplace plugin as optional/disabled — **never auto-re-enables it** (explicit user choice). +- `genie uninstall`: removes manifest-verified managed dirs per agent + stamped council.js + hermes symlink (extends existing `~/.agents/skills/genie` v4 residue cleanup). + +## Files + +**New:** `src/lib/genie-home.ts`, `src/lib/agent-sync.ts`, `src/lib/agent-sync.test.ts` (tmpdir-isolated: fresh-create/idempotent/update/adopt-backup/removal/orphan-kept/missing-agent-skip/digest-stability/stale-staging-cleanup + stamp parity vs `.cjs` via createRequire), `src/genie-commands/` wiring points below, validate gates `.genie/wishes/agent-sync/validate/{g1-engine,g2-wiring,g3-gate}.sh`. + +**Edits:** `src/genie-commands/update.ts` (sync call on short-circuit path + post-swap exec w/ `GENIE_UPDATE_SYNC_ONLY`, new `runAgentSyncSafe` beside `runV4CleanupSafe:1500`); `src/genie-commands/install.ts` (normalize + in-process sync, `--skip` seam mirroring `V4CleanupRunner:20-26`); `src/genie.ts` (env-var branch, no new command registration); `plugins/genie/scripts/smart-install.js` (delegate-to-CLI + fallback stamp via new `resolveStampInputs` in `council-stamp.cjs`); `src/lib/council-workflow-stamp.test.ts` (resolveStampInputs cases); `scripts/build.js` (drop copy block) + delete `scripts/smart-install.js`; `skills/{review,brainstorm}/SKILL.md` + `validate/g4-consumers.sh`; `src/genie-commands/doctor.ts` (pattern at :380); `src/genie-commands/uninstall.ts`; docs (`plugins/genie/README.md` distribution section, `CLAUDE.md` gotchas: "one stamp root", "managed-skill manifest + adopt-with-backup", agent-sync row). + +**Wish bookkeeping:** new wish `agent-sync` (this plan = its design; 3 groups: G1 engine+adapters ∥-safe new files → G2 wiring+hook+cleanups → G3 doctor/uninstall/docs/gates); `council-workflow` WISH G5 ritual + Decision 6/G2 note amended (CLI sync is primary; hook = trigger + CLI-less fallback); INDEX entries. + +## Verification + +1. `bun test src/lib/agent-sync.test.ts` — the behavior matrix above, green. +2. Parity: TS stamp output === `council-stamp.cjs` output. +3. `bun run check` green (725 pass / 1 skip baseline + new tests). +4. Gates `g1/g2/g3` fail-hard scripts green (g2 greps: update short-circuit calls sync, `GENIE_UPDATE_SYNC_ONLY` honored, hook delegates + fallback, `scripts/smart-install.js` ABSENT, build.js block gone, G4 skills mention `GENIE_HOME`). +5. **End-to-end on the reference machine (Felipe's ritual, one-time caveat):** merge → release → `genie update` (swaps binary; old code syncs nothing) → **`genie update` again** (short-circuits + syncs all three agents — no new commands, same canonical verb) → verify `~/.claude/skills` current + `~/.claude/workflows/council.js` present, `~/.codex/skills/.curated/` populated, `~/.hermes/plugins/genie` linked (+enabled) → run `/council` in Claude Code (council-workflow G5 QA evidence). **Every subsequent release: `genie update` once, everything converges — including via the CC-plugin SessionStart trigger.** diff --git a/.genie/wishes/agent-sync/WISH.md b/.genie/wishes/agent-sync/WISH.md new file mode 100644 index 000000000..aaa4c89ae --- /dev/null +++ b/.genie/wishes/agent-sync/WISH.md @@ -0,0 +1,141 @@ +# Wish: agent-sync — `genie update` converges every detected coding agent + +| Field | Value | +|-------|-------| +| **Status** | EXECUTED — G1/G2/G3 all SHIP (G1 after 1 fix loop; G2/G3 with orchestrator follow-ups), final execution review SHIP (2026-07-10, gates green, 806 pass / 1 skip); remaining: user-gated live ritual + PR-topology decision | +| **Slug** | `agent-sync` | +| **Date** | 2026-07-10 | +| **Author** | Felipe (planned with Fable 5) | +| **Appetite** | small-medium (2-4 days) | +| **Branch** | `wish/agent-sync` | +| **Design** | [DESIGN.md](DESIGN.md) — approved via /plan → ExitPlanMode (2026-07-10) | + +## Summary + +`genie update` today refreshes only `~/.genie/{plugins,skills,templates}`; no coding agent ever sees the result (CC marketplace plugin disabled+stale, `~/.hermes/plugins` empty, `~/.codex/skills` without genie skills, council stamp hung on a hook that never fires). This wish makes `genie update` the single canonical updater — **no new command, no new visible flag** — with an internal agent-sync phase that converges every DETECTED agent (Claude Code, Codex, Hermes) on every invocation, and turns the CC plugin's SessionStart hook into a mere trigger that delegates to it. + +## Scope + +### IN +- Internal engine `src/lib/agent-sync.ts` (+ `src/lib/genie-home.ts`): manifest-managed dirs (`.genie-sync.json`, dir-level digest), auto-adopt-with-backup to `~/.genie/state-backups/agent-sync-/`, removal of managed orphans (backup first), staged-`.new`+rename atomicity, per-agent report. +- Adapters: **claude** (skills → `${CLAUDE_CONFIG_DIR:-~/.claude}/skills/`, council stamp → `workflows/council.js` via TS `stampWorkflow` parity-locked to `council-stamp.cjs`, `LENS_ROOT` = stable source root), **codex** (skills as Agent-Skills folders → `~/.codex/skills/.curated//`; `.system` never touched; restart advisory), **hermes** (symlink `~/.hermes/plugins/genie -> ~/.genie/plugins/hermes-genie` + sticky-profile variant + `hermes plugins enable genie` when newly linked and binary present). +- Source root resolution: `~/.genie/plugins/genie` (fallback `~/.genie/bin/plugins/genie`); hermes source `~/.genie/plugins/hermes-genie`. +- Triggers: sync phase on EVERY `genie update` (including the already-at-latest short-circuit path); post-swap exec of the NEW binary with internal env `GENIE_UPDATE_SYNC_ONLY=1`; `genie install` runs it in-process + `normalizeAuxLayout()` (bin/ layout mismatch); smart-install.js delegates to `genie update` (env set, throttled via `~/.genie/.last-agent-sync`, non-fatal) with CLI-less fallback stamp via new `resolveStampInputs` (stable-root preference). +- Cleanups: delete `scripts/smart-install.js` + build.js copy block; lens-root anchor sentence in `skills/{review,brainstorm}/SKILL.md` + gate assertion; doctor per-agent freshness section; uninstall removes managed assets. +- Docs: plugins/genie/README.md distribution section, CLAUDE.md gotchas + row. +- council-workflow WISH amendments (ritual + Decision 6/G2 note: CLI sync primary, hook = trigger/fallback). + +### OUT +- Any new user-facing command or flag (hard requirement). +- Remote Hermes (cegonha) — only local agents converge. +- Auto-re-enabling the disabled `genie@automagik` marketplace plugin (explicit user choice; doctor reports only). +- `/council` workflow on Codex/Hermes (no dynamic-workflow runtime there — CC-only, as already decided). +- Repo-root `AGENTS.md` regeneration (buggy sed twin of CLAUDE.md — separate cleanup), `~/.codex/rules/default.rules` stale-rule reconciliation, `~/.codex/config.toml` beyond what codex-config.ts already writes. +- Refactoring the ~5 existing inline GENIE_HOME resolutions (hygiene pass later). + +## Decisions + +| # | Decision | Rationale | +|---|----------|-----------| +| 1 | Internal phase, not a command | Felipe's hard requirement: one canonical verb (`genie update`); internal env `GENIE_UPDATE_SYNC_ONLY=1` is the only re-entry contract | +| 2 | Sync runs even on the short-circuit path | "Update = converge everything, not just the binary" — also what makes the hook-trigger cheap and the one-time delivery caveat a plain second `genie update` | +| 3 | Auto-adopt-with-backup, no prompts | Zero-friction requirement; backups under state-backups/ make every replacement reversible; names genie never shipped are untouched | +| 4 | Managed-orphan removal is v1-mandatory | Zombie `~/.claude/skills/council` would resurrect the skill-vs-workflow name collision council-workflow Decision 8 exists to prevent | +| 5 | Single stable source root (`~/.genie/plugins/genie`) for stamps and sync | Version-coherent (tarball cp -RL), path never changes across versions; kills the stale-plugin-cache downgrade ping-pong between hook and CLI | +| 6 | Codex gets native Agent-Skills folders in `.curated/` | Machine-verified native SKILL.md support; historical precedent (old skill-installer rule shipped genie skills); `.system` is OpenAI-owned | +| 7 | Hermes via symlink + enable | install-local.sh's documented default; the atomic source swap freshens Hermes on every future update for free | +| 8 | Delete scripts/smart-install.js (not backport) | Root cause of the clobber hazard: two diverged copies; shipped copy becomes the single source; build.js copy block dies with it | +| 9 | Post-swap exec of the new binary | Established pattern in update.ts (three existing probes); makes every FUTURE update self-syncing; delivery release carries a one-time "run `genie update` again" caveat | + +## Success Criteria + +- [x] `bun test src/lib/agent-sync.test.ts` green: fresh-create / idempotent re-run all-unchanged / source-change→updated / adopt-with-backup (backup exists under state-backups) / managed-orphan removed+backed-up / unmanaged-never-shipped untouched / missing-agent → skip with note / digest stable under file order + excludes manifest / stale `.new` staging pre-cleaned / hermes symlink + real-dir adopt + foreign-symlink left / codex `.curated` placement / stamp parity TS === `.cjs` +- [x] `git grep -n 'GENIE_UPDATE_SYNC_ONLY' src/genie-commands/update.ts src/genie.ts` shows the env honored, and the short-circuit path calls the sync phase (structural greps in g2 gate) +- [x] `scripts/smart-install.js` no longer exists; `scripts/build.js` has no smart-install copy block; `plugins/genie/scripts/smart-install.js` delegates to `genie update` and stamps only as CLI-less fallback via `resolveStampInputs` +- [x] `skills/review/SKILL.md` + `skills/brainstorm/SKILL.md` carry the `$GENIE_HOME/plugins/genie` lens-root anchor; `validate/g4-consumers.sh` asserts it; council-workflow gates still green +- [x] doctor prints a per-agent freshness section; uninstall removes manifest-verified managed assets (unit-tested seams) +- [x] `bun run check` green (baseline 725 pass / 1 skip → 806 pass / 1 skip with new tests) +- [ ] **USER-GATED (post-release)** — Live (Felipe's ritual): `genie update` twice on the reference machine → `~/.claude/skills` current + `workflows/council.js` present, `~/.codex/skills/.curated/` populated, `~/.hermes/plugins/genie` linked; evidence in `qa/` + +## Execution Strategy + +| Wave | Group | Agent | Complexity | Model | Notes | +|------|-------|-------|------------|-------|-------| +| 1 | G1 engine + adapters + tests | engineer | 4 (fs engine, 3 adapters, parity) | inherit (fable·max) | New files only — safe alongside the concurrent session in this tree | +| 2 | G2 wiring + hook + cleanups | engineer | 3 (update/install/hook seams) | inherit (fable·max) | Touches skills/{review,brainstorm} — coordinate with concurrent uncommitted edits at dispatch time | +| 3 | G3 doctor + uninstall + docs + gate | engineer | 2 (surfaces + docs) | inherit (fable·max) | Final `bun run check` | + +--- + +## Execution Groups + +### Group 1: Engine + adapters + tests +**Goal:** The internal agent-sync engine exists, fully unit-tested, with claude/codex/hermes adapters and the parity-locked TS stamp — no wiring yet. + +**Deliverables:** +1. `src/lib/genie-home.ts` — `resolveGenieHome()`, `resolveClaudeDir()` (honors `CLAUDE_CONFIG_DIR`), plus codex/hermes dir resolvers (env-overridable for tests). +2. `src/lib/agent-sync.ts` — public API: `runAgentSync(opts) → AgentSyncReport` (agents: claude/codex/hermes, each `{detect, sync}`), manifest+digest model, auto-adopt-with-backup, orphan removal, staged-rename writes, `stampWorkflow()` TS twin, `resolveGenieSource()`. Split into a dir module if complexity budget warrants. +3. `src/lib/agent-sync.test.ts` — the behavior matrix from Success Criteria, tmpdir-isolated (GENIE_HOME + injected agent target dirs), real files, afterEach cleanup. + +**Acceptance Criteria:** +- [x] Behavior matrix covered and green; typecheck + biome clean +- [x] No wiring into commands (G2); no user-facing surface +- [x] Transient knip warnings for not-yet-wired exports are acceptable and reported (none occurred — knip clean) + +**Status:** DONE (2026-07-10) — gate `G1 PASS` (orchestrator-run, 31 tests), execution review FIX-FIRST → fixer → re-review SHIP (loop 1). HIGH closed with empirical re-proof: staging suffixes were `.new`/`.old` and a user's manual-backup sibling dir (`review.old`) was silently destroyed by the pre-clean — now collision-proof `.genie-sync.staging`/`.genie-sync.prev` constants shared by writer + orphan filter, locked by on-disk survival tests. LOWs: hermes enable also fires on the adopt transition; late adapter throws preserve the partial report. 27→31 tests, watched-fail-first on every fix. + +**Validation:** +```bash +cd "$(git rev-parse --show-toplevel)" +bash .genie/wishes/agent-sync/validate/g1-engine.sh +``` + +**depends-on:** none + +### Group 2: Wiring — update/install/hook + cleanups +**Goal:** Every trigger converges into the one engine; the diverged smart-install copy dies. + +**Deliverables:** +1. `src/genie-commands/update.ts`: `runAgentSyncSafe()` called on BOTH the short-circuit path and post-verify; post-swap exec of the new binary with `GENIE_UPDATE_SYNC_ONLY=1`; env honored early in `updateCommand` (sync-only fast path). +2. `src/genie-commands/install.ts`: `normalizeAuxLayout()` + in-process sync; injection seams mirroring `V4CleanupRunner`. +3. `plugins/genie/scripts/council-stamp.cjs`: `resolveStampInputs({claudePluginRoot, genieHome, exists})` (stable-root preference); `plugins/genie/scripts/smart-install.js`: delegate to `genie update` (env + throttle marker + try/catch), stamp only on the CLI-less fallback path; `src/lib/council-workflow-stamp.test.ts` extended. +4. Delete `scripts/smart-install.js`; remove the `scripts/build.js` copy block. +5. `skills/review/SKILL.md` + `skills/brainstorm/SKILL.md`: identical lens-root anchor sentence; `.genie/wishes/council-workflow/validate/g4-consumers.sh` gains the GENIE_HOME assertion. + +**Acceptance Criteria:** +- [x] g2 structural greps all pass; update/install tests extended and green +- [x] council-workflow gates (g2-engine, g4-consumers) still green +- [x] Coordinated with concurrent uncommitted edits to the same skill files (their session committed first; tree was clean at dispatch) + +**Status:** DONE (2026-07-10) — gate `G2 PASS` (orchestrator-run), execution review SHIP (0 CRITICAL/HIGH; MEDIUMs adjudicated). Orchestrator hardening pass applied post-review per reviewer recommendation: `findGenieBinary` PATH probe got `timeout: 5000`; delegation moved AFTER the `GENIE_WORKER` guard (workers never pay the ≤45s delegation — parent session converges for them); hook `GENIE_DIR` honors `GENIE_HOME` (throttle marker now matches the CLI's writes under relocation). Deviations accepted: one-line `.js` import-extension fix in the G1 engine (mcp lazy-load probe requires it once reachable from genie.ts); marker write lives inside `runAgentSyncSafe` (fires on all paths). Known debts recorded: g1-engine.sh line 9 inverse assertion now stale (G3 amends); hook throttle/delegation logic untested (fail-safe by construction — bad marker ⇒ allow; accepted); 3 duplicate plugin-resource-shipping commits on the branch vs dev (resolve before merge). `bun run check` 792 pass / 1 skip. + +**Validation:** +```bash +cd "$(git rev-parse --show-toplevel)" +bash .genie/wishes/agent-sync/validate/g2-wiring.sh +``` + +**depends-on:** Group 1 + +### Group 3: Doctor + uninstall + docs + final gate +**Goal:** Operability and docs: freshness visibility, clean removal, documented distribution model. + +**Deliverables:** +1. `src/genie-commands/doctor.ts`: per-agent freshness section (detected / managed-current / stale / unmanaged; council.js present+current; marketplace plugin reported as optional-disabled, never mutated). +2. `src/genie-commands/uninstall.ts`: manifest-verified managed-dir removal per agent + stamped council.js + hermes symlink. +3. Docs: `plugins/genie/README.md` distribution section rewrite (CLI sync primary; hook = trigger + CLI-less fallback; per-agent table); `CLAUDE.md` agent-sync row + two gotchas (one stamp root; manifest + adopt-with-backup). +4. council-workflow WISH G5 ritual + Decision 6 note amended (CLI primary; delivery-release caveat: `genie update` twice, once ever). + +**Acceptance Criteria:** +- [x] g3 gate green including full `bun run check` +- [x] Docs match the shipped behavior exactly (no aspirational claims) + +**Status:** DONE (2026-07-10) — gate `G3 PASS` (orchestrator-run, 807 tests incl. full check), execution review SHIP (0 CRITICAL/HIGH). Destructive-path audit clean: uninstall removal is manifest/signature/target-verified and fails safe (corrupt manifest ⇒ skip); doctor structurally read-only (no write primitive in import scope). Orchestrator follow-up pass applied per review: M1 — protocol constants (`MANIFEST_NAME`/`MANAGED_BY`/`TARGET_NAME`) now exported from the engine and imported by doctor/uninstall (single source of truth on the safety identifiers); L1 ritual wording ("the first only swaps the binary — the old code has no agent-sync"); L2 README removal-claims precision. L3 test pins accepted as debt (invariants correct by inspection + adjacent coverage). Marketplace-note-as-pass adjudicated acceptable. + +**Validation:** +```bash +cd "$(git rev-parse --show-toplevel)" +bash .genie/wishes/agent-sync/validate/g3-gate.sh +``` + +**depends-on:** Group 2 diff --git a/.genie/wishes/agent-sync/validate/g1-engine.sh b/.genie/wishes/agent-sync/validate/g1-engine.sh new file mode 100755 index 000000000..9d4ddeb66 --- /dev/null +++ b/.genie/wishes/agent-sync/validate/g1-engine.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(git rev-parse --show-toplevel)" + +test -f src/lib/genie-home.ts || { echo "FAIL: missing src/lib/genie-home.ts"; exit 1; } +test -e src/lib/agent-sync.ts || test -d src/lib/agent-sync || { echo "FAIL: missing agent-sync module"; exit 1; } +test -f src/lib/agent-sync.test.ts || { echo "FAIL: missing src/lib/agent-sync.test.ts"; exit 1; } + +# (retired) This line asserted src/genie-commands/ carried NO GENIE_UPDATE_SYNC_ONLY +# wiring — a G1-phase guard. G2 landed that wiring by design (update.ts sync-only +# fast path), so the assertion is obsolete and intentionally removed. Kept as a +# note so the gate stays re-runnable and the history of the check is legible. + +bun test src/lib/agent-sync.test.ts +bun run typecheck +bunx biome check src/lib/genie-home.ts src/lib/agent-sync.ts src/lib/agent-sync.test.ts 2>/dev/null || bunx biome check src/lib/ + +echo "G1 PASS" diff --git a/.genie/wishes/agent-sync/validate/g2-wiring.sh b/.genie/wishes/agent-sync/validate/g2-wiring.sh new file mode 100755 index 000000000..56ab6ad99 --- /dev/null +++ b/.genie/wishes/agent-sync/validate/g2-wiring.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(git rev-parse --show-toplevel)" + +grep -q 'runAgentSyncSafe' src/genie-commands/update.ts || { echo "FAIL: update.ts lacks runAgentSyncSafe"; exit 1; } +grep -q 'GENIE_UPDATE_SYNC_ONLY' src/genie-commands/update.ts || { echo "FAIL: update.ts does not honor GENIE_UPDATE_SYNC_ONLY"; exit 1; } +grep -Eq 'shortCircuit|already at the latest' src/genie-commands/update.ts || { echo "FAIL: cannot locate short-circuit path"; exit 1; } +grep -q 'normalizeAuxLayout' src/genie-commands/install.ts || { echo "FAIL: install.ts lacks normalizeAuxLayout"; exit 1; } +grep -Eq 'runAgentSync|agent-sync' src/genie-commands/install.ts || { echo "FAIL: install.ts does not run agent sync"; exit 1; } + +test ! -f scripts/smart-install.js || { echo "FAIL: scripts/smart-install.js still exists"; exit 1; } +! grep -q 'smart-install' scripts/build.js || { echo "FAIL: build.js still copies smart-install"; exit 1; } + +grep -q 'resolveStampInputs' plugins/genie/scripts/council-stamp.cjs || { echo "FAIL: council-stamp.cjs lacks resolveStampInputs"; exit 1; } +grep -q 'resolveStampInputs' plugins/genie/scripts/smart-install.js || { echo "FAIL: smart-install.js does not use resolveStampInputs"; exit 1; } +grep -q 'genie update' plugins/genie/scripts/smart-install.js || { echo "FAIL: smart-install.js does not delegate to genie update"; exit 1; } +grep -q 'GENIE_UPDATE_SYNC_ONLY' plugins/genie/scripts/smart-install.js || { echo "FAIL: smart-install.js delegation lacks the sync-only env"; exit 1; } + +grep -q 'GENIE_HOME' skills/review/SKILL.md || { echo "FAIL: review skill lacks the lens-root anchor"; exit 1; } +grep -q 'GENIE_HOME' skills/brainstorm/SKILL.md || { echo "FAIL: brainstorm skill lacks the lens-root anchor"; exit 1; } + +bash .genie/wishes/council-workflow/validate/g4-consumers.sh +bash .genie/wishes/council-workflow/validate/g2-engine.sh +bun test src/lib/council-workflow-stamp.test.ts src/lib/agent-sync.test.ts + +echo "G2 PASS" diff --git a/.genie/wishes/agent-sync/validate/g3-gate.sh b/.genie/wishes/agent-sync/validate/g3-gate.sh new file mode 100755 index 000000000..01c9e8bf0 --- /dev/null +++ b/.genie/wishes/agent-sync/validate/g3-gate.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(git rev-parse --show-toplevel)" + +grep -Eqi 'agent[ -]sync|agentSync' src/genie-commands/doctor.ts || { echo "FAIL: doctor lacks agent-sync freshness section"; exit 1; } +grep -Eqi 'agent[ -]sync|agentSync|genie-sync' src/genie-commands/uninstall.ts || { echo "FAIL: uninstall lacks managed-asset removal"; exit 1; } +grep -Eqi 'agent[ -]sync' plugins/genie/README.md || { echo "FAIL: plugin README lacks the distribution section"; exit 1; } +grep -Eqi 'agent[ -]sync' CLAUDE.md || { echo "FAIL: CLAUDE.md lacks the agent-sync row/gotchas"; exit 1; } +grep -q 'genie update' .genie/wishes/council-workflow/WISH.md || { echo "FAIL: council-workflow ritual not amended"; exit 1; } + +bun run check + +echo "G3 PASS" diff --git a/.genie/wishes/council-workflow/WISH.md b/.genie/wishes/council-workflow/WISH.md index 7a88be3cd..1ddc3b555 100644 --- a/.genie/wishes/council-workflow/WISH.md +++ b/.genie/wishes/council-workflow/WISH.md @@ -2,7 +2,7 @@ | Field | Value | |-------|-------| -| **Status** | DRAFT — design review SHIP + plan review SHIP (2026-07-09, same independent reviewer, 1 fix pass each); `/work` user-gated | +| **Status** | EXECUTED (2026-07-10) — G1–G5 all SHIP (see INDEX poured entry); design + plan reviews SHIP (2026-07-09, same independent reviewer, 1 fix pass each). **Live-QA ritual (Felipe: `/council "revisar tudo"`) and the final execution review remain pending post-stable-release** — the g5-gate parks in `qa/` until then. First real dogfood tonight surfaced and fixed a string-args defect (`ec68cd8f`): see [qa/overnight-observations-20260710.md](qa/overnight-observations-20260710.md) | | **Slug** | `council-workflow` | | **Date** | 2026-07-09 | | **Author** | Felipe (planned with Fable 5) | @@ -98,7 +98,7 @@ bash .genie/wishes/council-workflow/validate/g1-lane-skills.sh **Deliverables:** 1. `plugins/genie/workflows/council.js` — meta + Resolve/Round 1/Round 2/Synthesis/Persist phases, ROUTING table (absorbed from `members/routing.md`, incl. default trio + members override), LENSES map (`LENS_ROOT`-relative), deliberation and audit stage contracts, ≥2-members failure rule. 2. `plugins/genie/references/lenses/{questioner,simplifier,operator,deployer,measurer,tracer}.md` — frontmatter: name, modes, voice. -3. Stamp function as `plugins/genie/scripts/council-stamp.cjs` (pure, dependency-injectable; loaded via `createRequire` from the ESM SessionStart hook — it must ship with the plugin, not the bundled CLI; deviation from the originally drafted `src/lib/*.ts` path, gate-tested via `src/lib/council-workflow-stamp.test.ts` importing the `.cjs`): replaces the `LENS_ROOT` placeholder with the absolute installed-plugin path and writes `~/.claude/workflows/council.js`. **Call site pinned: the SessionStart hook (`plugins/genie/scripts/smart-install.js`, where `CLAUDE_PLUGIN_ROOT` is set), placed BEFORE its early-exit guards (deps-present, `GENIE_WORKER=1`) and idempotent via drift-check (rewrite only when stamped `LENS_ROOT` ≠ current `CLAUDE_PLUGIN_ROOT` or template hash changed). Re-stamp is therefore driven by the first session start after `claude plugin update` — the `genie update` CLI does not own it.** +3. Stamp function as `plugins/genie/scripts/council-stamp.cjs` (pure, dependency-injectable; loaded via `createRequire` from the ESM SessionStart hook — it must ship with the plugin, not the bundled CLI; deviation from the originally drafted `src/lib/*.ts` path, gate-tested via `src/lib/council-workflow-stamp.test.ts` importing the `.cjs`): replaces the `LENS_ROOT` placeholder with the absolute installed-plugin path and writes `~/.claude/workflows/council.js`. **Call site pinned: the SessionStart hook (`plugins/genie/scripts/smart-install.js`, where `CLAUDE_PLUGIN_ROOT` is set), placed BEFORE its early-exit guards (deps-present, `GENIE_WORKER=1`) and idempotent via drift-check (rewrite only when stamped `LENS_ROOT` ≠ current `CLAUDE_PLUGIN_ROOT` or template hash changed). Re-stamp is now owned by `genie update` (the canonical updater stamps via `resolveStampInputs`, preferring the stable `~/.genie/plugins/genie` root); the SessionStart hook became a throttled trigger that delegates to it, with a CLI-less fallback stamp on plugin-only machines — reversed by the agent-sync wish (2026-07-10).** 4. `scripts/council-workflow-lint.ts` + `package.json` script `lint:council-workflow`. **Acceptance Criteria:** @@ -169,7 +169,7 @@ bash .genie/wishes/council-workflow/validate/g4-consumers.sh **Deliverables:** 1. `lint:council-workflow` wired into `bun run check` (package.json). 2. Docs: skills/README.md, plugin README/docs notes, workflow requirements (CC ≥ 2.1.154, paid plans, `disableWorkflows`). -3. Live QA evidence: `.genie/wishes/council-workflow/qa/deliberation-run.md` + `qa/audit-run.md` — **USER-GATED, post-release (Felipe's ruling 2026-07-10):** the real test is the shipped surface, not a scriptPath simulation. Ritual: merge → release → plugin update lands on Felipe's machine → SessionStart stamp installs `~/.claude/workflows/council.js` → Felipe runs `/council` himself ("revisar tudo") to validate; the run outputs become the qa/ evidence files. `validate/g5-gate.sh` deliberately keeps failing at the qa/ assertions until then — that pending tail is the designed state, not a defect. +3. Live QA evidence: `.genie/wishes/council-workflow/qa/deliberation-run.md` + `qa/audit-run.md` — **USER-GATED, post-release (Felipe's ruling 2026-07-10):** the real test is the shipped surface, not a scriptPath simulation. Ritual (now driven by agent-sync): merge → release → on the reference machine run `genie update` twice (the first only swaps the binary — the old code has no agent-sync, so nothing converges; the second — the same canonical verb — converges every agent; every later release needs only one run) → verify `~/.claude/workflows/council.js` is stamped and `~/.claude/skills/` are current → Felipe runs `/council` himself ("revisar tudo") to validate; the run outputs become the qa/ evidence files. `validate/g5-gate.sh` deliberately keeps failing at the qa/ assertions until then — that pending tail is the designed state, not a defect. **Acceptance Criteria:** - [x] `bun run check` green AND its output proves `lint:council-workflow` actually ran (behavioral wiring check, not just script existence) diff --git a/.genie/wishes/council-workflow/qa/overnight-observations-20260710.md b/.genie/wishes/council-workflow/qa/overnight-observations-20260710.md new file mode 100644 index 000000000..812887a00 --- /dev/null +++ b/.genie/wishes/council-workflow/qa/overnight-observations-20260710.md @@ -0,0 +1,28 @@ +# /council — Overnight QA Observations (2026-07-10) + +**Context:** first real `/council` dogfood of the overnight run — a lens council deliberation (3 lenses, 2 rounds) plus the live-QA ritual against the stamped `~/.claude/workflows/council.js`. These are the observations to fold into the council-workflow **final execution review**, which stays pending Felipe's live-QA tail (`/council "revisar tudo"`) after the next stable release. + +--- + +## Defect found and fixed: stamped `council.js` rejected string args + +**Symptom.** Invoking the stamped `/council` with a string argument dead-ended three times in a row with `"No input received"` — the workflow never entered its Resolve phase. + +**Root cause.** The Claude Code **Workflow runtime stringifies** saved-workflow input before handing it to the script. The stamped `council.js` demanded an **object**-shaped argument and treated the incoming string as empty, so it fell through to the no-input branch. This is a runtime-contract mismatch, not a logic bug in the deliberation flow: the script's own arg-parsing assumed a shape the runtime does not deliver for saved workflows. + +**Fix.** Coerce a string argument into the expected shape at the workflow entry point (accept both a raw string topic and the object form). Validated **live** on a patched copy of the script first (the string invocation reached Resolve → Round 1), then upstreamed by the owning session and merged to dev as **`ec68cd8f`** (`fix(workflows): coerce string args in /council — runtime stringifies saved-workflow input`). Verified present on `origin/dev` in tonight's run. + +## Secondary observation: skill-text vs script-contract mismatch + +The `/council` **skill instruction text** tells the caller to pass arguments **as a string**, while the **pre-fix script** demanded an **object**. Before `ec68cd8f` these two contracts disagreed, which is exactly the surface the defect lived on. **Post-fix both work** — the coercion accepts the string form the skill text advertises and the object form the earlier script expected. No doc change is required now that the script tolerates both, but the final execution review should record that the skill text and the script contract were briefly out of sync and are now reconciled by coercion rather than by narrowing either side. + +## Related note carried from the wish record + +The stamp mechanism fired only on **SessionStart**, so a mid-session `/plugin update` left a stamping gap (worked around by running `smart-install.js` manually, plugin cache 5.260710.2). That SessionStart-only trigger gap is the premise **agent-sync** was built to close (agent-sync merged to dev 2026-07-10 via PR #2541); re-confirm the stamp path after the next stable release + `genie update` ×2. + +--- + +## Still pending (USER-GATED, post-stable-release) + +1. **Live-QA ritual** — Felipe runs `/council "revisar tudo"` against the released, self-synced `council.js`. This exercises the string-args path end-to-end on the shipped artifact (not a patched copy). +2. **Final execution review** — written after the ritual; the g5-gate parks in this `qa/` directory until then. It should incorporate this defect story and the deliberation-quality observations from the first 3-lens / 2-round dogfood. diff --git a/.genie/wishes/council-workflow/validate/g4-consumers.sh b/.genie/wishes/council-workflow/validate/g4-consumers.sh index 167cfa501..841050155 100755 --- a/.genie/wishes/council-workflow/validate/g4-consumers.sh +++ b/.genie/wishes/council-workflow/validate/g4-consumers.sh @@ -11,6 +11,11 @@ grep -q 'references/lenses' skills/review/SKILL.md || { echo "FAIL: review skill grep -qiE 'domain[ -]experts?' skills/brainstorm/SKILL.md || { echo "FAIL: brainstorm skill lacks a domain-experts step"; exit 1; } grep -q 'references/lenses' skills/brainstorm/SKILL.md || { echo "FAIL: brainstorm skill lacks lens-library reference"; exit 1; } +# --- lens-root anchor (agent-sync): both skills must name GENIE_HOME so synced +# copies resolve the lens root in any repo, not just the genie checkout +grep -q 'GENIE_HOME' skills/review/SKILL.md || { echo "FAIL: review skill lacks the lens-root GENIE_HOME anchor"; exit 1; } +grep -q 'GENIE_HOME' skills/brainstorm/SKILL.md || { echo "FAIL: brainstorm skill lacks the lens-root GENIE_HOME anchor"; exit 1; } + for f in skills/review/SKILL.md skills/brainstorm/SKILL.md; do # every cited lens-card path resolves while IFS= read -r p; do diff --git a/.genie/wishes/plugin-resource-shipping/WISH.md b/.genie/wishes/plugin-resource-shipping/WISH.md index fdbb7220e..938dfa065 100644 --- a/.genie/wishes/plugin-resource-shipping/WISH.md +++ b/.genie/wishes/plugin-resource-shipping/WISH.md @@ -92,7 +92,11 @@ test ! -e templates/wish-template.md || { echo "repo-root template still present grep -q 'CLAUDE_SKILL_DIR}/templates/wish-template.md' skills/wish/SKILL.md || { echo "scaffold not CLAUDE_SKILL_DIR-addressed"; exit 1; } grep -nE '^\s*(bash )?bun run wishes:lint' skills/wish/SKILL.md skills/brainstorm/SKILL.md skills/README.md | grep -v 'grep -q' && { echo "unguarded executable lint invocation remains"; exit 1; } grep -q 'cp templates/wish-template.md' skills/wish/SKILL.md && { echo "old bare cp form survives in wish skill"; exit 1; } -grep -rn 'templates/wish-template.md' . 2>/dev/null | grep -v 'skills/wish/templates/wish-template.md' | grep -v 'CLAUDE_SKILL_DIR}/templates/wish-template.md' | grep -v '^\./\.genie/' | grep -v '^\./node_modules/' | grep -v '^\./\.docs-vendor/' | grep -v '^\./\.git/' && { echo "stale old-path reference (any extension)"; exit 1; } +# git grep is tracked-only (auto-skips node_modules/.git and the .docs-vendor submodule); +# :(exclude) drops .genie history docs and the lint rule's own negative fixtures +# (skills-lint.test.ts intentionally ships bare `cp templates/...` strings) so the +# sweep stays replay-safe post-G2 while still catching a real stale old-path reference. +git grep -In 'templates/wish-template\.md' -- ':(exclude).genie/' ':(exclude)scripts/skills-lint.test.ts' | grep -v 'skills/wish/templates/wish-template.md' | grep -v 'CLAUDE_SKILL_DIR}/templates/wish-template.md' && { echo "stale old-path reference (any extension)"; exit 1; } grep -rn 'REPO_ROOT/templates' tests/ && { echo "e2e still reads repo-root template"; exit 1; } bun run wishes:lint || exit 1 bun run check || exit 1 @@ -178,7 +182,7 @@ _What must be verified on dev after merge. The QA agent tests each criterion._ **Plan review (2026-07-09): SHIP** after 2 fix loops. R1 FIX-FIRST: CRITICAL — repo-root template deletion would break the required CI e2e (`tests/e2e/v5-lifecycle.sh:156` consumer invisible to the extension-filtered sweep) + 3 MEDIUM (README consumer unplanned; invocation-vs-prose guard scope; G2 scan-surface underspec). R2: residual G1↔G2 self-contradiction → paraphrase rule; sweep hardening. R3: SHIP with one mechanical correction (raw-new-path content exclusion in the G1 sweep — applied to this document at gate close, reviewer-supplied line). **Hermes counter-read: UNAVAILABLE both attempts** (cegonha unreachable) — degradation policy applied, retry at execution review. -_Execution review: populated by `/review` after execution completes._ +**Execution review (final gate fable·high, 2026-07-10): FIX-FIRST → SHIP after branch surgery.** All three groups landed with 0 fix loops each (engineers opus·high, reviewers opus·xhigh — first wish executed under the routing matrix); full repo gate exits 0 (773 pass / 1 skip / 0 fail) and all 5 Success Criteria proven with fresh gate-produced evidence: template ships in-skill with routing-matrix columns and the repo-root copy is gone; no `cp templates/` or unguarded wish-linter invocations in skill files; skills:lint enforces the resource rule (15/15 fixture tests incl. the must-fail case); fresh-install smoke resolves both `${CLAUDE_SKILL_DIR}` refs and scaffolds a wish in a bare repo with no genie CLI; CI runs the smoke and the release-lag note is live. The gate's only HIGH/MEDIUM findings were topology, not code: a concurrent session had switched the shared checkout to `wish/agent-sync`, so the group commits landed there and the template rename rode a foreign docs commit. **Surgery (orchestrator, evidenced inline):** branch rebuilt off origin/dev in an isolated worktree — G1 recreated with the R100 rename folded in (`ecbb67fc`: rename + exactly the 4 reference repoints), G3/G2 cherry-picked clean (`203c97df`, `bbd6439e`); tree vs the gate-validated tip differs only by the dropped foreign `.genie/agent-sync` docs and the newer dev version strings. QA watch items (LOW, follow-up): `fresh-install-smoke.ts` temp-dir cleanup is bypassed by `process.exit` on phase-b failures; G1's validation sweep is not replay-safe post-G2 (trips on the lint rule's own negative fixtures); G2's same-line guard discriminator is substring-based ("package.json" mention passes). Live installed-plugin scaffold QA (wish QA criterion 1) pending the next plugin release. **Hermes counter-read: cegonha still unreachable — fail-open applied (third consecutive gate), logged.** --- diff --git a/.genie/wishes/rolling-pr-auth-hardening/WISH.md b/.genie/wishes/rolling-pr-auth-hardening/WISH.md index 0689663e1..1ff8fb97f 100644 --- a/.genie/wishes/rolling-pr-auth-hardening/WISH.md +++ b/.genie/wishes/rolling-pr-auth-hardening/WISH.md @@ -2,7 +2,7 @@ | Field | Value | |-------|-------| -| **Status** | DRAFT | +| **Status** | DONE — implemented on dev **prior to execution of this wish**: `c4fdb32b` (fail-fast on dead/absent PAT + read/create token split) and `422caaa2` (dev==main healthy no-op) together satisfy the acceptance criteria; both commits are also promoted to `origin/main`. Discovered already-shipped by a council freshness probe (2026-07-10) — no new engineering dispatched. Minting/refreshing the PAT itself remains the open human action for Felipe | | **Slug** | `rolling-pr-auth-hardening` | | **Date** | 2026-07-05 | | **Author** | Felipe (dogfooding the revamped v5 lifecycle) | @@ -37,10 +37,10 @@ Harden the workflow: fail fast with an actionable `::error` when the secret is a ## Success Criteria -- [ ] With the secret absent or dead (current reality: present, 401): `workflow_dispatch` run fails in seconds with the actionable `::error` text, not a raw gh auth message. -- [ ] `gh pr list` step authenticates via `github.token` (no PAT dependency on the read path). -- [ ] YAML parses; workflow diff touches only `rolling-pr.yml`. -- [ ] Once Felipe refreshes the PAT: the hourly run creates/confirms the rolling PR again (post-merge QA item). +- [x] With the secret absent or dead (current reality: present, 401): `workflow_dispatch` run fails in seconds with the actionable `::error` text, not a raw gh auth message. — satisfied by `c4fdb32b`. +- [x] `gh pr list` step authenticates via `github.token` (no PAT dependency on the read path). — satisfied by `c4fdb32b` (read/create token split). +- [x] YAML parses; workflow diff touches only `rolling-pr.yml`. — verified in the shipped commits (`c4fdb32b`, `422caaa2`). +- [ ] Once Felipe refreshes the PAT: the hourly run creates/confirms the rolling PR again (post-merge QA item — **open human action**, blocked on the PAT mint). ## Execution Strategy @@ -92,7 +92,7 @@ cd "$(git rev-parse --show-toplevel)" && python3 -c "import yaml,sys; yaml.safe_ ## Review Results -_Populated by `/review`._ +**Resolution (2026-07-10): already implemented on dev, no dispatch needed.** A council freshness probe found the hardening had landed independently: `c4fdb32b` adds the fail-fast guard on an absent/dead PAT and splits the read path (`gh pr list` on `github.token`) from the create path (PAT), and `422caaa2` makes the workflow a healthy no-op when dev==main. `git branch -r --contains` confirms both commits are on `origin/dev` and `origin/main`. Acceptance criteria 1–3 are satisfied by the shipped code; criterion 4 (hourly run creates the rolling PR again) stays blocked on Felipe minting/refreshing `RELEASE_PLEASE_TOKEN` with `contents:read` + `pull-requests:write`. No engineering was dispatched for this wish. --- diff --git a/.genie/wishes/routing-matrix/WISH.md b/.genie/wishes/routing-matrix/WISH.md index 8f93cf5da..759347f5c 100644 --- a/.genie/wishes/routing-matrix/WISH.md +++ b/.genie/wishes/routing-matrix/WISH.md @@ -2,7 +2,7 @@ | Field | Value | |-------|-------| -| **Status** | EXECUTED — execution review SHIP (2026-07-09; live LangWatch QA pending) | +| **Status** | EXECUTED — execution review SHIP (2026-07-09); day-1 live pin QA recorded 2026-07-10 — **inconclusive by delivery gap** (Fable share rose on every measure, but the 7 pinned role agents did NOT appear as subagent types under plugin cache 5.260710.2, so pins were applied by hand, not mechanically; properly-pinned wish ran ~11% Fable). Re-test after next stable release carrying agent-sync + `genie update` ×2 — see [qa/routing-pin-qa-20260710.md](qa/routing-pin-qa-20260710.md) | | **Slug** | `routing-matrix` | | **Date** | 2026-07-09 | | **Author** | Felipe (planned with Fable 5 + Hermes counter-read) | diff --git a/.genie/wishes/routing-matrix/qa/routing-pin-qa-20260710.md b/.genie/wishes/routing-matrix/qa/routing-pin-qa-20260710.md new file mode 100644 index 000000000..18194c9ac --- /dev/null +++ b/.genie/wishes/routing-matrix/qa/routing-pin-qa-20260710.md @@ -0,0 +1,121 @@ +# Routing-Matrix Pin — Day-1 Live QA (2026-07-10) + +**Destination:** `.genie/wishes/routing-matrix/qa/` +**Analyst run:** 2026-07-10 ~06:51Z · source: LangWatch (`https://langwatch.khal.ai`) via `langwatch` CLI +**Pins under test:** routing-matrix (PR #2535, merged 2026-07-09 ~21:51Z, released v5.260710.2) — Fable only at gates, Opus ladder for engineering, Haiku scouts. + +--- + +## Verdict — pins are NOT collapsing Fable on execution work yet (inconclusive as a design test) + +On the first partial day under the pins, **Fable's share rose on every measure** — the opposite of the designed collapse — while **Opus per-trace engagement fell from 84% to 48%** and the **Haiku scout lane went nearly silent (10 → 4 traces)**. + +This is **not** evidence the pinning *design* is wrong. It is evidence the pins **were not mechanically in effect**: the pinned role agents were not available as subagent types, so the overnight run defaulted to a Fable orchestrator with hand-applied model overrides. The measured rise reflects an atypically gate/review/orchestration-heavy dogfood night on a Fable default, sampled over only ~7 hours — not a refutation of the ladder. Treat day-1 as **inconclusive for the design, conclusive that the expected collapse has not occurred**, and re-verify once pins are actually delivered as subagent types. + +> **Observed delivery gap (verbatim, for the record):** +> Pinned role agents (engineer-trivial/standard/complex, fixer, reviewer, final-gate, scout) did NOT appear as available subagent types in a fresh Claude Code session on 2026-07-10 03:06 (plugin cache 5.260710.2) — the session applied the routing ladder manually via model overrides. This is the lazy-delivery gap agent-sync closes; agent-sync merged to dev 2026-07-10 06:47Z (PR #2541) — re-verify after the next stable release + genie update ×2. + +--- + +## The numbers + +Comparison is **2026-07-10 00:00→06:51Z (partial, ~6.85h, post-pin but pins not mechanically active)** vs **2026-07-09 full day (pre-pin baseline — pins only merged at 21:51Z that night)**. Shares are ratios, so they remain comparable across a partial vs full day even though absolute volumes do not. + +### Model share moved the wrong way on all three measures + +| Measure | Model | 07-09 (pre-pin) | 07-10 (post-pin, ~7h) | Direction | Design intent | +|---|---|---:|---:|---|---| +| **Billable cost** | Fable | 46% ($562) | **57% ($956)** | ▲ +11pt | ▼ down (gates only) | +| | Opus | 41% ($501) | 34% ($562) | ▼ | ▲ up (engineering) | +| | Haiku | 14% ($167) | 9% ($159) | ▼ | ▲ up (scouts) | +| **Token volume** (span-level, total_tokens) | Fable | 33% | **50%** | ▲ +17pt | ▼ down | +| | Opus | 43% | 36% | ▼ | ▲ up | +| | Haiku | 24% | 14% | ▼ | ▲ up | +| **Trace touch-rate** (traces touching model ÷ distinct) | Fable | 60% | **86%** | ▲ +26pt | ▼ down | +| | Opus | 84% | 48% | ▼ −36pt | ▲ up | +| | Haiku | 20% | 5% | ▼ | ▲ up | + +Totals: 07-09 billable $1,230 / 3.67M tokens / 50 distinct traces (82 model-touches). 07-10 (partial) billable $1,677 / 3.83M tokens / 84 distinct traces (116 model-touches). + +**Read:** every lens agrees. Fable is *up*, Opus and Haiku are *down*. The single clearest signal is the flip in dominant model — on 07-09 Opus was the most-engaged model (84% of traces touched it); on 07-10 Fable overtook it (86%) and Opus engagement collapsed to 48%. That is engineering work **not** being routed to the Opus ladder. + +### Effort distribution — high-effort throughout, scout lane silent + +Effort is a per-trace field (`gen_ai.request.reasoning_effort`), read directly from trace search (the analytics effort-filter is known to be silently broken, so it was not used). + +| Effort | 07-09 (n=50) | 07-10 (n=84) | +|---|---:|---:| +| xhigh | 27 (54%) | 37 (44%) | +| high | 6 (12%) | 27 (32%) | +| max | 17 (34%) | 20 (24%) | +| medium / low | 0 | 0 | + +**Read:** the population is entirely xhigh/high/max on both days — **zero low/medium-effort traces**. Haiku scouts, which would run cheap/low-effort, essentially did not fire (only 4 Haiku-touching traces on 07-10). The one favorable movement is a modest max→high shift (34%→24% max), consistent with *some* deep-reasoning work sliding down a tier, but it is well within noise for an n=84 sample. + +### Top-5 sessions by cost (07-10, by thread) + +| # | Thread (trunc) | Cost (trace-level) | Character | +|---|---|---:|---| +| 1 | `ae132245…` | $428.82 | Team-lead orchestrator, **max** effort, Fable — merge/coordination thread ("watch #2542, then genie update ×2") | +| 2 | `4f125d00…` | $210.95 | Long-running work thread | +| 3 | `8050dd5c…` | $108.48 | Carryover thread (was #1 on 07-09 at $442) | +| 4 | `52675102…` | $60.48 | — | +| 5 | `d0554818…` | $56.78 | *This QA analysis session itself* | + +**Read:** the cost is concentrated in **orchestration/coordination threads**, led by the team-lead planner running at max effort on Fable. That kind of thread is *legitimately* Fable-heavy under the design (planning/gates). The problem is not that these threads used Fable — it is that we cannot observe the counterbalancing Opus-engineering collapse, because engineering wasn't dispatched to pinned Opus agents. The "first dogfooded wish under pins ran at ~11% Fable tokens" data point shows the ladder *does* work where pins are applied; the aggregate day is simply swamped by unpinned orchestration/review threads. + +--- + +## Why day-1 is inconclusive — caveats that bound every number above + +1. **Pins were not mechanically enforced.** The pinned role agents did not exist as subagent types in the fresh session (verbatim finding above); the ladder was applied by hand. So 07-10 does **not** actually exercise the pinning mechanism — it measures an unpinned Fable-default run. +2. **Partial window.** 07-10 is only ~6.85h (00:00–06:51Z) vs a full 07-09. Shares are ratio-based and comparable; absolute volumes and run-rates are not. +3. **Atypical workload.** This window is the genie overnight dogfood — an orchestration/review/council/gate-heavy multi-agent run. That mix is Fable-heavy *by design*. A normal execution-heavy day would look different. +4. **07-09 is a clean-ish pre-pin baseline** (pins merged 21:51Z that night, so <2h of 07-09 was post-pin) — a fair "before". +5. **Trace-count-by-model double-counts.** 116 model-touches across 84 distinct traces → multi-model orchestration traces are counted under every model they touch. **Token share is the cleaner signal**; trace touch-rate is directional only. +6. **Trace-level cost ($1,049) < span-level analytics cost ($1,613) for 07-10** — a Claude Code trace bundles multiple model spans and the trace-level `total_cost` metric under-captures sub-span cost. **Analytics groupBy `metadata.model` is authoritative for model attribution**; trace-search costs are used only for per-trace effort/percentile work. + +**Re-verification trigger:** after the next *stable* release carrying agent-sync + `genie update` ×2 on a dogfood host, confirm (a) the seven pinned role agents appear as subagent types in a fresh session, then (b) re-pull this exact comparison. Expect Fable token share to fall toward gate-only levels (~the ~11% seen on the one properly-pinned wish) and Opus engineering share to rise. + +--- + +## Methodology & working query recipes (auth redacted) + +**Access resolution (the earlier 403).** Direct REST `POST /api/trace/search` and `/api/analytics` returned HTTP 403 earlier tonight under both `Authorization: Bearer` and `X-Auth-Token`. **Root cause: payload/route shape, not the key.** The same OTLP-ingest key works fine through the official `langwatch` CLI, which wraps those endpoints. Recommendation: **drive LangWatch through the CLI, not hand-rolled REST.** + +Auth is supplied to the CLI via two env vars (key read at runtime from the `OTEL_EXPORTER_OTLP_HEADERS` bearer token in `~/.claude/settings.json` — **never printed, never stored in any artifact**): + +``` +LANGWATCH_API_KEY= # the sk-… bearer token from OTEL_EXPORTER_OTLP_HEADERS +LANGWATCH_ENDPOINT=https://langwatch.khal.ai +``` + +Verify auth: `npx -y langwatch status` → returns project resource counts (200 = key valid). + +**Cost / tokens / trace-count by model** (authoritative for model share): +``` +npx -y langwatch analytics query \ + --metric performance.total_cost \ # or performance.cost_billed | performance.total_tokens + --aggregation sum \ # trace-count uses: --metric trace-count --aggregation cardinality + --group-by metadata.model \ + --start-date 2026-07-10T00:00:00Z --end-date 2026-07-10T23:59:59Z \ + --time-scale full --format json +``` +Returns `{ currentPeriod, previousPeriod }` — `previousPeriod` is the auto-computed prior equal-length window (a single-day query yields the prior day free; the two reconcile across queries). + +Valid metric enum (discovered via a bad-metric error): `performance.total_cost`, `.cost_billed`, `.cost_non_billed`, `.prompt_tokens`, `.completion_tokens`, `.cache_read_tokens`, `.cache_write_tokens`, `.reasoning_tokens`, `.total_processed_tokens`, `.total_tokens`, `.tokens_per_second`; plus `metadata.trace_id|thread_id|user_id|span_type`. + +**Top sessions by cost:** same as above with `--group-by metadata.thread_id`. + +**Effort distribution + per-trace percentiles** (bypass the broken analytics effort-filter): +``` +npx -y langwatch trace search \ + --start-date 2026-07-10T00:00:00Z --end-date 2026-07-10T23:59:59Z \ + --limit 2000 --format json +``` +Each trace carries `metadata.gen_ai.request.reasoning_effort` (per-trace) and `metrics.total_cost` — histogram + p50/p90 computed client-side. `pagination.totalHits` gives the true distinct-trace count. + +**Traps confirmed tonight:** +- `analytics query` exposes **no `--filter` flag** in the CLI — effort/metadata filtering is not available there. Use trace search for anything effort-scoped. (The underlying analytics effort-filter is also reported silently broken.) +- **Do not** combine `--group-by metadata.model` with a cache-read metric — known ClickHouse bug (avoided; not attempted). +- **Per-trace model is not available from trace search** (spans return empty; trace metadata has no model field). Per-model cost *distributions* (p50/p90) are therefore not derivable via the CLI — only per-model means (analytics cost ÷ trace-count) and per-effort percentiles. diff --git a/.genie/wishes/skills-fable5-revamp/WISH.md b/.genie/wishes/skills-fable5-revamp/WISH.md index e254a9874..270496e59 100644 --- a/.genie/wishes/skills-fable5-revamp/WISH.md +++ b/.genie/wishes/skills-fable5-revamp/WISH.md @@ -2,7 +2,7 @@ | Field | Value | |-------|-------| -| **Status** | DRAFT — plan review SHIP on full scope G1–G8 (2026-07-04, 2 review rounds); `/work` Wave 1 committed (G1–G6, G8), G7 verification complete (`verification.md`) — awaiting execution review | +| **Status** | EXECUTED — execution review **SHIP** (2026-07-10, HIGH 0 / MEDIUM 0 / 3 LOW observations; genie side independently re-verified, omni G5/G6 attested via `verification.md`) — [reports/execution-review-20260710.md](reports/execution-review-20260710.md). Merged PR #2518 (merge `1308e4c6`); plan review SHIP on full scope G1–G8 (2026-07-04, 2 rounds); G7 verification complete (`verification.md`). LOW follow-ups: omni-side confirm of G5/G6 numbers, `verification.md` diff-count one commit stale (cosmetic), and a "superseded by later wishes" note for `learn`/`council` (both since removed by other wishes) | | **Slug** | `skills-fable5-revamp` | | **Date** | 2026-07-04 | | **Author** | Felipe (planned with Fable 5) | diff --git a/.genie/wishes/skills-fable5-revamp/reports/execution-review-20260710.md b/.genie/wishes/skills-fable5-revamp/reports/execution-review-20260710.md new file mode 100644 index 000000000..ac6783289 --- /dev/null +++ b/.genie/wishes/skills-fable5-revamp/reports/execution-review-20260710.md @@ -0,0 +1,96 @@ +# Execution Review — `skills-fable5-revamp` + +## Verdict: **SHIP** + +The wish delivered all ten Success Criteria at merge (PR #2518, merge commit `1308e4c6`, fable5 tip `a4f089a5`), and its acceptance intent survives in the current `origin/dev` (`b1f07913`, v5.260710.5). Every difference between what the wish predicted and what `origin/dev` shows today is attributable to **later merged wishes** (council-workflow, token-efficiency, routing-pin, plugin-resource-shipping, agent-sync), not to a gap in this wish. I independently re-ran 8 of `verification.md`'s checks against the delivered commit and current dev; all corroborate. No HIGH or MEDIUM gaps on the genie surface. The only limitation is environmental: the omni half (G5/G6) lives in the separate `automagik-dev/omni` repo and cannot be re-run from this checkout — those two groups rest on `verification.md`'s attestation. + +**Gap count:** HIGH 0 · MEDIUM 0 · LOW 3 (all observations, none SHIP-blocking). + +--- + +## What I verified independently (not taken on faith) + +Reference points: **delivered** = `a4f089a5` (2nd parent of merge #2518, includes `verification.md` + the lint fix); **current** = `origin/dev` @ `b1f07913`. Checks run with `git grep` / `git show` (bare `grep` is unreliable here) from script files in the scratchpad. + +| # | Check (SC) | My result | verification.md claim | Match | +|---|-----------|-----------|-----------------------|-------| +| 1 | Dead-namespace grep on `skills/**` (SC4/SC10) | **0 hits** at both `a4f089a5` and `origin/dev` | 0 (was 118) | ✅ | +| 2 | Reasoning-extraction grep on `skills/**` (SC6) | **0 hits** at both refs | 0 | ✅ | +| 3 | 17-skill genie total, delivered (SC3) | **1,385 lines** (summed per-file) | 1,385 (−66.9%) | ✅ exact | +| 4 | Budgets — every `SKILL.md` ≤ 200 (SC2) | Max delivered = 125 (brainstorm); max current = 148 (review) | all ≤ 200 | ✅ | +| 5 | Frontmatter byte-0 + `name`=dir, 17 skills (SC1) | **17/17 OK** | 17/17 | ✅ | +| 6 | Diff shape vs merge-base `63015670` (SC8) | **13 A / 28 M, 0 D, 0 R**, all in-scope | 12 A / 27 M, 0 D/R | ✅ (see LOW-2) | +| 7 | G1 `refine/prompts/optimizer.md` present | **721 lines**, both refs | present | ✅ | +| 8 | G8 shared manifest / marker-gating / backup / uninstall consumption | read module; conforms (below) | PASS | ✅ | + +The SC8 merge-base I computed (`63015670`) is the exact commit `verification.md` cites — the diff-shape check is anchored to the same baseline. + +--- + +## Per-group findings + +| Group | Scope | Verdict | Evidence / notes | +|-------|-------|---------|------------------| +| **G1** genie extraction-heavy (`refine`, `genie-hacks`) | delivered | **SHIP** | `refine` 803→48, `genie-hacks` 626→47; `refine/prompts/optimizer.md` = 721 lines and the SKILL Reads it at dispatch; dead-ref grep clean. Catalog moved to `genie-hacks/references/{catalog,contributing}.md` (in SC8 A-list). | +| **G2** lifecycle core (`brainstorm`,`wish`,`work`,`review`,`fix`,`trace`) | delivered | **SHIP** | All six ≤ 200 at delivery; all 8 frozen-contract families present (template `cp`, `wishes:lint` gate, `genie task` linkage, DRAFT/SHIP/FIX-FIRST/BLOCKED, reviewer≠engineer, ≤2 fix loops, orchestrator-only `task done`, session-close outcome words). Later growth (work 103→123, fix 75→99, review 114→148, brainstorm 125→133, wish 71→73) is from routing-pin `1430def4`, council-workflow lens-panels `2af7ebd9`, template-shipping `ecbb67fc`, agent-sync `ff497ae4` — **not this wish**; all still ≤ 200. | +| **G3** routing & onboarding (`genie`,`wizard`,`learn`,`docs`,`omni`) | delivered | **SHIP** | All five delivered and lint-clean. `learn` (61 lines) shipped correctly, then **removed later** by the token-efficiency wish (`3d40966c`) — absence in current dev is attributable drift, not a G3 gap. Router route table preserved. | +| **G4** PM & multi-agent (`pm`,`dream`,`council`,`report`) | delivered | **SHIP** | All four delivered (pm 108, dream 103, council 107, report 101); grounded-progress clause present. `council` shipped correctly, then **retired later** by council-workflow G3 (`22a7ed50`, "/council is the native workflow now") — attributable drift, not a G4 gap. | +| **G5** omni skill tier (`omni`,`omni-agent`,`omni-setup`,`omni-ops`) | **not re-runnable here** | SHIP *(attested)* | Lives in `automagik-dev/omni`, absent from this genie checkout (`git ls-tree origin/dev plugins/` → only `plugins/genie`, `plugins/hermes-genie`). `verification.md` records `omni-ops` 484→41 and SC5 `G5-OK`. See LOW-1. | +| **G6** omni commands/agents/rules (15 files) | **not re-runnable here** | SHIP *(attested)* | Same repo boundary. `verification.md` SC5 `G6-OK`; `rules/omni-agent.md` no-frontmatter documented as a pre-existing baseline format, not a regression. See LOW-1. | +| **G7** cross-repo verification | delivered | **SHIP** | `verification.md` present, thorough, 10/10 SC PASS. I re-derived its genie-side numbers exactly (1,385 total; 0 dead refs; 0 reasoning-extraction; 0 D/R). Its two in-scope fixes (conventions `install` add; Status annotation) are consistent with the shipped tree. | +| **G8** v4 legacy audit & cleanup | delivered + current | **SHIP** | `legacy-v4.ts` present both refs. Conservative by construction; details below. Foundation was **reused** by `doctor.ts` in current dev (`--fix` calls `cleanupV4`) — intent expanded, not eroded. | + +### G8 detail (the one destructive surface — reviewed closely) + +`src/genie-commands/legacy-v4.ts` (read at `a4f089a5`) satisfies every G8 acceptance criterion: + +- **One shared manifest** — `V4_LEGACY_MANIFEST` is the single source of truth. `uninstall.ts:16` imports `orchestrationRulesPath` from `./legacy-v4.js` and derives `ORCHESTRATION_RULES_PATH` from it (`uninstall.ts:19`); **no literal path restated** (grep for `genie-orchestration.md` in uninstall.ts → none). AC4 met: zero duplicated path literals. +- **Marker-gating** — rules file removed only when content contains `genie spawn` or `genie team create`; unreadable/unmarked → `user-modified` → kept with warning (`cleanupRulesFile`). +- **Cache scoping** — only `4.*`-prefixed dirs carrying `.orphaned_at` are removed; `isDirectory()` excludes symlinks ("never follow a link out of the cache"). +- **Backup-first** — `backupFile` before `unlinkSync`; cache dirs back up a manifest listing (not the re-downloadable payload); log to `/logs/v4-cleanup.log`. +- **Idempotent** — clean machine → `noOp: true`, nothing printed/written. +- **install.sh** is flag-only: forwards `--skip-v4-cleanup`, hands off via a plain (non-exec) `genie install` call; the sole "legacy path" occurrence in `install.sh` is a **comment** (`install.sh:366`) pointing at the TS module, not bash logic. `bash -n install.sh` clean (SC9). +- **Adjudicated skills-lint expansion** (2nd OUT-scope expansion) confirmed: `scripts/skills-lint.ts` warns to stderr and skips omni checks when the omni CLI is absent, strict mode preserved behind `SKILLS_LINT_REQUIRE_OMNI=1` (exit 2). This is the +1 `M` explaining my 28-vs-27 count. + +--- + +## Success-criteria checklist (against merged state) + +| SC | Criterion | Status | Evidence | +|----|-----------|--------|----------| +| SC1 | 36 surfaces conform (FM byte-0, name=dir, trigger desc) | ✅ genie 17/17 re-verified; omni attested | script `verify-final.sh` ALL-OK; omni per verification.md (1 documented rules-file FM exception) | +| SC2 | Budgets (SKILL ≤200, cmd/agent ≤40, rule ≤30) | ✅ | delivered max 125; current dev max 148; all ≤ 200 | +| SC3 | Total always-loaded ≤ 3,300 (≥40% cut) | ✅ | genie 1,385 re-derived exactly; combined 2,016 (−63.4%) per verification.md | +| SC4 | `skills:lint` + `wishes:lint` exit 0 | ✅ | dead-ref grep 0 (I ran it); both scripts wired into `check`/`check:fast` (`package.json:24-25,29-30`) | +| SC5 | Per-file ceilings not treated as targets | ✅ | most files near ~120; binding ≤3,300 met with headroom | +| SC6 | Omni structural check exits 0 | ✅ attested | verification.md SC5 `G5-OK`/`G6-OK` (omni repo) | +| SC7 | Zero reasoning-extraction language | ✅ | grep 0 hits both refs (I ran it) | +| SC8 | Frozen contracts intact | ✅ | template `cp`, lint gates, `genie task` linkage, status vocab, three-tier omni routing, `allowed-tools` — verified at delivery; template later re-homed to `${CLAUDE_SKILL_DIR}` by plugin-resource-shipping `ecbb67fc` (intent preserved) | +| SC9 | Diff shape M+A only, no D/R | ✅ | 13 A / 28 M / 0 D / 0 R, all in-scope (I ran it) | +| SC10 | v4 rules verdict implemented, one shared manifest | ✅ | `legacy-v4.ts` + `uninstall.ts` consumption; colocated pgserve-free tests | + +*(SC numbering above follows the WISH "Success Criteria" list; verification.md's SC1–SC10 map 1:1.)* + +--- + +## Gaps (ranked) + +**HIGH — none. MEDIUM — none.** + +**LOW-1 — Omni half (G5/G6) is not independently verifiable from this checkout.** The omni plugin lives in `automagik-dev/omni`, which is not present in the genie repo (`plugins/` holds only `genie` and `hermes-genie`). My SHIP for G5/G6 rests entirely on `verification.md`'s pasted `G5-OK`/`G6-OK` and the recorded per-group execution reviews. *Recommendation:* have the reviewer with the omni checkout (or the live-QA pass) re-confirm the omni 1,329→631 numbers and the omni-ops routing-table→references resolution before treating the omni PR as reviewed. This is a review-environment limitation, not evidence of a defect. + +**LOW-2 — `verification.md` diff-shape count is one commit stale.** It records genie "12 A / 27 M"; the final delivered branch (`a4f089a5`) is "13 A / 28 M". The extra `M` is `scripts/skills-lint.ts`, modified by the lint-fix commit `a4f089a5` that landed *after* verification.md was authored at `af81783b`. Cosmetic staleness in the record; the load-bearing invariant (zero D, zero R, all files in-scope) holds. No action required beyond awareness. + +**LOW-3 — Two delivered skills (`learn`, `council`) no longer exist in current dev.** Both shipped correctly under this wish (G3 `learn`, G4 `council`) and were later removed by other wishes (`3d40966c` token-efficiency; `22a7ed50` council-workflow). A reader diffing `verification.md`'s per-file table against today's `origin/dev` will see the mismatch. *Optional:* a one-line "superseded by later wishes" note in the wish record would prevent future confusion. Not a defect in this wish. + +*(QA Criteria in the WISH — fresh-session routing, lifecycle dry-run, `/refine` runtime dispatch, omni verbs, live v4-cleanup on a v4 box — are runtime/live-QA checks outside a static read-only review's scope; they belong to the post-merge QA pass, not this execution review.)* + +--- + +## Bottom line + +The genie side of `skills-fable5-revamp` is a clean, well-evidenced SHIP: the 118→0 dead-CLI re-grounding, the ≥40% line reduction (1,385 genie / 2,016 combined), the byte-0 frontmatter conformance, the zero-deletion diff shape, the extracted `optimizer.md`, and a genuinely conservative G8 cleanup engine all verify independently. `verification.md` is accurate — every genie-side number I re-derived matched, and where it differs from today's tree the cause is a later merged wish, correctly outside this wish's ownership. Nothing here blocks flipping the wish Status to reflect an execution-review SHIP. + +--- + +*Reviewed by subagent (session d0554818 overnight run), 2026-07-10.* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ccdcc1b6..c5738a558 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,6 +74,9 @@ jobs: - name: Wishes lint run: bun run wishes:lint + - name: Fresh-install smoke + run: bun run scripts/fresh-install-smoke.ts + - name: Test run: bun test diff --git a/CLAUDE.md b/CLAUDE.md index 278761d97..af3010f21 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -167,6 +167,8 @@ Biome's `noExcessiveCognitiveComplexity` is set to `maxAllowedComplexity: 25` (w - **Codex OTel relay is real v5, not the deleted receiver** — `src/lib/codex-config.ts` wires Codex's `config.toml` to a fixed OTel relay on `127.0.0.1:14318` (`OTEL_RELAY_PORT`) for state detection. This is live; only the v4 receiver-probing env vars are gone. Do not blanket-purge "OTel" from docs — this relay is load-bearing. - **The Omni runner (`genie omni serve`) is the only optional daemon** — a foreground NATS bridge that drains the global approval queue. Everything else is fork-and-exit; no resident processes. - **`bun run dead-code`** (knip) has pre-existing false positives for biome/commitlint/husky devDeps — not regressions. +- **agent-sync converges every detected coding agent** — `genie update` and `genie install` fan the canonical source `~/.genie/plugins/genie` into every DETECTED agent (Claude Code skills + `~/.claude/workflows/council.js`; Codex `~/.codex/skills/.curated/`; the Hermes `~/.hermes/plugins/genie` symlink) via `src/lib/agent-sync.ts`. There is NO new command or flag — the internal env `GENIE_UPDATE_SYNC_ONLY=1` is the ONLY re-entry contract (the post-swap exec and the SessionStart-hook trigger both set it). Managed skill dirs carry `.genie-sync.json` (`managedBy: genie-agent-sync`); every replacement or removal is backed up first under `~/.genie/state-backups/`, so `genie doctor`/`genie uninstall` only ever touch what genie provably shipped. +- **One stamp root, never CLAUDE_PLUGIN_ROOT-primary** — council.js stamping (both the `genie update` CLI and the hook's CLI-less fallback) resolves its `LENS_ROOT` via `resolveStampInputs`, which PREFERS the stable `~/.genie/plugins/genie` root — that path never changes across versions — and only falls back to `CLAUDE_PLUGIN_ROOT` when the stable template is absent. Do not reintroduce CLAUDE_PLUGIN_ROOT-primary stamping: the marketplace root changes on every plugin update, which is what caused the stale-cache downgrade ping-pong the stable root exists to kill. ## PR Review Rules diff --git a/package.json b/package.json index 8357e6466..c0ee654c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@automagik/genie", - "version": "5.260710.2", + "version": "5.260710.7", "description": "Collaborative terminal toolkit for human + AI workflows. NOTE: npm distribution discontinued 2026-05-09 — install via `curl -fsSL https://raw.githubusercontent.com/automagik-dev/genie/main/install.sh | bash` (cosign + SLSA verified). See https://automagik.dev/genie/release-process", "type": "module", "bin": { diff --git a/plugins/genie/.claude-plugin/plugin.json b/plugins/genie/.claude-plugin/plugin.json index 4bb82f3b2..7dd2757a9 100644 --- a/plugins/genie/.claude-plugin/plugin.json +++ b/plugins/genie/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genie", - "version": "5.260710.2", + "version": "5.260710.7", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.", "author": { "name": "Namastex Labs" diff --git a/plugins/genie/README.md b/plugins/genie/README.md index 405825e9f..07a0e8844 100644 --- a/plugins/genie/README.md +++ b/plugins/genie/README.md @@ -32,13 +32,43 @@ Universal review gate (plan, execution, PR) returning `SHIP`, `FIX-FIRST`, or `B The multi-perspective engine ships as a native dynamic workflow, not a skill: - **What ships**: `workflows/council.js` (the engine template), `references/lenses/` (6 deliberation cards), and the 7 lane skills (`repo-hygiene`, `architecture`, `code-quality`, `qa`, `perf`, `supply-chain`, `dx-docs`) doubling as audit lenses. -- **Distribution**: the SessionStart hook stamps `LENS_ROOT` with the installed plugin path and copies the template to `~/.claude/workflows/council.js` — idempotent, and re-stamped on the first session after a plugin update. +- **Distribution**: `genie update` is the canonical updater — it stamps `LENS_ROOT` with the stable source root (`~/.genie/plugins/genie`) and writes `~/.claude/workflows/council.js`; the SessionStart hook is only a throttled trigger that delegates to it, with a CLI-less fallback stamp on plugin-only machines. See [Agent sync](#agent-sync) below. - **Modes**: - `/council ` — deliberation: 3-4 lenses routed by topic, 2-round Socratic exchange, dissent preserved verbatim. - `/council audit [focus]` — lane audit: assess-only, evidence-backed findings that route to `/wish`, profile updates merged single-writer into `.genie/repo-profile.md`. - **Requirements**: Claude Code ≥ 2.1.154 with dynamic workflows available (paid plans; an org-level `disableWorkflows` setting turns the command off). - **Override**: a project-level `.claude/workflows/council.js` takes precedence over the personal stamped copy. +## Agent sync + +`genie update` is the canonical updater. On every run — even when the binary is already at the latest release — it converges every **detected** coding agent from the single source root `~/.genie/plugins/genie`: + +| Agent | Target | What lands | +|-------|--------|------------| +| Claude Code | `~/.claude/skills/` + `~/.claude/workflows/council.js` | all genie skills + the stamped `/council` workflow | +| Codex | `~/.codex/skills/.curated/` | genie skills as Agent-Skills folders (`.system` is OpenAI's, never touched) | +| Hermes | `~/.hermes/plugins/genie` | symlink into `~/.genie/plugins/hermes-genie` | + +- **Managed and reversible**: every synced skill dir carries a `.genie-sync.json` manifest, so a re-run can tell "unchanged" from "you edited this" from "genie never shipped this name". Any dir genie replaces or removes is backed up first under `~/.genie/state-backups/` — nothing is ever lost, and dirs genie never shipped are left untouched. +- **The SessionStart hook is only a trigger**: when the genie CLI is on PATH it delegates to `genie update` (throttled ~6h via `~/.genie/.last-agent-sync`) and duplicates no sync logic. On a plugin-only machine with no CLI it falls back to stamping `~/.claude/workflows/council.js` directly. +- **The marketplace plugin is optional on CLI machines**: because `genie update` converges skills directly, the `genie@automagik` marketplace plugin is not required where the CLI is installed. `genie doctor` reports its state but never re-enables it. +- **Visibility and removal**: `genie doctor` prints a per-agent freshness line (current vs stale skills, council-stamp state, hermes link), advising `genie update` when anything is stale; `genie uninstall` removes only what genie provably shipped (skill dirs by manifest; council.js by its stamp signature; the hermes link only when it resolves into the genie home). + +## Release lag: pinned versions and update cadence + +On a machine **with the genie CLI installed**, `genie update` is the convergence path described in [Agent sync](#agent-sync) — it refreshes all detected agents directly, so the marketplace pin below matters mainly for plugin-only machines. + +Installed plugin versions **pin to GitHub Releases** — a `/plugin install` snapshots +whatever release is current and stays there until you update. It does **not** track +`dev` or `main`. The update cadence is manual: run `/plugin update` to advance a +machine to the latest published release. + +Because the pin is sticky, a machine can drift well behind the source tree. Observed +example: a machine sat pinned at `5.260703.5` for a week while `dev` moved on — the +plugin kept working, but none of the intervening fixes reached it until someone ran +`/plugin update`. Treat `/plugin update` as a periodic hygiene step, not a one-time +setup action. + ## Directory Structure ```text diff --git a/plugins/genie/package.json b/plugins/genie/package.json index 61227f473..1d73f63a5 100644 --- a/plugins/genie/package.json +++ b/plugins/genie/package.json @@ -1,6 +1,6 @@ { "name": "genie-plugin", - "version": "5.260710.2", + "version": "5.260710.7", "private": true, "description": "Runtime dependencies for genie bundled CLIs", "type": "module", diff --git a/plugins/genie/scripts/council-stamp.cjs b/plugins/genie/scripts/council-stamp.cjs index 1109b1ac9..7d9f9ea7f 100644 --- a/plugins/genie/scripts/council-stamp.cjs +++ b/plugins/genie/scripts/council-stamp.cjs @@ -50,4 +50,31 @@ function stampCouncilWorkflow({ templatePath, pluginRoot, targetDir } = {}) { return { action: 'written', targetPath }; } -module.exports = { stampCouncilWorkflow, PLACEHOLDER, TARGET_NAME }; +/** + * Resolve which plugin root to stamp from. Prefers the STABLE canonical source + * `/plugins/genie` whenever it actually carries the workflow template + * (`workflows/council.js`), because that path never changes across plugin + * versions — using it kills the stale-cache downgrade ping-pong where the + * marketplace `CLAUDE_PLUGIN_ROOT` (which changes on every plugin update) stamps + * an older-then-newer LENS_ROOT. Falls back to `claudePluginRoot` when the + * stable template is absent (plugin-only machines with no genie CLI install). + * + * `exists` is injectable (default fs.existsSync) so the preference logic is + * unit-testable without touching the real filesystem. + * + * @param {{claudePluginRoot: string, genieHome: string, exists?: (p: string) => boolean}} opts + * @returns {{pluginRoot: string, templatePath: string}} + */ +function resolveStampInputs({ claudePluginRoot, genieHome, exists = fs.existsSync } = {}) { + const stableRoot = path.join(genieHome, 'plugins', 'genie'); + const stableTemplate = path.join(stableRoot, 'workflows', TARGET_NAME); + if (exists(stableTemplate)) { + return { pluginRoot: stableRoot, templatePath: stableTemplate }; + } + return { + pluginRoot: claudePluginRoot, + templatePath: path.join(claudePluginRoot, 'workflows', TARGET_NAME), + }; +} + +module.exports = { stampCouncilWorkflow, resolveStampInputs, PLACEHOLDER, TARGET_NAME }; diff --git a/plugins/genie/scripts/smart-install.js b/plugins/genie/scripts/smart-install.js index f40a3fc19..baa983f5d 100644 --- a/plugins/genie/scripts/smart-install.js +++ b/plugins/genie/scripts/smart-install.js @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { execSync, spawnSync } from 'node:child_process'; +import { execFileSync, execSync, spawnSync } from 'node:child_process'; /** * Smart Install Script for genie * @@ -22,8 +22,15 @@ import { join } from 'node:path'; const requireCjs = createRequire(import.meta.url); const ROOT = process.env.CLAUDE_PLUGIN_ROOT || join(homedir(), '.claude', 'plugins', 'genie'); -const GENIE_DIR = join(homedir(), '.genie'); +// GENIE_HOME relocates all global genie state; the CLI honors it, so the hook +// must too or the throttle marker below would never match the CLI's writes. +const GENIE_DIR = process.env.GENIE_HOME || join(homedir(), '.genie'); const MARKER = join(GENIE_DIR, '.install-version'); +// Throttle marker the canonical agent-sync engine refreshes (ISO string). We +// delegate a session-start sync only when it is absent or older than 6h, so +// session starts stay cheap. +const AGENT_SYNC_MARKER = join(GENIE_DIR, '.last-agent-sync'); +const AGENT_SYNC_THROTTLE_MS = 6 * 60 * 60 * 1000; const IS_WINDOWS = process.platform === 'win32'; // Common installation paths (handles fresh installs before PATH reload) @@ -390,19 +397,72 @@ function adviseGenieCliInstall() { console.error(''); } -// Main execution -try { - // Stamp the /council workflow template into ~/.claude/workflows on every session - // start. This runs BEFORE the early-exit guards below (worker fast-path and - // deps-already-present) so a plugin update re-stamps LENS_ROOT even on machines - // that would otherwise skip all install work. The stamp is idempotent — it only - // writes when the stamped output would differ — and is fully sandboxed in - // try/catch so it can never break session start. +/** + * Resolve a genie binary path for agent-sync delegation. Prefers the canonical + * v5 location (~/.genie/bin/genie), then a `genie` on PATH. Returns null when no + * CLI is installed (plugin-only machine) so the caller falls back to the in-hook + * /council stamp. + */ +function findGenieBinary() { + const canonical = join(GENIE_DIR, 'bin', 'genie'); + if (existsSync(canonical)) return canonical; + try { + const result = spawnSync('genie', ['--version'], { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + shell: IS_WINDOWS, + timeout: 5000, + }); + if (result.status === 0) return 'genie'; + } catch { + // not on PATH + } + return null; +} + +/** + * Allow a delegated sync only when the last one is absent or older than 6h. The + * CLI refreshes AGENT_SYNC_MARKER (ISO string) on every sync phase. + */ +function agentSyncThrottleAllows() { try { - const { stampCouncilWorkflow } = requireCjs('./council-stamp.cjs'); + const last = Date.parse(readFileSync(AGENT_SYNC_MARKER, 'utf-8').trim()); + if (Number.isNaN(last)) return true; + return Date.now() - last > AGENT_SYNC_THROTTLE_MS; + } catch { + return true; // no marker / unreadable → allowed + } +} + +/** + * Delegate ALL syncing (skills + /council stamp for every detected agent) to the + * canonical engine via `genie update` with the internal sync-only env. Quiet, + * time-bounded, and fully sandboxed — a failure never breaks session start. + */ +function delegateAgentSync(geniePath) { + try { + execFileSync(geniePath, ['update'], { + env: { ...process.env, GENIE_UPDATE_SYNC_ONLY: '1' }, + stdio: 'ignore', + timeout: 45000, + }); + } catch (e) { + console.error(`Warning: agent sync via genie update failed: ${e.message}`); + } +} + +/** + * CLI-less fallback: stamp the /council workflow so plugin-only machines still + * get it. resolveStampInputs prefers the stable ~/.genie/plugins/genie root, + * falling back to CLAUDE_PLUGIN_ROOT. + */ +function stampCouncilFallback() { + try { + const { stampCouncilWorkflow, resolveStampInputs } = requireCjs('./council-stamp.cjs'); + const { pluginRoot, templatePath } = resolveStampInputs({ claudePluginRoot: ROOT, genieHome: GENIE_DIR }); const stampResult = stampCouncilWorkflow({ - templatePath: join(ROOT, 'workflows', 'council.js'), - pluginRoot: ROOT, + templatePath, + pluginRoot, targetDir: join(homedir(), '.claude', 'workflows'), }); if (stampResult.action === 'written') { @@ -411,12 +471,36 @@ try { } catch (e) { console.error(`Warning: could not stamp /council workflow: ${e.message}`); } +} - // Workers inherit parent's deps — skip all checks to reduce spawn latency (#712) +// Main execution +try { + // Workers inherit parent's deps AND the parent session's already-converged + // agents — skip everything to keep spawn latency flat (#712). The delegation + // below therefore only ever runs in top-level sessions. if (process.env.GENIE_WORKER === '1') { process.exit(0); } + // Converge coding agents on session start. This runs BEFORE the remaining + // early-exit guard (deps-already-present) so a plugin update refreshes skills + // + the /council stamp even on machines that would otherwise skip all install + // work. Prefer the canonical CLI engine: `genie update` with the internal + // sync-only env syncs skills AND stamps /council for every detected agent + // (claude/codex/hermes) from one source root — throttled to 6h so session + // starts stay cheap and no sync logic is duplicated in the hook. Only when NO + // genie CLI is installed do we fall back to an in-hook /council stamp so + // plugin-only machines still get the workflow. Fully sandboxed — nothing here + // can break session start. + const geniePath = findGenieBinary(); + if (geniePath) { + if (agentSyncThrottleAllows()) { + delegateAgentSync(geniePath); + } + } else { + stampCouncilFallback(); + } + // Quick check: if everything is already installed, exit silently if (isBunInstalled() && isTmuxInstalled() && !needsInstall() && !genieCliNeedsInstall()) { process.exit(0); diff --git a/plugins/genie/workflows/council.js b/plugins/genie/workflows/council.js index db5741432..023bec9b6 100644 --- a/plugins/genie/workflows/council.js +++ b/plugins/genie/workflows/council.js @@ -533,19 +533,40 @@ function renderAudit(focus, notConvened, silentRound1, synth, persist) { ].join('\n'); } -if (!args || typeof args !== 'object') { +// The Workflow runtime delivers saved-workflow args as a STRING (even JSON-object +// input arrives stringified — observed live 2026-07-10). Coerce before validating: +// JSON that parses to an object wins; an `audit …` prefix selects audit mode with +// the remainder as focus; anything else is the deliberation topic. +let input = args; +if (typeof input === 'string') { + const raw = input.trim(); + let parsed = null; + try { + parsed = JSON.parse(raw); + } catch { + parsed = null; + } + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + input = parsed; + } else if (/^audit(\s|$)/i.test(raw)) { + input = { mode: 'audit', focus: raw.replace(/^audit\s*/i, '') }; + } else { + input = { topic: raw }; + } +} +if (!input || typeof input !== 'object') { return failure('No input received. Try /council to deliberate, or /council audit [focus] to audit.'); } -const mode = args.mode === 'audit' ? 'audit' : 'deliberation'; -const topic = typeof args.topic === 'string' ? args.topic.trim() : ''; -const focus = typeof args.focus === 'string' ? args.focus.trim() : ''; +const mode = input.mode === 'audit' ? 'audit' : 'deliberation'; +const topic = typeof input.topic === 'string' ? input.topic.trim() : ''; +const focus = typeof input.focus === 'string' ? input.focus.trim() : ''; if (mode === 'deliberation' && !topic) { return failure('No topic to deliberate. Try /council .'); } -const roster = selectRoster(mode, topic, args.members); +const roster = selectRoster(mode, topic, input.members); if (!roster.length) { return failure('No lenses selected. Provide --members, or a topic that routes to a lens.'); } diff --git a/scripts/build.js b/scripts/build.js index 5dc050da2..eb5137d15 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -99,13 +99,10 @@ async function buildPlugin() { console.log(` ${target.name}.cjs (${(stats.size / 1024).toFixed(2)} KB)`); } - // Copy smart-install.js (stays as Node.js, not bundled) - const smartInstallSrc = path.join(rootDir, 'scripts/smart-install.js'); - const smartInstallDest = path.join(scriptsDir, 'smart-install.js'); - if (fs.existsSync(smartInstallSrc)) { - fs.copyFileSync(smartInstallSrc, smartInstallDest); - console.log('\nCopied smart-install.js'); - } + // NOTE: the shipped SessionStart hook under plugins/genie/scripts/ is now the + // single committed source of truth (agent-sync wish, Decision 8). The old + // copy-from-scripts step was removed — it was one `bun run build:plugin` away + // from clobbering the shipped hook's council stamp. // Update plugin.json version const pluginJsonPath = path.join(rootDir, 'plugins/genie/.claude-plugin/plugin.json'); diff --git a/scripts/fresh-install-smoke.test.ts b/scripts/fresh-install-smoke.test.ts new file mode 100644 index 000000000..69dfc19c4 --- /dev/null +++ b/scripts/fresh-install-smoke.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const SMOKE_SCRIPT = join(import.meta.dir, 'fresh-install-smoke.ts'); + +// Internal work dirs the script mkdtemps in runWishScaffoldSmoke. A survivor +// with this prefix (and NOT this test's own '-fixture-' dirs) means the phase-b +// cleanup was skipped. +const WORKDIR_PREFIX = 'genie-fresh-install-'; +function scaffoldWorkDirs(): Set { + return new Set(readdirSync(tmpdir()).filter((n) => n.startsWith(WORKDIR_PREFIX) && !n.includes('-fixture-'))); +} + +function runSmoke(args: string[] = []): { code: number; stdout: string; stderr: string } { + const result = Bun.spawnSync(['bun', SMOKE_SCRIPT, ...args], { + stdout: 'pipe', + stderr: 'pipe', + }); + return { + code: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + }; +} + +describe('fresh-install-smoke', () => { + test('exits 0 against the real repository skills tree', () => { + const result = runSmoke(); + // Surface the failure reason if this ever regresses. + expect(result.stdout + result.stderr).toContain('fresh-install-smoke: OK'); + expect(result.code).toBe(0); + }); + + describe('broken fixture', () => { + let skillsDir: string; + + beforeEach(() => { + skillsDir = mkdtempSync(join(tmpdir(), 'genie-fresh-install-fixture-')); + const skill = join(skillsDir, 'brokenskill'); + mkdirSync(skill, { recursive: true }); + writeFileSync( + join(skill, 'SKILL.md'), + '# Broken skill\n\n```bash\ncp "${CLAUDE_SKILL_DIR}/templates/does-not-exist.md" out.md\n```\n', + ); + }); + + afterEach(() => { + rmSync(skillsDir, { recursive: true, force: true }); + }); + + test('exits non-zero when a SKILL.md references a missing ${CLAUDE_SKILL_DIR} path', () => { + const result = runSmoke(['--skills-dir', skillsDir]); + expect(result.code).not.toBe(0); + expect(result.stderr).toContain('does not resolve to a real file'); + }); + }); + + // Phase-b failures create a scaffold work dir BEFORE the assertion trips, so + // they are the path where the old process.exit() bypassed cleanup. Induce one + // and prove the temp dir is gone regardless. + describe('phase-b failure cleanup', () => { + let skillsDir: string; + + // Wish skill whose SKILL.md references its in-skill template (phase-a + // passes) but whose template omits `## Execution Groups`, so the phase-b + // structural check fails after the work dir already exists. + function writeWishFixture(templateBody: string): void { + const wishDir = join(skillsDir, 'wish'); + mkdirSync(join(wishDir, 'templates'), { recursive: true }); + writeFileSync( + join(wishDir, 'SKILL.md'), + ['# wish', '', '```bash', 'cp "${CLAUDE_SKILL_DIR}/templates/wish-template.md" out.md', '```', ''].join('\n'), + ); + writeFileSync(join(wishDir, 'templates', 'wish-template.md'), templateBody); + } + + const FULL_SECTIONS = [ + '## Summary', + '## Scope', + '### IN', + '### OUT', + '## Success Criteria', + '## Execution Strategy', + ]; + + beforeEach(() => { + skillsDir = mkdtempSync(join(tmpdir(), 'phaseb-fixture-')); + }); + afterEach(() => { + rmSync(skillsDir, { recursive: true, force: true }); + }); + + test('a phase-b failure exits non-zero and leaves no scaffold temp dir behind', () => { + writeWishFixture(`${FULL_SECTIONS.join('\n')}\n`); // no '## Execution Groups' + const before = scaffoldWorkDirs(); + + const result = runSmoke(['--skills-dir', skillsDir]); + + expect(result.code).not.toBe(0); + expect(result.stderr).toContain('fresh-install-smoke: FAIL'); + expect(result.stderr).toContain('## Execution Groups'); + + const leaked = [...scaffoldWorkDirs()].filter((n) => !before.has(n)); + expect(leaked).toEqual([]); + }); + + test('a clean phase-b run exits 0 and leaves no scaffold temp dir behind', () => { + writeWishFixture(`${[...FULL_SECTIONS, '## Execution Groups'].join('\n')}\n`); + const before = scaffoldWorkDirs(); + + const result = runSmoke(['--skills-dir', skillsDir]); + + expect(result.code).toBe(0); + const leaked = [...scaffoldWorkDirs()].filter((n) => !before.has(n)); + expect(leaked).toEqual([]); + }); + }); +}); diff --git a/scripts/fresh-install-smoke.ts b/scripts/fresh-install-smoke.ts new file mode 100644 index 000000000..2518c0e99 --- /dev/null +++ b/scripts/fresh-install-smoke.ts @@ -0,0 +1,167 @@ +#!/usr/bin/env bun +/** + * fresh-install-smoke: a broken fresh install must never reach a release + * unnoticed. Two guarantees, both exercised against the shipped skill tree: + * + * (a) every `${CLAUDE_SKILL_DIR}/` reference inside a SKILL.md + * resolves to a real file INSIDE that skill's own directory — a plugin + * install materializes each skill under its own CLAUDE_SKILL_DIR, so a + * reference that escapes the dir or points at a missing file is a + * ship-blocking breakage the moment the plugin is installed. + * + * (b) the /wish scaffold step (template copy via the resolved + * CLAUDE_SKILL_DIR) succeeds in a fresh git repo with NO genie CLI on + * PATH, and the copied skeleton carries the structural checklist the + * parser and linter expect. + * + * Exits non-zero with a clear message on any violation. Temp dirs are removed + * even when an assertion fails. + * + * Usage: bun run scripts/fresh-install-smoke.ts [--skills-dir ] + */ + +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve, sep } from 'node:path'; + +const REPO_ROOT = new URL('..', import.meta.url).pathname.replace(/\/$/, ''); + +// A checked smoke violation. Thrown (not process.exit'd) so any `finally` +// cleanup on the call stack — notably the tmp-dir removal in +// runWishScaffoldSmoke — runs before we translate it to the exit-1 contract in +// main(). process.exit() would skip those finalizers and orphan the temp dir. +class SmokeFailure extends Error {} + +function fail(message: string): never { + throw new SmokeFailure(message); +} + +function parseArgs(argv: string[]): { skillsDir: string } { + let skillsDir = join(REPO_ROOT, 'skills'); + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--skills-dir') { + const next = argv[i + 1]; + if (!next) fail('--skills-dir requires a path argument'); + skillsDir = resolve(next); + i++; + } + } + return { skillsDir }; +} + +function listSkillDirs(skillsDir: string): string[] { + return readdirSync(skillsDir) + .map((name) => join(skillsDir, name)) + .filter((p) => statSync(p).isDirectory() && existsSync(join(p, 'SKILL.md'))); +} + +/** + * (a) Resolve every `${CLAUDE_SKILL_DIR}/` reference against the skill + * that owns the SKILL.md. Returns the number of references verified. + */ +function checkSkillDirReferences(skillsDir: string): number { + let refs = 0; + for (const skillDir of listSkillDirs(skillsDir)) { + const text = readFileSync(join(skillDir, 'SKILL.md'), 'utf8'); + // Capture the path chars after the token, stopping at whitespace, quote, + // backtick, or closing paren — the delimiters that surround it in prose or + // a shell fence. + const re = /\$\{CLAUDE_SKILL_DIR\}\/([^\s"'`)\\]+)/g; + let m: RegExpExecArray | null = re.exec(text); + while (m !== null) { + refs++; + const relPath = m[1].replace(/[.,;:)]+$/, ''); + const resolved = resolve(skillDir, relPath); + const within = resolved === skillDir || resolved.startsWith(skillDir + sep); + const label = `${skillDir}/SKILL.md: \${CLAUDE_SKILL_DIR}/${relPath}`; + if (!within) fail(`${label} escapes the skill directory`); + if (!existsSync(resolved) || !statSync(resolved).isFile()) { + fail(`${label} does not resolve to a real file`); + } + m = re.exec(text); + } + } + return refs; +} + +/** + * (b) Materialize the skill tree the way a plugin install lays it down, then + * run the /wish scaffold step in a fresh git repo with a genie-free PATH. + */ +function runWishScaffoldSmoke(skillsDir: string): void { + if (!existsSync(join(skillsDir, 'wish', 'SKILL.md'))) { + fail(`no wish skill under ${skillsDir} — cannot exercise the scaffold step`); + } + + const workRoot = mkdtempSync(join(tmpdir(), 'genie-fresh-install-')); + try { + // Copy the skills the way an installed plugin materializes them. + const pluginSkills = join(workRoot, 'plugin', 'skills'); + mkdirSync(dirname(pluginSkills), { recursive: true }); + cpSync(skillsDir, pluginSkills, { recursive: true }); + + // A fresh consumer repo with no genie state. + const repo = join(workRoot, 'consumer-repo'); + mkdirSync(repo, { recursive: true }); + const git = Bun.spawnSync(['git', 'init', '-q'], { cwd: repo, stdout: 'pipe', stderr: 'pipe' }); + if (git.exitCode !== 0) fail(`git init failed: ${git.stderr.toString().trim()}`); + + // Execute the /wish scaffold verbatim, under a PATH that cannot see genie. + const installedWishDir = join(pluginSkills, 'wish'); + const slug = 'smoke-wish'; + const script = [ + 'set -e', + 'if command -v genie >/dev/null 2>&1; then echo "genie unexpectedly on PATH" >&2; exit 3; fi', + `mkdir -p ".genie/wishes/${slug}"`, + `cp "\${CLAUDE_SKILL_DIR}/templates/wish-template.md" ".genie/wishes/${slug}/WISH.md"`, + ].join('\n'); + const scaffold = Bun.spawnSync(['bash', '-c', script], { + cwd: repo, + env: { PATH: '/usr/bin:/bin:/usr/sbin:/sbin', CLAUDE_SKILL_DIR: installedWishDir }, + stdout: 'pipe', + stderr: 'pipe', + }); + if (scaffold.exitCode !== 0) { + fail(`wish scaffold step failed (exit ${scaffold.exitCode}): ${scaffold.stderr.toString().trim()}`); + } + + // Structural checklist presence in the copied skeleton. + const wishPath = join(repo, '.genie', 'wishes', slug, 'WISH.md'); + if (!existsSync(wishPath)) fail('scaffold produced no WISH.md'); + const wish = readFileSync(wishPath, 'utf8'); + const required = [ + '## Summary', + '## Scope', + '### IN', + '### OUT', + '## Success Criteria', + '## Execution Strategy', + '## Execution Groups', + ]; + const missing = required.filter((section) => !wish.includes(section)); + if (missing.length > 0) { + fail(`scaffolded WISH.md missing structural section(s): ${missing.join(', ')}`); + } + } finally { + rmSync(workRoot, { recursive: true, force: true }); + } +} + +function main(): void { + try { + const { skillsDir } = parseArgs(process.argv.slice(2)); + if (!existsSync(skillsDir)) fail(`skills dir not found: ${skillsDir}`); + const refs = checkSkillDirReferences(skillsDir); + runWishScaffoldSmoke(skillsDir); + const summary = `${refs} \${CLAUDE_SKILL_DIR} reference(s) resolved, wish scaffold works with no genie on PATH`; + console.log(`fresh-install-smoke: OK (${summary})`); + } catch (err) { + // Checked violations become the exit-1 contract CI depends on; any other + // error propagates untouched (non-zero exit + stack trace). + if (!(err instanceof SmokeFailure)) throw err; + console.error(`fresh-install-smoke: FAIL — ${err.message}`); + process.exit(1); + } +} + +if (import.meta.main) main(); diff --git a/scripts/skills-lint.test.ts b/scripts/skills-lint.test.ts new file mode 100644 index 000000000..8d91e4210 --- /dev/null +++ b/scripts/skills-lint.test.ts @@ -0,0 +1,189 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + checkResourceLine, + collectResourceViolations, + extractInlineCodeSpans, + isResourceAllowlisted, +} from './skills-lint.ts'; + +const SCRIPT = join(import.meta.dir, 'skills-lint.ts'); + +describe('checkResourceLine — imperative discriminators', () => { + test('flags an imperative repo-root template copy', () => { + expect(checkResourceLine('cp templates/wish-template.md dest.md').map((v) => v.rule)).toEqual(['cp-repo-template']); + expect(checkResourceLine('cp -r templates/foo bar').map((v) => v.rule)).toEqual(['cp-repo-template']); + expect(checkResourceLine('cp ./templates/foo.md dest').map((v) => v.rule)).toEqual(['cp-repo-template']); + }); + + test('passes a ${CLAUDE_SKILL_DIR}-addressed template copy', () => { + expect(checkResourceLine('cp "${CLAUDE_SKILL_DIR}/templates/wish-template.md" dest.md')).toEqual([]); + expect(checkResourceLine('cp "${CLAUDE_PLUGIN_ROOT}/templates/foo.md" dest.md')).toEqual([]); + }); + + test('flags an unguarded repo-only lint invocation', () => { + expect(checkResourceLine('bun run wishes:lint').map((v) => v.rule)).toEqual(['unguarded-repo-lint']); + expect(checkResourceLine('bun run skills:lint').map((v) => v.rule)).toEqual(['unguarded-repo-lint']); + }); + + test('passes a SAME-LINE package.json-guarded invocation', () => { + const guarded = `grep -q '"wishes:lint"' package.json 2>/dev/null && bun run wishes:lint`; + expect(checkResourceLine(guarded)).toEqual([]); + }); + + test('passes other short-circuit package.json probe shapes', () => { + expect(checkResourceLine('test -f package.json && bun run skills:lint')).toEqual([]); + expect(checkResourceLine('[ -f package.json ] && bun run wishes:lint')).toEqual([]); + }); + + test('flags a line that only mentions package.json incidentally', () => { + // Trailing comment — the probe does not gate the command. + expect(checkResourceLine('bun run skills:lint # regenerates package.json entries').map((v) => v.rule)).toEqual([ + 'unguarded-repo-lint', + ]); + // package.json referenced AFTER the command — no short-circuit guard. + expect(checkResourceLine('bun run wishes:lint && cat package.json').map((v) => v.rule)).toEqual([ + 'unguarded-repo-lint', + ]); + // Mention in a `;`-joined prose segment is not a short-circuit guard. + expect(checkResourceLine('echo "see package.json"; bun run skills:lint').map((v) => v.rule)).toEqual([ + 'unguarded-repo-lint', + ]); + }); + + test('flags an imperative repo-script invocation but not a descriptive mention', () => { + expect(checkResourceLine('bun run scripts/skills-lint.ts').map((v) => v.rule)).toEqual(['repo-script-invocation']); + expect(checkResourceLine('node scripts/foo.ts').map((v) => v.rule)).toEqual(['repo-script-invocation']); + // Descriptive/paraphrase mention with no run verb must NOT trip. + expect(checkResourceLine('The linter (scripts/wishes-lint.ts) accepts the stub text.')).toEqual([]); + }); +}); + +describe('collectResourceViolations — fence + inline surfaces', () => { + test('scans inline-code spans, not just fences', () => { + const md = 'Run the linter — `bun run wishes:lint` after editing.'; + expect(collectResourceViolations(md).map((v) => v.rule)).toEqual(['unguarded-repo-lint']); + }); + + test('same-line guard inside one inline span passes', () => { + const md = 'Handoff: `grep -q \'"wishes:lint"\' package.json 2>/dev/null && bun run wishes:lint`.'; + expect(collectResourceViolations(md)).toEqual([]); + }); + + test('SPLIT-LINE guard (probe on line N, command on line N+1) still FAILS', () => { + const md = ['```bash', `grep -q '"wishes:lint"' package.json 2>/dev/null`, 'bun run wishes:lint', '```'].join('\n'); + expect(collectResourceViolations(md).map((v) => v.rule)).toEqual(['unguarded-repo-lint']); + }); + + test('descriptive prose path mention outside code context is clean', () => { + const md = 'The template lives under templates/ and scripts/foo.ts documents it.'; + expect(collectResourceViolations(md)).toEqual([]); + }); +}); + +describe('extractInlineCodeSpans', () => { + test('captures single-line backtick spans, skips fences-only content', () => { + expect(extractInlineCodeSpans('a `one` b `two` c')).toEqual(['one', 'two']); + expect(extractInlineCodeSpans('no code here')).toEqual([]); + }); +}); + +describe('end-to-end: skills-lint against fixture skills trees', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'skills-lint-')); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + function writeSkill(name: string, body: string): void { + const skillDir = join(dir, name); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, 'SKILL.md'), body); + } + + function runLint(): { code: number; stdout: string; stderr: string } { + try { + const stdout = execFileSync('bun', [SCRIPT], { + env: { ...process.env, SKILLS_LINT_DIR: dir }, + encoding: 'utf8', + }); + return { code: 0, stdout, stderr: '' }; + } catch (err) { + const e = err as { status?: number; stdout?: Buffer | string; stderr?: Buffer | string }; + return { + code: e.status ?? 1, + stdout: e.stdout?.toString() ?? '', + stderr: e.stderr?.toString() ?? '', + }; + } + } + + test('an offending skill (cp templates/...) exits non-zero', () => { + writeSkill('bad', ['# bad', '', '```bash', 'cp templates/wish-template.md dest.md', '```', ''].join('\n')); + const { code, stderr } = runLint(); + expect(code).not.toBe(0); + expect(stderr).toContain('cp-repo-template'); + }); + + test('a ${CLAUDE_SKILL_DIR} skill passes', () => { + writeSkill( + 'good', + ['# good', '', '```bash', 'cp "${CLAUDE_SKILL_DIR}/templates/wish-template.md" dest.md', '```', ''].join('\n'), + ); + expect(runLint().code).toBe(0); + }); + + test('allowlisted genie-hacks content passes even with repo-root recipes', () => { + writeSkill( + 'genie-hacks', + ['# hacks', '', '```bash', 'cp templates/foo.md dest.md', 'bun run wishes:lint', '```', ''].join('\n'), + ); + expect(runLint().code).toBe(0); + }); + + test('a same-line-guarded invocation passes while a split-line guard fails', () => { + writeSkill( + 'guarded', + [ + '# guarded', + '', + 'Handoff: `grep -q \'"wishes:lint"\' package.json 2>/dev/null && bun run wishes:lint`.', + '', + ].join('\n'), + ); + expect(runLint().code).toBe(0); + + rmSync(join(dir, 'guarded'), { recursive: true, force: true }); + writeSkill( + 'split', + [ + '# split', + '', + '```bash', + `grep -q '"wishes:lint"' package.json 2>/dev/null`, + 'bun run wishes:lint', + '```', + '', + ].join('\n'), + ); + const { code, stderr } = runLint(); + expect(code).not.toBe(0); + expect(stderr).toContain('unguarded-repo-lint'); + }); +}); + +describe('isResourceAllowlisted', () => { + test('genie-hacks is allowlisted; README.md is not', () => { + const skillsDir = '/repo/skills'; + expect(isResourceAllowlisted('/repo/skills/genie-hacks/SKILL.md', skillsDir)).toBe(true); + expect(isResourceAllowlisted('/repo/skills/genie-hacks/references/catalog.md', skillsDir)).toBe(true); + expect(isResourceAllowlisted('/repo/skills/README.md', skillsDir)).toBe(false); + expect(isResourceAllowlisted('/repo/skills/wish/SKILL.md', skillsDir)).toBe(false); + }); +}); diff --git a/scripts/skills-lint.ts b/scripts/skills-lint.ts index ef46e2664..c4dda3726 100644 --- a/scripts/skills-lint.ts +++ b/scripts/skills-lint.ts @@ -10,10 +10,25 @@ import { execSync } from 'node:child_process'; import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; -import { join, relative } from 'node:path'; +import { join, relative, sep } from 'node:path'; const ROOT = new URL('..', import.meta.url).pathname.replace(/\/$/, ''); -const SKILLS_DIR = join(ROOT, 'skills'); +// SKILLS_LINT_DIR lets tests point the scanner at a fixture tree; defaults to +// the repo's own skills/ directory. +const SKILLS_DIR = process.env.SKILLS_LINT_DIR ?? join(ROOT, 'skills'); + +// Resource-shipping allowlist: catalog/recipe content is allowed to show +// repo-root command recipes verbatim (they are illustrative, not runtime +// instructions). Matched by the first path segment under the scanned skills +// dir. skills/README.md is intentionally NOT allowlisted — real skill prose +// must ship its own resources via ${CLAUDE_SKILL_DIR}/${CLAUDE_PLUGIN_ROOT}. +const RESOURCE_ALLOWLIST_SEGMENTS = new Set(['genie-hacks']); + +export function isResourceAllowlisted(file: string, skillsDir: string = SKILLS_DIR): boolean { + const rel = relative(skillsDir, file); + const first = rel.split(sep)[0]; + return RESOURCE_ALLOWLIST_SEGMENTS.has(first); +} function collectSubcommands(helpText: string): Set { const cmds = new Set(); @@ -116,9 +131,92 @@ function extractInvocations(fence: string, tool: 'genie' | 'omni'): string[] { return hits; } +/** Extract inline-code spans (single-line backtick spans) from markdown. */ +export function extractInlineCodeSpans(text: string): string[] { + const spans: string[] = []; + const re = /`([^`\n]+)`/g; + let m: RegExpExecArray | null = re.exec(text); + while (m !== null) { + spans.push(m[1]); + m = re.exec(text); + } + return spans; +} + +export type ResourceRule = 'cp-repo-template' | 'unguarded-repo-lint' | 'repo-script-invocation'; + +export interface ResourceViolation { + rule: ResourceRule; + snippet: string; +} + +/** + * Inspect a single line of command context (a fence line or an inline-code + * span) for imperative resource-shipping violations. Skill-shipped files MUST + * be addressed via ${CLAUDE_SKILL_DIR}/${CLAUDE_PLUGIN_ROOT}; repo-only + * commands MUST be guarded by a same-line package.json existence probe. Bare + * descriptive path mentions in prose never reach here (only code context does) + * and never match — every rule keys on an imperative verb. + */ +export function checkResourceLine(line: string): ResourceViolation[] { + const violations: ResourceViolation[] = []; + const snippet = line.trim(); + + // (a) Imperative repo-root template copy: `cp templates/...`. The shipped + // form is `cp "${CLAUDE_SKILL_DIR}/templates/..."`, whose source token is + // NOT a bare `templates/`, so it is not matched. + if (/\bcp\b(?:\s+-\S+)*\s+["']?(?:\.\/)?templates\//.test(line)) { + violations.push({ rule: 'cp-repo-template', snippet }); + } + + // (b) Repo-only lint invocation without the SAME-LINE package.json guard. + // The guard must be a package.json probe that short-circuits (`&&`) INTO the + // command, e.g. `grep -q '"wishes:lint"' package.json 2>/dev/null && bun run + // wishes:lint` or `test -f package.json && bun run skills:lint`. A bare + // mention of package.json elsewhere on the line — a trailing comment, an echo + // arg, or a reference AFTER the command — does not gate the run, so it must + // NOT exempt it. A split-line guard (probe on the previous line) also fails: + // the probe must sit on the same line, ahead of the command it protects. + const lintMatch = /\bbun run (?:wishes|skills):lint\b/.exec(line); + if (lintMatch) { + const guard = line.slice(0, lintMatch.index); + const guarded = /\bpackage\.json\b[^&|;]*&&/.test(guard); + if (!guarded) violations.push({ rule: 'unguarded-repo-lint', snippet }); + } + + // (c) Imperative execution of a repo-root script — scripts/*.ts is repo-only. + // Runtime instructions must address skill-shipped scripts via + // ${CLAUDE_SKILL_DIR}. A descriptive `scripts/foo.ts` mention (no run verb) + // does not match. + if (/(?:\bbun run |\bbun |\bnode |\.\/|\bsh |\bbash )scripts\/[A-Za-z0-9_./-]+\.ts\b/.test(line)) { + violations.push({ rule: 'repo-script-invocation', snippet }); + } + + return violations; +} + +/** + * Collect resource-shipping violations across a skill's command surface: + * every bash/sh fence line AND every inline-code span. Prose outside code + * context is never scanned, so descriptive path mentions cannot trip the rule. + */ +export function collectResourceViolations(text: string): ResourceViolation[] { + const lines: string[] = []; + for (const fence of extractBashFences(text)) { + lines.push(...fence.split('\n')); + } + lines.push(...extractInlineCodeSpans(text)); + const violations: ResourceViolation[] = []; + for (const line of lines) { + violations.push(...checkResourceLine(line)); + } + return violations; +} + interface Report { skill: string; missingCommands: Array<{ tool: string; command: string }>; + resourceViolations: ResourceViolation[]; } function main() { @@ -136,7 +234,7 @@ function main() { // is only probed when some scanned skill actually references it; when the // probe fails, getOmniCommands() returns null and omni checks are skipped // (loudly) instead of failing the gate — see its contract comment. - const scanned: Array<{ file: string; genie: string[]; omni: string[] }> = []; + const scanned: Array<{ file: string; genie: string[]; omni: string[]; resource: ResourceViolation[] }> = []; for (const file of files) { const text = readFileSync(file, 'utf8'); if (text.includes('')) continue; @@ -146,14 +244,17 @@ function main() { genie.push(...extractInvocations(fence, 'genie')); omni.push(...extractInvocations(fence, 'omni')); } - scanned.push({ file, genie, omni }); + // Catalog/recipe content (genie-hacks) is allowed to show repo-root + // recipes verbatim; every other skill must ship its own resources. + const resource = isResourceAllowlisted(file) ? [] : collectResourceViolations(text); + scanned.push({ file, genie, omni, resource }); } const omniNeeded = scanned.some((s) => s.omni.length > 0); const omniCmds = omniNeeded ? getOmniCommands() : new Set(); const omniSkipped = omniCmds === null; - for (const { file, genie, omni } of scanned) { + for (const { file, genie, omni, resource } of scanned) { const missing: Report['missingCommands'] = []; for (const cmd of genie) { if (!genieCmds.has(cmd)) missing.push({ tool: 'genie', command: cmd }); @@ -163,18 +264,33 @@ function main() { if (!omniCmds.has(cmd)) missing.push({ tool: 'omni', command: cmd }); } } - reports.push({ skill: relative(ROOT, file), missingCommands: missing }); + reports.push({ skill: relative(ROOT, file), missingCommands: missing, resourceViolations: resource }); } - const failed = reports.filter((r) => r.missingCommands.length > 0); + const missingFailed = reports.filter((r) => r.missingCommands.length > 0); + const resourceFailed = reports.filter((r) => r.resourceViolations.length > 0); console.log(JSON.stringify(reports, null, 2)); - if (failed.length > 0) { - console.error(`\nskills-lint: ${failed.length} skill(s) reference missing commands`); + if (missingFailed.length > 0 || resourceFailed.length > 0) { + if (missingFailed.length > 0) { + console.error(`\nskills-lint: ${missingFailed.length} skill(s) reference missing commands`); + } + if (resourceFailed.length > 0) { + console.error(`\nskills-lint: ${resourceFailed.length} skill(s) reference repo-only resources`); + console.error('skills-lint: skill-shipped paths must use ${CLAUDE_SKILL_DIR}/${CLAUDE_PLUGIN_ROOT}'); + for (const r of resourceFailed) { + for (const v of r.resourceViolations) { + console.error(` ${r.skill}: [${v.rule}] ${v.snippet}`); + } + } + } process.exit(1); } const omniNote = omniSkipped ? ', omni checks skipped' : ''; - console.error(`skills-lint: OK (${reports.length} files scanned, 0 missing${omniNote})`); + console.error(`skills-lint: OK (${reports.length} files scanned, 0 missing, 0 resource violations${omniNote})`); } -main(); +// Only run the linter when executed directly, not when imported by tests. +if (import.meta.main) { + main(); +} diff --git a/scripts/smart-install.js b/scripts/smart-install.js deleted file mode 100644 index a28fdf708..000000000 --- a/scripts/smart-install.js +++ /dev/null @@ -1,435 +0,0 @@ -#!/usr/bin/env node -import { execSync, spawnSync } from 'node:child_process'; -/** - * Smart Install Script for genie - * - * Ensures required dependencies are installed: - * - Bun runtime (auto-installs if missing) - * - tmux (guides user if missing - can't auto-install) - * - beads (auto-installs if missing via npm) - * - genie CLI (installed globally via bun) - * - * Also handles: - * - Dependency installation when version changes - * - Version marker management - */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; - -const ROOT = process.env.CLAUDE_PLUGIN_ROOT || join(homedir(), '.claude', 'plugins', 'genie'); -const GENIE_DIR = join(homedir(), '.genie'); -const MARKER = join(GENIE_DIR, '.install-version'); -const IS_WINDOWS = process.platform === 'win32'; - -// Common installation paths (handles fresh installs before PATH reload) -const BUN_COMMON_PATHS = IS_WINDOWS - ? [join(homedir(), '.bun', 'bin', 'bun.exe')] - : [join(homedir(), '.bun', 'bin', 'bun'), '/usr/local/bin/bun', '/opt/homebrew/bin/bun']; - -const BEADS_COMMON_PATHS = IS_WINDOWS - ? [join(homedir(), 'AppData', 'Roaming', 'npm', 'bd.cmd')] - : [join(homedir(), '.local', 'bin', 'bd'), '/usr/local/bin/bd', '/opt/homebrew/bin/bd']; - -const GENIE_COMMON_PATHS = IS_WINDOWS - ? [join(homedir(), '.bun', 'bin', 'genie.exe')] - : [join(homedir(), '.bun', 'bin', 'genie'), '/usr/local/bin/genie', '/opt/homebrew/bin/genie']; - -/** - * Get the Bun executable path - */ -function getBunPath() { - // Try PATH first - try { - const result = spawnSync('bun', ['--version'], { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - shell: IS_WINDOWS, - }); - if (result.status === 0) return 'bun'; - } catch { - // Not in PATH - } - return BUN_COMMON_PATHS.find(existsSync) || null; -} - -function isBunInstalled() { - return getBunPath() !== null; -} - -function getBunVersion() { - const bunPath = getBunPath(); - if (!bunPath) return null; - try { - const result = spawnSync(bunPath, ['--version'], { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - shell: IS_WINDOWS, - }); - return result.status === 0 ? result.stdout.trim() : null; - } catch { - return null; - } -} - -/** - * Install Bun automatically - */ -function installBun() { - console.error('Installing Bun runtime...'); - try { - if (IS_WINDOWS) { - execSync('powershell -c "irm bun.com/install.ps1 | iex"', { stdio: 'inherit', shell: true }); - } else { - execSync('curl -fsSL https://bun.com/install | bash', { stdio: 'inherit', shell: true }); - } - if (!isBunInstalled()) { - throw new Error('Bun installation completed but binary not found. Please restart your terminal.'); - } - console.error(`Bun ${getBunVersion()} installed`); - } catch (error) { - console.error('Failed to install Bun. Please install manually:'); - if (IS_WINDOWS) { - console.error(' winget install Oven-sh.Bun'); - } else { - console.error(' curl -fsSL https://bun.com/install | bash'); - } - throw error; - } -} - -/** - * Check if tmux is installed - */ -function isTmuxInstalled() { - try { - const result = spawnSync('tmux', ['-V'], { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - shell: IS_WINDOWS, - }); - return result.status === 0; - } catch { - return false; - } -} - -/** - * Get tmux version - */ -function getTmuxVersion() { - try { - const result = spawnSync('tmux', ['-V'], { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - shell: IS_WINDOWS, - }); - return result.status === 0 ? result.stdout.trim() : null; - } catch { - return null; - } -} - -/** - * Download static tmux binary from official tmux-builds. - * Inlined here because the plugin build only copies smart-install.js. - */ -async function downloadTmuxBinary() { - const TMUX_VERSION = '3.6a'; - const os = process.platform; - const cpu = process.arch; - const assetMap = { - 'linux-x64': `tmux-${TMUX_VERSION}-linux-x86_64.tar.gz`, - 'linux-arm64': `tmux-${TMUX_VERSION}-linux-arm64.tar.gz`, - 'darwin-arm64': `tmux-${TMUX_VERSION}-macos-arm64.tar.gz`, - 'darwin-x64': `tmux-${TMUX_VERSION}-macos-x86_64.tar.gz`, - }; - const asset = assetMap[`${os}-${cpu}`]; - if (!asset) { - console.error(`tmux: no prebuilt binary for ${os}-${cpu}.`); - return false; - } - - const url = `https://github.com/tmux/tmux-builds/releases/download/v${TMUX_VERSION}/${asset}`; - const { tmpdir } = await import('node:os'); - const { copyFileSync, chmodSync, rmSync, writeFileSync } = await import('node:fs'); - const tempDir = join(tmpdir(), `genie-tmux-${Date.now()}`); - const dest = join(GENIE_DIR, 'bin', 'tmux'); - - console.error(`Downloading tmux ${TMUX_VERSION} (${asset})...`); - try { - const res = await fetch(url); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const buffer = Buffer.from(await res.arrayBuffer()); - console.error(`Downloaded ${(buffer.byteLength / 1024 / 1024).toFixed(1)} MB`); - - mkdirSync(tempDir, { recursive: true }); - writeFileSync(join(tempDir, asset), buffer); - const tarResult = spawnSync('tar', ['-xzf', join(tempDir, asset), '-C', tempDir], { stdio: 'ignore' }); - if (tarResult.status !== 0) throw new Error('tar extraction failed'); - - const extracted = join(tempDir, 'tmux'); - if (!existsSync(extracted)) throw new Error('tarball did not contain tmux binary'); - - mkdirSync(join(GENIE_DIR, 'bin'), { recursive: true }); - copyFileSync(extracted, dest); - chmodSync(dest, 0o755); - - const verify = spawnSync(dest, ['-V'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }); - if (verify.status !== 0) throw new Error('binary not executable'); - - console.error(`${verify.stdout.trim()} installed to ${dest}`); - return true; - } catch (e) { - try { - if (existsSync(dest)) (await import('node:fs')).unlinkSync(dest); - } catch {} - console.error(`tmux download failed: ${e.message}`); - return false; - } finally { - try { - rmSync(tempDir, { recursive: true, force: true }); - } catch {} - } -} - -/** - * Check if beads (bd) is installed - */ -function getBeadsPath() { - try { - const result = spawnSync('bd', ['--version'], { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - shell: IS_WINDOWS, - }); - if (result.status === 0) return 'bd'; - } catch { - // Not in PATH - } - return BEADS_COMMON_PATHS.find(existsSync) || null; -} - -function isBeadsInstalled() { - return getBeadsPath() !== null; -} - -/** - * Install beads via npm - */ -function installBeads() { - console.error('Installing beads (bd)...'); - try { - execSync('npm install -g @anthropic-ai/bd', { stdio: 'inherit', shell: true }); - if (!isBeadsInstalled()) { - throw new Error('beads installation completed but bd not found.'); - } - console.error('beads installed'); - } catch (error) { - console.error('Failed to install beads. Please install manually:'); - console.error(' npm install -g @anthropic-ai/bd'); - throw error; - } -} - -/** - * Check if dependencies need to be installed - */ -function needsInstall() { - if (!existsSync(join(ROOT, 'node_modules'))) return true; - if (!existsSync(join(ROOT, 'package.json'))) return false; // No package.json = no deps needed - - try { - const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')); - if (!existsSync(MARKER)) return true; - const marker = JSON.parse(readFileSync(MARKER, 'utf-8')); - return pkg.version !== marker.version || getBunVersion() !== marker.bun; - } catch { - return true; - } -} - -/** - * Install dependencies using Bun - */ -function installDeps() { - const bunPath = getBunPath(); - if (!bunPath) { - throw new Error('Bun executable not found'); - } - - console.error('Installing dependencies...'); - - // Ensure .genie directory exists - if (!existsSync(GENIE_DIR)) { - mkdirSync(GENIE_DIR, { recursive: true }); - } - - const bunCmd = IS_WINDOWS && bunPath.includes(' ') ? `"${bunPath}"` : bunPath; - execSync(`${bunCmd} install`, { cwd: ROOT, stdio: 'inherit', shell: IS_WINDOWS }); - - // Write version marker - let version = 'unknown'; - try { - const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')); - version = pkg.version; - } catch { - // Ignore - } - - writeFileSync( - MARKER, - JSON.stringify({ - version, - bun: getBunVersion(), - tmux: getTmuxVersion(), - installedAt: new Date().toISOString(), - }), - ); -} - -/** - * Get the genie executable path - */ -function getGeniePath() { - // Try PATH first - try { - const result = spawnSync('genie', ['--version'], { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - shell: IS_WINDOWS, - }); - if (result.status === 0) return 'genie'; - } catch { - // Not in PATH - } - return GENIE_COMMON_PATHS.find(existsSync) || null; -} - -/** - * Get installed genie CLI version (via bun global) - */ -function getGenieVersion() { - const geniePath = getGeniePath(); - if (!geniePath) return null; - try { - const result = spawnSync(geniePath, ['--version'], { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - shell: IS_WINDOWS, - }); - return result.status === 0 ? result.stdout.trim() : null; - } catch { - return null; - } -} - -/** - * Get the plugin's package version - */ -function getPluginVersion() { - try { - const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')); - return pkg.version || null; - } catch { - return null; - } -} - -/** - * Check if genie CLI needs install or upgrade via bun global - */ -function genieCliNeedsInstall() { - const installed = getGenieVersion(); - if (!installed) return true; - const pluginVersion = getPluginVersion(); - if (!pluginVersion) return false; - return installed !== pluginVersion; -} - -/** - * Install or upgrade genie CLI globally via bun - */ -function installGenieCli() { - const bunPath = getBunPath(); - if (!bunPath) { - throw new Error('Bun executable not found — cannot install genie CLI'); - } - - const pluginVersion = getPluginVersion(); - const installed = getGenieVersion(); - - if (installed) { - console.error(`Upgrading genie CLI: ${installed} → ${pluginVersion}...`); - } else { - console.error('Installing genie CLI globally via bun...'); - } - - const bunCmd = IS_WINDOWS && bunPath.includes(' ') ? `"${bunPath}"` : bunPath; - const versionSuffix = pluginVersion ? `@${pluginVersion}` : ''; - execSync(`${bunCmd} install -g @automagik/genie${versionSuffix}`, { stdio: 'inherit', shell: IS_WINDOWS }); - - const newVersion = getGenieVersion(); - if (!newVersion) { - throw new Error('genie CLI installation completed but binary not found. Restart your terminal.'); - } - console.error(`genie CLI ${newVersion} installed`); -} - -// Main execution -try { - // Quick check: if everything is already installed, exit silently - if (isBunInstalled() && isTmuxInstalled() && isBeadsInstalled() && !needsInstall() && !genieCliNeedsInstall()) { - process.exit(0); - } - - // 1. Check/install Bun - if (!isBunInstalled()) { - installBun(); - } - - // 2. Check tmux — auto-download static binary if missing - if (!isTmuxInstalled()) { - const tmuxCached = join(GENIE_DIR, 'bin', 'tmux'); - if (existsSync(tmuxCached)) { - console.error(`tmux found at ${tmuxCached}`); - } else { - const ok = await downloadTmuxBinary(); - if (!ok) { - console.error('Please install tmux manually:'); - if (process.platform === 'darwin') { - console.error(' brew install tmux'); - } else if (process.platform === 'linux') { - console.error(' sudo apt install tmux # Debian/Ubuntu'); - console.error(' sudo dnf install tmux # Fedora/RHEL'); - console.error(' sudo pacman -S tmux # Arch'); - } else if (IS_WINDOWS) { - console.error(' WSL is required for tmux on Windows'); - console.error(' Inside WSL: sudo apt install tmux'); - } - console.error(''); - console.error('Then restart Claude Code.'); - process.exit(2); - } - } - } - - // 3. Check/install beads - if (!isBeadsInstalled()) { - installBeads(); - } - - // 4. Install plugin dependencies if needed - if (needsInstall()) { - installDeps(); - console.error('Dependencies installed'); - } - - // 5. Install or upgrade genie CLI via bun global - if (genieCliNeedsInstall()) { - installGenieCli(); - } -} catch (e) { - console.error('Installation failed:', e.message); - process.exit(1); -} diff --git a/skills/README.md b/skills/README.md index 1b8ca185e..0d597777e 100644 --- a/skills/README.md +++ b/skills/README.md @@ -16,7 +16,7 @@ Decision legend: | Skill | Decision | Rationale | |-------|----------|-----------| | `brainstorm` | Keep — core (rewritten here) | Ideation → DESIGN.md. WRS scoring and crystallize are pure methodology; only the tracking-task call moved to `genie task`, artifacts stay in `.genie/`. | -| `wish` | Keep — core (rewritten here) | DESIGN.md → WISH.md with groups + DAG. Scaffold is now a `cp` of `templates/wish-template.md`; per-group tasks via `genie task`; lint via `bun run wishes:lint`. | +| `wish` | Keep — core (rewritten here) | DESIGN.md → WISH.md with groups + DAG. Scaffold is now a `cp` of `skills/wish/templates/wish-template.md`; per-group tasks via `genie task`; lint via `grep -q '"wishes:lint"' package.json 2>/dev/null && bun run wishes:lint`. | | `work` | Keep — core (rewritten here) | Wave dispatch + fix loops + validation. Dispatch is now the Agent tool (native team), state via `genie task checkout/done`, completion by notification (no polling). | | `review` | Keep — core (rewritten here) | SHIP/FIX-FIRST/BLOCKED gate. Verdict is the output (reported, not a task mutation); dispatched as a separate subagent (reviewer ≠ engineer) via the Agent tool. | | `genie` | Keep — portable now | Natural-language router into the other skills. Routing logic is runtime-agnostic; any command hand-offs re-point to the `genie` namespace during its own port. | diff --git a/skills/brainstorm/SKILL.md b/skills/brainstorm/SKILL.md index f0ca95e78..37077e8db 100644 --- a/skills/brainstorm/SKILL.md +++ b/skills/brainstorm/SKILL.md @@ -48,6 +48,8 @@ WRS: ██████░░░░ 60/100 If **Decisions** stays unfilled after 2+ exchanges, convene **domain experts**: dispatch 2-3 lens subagents in parallel (Agent tool), each reading a distinct lens — a deliberation card from `plugins/genie/references/lenses/` (questioner, simplifier, operator, …) plus, when the tradeoff is technical, the matching lane skill at `skills//SKILL.md`. Present their perspectives to the user, then keep refining. Escalate to the full `/council` workflow when the decision deserves a durable deliberation record. +Lens root: `$GENIE_HOME/plugins/genie` (default `~/.genie/plugins/genie`); inside the genie repo itself, resolve `references/lenses/` cards and `skills//SKILL.md` lanes relative to `plugins/genie/` and the repo root. + ## Scope Size Multi-subsystem requests waste refinement — assumptions for subsystem A rarely hold for B. Signs: 3+ unrelated modules, infrastructure + application layers together, UI + API + data model with no shared interface, parts that could ship or be staffed independently. When detected: stop refining, tell the user the request spans independent subsystems, decompose into sub-projects (purpose, rough scope, dependencies for each), and start a fresh brainstorm for the first one. @@ -88,7 +90,7 @@ At WRS = 100: ```bash git add .genie/brainstorms//DESIGN.md .genie/brainstorms//DRAFT.md ``` - Stage exactly these two; other brainstorm artifacts stay untracked. `bun run wishes:lint` fails any wish whose design link doesn't resolve to a real file — uncommitted brainstorms are missing in CI and sibling worktrees, so never skip the stage. + Stage exactly these two; other brainstorm artifacts stay untracked. The genie repo's wish linter fails any wish whose design link doesn't resolve to a real file — uncommitted brainstorms are missing in CI and sibling worktrees, so never skip the stage. 4. Update the jar — move the entry to Poured with the wish link. 5. Create a board pointer; if this fails (no `.genie/genie.db` yet, CLI unavailable), warn and continue — DESIGN.md and the jar in git are the source of truth: ```bash diff --git a/skills/review/SKILL.md b/skills/review/SKILL.md index d5c49d9c6..251944d45 100644 --- a/skills/review/SKILL.md +++ b/skills/review/SKILL.md @@ -116,6 +116,8 @@ When the change-type warrants it, the orchestrator dispatches **lens reviewers** | Test-strategy changes | `skills/qa/SKILL.md` | | Plan / wish reviews | `plugins/genie/references/lenses/questioner.md` | +Lens root: `$GENIE_HOME/plugins/genie` (default `~/.genie/plugins/genie`); inside the genie repo itself, resolve `references/lenses/` cards and `skills//SKILL.md` lanes relative to `plugins/genie/` and the repo root. + ## Verdict Reporting The verdict plus severity-tagged gaps ARE the review output — deliver them in your final message (and, for a plan/PR, in review notes committed to git). The reviewer never mutates task state: diff --git a/skills/wish/SKILL.md b/skills/wish/SKILL.md index 8da00b29c..2563e9245 100644 --- a/skills/wish/SKILL.md +++ b/skills/wish/SKILL.md @@ -35,9 +35,9 @@ test -f .genie/brainstorms//DESIGN.md 5. **Scaffold** — always copy the template, never hand-write WISH.md: ```bash mkdir -p .genie/wishes/ - cp templates/wish-template.md .genie/wishes//WISH.md + cp "${CLAUDE_SKILL_DIR}/templates/wish-template.md" .genie/wishes//WISH.md ``` - `templates/wish-template.md` (genie repo) is the single source of truth for wish structure — a plain git document, no runtime scaffolder. Copying guarantees the skeleton the parser and linter expect; ad-hoc wishes regularly fail `bun run wishes:lint`. + The template ships inside this skill as the single source of truth for wish structure — a plain document, no runtime scaffolder. Copying guarantees the skeleton the parser and linter expect; ad-hoc wishes regularly fail structural lint. 6. **Fill:** replace the `{{slug}}`/`{{date}}` tokens and every `` marker with real content. Every group gets acceptance criteria plus a validation command. 7. **Declare dependencies:** `depends-on` between execution groups and cross-wish `depends-on`/`blocks` in the WISH.md — the DAG is a planning artifact in git. 8. **Create tasks** — one per execution group, so `/work` can claim and complete each group and the board reflects progress: @@ -46,7 +46,7 @@ test -f .genie/brainstorms//DESIGN.md genie task list --wish # inspect what was created ``` Tasks carry the `--wish`/`--group` linkage; the dependency DAG stays in the WISH.md document, not in task rows. If creation fails (no `.genie/genie.db` yet, CLI unavailable), warn and continue — WISH.md in git is the source of truth and must remain usable by `/work` without task rows. -9. **Handoff:** run `bun run wishes:lint`. If it reports any error, surface it and stop — never hand a structurally broken wish onward. Only after lint passes, auto-invoke `/review` (plan review) on the WISH.md. Never suggest `/work` directly — the review gate comes first. +9. **Handoff:** run the wish linter — inside the genie repo, `grep -q '"wishes:lint"' package.json 2>/dev/null && bun run wishes:lint`. If it reports any error, surface it and stop — never hand a structurally broken wish onward. Only after lint passes, auto-invoke `/review` (plan review) on the WISH.md. Never suggest `/work` directly — the review gate comes first. ## Wish Document Sections @@ -64,8 +64,8 @@ test -f .genie/brainstorms//DESIGN.md | Assumptions / Risks | No | What could invalidate the plan | ## Rules -- Never write WISH.md from scratch — always `cp templates/wish-template.md`, then edit. -- Lint before handoff: `bun run wishes:lint` must pass before `/review` sees the wish. +- Never write WISH.md from scratch — always copy the in-skill template, then edit. +- Lint before handoff: the genie repo's wish linter must pass before `/review` sees the wish. - Never emit a bracket-link to a non-existent brainstorm — use the `_No brainstorm — direct wish_` stub. - No implementation during `/wish` — planning only. - Every group testable, bite-sized, and independently shippable; no vague tasks ("improve everything"). diff --git a/templates/wish-template.md b/skills/wish/templates/wish-template.md similarity index 100% rename from templates/wish-template.md rename to skills/wish/templates/wish-template.md diff --git a/src/genie-commands/__tests__/update.test.ts b/src/genie-commands/__tests__/update.test.ts index b9acf38e7..d84a8ca27 100644 --- a/src/genie-commands/__tests__/update.test.ts +++ b/src/genie-commands/__tests__/update.test.ts @@ -15,6 +15,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import type { AgentSyncReport } from '../../lib/agent-sync'; import { type LatestManifest, type VerifyResult, @@ -33,6 +34,8 @@ import { resolveLiveBinaryPath, resolvePlatformId, rollbackBinary, + runAgentSyncSafe, + runFreshBinaryAgentSync, runV4CleanupSafe, runVerifyProbe, shortCircuitIfCurrent, @@ -1423,6 +1426,158 @@ describe('runV4CleanupSafe', () => { }); }); +// ============================================================================ +// Agent-sync wiring (agent-sync wish G2). `genie update` is the ONE canonical +// updater: the sync phase runs on the sync-only fast path, on the already- +// current short-circuit, and via a post-swap re-exec of the fresh binary. The +// internal env GENIE_UPDATE_SYNC_ONLY=1 is the only re-entry contract — no new +// user-facing command or flag. Engine failures are non-fatal advisories. +// ============================================================================ + +describe('runAgentSyncSafe (agent-sync phase)', () => { + function makeReport(): AgentSyncReport { + return { + source: { pluginRoot: '/home/.genie/plugins/genie', hermesRoot: null, version: '5.0.0' }, + agents: [ + { + agent: 'claude', + detected: true, + skills: [ + { name: 'wish', action: 'created' }, + { name: 'work', action: 'updated' }, + { name: 'review', action: 'created' }, + ], + extras: [{ kind: 'stamp', action: 'written', detail: '/x/council.js' }], + advisories: [], + }, + { agent: 'codex', detected: false, skills: [], extras: [], advisories: [] }, + { + agent: 'hermes', + detected: true, + skills: [], + extras: [{ kind: 'symlink', action: 'created' }], + advisories: ['hermes plugins enable genie failed: boom'], + }, + ], + backupsDir: null, + }; + } + + test('runs the injected engine and prints a compact per-agent summary', () => { + const lines: string[] = []; + const marker = join(mkdtempSync(join(tmpdir(), 'genie-asm-')), '.last-agent-sync'); + runAgentSyncSafe({ sync: makeReport, log: (l) => lines.push(l), markerPath: marker }); + const joined = lines.join('\n'); + expect(joined).toContain('claude'); + expect(joined).toContain('created 2'); + expect(joined).toContain('updated 1'); + expect(joined).toContain('codex not detected'); + expect(joined).toContain('hermes plugins enable genie failed'); // advisory surfaced + }); + + test('an engine throw is non-fatal and reported as an advisory', () => { + const lines: string[] = []; + const marker = join(mkdtempSync(join(tmpdir(), 'genie-asm-')), '.last-agent-sync'); + expect(() => + runAgentSyncSafe({ + sync: () => { + throw new Error('boom'); + }, + log: (l) => lines.push(l), + markerPath: marker, + }), + ).not.toThrow(); + expect(lines.join('\n')).toContain('agent sync failed: boom'); + }); + + test('refreshes the ~/.genie/.last-agent-sync marker with an ISO timestamp', () => { + const dir = mkdtempSync(join(tmpdir(), 'genie-asm-')); + const marker = join(dir, '.last-agent-sync'); + try { + runAgentSyncSafe({ + sync: makeReport, + log: () => {}, + markerPath: marker, + now: () => new Date('2026-07-10T00:00:00.000Z'), + }); + expect(readFileSync(marker, 'utf-8').trim()).toBe('2026-07-10T00:00:00.000Z'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('marker is refreshed even when the engine throws (the sync phase ran)', () => { + const dir = mkdtempSync(join(tmpdir(), 'genie-asm-')); + const marker = join(dir, '.last-agent-sync'); + try { + runAgentSyncSafe({ + sync: () => { + throw new Error('x'); + }, + log: () => {}, + markerPath: marker, + }); + expect(existsSync(marker)).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('updateCommand runs the sync-only fast path before any network/delivery', () => { + const source = readFileSync(join(import.meta.dir, '..', 'update.ts'), 'utf-8'); + const cmdStart = source.indexOf('export async function updateCommand'); + const cmdBody = source.slice(cmdStart); + const fastPathIdx = cmdBody.indexOf("process.env.GENIE_UPDATE_SYNC_ONLY === '1'"); + const fetchIdx = cmdBody.indexOf('await fetchLatestManifest('); + const deliveryIdx = cmdBody.indexOf('await runDelivery('); + expect(fastPathIdx).toBeGreaterThan(-1); + expect(fastPathIdx).toBeLessThan(fetchIdx); + expect(fastPathIdx).toBeLessThan(deliveryIdx); + // The fast-path block calls the sync phase (and only that path does, pre-fetch). + expect(cmdBody.slice(fastPathIdx, fetchIdx)).toContain('runAgentSyncSafe()'); + }); + + test('short-circuit (already-current) path calls the sync phase before returning', () => { + const source = readFileSync(join(import.meta.dir, '..', 'update.ts'), 'utf-8'); + const scIdx = source.indexOf('shortCircuitIfCurrent(installedVersion, latestVersion)'); + expect(scIdx).toBeGreaterThan(-1); + expect(source.slice(scIdx, scIdx + 400)).toContain('runAgentSyncSafe()'); + }); +}); + +describe('runFreshBinaryAgentSync (post-swap re-exec)', () => { + test('execs the freshly installed binary with `update` + GENIE_UPDATE_SYNC_ONLY=1', () => { + const calls: Array<{ bin: string; env: NodeJS.ProcessEnv }> = []; + runFreshBinaryAgentSync({ + exec: (bin, env) => { + calls.push({ bin, env }); + }, + }); + expect(calls).toHaveLength(1); + expect(calls[0].bin).toContain('genie'); + expect(calls[0].env.GENIE_UPDATE_SYNC_ONLY).toBe('1'); + }); + + test('a failed re-exec is non-fatal', () => { + expect(() => + runFreshBinaryAgentSync({ + exec: () => { + throw new Error('spawn failed'); + }, + }), + ).not.toThrow(); + }); + + test('updateCommand re-execs the fresh binary after the post-update verify', () => { + const source = readFileSync(join(import.meta.dir, '..', 'update.ts'), 'utf-8'); + const verifyIdx = source.indexOf('await runPostUpdateVerifySafe('); + const freshIdx = source.indexOf('runFreshBinaryAgentSync();'); + expect(freshIdx).toBeGreaterThan(-1); + expect(verifyIdx).toBeGreaterThan(-1); + expect(verifyIdx).toBeLessThan(freshIdx); + }); +}); + // ============================================================================ // Scheduler-signal age filter (wish v4-home-residue-doctor): a June disk-full // incident must not resurface as "Recent scheduler signals" weeks later. diff --git a/src/genie-commands/doctor.test.ts b/src/genie-commands/doctor.test.ts index 748351843..923dbbdb2 100644 --- a/src/genie-commands/doctor.test.ts +++ b/src/genie-commands/doctor.test.ts @@ -2,6 +2,7 @@ import { Database } from 'bun:sqlite'; import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { execFileSync } from 'node:child_process'; import { + cpSync, existsSync, mkdirSync, mkdtempSync, @@ -9,11 +10,14 @@ import { readdirSync, rmSync, statSync, + symlinkSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { computeDirDigest } from '../lib/agent-sync.js'; import { + checkAgentSync, checkSubagentModelOverride, checkV4Residue, doctorCommand, @@ -438,3 +442,161 @@ describe('checkV4Residue — accounting + uncertain keeps + json fix', () => { expect(proc.stderr.toString()).toContain('Removed v4 residue'); // chatter rerouted, not lost }); }); + +// ============================================================================ +// agent-sync freshness (wish agent-sync, Group 3) — read-only, path-injected +// ============================================================================ + +describe('checkAgentSync', () => { + let tmp: string; + let genieHome: string; + let pluginRoot: string; + let claudeDir: string; + let codexDir: string; + let hermesHome: string; + + function writeSourceSkill(name: string, body: string): void { + mkdirSync(join(pluginRoot, 'skills', name), { recursive: true }); + writeFileSync(join(pluginRoot, 'skills', name, 'SKILL.md'), body, 'utf8'); + } + + /** Copy a source skill into a target parent + stamp a manifest (current unless a digest is forced). */ + function seedManaged(sourceDir: string, destDir: string, digest?: string): void { + cpSync(sourceDir, destDir, { recursive: true }); + writeFileSync( + join(destDir, '.genie-sync.json'), + JSON.stringify({ + managedBy: 'genie-agent-sync', + version: '1', + digest: digest ?? computeDirDigest(sourceDir), + syncedAt: '2026-01-01T00:00:00.000Z', + }), + 'utf8', + ); + } + + function stampCouncil(lensRoot: string): void { + mkdirSync(join(claudeDir, 'workflows'), { recursive: true }); + writeFileSync( + join(claudeDir, 'workflows', 'council.js'), + `export const meta = { name: 'council' };\nconst LENS_ROOT = '${lensRoot}';\n`, + 'utf8', + ); + } + + function paths() { + return { genieHome, claudeDir, codexDir, hermesHome, settingsPath: join(claudeDir, 'settings.json') }; + } + + const find = (results: ReturnType, name: string) => results.find((r) => r.name === name); + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'doctor-agentsync-')); + genieHome = join(tmp, 'genie'); + pluginRoot = join(genieHome, 'plugins', 'genie'); + claudeDir = join(tmp, 'claude'); + codexDir = join(tmp, 'codex'); + hermesHome = join(tmp, 'hermes'); + mkdirSync(join(pluginRoot, 'skills'), { recursive: true }); + mkdirSync(join(genieHome, 'plugins', 'hermes-genie'), { recursive: true }); + writeFileSync(join(genieHome, 'VERSION'), '5.0.0\n', 'utf8'); + writeSourceSkill('wish', '# wish\n'); + writeSourceSkill('review', '# review\n'); + }); + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + test('no plugin source → one pass advisory pointing at genie update', () => { + const results = checkAgentSync({ genieHome: join(tmp, 'absent'), claudeDir, codexDir, hermesHome }); + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ name: 'agent sync', status: 'pass' }); + expect(results[0].detail).toContain('genie update'); + }); + + test('undetected agents each report "not detected", never a failure', () => { + const results = checkAgentSync(paths()); + expect(find(results, 'agent sync: claude')?.detail).toBe('not detected'); + expect(find(results, 'agent sync: codex')?.detail).toBe('not detected'); + expect(find(results, 'agent sync: hermes')?.detail).toBe('not detected'); + expect(results.every((r) => r.status !== 'fail')).toBe(true); + }); + + test('all-current skills + correct council stamp + correct hermes link → pass, no advice', () => { + seedManaged(join(pluginRoot, 'skills', 'wish'), join(claudeDir, 'skills', 'wish')); + seedManaged(join(pluginRoot, 'skills', 'review'), join(claudeDir, 'skills', 'review')); + stampCouncil(pluginRoot); + seedManaged(join(pluginRoot, 'skills', 'wish'), join(codexDir, 'skills', '.curated', 'wish')); + seedManaged(join(pluginRoot, 'skills', 'review'), join(codexDir, 'skills', '.curated', 'review')); + mkdirSync(join(hermesHome, 'plugins'), { recursive: true }); + symlinkSync(join(genieHome, 'plugins', 'hermes-genie'), join(hermesHome, 'plugins', 'genie')); + + const results = checkAgentSync(paths()); + const claude = find(results, 'agent sync: claude'); + expect(claude?.status).toBe('pass'); + expect(claude?.detail).toContain('2/2 source skills current'); + expect(claude?.detail).toContain('council.js current'); + expect(claude?.suggestion).toBeUndefined(); + expect(find(results, 'agent sync: codex')?.status).toBe('pass'); + const hermes = find(results, 'agent sync: hermes'); + expect(hermes?.status).toBe('pass'); + expect(hermes?.detail).toContain('linked'); + }); + + test('stale managed skill + wrong council stamp → warn + genie-update advice', () => { + seedManaged(join(pluginRoot, 'skills', 'wish'), join(claudeDir, 'skills', 'wish')); + seedManaged(join(pluginRoot, 'skills', 'review'), join(claudeDir, 'skills', 'review'), 'deadbeef'); + stampCouncil('/old/plugin/root'); + + const claude = find(checkAgentSync(paths()), 'agent sync: claude'); + expect(claude?.status).toBe('warn'); + expect(claude?.detail).toContain('1 stale'); + expect(claude?.detail).toContain('council.js stale'); + expect(claude?.suggestion).toContain('genie update'); + }); + + test('unmanaged skill dirs are never counted (genie only speaks for what it shipped)', () => { + mkdirSync(join(claudeDir, 'skills', 'my-own'), { recursive: true }); + writeFileSync(join(claudeDir, 'skills', 'my-own', 'SKILL.md'), '# mine\n', 'utf8'); + stampCouncil(pluginRoot); + + const claude = find(checkAgentSync(paths()), 'agent sync: claude'); + expect(claude?.detail).toContain('0/2 source skills current'); + }); + + test('hermes link pointing elsewhere → warn', () => { + mkdirSync(join(hermesHome, 'plugins'), { recursive: true }); + symlinkSync(join(tmp, 'somewhere-else'), join(hermesHome, 'plugins', 'genie')); + const hermes = find(checkAgentSync(paths()), 'agent sync: hermes'); + expect(hermes?.status).toBe('warn'); + expect(hermes?.detail).toContain('points elsewhere'); + }); + + test('codex detected but .curated empty → warn (not populated)', () => { + mkdirSync(codexDir, { recursive: true }); + const codex = find(checkAgentSync(paths()), 'agent sync: codex'); + expect(codex?.status).toBe('warn'); + expect(codex?.detail).toContain('.curated not populated'); + }); + + test('marketplace plugin: disabled/absent → optional pass note; enabled → silent', () => { + mkdirSync(claudeDir, { recursive: true }); + writeFileSync( + join(claudeDir, 'settings.json'), + JSON.stringify({ enabledPlugins: { 'genie@automagik': false } }), + 'utf8', + ); + let mkt = find(checkAgentSync(paths()), 'agent sync: marketplace plugin'); + expect(mkt?.status).toBe('pass'); + expect(mkt?.detail).toContain('not enabled'); + + writeFileSync( + join(claudeDir, 'settings.json'), + JSON.stringify({ enabledPlugins: { 'genie@automagik': true } }), + 'utf8', + ); + mkt = find(checkAgentSync(paths()), 'agent sync: marketplace plugin'); + expect(mkt).toBeUndefined(); + }); +}); diff --git a/src/genie-commands/doctor.ts b/src/genie-commands/doctor.ts index b75d268e6..a6691923d 100644 --- a/src/genie-commands/doctor.ts +++ b/src/genie-commands/doctor.ts @@ -14,9 +14,16 @@ */ import { execFileSync } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, lstatSync, readFileSync, readdirSync, readlinkSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; +import { MANAGED_BY, MANIFEST_NAME, TARGET_NAME, computeDirDigest, resolveGenieSource } from '../lib/agent-sync.js'; +import { + resolveClaudeDir, + resolveCodexDir, + resolveGenieHome as resolveGlobalGenieHome, + resolveHermesHome, +} from '../lib/genie-home.js'; import { resolveOmniRuntimeConfig } from '../lib/omni-config.js'; import { CURRENT_SCHEMA_VERSION, GenieDbError, openDb, resolveDbPath, resolveRepoRoot } from '../lib/v5/genie-db.js'; import { VERSION } from '../lib/version.js'; @@ -390,6 +397,253 @@ async function checkOmniHookTimeout(): Promise { return result ? [result] : []; } +// ============================================================================ +// agent-sync freshness (READ-ONLY — never writes; converging is `genie update`'s job) +// ============================================================================ + +// The managed-dir contract mirrored from src/lib/agent-sync.ts. Kept as local +// Protocol identifiers come from the engine (single source of truth) so a +// rename there can never silently desync this read-only surface. +const SYNC_MANIFEST_NAME = MANIFEST_NAME; +const SYNC_MANAGED_BY = MANAGED_BY; +const COUNCIL_WORKFLOW_FILE = TARGET_NAME; +const SYNC_SUGGESTION = 'Run `genie update` to converge all detected coding agents.'; + +interface AgentSyncPaths { + genieHome?: string; + claudeDir?: string; + codexDir?: string; + hermesHome?: string; + settingsPath?: string; +} + +interface ManagedSkillsSummary { + sourceCount: number; + current: number; + stale: number; +} + +/** Immediate subdirectories of `parent` (following symlinks); [] when unreadable. */ +function listSubdirs(parent: string): string[] { + try { + return readdirSync(parent).filter((name) => { + try { + return statSync(join(parent, name)).isDirectory(); + } catch { + return false; + } + }); + } catch { + return []; + } +} + +/** Content digest recorded in a dir's `.genie-sync.json`, or null when not genie-managed. */ +function readManagedDigest(dir: string): string | null { + try { + const parsed = JSON.parse(readFileSync(join(dir, SYNC_MANIFEST_NAME), 'utf8')) as { + managedBy?: string; + digest?: unknown; + }; + if (parsed.managedBy === SYNC_MANAGED_BY && typeof parsed.digest === 'string') return parsed.digest; + } catch { + /* absent / unreadable / not ours */ + } + return null; +} + +/** Source skills = dirs under `/skills` carrying a SKILL.md → name → digest. */ +function sourceSkillDigests(pluginRoot: string): Map { + const out = new Map(); + const skillsRoot = join(pluginRoot, 'skills'); + for (const name of listSubdirs(skillsRoot)) { + const dir = join(skillsRoot, name); + if (existsSync(join(dir, 'SKILL.md'))) out.set(name, computeDirDigest(dir)); + } + return out; +} + +/** + * Count managed skill dirs under `targetParent` that a `genie update` would leave + * untouched (current) vs rewrite (stale). "Current" == a next sync reports + * `unchanged`: manifest present, on-disk content matches its manifest, and the + * manifest matches the current source digest. Unmanaged dirs are ignored — genie + * only speaks for what it provably shipped. + */ +function summarizeManagedSkills(pluginRoot: string, targetParent: string): ManagedSkillsSummary { + const source = sourceSkillDigests(pluginRoot); + let current = 0; + let stale = 0; + for (const name of listSubdirs(targetParent)) { + const dir = join(targetParent, name); + const managedDigest = readManagedDigest(dir); + if (managedDigest === null) continue; + const sourceDigest = source.get(name); + if (sourceDigest !== undefined && sourceDigest === managedDigest && computeDirDigest(dir) === managedDigest) { + current += 1; + } else { + stale += 1; + } + } + return { sourceCount: source.size, current, stale }; +} + +function skillsFreshness(summary: ManagedSkillsSummary): { detail: string; stale: boolean } { + const missing = summary.current < summary.sourceCount; + const stale = summary.stale > 0 || missing; + const staleNote = summary.stale > 0 ? `, ${summary.stale} stale` : ''; + return { detail: `${summary.current}/${summary.sourceCount} source skills current${staleNote}`, stale }; +} + +/** Whether `/workflows/council.js` is stamped for the current stable source root. */ +function councilStampState(councilPath: string, pluginRoot: string): { stale: boolean; label: string } { + let content: string; + try { + content = readFileSync(councilPath, 'utf8'); + } catch { + return { stale: true, label: 'absent' }; + } + const match = content.match(/const LENS_ROOT = '([^']*)'/); + const root = match ? match[1] : null; + if (root === pluginRoot) return { stale: false, label: 'current' }; + return { stale: true, label: `stale (LENS_ROOT ${root ?? 'unreadable'})` }; +} + +function checkClaudeSync(pluginRoot: string, claudeDir: string): CheckResult[] { + if (!existsSync(claudeDir)) return [{ name: 'agent sync: claude', status: 'pass', detail: 'not detected' }]; + const skills = skillsFreshness(summarizeManagedSkills(pluginRoot, join(claudeDir, 'skills'))); + const council = councilStampState(join(claudeDir, 'workflows', COUNCIL_WORKFLOW_FILE), pluginRoot); + const stale = skills.stale || council.stale; + return [ + { + name: 'agent sync: claude', + status: stale ? 'warn' : 'pass', + detail: `${skills.detail}; council.js ${council.label}`, + suggestion: stale ? SYNC_SUGGESTION : undefined, + }, + ]; +} + +function checkCodexSync(pluginRoot: string, codexDir: string): CheckResult[] { + if (!existsSync(codexDir)) return [{ name: 'agent sync: codex', status: 'pass', detail: 'not detected' }]; + const summary = summarizeManagedSkills(pluginRoot, join(codexDir, 'skills', '.curated')); + const populated = summary.current + summary.stale > 0; + const skills = skillsFreshness(summary); + const stale = skills.stale || !populated; + return [ + { + name: 'agent sync: codex', + status: stale ? 'warn' : 'pass', + detail: populated ? skills.detail : '.curated not populated', + suggestion: stale ? SYNC_SUGGESTION : undefined, + }, + ]; +} + +function checkHermesSync(hermesRoot: string | null, hermesHome: string): CheckResult[] { + if (!existsSync(hermesHome)) return [{ name: 'agent sync: hermes', status: 'pass', detail: 'not detected' }]; + if (hermesRoot === null) { + return [{ name: 'agent sync: hermes', status: 'pass', detail: 'hermes-genie source absent — link check skipped' }]; + } + const link = hermesLinkState(join(hermesHome, 'plugins', 'genie'), hermesRoot); + return [ + { + name: 'agent sync: hermes', + status: link.ok ? 'pass' : 'warn', + detail: link.detail, + suggestion: link.ok ? undefined : SYNC_SUGGESTION, + }, + ]; +} + +function hermesLinkState(linkPath: string, hermesRoot: string): { ok: boolean; detail: string } { + let stat: ReturnType; + try { + stat = lstatSync(linkPath); + } catch { + return { ok: false, detail: 'plugins/genie link absent' }; + } + if (!stat.isSymbolicLink()) return { ok: false, detail: 'plugins/genie present but not a symlink' }; + try { + const target = readlinkSync(linkPath); + if (resolve(dirname(linkPath), target) === resolve(hermesRoot)) + return { ok: true, detail: `linked → ${hermesRoot}` }; + return { ok: false, detail: `points elsewhere (${target})` }; + } catch { + return { ok: false, detail: 'plugins/genie symlink unreadable' }; + } +} + +/** + * Report the optional `genie@automagik` marketplace plugin — never mutate it. + * Enabled → silent; disabled/absent → one optional-note line; settings + * unreadable → an unknown line. `enabledPlugins` maps `@` + * to a boolean in Claude Code's settings.json. + */ +function checkMarketplacePlugin(settingsPath: string): CheckResult[] { + const name = 'agent sync: marketplace plugin'; + let enabled: boolean | null; + try { + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as { enabledPlugins?: Record }; + const value = settings.enabledPlugins?.['genie@automagik']; + enabled = typeof value === 'boolean' ? value : false; + } catch { + enabled = null; + } + if (enabled === true) return []; + if (enabled === null) + return [{ name, status: 'pass', detail: 'genie@automagik state unknown (settings.json unreadable)' }]; + return [ + { + name, + status: 'pass', + detail: + 'genie@automagik not enabled — optional; `genie update` converges skills directly (never auto-re-enabled)', + }, + ]; +} + +function safeAgentChecks(agent: string, fn: () => CheckResult[]): CheckResult[] { + try { + return fn(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return [{ name: `agent sync: ${agent}`, status: 'warn', detail: `check failed: ${message}` }]; + } +} + +/** + * Per-agent agent-sync freshness. Pure read: reports whether each detected agent + * (claude/codex/hermes) carries current genie-managed skills, whether Claude's + * council.js is stamped for the current source root, and whether the Hermes link + * is correct — advising `genie update` when anything is stale. Exported with + * injectable paths so tests never touch the real HOME. + */ +export function checkAgentSync(paths: AgentSyncPaths = {}): CheckResult[] { + const genieHome = paths.genieHome ?? resolveGlobalGenieHome(); + const claudeDir = paths.claudeDir ?? resolveClaudeDir(); + const codexDir = paths.codexDir ?? resolveCodexDir(); + const hermesHome = paths.hermesHome ?? resolveHermesHome(); + const settingsPath = paths.settingsPath ?? join(claudeDir, 'settings.json'); + const source = resolveGenieSource(genieHome); + if (source.pluginRoot === null) { + return [ + { + name: 'agent sync', + status: 'pass', + detail: `no genie plugin source at ${join(genieHome, 'plugins', 'genie')} — run \`genie update\` after install`, + }, + ]; + } + const pluginRoot = source.pluginRoot; + return [ + ...safeAgentChecks('claude', () => checkClaudeSync(pluginRoot, claudeDir)), + ...safeAgentChecks('codex', () => checkCodexSync(pluginRoot, codexDir)), + ...safeAgentChecks('hermes', () => checkHermesSync(source.hermesRoot, hermesHome)), + ...safeAgentChecks('marketplace', () => checkMarketplacePlugin(settingsPath)), + ]; +} + // ============================================================================ // Entry point // ============================================================================ @@ -411,6 +665,7 @@ export async function doctorCommand(options?: { json?: boolean; fix?: boolean }) ...checkBun(), ...checkSubagentModelOverride(), ...checkV4Residue(), + ...checkAgentSync(), ...(await checkOmniHookTimeout()), ]; diff --git a/src/genie-commands/install.test.ts b/src/genie-commands/install.test.ts index 2d29d92d5..ad427f2a1 100644 --- a/src/genie-commands/install.test.ts +++ b/src/genie-commands/install.test.ts @@ -1,14 +1,18 @@ /** * Tests for the `genie install` post-install finisher. * - * The v4 cleanup engine is covered by legacy-v4.test.ts; here we only prove - * the command wiring: cleanup runs by default and --skip-v4-cleanup opts out. - * The runner is always injected — calling the real cleanup from a test would - * target the actual home directory. + * The v4 cleanup engine is covered by legacy-v4.test.ts and the agent-sync + * engine by agent-sync.test.ts; here we only prove the command wiring: v4 + * cleanup is gated by --skip-v4-cleanup, while the layout-normalize and + * agent-sync steps always run. Every seam is injected — calling the real + * cleanup/normalize/sync from a test would target the actual home directory. */ -import { describe, expect, test } from 'bun:test'; -import { installCommand } from './install.js'; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { installCommand, normalizeAuxLayout } from './install.js'; import type { cleanupV4 } from './legacy-v4.js'; function makeCleanupSpy(): { runner: typeof cleanupV4; calls: () => number } { @@ -28,15 +32,72 @@ function makeCleanupSpy(): { runner: typeof cleanupV4; calls: () => number } { } describe('installCommand', () => { - test('runs the v4 cleanup by default', () => { + test('runs v4 cleanup + layout normalize + agent sync by default', () => { const spy = makeCleanupSpy(); - installCommand({}, spy.runner); + let normalizeCalls = 0; + let syncCalls = 0; + installCommand( + {}, + spy.runner, + () => { + normalizeCalls += 1; + }, + () => { + syncCalls += 1; + }, + ); expect(spy.calls()).toBe(1); + expect(normalizeCalls).toBe(1); + expect(syncCalls).toBe(1); }); - test('--skip-v4-cleanup opts out of the cleanup', () => { + test('--skip-v4-cleanup skips ONLY the cleanup; normalize + sync still run', () => { const spy = makeCleanupSpy(); - installCommand({ skipV4Cleanup: true }, spy.runner); + let normalizeCalls = 0; + let syncCalls = 0; + installCommand( + { skipV4Cleanup: true }, + spy.runner, + () => { + normalizeCalls += 1; + }, + () => { + syncCalls += 1; + }, + ); expect(spy.calls()).toBe(0); + expect(normalizeCalls).toBe(1); + expect(syncCalls).toBe(1); + }); +}); + +describe('normalizeAuxLayout', () => { + let home: string; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'genie-normalize-')); + }); + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + }); + + test('moves bin/ to the canonical when the target is absent', () => { + mkdirSync(join(home, 'bin', 'plugins', 'genie'), { recursive: true }); + normalizeAuxLayout(home); + expect(existsSync(join(home, 'plugins', 'genie'))).toBe(true); + expect(existsSync(join(home, 'bin', 'plugins'))).toBe(false); + }); + + test('leaves the bin/ copy untouched when the canonical target already exists', () => { + mkdirSync(join(home, 'bin', 'skills'), { recursive: true }); + mkdirSync(join(home, 'skills', 'existing'), { recursive: true }); + normalizeAuxLayout(home); + expect(existsSync(join(home, 'bin', 'skills'))).toBe(true); + expect(existsSync(join(home, 'skills', 'existing'))).toBe(true); + }); + + test('is a non-throwing no-op when neither layout is present', () => { + expect(() => normalizeAuxLayout(home)).not.toThrow(); + expect(existsSync(join(home, 'plugins'))).toBe(false); }); }); diff --git a/src/genie-commands/install.ts b/src/genie-commands/install.ts index 13f199978..a088cf394 100644 --- a/src/genie-commands/install.ts +++ b/src/genie-commands/install.ts @@ -3,14 +3,25 @@ * * install.sh downloads, verifies, extracts, links and PATH-wires the binary in * bash, then hands off to `genie install` on the freshly linked binary for the - * finishing steps that belong in TypeScript. v5 keeps this deliberately thin; - * today the only finisher is the v4 legacy cleanup (see legacy-v4.ts). + * finishing steps that belong in TypeScript. v5 keeps this deliberately thin: + * v4 legacy cleanup, then the layout normalization + agent-sync phase that + * converges every detected coding agent from the canonical source root. * - * Opt out with `--skip-v4-cleanup` — install.sh forwards its CLI args, so - * `curl ... | bash -s -- --skip-v4-cleanup` reaches this flag. + * Opt out of the v4 cleanup with `--skip-v4-cleanup` — install.sh forwards its + * CLI args, so `curl ... | bash -s -- --skip-v4-cleanup` reaches this flag. The + * layout-normalize + agent-sync steps always run: install must converge agents. */ +import { existsSync, mkdirSync, renameSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; import { cleanupV4 } from './legacy-v4.js'; +import { runAgentSyncSafe } from './update.js'; + +const GENIE_HOME = process.env.GENIE_HOME || join(homedir(), '.genie'); + +/** Auxiliary trees managed by `genie update`'s syncAuxiliaryContent. */ +const AUX_LAYOUT_DIRS = ['plugins', 'skills', 'templates'] as const; export interface InstallOptions { /** Set by --skip-v4-cleanup: leave v4-era artifacts in place. */ @@ -18,15 +29,51 @@ export interface InstallOptions { } type V4CleanupRunner = typeof cleanupV4; +type NormalizeAuxLayoutFn = (genieHome: string) => void; +type AgentSyncRunner = () => void; + +/** + * Migrate the legacy `/bin/{plugins,skills,templates}` layout (install.sh + * pre-cutover) to the canonical `/{plugins,skills,templates}` that + * `genie update` and the agent-sync source resolver expect. Only moves a tree + * when the bin/ copy exists AND the canonical target does not — a plain + * same-filesystem `renameSync` is atomic, so readers never observe a partial + * state. Best-effort per directory: a failure on one never aborts the rest or + * the install. + */ +export function normalizeAuxLayout(genieHome: string): void { + for (const name of AUX_LAYOUT_DIRS) { + try { + const binPath = join(genieHome, 'bin', name); + const homePath = join(genieHome, name); + if (existsSync(binPath) && !existsSync(homePath)) { + mkdirSync(dirname(homePath), { recursive: true }); + renameSync(binPath, homePath); + } + } catch { + // layout normalization is best-effort; never fail the install over it. + } + } +} /** - * Run the post-install finishers. `runV4Cleanup` is an injection seam for - * tests — production callers pass options only. + * Run the post-install finishers. `runV4Cleanup` / `normalizeLayout` / `runSync` + * are injection seams for tests (mirrors runV4CleanupSafe) — production callers + * pass options only. */ -export function installCommand(options: InstallOptions = {}, runV4Cleanup: V4CleanupRunner = cleanupV4): void { +export function installCommand( + options: InstallOptions = {}, + runV4Cleanup: V4CleanupRunner = cleanupV4, + normalizeLayout: NormalizeAuxLayoutFn = normalizeAuxLayout, + runSync: AgentSyncRunner = runAgentSyncSafe, +): void { if (options.skipV4Cleanup) { console.log('\x1b[2mSkipping v4 legacy cleanup (--skip-v4-cleanup).\x1b[0m'); - return; + } else { + runV4Cleanup(); } - runV4Cleanup(); + // Always converge agents: fix the bin/ layout mismatch, then sync in-process + // (the freshly-linked binary is already this version, so no re-exec needed). + normalizeLayout(GENIE_HOME); + runSync(); } diff --git a/src/genie-commands/uninstall.test.ts b/src/genie-commands/uninstall.test.ts new file mode 100644 index 000000000..b2a549feb --- /dev/null +++ b/src/genie-commands/uninstall.test.ts @@ -0,0 +1,121 @@ +/** + * Tests for the agent-sync managed-asset removal in `genie uninstall`. + * + * The full uninstallCommand is interactive (confirm prompt) and targets the real + * home; here we only prove the manifest-verified collect/remove seams — the code + * that decides WHICH external agent assets uninstall is allowed to delete. Every + * path is injected into a tmpdir, so no test ever touches the real HOME. + */ + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { collectAgentSyncAssets, removeAgentSyncAssets } from './uninstall.js'; + +const MANAGED_MANIFEST = JSON.stringify({ managedBy: 'genie-agent-sync', version: '1', digest: 'x', syncedAt: 'now' }); + +describe('agent-sync managed-asset removal', () => { + let tmp: string; + let claudeDir: string; + let codexDir: string; + let hermesHome: string; + let genieHome: string; + + function targets() { + return { claudeDir, codexDir, hermesHome, genieHome }; + } + + function managedSkill(parent: string, name: string): string { + const dir = join(parent, name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'SKILL.md'), '# x\n', 'utf8'); + writeFileSync(join(dir, '.genie-sync.json'), MANAGED_MANIFEST, 'utf8'); + return dir; + } + + function unmanagedSkill(parent: string, name: string): string { + const dir = join(parent, name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'SKILL.md'), '# mine\n', 'utf8'); + return dir; + } + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'uninstall-agentsync-')); + claudeDir = join(tmp, 'claude'); + codexDir = join(tmp, 'codex'); + hermesHome = join(tmp, 'hermes'); + genieHome = join(tmp, 'genie'); + mkdirSync(join(genieHome, 'plugins', 'hermes-genie'), { recursive: true }); + }); + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + test('collects only genie-managed skill dirs; unmanaged ones are invisible', () => { + const managed = managedSkill(join(claudeDir, 'skills'), 'wish'); + const mine = unmanagedSkill(join(claudeDir, 'skills'), 'my-own'); + const codexManaged = managedSkill(join(codexDir, 'skills', '.curated'), 'review'); + + const paths = collectAgentSyncAssets(targets()).map((a) => a.path); + expect(paths).toContain(managed); + expect(paths).toContain(codexManaged); + expect(paths).not.toContain(mine); + }); + + test('removes managed skills but leaves unmanaged dirs intact', () => { + const managed = managedSkill(join(claudeDir, 'skills'), 'wish'); + const mine = unmanagedSkill(join(claudeDir, 'skills'), 'my-own'); + + const removed = removeAgentSyncAssets(targets()); + expect(removed).toContain(managed); + expect(existsSync(managed)).toBe(false); + expect(existsSync(mine)).toBe(true); + }); + + test('stamped council.js is removed; a non-stamped one at the same path is kept', () => { + mkdirSync(join(claudeDir, 'workflows'), { recursive: true }); + const council = join(claudeDir, 'workflows', 'council.js'); + writeFileSync(council, "export const meta = { name: 'council' };\nconst LENS_ROOT = '/x';\n", 'utf8'); + + let removed = removeAgentSyncAssets(targets()); + expect(removed).toContain(council); + expect(existsSync(council)).toBe(false); + + writeFileSync(council, 'console.log("my own workflow");\n', 'utf8'); + removed = removeAgentSyncAssets(targets()); + expect(removed).not.toContain(council); + expect(existsSync(council)).toBe(true); + }); + + test('hermes symlink into the genie home is removed; one pointing elsewhere is kept', () => { + mkdirSync(join(hermesHome, 'plugins'), { recursive: true }); + const link = join(hermesHome, 'plugins', 'genie'); + symlinkSync(join(genieHome, 'plugins', 'hermes-genie'), link); + + expect(removeAgentSyncAssets(targets())).toContain(link); + expect(existsSync(link)).toBe(false); + + const elsewhere = join(tmp, 'elsewhere'); + mkdirSync(elsewhere, { recursive: true }); + symlinkSync(elsewhere, link); + expect(removeAgentSyncAssets(targets())).not.toContain(link); + expect(existsSync(link)).toBe(true); + }); + + test('a real (non-symlink) dir at hermes plugins/genie is never removed', () => { + const link = join(hermesHome, 'plugins', 'genie'); + mkdirSync(link, { recursive: true }); + writeFileSync(join(link, 'plugin.json'), '{}', 'utf8'); + + expect(removeAgentSyncAssets(targets())).not.toContain(link); + expect(existsSync(link)).toBe(true); + }); + + test('empty / agentless home → nothing collected, nothing removed', () => { + expect(collectAgentSyncAssets(targets())).toEqual([]); + expect(removeAgentSyncAssets(targets())).toEqual([]); + }); +}); diff --git a/src/genie-commands/uninstall.ts b/src/genie-commands/uninstall.ts index 966c2cc76..c459dbb31 100644 --- a/src/genie-commands/uninstall.ts +++ b/src/genie-commands/uninstall.ts @@ -7,12 +7,14 @@ * - Remove symlinks from ~/.local/bin */ -import { existsSync, lstatSync, rmSync, unlinkSync } from 'node:fs'; +import { existsSync, lstatSync, readFileSync, readdirSync, readlinkSync, rmSync, statSync, unlinkSync } from 'node:fs'; import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { confirm } from '@inquirer/prompts'; +import { MANAGED_BY, MANIFEST_NAME } from '../lib/agent-sync.js'; import { hookScriptExists, removeHookScript } from '../lib/claude-settings.js'; import { contractPath, getGenieDir } from '../lib/genie-config.js'; +import { resolveClaudeDir, resolveCodexDir, resolveGenieHome, resolveHermesHome } from '../lib/genie-home.js'; import { orchestrationRulesPath } from './legacy-v4.js'; // Shared v4 legacy manifest owns this path — see legacy-v4.ts. @@ -59,6 +61,123 @@ function removeSymlinks(): string[] { return removed; } +// ============================================================================ +// agent-sync managed assets (wish agent-sync) — removed only when provably ours +// ============================================================================ + +// The managed-dir contract mirrored from src/lib/agent-sync.ts. Uninstall only +// removes what genie provably shipped: skill dirs carrying this manifest, the +// stamped council.js, and the hermes symlink that resolves into the genie home. +// Protocol identifiers imported from the engine — single source of truth. +const SYNC_MANIFEST_NAME = MANIFEST_NAME; +const SYNC_MANAGED_BY = MANAGED_BY; + +interface AgentSyncRemovalTargets { + claudeDir?: string; + codexDir?: string; + hermesHome?: string; + genieHome?: string; +} + +interface AgentSyncAsset { + agent: 'claude' | 'codex' | 'hermes'; + kind: 'skill' | 'workflow' | 'link'; + path: string; +} + +/** True only when `dir` carries a genie-agent-sync manifest — i.e. we shipped it. */ +function isManagedSkillDir(dir: string): boolean { + try { + const parsed = JSON.parse(readFileSync(join(dir, SYNC_MANIFEST_NAME), 'utf8')) as { managedBy?: string }; + return parsed.managedBy === SYNC_MANAGED_BY; + } catch { + return false; + } +} + +function collectManagedSkillDirs(parent: string, agent: AgentSyncAsset['agent'], out: AgentSyncAsset[]): void { + let names: string[]; + try { + names = readdirSync(parent); + } catch { + return; + } + for (const name of names) { + const dir = join(parent, name); + let isDir = false; + try { + isDir = statSync(dir).isDirectory(); + } catch { + isDir = false; + } + if (isDir && isManagedSkillDir(dir)) out.push({ agent, kind: 'skill', path: dir }); + } +} + +/** The stamped council.js carries `const LENS_ROOT = '…'` plus the `name: 'council'` meta. */ +function isStampedCouncil(councilPath: string): boolean { + try { + const content = readFileSync(councilPath, 'utf8'); + return content.includes('const LENS_ROOT =') && /name:\s*'council'/.test(content); + } catch { + return false; + } +} + +function collectStampedCouncil(claudeDir: string, out: AgentSyncAsset[]): void { + const councilPath = join(claudeDir, 'workflows', 'council.js'); + if (isStampedCouncil(councilPath)) out.push({ agent: 'claude', kind: 'workflow', path: councilPath }); +} + +/** The hermes plugin link is ours only when the symlink resolves into the genie home. */ +function collectHermesLink(hermesHome: string, genieHome: string, out: AgentSyncAsset[]): void { + const linkPath = join(hermesHome, 'plugins', 'genie'); + let stat: ReturnType; + try { + stat = lstatSync(linkPath); + } catch { + return; + } + if (!stat.isSymbolicLink()) return; + try { + const resolved = resolve(join(hermesHome, 'plugins'), readlinkSync(linkPath)); + const home = resolve(genieHome); + if (resolved === home || resolved.startsWith(`${home}/`)) + out.push({ agent: 'hermes', kind: 'link', path: linkPath }); + } catch { + /* unreadable symlink → leave it */ + } +} + +/** Read-only scan for genie-managed agent assets (skills, stamped council.js, hermes link). */ +export function collectAgentSyncAssets(targets: AgentSyncRemovalTargets = {}): AgentSyncAsset[] { + const claudeDir = targets.claudeDir ?? resolveClaudeDir(); + const codexDir = targets.codexDir ?? resolveCodexDir(); + const hermesHome = targets.hermesHome ?? resolveHermesHome(); + const genieHome = targets.genieHome ?? resolveGenieHome(); + const out: AgentSyncAsset[] = []; + collectManagedSkillDirs(join(claudeDir, 'skills'), 'claude', out); + collectManagedSkillDirs(join(codexDir, 'skills', '.curated'), 'codex', out); + collectStampedCouncil(claudeDir, out); + collectHermesLink(hermesHome, genieHome, out); + return out; +} + +/** Remove every asset {@link collectAgentSyncAssets} finds; returns the removed paths. */ +export function removeAgentSyncAssets(targets: AgentSyncRemovalTargets = {}): string[] { + const removed: string[] = []; + for (const asset of collectAgentSyncAssets(targets)) { + try { + if (asset.kind === 'skill') rmSync(asset.path, { recursive: true, force: true }); + else unlinkSync(asset.path); + removed.push(asset.path); + } catch { + // best-effort — a failed asset removal never blocks the rest of uninstall + } + } + return removed; +} + /** Try an uninstall step, logging success or warning on failure. */ function tryRemoveStep(label: string, successMsg: string, fn: () => void): void { console.log(`\x1b[2m${label}\x1b[0m`); @@ -79,6 +198,7 @@ function performUninstall( existingSymlinks: string[], genieDir: string, hasGenieDir: boolean, + hasAgentAssets: boolean, ): void { if (hasHookScript) { tryRemoveStep('Removing hook script...', 'Hook script removed', () => removeHookScript()); @@ -100,6 +220,14 @@ function performUninstall( ); } + // Managed agent assets live OUTSIDE the genie home (~/.claude, ~/.codex, + // ~/.hermes), so removing them is a distinct step from deleting ~/.genie. + if (hasAgentAssets) { + console.log('\x1b[2mRemoving synced agent assets...\x1b[0m'); + const removed = removeAgentSyncAssets(); + console.log(` \x1b[32m+\x1b[0m Removed ${removed.length} managed asset(s) (skills / council.js / hermes link)`); + } + if (hasGenieDir) { tryRemoveStep('Removing genie directory...', 'Directory removed', () => rmSync(genieDir, { recursive: true, force: true }), @@ -117,6 +245,8 @@ export async function uninstallCommand(): Promise { const hasHookScript = hookScriptExists(); const hasOrchestrationRules = existsSync(ORCHESTRATION_RULES_PATH); const existingSymlinks = SYMLINKS.filter((name) => isGenieSymlink(join(LOCAL_BIN, name))); + const agentAssets = collectAgentSyncAssets(); + const hasAgentAssets = agentAssets.length > 0; console.log('\x1b[2mThis will remove:\x1b[0m'); if (hasHookScript) console.log(' \x1b[31m-\x1b[0m Hook script (~/.claude/hooks/genie-bash-hook.sh)'); @@ -125,9 +255,13 @@ export async function uninstallCommand(): Promise { if (hasGenieDir) console.log(` \x1b[31m-\x1b[0m Genie directory (${contractPath(genieDir)})`); if (existingSymlinks.length > 0) console.log(` \x1b[31m-\x1b[0m Symlinks from ~/.local/bin: ${existingSymlinks.join(', ')}`); + if (hasAgentAssets) + console.log( + ` \x1b[31m-\x1b[0m Synced agent assets: ${agentAssets.length} managed skill dir(s)/council.js/hermes link across claude/codex/hermes`, + ); console.log(); - if (!hasGenieDir && !hasHookScript && !hasOrchestrationRules && existingSymlinks.length === 0) { + if (!hasGenieDir && !hasHookScript && !hasOrchestrationRules && existingSymlinks.length === 0 && !hasAgentAssets) { console.log('\x1b[33mNothing to uninstall.\x1b[0m'); console.log(); return; @@ -142,7 +276,7 @@ export async function uninstallCommand(): Promise { } console.log(); - performUninstall(hasHookScript, existingSymlinks, genieDir, hasGenieDir); + performUninstall(hasHookScript, existingSymlinks, genieDir, hasGenieDir, hasAgentAssets); console.log(); console.log('\x1b[32m+\x1b[0m Genie CLI uninstalled.'); diff --git a/src/genie-commands/update.ts b/src/genie-commands/update.ts index f241b8642..d6f252881 100644 --- a/src/genie-commands/update.ts +++ b/src/genie-commands/update.ts @@ -18,6 +18,7 @@ import { } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; +import { type AgentSyncReport, runAgentSync } from '../lib/agent-sync.js'; import { genieConfigExists, loadGenieConfig, saveGenieConfig } from '../lib/genie-config.js'; import { VERSION } from '../lib/version.js'; import { cleanupV4 } from './legacy-v4.js'; @@ -1393,6 +1394,15 @@ export interface UpdateCommandOptions { } export async function updateCommand(options: UpdateCommandOptions = {}): Promise { + // Sync-only fast path — the ONLY internal re-entry contract (no user-facing + // flag). The freshly-swapped binary (runFreshBinaryAgentSync) and the CC + // SessionStart hook both re-invoke `genie update` with GENIE_UPDATE_SYNC_ONLY=1 + // to converge agents with no network, manifest fetch, or channel persistence. + if (process.env.GENIE_UPDATE_SYNC_ONLY === '1') { + runAgentSyncSafe(); + return; + } + console.log(); console.log(`${colorize('\x1b[1m', '\x1b[0m', '🧞 Genie CLI Update')}`); console.log(`${colorize('\x1b[2m', '\x1b[0m', '────────────────────────────────────')}`); @@ -1423,6 +1433,9 @@ export async function updateCommand(options: UpdateCommandOptions = {}): Promise const installedVersion = resolveInstalledVersion(); if (shortCircuitIfCurrent(installedVersion, latestVersion)) { success(`Already up to date (v${normalizeVersion(installedVersion)}, channel ${channel})`); + // Update = converge everything, not just the binary: sync agents even when + // the installed binary is already at the latest version. + runAgentSyncSafe(); console.log(); return; } @@ -1488,6 +1501,9 @@ export async function updateCommand(options: UpdateCommandOptions = {}): Promise runV4CleanupSafe(); await runPostUpdateVerifySafe({ ...options, noRestart, noVerify }, diagnosticsCtx); + // Re-exec the freshly installed binary so the NEW version's agent-sync logic + // runs (this process is still the old binary). Non-fatal. + runFreshBinaryAgentSync(); } /** @@ -1505,6 +1521,104 @@ export function runV4CleanupSafe(runner: typeof cleanupV4 = cleanupV4): void { } } +/** + * Agent-sync phase — converge the genie skill set + the /council stamp into + * every detected coding agent (claude/codex/hermes) from the canonical source + * root. This is the ONE printer: the sync-only fast path, the already-current + * short-circuit, and `genie install` all funnel through here. + * + * Non-fatal by contract — an engine failure becomes a single advisory line, + * never a thrown error — and it always refreshes the `~/.genie/.last-agent-sync` + * throttle marker the SessionStart hook reads, so the marker records that the + * sync phase ran regardless of outcome. + * + * `sync` / `log` / `markerPath` / `now` are injection seams (mirrors + * runV4CleanupSafe + verifySwappedBinary) so the wiring is unit-testable without + * touching a real home directory. + */ +export interface RunAgentSyncSafeOptions { + /** Test seam: replaces the real agent-sync engine call. */ + sync?: typeof runAgentSync; + /** Test seam: sink for the compact summary; defaults to the module `log`. */ + log?: (line: string) => void; + /** Throttle marker path; defaults to `/.last-agent-sync`. */ + markerPath?: string; + /** Injectable clock for the marker timestamp. */ + now?: () => Date; +} + +export function runAgentSyncSafe(opts: RunAgentSyncSafeOptions = {}): void { + const emit = opts.log ?? log; + try { + const report = (opts.sync ?? runAgentSync)(); + for (const line of formatAgentSyncSummary(report)) emit(line); + } catch (err) { + emit(`agent sync failed: ${errMsg(err)} — will retry on the next genie update`); + } + touchAgentSyncMarker(opts.markerPath ?? join(GENIE_HOME, '.last-agent-sync'), (opts.now ?? (() => new Date()))()); +} + +/** Compact per-agent summary: detected + counts by action + advisories. */ +function formatAgentSyncSummary(report: AgentSyncReport): string[] { + if (report.source.pluginRoot === null) { + return ['agent-sync: no genie plugin source found (plugins/genie); skipped']; + } + const lines: string[] = []; + for (const agent of report.agents) { + if (!agent.detected) { + lines.push(`agent-sync: ${agent.agent} not detected — skipped`); + continue; + } + const counts = new Map(); + for (const skill of agent.skills) counts.set(skill.action, (counts.get(skill.action) ?? 0) + 1); + const parts = [...counts.entries()].map(([action, n]) => `${action} ${n}`); + for (const extra of agent.extras) parts.push(`${extra.kind} ${extra.action}`); + lines.push(`agent-sync: ${agent.agent} — ${parts.join(', ') || 'no changes'}`); + for (const advisory of agent.advisories) lines.push(` ${agent.agent}: ${advisory}`); + } + if (report.backupsDir !== null) lines.push(`agent-sync: backups saved to ${report.backupsDir}`); + return lines; +} + +/** Best-effort refresh of the SessionStart-hook throttle marker (ISO string). */ +function touchAgentSyncMarker(markerPath: string, now: Date): void { + try { + writeFileSync(markerPath, `${now.toISOString()}\n`); + } catch { + // the marker only optimizes the hook throttle; never fail the sync over it. + } +} + +/** + * After a real binary swap, re-exec the FRESHLY installed binary so the NEW + * version's agent-sync logic runs — the current process is still the OLD binary. + * Established pattern (see the `--version` probes at resolveInstalledVersion / + * verifySwappedBinary). The child hits the sync-only fast path via + * GENIE_UPDATE_SYNC_ONLY=1 and returns without any network. Non-fatal: a failed + * re-exec is a one-line advisory. `exec` is an injection seam so the wiring is + * unit-testable without spawning a binary. + */ +export interface FreshBinaryAgentSyncOptions { + exec?: (binaryPath: string, env: NodeJS.ProcessEnv) => void; +} + +export function runFreshBinaryAgentSync(opts: FreshBinaryAgentSyncOptions = {}): void { + const exec = + opts.exec ?? + ((binaryPath: string, env: NodeJS.ProcessEnv) => { + execFileSync(binaryPath, ['update'], { env, stdio: 'inherit', timeout: 120_000 }); + }); + try { + exec(join(GENIE_BIN, 'genie'), { ...process.env, GENIE_UPDATE_SYNC_ONLY: '1' }); + } catch (err) { + log(`agent sync (post-update) skipped: ${errMsg(err)}`); + } +} + +function errMsg(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + /** * Tarball delivery + binary swap. Linear flow extracted from `updateCommand` * to keep the command body readable: download → verify → extract → swap → diff --git a/src/lib/agent-sync.test.ts b/src/lib/agent-sync.test.ts new file mode 100644 index 000000000..7cba6cfdb --- /dev/null +++ b/src/lib/agent-sync.test.ts @@ -0,0 +1,676 @@ +/** + * Tests for the agent-sync engine. Everything runs inside a tmpdir: GENIE_HOME + * and every agent target dir are injected, so the real `$HOME` is never + * touched. afterEach removes the tmpdir. Real files, no mocks — the only seams + * are the injectable clock, the hermes binary override, and the hermes-enable + * exec spy (so no process is ever spawned). + * + * The stamp-parity test loads the shipped council-stamp.cjs through + * createRequire and asserts byte-identical output + identical skip semantics. + * + * Run with: bun test src/lib/agent-sync.test.ts + */ + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readlinkSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { + type AgentReport, + type AgentSyncOptions, + type AgentSyncReport, + computeDirDigest, + resolveGenieSource, + runAgentSync, + stampWorkflow, +} from './agent-sync'; + +const require = createRequire(import.meta.url); +const { stampCouncilWorkflow, PLACEHOLDER } = require('../../plugins/genie/scripts/council-stamp.cjs') as { + stampCouncilWorkflow: (opts: { templatePath: string; pluginRoot: string; targetDir: string }) => { + action: 'written' | 'skipped'; + targetPath: string; + }; + PLACEHOLDER: string; +}; + +const MANIFEST_NAME = '.genie-sync.json'; +const FIXED_NOW = () => new Date('2026-07-10T12:00:00.000Z'); +const TEMPLATE_BODY = `export const meta = { name: 'council' };\nconst LENS_ROOT = '${PLACEHOLDER}';\n`; + +// --------------------------------------------------------------------------- +// Fixture harness +// --------------------------------------------------------------------------- + +interface Fixture { + root: string; + genieHome: string; + pluginRoot: string; + claudeDir: string; + codexDir: string; + hermesHome: string; + hermesSource: string; +} + +let fixture: Fixture; + +function writeFile(path: string, content: string): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, 'utf8'); +} + +/** Materialize a source skill dir under the plugin root. */ +function writeSourceSkill(pluginRoot: string, name: string, files: Record): void { + for (const [rel, content] of Object.entries(files)) { + writeFile(join(pluginRoot, 'skills', name, rel), content); + } +} + +interface SetupOptions { + binLayout?: boolean; + version?: string | null; + skills?: Record>; + withTemplate?: boolean; +} + +function setup(opts: SetupOptions = {}): Fixture { + const root = mkdtempSync(join(tmpdir(), 'agent-sync-')); + const genieHome = join(root, 'genie'); + const pluginsBase = opts.binLayout ? join(genieHome, 'bin', 'plugins') : join(genieHome, 'plugins'); + const pluginRoot = join(pluginsBase, 'genie'); + const hermesSource = join(pluginsBase, 'hermes-genie'); + + const skills = opts.skills ?? { + alpha: { 'SKILL.md': '# alpha\n', 'references/a.md': 'alpha ref\n' }, + beta: { 'SKILL.md': '# beta\n' }, + }; + for (const [name, files] of Object.entries(skills)) writeSourceSkill(pluginRoot, name, files); + + if (opts.withTemplate ?? true) writeFile(join(pluginRoot, 'workflows', 'council.js'), TEMPLATE_BODY); + writeFile(join(hermesSource, 'plugin.json'), '{"name":"hermes-genie"}\n'); + + const version = opts.version === undefined ? '9.9.9' : opts.version; + if (version !== null) writeFile(join(genieHome, 'VERSION'), `${version}\n`); + + return { + root, + genieHome, + pluginRoot, + claudeDir: join(root, 'claude'), + codexDir: join(root, 'codex'), + hermesHome: join(root, 'hermes'), + hermesSource, + }; +} + +function present(dir: string): void { + mkdirSync(dir, { recursive: true }); +} + +/** Run the engine against the fixture with test-safe defaults (no PATH, no exec). */ +function run(extra: Partial = {}): AgentSyncReport { + return runAgentSync({ + genieHome: fixture.genieHome, + targets: { claude: fixture.claudeDir, codex: fixture.codexDir, hermes: fixture.hermesHome }, + hermesBinary: null, + now: FIXED_NOW, + log: () => undefined, + ...extra, + }); +} + +function agentReport(report: AgentSyncReport, agent: AgentReport['agent']): AgentReport { + const found = report.agents.find((entry) => entry.agent === agent); + if (!found) throw new Error(`no ${agent} report`); + return found; +} + +function skillAction(report: AgentReport, name: string): string | undefined { + return report.skills.find((skill) => skill.name === name)?.action; +} + +function extraAction(report: AgentReport, kind: string): string | undefined { + return report.extras.find((entry) => entry.kind === kind)?.action; +} + +function readManifest(dir: string): { managedBy: string; version: string | null; digest: string; syncedAt: string } { + return JSON.parse(readFileSync(join(dir, MANIFEST_NAME), 'utf8')); +} + +beforeEach(() => { + fixture = setup(); +}); + +afterEach(() => { + rmSync(fixture.root, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// Fresh create +// --------------------------------------------------------------------------- + +describe('fresh create', () => { + test('claude: skills created with manifests + council.js stamped', () => { + present(fixture.claudeDir); + const report = agentReport(run(), 'claude'); + + expect(report.detected).toBe(true); + expect(skillAction(report, 'alpha')).toBe('created'); + expect(skillAction(report, 'beta')).toBe('created'); + + const alphaDir = join(fixture.claudeDir, 'skills', 'alpha'); + expect(readFileSync(join(alphaDir, 'SKILL.md'), 'utf8')).toBe('# alpha\n'); + expect(readFileSync(join(alphaDir, 'references', 'a.md'), 'utf8')).toBe('alpha ref\n'); + expect(readManifest(alphaDir).managedBy).toBe('genie-agent-sync'); + expect(readManifest(alphaDir).version).toBe('9.9.9'); + expect(readManifest(alphaDir).syncedAt).toBe('2026-07-10T12:00:00.000Z'); + + expect(extraAction(report, 'stamp')).toBe('written'); + const council = readFileSync(join(fixture.claudeDir, 'workflows', 'council.js'), 'utf8'); + expect(council).toContain(`const LENS_ROOT = '${fixture.pluginRoot}';`); + expect(council).not.toContain(PLACEHOLDER); + }); + + test('codex: skills land under skills/.curated with a restart advisory', () => { + present(fixture.codexDir); + const report = agentReport(run(), 'codex'); + + expect(report.detected).toBe(true); + expect(existsSync(join(fixture.codexDir, 'skills', '.curated', 'alpha', 'SKILL.md'))).toBe(true); + expect(readManifest(join(fixture.codexDir, 'skills', '.curated', 'alpha')).managedBy).toBe('genie-agent-sync'); + expect(report.advisories).toContain('restart Codex to pick up updated skills'); + }); + + test('hermes: symlink created + enable exec fired exactly once', () => { + present(fixture.hermesHome); + const enableCalls: string[][] = []; + const report = agentReport( + run({ hermesBinary: '/fake/bin/hermes', execHermesEnable: (args) => enableCalls.push(args) }), + 'hermes', + ); + + expect(report.detected).toBe(true); + const link = join(fixture.hermesHome, 'plugins', 'genie'); + expect(lstatSync(link).isSymbolicLink()).toBe(true); + expect(readlinkSync(link)).toBe(fixture.hermesSource); + expect(extraAction(report, 'symlink')).toBe('created'); + expect(enableCalls).toEqual([['plugins', 'enable', 'genie']]); + expect(extraAction(report, 'enable')).toBe('ran'); + }); + + test('report carries the resolved source metadata', () => { + present(fixture.claudeDir); + const report = run(); + expect(report.source.pluginRoot).toBe(fixture.pluginRoot); + expect(report.source.hermesRoot).toBe(fixture.hermesSource); + expect(report.source.version).toBe('9.9.9'); + }); +}); + +// --------------------------------------------------------------------------- +// Idempotency +// --------------------------------------------------------------------------- + +describe('idempotent re-run', () => { + test('everything unchanged, stamp skipped, enable not re-fired, no backups', () => { + present(fixture.claudeDir); + present(fixture.codexDir); + present(fixture.hermesHome); + const enableCalls: string[][] = []; + const opts: Partial = { + hermesBinary: '/fake/bin/hermes', + execHermesEnable: (args) => enableCalls.push(args), + }; + + run(opts); + const second = run(opts); + + const claude = agentReport(second, 'claude'); + expect(skillAction(claude, 'alpha')).toBe('unchanged'); + expect(skillAction(claude, 'beta')).toBe('unchanged'); + expect(extraAction(claude, 'stamp')).toBe('skipped'); + + const codex = agentReport(second, 'codex'); + expect(skillAction(codex, 'alpha')).toBe('unchanged'); + expect(codex.advisories).not.toContain('restart Codex to pick up updated skills'); + + const hermes = agentReport(second, 'hermes'); + expect(extraAction(hermes, 'symlink')).toBe('unchanged'); + + expect(enableCalls).toHaveLength(1); // fired on the first run only + expect(second.backupsDir).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Update on source change +// --------------------------------------------------------------------------- + +describe('source change', () => { + test('a changed source skill is updated and the manifest digest advances', () => { + present(fixture.claudeDir); + run(); + const before = readManifest(join(fixture.claudeDir, 'skills', 'alpha')).digest; + + writeFile(join(fixture.pluginRoot, 'skills', 'alpha', 'SKILL.md'), '# alpha v2\n'); + const report = agentReport(run(), 'claude'); + + expect(skillAction(report, 'alpha')).toBe('updated'); + expect(skillAction(report, 'beta')).toBe('unchanged'); + const alphaDir = join(fixture.claudeDir, 'skills', 'alpha'); + expect(readFileSync(join(alphaDir, 'SKILL.md'), 'utf8')).toBe('# alpha v2\n'); + expect(readManifest(alphaDir).digest).not.toBe(before); + }); +}); + +// --------------------------------------------------------------------------- +// Adopt-with-backup +// --------------------------------------------------------------------------- + +describe('auto-adopt with backup', () => { + test('a user-modified managed skill is backed up then rewritten', () => { + present(fixture.claudeDir); + run(); + const alphaDir = join(fixture.claudeDir, 'skills', 'alpha'); + writeFile(join(alphaDir, 'SKILL.md'), '# hand-edited\n'); + + const report = run(); + const claude = agentReport(report, 'claude'); + expect(skillAction(claude, 'alpha')).toBe('adopted'); + expect(readFileSync(join(alphaDir, 'SKILL.md'), 'utf8')).toBe('# alpha\n'); // restored from source + + expect(report.backupsDir).not.toBeNull(); + const backup = join(report.backupsDir as string, 'claude', 'alpha', 'SKILL.md'); + expect(readFileSync(backup, 'utf8')).toBe('# hand-edited\n'); // the edit is preserved + }); + + test('an unmanaged same-name dir (no manifest) is adopted with a backup', () => { + present(fixture.claudeDir); + const alphaDir = join(fixture.claudeDir, 'skills', 'alpha'); + writeFile(join(alphaDir, 'SKILL.md'), '# pre-existing unmanaged\n'); + + const report = run(); + const claude = agentReport(report, 'claude'); + expect(skillAction(claude, 'alpha')).toBe('adopted'); + expect(existsSync(join(alphaDir, MANIFEST_NAME))).toBe(true); // now managed + expect(readFileSync(join(alphaDir, 'SKILL.md'), 'utf8')).toBe('# alpha\n'); + + const backup = join(report.backupsDir as string, 'claude', 'alpha', 'SKILL.md'); + expect(readFileSync(backup, 'utf8')).toBe('# pre-existing unmanaged\n'); + }); + + test('a target dir with a corrupt manifest is adopted with a backup, never crashes', () => { + present(fixture.claudeDir); + const alphaDir = join(fixture.claudeDir, 'skills', 'alpha'); + writeFile(join(alphaDir, 'SKILL.md'), '# pre-existing with corrupt manifest\n'); + writeFile(join(alphaDir, MANIFEST_NAME), '{ this is not valid json '); // unparsable → treated as unmanaged + + const report = run(); + const claude = agentReport(report, 'claude'); + expect(skillAction(claude, 'alpha')).toBe('adopted'); + expect(readFileSync(join(alphaDir, 'SKILL.md'), 'utf8')).toBe('# alpha\n'); // restored from source + + expect(report.backupsDir).not.toBeNull(); + const backup = join(report.backupsDir as string, 'claude', 'alpha', 'SKILL.md'); + expect(readFileSync(backup, 'utf8')).toBe('# pre-existing with corrupt manifest\n'); // the edit is preserved + }); + + test('a dir genie never shipped is left completely untouched', () => { + present(fixture.claudeDir); + const customDir = join(fixture.claudeDir, 'skills', 'my-custom-skill'); + writeFile(join(customDir, 'SKILL.md'), '# mine\n'); + + const report = run(); + const claude = agentReport(report, 'claude'); + expect(skillAction(claude, 'my-custom-skill')).toBeUndefined(); + expect(readFileSync(join(customDir, 'SKILL.md'), 'utf8')).toBe('# mine\n'); + expect(existsSync(join(customDir, MANIFEST_NAME))).toBe(false); + expect(report.backupsDir).toBeNull(); // nothing was touched, so no backup root + }); +}); + +// --------------------------------------------------------------------------- +// Orphan removal +// --------------------------------------------------------------------------- + +describe('managed orphan handling', () => { + test('an unmodified managed orphan is backed up then removed', () => { + present(fixture.claudeDir); + run(); // creates alpha + beta as managed + rmSync(join(fixture.pluginRoot, 'skills', 'beta'), { recursive: true, force: true }); // beta leaves source + + const report = run(); + const claude = agentReport(report, 'claude'); + expect(skillAction(claude, 'beta')).toBe('removed'); + expect(existsSync(join(fixture.claudeDir, 'skills', 'beta'))).toBe(false); + + const backup = join(report.backupsDir as string, 'claude', 'beta', 'SKILL.md'); + expect(readFileSync(backup, 'utf8')).toBe('# beta\n'); + }); + + test('a modified managed orphan is kept with an advisory, never deleted', () => { + present(fixture.claudeDir); + run(); + const betaDir = join(fixture.claudeDir, 'skills', 'beta'); + writeFile(join(betaDir, 'SKILL.md'), '# beta hand-edited\n'); // now diverges from manifest + rmSync(join(fixture.pluginRoot, 'skills', 'beta'), { recursive: true, force: true }); + + const report = run(); + const claude = agentReport(report, 'claude'); + expect(skillAction(claude, 'beta')).toBe('kept-modified-orphan'); + expect(existsSync(join(betaDir, 'SKILL.md'))).toBe(true); + expect(claude.advisories.some((line) => line.includes('kept modified orphan beta'))).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Missing agents +// --------------------------------------------------------------------------- + +describe('undetected agents', () => { + test('a missing agent dir yields detected:false and zero writes', () => { + // no target dirs created at all + const report = run(); + for (const agent of ['claude', 'codex', 'hermes'] as const) { + expect(agentReport(report, agent).detected).toBe(false); + } + expect(existsSync(fixture.claudeDir)).toBe(false); + expect(existsSync(fixture.codexDir)).toBe(false); + expect(existsSync(join(fixture.hermesHome, 'plugins'))).toBe(false); + expect(report.backupsDir).toBeNull(); + }); + + test('a null plugin source yields an empty report and no agents', () => { + const emptyHome = join(fixture.root, 'empty-genie'); + mkdirSync(emptyHome, { recursive: true }); + const report = runAgentSync({ genieHome: emptyHome, hermesBinary: null, log: () => undefined }); + expect(report.source.pluginRoot).toBeNull(); + expect(report.agents).toHaveLength(0); + expect(report.backupsDir).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Report fidelity on a late adapter failure +// --------------------------------------------------------------------------- + +describe('partial report preservation on late failure', () => { + test('a throw after skills are collected keeps the partial report, not a fresh empty one', () => { + present(fixture.claudeDir); + // Force a late throw INSIDE syncClaude, AFTER skills are synced: make the + // workflows target a file so the stamp step's mkdirSync throws. + writeFile(join(fixture.claudeDir, 'workflows'), 'not a directory\n'); + + const claude = agentReport(run(), 'claude'); + + // detection + the skill lines collected before the throw all survive... + expect(claude.detected).toBe(true); + expect(skillAction(claude, 'alpha')).toBe('created'); + expect(skillAction(claude, 'beta')).toBe('created'); + // ...and the failure is surfaced as an advisory rather than discarded + expect(claude.advisories.some((line) => line.startsWith('claude sync failed'))).toBe(true); + // the writes really landed on disk before the throw + expect(readFileSync(join(fixture.claudeDir, 'skills', 'alpha', 'SKILL.md'), 'utf8')).toBe('# alpha\n'); + }); +}); + +// --------------------------------------------------------------------------- +// Digest properties +// --------------------------------------------------------------------------- + +describe('computeDirDigest', () => { + test('is stable regardless of directory entry creation order', () => { + const dirA = join(fixture.root, 'digest-a'); + writeFile(join(dirA, 'b.md'), 'B'); + writeFile(join(dirA, 'a.md'), 'A'); + writeFile(join(dirA, 'nested', 'c.md'), 'C'); + + const dirB = join(fixture.root, 'digest-b'); + writeFile(join(dirB, 'nested', 'c.md'), 'C'); + writeFile(join(dirB, 'a.md'), 'A'); + writeFile(join(dirB, 'b.md'), 'B'); + + expect(computeDirDigest(dirA)).toBe(computeDirDigest(dirB)); + }); + + test('excludes the manifest so a manifest does not change the digest', () => { + const dir = join(fixture.root, 'digest-manifest'); + writeFile(join(dir, 'SKILL.md'), 'body'); + const before = computeDirDigest(dir); + writeFile(join(dir, MANIFEST_NAME), '{"managedBy":"genie-agent-sync","digest":"x"}'); + expect(computeDirDigest(dir)).toBe(before); + }); + + test('changes when file content changes', () => { + const dir = join(fixture.root, 'digest-content'); + writeFile(join(dir, 'SKILL.md'), 'one'); + const before = computeDirDigest(dir); + writeFile(join(dir, 'SKILL.md'), 'two'); + expect(computeDirDigest(dir)).not.toBe(before); + }); +}); + +// --------------------------------------------------------------------------- +// Crash-recovery staging +// --------------------------------------------------------------------------- + +describe('staging cleanup', () => { + test('stale genie-sync staging debris from a crashed run is pre-cleaned', () => { + present(fixture.claudeDir); + const staleStage = join(fixture.claudeDir, 'skills', 'alpha.genie-sync.staging'); + writeFile(join(staleStage, 'garbage.txt'), 'left over from a crash'); + + const report = agentReport(run(), 'claude'); + expect(skillAction(report, 'alpha')).toBe('created'); + expect(existsSync(staleStage)).toBe(false); + const alphaDir = join(fixture.claudeDir, 'skills', 'alpha'); + expect(existsSync(join(alphaDir, 'garbage.txt'))).toBe(false); + expect(readFileSync(join(alphaDir, 'SKILL.md'), 'utf8')).toBe('# alpha\n'); + }); + + test('HIGH-1: a user .old backup sibling survives sync untouched', () => { + // Common manual-backup convention: `mv alpha alpha.old` before letting genie + // resync alpha. The old `.old` staging suffix would have DELETED this on the + // first sync; the collision-proof suffix must leave it completely intact. + present(fixture.claudeDir); + const skillsDir = join(fixture.claudeDir, 'skills'); + const userBackup = join(skillsDir, 'alpha.old'); + writeFile(join(userBackup, 'SKILL.md'), '# user manual backup — do not delete\n'); + // genie's own crashed-run staging debris sitting next to the same skill + writeFile(join(skillsDir, 'alpha.genie-sync.staging', 'garbage.txt'), 'crash debris\n'); + + const report = agentReport(run(), 'claude'); + + // the real skill is (re)created from source + expect(skillAction(report, 'alpha')).toBe('created'); + // the user's sibling survives on disk with its content intact + expect(existsSync(userBackup)).toBe(true); + expect(readFileSync(join(userBackup, 'SKILL.md'), 'utf8')).toBe('# user manual backup — do not delete\n'); + // and it never appears anywhere as a removed skill + const removed = report.skills.filter((skill) => skill.action === 'removed').map((skill) => skill.name); + expect(removed).not.toContain('alpha.old'); + // genie's own crashed-run staging debris IS cleaned + expect(existsSync(join(skillsDir, 'alpha.genie-sync.staging'))).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Hermes edge cases +// --------------------------------------------------------------------------- + +describe('hermes linking', () => { + test('a real dir at the link target is adopted with a backup', () => { + const link = join(fixture.hermesHome, 'plugins', 'genie'); + writeFile(join(link, 'stale-real-dir.txt'), 'was a real dir\n'); + + const report = run(); + const hermes = agentReport(report, 'hermes'); + expect(extraAction(hermes, 'symlink')).toBe('adopted'); + expect(lstatSync(link).isSymbolicLink()).toBe(true); + expect(readlinkSync(link)).toBe(fixture.hermesSource); + + const backup = join(report.backupsDir as string, 'hermes', 'plugins-genie', 'stale-real-dir.txt'); + expect(readFileSync(backup, 'utf8')).toBe('was a real dir\n'); + }); + + test('the adopt transition (real dir → symlink) also fires enable exactly once', () => { + // A real dir where the link belongs, plus a detected binary: adopting it is + // "newly linked" just like a fresh create, so enable must fire once. + const link = join(fixture.hermesHome, 'plugins', 'genie'); + writeFile(join(link, 'stale-real-dir.txt'), 'was a real dir\n'); + const enableCalls: string[][] = []; + + const hermes = agentReport( + run({ hermesBinary: '/fake/bin/hermes', execHermesEnable: (args) => enableCalls.push(args) }), + 'hermes', + ); + expect(extraAction(hermes, 'symlink')).toBe('adopted'); + expect(enableCalls).toEqual([['plugins', 'enable', 'genie']]); + expect(extraAction(hermes, 'enable')).toBe('ran'); + }); + + test('a foreign symlink (dev checkout) is left alone with an advisory', () => { + const foreign = join(fixture.root, 'dev-checkout'); + mkdirSync(foreign, { recursive: true }); + const link = join(fixture.hermesHome, 'plugins', 'genie'); + mkdirSync(dirname(link), { recursive: true }); + symlinkSync(foreign, link); + + const hermes = agentReport(run(), 'hermes'); + expect(extraAction(hermes, 'symlink')).toBe('skipped-unmanaged-kept'); + expect(readlinkSync(link)).toBe(foreign); // untouched + expect(hermes.advisories.some((line) => line.includes('points elsewhere'))).toBe(true); + }); + + test('an active sticky profile also gets its plugins/genie link', () => { + present(fixture.hermesHome); + writeFile(join(fixture.hermesHome, 'active_profile'), 'work\n'); + + run(); + const profileLink = join(fixture.hermesHome, 'profiles', 'work', 'plugins', 'genie'); + expect(lstatSync(profileLink).isSymbolicLink()).toBe(true); + expect(readlinkSync(profileLink)).toBe(fixture.hermesSource); + }); + + test('detection via binary alone creates the link under a fresh hermes home', () => { + // hermesHome does not exist; only a binary override is provided + const enableCalls: string[][] = []; + const hermes = agentReport( + run({ hermesBinary: '/fake/bin/hermes', execHermesEnable: (args) => enableCalls.push(args) }), + 'hermes', + ); + expect(hermes.detected).toBe(true); + expect(lstatSync(join(fixture.hermesHome, 'plugins', 'genie')).isSymbolicLink()).toBe(true); + expect(enableCalls).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// Codex .system protection +// --------------------------------------------------------------------------- + +describe('codex .system', () => { + test('the OpenAI-owned .system tree is never enumerated or touched', () => { + present(fixture.codexDir); + const systemSkill = join(fixture.codexDir, 'skills', '.system', 'openai-builtin', 'SKILL.md'); + writeFile(systemSkill, '# openai builtin\n'); + + const report = agentReport(run(), 'codex'); + expect(skillAction(report, 'openai-builtin')).toBeUndefined(); + expect(readFileSync(systemSkill, 'utf8')).toBe('# openai builtin\n'); + expect(existsSync(join(systemSkill, '..', MANIFEST_NAME))).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Source resolution +// --------------------------------------------------------------------------- + +describe('resolveGenieSource', () => { + test('falls back to the bin/plugins layout', () => { + rmSync(fixture.root, { recursive: true, force: true }); + fixture = setup({ binLayout: true }); + present(fixture.claudeDir); + + const report = run(); + expect(report.source.pluginRoot).toBe(fixture.pluginRoot); + expect(report.source.pluginRoot).toContain(join('bin', 'plugins', 'genie')); + expect(skillAction(agentReport(report, 'claude'), 'alpha')).toBe('created'); + }); + + test('reads the version from the VERSION file, or null when absent', () => { + expect(resolveGenieSource(fixture.genieHome).version).toBe('9.9.9'); + + rmSync(fixture.root, { recursive: true, force: true }); + fixture = setup({ version: null }); + expect(resolveGenieSource(fixture.genieHome).version).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Stamp parity with the shipped .cjs +// --------------------------------------------------------------------------- + +describe('stampWorkflow parity with council-stamp.cjs', () => { + test('produces byte-identical output and identical skip semantics', () => { + const templatePath = join(fixture.root, 'council.template.js'); + writeFileSync(templatePath, TEMPLATE_BODY, 'utf8'); + const pluginRoot = '/opt/some/plugins/genie'; + const tsDir = join(fixture.root, 'ts-out'); + const cjsDir = join(fixture.root, 'cjs-out'); + + const tsWrite = stampWorkflow({ templatePath, pluginRoot, targetDir: tsDir }); + const cjsWrite = stampCouncilWorkflow({ templatePath, pluginRoot, targetDir: cjsDir }); + expect(tsWrite.action).toBe('written'); + expect(cjsWrite.action).toBe('written'); + expect(readFileSync(join(tsDir, 'council.js'), 'utf8')).toBe(readFileSync(join(cjsDir, 'council.js'), 'utf8')); + + // idempotent skip on the unchanged re-run, for both implementations + expect(stampWorkflow({ templatePath, pluginRoot, targetDir: tsDir }).action).toBe('skipped'); + expect(stampCouncilWorkflow({ templatePath, pluginRoot, targetDir: cjsDir }).action).toBe('skipped'); + }); + + test('claude stamp reports unavailable (never throws) when the template is missing', () => { + rmSync(fixture.root, { recursive: true, force: true }); + fixture = setup({ withTemplate: false }); + present(fixture.claudeDir); + + const claude = agentReport(run(), 'claude'); + expect(extraAction(claude, 'stamp')).toBe('unavailable'); + expect(existsSync(join(fixture.claudeDir, 'workflows', 'council.js'))).toBe(false); + expect(skillAction(claude, 'alpha')).toBe('created'); // skills still synced + }); +}); + +// --------------------------------------------------------------------------- +// Digest guards a real tree (belt-and-suspenders on readdir usage) +// --------------------------------------------------------------------------- + +describe('orphan detection ignores non-managed siblings', () => { + test('staging siblings and unmanaged dirs never appear as removed and survive on disk', () => { + present(fixture.claudeDir); + run(); + const skillsDir = join(fixture.claudeDir, 'skills'); + // an unmanaged sibling + a genie-sync staging sibling should both be ignored by removal + writeFile(join(skillsDir, 'unmanaged', 'SKILL.md'), '# unmanaged\n'); + writeFile(join(skillsDir, 'beta.genie-sync.prev', 'left.txt'), 'staging debris\n'); + + const claude = agentReport(run(), 'claude'); + const removed = claude.skills.filter((skill) => skill.action === 'removed').map((skill) => skill.name); + expect(removed).toHaveLength(0); + // both survive on disk, not merely absent from the report + expect(existsSync(join(skillsDir, 'unmanaged', 'SKILL.md'))).toBe(true); + expect(existsSync(join(skillsDir, 'beta.genie-sync.prev', 'left.txt'))).toBe(true); + }); +}); diff --git a/src/lib/agent-sync.ts b/src/lib/agent-sync.ts new file mode 100644 index 000000000..498a4e232 --- /dev/null +++ b/src/lib/agent-sync.ts @@ -0,0 +1,633 @@ +/** + * agent-sync engine — converge the genie skill set (and the /council workflow + * stamp) into every detected coding agent from one canonical source root. + * + * The source of truth is `/plugins/genie` (fallback + * `/bin/plugins/genie`), refreshed atomically by `genie update`. + * This module is pure library: it takes injectable directories + seams and + * returns a structured report; it wires into no command (that is G2's scope). + * + * Managed-dir contract: every skill dir this engine writes carries a + * `.genie-sync.json` manifest recording the content digest it was synced from. + * That manifest is what lets a re-run tell "unchanged" from "the user edited + * this" from "we never shipped this name" — and it is what makes every + * destructive step (adopt, remove) back up first, so nothing is ever lost. + * + * Everything is non-fatal: an adapter that throws is caught per-agent and + * reported as an advisory; {@link runAgentSync} never throws for agent-level + * failures. + */ + +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + type Dirent, + type Stats, + cpSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + readlinkSync, + renameSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { resolveClaudeDir, resolveCodexDir, resolveGenieHome, resolveHermesHome } from './genie-home.js'; + +// ============================================================================ +// Constants +// ============================================================================ + +/** Placeholder the /council template carries for its lens-card root. */ +const PLACEHOLDER = '__GENIE_LENS_ROOT__'; +/** Stamped/synced workflow filename. Exported: doctor/uninstall key their checks on it. */ +export const TARGET_NAME = 'council.js'; +/** Manifest marker written into every managed skill dir. Exported: single source of truth. */ +export const MANIFEST_NAME = '.genie-sync.json'; +/** `managedBy` value that certifies a dir as one this engine owns. Exported: single source of truth. */ +export const MANAGED_BY = 'genie-agent-sync'; +/** Skill actions that represent an actual write to the target. */ +const WRITE_ACTIONS = new Set(['created', 'updated', 'adopted', 'removed']); +/** + * Collision-proof staging suffixes for atomic managed-dir writes. Chosen so no + * human backup convention (e.g. `mv review review.old`, or a `review.new`) can + * ever collide with genie's staging tree — the pre-clean rmSync then only ever + * removes genie's own crashed-run debris, never a user's sibling dir. + */ +const STAGING_SUFFIX = '.genie-sync.staging'; +const PREV_SUFFIX = '.genie-sync.prev'; + +// ============================================================================ +// Public types +// ============================================================================ + +export interface AgentSyncOptions { + /** Global genie root; defaults to {@link resolveGenieHome}. */ + genieHome?: string; + /** Per-agent target dir overrides (tests inject tmpdirs here). */ + targets?: { claude?: string; codex?: string; hermes?: string }; + /** + * Hermes binary override for enable-exec detection. A non-null string forces + * "detected"; `null` explicitly skips exec; `undefined` probes PATH. + */ + hermesBinary?: string | null; + /** Injectable exec seam for `hermes plugins enable genie` (default execFileSync). */ + execHermesEnable?: (args: string[]) => void; + /** Structured log sink (no console in src). */ + log?: (line: string) => void; + /** Injectable clock for manifest + backup timestamps. */ + now?: () => Date; +} + +export type SkillAction = + | 'created' + | 'updated' + | 'unchanged' + | 'adopted' + | 'removed' + | 'skipped-unmanaged-kept' + | 'kept-modified-orphan'; + +export interface AgentReport { + agent: 'claude' | 'codex' | 'hermes'; + detected: boolean; + skills: Array<{ name: string; action: SkillAction; detail?: string }>; + /** Non-skill outcomes: stamp / symlink / enable lines. */ + extras: Array<{ kind: string; action: string; detail?: string }>; + advisories: string[]; +} + +export interface AgentSyncReport { + source: GenieSource; + agents: AgentReport[]; + backupsDir: string | null; +} + +export interface GenieSource { + pluginRoot: string | null; + hermesRoot: string | null; + version: string | null; +} + +// ============================================================================ +// Internal types +// ============================================================================ + +interface SyncManifest { + managedBy: 'genie-agent-sync'; + version: string | null; + digest: string; + syncedAt: string; +} + +interface RunContext { + pluginRoot: string; + hermesRoot: string | null; + version: string | null; + now: () => Date; + targets: { claude: string; codex: string; hermes: string }; + /** Copy `existingDir` into the run's backup root and return the backup path. */ + backupInto: (agent: string, name: string, existingDir: string) => string; + /** The backup root path, or null when nothing has been backed up this run. */ + backupsDirIfCreated: () => string | null; +} + +interface SourceSkill { + name: string; + dir: string; +} + +// ============================================================================ +// Source resolution +// ============================================================================ + +/** + * Resolve the canonical source roots under `genieHome`: + * - pluginRoot: first existing of `plugins/genie`, `bin/plugins/genie`, + * - hermesRoot: sibling `hermes-genie` in the same plugins layout, + * - version: trimmed `VERSION` file, else null. + */ +export function resolveGenieSource(genieHome: string): GenieSource { + const pluginRoot = firstExisting([join(genieHome, 'plugins', 'genie'), join(genieHome, 'bin', 'plugins', 'genie')]); + const hermesRoot = firstExisting([ + join(genieHome, 'plugins', 'hermes-genie'), + join(genieHome, 'bin', 'plugins', 'hermes-genie'), + ]); + const version = readTrimmed(join(genieHome, 'VERSION')) || null; + return { pluginRoot, hermesRoot, version }; +} + +function firstExisting(paths: string[]): string | null { + for (const path of paths) { + if (existsSync(path)) return path; + } + return null; +} + +// ============================================================================ +// Digest — a stable fingerprint of a directory's file content +// ============================================================================ + +/** + * sha256 over the sorted `(relpath, sha256(content))` pairs of every file in + * `dir`. The manifest is always excluded (its digest field would otherwise be + * self-referential); callers may exclude additional relpaths. Directory entry + * order does not affect the result. + */ +export function computeDirDigest(dir: string, exclude?: Set): string { + const excluded = new Set(exclude ?? []); + excluded.add(MANIFEST_NAME); + const files: Array<{ rel: string; hash: string }> = []; + collectFileHashes(dir, dir, excluded, files); + files.sort(byRel); + const digest = createHash('sha256'); + for (const file of files) { + digest.update(file.rel); + digest.update('\0'); + digest.update(file.hash); + digest.update('\0'); + } + return digest.digest('hex'); +} + +function byRel(a: { rel: string }, b: { rel: string }): number { + if (a.rel < b.rel) return -1; + if (a.rel > b.rel) return 1; + return 0; +} + +function collectFileHashes( + root: string, + current: string, + excluded: Set, + out: Array<{ rel: string; hash: string }>, +): void { + for (const entry of readdirSync(current, { withFileTypes: true })) { + const abs = join(current, entry.name); + const rel = relative(root, abs); + if (excluded.has(rel)) continue; + const kind = classifyEntry(abs, entry); + if (kind === 'dir') collectFileHashes(root, abs, excluded, out); + else if (kind === 'file') out.push({ rel, hash: hashFile(abs) }); + } +} + +/** Resolve a dirent to file/dir/skip, following symlinks and dropping broken ones. */ +function classifyEntry(abs: string, entry: Dirent): 'file' | 'dir' | 'skip' { + if (entry.isFile()) return 'file'; + if (entry.isDirectory()) return 'dir'; + if (entry.isSymbolicLink()) { + try { + return statSync(abs).isDirectory() ? 'dir' : 'file'; + } catch { + return 'skip'; + } + } + return 'skip'; +} + +function hashFile(path: string): string { + return createHash('sha256').update(readFileSync(path)).digest('hex'); +} + +// ============================================================================ +// Manifest + atomic managed-dir writes +// ============================================================================ + +function readManifest(dir: string): SyncManifest | null { + try { + const parsed = JSON.parse(readFileSync(join(dir, MANIFEST_NAME), 'utf8')) as Partial; + if (parsed.managedBy === MANAGED_BY && typeof parsed.digest === 'string') { + return { + managedBy: MANAGED_BY, + version: parsed.version ?? null, + digest: parsed.digest, + syncedAt: typeof parsed.syncedAt === 'string' ? parsed.syncedAt : '', + }; + } + } catch { + // absent, unreadable, or unparsable → treat as unmanaged + } + return null; +} + +function writeManifest(dir: string, manifest: SyncManifest): void { + writeFileSync(join(dir, MANIFEST_NAME), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); +} + +function buildManifest(ctx: RunContext, digest: string): SyncManifest { + return { managedBy: MANAGED_BY, version: ctx.version, digest, syncedAt: ctx.now().toISOString() }; +} + +/** + * Copy `sourceDir` into `destDir` and stamp a fresh manifest, atomically. Stage + * to `.genie-sync.staging`, rename the live tree to `.genie-sync.prev`, + * rename staging into place, then delete the prev tree. The suffixes are + * collision-proof (see {@link STAGING_SUFFIX}), so the pre-clean rmSync only ever + * removes genie's own crashed-run debris — never a user's sibling backup dir. + * Mirrors update.ts's swapAuxiliaryTree (reimplemented, not imported). + */ +function writeManagedDir(sourceDir: string, destDir: string, manifest: SyncManifest): void { + const stageDir = `${destDir}${STAGING_SUFFIX}`; + const oldDir = `${destDir}${PREV_SUFFIX}`; + if (existsSync(stageDir)) rmSync(stageDir, { recursive: true, force: true }); + if (existsSync(oldDir)) rmSync(oldDir, { recursive: true, force: true }); + cpSync(sourceDir, stageDir, { recursive: true }); + writeManifest(stageDir, manifest); + if (existsSync(destDir)) renameSync(destDir, oldDir); + renameSync(stageDir, destDir); + if (existsSync(oldDir)) rmSync(oldDir, { recursive: true, force: true }); +} + +// ============================================================================ +// Skill enumeration + per-dir policy +// ============================================================================ + +/** Source skills = dirs under `/skills` that contain a SKILL.md. */ +function enumerateSourceSkills(pluginRoot: string): SourceSkill[] { + const skillsRoot = join(pluginRoot, 'skills'); + if (!existsSync(skillsRoot)) return []; + const skills: SourceSkill[] = []; + for (const entry of readdirSync(skillsRoot, { withFileTypes: true })) { + const dir = join(skillsRoot, entry.name); + if (classifyEntry(dir, entry) !== 'dir') continue; + if (existsSync(join(dir, 'SKILL.md'))) skills.push({ name: entry.name, dir }); + } + return skills; +} + +/** + * Sync every source skill into `targetParent`, then remove managed orphans. + * Each skill is guarded independently so one failure cannot sink the rest. + */ +function syncSkillDirsInto(ctx: RunContext, agent: string, targetParent: string, report: AgentReport): void { + const sourceSkills = enumerateSourceSkills(ctx.pluginRoot); + const sourceNames = new Set(sourceSkills.map((skill) => skill.name)); + mkdirSync(targetParent, { recursive: true }); + for (const skill of sourceSkills) { + try { + report.skills.push({ name: skill.name, action: syncOneSkill(ctx, agent, skill, targetParent) }); + } catch (err) { + report.advisories.push(`skill ${skill.name} (${agent}) failed: ${errMsg(err)}`); + } + } + removeManagedOrphans(ctx, agent, targetParent, sourceNames, report); +} + +/** + * Per-dir policy: + * absent → create + * managed, files match manifest, source same → unchanged (no writes) + * managed, files match manifest, source differs → update + * managed but files edited, OR unmanaged → adopt-with-backup + */ +function syncOneSkill(ctx: RunContext, agent: string, skill: SourceSkill, targetParent: string): SkillAction { + const destDir = join(targetParent, skill.name); + const sourceDigest = computeDirDigest(skill.dir); + const manifest = buildManifest(ctx, sourceDigest); + if (!existsSync(destDir)) { + writeManagedDir(skill.dir, destDir, manifest); + return 'created'; + } + const existing = readManifest(destDir); + const currentDigest = computeDirDigest(destDir); + if (existing !== null && currentDigest === existing.digest) { + if (sourceDigest === existing.digest) return 'unchanged'; + writeManagedDir(skill.dir, destDir, manifest); + return 'updated'; + } + ctx.backupInto(agent, skill.name, destDir); + writeManagedDir(skill.dir, destDir, manifest); + return 'adopted'; +} + +/** + * A managed dir whose name is no longer in source is an orphan. Unmodified → + * back up + remove (kills zombie skills). Modified → keep + advise. Dirs without + * a manifest are never touched — genie only removes what it provably shipped. + */ +function removeManagedOrphans( + ctx: RunContext, + agent: string, + targetParent: string, + sourceNames: Set, + report: AgentReport, +): void { + for (const entry of readdirSync(targetParent, { withFileTypes: true })) { + if (entry.name.endsWith(STAGING_SUFFIX) || entry.name.endsWith(PREV_SUFFIX)) continue; + const dir = join(targetParent, entry.name); + if (classifyEntry(dir, entry) !== 'dir' || sourceNames.has(entry.name)) continue; + const manifest = readManifest(dir); + if (manifest === null) continue; + if (computeDirDigest(dir) === manifest.digest) { + ctx.backupInto(agent, entry.name, dir); + rmSync(dir, { recursive: true, force: true }); + report.skills.push({ name: entry.name, action: 'removed' }); + } else { + report.skills.push({ name: entry.name, action: 'kept-modified-orphan' }); + report.advisories.push(`kept modified orphan ${entry.name} (${agent})`); + } + } +} + +// ============================================================================ +// Workflow stamp (parity-locked to council-stamp.cjs) +// ============================================================================ + +/** + * Stamp the /council template's LENS_ROOT placeholder with `pluginRoot` and + * write `/council.js`. Byte-identical output and idempotent-skip + * semantics to plugins/genie/scripts/council-stamp.cjs (parity test locks it). + */ +export function stampWorkflow(opts: { templatePath: string; pluginRoot: string; targetDir: string }): { + action: 'written' | 'skipped'; + targetPath: string; +} { + const { templatePath, pluginRoot, targetDir } = opts; + const template = readFileSync(templatePath, 'utf8'); + const stamped = template.split(PLACEHOLDER).join(pluginRoot); + const targetPath = join(targetDir, TARGET_NAME); + if (existsSync(targetPath) && readFileSync(targetPath, 'utf8') === stamped) { + return { action: 'skipped', targetPath }; + } + mkdirSync(targetDir, { recursive: true }); + writeFileSync(targetPath, stamped, 'utf8'); + return { action: 'written', targetPath }; +} + +// ============================================================================ +// Adapters +// ============================================================================ + +function syncClaude(ctx: RunContext, report: AgentReport): void { + const claudeDir = ctx.targets.claude; + if (!existsSync(claudeDir)) return; + report.detected = true; + syncSkillDirsInto(ctx, 'claude', join(claudeDir, 'skills'), report); + stampClaudeWorkflow(ctx, claudeDir, report); +} + +function stampClaudeWorkflow(ctx: RunContext, claudeDir: string, report: AgentReport): void { + const templatePath = join(ctx.pluginRoot, 'workflows', TARGET_NAME); + if (!existsSync(templatePath)) { + report.extras.push({ kind: 'stamp', action: 'unavailable', detail: `${templatePath} missing` }); + return; + } + const res = stampWorkflow({ templatePath, pluginRoot: ctx.pluginRoot, targetDir: join(claudeDir, 'workflows') }); + report.extras.push({ kind: 'stamp', action: res.action, detail: res.targetPath }); +} + +function syncCodex(ctx: RunContext, report: AgentReport): void { + const codexDir = ctx.targets.codex; + if (!existsSync(codexDir)) return; + report.detected = true; + // `.curated/` is genie's lane; `.system/` is OpenAI's and is never enumerated. + syncSkillDirsInto(ctx, 'codex', join(codexDir, 'skills', '.curated'), report); + if (report.skills.some((skill) => WRITE_ACTIONS.has(skill.action))) { + report.advisories.push('restart Codex to pick up updated skills'); + } +} + +type LinkAction = 'created' | 'unchanged' | 'adopted' | 'skipped-unmanaged-kept'; + +function syncHermes(ctx: RunContext, opts: AgentSyncOptions, report: AgentReport): void { + const hermesHome = ctx.targets.hermes; + const binary = detectHermesBinary(opts); + if (!existsSync(hermesHome) && binary === null) return; + report.detected = true; + if (ctx.hermesRoot === null) { + report.advisories.push('hermes source (hermes-genie) not found next to plugins/genie; skipping link'); + return; + } + const hermesRoot = ctx.hermesRoot; + const mainAction = ensureHermesLink(ctx, join(hermesHome, 'plugins', 'genie'), hermesRoot, 'plugins-genie', report); + ensureStickyProfileLink(ctx, hermesHome, hermesRoot, report); + // A freshly linked main plugin — created OR adopted from a real dir — is newly + // enabled; fire enable exactly once. Never gated on the sticky-profile link. + if ((mainAction === 'created' || mainAction === 'adopted') && binary !== null) { + runHermesEnable(opts, binary, report); + } +} + +/** + * Converge `linkPath` onto a symlink at `ctx.hermesRoot`: + * missing → create the symlink, + * our symlink → unchanged, + * other symlink → leave it (dev checkout) + advise, + * real dir/file → back up, then replace with the symlink. + */ +function ensureHermesLink( + ctx: RunContext, + linkPath: string, + hermesRoot: string, + backupName: string, + report: AgentReport, +): LinkAction { + mkdirSync(dirname(linkPath), { recursive: true }); + const stat = lstatSafe(linkPath); + if (stat === null) { + symlinkSync(hermesRoot, linkPath); + report.extras.push({ kind: 'symlink', action: 'created', detail: `${linkPath} -> ${hermesRoot}` }); + return 'created'; + } + if (stat.isSymbolicLink()) return reconcileExistingSymlink(linkPath, hermesRoot, report); + const backup = ctx.backupInto('hermes', backupName, linkPath); + rmSync(linkPath, { recursive: true, force: true }); + symlinkSync(hermesRoot, linkPath); + report.extras.push({ kind: 'symlink', action: 'adopted', detail: `real dir backed up to ${backup}` }); + return 'adopted'; +} + +function reconcileExistingSymlink(linkPath: string, hermesRoot: string, report: AgentReport): LinkAction { + const current = readlinkSync(linkPath); + if (resolve(dirname(linkPath), current) === resolve(hermesRoot)) { + report.extras.push({ kind: 'symlink', action: 'unchanged', detail: linkPath }); + return 'unchanged'; + } + report.extras.push({ kind: 'symlink', action: 'skipped-unmanaged-kept', detail: current }); + report.advisories.push(`hermes link ${linkPath} points elsewhere (${current}); left as-is`); + return 'skipped-unmanaged-kept'; +} + +/** When a profile is active, freshen its plugins/genie link too. */ +function ensureStickyProfileLink(ctx: RunContext, hermesHome: string, hermesRoot: string, report: AgentReport): void { + const active = readTrimmed(join(hermesHome, 'active_profile')); + if (active === null || active === '') return; + const linkPath = join(hermesHome, 'profiles', active, 'plugins', 'genie'); + ensureHermesLink(ctx, linkPath, hermesRoot, `profiles-${active}-plugins-genie`, report); +} + +function runHermesEnable(opts: AgentSyncOptions, binary: string, report: AgentReport): void { + const exec = + opts.execHermesEnable ?? + ((args: string[]) => { + execFileSync(binary, args, { stdio: 'ignore' }); + }); + try { + exec(['plugins', 'enable', 'genie']); + report.extras.push({ kind: 'enable', action: 'ran', detail: 'hermes plugins enable genie' }); + } catch (err) { + report.extras.push({ kind: 'enable', action: 'failed', detail: errMsg(err) }); + report.advisories.push(`hermes plugins enable genie failed: ${errMsg(err)}`); + } +} + +function detectHermesBinary(opts: AgentSyncOptions): string | null { + if (opts.hermesBinary !== undefined) return opts.hermesBinary; + if (typeof Bun !== 'undefined') return Bun.which('hermes'); + try { + const found = execFileSync('which', ['hermes'], { encoding: 'utf8' }).trim(); + return found === '' ? null : found; + } catch { + return null; + } +} + +// ============================================================================ +// Orchestration +// ============================================================================ + +/** + * Converge every detected agent from the resolved source. Never throws for + * agent-level failures; a null pluginRoot yields an empty report. + */ +export function runAgentSync(opts: AgentSyncOptions = {}): AgentSyncReport { + const genieHome = opts.genieHome ?? resolveGenieHome(); + const source = resolveGenieSource(genieHome); + const log = opts.log ?? (() => undefined); + if (source.pluginRoot === null) { + log('agent-sync: no genie plugin source found (looked for plugins/genie); skipping'); + return { source, agents: [], backupsDir: null }; + } + const ctx = createRunContext(genieHome, source.pluginRoot, source, opts); + const agents: AgentReport[] = [ + runAgentSafe('claude', (report) => syncClaude(ctx, report)), + runAgentSafe('codex', (report) => syncCodex(ctx, report)), + runAgentSafe('hermes', (report) => syncHermes(ctx, opts, report)), + ]; + return { source, agents, backupsDir: ctx.backupsDirIfCreated() }; +} + +function createRunContext( + genieHome: string, + pluginRoot: string, + source: GenieSource, + opts: AgentSyncOptions, +): RunContext { + const now = opts.now ?? (() => new Date()); + const targets = { + claude: opts.targets?.claude ?? resolveClaudeDir(), + codex: opts.targets?.codex ?? resolveCodexDir(), + hermes: opts.targets?.hermes ?? resolveHermesHome(), + }; + const stamp = now().toISOString(); + let backupsDir: string | null = null; + const backupInto = (agent: string, name: string, existingDir: string): string => { + if (backupsDir === null) { + backupsDir = join(genieHome, 'state-backups', `agent-sync-${stamp}`); + mkdirSync(backupsDir, { recursive: true }); + } + const dest = join(backupsDir, agent, name); + mkdirSync(dirname(dest), { recursive: true }); + cpSync(existingDir, dest, { recursive: true }); + return dest; + }; + return { + pluginRoot, + hermesRoot: source.hermesRoot, + version: source.version, + now, + targets, + backupInto, + backupsDirIfCreated: () => backupsDir, + }; +} + +/** + * Run one adapter against a report this function owns, so a late throw (e.g. in + * removeManagedOrphans, after writes already landed on disk) keeps whatever the + * adapter collected up to the failure point — the error is appended as an + * advisory rather than discarding the partial report. Never throws. + */ +function runAgentSafe(agent: AgentReport['agent'], run: (report: AgentReport) => void): AgentReport { + const report = emptyReport(agent); + try { + run(report); + } catch (err) { + report.advisories.push(`${agent} sync failed: ${errMsg(err)}`); + } + return report; +} + +// ============================================================================ +// Small shared helpers +// ============================================================================ + +function emptyReport(agent: AgentReport['agent']): AgentReport { + return { agent, detected: false, skills: [], extras: [], advisories: [] }; +} + +function lstatSafe(path: string): Stats | null { + try { + return lstatSync(path); + } catch { + return null; + } +} + +function readTrimmed(path: string): string | null { + try { + return readFileSync(path, 'utf8').trim(); + } catch { + return null; + } +} + +function errMsg(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/src/lib/council-workflow-stamp.test.ts b/src/lib/council-workflow-stamp.test.ts index 368adc8bf..6eb365f63 100644 --- a/src/lib/council-workflow-stamp.test.ts +++ b/src/lib/council-workflow-stamp.test.ts @@ -16,13 +16,18 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; const require = createRequire(import.meta.url); -const { stampCouncilWorkflow, PLACEHOLDER } = require('../../plugins/genie/scripts/council-stamp.cjs') as { - stampCouncilWorkflow: (opts: { templatePath: string; pluginRoot: string; targetDir: string }) => { - action: 'written' | 'skipped'; - targetPath: string; +const { stampCouncilWorkflow, resolveStampInputs, PLACEHOLDER } = + require('../../plugins/genie/scripts/council-stamp.cjs') as { + stampCouncilWorkflow: (opts: { templatePath: string; pluginRoot: string; targetDir: string }) => { + action: 'written' | 'skipped'; + targetPath: string; + }; + resolveStampInputs: (opts: { claudePluginRoot: string; genieHome: string; exists?: (p: string) => boolean }) => { + pluginRoot: string; + templatePath: string; + }; + PLACEHOLDER: string; }; - PLACEHOLDER: string; -}; const TEMPLATE_BODY = [ "export const meta = { name: 'council' };", @@ -100,3 +105,43 @@ describe('stampCouncilWorkflow', () => { expect(() => stampCouncilWorkflow({ templatePath, pluginRoot: '/x' })).toThrow(/requires/); }); }); + +describe('resolveStampInputs (stable-root preference)', () => { + const claudePluginRoot = '/home/user/.claude/plugins/genie'; + const genieHome = '/home/user/.genie'; + const stableRoot = join(genieHome, 'plugins', 'genie'); + const stableTemplate = join(stableRoot, 'workflows', 'council.js'); + + test('prefers the stable ~/.genie/plugins/genie root when it carries the template', () => { + const res = resolveStampInputs({ + claudePluginRoot, + genieHome, + exists: (p) => p === stableTemplate, + }); + expect(res.pluginRoot).toBe(stableRoot); + expect(res.templatePath).toBe(stableTemplate); + }); + + test('falls back to claudePluginRoot when the stable template is absent', () => { + const res = resolveStampInputs({ + claudePluginRoot, + genieHome, + exists: () => false, + }); + expect(res.pluginRoot).toBe(claudePluginRoot); + expect(res.templatePath).toBe(join(claudePluginRoot, 'workflows', 'council.js')); + }); + + test('exists() is injectable — the preference probes the stable template path', () => { + const probed: string[] = []; + resolveStampInputs({ + claudePluginRoot, + genieHome, + exists: (p) => { + probed.push(p); + return false; + }, + }); + expect(probed).toContain(stableTemplate); + }); +}); diff --git a/src/lib/genie-home.ts b/src/lib/genie-home.ts new file mode 100644 index 000000000..e2dcecceb --- /dev/null +++ b/src/lib/genie-home.ts @@ -0,0 +1,31 @@ +/** + * Genie home + agent directory resolution. + * + * Every path the agent-sync engine reads or writes is derived from one of these + * four roots. Each honors its conventional environment override so tests can + * redirect ALL state into a tmpdir and never touch the real `$HOME`, and so + * operators can relocate any one agent's config without moving the others. + */ + +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** Global genie state root — `$GENIE_HOME` or `~/.genie`. */ +export function resolveGenieHome(): string { + return process.env.GENIE_HOME || join(homedir(), '.genie'); +} + +/** Claude Code config root — `$CLAUDE_CONFIG_DIR` or `~/.claude`. */ +export function resolveClaudeDir(): string { + return process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'); +} + +/** Codex config root — `$CODEX_HOME` or `~/.codex`. */ +export function resolveCodexDir(): string { + return process.env.CODEX_HOME || join(homedir(), '.codex'); +} + +/** Hermes home — `$HERMES_HOME` or `~/.hermes`. */ +export function resolveHermesHome(): string { + return process.env.HERMES_HOME || join(homedir(), '.hermes'); +} diff --git a/tests/e2e/v5-lifecycle.sh b/tests/e2e/v5-lifecycle.sh index 7433890fb..00377e805 100755 --- a/tests/e2e/v5-lifecycle.sh +++ b/tests/e2e/v5-lifecycle.sh @@ -153,7 +153,7 @@ step "author wish documents" WISH_DIR="$FIXTURE/.genie/wishes/$SLUG" mkdir -p "$WISH_DIR" # Render a WISH.md from the repo template (skills copy this template verbatim). -sed "s/{{slug}}/$SLUG/g; s/{{date}}/$(date +%F)/g" "$REPO_ROOT/templates/wish-template.md" > "$WISH_DIR/WISH.md" +sed "s/{{slug}}/$SLUG/g; s/{{date}}/$(date +%F)/g" "$REPO_ROOT/skills/wish/templates/wish-template.md" > "$WISH_DIR/WISH.md" # A brainstorm design note (the skills' upstream artifact). printf '# Design: %s\n\nZero-daemon lifecycle proof.\n' "$SLUG" > "$WISH_DIR/DESIGN.md" git -C "$FIXTURE" add ".genie/wishes/$SLUG/WISH.md" ".genie/wishes/$SLUG/DESIGN.md"