Merge remote-tracking branch 'origin/main' into chore/node-22-floor

This commit is contained in:
Dragan Spiridonov
2026-09-06 12:37:00 +00:00
21 changed files with 647 additions and 134 deletions
+2 -1
View File
@@ -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+)/);
+13 -13
View File
@@ -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 {
+14
View File
@@ -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:
+32 -10
View File
@@ -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 = '<!-- BEGIN AGENTIC-QE CODEX -->';
const end = '<!-- END AGENTIC-QE CODEX -->';
const blocks = content.match(/<!-- BEGIN AGENTIC-QE CODEX -->[\s\S]*?<!-- END AGENTIC-QE CODEX -->/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 <mode>', '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<unknown> }) | undefined;
let factory: ((opts: { projectRoot: string; overwrite?: boolean; guidancePolicy?: 'full' | 'compact' | 'none' }) => { install: () => Promise<unknown> }) | 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 <mode>', '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}`;
+15 -5
View File
@@ -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 <mode>', '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 <mode> 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;
+50 -5
View File
@@ -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 };
}
+102 -31
View File
@@ -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<string, unknown[]>;
};
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(/<!-- BEGIN AGENTIC-QE CODEX -->/g)?.length ?? 0;
const ends = content.match(/<!-- END AGENTIC-QE CODEX -->/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 /<!-- BEGIN AGENTIC-QE CODEX -->[\s\S]*?<!-- END AGENTIC-QE CODEX -->(?:\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}`;
}
}
+11 -11
View File
@@ -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,
},
],
+5
View File
@@ -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<string, unknown> | undefined)?.configured as boolean ?? false,
claudeMdGenerated: (claudeMdResult?.data as Record<string, unknown> | undefined)?.generated as boolean ?? false,
workersStarted: (workersResult?.data as Record<string, unknown> | 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(),
+19 -18
View File
@@ -353,7 +353,7 @@ export class HooksPhase extends BasePhase<HooksResult> {
/**
* Install the resilient hook shim (aqe-hook.cjs) into .claude/hooks/.
* The generated settings.json hooks run `node .../aqe-hook.cjs <cmd>` 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<HooksResult> {
// 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<string, unknown[]> {
// 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,
},
],
+21 -15
View File
@@ -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<AssetsResult> {
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<AssetsResult> {
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<AssetsResult> {
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<AssetsResult> {
kiroSkills,
kiroHooks,
platformsConfigured,
browserEngine,
browserEngine,
codexGuidance,
};
}
+2
View File
@@ -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 */
+8 -4
View File
@@ -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;
+15
View File
@@ -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);
+32 -2
View File
@@ -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', '<!-- BEGIN AGENTIC-QE CODEX -->\n# Quality Engineering Standards (Agentic QE)\n<!-- END AGENTIC-QE CODEX -->\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', '<!-- BEGIN AGENTIC-QE CODEX -->\n# Agentic QE\nDiscover AQE tools and skills from their live schemas.\n<!-- END AGENTIC-QE CODEX -->\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);
+20 -5
View File
@@ -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,
@@ -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");
});
});
+104 -13
View File
@@ -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)', () => {
@@ -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 = '<!-- BEGIN AGENTIC-QE CODEX -->';
const END = '<!-- END AGENTIC-QE CODEX -->';
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(/^<!-- BEGIN AGENTIC-QE CODEX -->/);
});
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);
});
});
+26
View File
@@ -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();
+2 -1
View File
@@ -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);
});