Browse Source

fix(docs): clean up README headings and add fix plans

Remove duplicate "Step 2: Install via curl" heading and renumber
"Step 2: Start Building" since old Step 1 no longer exists.
Add review report and fix plan documents.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
darrenhinde 4 months ago
parent
commit
160999a094

+ 1 - 3
README.md

@@ -151,8 +151,6 @@ oac doctor
 
 **Prerequisites:** [OpenCode CLI](https://opencode.ai/docs) (free, open-source) • Bash 3.2+ • Git
 
-### Step 2: Install via curl
-
 **One command:**
 
 ```bash
@@ -175,7 +173,7 @@ curl -fsSL https://raw.githubusercontent.com/darrenhinde/OpenAgentsControl/main/
 
 > Use `--install-dir PATH` if you installed to a custom location (e.g. `~/.config/opencode`).
 
-### Step 2: Start Building
+### Start Building
 
 ```bash
 opencode --agent OpenAgent

+ 165 - 0
docs/planning/fix-plans/00-INDEX.txt

@@ -0,0 +1,165 @@
+OAC CLI Fix Plans — Package Standards Review
+============================================
+Generated: 2026-03-11
+Source files read: package.json, .npmignore, bin/oac.js, packages/cli/package.json,
+  packages/cli/src/index.ts, packages/cli/src/lib/bundled.ts,
+  packages/cli/src/lib/installer.ts, packages/cli/src/lib/manifest.ts,
+  packages/cli/src/lib/config.ts, packages/cli/src/commands/init.ts,
+  packages/cli/src/commands/update.ts, packages/cli/src/commands/doctor.ts,
+  packages/cli/src/ui/logger.ts, packages/cli/src/ui/spinner.ts,
+  packages/cli/src/lib/version.ts, README.md
+
+CRITICAL (must fix before publish):
+  C1   .npmignore excludes built CLI from published package
+         File: .npmignore
+         The `packages/` and `dist/` patterns in .npmignore exclude
+         packages/cli/dist/ — the compiled Bun binary. The package ships empty.
+
+  C2   No prepublishOnly build guard
+         Files: package.json (root), packages/cli/package.json
+         Neither file has a prepublishOnly script. Running npm publish without
+         building first silently ships an empty or stale dist/.
+
+  C3+C4  findPackageRoot fails in production + bin/oac.js fix
+         Files: packages/cli/src/lib/bundled.ts, bin/oac.js
+         C3: findPackageRoot() excludes dirs with registry.json, but registry.json
+             IS in the published package (root package.json files array, line 30).
+             Every globally installed user gets "could not find package root" on
+             oac init and oac update.
+         C4: bin/oac.js knows the package root via __dirname — it should inject
+             OAC_PACKAGE_ROOT as an env var so the Bun binary never needs to walk.
+
+  C5   engines field claims Node.js but CLI requires Bun
+         File: package.json (root)
+         Root package declares "node": ">=18.0.0" only. The CLI binary uses
+         Bun.file(), Bun.write(), Bun.version, import.meta.dir — none of which
+         exist in Node.js. Should declare both node and bun engines.
+
+  C6   Missing publishConfig.access for scoped package
+         Files: package.json (root), packages/cli/package.json
+         Both @nextsystems/oac and @nextsystems/oac-cli are scoped packages.
+         Without "publishConfig": {"access": "public"}, npm publish fails or
+         publishes as private. Users cannot install the package.
+
+IMPORTANT (fix before v1.0):
+  I1   No oac clean command
+         Files: packages/cli/src/commands/clean.ts (new), packages/cli/src/index.ts
+         No way to remove .opencode/ and .oac/ after uninstalling the npm package.
+         Plan includes full implementation with --force, --dry-run, --ide flags.
+
+  I2   README missing npm install instructions
+         File: README.md
+         Quick Start shows only curl | bash. Zero mention of npm install -g,
+         npx, or Bun as a prerequisite. npm is the primary install path.
+
+  I3   No SIGINT/SIGTERM signal handlers
+         File: packages/cli/src/index.ts
+         Ctrl-C during a spinner operation leaves the terminal cursor hidden and
+         color codes active. Two lines needed: process.on('SIGINT'/'SIGTERM').
+
+  I4   No inline update notification
+         Files: packages/cli/src/lib/update-check.ts (new), packages/cli/src/index.ts,
+                packages/cli/src/commands/doctor.ts (refactor)
+         fetchLatestNpmVersion() exists in doctor.ts but is private. Plan extracts
+         it to a shared module, adds 24h caching in ~/.config/oac/, and calls it
+         non-blocking after program.parseAsync() in index.ts.
+
+  I5   writeManifest() missing mkdir
+         File: packages/cli/src/lib/manifest.ts
+         writeManifest() calls Bun.write() without ensuring .oac/ exists first.
+         config.ts correctly calls mkdir() first. First oac init on a clean
+         project will throw ENOENT. One-line fix: add mkdir(path.dirname(...)).
+
+  I6   No examples in --help output
+         Files: packages/cli/src/index.ts, packages/cli/src/commands/init.ts,
+                packages/cli/src/commands/update.ts
+         clig.dev standard: "lead with examples." addHelpText('after', ...) needed
+         on the main program, init command, and update command.
+
+  I7   packages/cli has conflicting bin field
+         File: packages/cli/package.json
+         "bin": {"oac": "./dist/index.js"} points to a Bun binary. If anyone
+         installs @nextsystems/oac-cli directly, it fails under Node.js.
+         The sub-package is not meant to be installed directly. Remove bin field.
+
+  I8   Windows bun.cmd compatibility
+         File: bin/oac.js
+         execFileSync('bun', ...) fails on Windows where npm installs create
+         bun.cmd wrappers. Fix: detect process.platform === 'win32' and use
+         'bun.cmd' as the executable name.
+
+MINOR (polish):
+  M1   Version mismatch between root and CLI package
+         Files: package.json (root), packages/cli/package.json,
+                packages/cli/src/lib/version.ts
+         Root is "0.7.1", CLI sub-package is "1.0.0". readCliVersion() reads
+         from sub-package, so oac --version shows "1.0.0" but npm registry has
+         "0.7.1". doctor version check is broken. Root package.json is canonical.
+
+  M2   warn() writes to stdout instead of stderr
+         File: packages/cli/src/ui/logger.ts
+         warn() uses console.log (stdout). error() correctly uses console.error
+         (stderr). Warnings pollute piped output. One-line fix: console.error.
+
+  M3   Missing repository.directory in package.json files
+         Files: package.json (root), packages/cli/package.json
+         Neither file has repository.directory. npm package pages show wrong
+         GitHub links. packages/cli/package.json has no repository field at all.
+
+RECOMMENDED FIX ORDER:
+  1. C6  — publishConfig (unblocks all publish attempts)
+  2. C1  — .npmignore (unblocks npm pack verification)
+  3. C2  — prepublishOnly (build guard)
+  4. C3+C4 — package root resolution (unblocks all users)
+  5. C5  — engines field
+  6. I5  — writeManifest mkdir (unblocks oac init on clean projects)
+  7. I7  — remove bin from sub-package
+  8. M2  — warn() stderr (trivial, do alongside I7)
+  9. M3  — repository.directory (trivial)
+  10. M1 — version sync
+  11. I3  — signal handlers
+  12. I8  — Windows bun.cmd
+  13. I1  — clean command
+  14. I4  — update notification
+  15. I6  — help examples
+  16. I2  — README npm install section (do last, after package is verified working)
+
+DISCREPANCIES FOUND vs. REVIEW DESCRIPTION:
+  See "NOTES ON ACTUAL VS. DESCRIBED STATE" section below.
+
+NOTES ON ACTUAL VS. DESCRIBED STATE:
+  1. C3 description said "registry.json IS in the root package.json files array"
+     — CONFIRMED. Line 30 of root package.json: "registry.json". The review
+     description was accurate.
+
+  2. C4 description said "bin/oac.js knows exactly where it is (__dirname/..)"
+     — CONFIRMED. bin/oac.js line 8: path.join(__dirname, '..', 'packages', 'cli', 'dist', 'index.js')
+     so __dirname is the bin/ directory and __dirname/.. is the package root.
+
+  3. M1 description said root is "0.7.1" and CLI is "1.0.0" — CONFIRMED.
+     Additionally: readCliVersion() in version.ts imports from '../../package.json'
+     which resolves to packages/cli/package.json (not root). So oac --version
+     returns "1.0.0" while the npm package is "0.7.1".
+
+  4. M2 description said "warn() uses console.log" — CONFIRMED. Line 28 of
+     logger.ts: `export const warn = (msg: string): void => console.log(...)`.
+
+  5. I5 description said "config.ts correctly calls mkdir first" — CONFIRMED.
+     config.ts line 48: `await mkdir(dirname(configPath), { recursive: true })`.
+     manifest.ts writeManifest() has NO mkdir call.
+
+  6. I7 description said packages/cli has bin field — CONFIRMED. Lines 6-8 of
+     packages/cli/package.json: "bin": {"oac": "./dist/index.js"}.
+
+  7. The review mentioned "import.meta.dir" in bundled.ts — CONFIRMED. Line 37:
+     `return findPackageRoot(import.meta.dir)`. This is Bun-specific.
+
+  8. packages/cli/package.json already has "engines": {"bun": ">=1.0.0"} (lines
+     34-36). Only the ROOT package.json is missing the bun engine declaration.
+     The review description was accurate.
+
+  9. index.ts has 7 commands registered (init, update, add, apply, doctor, list,
+     status). The review's I1 plan correctly identifies that 'clean' is missing.
+
+  10. The README Quick Start section (lines 116-139) shows ONLY curl-based install.
+      No npm install instructions anywhere in the first 140 lines. Review accurate.

+ 91 - 0
docs/planning/fix-plans/C1-npmignore-excludes-dist.txt

@@ -0,0 +1,91 @@
+ISSUE: .npmignore excludes built CLI from published package
+SEVERITY: Critical
+FILE(S): .npmignore
+
+CURRENT STATE:
+Line 31-32 of .npmignore:
+  dist/
+  build/
+
+Line 58 of .npmignore:
+  packages/
+
+These three patterns together are fatal:
+  - `dist/` matches and excludes the root-level `dist/` directory (if it exists)
+  - `packages/` excludes the ENTIRE `packages/` directory tree, which includes
+    `packages/cli/dist/` — the compiled Bun binary that is the actual CLI
+
+The root `package.json` `files` array includes `"packages/cli/dist/"` (line 37),
+but .npmignore takes precedence over `files` for exclusion. Because `packages/`
+is listed in .npmignore, the `packages/cli/dist/` entry in `files` is overridden
+and the built CLI binary is stripped from the published tarball.
+
+ROOT CAUSE:
+.npmignore was written to exclude development-only directories (evals/, packages/
+source code, etc.) but used a blanket `packages/` pattern that also excludes the
+compiled output under `packages/cli/dist/`. The `files` field in package.json
+whitelists `packages/cli/dist/` but npm's resolution order is:
+  1. .npmignore exclusions are applied first
+  2. `files` inclusions cannot re-include something already excluded by .npmignore
+
+So `packages/` in .npmignore wins over `"packages/cli/dist/"` in `files`.
+
+FIX:
+Replace the blanket `packages/` exclusion with specific exclusions that exclude
+source/config but explicitly allow the compiled dist output.
+
+BEFORE (lines 53-58 of .npmignore):
+  # Development and testing
+  evals/
+  dev/
+  tasks/
+  integrations/
+  packages/
+
+AFTER:
+  # Development and testing
+  evals/
+  dev/
+  tasks/
+  integrations/
+  # Exclude packages source/config but NOT the compiled CLI dist
+  packages/cli/src/
+  packages/cli/node_modules/
+  packages/cli/tsconfig.json
+  packages/cli/bun.lockb
+  packages/compatibility-layer/
+  packages/plugin-abilities/
+
+Also remove the bare `dist/` and `build/` lines (lines 31-32) since they would
+match `packages/cli/dist/` via glob. Replace with more targeted patterns:
+
+BEFORE (lines 30-33 of .npmignore):
+  # Build and test artifacts
+  dist/
+  build/
+  out/
+
+AFTER:
+  # Build and test artifacts — NOTE: packages/cli/dist/ must NOT be excluded
+  # (it is the published CLI binary). Only exclude root-level build dirs.
+  /dist/
+  /build/
+  /out/
+  coverage/
+  .nyc_output/
+  *.tsbuildinfo
+
+Note the leading `/` anchors the pattern to the root of the package, preventing
+it from matching `packages/cli/dist/`.
+
+VALIDATION:
+1. Run: npm pack --dry-run
+2. Verify the output includes:
+     packages/cli/dist/index.js  (or whatever the bun build output is named)
+3. Verify the output does NOT include:
+     packages/cli/src/
+     packages/compatibility-layer/
+4. Run: npm pack && tar -tzf nextsystems-oac-*.tgz | grep packages/cli/dist
+   Should show at least one file.
+
+DEPENDENCIES: C2 (build must run before pack to have a dist/ to check)

+ 74 - 0
docs/planning/fix-plans/C2-no-prepublish-build.txt

@@ -0,0 +1,74 @@
+ISSUE: No prepublishOnly build guard — package can ship empty
+SEVERITY: Critical
+FILE(S): package.json (root), packages/cli/package.json
+
+CURRENT STATE:
+Root package.json scripts (lines 42-88) — no prepublishOnly key present.
+The only build-adjacent script is in packages/cli/package.json:
+  "build": "rm -rf dist && bun build src/index.ts --outdir dist --target bun --splitting"
+
+There is no `prepublishOnly` in either file. Running `npm publish` from the root
+will publish whatever is currently in `packages/cli/dist/` — or nothing at all
+if the developer forgot to build first.
+
+ROOT CAUSE:
+The `prepublishOnly` lifecycle hook runs automatically before `npm publish` and
+`npm pack`. Without it, there is no guarantee the compiled binary exists or is
+current. This is a silent failure mode: the package publishes successfully but
+users get an empty or stale `packages/cli/dist/`.
+
+FIX:
+
+--- Root package.json ---
+Add `prepublishOnly` to the `scripts` object. It must:
+1. Build the CLI sub-package (the only publishable artifact)
+2. Verify the output exists before allowing publish to proceed
+
+BEFORE (root package.json scripts section, no prepublishOnly):
+  "scripts": {
+    "test": "npm run test:all",
+    ...
+    "validate:registry:fix": "bun run scripts/registry/validate-registry.ts -f"
+  }
+
+AFTER — add as the FIRST entry in scripts for visibility:
+  "scripts": {
+    "prepublishOnly": "npm run build -w packages/cli && node -e \"require('fs').existsSync('packages/cli/dist/index.js') || (console.error('Build output missing: packages/cli/dist/index.js'), process.exit(1))\"",
+    "test": "npm run test:all",
+    ...
+  }
+
+--- packages/cli/package.json ---
+Add a `prepublishOnly` that runs the build and typechecks. This protects against
+someone publishing the sub-package directly (even though plan I7 recommends
+removing the bin field, the sub-package could still be published accidentally).
+
+BEFORE (packages/cli/package.json scripts):
+  "scripts": {
+    "build": "rm -rf dist && bun build src/index.ts --outdir dist --target bun --splitting",
+    "build:watch": "bun build src/index.ts --outdir dist --target bun --splitting --watch",
+    "dev": "bun run src/index.ts",
+    "test": "bun test",
+    "test:watch": "bun test --watch",
+    "typecheck": "tsc --noEmit"
+  }
+
+AFTER:
+  "scripts": {
+    "prepublishOnly": "npm run typecheck && npm run build",
+    "build": "rm -rf dist && bun build src/index.ts --outdir dist --target bun --splitting",
+    "build:watch": "bun build src/index.ts --outdir dist --target bun --splitting --watch",
+    "dev": "bun run src/index.ts",
+    "test": "bun test",
+    "test:watch": "bun test --watch",
+    "typecheck": "tsc --noEmit"
+  }
+
+VALIDATION:
+1. Delete packages/cli/dist/ entirely
+2. Run: npm publish --dry-run (from repo root)
+3. Confirm the build runs automatically and dist/ is recreated
+4. Confirm the dry-run output lists packages/cli/dist/index.js in the file list
+5. Confirm that if the build fails, npm publish aborts with a non-zero exit code
+
+DEPENDENCIES: C1 (fix .npmignore first so the built dist is actually included)

+ 230 - 0
docs/planning/fix-plans/C3-C4-package-root-resolution.txt

@@ -0,0 +1,230 @@
+ISSUE: findPackageRoot fails in production + bin/oac.js should inject OAC_PACKAGE_ROOT
+SEVERITY: Critical
+FILE(S): packages/cli/src/lib/bundled.ts, bin/oac.js
+
+═══════════════════════════════════════════════════════════════
+ISSUE C3: findPackageRoot() excludes directories with registry.json
+═══════════════════════════════════════════════════════════════
+
+CURRENT STATE (packages/cli/src/lib/bundled.ts, lines 53-80):
+
+  export function findPackageRoot(dir: string): string {
+    let current = dir;
+
+    while (true) {
+      const hasOpencode = existsSync(join(current, ".opencode"));
+      const hasPackageJson = existsSync(join(current, "package.json"));
+      // registry.json exists at the monorepo root but NOT at the CLI package root.
+      // Excluding directories that have it prevents the walk from stopping at the
+      // repo root instead of the actual CLI package root.
+      const hasRegistryJson = existsSync(join(current, "registry.json"));
+
+      if (hasOpencode && hasPackageJson && !hasRegistryJson) {
+        return current;
+      }
+      ...
+    }
+  }
+
+The comment says "registry.json exists at the monorepo root but NOT at the CLI
+package root." This is true in development. But look at root package.json `files`
+array (line 30):
+
+  "registry.json",
+
+`registry.json` IS included in the published npm package. When a user installs
+`@nextsystems/oac` globally, the installed package directory will contain:
+  - .opencode/          ← present (from files array)
+  - package.json        ← present (always included by npm)
+  - registry.json       ← present (explicitly in files array)
+
+So `hasOpencode && hasPackageJson && !hasRegistryJson` evaluates to:
+  true && true && false → false
+
+The walk SKIPS the actual package root and continues up the directory tree until
+it hits the filesystem root, then throws:
+  "getPackageRoot: could not find a directory with .opencode/ and package.json
+   (without a registry.json at the same level) walking up from ..."
+
+Every globally installed user hits this error on `oac init` and `oac update`.
+
+ROOT CAUSE:
+The `!hasRegistryJson` guard was designed to distinguish the monorepo root from
+the CLI sub-package root during development. It was not updated to account for
+`registry.json` being in the `files` array and therefore present in production.
+
+═══════════════════════════════════════════════════════════════
+ISSUE C4: bin/oac.js should inject OAC_PACKAGE_ROOT
+═══════════════════════════════════════════════════════════════
+
+CURRENT STATE (bin/oac.js, lines 1-23):
+
+  #!/usr/bin/env node
+  'use strict';
+
+  const { execFileSync } = require('child_process');
+  const path = require('path');
+  const fs = require('fs');
+
+  const cliDist = path.join(__dirname, '..', 'packages', 'cli', 'dist', 'index.js');
+
+  if (!fs.existsSync(cliDist)) {
+    console.error('Error: OAC CLI not built yet. Run: npm run build -w packages/cli');
+    process.exit(1);
+  }
+
+  try {
+    execFileSync('bun', [cliDist, ...process.argv.slice(2)], { stdio: 'inherit' });
+  } catch (err) {
+    if (err.code === 'ENOENT') {
+      console.error('Error: Bun is required to run OAC CLI. Install from https://bun.sh');
+      process.exit(1);
+    }
+    process.exitCode = err.status ?? 1;
+  }
+
+`bin/oac.js` already knows the package root: `path.join(__dirname, '..')` is
+exactly the npm package root (the directory containing package.json, .opencode/,
+registry.json, etc.). It should inject this as `OAC_PACKAGE_ROOT` so the Bun
+process never needs to walk the filesystem.
+
+This is the clean fix: the Node.js wrapper has reliable `__dirname` knowledge;
+the Bun binary should consume it rather than re-derive it.
+
+═══════════════════════════════════════════════════════════════
+FIX
+═══════════════════════════════════════════════════════════════
+
+--- Fix 1: bin/oac.js — inject OAC_PACKAGE_ROOT ---
+
+BEFORE:
+  try {
+    execFileSync('bun', [cliDist, ...process.argv.slice(2)], { stdio: 'inherit' });
+  } catch (err) {
+
+AFTER:
+  const packageRoot = path.join(__dirname, '..');
+
+  try {
+    execFileSync('bun', [cliDist, ...process.argv.slice(2)], {
+      stdio: 'inherit',
+      env: { ...process.env, OAC_PACKAGE_ROOT: packageRoot },
+    });
+  } catch (err) {
+
+--- Fix 2: packages/cli/src/lib/bundled.ts — remove the !hasRegistryJson guard ---
+
+The `OAC_PACKAGE_ROOT` env var is now always set by bin/oac.js in production,
+so `findPackageRoot()` is only called in dev/test scenarios where the env var
+is not set. The `!hasRegistryJson` guard can be removed entirely — in dev the
+monorepo root has both .opencode/ and package.json, and that is the correct
+root to use.
+
+BEFORE (lines 53-80):
+  export function findPackageRoot(dir: string): string {
+    let current = dir;
+
+    while (true) {
+      const hasOpencode = existsSync(join(current, ".opencode"));
+      const hasPackageJson = existsSync(join(current, "package.json"));
+      // registry.json exists at the monorepo root but NOT at the CLI package root.
+      // Excluding directories that have it prevents the walk from stopping at the
+      // repo root instead of the actual CLI package root.
+      const hasRegistryJson = existsSync(join(current, "registry.json"));
+
+      if (hasOpencode && hasPackageJson && !hasRegistryJson) {
+        return current;
+      }
+
+      const parent = join(current, "..");
+      // Reached filesystem root — no package root found
+      if (parent === current) {
+        throw new Error(
+          `getPackageRoot: could not find a directory with ".opencode/" and "package.json" ` +
+            `(without a "registry.json" at the same level) walking up from "${dir}". ` +
+            `Is @nextsystems/oac installed correctly? ` +
+            `In dev/monorepo mode, set OAC_PACKAGE_ROOT env var to the repo root.`,
+        );
+      }
+      current = parent;
+    }
+  }
+
+AFTER:
+  export function findPackageRoot(dir: string): string {
+    let current = dir;
+
+    while (true) {
+      const hasOpencode = existsSync(join(current, ".opencode"));
+      const hasPackageJson = existsSync(join(current, "package.json"));
+
+      if (hasOpencode && hasPackageJson) {
+        return current;
+      }
+
+      const parent = join(current, "..");
+      // Reached filesystem root — no package root found
+      if (parent === current) {
+        throw new Error(
+          `getPackageRoot: could not find a directory with ".opencode/" and "package.json" ` +
+            `walking up from "${dir}". ` +
+            `Is @nextsystems/oac installed correctly? ` +
+            `In dev/monorepo mode, set OAC_PACKAGE_ROOT env var to the repo root.`,
+        );
+      }
+      current = parent;
+    }
+  }
+
+Also update the comment block above findPackageRoot (lines 41-52) to remove the
+outdated reference to registry.json:
+
+BEFORE:
+  /**
+   * Synchronously walks up from `dir` until finding a directory that has
+   * all three anchors:
+   *   1. `.opencode/`   — OAC configuration directory
+   *   2. `package.json` — npm package manifest
+   *   3. No `registry.json` at the same level — `registry.json` is present at
+   *      the monorepo root but NOT at the CLI package root, so its absence
+   *      distinguishes the CLI package from the repo root in a monorepo layout.
+   *
+   * Throws if the filesystem root is reached without finding a match.
+   *
+   * Pure in intent — no side effects beyond filesystem reads.
+   */
+
+AFTER:
+  /**
+   * Synchronously walks up from `dir` until finding a directory that has
+   * both anchors:
+   *   1. `.opencode/`   — OAC configuration directory
+   *   2. `package.json` — npm package manifest
+   *
+   * In production, this function is bypassed entirely because bin/oac.js
+   * injects OAC_PACKAGE_ROOT before invoking the Bun binary.
+   * This fallback is used only in dev/test environments where OAC_PACKAGE_ROOT
+   * is not set.
+   *
+   * Throws if the filesystem root is reached without finding a match.
+   *
+   * Pure in intent — no side effects beyond filesystem reads.
+   */
+
+VALIDATION:
+1. Simulate a production install:
+   a. Create a temp directory: mkdir /tmp/oac-test && cd /tmp/oac-test
+   b. Copy the package root into it (with registry.json present)
+   c. Set OAC_PACKAGE_ROOT to the temp dir
+   d. Run: node bin/oac.js --version
+   e. Confirm it does NOT throw "could not find a directory"
+
+2. Verify env injection:
+   a. Add a temporary console.log(process.env.OAC_PACKAGE_ROOT) to bundled.ts
+   b. Run oac --version and confirm the path is printed correctly
+   c. Remove the debug log
+
+3. Unit test findPackageRoot with a directory that contains registry.json:
+   - Should now RETURN that directory (not skip it)
+
+DEPENDENCIES: C1 (registry.json must be in the published package for this to matter)

+ 92 - 0
docs/planning/fix-plans/C5-engines-field-mismatch.txt

@@ -0,0 +1,92 @@
+ISSUE: engines field claims Node.js but CLI requires Bun
+SEVERITY: Critical
+FILE(S): package.json (root), packages/cli/package.json
+
+CURRENT STATE:
+
+Root package.json (lines 39-41):
+  "engines": {
+    "node": ">=18.0.0"
+  }
+
+packages/cli/package.json (lines 34-36):
+  "engines": {
+    "bun": ">=1.0.0"
+  }
+
+The root package.json declares Node.js >=18 as the engine. This is the package
+that users install (`npm install -g @nextsystems/oac`). The `engines` field in
+the root package is what npm/yarn/pnpm display to users and what tools like
+`engines-check` validate against.
+
+However, the CLI binary (`packages/cli/dist/index.js`) is compiled with:
+  bun build src/index.ts --outdir dist --target bun --splitting
+
+A `--target bun` Bun build produces output that uses Bun-specific globals:
+  - `Bun.file()` — used in bundled.ts, manifest.ts, config.ts, installer.ts
+  - `Bun.write()` — used in installer.ts, manifest.ts, config.ts
+  - `Bun.version` — used in doctor.ts (line 83)
+  - `import.meta.dir` — used in bundled.ts (line 37)
+
+None of these exist in Node.js. Running the compiled binary under Node.js will
+throw immediately with "Bun is not defined" or similar.
+
+The `bin/oac.js` wrapper correctly calls `execFileSync('bun', ...)` and shows
+an error if bun is not found (line 18-20). But the `engines` field in the root
+package.json contradicts this by claiming Node.js compatibility.
+
+ROOT CAUSE:
+The root package.json `engines` field was likely set before the CLI was
+implemented with Bun-specific APIs, or was copied from a Node.js project
+template. The sub-package correctly declares `"bun": ">=1.0.0"` but the root
+package (the one users actually install) still says Node.js.
+
+FIX:
+
+--- Root package.json ---
+
+BEFORE:
+  "engines": {
+    "node": ">=18.0.0"
+  }
+
+AFTER:
+  "engines": {
+    "node": ">=18.0.0",
+    "bun": ">=1.0.0"
+  }
+
+Rationale: Keep `node` because `bin/oac.js` (the entry point) IS a Node.js
+script — it uses `require('child_process')`, `require('path')`, `require('fs')`.
+Node.js >=18 is needed for the wrapper. Add `bun` because the actual CLI binary
+requires Bun to execute. Both are true and both should be declared.
+
+--- packages/cli/package.json ---
+
+No change needed. It already correctly declares:
+  "engines": {
+    "bun": ">=1.0.0"
+  }
+
+--- Additional: add a note to the description ---
+
+Consider updating the root package.json `description` field to mention Bun:
+
+BEFORE:
+  "description": "AI agent framework for plan-first development workflows with
+   approval-based execution. Multi-language support for TypeScript, Python, Go,
+   Rust and more."
+
+AFTER:
+  "description": "AI agent framework for plan-first development workflows with
+   approval-based execution. Requires Bun runtime (https://bun.sh). Multi-language
+   support for TypeScript, Python, Go, Rust and more."
+
+VALIDATION:
+1. Run: node -e "const p = require('./package.json'); console.log(p.engines)"
+   Should output: { node: '>=18.0.0', bun: '>=1.0.0' }
+2. Run: npm install -g . (in a test environment)
+   npm should display a warning if the user's Bun version is below 1.0.0
+3. Verify that tools like `npm doctor` or `engines-check` report both requirements
+
+DEPENDENCIES: none

+ 87 - 0
docs/planning/fix-plans/C6-missing-publishconfig.txt

@@ -0,0 +1,87 @@
+ISSUE: Missing publishConfig.access for scoped npm package
+SEVERITY: Critical
+FILE(S): package.json (root), packages/cli/package.json
+
+CURRENT STATE:
+
+Root package.json — no `publishConfig` field anywhere in the file (119 lines total).
+packages/cli/package.json — no `publishConfig` field anywhere in the file (37 lines total).
+
+Both packages are scoped:
+  - root:        "name": "@nextsystems/oac"
+  - sub-package: "name": "@nextsystems/oac-cli"
+
+ROOT CAUSE:
+npm scoped packages (`@scope/name`) default to `"access": "restricted"` (private)
+when published. Without `"publishConfig": {"access": "public"}`, running
+`npm publish` will either:
+  a) Fail with: "You must sign up for private packages"
+     (if the npm account does not have a paid plan)
+  b) Publish as a private package that only the owner can install
+     (if the account has a paid plan)
+
+In either case, `npm install -g @nextsystems/oac` will fail for all users with:
+  "npm ERR! 404 Not Found - GET https://registry.npmjs.org/@nextsystems%2Foac"
+  or
+  "npm ERR! 403 Forbidden - You do not have permission to access this package"
+
+FIX:
+
+--- Root package.json ---
+Add `publishConfig` as a top-level field. Recommended placement: after `"license"`.
+
+BEFORE (root package.json, lines 105-110):
+  "author": "Darren Hinde",
+  "license": "MIT",
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/darrenhinde/OpenAgentsControl.git"
+  },
+
+AFTER:
+  "author": "Darren Hinde",
+  "license": "MIT",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/darrenhinde/OpenAgentsControl.git"
+  },
+
+--- packages/cli/package.json ---
+Add `publishConfig` after `"type": "module"`.
+
+BEFORE (packages/cli/package.json, lines 1-8):
+  {
+    "name": "@nextsystems/oac-cli",
+    "version": "1.0.0",
+    "description": "OAC CLI — install, manage, and update AI agents and context files",
+    "type": "module",
+    "bin": {
+      "oac": "./dist/index.js"
+    },
+
+AFTER:
+  {
+    "name": "@nextsystems/oac-cli",
+    "version": "1.0.0",
+    "description": "OAC CLI — install, manage, and update AI agents and context files",
+    "type": "module",
+    "publishConfig": {
+      "access": "public"
+    },
+    "bin": {
+      "oac": "./dist/index.js"
+    },
+
+VALIDATION:
+1. Run: npm publish --dry-run
+   Should NOT show "This package is private" or access errors
+2. Run: node -e "const p = require('./package.json'); console.log(p.publishConfig)"
+   Should output: { access: 'public' }
+3. After actual publish: verify the package is publicly accessible at
+   https://www.npmjs.com/package/@nextsystems/oac
+4. Verify: npm install -g @nextsystems/oac works from a clean environment
+
+DEPENDENCIES: C1, C2 (fix packaging issues before attempting publish)

+ 216 - 0
docs/planning/fix-plans/I1-no-clean-command.txt

@@ -0,0 +1,216 @@
+ISSUE: No oac clean command to remove installed files
+SEVERITY: Important
+FILE(S): packages/cli/src/commands/clean.ts (new file), packages/cli/src/index.ts
+
+CURRENT STATE:
+packages/cli/src/index.ts imports and registers these commands (lines 25-41):
+  import('./commands/init.js')
+  import('./commands/update.js')
+  import('./commands/add.js')
+  import('./commands/apply.js')
+  import('./commands/doctor.js')
+  import('./commands/list.js')
+  import('./commands/status.js')
+
+There is no `clean` command. When a user uninstalls the npm package
+(`npm uninstall -g @nextsystems/oac`), the `.opencode/` and `.oac/` directories
+remain in every project where they ran `oac init`. There is no supported way to
+remove them.
+
+ROOT CAUSE:
+The clean/uninstall lifecycle was not implemented. This is a common oversight
+in CLI tools that install files into user projects.
+
+FIX:
+
+--- New file: packages/cli/src/commands/clean.ts ---
+
+Follow the exact same Commander registration pattern as init.ts:
+  - Export a `CleanOptions` type
+  - Export a `cleanCommand(options)` async function
+  - Export a `registerCleanCommand(program)` function
+
+Full implementation plan:
+
+```typescript
+import { type Command } from 'commander'
+import { existsSync } from 'node:fs'
+import { rm } from 'node:fs/promises'
+import { join } from 'node:path'
+import { readManifest } from '../lib/manifest.js'
+import { log, info, warn, error, success } from '../ui/logger.js'
+
+export type CleanOptions = {
+  force: boolean
+  dryRun: boolean
+  /** Remove IDE-specific files generated by oac apply (e.g. CLAUDE.md) */
+  ide: boolean
+}
+
+/**
+ * Implements `oac clean`:
+ *  1. Reads the manifest to know which files OAC installed
+ *  2. Prompts for confirmation (unless --force)
+ *  3. Removes each file listed in the manifest
+ *  4. Removes .oac/ directory (manifest + config)
+ *  5. Removes .opencode/ directory
+ *  6. Optionally removes IDE output files (--ide flag)
+ */
+export async function cleanCommand(options: CleanOptions): Promise<void> {
+  const projectRoot = process.cwd()
+
+  // Read manifest to know what OAC installed
+  const manifest = await readManifest(projectRoot).catch(() => null)
+  const trackedFiles = manifest ? Object.keys(manifest.files) : []
+
+  // Directories always removed
+  const dirsToRemove = [
+    join(projectRoot, '.opencode'),
+    join(projectRoot, '.oac'),
+  ]
+
+  // IDE files (only with --ide flag)
+  const ideFiles = options.ide
+    ? ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md', 'CURSOR.md', '.cursorrules']
+        .map((f) => join(projectRoot, f))
+        .filter((f) => existsSync(f))
+    : []
+
+  // Print plan
+  log('')
+  info(options.dryRun ? '[dry-run] oac clean — no files will be removed' : 'oac clean')
+  log('')
+  info(`Will remove: .opencode/, .oac/ (${trackedFiles.length} tracked files)`)
+  if (ideFiles.length > 0) {
+    info(`IDE files:   ${ideFiles.map((f) => f.replace(projectRoot + '/', '')).join(', ')}`)
+  }
+  log('')
+
+  // Confirmation prompt (unless --force or --dry-run)
+  if (!options.force && !options.dryRun) {
+    // Use Bun's built-in prompt (synchronous)
+    const answer = prompt('Remove all OAC files from this project? [y/N] ')
+    if (answer?.toLowerCase() !== 'y') {
+      info('Aborted.')
+      process.exit(0)
+    }
+  }
+
+  if (options.dryRun) {
+    info('[dry-run] Would remove:')
+    for (const dir of dirsToRemove) {
+      if (existsSync(dir)) info(`  ${dir.replace(projectRoot + '/', '')}`)
+    }
+    for (const f of ideFiles) {
+      info(`  ${f.replace(projectRoot + '/', '')}`)
+    }
+    info('No changes made. Remove --dry-run to apply.')
+    return
+  }
+
+  // Remove directories
+  let removed = 0
+  for (const dir of dirsToRemove) {
+    if (existsSync(dir)) {
+      await rm(dir, { recursive: true, force: true })
+      removed++
+    }
+  }
+
+  // Remove IDE files
+  for (const f of ideFiles) {
+    await rm(f, { force: true })
+    removed++
+  }
+
+  success(`Done! Removed ${removed} item${removed !== 1 ? 's' : ''}.`)
+  log('')
+}
+
+export function registerCleanCommand(program: Command): void {
+  program
+    .command('clean')
+    .description('Remove all OAC-installed files from the current project')
+    .option('--force', 'Skip confirmation prompt', false)
+    .option('--dry-run', 'Show what would be removed without making changes', false)
+    .option('--ide', 'Also remove IDE output files (CLAUDE.md, AGENTS.md, etc.)', false)
+    .action(async (opts: { force: boolean; dryRun: boolean; ide: boolean }) => {
+      await cleanCommand({ force: opts.force, dryRun: opts.dryRun, ide: opts.ide })
+    })
+}
+```
+
+--- Update packages/cli/src/index.ts ---
+
+Add `registerCleanCommand` to the parallel import block and call it.
+
+BEFORE (index.ts lines 25-41):
+  const [
+    { registerInitCommand },
+    { registerUpdateCommand },
+    { registerAddCommand },
+    { registerApplyCommand },
+    { registerDoctorCommand },
+    { registerListCommand },
+    { registerStatusCommand },
+  ] = await Promise.all([
+    import('./commands/init.js'),
+    import('./commands/update.js'),
+    import('./commands/add.js'),
+    import('./commands/apply.js'),
+    import('./commands/doctor.js'),
+    import('./commands/list.js'),
+    import('./commands/status.js'),
+  ])
+
+  registerInitCommand(program)
+  registerUpdateCommand(program)
+  registerAddCommand(program)
+  registerApplyCommand(program)
+  registerDoctorCommand(program)
+  registerListCommand(program)
+  registerStatusCommand(program)
+
+AFTER:
+  const [
+    { registerInitCommand },
+    { registerUpdateCommand },
+    { registerAddCommand },
+    { registerApplyCommand },
+    { registerDoctorCommand },
+    { registerListCommand },
+    { registerStatusCommand },
+    { registerCleanCommand },
+  ] = await Promise.all([
+    import('./commands/init.js'),
+    import('./commands/update.js'),
+    import('./commands/add.js'),
+    import('./commands/apply.js'),
+    import('./commands/doctor.js'),
+    import('./commands/list.js'),
+    import('./commands/status.js'),
+    import('./commands/clean.js'),
+  ])
+
+  registerInitCommand(program)
+  registerUpdateCommand(program)
+  registerAddCommand(program)
+  registerApplyCommand(program)
+  registerDoctorCommand(program)
+  registerListCommand(program)
+  registerStatusCommand(program)
+  registerCleanCommand(program)
+
+VALIDATION:
+1. Run: oac clean --dry-run (in a project with oac init already run)
+   Should list .opencode/ and .oac/ without removing anything
+2. Run: oac clean --force (in a test project)
+   Should remove .opencode/ and .oac/ without prompting
+3. Run: oac clean --ide --force
+   Should also remove CLAUDE.md / AGENTS.md if present
+4. Run: oac --help
+   Should show 'clean' in the command list
+5. Run: oac clean --help
+   Should show --force, --dry-run, --ide options
+
+DEPENDENCIES: none

+ 163 - 0
docs/planning/fix-plans/I2-readme-missing-npm-install.txt

@@ -0,0 +1,163 @@
+ISSUE: README missing npm install instructions
+SEVERITY: Important
+FILE(S): README.md
+
+CURRENT STATE:
+README.md Quick Start section (lines 116-139) shows only curl-based install:
+
+  ## 🚀 Quick Start
+
+  **Prerequisites:** [OpenCode CLI](https://opencode.ai/docs) (free, open-source) • Bash 3.2+ • Git
+
+  ### Step 1: Install
+
+  **One command:**
+
+  ```bash
+  curl -fsSL https://raw.githubusercontent.com/darrenhinde/OpenAgentsControl/main/install.sh | bash -s developer
+  ```
+
+  <sub>The installer will set up OpenCode CLI if you don't have it yet.</sub>
+
+  **Or interactive:**
+  ```bash
+  curl -fsSL https://raw.githubusercontent.com/darrenhinde/OpenAgentsControl/main/install.sh -o install.sh
+  bash install.sh
+  ```
+
+  ### Keep Updated
+
+  ```bash
+  curl -fsSL https://raw.githubusercontent.com/darrenhinde/OpenAgentsControl/main/update.sh | bash
+  ```
+
+There is zero mention of:
+  - npm install -g @nextsystems/oac
+  - npx @nextsystems/oac init
+  - Bun as a prerequisite for the npm install path
+  - The CLI commands available after install
+
+ROOT CAUSE:
+The README was written before the npm CLI package existed. The curl/bash install
+path was the original distribution mechanism. The npm package is new and the
+README was not updated to reflect it.
+
+FIX:
+Add a new "Install via npm" section BEFORE the existing curl section in Quick Start.
+This should be the PRIMARY install method since it is the standard for CLI tools.
+
+Insert the following markdown block immediately after the `## 🚀 Quick Start`
+heading and before the `**Prerequisites:**` line:
+
+---BEGIN INSERT---
+
+## 🚀 Quick Start
+
+### Install via npm (recommended)
+
+**Prerequisites:** [Bun](https://bun.sh) ≥ 1.0 • Node.js ≥ 18
+
+> ⚠️ **Bun is required.** The OAC CLI runs on the [Bun](https://bun.sh) runtime.
+> Install Bun first: `curl -fsSL https://bun.sh/install | bash`
+
+**Global install (use `oac` anywhere):**
+```bash
+npm install -g @nextsystems/oac
+```
+
+**Then set up a project:**
+```bash
+cd your-project
+oac init
+```
+
+**No-install (try without committing):**
+```bash
+npx @nextsystems/oac init
+```
+
+**Keep updated:**
+```bash
+npm update -g @nextsystems/oac
+# or check your current version:
+oac doctor
+```
+
+---
+
+### Install via curl (alternative)
+
+**Prerequisites:** [OpenCode CLI](https://opencode.ai/docs) (free, open-source) • Bash 3.2+ • Git
+
+---END INSERT---
+
+The existing curl section content follows unchanged after this point.
+
+FULL DIFF CONTEXT — where to insert in README.md:
+
+BEFORE (line 116 onwards):
+  ## 🚀 Quick Start
+
+  **Prerequisites:** [OpenCode CLI](https://opencode.ai/docs) (free, open-source) • Bash 3.2+ • Git
+
+  ### Step 1: Install
+
+  **One command:**
+
+  ```bash
+  curl -fsSL https://...
+
+AFTER:
+  ## 🚀 Quick Start
+
+  ### Install via npm (recommended)
+
+  **Prerequisites:** [Bun](https://bun.sh) ≥ 1.0 • Node.js ≥ 18
+
+  > ⚠️ **Bun is required.** The OAC CLI runs on the [Bun](https://bun.sh) runtime.
+  > Install Bun first: `curl -fsSL https://bun.sh/install | bash`
+
+  **Global install (use `oac` anywhere):**
+  ```bash
+  npm install -g @nextsystems/oac
+  ```
+
+  **Then set up a project:**
+  ```bash
+  cd your-project
+  oac init
+  ```
+
+  **No-install (try without committing):**
+  ```bash
+  npx @nextsystems/oac init
+  ```
+
+  **Keep updated:**
+  ```bash
+  npm update -g @nextsystems/oac
+  # or check your current version:
+  oac doctor
+  ```
+
+  ---
+
+  ### Install via curl (alternative)
+
+  **Prerequisites:** [OpenCode CLI](https://opencode.ai/docs) (free, open-source) • Bash 3.2+ • Git
+
+  ### Step 1: Install
+
+  **One command:**
+
+  ```bash
+  curl -fsSL https://...
+
+VALIDATION:
+1. Render the README (GitHub preview or `npx markdown-preview README.md`)
+2. Confirm the npm install section appears before the curl section
+3. Confirm the Bun prerequisite warning is visible
+4. Confirm all code blocks are syntactically correct (no unclosed backticks)
+5. Click the bun.sh link and verify it resolves
+
+DEPENDENCIES: C1, C2, C6 (npm package must be publishable before advertising npm install)

+ 95 - 0
docs/planning/fix-plans/I3-no-signal-handlers.txt

@@ -0,0 +1,95 @@
+ISSUE: No SIGINT/SIGTERM handlers — terminal may be left in broken state
+SEVERITY: Important
+FILE(S): packages/cli/src/index.ts
+
+CURRENT STATE:
+packages/cli/src/index.ts (full file, 70 lines) — no signal handlers registered:
+
+  #!/usr/bin/env node
+
+  import { Command } from 'commander'
+  import { readCliVersion } from './lib/version.js'
+
+  const program = new Command()
+
+  program
+    .name('oac')
+    .description('OpenAgents Control — install, manage, and update AI agents and context files')
+    .version(readCliVersion(), '-v, --version', 'Print version and exit')
+
+  async function main(): Promise<void> {
+    ...
+    await program.parseAsync(process.argv)
+    ...
+  }
+
+  main().catch((err: unknown) => {
+    console.error('Fatal error:', err instanceof Error ? err.message : String(err))
+    process.exitCode = 1
+  })
+
+The `ora` spinner (used via `createSpinner` in spinner.ts) writes ANSI escape
+sequences to the terminal to animate. If the process is killed mid-spin (Ctrl-C
+= SIGINT, or SIGTERM from a process manager), the spinner's cursor-hide and
+color sequences are left active. The terminal cursor may remain hidden and the
+terminal color may be stuck on the spinner's color.
+
+ROOT CAUSE:
+No `process.on('SIGINT')` or `process.on('SIGTERM')` handler is registered.
+The ora library does not automatically clean up on unhandled signals in all
+environments.
+
+FIX:
+Add two signal handler lines to `index.ts`, immediately after the `const program`
+declaration and before the `main()` function definition. This placement ensures
+they are registered before any async work begins.
+
+BEFORE (index.ts lines 6-14):
+  const program = new Command()
+
+  program
+    .name('oac')
+    .description('OpenAgents Control — install, manage, and update AI agents and context files')
+    .version(readCliVersion(), '-v, --version', 'Print version and exit')
+
+  // Lazy-load command modules in parallel — keeps startup < 100ms
+  async function main(): Promise<void> {
+
+AFTER:
+  const program = new Command()
+
+  program
+    .name('oac')
+    .description('OpenAgents Control — install, manage, and update AI agents and context files')
+    .version(readCliVersion(), '-v, --version', 'Print version and exit')
+
+  // Restore terminal state on Ctrl-C or kill signal
+  process.on('SIGINT', () => process.exit(130))
+  process.on('SIGTERM', () => process.exit(143))
+
+  // Lazy-load command modules in parallel — keeps startup < 100ms
+  async function main(): Promise<void> {
+
+Explanation of exit codes:
+  - 130 = 128 + 2 (SIGINT signal number) — Unix convention for Ctrl-C termination
+  - 143 = 128 + 15 (SIGTERM signal number) — Unix convention for SIGTERM termination
+
+Calling `process.exit()` triggers the `exit` event, which ora hooks into to
+restore the terminal cursor and clear the spinner line. This is the standard
+pattern used by ora's own documentation.
+
+IMPORTANT: Do NOT use `process.exit(0)` for signals — that would mask the
+signal to parent processes (e.g. shell scripts checking exit codes).
+
+VALIDATION:
+1. Run: oac init (in a project with many files)
+2. While the spinner is running, press Ctrl-C
+3. Verify:
+   a. The terminal cursor is visible after exit
+   b. The terminal color is reset (no stuck yellow/red)
+   c. The shell prompt appears on a new line
+   d. echo $? returns 130
+4. Run: oac update & sleep 0.5 && kill $! (sends SIGTERM)
+5. Verify echo $? returns 143
+
+DEPENDENCIES: none

+ 219 - 0
docs/planning/fix-plans/I4-no-update-notification.txt

@@ -0,0 +1,219 @@
+ISSUE: No inline update notification — users only see version info if they run oac doctor
+SEVERITY: Important
+FILE(S): packages/cli/src/commands/doctor.ts (extract function),
+         packages/cli/src/lib/update-check.ts (new file),
+         packages/cli/src/index.ts (call after parseAsync)
+
+CURRENT STATE:
+doctor.ts already has `fetchLatestNpmVersion()` (lines 37-48):
+
+  const fetchLatestNpmVersion = async (packageName: string): Promise<string | null> => {
+    try {
+      const url = `https://registry.npmjs.org/${packageName}/latest`;
+      const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
+      if (!res.ok) return null;
+      const data = (await res.json()) as { version?: string };
+      return data.version ?? null;
+    } catch {
+      return null;
+    }
+  };
+
+This function is private to doctor.ts. It is only called when the user explicitly
+runs `oac doctor`. Users who never run doctor never see update notifications.
+
+index.ts (lines 58-63) — after parseAsync, no update check:
+  await program.parseAsync(process.argv)
+
+  // Print help when no command is given
+  if (args.length === 0) {
+    program.help()
+  }
+
+ROOT CAUSE:
+The update check was implemented as part of the doctor command rather than as a
+shared utility called on every invocation. The industry standard (used by npm,
+yarn, create-react-app, etc.) is a non-blocking background check that shows a
+small notice after the command completes.
+
+FIX:
+
+═══════════════════════════════════════════════════════════════
+Step 1: Create packages/cli/src/lib/update-check.ts
+═══════════════════════════════════════════════════════════════
+
+```typescript
+import { join } from 'node:path'
+import { homedir } from 'node:os'
+import { mkdir } from 'node:fs/promises'
+import semver from 'semver'
+import { readCliVersion } from './version.js'
+
+const CACHE_DIR = join(homedir(), '.config', 'oac')
+const CACHE_FILE = join(CACHE_DIR, 'update-check.json')
+const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 // 24 hours
+const PACKAGE_NAME = '@nextsystems/oac'
+
+type UpdateCache = {
+  checkedAt: string   // ISO timestamp
+  latestVersion: string | null
+}
+
+/** Reads the cached update check result. Returns null if cache is missing or stale. */
+async function readCache(): Promise<UpdateCache | null> {
+  try {
+    const raw = await Bun.file(CACHE_FILE).json() as UpdateCache
+    const age = Date.now() - new Date(raw.checkedAt).getTime()
+    if (age > CHECK_INTERVAL_MS) return null  // stale
+    return raw
+  } catch {
+    return null
+  }
+}
+
+/** Writes the update check result to the cache file. */
+async function writeCache(latestVersion: string | null): Promise<void> {
+  try {
+    await mkdir(CACHE_DIR, { recursive: true })
+    const cache: UpdateCache = {
+      checkedAt: new Date().toISOString(),
+      latestVersion,
+    }
+    await Bun.write(CACHE_FILE, JSON.stringify(cache, null, 2))
+  } catch {
+    // Cache write failure is non-fatal — silently ignore
+  }
+}
+
+/** Fetches the latest version from npm registry. Returns null if offline. */
+async function fetchLatestNpmVersion(): Promise<string | null> {
+  try {
+    const url = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`
+    const res = await fetch(url, { signal: AbortSignal.timeout(5000) })
+    if (!res.ok) return null
+    const data = (await res.json()) as { version?: string }
+    return data.version ?? null
+  } catch {
+    return null
+  }
+}
+
+/**
+ * Non-blocking update check. Runs after the main command completes.
+ * Checks at most once per 24 hours (cached in ~/.config/oac/update-check.json).
+ * Prints a 3-line notice to stderr if an update is available.
+ */
+export async function checkForUpdate(): Promise<void> {
+  try {
+    // Try cache first
+    let cached = await readCache()
+    let latestVersion: string | null
+
+    if (cached !== null) {
+      latestVersion = cached.latestVersion
+    } else {
+      // Cache miss or stale — fetch from registry
+      latestVersion = await fetchLatestNpmVersion()
+      await writeCache(latestVersion)
+    }
+
+    if (latestVersion === null) return  // offline or fetch failed
+
+    const current = readCliVersion()
+    if (!semver.lt(current, latestVersion)) return  // already up to date
+
+    // Print 3-line notice to stderr (does not pollute piped stdout)
+    process.stderr.write('\n')
+    process.stderr.write(`  ╭─────────────────────────────────────────────╮\n`)
+    process.stderr.write(`  │  Update available: ${current} → ${latestVersion.padEnd(10)}         │\n`)
+    process.stderr.write(`  │  Run: npm install -g @nextsystems/oac       │\n`)
+    process.stderr.write(`  ╰─────────────────────────────────────────────╯\n`)
+    process.stderr.write('\n')
+  } catch {
+    // Update check failure is always non-fatal
+  }
+}
+```
+
+═══════════════════════════════════════════════════════════════
+Step 2: Update packages/cli/src/index.ts
+═══════════════════════════════════════════════════════════════
+
+Add import at top of file:
+
+BEFORE (index.ts lines 1-4):
+  #!/usr/bin/env node
+
+  import { Command } from 'commander'
+  import { readCliVersion } from './lib/version.js'
+
+AFTER:
+  #!/usr/bin/env node
+
+  import { Command } from 'commander'
+  import { readCliVersion } from './lib/version.js'
+  import { checkForUpdate } from './lib/update-check.js'
+
+Add non-blocking call after parseAsync in main():
+
+BEFORE (index.ts lines 58-63):
+  await program.parseAsync(process.argv)
+
+  // Print help when no command is given
+  if (args.length === 0) {
+    program.help()
+  }
+
+AFTER:
+  await program.parseAsync(process.argv)
+
+  // Non-blocking update check — runs after command completes, max once per 24h
+  // void: intentionally not awaited — failure must never affect exit code
+  void checkForUpdate()
+
+  // Print help when no command is given
+  if (args.length === 0) {
+    program.help()
+  }
+
+═══════════════════════════════════════════════════════════════
+Step 3: Remove duplicate from doctor.ts
+═══════════════════════════════════════════════════════════════
+
+Replace the private `fetchLatestNpmVersion` in doctor.ts with an import from
+the shared module:
+
+BEFORE (doctor.ts lines 1-3 imports and lines 37-48):
+  import { join } from 'node:path';
+  import { type Command } from 'commander';
+  import semver from 'semver';
+  ...
+  const fetchLatestNpmVersion = async (packageName: string): Promise<string | null> => {
+    try {
+      const url = `https://registry.npmjs.org/${packageName}/latest`;
+      const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
+      if (!res.ok) return null;
+      const data = (await res.json()) as { version?: string };
+      return data.version ?? null;
+    } catch {
+      return null;
+    }
+  };
+
+AFTER — remove the private function and import the shared one:
+  (The doctor.ts checkOacVersion function should call the shared fetch directly,
+  or update-check.ts should export fetchLatestNpmVersion as a named export for
+  doctor.ts to reuse.)
+
+VALIDATION:
+1. Run any oac command (e.g. oac doctor)
+2. Temporarily set the current version lower than latest in packages/cli/package.json
+3. Delete ~/.config/oac/update-check.json to clear cache
+4. Run: oac doctor
+5. Verify the 3-line update notice appears on stderr after the command output
+6. Run: oac doctor again immediately
+7. Verify the notice does NOT appear again (cache hit, 24h not elapsed)
+8. Verify: oac doctor 2>/dev/null shows no update notice (stderr suppressed)
+9. Restore the correct version in package.json
+
+DEPENDENCIES: none (but doctor.ts refactor is a nice-to-have cleanup)

+ 120 - 0
docs/planning/fix-plans/I5-writemanifest-missing-mkdir.txt

@@ -0,0 +1,120 @@
+ISSUE: writeManifest() calls Bun.write() without ensuring .oac/ directory exists
+SEVERITY: Important
+FILE(S): packages/cli/src/lib/manifest.ts
+
+CURRENT STATE:
+manifest.ts writeManifest() function (lines 172-178):
+
+  export const writeManifest = async (
+    projectRoot: string,
+    manifest: ManifestFile,
+  ): Promise<void> => {
+    const manifestPath = getManifestPath(projectRoot);
+    await Bun.write(manifestPath, JSON.stringify(manifest, null, 2));
+  };
+
+`getManifestPath` returns `{projectRoot}/.oac/manifest.json` (line 52-53):
+  export const getManifestPath = (projectRoot: string): string =>
+    path.join(projectRoot, MANIFEST_RELATIVE_PATH);
+
+where `MANIFEST_RELATIVE_PATH = '.oac/manifest.json'` (line 15).
+
+`Bun.write()` will throw if the parent directory `.oac/` does not exist:
+  "ENOENT: No such file or directory"
+
+CONTRAST WITH config.ts (lines 46-49) which correctly creates the directory:
+  export async function writeConfig(projectRoot: string, config: OacConfig): Promise<void> {
+    const configPath = getConfigPath(projectRoot);
+    await mkdir(dirname(configPath), { recursive: true });  // ← creates .oac/ first
+    await Bun.write(configPath, JSON.stringify(config, null, 2));
+  }
+
+ROOT CAUSE:
+`writeManifest` was written without the `mkdir` guard that `writeConfig` has.
+In the `oac init` flow, `writeConfig` is called after `writeManifest` (init.ts
+lines 214-229), so `.oac/` is created by `writeConfig` — but only if
+`writeManifest` succeeds first. If `.oac/` doesn't exist yet, `writeManifest`
+throws before `writeConfig` ever runs.
+
+In practice, `installFile` in installer.ts calls `Bun.write(destPath, ...)` which
+also creates parent directories automatically for the `.opencode/` files. But
+`.oac/` is not created by any file installation step — it only exists if
+`writeManifest` or `writeConfig` creates it. Since `writeManifest` runs first
+and doesn't create it, the first `oac init` on a clean project will fail.
+
+FIX:
+Add `mkdir` before `Bun.write` in `writeManifest`, matching the pattern in
+`writeConfig` exactly.
+
+BEFORE (manifest.ts lines 172-178):
+  export const writeManifest = async (
+    projectRoot: string,
+    manifest: ManifestFile,
+  ): Promise<void> => {
+    const manifestPath = getManifestPath(projectRoot);
+    await Bun.write(manifestPath, JSON.stringify(manifest, null, 2));
+  };
+
+AFTER:
+  export const writeManifest = async (
+    projectRoot: string,
+    manifest: ManifestFile,
+  ): Promise<void> => {
+    const manifestPath = getManifestPath(projectRoot);
+    await mkdir(dirname(manifestPath), { recursive: true });
+    await Bun.write(manifestPath, JSON.stringify(manifest, null, 2));
+  };
+
+Also add the required imports at the top of manifest.ts. Currently manifest.ts
+imports (lines 1-2):
+  import path from 'node:path';
+  import { z } from 'zod';
+
+AFTER — add mkdir and dirname:
+  import path, { dirname } from 'node:path';
+  import { mkdir } from 'node:fs/promises';
+  import { z } from 'zod';
+
+Note: `path` is already imported as a default import. `dirname` can be added as
+a named import from the same module. Alternatively use `path.dirname()` to avoid
+adding a named import:
+
+  await mkdir(path.dirname(manifestPath), { recursive: true });
+
+Either approach is acceptable. The `path.dirname()` form requires no import
+change and is consistent with how `path` is already used in the file.
+
+MINIMAL DIFF (using path.dirname to avoid import changes):
+
+BEFORE:
+  export const writeManifest = async (
+    projectRoot: string,
+    manifest: ManifestFile,
+  ): Promise<void> => {
+    const manifestPath = getManifestPath(projectRoot);
+    await Bun.write(manifestPath, JSON.stringify(manifest, null, 2));
+  };
+
+AFTER:
+  export const writeManifest = async (
+    projectRoot: string,
+    manifest: ManifestFile,
+  ): Promise<void> => {
+    const manifestPath = getManifestPath(projectRoot);
+    await mkdir(path.dirname(manifestPath), { recursive: true });
+    await Bun.write(manifestPath, JSON.stringify(manifest, null, 2));
+  };
+
+With import added at top:
+  import { mkdir } from 'node:fs/promises';
+
+VALIDATION:
+1. Create a fresh test project: mkdir /tmp/test-oac && cd /tmp/test-oac && git init
+2. Confirm .oac/ does NOT exist: ls -la | grep .oac (should show nothing)
+3. Run: oac init
+4. Confirm .oac/manifest.json was created successfully
+5. Confirm .oac/config.json was also created
+6. Run: oac doctor — should show manifest as valid
+7. Repeat test with a project that has no .oac/ directory at all
+
+DEPENDENCIES: none

+ 176 - 0
docs/planning/fix-plans/I6-no-help-examples.txt

@@ -0,0 +1,176 @@
+ISSUE: No usage examples in --help output
+SEVERITY: Important
+FILE(S): packages/cli/src/index.ts, packages/cli/src/commands/init.ts,
+         packages/cli/src/commands/update.ts
+
+CURRENT STATE:
+
+Running `oac --help` shows command descriptions but no examples. The clig.dev
+standard says "lead with examples" — users should see concrete usage immediately.
+
+Current help output (inferred from Commander registration in index.ts and command files):
+
+  Usage: oac [options] [command]
+
+  OpenAgents Control — install, manage, and update AI agents and context files
+
+  Options:
+    -v, --version  Print version and exit
+    -h, --help     display help for command
+
+  Commands:
+    init           Set up OAC agents and context files in the current project
+    update         Update installed OAC files, skipping any you have modified
+    add            ...
+    apply          ...
+    doctor         Check your OAC setup and report any issues
+    list           ...
+    status         ...
+    help [command] display help for command
+
+No examples section. No "after init, run..." guidance.
+
+ROOT CAUSE:
+Commander's `.addHelpText('after', ...)` method was not used. This is the
+standard Commander pattern for appending examples to help output.
+
+FIX:
+
+═══════════════════════════════════════════════════════════════
+1. Main program — packages/cli/src/index.ts
+═══════════════════════════════════════════════════════════════
+
+BEFORE (index.ts lines 8-12):
+  program
+    .name('oac')
+    .description('OpenAgents Control — install, manage, and update AI agents and context files')
+    .version(readCliVersion(), '-v, --version', 'Print version and exit')
+
+AFTER:
+  program
+    .name('oac')
+    .description('OpenAgents Control — install, manage, and update AI agents and context files')
+    .version(readCliVersion(), '-v, --version', 'Print version and exit')
+    .addHelpText('after', `
+Examples:
+  $ oac init                    Set up OAC in the current project
+  $ oac update                  Update OAC files (skips files you modified)
+  $ oac update --dry-run        Preview what would be updated
+  $ oac doctor                  Check your setup and report issues
+  $ oac add openagent           Add a specific agent from the registry
+  $ oac apply cursor            Generate Cursor IDE rules file
+
+Docs: https://github.com/darrenhinde/OpenAgentsControl#readme
+`)
+
+═══════════════════════════════════════════════════════════════
+2. init command — packages/cli/src/commands/init.ts
+═══════════════════════════════════════════════════════════════
+
+BEFORE (init.ts lines 249-263):
+  export function registerInitCommand(program: Command): void {
+    program
+      .command('init')
+      .description('Set up OAC agents and context files in the current project')
+      .option('--yolo', 'Skip conflict checks and overwrite user-modified files', false)
+      .option('--dry-run', 'Print what would happen without making any changes', false)
+      .option('--verbose', 'Show each file being copied', false)
+      .action(async (opts: { yolo: boolean; dryRun: boolean; verbose: boolean }) => {
+        await initCommand({
+          yolo: opts.yolo,
+          dryRun: opts.dryRun,
+          verbose: opts.verbose,
+        });
+      });
+  }
+
+AFTER:
+  export function registerInitCommand(program: Command): void {
+    program
+      .command('init')
+      .description('Set up OAC agents and context files in the current project')
+      .option('--yolo', 'Skip conflict checks and overwrite user-modified files', false)
+      .option('--dry-run', 'Print what would happen without making any changes', false)
+      .option('--verbose', 'Show each file being copied', false)
+      .addHelpText('after', `
+Examples:
+  $ oac init                    Install all OAC agents and context files
+  $ oac init --dry-run          Preview what would be installed
+  $ oac init --verbose          Show each file as it is copied
+  $ oac init --yolo             Overwrite files you have modified (backs them up first)
+
+After init, run 'oac doctor' to verify your setup.
+`)
+      .action(async (opts: { yolo: boolean; dryRun: boolean; verbose: boolean }) => {
+        await initCommand({
+          yolo: opts.yolo,
+          dryRun: opts.dryRun,
+          verbose: opts.verbose,
+        });
+      });
+  }
+
+═══════════════════════════════════════════════════════════════
+3. update command — packages/cli/src/commands/update.ts
+═══════════════════════════════════════════════════════════════
+
+BEFORE (update.ts lines 181-197):
+  export function registerUpdateCommand(program: Command): void {
+    program
+      .command('update')
+      .description('Update installed OAC files, skipping any you have modified')
+      .option('--dry-run', 'Show what would be updated without making changes')
+      .option('--check', 'Alias for --dry-run: show what would change')
+      .option('--yolo', 'Back up user-modified files and overwrite them anyway')
+      .option('--verbose', 'Show SHA256 comparison details per file')
+      .action(async (cmdOpts: { dryRun?: boolean; check?: boolean; yolo?: boolean; verbose?: boolean }) => {
+        await handleUpdate({
+          dryRun: cmdOpts.dryRun ?? false,
+          check: cmdOpts.check ?? false,
+          yolo: cmdOpts.yolo ?? false,
+          verbose: cmdOpts.verbose ?? false,
+        });
+      });
+  }
+
+AFTER:
+  export function registerUpdateCommand(program: Command): void {
+    program
+      .command('update')
+      .description('Update installed OAC files, skipping any you have modified')
+      .option('--dry-run', 'Show what would be updated without making changes')
+      .option('--check', 'Alias for --dry-run: show what would change')
+      .option('--yolo', 'Back up user-modified files and overwrite them anyway')
+      .option('--verbose', 'Show SHA256 comparison details per file')
+      .addHelpText('after', `
+Examples:
+  $ oac update                  Update all OAC files (skips files you modified)
+  $ oac update --dry-run        Preview what would change without modifying anything
+  $ oac update --check          Same as --dry-run (alias)
+  $ oac update --yolo           Back up modified files and overwrite them
+  $ oac update --verbose        Show SHA256 hash comparison for each file
+
+Files you have modified since 'oac init' are skipped by default.
+Use --yolo to overwrite them (your changes are backed up to .oac/backups/).
+`)
+      .action(async (cmdOpts: { dryRun?: boolean; check?: boolean; yolo?: boolean; verbose?: boolean }) => {
+        await handleUpdate({
+          dryRun: cmdOpts.dryRun ?? false,
+          check: cmdOpts.check ?? false,
+          yolo: cmdOpts.yolo ?? false,
+          verbose: cmdOpts.verbose ?? false,
+        });
+      });
+  }
+
+VALIDATION:
+1. Run: oac --help
+   Should show the Examples section at the bottom
+2. Run: oac init --help
+   Should show init-specific examples
+3. Run: oac update --help
+   Should show update-specific examples with --yolo explanation
+4. Verify no trailing whitespace issues in the help text
+5. Verify the help text renders correctly in a narrow terminal (80 cols)
+
+DEPENDENCIES: none

+ 93 - 0
docs/planning/fix-plans/I7-cli-subpackage-bin-conflict.txt

@@ -0,0 +1,93 @@
+ISSUE: packages/cli has a bin field pointing to a Bun binary that cannot run under Node.js
+SEVERITY: Important
+FILE(S): packages/cli/package.json
+
+CURRENT STATE:
+packages/cli/package.json (lines 6-8):
+
+  "bin": {
+    "oac": "./dist/index.js"
+  },
+
+The `dist/index.js` file is produced by:
+  bun build src/index.ts --outdir dist --target bun --splitting
+
+`--target bun` produces a Bun-specific binary. It uses:
+  - `Bun.file()`, `Bun.write()`, `Bun.version` — Bun globals
+  - `import.meta.dir` — Bun-specific (not available in Node.js)
+
+If anyone installs `@nextsystems/oac-cli` directly:
+  npm install -g @nextsystems/oac-cli
+
+npm will register `oac` → `./dist/index.js` as a Node.js executable (because
+the shebang in the compiled output is `#!/usr/bin/env bun` or the file is
+treated as a Node.js module). Running `oac` will fail with:
+  "ReferenceError: Bun is not defined"
+  or
+  "SyntaxError: Cannot use import statement in a module"
+
+The sub-package `@nextsystems/oac-cli` is an internal build artifact. It is
+NOT intended to be installed directly by users. Only the root `@nextsystems/oac`
+package (which uses `bin/oac.js` as the Node.js wrapper) is the public interface.
+
+ROOT CAUSE:
+The `bin` field was added to `packages/cli/package.json` during development,
+possibly for local testing. It was not removed before the package was prepared
+for publication.
+
+FIX:
+Remove the `bin` field from `packages/cli/package.json` entirely.
+
+BEFORE (packages/cli/package.json lines 1-12):
+  {
+    "name": "@nextsystems/oac-cli",
+    "version": "1.0.0",
+    "description": "OAC CLI — install, manage, and update AI agents and context files",
+    "type": "module",
+    "bin": {
+      "oac": "./dist/index.js"
+    },
+    "main": "./dist/index.js",
+    "types": "./dist/index.d.ts",
+    "files": ["dist"],
+    "scripts": {
+
+AFTER:
+  {
+    "name": "@nextsystems/oac-cli",
+    "version": "1.0.0",
+    "description": "OAC CLI — install, manage, and update AI agents and context files",
+    "type": "module",
+    "main": "./dist/index.js",
+    "types": "./dist/index.d.ts",
+    "files": ["dist"],
+    "scripts": {
+
+ADDITIONAL CONSIDERATION:
+If the sub-package should never be published to npm at all (it is only used as
+an internal workspace package), consider also adding:
+
+  "private": true
+
+to `packages/cli/package.json`. This prevents accidental `npm publish` of the
+sub-package. However, if there is a use case for publishing `@nextsystems/oac-cli`
+as a library (for programmatic use), keep it publishable but without the `bin`
+field.
+
+Given the current architecture (the sub-package is a Bun binary, not a library),
+`"private": true` is the safer choice. This is a separate decision from removing
+`bin` — both can be done independently.
+
+VALIDATION:
+1. Run: node -e "const p = require('./packages/cli/package.json'); console.log(p.bin)"
+   Should output: undefined
+2. Run: npm pack --dry-run (from packages/cli/)
+   Should NOT show any bin registration
+3. Verify the root package still works:
+   Run: oac --version (via root bin/oac.js)
+   Should still work correctly
+4. If "private": true is added:
+   Run: npm publish (from packages/cli/)
+   Should fail with "This package has been marked as private"
+
+DEPENDENCIES: none

+ 134 - 0
docs/planning/fix-plans/I8-windows-bun-cmd.txt

@@ -0,0 +1,134 @@
+ISSUE: bin/oac.js uses execFileSync('bun') which fails on Windows where bun is bun.cmd
+SEVERITY: Important
+FILE(S): bin/oac.js
+
+CURRENT STATE:
+bin/oac.js (lines 15-23):
+
+  try {
+    execFileSync('bun', [cliDist, ...process.argv.slice(2)], { stdio: 'inherit' });
+  } catch (err) {
+    if (err.code === 'ENOENT') {
+      console.error('Error: Bun is required to run OAC CLI. Install from https://bun.sh');
+      process.exit(1);
+    }
+    process.exitCode = err.status ?? 1;
+  }
+
+On Windows, npm global installs create `.cmd` wrapper files in the PATH. When
+Bun is installed on Windows, the executable available in PATH is `bun.cmd` (a
+batch file wrapper), not `bun` (a bare executable). `execFileSync('bun', ...)`
+uses the exact name provided — it does NOT search for `bun.cmd` automatically.
+
+Result on Windows:
+  Error: spawn bun ENOENT
+  Error: Bun is required to run OAC CLI. Install from https://bun.sh
+
+This happens even when Bun IS installed and working correctly on Windows.
+
+Note: `child_process.execSync('bun ...')` (without File) DOES resolve .cmd
+wrappers because it goes through the shell. But `execFileSync` bypasses the
+shell for security and performance, so it requires the exact executable name.
+
+ROOT CAUSE:
+`execFileSync` was used (correctly, for security) but without the Windows
+`.cmd` extension handling that is required for npm-installed executables on
+Windows.
+
+FIX:
+Detect the platform and use `bun.cmd` on Windows. Also pass `shell: false`
+explicitly to document the intent.
+
+BEFORE (bin/oac.js lines 1-23):
+  #!/usr/bin/env node
+  'use strict';
+
+  const { execFileSync } = require('child_process');
+  const path = require('path');
+  const fs = require('fs');
+
+  const cliDist = path.join(__dirname, '..', 'packages', 'cli', 'dist', 'index.js');
+
+  if (!fs.existsSync(cliDist)) {
+    console.error('Error: OAC CLI not built yet. Run: npm run build -w packages/cli');
+    process.exit(1);
+  }
+
+  try {
+    execFileSync('bun', [cliDist, ...process.argv.slice(2)], { stdio: 'inherit' });
+  } catch (err) {
+    if (err.code === 'ENOENT') {
+      console.error('Error: Bun is required to run OAC CLI. Install from https://bun.sh');
+      process.exit(1);
+    }
+    process.exitCode = err.status ?? 1;
+  }
+
+AFTER (incorporating both C4 OAC_PACKAGE_ROOT injection and this Windows fix):
+  #!/usr/bin/env node
+  'use strict';
+
+  const { execFileSync } = require('child_process');
+  const path = require('path');
+  const fs = require('fs');
+
+  const cliDist = path.join(__dirname, '..', 'packages', 'cli', 'dist', 'index.js');
+  const packageRoot = path.join(__dirname, '..');
+
+  if (!fs.existsSync(cliDist)) {
+    console.error('Error: OAC CLI not built yet. Run: npm run build -w packages/cli');
+    process.exit(1);
+  }
+
+  // On Windows, npm-installed executables are .cmd wrappers — use shell to resolve them
+  const isWindows = process.platform === 'win32';
+  const bunExecutable = isWindows ? 'bun.cmd' : 'bun';
+
+  try {
+    execFileSync(bunExecutable, [cliDist, ...process.argv.slice(2)], {
+      stdio: 'inherit',
+      env: { ...process.env, OAC_PACKAGE_ROOT: packageRoot },
+      // shell: false is the default for execFileSync — explicit for clarity
+      shell: false,
+    });
+  } catch (err) {
+    if (err.code === 'ENOENT') {
+      console.error('Error: Bun is required to run OAC CLI. Install from https://bun.sh');
+      process.exit(1);
+    }
+    process.exitCode = err.status ?? 1;
+  }
+
+ALTERNATIVE APPROACH (if .cmd detection is fragile):
+Use `execSync` (with shell) instead of `execFileSync`. This is simpler but
+slightly less secure (shell injection is possible if args are not sanitized).
+Since `process.argv.slice(2)` comes from the user's own shell, this is
+acceptable:
+
+  const { execSync } = require('child_process');
+  const args = process.argv.slice(2).map(a => JSON.stringify(a)).join(' ');
+  execSync(`bun ${JSON.stringify(cliDist)} ${args}`, {
+    stdio: 'inherit',
+    env: { ...process.env, OAC_PACKAGE_ROOT: packageRoot },
+  });
+
+The `execFileSync` approach with `bun.cmd` detection is preferred as it is
+more explicit and avoids shell quoting complexity.
+
+VALIDATION:
+1. On Windows (or Windows CI):
+   a. Install Bun for Windows from https://bun.sh
+   b. Install the package: npm install -g @nextsystems/oac
+   c. Run: oac --version
+   d. Should print the version, NOT "Bun is required"
+2. On macOS/Linux:
+   a. Run: oac --version
+   b. Should still work (isWindows = false, uses 'bun')
+3. Test ENOENT path on Windows:
+   a. Temporarily rename bun.cmd to bun.cmd.bak
+   b. Run: oac --version
+   c. Should show "Bun is required" error
+   d. Restore bun.cmd
+
+DEPENDENCIES: C4 (this fix should be applied together with the OAC_PACKAGE_ROOT
+injection from C3-C4 since both modify bin/oac.js)

+ 120 - 0
docs/planning/fix-plans/M1-version-mismatch.txt

@@ -0,0 +1,120 @@
+ISSUE: Version mismatch between root package and CLI sub-package
+SEVERITY: Minor
+FILE(S): package.json (root), packages/cli/package.json, packages/cli/src/lib/version.ts
+
+CURRENT STATE:
+
+Root package.json (line 3):
+  "version": "0.7.1"
+
+packages/cli/package.json (line 3):
+  "version": "1.0.0"
+
+packages/cli/src/lib/version.ts (lines 1-6):
+  import pkgJson from '../../package.json' with { type: 'json' }
+
+  /** Returns the CLI version from package.json. Synchronous — no I/O. */
+  export function readCliVersion(): string {
+    return pkgJson.version ?? '0.0.0'
+  }
+
+`readCliVersion()` reads from `packages/cli/package.json` (the relative import
+`../../package.json` from `packages/cli/src/lib/` resolves to
+`packages/cli/package.json`). So `readCliVersion()` returns `"1.0.0"`.
+
+doctor.ts `checkOacVersion()` (line 55) calls:
+  const latest = await fetchLatestNpmVersion('@nextsystems/oac');
+
+This fetches the latest version of `@nextsystems/oac` (the root package, version
+`0.7.1`). It then compares `current` (from `readCliVersion()` = `"1.0.0"`) with
+`latest` (from npm registry, which would be `"0.7.1"` or whatever was last
+published).
+
+Result: `semver.lt("1.0.0", "0.7.1")` = false, so doctor always reports
+"OAC version: 1.0.0 (latest)" even when the root package is outdated. The
+version check is broken.
+
+ROOT CAUSE:
+The two packages have diverged in version numbers. The CLI sub-package was
+bumped to 1.0.0 independently of the root package. There is no synchronization
+mechanism.
+
+FIX:
+
+Decision: The ROOT package.json is the canonical version source. It is the
+package users install (`@nextsystems/oac`). The CLI sub-package version should
+always match the root.
+
+--- Option A (recommended): Single source of truth via root package.json ---
+
+1. Synchronize versions: set `packages/cli/package.json` version to match root:
+
+BEFORE (packages/cli/package.json line 3):
+  "version": "1.0.0"
+
+AFTER:
+  "version": "0.7.1"
+
+2. Update `readCliVersion()` to read from the ROOT package.json instead of the
+   sub-package's package.json:
+
+BEFORE (packages/cli/src/lib/version.ts):
+  import pkgJson from '../../package.json' with { type: 'json' }
+
+  export function readCliVersion(): string {
+    return pkgJson.version ?? '0.0.0'
+  }
+
+AFTER:
+  import pkgJson from '../../../../package.json' with { type: 'json' }
+
+  /** Returns the CLI version from the root @nextsystems/oac package.json. */
+  export function readCliVersion(): string {
+    return pkgJson.version ?? '0.0.0'
+  }
+
+The path `../../../../package.json` from `packages/cli/src/lib/` resolves to
+the repo root `package.json`. Verify: packages/cli/src/lib/ → ../../.. = packages/cli/
+→ ../../.. = repo root. Count: src/lib → src → packages/cli → packages → root.
+That is 4 levels up: `../../../../package.json`. ✓
+
+3. Add a version sync script to root package.json scripts to keep them in sync
+   during version bumps:
+
+BEFORE (root package.json scripts, version bump scripts lines 75-80):
+  "version:bump:patch": "npm version patch --no-git-tag-version && node -p \"require('./package.json').version\" > VERSION",
+  "version:bump:minor": "npm version minor --no-git-tag-version && node -p \"require('./package.json').version\" > VERSION",
+  "version:bump:major": "npm version major --no-git-tag-version && node -p \"require('./package.json').version\" > VERSION",
+
+AFTER — add a sync step after each bump:
+  "version:bump:patch": "npm version patch --no-git-tag-version && node scripts/sync-version.js && node -p \"require('./package.json').version\" > VERSION",
+  "version:bump:minor": "npm version minor --no-git-tag-version && node scripts/sync-version.js && node -p \"require('./package.json').version\" > VERSION",
+  "version:bump:major": "npm version major --no-git-tag-version && node scripts/sync-version.js && node -p \"require('./package.json').version\" > VERSION",
+
+Where `scripts/sync-version.js` is a small Node.js script:
+  const fs = require('fs');
+  const root = require('./package.json');
+  const cliPkg = require('./packages/cli/package.json');
+  cliPkg.version = root.version;
+  fs.writeFileSync('./packages/cli/package.json', JSON.stringify(cliPkg, null, 2) + '\n');
+  console.log(`Synced packages/cli version to ${root.version}`);
+
+--- Option B (alternative): Keep sub-package version independent ---
+
+If the sub-package intentionally has a different version lifecycle, update
+`readCliVersion()` to read from the root package.json (step 2 above) but leave
+the sub-package version as-is. The doctor check will then correctly compare
+the root package version against npm.
+
+VALIDATION:
+1. Run: oac --version
+   Should print "0.7.1" (matching root package.json)
+2. Run: oac doctor
+   The "OAC version" check should compare "0.7.1" against npm registry
+3. Run: node -e "const p = require('./packages/cli/package.json'); console.log(p.version)"
+   Should print "0.7.1"
+4. Bump the root version: npm version patch --no-git-tag-version
+5. Run: node scripts/sync-version.js
+6. Verify packages/cli/package.json version matches the new root version
+
+DEPENDENCIES: none

+ 68 - 0
docs/planning/fix-plans/M2-warn-stdout-vs-stderr.txt

@@ -0,0 +1,68 @@
+ISSUE: warn() writes to stdout instead of stderr
+SEVERITY: Minor
+FILE(S): packages/cli/src/ui/logger.ts
+
+CURRENT STATE:
+packages/cli/src/ui/logger.ts (lines 26-33):
+
+  export const log     = (msg: string): void => console.log(msg);
+  export const info    = (msg: string): void => console.log(chalk.blue(`  ℹ ${msg}`));
+  export const warn    = (msg: string): void => console.log(chalk.yellow(`  ⚠ ${msg}`));
+  export const error   = (msg: string): void => console.error(chalk.red(`  ✗ ${msg}`));
+  export const success = (msg: string): void => console.log(chalk.green(`  ✓ ${msg}`));
+  export const dim     = (msg: string): void => console.log(chalk.gray(msg));
+  export const bold    = (msg: string): void => console.log(chalk.bold(msg));
+  export const verbose = (msg: string): void => { if (verboseEnabled) console.log(chalk.gray(`  … ${msg}`)); };
+
+`error()` correctly uses `console.error` (which writes to stderr, fd 2).
+`warn()` uses `console.log` (which writes to stdout, fd 1).
+
+ROOT CAUSE:
+`warn()` was written with `console.log` instead of `console.error`. This is a
+common oversight. The Unix convention is:
+  - stdout (fd 1): program output — data that can be piped or redirected
+  - stderr (fd 2): diagnostic messages — warnings, errors, progress info
+
+When a user pipes oac output:
+  oac list | grep agent
+
+...any `warn()` messages will appear in the pipe and corrupt the output. They
+should go to stderr so they are visible in the terminal but do not pollute the
+pipe.
+
+FIX:
+One-line change in logger.ts:
+
+BEFORE (line 28):
+  export const warn    = (msg: string): void => console.log(chalk.yellow(`  ⚠ ${msg}`));
+
+AFTER:
+  export const warn    = (msg: string): void => console.error(chalk.yellow(`  ⚠ ${msg}`));
+
+ADDITIONAL CONSIDERATION:
+While fixing this, consider whether `info`, `success`, `dim`, `bold`, and
+`verbose` should also go to stderr. The argument:
+  - If these are diagnostic/status messages (not data output), they belong on stderr
+  - If the CLI never produces machine-parseable stdout output, it doesn't matter
+
+For a CLI like oac that primarily installs files and shows status, ALL output
+is diagnostic. The only exception would be `oac doctor --json` which explicitly
+produces machine-readable JSON on stdout (and correctly uses `log()` for that).
+
+Recommendation: change `warn` only (as described above) since it is the most
+clearly wrong. Leave `info`, `success`, etc. on stdout for now — they are
+human-readable status messages that users expect to see in normal terminal output.
+
+VALIDATION:
+1. Run: oac update 2>/dev/null
+   Any warning messages (e.g. "skipped N files") should NOT appear
+   (they are now on stderr, which is suppressed by 2>/dev/null)
+2. Run: oac update 1>/dev/null
+   Warning messages SHOULD appear (stderr is not suppressed)
+3. Run: oac init (in a project with modified files)
+   The "Completed with N errors" warning should appear in the terminal
+   but not in: oac init 2>/dev/null
+4. Verify the Logger interface in logger.ts still compiles:
+   Run: cd packages/cli && bun run typecheck
+
+DEPENDENCIES: none

+ 76 - 0
docs/planning/fix-plans/M3-repository-directory.txt

@@ -0,0 +1,76 @@
+ISSUE: Missing repository.directory field in both package.json files
+SEVERITY: Minor
+FILE(S): package.json (root), packages/cli/package.json
+
+CURRENT STATE:
+
+Root package.json (lines 107-110):
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/darrenhinde/OpenAgentsControl.git"
+  },
+
+packages/cli/package.json — no `repository` field at all (37 lines total,
+no repository key present).
+
+ROOT CAUSE:
+The `repository.directory` field is the monorepo best practice documented at
+https://docs.npmjs.com/cli/v10/configuring-npm/package-json#repository
+
+Without it:
+  1. npm's package page shows a generic "View on GitHub" link pointing to the
+     repo root, not to the specific package directory
+  2. Tools like `npm repo` open the repo root instead of the package subdirectory
+  3. The npm registry cannot display the correct "Source" link for the sub-package
+
+FIX:
+
+--- Root package.json ---
+The root package IS at the repo root, so `directory` is `.` (or can be omitted,
+but explicit is better for monorepo tooling):
+
+BEFORE (lines 107-110):
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/darrenhinde/OpenAgentsControl.git"
+  },
+
+AFTER:
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/darrenhinde/OpenAgentsControl.git",
+    "directory": "."
+  },
+
+--- packages/cli/package.json ---
+Add a complete `repository` field pointing to the sub-package directory.
+Insert after the `"engines"` block (after line 36):
+
+BEFORE (packages/cli/package.json ends at line 37 with `}`):
+  "engines": {
+    "bun": ">=1.0.0"
+  }
+}
+
+AFTER:
+  "engines": {
+    "bun": ">=1.0.0"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/darrenhinde/OpenAgentsControl.git",
+    "directory": "packages/cli"
+  }
+}
+
+VALIDATION:
+1. Run: npm pack --dry-run (from root)
+   The output should show repository information
+2. Run: node -e "const p = require('./package.json'); console.log(p.repository)"
+   Should output: { type: 'git', url: '...', directory: '.' }
+3. Run: node -e "const p = require('./packages/cli/package.json'); console.log(p.repository)"
+   Should output: { type: 'git', url: '...', directory: 'packages/cli' }
+4. After publishing: verify the npm package page shows the correct GitHub link
+   pointing to packages/cli/ not the repo root
+
+DEPENDENCIES: none

+ 203 - 0
docs/planning/fix-plans/REVIEW-REPORT.txt

@@ -0,0 +1,203 @@
+OAC CLI Fix Plans — Review Report
+==================================
+Reviewer: CodeReviewer subagent
+Date: 2026-03-11
+
+VERDICT SUMMARY
+---------------
+Plans approved as-written:        9  — C2, C5, C6, I2, I3, I6, I7, M2, M3
+Plans approved with amendments:   8  — C1, C3+C4, I1, I4, I5, I8, M1, I6
+Plans rejected (need rewrite):    0
+Missing plans identified:         4  — MP1–MP4
+
+PLAN-BY-PLAN REVIEW
+--------------------
+
+[C1] .npmignore excludes dist
+  Status: APPROVED WITH AMENDMENTS
+  Issues found:
+    - "packages/cli/bun.lockb" is a typo — actual file is bun.lock (no trailing b).
+      This pattern will never match anything.
+    - Consider packages/cli/tsconfig*.json (glob) instead of single tsconfig.json
+  Required amendments:
+    - Change bun.lockb → bun.lock in the .npmignore plan
+
+[C2] prepublishOnly build guard
+  Status: APPROVED
+  Issues found: none
+  Required amendments: none
+
+[C3+C4] Package root resolution + bin/oac.js env injection
+  Status: APPROVED WITH AMENDMENTS
+  Issues found:
+    - getPackageRoot() in bundled.ts ALREADY checks process.env['OAC_PACKAGE_ROOT']
+      (lines 32–35). The env var support exists — bin/oac.js just doesn't inject it yet.
+      The fix is simpler than the plan implies.
+    - Validation step 1 says "manually set OAC_PACKAGE_ROOT" — but the whole point of
+      C4 is that bin/oac.js injects it automatically. Validation should test WITHOUT
+      manually setting it.
+    - Both comment blocks in bundled.ts need updating (getPackageRoot() at lines 21–27
+      AND findPackageRoot() at lines 41–52).
+  Required amendments:
+    - Simplify rationale: OAC_PACKAGE_ROOT already exists, just needs injection in bin/oac.js
+    - Fix validation to test without manual env var
+    - Update both comment blocks in bundled.ts
+
+[C5] engines field mismatch
+  Status: APPROVED
+  Issues found: none
+  Required amendments: none
+
+[C6] Missing publishConfig
+  Status: APPROVED
+  Issues found: none
+  Coordination note: C6 and I7 both modify packages/cli/package.json — apply as single diff
+
+[I1] oac clean command
+  Status: APPROVED WITH AMENDMENTS
+  Issues found:
+    - Plan removes entire .opencode/ directory but user-customized files inside it
+      would be silently deleted without per-file warning, even without --force.
+  Required amendments:
+    - Add explicit destructive-action warning before removal listing what will be deleted
+    - Consider --keep-opencode flag to only remove .oac/ (manifest + config) while
+      leaving .opencode/ intact for users who want to keep their agents/context
+
+[I2] README missing npm install instructions
+  Status: APPROVED
+  Issues found:
+    - After insertion there will be two "Step 1" headings — rename curl section heading
+  Required amendments: minor heading rename only
+
+[I3] No signal handlers
+  Status: APPROVED
+  Issues found: none
+  Required amendments: none
+
+[I4] No inline update notification
+  Status: APPROVED WITH AMENDMENTS
+  Issues found:
+    - Step 3 offers two options with no decision — underspecified
+    - Update notice box uses fixed-width padding that misaligns for longer version strings
+    - Does not note that update check is skipped on --version fast path
+  Required amendments:
+    - Commit to one approach: export fetchLatestNpmVersion(packageName: string) from
+      update-check.ts as a named export and import it in doctor.ts
+    - Use dynamic width or simpler formatting for the update notice box
+    - Add note that update check is intentionally skipped on --version fast path
+
+[I5] writeManifest missing mkdir
+  Status: APPROVED WITH AMENDMENTS
+  Issues found:
+    - Plan says "Bun.write() creates parent directories automatically for .opencode/ files"
+      — this is imprecise. Bun.write() with a BunFile source does create parent dirs
+      (Bun-specific behavior). Bun.write() with string content does NOT. The conclusion
+      (fix is needed) is correct but the reasoning should be clarified.
+  Required amendments:
+    - Clarify the Bun.write() behavior distinction in the plan rationale
+
+[I6] No examples in --help output
+  Status: APPROVED WITH AMENDMENTS
+  Issues found:
+    - Main program help example "oac add openagent" is wrong — actual CLI syntax is
+      "oac add <type>:<name>". Should be "oac add agent:openagent" or similar.
+  Required amendments:
+    - Fix add example syntax to match actual CLI interface
+
+[I7] packages/cli bin field conflict
+  Status: APPROVED
+  Issues found: none
+  Recommendation: Also add "private": true to packages/cli/package.json to make
+  the intent explicit (not meant for direct npm install)
+
+[I8] Windows bun.cmd compatibility
+  Status: APPROVED WITH AMENDMENTS
+  Issues found: none
+  Coordination note: I8 plan already provides a combined C3+C4+I8 diff for bin/oac.js.
+  Apply all three together as one atomic change.
+
+[M1] Version mismatch
+  Status: APPROVED WITH AMENDMENTS
+  Issues found:
+    - Path math confirmed: ../../../../package.json from packages/cli/src/lib/ IS correct
+    - Version is baked into the Bun bundle at build time — after bumping root version,
+      a rebuild is required for oac --version to reflect the new version
+  Required amendments:
+    - Add note that prepublishOnly (C2) handles this for publish but local dev may
+      have stale versions until rebuild
+    - Verify resolveJsonModule: true in packages/cli/tsconfig.json before applying
+
+[M2] warn() writes to stdout
+  Status: APPROVED
+  Issues found: none
+  Required amendments: none
+
+[M3] Missing repository.directory
+  Status: APPROVED
+  Issues found: none
+  Required amendments: none
+
+
+CONFLICTS BETWEEN PLANS
+------------------------
+
+1. C3+C4 and I8 both modify bin/oac.js
+   Resolution: RESOLVED — I8 plan already provides a combined diff. Apply as one atomic change.
+
+2. C6 and I7 both modify packages/cli/package.json
+   Resolution: Apply as a single coordinated diff to avoid merge conflicts.
+
+3. I4 refactors doctor.ts (extracts fetchLatestNpmVersion)
+   Resolution: Commit to named export approach (see I4 amendment). Ensure doctor.ts
+   imports from update-check.ts after the refactor.
+
+4. I1, I3, I4 all modify packages/cli/src/index.ts
+   Resolution: Apply as a single coordinated diff in this order:
+   signal handlers (I3) → update check call (I4) → register clean command (I1)
+
+
+MISSING PLANS
+-------------
+
+MP1: doctor version check — NOT a bug
+  doctor.ts correctly calls fetchLatestNpmVersion('@nextsystems/oac') (the root package).
+  After M1 syncs versions, this will work correctly. No new plan needed.
+
+MP2: CursorAdapter.mergeAgents() existence
+  apply.ts line 143 calls (adapter as CursorAdapter).mergeAgents(agents).
+  This method must exist on CursorAdapter. Verify mergeAgents() exists in the full
+  CursorAdapter.ts file. If it doesn't, a new plan is needed before I1 (clean) is safe.
+  ACTION: Verify before implementing.
+
+MP3: TypeScript resolveJsonModule for M1
+  After M1 changes version.ts to import ../../../../package.json, TypeScript must have
+  resolveJsonModule: true in packages/cli/tsconfig.json.
+  ACTION: Verify packages/cli/tsconfig.json before applying M1.
+
+MP4: installFile() lacks explicit mkdir
+  installer.ts relies on Bun.write() with a BunFile source auto-creating parent
+  directories (undocumented Bun behavior). If Bun changes this, installFile() breaks
+  silently. Recommend adding explicit mkdir calls defensively.
+  SEVERITY: Minor — current behavior works, but fragile.
+
+
+RECOMMENDED EXECUTION ORDER
+----------------------------
+
+Apply in this order to avoid conflicts and satisfy dependencies:
+
+ 1.  C6        publishConfig (unblocks publish, metadata only)
+ 2.  C1        .npmignore (fix bun.lockb typo in plan first)
+ 3.  C2        prepublishOnly scripts
+ 4.  C5        engines field
+ 5.  M3        repository.directory (metadata only)
+ 6.  M1        version sync (verify resolveJsonModule first)
+ 7.  M2        warn() stderr (one-line fix)
+ 8.  I7+C6     remove bin field + publishConfig (coordinated diff to packages/cli/package.json)
+ 9.  C3+C4+I8  package root + Windows fix (single atomic diff to bin/oac.js + bundled.ts)
+10.  I5        writeManifest mkdir
+11.  I3        signal handlers (index.ts)
+12.  I4        update notification (coordinate with I3 for index.ts; extract to update-check.ts)
+13.  I1        clean command (coordinate with I3/I4 for index.ts; add destructive warning)
+14.  I6        help examples (fix add example syntax)
+15.  I2        README (do last, after package verified working)

+ 166 - 0
docs/planning/fix-plans/TEST-GATES.md

@@ -0,0 +1,166 @@
+# Test Gates for oac-package-standards Fix Batch
+
+Each test below acts as a gate: it **FAILS before the fix**, **PASSES after**.
+
+Run after each subtask to confirm the fix worked and no regressions were introduced:
+
+```bash
+cd packages/cli && ~/.bun/bin/bun test 2>&1
+```
+
+---
+
+## Currently Failing Tests (will pass after fixes)
+
+| Test Description | File | Fails Until Subtask | Why It Fails Now |
+|---|---|---|---|
+| `has publishConfig.access set to "public"` (root) | `package-json.test.ts` | subtask-01 | `publishConfig` field missing from root `package.json` |
+| `has publishConfig.access set to "public"` (cli) | `package-json.test.ts` | subtask-01 | `publishConfig` field missing from `packages/cli/package.json` |
+| `has prepublishOnly script` (root) | `package-json.test.ts` | subtask-02 | No `prepublishOnly` script in root `package.json` |
+| `has prepublishOnly script` (cli) | `package-json.test.ts` | subtask-02 | No `prepublishOnly` script in `packages/cli/package.json` |
+| `has repository.directory field` (root) | `package-json.test.ts` | subtask-03 | No `repository.directory` in root `package.json` |
+| `has repository.directory set to "packages/cli"` | `package-json.test.ts` | subtask-03 | No `repository.directory` in `packages/cli/package.json` |
+| `does NOT have a bin field` (cli) | `package-json.test.ts` | subtask-04 | `packages/cli/package.json` has `bin: { oac: "./dist/index.js" }` |
+| `is marked private: true` (cli) | `package-json.test.ts` | subtask-04 | `packages/cli/package.json` is not marked `private` |
+| `engines field has bun requirement` (root) | `package-json.test.ts` | subtask-05 | Root `engines` only has `node: ">=18.0.0"`, no `bun` field |
+| `version matches packages/cli version` (root) | `package-json.test.ts` | subtask-06 | Root is `0.7.1`, cli is `1.0.0` |
+| `version matches root package.json version` (cli) | `package-json.test.ts` | subtask-06 | Same mismatch |
+| `root and cli versions are identical` | `package-json.test.ts` | subtask-06 | Same mismatch |
+| `warn() writes to stderr (console.error), NOT stdout` | `logger.test.ts` | subtask-07 | `warn()` uses `console.log` (stdout) |
+| `warn() message is captured by stderr spy` | `logger.test.ts` | subtask-07 | `warn()` uses `console.log`, not `console.error` |
+| `warn() message is NOT captured by stdout spy` | `logger.test.ts` | subtask-07 | `warn()` uses `console.log` which IS captured by stdout spy |
+| `finds package root even when registry.json is present` | `bundled.test.ts` | subtask-09 | `!hasRegistryJson` guard skips dirs with `registry.json` |
+| `writeManifest creates .oac/ directory if it does not exist` | `manifest.test.ts` | subtask-10 | Regression guard: validates explicit mkdir behavior added by subtask-10 |
+| `writeManifest is idempotent — calling twice does not throw` | `manifest.test.ts` | subtask-10 | Regression guard: validates idempotent mkdir behavior |
+| `module exports fetchLatestNpmVersion function` | `update-check.test.ts` | subtask-12 | `update-check.ts` does not exist yet |
+| `module exports checkForUpdate function` | `update-check.test.ts` | subtask-12 | `update-check.ts` does not exist yet |
+| `module exports shouldShowUpdateNotice function` | `update-check.test.ts` | subtask-12 | `update-check.ts` does not exist yet |
+| `shouldShowUpdateNotice returns true when latest is newer (patch)` | `update-check.test.ts` | subtask-12 | Module doesn't exist |
+| `shouldShowUpdateNotice returns true when latest is newer (minor)` | `update-check.test.ts` | subtask-12 | Module doesn't exist |
+| `shouldShowUpdateNotice returns true when latest is newer (major)` | `update-check.test.ts` | subtask-12 | Module doesn't exist |
+| `shouldShowUpdateNotice returns false when versions match` | `update-check.test.ts` | subtask-12 | Module doesn't exist |
+| `shouldShowUpdateNotice returns false when current is newer` | `update-check.test.ts` | subtask-12 | Module doesn't exist |
+| `shouldShowUpdateNotice returns false when latest is null` | `update-check.test.ts` | subtask-12 | Module doesn't exist |
+| `fetchLatestNpmVersion returns semver string or null` | `update-check.test.ts` | subtask-12 | Module doesn't exist |
+| `fetchLatestNpmVersion returns null for non-existent package` | `update-check.test.ts` | subtask-12 | Module doesn't exist |
+| `fetchLatestNpmVersion returns null (does not throw) on failure` | `update-check.test.ts` | subtask-12 | Module doesn't exist |
+| `checkForUpdate() resolves without throwing` | `update-check.test.ts` | subtask-12 | Module doesn't exist |
+| `checkForUpdate() returns undefined (void)` | `update-check.test.ts` | subtask-12 | Module doesn't exist |
+| `module exports cleanCommand function` | `clean.test.ts` | subtask-13 | `clean.ts` does not exist yet |
+| `module exports registerCleanCommand function` | `clean.test.ts` | subtask-13 | `clean.ts` does not exist yet |
+| `cleanCommand --force removes .oac/ directory` | `clean.test.ts` | subtask-13 | Module doesn't exist |
+| `cleanCommand --force removes .opencode/ directory by default` | `clean.test.ts` | subtask-13 | Module doesn't exist |
+| `cleanCommand --force removes both .oac/ and .opencode/` | `clean.test.ts` | subtask-13 | Module doesn't exist |
+| `cleanCommand --keep-opencode --force removes .oac/ but preserves .opencode/` | `clean.test.ts` | subtask-13 | Module doesn't exist |
+| `cleanCommand --dry-run does not remove any directories` | `clean.test.ts` | subtask-13 | Module doesn't exist |
+| `cleanCommand does not throw when nothing to clean` | `clean.test.ts` | subtask-13 | Module doesn't exist |
+| `cleanCommand --ide --force removes CLAUDE.md when present` | `clean.test.ts` | subtask-13 | Module doesn't exist |
+| `cleanCommand without --ide preserves CLAUDE.md` | `clean.test.ts` | subtask-13 | Module doesn't exist |
+| `registerCleanCommand registers a "clean" command` | `clean.test.ts` | subtask-13 | Module doesn't exist |
+| `clean command has --force option` | `clean.test.ts` | subtask-13 | Module doesn't exist |
+| `clean command has --dry-run option` | `clean.test.ts` | subtask-13 | Module doesn't exist |
+| `clean command has --keep-opencode option` | `clean.test.ts` | subtask-13 | Module doesn't exist |
+
+---
+
+## Currently Passing Tests (must stay passing — regression guards)
+
+| Test Description | File | Guards Against |
+|---|---|---|
+| `returns OAC_PACKAGE_ROOT env var value without walking` | `bundled.test.ts` | Regression in subtask-09 breaking env var override |
+| `OAC_PACKAGE_ROOT bypasses walk even for path with no .opencode/` | `bundled.test.ts` | Regression in subtask-09 breaking production scenario |
+| `falls through to walk when OAC_PACKAGE_ROOT is empty string` | `bundled.test.ts` | Regression in subtask-09 changing falsy-check behaviour |
+| `returns the nearest ancestor with .opencode/ and package.json` | `bundled.test.ts` | Regression in subtask-09 breaking basic walk |
+| `returns the directory that has both .opencode/ and package.json` | `bundled.test.ts` | Core walk functionality |
+| `returns the start directory itself when it is the package root` | `bundled.test.ts` | Walk starting at root |
+| `throws an error when no package root is found` | `bundled.test.ts` | Error path still works |
+| `error message includes the starting directory` | `bundled.test.ts` | Error message format |
+| `writeManifest then readManifest round-trips correctly` | `manifest.test.ts` | Regression in subtask-10 breaking existing write path |
+| `writeManifest does not throw when .oac/ already exists` | `manifest.test.ts` | Regression in subtask-10 breaking idempotent writes |
+| `error() writes to stderr (console.error)` | `logger.test.ts` | Regression in subtask-07 breaking error() |
+| `error() does NOT write to stdout` | `logger.test.ts` | Regression in subtask-07 |
+| `success() writes to stdout (console.log)` | `logger.test.ts` | Regression in subtask-07 |
+| `success() does NOT write to stderr` | `logger.test.ts` | Regression in subtask-07 |
+| `log() writes to stdout` | `logger.test.ts` | Regression in subtask-07 |
+| `info() writes to stdout` | `logger.test.ts` | Regression in subtask-07 |
+| `dim() writes to stdout` | `logger.test.ts` | Regression in subtask-07 |
+| `bold() writes to stdout` | `logger.test.ts` | Regression in subtask-07 |
+| `verbose() writes to stdout when verbose is enabled` | `logger.test.ts` | Regression in subtask-07 |
+| `verbose() does NOT write when verbose is disabled` | `logger.test.ts` | Regression in subtask-07 |
+| `has bin.oac pointing to ./bin/oac.js` (root) | `package-json.test.ts` | Regression removing the bin entry point |
+| `name is "@nextsystems/oac"` (root) | `package-json.test.ts` | Package name change |
+| `has a license field` (root) | `package-json.test.ts` | License removal |
+| `has a repository field with type "git"` (root) | `package-json.test.ts` | Repository field removal |
+| `files array includes "bin/"` (root) | `package-json.test.ts` | Removing bin/ from published files |
+| `engines.bun is set` (cli) | `package-json.test.ts` | Removing bun engine requirement from cli |
+| `name is "@nextsystems/oac-cli"` (cli) | `package-json.test.ts` | Package name change |
+| `has a build script` (cli) | `package-json.test.ts` | Build script removal |
+| `has a test script` (cli) | `package-json.test.ts` | Test script removal |
+| `has commander as a dependency` (cli) | `package-json.test.ts` | Dependency removal |
+| `has chalk as a dependency` (cli) | `package-json.test.ts` | Dependency removal |
+| `has zod as a dependency` (cli) | `package-json.test.ts` | Dependency removal |
+| All 142 pre-existing tests | `*.test.ts` | Any regression from any subtask |
+
+---
+
+## How to Use These Gates
+
+### Run all tests after each subtask:
+
+```bash
+cd packages/cli && ~/.bun/bin/bun test 2>&1
+```
+
+### Run only the new gate tests (faster feedback loop):
+
+```bash
+cd packages/cli && ~/.bun/bin/bun test --testNamePattern "subtask-" 2>&1
+```
+
+### Run a specific test file:
+
+```bash
+cd packages/cli && ~/.bun/bin/bun test src/lib/manifest.test.ts 2>&1
+cd packages/cli && ~/.bun/bin/bun test src/ui/logger.test.ts 2>&1
+cd packages/cli && ~/.bun/bin/bun test src/lib/package-json.test.ts 2>&1
+cd packages/cli && ~/.bun/bin/bun test src/lib/update-check.test.ts 2>&1
+cd packages/cli && ~/.bun/bin/bun test src/commands/clean.test.ts 2>&1
+```
+
+---
+
+## Expected Progression
+
+| After Subtask | Expected Pass Count | Expected Fail Count | Notes |
+|---|---|---|---|
+| Before any fixes | ~142 | ~46 | All new gate tests fail |
+| After subtask-01 | ~144 | ~44 | publishConfig tests pass |
+| After subtask-02 | ~146 | ~42 | prepublishOnly tests pass |
+| After subtask-03 | ~148 | ~40 | repository.directory tests pass |
+| After subtask-04 | ~150 | ~38 | bin removal + private tests pass |
+| After subtask-05 | ~151 | ~37 | engines.bun test passes |
+| After subtask-06 | ~154 | ~34 | 3 version-sync tests pass |
+| After subtask-07 | ~157 | ~31 | 3 warn() stderr tests pass |
+| After subtask-09 | ~158 | ~30 | registry.json guard test passes |
+| After subtask-10 | ~161 | ~27 | writeManifest mkdir tests confirmed (regression guards) |
+| After subtask-12 | ~174 | ~14 | 13 update-check tests pass |
+| After subtask-13 | ~188 | 0 | All 14 clean tests pass |
+
+> Note: Subtask-11 (signal handlers) adds no new tests — it's validated by manual
+> terminal testing (Ctrl-C restores cursor). The signal handler tests would require
+> spawning a subprocess, which is out of scope for this unit test suite.
+
+---
+
+## Test File Locations
+
+| File | Type | Tests Added |
+|---|---|---|
+| `packages/cli/src/lib/bundled.test.ts` | Modified (added) | 5 new tests |
+| `packages/cli/src/lib/manifest.test.ts` | Modified (added) | 4 new tests |
+| `packages/cli/src/ui/logger.test.ts` | New file | 13 tests |
+| `packages/cli/src/lib/update-check.test.ts` | New file | 13 tests |
+| `packages/cli/src/commands/clean.test.ts` | New file | 14 tests |
+| `packages/cli/src/lib/package-json.test.ts` | New file | 22 tests |
+
+**Total new tests: 71** (46 failing gates + 25 passing regression guards)