---
name: apt:update
description: "Refresh installed skills/agents/hooks across every runtime and clear the update chip"
apt-skill-version: {{APT_VERSION}}
stage: ops
intent: configure
when_to_use: "The statusline chip says an Aperant update is available, or the user manually wants to refresh installed skills/agents/hooks across every installed runtime."
user_invocable: true
internal: false
spawns_agent: false
agent_name: null
task_context: none
default_execution_mode: auto
execution_modes:
  - auto
  - step
allowed-tools: "Bash, Read, Write"
argument-hint: "apt:update [--from-source]"
gates: []
---

<objective>
You re-install the Aperant Framework's runtime files (skills, agents, hooks) into every runtime root that's currently installed in the project. You ALWAYS use the existing install via the manifest — you never guess which runtimes to install. You always clear the cache file at the end so the statusline chip immediately reflects the fresh state.

**Output discipline:** hide the raw apt-tools JSON envelopes from the user. Parse them, extract the numbers, and print a short summary in the format specified under `<output_format>`. The user wants to see "version X → Y, files refreshed" — not a wall of JSON.
</objective>

<context>
The user invoked `/apt:update` because the statusline showed one of:

- `⬆ /apt:update` (yellow): npm has a newer version than what's installed.
- `⚠ stale skills — run /apt:update` (red): per-file drift was detected (header_mismatch, hash_mismatch, missing_header, or missing_file in one or more runtime manifests).
- `⚠ dev install — re-run installer` (yellow): installed version > npm latest (typical for local dev).
- `? /apt:update` (dim): package not yet published — you can still re-run the install to refresh from source.

The cache file is at `~/.cache/aperant/apt-update-check.json`. It contains `runtimes`, `framework.installed_version`, `framework.latest_version`, `update_available`, `dev_install_ahead`, `not_published`, `stale_files_total`, and per-runtime `stale_files`.

**Scope boundary (0.6.1).** `apt:update` refreshes installed runtime files (skills, agents, hooks) only — it does NOT reconcile `.aperant/config.json` against the shipped template. Schema drift (new keys added between framework versions, deprecated values, etc.) is detected and handled at the next `apt-tools init` call: in non-TTY mode `init` exits 2 with a `requires_input` envelope when a Class A setting is unresolved; in TTY mode the upgrade gate prompts for the missing keys before running the runtime install. If you suspect the user's config is out of date, point them at `apt-tools init . --accept-new-defaults` (Class B fills) or `apt-tools init . --yes` (full silent fill); a no-drift run returns `{"status":"up_to_date"}` without re-installing anything.
</context>

<process>

## 1. Read current state

<!-- TODO(followup): support PATH-only / npx-only kernel installs. The
     hardcoded `.aperant/deps/...` path below fails users who don't have a
     project-local kernel directory. Tracked as a separate task. -->
```bash
node .aperant/deps/node_modules/@aperant/framework/bin/apt-tools.mjs check-version . --json
```

Parse the JSON. From 0.6.7 the envelope merges the SessionStart worker's cache (when fresh — written within the last hour to `~/.cache/aperant/apt-update-check.json`). From 0.8.3 it also carries the workspace-aware `framework.workspace_version` field (Bundle A). Capture:

- `framework.installed_version` — what's currently on disk
- `framework.latest_version` — what npm reports as the latest published version (MAY BE `null` when the worker cache is stale / missing / wrong schema)
- `framework.workspace_version` — set when a workspace `packages/framework/package.json` (with `name === "@aperant/framework"`) is found above the project root; null otherwise. When non-null AND ahead of `installed_version`, the workspace is the source of truth for self-host contributors (auto-detect, no `--from-source` required).
- `runtimes[]` — list of installed runtime ids + per-runtime `stale_files` count
- `stale_files_total` — drift tally across runtimes
- `update_available` — true when worker cache is fresh AND `installed_version < latest_version`, OR when `workspace_version > installed_version` (Bundle A — fires regardless of npm cache state)
- `dev_install_ahead` — true when local kernel is ahead of published (typical local dev)
- `not_published` — true when npm returned 404 (package not yet on registry)
- `lookup_error` — non-null when npm view failed for any non-404 reason (timeout, ENETDOWN, etc.)

