Skip to content

Commit ce0fb78

Browse files
authored
feat: scaffold-agents subsystem — detect ADR-128 agent gaps, delegate to upstream fix when available (#132)
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.
1 parent 428db50 commit ce0fb78

4 files changed

Lines changed: 295 additions & 0 deletions

File tree

‎src/commands/status.mjs‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { drift as ruvectorDrift } from '../lib/ruvector.mjs';
3030
import { statuslineDrift } from '../lib/codex-statusline.mjs';
3131
import { inspectCodexPlugins } from '../lib/codex-plugins.mjs';
3232
import { projectMemoryStatus } from '../lib/project-memory.mjs';
33+
import { removedAgentGaps, upstreamFixAvailable } from '../lib/scaffold.mjs';
3334

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

225+
// Scaffold agents (ADR-128 Phase 2 removals — ruflo#2985). Upstream never
226+
// revisits an existing scaffold, so projects inited before ruflo 3.38.x are
227+
// missing up to 9 plugin-canonical agents (coder, researcher, reviewer, …).
228+
// The fix is upstream's `ruflo migrate fix --agents` (PR #2986): when the
229+
// installed CLI ships it, the row carries a fix and sync delegates; until
230+
// then it is advisory-only — a kit-side restore would fork plugin-canonical
231+
// content. Spawn-free (dist probe + file walk), project-scoped: silent when
232+
// the cwd has no .claude/agents tree.
233+
try {
234+
const { relevant, gaps } = removedAgentGaps(cwd);
235+
if (relevant && gaps.length > 0) {
236+
const named = gaps.slice(0, 3).map((g) => g.basename.replace(/\.md$/, '')).join(', ');
237+
const suffix = gaps.length > 3 ? ', …' : '';
238+
if (upstreamFixAvailable()) {
239+
rows.push(row('scaffold-agents', 'warn',
240+
`${gaps.length} ADR-128-removed agent(s) missing from .claude/agents (${named}${suffix})`,
241+
'sync delegates to `ruflo migrate fix --agents`'));
242+
} else {
243+
rows.push(row('scaffold-agents', 'info',
244+
`${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`));
245+
}
246+
} else if (relevant) {
247+
rows.push(row('scaffold-agents', 'ok', 'ADR-128-removed agents present or plugin-covered'));
248+
}
249+
} catch (e) {
250+
rows.push(row('scaffold-agents', 'warn', `scaffold agent check unavailable: ${e.message}`));
251+
}
252+
224253
// npx (stale ruflo-family cache envs — `npx --prefer-offline` fallbacks in the
225254
// statusline/hooks execute these verbatim, keeping retired defects alive)
226255
try {

‎src/commands/sync.mjs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { commandHosts, applyHosts, applyProviders, hostInstallState, installHost
1616
import { driftReport, selfDrift } from '../lib/versions.mjs';
1717
import { RUVECTOR_PKG, managed as ruvectorManaged } from '../lib/ruvector.mjs';
1818
import { pruneNpxStale } from '../lib/npx.mjs';
19+
import { runScaffoldAgentsFix } from '../lib/scaffold.mjs';
1920
import { nativesStatus, securityPresent } from '../lib/natives.mjs';
2021
import { readJson } from '../lib/settings.mjs';
2122
import { appendToConfig } from '../lib/health-history.mjs';
@@ -129,6 +130,15 @@ export async function run({ flags, pkgRoot }) {
129130
if (subsystems.has('npx') || subsystems.has('versions')) {
130131
report('npx', pruneNpxStale());
131132
}
133+
// Scaffold agents: the row only carries a fix (and so only enters the plan)
134+
// when the installed CLI already ships `migrate fix --agents` (ruflo#2986) —
135+
// delegation, never a kit-side restore. If THIS sync's upgrade step is what
136+
// delivered the capability, the pre-upgrade plan won't include it; the next
137+
// `ak status`/`ak sync` picks it up (same one-pass-behind rule as any
138+
// upgrade-delivered fix).
139+
if (subsystems.has('scaffold-agents')) {
140+
await step('scaffold agents', () => runScaffoldAgentsFix(cwd));
141+
}
132142
if (subsystems.has('aqe')) {
133143
report('rvf', heal.healRvf(paths.projectAqeDir(cwd)));
134144
}

‎src/lib/scaffold.mjs‎

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Scaffold-agents drift — ADR-128 Phase 2 deleted 9 agents from ruflo init's
2+
// template (each plugin is canonical since); any project scaffolded before
3+
// ruflo 3.38.x carries the gap silently, and no upstream code revisits an
4+
// existing scaffold. Detection here mirrors upstream's
5+
// migrate-agent-detection.ts semantics exactly (basename anywhere under
6+
// .claude/agents + owning-plugin coverage via ~/.claude/plugins/
7+
// installed_plugins.json), spawn-free so status and the nudge can call it.
8+
//
9+
// The FIX is deliberately not ours: when the installed CLI ships
10+
// `ruflo migrate fix --agents` (ruflo#2985 → PR #2986), sync delegates to it —
11+
// upstream restores from canonical plugin content with namespace rewrite and
12+
// provenance. Until that ships, the status row is advisory-only (no fix
13+
// string, so it never enters sync's plan): a kit-side restore would fork
14+
// content ADR-128 made plugin-canonical.
15+
import fs from 'node:fs';
16+
import path from 'node:path';
17+
import * as paths from './paths.mjs';
18+
import { run } from './exec.mjs';
19+
20+
/** Mirror of upstream REMOVED_AGENTS (src/commands/migrate.ts) — basename in
21+
* the pre-ADR-128 init template, and the marketplace plugin that owns it now. */
22+
export const REMOVED_AGENTS = [
23+
{ basename: 'coder.md', plugin: 'ruflo-core' },
24+
{ basename: 'researcher.md', plugin: 'ruflo-core' },
25+
{ basename: 'reviewer.md', plugin: 'ruflo-core' },
26+
{ basename: 'tester.md', plugin: 'ruflo-testgen' },
27+
{ basename: 'memory-specialist.md', plugin: 'ruflo-rag-memory' },
28+
{ basename: 'security-auditor.md', plugin: 'ruflo-security-audit' },
29+
{ basename: 'sparc-orchestrator.md', plugin: 'ruflo-sparc' },
30+
{ basename: 'goal-planner.md', plugin: 'ruflo-goals' },
31+
{ basename: 'adr-architect.md', plugin: 'ruflo-adr' },
32+
];
33+
34+
// Same shallow-tree depth guard as upstream's findBasename.
35+
const MAX_AGENT_DIR_DEPTH = 6;
36+
37+
function hasBasename(dir, basename, depth = 0) {
38+
if (depth > MAX_AGENT_DIR_DEPTH) return false;
39+
let entries;
40+
try {
41+
entries = fs.readdirSync(dir, { withFileTypes: true });
42+
} catch {
43+
return false;
44+
}
45+
for (const entry of entries) {
46+
if (entry.isDirectory()) {
47+
if (hasBasename(path.join(dir, entry.name), basename, depth + 1)) return true;
48+
} else if (entry.isFile() && entry.name === basename) {
49+
return true;
50+
}
51+
}
52+
return false;
53+
}
54+
55+
function installedPluginsRegistry(homeDir) {
56+
try {
57+
const raw = fs.readFileSync(
58+
path.join(homeDir, '.claude', 'plugins', 'installed_plugins.json'), 'utf8');
59+
const parsed = JSON.parse(raw);
60+
return parsed?.plugins && typeof parsed.plugins === 'object' ? parsed.plugins : {};
61+
} catch {
62+
return {};
63+
}
64+
}
65+
66+
// Upstream semantics: a "user"-scoped (or unscoped) install covers every
67+
// project; a "project"-scoped install covers only its own projectPath.
68+
function pluginCovers(registry, plugin, cwd) {
69+
const resolved = path.resolve(cwd);
70+
for (const [key, entries] of Object.entries(registry)) {
71+
if (!key.startsWith(`${plugin}@`) || !Array.isArray(entries)) continue;
72+
for (const entry of entries) {
73+
if (entry?.scope === 'project') {
74+
if (entry.projectPath && path.resolve(entry.projectPath) === resolved) return true;
75+
} else {
76+
return true;
77+
}
78+
}
79+
}
80+
return false;
81+
}
82+
83+
/**
84+
* Spawn-free gap probe. `relevant: false` when the project has no
85+
* .claude/agents tree at all (not a ruflo-scaffolded project — no row).
86+
* @returns {{ relevant: boolean, gaps: Array<{basename: string, plugin: string}> }}
87+
*/
88+
export function removedAgentGaps(cwd, { homeDir = paths.home } = {}) {
89+
const agentsDir = path.join(cwd, '.claude', 'agents');
90+
if (!fs.existsSync(agentsDir)) return { relevant: false, gaps: [] };
91+
const registry = installedPluginsRegistry(homeDir);
92+
const gaps = REMOVED_AGENTS.filter(
93+
({ basename, plugin }) =>
94+
!hasBasename(agentsDir, basename) && !pluginCovers(registry, plugin, cwd)
95+
);
96+
return { relevant: true, gaps };
97+
}
98+
99+
/**
100+
* Does the installed CLI ship `migrate fix --agents`? Probed the same way
101+
* mcp.mjs derives tool families — from what actually sits in the installed
102+
* dist (the restore module lands with ruflo#2986) — never from a version
103+
* string, so a backport or a fork build is detected identically.
104+
*/
105+
export function upstreamFixAvailable({ cliDist = paths.rufloCliDist() } = {}) {
106+
return fs.existsSync(path.join(cliDist, 'commands', 'migrate-agent-restore.js'));
107+
}
108+
109+
/** Delegate the restore to upstream. Only called by sync when
110+
* upstreamFixAvailable() held at collect time. */
111+
export async function runScaffoldAgentsFix(cwd, { runner = run, homeDir = paths.home } = {}) {
112+
const r = await runner('ruflo', ['migrate', 'fix', '--agents'], { cwd, timeout: 120_000 });
113+
if (r.code !== 0) {
114+
return { ok: false, detail: `ruflo migrate fix --agents failed: ${(r.stderr || r.stdout || '').slice(0, 200)}` };
115+
}
116+
const after = removedAgentGaps(cwd, { homeDir });
117+
return after.gaps.length === 0
118+
? { ok: true, detail: 'delegated to `ruflo migrate fix --agents` — all removed agents restored' }
119+
: { ok: false, detail: `ran \`ruflo migrate fix --agents\` but ${after.gaps.length} gap(s) remain — see \`ruflo migrate status\`` };
120+
}

‎tests/kit/scaffold.test.mjs‎

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { test } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import fs from 'node:fs';
4+
import os from 'node:os';
5+
import path from 'node:path';
6+
import {
7+
REMOVED_AGENTS,
8+
removedAgentGaps,
9+
upstreamFixAvailable,
10+
runScaffoldAgentsFix,
11+
} from '../../src/lib/scaffold.mjs';
12+
13+
// Detection mirrors upstream migrate-agent-detection.ts: basename anywhere
14+
// under .claude/agents + owning-plugin coverage from the injected home's
15+
// installed_plugins.json. Every test gets isolated tmp dirs — nothing here
16+
// depends on what is actually installed on the machine running the suite.
17+
18+
function tmpProject({ agentsDir = true } = {}) {
19+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-scaffold-'));
20+
if (agentsDir) fs.mkdirSync(path.join(dir, '.claude', 'agents'), { recursive: true });
21+
return dir;
22+
}
23+
24+
function tmpHome(plugins = null) {
25+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-scaffold-home-'));
26+
if (plugins) {
27+
const dir = path.join(home, '.claude', 'plugins');
28+
fs.mkdirSync(dir, { recursive: true });
29+
fs.writeFileSync(path.join(dir, 'installed_plugins.json'),
30+
JSON.stringify({ version: 2, plugins }));
31+
}
32+
return home;
33+
}
34+
35+
const rm = (dir) => fs.rmSync(dir, { recursive: true, force: true });
36+
37+
test('not relevant when the project has no .claude/agents tree', () => {
38+
const cwd = tmpProject({ agentsDir: false });
39+
const homeDir = tmpHome();
40+
try {
41+
assert.deepEqual(removedAgentGaps(cwd, { homeDir }), { relevant: false, gaps: [] });
42+
} finally { rm(cwd); rm(homeDir); }
43+
});
44+
45+
test('all 9 gaps when agents dir is empty and no plugins are installed', () => {
46+
const cwd = tmpProject();
47+
const homeDir = tmpHome();
48+
try {
49+
const { relevant, gaps } = removedAgentGaps(cwd, { homeDir });
50+
assert.equal(relevant, true);
51+
assert.equal(gaps.length, REMOVED_AGENTS.length);
52+
} finally { rm(cwd); rm(homeDir); }
53+
});
54+
55+
test('a basename anywhere under .claude/agents clears its gap', () => {
56+
const cwd = tmpProject();
57+
const homeDir = tmpHome();
58+
try {
59+
const nested = path.join(cwd, '.claude', 'agents', 'some', 'deep', 'category');
60+
fs.mkdirSync(nested, { recursive: true });
61+
fs.writeFileSync(path.join(nested, 'coder.md'), '# restored\n');
62+
const { gaps } = removedAgentGaps(cwd, { homeDir });
63+
assert.equal(gaps.length, REMOVED_AGENTS.length - 1);
64+
assert.ok(!gaps.some((g) => g.basename === 'coder.md'));
65+
} finally { rm(cwd); rm(homeDir); }
66+
});
67+
68+
test('user-scoped plugin install covers its agents; project scope only matches its own path', () => {
69+
const cwd = tmpProject();
70+
const other = tmpProject();
71+
const homeDir = tmpHome({
72+
'ruflo-core@ruflo': [{ scope: 'user' }],
73+
'ruflo-testgen@ruflo': [{ scope: 'project', projectPath: other }],
74+
});
75+
try {
76+
const { gaps } = removedAgentGaps(cwd, { homeDir });
77+
// ruflo-core owns coder/researcher/reviewer — covered by the user-scope install.
78+
assert.ok(!gaps.some((g) => g.plugin === 'ruflo-core'));
79+
// ruflo-testgen is installed for a DIFFERENT project — tester.md still gaps here.
80+
assert.ok(gaps.some((g) => g.basename === 'tester.md'));
81+
} finally { rm(cwd); rm(other); rm(homeDir); }
82+
});
83+
84+
test('upstreamFixAvailable probes the installed dist for the restore module', () => {
85+
const dist = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-scaffold-dist-'));
86+
try {
87+
assert.equal(upstreamFixAvailable({ cliDist: dist }), false);
88+
fs.mkdirSync(path.join(dist, 'commands'), { recursive: true });
89+
fs.writeFileSync(path.join(dist, 'commands', 'migrate-agent-restore.js'), '// #2986\n');
90+
assert.equal(upstreamFixAvailable({ cliDist: dist }), true);
91+
} finally { rm(dist); }
92+
});
93+
94+
test('runScaffoldAgentsFix delegates to ruflo and verifies convergence', async () => {
95+
const cwd = tmpProject();
96+
const homeDir = tmpHome();
97+
try {
98+
const calls = [];
99+
// Runner simulates upstream restoring every agent, then the post-check
100+
// re-probes the real tree — write the files so convergence holds.
101+
const runner = async (cmd, args, opts) => {
102+
calls.push({ cmd, args, cwd: opts.cwd });
103+
const dir = path.join(cwd, '.claude', 'agents', 'core');
104+
fs.mkdirSync(dir, { recursive: true });
105+
for (const { basename } of REMOVED_AGENTS) fs.writeFileSync(path.join(dir, basename), '# restored\n');
106+
return { code: 0, stdout: '9 agent(s) restored', stderr: '' };
107+
};
108+
const r = await runScaffoldAgentsFix(cwd, { runner, homeDir });
109+
assert.equal(r.ok, true);
110+
assert.deepEqual(calls[0].args, ['migrate', 'fix', '--agents']);
111+
assert.equal(calls[0].cmd, 'ruflo');
112+
assert.equal(calls[0].cwd, cwd);
113+
} finally { rm(cwd); rm(homeDir); }
114+
});
115+
116+
test('runScaffoldAgentsFix reports failure on nonzero exit without throwing', async () => {
117+
const cwd = tmpProject();
118+
const homeDir = tmpHome();
119+
try {
120+
const runner = async () => ({ code: 1, stdout: '', stderr: 'boom' });
121+
const r = await runScaffoldAgentsFix(cwd, { runner, homeDir });
122+
assert.equal(r.ok, false);
123+
assert.match(r.detail, /boom/);
124+
} finally { rm(cwd); rm(homeDir); }
125+
});
126+
127+
test('runScaffoldAgentsFix flags non-convergence when gaps remain after a zero exit', async () => {
128+
const cwd = tmpProject();
129+
const homeDir = tmpHome();
130+
try {
131+
const runner = async () => ({ code: 0, stdout: 'looked fine', stderr: '' });
132+
const r = await runScaffoldAgentsFix(cwd, { runner, homeDir });
133+
assert.equal(r.ok, false);
134+
assert.match(r.detail, /gap\(s\) remain/);
135+
} finally { rm(cwd); rm(homeDir); }
136+
});

0 commit comments

Comments
 (0)