Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,10 @@ Setup with Codex enabled inventories the effective user Codex MCP configuration
even in machine-only mode. When project setup is active it inventories that
project's Codex MCP configuration too. An exact recursive
`[mcp_servers.codex]` entry and the exact deprecated `claude-flow` Ruflo
transport are listed in the setup trust manifest, backed up, removed only after
transport are listed in the setup trust manifest, backed up, corrected only after
the setup confirmation (or `--yes`), and re-probed before setup may report
success. A fresh recovery copy captures the immediate pre-repair bytes; symlinked
success. The recursive entry is removed; the `claude-flow` alias is replaced by a
disabled placeholder so Codex's Claude config import cannot add it back. A fresh recovery copy captures the immediate pre-repair bytes; symlinked
or otherwise non-regular config files remain report-only. Project files and the
legacy Ruflo replacement stay outside this repair scope under `--minimal` or
when setup is run outside a project.
Expand Down
19 changes: 17 additions & 2 deletions docs/UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,23 @@ configuration and offers to remember correction of the recognized user-scope
manifest with `--yes`, authorizes later setup/sync runs to repeat this bounded
correction. Historical approvals are not converted into remembered consent.

Legacy alias removal runs after provisioning, with an enabled canonical `ruflo`
replacement present. Each removal retains the live fingerprint check, creates a
The correction disables the alias in place rather than deleting it:

```toml
[mcp_servers.claude-flow]
# agentic-kit: disabled placeholder — stops Codex's Claude import from
# re-adding a duplicate of [mcp_servers.ruflo]. Delete this table to undo.
command = "ruflo"
args = ["mcp", "start"]
enabled = false
```

Codex's Claude config import adds any Claude MCP server whose name Codex does
not already have. A deleted alias therefore returned within hours. The
placeholder keeps the name taken and launches nothing.