The envelope is **single-shape** — every field above is always present. When the worker cache is stale or missing, `latest_version` is `null`, the four booleans are `false`, `stale_files_total` falls back to a local-only count, and `lookup_error` is `null`. Your parser doesn't need to branch on cache freshness.

If `runtimes[]` is empty, tell the user they haven't installed Aperant into any runtime yet and suggest `apt-tools init . --claude` (or their preferred runtime). Stop.

## 1.5. Workspace-aware bump — auto-detect dev-host self-host (Bundle A)

The Aperant kernel lives at `.aperant/deps/node_modules/@aperant/framework/`. `apt-tools init` re-installs runtime files at THE LOCAL KERNEL'S VERSION — so a stale kernel means `/apt:update` re-stamps manifests at the OLD version. Bump the kernel BEFORE invoking init.

**FIRST**, branch on `framework.workspace_version` from step 1's envelope (Bundle A — automatic on every `/apt:update` run, no flag required):

- When `workspace_version` is non-null AND ahead of `installed_version` — the user is an Aperant contributor self-hosting the framework, and a same-PR change bumped `packages/framework/package.json:version`. The workspace is the source of truth (NOT npm). Proceed to §1.7's workspace-pack mechanism. Skip §1.6 (npm view).
- When `workspace_version` is null OR equal to `installed_version` — no workspace package, OR workspace + kernel already match. Fall through to §1.6 (npm bump path).

This auto-detection replaces the previous "user must remember `--from-source`" footgun. The `--from-source` flag is still honored as an explicit override (§1.7) for: (a) testing pre-publish bumps even when auto-detect didn't fire, (b) forcing the workspace path when a stale `latest_version > workspace_version`.

## 1.6. Bump the local kernel from npm when newer is available

When §1.5 fell through (no workspace-ahead signal), resolve `latest_version` from npm. First, resolve `latest_version` when the envelope had `null` (stale cache).

**Timeout enforcement (ID-04):** The `npm view` call must complete within 10 s — never block the user longer than the SessionStart worker would. Use npm's `--fetch-timeout` flag as the primary mechanism (universally portable across all npm versions); it sets the HTTP fetch timeout in milliseconds:

```bash
npm view @aperant/framework version --fetch-timeout=10000 --fetch-retries=0 --json --silent 2>/dev/null || true
```

On Linux/macOS with GNU coreutils installed, you MAY additionally wrap with a hard shell-level timer: `timeout 10 npm view ...` (Linux) or `gtimeout 10 npm view ...` (macOS via `brew install coreutils`). Shell-level wrapping is optional — `--fetch-timeout=10000` alone is sufficient because it is universal. Do NOT rely solely on the shell `timeout` command without the npm flag, as it is not available on all systems.

If npm exits non-zero or times out, surface a one-line warning (`⚠ npm view failed — using local kernel`) and proceed; the local kernel still works for the runtime refresh, just at the older version.

**Security (SEC-001):** Before interpolating `latest_version` into any shell command, verify it matches the semver pattern `^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$`. If it does not match, treat it as missing and fall back to the explicit version pinning path (proceed with the local kernel version, surface `⚠ latest_version invalid — using local kernel`). This protects against a poisoned cache entry reaching a shell command.

When `installed_version < latest_version`, bump:

```bash
npm install --prefix .aperant/deps @aperant/framework@<latest_version> --no-save --no-package-lock --no-audit --no-fund
```

On `npm 404` (`E404` in stderr) treat it as `not_published` and skip the kernel bump (proceed to step 3). On any other npm failure, surface the npm stderr verbatim and STOP — do NOT proceed to init with a half-installed kernel.

## 1.7. Self-host case — bump from local workspace (FRAMEWORK-BUG-017)

