diff --git a/docker/.gitignore b/docker/.gitignore new file mode 100644 index 0000000..0219604 --- /dev/null +++ b/docker/.gitignore @@ -0,0 +1,2 @@ +artifacts/* +!artifacts/.gitkeep diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..d7ce617 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:1 +# Isolated first-use environment for @pacphi/agentic-kit. +# +# Deliberately does NOT bake the kit into the image: the entrypoint installs +# @pacphi/agentic-kit@next at container start, so every fresh container +# exercises the true first-install path against whatever `next` currently is — +# no image rebuild per release. Apt + Node layers stay cached. +# +# See USER-GUIDE.md (running it) and MAINTAINER-GUIDE.md (design + knobs). + +ARG UBUNTU_VERSION=26.04 +FROM ubuntu:${UBUNTU_VERSION} + +ARG NODE_MAJOR=24 +ENV DEBIAN_FRONTEND=noninteractive \ + LANG=C.UTF-8 + +# procps: ruflo daemon discovery shells out to `ps -eo` (absent on slim bases). +# build-essential/python3: node-gyp fallback when a native prebuild is missing. +# socat: re-publishes the loopback-only dashboard on the container interface. +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl git less procps socat sudo xz-utils \ + build-essential python3 pkg-config \ + && rm -rf /var/lib/apt/lists/* + +# NodeSource Node (kit engines: node >=22). Override: --build-arg NODE_MAJOR=26 +RUN curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash - \ + && apt-get update && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +# Non-root: the claude CLI refuses permission-bypass modes as root, and a +# user-owned npm prefix (no sudo for `npm -g`) is the realistic first-use shape. +# Passwordless sudo stays available for in-container debugging. +RUN useradd -m -s /bin/bash tester \ + && echo 'tester ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/tester + +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +USER tester +ENV NPM_CONFIG_PREFIX=/home/tester/.npm-global \ + PATH=/home/tester/.npm-global/bin:$PATH +WORKDIR /home/tester/work + +# 7432 is the socat bridge, NOT the dashboard itself — the dashboard binds +# container-loopback 7431 by design and is unreachable via port publishing. +EXPOSE 7432 + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["dashboard"] diff --git a/docker/MAINTAINER-GUIDE.md b/docker/MAINTAINER-GUIDE.md new file mode 100644 index 0000000..b974585 --- /dev/null +++ b/docker/MAINTAINER-GUIDE.md @@ -0,0 +1,92 @@ +# First-use environment — maintainer guide + +Audience: agentic-kit maintainers using this environment to validate releases, +reproduce first-install bugs, and test upgrade paths. For "how do I run it," +see [USER-GUIDE.md](USER-GUIDE.md); this page is the *why* and the knobs. + +## Design decisions + +- **The kit is installed at container start, not image build.** The + entrypoint runs `npm i -g @pacphi/agentic-kit@$AK_DIST_TAG` on first boot, + so every ephemeral run exercises the true install path against the live + dist-tag — publishing a new alpha requires **no image rebuild** to be + covered. The image layers (apt, NodeSource) are pure cache. +- **Non-root `tester` user, user-owned npm prefix.** The claude CLI refuses + permission-bypass modes as root, and a no-sudo `npm -g` is the realistic + first-use shape. `NPM_CONFIG_PREFIX=/home/tester/.npm-global` is set in the + image — the container's global root is always known and self-contained. +- **The dashboard needs a bridge, not a port mapping.** `ak dashboard` + listens on a loopback literal with a per-session token + ([ADR-0014](../docs/adr/0014-dashboard-auth-and-remediation.md)); Docker + port publishing can't reach a container-loopback listener. The entrypoint + runs `socat` (container `0.0.0.0:7432` → `127.0.0.1:7431`) and compose maps + it back to **host-loopback only** (`127.0.0.1:7431:7432`) — the security + envelope (local browsers + token) is preserved, and the printed `#token` + URL works verbatim on the host because the host port mirrors 7431. Do not + "fix" this by adding a bind flag to the dashboard; the loopback literal is + deliberate. +- **Isolation is absolute, both directions.** No host path is mounted except + `./artifacts`. Named volumes carry all persistent state, namespaced + `agentic-kit-firstuse_*`. A host agentic-kit install (any version, any + prefix) and this environment cannot see each other. Keep it that way when + extending: auth material enters via `docker cp` or env, never a mount. +- **`--yes` in the default setup flags.** Compose `up` has no interactive + stdin; `ak setup` prompts would either hang (TTY allocated) or silently + default (no TTY). `--yes` makes the accept-defaults choice explicit and + deterministic. Interactive prompt testing: `docker compose run --rm -e + AK_SKIP_SETUP=1 ak bash`, then run setup by hand. + +## Knobs + +| Knob | Default | Purpose | +| --- | --- | --- | +| `AK_DIST_TAG` (env) | `next` | Which dist-tag/version the entrypoint installs — pin an exact alpha to bisect a regression | +| `AK_INSTALL_SPEC` (env) | `@pacphi/agentic-kit@$AK_DIST_TAG` | Full npm spec override — test an unpublished build: `npm pack` the checkout, drop the tarball in `./artifacts`, set `AK_INSTALL_SPEC=/artifacts/.tgz` | +| `AK_SETUP_FLAGS` (env) | `--codex --opencode --yes` | Full setup surface; add `--no-ruvnet-brain` to skip the ~2 GB KB, `--minimal` for the smallest footprint | +| `AK_SKIP_SETUP` (env) | `0` | `1` = install the kit but stop before setup (bare-kit debugging) | +| `AK_DASHBOARD_PORT` / `AK_BRIDGE_PORT` (env) | `7431` / `7432` | Only needed if you change the compose port mapping too | +| `UBUNTU_VERSION` (build arg) | `26.04` | OS matrix testing | +| `NODE_MAJOR` (build arg) | `24` | Node matrix testing (kit engines: `>=22`) | + +## Standard maintainer workflows + +```bash +# Validate the current `next` end-to-end (the release smoke) +docker compose up --build ak + +# Bisect: does alpha.31 also fail? +AK_DIST_TAG=4.0.0-alpha.31 docker compose up ak + +# Upgrade-path test: converge on alpha.N, then sync to alpha.N+1 +docker compose --profile persistent run --rm -e AK_DIST_TAG=4.0.0-alpha.32 ak-persistent bash +# … let setup converge, exit … +docker compose --profile persistent run --rm ak-persistent bash +# inside: npm i -g @pacphi/agentic-kit@next && ak sync + +# Architecture matrix (CI or a beefy host) +docker buildx build --platform linux/amd64,linux/arm64 . +``` + +## Regression artifacts + +Each run that completes setup writes `artifacts/first-use-status.json` — +`ak status --json` as seen by a brand-new machine. Diff it across releases to +catch first-use regressions (a subsystem newly failing on clean install is +exactly the class of bug maintainers' converged machines can't see). This is +the seam for a future nightly job: GitHub Actions runs this same compose file +natively on Linux; compare the JSON against the previous run and alert on new +`fail` rows. + +## Maintenance duties + +- Keep `AK_SETUP_FLAGS` in step with `ak setup`'s option surface + (`src/commands/setup.mjs`) — a renamed flag here fails loudly at entrypoint. +- Bump `NODE_MAJOR` when the kit's `engines` floor moves; bump + `UBUNTU_VERSION` on new LTS. Both are build args — CI can matrix them + without file edits. +- The healthcheck allows `start_period: 600s` because install + full setup + precede the dashboard; if setup grows meaningfully slower, raise it rather + than letting orchestrators flap the container. +- `docker/*.md` is deliberately outside the repo's markdownlint globs + (`.markdownlint-cli2.jsonc` covers `docs/**`); keep these guides tidy by + hand. diff --git a/docker/USER-GUIDE.md b/docker/USER-GUIDE.md new file mode 100644 index 0000000..eca8b31 --- /dev/null +++ b/docker/USER-GUIDE.md @@ -0,0 +1,106 @@ +# First-use environment — user guide + +You want to try `agentic-kit` the way a brand-new user would — on a clean OS, +with nothing pre-installed — without touching the tooling already on your +machine. This directory gives you that as one command, identically on macOS, +Windows (Docker Desktop / WSL2), and Linux. + +**Isolation promise:** the container never mounts or reads your host's +`~/.claude`, `~/.codex`, `~/.npmrc`, npm prefix, or any installed CLI. A host +with any version of agentic-kit installed cannot conflict with these +containers, and the containers cannot alter your host install. Persistent +state lives only in Docker-managed named volumes; the single bind mount is +`./artifacts` inside this directory. + +## Prerequisites + +- Docker Desktop (macOS/Windows) or Docker Engine + Compose v2 (Linux). +- Nothing else — no Node, no npm, no agentic-kit on the host. + +## Quick start + +```bash +cd docker +docker compose up --build ak +``` + +What happens, in order — expect **10–15 minutes, network-dependent, on +*every* run of this service**: a true first-use re-downloads and re-installs +everything by design (only the image build itself is cached): + +1. Ubuntu 26.04 + Node image builds (cached on later runs). +2. The container installs `@pacphi/agentic-kit@next` — the real first-install + path, against whatever `next` currently is. +3. `ak setup --codex --opencode --yes` runs: installs ruflo, agentic-qe, and + the claude/codex/opencode CLIs inside the container, wires everything. +4. The dashboard starts. **Watch the logs for a URL like:** + + ```text + http://127.0.0.1:7431/#token=… + ``` + + Copy it into your **host** browser — it works verbatim. (The token is + required; the bare URL without the `#token` fragment is turned away.) + +Stop with Ctrl-C (or `docker compose down`). Because this service keeps no +volumes, the next `up` is a genuine first-use again. + +## Interactive exploration instead of the dashboard + +```bash +docker compose run --rm ak bash # install + setup, then a shell +docker compose run --rm -e AK_SKIP_SETUP=1 ak bash # skip setup, bare kit +``` + +Inside: a sandbox git repo at `~/work/sandbox` is the project ak operates on. +`ak status`, `ak sync --dry-run`, `ak x verify all` etc. all work there. + +## Keeping state between runs + +```bash +docker compose --profile persistent up ak-persistent +``` + +Same environment, but `/home/tester` lives in a named volume, so the +converged install (and the ~2 GB RuvNet Brain KB, if you enable it) survives +restarts. Only the volume's first run pays the 10–15 minute install + setup; +converged restarts come up in seconds. Reset to factory: +`docker compose down --volumes`. Don't run both services at once — they +share the host port. + +## Signing in to the AI CLIs (optional) + +Everything infrastructural — setup, status, sync, dashboard, statusline — +works with **no** AI login. This is verified, not assumed: a zero-credential +container completes `ak setup --codex --opencode --yes` (including both MCP +bridge registrations, `ruflo init`/memory/swarm/daemon with a verified memory +write, and `aqe init --with-codex`), and a follow-up `ak sync` reports +**converged — no failing subsystems**. You only need auth to actually drive +sessions: + +| CLI | Headless-container strategy | +| --- | --- | +| `claude` | `claude` login supports a paste-a-code flow in the terminal — run it inside `docker compose run --rm ak bash`. | +| `codex` | Easiest: `export OPENAI_API_KEY=…` before starting. The OAuth flow's `localhost:1455` callback can't cross the container boundary without extra bridging. | +| `opencode` | API keys via `opencode auth login` in the container shell. | + +Never bind-mount host credential dirs into the container — if you must reuse +a login, `docker cp` the specific file in, deliberately. + +## Common issues + +- **Dashboard URL doesn't load** — use the exact printed URL (with `#token=`) + and confirm the container is still up. Plain `docker run -p` without this + compose file will *never* work: the dashboard binds loopback inside the + container by design; the bridge in this setup is what makes it reachable. +- **Port 7431 busy on the host** — another dashboard (maybe your host ak!) is + using it. Edit the left side of the port mapping in `compose.yaml`. +- **Apple Silicon vs Intel** — the image builds for your machine's native + architecture automatically. Don't force `--platform linux/amd64` on an + arm64 Mac; emulation breaks native Node modules and produces false + failures. +- **`artifacts/` permission errors (Linux)** — if Docker created the dir + root-owned, `sudo chown $USER docker/artifacts`. + +Maintainers: design rationale, knobs, upgrade-path testing, and CI notes are +in [MAINTAINER-GUIDE.md](MAINTAINER-GUIDE.md). diff --git a/docker/artifacts/.gitkeep b/docker/artifacts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker/compose.yaml b/docker/compose.yaml new file mode 100644 index 0000000..9c858ee --- /dev/null +++ b/docker/compose.yaml @@ -0,0 +1,47 @@ +# Isolated first-use environments for @pacphi/agentic-kit. +# +# Isolation contract: NOTHING from the host's own agentic-kit install is ever +# mounted or touched — no ~/.claude, ~/.codex, ~/.npmrc, no host binaries, no +# host npm prefix. The only bind mount is the repo-local ./artifacts dir; all +# persistent state lives in Docker-managed named volumes (namespaced +# agentic-kit-firstuse_*), so a host with any agentic-kit version installed +# cannot conflict with these containers, and vice versa. +name: agentic-kit-firstuse + +services: + # True first-use: nothing persists. Every `up`/`run` starts from a bare + # Ubuntu + Node image, installs @next, runs setup, serves the dashboard. + ak: + build: . + image: agentic-kit-firstuse + init: true # reap daemon zombies; ak setup starts background workers + ports: + # HOST-loopback only — preserves the dashboard's localhost-only posture. + # Host 7431 → container 7432 (socat bridge) → container-loopback 7431, + # so the printed #token URL works verbatim in the host browser. + - "127.0.0.1:7431:7432" + environment: + AK_DIST_TAG: ${AK_DIST_TAG:-next} + AK_SETUP_FLAGS: ${AK_SETUP_FLAGS:---codex --opencode --yes} + volumes: + - ./artifacts:/artifacts + healthcheck: + test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/7431"] + interval: 15s + retries: 3 + start_period: 600s # install + setup runs before the dashboard comes up + + # Converged install that survives restarts — for iterating on a completed + # setup and for UPGRADE-path testing (install alpha.N, restart with + # AK_DIST_TAG pinned higher, run `ak sync` inside). Same port mapping, so + # don't run both profiles at once. + ak-persistent: + profiles: ["persistent"] + extends: + service: ak + volumes: + - home:/home/tester # named volume: seeds from the image on first run + - ./artifacts:/artifacts + +volumes: + home: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..2061806 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# First-use entrypoint: install the kit → run setup → serve the dashboard +# (or exec whatever command was given, e.g. `bash` for an interactive shell). +# Every knob is an env var so `docker compose run -e ...` can retune a run +# without an image rebuild. See MAINTAINER-GUIDE.md for the full knob table. +set -euo pipefail + +AK_DIST_TAG="${AK_DIST_TAG:-next}" +# Full install spec override — lets maintainers test an UNPUBLISHED build: +# npm pack the checkout, drop the tarball into ./artifacts, and set +# AK_INSTALL_SPEC=/artifacts/.tgz (mounted read-write by compose). +AK_INSTALL_SPEC="${AK_INSTALL_SPEC:-@pacphi/agentic-kit@${AK_DIST_TAG}}" +AK_SETUP_FLAGS="${AK_SETUP_FLAGS:---codex --opencode --yes}" +AK_SKIP_SETUP="${AK_SKIP_SETUP:-0}" +AK_DASHBOARD_PORT="${AK_DASHBOARD_PORT:-7431}" # container-loopback (ak's default) +AK_BRIDGE_PORT="${AK_BRIDGE_PORT:-7432}" # socat re-publish; the EXPOSEd port + +# Sample project: ak is project-aware (statusline, settings.local.json, and the +# aqe router all anchor at a repo root), so first-use runs from a real git repo. +mkdir -p "$HOME/work/sandbox" +cd "$HOME/work/sandbox" +if [ ! -d .git ]; then + git init -q + git config user.email "tester@firstuse.invalid" + git config user.name "First-Use Tester" + echo "# sandbox — agentic-kit first-use project" > README.md + git add README.md && git commit -qm "init sandbox" +fi + +if ! command -v ak >/dev/null 2>&1; then + echo "▶ installing ${AK_INSTALL_SPEC} (the real first-install path)" + npm install -g "${AK_INSTALL_SPEC}" +fi +echo "▶ agentic-kit $(ak --version 2>/dev/null || echo '(version probe failed)')" + +if [ "${AK_SKIP_SETUP}" != "1" ]; then + echo "▶ ak setup ${AK_SETUP_FLAGS}" + # shellcheck disable=SC2086 # flags are deliberately word-split + ak setup ${AK_SETUP_FLAGS} \ + || echo "⚠ ak setup exited $? — continuing so the state stays inspectable" +fi + +# Regression artifact: a machine-readable snapshot of what first-use produced. +if [ -d /artifacts ] && [ -w /artifacts ]; then + ak status --json > /artifacts/first-use-status.json 2>/dev/null \ + && echo "▶ wrote /artifacts/first-use-status.json" +fi + +case "${1:-dashboard}" in + dashboard) + # ak's dashboard binds 127.0.0.1 by design (loopback + per-session token; + # docs/adr/0014-dashboard-auth-and-remediation.md). Docker port publishing + # cannot reach a container-loopback listener, so socat re-publishes it on + # the container interface; compose maps that back to HOST-loopback only — + # same security envelope, one hop longer. + socat "TCP-LISTEN:${AK_BRIDGE_PORT},fork,reuseaddr" \ + "TCP:127.0.0.1:${AK_DASHBOARD_PORT}" & + echo "▶ copy the #token URL below into your HOST browser — it works verbatim" + echo " (host 127.0.0.1:${AK_DASHBOARD_PORT} → bridge :${AK_BRIDGE_PORT} → dashboard)" + exec ak dashboard --no-open --port "${AK_DASHBOARD_PORT}" + ;; + *) + exec "$@" + ;; +esac diff --git a/src/commands/setup.mjs b/src/commands/setup.mjs index ec35f79..423b491 100644 --- a/src/commands/setup.mjs +++ b/src/commands/setup.mjs @@ -10,12 +10,12 @@ import readline from 'node:readline/promises'; import { run as runCmd, have } from '../lib/exec.mjs'; import * as heal from '../lib/heal.mjs'; import { fixStatusline } from '../lib/statusline.mjs'; -import { registry, syncBlocks } from '../lib/blocks.mjs'; +import { reconcileGuidance } from '../lib/blocks.mjs'; import { register as mcpRegister, applyExclusions } from '../lib/mcp.mjs'; import { OPENCODE_LIFECYCLE_ADAPTER, reconcileOpencodeGuidance } from '../lib/opencode.mjs'; import { runLifecycle } from '../lib/adapters/lifecycle.mjs'; import { loadKitConfig, saveKitConfig } from '../lib/config.mjs'; -import { HOSTS, applyHosts, applyProviders, hostInstallState, installHost, applyAqeRouter, seedActivityRoutesIfMultiHost, printActivityRoutingTable, aqeSupportsAgentOverrides, ensureCodexMcp, ensureRufloMcpInCodex, applySetupHostFlags } from '../lib/providers.mjs'; +import { HOSTS, applyHosts, applyProviders, hostInstallState, installHost, applyAqeRouter, seedActivityRoutesIfMultiHost, printActivityRoutingTable, aqeSupportsAgentOverrides, ensureCodexMcp, ensureRufloMcpInCodex, applySetupHostFlags, bothHostsEnabled } from '../lib/providers.mjs'; import { installedVersion } from '../lib/versions.mjs'; import * as rb from '../lib/ruvnet-brain.mjs'; import * as adb from '../lib/agentdb.mjs'; @@ -86,7 +86,7 @@ const ask = async (q, dflt, yes) => { export async function run_machine({ flags, pkgRoot, cfg }) { heading('machine setup'); - if (flags['dry-run']) { info('dry-run: would ensure packages (incl. ruvnet-brain), deploy skill, merge blocks, offer MCP'); return true; } + if (flags['dry-run']) { info('dry-run: would ensure packages (incl. ruvnet-brain), deploy skill (blocks + MCP land in the final pass)'); return true; } // 1. global packages if (!installedVersion('ruflo')) { @@ -140,22 +140,11 @@ export async function run_machine({ flags, pkgRoot, cfg }) { ok('skill deployed: ruflo-token-audit'); } - // 4. CLAUDE.md managed blocks - const rows = registry(cfg.customBlocks); - const resolve = (r) => (r.custom - ? (r.template.startsWith('~/') ? path.join(paths.home, r.template.slice(2)) : r.template) - : path.join(pkgRoot, 'claude', r.template)); - const res = await syncBlocks(paths.claudeMdPath(), rows, resolve); - ok(`CLAUDE.md blocks: ${res.filter((r) => r.action !== 'unchanged').length || 'no'} change(s)`); - - // 5. MCP (once; --reconfigure or `x mcp pick` to revisit) - const wantMcp = cfg.mcp.register && (flags.reconfigure || !(readJson(paths.claudeUserMcpPath(), {})?.mcpServers?.['claude-flow'])); - if (wantMcp && await ask('Register the ruflo MCP server at user scope (schemas load on demand)?', true, flags.yes)) { - if (await mcpRegister()) { - const { denied } = applyExclusions(cfg.mcp.excludeFamilies ?? []); - ok(`MCP registered${denied ? ` (${denied} tool(s) denied per kit.json)` : ''} — exclude families anytime: ak x mcp pick`); - } else warn('claude mcp add failed — run: ak x mcp pick'); - } + // 4+5. CLAUDE.md guidance blocks + user-scope MCP registration moved to the + // FINAL pass in run(): both depend on host CLIs that step 6 below is + // about to install (mcp needs `claude` on disk; several block detectors + // key on `codex` being on PATH / dual-mode enablement). Running them + // here warned + drifted on genuinely bare machines. // 6. frontier hosts — install any ENABLED host that is entirely absent (default // enables claude only). External installs (mise/native/brew) are left alone. @@ -402,6 +391,26 @@ export async function run({ flags, pkgRoot }) { } else if (!flags.minimal) { info('not inside a project (no .git here) — run `ak setup` from a repo to set one up'); } + // Final reconcile pass — deliberately AFTER the hosts branch (which installs + // the claude/codex/opencode CLIs) and the project phase (whose codex bridge + // creates ~/.codex): the user-scope MCP registration needs the claude CLI on + // disk, and several guidance blocks gate on freshly-installed hosts + // (command:codex, flag:dualMode). Shares blocks.mjs reconcileGuidance with + // `ak sync` so setup and sync converge guidance identically. + if (!flags['dry-run']) { + const ctx = { flags: { dualMode: bothHostsEnabled(cfg), opencodeEnabled: !!cfg.integrations?.hosts?.opencode } }; + for (const t of await reconcileGuidance({ cwd: process.cwd(), cfg, pkgRoot, context: ctx })) { + if (t.name === 'claude' || t.changed) ok(`blocks(${t.label}): ${t.changed || 'in sync'}`); + } + const wantMcp = cfg.mcp.register && (flags.reconfigure || !(readJson(paths.claudeUserMcpPath(), {})?.mcpServers?.['claude-flow'])); + if (wantMcp && await ask('Register the ruflo MCP server at user scope (schemas load on demand)?', true, flags.yes)) { + if (await mcpRegister()) { + const { denied } = applyExclusions(cfg.mcp.excludeFamilies ?? []); + ok(`MCP registered${denied ? ` (${denied} tool(s) denied per kit.json)` : ''} — exclude families anytime: ak x mcp pick`); + } else warn('claude mcp add failed — run: ak x mcp pick'); + } + } + console.log(''); ok(bold('setup complete — `agentic-kit` anytime for status, `ak sync` after upgrades')); info(dim('📊 dashboard: run `ak dashboard` → opens http://127.0.0.1:7431 (local, read-only)')); diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index 0dda73a..95eafcb 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -6,7 +6,7 @@ import { collect } from './status.mjs'; import * as heal from '../lib/heal.mjs'; import { have } from '../lib/exec.mjs'; import { fixStatusline, helperStampStale } from '../lib/statusline.mjs'; -import { registry, syncBlocks, blocksForTarget, retiredForTarget, guidanceTargets } from '../lib/blocks.mjs'; +import { reconcileGuidance } from '../lib/blocks.mjs'; import { register as mcpRegister, applyExclusions } from '../lib/mcp.mjs'; import { OPENCODE_LIFECYCLE_ADAPTER } from '../lib/opencode.mjs'; import { runLifecycle } from '../lib/adapters/lifecycle.mjs'; @@ -192,30 +192,15 @@ export async function run({ flags, pkgRoot }) { // (codex-review r3). When the CLI is absent the target's own config-home // gate still refuses to fabricate anything. if (subsystems.has('blocks') || subsystems.has('versions') || subsystems.has('opencode')) { - const rowsReg = registry(cfg.customBlocks); - const resolve = (r) => (r.custom - ? (r.template.startsWith('~/') ? path.join(paths.home, r.template.slice(2)) : r.template) - : path.join(pkgRoot, 'claude', r.template)); - // Guidance targets (guidanceTargets): machine-wide ~/.claude/CLAUDE.md - // (claude), the project's own /AGENTS.md (agents), machine-wide - // ~/.codex/AGENTS.md when ~/.codex exists (agents-user), and opencode's - // ~/.config/opencode/AGENTS.md when its config home exists - // (agents-opencode — created by the opencode branch above on a fresh - // enable). The dual-mode block's flag detector gates it on both hosts being - // enabled, so single-host setups leave the agents files untouched (no - // .bak). Each target also force-strips blocks that no longer belong in it - // (retiredForTarget) — the migration path that clears the dual block out of - // any project AGENTS.md that still carries it after the re-scope - // (ADR-0008). + // The reconcile loop itself (targets, retired-row strips, dual-mode/ + // opencode flag gating) lives in blocks.mjs reconcileGuidance — shared + // with setup's final pass so the two commands cannot drift (ADR-0008 on + // target scoping). const ctx = { flags: { dualMode: bothHostsEnabled(cfg), opencodeEnabled: !!cfg.integrations?.hosts?.opencode } }; - for (const t of guidanceTargets({ cwd, cfg })) { - const treg = [...blocksForTarget(rowsReg, t.name), ...retiredForTarget(rowsReg, t.name)]; - const res = await syncBlocks(t.file, treg, resolve, { context: ctx }); - const changed = res.filter((r) => r.action !== 'unchanged' && r.action !== 'skipped') - .map((r) => `${r.slug} ${r.action}`).join(', '); + for (const t of await reconcileGuidance({ cwd, cfg, pkgRoot, context: ctx })) { // stay quiet on the agents targets unless they actually changed (single-host // leaves them unmanaged); always report the claude target. - if (t.name === 'claude' || changed) ok(`blocks(${t.label}): ${changed || 'in sync'}`); + if (t.name === 'claude' || t.changed) ok(`blocks(${t.label}): ${t.changed || 'in sync'}`); } } if (subsystems.has('providers') || subsystems.has('routing') || subsystems.has('codex-mcp')) { diff --git a/src/lib/blocks.mjs b/src/lib/blocks.mjs index 93de316..3ccdaff 100644 --- a/src/lib/blocks.mjs +++ b/src/lib/blocks.mjs @@ -290,6 +290,36 @@ export function guidanceTargets({ cwd = process.cwd(), codexRoot = codexDir(), o return targets; } +/** Package-relative template resolution shared by setup and sync: custom rows + * are absolute or ~-expanded paths; built-ins resolve against the kit's own + * claude/ dir. */ +export function templateResolver(pkgRoot) { + return (r) => (r.custom + ? (r.template.startsWith('~/') ? path.join(home, r.template.slice(2)) : r.template) + : path.join(pkgRoot, 'claude', r.template)); +} + +/** Reconcile EVERY guidance target against the registry — the one loop + * `sync` (apply) and `setup`'s final pass both run, so the two commands can + * never drift. Per target: active rows upsert/strip per detector, and + * re-scoped rows are force-stripped (retiredForTarget). `context` carries the + * caller's flag signals for `flag` detectors (dualMode, opencodeEnabled). + * Returns [{name, label, changed}] where `changed` is a human-readable action + * summary ('' when the target was already in sync). */ +export async function reconcileGuidance({ cwd, cfg, pkgRoot, context = {}, dryRun = false }) { + const rows = registry(cfg.customBlocks); + const resolve = templateResolver(pkgRoot); + const out = []; + for (const t of guidanceTargets({ cwd, cfg })) { + const treg = [...blocksForTarget(rows, t.name), ...retiredForTarget(rows, t.name)]; + const res = await syncBlocks(t.file, treg, resolve, { context, dryRun }); + const changed = res.filter((r) => r.action !== 'unchanged' && r.action !== 'skipped') + .map((r) => `${r.slug} ${r.action}`).join(', '); + out.push({ name: t.name, label: t.label, changed }); + } + return out; +} + /** Reconcile every registry row against its detector on a file. * resolveTemplate(row) → absolute template path (built-ins resolve against the * package's claude/ dir; custom rows are absolute or ~-expanded already).