Преглед изворни кода

fix(cli): npm package standards — publish config, engines, package root, update check

Batch A (subtasks 01, 04-07, 09-11):
- publishConfig.access: public in root + packages/cli package.json
- engines.bun: >=1.0.0 added to root package.json
- repository.directory set in both package.json files
- version synced to 0.7.1; version.ts reads root package.json
- scripts/sync-version.js: keeps packages/cli version in sync on bump
- warn() output moved to stderr (console.error) in logger.ts
- bin/oac.js: injects OAC_PACKAGE_ROOT, Windows bun.cmd support
- bundled.ts: removed !hasRegistryJson guard from findPackageRoot()
- manifest.ts: mkdir guard before writeManifest()
- index.ts: SIGINT/SIGTERM signal handlers

Batch B (subtasks 02, 03, 08, 12):
- .npmignore: anchored /dist/, removed packages/ blanket exclusion
- prepublishOnly: typecheck → build → dist existence check
- packages/cli package.json: removed bin field, added private: true
- update-check.ts: new module with fetchLatestNpmVersion, shouldShowUpdateNotice,
  checkForUpdate (24h cache, 3s timeout, stderr output, never throws)
- doctor.ts: refactored to import fetchLatestNpmVersion from update-check.ts
- index.ts: checkForUpdate() called after parseAsync; help() before update check

Test gates: 202 pass, 14 fail (14 remaining are clean command gates for Batch C)
darrenhinde пре 5 месеци
родитељ
комит
04e94e0bd0

+ 12 - 5
.npmignore

@@ -27,10 +27,11 @@ package-lock.json
 .opencode/tool/
 **/.opencode/tool/
 
-# Build and test artifacts
-dist/
-build/
-out/
+# 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
@@ -55,7 +56,13 @@ evals/
 dev/
 tasks/
 integrations/
-packages/
+# Exclude packages source/config but NOT the compiled CLI dist
+packages/cli/src/
+packages/cli/node_modules/
+packages/cli/tsconfig*.json
+packages/cli/bun.lock
+packages/compatibility-layer/
+packages/plugin-abilities/
 
 # Test and development scripts
 Makefile

+ 10 - 1
bin/oac.js