Fires when EITHER §1.5's workspace-ahead auto-detect signaled it, OR the user invoked `/apt:update --from-source` as an explicit override, OR the route envelope's `update_check.reason` mentions `workspace source ahead of installed kernel`. The source of truth is the local `packages/framework/` workspace — NOT npm. This is the Aperant-contributor self-host case: a same-PR framework fix bumps the workspace `packages/framework/package.json:version` but the kernel under `.aperant/deps/node_modules/@aperant/framework/` still reflects the previous version. §1.6's `npm view` would fetch the published version (still the OLD one), so we skip it.

The reliable bump mechanism is the `apt-tools install-from-source` subcommand (Bundle D), which packs the framework AND every `workspace:*` dep (transitively) into a single `npm install --prefix` invocation. Handles the same-PR unpublished-pair case (e.g. `framework@0.8.3` + `driver-sdk@0.2.0` both at `workspace:*`, both unpublished) without `EUNSUPPORTEDPROTOCOL`.

```bash
node .aperant/deps/node_modules/@aperant/framework/bin/apt-tools.mjs install-from-source . --json
```

Parse the returned envelope:

```json
{
  "status": "ok",
  "command": "install-from-source",
  "tarballs": ["/tmp/aperant-driver-sdk-0.2.0.tgz", "/tmp/aperant-framework-0.8.3.tgz"],
  "kernel_path": "/path/to/project/.aperant/deps/node_modules/@aperant/framework/bin/apt-tools.mjs"
}
```

