From ab364db79a746334822d358d59b6a0a2f3ff128f Mon Sep 17 00:00:00 2001 From: Carlos Martins <676892+cmartins88@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:38:19 +0100 Subject: [PATCH] feat(codex): add native runtime support Install nForma workflows as Codex skills, convert custom agents and hooks, manage MCP configuration safely, and cover install/reinstall/uninstall behavior. --- .gitignore | 1 + CHANGELOG.md | 1 + README.md | 15 +- bin/codex-install.cjs | 389 ++++++++++++++++++++++++++++ bin/codex-install.test.cjs | 113 ++++++++ bin/install.js | 488 ++++++++++++++++++++++++++--------- hooks/dist/config-loader.js | 10 + package.json | 6 +- test/install-virgin.test.cjs | 228 +++++++++++++++- 9 files changed, 1129 insertions(+), 122 deletions(-) create mode 100644 bin/codex-install.cjs create mode 100644 bin/codex-install.test.cjs diff --git a/.gitignore b/.gitignore index 0c486f9c04..49e85911e3 100644 --- a/.gitignore +++ b/.gitignore @@ -186,3 +186,4 @@ nf-benchmark/baseline.json # they accumulate one file per session forever. Local state, never committed. .claude/nf-session-seen-*.flag .copilot/nf-session-seen-*.flag +.codex/nf-session-seen-*.flag diff --git a/CHANGELOG.md b/CHANGELOG.md index 07ee60e175..916e2569a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- feat(runtime): **native OpenAI Codex support.** The installer now accepts `--codex` for global or project-local installs, converts every `/nf:*` command into a discoverable `$nf:*` Codex skill, emits native TOML custom agents, writes Codex `hooks.json`, and manages nForma-owned MCP server blocks in `config.toml` without replacing user settings. Reinstall and uninstall paths migrate legacy Codex files, preserve unrelated configuration, and keep generated skills and MCP registrations idempotent. New conversion/unit coverage and a Codex virgin-install suite exercise install, hook adaptation, quorum context, MCP configuration, reinstall, and uninstall behavior. - feat(quorum): **opt-in thread persistence per slot** (`quorum.persistent_threads`, default false). The CE-5 full-convergence loop was stateless by design: each round spawns a fresh CLI invocation with prior-round outputs spliced into the prompt, and the convergence check diffs that prompt-injected state. With `persistent_threads: true`, slot CLIs keep a native conversation across rounds — `codex exec resume `, `-p --resume `, or `-c` for CWD-scoped continue (agy, kimi). Round 1 captures the session id from stdout (codex JSONL `thread.started`, claude `--output-format json`) and persists it to `.planning/quorum/sessions/-.json`; round 2+ reads the file and replaces the fresh argv template with the resume argv before `{prompt}` substitution. GC: every invocation sweeps session files >24h old, fail-open. Drop-guard mirrors the existing pattern (garbage `persistent_threads` warns + falls back to false; absent restores silently). New: `bin/quorum-resume.cjs` (helper), `bin/quorum-sessions-store.cjs` (scratch store), `bin/quorum-resume.test.cjs` (28 PURE tests, red-proven). Also fixes a latent path bug: `bin/call-quorum-slot.cjs` required `./config-loader` which resolves to a nonexistent path; corrected to `../hooks/config-loader`. **Empirical findings (3 task classes, codex/gpt-5.5, judged on the FINAL design produced, not on word-reuse proxies):** the prevailing assumption "thread memory always helps" is **not what the data shows**. Across a 2-round essay expansion, a 3-round plan review, and a 5-round complex synthesis, the **default `false` (stateless) is at least as good as `true`** (persistent) in 2 of 3 task classes and ties the third. `FRESH` (no context at all) always refuses with a safety message — better than hallucinating. The right rule of thumb: enable persistent when the reasoning chain spans 10+ rounds, the orchestrator's prompt-injection window is too narrow for prior rounds, or the model is *evolving* its own prior reasoning rather than answering fresh; otherwise stateless preserves the documented CE-5 "team has nothing left to add" semantics and beats the model's noisier thread memory in mid-complexity tasks. **No change for existing callers** — default `false` preserves the current stateless CE-5 semantics. diff --git a/README.md b/README.md index f3c90cb516..5318b233fe 100644 --- a/README.md +++ b/README.md @@ -156,9 +156,15 @@ npm install @huggingface/transformers > Runs a local [all-MiniLM-L6-v2](https://huggingface.co/Xenova/all-MiniLM-L6-v2) model on CPU (~23MB). No API keys or cloud calls. The `/nf:proximity` pipeline automatically uses embeddings when this dependency is present. The installer prompts you to choose: -1. **Runtime** — Claude Code, OpenCode, Gemini, or all +1. **Runtime** — Claude Code, OpenCode, Gemini, Codex, or another supported runtime 2. **Location** — Global (all projects) or local (current project only) +> [!NOTE] +> **Using Codex?** Choose Codex in the installer, or run +> `npx @nforma.ai/nforma --codex --global`. nForma installs native Codex skills, +> custom agents, hooks, and MCP servers. Invoke workflows with `$nf:help`, +> `$nf:new-project`, and the other `$nf:*` skill names. + ### 2. Use Skip-Permissions Mode nForma is designed for frictionless automation. Run Claude Code with: @@ -201,6 +207,7 @@ If you prefer not to use that flag, add this to your project's `.claude/settings ``` You should see the full command list. If not, restart your runtime to reload commands. +In Codex, use `$nf:help` (or open `/skills` and select `nf:help`) instead. ### 4. Set Up Your Quorum (Optional) @@ -272,12 +279,16 @@ npx @nforma.ai/nforma --opencode --global # Install to ~/.config/opencode/ # Gemini CLI npx @nforma.ai/nforma --gemini --global # Install to ~/.gemini/ +# OpenAI Codex +npx @nforma.ai/nforma --codex --global # Skills: ~/.agents/skills; config: ~/.codex/ +npx @nforma.ai/nforma --codex --local # Skills: ./.agents/skills; config: ./.codex/ + # All runtimes npx @nforma.ai/nforma --all --global # Install to all directories ``` Use `--global` (`-g`) or `--local` (`-l`) to skip the location prompt. -Use `--claude`, `--opencode`, `--gemini`, or `--all` to skip the runtime prompt. +Use `--claude`, `--opencode`, `--gemini`, `--codex`, or `--all` to skip the runtime prompt. diff --git a/bin/codex-install.cjs b/bin/codex-install.cjs new file mode 100644 index 0000000000..d8a8171405 --- /dev/null +++ b/bin/codex-install.cjs @@ -0,0 +1,389 @@ +'use strict'; + +/** + * Native Codex installation helpers. + * + * Codex does not load Claude-style commands or Markdown agents. It discovers + * reusable workflows as skills and custom agents as standalone TOML files. + * These helpers keep the installer conversion deterministic and testable. + */ + +const fs = require('fs'); +const path = require('path'); +const { argsTemplateFor } = require('./provider-arg-templates.cjs'); + +const MCP_BLOCK_BEGIN = '# BEGIN nForma managed MCP servers'; +const MCP_BLOCK_END = '# END nForma managed MCP servers'; + +function splitFrontmatter(content) { + if (!content.startsWith('---')) { + return { frontmatter: '', body: content }; + } + + const lines = content.split(/\r?\n/); + if (lines[0].trim() !== '---') { + return { frontmatter: '', body: content }; + } + + const end = lines.findIndex((line, index) => index > 0 && line.trim() === '---'); + if (end === -1) { + return { frontmatter: '', body: content }; + } + + return { + frontmatter: lines.slice(1, end).join('\n'), + body: lines.slice(end + 1).join('\n').replace(/^\n+/, ''), + }; +} + +function readFrontmatterField(frontmatter, field) { + const lines = frontmatter.split(/\r?\n/); + const fieldPattern = new RegExp(`^${field}:\\s*(.*)$`); + + for (let index = 0; index < lines.length; index++) { + const match = lines[index].match(fieldPattern); + if (!match) continue; + + const value = match[1].trim(); + if (value !== '>' && value !== '|') { + return value.replace(/^(['"])(.*)\1$/, '$2'); + } + + const continuation = []; + for (index += 1; index < lines.length; index++) { + if (!/^\s+/.test(lines[index])) break; + continuation.push(lines[index].trim()); + } + return value === '>' ? continuation.join(' ') : continuation.join('\n'); + } + + return ''; +} + +function normalizeSkillName(name, fallbackName) { + let result = (name || fallbackName || '').trim(); + if (!result.startsWith('nf:')) { + result = `nf:${result.replace(/^nf[-:]/, '')}`; + } + return result; +} + +function yamlScalar(value) { + return JSON.stringify(String(value)); +} + +function adaptCodexMarkdown(content, pathPrefix) { + const prefix = pathPrefix.endsWith('/') ? pathPrefix : `${pathPrefix}/`; + return content + .replace(/~\/\.claude\//g, prefix) + .replace(/\.\/\.claude\//g, prefix) + .replace(/\/nf:/g, '$nf:') + .replace(/(\/agents\/nf-[a-z0-9-]+)\.md\b/gi, '$1.toml'); +} + +function convertMarkdownToCodexSkill(content, fallbackName) { + const { frontmatter, body } = splitFrontmatter(content); + const name = normalizeSkillName(readFrontmatterField(frontmatter, 'name'), fallbackName); + const description = readFrontmatterField(frontmatter, 'description') + || `Run the ${name} nForma workflow in Codex.`; + + const adapter = [ + '', + 'This nForma workflow is running in Codex.', + `- Treat text after \`$${name}\` as \`$ARGUMENTS\`.`, + '- Read every file referenced with `@` in an `` before acting.', + '- Interpret Claude-style `Task(...)` blocks as native Codex subagent delegation. Use the custom agent named by `subagent_type` and preserve the prompt, parallelism, and result-handling instructions.', + '- Invoke another nForma workflow by mentioning its `$nf:*` skill.', + '- Map Claude-specific tool names to the equivalent available Codex tools while preserving the workflow intent and safety gates.', + '', + '', + ].join('\n'); + + return { + name, + description, + content: [ + '---', + `name: ${yamlScalar(name)}`, + `description: ${yamlScalar(description.replace(/\/nf:/g, '$nf:'))}`, + '---', + '', + adapter + body, + ].join('\n').replace(/\s+$/, '') + '\n', + }; +} + +function convertMarkdownToCodexAgent(content, fallbackName) { + const { frontmatter, body } = splitFrontmatter(content); + const name = readFrontmatterField(frontmatter, 'name') || fallbackName; + const description = (readFrontmatterField(frontmatter, 'description') + || `nForma custom agent: ${name}`).replace(/\/nf:/g, '$nf:'); + const instructions = [ + 'You are a native Codex custom agent converted from an nForma agent definition.', + 'Map any Claude-specific tool names to equivalent Codex tools. Preserve all role boundaries, required reads, output contracts, and safety rules.', + '', + body, + ].join('\n').replace(/\s+$/, '') + '\n'; + + return [ + `name = ${JSON.stringify(name)}`, + `description = ${JSON.stringify(description)}`, + `developer_instructions = ${JSON.stringify(instructions)}`, + '', + ].join('\n'); +} + +function collectMarkdownFiles(rootDir) { + const files = []; + if (!fs.existsSync(rootDir)) return files; + + for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) { + const fullPath = path.join(rootDir, entry.name); + if (entry.isDirectory()) { + files.push(...collectMarkdownFiles(fullPath)); + } else if (entry.name.endsWith('.md')) { + files.push(fullPath); + } + } + return files; +} + +function removeOwnedSkillDirectories(skillsDir) { + if (!fs.existsSync(skillsDir)) return; + for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const skillPath = path.join(skillsDir, entry.name, 'SKILL.md'); + if (!fs.existsSync(skillPath)) continue; + const { frontmatter } = splitFrontmatter(fs.readFileSync(skillPath, 'utf8')); + if (readFrontmatterField(frontmatter, 'name').startsWith('nf:')) { + fs.rmSync(path.join(skillsDir, entry.name), { recursive: true, force: true }); + } + } +} + +function installCodexSkills({ + commandsDir, + packagedSkillsDir, + destinationDirs, + pathPrefix, + transformContent = content => content, +}) { + const skillsByName = new Map(); + + for (const commandPath of collectMarkdownFiles(commandsDir)) { + const fallbackName = path.relative(commandsDir, commandPath) + .replace(/\\/g, '-') + .replace(/\.md$/, ''); + const adapted = adaptCodexMarkdown(transformContent(fs.readFileSync(commandPath, 'utf8')), pathPrefix); + const skill = convertMarkdownToCodexSkill(adapted, fallbackName); + skillsByName.set(skill.name, skill); + } + + // Hand-authored packaged skills are more focused than their command wrappers, + // so they intentionally win when the two sources expose the same skill name. + if (fs.existsSync(packagedSkillsDir)) { + for (const entry of fs.readdirSync(packagedSkillsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const skillPath = path.join(packagedSkillsDir, entry.name, 'SKILL.md'); + if (!fs.existsSync(skillPath)) continue; + const adapted = adaptCodexMarkdown(transformContent(fs.readFileSync(skillPath, 'utf8')), pathPrefix); + const skill = convertMarkdownToCodexSkill(adapted, entry.name); + skillsByName.set(skill.name, skill); + } + } + + const uniqueDestinations = [...new Set(destinationDirs.map(dir => path.resolve(dir)))]; + for (const destination of uniqueDestinations) { + fs.mkdirSync(destination, { recursive: true }); + removeOwnedSkillDirectories(destination); + for (const skill of skillsByName.values()) { + const directoryName = skill.name.replace(':', '-').replace(/[^a-zA-Z0-9_-]/g, '-'); + const skillDir = path.join(destination, directoryName); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skill.content, 'utf8'); + } + } + + return skillsByName.size; +} + +function removeCodexSkills(skillsDir) { + if (!fs.existsSync(skillsDir)) return 0; + const before = fs.readdirSync(skillsDir).length; + removeOwnedSkillDirectories(skillsDir); + return before - fs.readdirSync(skillsDir).length; +} + +function normalizeDetectedProvider(provider) { + const family = provider.mainTool || provider.name; + if (!family) return null; + const argsTemplate = provider.args_template || argsTemplateFor(family); + if (!argsTemplate) return null; + const rawName = (provider.name || family).replace(/^nforma-/, ''); + const slotName = rawName === family ? `${family}-1` : rawName; + const aliases = { + codex: [{ + name: 'review', + description: 'Send a review prompt to the Codex CLI.', + args_template: argsTemplate, + }], + copilot: [{ + name: 'ask', + description: 'Send a prompt to the GitHub Copilot CLI.', + args_template: argsTemplate, + }], + }; + + return { + ...provider, + name: `nforma-${slotName}`, + provider: provider.provider || 'auto-detected', + type: provider.type || 'subprocess', + description: provider.description || `Auto-detected ${family} on PATH`, + mainTool: family, + cli: provider.resolvedPath || provider.cli || family, + display_type: provider.display_type || `${family}-cli`, + display_provider: provider.display_provider || family.charAt(0).toUpperCase() + family.slice(1), + args_template: argsTemplate, + ...(provider.extraTools + ? { extraTools: provider.extraTools } + : (aliases[family] ? { extraTools: aliases[family] } : {})), + ...(provider.env ? { env: provider.env } : {}), + ...(provider.model ? { model: provider.model } : {}), + }; +} + +function ensureCodexProviders(providersPath, detectedProviders, selectedSlots = null) { + let data = { providers: [] }; + try { + if (fs.existsSync(providersPath)) { + data = JSON.parse(fs.readFileSync(providersPath, 'utf8')); + } + } catch (_) { + data = { providers: [] }; + } + + const existing = (Array.isArray(data.providers) ? data.providers : []) + .map(normalizeDetectedProvider) + .filter(Boolean); + const byName = new Map(existing.map(provider => [provider.name, provider])); + + for (const rawProvider of detectedProviders || []) { + const provider = normalizeDetectedProvider(rawProvider); + if (!provider) continue; + if (selectedSlots + && !selectedSlots.includes(provider.name) + && !selectedSlots.includes(rawProvider.name) + && !selectedSlots.includes(provider.mainTool)) { + continue; + } + if (!byName.has(provider.name)) byName.set(provider.name, provider); + } + + const isSelected = provider => { + if (!selectedSlots) return true; + const unprefixedName = provider.name.replace(/^nforma-/, ''); + const familyName = unprefixedName.replace(/-\d+$/, ''); + return selectedSlots.includes(provider.name) + || selectedSlots.includes(unprefixedName) + || selectedSlots.includes(provider.mainTool) + || selectedSlots.includes(familyName); + }; + const active = [...byName.values()].filter(provider => + provider && provider.name && provider.active !== false && isSelected(provider) + ); + fs.mkdirSync(path.dirname(providersPath), { recursive: true }); + fs.writeFileSync(providersPath, JSON.stringify({ ...data, providers: [...byName.values()] }, null, 2) + '\n', 'utf8'); + return active; +} + +function tomlString(value) { + return JSON.stringify(String(value)); +} + +function renderCodexMcpBlock(providers, targetDir, providersPath) { + if (!providers.length) return ''; + const unifiedServer = path.join(targetDir, 'nf-bin', 'unified-mcp-server.mjs'); + const lines = [MCP_BLOCK_BEGIN]; + + for (const provider of providers) { + const serverName = provider.name.startsWith('nforma-') ? provider.name : `nforma-${provider.name}`; + lines.push( + '', + `[mcp_servers.${tomlString(serverName)}]`, + 'command = "node"', + `args = [${tomlString(unifiedServer)}]`, + '', + `[mcp_servers.${tomlString(serverName)}.env]`, + `PROVIDER_SLOT = ${tomlString(provider.name)}`, + `UNIFIED_PROVIDERS_CONFIG = ${tomlString(providersPath)}`, + ); + } + lines.push('', MCP_BLOCK_END); + return lines.join('\n'); +} + +function replaceManagedMcpBlock(content, block) { + const escapedBegin = MCP_BLOCK_BEGIN.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const escapedEnd = MCP_BLOCK_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const managedPattern = new RegExp(`(?:^|\\n)${escapedBegin}[\\s\\S]*?${escapedEnd}(?:\\n|$)`); + const withoutManaged = content.replace(managedPattern, '\n').replace(/\s+$/, ''); + return [withoutManaged, block].filter(Boolean).join(withoutManaged ? '\n\n' : '') + '\n'; +} + +function configureCodexMcp(configPath, providers, targetDir, providersPath) { + let existing = ''; + try { + existing = fs.readFileSync(configPath, 'utf8'); + } catch (_) { + // A missing config is a normal first-install case. + } + const block = renderCodexMcpBlock(providers, targetDir, providersPath); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, replaceManagedMcpBlock(existing, block), 'utf8'); + + const requiredModels = {}; + const activeSlots = []; + for (const provider of providers) { + const family = provider.mainTool || provider.name.replace(/-\d+$/, ''); + const serverName = provider.name.startsWith('nforma-') ? provider.name : `nforma-${provider.name}`; + activeSlots.push(provider.name); + if (!requiredModels[family]) { + requiredModels[family] = { + tool_prefix: `mcp__${serverName}__`, + required: true, + }; + } + } + return { requiredModels, activeSlots }; +} + +function removeCodexMcp(configPath) { + if (!fs.existsSync(configPath)) return false; + const existing = fs.readFileSync(configPath, 'utf8'); + if (!existing.includes(MCP_BLOCK_BEGIN)) return false; + const updated = replaceManagedMcpBlock(existing, ''); + if (updated === existing) return false; + if (updated.trim()) { + fs.writeFileSync(configPath, updated, 'utf8'); + } else { + fs.unlinkSync(configPath); + } + return true; +} + +module.exports = { + MCP_BLOCK_BEGIN, + MCP_BLOCK_END, + adaptCodexMarkdown, + configureCodexMcp, + convertMarkdownToCodexAgent, + convertMarkdownToCodexSkill, + ensureCodexProviders, + installCodexSkills, + normalizeDetectedProvider, + removeCodexMcp, + removeCodexSkills, + replaceManagedMcpBlock, + splitFrontmatter, +}; diff --git a/bin/codex-install.test.cjs b/bin/codex-install.test.cjs new file mode 100644 index 0000000000..86138be0a2 --- /dev/null +++ b/bin/codex-install.test.cjs @@ -0,0 +1,113 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { + MCP_BLOCK_BEGIN, + MCP_BLOCK_END, + convertMarkdownToCodexAgent, + convertMarkdownToCodexSkill, + ensureCodexProviders, + normalizeDetectedProvider, + replaceManagedMcpBlock, +} = require('./codex-install.cjs'); + +test('converts a Claude command into a minimal Codex skill', () => { + const source = `--- +name: nf:plan-phase +description: Plan a phase with /nf:verify-work +argument-hint: "[N]" +allowed-tools: + - Read + - Task +--- +Run /nf:verify-work with $ARGUMENTS. +Task(subagent_type="nf-planner") +`; + const skill = convertMarkdownToCodexSkill(source.replace(/\/nf:/g, '$nf:'), 'plan-phase'); + + assert.equal(skill.name, 'nf:plan-phase'); + assert.equal(skill.description, 'Plan a phase with $nf:verify-work'); + assert.match(skill.content, /name: "nf:plan-phase"/); + assert.doesNotMatch(skill.content, /allowed-tools|argument-hint/); + assert.match(skill.content, /native Codex subagent delegation/); + assert.match(skill.content, /\$nf:verify-work/); +}); + +test('normalizes unprefixed command names for Codex skill discovery', () => { + const skill = convertMarkdownToCodexSkill(`--- +name: close-formal-gaps +description: Close gaps +--- +Do it. +`, 'close-formal-gaps'); + assert.equal(skill.name, 'nf:close-formal-gaps'); +}); + +test('converts folded Markdown agent metadata to valid TOML string fields', () => { + const source = `--- +name: nf-reviewer +description: > + Review correctness and + preserve safety gates. +tools: Read, Bash +--- +Follow the role. +`; + const toml = convertMarkdownToCodexAgent(source, 'nf-reviewer'); + + assert.match(toml, /^name = "nf-reviewer"$/m); + assert.match(toml, /^description = "Review correctness and preserve safety gates\."$/m); + assert.match(toml, /^developer_instructions = "/m); + assert.doesNotMatch(toml, /^tools =/m); +}); + +test('replaces only the nForma-managed MCP TOML block idempotently', () => { + const userConfig = 'model = "gpt-5.4"\n\n[features]\nhooks = true\n'; + const block = `${MCP_BLOCK_BEGIN} +[mcp_servers."nforma-gemini-1"] +command = "node" +${MCP_BLOCK_END}`; + const first = replaceManagedMcpBlock(userConfig, block); + const second = replaceManagedMcpBlock(first, block); + + assert.equal(first, second); + assert.match(first, /model = "gpt-5\.4"/); + assert.match(first, /\[features\]/); + assert.equal(first.split(MCP_BLOCK_BEGIN).length - 1, 1); +}); + +test('normalizes an auto-detected CLI into an nForma provider slot', () => { + const provider = normalizeDetectedProvider({ + name: 'codex', + cli: 'codex', + resolvedPath: '/usr/local/bin/codex', + }); + + assert.equal(provider.name, 'nforma-codex-1'); + assert.equal(provider.mainTool, 'codex'); + assert.equal(provider.cli, '/usr/local/bin/codex'); + assert.deepEqual(provider.args_template, ['exec', '{prompt}']); + assert.equal(provider.extraTools[0].name, 'review'); +}); + +test('keeps an explicitly selected numbered slot after adding the managed prefix', t => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nf-codex-provider-')); + t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + const providersPath = path.join(tmpDir, 'providers.json'); + fs.writeFileSync(providersPath, JSON.stringify({ + providers: [{ + name: 'gemini-1', + mainTool: 'gemini', + cli: 'gemini', + args_template: ['-p', '{prompt}'], + }], + })); + + const active = ensureCodexProviders(providersPath, [], ['gemini-1']); + + assert.deepEqual(active.map(provider => provider.name), ['nforma-gemini-1']); +}); diff --git a/bin/install.js b/bin/install.js index 6df37f48fa..51bbeadf68 100755 --- a/bin/install.js +++ b/bin/install.js @@ -5,6 +5,15 @@ const path = require('path'); const os = require('os'); const readline = require('readline'); const crypto = require('crypto'); +const { + adaptCodexMarkdown, + configureCodexMcp, + convertMarkdownToCodexAgent, + ensureCodexProviders, + installCodexSkills, + removeCodexMcp, + removeCodexSkills, +} = require('./codex-install.cjs'); // Colors const cyan = '\x1b[36m'; @@ -317,10 +326,13 @@ function getGlobalDir(runtime, explicitDir = null) { } if (runtime === 'codex') { - // Codex: --config-dir > CODEX_CONFIG_DIR > ~/.codex + // Codex: --config-dir > CODEX_HOME > legacy CODEX_CONFIG_DIR > ~/.codex if (explicitDir) { return expandTilde(explicitDir); } + if (process.env.CODEX_HOME) { + return expandTilde(process.env.CODEX_HOME); + } if (process.env.CODEX_CONFIG_DIR) { return expandTilde(process.env.CODEX_CONFIG_DIR); } @@ -1082,6 +1094,43 @@ function writeSettings(settingsPath, settings) { fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); } +/** + * Remove nForma-owned registrations written to settings.json by legacy Codex + * installs. Preserve every unrelated user setting and hook. + */ +function cleanupLegacyCodexSettings(targetDir) { + const legacyPath = path.join(targetDir, 'settings.json'); + if (!fs.existsSync(legacyPath)) return false; + const legacy = readSettings(legacyPath); + let modified = false; + + if (legacy.hooks) { + for (const event of Object.keys(legacy.hooks)) { + if (!Array.isArray(legacy.hooks[event])) continue; + const before = legacy.hooks[event].length; + legacy.hooks[event] = legacy.hooks[event].filter(group => + !(group.hooks || []).some(h => /[/\\]hooks[/\\]nf-/.test(h.command || '')) + ); + if (legacy.hooks[event].length < before) modified = true; + if (legacy.hooks[event].length === 0) delete legacy.hooks[event]; + } + if (Object.keys(legacy.hooks).length === 0) delete legacy.hooks; + } + + if (legacy.statusLine && (legacy.statusLine.command || '').includes('nf-statusline')) { + delete legacy.statusLine; + modified = true; + } + + if (!modified) return false; + if (Object.keys(legacy).length === 0) { + fs.unlinkSync(legacyPath); + } else { + writeSettings(legacyPath, legacy); + } + return true; +} + // Cache for attribution settings (populated once per runtime during install) const attributionCache = new Map(); @@ -1560,6 +1609,7 @@ function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) { */ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime) { const isOpencode = runtime === 'opencode'; + const isCodex = runtime === 'codex'; const dirName = getDirName(runtime); // Clean install: remove existing destination to prevent orphaned files @@ -1596,6 +1646,8 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime) { // Replace extension with .toml const tomlPath = destPath.replace(/\.md$/, '.toml'); fs.writeFileSync(tomlPath, tomlContent); + } else if (isCodex) { + fs.writeFileSync(destPath, adaptCodexMarkdown(content, pathPrefix)); } else { fs.writeFileSync(destPath, content); } @@ -1709,6 +1761,7 @@ function cleanupOrphanedHooks(settings) { */ function uninstall(isGlobal, runtime = 'claude') { const isOpencode = runtime === 'opencode'; + const isCodex = runtime === 'codex'; const dirName = getDirName(runtime); // Get the target directory based on runtime and install type @@ -1768,6 +1821,18 @@ function uninstall(isGlobal, runtime = 'claude') { } } + if (isCodex) { + const officialSkillsDir = isGlobal + ? path.join(os.homedir(), '.agents', 'skills') + : path.join(process.cwd(), '.agents', 'skills'); + const removedSkills = removeCodexSkills(path.join(targetDir, 'skills')) + + removeCodexSkills(officialSkillsDir); + if (removedSkills > 0) { + removedCount++; + console.log(` ${green}✓${reset} Removed ${removedSkills} native Codex skill directories`); + } + } + // 2. Remove nf directory const nfDir = path.join(targetDir, 'nf'); if (fs.existsSync(nfDir)) { @@ -1790,13 +1855,13 @@ function uninstall(isGlobal, runtime = 'claude') { console.log(); } - // 3. Remove NF agents (nf-*.md files only) + // 3. Remove NF agents const agentsDir = path.join(targetDir, 'agents'); if (fs.existsSync(agentsDir)) { const files = fs.readdirSync(agentsDir); let agentCount = 0; for (const file of files) { - if (file.startsWith('nf-') && file.endsWith('.md')) { + if (file.startsWith('nf-') && (file.endsWith('.md') || (isCodex && file.endsWith('.toml')))) { fs.unlinkSync(path.join(agentsDir, file)); agentCount++; } @@ -1819,11 +1884,13 @@ function uninstall(isGlobal, runtime = 'claude') { // 4. Remove NF hooks const hooksDir = path.join(targetDir, 'hooks'); if (fs.existsSync(hooksDir)) { - const nfHooks = [ - 'nf-statusline.js', 'nf-check-update.js', 'nf-check-update.sh', - 'nf-prompt.js', 'nf-stop.js', 'nf-circuit-breaker.js', - 'nf-session-start.js', 'nf-check-update.js', 'nf-statusline.js', - ]; + const nfHooks = isCodex + ? fs.readdirSync(hooksDir).filter(file => file.startsWith('nf-')) + : [ + 'nf-statusline.js', 'nf-check-update.js', 'nf-check-update.sh', + 'nf-prompt.js', 'nf-stop.js', 'nf-circuit-breaker.js', + 'nf-session-start.js', 'nf-check-update.js', 'nf-statusline.js', + ]; let hookCount = 0; for (const hook of nfHooks) { const hookPath = path.join(hooksDir, hook); @@ -1854,12 +1921,24 @@ function uninstall(isGlobal, runtime = 'claude') { } } - // 6. Clean up settings.json (remove NF hooks and statusline) - const settingsPath = path.join(targetDir, 'settings.json'); + // 6. Clean up the runtime hook configuration. + const settingsPath = path.join(targetDir, isCodex ? 'hooks.json' : 'settings.json'); if (fs.existsSync(settingsPath)) { let settings = readSettings(settingsPath); let settingsModified = false; + if (isCodex && settings.hooks) { + for (const event of Object.keys(settings.hooks)) { + const before = Array.isArray(settings.hooks[event]) ? settings.hooks[event].length : 0; + if (!before) continue; + settings.hooks[event] = settings.hooks[event].filter(entry => + !(entry.hooks && entry.hooks.some(h => /[/\\]hooks[/\\]nf-/.test(h.command || ''))) + ); + if (settings.hooks[event].length < before) settingsModified = true; + if (settings.hooks[event].length === 0) delete settings.hooks[event]; + } + } + // Remove NF statusline if it references our hook (nf-* or old nf-*) if (settings.statusLine && settings.statusLine.command && (settings.statusLine.command.includes('nf-statusline') || settings.statusLine.command.includes('nf-statusline'))) { @@ -2030,11 +2109,20 @@ function uninstall(isGlobal, runtime = 'claude') { } if (settingsModified) { - writeSettings(settingsPath, settings); + if (isCodex && Object.keys(settings).every(key => key === 'description')) { + fs.unlinkSync(settingsPath); + } else { + writeSettings(settingsPath, settings); + } removedCount++; } } + if (isCodex && removeCodexMcp(path.join(targetDir, 'config.toml'))) { + removedCount++; + console.log(` ${green}✓${reset} Removed nForma MCP servers from config.toml`); + } + // 6. For OpenCode, clean up permissions from opencode.json if (isOpencode) { // For local uninstalls, clean up ./.opencode/opencode.json @@ -2366,10 +2454,11 @@ function validateHookPaths(hooksDest, targetDir) { * @param {string} runtime - 'claude', 'opencode', or 'gemini' * @returns {string[]} List of error messages (empty if pass) */ -function validateStructuralIntegrity(targetDir, runtime) { +function validateStructuralIntegrity(targetDir, runtime, codexSkillsDir = null) { const errors = []; const isOpencode = runtime === 'opencode'; const isGemini = runtime === 'gemini'; + const isCodex = runtime === 'codex'; // Helper: Recursive file count const countFiles = (dir, filter) => { @@ -2386,11 +2475,26 @@ function validateStructuralIntegrity(targetDir, runtime) { } return count; }; + const collectFilesNamed = (dir, fileName) => { + if (!fs.existsSync(dir)) return []; + const matches = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + matches.push(...collectFilesNamed(fullPath, fileName)); + } else if (entry.name === fileName) { + matches.push(fullPath); + } + } + return matches; + }; // 1. Verify Commands (Expected: 60) const EXPECTED_COMMANDS = 60; let commandCount = 0; - if (isOpencode) { + if (isCodex) { + commandCount = countFiles(codexSkillsDir || path.join(targetDir, 'skills'), f => f === 'SKILL.md'); + } else if (isOpencode) { commandCount = countFiles(path.join(targetDir, 'command'), f => f.startsWith('nf-') && f.endsWith('.md')); } else { const ext = isGemini ? '.toml' : '.md'; @@ -2403,7 +2507,8 @@ function validateStructuralIntegrity(targetDir, runtime) { // 2. Verify Agents (Expected: 17 for Claude/OpenCode, ~13 for Gemini) const EXPECTED_AGENTS = isGemini ? 13 : 17; - const agentCount = countFiles(path.join(targetDir, 'agents'), f => f.startsWith('nf-') && f.endsWith('.md')); + const agentExtension = isCodex ? '.toml' : '.md'; + const agentCount = countFiles(path.join(targetDir, 'agents'), f => f.startsWith('nf-') && f.endsWith(agentExtension)); if (agentCount < EXPECTED_AGENTS) { errors.push(`Agent count mismatch: expected at least ${EXPECTED_AGENTS}, found ${agentCount}`); } @@ -2432,7 +2537,15 @@ function validateStructuralIntegrity(targetDir, runtime) { } // 4. Runtime Transformation Check - if (isOpencode) { + if (isCodex) { + const skillsDir = codexSkillsDir || path.join(targetDir, 'skills'); + for (const skillFile of collectFilesNamed(skillsDir, 'SKILL.md')) { + const content = fs.readFileSync(skillFile, 'utf8'); + if (content.includes('/nf:')) { + errors.push(`Codex transformation failed: found /nf: reference in ${skillFile}`); + } + } + } else if (isOpencode) { const cmdDir = path.join(targetDir, 'command'); if (fs.existsSync(cmdDir)) { const cmdFiles = fs.readdirSync(cmdDir).filter(f => f.startsWith('nf-') && f.endsWith('.md')); @@ -2494,7 +2607,7 @@ function generateManifest(dir, baseDir) { /** * Write file manifest after installation for future modification detection */ -function writeManifest(configDir) { +function writeManifest(configDir, runtime = 'claude') { const nfDir = path.join(configDir, 'nf'); const commandsDir = path.join(configDir, 'commands', 'nf'); const agentsDir = path.join(configDir, 'agents'); @@ -2512,7 +2625,8 @@ function writeManifest(configDir) { } if (fs.existsSync(agentsDir)) { for (const file of fs.readdirSync(agentsDir)) { - if (file.startsWith('nf-') && file.endsWith('.md')) { + const expectedExtension = runtime === 'codex' ? '.toml' : '.md'; + if (file.startsWith('nf-') && file.endsWith(expectedExtension)) { manifest.files['agents/' + file] = fileHash(path.join(agentsDir, file)); } } @@ -2592,6 +2706,7 @@ function reportLocalPatches(configDir) { function install(isGlobal, runtime = 'claude') { const isOpencode = runtime === 'opencode'; const isGemini = runtime === 'gemini'; + const isCodex = runtime === 'codex'; const dirName = getDirName(runtime); const src = path.join(__dirname, '..'); @@ -2599,6 +2714,11 @@ function install(isGlobal, runtime = 'claude') { const targetDir = isGlobal ? getGlobalDir(runtime, explicitConfigDir) : path.join(process.cwd(), dirName); + const codexSkillsDir = isCodex + ? (isGlobal + ? path.join(os.homedir(), '.agents', 'skills') + : path.join(process.cwd(), '.agents', 'skills')) + : null; const locationLabel = isGlobal ? targetDir.replace(os.homedir(), '~') @@ -2635,9 +2755,19 @@ function install(isGlobal, runtime = 'claude') { // Clean up orphaned files from previous versions cleanupOrphanedFiles(targetDir); - // OpenCode uses 'command/' (singular) with flat structure - // Claude Code & Gemini use 'commands/' (plural) with nested structure - if (isOpencode) { + // Codex workflows are installed as skills later in this function. + // OpenCode uses command/ (singular); Claude Code and Gemini use commands/. + if (isCodex) { + const legacyCommandsDir = path.join(targetDir, 'commands', 'nf'); + if (fs.existsSync(legacyCommandsDir)) { + fs.rmSync(legacyCommandsDir, { recursive: true, force: true }); + log(` ${green}✓${reset} Removed legacy Codex commands/nf/`); + } + const legacySkillCount = removeCodexSkills(path.join(targetDir, 'skills')); + if (legacySkillCount > 0) { + log(` ${green}✓${reset} Removed ${legacySkillCount} legacy Codex skill director${legacySkillCount === 1 ? 'y' : 'ies'}`); + } + } else if (isOpencode) { // OpenCode: flat structure in command/ directory const commandDir = path.join(targetDir, 'command'); fs.mkdirSync(commandDir, { recursive: true }); @@ -2682,10 +2812,10 @@ function install(isGlobal, runtime = 'claude') { const agentsDest = path.join(targetDir, 'agents'); fs.mkdirSync(agentsDest, { recursive: true }); - // Remove old nf-*.md files before copying new ones + // Remove old nForma agent files before copying new ones. if (fs.existsSync(agentsDest)) { for (const file of fs.readdirSync(agentsDest)) { - if (file.startsWith('nf-') && file.endsWith('.md')) { + if (file.startsWith('nf-') && (file.endsWith('.md') || (isCodex && file.endsWith('.toml')))) { fs.unlinkSync(path.join(agentsDest, file)); } } @@ -2705,8 +2835,16 @@ function install(isGlobal, runtime = 'claude') { content = convertClaudeToOpencodeFrontmatter(content); } else if (isGemini) { content = convertClaudeToGeminiAgent(content); + } else if (isCodex) { + content = adaptCodexMarkdown(content, pathPrefix); + } + if (isCodex) { + const agentName = entry.name.replace(/\.md$/, ''); + const toml = convertMarkdownToCodexAgent(content, agentName); + fs.writeFileSync(path.join(agentsDest, `${agentName}.toml`), toml, 'utf8'); + } else { + fs.writeFileSync(path.join(agentsDest, entry.name), content); } - fs.writeFileSync(path.join(agentsDest, entry.name), content); } } if (verifyInstalled(agentsDest, 'agents')) { @@ -2728,9 +2866,23 @@ function install(isGlobal, runtime = 'claude') { } } - // Copy skills to skills directory (Claude Code / OpenCode only) + // Copy packaged skills. Codex converts every command into a native skill + // under the current ~/.agents/skills discovery location. const skillsSrc = path.join(agentsSrc, 'skills'); - if (fs.existsSync(skillsSrc)) { + if (isCodex) { + const skillCount = installCodexSkills({ + commandsDir: path.join(src, 'commands', 'nf'), + packagedSkillsDir: skillsSrc, + destinationDirs: [codexSkillsDir], + pathPrefix, + transformContent: content => processAttribution(content, getCommitAttribution(runtime)), + }); + if (skillCount > 0) { + log(` ${green}✓${reset} Installed ${skillCount} native Codex skills to ${codexSkillsDir}`); + } else { + failures.push('skills'); + } + } else if (fs.existsSync(skillsSrc)) { const skillsDest = path.join(targetDir, 'skills'); let skillCount = 0; for (const entry of fs.readdirSync(skillsSrc, { withFileTypes: true })) { @@ -2798,6 +2950,29 @@ function install(isGlobal, runtime = 'claude') { if (entry.endsWith('.js')) { let content = fs.readFileSync(srcFile, 'utf8'); content = content.replace(/'\.claude'/g, configDirReplacement); + if (isCodex) { + content = adaptCodexMarkdown(content, pathPrefix) + .replace(/Task\(subagent_type=/g, 'CodexSubagent(agent=') + .replace(/Claude's vote/g, "Codex's vote") + .replace(/Claude \+/g, 'Codex +'); + if (entry === 'nf-prompt.js') { + // Keep the mature command-matching logic unchanged internally: + // normalize Codex's $nf:* invocation to the slash form it parses. + content = content.replace( + "const prompt = (input.prompt || '').trim();", + "const prompt = (input.prompt || '').trim().replace(/^\\s*\\$nf:/, '/nf:');" + ); + } + if (entry === 'config-loader.js') { + // Codex MCP server names use an nforma- prefix to avoid colliding + // with user-defined servers. Strip it only for provider-family + // lookup while retaining it in the actual MCP tool prefix. + content = content.replace( + "const family = slotName.replace(/-\\d+$/, '');", + "const family = slotName.replace(/^nforma-/, '').replace(/-\\d+$/, '');" + ); + } + } fs.writeFileSync(destFile, content); } else { fs.copyFileSync(srcFile, destFile); @@ -3024,6 +3199,7 @@ function install(isGlobal, runtime = 'claude') { const nfPythonEnv = path.join(os.homedir(), '.claude', 'nf-python-env'); const nfPython = path.join(nfPythonEnv, 'bin', 'python'); try { + let uvAvailable = true; // Check uv availability first — install it if missing const uvCheck = _spawnRiver('which', ['uv'], { timeout: 3000 }); if (uvCheck.status !== 0) { @@ -3031,32 +3207,40 @@ function install(isGlobal, runtime = 'claude') { const uvInstall = _spawnRiver('curl', ['-sSL', 'https://astral.sh/uv/install.sh'], { timeout: 30000 }); if (uvInstall.status !== 0) { log(` ${yellow}⚠${reset} uv install failed — skipping River ML`); - return; - } - // Reload PATH so uv is found immediately after install - const newPath = [...new Set([path.join(os.homedir(), '.local', 'bin'), ...process.env.PATH.split(':')])].join(':'); - const uvPathCheck = _spawnRiver('which', ['uv'], { timeout: 3000, env: { ...process.env, PATH: newPath } }); - if (uvPathCheck.status !== 0) { - log(` ${yellow}⚠${reset} uv not in PATH after install — skipping River ML`); - return; + uvAvailable = false; + } else { + // Reload PATH so uv is found immediately after install. + const currentPath = process.env.PATH || ''; + const newPath = [...new Set([ + path.join(os.homedir(), '.local', 'bin'), + ...currentPath.split(path.delimiter).filter(Boolean), + ])].join(path.delimiter); + const uvPathCheck = _spawnRiver('which', ['uv'], { timeout: 3000, env: { ...process.env, PATH: newPath } }); + if (uvPathCheck.status !== 0) { + log(` ${yellow}⚠${reset} uv not in PATH after install — skipping River ML`); + uvAvailable = false; + } } } - // Create venv first if missing — River needs the file to be active - if (!fs.existsSync(nfPythonEnv)) { - _spawnRiver('uv', ['venv', nfPythonEnv], { timeout: 30000 }); - } - const riverCheck = _spawnRiver(nfPython, ['-c', 'import river'], { timeout: 3000 }); - if (riverCheck.status !== 0) { - console.log(` ${cyan}↓${reset} Installing River ML (uv)...`); - const riverInstall = _spawnRiver('uv', ['pip', 'install', '--python', nfPythonEnv, 'river'], { timeout: 60000 }); - if (riverInstall.status === 0) { - console.log(` ${green}✓${reset} River ML installed`); - } else { - const errOut = riverInstall.stderr ? riverInstall.stderr.toString().slice(0, 120) : ''; - console.log(` ${yellow}⚠${reset} River ML install skipped: uv returned non-zero${errOut ? ' (' + errOut + ')' : ''}`); + + if (uvAvailable) { + // Create venv first if missing — River needs the file to be active + if (!fs.existsSync(nfPythonEnv)) { + _spawnRiver('uv', ['venv', nfPythonEnv], { timeout: 30000 }); + } + const riverCheck = _spawnRiver(nfPython, ['-c', 'import river'], { timeout: 3000 }); + if (riverCheck.status !== 0) { + console.log(` ${cyan}↓${reset} Installing River ML (uv)...`); + const riverInstall = _spawnRiver('uv', ['pip', 'install', '--python', nfPythonEnv, 'river'], { timeout: 60000 }); + if (riverInstall.status === 0) { + console.log(` ${green}✓${reset} River ML installed`); + } else { + const errOut = riverInstall.stderr ? riverInstall.stderr.toString().slice(0, 120) : ''; + console.log(` ${yellow}⚠${reset} River ML install skipped: uv returned non-zero${errOut ? ' (' + errOut + ')' : ''}`); + } } + // status === 0: River already importable — skip silently } - // status === 0: River already importable — skip silently } catch (e) { // uv or nfPython not found or timed out — skip silently (fail-open) } @@ -3117,7 +3301,7 @@ function install(isGlobal, runtime = 'claude') { } // Deep structural validation - const structuralErrors = validateStructuralIntegrity(targetDir, runtime); + const structuralErrors = validateStructuralIntegrity(targetDir, runtime, codexSkillsDir); if (structuralErrors.length > 0) { console.log(`\n ${yellow}Structural validation failed:${reset}`); for (const err of structuralErrors) { @@ -3133,14 +3317,31 @@ function install(isGlobal, runtime = 'claude') { process.exit(1); } - // Configure statusline and hooks in settings.json - // Gemini shares same hook system as Claude Code for now - const settingsPath = path.join(targetDir, 'settings.json'); + // Codex discovers hooks.json next to config.toml. Other supported runtimes + // continue using their settings.json hook configuration. + const settingsPath = path.join(targetDir, isCodex ? 'hooks.json' : 'settings.json'); const settings = cleanupOrphanedHooks(readSettings(settingsPath)); - const statuslineCommand = isGlobal + if (isCodex && !settings.description) { + settings.description = 'nForma lifecycle, safety, and quorum hooks for Codex.'; + } + if (isCodex && cleanupLegacyCodexSettings(targetDir)) { + log(` ${green}✓${reset} Removed legacy nForma hooks from Codex settings.json`); + } + if (isCodex && settings.hooks) { + const incompatibleSessionHooks = ['nf-session-start', 'nf-slot-health-probe']; + if (settings.hooks.SessionStart) { + settings.hooks.SessionStart = settings.hooks.SessionStart.filter(group => + !(group.hooks || []).some(h => + incompatibleSessionHooks.some(name => (h.command || '').includes(name)) + ) + ); + if (settings.hooks.SessionStart.length === 0) delete settings.hooks.SessionStart; + } + } + const statuslineCommand = (isGlobal || isCodex) ? buildHookCommand(targetDir, 'nf-statusline.js') : 'node ' + dirName + '/hooks/nf-statusline.js'; - const updateCheckCommand = isGlobal + const updateCheckCommand = (isGlobal || isCodex) ? buildHookCommand(targetDir, 'nf-check-update.js') : 'node ' + dirName + '/hooks/nf-check-update.js'; @@ -3199,43 +3400,47 @@ function install(isGlobal, runtime = 'claude') { log(` ${green}✓${reset} Configured update check hook`); } - // Register nForma session-start secret sync hook - const hasNfSessionStartHook = settings.hooks.SessionStart.some(entry => - entry.hooks && entry.hooks.some(h => h.command && h.command.includes('nf-session-start')) - ); - if (!hasNfSessionStartHook) { - settings.hooks.SessionStart.push({ - hooks: [ - { - type: 'command', - command: buildHookCommand(targetDir, 'nf-session-start.js') - } - ] - }); - log(` ${green}✓${reset} Configured nForma secret sync hook (SessionStart)`); - } + // These two SessionStart hooks mutate/read Claude's ~/.claude.json MCP + // registry. Codex uses config.toml, so registering them there would be a + // misleading no-op and could create unrelated Claude state. + if (!isCodex) { + const hasNfSessionStartHook = settings.hooks.SessionStart.some(entry => + entry.hooks && entry.hooks.some(h => h.command && h.command.includes('nf-session-start')) + ); + if (!hasNfSessionStartHook) { + settings.hooks.SessionStart.push({ + hooks: [ + { + type: 'command', + command: buildHookCommand(targetDir, 'nf-session-start.js') + } + ] + }); + log(` ${green}✓${reset} Configured nForma secret sync hook (SessionStart)`); + } - // Register nForma slot-health probe (populates ~/.claude/nf/slot-health.json - // for the statusline's quorum slots line). Probe runs in parallel and finishes - // in ~400ms for a typical 5-7 slot setup; capped at 15s. - const hasSlotHealthProbeHook = settings.hooks.SessionStart.some(entry => - entry.hooks && entry.hooks.some(h => h.command && h.command.includes('nf-slot-health-probe')) - ); - if (!hasSlotHealthProbeHook) { - settings.hooks.SessionStart.push({ - hooks: [ - { - type: 'command', - command: buildHookCommand(targetDir, 'nf-slot-health-probe.js'), - timeout: 15 - } - ] - }); - log(` ${green}✓${reset} Configured nForma slot-health probe (SessionStart)`); + // Register nForma slot-health probe (populates the statusline cache). + const hasSlotHealthProbeHook = settings.hooks.SessionStart.some(entry => + entry.hooks && entry.hooks.some(h => h.command && h.command.includes('nf-slot-health-probe')) + ); + if (!hasSlotHealthProbeHook) { + settings.hooks.SessionStart.push({ + hooks: [ + { + type: 'command', + command: buildHookCommand(targetDir, 'nf-slot-health-probe.js'), + timeout: 15 + } + ] + }); + log(` ${green}✓${reset} Configured nForma slot-health probe (SessionStart)`); + } } // INST-05: Warn (yellow) if quorum MCP servers are absent — runs every install - warnMissingMcpServers(); + if (!isCodex) { + warnMissingMcpServers(); + } // ── MIGRATION: remove old nf-* hook entries ───────────────────────── // Old installs registered hooks as nf-prompt.js, nf-stop.js, nf-circuit-breaker.js. @@ -3252,7 +3457,7 @@ function install(isGlobal, runtime = 'claude') { Stop: 'nf-stop', PreToolUse: 'nf-circuit-breaker', PostToolUse: ['nf-spec-regen', 'nf-context-monitor', 'nf-context-monitor'], - SessionStart: ['nf-check-update'], + SessionStart: isCodex ? [] : ['nf-check-update'], }; for (const [event, oldNames] of Object.entries(OLD_HOOK_MAP)) { if (settings.hooks[event]) { @@ -3273,8 +3478,8 @@ function install(isGlobal, runtime = 'claude') { // ── Non-Session hooks: skip for Gemini (only supports SessionStart + SessionEnd) ── if (!isGemini) { - // Register nForma UserPromptSubmit hook (quorum injection) - // MUST be in settings.json — plugin hooks.json silently discards UserPromptSubmit output (GitHub #10225) + // Register nForma UserPromptSubmit hook (quorum injection). Claude requires + // settings.json; Codex loads the same event from its native hooks.json. if (!settings.hooks.UserPromptSubmit) settings.hooks.UserPromptSubmit = []; const hasNfPromptHook = settings.hooks.UserPromptSubmit.some(entry => entry.hooks && entry.hooks.some(h => h.command && h.command.includes('nf-prompt')) @@ -3286,19 +3491,23 @@ function install(isGlobal, runtime = 'claude') { log(` ${green}✓${reset} Configured nForma quorum injection hook (UserPromptSubmit)`); } - // Register nForma Stop hook (quorum gate — verifies quorum evidence before Claude delivers planning output) - if (!settings.hooks.Stop) settings.hooks.Stop = []; - const hasNfStopHook = settings.hooks.Stop.some(entry => - entry.hooks && entry.hooks.some(h => h.command && h.command.includes('nf-stop')) - ); - if (!hasNfStopHook) { - settings.hooks.Stop.push({ - hooks: [{ type: 'command', command: buildHookCommand(targetDir, 'nf-stop.js'), timeout: 30 }] - }); - log(` ${green}✓${reset} Configured nForma quorum gate hook (Stop)`); + // nf-stop parses Claude's transcript representation. Codex explicitly + // treats transcript format as unstable, so keep quorum fail-open there + // instead of risking false blocks until a native evidence adapter exists. + if (!isCodex) { + if (!settings.hooks.Stop) settings.hooks.Stop = []; + const hasNfStopHook = settings.hooks.Stop.some(entry => + entry.hooks && entry.hooks.some(h => h.command && h.command.includes('nf-stop')) + ); + if (!hasNfStopHook) { + settings.hooks.Stop.push({ + hooks: [{ type: 'command', command: buildHookCommand(targetDir, 'nf-stop.js'), timeout: 30 }] + }); + log(` ${green}✓${reset} Configured nForma quorum gate hook (Stop)`); + } } - // INST-08: Register nForma circuit breaker hook (PreToolUse — Claude Code only) + // INST-08: Register nForma circuit breaker hook (PreToolUse) if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = []; const hasCircuitBreakerHook = settings.hooks.PreToolUse.some(entry => entry.hooks && entry.hooks.some(h => h.command && h.command.includes('nf-circuit-breaker')) @@ -3454,10 +3663,30 @@ function install(isGlobal, runtime = 'claude') { log(` ${green}✓${reset} Configured nForma session-end hook (SessionEnd)`); } - // MULTI-03: ensureMcpSlotsFromProviders() MUST run before buildActiveSlots() because - // buildActiveSlots() discovers slots from existing mcpServers keys in ~/.claude.json. - // This ensures codex-2, gemini-2, and all other provider slots have MCP entries before quorum_active discovery. - ensureMcpSlotsFromProviders(); + // Codex stores MCP servers in config.toml. Claude-compatible runtimes keep + // their existing ~/.claude.json slot synchronization path. + let codexMcp = null; + if (isCodex) { + const installedProvidersPath = path.join(targetDir, 'nf-bin', 'providers.json'); + const codexProviders = ensureCodexProviders(installedProvidersPath, providers, selectedProviderSlots); + codexMcp = configureCodexMcp( + path.join(targetDir, 'config.toml'), + codexProviders, + targetDir, + installedProvidersPath + ); + log(` ${green}✓${reset} Configured ${codexProviders.length} nForma MCP server${codexProviders.length === 1 ? '' : 's'} in config.toml`); + } else { + // MULTI-03: this MUST run before buildActiveSlots(), which discovers + // slots from existing mcpServers keys in ~/.claude.json. + ensureMcpSlotsFromProviders(); + } + const detectRequiredModels = () => isCodex + ? codexMcp.requiredModels + : buildRequiredModelsFromMcp(); + const detectActiveSlots = () => isCodex + ? codexMcp.activeSlots + : buildActiveSlots(); // Write nForma config — skip if exists unless --redetect-mcps flag set const nfConfigPath = path.join(targetDir, 'nf.json'); @@ -3470,7 +3699,7 @@ function install(isGlobal, runtime = 'claude') { if (!fs.existsSync(nfConfigPath)) { // Build config with auto-detected MCP prefixes - const detectedModels = buildRequiredModelsFromMcp(); + const detectedModels = detectRequiredModels(); const nfConfig = { quorum_commands: [ 'plan-phase', 'new-project', 'new-milestone', @@ -3478,9 +3707,10 @@ function install(isGlobal, runtime = 'claude') { ], fail_mode: 'open', required_models: detectedModels, - quorum_active: buildActiveSlots(), // COMP-04: populated from all discovered slots - // Generated from detected prefixes — behavioral instructions match structural enforcement - quorum_instructions: buildQuorumInstructions(detectedModels), + quorum_active: detectActiveSlots(), // COMP-04: populated from all discovered slots + // Codex uses quorum_active so nf-prompt emits native subagent dispatch. + // Other runtimes retain direct MCP instructions for backward compatibility. + ...(isCodex ? {} : { quorum_instructions: buildQuorumInstructions(detectedModels) }), // INST-09: Must match DEFAULT_CONFIG.circuit_breaker in hooks/config-loader.js circuit_breaker: { oscillation_depth: 3, @@ -3489,7 +3719,7 @@ function install(isGlobal, runtime = 'claude') { }; fs.writeFileSync(nfConfigPath, JSON.stringify(nfConfig, null, 2) + '\n', 'utf8'); - log(` ${green}✓${reset} Wrote nForma config with detected MCP prefixes (~/.claude/nf.json)`); + log(` ${green}✓${reset} Wrote nForma config with detected MCP prefixes (${nfConfigPath})`); log(` ${green}✓${reset} Wrote quorum_active (${nfConfig.quorum_active.length} slots) to nf.json`); } else { // INST-06: print active config summary on reinstall @@ -3526,7 +3756,7 @@ function install(isGlobal, runtime = 'claude') { // COMP-04: Backfill quorum_active if absent or empty (same pattern as circuit_breaker backfill) if (!existingConfig.quorum_active || existingConfig.quorum_active.length === 0) { - const discoveredSlots = buildActiveSlots(); + const discoveredSlots = detectActiveSlots(); if (discoveredSlots.length > 0) { existingConfig.quorum_active = discoveredSlots; fs.writeFileSync(nfConfigPath, JSON.stringify(existingConfig, null, 2) + '\n', 'utf8'); @@ -3538,7 +3768,7 @@ function install(isGlobal, runtime = 'claude') { // Only runs if quorum_active is already set (non-empty); new slots are appended, existing preserved if (existingConfig.quorum_active && existingConfig.quorum_active.length > 0) { const { addSlotToQuorumActive } = require('./migrate-to-slots.cjs'); - const allCurrentSlots = buildActiveSlots(); + const allCurrentSlots = detectActiveSlots(); const newSlots = allCurrentSlots.filter(s => !existingConfig.quorum_active.includes(s)); for (const newSlot of newSlots) { const result = addSlotToQuorumActive(newSlot, nfConfigPath); @@ -3555,8 +3785,21 @@ function install(isGlobal, runtime = 'claude') { } } + if (isCodex && settings.hooks) { + // Codex currently parses but does not execute asynchronous command hooks. + // Run the two accounting hooks synchronously so they are not silently skipped. + for (const groups of Object.values(settings.hooks)) { + if (!Array.isArray(groups)) continue; + for (const group of groups) { + for (const hook of group.hooks || []) { + delete hook.async; + } + } + } + } + // Write file manifest for future modification detection - writeManifest(targetDir); + writeManifest(targetDir, runtime); log(` ${green}✓${reset} Wrote file manifest (${MANIFEST_NAME})`); // Report any backed-up local patches @@ -3571,8 +3814,9 @@ function install(isGlobal, runtime = 'claude') { */ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude', isGlobal = true, multiRuntime = false) { const isOpencode = runtime === 'opencode'; + const isCodex = runtime === 'codex'; - if (shouldInstallStatusline && !isOpencode) { + if (shouldInstallStatusline && !isOpencode && !isCodex) { settings.statusLine = { type: 'command', command: statuslineCommand @@ -3582,6 +3826,13 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS // PRIO-01: Sort hooks by priority for deterministic execution order const nfConfig = (() => { + if (isCodex) { + try { + return JSON.parse(fs.readFileSync(path.join(path.dirname(settingsPath), 'nf.json'), 'utf8')); + } catch { + return { hook_priorities: {} }; + } + } try { const { loadConfig } = require('../hooks/config-loader'); return loadConfig(process.cwd()); @@ -3616,7 +3867,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS if (runtime === 'trae') program = 'Trae'; if (runtime === 'cline') program = 'Cline'; - const command = isOpencode ? '/nf-help' : '/nf:help'; + const command = isOpencode ? '/nf-help' : (isCodex ? '$nf:help' : '/nf:help'); let nudge = ''; if (runtime === 'claude' && !hasClaudeMcpAgents()) { @@ -3633,7 +3884,7 @@ ${nudge} `); // Best-effort formal tools — always runs after success banner, never blocks main install - if (!hasUninstall && !hasFormal) { + if (!hasUninstall && !hasFormal && process.env.NF_INSTALL_SKIP_FORMAL !== '1') { const { spawnSync: _formalSpawn } = require('child_process'); const formalScript = path.join(__dirname, 'install-formal-tools.cjs'); if (fs.existsSync(formalScript)) { @@ -4160,7 +4411,7 @@ function printMultiRuntimeSummary(runtimes, isGlobal) { ${green}Done!${reset} Installed for ${cyan}${runtimeNames.length}${reset} runtime${runtimeNames.length > 1 ? 's' : ''}: ${runtimeNames.map(n => `${green}✓${reset} ${n}`).join('\n ')} ${nudge} - Run ${cyan}/nf:help${reset} (or ${cyan}/nf-help${reset} in OpenCode) to get started. + Run ${cyan}/nf:help${reset} (${cyan}/nf-help${reset} in OpenCode, ${cyan}$nf:help${reset} in Codex) to get started. ${dim}TUI dashboard:${reset} ${cyan}npx @nforma.ai/nforma tui${reset} ${dim}Or install globally:${reset} ${cyan}npm install -g @nforma.ai/nforma${reset} → then run ${cyan}nforma${reset} @@ -4169,7 +4420,7 @@ ${nudge} `); // Best-effort formal tools — always runs after success banner, never blocks main install - if (!hasUninstall && !hasFormal) { + if (!hasUninstall && !hasFormal && process.env.NF_INSTALL_SKIP_FORMAL !== '1') { const { spawnSync: _formalSpawn } = require('child_process'); const formalScript = path.join(__dirname, 'install-formal-tools.cjs'); if (fs.existsSync(formalScript)) { @@ -4188,6 +4439,9 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) { for (const runtime of runtimes) { const result = install(isGlobal, runtime); + if (!result || typeof result !== 'object') { + throw new Error(`Internal installer error: ${RUNTIME_LABELS[runtime] || runtime} did not return an installation result`); + } results.push(result); } diff --git a/hooks/dist/config-loader.js b/hooks/dist/config-loader.js index 74d5c421a3..0833a3d1a6 100644 --- a/hooks/dist/config-loader.js +++ b/hooks/dist/config-loader.js @@ -144,6 +144,7 @@ const DEFAULT_CONFIG = { min_live_voters: 2, // minimum live voters for valid consensus (issue #170) full_convergence: true, // CE-5: loop until unanimous AND no new improvements (all reviewed by all) max_rounds: 10, // R3.3: total rounds (incl. Round 1) before escalate. Backstop for CE-5 convergence; raise for "round until dry". + persistent_threads: false, // opt-in: if true, the quorum reuses each slot's CLI session across rounds via --resume / -c. Default false preserves current stateless CE-5 semantics. }, // agent_config: per-slot metadata. // auth_type: "sub" (subscription, flat-fee) | "api" (pay-per-token) @@ -450,6 +451,15 @@ function validateConfig(config) { if (!Number.isInteger(config.quorum.max_rounds) || config.quorum.max_rounds < 1) { config.quorum.max_rounds = DEFAULT_CONFIG.quorum.max_rounds; } + // Same partial-merge pattern for the opt-in thread-persistence flag: an absent + // value is a partial-merge drop (NOT a user error), restore silently. A garbage + // explicit value (`"yes"`, `1`) is a real error — coerce to the default. + if (typeof config.quorum.persistent_threads !== 'boolean') { + if (config.quorum.persistent_threads !== undefined) { + process.stderr.write('[nf] WARNING: nf.json: quorum.persistent_threads must be boolean; defaulting to false\n'); + } + config.quorum.persistent_threads = DEFAULT_CONFIG.quorum.persistent_threads; + } } // Validate agent_config diff --git a/package.json b/package.json index afd2e4d7a1..03fc1cea79 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,8 @@ "devtools", "gemini", "gemini-cli", + "codex", + "openai-codex", "opencode" ], "author": "nForma AI", @@ -114,9 +116,9 @@ "secrets:history": "bash scripts/secret-audit.sh", "test": "npm run test:ci && npm run test:tui && npm run test:formal", "test:changed": "node bin/test-changed.cjs", - "test:ci": "node scripts/lint-isolation.js && node scripts/verify-hooks-sync.cjs && node --test hooks/nf-precompact.test.js hooks/nf-context-monitor.test.js hooks/nf-session-start.test.js bin/conformance-schema.test.cjs bin/resolve-cli.test.cjs bin/secrets.test.cjs bin/verify-quorum-health.test.cjs hooks/nf-stop.test.js hooks/config-loader.test.js hooks/config-loader-validate-adversarial.test.cjs hooks/config-loader-load-adversarial.test.cjs hooks/config-loader-adversarial2.test.cjs core/bin/nf-tools.test.cjs hooks/nf-circuit-breaker.test.js hooks/nf-prompt.test.js bin/update-scoreboard.test.cjs bin/update-scoreboard-recording-adversarial.test.cjs bin/update-scoreboard-availability-adversarial.test.cjs bin/update-scoreboard-adversarial2.test.cjs hooks/nf-statusline.test.js bin/review-mcp-logs.test.cjs bin/migrate-to-slots.test.cjs bin/validate-traces.test.cjs bin/write-check-result.test.cjs bin/check-results-exit.test.cjs bin/check-trace-redaction.test.cjs bin/check-trace-schema-drift.test.cjs bin/nForma.test.cjs bin/set-secret.test.cjs bin/issue-classifier.test.cjs bin/generate-tla-cfg.test.cjs bin/ccr-secure-config.test.cjs bin/nf-quorum-slot-worker-improvements.test.cjs bin/quorum-improvements-signal.test.cjs bin/quorum-checkpoint.test.cjs bin/claude-md-references.test.cjs hooks/nf-spec-regen.test.js bin/propose-debug-invariants.test.cjs bin/aggregate-requirements.test.cjs bin/validate-invariant.test.cjs bin/validate-memory.test.cjs bin/validate-requirements-haiku.test.cjs bin/call-quorum-slot-retry.test.cjs bin/provider-mapping.test.cjs bin/provider-concurrency.test.cjs bin/token-dashboard.test.cjs bin/batch7-guards.test.cjs bin/execution-progress.test.cjs bin/memory-store.test.cjs hooks/nf-session-end.test.js bin/skill-extractor.test.cjs bin/learning-extractor.test.cjs bin/continuous-verify.test.cjs bin/context-retriever.test.cjs bin/context-stack.test.cjs hooks/nf-destructive-git-guard.test.js bin/worktree-merge.test.cjs bin/call-quorum-slot-latency.test.cjs bin/nforma-cli.test.cjs bin/solve-trend.test.cjs bin/dedup-changelog.test.cjs bin/changelog-sections.test.cjs bin/oscillation-detector.test.cjs bin/gate-stability.test.cjs bin/predictive-power.test.cjs bin/convergence-report.test.cjs bin/escalation-classifier.test.cjs test/formal-scope-scan-semantic.test.cjs bin/repowise/escape-xml.test.cjs bin/repowise/pack-file.test.cjs bin/repowise/context-packer.test.cjs bin/repowise/hotspot.test.cjs bin/repowise/resolve-hotspot-risk.test.cjs bin/repowise/cochange.test.cjs bin/repowise/inject-cochange-debug.test.cjs bin/repowise/skeleton.test.cjs bin/repowise/budget-compressor.test.cjs test/providers-path-consolidation.test.cjs test/quorum-consensus-gate-cluster.test.cjs test/quorum-preflight-maintool-healthy.test.cjs test/quorum-preflight-probe.test.cjs test/quorum-preflight-dedup.test.cjs test/quorum-preflight-roster-adversarial.test.cjs test/quorum-preflight-roster-adversarial2.test.cjs test/quorum-preflight-probe-adversarial.test.cjs test/quorum-preflight-binary-cache.test.cjs test/resolve-providers-order.test.cjs test/proximity-resolve-guards.test.cjs test/observe-polyrepo-guards.test.cjs bin/no-side-effects-on-require.test.cjs bin/alloy-exec.test.cjs test/install-mcp-nfbin.test.cjs bin/semaphore-concurrency.test.cjs bin/quorum-dispatch-argv.test.cjs bin/quorum-content-verdict-integrity.test.cjs bin/quorum-slot-authenticity.test.cjs bin/call-quorum-slot-cooldown-path.test.cjs hooks/nf-prompt-cache-budget.test.js bin/coderlm-adapter-filter.test.cjs bin/coderlm-skill-blocks.test.cjs bin/solve-commit-artifacts.test.cjs bin/solve-debt-bridge.test.cjs test/baseline-drift.test.cjs bin/cli-robustness.test.cjs bin/mcp-skill-embedded.test.cjs bin/skill-eval-args.test.cjs bin/observe-handler-upstream.test.cjs bin/skill-mcp-tool-names.test.cjs bin/skill-command-correctness.test.cjs bin/mcp-update-classify.test.cjs bin/formal-test-sync-safety.test.cjs bin/skill-papercut-sweep.test.cjs bin/roadmap-health-parser.test.cjs bin/mcp-setup-env-order.test.cjs bin/sync-baseline-dry-run.test.cjs bin/skill-cli-sweep.test.cjs bin/validate-requirements-staleness.test.cjs bin/audit-allowlist-precision.test.cjs bin/skill-eval-lint.test.cjs bin/release-guards.test.cjs bin/quorum-resume.test.cjs bin/autonomous-grind.test.cjs bin/goal-writer-blocks.test.cjs bin/skill-mcp-lint.test.cjs bin/skill-invocation-lint.test.cjs bin/skill-state-guards.test.cjs bin/skill-state-parse-lint.test.cjs bin/map-codebase-secret-scan.test.cjs bin/verify-work-falsepass.test.cjs bin/solve-tui-itemkey.test.cjs bin/provider-arg-templates.test.cjs test/unified-mcp-server-slot-health.test.cjs bin/call-quorum-slot-stall-timeout.test.cjs bin/mcp-repair-statusline-refresh.test.cjs bin/observe-config.test.cjs bin/observe-debt-writer.test.cjs bin/observe-handler-deps.test.cjs bin/observe-handler-grafana.test.cjs bin/observe-handler-logstash.test.cjs bin/observe-handler-prometheus.test.cjs bin/observe-handler-session-insights.test.cjs bin/observe-handlers.test.cjs bin/observe-registry.test.cjs bin/observe-render.test.cjs bin/observe-solve-pipe.test.cjs bin/observe-tooling.test.cjs bin/observed-fsm.test.cjs bin/observe-handler-internal.test.cjs bin/observe-pipeline.test.cjs bin/delegate-session.test.cjs bin/delegate-session-store.test.cjs bin/delegate-auditor.test.cjs bin/delegate-loop.test.cjs bin/coding-task-router.test.cjs", + "test:ci": "node scripts/lint-isolation.js && node scripts/verify-hooks-sync.cjs && node --test bin/codex-install.test.cjs hooks/nf-precompact.test.js hooks/nf-context-monitor.test.js hooks/nf-session-start.test.js bin/conformance-schema.test.cjs bin/resolve-cli.test.cjs bin/secrets.test.cjs bin/verify-quorum-health.test.cjs hooks/nf-stop.test.js hooks/config-loader.test.js hooks/config-loader-validate-adversarial.test.cjs hooks/config-loader-load-adversarial.test.cjs hooks/config-loader-adversarial2.test.cjs core/bin/nf-tools.test.cjs hooks/nf-circuit-breaker.test.js hooks/nf-prompt.test.js bin/update-scoreboard.test.cjs bin/update-scoreboard-recording-adversarial.test.cjs bin/update-scoreboard-availability-adversarial.test.cjs bin/update-scoreboard-adversarial2.test.cjs hooks/nf-statusline.test.js bin/review-mcp-logs.test.cjs bin/migrate-to-slots.test.cjs bin/validate-traces.test.cjs bin/write-check-result.test.cjs bin/check-results-exit.test.cjs bin/check-trace-redaction.test.cjs bin/check-trace-schema-drift.test.cjs bin/nForma.test.cjs bin/set-secret.test.cjs bin/issue-classifier.test.cjs bin/generate-tla-cfg.test.cjs bin/ccr-secure-config.test.cjs bin/nf-quorum-slot-worker-improvements.test.cjs bin/quorum-improvements-signal.test.cjs bin/quorum-checkpoint.test.cjs bin/claude-md-references.test.cjs hooks/nf-spec-regen.test.js bin/propose-debug-invariants.test.cjs bin/aggregate-requirements.test.cjs bin/validate-invariant.test.cjs bin/validate-memory.test.cjs bin/validate-requirements-haiku.test.cjs bin/call-quorum-slot-retry.test.cjs bin/provider-mapping.test.cjs bin/provider-concurrency.test.cjs bin/token-dashboard.test.cjs bin/batch7-guards.test.cjs bin/execution-progress.test.cjs bin/memory-store.test.cjs hooks/nf-session-end.test.js bin/skill-extractor.test.cjs bin/learning-extractor.test.cjs bin/continuous-verify.test.cjs bin/context-retriever.test.cjs bin/context-stack.test.cjs hooks/nf-destructive-git-guard.test.js bin/worktree-merge.test.cjs bin/call-quorum-slot-latency.test.cjs bin/nforma-cli.test.cjs bin/solve-trend.test.cjs bin/dedup-changelog.test.cjs bin/changelog-sections.test.cjs bin/oscillation-detector.test.cjs bin/gate-stability.test.cjs bin/predictive-power.test.cjs bin/convergence-report.test.cjs bin/escalation-classifier.test.cjs test/formal-scope-scan-semantic.test.cjs bin/repowise/escape-xml.test.cjs bin/repowise/pack-file.test.cjs bin/repowise/context-packer.test.cjs bin/repowise/hotspot.test.cjs bin/repowise/resolve-hotspot-risk.test.cjs bin/repowise/cochange.test.cjs bin/repowise/inject-cochange-debug.test.cjs bin/repowise/skeleton.test.cjs bin/repowise/budget-compressor.test.cjs test/providers-path-consolidation.test.cjs test/quorum-consensus-gate-cluster.test.cjs test/quorum-preflight-maintool-healthy.test.cjs test/quorum-preflight-probe.test.cjs test/quorum-preflight-dedup.test.cjs test/quorum-preflight-roster-adversarial.test.cjs test/quorum-preflight-roster-adversarial2.test.cjs test/quorum-preflight-probe-adversarial.test.cjs test/quorum-preflight-binary-cache.test.cjs test/resolve-providers-order.test.cjs test/proximity-resolve-guards.test.cjs test/observe-polyrepo-guards.test.cjs bin/no-side-effects-on-require.test.cjs bin/alloy-exec.test.cjs test/install-mcp-nfbin.test.cjs bin/semaphore-concurrency.test.cjs bin/quorum-dispatch-argv.test.cjs bin/quorum-content-verdict-integrity.test.cjs bin/quorum-slot-authenticity.test.cjs bin/call-quorum-slot-cooldown-path.test.cjs hooks/nf-prompt-cache-budget.test.js bin/coderlm-adapter-filter.test.cjs bin/coderlm-skill-blocks.test.cjs bin/solve-commit-artifacts.test.cjs bin/solve-debt-bridge.test.cjs test/baseline-drift.test.cjs bin/cli-robustness.test.cjs bin/mcp-skill-embedded.test.cjs bin/skill-eval-args.test.cjs bin/observe-handler-upstream.test.cjs bin/skill-mcp-tool-names.test.cjs bin/skill-command-correctness.test.cjs bin/mcp-update-classify.test.cjs bin/formal-test-sync-safety.test.cjs bin/skill-papercut-sweep.test.cjs bin/roadmap-health-parser.test.cjs bin/mcp-setup-env-order.test.cjs bin/sync-baseline-dry-run.test.cjs bin/skill-cli-sweep.test.cjs bin/validate-requirements-staleness.test.cjs bin/audit-allowlist-precision.test.cjs bin/skill-eval-lint.test.cjs bin/release-guards.test.cjs bin/quorum-resume.test.cjs bin/autonomous-grind.test.cjs bin/goal-writer-blocks.test.cjs bin/skill-mcp-lint.test.cjs bin/skill-invocation-lint.test.cjs bin/skill-state-guards.test.cjs bin/skill-state-parse-lint.test.cjs bin/map-codebase-secret-scan.test.cjs bin/verify-work-falsepass.test.cjs bin/solve-tui-itemkey.test.cjs bin/provider-arg-templates.test.cjs test/unified-mcp-server-slot-health.test.cjs bin/call-quorum-slot-stall-timeout.test.cjs bin/mcp-repair-statusline-refresh.test.cjs bin/observe-config.test.cjs bin/observe-debt-writer.test.cjs bin/observe-handler-deps.test.cjs bin/observe-handler-grafana.test.cjs bin/observe-handler-logstash.test.cjs bin/observe-handler-prometheus.test.cjs bin/observe-handler-session-insights.test.cjs bin/observe-handlers.test.cjs bin/observe-registry.test.cjs bin/observe-render.test.cjs bin/observe-solve-pipe.test.cjs bin/observe-tooling.test.cjs bin/observed-fsm.test.cjs bin/observe-handler-internal.test.cjs bin/observe-pipeline.test.cjs bin/delegate-session.test.cjs bin/delegate-session-store.test.cjs bin/delegate-auditor.test.cjs bin/delegate-loop.test.cjs bin/coding-task-router.test.cjs", "test:tui": "NF_TEST_MODE=1 node scripts/run-tui-tests.cjs", - "test:install": "node --test test/install-virgin.test.cjs", + "test:install": "node --test bin/codex-install.test.cjs test/install-virgin.test.cjs", "test:formal": "node --test bin/run-tlc.test.cjs bin/run-alloy.test.cjs bin/export-prism-constants.test.cjs bin/generate-petri-net.test.cjs bin/run-breaker-tlc.test.cjs bin/run-oscillation-tlc.test.cjs bin/run-protocol-tlc.test.cjs bin/run-audit-alloy.test.cjs bin/run-transcript-alloy.test.cjs bin/run-installer-alloy.test.cjs bin/run-formal-verify.test.cjs bin/xstate-to-tla.test.cjs bin/run-account-manager-tlc.test.cjs bin/run-account-pool-alloy.test.cjs bin/run-oauth-rotation-prism.test.cjs bin/run-prism.test.cjs bin/check-spec-sync.test.cjs bin/sensitivity-sweep-feedback.test.cjs bin/roadmapper-formal-integration.test.cjs bin/test-formal-integration.test.cjs test/alloy-headless.test.cjs bin/adapters/ir.test.cjs bin/adapters/emitter-tla.test.cjs bin/adapters/detect.test.cjs bin/adapters/scaffold-config.test.cjs bin/adapters/xstate-v5.test.cjs bin/adapters/xstate-v4.test.cjs bin/adapters/jsm.test.cjs bin/adapters/robot.test.cjs bin/adapters/asl.test.cjs bin/adapters/stately.test.cjs bin/adapters/python-transitions.test.cjs bin/adapters/sismic.test.cjs bin/adapters/looplab-fsm.test.cjs bin/adapters/qmuntal-stateless.test.cjs bin/fsm-to-tla.test.cjs bin/check-formal-discrimination.test.cjs bin/check-debug-chain.test.cjs bin/nf-property-bridge.test.cjs", "prepare": "husky" } diff --git a/test/install-virgin.test.cjs b/test/install-virgin.test.cjs index 4d309f7052..55018c4206 100644 --- a/test/install-virgin.test.cjs +++ b/test/install-virgin.test.cjs @@ -61,7 +61,7 @@ function countAllFiles(dir) { /** * Run the installer for a given runtime into a temp directory. */ -function runInstall(tmpDir, runtime) { +function runInstall(tmpDir, runtime, homeDir = null) { return execFileSync(process.execPath, [ INSTALL_SCRIPT, `--${runtime}`, @@ -79,11 +79,41 @@ function runInstall(tmpDir, runtime) { // Prevent any env var overrides from affecting test CLAUDE_CONFIG_DIR: undefined, GEMINI_CONFIG_DIR: undefined, + CODEX_HOME: undefined, + CODEX_CONFIG_DIR: undefined, OPENCODE_CONFIG_DIR: undefined, OPENCODE_CONFIG: undefined, XDG_CONFIG_HOME: undefined, // Skip heavy network installs (River ML, @huggingface/transformers) to avoid CI timeouts NF_INSTALL_SKIP_OPTIONAL: '1', + NF_INSTALL_SKIP_FORMAL: '1', + ...(homeDir ? { HOME: homeDir, USERPROFILE: homeDir } : {}), + }, + }); +} + +function runUninstall(tmpDir, runtime, homeDir = null) { + return execFileSync(process.execPath, [ + INSTALL_SCRIPT, + `--${runtime}`, + '--uninstall', + '--global', + '--config-dir', tmpDir, + ], { + stdio: 'pipe', + timeout: 120000, + env: { + ...process.env, + CLAUDE_CONFIG_DIR: undefined, + GEMINI_CONFIG_DIR: undefined, + CODEX_HOME: undefined, + CODEX_CONFIG_DIR: undefined, + OPENCODE_CONFIG_DIR: undefined, + OPENCODE_CONFIG: undefined, + XDG_CONFIG_HOME: undefined, + NF_INSTALL_SKIP_OPTIONAL: '1', + NF_INSTALL_SKIP_FORMAL: '1', + ...(homeDir ? { HOME: homeDir, USERPROFILE: homeDir } : {}), }, }); } @@ -207,6 +237,202 @@ describe('virgin install: claude', () => { }); }); +// ── Codex Runtime ────────────────────────────────────────────────────────── + +describe('virgin install: codex', () => { + let tmpRoot; + let homeDir; + let tmpDir; + + before(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'nf-codex-install-test-')); + homeDir = path.join(tmpRoot, 'home'); + tmpDir = path.join(homeDir, '.codex'); + fs.mkdirSync(path.join(tmpDir, 'nf-bin'), { recursive: true }); + + // Seed one provider so MCP assertions stay deterministic on CI hosts that + // do not have any supported quorum CLI installed on PATH. + fs.writeFileSync(path.join(tmpDir, 'nf-bin', 'providers.json'), JSON.stringify({ + providers: [{ + name: 'gemini-1', + provider: 'test', + type: 'subprocess', + description: 'Test Gemini slot', + mainTool: 'gemini', + cli: 'gemini', + args_template: ['-p', '{prompt}'], + }], + }, null, 2) + '\n'); + fs.writeFileSync(path.join(tmpDir, 'config.toml'), 'model = "gpt-5.4"\n'); + fs.writeFileSync(path.join(tmpDir, 'settings.json'), JSON.stringify({ + hooks: { + Stop: [{ + hooks: [{ type: 'command', command: `node "${tmpDir}/hooks/nf-stop.js"` }], + }], + }, + statusLine: { + type: 'command', + command: `node "${tmpDir}/hooks/nf-statusline.js"`, + }, + }, null, 2) + '\n'); + fs.writeFileSync(path.join(tmpDir, 'hooks.json'), JSON.stringify({ + hooks: { + SessionStart: [ + { hooks: [{ type: 'command', command: `node "${tmpDir}/hooks/nf-session-start.js"` }] }, + { hooks: [{ type: 'command', command: `node "${tmpDir}/hooks/nf-slot-health-probe.js"` }] }, + ], + }, + }, null, 2) + '\n'); + + runInstall(tmpDir, 'codex', homeDir); + }); + + after(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + test('installs command workflows in the native Codex skill discovery location', () => { + const officialDir = path.join(homeDir, '.agents', 'skills'); + assert.ok(countFiles(officialDir, 'SKILL.md') >= 60); + assert.ok(!fs.existsSync(path.join(tmpDir, 'skills')), 'legacy .codex/skills mirror must not be installed'); + assert.ok(!fs.existsSync(path.join(tmpDir, 'commands', 'nf')), 'legacy commands/nf must not be installed'); + + const newProject = readIfExists(path.join(officialDir, 'nf-new-project', 'SKILL.md')); + assert.ok(newProject, 'nf:new-project must be installed as a skill'); + assert.match(newProject, /name: "nf:new-project"/); + assert.match(newProject, /native Codex subagent delegation/); + assert.ok(!newProject.includes('/nf:'), 'Codex skills must use $nf: references'); + }); + + test('installs native TOML custom agents', () => { + const agentsDir = path.join(tmpDir, 'agents'); + assert.ok(countFiles(agentsDir, '.toml') >= 17); + assert.equal(countFiles(agentsDir, '.md'), 0); + const planner = readIfExists(path.join(agentsDir, 'nf-planner.toml')); + assert.match(planner, /^name = "nf-planner"$/m); + assert.match(planner, /^description = "/m); + assert.match(planner, /^developer_instructions = "/m); + }); + + test('writes hooks.json instead of Claude settings.json', () => { + const hooks = JSON.parse(readIfExists(path.join(tmpDir, 'hooks.json'))); + assert.ok(hooks.hooks.SessionStart); + assert.ok(hooks.hooks.UserPromptSubmit); + assert.ok(hooks.hooks.Stop); + assert.ok(hooks.hooks.PreToolUse); + assert.ok(!fs.existsSync(path.join(tmpDir, 'settings.json'))); + assert.ok(!JSON.stringify(hooks).includes('"async"'), 'unsupported async hooks must be removed'); + const hookCommands = Object.values(hooks.hooks) + .flatMap(groups => groups) + .flatMap(group => group.hooks || []) + .map(hook => hook.command || ''); + assert.ok(!hookCommands.some(command => command.includes('nf-session-start')), 'Claude secret sync must not run in Codex'); + assert.ok(!hookCommands.some(command => command.includes('nf-slot-health-probe')), 'Claude MCP health probe must not run in Codex'); + assert.ok(!hookCommands.some(command => command.includes('nf-stop.js')), 'Claude transcript gate must not run in Codex'); + const configLoader = readIfExists(path.join(tmpDir, 'hooks', 'config-loader.js')); + assert.match(configLoader, /replace\(\/\^nforma-\//, 'Codex hook tool lookup must understand managed MCP names'); + const promptHook = readIfExists(path.join(tmpDir, 'hooks', 'nf-prompt.js')); + assert.match(promptHook, /CodexSubagent\(agent=/); + assert.ok(!promptHook.includes('~/.claude/')); + }); + + test('$nf planning prompts receive Codex-native quorum context', () => { + const output = execFileSync(process.execPath, [path.join(tmpDir, 'hooks', 'nf-prompt.js')], { + input: JSON.stringify({ + hook_event_name: 'UserPromptSubmit', + prompt: '$nf:plan-phase 1', + cwd: tmpRoot, + session_id: 'codex-hook-test', + }), + encoding: 'utf8', + env: { + ...process.env, + HOME: homeDir, + USERPROFILE: homeDir, + NF_SKIP_PREFLIGHT: '1', + }, + }); + const parsed = JSON.parse(output); + const context = parsed.hookSpecificOutput.additionalContext; + assert.match(context, /CodexSubagent\(agent="nf-quorum-slot-worker"/); + assert.ok(!context.includes('~/.claude/')); + assert.ok(!context.includes("Claude's vote")); + }); + + test('preserves user config.toml and adds managed MCP servers', () => { + const config = readIfExists(path.join(tmpDir, 'config.toml')); + assert.match(config, /model = "gpt-5\.4"/); + assert.match(config, /# BEGIN nForma managed MCP servers/); + assert.match(config, /\[mcp_servers\."nforma-gemini-1"\]/); + assert.match(config, /UNIFIED_PROVIDERS_CONFIG/); + + const nfConfig = JSON.parse(readIfExists(path.join(tmpDir, 'nf.json'))); + assert.equal(nfConfig.required_models.gemini.tool_prefix, 'mcp__nforma-gemini-1__'); + assert.ok(nfConfig.quorum_active.includes('nforma-gemini-1')); + assert.ok(!Object.hasOwn(nfConfig, 'quorum_instructions'), 'Codex must use native subagent quorum dispatch'); + }); + + test('re-install is idempotent across native files and managed TOML', () => { + const officialSkillsBefore = countFiles(path.join(homeDir, '.agents', 'skills'), 'SKILL.md'); + const agentsBefore = countFiles(path.join(tmpDir, 'agents'), '.toml'); + + runInstall(tmpDir, 'codex', homeDir); + + assert.equal(countFiles(path.join(homeDir, '.agents', 'skills'), 'SKILL.md'), officialSkillsBefore); + assert.equal(countFiles(path.join(tmpDir, 'agents'), '.toml'), agentsBefore); + const config = readIfExists(path.join(tmpDir, 'config.toml')); + assert.equal(config.split('# BEGIN nForma managed MCP servers').length - 1, 1); + }); + + test('uninstall removes native Codex integration while preserving user TOML', () => { + runUninstall(tmpDir, 'codex', homeDir); + + assert.equal(countFiles(path.join(homeDir, '.agents', 'skills'), 'SKILL.md'), 0); + assert.equal(countFiles(path.join(tmpDir, 'agents'), '.toml'), 0); + assert.ok(!fs.existsSync(path.join(tmpDir, 'hooks.json'))); + const config = readIfExists(path.join(tmpDir, 'config.toml')); + assert.match(config, /model = "gpt-5\.4"/); + assert.ok(!config.includes('# BEGIN nForma managed MCP servers')); + }); +}); + +test('optional dependency failures do not abort multi-runtime installation', () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'nf-optional-fail-open-')); + const homeDir = path.join(tmpRoot, 'home'); + const cachedCoderlm = path.join(homeDir, '.claude', 'nf-bin', 'coderlm'); + fs.mkdirSync(path.dirname(cachedCoderlm), { recursive: true }); + fs.writeFileSync(cachedCoderlm, ''); + fs.chmodSync(cachedCoderlm, 0o755); + + try { + execFileSync(process.execPath, [ + INSTALL_SCRIPT, + '--claude', + '--codex', + '--global', + ], { + stdio: 'pipe', + timeout: 120000, + env: { + ...process.env, + HOME: homeDir, + USERPROFILE: homeDir, + PATH: '', + CLAUDE_CONFIG_DIR: undefined, + CODEX_HOME: undefined, + CODEX_CONFIG_DIR: undefined, + NF_INSTALL_SKIP_OPTIONAL: undefined, + NF_INSTALL_SKIP_FORMAL: '1', + }, + }); + + assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'settings.json'))); + assert.ok(fs.existsSync(path.join(homeDir, '.codex', 'hooks.json'))); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } +}); + // ── OpenCode Runtime ──────────────────────────────────────────────────────── describe('virgin install: opencode', () => {