@sabaiway/agent-workflow-kit 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,67 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
4
4
  is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
5
5
  every `migrations/<version>-<slug>.md` newer than it, in semver order.
6
6
 
7
+ ## 1.8.0 — Stale-version DX: `@latest` everywhere + a no-network never-downgrade gate
8
+
9
+ A returning user ran the headline `npx @sabaiway/agent-workflow-kit init` and it quietly did nothing:
10
+ a bare `npx <pkg> init` (no `@latest`) reuses the npx cache and re-runs an **older cached build** of
11
+ the installer, which exits 0 and reports it "updated" — to the same stale version. This release makes
12
+ that mistake hard to miss while **the installer itself stays 100% network-free** — the only thing
13
+ that ever contacts npm is npx resolving `@latest`, exactly as it already does (the no-phone-home
14
+ principle is preserved; see AD-012):
15
+
16
+ - **`@latest` is the documented default everywhere.** Every prescribing surface (both READMEs, the
17
+ bridge `SKILL.md` files + their bundled mirrors, the installer `--help` / header) now shows
18
+ `npx @sabaiway/agent-workflow-kit@latest init`. A new drift guard
19
+ (`test/init-command-uses-latest.test.mjs`) fails the build if a bare form sneaks back in (historical
20
+ contexts — CHANGELOG / `releases/` / `migrations/` — are exempt).
21
+ - **Never-downgrade gate (no network).** `init` reads the installed skill's version from
22
+ `SKILL.md` **before** writing; if the installed kit is **newer** than the version you ran (the exact
23
+ stale-cache signature), it **refuses** (nonzero) and points at `@latest`, rather than silently
24
+ overwriting a newer install with old code. `--force` overrides. A legacy install with no version
25
+ stamp still upgrades cleanly.
26
+ - **No-op re-run hint.** When `init` refreshes the skill with the *same* version it already had, it
27
+ says so and points at `@latest` — the no-network signal that catches the reported scenario.
28
+ - **In-agent skill** (`SKILL.md`): surfaces a one-line version status (project `docs/ai/.workflow-version`
29
+ vs the lineage head) + routes (bootstrap / upgrade / current), spells out the **two independent
30
+ version axes** (project deployment vs kit freshness — the latter is the npx installer's job, never
31
+ this skill's), and tells you to **restart the session** after refreshing the kit so the new skill
32
+ files load.
33
+
34
+ New users are unaffected (an empty npx cache already fetches `latest`); this targets the returning-user
35
+ trap. No `docs/ai` structural change → the deployment-lineage head stays **`1.3.0`**; no migration.
36
+
37
+ ## 1.7.0 — Link-only backend auto-setup; bridges bundled in the tarball
38
+
39
+ The optional execution-backend bridges (`codex-cli-bridge` → `codex`, `antigravity-cli-bridge` →
40
+ `agy`) can now be set up from the kit itself, via a new **opt-in, in-agent** mode —
41
+ **`/agent-workflow-kit setup [backend]`** (`tools/setup-backends.mjs`). It owns only the two
42
+ **deterministic, secret-free** steps and **guides** the rest (AD-011):
43
+
44
+ - **Bridges are bundled in the kit's npm tarball** under `bridges/<name>/` — a **byte-identical
45
+ mirror** of the repo-root bridges, pinned by `test/bridges-mirror.test.mjs` (the same drift-guard
46
+ pattern as the methodology mirror). So `setup` places a bridge from local files, with **no network
47
+ fetch**. `init` (npx) bundles them but still **does not place** them — that stays the opt-in
48
+ `setup` job (preserving the honest `init` ≠ deploy claim).
49
+ - **`setup` places/refreshes the bundled bridge skill**, but only into a dir that is **absent /
50
+ empty / proven-managed** (valid manifest, matching `name`+`kind`); a stub/foreign/invalid/
51
+ unsupported manifest, a marker fs-error, a non-empty unknown dir, or a symlinked dir → **STOP**,
52
+ never overwritten. Refresh re-runs on a managed dir so re-running `setup` delivers bundled fixes.
53
+ - **It links the wrappers** (`codex-exec` / `codex-review`; `agy-run`) onto `PATH` (`~/.local/bin`,
54
+ override with `--bindir`) via **managed symlinks** — replacing only a symlink already pointing at
55
+ our source. It **preflights every target first**, so a conflict on one wrapper makes **zero**
56
+ changes; a non-symlink or a foreign symlink → STOP. Wrapper presence is judged **per-bindir**, not
57
+ PATH-wide. `--dry-run` prints the plan and changes nothing.
58
+ - **The binary install + the interactive subscription login stay manual** — `setup` prints the exact
59
+ commands (the detector's axis-aware `guideFor`), never runs a subscription CLI, never commits. On
60
+ **Windows** it reports *unsupported — use WSL* and mutates nothing (the wrappers are POSIX `.sh`).
61
+ - Internal: the symlink-traversal-safe copy/link primitives are now shared in `tools/fs-safe.mjs`
62
+ (the npx installer consumes them and gained an `isDirectRun` guard so importing it runs nothing).
63
+ The per-package publish workflow now gates the kit on its **whole** test suite, not just the
64
+ shipped enforcement scripts.
65
+
66
+ No `docs/ai` structural change → the deployment-lineage head stays **`1.3.0`**; no migration.
67
+
7
68
  ## 1.6.0 — Methodology slot reconciliation; engine becomes the canonical methodology home
8
69
 
9
70
  The workflow methodology now has a **single canonical home** in `agent-workflow-engine`
package/README.md CHANGED
@@ -18,7 +18,7 @@ decisions. Works with Claude Code, Codex, Cursor, and any agent that reads `AGEN
18
18
  **One command to start:**
19
19
 
20
20
  ```bash
21
- npx @sabaiway/agent-workflow-kit init
21
+ npx @sabaiway/agent-workflow-kit@latest init
22
22
  ```
23
23
 
24
24
  <sub>This installs the **global skill** — deploying into a project is a separate step ([below](#-install)).</sub>
@@ -134,7 +134,7 @@ Hidden changes how the files are *tracked*, not where agents find them.
134
134
  ### 1. Install the global skill — once per machine
135
135
 
136
136
  ```bash
137
- npx @sabaiway/agent-workflow-kit init
137
+ npx @sabaiway/agent-workflow-kit@latest init
138
138
  ```
139
139
 
140
140
  `init` installs/refreshes the skill at `~/.claude/skills/agent-workflow-kit/` and wires launchers for
@@ -216,6 +216,7 @@ command is printed).
216
216
  | `/agent-workflow-kit` | new / empty project | recon → **asks visible-or-hidden** + **conversational language** + **agent attribution** (default off) → deploys `AGENTS.md` + `docs/ai/` filled with real recon data → installs enforcement → **asks before committing** |
217
217
  | `/agent-workflow-kit upgrade` | existing deployment | reads `docs/ai/.workflow-version`, shows the changelog diff, preserves your authored memory, applies migrations, re-stamps |
218
218
  | `/agent-workflow-kit backends` | any time | **read-only** check of the optional execution-backends (the `codex` / `agy` bridges): what's set up vs missing and the next step. Never writes, never commits, never runs a subscription CLI (credentials = marker-file presence, not a live login). |
219
+ | `/agent-workflow-kit setup [backend]` | opt-in, any time | **link-only** auto-setup of a bridge: places the bundled bridge skill (only into an absent / empty / managed dir — never overwrites an unmanaged one) + links its wrappers onto `PATH` via managed symlinks (idempotent; refuses to clobber a non-symlink; try `--dry-run` to preview). The binary install + the one-time subscription login stay **manual**: it prints the exact **login** command and points the binary install at each bridge's `setup/README.md`. POSIX wrappers — on Windows use WSL. Never commits, never runs a subscription CLI. |
219
220
 
220
221
  It **never auto-commits** and **never overwrites** an existing `AGENTS.md` without asking.
221
222
 
@@ -248,17 +249,20 @@ agent-workflow-kit — the composition root (installed via npx … init)
248
249
  ├─ delegates ─▶ memory substrate (healthy copy, else bundled fallback)
249
250
  ├─ injects ─▶ workflow methodology (engine = future supplier; stub)
250
251
  ├─ deploys ─▶ AGENTS.md + docs/ai/ + Node scripts + pre-commit hook
251
- └─ detects ─▶ optional backends (codex / agy — not by init)
252
+ ├─ detects ─▶ optional backends (codex / agy, read-only)
253
+ └─ sets up ─▶ a bridge (opt-in) (place skill + link wrappers)
252
254
  ```
253
255
 
254
256
  - **Delegates** substrate deployment to **`@sabaiway/agent-workflow-memory`** when a healthy
255
257
  standalone copy is present, else uses its **bundled fallback** — same `docs/ai/` either way.
256
258
  - **Injects** the bounded workflow methodology into the deployed `AGENTS.md`. Its *future* home is
257
259
  **`agent-workflow-engine`** — today an `available: false` stub, never one of the shipped backends.
258
- - **Detects** the optional `codex` / `agy` **bridges** — agent skills with a manual once-per-machine
259
- setup (not npm, not installed by `init`); `/agent-workflow-kit backends` reports readiness
260
- read-only. A bridge reads the deployed memory only if it wins that tool's context-file priority,
261
- and the bridges call third-party services (so "no telemetry" covers family code, not those).
260
+ - **Detects & (opt-in) sets up** the optional `codex` / `agy` **bridges** — agent skills (not npm, not
261
+ installed by `init`). `/agent-workflow-kit backends` reports readiness **read-only**;
262
+ `/agent-workflow-kit setup` does the **link-only** part (place the bundled bridge skill + link its
263
+ wrappers), while the binary install + the subscription login stay manual. A bridge reads the deployed
264
+ memory only if it wins that tool's context-file priority, and the bridges call third-party services
265
+ (so "no telemetry" covers family code, not those).
262
266
 
263
267
  > Full member-by-member map + the whole-family story: the
264
268
  > **[family front door](https://github.com/sabaiway/agent-workflow#readme)** — this page stays the
@@ -295,7 +299,10 @@ agent-workflow-kit/
295
299
  │ ├── delegation.mjs ← detect substrate · delegate-or-fall-back
296
300
  │ ├── inject-methodology.mjs ← write the methodology slot
297
301
  │ ├── detect-backends.mjs ← read-only backend detector
302
+ │ ├── setup-backends.mjs ← link-only backend setup
303
+ │ ├── fs-safe.mjs ← symlink-safe copy/link
298
304
  │ └── release-scan.mjs ← attribution / release gate
305
+ ├── bridges/ ← bundled bridge skill mirrors (codex / antigravity)
299
306
  ├── launchers/ ← Codex / Devin Desktop / Cursor entries
300
307
  └── migrations/ ← per-version upgrade steps
301
308
  ```
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-kit
3
3
  description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '1.6.0'
6
+ version: '1.8.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
@@ -83,6 +83,23 @@ Pick the mode from the user's invocation. Auto-detect an existing `docs/ai/` to
83
83
  - **`/agent-workflow-kit`** (default) — bootstrap a new or empty project. If `docs/ai/` already exists, stop and ask whether they meant `upgrade`.
84
84
  - **`/agent-workflow-kit upgrade`** — upgrade an existing deployment to the skill's current `version`.
85
85
  - **`/agent-workflow-kit backends`** — read-only environment check: which optional **execution-backends** (the `codex` / `agy` bridges) are set up vs missing. Never writes, never commits, never runs a subscription CLI.
86
+ - **`/agent-workflow-kit setup [backend]`** — the **link-only**, opt-in companion to `backends`: place the bundled bridge skill + link its wrappers onto `PATH`. **In-agent only** — `init` (npx) never places bridges. The binary install + the interactive subscription login stay **manual** (it prints the exact commands); idempotent; refuses to clobber a non-symlink; never commits, never runs a subscription CLI.
87
+
88
+ ### Version status & the two axes — surface this on every invocation
89
+
90
+ Before acting, read `docs/ai/.workflow-version` (the project's stamp), state a one-line status, then route:
91
+
92
+ - **absent** → bootstrap (a fresh deployment).
93
+ - **stamp < `1.3.0`** (the deployment-lineage head) → `upgrade`.
94
+ - **stamp == `1.3.0`** → already current; only the stamp-independent methodology-slot reconcile may run.
95
+ - **stamp > head / unparseable** → STOP — never-downgrade gate (see *Mode: upgrade* step 2).
96
+
97
+ **Two independent version axes — never conflate them:**
98
+
99
+ 1. **Project deployment** — `docs/ai/.workflow-version` vs the lineage head (`1.3.0`). This is the **only** axis this skill compares.
100
+ 2. **Kit freshness** — this skill's own files vs the published npm package. That is the **npx installer's** job: `npx @sabaiway/agent-workflow-kit@latest init` (it refuses a stale-cache downgrade by comparing the version on disk — **no network**). This skill never checks npm, and the package version (e.g. `1.x`) is **not** the lineage head.
101
+
102
+ **Refreshed the kit but nothing changed?** The skill you are running is whatever was on disk when the session started. After `npx @sabaiway/agent-workflow-kit@latest init` updates `~/.claude/skills/agent-workflow-kit/`, **restart the session** so the agent reloads the new skill files (the slash command + this `SKILL.md`).
86
103
 
87
104
  ### Mode: bootstrap
88
105
 
@@ -145,6 +162,26 @@ Read-only. Answers *"which optional execution-backends are set up vs missing, an
145
162
  - **"credentials present"** means the credential-marker **file** exists — it is **not** a live login check. The detector never runs `codex login status` / `agy` (that would spawn a paid, slow, networked subscription CLI).
146
163
  - The bridges' wrappers are **POSIX `.sh`** scripts. On Windows the detector still works, but the bridges themselves are **not promised to run** — say so rather than implying they will.
147
164
 
165
+ ### Mode: setup
166
+
167
+ The **only writer** among the backend modes, and **opt-in / in-agent only** — it is **never** part of `init`. The npx installer deploys the *kit* and bundles the bridge skills in its tarball, but **does not place** them (that honesty claim is load-bearing — see `decisions.md` AD-009 / AD-011). `setup` owns exactly the two deterministic, secret-free steps and **guides** the rest. It **never commits and never runs a subscription CLI**.
168
+
169
+ Run `node ${CLAUDE_SKILL_DIR}/tools/setup-backends.mjs [<backend>] [--bindir <path>] [--dry-run]`:
170
+
171
+ - `<backend>` — `codex` | `agy` | `antigravity` | `codex-cli-bridge` | `antigravity-cli-bridge`; omit for **all**.
172
+ - `--bindir <path>` — where to link the wrappers (default `~/.local/bin`).
173
+ - `--dry-run` — print the per-backend plan and change **nothing** (run this first).
174
+ - `--help`, `-h` — usage.
175
+
176
+ For each backend it:
177
+ 1. **Places / refreshes the bundled bridge skill** (from the kit's `bridges/<name>/` mirror) into its canonical dir — but only when that dir is **absent / empty / proven-managed** (valid manifest, matching `name`+`kind`). A `stub` / `foreign` / `invalid` / `unsupported` dir, a marker fs-error, or a symlinked dir → **STOP**, never overwritten. Refresh re-runs on a proven-managed dir so re-running `setup` delivers bundled fixes.
178
+ 2. **Links its wrappers** (`codex-exec` / `codex-review`; `agy-run`) onto `--bindir` via **managed symlinks** — replacing only a symlink that already points at our source. A non-symlink or a foreign symlink → **STOP**; it **preflights every target first**, so a conflict on one wrapper makes **zero** changes. If `--bindir` is not on `PATH`, it prints the one-line `export PATH=…` to add — it never edits a shell rc.
179
+ 3. **Guides the manual, secret-bearing steps it will NOT automate** — the binary install (each bridge's `setup/README.md` §1) and the one-time interactive subscription login (`codex login` / `agy`) — printing the exact command for whichever axis is still missing (axis-aware: it can ask for both the CLI and the login at once).
180
+
181
+ **Windows:** the wrappers are POSIX `.sh`; on `win32` it reports *unsupported — use WSL* and mutates nothing.
182
+
183
+ **Exit codes:** `0` = done / already set up / only manual steps remain (guidance is never a failure); **non-zero** = a STOP (a dir/symlink it refuses to clobber), a bad argument, a missing bundle, or a native fs error (the underlying reason is preserved in the message).
184
+
148
185
  ---
149
186
 
150
187
  ## Gotchas
@@ -214,6 +251,6 @@ Deploy these into `AGENTS.md`; remove rows that don't apply to the stack.
214
251
  - [`references/scripts/`](references/scripts/) — the Node enforcement scripts (caps + staleness + index-freshness gate, 3-tier archive, hook installer) and their unit tests.
215
252
  - [`migrations/`](migrations/) — per-version upgrade steps; see `migrations/README.md`.
216
253
  - [`launchers/`](launchers/) — run the bootstrapper from non-Claude agents (`SKILL.md` is a native Codex skill; a Devin Desktop workflow launcher + install script). See `launchers/README.md`.
217
- - [`tools/`](tools/) — the family-wide tooling the kit **owns and ships**: `manifest/{schema.md,validate.mjs}` (the `capability.json` schema + the validator the kit runs as the memory detector, and root CI invokes), `delegation.mjs` (the executable delegate/fallback decision + hand-off plan), `inject-methodology.mjs` + `methodology-slot.md` (the bounded slot reconciliation — ensure-slot / inject-if-empty / cap; the fragment is a byte-identical mirror of the `agent-workflow-engine` canon, pinned by `methodology-mirror.test.mjs`), `detect-backends.mjs` (the read-only **backend detector** behind `/agent-workflow-kit backends`), and `release-scan.mjs` (the attribution-off release gate). See [`tools/manifest/schema.md`](tools/manifest/schema.md).
254
+ - [`tools/`](tools/) — the family-wide tooling the kit **owns and ships**: `manifest/{schema.md,validate.mjs}` (the `capability.json` schema + the validator the kit runs as the memory detector, and root CI invokes), `delegation.mjs` (the executable delegate/fallback decision + hand-off plan), `inject-methodology.mjs` + `methodology-slot.md` (the bounded slot reconciliation — ensure-slot / inject-if-empty / cap; the fragment is a byte-identical mirror of the `agent-workflow-engine` canon, pinned by `methodology-mirror.test.mjs`), `detect-backends.mjs` (the read-only **backend detector** behind `/agent-workflow-kit backends`, plus the axis-aware `guideFor`), `setup-backends.mjs` (the **link-only** backend setup behind `/agent-workflow-kit setup` — place the bundled bridge + link wrappers), `fs-safe.mjs` (the shared symlink-traversal-safe copy/link primitives both `setup-backends` and the npx installer use), and `release-scan.mjs` (the attribution-off release gate). The bundled bridge skill mirrors live under [`bridges/`](bridges/) (byte-identical to the repo-root bridges, pinned by `test/bridges-mirror.test.mjs`). See [`tools/manifest/schema.md`](tools/manifest/schema.md).
218
255
  - [`capability.json`](capability.json) — the kit's own `agent-workflow` family manifest (`kind: composition-root`).
219
256
  - [`CHANGELOG.md`](CHANGELOG.md) — version history of this kernel.
package/bin/install.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // One-shot installer for @sabaiway/agent-workflow-kit.
3
3
  //
4
- // npx @sabaiway/agent-workflow-kit init
4
+ // npx @sabaiway/agent-workflow-kit@latest init
5
5
  //
6
6
  // Copies the kit into the canonical skill home (~/.claude/skills/agent-workflow-kit),
7
7
  // then runs the cross-agent launcher (auto-detects Codex / Devin Desktop — only touches tools
@@ -14,23 +14,30 @@
14
14
  // docs/ai deployment — see README "Use".
15
15
  //
16
16
  // No telemetry, no phone-home: adoption is the npm registry's public, passive per-version
17
- // download numbers (api.npmjs.org/downloads). Nothing here contacts a server.
17
+ // download numbers (api.npmjs.org/downloads). Nothing here contacts a server — including the
18
+ // stale-version defenses below, which compare the version already on disk (the installed SKILL.md)
19
+ // against this runner's own version, never the registry. That is why `@latest` (above) is the
20
+ // documented form: a bare `npx … init` can reuse an OLDER cached build of this installer, so a
21
+ // returning user must bypass the cache to actually upgrade. See decisions.md AD-012.
18
22
  //
19
23
  // Dependency-free, Node >= 18.
20
24
 
21
- import { readFile, mkdir, readdir, copyFile, lstat, readlink, symlink } from 'node:fs/promises';
25
+ import { readFile, mkdir } from 'node:fs/promises';
22
26
  import { existsSync, lstatSync } from 'node:fs';
23
- import { dirname, join, resolve, relative, sep, isAbsolute } from 'node:path';
24
- import { fileURLToPath } from 'node:url';
27
+ import { dirname, resolve } from 'node:path';
28
+ import { fileURLToPath, pathToFileURL } from 'node:url';
25
29
  import { homedir } from 'node:os';
26
30
  import { spawnSync } from 'node:child_process';
31
+ import { copyTreeRefresh } from '../tools/fs-safe.mjs';
27
32
 
28
33
  const __dirname = dirname(fileURLToPath(import.meta.url));
29
34
  const PKG_ROOT = resolve(__dirname, '..');
30
35
 
31
36
  // The deployable skill = everything except the npm wrapper (package.json, bin/).
32
37
  // capability.json (the family manifest) + tools/ (the family schema + validator the kit runs
33
- // as the memory detector) must land in the installed skill dir too.
38
+ // as the memory detector) must land in the installed skill dir too. bridges/ carries the
39
+ // byte-identical execution-backend skill mirrors (codex/antigravity) so `setup` can place a bridge
40
+ // from the installed kit, with no network fetch (Plan B / AD-011).
34
41
  const PAYLOAD = [
35
42
  'SKILL.md',
36
43
  'README.md',
@@ -40,6 +47,7 @@ const PAYLOAD = [
40
47
  'launchers',
41
48
  'migrations',
42
49
  'tools',
50
+ 'bridges',
43
51
  ];
44
52
 
45
53
  const tildify = (path) => path.replace(homedir(), '~');
@@ -53,6 +61,58 @@ const readVersion = async () => {
53
61
  }
54
62
  };
55
63
 
64
+ // Dependency-free semver: parse the leading `x.y.z` (prerelease/build ignored — kit versions are
65
+ // plain). compareSemver returns -1 | 0 | 1, or null when either side is unparseable (legacy installs
66
+ // predate any version stamp). No `let`: a small functional comparison (AGENTS.md §2.3).
67
+ const parseSemver = (str) => {
68
+ const m = typeof str === 'string' ? str.trim().match(/^(\d+)\.(\d+)\.(\d+)/) : null;
69
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
70
+ };
71
+
72
+ const compareSemver = (a, b) => {
73
+ const pa = parseSemver(a);
74
+ const pb = parseSemver(b);
75
+ if (!pa || !pb) return null;
76
+ const firstDiff = [0, 1, 2].map((i) => (pa[i] === pb[i] ? 0 : pa[i] < pb[i] ? -1 : 1)).find((c) => c !== 0);
77
+ return firstDiff ?? 0;
78
+ };
79
+
80
+ // Extract the version that is a DIRECT child of the top-level `metadata:` key — never a top-level
81
+ // or deeper-nested decoy `version:` (mirrors the manifest validator's rigor; the kit ships manifest
82
+ // fixtures that probe exactly those decoys). Pure string walk over the frontmatter block, no deps.
83
+ const metadataVersion = (frontmatter) => {
84
+ const lines = frontmatter.split(/\r?\n/);
85
+ const metaIdx = lines.findIndex((l) => /^metadata:[ \t]*$/.test(l));
86
+ if (metaIdx === -1) return null;
87
+ const after = lines.slice(metaIdx + 1);
88
+ const dedent = after.findIndex((l) => /^[^ \t]/.test(l)); // a column-0 line closes the metadata block
89
+ const block = dedent === -1 ? after : after.slice(0, dedent);
90
+ // Direct children share the first child's indent; a nested decoy is MORE indented, so a
91
+ // `<baseIndent>version:` prefix match excludes it. baseIndent is non-empty, so a top-level
92
+ // `version:` (column 0, and before `metadata:` anyway) is excluded too.
93
+ const baseIndent = block.length ? (block[0].match(/^[ \t]*/)?.[0] ?? '') : '';
94
+ const verLine = block.find((l) => l.startsWith(`${baseIndent}version:`));
95
+ return verLine?.match(/version:[ \t]*['"]?(\d+\.\d+\.\d+)['"]?/)?.[1] ?? null;
96
+ };
97
+
98
+ // The installed version is read from the target's SKILL.md frontmatter (`metadata.version`) — the
99
+ // manifest's canonical `detect.installed.file`, present even on legacy installs that predate
100
+ // capability.json. Returns the semver string, or null when ABSENT / has no parseable stamp (legacy
101
+ // → no gate). A SKILL.md that EXISTS but cannot be read is NOT swallowed as "legacy": we fail closed
102
+ // (throw) so the never-downgrade gate can never be silently bypassed (AGENTS.md: no silent failures).
103
+ const readInstalledVersion = async (target) => {
104
+ const skill = resolve(target, 'SKILL.md');
105
+ if (!existsSync(skill)) return null; // absent → new/legacy install, nothing to compare
106
+ const text = await readFile(skill, 'utf8').catch((err) => {
107
+ throw new Error(
108
+ `[agent-workflow-kit] cannot read the installed SKILL.md (${tildify(skill)}): ${err.message}. ` +
109
+ `Refusing to install over an unreadable kit — fix permissions/contents or remove it, then re-run.`,
110
+ );
111
+ });
112
+ const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
113
+ return fm ? metadataVersion(fm[1]) : null;
114
+ };
115
+
56
116
  // lstat without following symlinks; null when absent. existsSync FOLLOWS symlinks (so a
57
117
  // *dangling* symlink reads as absent) — lstat is what lets the guard catch a dangling dest symlink.
58
118
  const lstatNoFollow = (path) => {
@@ -64,42 +124,10 @@ const lstatNoFollow = (path) => {
64
124
  }
65
125
  };
66
126
 
67
- // Symlink-traversal guard: refuse to write *through* any symlink at or above `dest` within
68
- // `root` (root / intermediate dir / leaf, including a dangling one), or to a dest outside `root`.
69
- const assertContainedRealPath = (root, dest) => {
70
- const rel = relative(root, dest);
71
- if (rel.startsWith('..') || isAbsolute(rel)) {
72
- throw new Error(`[agent-workflow-kit] refusing to write outside the target dir: ${dest}`);
73
- }
74
- if (lstatNoFollow(root)?.isSymbolicLink()) {
75
- throw new Error(`[agent-workflow-kit] refusing to install into a symlinked target dir: ${root}`);
76
- }
77
- const walk = (acc, part) => {
78
- const cur = join(acc, part);
79
- if (lstatNoFollow(cur)?.isSymbolicLink()) {
80
- throw new Error(`[agent-workflow-kit] refusing to write through a symlink at ${cur} (would escape ${root}).`);
81
- }
82
- return cur;
83
- };
84
- rel.split(sep).filter(Boolean).reduce(walk, root);
85
- };
86
-
87
- const copyRecursive = async (src, dest, root) => {
88
- assertContainedRealPath(root, dest); // never write through a dest symlink (root/intermediate/leaf)
89
- const stat = await lstat(src);
90
- if (stat.isSymbolicLink()) {
91
- if (existsSync(dest)) return; // additive: never delete/replace an existing entry
92
- const linkTarget = await readlink(src);
93
- await symlink(linkTarget, dest);
94
- } else if (stat.isDirectory()) {
95
- await mkdir(dest, { recursive: true });
96
- const entries = await readdir(src);
97
- await Promise.all(entries.map((entry) => copyRecursive(join(src, entry), join(dest, entry), root)));
98
- } else {
99
- await mkdir(dirname(dest), { recursive: true });
100
- await copyFile(src, dest);
101
- }
102
- };
127
+ // The symlink-traversal guard + the recursive refresh copy now live in tools/fs-safe.mjs (shared,
128
+ // dependency-injectable, unit-tested in isolation). install.mjs consumes copyTreeRefresh, which guards
129
+ // every dest via assertContainedRealPath internally. The local lstatNoFollow above stays for the
130
+ // pre-flight check on the target root itself (a nicer error than letting the copy throw).
103
131
 
104
132
  const parseArgs = (argv) => {
105
133
  const dirFlag = argv.indexOf('--dir');
@@ -108,6 +136,7 @@ const parseArgs = (argv) => {
108
136
  version: argv.includes('--version') || argv.includes('-v'),
109
137
  noLaunchers: argv.includes('--no-launchers'),
110
138
  force: argv.includes('--force'),
139
+ allowDowngrade: argv.includes('--allow-downgrade'),
111
140
  dir: dirFlag >= 0 ? argv[dirFlag + 1] : undefined,
112
141
  };
113
142
  };
@@ -122,15 +151,20 @@ const printHelp = (version) => {
122
151
  console.log(`agent-workflow-kit ${version}
123
152
 
124
153
  Usage:
125
- npx @sabaiway/agent-workflow-kit init [--dir <path>] [--no-launchers] [--force]
126
- npx @sabaiway/agent-workflow-kit --version
127
- npx @sabaiway/agent-workflow-kit --help
154
+ npx @sabaiway/agent-workflow-kit@latest init [--dir <path>] [--no-launchers] [--force] [--allow-downgrade]
155
+ npx @sabaiway/agent-workflow-kit@latest --version
156
+ npx @sabaiway/agent-workflow-kit@latest --help
157
+
158
+ Use the @latest form: a bare \`npx … init\` (no @latest) can reuse an OLDER cached
159
+ build of this installer, so a returning user must bypass the npx cache to upgrade.
128
160
 
129
161
  Installs/refreshes the kit at ~/.claude/skills/agent-workflow-kit
130
162
  (override with --dir <path> or AGENT_WORKFLOW_KIT_DIR), then wires any
131
163
  Codex / Devin Desktop you have. --no-launchers skips that wiring; --force replaces a
132
164
  pre-existing non-kit launcher file (backed up first). init is additive — it never
133
- deletes your settings.
165
+ deletes your settings. If the installed kit is newer than the version you ran, init
166
+ refuses (no network — it compares the version on disk) and points you at @latest;
167
+ --allow-downgrade overrides that refusal (distinct from --force, which is launcher-only).
134
168
 
135
169
  After install, invoke the skill in your agent, inside a project:
136
170
  first time in the project -> /agent-workflow-kit
@@ -150,7 +184,15 @@ const main = async () => {
150
184
 
151
185
  // Critical payload must be present, or the install would silently ship a kit that can't run
152
186
  // its own detector (tools/) or family contract (capability.json). Fail loudly, don't filter away.
153
- const REQUIRED = ['SKILL.md', 'capability.json', 'references', 'tools', 'migrations'];
187
+ const REQUIRED = [
188
+ 'SKILL.md',
189
+ 'capability.json',
190
+ 'references',
191
+ 'tools',
192
+ 'migrations',
193
+ 'bridges/codex-cli-bridge',
194
+ 'bridges/antigravity-cli-bridge',
195
+ ];
154
196
  const missing = REQUIRED.filter((entry) => !existsSync(resolve(PKG_ROOT, entry)));
155
197
  if (missing.length > 0) {
156
198
  console.error(`[agent-workflow-kit] package payload incomplete — missing: ${missing.join(', ')} (corrupt install?)`);
@@ -163,14 +205,46 @@ const main = async () => {
163
205
  console.error(`[agent-workflow-kit] target dir is a symlink — refusing to write through it: ${tildify(target)}`);
164
206
  process.exit(1);
165
207
  }
208
+
209
+ // Stale-cache defenses (no network — version already on disk vs this runner). Read BEFORE any
210
+ // write so a refusal touches nothing. cmp is null on a legacy/unparseable install → no gate.
211
+ const installedVersion = wasPresent ? await readInstalledVersion(target) : null;
212
+ const cmp = installedVersion ? compareSemver(installedVersion, version) : null;
213
+
214
+ // Never-downgrade gate: a bare `npx … init` can run an OLDER cached build of this installer, which
215
+ // would overwrite a NEWER installed skill with old code. Refuse loudly (nonzero) unless the
216
+ // dedicated --allow-downgrade override is passed — surfacing the cache trap instead of silently
217
+ // regressing the install (AGENTS.md: no silent failures). The override is its OWN flag, NOT --force:
218
+ // --force means "replace a foreign launcher file" and is forwarded to the launcher; conflating them
219
+ // would let someone clearing the version gate also clobber launchers by accident.
220
+ if (cmp === 1 && !args.allowDowngrade) {
221
+ console.error(
222
+ `[agent-workflow-kit] refusing to downgrade: the installed kit is v${installedVersion}, but this ` +
223
+ `runner is the OLDER v${version}.\n` +
224
+ ` This is the classic npx cache serving a stale build. To get the newest kit, bypass the cache:\n` +
225
+ ` npx @sabaiway/agent-workflow-kit@latest init\n` +
226
+ ` (or pass --allow-downgrade to overwrite the newer install with v${version} anyway).`,
227
+ );
228
+ process.exit(1);
229
+ }
230
+
166
231
  await mkdir(target, { recursive: true });
167
- await Promise.all(
168
- PAYLOAD.filter((entry) => existsSync(resolve(PKG_ROOT, entry))).map((entry) =>
169
- copyRecursive(resolve(PKG_ROOT, entry), resolve(target, entry), target),
170
- ),
171
- );
232
+ for (const entry of PAYLOAD.filter((e) => existsSync(resolve(PKG_ROOT, e)))) {
233
+ copyTreeRefresh(resolve(PKG_ROOT, entry), resolve(target, entry), target);
234
+ }
172
235
  console.log(`[agent-workflow-kit] ${wasPresent ? 'updated the kit to' : 'installed'} v${version} -> ${tildify(target)}`);
173
236
 
237
+ // No-op re-run: the install just refreshed the skill with the SAME version it already had. For a
238
+ // user who ran `init` expecting an upgrade, that almost always means npx reused a cached build —
239
+ // say so explicitly and point at @latest (the no-network signal that catches the reported scenario).
240
+ if (cmp === 0) {
241
+ console.log(
242
+ `[agent-workflow-kit] note: no version change — the kit was already v${version}. If you expected ` +
243
+ `an update, npx likely served a cached build; re-run bypassing the cache:\n` +
244
+ ` npx @sabaiway/agent-workflow-kit@latest init`,
245
+ );
246
+ }
247
+
174
248
  // Wire non-Claude agents — best-effort; the launcher only touches tools you have.
175
249
  const launcher = resolve(target, 'launchers/install-launchers.sh');
176
250
  if (args.noLaunchers) {
@@ -198,7 +272,12 @@ This command only installs/updates the kit itself (in ${tildify(target)}).
198
272
  To update the kit later, re-run: npx @sabaiway/agent-workflow-kit@latest init`);
199
273
  };
200
274
 
201
- main().catch((err) => {
202
- console.error(err);
203
- process.exit(1);
204
- });
275
+ // Run main() only when executed directly (npx / node bin/install.mjs), never on import — so tests
276
+ // can import this module to assert it has no side effects. Same idiom as tools/detect-backends.mjs.
277
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
278
+ if (isDirectRun) {
279
+ main().catch((err) => {
280
+ console.error(err);
281
+ process.exit(1);
282
+ });
283
+ }
@@ -1,16 +1,29 @@
1
1
  import { describe, it, beforeEach, afterEach } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { spawnSync } from 'node:child_process';
4
- import { mkdtemp, rm, mkdir, symlink, readdir } from 'node:fs/promises';
4
+ import { mkdtemp, rm, mkdir, symlink, readdir, readFile, writeFile } from 'node:fs/promises';
5
5
  import { existsSync } from 'node:fs';
6
6
  import { tmpdir } from 'node:os';
7
7
  import { dirname, join } from 'node:path';
8
- import { fileURLToPath } from 'node:url';
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
9
 
10
10
  const INSTALLER = join(dirname(fileURLToPath(import.meta.url)), 'install.mjs');
11
- // --no-launchers so the test never wires Codex/Devin on the host.
12
- const runInstaller = (target) =>
13
- spawnSync(process.execPath, [INSTALLER, '--dir', target, '--no-launchers'], { encoding: 'utf8' });
11
+ const KIT_ROOT = dirname(dirname(INSTALLER));
12
+ // --no-launchers so the test never wires Codex/Devin on the host. `extra` appends flags (e.g. --force).
13
+ const runInstaller = (target, extra = []) =>
14
+ spawnSync(process.execPath, [INSTALLER, '--dir', target, '--no-launchers', ...extra], { encoding: 'utf8' });
15
+
16
+ // Rewrite / read the installed skill's frontmatter version — used to simulate "a newer kit is already
17
+ // installed" (the stale-cache downgrade scenario). The installer reads exactly this field.
18
+ const setInstalledVersion = async (target, v) => {
19
+ const p = join(target, 'SKILL.md');
20
+ const text = await readFile(p, 'utf8');
21
+ await writeFile(p, text.replace(/version:\s*['"]?\d+\.\d+\.\d+['"]?/, `version: '${v}'`));
22
+ };
23
+ const getInstalledVersion = async (target) =>
24
+ (await readFile(join(target, 'SKILL.md'), 'utf8')).match(/version:\s*['"]?(\d+\.\d+\.\d+)['"]?/)?.[1] ?? null;
25
+ const pkgVersion = async () =>
26
+ JSON.parse(await readFile(join(KIT_ROOT, 'package.json'), 'utf8')).version;
14
27
 
15
28
  describe('kit installer — payload + symlink-traversal hardening', () => {
16
29
  let dir;
@@ -64,3 +77,123 @@ describe('kit installer — payload + symlink-traversal hardening', () => {
64
77
  assert.deepEqual(await readdir(real), []);
65
78
  });
66
79
  });
80
+
81
+ describe('kit installer — module hygiene', () => {
82
+ it('importing install.mjs runs nothing (main() is guarded by isDirectRun)', () => {
83
+ // `node -e` has no argv[1], so isDirectRun is false → importing must not run main()
84
+ // (no FS writes, no exit). A child process keeps the assertion off the test runner's own state.
85
+ const url = JSON.stringify(pathToFileURL(INSTALLER).href);
86
+ const res = spawnSync(
87
+ process.execPath,
88
+ ['--input-type=module', '-e', `import(${url}).then(() => console.log('IMPORT_OK'));`],
89
+ { encoding: 'utf8' },
90
+ );
91
+ assert.equal(res.status, 0, res.stderr);
92
+ assert.match(res.stdout, /IMPORT_OK/);
93
+ assert.doesNotMatch(res.stdout, /installed v|updated the kit/);
94
+ });
95
+ });
96
+
97
+ describe('kit installer — stale-cache defenses (no network)', () => {
98
+ let dir;
99
+ beforeEach(async () => {
100
+ dir = await mkdtemp(join(tmpdir(), 'aw-kit-stale-'));
101
+ });
102
+ afterEach(async () => {
103
+ await rm(dir, { recursive: true, force: true });
104
+ });
105
+
106
+ it('a no-op re-run (same version) flags the likely npx cache and points at @latest', async () => {
107
+ const target = join(dir, 'agent-workflow-kit');
108
+ assert.equal(runInstaller(target).status, 0); // first install
109
+ const again = runInstaller(target); // second run: installed == running
110
+ assert.equal(again.status, 0, again.stderr);
111
+ assert.match(again.stdout, /no version change/i);
112
+ assert.match(again.stdout, /cache/i);
113
+ assert.match(again.stdout, /@latest/);
114
+ });
115
+
116
+ it('refuses to downgrade when the installed kit is NEWER, and writes nothing', async () => {
117
+ const target = join(dir, 'agent-workflow-kit');
118
+ assert.equal(runInstaller(target).status, 0);
119
+ await setInstalledVersion(target, '99.0.0'); // pretend a newer kit is already installed
120
+ const res = runInstaller(target); // running version < installed → downgrade
121
+ assert.notEqual(res.status, 0);
122
+ assert.match(res.stderr, /downgrade/i);
123
+ assert.match(res.stderr, /@latest/);
124
+ assert.equal(await getInstalledVersion(target), '99.0.0', 'newer install must be left untouched');
125
+ });
126
+
127
+ it('--allow-downgrade overrides the refusal and overwrites with the runner version', async () => {
128
+ const target = join(dir, 'agent-workflow-kit');
129
+ assert.equal(runInstaller(target).status, 0);
130
+ await setInstalledVersion(target, '99.0.0');
131
+ const res = runInstaller(target, ['--allow-downgrade']);
132
+ assert.equal(res.status, 0, res.stderr);
133
+ assert.equal(await getInstalledVersion(target), await pkgVersion());
134
+ });
135
+
136
+ it('--force alone does NOT override the downgrade gate (the override is its own flag)', async () => {
137
+ const target = join(dir, 'agent-workflow-kit');
138
+ assert.equal(runInstaller(target).status, 0);
139
+ await setInstalledVersion(target, '99.0.0');
140
+ const res = runInstaller(target, ['--force']); // launcher-clobber flag, not the gate override
141
+ assert.notEqual(res.status, 0, 'launcher --force must not silently clear the version gate');
142
+ assert.equal(await getInstalledVersion(target), '99.0.0', 'newer install must be left untouched');
143
+ });
144
+
145
+ it('reads the version under `metadata:`, never a top-level / nested decoy `version:`', async () => {
146
+ // A crafted SKILL.md whose REAL metadata.version (0.0.1) is OLDER than the runner, but with decoy
147
+ // `version:` lines that are NEWER. A naive "first version: in frontmatter" read would see 99.0.0
148
+ // and wrongly refuse; the correct read sees 0.0.1 → a normal upgrade (exit 0).
149
+ const target = join(dir, 'agent-workflow-kit');
150
+ await mkdir(target, { recursive: true });
151
+ const decoy = [
152
+ '---',
153
+ "version: '99.0.0'", // top-level decoy (column 0)
154
+ 'name: agent-workflow-kit',
155
+ 'metadata:',
156
+ ' nested:',
157
+ " version: '98.0.0'", // deeper-nested decoy
158
+ " version: '0.0.1'", // the authoritative direct child
159
+ '---',
160
+ '# decoy',
161
+ '',
162
+ ].join('\n');
163
+ await writeFile(join(target, 'SKILL.md'), decoy);
164
+ const res = runInstaller(target);
165
+ assert.equal(res.status, 0, `expected a normal upgrade (read 0.0.1), got: ${res.stderr}`);
166
+ assert.match(res.stdout, /updated the kit to/);
167
+ });
168
+
169
+ it('fails closed (does not silently treat as legacy) when an existing SKILL.md cannot be read', async () => {
170
+ // SKILL.md present but unreadable (a directory → EISDIR on read). The gate must NOT be bypassed:
171
+ // we refuse rather than overwrite a kit whose version we could not determine (no silent failure).
172
+ const target = join(dir, 'agent-workflow-kit');
173
+ await mkdir(join(target, 'SKILL.md'), { recursive: true });
174
+ const res = runInstaller(target);
175
+ assert.notEqual(res.status, 0);
176
+ assert.match(res.stderr, /cannot read the installed SKILL\.md/i);
177
+ });
178
+
179
+ it('a legacy install with no version stamp still upgrades (no false downgrade, no crash)', async () => {
180
+ const target = join(dir, 'agent-workflow-kit');
181
+ await mkdir(target, { recursive: true });
182
+ await writeFile(join(target, 'SKILL.md'), '---\nname: agent-workflow-kit\n---\n# legacy stub\n');
183
+ const res = runInstaller(target);
184
+ assert.equal(res.status, 0, res.stderr);
185
+ assert.match(res.stdout, /updated the kit to/);
186
+ });
187
+ });
188
+
189
+ describe('kit installer — published tarball bundles the bridges', () => {
190
+ it('npm pack ships bridges/<name>/ (the execution-backend skill mirrors)', () => {
191
+ // The real `files` whitelist decides what publishes — assert against `npm pack`, not the source
192
+ // tree, so a dropped `bridges/` entry in package.json fails here (not silently at install time).
193
+ const res = spawnSync('npm', ['pack', '--dry-run', '--json'], { cwd: KIT_ROOT, encoding: 'utf8' });
194
+ assert.equal(res.status, 0, res.stderr || res.error?.message);
195
+ const paths = JSON.parse(res.stdout)[0].files.map((f) => f.path);
196
+ assert.ok(paths.includes('bridges/codex-cli-bridge/SKILL.md'), 'codex bridge SKILL.md not packed');
197
+ assert.ok(paths.includes('bridges/antigravity-cli-bridge/bin/agy.sh'), 'antigravity agy.sh not packed');
198
+ });
199
+ });