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
29 changes: 29 additions & 0 deletions src/commands/status.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { drift as ruvectorDrift } from '../lib/ruvector.mjs';
import { statuslineDrift } from '../lib/codex-statusline.mjs';
import { inspectCodexPlugins } from '../lib/codex-plugins.mjs';
import { projectMemoryStatus } from '../lib/project-memory.mjs';
import { removedAgentGaps, upstreamFixAvailable } from '../lib/scaffold.mjs';

export const options = {
json: { type: 'boolean', default: false },
Expand Down Expand Up @@ -221,6 +222,34 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) {
rows.push(row('memory', 'warn', `project memory check unavailable: ${e.message}`));
}

// Scaffold agents (ADR-128 Phase 2 removals — ruflo#2985). Upstream never
// revisits an existing scaffold, so projects inited before ruflo 3.38.x are
// missing up to 9 plugin-canonical agents (coder, researcher, reviewer, …).
// The fix is upstream's `ruflo migrate fix --agents` (PR #2986): when the
// installed CLI ships it, the row carries a fix and sync delegates; until
// then it is advisory-only — a kit-side restore would fork plugin-canonical
// content. Spawn-free (dist probe + file walk), project-scoped: silent when
// the cwd has no .claude/agents tree.
try {
const { relevant, gaps } = removedAgentGaps(cwd);
if (relevant && gaps.length > 0) {
const named = gaps.slice(0, 3).map((g) => g.basename.replace(/\.md$/, '')).join(', ');
const suffix = gaps.length > 3 ? ', …' : '';
if (upstreamFixAvailable()) {
rows.push(row('scaffold-agents', 'warn',
`${gaps.length} ADR-128-removed agent(s) missing from .claude/agents (${named}${suffix})`,
'sync delegates to `ruflo migrate fix --agents`'));
} else {
rows.push(row('scaffold-agents', 'info',
`${gaps.length} ADR-128-removed agent(s) missing (${named}${suffix}) — installed ruflo lacks \`migrate fix --agents\` (ruflo#2986 pending); upgrade ruflo or install the owning plugins`));
}
} else if (relevant) {
rows.push(row('scaffold-agents', 'ok', 'ADR-128-removed agents present or plugin-covered'));
}
} catch (e) {
rows.push(row('scaffold-agents', 'warn', `scaffold agent check unavailable: ${e.message}`));
}