The correction runs after provisioning, with an enabled canonical `ruflo`
replacement present. Each correction retains the live fingerprint check, creates a
current-state backup, and verifies the result. The remembered correction is used
only while agentic-kit owns the workspace-aware `ak x ruflo-mcp` replacement.
It does not authorize removing project entries, other names, custom commands,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
- **Status:** Implemented; handoff transport amended by
[ADR-0034](https://github.com/pacphi/agentic-kit/blob/main/docs/adr/0034-schema-native-handoffs-and-hermetic-seats.md)
- **Date:** 2026-08-25
- **Updated:** 2026-09-10
- **Updated:** 2026-09-23
- **Update note:** Initial implementation retires only receipt-owned legacy MCP state,
diagnoses effective Codex MCP topology, extends POSIX cleanup to process groups, and adds
fail-closed QE-Court readiness plus a reciprocal live participant-transport regression.
Expand All @@ -22,6 +22,13 @@
removal can record consent for matching future repairs while the workspace-aware
replacement remains agentic-kit-owned. Final topology verification rejects
unresolved duplication and in-scope recursion; custom entries remain preserved.
2026-09-23: the legacy `claude-flow` alias is now replaced in place by a disabled
placeholder (`enabled = false`, bounded exact-table edit with backup, child tables
included) instead of deleted. Codex's Claude config import (re-run on a schedule by
the desktop app) adds any Claude MCP server whose name Codex lacks, so deletion was
undone within hours and every sync repeated the repair (14 repairs observed
2026-09-04 → 2026-09-23). The placeholder keeps the name taken and is never itself a
repair target.
- **Deciders:** agentic-kit maintainers
- **Related:** [ADR-0001](https://github.com/pacphi/agentic-kit/blob/main/docs/adr/0001-one-routing-policy-many-projections.md),
[ADR-0006](https://github.com/pacphi/agentic-kit/blob/main/docs/adr/0006-primary-host-and-ambidextrous-mirroring.md),
Expand Down
1 change: 1 addition & 0 deletions docs/adr/0055-aqe-embedding-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
- **Release target:** `4.0.0-alpha.53`
- **Date:** 2026-09-20
- **Updated:** 2026-09-20 — implemented explicit defaults, owned Claude/Codex/OpenCode projections and qualified runtime proof
- **Updated:** 2026-09-23 — project projections are scoped to the enclosing git repository root; outside a repository only the user Codex target applies (earlier project-scope values are relinquished by receipt), and the `.mcp.json` AQE registration is required only in an AQE-initialized project
- **Related:** [ADR-0023](0023-fail-closed-operations-and-explicit-degradation.md),
[September repair](../audits/2026-09-09-aqe-integration-repair.md)

Expand Down
19 changes: 12 additions & 7 deletions src/commands/sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import { collect } from './status.mjs';
import * as heal from '../lib/heal.mjs';
import { have } from '../lib/exec.mjs';
import { fixStatusline, helperStampStale, refreshRufloHelpers } from '../lib/statusline.mjs';
import { fixStatusline, helperStampStale, runHelperRefresh } from '../lib/statusline.mjs';
import { reconcileGuidance } from '../lib/blocks.mjs';
import {
register as mcpRegister, applyExclusions, codexMcpTopology, codexMcpRepairPlan,
Expand Down Expand Up @@ -446,9 +446,12 @@
id: 'ruflo-helpers',
when: (subs) => subs.has('versions'),
run: async (ctx) => {
const refreshed = await withProgress('ruflo helpers', async () => refreshRufloHelpers(ctx.cwd));
if (refreshed) ok('ruflo helpers: signed generated helpers refreshed');
else if (helperStampStale(ctx.cwd)) warn('ruflo helpers: refresh did not converge; generated helpers remain stale');
// Helpers live at the project root, not a subdirectory cwd (repoRoot is
// the kit-wide project gate); outside a repository the cwd is used as-is.
const root = paths.repoRoot(ctx.cwd) ?? ctx.cwd;
const outcome = await withProgress('ruflo helpers', async () => runHelperRefresh(root));
if (outcome === 'refreshed') ok('ruflo helpers: signed generated helpers refreshed');
else if (helperStampStale(root)) warn('ruflo helpers: refresh did not converge; generated helpers remain stale');
else info('ruflo helpers: current');
},
},
Expand All @@ -466,14 +469,16 @@
// refresh, up to 30s). The interval can't animate through a synchronous
// execFileSync, but the initial "⏳ statusline" render lands before the
// block — a visible label beats a frozen prompt.
const r = await withProgress('statusline', async () => fixStatusline(ctx.cwd));
(r.applied || !r.reason ? ok : warn)(`statusline: ${r.applied ? `footer injected (v${r.version})` : r.reason ?? 'in sync'}`);
const root = paths.repoRoot(ctx.cwd) ?? ctx.cwd;
const r = await withProgress('statusline', async () => fixStatusline(root));
if (r.absent) info('statusline: no ruflo helpers here — nothing to patch');
else (r.applied || !r.reason ? ok : warn)(`statusline: ${r.applied ? `footer injected (v${r.version})` : r.reason ?? 'in sync'}`);
// Honest success: fixStatusline invokes ruflo's PRIVATE helper-refresh
// internal, best-effort. If the stamp is STILL stale after the heal, that
// refresh silently no-oped (e.g. upstream moved the dist module) and the
// next ruflo command will wipe the footer we just injected — say so
// instead of letting "footer injected" read as converged.
if (helperStampStale(ctx.cwd)) {
if (helperStampStale(root)) {
warn('statusline: helper stamp still stale after heal — ruflo\'s refresh did not run; the footer may not survive the next ruflo command');
}
},
Expand Down Expand Up @@ -511,7 +516,7 @@
},
];

