From ba41c2385a0ec3a3579701af7d747efdde6d046e Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 12 Aug 2026 11:16:42 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20scaffold-agents=20subsystem=20=E2=80=94?= =?UTF-8?q?=20detect=20ADR-128=20agent=20gaps,=20delegate=20to=20upstream?= =?UTF-8?q?=20fix=20when=20available?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projects scaffolded by ruflo init before 3.38.x are silently missing up to 9 plugin-canonical agents (ADR-128 Phase 2 deletions: coder, researcher, reviewer, tester, ...) and nothing upstream revisits an existing scaffold. ruflo#2985 / PR #2986 add 'ruflo migrate fix --agents' upstream; this subsystem makes ak the bridge: - src/lib/scaffold.mjs: spawn-free gap detection mirroring upstream's migrate-agent-detection.ts semantics (basename anywhere under .claude/agents + owning-plugin coverage from installed_plugins.json); capability detection probes the installed dist for migrate-agent-restore.js (dist-derived truth, same discipline as the MCP deny rules — never a version-string guess); delegation runner with a convergence re-check. - status: scaffold-agents row — ok / advisory-info (CLI lacks the fix; ruflo#2986 pending) / warn-with-fix, which auto-enters sync's plan. - sync: delegates to 'ruflo migrate fix --agents' when planned. Deliberately never a kit-side restore — that would fork content ADR-128 made plugin-canonical. One-pass-behind when the same sync's upgrade delivers the capability (documented inline). 8 tests (isolated tmp home/project, injected runner/dist), pnpm run check green, both reachable modes live-verified on-machine. --- src/commands/status.mjs | 29 ++++++++ src/commands/sync.mjs | 10 +++ src/lib/scaffold.mjs | 120 +++++++++++++++++++++++++++++++ tests/kit/scaffold.test.mjs | 136 ++++++++++++++++++++++++++++++++++++ 4 files changed, 295 insertions(+) create mode 100644 src/lib/scaffold.mjs create mode 100644 tests/kit/scaffold.test.mjs diff --git a/src/commands/status.mjs b/src/commands/status.mjs index 72f7573..7a0cc95 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -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 }, @@ -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 { diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index cbd3ec2..efa64fe 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -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'; @@ -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))); } diff --git a/src/lib/scaffold.mjs b/src/lib/scaffold.mjs new file mode 100644 index 0000000..432ef27 --- /dev/null +++ b/src/lib/scaffold.mjs @@ -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\`` }; +} diff --git a/tests/kit/scaffold.test.mjs b/tests/kit/scaffold.test.mjs new file mode 100644 index 0000000..c5cd9d3 --- /dev/null +++ b/tests/kit/scaffold.test.mjs @@ -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); } +});