From 7e30ae6195d4be671ef876487981714d1510031c Mon Sep 17 00:00:00 2001 From: Dragan Spiridonov Date: Sun, 6 Sep 2026 09:09:37 +0000 Subject: [PATCH] fix(init): harden platform setup contracts --- .claude/helpers/brain-checkpoint.cjs | 3 +- .claude/hooks/aqe-hook.cjs | 26 ++-- docs/platform-setup-guide.md | 14 ++ src/cli/commands/platform.ts | 42 ++++-- src/cli/handlers/init-handler.ts | 20 ++- src/init/browser-engine-installer.ts | 55 +++++++- src/init/codex-installer.ts | 133 ++++++++++++++---- src/init/init-wizard-hooks.ts | 22 +-- src/init/orchestrator.ts | 5 + src/init/phases/07-hooks.ts | 37 ++--- src/init/phases/09-assets.ts | 36 +++-- src/init/phases/phase-interface.ts | 2 + src/init/types.ts | 12 +- tests/unit/cli/init-command.test.ts | 15 ++ tests/unit/cli/platform.test.ts | 34 ++++- tests/unit/hooks/aqe-hook-shim.test.ts | 25 +++- tests/unit/hooks/claude-hook-contract.test.ts | 22 +++ .../init/browser-engine-installer.test.ts | 117 +++++++++++++-- tests/unit/init/codex-guidance-policy.test.ts | 132 +++++++++++++++++ tests/unit/init/codex-installer.test.ts | 26 ++++ tests/unit/init/init-wizard.test.ts | 3 +- 21 files changed, 647 insertions(+), 134 deletions(-) create mode 100644 tests/unit/hooks/claude-hook-contract.test.ts create mode 100644 tests/unit/init/codex-guidance-policy.test.ts diff --git a/.claude/helpers/brain-checkpoint.cjs b/.claude/helpers/brain-checkpoint.cjs index fefd433d..95f8b577 100644 --- a/.claude/helpers/brain-checkpoint.cjs +++ b/.claude/helpers/brain-checkpoint.cjs @@ -17,6 +17,7 @@ const AQE_DIR = path.join(PROJECT_ROOT, '.agentic-qe'); const RVF_PATH = path.join(AQE_DIR, 'aqe.rvf'); const DB_PATH = path.join(AQE_DIR, 'memory.db'); const MAX_AGE_HOURS = 24; +const AQE_BIN = process.env.AQE_HOOK_BIN || (process.platform === 'win32' ? 'aqe.cmd' : 'aqe'); // Mount-local kill switch. `brain export` (below) spawns a native RVF writer // that, on a macOS Docker virtiofs bind mount, deadlocks in a futex and IGNORES @@ -37,7 +38,7 @@ function exportBrain() { const idmap = RVF_PATH + '.idmap.json'; if (fs.existsSync(idmap)) fs.unlinkSync(idmap); const result = execFileSync( - 'npx', ['agentic-qe', 'brain', 'export', '-o', RVF_PATH, '--format', 'rvf'], + AQE_BIN, ['brain', 'export', '-o', RVF_PATH, '--format', 'rvf'], { timeout: 60000, encoding: 'utf-8' } ); const m = result.match(/Patterns:\s+(\d+)/); diff --git a/.claude/hooks/aqe-hook.cjs b/.claude/hooks/aqe-hook.cjs index 20f501f0..eb4b4a6d 100755 --- a/.claude/hooks/aqe-hook.cjs +++ b/.claude/hooks/aqe-hook.cjs @@ -14,9 +14,9 @@ * polluting the transcript, and a crash/slow init can surface a hook error. * * Contract: - * * resolve a PROJECT-LOCAL AQE bundle and run it via THIS node (no shell, no - * .bin/.cmd wrapper) — or fall back to `npx agentic-qe` for npx-only installs - * (disable that fallback with AQE_HOOK_NPX=0); + * * resolve a PROJECT-LOCAL AQE bundle and run it via THIS node (no shell), + * or invoke an already-installed `aqe` binary without package-manager or + * network resolution; * * if nothing resolves, no-op (a hook must never block a turn); * * keep stderr OUT of the transcript, but tee fatal native-init markers * (e.g. better-sqlite3 `invalid ELF header`) to a throttled, durable @@ -49,14 +49,14 @@ const args = process.argv.slice(2); // hook subcommand + its args // 2. local source build (this repo: ./dist) // -> run either directly via process.execPath (this node): no shell, no // node_modules/.bin/aqe(.cmd) wrapper, fully cross-platform. -// 3. npx fallback (npx-only installs with no project-local -// bundle) — `npx -y --prefer-offline agentic-qe hooks ...`. Disable with -// AQE_HOOK_NPX=0 (used by tests and by anyone who never wants a network -// reach). When disabled and no local bundle exists, we no-op. +// 3. installed `aqe` binary (global/managed installs without a local +// bundle). AQE_HOOK_BIN is a test/managed-install seam; no package manager +// is ever invoked from a lifecycle hook. const candidates = [ + process.env.AQE_HOOK_BUNDLE, path.join(PROJECT, 'node_modules', 'agentic-qe', 'dist', 'cli', 'bundle.js'), path.join(PROJECT, 'dist', 'cli', 'bundle.js'), -]; +].filter(Boolean); let cmd; let cmdArgs; @@ -67,11 +67,9 @@ for (const p of candidates) { if (bundle) { cmd = process.execPath; cmdArgs = [bundle, 'hooks', ...args]; -} else if (process.env.AQE_HOOK_NPX !== '0') { - cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx'; - cmdArgs = ['-y', '--prefer-offline', 'agentic-qe', 'hooks', ...args]; } else { - process.exit(0); // no project-local AQE and npx disabled -> no-op, never block + cmd = process.env.AQE_HOOK_BIN || (process.platform === 'win32' ? 'aqe.cmd' : 'aqe'); + cmdArgs = ['hooks', ...args]; } // Fatal init markers that mean the hook ran but its persistence layer is dead @@ -94,7 +92,9 @@ const FATAL_MARKERS = [ // harness timeout vs. this shim's own admitted ~30-60s cold start left // routing_outcomes writes silently dropped for a month — nothing recorded // the mismatch because nothing was watching for it.) -const SPAWN_TIMEOUT_MS = Number(process.env.AQE_HOOK_TIMEOUT_MS) || 18000; +const FAST_HOOKS = new Set(['guard', 'pre-command']); +const SPAWN_TIMEOUT_MS = Number(process.env.AQE_HOOK_TIMEOUT_MS) + || (FAST_HOOKS.has(args[0]) ? 2500 : 4500); function recordHookHealth(line) { try { diff --git a/docs/platform-setup-guide.md b/docs/platform-setup-guide.md index 80b57f26..95c4c7f9 100644 --- a/docs/platform-setup-guide.md +++ b/docs/platform-setup-guide.md @@ -252,6 +252,20 @@ npx agentic-qe init --auto --with-codex npx agentic-qe platform setup codex ``` +Codex guidance is independent from MCP, hooks, and skills. Choose the eager +`AGENTS.md` payload with `--codex-guidance full|compact|none` on either command. +`full` is the default. `compact` keeps only the safety and verification +contract and is capped at 512 UTF-8 bytes including AQE's ownership sentinels +(a conservative ceiling of 171 planning tokens at three bytes per token). +`none` removes only well-formed AQE-owned sentinel blocks. It leaves all other +`AGENTS.md` bytes untouched and still provisions MCP, hooks, and skills. + +```bash +npx agentic-qe init --auto --with-codex --codex-guidance compact +npx agentic-qe platform setup codex --codex-guidance none +npx agentic-qe platform verify codex --codex-guidance none +``` + Ruflo integration is deliberately opt-in. Add it only when you want its development-time coordination guidance and lifecycle hooks: diff --git a/src/cli/commands/platform.ts b/src/cli/commands/platform.ts index 715bb06a..3f285351 100644 --- a/src/cli/commands/platform.ts +++ b/src/cli/commands/platform.ts @@ -8,7 +8,7 @@ * verify - Verify a platform's configuration */ -import { Command } from 'commander'; +import { Command, Option } from 'commander'; import chalk from 'chalk'; import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; @@ -93,6 +93,15 @@ export interface PlatformVerificationResult { export interface PlatformVerificationOptions { expectMcp?: boolean; expectRuflo?: boolean; + guidancePolicy?: 'full' | 'compact' | 'none'; +} + +function detectCodexGuidance(content: string): 'full' | 'compact' | 'none' | 'malformed' { + const start = ''; + const end = ''; + const blocks = content.match(/[\s\S]*?/g) || []; + if (blocks.length === 0) return content.includes(start) || content.includes(end) ? 'malformed' : 'none'; + return blocks[0]!.includes('Discover AQE tools and skills from their live schemas') ? 'compact' : 'full'; } /** Inspect the complete installed surface for a platform without changing it. */ @@ -125,11 +134,14 @@ export function verifyPlatformConfiguration( const rulesPath = path.join(projectRoot, platform.rulesPath); const rulesExists = existsSync(rulesPath); - add('Behavioral rules', rulesExists, rulesExists ? platform.rulesPath : `missing: ${platform.rulesPath}`); - if (platformId === 'codex' && rulesExists) { - const rules = readFileSync(rulesPath, 'utf-8'); - add('AQE instructions', rules.includes('Quality Engineering Standards (Agentic QE)'), - 'AGENTS.md contains generated AQE guidance'); + if (platformId === 'codex') { + const expected = options.guidancePolicy ?? 'full'; + const actual = rulesExists ? detectCodexGuidance(readFileSync(rulesPath, 'utf-8')) : 'none'; + add('Behavioral rules', expected === 'none' || rulesExists, + expected === 'none' ? 'intentionally disabled' : (rulesExists ? platform.rulesPath : `missing: ${platform.rulesPath}`)); + add('Codex guidance', actual === expected, `selected=${expected}, detected=${actual}`); + } else { + add('Behavioral rules', rulesExists, rulesExists ? platform.rulesPath : `missing: ${platform.rulesPath}`); } if (platformId === 'codex') { @@ -234,7 +246,10 @@ export function createPlatformCommand(): Command { .description('Set up a specific platform configuration') .option('--overwrite', 'Overwrite existing configuration files') .option('--with-ruflo', 'Add optional Ruflo guidance and lifecycle hooks (Codex only)') - .action(async (name: string, options: { overwrite?: boolean; withRuflo?: boolean }) => { + .addOption(new Option('--codex-guidance ', 'Codex AGENTS.md guidance policy') + .choices(['full', 'compact', 'none']) + .default('full')) + .action(async (name: string, options: { overwrite?: boolean; withRuflo?: boolean; codexGuidance?: 'full' | 'compact' | 'none' }) => { const projectRoot = process.cwd(); if (!isValidPlatformId(name)) { @@ -264,7 +279,7 @@ export function createPlatformCommand(): Command { `create${capitalize(name)}Installer`, ]; - let factory: ((opts: { projectRoot: string; overwrite?: boolean }) => { install: () => Promise }) | undefined; + let factory: ((opts: { projectRoot: string; overwrite?: boolean; guidancePolicy?: 'full' | 'compact' | 'none' }) => { install: () => Promise }) | undefined; for (const fn of possibleNames) { if (typeof installerModule[fn] === 'function') { factory = installerModule[fn]; @@ -290,7 +305,10 @@ export function createPlatformCommand(): Command { const installer = factory({ projectRoot, overwrite: options.overwrite, - ...(name === 'codex' ? { includeRuflo: options.withRuflo } : {}), + ...(name === 'codex' ? { + includeRuflo: options.withRuflo, + guidancePolicy: options.codexGuidance, + } : {}), }); const result = await installer.install() as { @@ -339,7 +357,10 @@ export function createPlatformCommand(): Command { .description('Verify a platform configuration is correct') .option('--no-mcp', 'Verify a deliberately MCP-free platform installation') .option('--with-ruflo', 'Require optional Ruflo guidance and lifecycle hooks (Codex only)') - .action(async (name: string, options: { mcp?: boolean; withRuflo?: boolean }) => { + .addOption(new Option('--codex-guidance ', 'Expected Codex AGENTS.md guidance policy') + .choices(['full', 'compact', 'none']) + .default('full')) + .action(async (name: string, options: { mcp?: boolean; withRuflo?: boolean; codexGuidance?: 'full' | 'compact' | 'none' }) => { const projectRoot = process.cwd(); if (!isValidPlatformId(name)) { @@ -359,6 +380,7 @@ export function createPlatformCommand(): Command { const verification = verifyPlatformConfiguration(projectRoot, name, { expectMcp: options.mcp !== false, expectRuflo: options.withRuflo, + guidancePolicy: options.codexGuidance, }); for (const check of verification.checks) { const message = ` [${check.passed ? 'pass' : 'fail'}] ${check.label}: ${check.detail}`; diff --git a/src/cli/handlers/init-handler.ts b/src/cli/handlers/init-handler.ts index 6734be97..018edf41 100644 --- a/src/cli/handlers/init-handler.ts +++ b/src/cli/handlers/init-handler.ts @@ -4,7 +4,7 @@ * Handles the 'aqe init' command for system initialization. */ -import { Command } from 'commander'; +import { Command, Option } from 'commander'; import chalk from 'chalk'; import { createRequire } from 'node:module'; import { ICommandHandler, CLIContext } from './interfaces.js'; @@ -64,6 +64,9 @@ export class InitHandler implements ICommandHandler { .option('--with-kilocode', 'Include Kilo Code MCP config and custom QE mode') .option('--with-roocode', 'Include Roo Code MCP config and custom QE mode') .option('--with-codex', 'Include OpenAI Codex CLI MCP, AGENTS.md, hooks, and QE skills') + .addOption(new Option('--codex-guidance ', 'Codex AGENTS.md guidance policy') + .choices(['full', 'compact', 'none']) + .default('full')) .option('--with-ruflo', 'Add optional Ruflo guidance and lifecycle hooks to the Codex setup') .option('--with-windsurf', 'Include Windsurf MCP config and rules') .option('--with-continuedev', 'Include Continue.dev MCP config and rules') @@ -212,6 +215,7 @@ export class InitHandler implements ICommandHandler { withRooCode: options.withRoocode, withCodex: options.withCodex, withRuflo: options.withRuflo, + codexGuidance: options.codexGuidance, withWindsurf: options.withWindsurf, withContinueDev: options.withContinuedev, noMcp: options.noMcp && !options.withMcp, @@ -535,6 +539,7 @@ Options: --with-n8n Install n8n workflow testing agents and skills --with-opencode Include OpenCode agent/skill provisioning --with-codex Include OpenAI Codex hooks, instructions, skills, and MCP + --codex-guidance Codex AGENTS.md guidance: full, compact, or none --with-ruflo Opt into Ruflo guidance and lifecycle hooks for Codex --auto-migrate Automatically migrate from v2 if detected --with-claude-flow Force Claude Flow integration setup @@ -610,10 +615,14 @@ export interface InitJsonOutput { mcpConfigured: boolean; claudeMdGenerated: boolean; workersStarted: number; - n8nInstalled?: { - agents: number; - skills: number; - }; + n8nInstalled?: { + agents: number; + skills: number; + }; + codexGuidance?: { + policy: 'full' | 'compact' | 'none'; + ownedBytes: number; + }; }; totalDurationMs: number; timestamp: string; @@ -643,6 +652,7 @@ interface InitOptions { withRoocode?: boolean; withCodex?: boolean; withRuflo?: boolean; + codexGuidance?: 'full' | 'compact' | 'none'; withWindsurf?: boolean; withContinuedev?: boolean; withAllPlatforms?: boolean; diff --git a/src/init/browser-engine-installer.ts b/src/init/browser-engine-installer.ts index a4155aee..3493388a 100644 --- a/src/init/browser-engine-installer.ts +++ b/src/init/browser-engine-installer.ts @@ -81,6 +81,14 @@ export interface BrowserEngineInstallResult { platformHint?: PlatformHint; } +export type BrowserEngineDetectionStatus = 'ready' | 'cli-missing' | 'payload-missing'; + +export interface BrowserEngineDetectionResult { + status: BrowserEngineDetectionStatus; + version?: string; + message?: string; +} + export interface PlatformHint { /** * Short machine-readable tag for the condition being reported. Consumers @@ -201,6 +209,31 @@ export function detectVibium(spawner: Spawner = defaultSpawner, timeoutMs = 5_00 return match ? match[1] : raw.split(/\s+/)[0] || 'unknown'; } +/** + * Detect Vibium readiness without changing the host. A usable browser engine + * requires both the CLI and Vibium's same-revision Chrome/chromedriver payload. + */ +export function detectBrowserEngine( + spawner: Spawner = defaultSpawner, + timeoutMs = 5_000 +): BrowserEngineDetectionResult { + const version = detectVibium(spawner, timeoutMs); + if (!version) return { status: 'cli-missing' }; + + const payload = tryRun(spawner, 'vibium', ['is-installed'], timeoutMs); + if (payload.status === 0) return { status: 'ready', version }; + + return { + status: 'payload-missing', + version, + message: + payload.stderr?.trim() || + payload.stdout?.trim() || + toErrorMessage(payload.error) || + 'Vibium Chrome/chromedriver payload is missing or incomplete', + }; +} + /** * Install Vibium via `npm install -g`. Returns a structured result the * assets phase can log/summarize — never throws for expected failures. @@ -217,11 +250,11 @@ export function installBrowserEngine( const platformProbe = options.platformProbe || defaultPlatformProbe; const platformHint = diagnosePlatform(platformProbe); - const alreadyInstalled = detectVibium(spawner); - if (alreadyInstalled) { + const existing = detectBrowserEngine(spawner); + if (existing.status === 'ready') { return { status: 'already-installed', - version: alreadyInstalled, + version: existing.version, packageSpec, platformHint, }; @@ -252,6 +285,18 @@ export function installBrowserEngine( }; } - const version = detectVibium(spawner) || 'unknown'; - return { status: 'installed', version, packageSpec, platformHint }; + const verified = detectBrowserEngine(spawner); + if (verified.status !== 'ready') { + const reason = verified.message ? `: ${verified.message}` : ''; + return { + status: 'install-failed', + packageSpec, + platformHint, + message: + `npm completed, but Vibium readiness verification failed (${verified.status})${reason}. ` + + `Run \`npm install -g ${packageSpec}\`, then verify with \`vibium is-installed\`.`, + }; + } + + return { status: 'installed', version: verified.version, packageSpec, platformHint }; } diff --git a/src/init/codex-installer.ts b/src/init/codex-installer.ts index c1718d06..48919ee9 100644 --- a/src/init/codex-installer.ts +++ b/src/init/codex-installer.ts @@ -35,6 +35,8 @@ export interface CodexInstallerOptions { installMcp?: boolean; /** Install optional Ruflo guidance, adapter, runtime, and lifecycle groups. */ includeRuflo?: boolean; + /** Eager AGENTS.md guidance policy. Defaults to full. */ + guidancePolicy?: CodexGuidancePolicy; /** * Memory backend for this install. 'memory' => database-free: the MCP config * is written to run in-memory (AQE_MEMORY_BACKEND=memory, no AQE_MEMORY_PATH). (#533) @@ -42,6 +44,13 @@ export interface CodexInstallerOptions { memoryBackend?: 'memory' | 'sqlite' | 'agentdb' | 'hybrid'; } +export type CodexGuidancePolicy = 'full' | 'compact' | 'none'; +export const CODEX_COMPACT_GUIDANCE_MAX_BYTES = 512; + +const COMPACT_CODEX_GUIDANCE = `# Agentic QE + +Preserve project data, especially .agentic-qe/memory.db. Read affected code and tests before editing, validate inputs at system boundaries, and run focused checks before broader gates. Discover AQE tools and skills from their live schemas.`; + export interface CodexInstallResult { success: boolean; mcpConfigured: boolean; @@ -53,6 +62,8 @@ export interface CodexInstallResult { agentsMdPath: string; hooksPath: string; skillsPath: string; + guidancePolicy: CodexGuidancePolicy; + ownedGuidanceBytes: number; /** Per-component outcomes. Legacy booleans above remain supported. */ components: { mcp: CodexComponentOutcome; @@ -99,6 +110,8 @@ export class CodexInstaller { agentsMdPath: '', hooksPath: join(this.projectRoot, '.codex', 'hooks.json'), skillsPath: join(this.projectRoot, '.agents', 'skills'), + guidancePolicy: this.options.guidancePolicy ?? 'full', + ownedGuidanceBytes: 0, components: { mcp: { status: 'skipped' }, rules: { status: 'skipped' }, @@ -137,23 +150,49 @@ export class CodexInstaller { } try { - // Generate AGENTS.md behavioral rules + const policy = this.options.guidancePolicy ?? 'full'; const rules = this.generator.generateBehavioralRules('codex'); - const agentsMdPath = join(this.projectRoot, rules.path); - result.agentsMdPath = agentsMdPath; - + const agentsMdPath = join(this.projectRoot, rules.path); + result.agentsMdPath = agentsMdPath; + const rulesExist = existsSync(agentsMdPath); - if (!rulesExist || this.overwrite) { - if (rulesExist && this.overwrite) { - const merged = this.mergeExistingAgentsMd(agentsMdPath, rules.content); - writeFileSync(agentsMdPath, merged); - } else { - writeFileSync(agentsMdPath, this.markAgentsSection(rules.content)); + if (policy === 'none') { + if (!rulesExist) { + result.components.rules.status = 'skipped'; + } else { + const existing = readFileSync(agentsMdPath, 'utf-8'); + const updated = this.removeOwnedAgentsSections(existing); + if (updated !== existing) { + writeFileSync(agentsMdPath, updated); + result.components.rules.status = 'updated'; + } else { + result.components.rules.status = 'preserved'; + } } + } else if (!rulesExist) { + const content = policy === 'compact' ? COMPACT_CODEX_GUIDANCE : rules.content; + const marked = this.markAgentsSection(content); + writeFileSync(agentsMdPath, marked); result.agentsMdInstalled = true; - result.components.rules.status = rulesExist ? 'updated' : 'installed'; + result.ownedGuidanceBytes = Buffer.byteLength(marked); + result.components.rules.status = 'installed'; + } else if (this.overwrite || policy === 'compact') { + const content = policy === 'compact' ? COMPACT_CODEX_GUIDANCE : rules.content; + const existing = readFileSync(agentsMdPath, 'utf-8'); + const merged = this.mergeExistingAgentsMdContent(existing, content); + if (merged !== existing) { + writeFileSync(agentsMdPath, merged); + result.agentsMdInstalled = true; + result.components.rules.status = 'updated'; + } else { + result.components.rules.status = 'preserved'; + } + result.ownedGuidanceBytes = this.measureOwnedAgentsSection(merged); } else { result.components.rules.status = 'preserved'; + result.ownedGuidanceBytes = this.measureOwnedAgentsSection( + readFileSync(agentsMdPath, 'utf-8'), + ); } } catch (error) { this.recordComponentFailure(result, 'rules', error); @@ -232,13 +271,22 @@ export class CodexInstaller { description?: string; hooks?: Record; }; - const isRufloGroup = (value: unknown): boolean => - JSON.stringify(value).includes('ruflo-codex-hook.cjs'); + const withoutRufloHooks = (groups: unknown[]): unknown[] => groups.flatMap((group) => { + if (!group || typeof group !== 'object') return [group]; + const candidate = group as { hooks?: unknown[] }; + if (!Array.isArray(candidate.hooks)) { + return JSON.stringify(group).includes('ruflo-codex-hook.cjs') ? [] : [group]; + } + const hooks = candidate.hooks.filter( + (hook) => !JSON.stringify(hook).includes('ruflo-codex-hook.cjs'), + ); + return hooks.length > 0 ? [{ ...candidate, hooks }] : []; + }); const generated = { ...generatedSource, hooks: Object.fromEntries(Object.entries(generatedSource.hooks || {}).map(([event, groups]) => [ event, - this.options.includeRuflo ? groups : groups.filter((group) => !isRufloGroup(group)), + this.options.includeRuflo ? groups : withoutRufloHooks(groups), ])), }; const targetConfig = join(targetCodexDir, 'hooks.json'); @@ -370,25 +418,48 @@ export class CodexInstaller { * Merge AQE section into existing AGENTS.md. * Replaces a previously marked AQE section or appends a new marked section. */ - private mergeExistingAgentsMd(agentsMdPath: string, newContent: string): string { - const existing = readFileSync(agentsMdPath, 'utf-8'); - - const marked = this.markAgentsSection(newContent); - const start = CodexInstaller.AGENTS_START; - const end = CodexInstaller.AGENTS_END; - const startIndex = existing.indexOf(start); - if (startIndex >= 0) { - const endIndex = existing.indexOf(end, startIndex); - if (endIndex >= 0) { - return existing.slice(0, startIndex) + marked - + existing.slice(endIndex + end.length); - } - } - return existing.trimEnd() + '\n\n---\n\n' + marked; + private mergeExistingAgentsMdContent(existing: string, newContent: string): string { + this.assertOwnedAgentsSectionsWellFormed(existing); + const eol = existing.includes('\r\n') ? '\r\n' : '\n'; + const marked = this.markAgentsSection(newContent, eol); + let replaced = false; + const merged = existing.replace(this.ownedAgentsPattern(), () => { + if (replaced) return ''; + replaced = true; + return marked; + }); + if (replaced) return merged; + if (existing.length === 0) return marked; + return existing.trimEnd() + `${eol}${eol}---${eol}${eol}` + marked; } - private markAgentsSection(content: string): string { - return `${CodexInstaller.AGENTS_START}\n${content.trim()}\n${CodexInstaller.AGENTS_END}\n`; + private removeOwnedAgentsSections(existing: string): string { + this.assertOwnedAgentsSectionsWellFormed(existing); + return existing.replace(this.ownedAgentsPattern(), ''); + } + + private measureOwnedAgentsSection(content: string): number { + this.assertOwnedAgentsSectionsWellFormed(content); + const match = content.match(this.ownedAgentsPattern()); + return match ? Buffer.byteLength(match[0]) : 0; + } + + private assertOwnedAgentsSectionsWellFormed(content: string): void { + const starts = content.match(//g)?.length ?? 0; + const ends = content.match(//g)?.length ?? 0; + const complete = content.match(this.ownedAgentsPattern())?.length ?? 0; + if (starts !== ends || complete !== starts) { + throw new Error('Malformed Agentic QE Codex sentinel in AGENTS.md; file was preserved'); + } + } + + private ownedAgentsPattern(): RegExp { + return /[\s\S]*?(?:\r?\n)?/g; + } + + private markAgentsSection(content: string, eol = '\n'): string { + const normalized = content.trim().replace(/\r?\n/g, eol); + return `${CodexInstaller.AGENTS_START}${eol}${normalized}${eol}${CodexInstaller.AGENTS_END}${eol}`; } } diff --git a/src/init/init-wizard-hooks.ts b/src/init/init-wizard-hooks.ts index 91a874b7..28c8c42a 100644 --- a/src/init/init-wizard-hooks.ts +++ b/src/init/init-wizard-hooks.ts @@ -68,7 +68,7 @@ export async function configureHooks(projectRoot: string, config: AQEInitConfig) { type: 'command', command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/aqe-hook.cjs" guard --file "$TOOL_INPUT_file_path" --json', - timeout: 3000, + timeout: 3, continueOnError: true, }, ], @@ -79,7 +79,7 @@ export async function configureHooks(projectRoot: string, config: AQEInitConfig) { type: 'command', command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/aqe-hook.cjs" pre-edit --file "$TOOL_INPUT_file_path" --json', - timeout: 5000, + timeout: 5, continueOnError: true, }, ], @@ -90,7 +90,7 @@ export async function configureHooks(projectRoot: string, config: AQEInitConfig) { type: 'command', command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/aqe-hook.cjs" pre-command --command "$TOOL_INPUT_command" --json', - timeout: 3000, + timeout: 3, continueOnError: true, }, ], @@ -101,7 +101,7 @@ export async function configureHooks(projectRoot: string, config: AQEInitConfig) { type: 'command', command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/aqe-hook.cjs" pre-task --description "$TOOL_INPUT_prompt" --json', - timeout: 5000, + timeout: 5, continueOnError: true, }, ], @@ -114,7 +114,7 @@ export async function configureHooks(projectRoot: string, config: AQEInitConfig) { type: 'command', command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/aqe-hook.cjs" post-edit --file "$TOOL_INPUT_file_path" --success --json', - timeout: 5000, + timeout: 5, continueOnError: true, }, ], @@ -125,7 +125,7 @@ export async function configureHooks(projectRoot: string, config: AQEInitConfig) { type: 'command', command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/aqe-hook.cjs" post-command --command "$TOOL_INPUT_command" --success true --json', - timeout: 5000, + timeout: 5, continueOnError: true, }, ], @@ -136,7 +136,7 @@ export async function configureHooks(projectRoot: string, config: AQEInitConfig) { type: 'command', command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/aqe-hook.cjs" post-task --task-id "$TOOL_RESULT_agent_id" --agent "$TOOL_INPUT_subagent_type" --success true --description "$TOOL_INPUT_prompt" --json', - timeout: 5000, + timeout: 5, continueOnError: true, }, ], @@ -151,7 +151,7 @@ export async function configureHooks(projectRoot: string, config: AQEInitConfig) // var, so we let the CLI read stdin directly. type: 'command', command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/aqe-hook.cjs" route --json', - timeout: 5000, + timeout: 5, continueOnError: true, }, ], @@ -163,7 +163,7 @@ export async function configureHooks(projectRoot: string, config: AQEInitConfig) { type: 'command', command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/aqe-hook.cjs" session-start --session-id "$SESSION_ID" --json', - timeout: 10000, + timeout: 10, continueOnError: true, }, ], @@ -175,7 +175,7 @@ export async function configureHooks(projectRoot: string, config: AQEInitConfig) { type: 'command', command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/aqe-hook.cjs" session-end --save-state --json', - timeout: 5000, + timeout: 5, continueOnError: true, }, ], @@ -188,7 +188,7 @@ export async function configureHooks(projectRoot: string, config: AQEInitConfig) // sentinels accumulate at quality_score=-1 indefinitely. type: 'command', command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/aqe-hook.cjs" post-route --success true --json', - timeout: 5000, + timeout: 5, continueOnError: true, }, ], diff --git a/src/init/orchestrator.ts b/src/init/orchestrator.ts index cb679920..d768ca14 100644 --- a/src/init/orchestrator.ts +++ b/src/init/orchestrator.ts @@ -64,6 +64,7 @@ export class ModularInitOrchestrator { withRooCode: options.withRooCode || allPlatforms, withCodex: options.withCodex || allPlatforms, withRuflo: options.withRuflo, + codexGuidance: options.codexGuidance, withWindsurf: options.withWindsurf || allPlatforms, withContinueDev: options.withContinueDev || allPlatforms, withAllPlatforms: options.withAllPlatforms, @@ -191,6 +192,10 @@ export class ModularInitOrchestrator { mcpConfigured: (mcpResult?.data as Record | undefined)?.configured as boolean ?? false, claudeMdGenerated: (claudeMdResult?.data as Record | undefined)?.generated as boolean ?? false, workersStarted: (workersResult?.data as Record | undefined)?.workersConfigured as number ?? 0, + codexGuidance: (assetsResult?.data as { codexGuidance?: { + policy: 'full' | 'compact' | 'none'; + ownedBytes: number; + } } | undefined)?.codexGuidance, }, totalDurationMs: Date.now() - startTime, timestamp: new Date(), diff --git a/src/init/phases/07-hooks.ts b/src/init/phases/07-hooks.ts index 69a3f090..40987724 100644 --- a/src/init/phases/07-hooks.ts +++ b/src/init/phases/07-hooks.ts @@ -353,7 +353,7 @@ export class HooksPhase extends BasePhase { /** * Install the resilient hook shim (aqe-hook.cjs) into .claude/hooks/. * The generated settings.json hooks run `node .../aqe-hook.cjs ` — the - * shim resolves a project-local AQE bundle (or falls back to npx), strips + * shim resolves a project-local AQE bundle (or an installed aqe binary), strips * init noise from stdout, swallows stderr, and always exits 0. */ private installHookShim(projectRoot: string, context: InitContext): void { @@ -386,7 +386,7 @@ export class HooksPhase extends BasePhase { // Should not happen for a real install (the file ships in the package). context.services.log( - ' ⚠ aqe-hook.cjs source not found — hooks will fall back to `npx agentic-qe`', + ' ⚠ aqe-hook.cjs source not found — lifecycle hooks cannot run until AQE is reinstalled', ); } @@ -449,6 +449,7 @@ const AQE_DIR = path.join(PROJECT_ROOT, '.agentic-qe'); const RVF_PATH = path.join(AQE_DIR, 'aqe.rvf'); const DB_PATH = path.join(AQE_DIR, 'memory.db'); const MAX_AGE_HOURS = 24; +const AQE_BIN = process.env.AQE_HOOK_BIN || (process.platform === 'win32' ? 'aqe.cmd' : 'aqe'); function log(msg) { process.stderr.write('[brain-checkpoint] ' + msg + '\\n'); } @@ -456,7 +457,7 @@ function exportBrain() { if (!fs.existsSync(DB_PATH)) { log('No memory.db, skipping'); return { exported: false }; } try { const result = execFileSync( - 'npx', ['agentic-qe', 'brain', 'export', '-o', RVF_PATH, '--format', 'rvf'], + AQE_BIN, ['brain', 'export', '-o', RVF_PATH, '--format', 'rvf'], { timeout: 60000, encoding: 'utf-8' } ); const m = result.match(/Patterns:\\s+(\\d+)/); @@ -491,8 +492,8 @@ if (process.argv.includes('--json')) process.stdout.write(JSON.stringify(result) /** * Generate hooks configuration * - * Uses `npx agentic-qe` for portability - works without global installation. - * All hooks use --json output for structured data and fail silently with continueOnError. + * Timeout values use Claude Code's native seconds unit. The shim itself uses + * a smaller millisecond budget so it can record failures before the host stops it. */ private generateHooksConfig(_config: AQEInitConfig, _projectRoot: string): Record { // Shell injection safety: env vars like $TOOL_INPUT_file_path are set by @@ -511,7 +512,7 @@ if (process.argv.includes('--json')) process.stdout.write(JSON.stringify(result) { type: 'command', command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/aqe-hook.cjs" guard --file "$TOOL_INPUT_file_path" --json', - timeout: 3000, + timeout: 3, continueOnError: true, }, ], @@ -523,7 +524,7 @@ if (process.argv.includes('--json')) process.stdout.write(JSON.stringify(result) { type: 'command', command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/aqe-hook.cjs" pre-edit --file "$TOOL_INPUT_file_path" --json', - timeout: 5000, + timeout: 5, continueOnError: true, }, ], @@ -535,7 +536,7 @@ if (process.argv.includes('--json')) process.stdout.write(JSON.stringify(result) { type: 'command', command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/aqe-hook.cjs" pre-command --command "$TOOL_INPUT_command" --json', - timeout: 3000, + timeout: 3, continueOnError: true, }, ], @@ -547,7 +548,7 @@ if (process.argv.includes('--json')) process.stdout.write(JSON.stringify(result) { type: 'command', command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/aqe-hook.cjs" pre-task --description "$TOOL_INPUT_prompt" --json', - timeout: 5000, + timeout: 5, continueOnError: true, }, ], @@ -561,7 +562,7 @@ if (process.argv.includes('--json')) process.stdout.write(JSON.stringify(result) { type: 'command', command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/aqe-hook.cjs" post-edit --file "$TOOL_INPUT_file_path" --success --json', - timeout: 5000, + timeout: 5, continueOnError: true, }, ], @@ -572,7 +573,7 @@ if (process.argv.includes('--json')) process.stdout.write(JSON.stringify(result) { type: 'command', command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/aqe-hook.cjs" post-command --command "$TOOL_INPUT_command" --success true --json', - timeout: 5000, + timeout: 5, continueOnError: true, }, ], @@ -583,7 +584,7 @@ if (process.argv.includes('--json')) process.stdout.write(JSON.stringify(result) { type: 'command', command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/aqe-hook.cjs" post-task --task-id "$TOOL_RESULT_agent_id" --agent "$TOOL_INPUT_subagent_type" --success true --description "$TOOL_INPUT_prompt" --json', - timeout: 5000, + timeout: 5, continueOnError: true, }, ], @@ -599,7 +600,7 @@ if (process.argv.includes('--json')) process.stdout.write(JSON.stringify(result) // var, so we let the CLI read stdin directly. type: 'command', command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/aqe-hook.cjs" route --json', - timeout: 5000, + timeout: 5, continueOnError: true, }, ], @@ -612,7 +613,7 @@ if (process.argv.includes('--json')) process.stdout.write(JSON.stringify(result) { type: 'command', command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/aqe-hook.cjs" session-start --session-id "$SESSION_ID" --json', - timeout: 10000, + timeout: 10, continueOnError: true, }, ], @@ -622,7 +623,7 @@ if (process.argv.includes('--json')) process.stdout.write(JSON.stringify(result) { type: 'command', command: 'sh -c \'exec node "${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/brain-checkpoint.cjs" verify --json\'', - timeout: 5000, + timeout: 5, continueOnError: true, }, ], @@ -635,7 +636,7 @@ if (process.argv.includes('--json')) process.stdout.write(JSON.stringify(result) { type: 'command', command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/aqe-hook.cjs" session-end --save-state --json', - timeout: 5000, + timeout: 5, continueOnError: true, }, ], @@ -649,7 +650,7 @@ if (process.argv.includes('--json')) process.stdout.write(JSON.stringify(result) // rows forever in direct-work sessions (no Task/Agent spawned). type: 'command', command: 'node "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/aqe-hook.cjs" post-route --success true --json', - timeout: 5000, + timeout: 5, continueOnError: true, }, ], @@ -659,7 +660,7 @@ if (process.argv.includes('--json')) process.stdout.write(JSON.stringify(result) { type: 'command', command: 'sh -c \'exec node "${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/brain-checkpoint.cjs" export --json\'', - timeout: 60000, + timeout: 60, continueOnError: true, }, ], diff --git a/src/init/phases/09-assets.ts b/src/init/phases/09-assets.ts index 877be765..4ab17f65 100644 --- a/src/init/phases/09-assets.ts +++ b/src/init/phases/09-assets.ts @@ -13,14 +13,16 @@ import { createSkillsInstaller } from '../skills-installer.js'; import { createAgentsInstaller } from '../agents-installer.js'; import { createN8nInstaller } from '../n8n-installer.js'; import { - installBrowserEngine, + installBrowserEngine, + detectBrowserEngine, + diagnosePlatform, DEFAULT_VIBIUM_SPEC, type BrowserEngineInstallResult, } from '../browser-engine-installer.js'; import { initializeOverlays } from '../../routing/qe-agent-registry.js'; import type { AQEInitConfig } from '../types.js'; -export interface AssetsResult { +export interface AssetsResult { skillsInstalled: number; agentsInstalled: number; n8nAgents: number; @@ -31,7 +33,8 @@ export interface AssetsResult { kiroSkills: number; kiroHooks: number; platformsConfigured: string[]; - browserEngine?: BrowserEngineInstallResult; + browserEngine?: BrowserEngineInstallResult; + codexGuidance?: { policy: 'full' | 'compact' | 'none'; ownedBytes: number }; } /** @@ -63,7 +66,8 @@ export class AssetsPhase extends BasePhase { let openCodeSkills = 0; let kiroAgents = 0; let kiroSkills = 0; - let kiroHooks = 0; + let kiroHooks = 0; + let codexGuidance: AssetsResult['codexGuidance']; if (options.upgrade) { context.services.log(` Upgrade mode: overwriting existing files`); @@ -128,15 +132,14 @@ export class AssetsPhase extends BasePhase { try { // Pre-flight check: if vibium is already on PATH, skip the loud // banner so we don't scare users on the common path. - const alreadyHere = installBrowserEngine({ - skip: false, - // Use a tiny timeout for the pre-flight detect-only call. The - // installer will short-circuit on already-installed without - // ever invoking npm. - timeoutMs: 5_000, - }); - if (alreadyHere.status === 'already-installed') { - browserEngine = alreadyHere; + const detected = detectBrowserEngine(); + if (detected.status === 'ready') { + browserEngine = { + status: 'already-installed', + version: detected.version, + packageSpec: DEFAULT_VIBIUM_SPEC, + platformHint: diagnosePlatform(), + }; context.services.log( ` Browser engine: vibium ${browserEngine.version} (already installed)` ); @@ -341,9 +344,11 @@ export class AssetsPhase extends BasePhase { overwrite: shouldOverwrite, installMcp: !options.noMcp, includeRuflo: options.withRuflo, + guidancePolicy: options.codexGuidance, memoryBackend: options.memoryBackend === 'memory' ? 'memory' : undefined, }); - const res = await installer.install(); + const res = await installer.install(); + codexGuidance = { policy: res.guidancePolicy, ownedBytes: res.ownedGuidanceBytes }; if (res.mcpConfigured) platformsConfigured.push('codex'); if (res.errors.length > 0) context.services.warn(`Codex warnings: ${res.errors.join(', ')}`); if (res.mcpConfigured) context.services.log(` Codex MCP: ${res.configPath}`); @@ -390,7 +395,8 @@ export class AssetsPhase extends BasePhase { kiroSkills, kiroHooks, platformsConfigured, - browserEngine, + browserEngine, + codexGuidance, }; } diff --git a/src/init/phases/phase-interface.ts b/src/init/phases/phase-interface.ts index 5ae383c5..dbf9223c 100644 --- a/src/init/phases/phase-interface.ts +++ b/src/init/phases/phase-interface.ts @@ -136,6 +136,8 @@ export interface InitOptions { withCodex?: boolean; /** Include optional Ruflo guidance and Codex lifecycle integration */ withRuflo?: boolean; + /** Codex AGENTS.md guidance policy. */ + codexGuidance?: 'full' | 'compact' | 'none'; /** Install Windsurf MCP config and rules */ withWindsurf?: boolean; /** Install Continue.dev MCP config and rules */ diff --git a/src/init/types.ts b/src/init/types.ts index 95f2e1e3..551df2c1 100644 --- a/src/init/types.ts +++ b/src/init/types.ts @@ -263,10 +263,14 @@ export interface InitResult { claudeMdGenerated: boolean; workersStarted: number; // Platform integration results - n8nInstalled?: { - agents: number; - skills: number; - }; + n8nInstalled?: { + agents: number; + skills: number; + }; + codexGuidance?: { + policy: 'full' | 'compact' | 'none'; + ownedBytes: number; + }; }; totalDurationMs: number; diff --git a/tests/unit/cli/init-command.test.ts b/tests/unit/cli/init-command.test.ts index d633e988..0dabdb96 100644 --- a/tests/unit/cli/init-command.test.ts +++ b/tests/unit/cli/init-command.test.ts @@ -92,6 +92,21 @@ describe('Init Command', () => { }); describe('Init Options Validation', () => { + it('should_parseCompactCodexGuidance_when_requested', async () => { + const program = new Command(); + const handler = new InitHandler(async () => undefined as never); + const context = {} as CLIContext; + const execute = vi.spyOn(handler, 'execute').mockResolvedValue(undefined); + handler.register(program, context); + + await program.parseAsync(['node', 'aqe', 'init', '--with-codex', '--codex-guidance', 'compact']); + + expect(execute).toHaveBeenCalledWith( + expect.objectContaining({ withCodex: true, codexGuidance: 'compact' }), + context, + ); + }); + it('should parse --no-statusline as an explicit opt-out', async () => { const program = new Command(); const handler = new InitHandler(async () => undefined as never); diff --git a/tests/unit/cli/platform.test.ts b/tests/unit/cli/platform.test.ts index c35b69eb..275193de 100644 --- a/tests/unit/cli/platform.test.ts +++ b/tests/unit/cli/platform.test.ts @@ -26,7 +26,7 @@ describe('verifyPlatformConfiguration', () => { function arrangeCompleteCodexInstall(projectRoot: string): void { write(projectRoot, '.codex/config.toml', '[mcp_servers.agentic-qe]\ncommand = "npx"\n'); - write(projectRoot, 'AGENTS.md', '# Quality Engineering Standards (Agentic QE)\n'); + write(projectRoot, 'AGENTS.md', '\n# Quality Engineering Standards (Agentic QE)\n\n'); write(projectRoot, '.codex/hooks.json', JSON.stringify({ hooks: { SessionStart: [{ hooks: [{ command: 'node .codex/hooks/aqe-codex-hook.cjs' }] }], @@ -51,7 +51,7 @@ describe('verifyPlatformConfiguration', () => { 'Config syntax', 'AQE MCP entry', 'Behavioral rules', - 'AQE instructions', + 'Codex guidance', 'Lifecycle hooks', 'Hook adapters', 'Hook runtimes', @@ -94,6 +94,36 @@ describe('verifyPlatformConfiguration', () => { }); }); + it('should_pass_when_compactCodexGuidanceIsSelected', () => { + const projectRoot = root(); + arrangeCompleteCodexInstall(projectRoot); + write(projectRoot, 'AGENTS.md', '\n# Agentic QE\nDiscover AQE tools and skills from their live schemas.\n\n'); + + const result = verifyPlatformConfiguration(projectRoot, 'codex', { guidancePolicy: 'compact' }); + + expect(result.passed).toBe(true); + expect(result.checks).toContainEqual({ + label: 'Codex guidance', + passed: true, + detail: 'selected=compact, detected=compact', + }); + }); + + it('should_pass_when_CodexGuidanceIsIntentionallyDisabled', () => { + const projectRoot = root(); + arrangeCompleteCodexInstall(projectRoot); + rmSync(join(projectRoot, 'AGENTS.md')); + + const result = verifyPlatformConfiguration(projectRoot, 'codex', { guidancePolicy: 'none' }); + + expect(result.passed).toBe(true); + expect(result.checks).toContainEqual({ + label: 'Behavioral rules', + passed: true, + detail: 'intentionally disabled', + }); + }); + it('should_requireRufloSkill_only_when_RufloIsRequested', () => { const projectRoot = root(); arrangeCompleteCodexInstall(projectRoot); diff --git a/tests/unit/hooks/aqe-hook-shim.test.ts b/tests/unit/hooks/aqe-hook-shim.test.ts index 88ad27da..ed47c3d6 100644 --- a/tests/unit/hooks/aqe-hook-shim.test.ts +++ b/tests/unit/hooks/aqe-hook-shim.test.ts @@ -29,9 +29,7 @@ function runShim( try { const stdout = execFileSync('node', [SHIM, ...args], { cwd: REPO_ROOT, - // AQE_HOOK_NPX=0 disables the npx fallback so the no-local-bundle cases - // no-op deterministically instead of reaching the network. - env: { ...process.env, CLAUDE_PROJECT_DIR: projectDir, AQE_HOOK_NPX: '0', ...extraEnv }, + env: { ...process.env, CLAUDE_PROJECT_DIR: projectDir, AQE_HOOK_BIN: '/definitely/missing/aqe', ...extraEnv }, encoding: 'utf-8', timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'], @@ -47,12 +45,29 @@ describe('aqe-hook.cjs resilient shim (#510 item 5)', () => { expect(existsSync(SHIM)).toBe(true); }); - it('exits 0 and emits nothing when no project-local AQE exists (never blocks a turn)', () => { + it('exits 0 and emits nothing when no AQE executable exists (never blocks a turn)', () => { const r = runShim(['route', '--task', 'x', '--json'], '/nonexistent-project-root'); expect(r.code).toBe(0); expect(r.stdout.trim()).toBe(''); }); + it('should_useResolvedManagedBundle_withoutInvokingPackageManager_when_projectBundleIsAbsent', () => { + const tmp = mkdtempSync(join(tmpdir(), 'aqe-shim-bin-')); + try { + const fakeAqe = join(tmp, 'managed-aqe-bundle.cjs'); + writeFileSync(fakeAqe, `console.log(JSON.stringify({ argv: process.argv.slice(2) }));`); + + const r = runShim(['session-end', '--json'], tmp, { + AQE_HOOK_BUNDLE: fakeAqe, + }); + + expect(r.code).toBe(0); + expect(JSON.parse(r.stdout)).toEqual({ argv: ['hooks', 'session-end', '--json'] }); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + it('exits 0 on an unknown subcommand and never leaks stderr to the caller', () => { // Copy the REAL built bundle into a tmpdir rather than pointing projectDir // at '.' (the repo) — using the repo root here would make this test's @@ -199,7 +214,7 @@ describe('aqe-hook.cjs resilient shim (#510 item 5)', () => { ); const stdout = execFileSync('node', [SHIM, 'route', '--json'], { cwd: REPO_ROOT, - env: { ...process.env, CLAUDE_PROJECT_DIR: tmp, AQE_HOOK_NPX: '0' }, + env: { ...process.env, CLAUDE_PROJECT_DIR: tmp, AQE_HOOK_BIN: '/definitely/missing/aqe' }, input: JSON.stringify({ prompt: 'find flaky tests in the coverage module' }), encoding: 'utf-8', timeout: 60_000, diff --git a/tests/unit/hooks/claude-hook-contract.test.ts b/tests/unit/hooks/claude-hook-contract.test.ts new file mode 100644 index 00000000..75bce113 --- /dev/null +++ b/tests/unit/hooks/claude-hook-contract.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const repoRoot = resolve(__dirname, '../../..'); + +describe('Claude lifecycle hook contract', () => { + it('should_avoidPackageManagerResolution_when_hooksRun', () => { + const hookShim = readFileSync(resolve(repoRoot, '.claude/hooks/aqe-hook.cjs'), 'utf8'); + const checkpoint = readFileSync(resolve(repoRoot, '.claude/helpers/brain-checkpoint.cjs'), 'utf8'); + + expect(hookShim).not.toMatch(/spawnSync\(['"]npx/); + expect(hookShim).not.toContain('--prefer-offline'); + expect(checkpoint).not.toMatch(/execFileSync\(\s*['"]npx/); + }); + + it('should_keepInternalHookBudgetBelowClaudeStopBudget', () => { + const hookShim = readFileSync(resolve(repoRoot, '.claude/hooks/aqe-hook.cjs'), 'utf8'); + + expect(hookShim).toContain("FAST_HOOKS.has(args[0]) ? 2500 : 4500"); + }); +}); diff --git a/tests/unit/init/browser-engine-installer.test.ts b/tests/unit/init/browser-engine-installer.test.ts index 1b77ad00..30c0a0e9 100644 --- a/tests/unit/init/browser-engine-installer.test.ts +++ b/tests/unit/init/browser-engine-installer.test.ts @@ -11,6 +11,7 @@ import type { SpawnSyncReturns } from 'node:child_process'; import { installBrowserEngine, + detectBrowserEngine, detectVibium, diagnosePlatform, DEFAULT_VIBIUM_SPEC, @@ -61,6 +62,50 @@ function makeSpawner( } describe('browser-engine-installer', () => { + describe('detectBrowserEngine', () => { + it('should_reportReady_when_cliAndBrowserPayloadAreUsable', () => { + const { spawner, calls } = makeSpawner((call) => { + if (call.args[0] === '--version') return canned({ stdout: 'v26.5.31\n' }); + if (call.args[0] === 'is-installed') return canned({ stdout: 'installed\n' }); + throw new Error(`unexpected call: ${call.bin} ${call.args.join(' ')}`); + }); + + const result = detectBrowserEngine(spawner); + + expect(result).toEqual({ status: 'ready', version: '26.5.31' }); + expect(calls).toEqual([ + { bin: 'vibium', args: ['--version'] }, + { bin: 'vibium', args: ['is-installed'] }, + ]); + }); + + it('should_reportCliMissing_when_versionProbeCannotStart', () => { + const { spawner, calls } = makeSpawner(() => enoent()); + + const result = detectBrowserEngine(spawner); + + expect(result.status).toBe('cli-missing'); + expect(calls).toEqual([{ bin: 'vibium', args: ['--version'] }]); + }); + + it('should_reportPayloadMissing_when_cliExistsWithoutBrowserPair', () => { + const { spawner, calls } = makeSpawner((call) => + call.args[0] === '--version' + ? canned({ stdout: 'v26.5.31\n' }) + : canned({ status: 1, stderr: 'Chrome and chromedriver revision mismatch' }) + ); + + const result = detectBrowserEngine(spawner); + + expect(result).toEqual({ + status: 'payload-missing', + version: '26.5.31', + message: 'Chrome and chromedriver revision mismatch', + }); + expect(calls.every((call) => call.bin === 'vibium')).toBe(true); + }); + }); + describe('detectVibium', () => { it('extracts semver from stdout v-prefixed output', () => { const { spawner, calls } = makeSpawner(() => canned({ stdout: 'v26.3.18\n' })); @@ -122,15 +167,20 @@ describe('browser-engine-installer', () => { expect(DEFAULT_VIBIUM_SPEC).toMatch(/^vibium@/); }); - it('returns already-installed when vibium is on PATH', () => { - const { spawner, calls } = makeSpawner(() => canned({ stdout: 'v26.3.18\n' })); + it('returns already-installed when vibium and its browser payload are ready', () => { + const { spawner, calls } = makeSpawner((call) => + call.args[0] === '--version' + ? canned({ stdout: 'v26.3.18\n' }) + : canned({ stdout: 'installed\n' }) + ); const result = installBrowserEngine({ spawner }); expect(result.status).toBe('already-installed'); // H1 fix: detectVibium now extracts the bare semver, dropping the v prefix. expect(result.version).toBe('26.3.18'); - // Only the detection call, no npm install attempt. - expect(calls).toHaveLength(1); - expect(calls[0]).toEqual({ bin: 'vibium', args: ['--version'] }); + expect(calls).toEqual([ + { bin: 'vibium', args: ['--version'] }, + { bin: 'vibium', args: ['is-installed'] }, + ]); }); it('returns npm-unavailable when npm is missing', () => { @@ -147,17 +197,19 @@ describe('browser-engine-installer', () => { }); it('installs via npm when vibium is missing but npm works', () => { - let vibiumChecks = 0; + let installed = false; const { spawner, calls } = makeSpawner((call) => { - if (call.bin === 'vibium') { - vibiumChecks += 1; - // First call: not installed. Second call (post-install): installed. - return vibiumChecks === 1 ? enoent() : canned({ stdout: 'v26.3.18' }); + if (call.bin === 'vibium' && call.args[0] === '--version') { + return installed ? canned({ stdout: 'v26.3.18' }) : enoent(); + } + if (call.bin === 'vibium' && call.args[0] === 'is-installed') { + return canned({ stdout: 'installed' }); } if (call.bin === 'npm' && call.args[0] === '--version') { return canned({ stdout: '10.2.0' }); } if (call.bin === 'npm' && call.args[0] === 'install') { + installed = true; return canned({ stdout: 'added 1 package' }); } throw new Error(`unexpected call: ${call.bin} ${call.args.join(' ')}`); @@ -167,11 +219,49 @@ describe('browser-engine-installer', () => { expect(result.status).toBe('installed'); // H1 fix: detectVibium now extracts the bare semver. expect(result.version).toBe('26.3.18'); - // vibium check, npm check, npm install, vibium re-check - expect(calls).toHaveLength(4); + // version check, npm check, npm install, version + payload verification + expect(calls).toHaveLength(5); expect(calls[2]).toEqual({ bin: 'npm', args: ['install', '-g', DEFAULT_VIBIUM_SPEC] }); }); + it('should_reinstall_when_cliExistsButBrowserPayloadIsMissing', () => { + let payloadChecks = 0; + const { spawner, calls } = makeSpawner((call) => { + if (call.bin === 'vibium' && call.args[0] === '--version') { + return canned({ stdout: 'v26.5.31' }); + } + if (call.bin === 'vibium' && call.args[0] === 'is-installed') { + payloadChecks += 1; + return payloadChecks === 1 + ? canned({ status: 1, stderr: 'browser assets missing' }) + : canned({ stdout: 'installed' }); + } + if (call.bin === 'npm' && call.args[0] === '--version') return canned({ stdout: '10.2.0' }); + if (call.bin === 'npm' && call.args[0] === 'install') return canned({ stdout: 'added 1 package' }); + throw new Error(`unexpected call: ${call.bin} ${call.args.join(' ')}`); + }); + + const result = installBrowserEngine({ spawner }); + + expect(result.status).toBe('installed'); + expect(calls.filter((call) => call.bin === 'npm' && call.args[0] === 'install')).toHaveLength(1); + }); + + it('should_reportInstallFailed_when_npmSucceedsButCliVerificationFails', () => { + const { spawner } = makeSpawner((call) => { + if (call.bin === 'vibium') return enoent(); + if (call.bin === 'npm' && call.args[0] === '--version') return canned({ stdout: '10.2.0' }); + if (call.bin === 'npm' && call.args[0] === 'install') return canned({ stdout: 'added 1 package' }); + throw new Error(`unexpected call: ${call.bin} ${call.args.join(' ')}`); + }); + + const result = installBrowserEngine({ spawner }); + + expect(result.status).toBe('install-failed'); + expect(result.version).toBeUndefined(); + expect(result.message).toMatch(/npm install -g vibium/i); + }); + it('returns install-failed when npm install exits non-zero', () => { const { spawner } = makeSpawner((call) => { if (call.bin === 'vibium') return enoent(); @@ -190,7 +280,8 @@ describe('browser-engine-installer', () => { const result = installBrowserEngine({ spawner, packageSpec: 'vibium@26.3.18' }); expect(result.packageSpec).toBe('vibium@26.3.18'); // Already-installed detection path should not trigger npm install. - expect(calls).toHaveLength(1); + expect(calls).toHaveLength(2); + expect(calls.every((call) => call.bin === 'vibium')).toBe(true); }); describe('Linux ARM64 platform hint (GAP-05)', () => { diff --git a/tests/unit/init/codex-guidance-policy.test.ts b/tests/unit/init/codex-guidance-policy.test.ts new file mode 100644 index 00000000..da781195 --- /dev/null +++ b/tests/unit/init/codex-guidance-policy.test.ts @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + CODEX_COMPACT_GUIDANCE_MAX_BYTES, + createCodexInstaller, +} from '../../../src/init/codex-installer.js'; + +const START = ''; +const END = ''; + +describe('Codex guidance policy', () => { + const roots: string[] = []; + + afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); + }); + + function projectRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'aqe-codex-guidance-')); + roots.push(root); + return root; + } + + it('should_writeCompactGuidance_withinDocumentedByteBudget', async () => { + const root = projectRoot(); + + const result = await createCodexInstaller({ + projectRoot: root, + installMcp: false, + guidancePolicy: 'compact', + }).install(); + + const content = readFileSync(join(root, 'AGENTS.md'), 'utf8'); + expect(result.guidancePolicy).toBe('compact'); + expect(result.ownedGuidanceBytes).toBe(Buffer.byteLength(content)); + expect(result.ownedGuidanceBytes).toBeLessThanOrEqual(CODEX_COMPACT_GUIDANCE_MAX_BYTES); + expect(content).toContain('Discover AQE tools and skills from their live schemas'); + }); + + it('should_removeEveryWellFormedOwnedBlock_andPreserveForeignBytes_when_policyIsNone', async () => { + const root = projectRoot(); + const existing = `alpha\r\n${START}\r\nold one\r\n${END}\r\nbeta\r\n${START}\r\nold two\r\n${END}\r\nomega\r\n`; + writeFileSync(join(root, 'AGENTS.md'), existing); + + const result = await createCodexInstaller({ + projectRoot: root, + installMcp: false, + guidancePolicy: 'none', + }).install(); + + expect(readFileSync(join(root, 'AGENTS.md'), 'utf8')).toBe('alpha\r\nbeta\r\nomega\r\n'); + expect(result.ownedGuidanceBytes).toBe(0); + expect(result.components.rules.status).toBe('updated'); + }); + + it('should_notCreateAgentsFile_when_nonePolicyHasNoOwnedBlock', async () => { + const root = projectRoot(); + + const result = await createCodexInstaller({ + projectRoot: root, + installMcp: false, + guidancePolicy: 'none', + }).install(); + + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false); + expect(result.ownedGuidanceBytes).toBe(0); + }); + + it('should_writeOnlyCompactBlock_when_existingAgentsFileIsEmpty', async () => { + const root = projectRoot(); + writeFileSync(join(root, 'AGENTS.md'), ''); + + await createCodexInstaller({ + projectRoot: root, + installMcp: false, + guidancePolicy: 'compact', + }).install(); + + expect(readFileSync(join(root, 'AGENTS.md'), 'utf8')).toMatch(/^/); + }); + + it('should_collapseDuplicateOwnedBlocks_when_compactPolicyIsApplied', async () => { + const root = projectRoot(); + writeFileSync(join(root, 'AGENTS.md'), `before\n${START}\nold one\n${END}\nmiddle\n${START}\nold two\n${END}\nafter\n`); + + await createCodexInstaller({ + projectRoot: root, + installMcp: false, + guidancePolicy: 'compact', + }).install(); + + const content = readFileSync(join(root, 'AGENTS.md'), 'utf8'); + expect(content.match(/BEGIN AGENTIC-QE CODEX/g)).toHaveLength(1); + expect(content).toContain('before\n'); + expect(content).toContain('middle\n'); + expect(content).toContain('after\n'); + }); + + it('should_preserveMalformedSentinel_when_policyIsNone', async () => { + const root = projectRoot(); + const malformed = `user bytes\n${START}\nunterminated user-visible text\n`; + writeFileSync(join(root, 'AGENTS.md'), malformed); + + const result = await createCodexInstaller({ + projectRoot: root, + installMcp: false, + guidancePolicy: 'none', + }).install(); + + expect(readFileSync(join(root, 'AGENTS.md'), 'utf8')).toBe(malformed); + expect(result.success).toBe(false); + expect(result.components.rules.status).toBe('failed'); + expect(result.errors[0]).toContain('Malformed Agentic QE Codex sentinel'); + }); + + it('should_remainByteIdempotent_withCrLf_when_compactPolicyRunsAgain', async () => { + const root = projectRoot(); + writeFileSync(join(root, 'AGENTS.md'), 'user line\r\n'); + const options = { projectRoot: root, installMcp: false, guidancePolicy: 'compact' as const }; + + await createCodexInstaller(options).install(); + const first = readFileSync(join(root, 'AGENTS.md'), 'utf8'); + await createCodexInstaller(options).install(); + const second = readFileSync(join(root, 'AGENTS.md'), 'utf8'); + + expect(second).toBe(first); + expect(second).toContain('\r\n'); + expect(second.match(/BEGIN AGENTIC-QE CODEX/g)).toHaveLength(1); + }); +}); diff --git a/tests/unit/init/codex-installer.test.ts b/tests/unit/init/codex-installer.test.ts index cac56bb9..f23c3dd4 100644 --- a/tests/unit/init/codex-installer.test.ts +++ b/tests/unit/init/codex-installer.test.ts @@ -219,6 +219,32 @@ describe('CodexInstaller', () => { ); }); + it('keeps AQE hooks when a generated group also contains Ruflo hooks', async () => { + mockExistsSync.mockImplementation((value: unknown) => { + const file = String(value); + if (file.startsWith(projectRoot)) return false; + return file.endsWith('/.codex/hooks.json') || file.endsWith('/.codex/hooks'); + }); + mockReaddirSync.mockReturnValue([]); + mockReadFileSync.mockImplementation((value: unknown) => String(value).endsWith('.codex/hooks.json') + ? JSON.stringify({ hooks: { SessionStart: [{ matcher: 'startup', hooks: [ + { command: 'node .codex/hooks/aqe-codex-hook.cjs session-start' }, + { command: 'node .codex/hooks/ruflo-codex-hook.cjs session-restore' }, + ] }] } }) + : ''); + + const { createCodexInstaller } = await import('../../../src/init/codex-installer.js'); + await createCodexInstaller({ projectRoot, installMcp: false }).install(); + + const hooksWrite = mockWriteFileSync.mock.calls.find( + (c: unknown[]) => String(c[0]) === join(projectRoot, '.codex', 'hooks.json'), + ); + const generated = JSON.parse(hooksWrite![1] as string); + expect(generated.hooks.SessionStart).toEqual([{ matcher: 'startup', hooks: [ + { command: 'node .codex/hooks/aqe-codex-hook.cjs session-start' }, + ] }]); + }); + it('does not write MCP config when installMcp is false', async () => { const { createCodexInstaller } = await import('../../../src/init/codex-installer.js'); const result = await createCodexInstaller({ projectRoot, installMcp: false }).install(); diff --git a/tests/unit/init/init-wizard.test.ts b/tests/unit/init/init-wizard.test.ts index a876c6f6..1f85ef3a 100644 --- a/tests/unit/init/init-wizard.test.ts +++ b/tests/unit/init/init-wizard.test.ts @@ -623,7 +623,8 @@ describe('InitOrchestrator', () => { expect(settingsContent.hooks.PreToolUse).toBeDefined(); expect(settingsContent.hooks.PostToolUse).toBeDefined(); expect(settingsContent.hooks.SessionStart).toBeDefined(); - expect(settingsContent.hooks.Stop).toBeDefined(); // Claude Code uses 'Stop' for session end + expect(settingsContent.hooks.Stop).toBeDefined(); // Claude Code uses 'Stop' for session end + expect(settingsContent.hooks.Stop.map((group: any) => group.hooks[0].timeout)).toEqual([5, 5]); expect(settingsContent.aqe).toBeDefined(); expect(settingsContent.aqe.hooksConfigured).toBe(true); });