// npx (stale ruflo-family cache envs — `npx --prefer-offline` fallbacks in the
// statusline/hooks execute these verbatim, keeping retired defects alive)
try {
Expand Down
10 changes: 10 additions & 0 deletions src/commands/sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { commandHosts, applyHosts, applyProviders, hostInstallState, installHost
import { driftReport, selfDrift } from '../lib/versions.mjs';
import { RUVECTOR_PKG, managed as ruvectorManaged } from '../lib/ruvector.mjs';
import { pruneNpxStale } from '../lib/npx.mjs';
import { runScaffoldAgentsFix } from '../lib/scaffold.mjs';
import { nativesStatus, securityPresent } from '../lib/natives.mjs';
import { readJson } from '../lib/settings.mjs';
import { appendToConfig } from '../lib/health-history.mjs';
Expand Down Expand Up @@ -129,6 +130,15 @@ export async function run({ flags, pkgRoot }) {
if (subsystems.has('npx') || subsystems.has('versions')) {
report('npx', pruneNpxStale());
}
// Scaffold agents: the row only carries a fix (and so only enters the plan)
// when the installed CLI already ships `migrate fix --agents` (ruflo#2986) —
// delegation, never a kit-side restore. If THIS sync's upgrade step is what
// delivered the capability, the pre-upgrade plan won't include it; the next
// `ak status`/`ak sync` picks it up (same one-pass-behind rule as any
// upgrade-delivered fix).
if (subsystems.has('scaffold-agents')) {
await step('scaffold agents', () => runScaffoldAgentsFix(cwd));
}
if (subsystems.has('aqe')) {
report('rvf', heal.healRvf(paths.projectAqeDir(cwd)));
}
Expand Down
120 changes: 120 additions & 0 deletions src/lib/scaffold.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Scaffold-agents drift — ADR-128 Phase 2 deleted 9 agents from ruflo init's
// template (each plugin is canonical since); any project scaffolded before
// ruflo 3.38.x carries the gap silently, and no upstream code revisits an
// existing scaffold. Detection here mirrors upstream's
// migrate-agent-detection.ts semantics exactly (basename anywhere under
// .claude/agents + owning-plugin coverage via ~/.claude/plugins/
// installed_plugins.json), spawn-free so status and the nudge can call it.
//
// The FIX is deliberately not ours: when the installed CLI ships
// `ruflo migrate fix --agents` (ruflo#2985 → PR #2986), sync delegates to it —
// upstream restores from canonical plugin content with namespace rewrite and
// provenance. Until that ships, the status row is advisory-only (no fix
// string, so it never enters sync's plan): a kit-side restore would fork
// content ADR-128 made plugin-canonical.
import fs from 'node:fs';
import path from 'node:path';
import * as paths from './paths.mjs';
import { run } from './exec.mjs';

/** Mirror of upstream REMOVED_AGENTS (src/commands/migrate.ts) — basename in
* the pre-ADR-128 init template, and the marketplace plugin that owns it now. */
export const REMOVED_AGENTS = [
{ basename: 'coder.md', plugin: 'ruflo-core' },
{ basename: 'researcher.md', plugin: 'ruflo-core' },
{ basename: 'reviewer.md', plugin: 'ruflo-core' },
{ basename: 'tester.md', plugin: 'ruflo-testgen' },
{ basename: 'memory-specialist.md', plugin: 'ruflo-rag-memory' },
{ basename: 'security-auditor.md', plugin: 'ruflo-security-audit' },
{ basename: 'sparc-orchestrator.md', plugin: 'ruflo-sparc' },
{ basename: 'goal-planner.md', plugin: 'ruflo-goals' },
{ basename: 'adr-architect.md', plugin: 'ruflo-adr' },
];

// Same shallow-tree depth guard as upstream's findBasename.
const MAX_AGENT_DIR_DEPTH = 6;

function hasBasename(dir, basename, depth = 0) {
if (depth > MAX_AGENT_DIR_DEPTH) return false;
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return false;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (hasBasename(path.join(dir, entry.name), basename, depth + 1)) return true;
} else if (entry.isFile() && entry.name === basename) {
return true;
}
}
return false;
}

function installedPluginsRegistry(homeDir) {
try {
const raw = fs.readFileSync(
path.join(homeDir, '.claude', 'plugins', 'installed_plugins.json'), 'utf8');
const parsed = JSON.parse(raw);
return parsed?.plugins && typeof parsed.plugins === 'object' ? parsed.plugins : {};
} catch {
return {};
}
}

// Upstream semantics: a "user"-scoped (or unscoped) install covers every
// project; a "project"-scoped install covers only its own projectPath.
function pluginCovers(registry, plugin, cwd) {
const resolved = path.resolve(cwd);
for (const [key, entries] of Object.entries(registry)) {
if (!key.startsWith(`${plugin}@`) || !Array.isArray(entries)) continue;
for (const entry of entries) {
if (entry?.scope === 'project') {
if (entry.projectPath && path.resolve(entry.projectPath) === resolved) return true;
} else {
return true;
}
}
}
return false;
}

/**
* Spawn-free gap probe. `relevant: false` when the project has no
* .claude/agents tree at all (not a ruflo-scaffolded project — no row).
* @returns {{ relevant: boolean, gaps: Array<{basename: string, plugin: string}> }}
*/
export function removedAgentGaps(cwd, { homeDir = paths.home } = {}) {
const agentsDir = path.join(cwd, '.claude', 'agents');
if (!fs.existsSync(agentsDir)) return { relevant: false, gaps: [] };
const registry = installedPluginsRegistry(homeDir);
const gaps = REMOVED_AGENTS.filter(
({ basename, plugin }) =>
!hasBasename(agentsDir, basename) && !pluginCovers(registry, plugin, cwd)
);
return { relevant: true, gaps };
}

/**
* Does the installed CLI ship `migrate fix --agents`? Probed the same way
* mcp.mjs derives tool families — from what actually sits in the installed
* dist (the restore module lands with ruflo#2986) — never from a version
* string, so a backport or a fork build is detected identically.
*/
export function upstreamFixAvailable({ cliDist = paths.rufloCliDist() } = {}) {
return fs.existsSync(path.join(cliDist, 'commands', 'migrate-agent-restore.js'));
}

/** Delegate the restore to upstream. Only called by sync when
* upstreamFixAvailable() held at collect time. */
export async function runScaffoldAgentsFix(cwd, { runner = run, homeDir = paths.home } = {}) {
const r = await runner('ruflo', ['migrate', 'fix', '--agents'], { cwd, timeout: 120_000 });
if (r.code !== 0) {
return { ok: false, detail: `ruflo migrate fix --agents failed: ${(r.stderr || r.stdout || '').slice(0, 200)}` };
}
const after = removedAgentGaps(cwd, { homeDir });
return after.gaps.length === 0
? { ok: true, detail: 'delegated to `ruflo migrate fix --agents` — all removed agents restored' }
: { ok: false, detail: `ran \`ruflo migrate fix --agents\` but ${after.gaps.length} gap(s) remain — see \`ruflo migrate status\`` };
}
136 changes: 136 additions & 0 deletions tests/kit/scaffold.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
REMOVED_AGENTS,
removedAgentGaps,
upstreamFixAvailable,
runScaffoldAgentsFix,
} from '../../src/lib/scaffold.mjs';

// Detection mirrors upstream migrate-agent-detection.ts: basename anywhere
// under .claude/agents + owning-plugin coverage from the injected home's
// installed_plugins.json. Every test gets isolated tmp dirs — nothing here
// depends on what is actually installed on the machine running the suite.

function tmpProject({ agentsDir = true } = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-scaffold-'));
if (agentsDir) fs.mkdirSync(path.join(dir, '.claude', 'agents'), { recursive: true });
return dir;
}