export async function run({

Check warning on line 519 in src/commands/sync.mjs

View workflow job for this annotation

GitHub Actions / quality (typecheck, lint, build, audit)

Async function 'run' has a complexity of 33. Maximum allowed is 25
flags,
pkgRoot,
fetchLatest,
Expand Down
36 changes: 26 additions & 10 deletions src/lib/aqe-embedding-projection.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { contextHome } from './codex-context-config.mjs';
import { writePrivateFileAtomic } from './file-write.mjs';
import { aqeTomlEnvironment } from './aqe-embedding-toml.mjs';
import { recognizedAqeTransport, parseEmbeddingJson } from './aqe-embedding-transport.mjs';
import { claudeUserMcpPath } from './paths.mjs';
import { claudeUserMcpPath, projectAqeDir, repoRoot } from './paths.mjs';

export const AQE_ENDPOINT_KEY = 'AQE_EMBEDDER_ENDPOINT';
const receiptPath = file => `${file}.agentic-kit-aqe-embedding.json`;
Expand Down Expand Up @@ -62,23 +62,35 @@ function jsonEnvironment(source, target) {
return { current: state(env), replace(next) {
container.env ??= {};
if (next.present) container.env[AQE_ENDPOINT_KEY] = next.value;
else delete container.env[AQE_ENDPOINT_KEY];
else {
delete container.env[AQE_ENDPOINT_KEY];
if (Object.keys(container.env).length === 0) delete container.env;
}
return JSON.stringify(doc, null, 2) + '\n';
} };
}

// Project scope is the enclosing git repository (paths.repoRoot), the same gate
// every other project-scoped writer uses. Outside a repository (e.g. running from
// ~/.claude or $HOME) there is no project to project into: project targets are
// kept only for receipt-based cleanup of values an earlier version wrote there.
// The AQE MCP registration is required only in a project AQE initialized.
function targets(cfg, cwd, codexHome) {
const hosts = cfg.integrations?.hosts ?? { claude: true };
const root = repoRoot(cwd);
const base = root ?? cwd;
const project = root !== null;
const aqeProject = project && fs.existsSync(projectAqeDir(root));
const result = [];
// Include disabled targets only for receipt-based cleanup. Never fabricate a registration.
const add = (file, kind, enabled, required = false, boundary = cwd) => {
const add = (file, kind, enabled, required = false, boundary = base) => {
if (enabled || fs.existsSync(receiptPath(file))) result.push({ file, kind, enabled, required, boundary });
};
add(path.join(cwd, '.mcp.json'), 'mcp', !!hosts.claude, true);
add(path.join(cwd, '.claude', 'settings.local.json'), 'settings', !!hosts.claude);
add(path.join(cwd, '.codex', 'config.toml'), 'toml', !!hosts.codex);
add(path.join(base, '.mcp.json'), 'mcp', project && !!hosts.claude, aqeProject);
add(path.join(base, '.claude', 'settings.local.json'), 'settings', project && !!hosts.claude);
add(path.join(base, '.codex', 'config.toml'), 'toml', project && !!hosts.codex);
const userCodex = path.join(codexHome, 'config.toml');
if (path.resolve(userCodex) !== path.resolve(cwd, '.codex', 'config.toml')) add(userCodex, 'toml', !!hosts.codex, false, codexHome);
if (path.resolve(userCodex) !== path.resolve(base, '.codex', 'config.toml')) add(userCodex, 'toml', !!hosts.codex, false, codexHome);
return result;
}

Expand Down Expand Up @@ -154,15 +166,18 @@ export function prepareAqeEmbeddingInitialization(cfg, cwd = process.cwd(), opti
return reconcileAqeEmbeddingProjections({ ...cfg, aqeEmbedding: { mode: 'unmanaged' } }, cwd, options);
}

function claudePrecedenceFindings(cfg, cwd, userFile, desired) {
const projectEntry = (doc, root) => (root === null ? undefined
: Object.entries(doc.projects ?? {}).find(([key]) => path.resolve(key) === root)?.[1]);

function claudePrecedenceFindings(cfg, root, userFile, desired) {
const hosts = cfg.integrations?.hosts ?? { claude: true };
if (!hosts.claude || !desired.present) return [];
try {
const source = readRegular(userFile);
if (source === null) return [];
const doc = parseEmbeddingJson(source);
if (!plain(doc) || (doc.projects !== undefined && !plain(doc.projects))) throw new Error('invalid Claude MCP configuration preserved');
const project = Object.entries(doc.projects ?? {}).find(([root]) => path.resolve(root) === cwd)?.[1];
const project = projectEntry(doc, root);
const local = project?.mcpServers?.['agentic-qe'];
const user = doc.mcpServers?.['agentic-qe'];
const findings = [];
Expand All @@ -188,7 +203,8 @@ export function reconcileAqeEmbeddingProjections(cfg, cwd = process.cwd(), {
} = {}) {
if (cfg.aqe === false) return { ok: true, changed: false, detail: 'AQE disabled; projections skipped', findings: [] };
const desired = state(aqeEmbeddingProjectionEnv(cfg));
const findings = claudePrecedenceFindings(cfg, path.resolve(cwd), claudeUserFile, desired);
const root = repoRoot(cwd);
const findings = claudePrecedenceFindings(cfg, root === null ? null : path.resolve(root), claudeUserFile, desired);
for (const target of targets(cfg, path.resolve(cwd), codexHome)) {
try {
const plan = targetPlan(target, desired);
Expand Down
5 changes: 4 additions & 1 deletion src/lib/codex-mcp-reconcile.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ export function rememberCodexMcpRepairs(cfg, targets, topology) {
export async function confirmCodexMcpRepairs(cfg, targets, topology, { yes, confirm }) {
if (!targets.length) return true;
console.log(`Codex repair plan (${targets.length} action(s)):`);
for (const entry of targets) console.log(` • remove ${entry.file} → [mcp_servers.${entry.name}] — ${entry.reason}`);
for (const entry of targets) {
const action = entry.repairKind === 'legacy-ruflo' ? 'disable' : 'remove';
console.log(` • ${action} ${entry.file} → [mcp_servers.${entry.name}] — ${entry.reason}`);
}
const pending = targets.filter(entry => !hasCodexMcpRepairConsent(cfg, entry, topology));
if (!pending.length) {
console.log('Using remembered consent for the recognized legacy Ruflo correction.');
Expand Down
79 changes: 56 additions & 23 deletions src/lib/mcp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,14 @@ function mcpTableName(table) {
return match ? (match[1] ?? match[2]) : null;
}

/** A table's own children ([mcp_servers.<name>.env] …) belong to its block:
* cutting only up to the next header would orphan them. */
function ownedBlockEnd(headers, index, childTables, sourceLength) {
let last = index;
while (headers[last + 1] && childTables.includes(headers[last + 1])) last++;
return headers[last + 1]?.index ?? sourceLength;
}

/** Read the bounded base-table sections behind Codex MCP registrations. This
* is deliberately not a general TOML parser: only a base
* `[mcp_servers.<name>]` table with string command and string-array args facts is
Expand Down Expand Up @@ -210,6 +218,7 @@ function codexMcpSections(file, scope) {
const childPrefixes = [`mcp_servers.${name}.`, `mcp_servers."${name}".`];
const childTables = headers.filter((candidate) =>
childPrefixes.some((prefix) => candidate[1].trim().startsWith(prefix)));
const blockEnd = ownedBlockEnd(headers, index, childTables, source.length);
const hasChildren = childTables.length > 0;
const managedBrowserChild = childTables.length === 1
&& childTables[0][1].trim() === `mcp_servers.${name}.env`
Expand All @@ -231,7 +240,7 @@ function codexMcpSections(file, scope) {
return [{
name, scope, file, command, args, enabled, repairKind, regularFile,
fingerprint: fingerprint(source.slice(header.index, bodyEnd)),
start: header.index, end: bodyEnd, source,
start: header.index, end: blockEnd, source,
}];
});
}
Expand Down Expand Up @@ -325,11 +334,29 @@ function validRepairTarget(target) {
return false;
}

function removeProjectCodexMcpTarget(target) {

/** The legacy alias is disabled in place rather than deleted. Codex's Claude
* config import (the desktop app re-runs it on a schedule) adds a Claude MCP
* server whenever Codex has no server of that NAME, so a deleted
* [mcp_servers.claude-flow] came straight back and every sync removed it again.
* A disabled table keeps the name taken, launches nothing, and is not a repair
* target (its extra field and comment make it a non-exact shape). */
export const LEGACY_RUFLO_PLACEHOLDER = [
'[mcp_servers.claude-flow]',
'# agentic-kit: disabled placeholder — stops Codex\'s Claude import from',
'# re-adding a duplicate of [mcp_servers.ruflo]. Delete this table to undo.',
'command = "ruflo"',
'args = ["mcp", "start"]',
'enabled = false',
].join('\n');

function replaceCodexMcpTarget(target, replacement) {
const current = codexMcpSections(target.file, target.scope)
.find((entry) => sameRepairIdentity(entry, target));
if (!current) return false;
writeFileWithBackup(target.file, current.source.slice(0, current.start) + current.source.slice(current.end));
const tail = current.source.slice(current.end);
const block = replacement ? `${replacement}\n${tail ? '\n' : ''}` : '';
writeFileWithBackup(target.file, current.source.slice(0, current.start) + block + tail);
return true;
}

Expand All @@ -349,6 +376,26 @@ function createCurrentRepairBackup(file) {
return backup;
}

/** Mutate one identity-checked target; returns a failure detail or null. The
* legacy alias is disabled in place (see LEGACY_RUFLO_PLACEHOLDER); project
* tables are edited directly; other user tables go through `codex mcp remove`. */
async function applyCodexRepairTarget(target, live, runner, cwd) {
const disable = target.repairKind === 'legacy-ruflo';
if (target.scope === 'project' || disable) {
try {
if (!replaceCodexMcpTarget(live, disable ? LEGACY_RUFLO_PLACEHOLDER : null)) {
throw new Error('exact confirmed table was not found');
}
return null;
} catch (error) {
return `could not ${disable ? 'disable' : 'remove'} [mcp_servers.${target.name}] in ${target.file}: ${error.message}`;
}
}
const result = await runner('codex', ['mcp', 'remove', target.name], { cwd });
if (result.code === 0) return null;
return `could not remove [mcp_servers.${target.name}] from ${target.file}: ${(result.stderr || result.stdout || `exit ${result.code}`).split('\n')[0].slice(0, 160)}`;
}

/** Apply a previously disclosed repair plan. Project-scoped tables are edited
* through a bounded, backup-first exact-section removal because Codex's MCP
* command writes only the user config. User-scoped tables go through Codex's
Expand Down Expand Up @@ -387,35 +434,21 @@ export async function repairCodexMcpTopology(targets, cwd = process.cwd(), {
};
}
}
if (target.scope === 'project') {
try {
if (!removeProjectCodexMcpTarget(live)) throw new Error('exact confirmed table was not found');
} catch (error) {
return {
ok: false, changed: removed.length > 0,
detail: `could not remove [mcp_servers.${target.name}] from ${target.file}: ${error.message}`,
};
}
} else {
const result = await runner('codex', ['mcp', 'remove', target.name], { cwd });
if (result.code !== 0) {
return {
ok: false, changed: removed.length > 0,
detail: `could not remove [mcp_servers.${target.name}] from ${target.file}: ${(result.stderr || result.stdout || `exit ${result.code}`).split('\n')[0].slice(0, 160)}`,
};
}
}
const failure = await applyCodexRepairTarget(target, live, runner, cwd);
if (failure) return { ok: false, changed: removed.length > 0, detail: failure };
const remaining = inspect({ cwd }).registrations.find((entry) =>
entry.file === target.file && entry.scope === target.scope && entry.name === target.name);
if (remaining) {
if (target.repairKind === 'legacy-ruflo' ? remaining?.enabled !== false || remaining.repairKind : remaining) {
return {
ok: false, changed: true,
detail: `Codex MCP repair could not be verified; [mcp_servers.${target.name}] remains`,
};
}
removed.push(target);
}
return { ok: true, changed: removed.length > 0, detail: `removed ${removed.map((target) => `[mcp_servers.${target.name}]`).join(', ')}` };
const verb = (target) => (target.repairKind === 'legacy-ruflo' ? 'disabled' : 'removed');
return { ok: true, changed: removed.length > 0,
detail: removed.map((target) => `${verb(target)} [mcp_servers.${target.name}]`).join(', ') };
}

/**
Expand Down
Loading
Loading