@@ -6,14 +6,23 @@ 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 exact name to resolve them
+const isWindows = process.platform === 'win32';
+const bunExecutable = isWindows ? 'bun.cmd' : 'bun';
+
 try {
-  execFileSync('bun', [cliDist, ...process.argv.slice(2)], { stdio: 'inherit' });
+  execFileSync(bunExecutable, [cliDist, ...process.argv.slice(2)], {
+    stdio: 'inherit',
+    env: { ...process.env, OAC_PACKAGE_ROOT: packageRoot },
+    shell: false,
+  });
 } catch (err) {
   if (err.code === 'ENOENT') {
     console.error('Error: Bun is required to run OAC CLI. Install from https://bun.sh');

+ 11 - 5
package.json

@@ -37,9 +37,11 @@
     "packages/cli/dist/"
   ],
   "engines": {
-    "node": ">=18.0.0"
+    "node": ">=18.0.0",
+    "bun": ">=1.0.0"
   },
   "scripts": {
+    "prepublishOnly": "npm run typecheck -w packages/cli && 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",
     "test:all": "cd evals/framework && npm run eval:sdk",
     "test:core": "npm run test:openagent:core",
@@ -72,9 +74,9 @@
     "results:latest": "cat evals/results/latest.json 2>/dev/null | jq '.agent, .passed, .failed' || echo 'No results yet'",
     "version": "node -p \"require('./package.json').version\"",
     "version:bump": "./scripts/versioning/bump-version.sh",
-    "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",
+    "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",
     "version:bump:alpha": "npm version prerelease --preid=alpha --no-git-tag-version && node -p \"require('./package.json').version\" > VERSION",
     "version:bump:beta": "npm version prerelease --preid=beta --no-git-tag-version && node -p \"require('./package.json').version\" > VERSION",
     "version:bump:rc": "npm version prerelease --preid=rc --no-git-tag-version && node -p \"require('./package.json').version\" > VERSION",
@@ -104,9 +106,13 @@
   ],
   "author": "Darren Hinde",
   "license": "MIT",
+  "publishConfig": {
+    "access": "public"
+  },
   "repository": {
     "type": "git",
-    "url": "https://github.com/darrenhinde/OpenAgentsControl.git"
+    "url": "https://github.com/darrenhinde/OpenAgentsControl.git",
+    "directory": "."
   },
   "bugs": {
     "url": "https://github.com/darrenhinde/OpenAgentsControl/issues"

+ 10 - 3
packages/cli/package.json

@@ -1,15 +1,17 @@
 {
   "name": "@nextsystems/oac-cli",
-  "version": "1.0.0",
+  "version": "0.7.1",
   "description": "OAC CLI — install, manage, and update AI agents and context files",
   "type": "module",
-  "bin": {
-    "oac": "./dist/index.js"
+  "private": true,
+  "publishConfig": {
+    "access": "public"
   },
   "main": "./dist/index.js",
   "types": "./dist/index.d.ts",
   "files": ["dist"],
   "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",
@@ -33,5 +35,10 @@
   },
   "engines": {
     "bun": ">=1.0.0"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/darrenhinde/OpenAgentsControl.git",
+    "directory": "packages/cli"
   }
 }

+ 322 - 0
packages/cli/src/commands/clean.test.ts

@@ -0,0 +1,322 @@
+/**
+ * Tests for clean.ts — verifies oac clean removes correct directories.
+ *
+ * These tests FAIL until subtask-13 creates packages/cli/src/commands/clean.ts.
+ * After subtask-13, all tests should pass.
+ *
+ * Design note: cleanCommand() uses process.cwd() internally to determine the
+ * project root. Tests use process.chdir() to point it at a temp directory,
+ * and restore the original cwd in afterAll/finally blocks.
+ *
+ * Note on TypeScript errors: tsconfig.json excludes *.test.ts from type checking.
+ * The "Cannot find module" errors are expected — they prove the module doesn't
+ * exist yet. Bun's test runner resolves modules at runtime.
+ */
+import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
+import { mkdtemp, rm, mkdir, writeFile, access } from 'node:fs/promises';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import type { Option } from 'commander';
+
+// ── Helper: load the module or throw a clear error ────────────────────────────
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+async function loadClean(): Promise<any> {
+  // Dynamic import — fails with "Cannot find module" until subtask-13 creates the file.
+  const modulePath = './clean.js';
+  return import(modulePath);
+}
+
+/** Returns true if the path exists (file or directory). */
+async function pathExists(p: string): Promise<boolean> {
+  try {
+    await access(p);
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+// ── Module existence (subtask-13 gate) ────────────────────────────────────────
+
+describe('clean module exports (subtask-13 gate)', () => {
+  // ❌ CURRENTLY FAILS: module does not exist yet.
+  // WILL PASS after subtask-13 creates clean.ts.
+  test('module exports cleanCommand function', async () => {
+    const mod = await loadClean();
+    expect(typeof mod.cleanCommand).toBe('function');
+  });
+
+  // ❌ CURRENTLY FAILS: module does not exist yet.
+  test('module exports registerCleanCommand function', async () => {
+    const mod = await loadClean();
+    expect(typeof mod.registerCleanCommand).toBe('function');
+  });
+});
+
+// ── cleanCommand() — core removal behaviour ───────────────────────────────────
+
+describe('cleanCommand() removal behaviour (subtask-13 gate)', () => {
+  let tmpDir: string;
+  let originalCwd: string;
+
+  beforeAll(async () => {
+    tmpDir = await mkdtemp(join(tmpdir(), 'oac-clean-test-'));
+    originalCwd = process.cwd();
+  });
+
+  afterAll(async () => {
+    // Always restore cwd before cleaning up
+    process.chdir(originalCwd);
+    await rm(tmpDir, { recursive: true, force: true });
+  });
+
+  // ❌ CURRENTLY FAILS: module does not exist yet.
+  // ✅ Positive: cleanCommand removes .oac/ directory
+  test('cleanCommand --force removes .oac/ directory', async () => {
+    // Arrange
+    const projectDir = join(tmpDir, 'test-remove-oac');
+    await mkdir(join(projectDir, '.oac'), { recursive: true });
+    await writeFile(join(projectDir, '.oac', 'manifest.json'), '{}');
+    process.chdir(projectDir);
+
+    const { cleanCommand } = await loadClean();
+
+    // Act — force mode skips confirmation prompt
+    await cleanCommand({ force: true, keepOpencode: false, dryRun: false, ide: false });
+
+    // Assert — .oac/ should be gone
+    expect(await pathExists(join(projectDir, '.oac'))).toBe(false);
+
+    // Cleanup
+    process.chdir(originalCwd);
+  });
+
+  // ❌ CURRENTLY FAILS: module does not exist yet.
+  // ✅ Positive: cleanCommand removes .opencode/ directory by default
+  test('cleanCommand --force removes .opencode/ directory by default', async () => {
+    // Arrange
+    const projectDir = join(tmpDir, 'test-remove-opencode');
+    await mkdir(join(projectDir, '.opencode', 'agent'), { recursive: true });
+    await writeFile(join(projectDir, '.opencode', 'agent', 'test.md'), '# test');
+    process.chdir(projectDir);
+
+    const { cleanCommand } = await loadClean();
+
+    // Act
+    await cleanCommand({ force: true, keepOpencode: false, dryRun: false, ide: false });
+
+    // Assert
+    expect(await pathExists(join(projectDir, '.opencode'))).toBe(false);
+
+    // Cleanup
+    process.chdir(originalCwd);
+  });
+
+  // ❌ CURRENTLY FAILS: module does not exist yet.
+  // ✅ Positive: cleanCommand removes both .oac/ and .opencode/ when both exist
+  test('cleanCommand --force removes both .oac/ and .opencode/ when both exist', async () => {
+    // Arrange
+    const projectDir = join(tmpDir, 'test-remove-both');
+    await mkdir(join(projectDir, '.oac'), { recursive: true });
+    await mkdir(join(projectDir, '.opencode', 'agent'), { recursive: true });
+    await writeFile(join(projectDir, '.oac', 'manifest.json'), '{}');
+    await writeFile(join(projectDir, '.opencode', 'agent', 'test.md'), '# test');
+    process.chdir(projectDir);
+
+    const { cleanCommand } = await loadClean();
+
+    // Act
+    await cleanCommand({ force: true, keepOpencode: false, dryRun: false, ide: false });
+
+    // Assert — both gone
+    expect(await pathExists(join(projectDir, '.oac'))).toBe(false);
+    expect(await pathExists(join(projectDir, '.opencode'))).toBe(false);
+
+    // Cleanup
+    process.chdir(originalCwd);
+  });
+
+  // ❌ CURRENTLY FAILS: module does not exist yet.
+  // ✅ Positive: --keep-opencode preserves .opencode/ while removing .oac/
+  test('cleanCommand --keep-opencode --force removes .oac/ but preserves .opencode/', async () => {
+    // Arrange
+    const projectDir = join(tmpDir, 'test-keep-opencode');
+    await mkdir(join(projectDir, '.oac'), { recursive: true });
+    await mkdir(join(projectDir, '.opencode', 'agent'), { recursive: true });
+    await writeFile(join(projectDir, '.oac', 'manifest.json'), '{}');
+    await writeFile(join(projectDir, '.opencode', 'agent', 'test.md'), '# test');
+    process.chdir(projectDir);
+
+    const { cleanCommand } = await loadClean();
+
+    // Act — keepOpencode: true
+    await cleanCommand({ force: true, keepOpencode: true, dryRun: false, ide: false });
+
+    // Assert — .oac/ gone, .opencode/ preserved
+    expect(await pathExists(join(projectDir, '.oac'))).toBe(false);
+    expect(await pathExists(join(projectDir, '.opencode'))).toBe(true);
+    expect(await pathExists(join(projectDir, '.opencode', 'agent', 'test.md'))).toBe(true);
+
+    // Cleanup
+    process.chdir(originalCwd);
+  });
+
+  // ❌ CURRENTLY FAILS: module does not exist yet.
+  // ❌ Negative: --dry-run does NOT remove anything
+  test('cleanCommand --dry-run does not remove any directories', async () => {
+    // Arrange
+    const projectDir = join(tmpDir, 'test-dryrun');
+    await mkdir(join(projectDir, '.oac'), { recursive: true });
+    await mkdir(join(projectDir, '.opencode'), { recursive: true });
+    await writeFile(join(projectDir, '.oac', 'manifest.json'), '{}');
+    process.chdir(projectDir);
+
+    const { cleanCommand } = await loadClean();
+
+    // Act — dry-run: nothing should be removed
+    await cleanCommand({ force: true, keepOpencode: false, dryRun: true, ide: false });
+
+    // Assert — both directories still exist
+    expect(await pathExists(join(projectDir, '.oac'))).toBe(true);
+    expect(await pathExists(join(projectDir, '.opencode'))).toBe(true);
+
+    // Cleanup
+    process.chdir(originalCwd);
+  });
+
+  // ❌ CURRENTLY FAILS: module does not exist yet.
+  // ❌ Negative: cleanCommand does not throw when neither .oac/ nor .opencode/ exists
+  test('cleanCommand does not throw when nothing to clean', async () => {
+    // Arrange — empty project directory
+    const projectDir = join(tmpDir, 'test-nothing-to-clean');
+    await mkdir(projectDir, { recursive: true });
+    process.chdir(projectDir);
+
+    const { cleanCommand } = await loadClean();
+
+    // Act & Assert — must not throw
+    await expect(
+      cleanCommand({ force: true, keepOpencode: false, dryRun: false, ide: false })
+    ).resolves.toBeUndefined();
+
+    // Cleanup
+    process.chdir(originalCwd);
+  });
+
+  // ❌ CURRENTLY FAILS: module does not exist yet.
+  // ✅ Positive: --ide flag removes CLAUDE.md when present
+  test('cleanCommand --ide --force removes CLAUDE.md when present', async () => {
+    // Arrange
+    const projectDir = join(tmpDir, 'test-ide-files');
+    await mkdir(join(projectDir, '.oac'), { recursive: true });
+    await writeFile(join(projectDir, '.oac', 'manifest.json'), '{}');
+    await writeFile(join(projectDir, 'CLAUDE.md'), '# Claude instructions');
+    process.chdir(projectDir);
+
+    const { cleanCommand } = await loadClean();
+
+    // Act
+    await cleanCommand({ force: true, keepOpencode: false, dryRun: false, ide: true });
+
+    // Assert — CLAUDE.md removed
+    expect(await pathExists(join(projectDir, 'CLAUDE.md'))).toBe(false);
+
+    // Cleanup
+    process.chdir(originalCwd);
+  });
+
+  // ❌ CURRENTLY FAILS: module does not exist yet.
+  // ❌ Negative: without --ide flag, CLAUDE.md is preserved
+  test('cleanCommand without --ide preserves CLAUDE.md', async () => {
+    // Arrange
+    const projectDir = join(tmpDir, 'test-no-ide-flag');
+    await mkdir(join(projectDir, '.oac'), { recursive: true });
+    await writeFile(join(projectDir, '.oac', 'manifest.json'), '{}');
+    await writeFile(join(projectDir, 'CLAUDE.md'), '# Claude instructions');
+    process.chdir(projectDir);
+
+    const { cleanCommand } = await loadClean();
+
+    // Act — ide: false (default)
+    await cleanCommand({ force: true, keepOpencode: false, dryRun: false, ide: false });
+
+    // Assert — CLAUDE.md preserved
+    expect(await pathExists(join(projectDir, 'CLAUDE.md'))).toBe(true);
+
+    // Cleanup
+    process.chdir(originalCwd);
+  });
+});
+
+// ── registerCleanCommand() — Commander integration ────────────────────────────
+
+describe('registerCleanCommand() Commander integration (subtask-13 gate)', () => {
+  // ❌ CURRENTLY FAILS: module does not exist yet.
+  // ✅ Positive: registerCleanCommand registers 'clean' on a Commander program
+  test('registerCleanCommand registers a "clean" command on the program', async () => {
+    // Arrange
+    const { Command } = await import('commander');
+    const { registerCleanCommand } = await loadClean();
+    const program = new Command();
+
+    // Act
+    registerCleanCommand(program);
+
+    // Assert — 'clean' command is now registered
+    const commands = program.commands.map((c: { name: () => string }) => c.name());
+    expect(commands).toContain('clean');
+  });
+
+  // ❌ CURRENTLY FAILS: module does not exist yet.
+  // ✅ Positive: clean command has --force option
+  test('clean command has --force option', async () => {
+    // Arrange
+    const { Command } = await import('commander');
+    const { registerCleanCommand } = await loadClean();
+    const program = new Command();
+    registerCleanCommand(program);
+
+    // Act
+    const cleanCmd = program.commands.find((c: { name: () => string }) => c.name() === 'clean');
+
+    // Assert
+    expect(cleanCmd).toBeDefined();
+    const optionNames = cleanCmd!.options.map((o: Option) => o.long ?? '');
+    expect(optionNames).toContain('--force');
+  });
+
+  // ❌ CURRENTLY FAILS: module does not exist yet.
+  // ✅ Positive: clean command has --dry-run option
+  test('clean command has --dry-run option', async () => {
+    // Arrange
+    const { Command } = await import('commander');
+    const { registerCleanCommand } = await loadClean();
+    const program = new Command();
+    registerCleanCommand(program);
+
+    // Act
+    const cleanCmd = program.commands.find((c: { name: () => string }) => c.name() === 'clean');
+    const optionNames = cleanCmd!.options.map((o: Option) => o.long ?? '');
+
+    // Assert
+    expect(optionNames).toContain('--dry-run');
+  });
+
+  // ❌ CURRENTLY FAILS: module does not exist yet.
+  // ✅ Positive: clean command has --keep-opencode option
+  test('clean command has --keep-opencode option', async () => {
+    // Arrange
+    const { Command } = await import('commander');
+    const { registerCleanCommand } = await loadClean();
+    const program = new Command();
+    registerCleanCommand(program);
+
+    // Act
+    const cleanCmd = program.commands.find((c: { name: () => string }) => c.name() === 'clean');
+    const optionNames = cleanCmd!.options.map((o: Option) => o.long ?? '');
+
+    // Assert
+    expect(optionNames).toContain('--keep-opencode');
+  });
+});

+ 1 - 16
packages/cli/src/commands/doctor.ts

@@ -3,6 +3,7 @@ import { type Command } from 'commander';
 import semver from 'semver';
 
 import { readCliVersion } from '../lib/version.js';
+import { fetchLatestNpmVersion } from '../lib/update-check.js';
 import { readManifest } from '../lib/manifest.js';
 import { readConfig } from '../lib/config.js';
 import { computeFileHash, hashesMatch } from '../lib/sha256.js';
@@ -31,22 +32,6 @@ type DoctorSummary = {
   errors: number;
 };
 
-// ── Version helpers ───────────────────────────────────────────────────────────
-
-/** Fetches the latest version from the npm registry. Returns null if offline. */
-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 {
-    // Network unavailable or timeout — non-blocking
-    return null;
-  }
-};
-
 // ── Individual check functions ────────────────────────────────────────────────
 
 /** Check 1: OAC version vs npm registry (non-blocking, skipped if offline). */

+ 16 - 4
packages/cli/src/index.ts

@@ -2,6 +2,7 @@
 
 import { Command } from 'commander'
 import { readCliVersion } from './lib/version.js'
+import { checkForUpdate } from './lib/update-check.js'
 
 const program = new Command()
 
@@ -10,6 +11,11 @@ program
   .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
+// Exit codes follow Unix convention: 128 + signal number
+process.on('SIGINT', () => process.exit(130))   // 128 + 2 (SIGINT)
+process.on('SIGTERM', () => process.exit(143))  // 128 + 15 (SIGTERM)
+
 // Lazy-load command modules in parallel — keeps startup < 100ms
 async function main(): Promise<void> {
   // Fast path: --version only — --help needs all commands registered first
@@ -55,12 +61,18 @@ async function main(): Promise<void> {
     process.exitCode = 1
   })
 
-  await program.parseAsync(process.argv)
-
-  // Print help when no command is given
+  // Print help when no command is given — must happen before update check
+  // so we don't fire a background fetch that gets abandoned on process.exit()
   if (args.length === 0) {
-    program.help()
+    program.help() // exits the process
   }
+
+  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
+  // Note: skipped on --version fast path (returns before reaching this line)
+  void checkForUpdate()
 }
 
 main().catch((err: unknown) => {

+ 127 - 0
packages/cli/src/lib/bundled.test.ts

@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
 import {
   classifyBundledFile,
   findPackageRoot,
+  getPackageRoot,
   getBundledFilePath,
   listBundledFiles,
   bundledFileExists,
@@ -219,6 +220,132 @@ describe('findPackageRoot', () => {
     // Either it threw (correct) or found a higher-level root (also acceptable)
     expect(true).toBe(true); // test passes either way — the key is it didn't return noOpencode
   });
+
+  // ✅ Positive (subtask-09): findPackageRoot finds a dir with .opencode/ + package.json
+  // even when registry.json is also present (the !hasRegistryJson guard is removed).
+  // ✅ This test now passes — the !hasRegistryJson guard was removed in Batch A.
+  test('finds package root even when registry.json is present (subtask-09 gate)', async () => {
+    // Arrange — create a dir that has .opencode/, package.json, AND registry.json
+    // This simulates the published npm package layout where registry.json is included
+    const pkgWithRegistry = join(tmpDir, 'pkg-with-registry');
+    await mkdir(join(pkgWithRegistry, '.opencode'), { recursive: true });
+    await writeFile(join(pkgWithRegistry, 'package.json'), '{}', 'utf8');
+    await writeFile(join(pkgWithRegistry, 'registry.json'), '{}', 'utf8');
+    // Start the walk from a child subdirectory
+    const startDir = join(pkgWithRegistry, 'dist', 'lib');
+    await mkdir(startDir, { recursive: true });
+
+    // Act — ✅ This test now passes — the !hasRegistryJson guard was removed in Batch A,
+    // so findPackageRoot returns pkgWithRegistry directly.
+    const result = findPackageRoot(startDir);
+
+    // Assert
+    expect(result).toBe(pkgWithRegistry);
+  });
+
+  // ✅ Positive (subtask-09): findPackageRoot skips a dir that has registry.json
+  // when a parent dir without registry.json is the correct match.
+  // This test validates the CURRENT behaviour (before subtask-09) — it should
+  // PASS now and CONTINUE to pass after the fix (the fix changes which dir is
+  // returned, but this test uses a layout where the correct root has no registry.json).
+  test('returns the nearest ancestor with .opencode/ and package.json', async () => {
+    // Arrange — child dir has .opencode/ + package.json (no registry.json)
+    const correctRoot = join(tmpDir, 'correct-root-no-registry');
+    await mkdir(join(correctRoot, '.opencode'), { recursive: true });
+    await writeFile(join(correctRoot, 'package.json'), '{}', 'utf8');
+    const startDir = join(correctRoot, 'src', 'lib');
+    await mkdir(startDir, { recursive: true });
+
+    // Act
+    const result = findPackageRoot(startDir);
+
+    // Assert — should find correctRoot (no registry.json, so both old and new code agree)
+    expect(result).toBe(correctRoot);
+  });
+});
+
+// ── getPackageRoot ────────────────────────────────────────────────────────────
+
+describe('getPackageRoot', () => {
+  // ✅ Positive: OAC_PACKAGE_ROOT env var overrides the walk entirely
+  // This test PASSES now (the env var check already exists in bundled.ts lines 32-35).
+  // It acts as a regression guard — subtask-09 must not break this behaviour.
+  test('returns OAC_PACKAGE_ROOT env var value without walking the filesystem', () => {
+    // Arrange
+    const savedEnv = process.env['OAC_PACKAGE_ROOT'];
+    process.env['OAC_PACKAGE_ROOT'] = '/some/fake/injected/path';
+
+    try {
+      // Act
+      const result = getPackageRoot();
+
+      // Assert — must return the env var value, not walk the filesystem
+      expect(result).toBe('/some/fake/injected/path');
+    } finally {
+      // Cleanup — restore original env state
+      if (savedEnv !== undefined) {
+        process.env['OAC_PACKAGE_ROOT'] = savedEnv;
+      } else {
+        delete process.env['OAC_PACKAGE_ROOT'];
+      }
+    }
+  });
+
+  // ✅ Positive: OAC_PACKAGE_ROOT bypasses the walk even when the path has no .opencode/
+  // This is the critical production test: bin/oac.js injects OAC_PACKAGE_ROOT so the
+  // walk never runs. Even if the walk would fail (no valid package root in the tree),
+  // OAC_PACKAGE_ROOT makes getPackageRoot() succeed.
+  // CURRENTLY PASSES (env var check exists). Guards against regression in subtask-09.
+  test('OAC_PACKAGE_ROOT bypasses walk even for a path with no .opencode/ (production scenario)', async () => {
+    // Arrange — a temp dir with NO .opencode/ (walk would throw if it ran)
+    const bareDir = await mkdtemp(join(tmpdir(), 'oac-bare-dir-'));
+    const savedEnv = process.env['OAC_PACKAGE_ROOT'];
+    process.env['OAC_PACKAGE_ROOT'] = bareDir;
+
+    try {
+      // Act — should NOT throw even though bareDir has no .opencode/
+      const result = getPackageRoot();
+
+      // Assert
+      expect(result).toBe(bareDir);
+    } finally {
+      // Cleanup
+      if (savedEnv !== undefined) {
+        process.env['OAC_PACKAGE_ROOT'] = savedEnv;
+      } else {
+        delete process.env['OAC_PACKAGE_ROOT'];
+      }
+      await rm(bareDir, { recursive: true, force: true });
+    }
+  });
+
+  // ❌ Negative: when OAC_PACKAGE_ROOT is empty string, falls through to walk
+  // (empty string is falsy in JS — the env var check uses `if (envOverride)`)
+  test('falls through to walk when OAC_PACKAGE_ROOT is empty string', () => {
+    // Arrange
+    const savedEnv = process.env['OAC_PACKAGE_ROOT'];
+    process.env['OAC_PACKAGE_ROOT'] = '';
+
+    try {
+      // Act & Assert — empty string is falsy, so the walk runs and throws from '/'
+      // (We can't easily test the walk succeeding here without a valid package root
+      // in the test tree, so we verify the env var is ignored by checking it throws
+      // the walk error rather than returning '')
+      // The walk from import.meta.dir will find the monorepo root in dev, so we
+      // can't assert it throws. Instead, assert it does NOT return empty string.
+      const result = getPackageRoot();
+      expect(result).not.toBe('');
+    } catch {
+      // Walk threw — that's also acceptable (proves empty string was ignored)
+      expect(true).toBe(true);
+    } finally {
+      if (savedEnv !== undefined) {
+        process.env['OAC_PACKAGE_ROOT'] = savedEnv;
+      } else {
+        delete process.env['OAC_PACKAGE_ROOT'];
+      }
+    }
+  });
 });
 
 // ── listBundledFiles ──────────────────────────────────────────────────────────

+ 14 - 16
packages/cli/src/lib/bundled.ts

@@ -19,15 +19,15 @@ const BUNDLED_SUBDIRS = [
 // --- Package root resolution ---
 
 /**
- * Walks up the directory tree from `startDir` until it finds a directory
- * that contains both `.opencode/` and `package.json` — the npm package root.
+ * Returns the OAC package root directory.
  *
- * Works in both development (monorepo) and when installed via npm.
- * import.meta.dir is Bun's native equivalent of __dirname.
+ * In production (npm global install), bin/oac.js injects OAC_PACKAGE_ROOT
+ * before invoking the Bun binary, so this function returns immediately.
+ * In dev/test environments where OAC_PACKAGE_ROOT is not set, falls back
+ * to walking up the directory tree from import.meta.dir.
  */
 export function getPackageRoot(): string {
-  // Allow dev/monorepo override via environment variable.
-  // In production (npm install), OAC_PACKAGE_ROOT is not set so the walk runs as before.
+  // In production, bin/oac.js always injects OAC_PACKAGE_ROOT.
   // In dev, set OAC_PACKAGE_ROOT=/path/to/repo to bypass the walk entirely.
   const envOverride = process.env['OAC_PACKAGE_ROOT'];
   if (envOverride) {
@@ -39,12 +39,14 @@ export function getPackageRoot(): string {
 
 /**
  * Synchronously walks up from `dir` until finding a directory that has
- * all three anchors:
+ * both 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.
+ *
+ * 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.
  *
@@ -56,12 +58,8 @@ export function findPackageRoot(dir: string): string {
   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) {
+    if (hasOpencode && hasPackageJson) {
       return current;
     }
 
@@ -70,7 +68,7 @@ export function findPackageRoot(dir: string): string {
     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}". ` +
+          `walking up from "${dir}". ` +
           `Is @nextsystems/oac installed correctly? ` +
           `In dev/monorepo mode, set OAC_PACKAGE_ROOT env var to the repo root.`,
       );

+ 69 - 0
packages/cli/src/lib/manifest.test.ts

@@ -206,4 +206,73 @@ describe('readManifest / writeManifest', () => {
       await rm(dir, { recursive: true, force: true });
     }
   });
+
+  // ✅ Positive (subtask-10 gate): writeManifest creates .oac/ directory if it does not exist.
+  //
+  // Context: Bun.write() with a string path currently auto-creates parent directories
+  // as undocumented behavior. The subtask-10 fix adds an explicit mkdir() call to make
+  // this behavior documented and reliable across Bun versions.
+  //
+  // This test CURRENTLY PASSES (Bun.write auto-creates dirs in current Bun version).
+  // It acts as a REGRESSION GUARD — after subtask-10 adds explicit mkdir, the test
+  // must continue to pass. If Bun ever removes the auto-create behavior, the explicit
+  // mkdir in subtask-10 ensures this test still passes.
+  //
+  // The test is labeled "subtask-10 gate" because it validates the acceptance criteria
+  // of subtask-10 (writeManifest must work on a fresh directory with no .oac/).
+  test('writeManifest creates .oac/ directory if it does not exist (subtask-10 gate)', async () => {
+    // Arrange — a completely fresh directory with NO .oac/ subdirectory
+    const freshDir = await mkdtemp(join(tmpdir(), 'oac-manifest-mkdir-'));
+    try {
+      // Verify .oac/ does NOT exist before the call
+      const oacDir = join(freshDir, '.oac');
+      expect(await Bun.file(join(oacDir, 'manifest.json')).exists()).toBe(false);
+
+      // Act — must succeed whether or not Bun.write auto-creates dirs
+      // After subtask-10: explicit mkdir guarantees this works on all Bun versions
+      await writeManifest(freshDir, createEmptyManifest('1.0.0'));
+
+      // Assert — .oac/manifest.json must now exist
+      expect(await Bun.file(join(oacDir, 'manifest.json')).exists()).toBe(true);
+    } finally {
+      await rm(freshDir, { recursive: true, force: true });
+    }
+  });
+
+  // ✅ Positive (subtask-10 gate): writeManifest is idempotent — calling twice does not throw.
+  // CURRENTLY PASSES. Regression guard for subtask-10.
+  test('writeManifest is idempotent — calling twice with different manifests does not throw (subtask-10 gate)', async () => {
+    // Arrange — fresh directory, no .oac/
+    const freshDir = await mkdtemp(join(tmpdir(), 'oac-manifest-idempotent-'));
+    try {
+      const first = createEmptyManifest('1.0.0');
+      const second = createEmptyManifest('2.0.0');
+
+      // Act — both calls must succeed without throwing
+      await writeManifest(freshDir, first);
+      await writeManifest(freshDir, second);
+
+      // Assert — the second write's data is what readManifest returns
+      const read = await readManifest(freshDir);
+      expect(read?.oacVersion).toBe('2.0.0');
+    } finally {
+      await rm(freshDir, { recursive: true, force: true });
+    }
+  });
+
+  // ❌ Negative (regression guard): writeManifest on an existing .oac/ dir does not throw.
+  // This test CURRENTLY PASSES (the existing round-trip test already covers this).
+  // It acts as a regression guard — subtask-10 must not break the happy path.
+  test('writeManifest does not throw when .oac/ already exists (regression guard)', async () => {
+    // Arrange — use the shared tmpDir which already has .oac/ from the round-trip test
+    const existingDir = await mkdtemp(join(tmpdir(), 'oac-manifest-existing-'));
+    try {
+      // First write creates .oac/
+      await writeManifest(existingDir, createEmptyManifest('1.0.0'));
+      // Second write — .oac/ already exists, must not throw
+      await expect(writeManifest(existingDir, createEmptyManifest('1.1.0'))).resolves.toBeUndefined();
+    } finally {
+      await rm(existingDir, { recursive: true, force: true });
+    }
+  });
 });

+ 2 - 0
packages/cli/src/lib/manifest.ts

@@ -1,4 +1,5 @@
 import path from 'node:path';
+import { mkdir } from 'node:fs/promises';
 import { z } from 'zod';
 
 // ── Errors ────────────────────────────────────────────────────────────────────
@@ -174,5 +175,6 @@ export const writeManifest = async (
   manifest: ManifestFile,
 ): Promise<void> => {
   const manifestPath = getManifestPath(projectRoot);
+  await mkdir(path.dirname(manifestPath), { recursive: true });
   await Bun.write(manifestPath, JSON.stringify(manifest, null, 2));
 };

+ 206 - 0
packages/cli/src/lib/package-json.test.ts

@@ -0,0 +1,206 @@
+/**
+ * Structural tests for package.json correctness.
+ *
+ * These tests validate that package metadata follows npm best practices.
+ * Several CURRENTLY FAIL — they will pass after the fix subtasks are applied.
+ *
+ * Each test is annotated with:
+ *   - The subtask that fixes it (e.g. "subtask-01")
+ *   - Whether it CURRENTLY FAILS or CURRENTLY PASSES
+ *
+ * Using Bun.file().json() for JSON loading (more reliable in Bun than
+ * import assertions, and avoids module caching issues between test runs).
+ */
+import { describe, test, expect } from 'bun:test';
+import { join } from 'node:path';
+
+// ── Load both package.json files ──────────────────────────────────────────────
+
+// Paths relative to this test file: packages/cli/src/lib/package-json.test.ts
+// Root package.json: ../../../../package.json (4 levels up)
+// CLI package.json:  ../../package.json (2 levels up)
+
+const rootPkgPath = join(import.meta.dir, '../../../../package.json');
+const cliPkgPath = join(import.meta.dir, '../../package.json');
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const rootPkg: any = await Bun.file(rootPkgPath).json();
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const cliPkg: any = await Bun.file(cliPkgPath).json();
+
+// ── Root package.json ─────────────────────────────────────────────────────────
+
+describe('root package.json structural requirements', () => {
+  // ❌ CURRENTLY FAILS: root package.json has no publishConfig field.
+  // WILL PASS after subtask-01 adds publishConfig.access = "public".
+  test('has publishConfig.access set to "public" (subtask-01 gate)', () => {
+    // Arrange — rootPkg loaded above
+    // Act & Assert
+    expect(rootPkg.publishConfig?.access).toBe('public');
+  });
+
+  // ❌ CURRENTLY FAILS: root package.json has engines.node = ">=18.0.0" but
+  // the CLI requires Bun, not Node.js. After subtask-05, engines should have
+  // a bun field instead of (or in addition to) node.
+  // WILL PASS after subtask-05 fixes the engines field.
+  test('engines field has bun requirement (not just node) (subtask-05 gate)', () => {
+    // Arrange
+    const engines = rootPkg.engines ?? {};
+
+    // Assert — must have a bun engine requirement
+    expect('bun' in engines).toBe(true);
+  });
+
+  // ❌ CURRENTLY FAILS: root package.json has no repository.directory field.
+  // WILL PASS after subtask-03 adds repository.directory.
+  test('has repository.directory field (subtask-03 gate)', () => {
+    expect(rootPkg.repository?.directory).toBeDefined();
+  });
+
+  // ❌ CURRENTLY FAILS: root package.json has no prepublishOnly script.
+  // WILL PASS after subtask-02 adds prepublishOnly.
+  test('has prepublishOnly script (subtask-02 gate)', () => {
+    expect(rootPkg.scripts?.prepublishOnly).toBeDefined();
+  });
+
+  // ❌ CURRENTLY FAILS: root is "0.7.1", cli is "1.0.0" — they don't match.
+  // WILL PASS after subtask-06 syncs packages/cli version to root version.
+  test('version matches packages/cli version (subtask-06 gate)', () => {
+    // Both package.json files must have the same version string
+    expect(rootPkg.version).toBe(cliPkg.version);
+  });
+
+  // ✅ CURRENTLY PASSES: root has a bin field pointing to bin/oac.js.
+  // Regression guard — must not be removed by any subtask.
+  test('has bin.oac pointing to ./bin/oac.js (regression guard)', () => {
+    expect(rootPkg.bin?.oac).toBe('./bin/oac.js');
+  });
+
+  // ✅ CURRENTLY PASSES: root has a name field.
+  // Regression guard.
+  test('name is "@nextsystems/oac" (regression guard)', () => {
+    expect(rootPkg.name).toBe('@nextsystems/oac');
+  });
+
+  // ✅ CURRENTLY PASSES: root has a license field.
+  // Regression guard.
+  test('has a license field (regression guard)', () => {
+    expect(rootPkg.license).toBeDefined();
+    expect(typeof rootPkg.license).toBe('string');
+  });
+
+  // ✅ CURRENTLY PASSES: root has a repository field.
+  // Regression guard.
+  test('has a repository field with type "git" (regression guard)', () => {
+    expect(rootPkg.repository?.type).toBe('git');
+  });
+
+  // ✅ CURRENTLY PASSES: root has a files array.
+  // Regression guard — the files array must include bin/ and .opencode/.
+  test('files array includes "bin/" (regression guard)', () => {
+    expect(Array.isArray(rootPkg.files)).toBe(true);
+    expect(rootPkg.files).toContain('bin/');
+  });
+});
+
+// ── packages/cli/package.json ─────────────────────────────────────────────────
+
+describe('packages/cli/package.json structural requirements', () => {
+  // ❌ CURRENTLY FAILS: packages/cli has no publishConfig field.
+  // WILL PASS after subtask-01 adds publishConfig.access = "public".
+  test('has publishConfig.access set to "public" (subtask-01 gate)', () => {
+    expect(cliPkg.publishConfig?.access).toBe('public');
+  });
+
+  // ❌ CURRENTLY FAILS: packages/cli has a bin field { oac: "./dist/index.js" }.
+  // The sub-package should not be directly installable as a CLI tool —
+  // the root package owns the bin entry point.
+  // WILL PASS after subtask-04 removes the bin field from packages/cli.
+  test('does NOT have a bin field (subtask-04 gate)', () => {
+    expect(cliPkg.bin).toBeUndefined();
+  });
+
+  // ❌ CURRENTLY FAILS: packages/cli has no repository.directory field.
+  // WILL PASS after subtask-03 adds repository.directory = "packages/cli".
+  test('has repository.directory set to "packages/cli" (subtask-03 gate)', () => {
+    expect(cliPkg.repository?.directory).toBe('packages/cli');
+  });
+
+  // ❌ CURRENTLY FAILS: packages/cli has no prepublishOnly script.
+  // WILL PASS after subtask-02 adds prepublishOnly.
+  test('has prepublishOnly script (subtask-02 gate)', () => {
+    expect(cliPkg.scripts?.prepublishOnly).toBeDefined();
+  });
+
+  // ❌ CURRENTLY FAILS: packages/cli is not marked private.
+  // The sub-package should be private (not directly publishable to npm).
+  // WILL PASS after subtask-04 adds "private": true to packages/cli.
+  test('is marked private: true (subtask-04 gate)', () => {
+    expect(cliPkg.private).toBe(true);
+  });
+
+  // ❌ CURRENTLY FAILS: packages/cli version is "1.0.0", root is "0.7.1".
+  // WILL PASS after subtask-06 syncs the version.
+  test('version matches root package.json version (subtask-06 gate)', () => {
+    expect(cliPkg.version).toBe(rootPkg.version);
+  });
+
+  // ✅ CURRENTLY PASSES: packages/cli has engines.bun >= 1.0.0.
+  // Regression guard.
+  test('engines.bun is set (regression guard)', () => {
+    expect(cliPkg.engines?.bun).toBeDefined();
+  });
+
+  // ✅ CURRENTLY PASSES: packages/cli has a name field.
+  // Regression guard.
+  test('name is "@nextsystems/oac-cli" (regression guard)', () => {
+    expect(cliPkg.name).toBe('@nextsystems/oac-cli');
+  });
+
+  // ✅ CURRENTLY PASSES: packages/cli has a build script.
+  // Regression guard — build script must not be removed.
+  test('has a build script (regression guard)', () => {
+    expect(cliPkg.scripts?.build).toBeDefined();
+  });
+
+  // ✅ CURRENTLY PASSES: packages/cli has a test script.
+  // Regression guard.
+  test('has a test script (regression guard)', () => {
+    expect(cliPkg.scripts?.test).toBeDefined();
+  });
+
+  // ✅ CURRENTLY PASSES: packages/cli has required runtime dependencies.
+  // Regression guard — commander and chalk must remain.
+  test('has commander as a dependency (regression guard)', () => {
+    expect(cliPkg.dependencies?.commander).toBeDefined();
+  });
+
+  test('has chalk as a dependency (regression guard)', () => {
+    expect(cliPkg.dependencies?.chalk).toBeDefined();
+  });
+
+  test('has zod as a dependency (regression guard)', () => {
+    expect(cliPkg.dependencies?.zod).toBeDefined();
+  });
+});
+
+// ── Cross-package consistency ─────────────────────────────────────────────────
+
+describe('cross-package consistency', () => {
+  // ❌ CURRENTLY FAILS: versions are out of sync (0.7.1 vs 1.0.0).
+  // WILL PASS after subtask-06.
+  test('root and cli versions are identical (subtask-06 gate)', () => {
+    expect(rootPkg.version).toBe(cliPkg.version);
+  });
+
+  // ✅ CURRENTLY PASSES: both packages have the same license.
+  // Regression guard.
+  test('root and cli have the same license (regression guard)', () => {
+    // Both should be MIT (or whatever the root specifies)
+    if (rootPkg.license && cliPkg.license) {
+      expect(cliPkg.license).toBe(rootPkg.license);
+    }
+    // If cli doesn't have a license field yet, that's acceptable
+    expect(true).toBe(true);
+  });
+});

+ 187 - 0
packages/cli/src/lib/update-check.test.ts

@@ -0,0 +1,187 @@
+/**
+ * Tests for update-check.ts — verifies update notification logic.
+ *
+ * These tests FAIL until subtask-12 creates packages/cli/src/lib/update-check.ts.
+ * After subtask-12, all tests should pass.
+ *
+ * Design notes:
+ * - fetchLatestNpmVersion() makes real network calls in production.
+ *   Tests that call it use a known-stable package ('commander') and handle
+ *   null gracefully (network may be unavailable in CI).
+ * - shouldShowUpdateNotice() is a pure function — fully deterministic tests.
+ * - Module-existence tests fail immediately with "Cannot find module" until
+ *   the file is created.
+ *
+ * Note on TypeScript errors: tsconfig.json excludes *.test.ts from type checking
+ * (line 26: "exclude": [..., "**\/*.test.ts"]). The "Cannot find module" errors
+ * shown by the editor are expected — they prove the module doesn't exist yet.
+ * Bun's test runner resolves modules at runtime, so the tests run and fail with
+ * a clear "Cannot find module" error message.
+ */
+import { describe, test, expect } from 'bun:test';
+
+// ── Helper: load the module or throw a clear error ────────────────────────────
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+async function loadUpdateCheck(): Promise<any> {
+  // Dynamic import — fails with "Cannot find module" until subtask-12 creates the file.
+  // Using a string expression (not a literal) to prevent TypeScript from resolving
+  // the module at compile time and emitting a hard error.
+  const modulePath = './update-check.js';
+  return import(modulePath);
+}
+
+// ── Module existence (subtask-12 gate) ────────────────────────────────────────
+
+describe('update-check module exports (subtask-12 gate)', () => {
+  // ❌ CURRENTLY FAILS: module does not exist yet.
+  // WILL PASS after subtask-12 creates update-check.ts.
+  test('module exports fetchLatestNpmVersion function', async () => {
+    // Act — throws "Cannot find module" until update-check.ts is created
+    const mod = await loadUpdateCheck();
+
+    // Assert
+    expect(typeof mod.fetchLatestNpmVersion).toBe('function');
+  });
+
+  // ❌ CURRENTLY FAILS: module does not exist yet.
+  test('module exports checkForUpdate function', async () => {
+    const mod = await loadUpdateCheck();
+    expect(typeof mod.checkForUpdate).toBe('function');
+  });
+
+  // ❌ CURRENTLY FAILS: module does not exist yet.
+  // shouldShowUpdateNotice is a pure helper — exported for testability.
+  test('module exports shouldShowUpdateNotice function', async () => {
+    const mod = await loadUpdateCheck();
+    expect(typeof mod.shouldShowUpdateNotice).toBe('function');
+  });
+});
+
+// ── shouldShowUpdateNotice() — pure function, fully deterministic ─────────────
+
+describe('shouldShowUpdateNotice() (subtask-12 gate)', () => {
+  // ❌ CURRENTLY FAILS: module does not exist yet.
+  // WILL PASS after subtask-12.
+
+  // ✅ Positive: returns true when latest version is strictly newer
+  test('returns true when latest is newer than current (patch bump)', async () => {
+    // Arrange
+    const { shouldShowUpdateNotice } = await loadUpdateCheck();
+
+    // Act
+    const result = shouldShowUpdateNotice('1.0.0', '1.0.1');
+
+    // Assert
+    expect(result).toBe(true);
+  });
+
+  // ✅ Positive: returns true when latest is newer (minor bump)
+  test('returns true when latest is newer than current (minor bump)', async () => {
+    const { shouldShowUpdateNotice } = await loadUpdateCheck();
+    expect(shouldShowUpdateNotice('1.0.0', '1.1.0')).toBe(true);
+  });
+
+  // ✅ Positive: returns true when latest is newer (major bump)
+  test('returns true when latest is newer than current (major bump)', async () => {
+    const { shouldShowUpdateNotice } = await loadUpdateCheck();
+    expect(shouldShowUpdateNotice('1.0.0', '2.0.0')).toBe(true);
+  });
+
+  // ❌ Negative: returns false when versions are identical
+  test('returns false when current equals latest', async () => {
+    const { shouldShowUpdateNotice } = await loadUpdateCheck();
+    expect(shouldShowUpdateNotice('1.0.0', '1.0.0')).toBe(false);
+  });
+
+  // ❌ Negative: returns false when current is NEWER than latest (pre-release / dev build)
+  test('returns false when current is newer than latest (pre-release scenario)', async () => {
+    const { shouldShowUpdateNotice } = await loadUpdateCheck();
+    expect(shouldShowUpdateNotice('2.0.0', '1.9.9')).toBe(false);
+  });
+
+  // ❌ Negative: returns false when latest is null (offline / fetch failed)
+  test('returns false when latest is null (offline scenario)', async () => {
+    const { shouldShowUpdateNotice } = await loadUpdateCheck();
+    expect(shouldShowUpdateNotice('1.0.0', null)).toBe(false);
+  });
+});
+
+// ── fetchLatestNpmVersion() — network call, graceful null on failure ──────────
+
+describe('fetchLatestNpmVersion() (subtask-12 gate)', () => {
+  // ❌ CURRENTLY FAILS: module does not exist yet.
+  // WILL PASS after subtask-12.
+
+  // ✅ Positive: returns a semver string for a known package (or null if offline)
+  test('returns a semver string or null for a known npm package', async () => {
+    // Arrange
+    const { fetchLatestNpmVersion } = await loadUpdateCheck();
+
+    // Act — use 'commander' which is a stable, always-published package
+    const version = await fetchLatestNpmVersion('commander');
+
+    // Assert — either a valid semver string or null (if network unavailable in CI)
+    if (version !== null) {
+      expect(version).toMatch(/^\d+\.\d+\.\d+/);
+    } else {
+      // null is acceptable — network may be unavailable
+      expect(version).toBeNull();
+    }
+  });
+
+  // ❌ Negative: returns null for a non-existent package (404 from registry)
+  test('returns null for a package that does not exist on npm', async () => {
+    // Arrange
+    const { fetchLatestNpmVersion } = await loadUpdateCheck();
+
+    // Act — this package definitely does not exist
+    const version = await fetchLatestNpmVersion('@nextsystems/this-package-does-not-exist-xyz-abc-123');
+
+    // Assert — must return null, not throw
+    expect(version).toBeNull();
+  });
+
+  // ❌ Negative: returns null (does not throw) when fetch fails
+  test('returns null (does not throw) when fetch fails for invalid package', async () => {
+    // Arrange
+    const { fetchLatestNpmVersion } = await loadUpdateCheck();
+
+    // Act — use a clearly invalid package name that will 404
+    let result: string | null;
+    let threw = false;
+    try {
+      result = await fetchLatestNpmVersion('@invalid-scope-xyz/no-such-package-ever');
+    } catch {
+      threw = true;
+      result = null;
+    }
+
+    // Assert — must return null, never throw
+    expect(threw).toBe(false);
+    expect(result).toBeNull();
+  });
+});
+
+// ── checkForUpdate() — integration, non-blocking ─────────────────────────────
+
+describe('checkForUpdate() (subtask-12 gate)', () => {
+  // ❌ CURRENTLY FAILS: module does not exist yet.
+  // WILL PASS after subtask-12.
+
+  // ✅ Positive: checkForUpdate() resolves without throwing
+  test('checkForUpdate() resolves without throwing (non-blocking contract)', async () => {
+    // Arrange
+    const { checkForUpdate } = await loadUpdateCheck();
+
+    // Act & Assert — must never throw, even if network is unavailable
+    await expect(checkForUpdate()).resolves.toBeUndefined();
+  });
+
+  // ✅ Positive: checkForUpdate() returns void (undefined), not a value
+  test('checkForUpdate() returns undefined (void)', async () => {
+    const { checkForUpdate } = await loadUpdateCheck();
+    const result = await checkForUpdate();
+    expect(result).toBeUndefined();
+  });
+});

+ 111 - 0
packages/cli/src/lib/update-check.ts

@@ -0,0 +1,111 @@
+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. Failure is non-fatal. */
+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 of a package from the npm registry.
+ * Returns null on any error (network unavailable, timeout, 404, parse error).
+ * Uses a 3-second timeout so it never blocks the CLI.
+ *
+ * Exported as a named export so doctor.ts can import it instead of duplicating it.
+ */
+export async function fetchLatestNpmVersion(packageName: string): Promise<string | null> {
+  try {
+    const url = `https://registry.npmjs.org/${packageName}/latest`
+    const res = await fetch(url, { signal: AbortSignal.timeout(3000) })
+    if (!res.ok) return null
+    const data = (await res.json()) as { version?: string }
+    return data.version ?? null
+  } catch {
+    // Network unavailable, timeout, or parse error — always return null, never throw
+    return null
+  }
+}
+
+/**
+ * Pure function: returns true if latestVersion is semver-greater than currentVersion.
+ * Returns false if latestVersion is null (offline / fetch failed).
+ *
+ * Exported for testability — deterministic, no side effects.
+ */
+export function shouldShowUpdateNotice(
+  currentVersion: string,
+  latestVersion: string | null,
+): boolean {
+  if (latestVersion === null) return false
+  // semver.lt returns false for invalid versions — safe to call without validation
+  return semver.lt(currentVersion, latestVersion)
+}
+
+/**
+ * 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 simple notice to stderr if an update is available.
+ *
+ * Intentionally skipped on --version fast path (index.ts returns before reaching this).
+ * Never throws — all errors are swallowed to protect the CLI exit code.
+ */
+export async function checkForUpdate(): Promise<void> {
+  try {
+    // Try cache first to avoid hitting the registry on every command
+    const cached = await readCache()
+    let latestVersion: string | null
+
+    if (cached !== null) {
+      latestVersion = cached.latestVersion
+    } else {
+      // Cache miss or stale — fetch from registry and persist result
+      latestVersion = await fetchLatestNpmVersion(PACKAGE_NAME)
+      await writeCache(latestVersion)
+    }
+
+    const current = readCliVersion()
+    if (!shouldShowUpdateNotice(current, latestVersion)) return
+
+    // Print notice to stderr — does not pollute piped stdout
+    // latestVersion is guaranteed non-null here: shouldShowUpdateNotice returns false when null
+    if (latestVersion === null) return
+    process.stderr.write(`\n  Update available: ${current} → ${latestVersion}\n`)
+    process.stderr.write(`  Run: npm install -g @nextsystems/oac\n\n`)
+  } catch {
+    // Update check failure is always non-fatal — never affect exit code
+  }
+}

+ 2 - 2
packages/cli/src/lib/version.ts

@@ -1,6 +1,6 @@
-import pkgJson from '../../package.json' with { type: 'json' }
+import pkgJson from '../../../../package.json' with { type: 'json' }
 
-/** Returns the CLI version from package.json. Synchronous — no I/O. */
+/** Returns the CLI version from the root @nextsystems/oac package.json. Synchronous — no I/O. */
 export function readCliVersion(): string {
   return pkgJson.version ?? '0.0.0'
 }

+ 221 - 0
packages/cli/src/ui/logger.test.ts

@@ -0,0 +1,221 @@
+/**
+ * Tests for logger.ts — verifies each function writes to the correct stream.
+ *
+ * Unix convention: diagnostic messages (warn, error) → stderr
+ *                  status/progress messages (log, info, success, dim, bold) → stdout
+ *
+ * Subtask-07 gate: warn() currently uses console.log (stdout).
+ * After subtask-07 it must use console.error (stderr).
+ *
+ * Pattern: capture console.log and console.error calls via spy wrappers,
+ * restore originals in finally blocks to avoid test pollution.
+ */
+import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
+import { log, info, warn, error, success, dim, bold, verbose, setVerbose } from './logger.js';
+
+// ── Stream capture helpers ────────────────────────────────────────────────────
+
+/** Captures all arguments passed to console.log during a callback. */
+function captureStdout(fn: () => void): string[] {
+  const captured: string[] = [];
+  const orig = console.log;
+  console.log = (...args: unknown[]) => {
+    captured.push(args.map(String).join(' '));
+  };
+  try {
+    fn();
+  } finally {
+    console.log = orig;
+  }
+  return captured;
+}
+
+/** Captures all arguments passed to console.error during a callback. */
+function captureStderr(fn: () => void): string[] {
+  const captured: string[] = [];
+  const orig = console.error;
+  console.error = (...args: unknown[]) => {
+    captured.push(args.map(String).join(' '));
+  };
+  try {
+    fn();
+  } finally {
+    console.error = orig;
+  }
+  return captured;
+}
+
+// ── warn() — subtask-07 gate ──────────────────────────────────────────────────
+
+describe('warn() output stream (subtask-07 gate)', () => {
+  // ❌ CURRENTLY FAILS: warn() uses console.log (stdout), not console.error (stderr).
+  // WILL PASS after subtask-07 changes warn() to use console.error.
+  test('warn() writes to stderr (console.error), NOT stdout (subtask-07 gate)', () => {
+    // Arrange
+    const stderrLines: string[] = [];
+    const stdoutLines: string[] = [];
+    const origError = console.error;
+    const origLog = console.log;
+    console.error = (...args: unknown[]) => { stderrLines.push(args.map(String).join(' ')); };
+    console.log = (...args: unknown[]) => { stdoutLines.push(args.map(String).join(' ')); };
+
+    try {
+      // Act
+      warn('test warning message');
+
+      // Assert — message must appear on stderr
+      expect(stderrLines.some(s => s.includes('test warning message'))).toBe(true);
+      // Assert — message must NOT appear on stdout
+      expect(stdoutLines.some(s => s.includes('test warning message'))).toBe(false);
+    } finally {
+      console.error = origError;
+      console.log = origLog;
+    }
+  });
+
+  // ❌ CURRENTLY FAILS: warn() goes to stdout, so suppressing stderr (2>/dev/null)
+  // would NOT hide the warning. After the fix, stderr capture should contain it.
+  test('warn() message is captured by stderr spy (subtask-07 gate)', () => {
+    // Arrange & Act
+    const stderrOutput = captureStderr(() => warn('stderr-only warning'));
+
+    // Assert — CURRENTLY FAILS (warn uses console.log, not console.error)
+    expect(stderrOutput.some(s => s.includes('stderr-only warning'))).toBe(true);
+  });
+
+  // ❌ CURRENTLY FAILS: warn() goes to stdout, so stdout spy captures it.
+  // After the fix, stdout spy must NOT capture warn() output.
+  test('warn() message is NOT captured by stdout spy (subtask-07 gate)', () => {
+    // Arrange & Act
+    const stdoutOutput = captureStdout(() => warn('should-not-be-on-stdout'));
+
+    // Assert — CURRENTLY FAILS (warn uses console.log which IS captured by stdout spy)
+    expect(stdoutOutput.some(s => s.includes('should-not-be-on-stdout'))).toBe(false);
+  });
+});
+
+// ── error() — already correct, regression guard ───────────────────────────────
+
+describe('error() output stream (regression guard)', () => {
+  // ✅ CURRENTLY PASSES: error() already uses console.error.
+  // Guards against regression — subtask-07 must not break error().
+  test('error() writes to stderr (console.error)', () => {
+    // Arrange & Act
+    const stderrOutput = captureStderr(() => error('test error message'));
+
+    // Assert
+    expect(stderrOutput.some(s => s.includes('test error message'))).toBe(true);
+  });
+
+  // ✅ CURRENTLY PASSES: error() does not write to stdout.
+  test('error() does NOT write to stdout', () => {
+    // Arrange & Act
+    const stdoutOutput = captureStdout(() => error('error-not-on-stdout'));
+
+    // Assert
+    expect(stdoutOutput.some(s => s.includes('error-not-on-stdout'))).toBe(false);
+  });
+});
+
+// ── success() — stdout, regression guard ─────────────────────────────────────
+
+describe('success() output stream (regression guard)', () => {
+  // ✅ CURRENTLY PASSES: success() uses console.log (stdout).
+  test('success() writes to stdout (console.log)', () => {
+    // Arrange & Act
+    const stdoutOutput = captureStdout(() => success('test success message'));
+
+    // Assert
+    expect(stdoutOutput.some(s => s.includes('test success message'))).toBe(true);
+  });
+
+  // ✅ CURRENTLY PASSES: success() does not write to stderr.
+  test('success() does NOT write to stderr', () => {
+    // Arrange & Act
+    const stderrOutput = captureStderr(() => success('success-not-on-stderr'));
+
+    // Assert
+    expect(stderrOutput.some(s => s.includes('success-not-on-stderr'))).toBe(false);
+  });
+});
+
+// ── log() — stdout, regression guard ─────────────────────────────────────────
+
+describe('log() output stream (regression guard)', () => {
+  // ✅ CURRENTLY PASSES
+  test('log() writes to stdout', () => {
+    const stdoutOutput = captureStdout(() => log('plain log message'));
+    expect(stdoutOutput.some(s => s.includes('plain log message'))).toBe(true);
+  });
+});
+
+// ── info() — stdout, regression guard ────────────────────────────────────────
+
+describe('info() output stream (regression guard)', () => {
+  // ✅ CURRENTLY PASSES
+  test('info() writes to stdout', () => {
+    const stdoutOutput = captureStdout(() => info('info message'));
+    expect(stdoutOutput.some(s => s.includes('info message'))).toBe(true);
+  });
+});
+
+// ── dim() — stdout, regression guard ─────────────────────────────────────────
+
+describe('dim() output stream (regression guard)', () => {
+  // ✅ CURRENTLY PASSES
+  test('dim() writes to stdout', () => {
+    const stdoutOutput = captureStdout(() => dim('dim message'));
+    expect(stdoutOutput.some(s => s.includes('dim message'))).toBe(true);
+  });
+});
+
+// ── bold() — stdout, regression guard ────────────────────────────────────────
+
+describe('bold() output stream (regression guard)', () => {
+  // ✅ CURRENTLY PASSES
+  test('bold() writes to stdout', () => {
+    const stdoutOutput = captureStdout(() => bold('bold message'));
+    expect(stdoutOutput.some(s => s.includes('bold message'))).toBe(true);
+  });
+});
+
+// ── verbose() — conditional stdout ───────────────────────────────────────────
+
+describe('verbose() output stream', () => {
+  afterEach(() => {
+    // Always reset verbose state after each test
+    setVerbose(false);
+  });
+
+  // ✅ CURRENTLY PASSES: verbose() writes to stdout when enabled
+  test('verbose() writes to stdout when verbose is enabled', () => {
+    // Arrange
+    setVerbose(true);
+
+    // Act
+    const stdoutOutput = captureStdout(() => verbose('verbose message'));
+
+    // Assert
+    expect(stdoutOutput.some(s => s.includes('verbose message'))).toBe(true);
+  });
+
+  // ✅ CURRENTLY PASSES: verbose() is silent when disabled
+  test('verbose() does NOT write when verbose is disabled', () => {
+    // Arrange
+    setVerbose(false);
+
+    // Act
+    const stdoutOutput = captureStdout(() => verbose('silent verbose'));
+
+    // Assert
+    expect(stdoutOutput.some(s => s.includes('silent verbose'))).toBe(false);
+  });
+});
+
+// ── Stream separation summary ─────────────────────────────────────────────────
+// After all fixes are applied, the stream contract is:
+//   stdout (console.log):  log, info, success, dim, bold, verbose
+//   stderr (console.error): warn, error
+//
+// This matches Unix convention: diagnostic messages go to stderr so they
+// don't corrupt piped output (e.g. `oac list | grep agent`).

+ 1 - 1
packages/cli/src/ui/logger.ts

@@ -25,7 +25,7 @@ export const setVerbose = (enabled: boolean): void => {
 
 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 warn    = (msg: string): void => console.error(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));

+ 9 - 0
scripts/sync-version.js

@@ -0,0 +1,9 @@
+#!/usr/bin/env node
+'use strict';
+const fs = require('fs');
+const root = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
+const cliPkgPath = './packages/cli/package.json';
+const cliPkg = JSON.parse(fs.readFileSync(cliPkgPath, 'utf8'));
+cliPkg.version = root.version;
+fs.writeFileSync(cliPkgPath, JSON.stringify(cliPkg, null, 2) + '\n');
+console.log(`Synced packages/cli version to ${root.version}`);