function tmpHome(plugins = null) {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-scaffold-home-'));
if (plugins) {
const dir = path.join(home, '.claude', 'plugins');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'installed_plugins.json'),
JSON.stringify({ version: 2, plugins }));
}
return home;
}

const rm = (dir) => fs.rmSync(dir, { recursive: true, force: true });

test('not relevant when the project has no .claude/agents tree', () => {
const cwd = tmpProject({ agentsDir: false });
const homeDir = tmpHome();
try {
assert.deepEqual(removedAgentGaps(cwd, { homeDir }), { relevant: false, gaps: [] });
} finally { rm(cwd); rm(homeDir); }
});

test('all 9 gaps when agents dir is empty and no plugins are installed', () => {
const cwd = tmpProject();
const homeDir = tmpHome();
try {
const { relevant, gaps } = removedAgentGaps(cwd, { homeDir });
assert.equal(relevant, true);
assert.equal(gaps.length, REMOVED_AGENTS.length);
} finally { rm(cwd); rm(homeDir); }
});

test('a basename anywhere under .claude/agents clears its gap', () => {
const cwd = tmpProject();
const homeDir = tmpHome();
try {
const nested = path.join(cwd, '.claude', 'agents', 'some', 'deep', 'category');
fs.mkdirSync(nested, { recursive: true });
fs.writeFileSync(path.join(nested, 'coder.md'), '# restored\n');
const { gaps } = removedAgentGaps(cwd, { homeDir });
assert.equal(gaps.length, REMOVED_AGENTS.length - 1);
assert.ok(!gaps.some((g) => g.basename === 'coder.md'));
} finally { rm(cwd); rm(homeDir); }
});

test('user-scoped plugin install covers its agents; project scope only matches its own path', () => {
const cwd = tmpProject();
const other = tmpProject();
const homeDir = tmpHome({
'ruflo-core@ruflo': [{ scope: 'user' }],
'ruflo-testgen@ruflo': [{ scope: 'project', projectPath: other }],
});
try {
const { gaps } = removedAgentGaps(cwd, { homeDir });
// ruflo-core owns coder/researcher/reviewer — covered by the user-scope install.
assert.ok(!gaps.some((g) => g.plugin === 'ruflo-core'));
// ruflo-testgen is installed for a DIFFERENT project — tester.md still gaps here.
assert.ok(gaps.some((g) => g.basename === 'tester.md'));
} finally { rm(cwd); rm(other); rm(homeDir); }
});

test('upstreamFixAvailable probes the installed dist for the restore module', () => {
const dist = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-scaffold-dist-'));
try {
assert.equal(upstreamFixAvailable({ cliDist: dist }), false);
fs.mkdirSync(path.join(dist, 'commands'), { recursive: true });
fs.writeFileSync(path.join(dist, 'commands', 'migrate-agent-restore.js'), '// #2986\n');
assert.equal(upstreamFixAvailable({ cliDist: dist }), true);
} finally { rm(dist); }
});

test('runScaffoldAgentsFix delegates to ruflo and verifies convergence', async () => {
const cwd = tmpProject();
const homeDir = tmpHome();
try {
const calls = [];
// Runner simulates upstream restoring every agent, then the post-check
// re-probes the real tree — write the files so convergence holds.
const runner = async (cmd, args, opts) => {
calls.push({ cmd, args, cwd: opts.cwd });
const dir = path.join(cwd, '.claude', 'agents', 'core');
fs.mkdirSync(dir, { recursive: true });
for (const { basename } of REMOVED_AGENTS) fs.writeFileSync(path.join(dir, basename), '# restored\n');
return { code: 0, stdout: '9 agent(s) restored', stderr: '' };
};
const r = await runScaffoldAgentsFix(cwd, { runner, homeDir });
assert.equal(r.ok, true);
assert.deepEqual(calls[0].args, ['migrate', 'fix', '--agents']);
assert.equal(calls[0].cmd, 'ruflo');
assert.equal(calls[0].cwd, cwd);
} finally { rm(cwd); rm(homeDir); }
});

test('runScaffoldAgentsFix reports failure on nonzero exit without throwing', async () => {
const cwd = tmpProject();
const homeDir = tmpHome();
try {
const runner = async () => ({ code: 1, stdout: '', stderr: 'boom' });
const r = await runScaffoldAgentsFix(cwd, { runner, homeDir });
assert.equal(r.ok, false);
assert.match(r.detail, /boom/);
} finally { rm(cwd); rm(homeDir); }
});

test('runScaffoldAgentsFix flags non-convergence when gaps remain after a zero exit', async () => {
const cwd = tmpProject();
const homeDir = tmpHome();
try {
const runner = async () => ({ code: 0, stdout: 'looked fine', stderr: '' });
const r = await runScaffoldAgentsFix(cwd, { runner, homeDir });
assert.equal(r.ok, false);
assert.match(r.detail, /gap\(s\) remain/);
} finally { rm(cwd); rm(homeDir); }
});
Loading