Capture `tarballs[]` count and `kernel_path` for the Scenario A summary line (the kernel-version slot reads from `kernel_path`'s parent `package.json` rather than a static `{latest_version}` format string when this branch fired).

On `status: 'error'`, the envelope carries an `error` field with the underlying failure (e.g. `pnpm pack ... exited with N`, `no @aperant/framework workspace found`, etc.). Surface it verbatim and STOP — do NOT proceed to init with a half-installed kernel.

If `--from-source` was passed but `install-from-source` returns `no @aperant/framework workspace found`, surface a one-line error (`⚠ --from-source: no workspace package.json found — falling back to npm`) and proceed with the standard §1.6 npm path.

Then proceed to step 3 to re-install the runtime files at the new kernel version. Skip step 4 (cache clear) — the cache is keyed on `latest_version` from npm, and a `--from-source` bump never wrote to it, so there's nothing to invalidate.

### What `install-from-source` does under the hood

The `apt-tools install-from-source` helper materializes the workspace chain end-to-end. This is the equivalent manual recipe — useful for debugging or for users who want to understand the mechanism. **Prefer the one-line invocation above** unless you have a reason to do it by hand:

⚠ Do NOT substitute `npm pack` — it leaves `workspace:*` literal in the tarball's package.json and breaks `npm install --prefix` with `EUNSUPPORTEDPROTOCOL`. Only `pnpm pack` resolves workspace protocols.

```bash
# 0. Ensure the dep closure is materialized — the `...` suffix installs transitive workspace deps.
cd <workspace-root>
pnpm install --filter @aperant/framework... --frozen-lockfile=false

# 1. Pack the workspace source into a versioned tarball.
pnpm --filter @aperant/framework pack --pack-destination /tmp

# 2. Install the tarball into the project's kernel root.
cd <project-root>
npm install --prefix .aperant/deps /tmp/aperant-framework-<version>.tgz --no-save --no-package-lock --no-audit --no-fund
```

The manual recipe DOES NOT handle workspace:* deps transitively — for a same-PR pair (framework + driver-sdk both at `workspace:*`), you would need to pnpm-pack each dep separately and pass every tarball to the single `npm install --prefix` call. The `apt-tools install-from-source` subcommand walks `dependencies` + `peerDependencies` automatically.

## 2. Classify the update scenario

Pick ONE of three scenarios based on what you parsed in step 1:

- **A. Version bump available** — `latest_version != null && installed_version !== latest_version` (regardless of drift). Users see this most often. Step 1.5 (workspace) or 1.6 (npm) bumped the kernel; step 3 re-stamps manifests at the new version. **Important:** the `latest_version != null` guard prevents this scenario from firing when npm-latest is unknown (cache stale, package not published, network down — see Scenario E for that case).
- **B. Drift only** — `installed_version === latest_version` but `stale_files_total > 0`. User hand-edited an installed file or something was partially overwritten. Steps 1.5 / 1.6 were no-ops; step 3 still needs `--force-runtimes` to re-stamp.
- **C. Nothing to do** — `installed_version === latest_version` AND `stale_files_total === 0`. Still re-run init with `--force-runtimes` to refresh manifests + cache, but tell the user plainly that no version change happened.
- **E. No version info available** — `latest_version === null` (cache stale OR package not published OR network down) AND `stale_files_total === 0`. Step 1.5 was a no-op (no workspace-ahead signal), step 1.6 found no usable npm version. Step 3 still re-stamps runtime files for safety, but the skill does NOT claim a version bump happened.

## 3. Re-install the currently-installed runtimes

Build the flags list from the runtime ids (e.g. `--claude --codex`) and pass `--force-runtimes` so init bypasses the `upgrade_noop` short-circuit (Bug 2 fix, 0.6.7) and re-stamps every manifest:

```bash
node .aperant/deps/node_modules/@aperant/framework/bin/apt-tools.mjs init . --claude --force-runtimes
```

Example with multiple runtimes installed:

```bash
node .aperant/deps/node_modules/@aperant/framework/bin/apt-tools.mjs init . --claude --codex --force-runtimes
```

**Do NOT add `--yes`, `--solo`, `--team`, or any other Class A flag.** This command is meant to refresh runtime files only — adding `--yes` triggers a config schema migration that can silently flip `share.visibility` to `solo` and re-write `.gitignore`, which is exactly the 0.6.0/0.6.1 dogfood bug. `--force-runtimes` is the ONLY new flag this skill adds. If `init` returns a `requires_input` envelope (schema drift), surface it to the user verbatim and stop; let them decide team vs solo with the explicit flag. From 0.6.4 onward `--yes` infers visibility from existing git tracking, but the skill must still not add it — keep this command scoped to runtime refresh.

Parse the init JSON. Capture for each runtime:
- `install_root` (shortened to the basename, e.g. `.claude`)
- `file_count`
- Kernel status from `kernel.status` + `kernel.path` (resolves to `.aperant/deps/`)

In autonomy 3 (YOLO) run immediately. In lower autonomy, skip the prompt too — `/apt:update` is idempotent by design and users who typed it already consented.

## 4. Clear the update-check cache

```bash
rm -f ~/.cache/aperant/apt-update-check.json
```

The next SessionStart will repopulate it; until then, the statusline shows no chips.

## 5. Emit the status summary

Use the format in `<output_format>` below, tailored to the scenario from step 2. Nothing else. Do NOT dump the init JSON, do NOT recap the process, do NOT add a bulleted "what's new" list unless you have concrete facts from a local changelog.

</process>

<output_format>

## Scenario A — version bump applied

```
✓ Aperant updated {installed_version} → {latest_version}

  {install_root_basename}     {file_count} files refreshed
  {install_root_basename}     {file_count} files refreshed
  .aperant/deps/   kernel v{latest_version}
  Migrated:        legacy .codex/agents/* swept ({removed_count} files removed)

  Next: restart Claude Code / Codex to activate the refreshed skills.
```

Example:
```
✓ Aperant updated 0.2.1 → 0.2.5

  .claude/         47 files refreshed
  .codex/          67 files refreshed
  .aperant/deps/   kernel v0.2.5

  Next: restart Claude Code / Codex to activate the refreshed skills.
```

## Scenario B — drift detected and fixed

```
⚠ Aperant had drift — refreshed

  Detected:  {stale_files_total} stale files across {runtime list}
  After:     all files match manifest

  {install_root_basename}     {file_count} files ({stale_count} refreshed)
  {install_root_basename}     {file_count} files
  Migrated:        legacy .codex/agents/* swept ({removed_count} files removed)

  Statusline chip cleared.
```

## Scenario C — already up to date

```
✓ Aperant is up to date ({installed_version})

  {install_root_basename}   {file_count} files
  {install_root_basename}   {file_count} files
  Migrated:                 legacy .codex/agents/* swept ({removed_count} files removed)

  No changes applied. Statusline cache cleared.
```

## Scenario E — no version info available

```
⚠ Aperant version info unavailable — refreshed runtime files only

  Reason: {lookup_error || 'cache stale / not published'}
  {install_root_basename}   {file_count} files
  {install_root_basename}   {file_count} files
  Migrated:                 legacy .codex/agents/* swept ({removed_count} files removed)

  Run /apt:update again later to bump the kernel when npm view becomes reachable.
```

Scenario E fires when `latest_version === null` AND `stale_files_total === 0`. Step 1.5 (workspace-aware) was a no-op (no workspace-ahead signal); step 1.6 (npm view) returned null (cache stale, not published, or network down). The runtime refresh in step 3 still happens — the skill just does NOT claim a bump occurred.

**`Migrated:` slot — conditional render.** The `Migrated:` line in Scenarios A / B / C / E fires ONLY when `init.legacy_cleanup?.ran === true && init.legacy_cleanup.rmdirs.some(p => p.includes('.codex'))`. When no migration happened, OMIT the line entirely (do NOT render an empty placeholder). The label reports the legacy Codex topology sweep (v0.7/v0.8 `.codex/agents/` + `.codex/commands/` misdirect → v0.8.2+ canonical `.agents/skills/<folder>/SKILL.md`) so users understand what changed under `.codex/` on disk.

## Scenario D — kernel install failed

If init's `kernel.status !== "installed"`, append a warning ABOVE the summary:

```
⚠ Kernel refresh failed: {kernel.reason}
  Manual fallback: npm install --prefix .aperant/deps @aperant/framework@{latest_version}

[...scenario A/B/C summary as normal...]
```

Runtime files were still refreshed; `/apt` will still work from PATH if `apt-tools` is globally installed. The warning tells the user why the self-contained kernel didn't provision.

</output_format>

<edge_cases>

- **No runtimes installed yet**: `check-version --json` returns an empty `runtimes` array. Tell the user to run `apt-tools init . --claude` (or their preferred runtime) first. Do not attempt the re-install.
- **Worker hasn't run yet**: the cache file is missing. That's fine — run check-version directly (step 1 is robust to a missing cache). Proceed normally.
- **Re-install fails (non-zero exit from init)**: surface the JSON `error` field verbatim in a single line, then stop. Do NOT delete the cache file — the chip should remain visible so the user sees the failure persists.
- **Kernel install fails but runtime install succeeds**: see Scenario D above. Do not treat this as a total failure; the user's skills still work via PATH fallback.
- **Instruction-file foreign rows (exit 2, `instruction-file.foreign-rows`)** — 0.7.0+ — init now refuses to silently clobber custom rows in the `<!-- APT:framework-* -->` block. Since the multi-file fix, init stamps the block into EVERY instruction file the selected runtimes read (claude → CLAUDE.md, codex/opencode → AGENTS.md, gemini → GEMINI.md) and runs an all-target foreign-row preflight: if ANY target has foreign rows, init writes NOTHING and exits 2 (no partial install). If `init` exits 2 with `code: instruction-file.foreign-rows`, the `foreignRowIds[]` field lists the rows the generator did NOT produce and `instructionFiles[]` lists the offending files. Two paths: (a) remove the foreign rows from the listed file(s) by hand and re-run init, or (b) re-run with `--force-overwrite-catalog` to regenerate anyway. Unattended upgrade flows should detect this code and surface it to the operator — silent bypass is a regression.

</edge_cases>
